From 427c2bc8d00b526580a7fd889df76b521d978c28 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 13 Sep 2026 14:41:10 +0800 Subject: [PATCH 1/3] Test environment variable and clock code without changing it Two common testing problems get their own integration tests, each written against business code that calls the standard library directly. tests/environment.rs mocks env::var. In the 2024 edition env::set_var is unsafe, and it changes the environment of the whole test process, so tests that set variables must share a lock or run one at a time. The tests read a log level and a database URL with values chosen per thread, cover a missing variable and a default port, check that three overlapping threads each see their own value, and check that a thread without a session still finds the variable unset. The file forbids unsafe code. tests/clock.rs mocks SystemTime::now. Code that reads the clock directly usually has to take a clock parameter before a test can choose the time. The tests run a morning check at chosen instants, step across both edges of the morning window, advance the clock 20 seconds per reading so a token expires without sleeping, and check that other threads keep the machine clock. Checked locally with cargo fmt and cargo clippy --workspace --all-targets -- -D warnings on x86_64-pc-windows-msvc, and cargo clippy -- -D warnings for the two new test targets on aarch64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin and aarch64-pc-windows-msvc. The tests run in CI. --- tests/clock.rs | 107 +++++++++++++++++++++++++++++++ tests/environment.rs | 149 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 tests/clock.rs create mode 100644 tests/environment.rs diff --git a/tests/clock.rs b/tests/clock.rs new file mode 100644 index 0000000..2e34a1d --- /dev/null +++ b/tests/clock.rs @@ -0,0 +1,107 @@ +//! Time-dependent logic tested at chosen instants, without a clock trait. +//! +//! Code that calls `SystemTime::now()` directly usually has to be rewritten to +//! take a clock before a test can choose the time. Mocking `SystemTime::now` +//! lets the tests below pick the time, step across boundaries, and let time pass +//! without sleeping, while the code under test stays as it is. + +#![forbid(unsafe_code)] + +use shimforge::{Session, mock}; +use std::thread; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +/// 2026-01-01T00:00:00Z. +const NEW_YEAR: u64 = 1_767_225_600; + +fn at(seconds: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_secs(seconds) +} + +// Business code below reads the system clock directly and stays unchanged. + +fn is_morning() -> bool { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("the clock is after 1970") + .as_secs(); + let hour = seconds % 86_400 / 3_600; + (7..12).contains(&hour) +} + +struct Token { + expires_at: SystemTime, +} + +impl Token { + /// A token stays valid for 30 seconds past its expiry to absorb clock skew. + fn is_expired(&self) -> bool { + SystemTime::now() > self.expires_at + Duration::from_secs(30) + } +} + +#[test] +fn the_morning_check_runs_at_a_chosen_time() { + let mut session = Session::new(); + let now = mock!(session, SystemTime::now, fn() -> SystemTime); + now.expect().once().returns(at(NEW_YEAR + 10 * 3_600)); + now.expect().once().returns(at(NEW_YEAR + 13 * 3_600)); + + assert!(is_morning()); + assert!(!is_morning()); + session.verify(); +} + +#[test] +fn both_edges_of_the_morning_are_checked() { + let edges = [ + (6 * 3_600 + 59 * 60 + 59, false), + (7 * 3_600, true), + (11 * 3_600 + 59 * 60 + 59, true), + (12 * 3_600, false), + ]; + let mut session = Session::new(); + let now = mock!(session, SystemTime::now, fn() -> SystemTime); + let mut times = edges.map(|(offset, _)| at(NEW_YEAR + offset)).into_iter(); + now.expect() + .times(4) + .returning(move || times.next().unwrap()); + + for (offset, morning) in edges { + assert_eq!(is_morning(), morning, "{offset} seconds after midnight"); + } + session.verify(); +} + +#[test] +fn a_token_expires_as_time_passes_without_sleeping() { + let mut session = Session::new(); + let now = mock!(session, SystemTime::now, fn() -> SystemTime); + let mut current = at(NEW_YEAR); + // Each reading of the clock is 20 seconds after the previous one. + now.expect().times(4).returning(move || { + let reading = current; + current += Duration::from_secs(20); + reading + }); + + let token = Token { + expires_at: at(NEW_YEAR + 10), + }; + let checks: Vec = (0..4).map(|_| token.is_expired()).collect(); + // Expired only once the clock is more than 30 seconds past the expiry. + assert_eq!(checks, [false, false, false, true]); + session.verify(); +} + +#[test] +fn other_threads_keep_the_real_clock() { + let mut session = Session::new(); + let now = mock!(session, SystemTime::now, fn() -> SystemTime); + now.expect().returns(at(NEW_YEAR)); + assert_eq!(SystemTime::now(), at(NEW_YEAR)); + + // A thread with no session of its own reads the machine clock. + let real = thread::spawn(SystemTime::now).join().unwrap(); + assert!(real > at(NEW_YEAR), "{real:?}"); +} diff --git a/tests/environment.rs b/tests/environment.rs new file mode 100644 index 0000000..4d1dfe2 --- /dev/null +++ b/tests/environment.rs @@ -0,0 +1,149 @@ +//! Configuration read from environment variables, tested without setting any. +//! +//! In the 2024 edition `env::set_var` is `unsafe`, and it changes the environment +//! of the whole test process, so tests that set variables race each other unless +//! they run one at a time. Mocking `env::var` gives each test thread its own +//! values instead: nothing is written, no `unsafe` is needed, and the tests below +//! still run in parallel. + +#![forbid(unsafe_code)] + +use shimforge::{Session, mock}; +use std::env::{self, VarError}; +use std::sync::Barrier; +use std::thread; + +// Business code below reads the process environment directly and stays unchanged. + +#[derive(Debug, PartialEq)] +enum LogLevel { + Error, + Info, + Debug, +} + +fn log_level() -> LogLevel { + match env::var("ORDERS_LOG_LEVEL").as_deref() { + Ok("error") => LogLevel::Error, + Ok("debug") => LogLevel::Debug, + _ => LogLevel::Info, + } +} + +fn database_url() -> Result { + let host = env::var("ORDERS_DB_HOST").map_err(|error| format!("ORDERS_DB_HOST: {error}"))?; + let port = env::var("ORDERS_DB_PORT").unwrap_or_else(|_| "5432".to_owned()); + Ok(format!("postgres://{host}:{port}/orders")) +} + +#[test] +fn a_log_level_is_read_without_setting_the_variable() { + let mut session = Session::new(); + let var = mock!( + session, + env::var::<&str>, + fn(&str) -> Result + ); + var.expect() + .with(|name| *name == "ORDERS_LOG_LEVEL") + .once() + .returns(Ok("debug".to_owned())); + + assert_eq!(log_level(), LogLevel::Debug); + session.verify(); +} + +#[test] +fn parallel_threads_each_see_their_own_value() { + let start = Barrier::new(3); + thread::scope(|scope| { + for (value, level) in [ + ("error", LogLevel::Error), + ("info", LogLevel::Info), + ("debug", LogLevel::Debug), + ] { + let start = &start; + scope.spawn(move || { + let mut session = Session::new(); + let var = mock!( + session, + env::var::<&str>, + fn(&str) -> Result + ); + var.expect().times(100).returns(Ok(value.to_owned())); + // Start reading together so the three threads really overlap. + start.wait(); + for _ in 0..100 { + assert_eq!(log_level(), level); + } + session.verify(); + }); + } + }); +} + +#[test] +fn an_unset_port_falls_back_to_the_default() { + let mut session = Session::new(); + let var = mock!( + session, + env::var::<&str>, + fn(&str) -> Result + ); + var.expect() + .with(|name| *name == "ORDERS_DB_HOST") + .once() + .returns(Ok("orders-db.internal".to_owned())); + var.expect() + .with(|name| *name == "ORDERS_DB_PORT") + .once() + .returns(Err(VarError::NotPresent)); + + assert_eq!( + database_url().unwrap(), + "postgres://orders-db.internal:5432/orders" + ); + session.verify(); +} + +#[test] +fn a_missing_host_is_reported_by_name() { + let mut session = Session::new(); + let var = mock!( + session, + env::var::<&str>, + fn(&str) -> Result + ); + // Reading any other variable would be an unmatched call and fail the test. + var.expect() + .with(|name| *name == "ORDERS_DB_HOST") + .once() + .returns(Err(VarError::NotPresent)); + + assert_eq!( + database_url().unwrap_err(), + "ORDERS_DB_HOST: environment variable not found" + ); + session.verify(); +} + +#[test] +fn threads_without_a_session_read_the_real_environment() { + let mut session = Session::new(); + let var = mock!( + session, + env::var::<&str>, + fn(&str) -> Result + ); + var.expect().returns(Ok("debug".to_owned())); + assert_eq!(log_level(), LogLevel::Debug); + + // Nothing was written to the process environment, so other threads see it unset. + assert_eq!(thread::spawn(log_level).join().unwrap(), LogLevel::Info); + assert_eq!( + thread::spawn(|| env::var("ORDERS_LOG_LEVEL")) + .join() + .unwrap(), + Err(VarError::NotPresent) + ); +} From ed91117100a6666dc09127e798679f9a7b9f80ad Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 13 Sep 2026 14:46:36 +0800 Subject: [PATCH 2/3] Test home-directory display through a mocked HOME The environment tests now follow a case that keeps breaking CI in other projects: code that shortens paths under the home directory to ~, and tests that point HOME somewhere else with the unsafe env::set_var. Parallel tests then read each other's HOME, so projects add environment locks, serialize the tests, or change the function to take the home directory as a parameter. The business code reads HOME with env::var_os and stays unchanged. The tests mock env::var_os per thread and cover a path under home, home itself, a sibling directory that shares the prefix, an unset or empty HOME, three overlapping threads that each see their own HOME, and a thread without a session that still sees the real value. The file still forbids unsafe code. The log-level and database-URL examples from the previous commit are gone. The previous commit's tests passed CI on all seven jobs (run 34743354785). Checked this change locally with cargo fmt and cargo clippy -- -D warnings for the environment test target on x86_64-pc-windows-msvc, aarch64-unknown-linux-gnu, aarch64-apple-darwin, x86_64-apple-darwin and aarch64-pc-windows-msvc. --- tests/environment.rs | 204 +++++++++++++++++++++---------------------- 1 file changed, 100 insertions(+), 104 deletions(-) diff --git a/tests/environment.rs b/tests/environment.rs index 4d1dfe2..3278b77 100644 --- a/tests/environment.rs +++ b/tests/environment.rs @@ -1,80 +1,127 @@ -//! Configuration read from environment variables, tested without setting any. +//! Home-directory handling tested without writing `HOME`. //! -//! In the 2024 edition `env::set_var` is `unsafe`, and it changes the environment -//! of the whole test process, so tests that set variables race each other unless -//! they run one at a time. Mocking `env::var` gives each test thread its own -//! values instead: nothing is written, no `unsafe` is needed, and the tests below -//! still run in parallel. +//! Tests that point `HOME` somewhere else usually call `env::set_var`, which is +//! `unsafe` in the 2024 edition and changes the environment of the whole test +//! process. Tests running in parallel then read each other's value and fail at +//! random, so projects add environment locks, run those tests one at a time, or +//! change the code to take the home directory as a parameter. Mocking +//! `env::var_os` gives each test thread its own `HOME` instead: nothing is +//! written, no `unsafe` is needed, and the tests below still run in parallel. #![forbid(unsafe_code)] use shimforge::{Session, mock}; -use std::env::{self, VarError}; +use std::env; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; use std::sync::Barrier; use std::thread; // Business code below reads the process environment directly and stays unchanged. -#[derive(Debug, PartialEq)] -enum LogLevel { - Error, - Info, - Debug, +fn home_dir() -> Option { + env::var_os("HOME") + .filter(|home| !home.is_empty()) + .map(PathBuf::from) } -fn log_level() -> LogLevel { - match env::var("ORDERS_LOG_LEVEL").as_deref() { - Ok("error") => LogLevel::Error, - Ok("debug") => LogLevel::Debug, - _ => LogLevel::Info, +/// Shows a path with the home directory shortened to `~`. +fn display_path(path: &Path) -> String { + let Some(home) = home_dir() else { + return path.display().to_string(); + }; + match path.strip_prefix(&home) { + Ok(rest) if rest.as_os_str().is_empty() => "~".to_owned(), + Ok(rest) => { + let parts: Vec<_> = rest.iter().map(|part| part.to_string_lossy()).collect(); + format!("~/{}", parts.join("/")) + } + Err(_) => path.display().to_string(), } } -fn database_url() -> Result { - let host = env::var("ORDERS_DB_HOST").map_err(|error| format!("ORDERS_DB_HOST: {error}"))?; - let port = env::var("ORDERS_DB_PORT").unwrap_or_else(|_| "5432".to_owned()); - Ok(format!("postgres://{host}:{port}/orders")) +#[test] +fn a_path_under_home_is_shortened() { + let mut session = Session::new(); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") + .once() + .returns(Some(OsString::from("/home/ops"))); + + assert_eq!( + display_path(Path::new("/home/ops/projects/shimforge")), + "~/projects/shimforge" + ); + session.verify(); } #[test] -fn a_log_level_is_read_without_setting_the_variable() { +fn home_itself_is_shown_as_a_tilde() { let mut session = Session::new(); - let var = mock!( - session, - env::var::<&str>, - fn(&str) -> Result - ); - var.expect() - .with(|name| *name == "ORDERS_LOG_LEVEL") + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") + .once() + .returns(Some(OsString::from("/home/ops"))); + + assert_eq!(display_path(Path::new("/home/ops")), "~"); + session.verify(); +} + +#[test] +fn a_sibling_that_shares_the_prefix_is_left_alone() { + let mut session = Session::new(); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") .once() - .returns(Ok("debug".to_owned())); + .returns(Some(OsString::from("/home/ops"))); + + assert_eq!( + display_path(Path::new("/home/opsx/notes")), + "/home/opsx/notes" + ); + session.verify(); +} + +#[test] +fn an_unset_or_empty_home_leaves_paths_alone() { + let mut session = Session::new(); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os.expect().once().returns(None); + var_os.expect().once().returns(Some(OsString::new())); - assert_eq!(log_level(), LogLevel::Debug); + assert_eq!(display_path(Path::new("/home/ops/logs")), "/home/ops/logs"); + assert_eq!(display_path(Path::new("/home/ops/logs")), "/home/ops/logs"); session.verify(); } #[test] -fn parallel_threads_each_see_their_own_value() { +fn parallel_threads_each_get_their_own_home() { let start = Barrier::new(3); thread::scope(|scope| { - for (value, level) in [ - ("error", LogLevel::Error), - ("info", LogLevel::Info), - ("debug", LogLevel::Debug), + for (home, shown) in [ + ("/home/ci", "~/logs"), + ("/home", "~/ci/logs"), + ("/srv", "/home/ci/logs"), ] { let start = &start; scope.spawn(move || { let mut session = Session::new(); - let var = mock!( - session, - env::var::<&str>, - fn(&str) -> Result - ); - var.expect().times(100).returns(Ok(value.to_owned())); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") + .times(100) + .returns(Some(OsString::from(home))); // Start reading together so the three threads really overlap. start.wait(); for _ in 0..100 { - assert_eq!(log_level(), level); + assert_eq!(display_path(Path::new("/home/ci/logs")), shown); } session.verify(); }); @@ -83,67 +130,16 @@ fn parallel_threads_each_see_their_own_value() { } #[test] -fn an_unset_port_falls_back_to_the_default() { +fn other_threads_still_see_the_real_home() { let mut session = Session::new(); - let var = mock!( - session, - env::var::<&str>, - fn(&str) -> Result - ); - var.expect() - .with(|name| *name == "ORDERS_DB_HOST") - .once() - .returns(Ok("orders-db.internal".to_owned())); - var.expect() - .with(|name| *name == "ORDERS_DB_PORT") - .once() - .returns(Err(VarError::NotPresent)); - - assert_eq!( - database_url().unwrap(), - "postgres://orders-db.internal:5432/orders" - ); - session.verify(); -} - -#[test] -fn a_missing_host_is_reported_by_name() { - let mut session = Session::new(); - let var = mock!( - session, - env::var::<&str>, - fn(&str) -> Result - ); - // Reading any other variable would be an unmatched call and fail the test. - var.expect() - .with(|name| *name == "ORDERS_DB_HOST") - .once() - .returns(Err(VarError::NotPresent)); - - assert_eq!( - database_url().unwrap_err(), - "ORDERS_DB_HOST: environment variable not found" - ); - session.verify(); -} + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") + .returns(Some(OsString::from("/home/ops"))); + assert_eq!(display_path(Path::new("/home/ops")), "~"); -#[test] -fn threads_without_a_session_read_the_real_environment() { - let mut session = Session::new(); - let var = mock!( - session, - env::var::<&str>, - fn(&str) -> Result - ); - var.expect().returns(Ok("debug".to_owned())); - assert_eq!(log_level(), LogLevel::Debug); - - // Nothing was written to the process environment, so other threads see it unset. - assert_eq!(thread::spawn(log_level).join().unwrap(), LogLevel::Info); - assert_eq!( - thread::spawn(|| env::var("ORDERS_LOG_LEVEL")) - .join() - .unwrap(), - Err(VarError::NotPresent) - ); + // Nothing was written to the process environment. + let real = thread::spawn(|| env::var_os("HOME")).join().unwrap(); + assert_ne!(real, Some(OsString::from("/home/ops"))); } From d97459535a9648942b63c7af4d5479c2e14fba41 Mon Sep 17 00:00:00 2001 From: tanglearncode Date: Sun, 13 Sep 2026 14:57:08 +0800 Subject: [PATCH 3/3] Move the HOME and clock tests into runnable examples The HOME and clock scenarios are meant to be read on their own and linked to, so they now live in examples/environment.rs and examples/clock.rs. Each file keeps its business code at the top, adds a main that runs that code without mocks, and holds its tests in a cfg(test) module. Cargo builds examples during cargo test but runs their tests only when the example is declared with test = true, so Cargo.toml declares both. CI's cargo test --workspace and cargo clippy --all-targets therefore run and lint them in all seven required jobs. tests/examples.rs fails if a file in examples/ has no such entry, so a new example cannot drop out of CI unnoticed. The coverage gate already ignored test files; it now ignores examples/ too, because an example's main never runs under cargo llvm-cov and is not library code. The package include list gains examples/**, which tests/examples.rs reads. --- Cargo.toml | 11 ++- examples/clock.rs | 125 ++++++++++++++++++++++++++++++++ examples/environment.rs | 157 ++++++++++++++++++++++++++++++++++++++++ scripts/verify.ps1 | 2 +- scripts/verify.sh | 2 +- tests/clock.rs | 107 --------------------------- tests/environment.rs | 145 ------------------------------------- tests/examples.rs | 39 ++++++++++ 8 files changed, 333 insertions(+), 255 deletions(-) create mode 100644 examples/clock.rs create mode 100644 examples/environment.rs delete mode 100644 tests/clock.rs delete mode 100644 tests/environment.rs create mode 100644 tests/examples.rs diff --git a/Cargo.toml b/Cargo.toml index a4af910..8dfc894 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ repository = "https://github.com/XTSoftwareLabs/shimforge" readme = "README.md" keywords = ["mock", "testing", "detour"] categories = ["development-tools::testing"] -include = ["src/**", "tests/**", "Cargo.toml", "COMMERCIAL.md", "LICENSE*", "README.md"] +include = ["src/**", "tests/**", "examples/**", "Cargo.toml", "COMMERCIAL.md", "LICENSE*", "README.md"] [workspace] members = ["macros"] @@ -27,6 +27,15 @@ hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["client", "client-legacy", "http1"] } tokio = { version = "1", features = ["io-util", "macros", "net", "rt"], default-features = false } +# Examples double as tests: `test = true` makes `cargo test` run their #[test] functions. +[[example]] +name = "clock" +test = true + +[[example]] +name = "environment" +test = true + [profile.test] opt-level = 0 debug = true diff --git a/examples/clock.rs b/examples/clock.rs new file mode 100644 index 0000000..e790fa2 --- /dev/null +++ b/examples/clock.rs @@ -0,0 +1,125 @@ +//! Time-dependent logic tested at chosen instants, without a clock trait. +//! +//! Code that calls `SystemTime::now()` directly usually has to be rewritten to +//! take a clock before a test can choose the time. Mocking `SystemTime::now` +//! lets the tests below pick the time, step across boundaries, and let time pass +//! without sleeping, while the code under test stays as it is. +//! +//! Run the tests with `cargo test --example clock`. + +#![forbid(unsafe_code)] + +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +// Business code below reads the system clock directly and stays unchanged. + +fn is_morning() -> bool { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("the clock is after 1970") + .as_secs(); + let hour = seconds % 86_400 / 3_600; + (7..12).contains(&hour) +} + +struct Token { + expires_at: SystemTime, +} + +impl Token { + /// A token stays valid for 30 seconds past its expiry to absorb clock skew. + fn is_expired(&self) -> bool { + SystemTime::now() > self.expires_at + Duration::from_secs(30) + } +} + +fn main() { + println!("morning in UTC: {}", is_morning()); + let token = Token { + expires_at: SystemTime::now(), + }; + println!( + "a token that expires now is expired: {}", + token.is_expired() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use shimforge::{Session, mock}; + use std::thread; + + /// 2026-01-01T00:00:00Z. + const NEW_YEAR: u64 = 1_767_225_600; + + fn at(seconds: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_secs(seconds) + } + + #[test] + fn the_morning_check_runs_at_a_chosen_time() { + let mut session = Session::new(); + let now = mock!(session, SystemTime::now, fn() -> SystemTime); + now.expect().once().returns(at(NEW_YEAR + 10 * 3_600)); + now.expect().once().returns(at(NEW_YEAR + 13 * 3_600)); + + assert!(is_morning()); + assert!(!is_morning()); + session.verify(); + } + + #[test] + fn both_edges_of_the_morning_are_checked() { + let edges = [ + (6 * 3_600 + 59 * 60 + 59, false), + (7 * 3_600, true), + (11 * 3_600 + 59 * 60 + 59, true), + (12 * 3_600, false), + ]; + let mut session = Session::new(); + let now = mock!(session, SystemTime::now, fn() -> SystemTime); + let mut times = edges.map(|(offset, _)| at(NEW_YEAR + offset)).into_iter(); + now.expect() + .times(4) + .returning(move || times.next().unwrap()); + + for (offset, morning) in edges { + assert_eq!(is_morning(), morning, "{offset} seconds after midnight"); + } + session.verify(); + } + + #[test] + fn a_token_expires_as_time_passes_without_sleeping() { + let mut session = Session::new(); + let now = mock!(session, SystemTime::now, fn() -> SystemTime); + let mut current = at(NEW_YEAR); + // Each reading of the clock is 20 seconds after the previous one. + now.expect().times(4).returning(move || { + let reading = current; + current += Duration::from_secs(20); + reading + }); + + let token = Token { + expires_at: at(NEW_YEAR + 10), + }; + let checks: Vec = (0..4).map(|_| token.is_expired()).collect(); + // Expired only once the clock is more than 30 seconds past the expiry. + assert_eq!(checks, [false, false, false, true]); + session.verify(); + } + + #[test] + fn other_threads_keep_the_real_clock() { + let mut session = Session::new(); + let now = mock!(session, SystemTime::now, fn() -> SystemTime); + now.expect().returns(at(NEW_YEAR)); + assert_eq!(SystemTime::now(), at(NEW_YEAR)); + + // A thread with no session of its own reads the machine clock. + let real = thread::spawn(SystemTime::now).join().unwrap(); + assert!(real > at(NEW_YEAR), "{real:?}"); + } +} diff --git a/examples/environment.rs b/examples/environment.rs new file mode 100644 index 0000000..f976bf1 --- /dev/null +++ b/examples/environment.rs @@ -0,0 +1,157 @@ +//! Home-directory handling tested without writing `HOME`. +//! +//! Tests that point `HOME` somewhere else usually call `env::set_var`, which is +//! `unsafe` in the 2024 edition and changes the environment of the whole test +//! process. Tests running in parallel then read each other's value and fail at +//! random, so projects add environment locks, run those tests one at a time, or +//! change the code to take the home directory as a parameter. Mocking +//! `env::var_os` gives each test thread its own `HOME` instead: nothing is +//! written, no `unsafe` is needed, and the tests below still run in parallel. +//! +//! Run the tests with `cargo test --example environment`. + +#![forbid(unsafe_code)] + +use std::env; +use std::path::{Path, PathBuf}; + +// Business code below reads the process environment directly and stays unchanged. + +fn home_dir() -> Option { + env::var_os("HOME") + .filter(|home| !home.is_empty()) + .map(PathBuf::from) +} + +/// Shows a path with the home directory shortened to `~`. +fn display_path(path: &Path) -> String { + let Some(home) = home_dir() else { + return path.display().to_string(); + }; + match path.strip_prefix(&home) { + Ok(rest) if rest.as_os_str().is_empty() => "~".to_owned(), + Ok(rest) => { + let parts: Vec<_> = rest.iter().map(|part| part.to_string_lossy()).collect(); + format!("~/{}", parts.join("/")) + } + Err(_) => path.display().to_string(), + } +} + +fn main() { + let current = env::current_dir().expect("the current directory is readable"); + println!("{}", display_path(¤t)); +} + +#[cfg(test)] +mod tests { + use super::*; + use shimforge::{Session, mock}; + use std::ffi::OsString; + use std::sync::Barrier; + use std::thread; + + #[test] + fn a_path_under_home_is_shortened() { + let mut session = Session::new(); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") + .once() + .returns(Some(OsString::from("/home/ops"))); + + assert_eq!( + display_path(Path::new("/home/ops/projects/shimforge")), + "~/projects/shimforge" + ); + session.verify(); + } + + #[test] + fn home_itself_is_shown_as_a_tilde() { + let mut session = Session::new(); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") + .once() + .returns(Some(OsString::from("/home/ops"))); + + assert_eq!(display_path(Path::new("/home/ops")), "~"); + session.verify(); + } + + #[test] + fn a_sibling_that_shares_the_prefix_is_left_alone() { + let mut session = Session::new(); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") + .once() + .returns(Some(OsString::from("/home/ops"))); + + assert_eq!( + display_path(Path::new("/home/opsx/notes")), + "/home/opsx/notes" + ); + session.verify(); + } + + #[test] + fn an_unset_or_empty_home_leaves_paths_alone() { + let mut session = Session::new(); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os.expect().once().returns(None); + var_os.expect().once().returns(Some(OsString::new())); + + assert_eq!(display_path(Path::new("/home/ops/logs")), "/home/ops/logs"); + assert_eq!(display_path(Path::new("/home/ops/logs")), "/home/ops/logs"); + session.verify(); + } + + #[test] + fn parallel_threads_each_get_their_own_home() { + let start = Barrier::new(3); + thread::scope(|scope| { + for (home, shown) in [ + ("/home/ci", "~/logs"), + ("/home", "~/ci/logs"), + ("/srv", "/home/ci/logs"), + ] { + let start = &start; + scope.spawn(move || { + let mut session = Session::new(); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") + .times(100) + .returns(Some(OsString::from(home))); + // Start reading together so the three threads really overlap. + start.wait(); + for _ in 0..100 { + assert_eq!(display_path(Path::new("/home/ci/logs")), shown); + } + session.verify(); + }); + } + }); + } + + #[test] + fn other_threads_still_see_the_real_home() { + let mut session = Session::new(); + let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); + var_os + .expect() + .with(|name| *name == "HOME") + .returns(Some(OsString::from("/home/ops"))); + assert_eq!(display_path(Path::new("/home/ops")), "~"); + + // Nothing was written to the process environment. + let real = thread::spawn(|| env::var_os("HOME")).join().unwrap(); + assert_ne!(real, Some(OsString::from("/home/ops"))); + } +} diff --git a/scripts/verify.ps1 b/scripts/verify.ps1 index 17cb1e6..83b7b78 100644 --- a/scripts/verify.ps1 +++ b/scripts/verify.ps1 @@ -39,7 +39,7 @@ try { New-Item -ItemType Directory -Path coverage/windows -Force | Out-Null Invoke-Cargo -CargoArguments @( 'llvm-cov', '--workspace', '--all-targets', '--locked', - '--ignore-filename-regex', '(^|[/\\])tests([/\\]|\.rs$)', + '--ignore-filename-regex', '(^|[/\\])(tests|examples)([/\\]|\.rs$)', '--fail-under-lines', '100', '--fail-under-functions', '100', '--lcov', '--output-path', 'coverage/windows/lcov.info', '--', '--test-threads=1' diff --git a/scripts/verify.sh b/scripts/verify.sh index 6d1821a..94aa089 100644 --- a/scripts/verify.sh +++ b/scripts/verify.sh @@ -16,7 +16,7 @@ case "$(uname -s)" in esac mkdir -p "coverage/$platform" cargo llvm-cov --workspace --all-targets --locked \ - --ignore-filename-regex '(^|[/\\])tests([/\\]|\.rs$)' \ + --ignore-filename-regex '(^|[/\\])(tests|examples)([/\\]|\.rs$)' \ --fail-under-lines 100 --fail-under-functions 100 \ --lcov --output-path "coverage/$platform/lcov.info" \ -- --test-threads=1 diff --git a/tests/clock.rs b/tests/clock.rs deleted file mode 100644 index 2e34a1d..0000000 --- a/tests/clock.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! Time-dependent logic tested at chosen instants, without a clock trait. -//! -//! Code that calls `SystemTime::now()` directly usually has to be rewritten to -//! take a clock before a test can choose the time. Mocking `SystemTime::now` -//! lets the tests below pick the time, step across boundaries, and let time pass -//! without sleeping, while the code under test stays as it is. - -#![forbid(unsafe_code)] - -use shimforge::{Session, mock}; -use std::thread; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; - -/// 2026-01-01T00:00:00Z. -const NEW_YEAR: u64 = 1_767_225_600; - -fn at(seconds: u64) -> SystemTime { - UNIX_EPOCH + Duration::from_secs(seconds) -} - -// Business code below reads the system clock directly and stays unchanged. - -fn is_morning() -> bool { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("the clock is after 1970") - .as_secs(); - let hour = seconds % 86_400 / 3_600; - (7..12).contains(&hour) -} - -struct Token { - expires_at: SystemTime, -} - -impl Token { - /// A token stays valid for 30 seconds past its expiry to absorb clock skew. - fn is_expired(&self) -> bool { - SystemTime::now() > self.expires_at + Duration::from_secs(30) - } -} - -#[test] -fn the_morning_check_runs_at_a_chosen_time() { - let mut session = Session::new(); - let now = mock!(session, SystemTime::now, fn() -> SystemTime); - now.expect().once().returns(at(NEW_YEAR + 10 * 3_600)); - now.expect().once().returns(at(NEW_YEAR + 13 * 3_600)); - - assert!(is_morning()); - assert!(!is_morning()); - session.verify(); -} - -#[test] -fn both_edges_of_the_morning_are_checked() { - let edges = [ - (6 * 3_600 + 59 * 60 + 59, false), - (7 * 3_600, true), - (11 * 3_600 + 59 * 60 + 59, true), - (12 * 3_600, false), - ]; - let mut session = Session::new(); - let now = mock!(session, SystemTime::now, fn() -> SystemTime); - let mut times = edges.map(|(offset, _)| at(NEW_YEAR + offset)).into_iter(); - now.expect() - .times(4) - .returning(move || times.next().unwrap()); - - for (offset, morning) in edges { - assert_eq!(is_morning(), morning, "{offset} seconds after midnight"); - } - session.verify(); -} - -#[test] -fn a_token_expires_as_time_passes_without_sleeping() { - let mut session = Session::new(); - let now = mock!(session, SystemTime::now, fn() -> SystemTime); - let mut current = at(NEW_YEAR); - // Each reading of the clock is 20 seconds after the previous one. - now.expect().times(4).returning(move || { - let reading = current; - current += Duration::from_secs(20); - reading - }); - - let token = Token { - expires_at: at(NEW_YEAR + 10), - }; - let checks: Vec = (0..4).map(|_| token.is_expired()).collect(); - // Expired only once the clock is more than 30 seconds past the expiry. - assert_eq!(checks, [false, false, false, true]); - session.verify(); -} - -#[test] -fn other_threads_keep_the_real_clock() { - let mut session = Session::new(); - let now = mock!(session, SystemTime::now, fn() -> SystemTime); - now.expect().returns(at(NEW_YEAR)); - assert_eq!(SystemTime::now(), at(NEW_YEAR)); - - // A thread with no session of its own reads the machine clock. - let real = thread::spawn(SystemTime::now).join().unwrap(); - assert!(real > at(NEW_YEAR), "{real:?}"); -} diff --git a/tests/environment.rs b/tests/environment.rs deleted file mode 100644 index 3278b77..0000000 --- a/tests/environment.rs +++ /dev/null @@ -1,145 +0,0 @@ -//! Home-directory handling tested without writing `HOME`. -//! -//! Tests that point `HOME` somewhere else usually call `env::set_var`, which is -//! `unsafe` in the 2024 edition and changes the environment of the whole test -//! process. Tests running in parallel then read each other's value and fail at -//! random, so projects add environment locks, run those tests one at a time, or -//! change the code to take the home directory as a parameter. Mocking -//! `env::var_os` gives each test thread its own `HOME` instead: nothing is -//! written, no `unsafe` is needed, and the tests below still run in parallel. - -#![forbid(unsafe_code)] - -use shimforge::{Session, mock}; -use std::env; -use std::ffi::OsString; -use std::path::{Path, PathBuf}; -use std::sync::Barrier; -use std::thread; - -// Business code below reads the process environment directly and stays unchanged. - -fn home_dir() -> Option { - env::var_os("HOME") - .filter(|home| !home.is_empty()) - .map(PathBuf::from) -} - -/// Shows a path with the home directory shortened to `~`. -fn display_path(path: &Path) -> String { - let Some(home) = home_dir() else { - return path.display().to_string(); - }; - match path.strip_prefix(&home) { - Ok(rest) if rest.as_os_str().is_empty() => "~".to_owned(), - Ok(rest) => { - let parts: Vec<_> = rest.iter().map(|part| part.to_string_lossy()).collect(); - format!("~/{}", parts.join("/")) - } - Err(_) => path.display().to_string(), - } -} - -#[test] -fn a_path_under_home_is_shortened() { - let mut session = Session::new(); - let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); - var_os - .expect() - .with(|name| *name == "HOME") - .once() - .returns(Some(OsString::from("/home/ops"))); - - assert_eq!( - display_path(Path::new("/home/ops/projects/shimforge")), - "~/projects/shimforge" - ); - session.verify(); -} - -#[test] -fn home_itself_is_shown_as_a_tilde() { - let mut session = Session::new(); - let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); - var_os - .expect() - .with(|name| *name == "HOME") - .once() - .returns(Some(OsString::from("/home/ops"))); - - assert_eq!(display_path(Path::new("/home/ops")), "~"); - session.verify(); -} - -#[test] -fn a_sibling_that_shares_the_prefix_is_left_alone() { - let mut session = Session::new(); - let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); - var_os - .expect() - .with(|name| *name == "HOME") - .once() - .returns(Some(OsString::from("/home/ops"))); - - assert_eq!( - display_path(Path::new("/home/opsx/notes")), - "/home/opsx/notes" - ); - session.verify(); -} - -#[test] -fn an_unset_or_empty_home_leaves_paths_alone() { - let mut session = Session::new(); - let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); - var_os.expect().once().returns(None); - var_os.expect().once().returns(Some(OsString::new())); - - assert_eq!(display_path(Path::new("/home/ops/logs")), "/home/ops/logs"); - assert_eq!(display_path(Path::new("/home/ops/logs")), "/home/ops/logs"); - session.verify(); -} - -#[test] -fn parallel_threads_each_get_their_own_home() { - let start = Barrier::new(3); - thread::scope(|scope| { - for (home, shown) in [ - ("/home/ci", "~/logs"), - ("/home", "~/ci/logs"), - ("/srv", "/home/ci/logs"), - ] { - let start = &start; - scope.spawn(move || { - let mut session = Session::new(); - let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); - var_os - .expect() - .with(|name| *name == "HOME") - .times(100) - .returns(Some(OsString::from(home))); - // Start reading together so the three threads really overlap. - start.wait(); - for _ in 0..100 { - assert_eq!(display_path(Path::new("/home/ci/logs")), shown); - } - session.verify(); - }); - } - }); -} - -#[test] -fn other_threads_still_see_the_real_home() { - let mut session = Session::new(); - let var_os = mock!(session, env::var_os::<&str>, fn(&str) -> Option); - var_os - .expect() - .with(|name| *name == "HOME") - .returns(Some(OsString::from("/home/ops"))); - assert_eq!(display_path(Path::new("/home/ops")), "~"); - - // Nothing was written to the process environment. - let real = thread::spawn(|| env::var_os("HOME")).join().unwrap(); - assert_ne!(real, Some(OsString::from("/home/ops"))); -} diff --git a/tests/examples.rs b/tests/examples.rs new file mode 100644 index 0000000..4e21245 --- /dev/null +++ b/tests/examples.rs @@ -0,0 +1,39 @@ +//! Examples double as test files. `cargo test` runs an example's tests only when +//! `Cargo.toml` declares it with `test = true`, so a new example without that +//! entry would silently drop out of CI. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +#[test] +fn every_example_runs_its_tests_under_cargo_test() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")); + let manifest = fs::read_to_string(root.join("Cargo.toml")).unwrap(); + let mut tested = BTreeSet::new(); + let mut in_example = false; + let mut name = None; + for line in manifest.lines().map(str::trim) { + if line.starts_with('[') { + in_example = line == "[[example]]"; + name = None; + } else if in_example { + if let Some(value) = line.strip_prefix("name = ") { + name = Some(value.trim_matches('"').to_owned()); + } else if line == "test = true" { + tested.extend(name.clone()); + } + } + } + + let examples: BTreeSet = fs::read_dir(root.join("examples")) + .unwrap() + .map(|entry| entry.unwrap().path()) + .filter(|path| path.extension().is_some_and(|extension| extension == "rs")) + .map(|path| path.file_stem().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + examples, tested, + "declare every example in Cargo.toml as [[example]] with name first and test = true" + ); +}