diff --git a/crates/ledger-config/tests/effective_policy.rs b/crates/ledger-config/tests/effective_policy.rs index d087a58..97e2d53 100644 --- a/crates/ledger-config/tests/effective_policy.rs +++ b/crates/ledger-config/tests/effective_policy.rs @@ -3,11 +3,27 @@ use ledger_config::{BooksSettings, effective_receipt_policy}; -fn dir(tag: &str) -> std::path::PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn dir(tag: &str) -> TestDir { let d = std::env::temp_dir().join(format!("ledger-config-policy-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); - d + TestDir(d) } #[test] @@ -37,5 +53,4 @@ fn config_json_wins_else_the_settings_file() { err.starts_with(&format!("bad config {}", json.display())), "{err}" ); - std::fs::remove_dir_all(&d).unwrap(); } diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index c9e84c8..25bd97d 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -670,16 +670,16 @@ mod tests { let dir = std::env::temp_dir().join(format!("ledger-gui-attention-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); + // Loaded books need no files: removed once loaded, pass or panic. + let _cleanup = crate::test_support::TempDir(dir.clone()); let data_dir = dir.join("books-data"); ledger_store::Store::create(&data_dir, ledger_core::Currency::USD).unwrap(); - let b = crate::data::load_once(&crate::data::Source { + crate::data::load_once(&crate::data::Source { data_dir, books_toml: None, today: Some(today), }) - .unwrap(); - let _ = std::fs::remove_dir_all(&dir); - b + .unwrap() } /// INVARIANTS 2 and 4: a dated rule past its review is one Todo item diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index 6af5e82..f07d03c 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -2169,6 +2169,15 @@ mod tests { } use super::*; + /// A test's dir, removed when the test ends, pass or panic. + struct TestDir(std::path::PathBuf); + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + /// The fixed asset register report's tax columns are the register's: /// each held asset's contributor's basis, tax basis, and built-in gain /// (ledger-core's Asset::tax_basis and built_in_gain), the total its @@ -2178,6 +2187,7 @@ mod tests { let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); let dir = std::env::temp_dir().join(format!("ledger-gui-tax-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); + let _cleanup = TestDir(dir.clone()); crate::demo::build(&dir, today).unwrap(); let b = crate::data::load_once(&crate::data::Source { data_dir: dir.join("books-data"), @@ -2234,7 +2244,6 @@ mod tests { .iter() .any(|row| row.kind == Kind::Note && row.label.contains("fair market value")) ); - let _ = std::fs::remove_dir_all(&dir); } /// Every table-backed report typesets as a PDF, in its plain, by-month, @@ -2244,6 +2253,7 @@ mod tests { let today = NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); let dir = std::env::temp_dir().join(format!("ledger-gui-pdf-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); + let _cleanup = TestDir(dir.clone()); crate::demo::build(&dir, today).unwrap(); let b = crate::data::load_once(&crate::data::Source { data_dir: dir.join("books-data"), @@ -2296,6 +2306,5 @@ mod tests { std::fs::write(d.join(format!("{name}.pdf")), bytes).unwrap(); } } - let _ = std::fs::remove_dir_all(&dir); } } diff --git a/crates/ledger-gui/tests/breadcrumbs.rs b/crates/ledger-gui/tests/breadcrumbs.rs index 81b2382..5508593 100644 --- a/crates/ledger-gui/tests/breadcrumbs.rs +++ b/crates/ledger-gui/tests/breadcrumbs.rs @@ -17,22 +17,34 @@ use ledger_gui::nav::{ReportKind, Route, Section}; use ledger_gui::palette::{crumbs, describe}; use ledger_gui::{App, Source, data}; -fn demo(tag: &str) -> Source { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// The demo books, kept while the guard lives. +fn demo(tag: &str) -> (TestDir, Source) { let dir = std::env::temp_dir().join(format!("ledger-gui-crumbs-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); ledger_gui::demo::build(&dir, today).unwrap(); - Source { + let source = Source { data_dir: dir.join("books-data"), books_toml: Some(dir.join("wanderbooks.toml")), today: Some(today), - } + }; + (TestDir(dir), source) } /// INVARIANTS 1-3 on every kind of page in the demo books. #[test] fn crumbs_link_every_level_but_the_last() { - let b = data::load_once(&demo("unit")).unwrap(); + let (_dir, source) = demo("unit"); + let b = data::load_once(&source).unwrap(); let party = b.ledger.parties().next().unwrap().id; let rec = b.reconciliations.first().expect("the demo reconciles"); let entry = b.entries.first().unwrap().id; @@ -90,7 +102,7 @@ fn crumbs_link_every_level_but_the_last() { /// INVARIANT 4, through the top bar. #[test] fn clicking_a_crumb_goes_there() { - let source = demo("click"); + let (_dir, source) = demo("click"); let b = data::load_once(&source).unwrap(); let rec = b.reconciliations.first().unwrap().clone(); let account = describe(&b, &Route::Account(rec.account_id)); diff --git a/crates/ledger-gui/tests/copy.rs b/crates/ledger-gui/tests/copy.rs index 24f3652..8dc38af 100644 --- a/crates/ledger-gui/tests/copy.rs +++ b/crates/ledger-gui/tests/copy.rs @@ -21,6 +21,15 @@ use ledger_gui::{App, Source, data, fmt}; use proptest::prelude::*; use rust_decimal::Decimal; +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + proptest! { /// INVARIANT 1: every display style copies back to the same value. #[test] @@ -54,6 +63,7 @@ fn copied(h: &Harness<'_, App>) -> Vec { fn clicking_a_value_copies_it() { let dir = std::env::temp_dir().join(format!("ledger-gui-copy-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); + let _cleanup = TestDir(dir.clone()); let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); ledger_gui::demo::build(&dir, today).unwrap(); let source = Source { @@ -106,5 +116,4 @@ fn clicking_a_value_copies_it() { h.get_by_label(&format!("Entry #{id}")).click(); h.step(); assert_eq!(copied(&h), [format!("#{id}")]); - let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ledger-gui/tests/live.rs b/crates/ledger-gui/tests/live.rs index d1b7f37..04f57d4 100644 --- a/crates/ledger-gui/tests/live.rs +++ b/crates/ledger-gui/tests/live.rs @@ -9,10 +9,20 @@ use ledger_store::Store; use rust_decimal::Decimal; use std::time::{Duration, Instant}; +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + #[test] fn a_change_in_another_process_shows_up_live() { let dir = std::env::temp_dir().join(format!("ledger-gui-live-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); + let _cleanup = TestDir(dir.clone()); let data = dir.join("books-data"); let mut store = Store::create(&data, Currency::USD).unwrap(); let cash = store @@ -86,5 +96,4 @@ fn a_change_in_another_process_shows_up_live() { assert!(shown, "the show request never reached the viewer"); eprintln!("show request arrived in {:?}", asked.elapsed()); let _ = std::fs::remove_file(ledger_store::viewer::command_path(&data)); - let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ledger-gui/tests/newer_journal.rs b/crates/ledger-gui/tests/newer_journal.rs index b74980b..14e32d7 100644 --- a/crates/ledger-gui/tests/newer_journal.rs +++ b/crates/ledger-gui/tests/newer_journal.rs @@ -16,7 +16,27 @@ use serde_json::json; use std::path::{Path, PathBuf}; use std::time::{Duration, Instant}; -fn books(tag: &str) -> PathBuf { +/// A test's data dir; its books dir is removed when the test ends, pass or +/// panic. +struct DataDir { + dir: PathBuf, + data: PathBuf, +} + +impl std::ops::Deref for DataDir { + type Target = Path; + fn deref(&self) -> &Path { + &self.data + } +} + +impl Drop for DataDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +fn books(tag: &str) -> DataDir { let dir = std::env::temp_dir().join(format!("ledger-gui-gate-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); let data = dir.join("books-data"); @@ -24,7 +44,7 @@ fn books(tag: &str) -> PathBuf { store .add_account("1000", "Checking", AccountType::Asset, None, false) .unwrap(); - data + DataDir { dir, data } } fn source(data: &Path) -> Source { diff --git a/crates/ledger-gui/tests/read_only.rs b/crates/ledger-gui/tests/read_only.rs index 79d5d13..6a25e99 100644 --- a/crates/ledger-gui/tests/read_only.rs +++ b/crates/ledger-gui/tests/read_only.rs @@ -67,13 +67,15 @@ fn set_read_only(root: &Path, read_only: bool) { walk(root, read_only); } -/// Makes the books writable again when dropped, so a failing or -/// interrupted run never leaves a folder no one can delete. +/// Makes the books writable again and removes them when dropped, so a +/// run that passes or fails never leaves a folder behind, let alone one no +/// one can delete. struct Writable(PathBuf); impl Drop for Writable { fn drop(&mut self) { if self.0.exists() { set_read_only(&self.0, false); + let _ = std::fs::remove_dir_all(&self.0); } } } @@ -105,6 +107,7 @@ fn the_viewer_never_writes_into_the_books() { set_read_only(&dir, false); std::fs::remove_dir_all(&dir).unwrap(); } + let _restore = Writable(dir.clone()); let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); ledger_gui::demo::build(&dir, today).unwrap(); let source = Source { @@ -112,7 +115,6 @@ fn the_viewer_never_writes_into_the_books() { books_toml: Some(dir.join("wanderbooks.toml")), today: Some(today), }; - let _restore = Writable(dir.clone()); set_read_only(&dir, true); let before = tree(&dir); @@ -219,5 +221,4 @@ fn the_viewer_never_writes_into_the_books() { "the viewer created or removed files in writable books" ); assert!(before == after, "the viewer changed writable books"); - let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index 26fdea8..2227ced 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -7,6 +7,15 @@ use ledger_gui::nav::{ReportKind, Route}; use ledger_gui::{App, Source, data}; use std::path::PathBuf; +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + #[test] fn screenshots() { let Ok(out) = std::env::var("SHOT_DIR") else { @@ -15,6 +24,8 @@ fn screenshots() { let fixed_today: Option = std::env::var("SHOT_TODAY") .ok() .and_then(|d| d.parse().ok()); + // Demo books (no SHOT_BOOKS) go when the run ends, pass or panic. + let mut _demo = None; let books = match std::env::var("SHOT_BOOKS") { Ok(b) => PathBuf::from(b), Err(_) => { @@ -23,6 +34,7 @@ fn screenshots() { let dir = std::env::temp_dir().join(format!("ledger-gui-shots-demo-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); + _demo = Some(TestDir(dir.clone())); ledger_gui::demo::build(&dir, d).unwrap(); dir } diff --git a/crates/ledger-gui/tests/show.rs b/crates/ledger-gui/tests/show.rs index e674682..e530d6f 100644 --- a/crates/ledger-gui/tests/show.rs +++ b/crates/ledger-gui/tests/show.rs @@ -5,11 +5,21 @@ use ledger_gui::nav::{ReportKind, Route}; use ledger_gui::{App, Source, data}; use ledger_store::viewer::ViewerTarget; +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + #[test] fn show_requests_navigate_the_viewer() { let today = chrono::NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(); let dir = std::env::temp_dir().join(format!("ledger-gui-show-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); + let _cleanup = TestDir(dir.clone()); ledger_gui::demo::build(&dir, today).unwrap(); let source = Source { data_dir: dir.join("books-data"), @@ -108,5 +118,4 @@ fn show_requests_navigate_the_viewer() { end: None, }); assert_eq!(h.state_mut().route(), &Route::Transactions); - let _ = std::fs::remove_dir_all(&dir); } diff --git a/crates/ledger-gui/tests/vault_key_flag.rs b/crates/ledger-gui/tests/vault_key_flag.rs index 49b1d44..93a5c48 100644 --- a/crates/ledger-gui/tests/vault_key_flag.rs +++ b/crates/ledger-gui/tests/vault_key_flag.rs @@ -10,10 +10,20 @@ use ledger_vault::{Tin, Vault}; const SSN: &str = "123-45-6789"; +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + #[test] fn the_key_named_on_the_command_line_wins() { let root = std::env::temp_dir().join(format!("ledger-gui-key-flag-{}", std::process::id())); let _ = std::fs::remove_dir_all(&root); + let _cleanup = TestDir(root.clone()); let books = root.join("books"); let key = root.join("keys/vault.key"); std::fs::create_dir_all(&books).unwrap(); @@ -52,7 +62,6 @@ fn the_key_named_on_the_command_line_wins() { // The settings name a key that isn't there; the command line wins. b.settings.vault_key_file = Some(root.join("missing/vault.key")); assert_eq!(b.tin_from_vault(party).unwrap(), SSN); - let _ = std::fs::remove_dir_all(&root); } #[test] diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index cf45e3a..91f6d6e 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -51,14 +51,31 @@ fn plan() -> impl Strategy { }) } -fn dir(tag: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn dir(tag: &str) -> TestDir { let d = std::env::temp_dir().join(format!("ledger-gui-vm-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); - d + TestDir(d) } -fn build(plan: &Plan, tag: &str) -> (Source, ledger_core::Ledger) { +/// The books, where they are (kept while the guard lives), and their ledger. +fn build(plan: &Plan, tag: &str) -> (TestDir, Source, ledger_core::Ledger) { let d = dir(tag); let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap(); let ids: Vec<_> = CHART @@ -81,14 +98,12 @@ fn build(plan: &Plan, tag: &str) -> (Source, ledger_core::Ledger) { ) .unwrap(); let ledger = s.ledger().clone(); - ( - Source { - data_dir: d.join("books-data"), - books_toml: Some(d.join("wanderbooks.toml")), - today: Some(plan.today), - }, - ledger, - ) + let source = Source { + data_dir: d.join("books-data"), + books_toml: Some(d.join("wanderbooks.toml")), + today: Some(plan.today), + }; + (d, source, ledger) } /// An account's normal-side balance at the end of `date`, from the trial @@ -122,7 +137,7 @@ proptest! { /// Kpis INVARIANTS 1-7 and the period helpers, against ledger-core. #[test] fn the_overview_numbers_are_the_books_numbers(p in plan()) { - let (source, l) = build(&p, "kpi"); + let (_dir, source, l) = build(&p, "kpi"); let b = data::load_once(&source).unwrap(); let k = &b.kpis; let t = p.today; @@ -283,7 +298,6 @@ fn origins_and_parties() { assert_eq!(std::fs::read(path).unwrap(), b"%PDF receipt"); assert_eq!(b.integrity.files_ok, 1); assert!(b.integrity.file_failures.is_empty()); - let _ = std::fs::remove_dir_all(&d); } /// fmt helpers the viewer shows: percent change and tax forms. @@ -306,7 +320,7 @@ fn percent_change_and_tax_forms() { assert_eq!(fmt::compact(d("-1234")), "\u{2212}$1.2k"); } -fn store_at(tag: &str) -> (PathBuf, Store) { +fn store_at(tag: &str) -> (TestDir, Store) { let d = dir(tag); let s = Store::create(&d.join("books-data"), Currency::USD).unwrap(); (d, s) @@ -476,9 +490,6 @@ fn comparisons_and_burn_on_fixed_dates() { drop(s5); let b5 = data::load_once(&source(&d5, "", today)).unwrap(); assert_eq!(b5.kpis.monthly_burn, None); - for x in [d, d2, d3, d4, d5] { - let _ = std::fs::remove_dir_all(x); - } } /// The view model's indexes: cash accounts by name when the settings name @@ -548,7 +559,6 @@ fn indexes_cash_cards_accounts_and_fresh() { let integrity = ledger_gui::books::Integrity::check(&snap, &mut verified); let b2 = ledger_gui::books::Books::build(&snap, settings, today, Some(seq), integrity).unwrap(); assert_eq!(b2.fresh, [e2].into_iter().collect()); - let _ = std::fs::remove_dir_all(&d); } /// Integrity counts every stored file, read once: a second check with the @@ -580,7 +590,6 @@ fn integrity_counts_files_once_verified() { assert_eq!((first.files_ok, first.file_failures.len()), (2, 0)); let again = ledger_gui::books::Integrity::check(&snap, &mut verified); assert_eq!((again.files_ok, again.file_failures.len()), (2, 0)); - let _ = std::fs::remove_dir_all(&d); } /// Origins and parties for the rest of the journal's kinds: the invoice @@ -647,5 +656,4 @@ fn more_origins_and_parties() { let r = b.entry(remit).unwrap(); assert_eq!(r.origin, "Payroll taxes"); assert!(!r.missing_receipt, "a remittance isn't a hand-made entry"); - let _ = std::fs::remove_dir_all(&d); } diff --git a/crates/ledger-mcp/src/local_files.rs b/crates/ledger-mcp/src/local_files.rs index 59b539b..e937da6 100644 --- a/crates/ledger-mcp/src/local_files.rs +++ b/crates/ledger-mcp/src/local_files.rs @@ -660,6 +660,21 @@ mod tests { allowed: HashMap, } + impl Drop for Tree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } + } + + /// A test's dir, removed when the test ends, pass or panic. + struct TestDir(PathBuf); + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + fn tree(tag: &str) -> Tree { let base = std::env::temp_dir().join(format!( "ledger-mcp-local-files-{}-{tag}", @@ -813,25 +828,43 @@ mod tests { let _ = std::fs::remove_dir_all(b); } - proptest! { - #![proptest_config(ProptestConfig::with_cases(512))] - /// Any absolute path built from the tree's names, `.` and `..`, and - /// its symlinks is allowed exactly when it has no `.` or `..` name - /// and resolves to a file the oracle allows: a regular file inside - /// a root and outside every protected dir. - #[test] - fn import_allowed_iff_regular_file_inside_a_root_outside_the_books( + /// Any absolute path built from the tree's names, `.` and `..`, and + /// its symlinks is allowed exactly when it has no `.` or `..` name and + /// resolves to a file the oracle allows: a regular file inside a root + /// and outside every protected dir. (One tree for every case, removed + /// when the test ends.) + #[test] + fn import_allowed_iff_regular_file_inside_a_root_outside_the_books() { + let t = tree("prop"); + let starts = [ + "Downloads", + "tmp", + "tmp/books", + "tmp/books/books-data", + "tmp/keys", + "elsewhere", + "secret", + ]; + let names = [ + "..", + ".", + "sub", + "dir", + "receipt.pdf", + "scan.png", + "shot.png", + "journal.jsonl", + "vault.key", + "key-link", + "good-link", + "data-link", + "hard.key", + "fifo", + ]; + proptest!(ProptestConfig::with_cases(512), |( start in 0usize..7, parts in proptest::collection::vec(0usize..14, 0..5), - ) { - use std::sync::OnceLock; - static TREE: OnceLock = OnceLock::new(); - let t = TREE.get_or_init(|| tree("prop")); - let starts = ["Downloads", "tmp", "tmp/books", "tmp/books/books-data", "tmp/keys", "elsewhere", "secret"]; - let names = [ - "..", ".", "sub", "dir", "receipt.pdf", "scan.png", "shot.png", "journal.jsonl", - "vault.key", "key-link", "good-link", "data-link", "hard.key", "fifo", - ]; + )| { let mut p = t.base.join(starts[start]); for i in parts { p.push(names[i]); @@ -843,7 +876,7 @@ mod tests { _ => false, }; prop_assert_eq!(t.check(&p).is_ok(), want, "{}", p.display()); - } + }); } /// INVARIANT 2: a path that is missing, a dangling link, or outside @@ -1030,9 +1063,8 @@ mod tests { .is_ok_and(|s| s.success()); if made { let (tx, rx) = std::sync::mpsc::channel(); - std::thread::spawn(move || { - tx.send(read_checked(&canonical, &meta, &t.protected.inodes(), 100)) - }); + let inodes = t.protected.inodes(); + std::thread::spawn(move || tx.send(read_checked(&canonical, &meta, &inodes, 100))); let got = rx .recv_timeout(std::time::Duration::from_secs(10)) .expect("opening a pipe blocked"); @@ -1219,6 +1251,7 @@ mod tests { std::process::id() )); let _ = std::fs::remove_dir_all(&base); + let _cleanup = TestDir(base.clone()); let home = base.join("home"); std::fs::create_dir_all(home.join(".ssh")).unwrap(); std::fs::write(home.join(".ssh/id_ed25519"), b"PRIVATE KEY").unwrap(); diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 151bd7f..5afad1a 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -9472,11 +9472,25 @@ mod tests { Money::new(Decimal::from_str(s).unwrap(), Currency::USD) } - fn store(tag: &str) -> Store { + /// A test's books dir, removed when the test ends, pass or panic. + struct TestDir(std::path::PathBuf); + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn unit_dir(tag: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("ledger-mcp-unit-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); - Store::create(&dir, Currency::USD).unwrap() + TestDir(dir) + } + + /// New books in `dir`, which outlives them. + fn store(dir: &TestDir) -> Store { + Store::create(&dir.0, Currency::USD).unwrap() } /// lines_settled: posted, or a draft with lines whose debits equal its @@ -9635,7 +9649,8 @@ mod tests { /// close_into, and each member's capital and distributions accounts. #[test] fn config_mentions_finds_every_setting_naming_a_code() { - let server = BooksServer::new(store("mentions"), ReceiptPolicy::default()) + let books = unit_dir("mentions"); + let server = BooksServer::new(store(&books), ReceiptPolicy::default()) .with_company(members()) .with_cash_roles(CashRoles { cash: vec!["1000".into()], @@ -9664,20 +9679,21 @@ mod tests { ["[[company.members]] Ada distribution_account"] ); assert!(server.config_mentions("6000").is_empty()); - let solo = BooksServer::new(store("mentions-solo"), ReceiptPolicy::default()).with_company( - Company { + let books = unit_dir("mentions-solo"); + let solo = + BooksServer::new(store(&books), ReceiptPolicy::default()).with_company(Company { close_into: Some("3000".into()), ..Company::default() - }, - ); + }); assert_eq!(solo.config_mentions("3000"), ["[books] close_into"]); } /// Reports name each member with their accounts, in order. #[test] fn report_members_carry_names_and_accounts() { + let books = unit_dir("members"); let server = - BooksServer::new(store("members"), ReceiptPolicy::default()).with_company(members()); + BooksServer::new(store(&books), ReceiptPolicy::default()).with_company(members()); let m = server.report_members(); assert_eq!(m.len(), 2); assert_eq!( @@ -9697,7 +9713,8 @@ mod tests { /// included, so a closed month drops out). #[test] fn year_earnings_split_closed_from_unclosed() { - let mut s = store("earnings"); + let books = unit_dir("earnings"); + let mut s = store(&books); let cash = s .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); @@ -9747,7 +9764,8 @@ mod tests { /// December's income is never this year's). #[test] fn year_earnings_count_only_closes_and_never_before_the_year() { - let mut s = store("earnings-open"); + let books = unit_dir("earnings-open"); + let mut s = store(&books); let cash = s .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); @@ -9783,7 +9801,8 @@ mod tests { /// entry the policy covers (crate::policy). #[test] fn receipts_are_kept_only_where_the_policy_needs_them() { - let mut s = store("unlink"); + let books = unit_dir("unlink"); + let mut s = store(&books); let cash = s .add_account("1000", "Cash", AccountType::Asset, None, false) .unwrap(); @@ -10437,7 +10456,8 @@ mod tests { }), ]; for (how, body) in bodies { - let server = BooksServer::new(store("stops"), ReceiptPolicy::default()); + let books = unit_dir("stops"); + let server = BooksServer::new(store(&books), ReceiptPolicy::default()); rt.block_on(async { let seq: Body = |s| s.lock().map(|st| st.journal_seq()); assert_eq!(run(&server, seq).await.unwrap(), Ok(1), "{how}"); @@ -10459,7 +10479,8 @@ mod tests { } // A guard dropped by a panic on a thread of its own stops it too. - let server = BooksServer::new(store("stops-thread"), ReceiptPolicy::default()); + let books = unit_dir("stops-thread"); + let server = BooksServer::new(store(&books), ReceiptPolicy::default()); let s = server.clone(); let died = std::thread::spawn(move || { let _store = s.lock().unwrap(); @@ -10500,7 +10521,8 @@ mod tests { .enable_all() .build() .unwrap(); - let shared = SharedStore::new(store("wakes")); + let books = unit_dir("wakes"); + let shared = SharedStore::new(store(&books)); let got = rt.block_on(async { tokio::time::timeout( std::time::Duration::from_secs(10), @@ -10525,6 +10547,7 @@ mod tests { )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); + let _cleanup = TestDir(dir.clone()); let toml = dir.join("wanderbooks.toml"); let threshold = |t: &str| { std::fs::write( @@ -10540,7 +10563,8 @@ mod tests { config_json: dir.join("config.json"), }) .unwrap(); - let server = BooksServer::new(store("info"), ReceiptPolicy::default()) + let books = unit_dir("info"); + let server = BooksServer::new(store(&books), ReceiptPolicy::default()) .with_live_settings(Arc::new(Mutex::new(live))); let told = |s: &BooksServer| rmcp::ServerHandler::get_info(s).instructions.unwrap(); let before = told(&server); diff --git a/crates/ledger-mcp/src/settings.rs b/crates/ledger-mcp/src/settings.rs index c3f95d1..1782ae5 100644 --- a/crates/ledger-mcp/src/settings.rs +++ b/crates/ledger-mcp/src/settings.rs @@ -368,12 +368,28 @@ impl Live { mod tests { use super::*; - fn dir(tag: &str) -> PathBuf { + /// A test's dir, removed when the test ends, pass or panic. + struct TestDir(std::path::PathBuf); + + impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn dir(tag: &str) -> TestDir { let d = std::env::temp_dir().join(format!("ledger-mcp-settings-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); - d + TestDir(d) } fn source(d: &std::path::Path, required: bool) -> Source { @@ -497,7 +513,6 @@ mod tests { assert_eq!(live.current().company.name, "Kept Co 2"); assert_eq!(live.status().loads, 2); assert!(live.current().journaled && live.is_journaled()); - std::fs::remove_dir_all(&d).unwrap(); } /// Settings built in code are the books' own once synced. diff --git a/crates/ledger-mcp/src/tin_files.rs b/crates/ledger-mcp/src/tin_files.rs index c59ee2c..0b06bb2 100644 --- a/crates/ledger-mcp/src/tin_files.rs +++ b/crates/ledger-mcp/src/tin_files.rs @@ -357,11 +357,27 @@ fn nul_interleaved(bytes: &[u8]) -> bool { mod tests { use super::*; - fn dir(tag: &str) -> std::path::PathBuf { + /// A test's dir, removed when the test ends, pass or panic. + struct TestDir(std::path::PathBuf); + + impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } + } + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + fn dir(tag: &str) -> TestDir { let d = std::env::temp_dir().join(format!("ledger-mcp-tin-files-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&d); - d + TestDir(d) } /// INVARIANTS 1-2: a recorded file is refused from then on, by this @@ -382,7 +398,6 @@ mod tests { assert_eq!(text.lines().count(), 1); // Only the hash is kept. assert!(!text.contains("PNG")); - let _ = std::fs::remove_dir_all(&vault); } /// The registry's folder for a check (it never exists) and no vault. @@ -614,7 +629,6 @@ Date,Description,Amount,Running Bal.,Reference .expect_err("a registry that can't be read let a file in"); assert!(why.contains("couldn't check the file"), "{why}"); assert!(record(&vault, b"bytes").is_err()); - let _ = std::fs::remove_dir_all(&vault); } /// INVARIANT 4: text holding more than two TIN-shaped numbers ending diff --git a/crates/ledger-mcp/tests/hostile_args.rs b/crates/ledger-mcp/tests/hostile_args.rs index 3c168aa..9a44272 100644 --- a/crates/ledger-mcp/tests/hostile_args.rs +++ b/crates/ledger-mcp/tests/hostile_args.rs @@ -76,6 +76,13 @@ struct Places { sandbox: PathBuf, } +/// The places go when the test ends, pass or panic. +impl Drop for Places { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.base); + } +} + fn places(tag: &str) -> Places { let base = std::env::temp_dir().join(format!("ledger-mcp-hostile-{}-{tag}", std::process::id())); diff --git a/crates/ledger-mcp/tests/http_concurrency.rs b/crates/ledger-mcp/tests/http_concurrency.rs index 0e9d29c..a6499a3 100644 --- a/crates/ledger-mcp/tests/http_concurrency.rs +++ b/crates/ledger-mcp/tests/http_concurrency.rs @@ -28,6 +28,15 @@ impl Drop for Server { } } +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + fn free_port() -> u16 { std::net::TcpListener::bind("127.0.0.1:0") .unwrap() @@ -123,6 +132,8 @@ async fn agents_posting_at_once_leave_whole_books() { let dir = std::env::temp_dir().join(format!("ledger-mcp-http-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); + // Declared before the server, so dropped after it: pass or panic. + let _cleanup = TestDir(dir.clone()); let toml = dir.join("wanderbooks.toml"); std::fs::write(&toml, "[books]\nreceipt_threshold = \"0.00\"\n").unwrap(); let data = dir.join("books-data"); diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 1453d25..79e8932 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -126,11 +126,39 @@ impl Mcp { } } -fn test_dir(name: &str) -> std::path::PathBuf { +/// A test's dir and what sits beside it (`.inbox`, `.elsewhere.txt`), +/// removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + for beside in [".inbox", ".elsewhere.txt"] { + let p = std::path::PathBuf::from(format!("{}{beside}", self.0.display())); + let _ = std::fs::remove_dir_all(&p); + let _ = std::fs::remove_file(&p); + } + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("ledger-mcp-test-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } #[test] diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index 43085d2..7b0040e 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -56,7 +56,31 @@ impl Drop for Mcp { static DIR_SEQ: AtomicU64 = AtomicU64::new(0); -fn test_dir(name: &str) -> PathBuf { +/// A test's dir and the folder beside it (`.inbox`), +/// removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + let _ = std::fs::remove_dir_all(format!("{}.inbox", self.0.display())); + } +} + +fn test_dir(name: &str) -> TestDir { let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( "ledger-mcp-oracle-{}-{name}-{n}", @@ -64,7 +88,7 @@ fn test_dir(name: &str) -> PathBuf { )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } /// `wanderbooks.toml` for two 50/50 members on 3000/3001, cash 1000, @@ -2249,19 +2273,10 @@ fn receipts_and_documents_round_trip() { #[test] fn uploads_up_to_25_mib_are_accepted_and_no_more() { const LIMIT: usize = 25 * 1024 * 1024; + // Over 50 MB of books: removed however the test ends, with its inbox + // (the system temp dir is often RAM, and a mutation run starts this + // test hundreds of times). let dir = test_dir("limits"); - // Over 50 MB of books: removed however the test ends (the system temp - // dir is often RAM, and a mutation run starts this test hundreds of - // times). - struct Cleanup(Vec); - impl Drop for Cleanup { - fn drop(&mut self) { - for d in &self.0 { - let _ = std::fs::remove_dir_all(d); - } - } - } - let _cleanup = Cleanup(vec![dir.clone(), inbox(&dir)]); let s = new_store(&dir); drop(s); let mut m = Mcp::start(&dir, "1000000.00"); diff --git a/crates/ledger-mcp/tests/spec_assets.rs b/crates/ledger-mcp/tests/spec_assets.rs index 9e50e37..cf37efd 100644 --- a/crates/ledger-mcp/tests/spec_assets.rs +++ b/crates/ledger-mcp/tests/spec_assets.rs @@ -49,6 +49,9 @@ struct Mcp { next_id: u64, data: PathBuf, validators: HashMap, + /// The books dir when the server owns it: removed after the server + /// stops (fields drop after `Drop::drop`). + books_dir: Option, } impl Drop for Mcp { @@ -60,7 +63,29 @@ impl Drop for Mcp { static DIR_SEQ: AtomicU64 = AtomicU64::new(0); -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( "ledger-mcp-spec-assets-{}-{name}-{n}", @@ -68,7 +93,7 @@ fn test_dir(name: &str) -> PathBuf { )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } /// `wanderbooks.toml` with a company name and extra `[books]` TOML. The @@ -113,6 +138,7 @@ impl Mcp { } }); let mut mcp = Self { + books_dir: None, stdin, rx, child, @@ -356,6 +382,7 @@ fn books(name: &str) -> Mcp { let toml = books_toml(&dir, "Spec Co", "asset_tag_prefix = \"WL\"\n"); let mut m = Mcp::start(&dir, &toml); m.accounts(CHART); + m.books_dir = Some(dir); m } diff --git a/crates/ledger-mcp/tests/spec_attachments.rs b/crates/ledger-mcp/tests/spec_attachments.rs index 53a738f..b3c7ce0 100644 --- a/crates/ledger-mcp/tests/spec_attachments.rs +++ b/crates/ledger-mcp/tests/spec_attachments.rs @@ -52,6 +52,9 @@ struct Mcp { validators: HashMap, /// Every response line the server wrote, with the tool it answered. transcript: Vec<(String, String)>, + /// The books dir when the server owns it: removed after the server + /// stops (fields drop after `Drop::drop`). + books_dir: Option, } impl Drop for Mcp { @@ -63,7 +66,31 @@ impl Drop for Mcp { static DIR_SEQ: AtomicU64 = AtomicU64::new(0); -fn test_dir(name: &str) -> PathBuf { +/// A test's dir and the folder beside it (`-outside`), +/// removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + let _ = std::fs::remove_dir_all(format!("{}-outside", self.0.display())); + } +} + +fn test_dir(name: &str) -> TestDir { let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( "ledger-mcp-spec-attachments-{}-{name}-{n}", @@ -71,7 +98,7 @@ fn test_dir(name: &str) -> PathBuf { )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } /// A directory beside the books dir `dir`, outside it: the vault key, @@ -131,6 +158,7 @@ impl Mcp { } }); let mut mcp = Self { + books_dir: None, stdin, rx, child, @@ -363,6 +391,7 @@ fn books_at(name: &str, threshold: &str, extra: &str) -> Mcp { let toml = books_toml(&dir, threshold, extra); let mut m = Mcp::start(&dir, &toml); m.accounts(CHART); + m.books_dir = Some(dir); m } diff --git a/crates/ledger-mcp/tests/spec_kept.rs b/crates/ledger-mcp/tests/spec_kept.rs index 11da2c7..44857c0 100644 --- a/crates/ledger-mcp/tests/spec_kept.rs +++ b/crates/ledger-mcp/tests/spec_kept.rs @@ -112,11 +112,33 @@ impl Mcp { } } -fn test_dir(name: &str) -> std::path::PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("ledger-mcp-test-{}-{name}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn init(m: &mut Mcp) -> String { diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 67a506e..5418a1a 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -159,6 +159,9 @@ struct Mcp { read_only_checked: BTreeSet, /// Invariant 3 (amounts out) violations: `tool: path = value`. money_violations: Vec, + /// The books dir when the server owns it: removed after the server + /// stops (fields drop after `Drop::drop`). + books_dir: Option, } impl Drop for Mcp { @@ -170,14 +173,42 @@ impl Drop for Mcp { static DIR_SEQ: AtomicU64 = AtomicU64::new(0); +/// A test's dir and the folders beside it (`.inbox`, `-outside`), +/// removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + for beside in [".inbox", "-outside"] { + let p = PathBuf::from(format!("{}{beside}", self.0.display())); + let _ = std::fs::remove_dir_all(&p); + let _ = std::fs::remove_file(&p); + } + } +} + /// A fresh, empty per-test directory under the system temp dir. -fn test_dir(name: &str) -> PathBuf { +fn test_dir(name: &str) -> TestDir { let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!("ledger-mcp-spec-{}-{name}-{n}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } /// A directory beside the books dir `dir`, outside it (vault keys, files @@ -262,6 +293,7 @@ impl Mcp { } }); let mut mcp = Self { + books_dir: None, stdin, rx, child, @@ -1247,6 +1279,7 @@ fn happy_path(name: &str) -> Mcp { "save_to": outside(&dir).join("cpa.zip").to_str().unwrap()}), ); + m.books_dir = Some(dir); m } @@ -1692,7 +1725,7 @@ const REFUSED_AMOUNTS: &[&str] = &[ /// the core is expected to accept on a line. const ACCEPTED_AMOUNTS: &[&str] = &["5", "5.00", "123.45", "5.5", "007.50", "1000000.00"]; -fn amounts_server(name: &str) -> (PathBuf, Mcp, u64) { +fn amounts_server(name: &str) -> (TestDir, Mcp, u64) { let dir = test_dir(name); let toml = books_toml(&dir, "25.00", ""); let mut m = Mcp::start(&dir, &toml); diff --git a/crates/ledger-mcp/tests/spec_parties.rs b/crates/ledger-mcp/tests/spec_parties.rs index 19ba8b8..2e5629b 100644 --- a/crates/ledger-mcp/tests/spec_parties.rs +++ b/crates/ledger-mcp/tests/spec_parties.rs @@ -50,6 +50,9 @@ struct Mcp { next_id: u64, data: PathBuf, validators: HashMap, + /// The books dir when the server owns it: removed after the server + /// stops (fields drop after `Drop::drop`). + books_dir: Option, } impl Drop for Mcp { @@ -61,7 +64,29 @@ impl Drop for Mcp { static DIR_SEQ: AtomicU64 = AtomicU64::new(0); -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( "ledger-mcp-spec-parties-{}-{name}-{n}", @@ -69,7 +94,7 @@ fn test_dir(name: &str) -> PathBuf { )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } /// `wanderbooks.toml` with a company name and extra `[books]` TOML. The @@ -114,6 +139,7 @@ impl Mcp { } }); let mut mcp = Self { + books_dir: None, stdin, rx, child, @@ -339,6 +365,7 @@ fn books(name: &str) -> Mcp { let toml = books_toml(&dir, "Spec Co", ""); let mut m = Mcp::start(&dir, &toml); m.accounts(CHART); + m.books_dir = Some(dir); m } @@ -1161,6 +1188,7 @@ fn books_with(name: &str, extra: &str) -> Mcp { let toml = books_toml(&dir, "Spec Co", extra); let mut m = Mcp::start(&dir, &toml); m.accounts(CHART); + m.books_dir = Some(dir); m } @@ -1343,6 +1371,7 @@ fn member_books(name: &str, extra: &str) -> Mcp { m.accounts(&[("3001", "Capital K", "equity")]); m.add_party("Stephen Waits", &["member"]); m.add_party("Katherine Pendergast", &["member"]); + m.books_dir = Some(dir); m } diff --git a/crates/ledger-mcp/tests/spec_vault.rs b/crates/ledger-mcp/tests/spec_vault.rs index f60d7e2..d86d086 100644 --- a/crates/ledger-mcp/tests/spec_vault.rs +++ b/crates/ledger-mcp/tests/spec_vault.rs @@ -58,6 +58,9 @@ struct Mcp { validators: HashMap, /// Every response line the server wrote, with the tool it answered. transcript: Vec<(String, String)>, + /// The books dir when the server owns it: removed after the server + /// stops (fields drop after `Drop::drop`). + books_dir: Option, } impl Drop for Mcp { @@ -69,7 +72,35 @@ impl Drop for Mcp { static DIR_SEQ: AtomicU64 = AtomicU64::new(0); -fn test_dir(name: &str) -> PathBuf { +/// A test's dir and the folders beside it (`-inbox`, `-outside`), +/// removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + for beside in ["-inbox", "-outside"] { + let p = PathBuf::from(format!("{}{beside}", self.0.display())); + let _ = std::fs::remove_dir_all(&p); + let _ = std::fs::remove_file(&p); + } + } +} + +fn test_dir(name: &str) -> TestDir { let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); let dir = std::env::temp_dir().join(format!( "ledger-mcp-spec-vault-{}-{name}-{n}", @@ -77,7 +108,7 @@ fn test_dir(name: &str) -> PathBuf { )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } /// A directory beside the books dir `dir`, outside it: the vault key, @@ -164,6 +195,7 @@ impl Mcp { } }); let mut mcp = Self { + books_dir: None, stdin, rx, child, @@ -402,6 +434,7 @@ fn books_with(name: &str, extra: &str) -> Mcp { json!({"vendor": "Acme Design", "form_1099": "nec"}), ); m.add_party("Rent Co", &["landlord"]); + m.books_dir = Some(dir); m } diff --git a/crates/ledger-mcp/tests/startup.rs b/crates/ledger-mcp/tests/startup.rs index 24ca8bb..ca66b10 100644 --- a/crates/ledger-mcp/tests/startup.rs +++ b/crates/ledger-mcp/tests/startup.rs @@ -7,11 +7,39 @@ use std::time::{Duration, Instant}; const BIN: &str = env!("CARGO_BIN_EXE_ledger-mcp"); -fn temp(tag: &str) -> std::path::PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::ffi::OsStr { + self.0.as_os_str() + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn temp(tag: &str) -> TestDir { let d = std::env::temp_dir().join(format!("ledger-mcp-start-{}-{tag}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); - d + TestDir(d) } /// Start the server, initialize, call one tool, and return its text @@ -236,7 +264,8 @@ fn refuses_to_start(cmd: &mut Command) -> Option { /// used. #[test] fn an_import_root_holding_the_books_or_the_home_folder_is_refused() { - let dir = temp("bad-roots").canonicalize().unwrap(); + let tmp = temp("bad-roots"); + let dir = tmp.canonicalize().unwrap(); let home = dir.join("home"); let books = home.join("work/books"); let data = books.join("books-data"); @@ -316,7 +345,8 @@ fn an_import_root_holding_the_books_or_the_home_folder_is_refused() { /// is the root, and the rest of the home folder stays out of reach. #[test] fn a_download_dir_of_home_never_opens_the_home_folder() { - let dir = temp("xdg-home").canonicalize().unwrap(); + let tmp = temp("xdg-home"); + let dir = tmp.canonicalize().unwrap(); let home = dir.join("home"); std::fs::create_dir_all(home.join(".ssh")).unwrap(); std::fs::write(home.join(".ssh/id_ed25519"), "PRIVATE KEY").unwrap(); @@ -358,7 +388,8 @@ fn a_download_dir_of_home_never_opens_the_home_folder() { /// folder, and an import says so. #[test] fn a_default_download_dir_holding_the_books_is_dropped_not_fatal() { - let dir = temp("xdg-books").canonicalize().unwrap(); + let tmp = temp("xdg-books"); + let dir = tmp.canonicalize().unwrap(); let home = dir.join("home"); let downloads = dir.join("downloads"); std::fs::create_dir_all(&home).unwrap(); diff --git a/crates/ledger-mcp/tests/tin_files.rs b/crates/ledger-mcp/tests/tin_files.rs index 62ca7e1..b45a527 100644 --- a/crates/ledger-mcp/tests/tin_files.rs +++ b/crates/ledger-mcp/tests/tin_files.rs @@ -68,6 +68,16 @@ struct Books { toml: PathBuf, } +/// The books dir and the folders beside it go when the test ends, pass or +/// panic. +impl Drop for Books { + fn drop(&mut self) { + if let Some(base) = self.dir.parent() { + let _ = std::fs::remove_dir_all(base); + } + } +} + fn books(tag: &str) -> Books { let base = std::env::temp_dir().join(format!("ledger-mcp-tin-files-{}-{tag}", std::process::id())); diff --git a/crates/ledger-store/src/viewer.rs b/crates/ledger-store/src/viewer.rs index 83dd70c..7a961fb 100644 --- a/crates/ledger-store/src/viewer.rs +++ b/crates/ledger-store/src/viewer.rs @@ -139,10 +139,20 @@ pub fn read(data_dir: &Path) -> Option { mod tests { use super::*; + /// A test's dir, removed when the test ends, pass or panic. + struct TestDir(std::path::PathBuf); + + impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + #[test] fn requests_round_trip_and_count_up() { let dir = std::env::temp_dir().join(format!("wb-viewer-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); + let _cleanup = TestDir(dir.clone()); let a = request(&dir, ViewerTarget::Entry { entry_id: 7 }).unwrap(); let b = request( &dir, diff --git a/crates/ledger-store/tests/asset_tags.rs b/crates/ledger-store/tests/asset_tags.rs index b4e8bef..6f003f7 100644 --- a/crates/ledger-store/tests/asset_tags.rs +++ b/crates/ledger-store/tests/asset_tags.rs @@ -18,11 +18,33 @@ use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store, StoreError}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wb-asset-tags-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn usd(amount: &str) -> Money { @@ -78,7 +100,7 @@ fn laptop(store: &mut Store, a: &Accts, name: &str) -> AssetId { } /// A store with two untagged laptops. -fn fixture(name: &str) -> (PathBuf, Store, Accts, AssetId, AssetId) { +fn fixture(name: &str) -> (TestDir, Store, Accts, AssetId, AssetId) { let dir = test_dir(name); let mut s = Store::create(&dir, Currency::USD).unwrap(); let a = accounts(&mut s); diff --git a/crates/ledger-store/tests/attachments.rs b/crates/ledger-store/tests/attachments.rs index 5dc7ec3..a0d30eb 100644 --- a/crates/ledger-store/tests/attachments.rs +++ b/crates/ledger-store/tests/attachments.rs @@ -25,11 +25,33 @@ use std::str::FromStr; // ---------------------------------------------------------------- helpers -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wb-attach-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn dec(s: &str) -> Decimal { diff --git a/crates/ledger-store/tests/bounds.rs b/crates/ledger-store/tests/bounds.rs index 1b9698e..0da33e5 100644 --- a/crates/ledger-store/tests/bounds.rs +++ b/crates/ledger-store/tests/bounds.rs @@ -21,14 +21,36 @@ use rust_decimal::Decimal; use serde_json::json; use std::path::PathBuf; -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!( "wanderbooks-store-bounds-{name}-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn d(y: i32, m: u32, day: u32) -> NaiveDate { @@ -36,8 +58,8 @@ fn d(y: i32, m: u32, day: u32) -> NaiveDate { } /// The old-books fixture `name` (tests/fixtures/old-books: written by main -/// 188c90ea), opened from a copy. -fn open_old_books(name: &str) -> Store { +/// 188c90ea), opened from a copy (kept while the guard lives). +fn open_old_books(name: &str) -> (TestDir, Store) { let from = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests/fixtures/old-books") .join(name); @@ -55,7 +77,8 @@ fn open_old_books(name: &str) -> Store { std::fs::copy(&e, to).unwrap(); } } - Store::open(&dir).unwrap_or_else(|e| panic!("{name}: {e}")) + let store = Store::open(&dir).unwrap_or_else(|e| panic!("{name}: {e}")); + (dir, store) } /// Every file under `dir`. @@ -114,7 +137,7 @@ fn a_statement_the_books_cant_hold_is_refused() { .unwrap(); // A statement an earlier version recorded past both bounds opens, its // balances as recorded. - let old = open_old_books("x_statement_max"); + let (_dir, old) = open_old_books("x_statement_max"); let recorded = old.statements(None).unwrap(); assert_eq!( recorded[0].closing_balance, @@ -158,7 +181,7 @@ fn a_recurring_run_has_an_end() { assert!(s.run_recurring(last).unwrap().is_empty()); // One run an earlier version made of more than that opens, every // draft as generated. - let old = open_old_books("x_recurring_run_1044"); + let (_dir, old) = open_old_books("x_recurring_run_1044"); assert_eq!(old.ledger().next_entry_id(), 1045); old.verify().unwrap(); } @@ -206,7 +229,7 @@ fn disposal_proceeds_larger_than_a_line_are_refused() { assert!(s.run_depreciation(id, period).is_err(), "{period}"); } // Depreciation an earlier version posted for 10000-01 opens. - let old = open_old_books("x_depreciation_year10000"); + let (_dir, old) = open_old_books("x_depreciation_year10000"); assert_eq!(old.ledger().get_entry(2).unwrap().date(), d(10000, 1, 31)); } diff --git a/crates/ledger-store/tests/concurrency.rs b/crates/ledger-store/tests/concurrency.rs index a8c2fe8..49f7dc5 100644 --- a/crates/ledger-store/tests/concurrency.rs +++ b/crates/ledger-store/tests/concurrency.rs @@ -19,11 +19,33 @@ use rust_decimal::Decimal; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Barrier}; -fn test_dir(name: &str) -> std::path::PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(std::path::PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wanderbooks-conc-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } const ENTRIES: u32 = 400; diff --git a/crates/ledger-store/tests/faults.rs b/crates/ledger-store/tests/faults.rs index ac3da74..1a56fc1 100644 --- a/crates/ledger-store/tests/faults.rs +++ b/crates/ledger-store/tests/faults.rs @@ -15,11 +15,33 @@ use rust_decimal::Decimal; use std::path::{Path, PathBuf}; use std::process::Command; -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wanderbooks-fault-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn post_one(store: &mut Store, n: u32) -> Result<(), ledger_store::StoreError> { @@ -104,7 +126,7 @@ fn full_disk_case(name: &str, drafts: u32) -> String { "trap '' XFSZ; ulimit -f {blocks}; exec \"$0\" --exact child_append_until_the_disk_is_full --nocapture --test-threads=1" )) .arg(&exe) - .env("WB_FAULT_DIR", &dir) + .env("WB_FAULT_DIR", dir.as_os_str()) .output() .unwrap(); let stdout = String::from_utf8_lossy(&out.stdout); diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index d23a94e..a620620 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -27,12 +27,34 @@ use rust_decimal::Decimal; use serde_json::json; use std::path::{Path, PathBuf}; -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wanderbooks-format-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } /// Books with one posted entry, closed again. diff --git a/crates/ledger-store/tests/parties.rs b/crates/ledger-store/tests/parties.rs index 211e0e8..0c8165a 100644 --- a/crates/ledger-store/tests/parties.rs +++ b/crates/ledger-store/tests/parties.rs @@ -24,11 +24,33 @@ use std::str::FromStr; // ---------------------------------------------------------------- helpers -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wb-parties-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn dec(s: &str) -> Decimal { diff --git a/crates/ledger-store/tests/party_tin.rs b/crates/ledger-store/tests/party_tin.rs index 4018f77..1dfdad7 100644 --- a/crates/ledger-store/tests/party_tin.rs +++ b/crates/ledger-store/tests/party_tin.rs @@ -12,11 +12,33 @@ use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store}; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wb-party-tin-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn journal(dir: &Path) -> Vec<(String, Value)> { diff --git a/crates/ledger-store/tests/roles.rs b/crates/ledger-store/tests/roles.rs index bdac86f..628863e 100644 --- a/crates/ledger-store/tests/roles.rs +++ b/crates/ledger-store/tests/roles.rs @@ -18,11 +18,33 @@ use serde_json::{Value, json}; use std::path::{Path, PathBuf}; use std::str::FromStr; -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wb-roles-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn dec(s: &str) -> Decimal { diff --git a/crates/ledger-store/tests/settings.rs b/crates/ledger-store/tests/settings.rs index d98a0ac..1d89c8e 100644 --- a/crates/ledger-store/tests/settings.rs +++ b/crates/ledger-store/tests/settings.rs @@ -17,14 +17,36 @@ use rust_decimal::Decimal; use serde_json::{Value, json}; use std::path::PathBuf; -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!( "wanderbooks-store-settings-{name}-{}", std::process::id() )); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn d(y: i32, m: u32, day: u32) -> NaiveDate { @@ -48,7 +70,7 @@ fn set(k: &str, v: Value) -> Change { } /// Books with a small chart. -fn books(name: &str) -> (PathBuf, Store) { +fn books(name: &str) -> (TestDir, Store) { let dir = test_dir(name); let mut s = Store::create(&dir, Currency::USD).unwrap(); for (code, name, t) in [ diff --git a/crates/ledger-store/tests/snapshot.rs b/crates/ledger-store/tests/snapshot.rs index 288a2ae..77aee40 100644 --- a/crates/ledger-store/tests/snapshot.rs +++ b/crates/ledger-store/tests/snapshot.rs @@ -8,11 +8,33 @@ use rust_decimal::Decimal; use std::io::Write; use std::path::PathBuf; -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wanderbooks-snap-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn usd(n: i64) -> Money { diff --git a/crates/ledger-store/tests/spec_store.rs b/crates/ledger-store/tests/spec_store.rs index 8e9ad25..d590558 100644 --- a/crates/ledger-store/tests/spec_store.rs +++ b/crates/ledger-store/tests/spec_store.rs @@ -21,11 +21,33 @@ use std::path::{Path, PathBuf}; const GENESIS: &str = "0000000000000000000000000000000000000000000000000000000000000000"; -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wb-spec-store-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn usd(amount: u64) -> Money { diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 48bb9f5..160397c 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -13,11 +13,33 @@ fn usd(amount: u64) -> Money { Money::new(Decimal::from(amount), Currency::USD) } -fn test_dir(name: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl AsRef for TestDir { + fn as_ref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { let dir = std::env::temp_dir().join(format!("wanderbooks-{name}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - dir + TestDir(dir) } fn build_books(dir: &std::path::Path) -> Store { @@ -1619,7 +1641,7 @@ fn old_books() -> Vec { /// A fixture's books (journal and blobs, never expected.json) in a fresh /// directory of its own for `test`: opening writes a database and a lock /// beside them. -fn old_books_copy(fixture: &std::path::Path, test: &str) -> PathBuf { +fn old_books_copy(fixture: &std::path::Path, test: &str) -> TestDir { fn copy(from: &std::path::Path, to: &std::path::Path) { std::fs::create_dir_all(to).unwrap(); for e in std::fs::read_dir(from).unwrap() { diff --git a/crates/ledger-vault/tests/vault.rs b/crates/ledger-vault/tests/vault.rs index d26e7f5..a2baff0 100644 --- a/crates/ledger-vault/tests/vault.rs +++ b/crates/ledger-vault/tests/vault.rs @@ -3,11 +3,27 @@ use ledger_vault::{Error, Tin, TinType, Vault, W9File}; use std::path::PathBuf; -fn dir(tag: &str) -> PathBuf { +/// A test's dir, removed when the test ends, pass or panic. +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn dir(tag: &str) -> TestDir { let d = std::env::temp_dir().join(format!("wb-vault-{tag}-{}", std::process::id())); let _ = std::fs::remove_dir_all(&d); std::fs::create_dir_all(&d).unwrap(); - d + TestDir(d) } fn vault(d: &std::path::Path) -> Vault { diff --git a/justfile b/justfile index 4b3c27e..b9447b2 100644 --- a/justfile +++ b/justfile @@ -17,10 +17,12 @@ check: sweep-test-tmp cargo test --workspace @just sweep-test-tmp -# Test books go in the system temp dir (often RAM). A test cleans up after -# itself, but one killed mid-run (a mutant's timeout, ^C) can't: tens of -# thousands piled up to 23 GB of RAM (2026-10-15). Remove this user's test -# book dirs older than an hour, so a run in progress elsewhere keeps its own. +# Test books go in the system temp dir (often RAM). Every test's dir is +# removed when the test ends, pass or panic (a guard dropped on unwind; +# until 2026-10-17 many were removed only on pass, or never: 117,000 piled +# up). A run killed mid-test (a mutant's timeout, ^C) runs no destructor, +# so this sweep removes this user's test book dirs older than an hour; a +# run in progress elsewhere keeps its own. sweep-test-tmp: @find "${TMPDIR:-/tmp}" /tmp -maxdepth 1 -user "$(id -u)" -type d -mmin +60 \( -name 'wb-*' -o -name 'wanderbooks-*' -o -name 'ledger-*' \) -exec rm -rf {} + 2>/dev/null || true