//! Scratch directories for tests. //! //! Public and `#[doc(hidden)]` rather than `#[cfg(test)]`: the `tests/` //! integration binaries and the GUI crate are separate compilation units, so //! a test-gated item here would be invisible to them. use std::path::PathBuf; use std::sync::atomic::{AtomicUsize, Ordering}; /// Distinguishes directories requested within one process; a timestamp alone /// lets two tests in the same millisecond collide. static NEXT: AtomicUsize = AtomicUsize::new(0); /// The compressed body [`crate::db::repo::set_content_done`] wants, for tests /// that only care that a sidecar row gets written. /// /// Production callers compress a whole batch through one /// [`crate::db::repo::DocEncoder`] before taking the connection lock; a test /// writing one row has nothing to amortize and wants the one-liner. pub fn zstd_of(text: &str) -> Option> { crate::db::repo::encode_one(text, true).expect("zstd encode") } /// How old a leftover scratch directory must be before [`sweep_stale`] takes /// it. Far longer than any test run, so a failure investigated the same day — /// or the next morning — still has its tree. const STALE_AFTER: std::time::Duration = std::time::Duration::from_secs(12 * 60 * 60); /// Whether `name` is one of [`scratch_dir`]'s own directories. /// /// Matched on the *shape* — `quicksearch-{tag}-{pid}-{seq}`, so the last two /// dash-separated components must be numbers — rather than on the /// `quicksearch-` prefix alone. `packaging/capture.sh` keeps its output in /// `quicksearch-capture` in the same directory, and a prefix match would eat a /// capture run's screenshots along with the litter. fn is_scratch_name(name: &str) -> bool { let Some(rest) = name.strip_prefix("quicksearch-") else { return false; }; let numeric = |part: Option<&str>| { part.is_some_and(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit())) }; let mut tail = rest.rsplitn(3, '-'); // seq, then pid, and a tag must remain in front of them. numeric(tail.next()) && numeric(tail.next()) && tail.next().is_some_and(|tag| !tag.is_empty()) } /// Remove scratch directories left by runs that are long over. /// /// Nothing here cleans up on the way *out*: a failed test's tree is most of /// the evidence, which is why [`scratch_dir`] deliberately leaves it. But /// passing tests leave theirs too, and most never remove it — so the temp /// directory grew by roughly three hundred directories per full run and had /// accumulated some nine thousand of them. Where `/tmp` is a tmpfs that is /// gigabytes of RAM, which slows the whole suite and pushes the /// timing-sensitive tests toward their budgets. /// /// Sweeping on the way *in* keeps both halves: this run's evidence survives, /// and so does yesterday's, while nothing accumulates without bound. Only /// [`scratch_dir`]'s own naming is touched. fn sweep_stale() { let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else { return; }; let now = std::time::SystemTime::now(); for entry in entries.flatten() { let name = entry.file_name(); if !name.to_str().is_some_and(is_scratch_name) { continue; } let stale = entry .metadata() .and_then(|m| m.modified()) .ok() .and_then(|t| now.duration_since(t).ok()) .is_some_and(|age| age >= STALE_AFTER); if stale { // Best effort throughout: two test binaries starting together race // on the same directory and one of them loses, which is fine. std::fs::remove_dir_all(entry.path()).ok(); } } } /// A fresh, empty directory under the system temp dir, named for `tag`. /// /// Not cleaned up on drop: when a test fails, the tree it built is most of /// the evidence. Long-dead runs' trees are swept once per process instead — /// see [`sweep_stale`]. Panics — a test that cannot create a directory has /// nothing left to assert. #[doc(hidden)] pub fn scratch_dir(tag: &str) -> PathBuf { static SWEPT: std::sync::Once = std::sync::Once::new(); SWEPT.call_once(sweep_stale); let mut p = std::env::temp_dir(); p.push(format!( "quicksearch-{}-{}-{}", tag, std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir_all(&p).expect("create scratch dir"); p } /// [`scratch_dir`] canonicalized, for the tests that compare walked paths /// against the root they were given. On macOS `/tmp` is a symlink to /// `/private/tmp`, so an uncanonicalized root and a walked path disagree. #[doc(hidden)] pub fn scratch_dir_canonical(tag: &str) -> PathBuf { std::fs::canonicalize(scratch_dir(tag)).expect("canonicalize scratch dir") } /// Write `body` to `path`, creating parent directories as needed. #[doc(hidden)] pub fn touch(path: &std::path::Path, body: &[u8]) { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent).expect("create parent dir"); } std::fs::write(path, body).expect("write file"); } /// Power-of-two bucket, so memory-map sizes group by what allocated them /// rather than by their exact size. Shared by the memory probes. #[doc(hidden)] pub fn size_class(bytes: u64) -> String { let mib = bytes as f64 / (1024.0 * 1024.0); if mib < 1.0 { "< 1 MiB".to_string() } else { let bucket = 1u64 << (63 - (bytes / (1024 * 1024)).leading_zeros() as u64); format!("~{} MiB", bucket) } } #[doc(hidden)] pub fn mib(bytes: u64) -> String { format!("{:.1} MiB", bytes as f64 / (1024.0 * 1024.0)) } #[cfg(test)] mod tests { use super::*; #[test] fn every_call_gets_its_own_empty_directory() { let a = scratch_dir("selftest"); let b = scratch_dir("selftest"); assert_ne!(a, b, "two calls must not collide"); for d in [&a, &b] { assert!(d.is_dir()); assert_eq!(std::fs::read_dir(d).unwrap().count(), 0, "starts empty"); } } #[test] fn touch_creates_missing_parents() { let dir = scratch_dir("selftest-touch"); let deep = dir.join("a/b/c.txt"); touch(&deep, b"hi"); assert_eq!(std::fs::read(&deep).unwrap(), b"hi"); } /// The sweep runs against a shared temp directory, so what it matches is /// the whole safety argument. `quicksearch-capture` is the one that would /// hurt: `packaging/capture.sh` puts a run's screenshots and screencasts /// there, and a prefix match would delete them mid-capture. #[test] fn only_scratch_directories_are_swept() { for ours in [ "quicksearch-coord-1234-0", "quicksearch-stall-heavy-1001402-7", "quicksearch-a-0-0", // Tags contain dashes of their own; only the last two components // are read as numbers. "quicksearch-sniff-binary-db-2621744-1", ] { assert!(is_scratch_name(ours), "{ours} should be swept"); } for theirs in [ // The capture output directory, the reason this is a shape match. "quicksearch-capture", "quicksearch", "quicksearch-", // A tag but no pid/seq pair. "quicksearch-coord", "quicksearch-coord-1234", // Numbers, but nothing in front of them to be a tag. "quicksearch-1234-0", // Not ours at all. "cargo-install-abc-1-2", "tmp-quicksearch-coord-1-2", ] { assert!(!is_scratch_name(theirs), "{theirs} must not be swept"); } } /// Fresh directories survive; only long-dead runs are collected. Uses a /// hand-built name rather than `scratch_dir` so the assertion is about the /// age gate and not about whatever else the suite has left lying around. #[test] fn the_sweep_keeps_recent_trees_and_takes_old_ones() { let fresh = scratch_dir("sweep-fresh"); touch(&fresh.join("evidence.txt"), b"kept"); // Same shape, but back-dated past the threshold. `set_times` is the // only way to age a directory without waiting twelve hours for it. let old = std::env::temp_dir().join(format!( "quicksearch-sweep-old-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed) )); std::fs::create_dir_all(&old).expect("create the aged directory"); let long_ago = std::time::SystemTime::now() - STALE_AFTER - std::time::Duration::from_secs(60); std::fs::File::open(&old) .and_then(|d| { d.set_times( std::fs::FileTimes::new() .set_accessed(long_ago) .set_modified(long_ago), ) }) .expect("back-date the aged directory"); sweep_stale(); assert!(fresh.exists(), "a fresh scratch tree was swept away"); assert!(!old.exists(), "a long-dead scratch tree survived the sweep"); std::fs::remove_dir_all(&fresh).ok(); } }