From 89d1634a639d3eada12d4b9f36e0a98d72de6a6b Mon Sep 17 00:00:00 2001 From: jutty Date: Sun, 5 Apr 2026 19:46:31 -0300 Subject: [PATCH] Add test harness for IO serial tests --- .justfile | 32 ++++++++++- Cargo.toml | 6 +++ src/conf.rs | 72 +++++++++++++++++++++++-- src/dev.rs | 2 + src/{ => dev}/log.rs | 0 src/dev/test.rs | 124 +++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 5 +- src/main.rs | 14 ++--- src/os/debian.rs | 8 +-- src/run.rs | 2 +- src/run/executor.rs | 2 +- src/run/expeditor.rs | 2 +- src/run/teller.rs | 2 +- src/state.rs | 2 +- 14 files changed, 250 insertions(+), 23 deletions(-) create mode 100644 src/dev.rs rename src/{ => dev}/log.rs (100%) create mode 100644 src/dev/test.rs diff --git a/.justfile b/.justfile index 1068505..10b7698 100644 --- a/.justfile +++ b/.justfile @@ -1,5 +1,5 @@ -watch command="run" args="": - DEBUG=${DEBUG:-} watchexec -c -w src -- cargo {{ command }} {{ args }} +watch *args: + watchexec -c -w src -w Cargo.toml -- just {{ args }} alias w := watch @@ -12,3 +12,31 @@ verify: && return done +[env("PROPTEST_CASES", "16640")] +test pattern="": + cargo test {{ pattern}} --timings -- --test-threads=1 'serial_tests::' + cargo test {{ pattern}} --timings --bin tori + cargo test {{ pattern}} --timings --doc + cargo test {{ pattern}} --timings --lib -- --skip 'serial_tests::' + + + +mutate: + cargo mutants --iterate \ + -E ' bool' \ + --output target/mutants + + +cover: + cargo llvm-cov --no-report + cargo llvm-cov report --html + cargo llvm-cov report \ + | tail -1 | awk \ + '{ print " [ Regions:", $4, "• Functions:", $7, "• Lines:", $10, "]" }' + +cover-open: + cargo llvm-cov report --open + diff --git a/Cargo.toml b/Cargo.toml index 0e2cd89..6974154 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -170,3 +170,9 @@ used_underscore_items = "warn" useless_let_if_seq = "warn" zero_sized_map_values = "warn" zombie_processes = "deny" + +[profile.test.package.proptest] +opt-level = 3 + +[profile.test.package.rand_chacha] +opt-level = 3 diff --git a/src/conf.rs b/src/conf.rs index b336348..65c3c3f 100644 --- a/src/conf.rs +++ b/src/conf.rs @@ -5,24 +5,28 @@ use std::{ }; use crate::{ - log::{self, elog}, + dev::{log::elog}, run::Command, }; pub fn load() -> Result { - log::elog("Loading configuration"); + elog("Loading configuration"); let mut candidate = Configuration::default(); let root = get_root(); + elog(&format!("Reading 'tori.conf' from: {root:?}")); let contents = fs::read_to_string(root.join("tori.conf"))?; + elog(&format!("Read configuration: {contents:?}")); let map: HashMap = contents .lines() .filter_map(|line| line.split_once('=')) - .map(|(k, v)| (k.to_owned(), v.to_owned())) + .map(|(k, v)| (k.trim().to_owned(), v.trim().to_owned())) .collect(); + elog(&format!("Assembled configuration map: {map:#?}")); + if let Some(su_command) = map.get("su_command") { let wraps = map.get("su_command_wraps").is_some_and(|v| v == "true"); candidate.su_command = parse_su_command(su_command, wraps)?; @@ -36,6 +40,7 @@ pub fn load() -> Result { } } + elog(&format!("Assembled configuration candidate: {candidate:?}")); Ok(candidate) } @@ -183,9 +188,10 @@ impl Default for SuCommand { } } +#[derive(Debug)] pub struct Error { - message: String, - kind: ErrorKind, + pub message: String, + pub kind: ErrorKind, } impl Error { @@ -221,6 +227,7 @@ impl From for Error { } } +#[derive(Debug)] pub enum ErrorKind { CommandNotInPath, VarError, @@ -242,3 +249,58 @@ impl std::fmt::Display for ErrorKind { write!(f, "{s}") } } + +#[cfg(test)] +#[expect(clippy::panic_in_result_fn, //clippy::unwrap_in_result +)] +mod serial_tests { + use std::{env, fs, os::unix::fs::PermissionsExt as _, io::{Write as _}}; + use super::*; + use crate::{dev::test::{Directories, Error}}; + + #[test] + fn failed_config_read() -> Result<(), Error> { + let dirs = Directories::setup("failed_config_read")?; + + fs::write(&dirs.conf, [1, 0, 1])?; + let mut permissions = fs::metadata(&dirs.conf)?.permissions(); + permissions.set_mode(0o200); + fs::set_permissions(&dirs.conf, permissions)?; + + let new_permissions = fs::metadata(&dirs.conf)?.permissions(); + assert_eq!(new_permissions.mode() & 0o777, 0o200); + + let error = load().unwrap_err(); + + assert!(matches!(&error.kind, ErrorKind::IO)); + Ok(()) + } + + #[test] + fn prefer_system() -> Result<(), Error> { + let dirs = Directories::setup("prefer_system")?; + + let mut conf = fs::File::create_new(&dirs.conf)?; + println!("conf: {conf:#?}"); + println!("XDG_CONFIG_DIR: {:#?}", env::var("XDG_CONFIG_DIR")); + + let conf_root_contents = dirs.conf_root.read_dir(); + println!("conf_root_contents: {conf_root_contents:#?}"); + + let write_result = conf.write_all(b"merge_strategy = prefer system\n"); + println!("write_result: {write_result:#?}"); + conf.sync_all()?; + + let mut perms = fs::metadata(&dirs.conf)?.permissions(); + println!("perms: {perms:#?}"); + perms.set_mode(0o664); + conf.set_permissions(perms)?; + + let configuration = load()?; + println!("configuration: {configuration:#?}"); + + assert!(matches!(configuration.merge_strategy, MergeStrategy::PreferSystem)); + + Ok(()) + } +} diff --git a/src/dev.rs b/src/dev.rs new file mode 100644 index 0000000..c4eb55e --- /dev/null +++ b/src/dev.rs @@ -0,0 +1,2 @@ +pub mod log; +pub mod test; diff --git a/src/log.rs b/src/dev/log.rs similarity index 100% rename from src/log.rs rename to src/dev/log.rs diff --git a/src/dev/test.rs b/src/dev/test.rs new file mode 100644 index 0000000..320e2c9 --- /dev/null +++ b/src/dev/test.rs @@ -0,0 +1,124 @@ +use std::{env, fs, io, path::PathBuf}; + +use crate::{dev::log::elog, conf}; + +#[derive(Debug)] +pub struct Directories { + pub original: PathBuf, + pub tube: PathBuf, + pub conf: PathBuf, + pub conf_root: PathBuf, +} + +impl Directories { + /// Sets up self-cleaning original, temporary and 'templates' directories. + /// + /// # Errors + /// May return Error when: + /// - Current directory does not exist or lacking permissions + /// - Several I/O possibilities from directory creation failures + /// - Several I/O possibilities from working directory changing failures + pub fn setup(dir_name: &str) -> Result { + let original = env::current_dir()?; + let tube = original.join(format!("target/tubes/{dir_name}")); + let xdg_conf = tube.join(".config"); + let conf_root = xdg_conf.join("tori"); + let conf = conf_root.join("tori.conf"); + + drop(fs::remove_dir_all(&tube)); + + if let Err(error) = fs::create_dir_all(&conf_root) { + return Err(Error::with_io( + "Failed configuration root directory creation", + error, + )) + } + + if let Err(error) = env::set_current_dir(&tube) { + return Err(Error::with_io("Failed current directory change", error)) + } + + unsafe { env::set_var("XDG_CONFIG_DIR", &xdg_conf); } + + Ok(Directories { + original, + tube, + conf, + conf_root, + }) + } +} + +impl Drop for Directories { + fn drop(&mut self) { + if let Err(error) = std::env::set_current_dir(&self.original) { + elog(&format!("Couldn't reset to original directory: {error}")); + } + if let Err(error) = std::fs::remove_dir_all(&self.tube) { + elog(&format!("Couldn't cleanup tube directory: {error}")); + } + } +} + +#[derive(Debug)] +pub struct Error { + pub message: String, + pub inner: Option, +} + +#[derive(Debug, Default)] +pub struct InnerErrors { + pub io: Option, + pub conf: Option, +} + +impl Error { + fn with_io(message: &str, inner: io::Error) -> Error { + Error { + message: String::from(message), + inner: Some(InnerErrors { io: Some(inner), conf: None }), + } + } +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + let mut message = self.message.clone(); + + if let Some(inner) = &self.inner { + message = format!("{message}\n{inner:#?}"); + } + + write!(f, "{message}") + } +} + +impl From for Error { + fn from(string: String) -> Error { + Error { + message: string, + inner: None, + } + } +} + +impl From<&str> for Error { + fn from(str: &str) -> Error { Error::from(String::from(str)) } +} + +impl From for Error { + fn from(inner: io::Error) -> Error { + let mut error = Error::from(inner.to_string()); + error.inner = Some(InnerErrors { io: Some(inner), ..InnerErrors::default() }); + error + } +} + +impl From for Error { + fn from(conf_error: conf::Error) -> Error { + Error { + message: conf_error.message.clone(), + inner: Some(InnerErrors { conf: Some(conf_error), io: None }), + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 11eea8a..8c53dc9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,10 @@ +#![feature(slice_partition_dedup)] +#![allow(unused_features)] + pub mod conf; pub mod state; pub mod os; pub mod run; -pub mod log; +pub mod dev; diff --git a/src/main.rs b/src/main.rs index ee53102..42dc5a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ -use tori::{conf, log, run, state}; +use tori::{conf, dev::log::elog, run, state}; fn main() -> std::process::ExitCode { - log::elog(&format!("tori {}", env!("CARGO_PKG_VERSION"))); + elog(&format!("tori {}", env!("CARGO_PKG_VERSION"))); let configuration = match conf::load() { Ok(c) => c, Err(error) => { @@ -9,13 +9,13 @@ fn main() -> std::process::ExitCode { return 1.into(); } }; - log::elog(&format!("Configuration: {configuration:#?}")); + elog(&format!("Configuration: {configuration:#?}")); let order = run::teller::parse(std::env::args()); - log::elog(&format!("Order: {order:#?}")); + elog(&format!("Order: {order:#?}")); let state = state::setup(configuration, &[order]); - log::elog(&format!("State: {state:#?}")); - let result = run::expeditor::fulfill(&state); - log::elog(&format!("Filled Order: {result:#?}")); + elog(&format!("State: {state:#?}")); + let result = run::expeditor::expedite(&state); + elog(&format!("Filled Order: {result:#?}")); if result.is_ok() { 0.into() } else { 1.into() } } diff --git a/src/os/debian.rs b/src/os/debian.rs index 54707c3..fcce23b 100644 --- a/src/os/debian.rs +++ b/src/os/debian.rs @@ -2,7 +2,7 @@ use std::{collections::HashSet, fs::read_to_string, iter}; use crate::{ conf::Configuration, - log::elog, + dev::log::elog, os::{ Kind, OperatingSystem, pkg::{self, Package, PackagerVariant, Packages}, @@ -269,9 +269,11 @@ mod tests { auto_set.insert("sunflower".to_string()); auto_set.insert("turnip".to_string()); - let manual = Apt::determine_manual(raw_all, &auto_set); + let mut manual = Apt::determine_manual(raw_all, &auto_set); + let (uniques, dupes) = manual.as_mut_slice().partition_dedup(); + assert!(dupes.is_empty()); assert_eq!( - manual, + uniques, vec![ "avocado".into(), "carrot".into(), diff --git a/src/run.rs b/src/run.rs index dcfe4ba..1b92f9f 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,4 +1,4 @@ -use crate::{conf::Configuration, log::elog}; +use crate::{conf::Configuration, dev::log::elog}; pub mod executor; pub mod expeditor; diff --git a/src/run/executor.rs b/src/run/executor.rs index 4b0a165..31ff0a0 100644 --- a/src/run/executor.rs +++ b/src/run/executor.rs @@ -1,7 +1,7 @@ use std::process; use crate::{ - log::elog, + dev::log::elog, os::pkg::Package, run::{Command, Transaction, TransactionCommandStatus}, }; diff --git a/src/run/expeditor.rs b/src/run/expeditor.rs index f1f31a7..a6b386c 100644 --- a/src/run/expeditor.rs +++ b/src/run/expeditor.rs @@ -4,7 +4,7 @@ use crate::{ state::State, }; -pub fn fulfill(state: &State) -> Result<(), Error> { +pub fn expedite(state: &State) -> Result<(), Error> { let orders = state.orders(); for order in orders { diff --git a/src/run/teller.rs b/src/run/teller.rs index b2dc51d..82fe419 100644 --- a/src/run/teller.rs +++ b/src/run/teller.rs @@ -1,5 +1,5 @@ use crate::{ - log::elog, + dev::log::elog, run::{Order, Task, TaskKind}, }; use std::{env, path::PathBuf}; diff --git a/src/state.rs b/src/state.rs index ae245e2..0ad5c63 100644 --- a/src/state.rs +++ b/src/state.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use crate::{ conf::Configuration, - log::elog, + dev::log::elog, os::OperatingSystem, run::{Command, Order, executor::read}, };