From db82b592178a7455de22e81e677656ded5573610 Mon Sep 17 00:00:00 2001 From: Shuoliu Yang Date: Fri, 7 Aug 2026 14:48:41 +0800 Subject: [PATCH 1/2] streamline test coverage --- dot-cli/src/config/https.rs | 22 +- dot-cli/src/config/mod.rs | 510 +---------------------- dot-cli/src/main.rs | 30 -- dot-cli/tests/config_path_command.rs | 140 +++++++ src/config.rs | 2 +- src/interpolation/mod.rs | 598 --------------------------- src/interpolation/resolver.rs | 62 --- src/native/apply.rs | 235 +---------- src/native/fetch_content.rs | 71 +--- src/native/job_execution.rs | 34 -- src/native/link.rs | 24 -- src/platform.rs | 91 +--- src/validation.rs | 2 +- tests/action_runner.rs | 4 - tests/config.rs | 14 - tests/error_contract.rs | 477 --------------------- tests/fixture_support.rs | 26 -- tests/interpolation.rs | 259 ++++++++++++ tests/job.rs | 82 +--- tests/platform.rs | 91 ++++ tests/provider.rs | 4 - tests/provider_installs.rs | 2 - tests/report_schema.rs | 182 -------- tests/schema.rs | 69 +--- tests/validation.rs | 4 - 25 files changed, 513 insertions(+), 2522 deletions(-) create mode 100644 dot-cli/tests/config_path_command.rs delete mode 100644 tests/error_contract.rs delete mode 100644 tests/fixture_support.rs create mode 100644 tests/interpolation.rs create mode 100644 tests/platform.rs delete mode 100644 tests/report_schema.rs diff --git a/dot-cli/src/config/https.rs b/dot-cli/src/config/https.rs index 29bb1af..50e547a 100644 --- a/dot-cli/src/config/https.rs +++ b/dot-cli/src/config/https.rs @@ -101,7 +101,6 @@ impl HttpsError { #[cfg(test)] mod tests { - use std::error::Error; use std::io::{self, Cursor, Read}; use super::*; @@ -123,7 +122,6 @@ mod tests { "http://example.com/dot.toml".to_owned(), )); assert!(matches!(require_https, HttpsError::RequireHttpsOnly { .. })); - assert!(Error::source(&require_https).is_some_and(|source| source.is::())); let redirects = HttpsError::from_call(ureq::Error::TooManyRedirects); assert!(matches!(redirects, HttpsError::TooManyRedirects { .. })); @@ -139,20 +137,6 @@ mod tests { let transport = HttpsError::from_call(ureq::Error::ConnectionFailed); assert!(matches!(transport, HttpsError::Transport { .. })); - assert!(Error::source(&transport).is_some_and(|source| source.is::())); - } - - #[test] - fn maps_ureq_io_to_the_underlying_typed_transport_source() { - let error = HttpsError::from_call(ureq::Error::Io(io::Error::new( - io::ErrorKind::ConnectionAborted, - "network stopped", - ))); - - let source = Error::source(&error) - .and_then(|source| source.downcast_ref::()) - .expect("transport I/O should be the immediate typed source"); - assert_eq!(source.kind(), io::ErrorKind::ConnectionAborted); } #[test] @@ -183,12 +167,10 @@ mod tests { let read = read_body(FailingReader).expect_err("body I/O should fail"); assert!(matches!(read, HttpsError::BodyRead { .. })); - assert!(Error::source(&read).is_some_and(|source| source.is::())); + assert!(read.to_string().contains("response body stopped")); let utf8 = read_body(Cursor::new([0xff])).expect_err("invalid UTF-8 should fail"); assert!(matches!(utf8, HttpsError::InvalidUtf8 { .. })); - assert!( - Error::source(&utf8).is_some_and(|source| source.is::()) - ); + assert!(utf8.to_string().contains("UTF-8")); } } diff --git a/dot-cli/src/config/mod.rs b/dot-cli/src/config/mod.rs index 35c38d7..71cae8e 100644 --- a/dot-cli/src/config/mod.rs +++ b/dot-cli/src/config/mod.rs @@ -2,7 +2,7 @@ #![expect( clippy::result_large_err, - reason = "direct typed error sources preserve Error::source downcasts" + reason = "typed loading errors preserve precise source and path context without boxing" )] use std::env; @@ -402,12 +402,6 @@ pub(crate) enum ConfigLoadError { #[cfg(test)] mod tests { - use std::error::Error; - - use dot_core::interpolation::InterpolationError; - use dot_core::schema::{Identifier, SelectorIdentifier}; - use dot_core::validation::{ConfigValidationErrorKind, ConfigValidationJob}; - use super::*; #[test] @@ -473,16 +467,8 @@ mod tests { } } - fn fixture_path(relative: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .parent() - .expect("dot-cli should be inside the workspace") - .join("tests/fixtures") - .join(relative) - } - #[test] - fn remote_parse_and_validation_errors_name_the_source_url() { + fn remote_errors_name_the_source_url() { let url = Url::parse("https://example.com/config/dot.toml").expect("test URL should be valid"); let cwd = env::current_dir().expect("test should have a current directory"); @@ -514,7 +500,7 @@ platform = { os = "linux" } } #[test] - fn remote_loading_uses_the_absolute_invocation_directory_for_all_context() { + fn remote_loading_uses_the_invocation_directory_for_all_context() { let url = Url::parse("https://example.com/config/dot.toml").expect("test URL should be valid"); let cwd = env::current_dir().expect("test should have a current directory"); @@ -533,496 +519,18 @@ platform = { os = ["linux", "macos", "windows"] } assert_eq!(loaded.cwd(), cwd); } - fn unique_temp_path(label: &str) -> PathBuf { - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after the Unix epoch") - .as_nanos(); - env::temp_dir().join(format!("dot-{label}-{}-{nonce}", std::process::id())) - } - - fn path_request(path: PathBuf) -> ConfigRequest { - ConfigRequest::Source(ConfigSource::Path(path)) - } - - #[test] - fn explicit_request_bypasses_discovery() { - let requested = PathBuf::from("relative/config.toml"); - - let resolved = path_request(requested.clone()) - .resolve_with( - Path::new("/unused"), - || panic!("user root must not be queried"), - |_| -> io::Result { panic!("candidate must not be inspected") }, - ) - .expect("explicit request should resolve"); - - assert_eq!(resolved, ConfigSource::Path(requested)); - } - - #[test] - fn explicit_resolve_preserves_the_requested_path() { - let requested = PathBuf::from("relative/config.toml"); - - let resolved = path_request(requested.clone()) - .resolve(Path::new("/unused")) - .expect("explicit request should resolve"); - - assert_eq!(resolved, ConfigSource::Path(requested)); - } - - #[test] - fn local_candidate_wins_without_querying_user_root() { - let invocation_cwd = PathBuf::from("/work"); - let expected = invocation_cwd.join(".dot.toml"); - - let resolved = ConfigRequest::Discover - .resolve_with( - &invocation_cwd, - || panic!("user root must not be queried"), - |candidate| { - assert_eq!(candidate, expected); - Ok(true) - }, - ) - .expect("local candidate should resolve"); - - assert_eq!(resolved, ConfigSource::Path(expected)); - } - - #[test] - fn home_root_constructs_platform_neutral_manifest_path() { - let root = PathBuf::from("/home/alice"); - - let manifest = UserConfigRoot::Home(root.clone()).manifest_path(); - - assert_eq!(manifest, root.join(".config").join("dot").join(".dot.toml")); - } - - #[test] - fn roaming_root_constructs_platform_neutral_manifest_path() { - let root = PathBuf::from(r"C:\Users\alice\AppData\Roaming"); - - let manifest = UserConfigRoot::Roaming(root.clone()).manifest_path(); - - assert_eq!(manifest, root.join("dot").join(".dot.toml")); - } - - #[test] - fn missing_local_candidate_selects_user_candidate() { - let invocation_cwd = PathBuf::from("/work"); - let local = invocation_cwd.join(".dot.toml"); - let home = PathBuf::from("/home/alice"); - let user = home.join(".config").join("dot").join(".dot.toml"); - let mut inspected = Vec::new(); - - let resolved = ConfigRequest::Discover - .resolve_with( - &invocation_cwd, - || Some(UserConfigRoot::Home(home)), - |candidate| { - inspected.push(candidate.to_path_buf()); - Ok(candidate == user) - }, - ) - .expect("user candidate should resolve"); - - assert_eq!(resolved, ConfigSource::Path(user.clone())); - assert_eq!(inspected, vec![local, user]); - } - #[test] - fn local_inspection_error_stops_before_user_detection() { - let invocation_cwd = PathBuf::from("/work"); - let local = invocation_cwd.join(".dot.toml"); - - let error = ConfigRequest::Discover - .resolve_with( - &invocation_cwd, - || panic!("user root must not be queried"), - |_| { - Err(io::Error::new( - io::ErrorKind::PermissionDenied, - "inspection blocked", - )) - }, - ) - .expect_err("inspection should fail"); - - match error { - ConfigDiscoveryError::Inspect { path, source } => { - assert_eq!(path, local); - assert_eq!(source.kind(), io::ErrorKind::PermissionDenied); - } - other => panic!("expected inspect error, got {other:?}"), - } - } - - #[test] - fn unavailable_user_root_is_reported_only_after_local_is_missing() { - let invocation_cwd = PathBuf::from("/work"); - let local = invocation_cwd.join(".dot.toml"); - let mut inspected = Vec::new(); - - let error = ConfigRequest::Discover - .resolve_with( - &invocation_cwd, - || None, - |candidate| { - inspected.push(candidate.to_path_buf()); - Ok(false) - }, - ) - .expect_err("missing user root should fail"); - - assert!(matches!( - error, - ConfigDiscoveryError::UserDirectoryUnavailable - )); - assert_eq!(inspected, vec![local]); - } - - #[test] - fn missing_candidates_report_both_paths_in_probe_order() { - let invocation_cwd = PathBuf::from("/work"); - let local = invocation_cwd.join(".dot.toml"); + fn user_roots_construct_each_platform_manifest_path() { let home = PathBuf::from("/home/alice"); - let user = home.join(".config").join("dot").join(".dot.toml"); - - let error = ConfigRequest::Discover - .resolve_with( - &invocation_cwd, - || Some(UserConfigRoot::Home(home)), - |_| Ok(false), - ) - .expect_err("missing candidates should fail"); - - match &error { - ConfigDiscoveryError::NotFound { - local: actual_local, - user: actual_user, - } => { - assert_eq!(actual_local, &local); - assert_eq!(actual_user, &user); - } - other => panic!("expected not-found error, got {other:?}"), - } assert_eq!( - error.to_string(), - format!( - "configuration not found; checked `{}` then `{}`; use --config SOURCE to select another file", - local.display(), - user.display() - ) + UserConfigRoot::Home(home.clone()).manifest_path(), + home.join(".config").join("dot").join(".dot.toml") ); - assert!(error.to_string().contains("--config SOURCE")); - } - #[test] - fn discovery_errors_have_exact_messages() { - let unavailable = ConfigDiscoveryError::UserDirectoryUnavailable; + let roaming = PathBuf::from(r"C:\Users\alice\AppData\Roaming"); assert_eq!( - unavailable.to_string(), - "failed to determine the user configuration directory" - ); - - let candidate = PathBuf::from("/work/.dot.toml"); - let inspect = ConfigDiscoveryError::Inspect { - path: candidate.clone(), - source: io::Error::new(io::ErrorKind::PermissionDenied, "entry blocked"), - }; - assert_eq!( - inspect.to_string(), - format!( - "failed to inspect configuration candidate `{}`: entry blocked", - candidate.display() - ) - ); - } - - #[test] - fn discovery_inspect_error_exposes_its_io_source() { - let inspect = ConfigDiscoveryError::Inspect { - path: PathBuf::from("/work/.dot.toml"), - source: io::Error::new(io::ErrorKind::PermissionDenied, "entry blocked"), - }; - assert_eq!( - Error::source(&inspect) - .expect("inspect error should have a source") - .to_string(), - "entry blocked" - ); - - assert!(Error::source(&ConfigDiscoveryError::UserDirectoryUnavailable).is_none()); - assert!( - Error::source(&ConfigDiscoveryError::NotFound { - local: PathBuf::from("/work/.dot.toml"), - user: PathBuf::from("/home/alice/.config/dot/.dot.toml"), - }) - .is_none() - ); - } - - #[test] - fn missing_path_entry_is_absent() { - let missing = unique_temp_path("missing-config"); - - assert!(!path_entry_exists(&missing).expect("missing entry should be inspectable")); - } - - #[cfg(unix)] - #[test] - fn dangling_symlink_is_present_but_cannot_be_canonicalized() { - let directory = unique_temp_path("dangling-config"); - fs::create_dir(&directory).expect("temporary directory should be created"); - let candidate = directory.join(".dot.toml"); - std::os::unix::fs::symlink(directory.join("missing-target"), &candidate) - .expect("dangling symlink should be created"); - - let present = path_entry_exists(&candidate).expect("symlink entry should be inspectable"); - let load_error = load_config(path_request(candidate.clone())) - .expect_err("dangling symlink should not load"); - - fs::remove_file(&candidate).expect("temporary symlink should be removed"); - fs::remove_dir(&directory).expect("temporary directory should be removed"); - - assert!(present); - match load_error { - ConfigLoadError::Canonicalize { path, source } => { - assert_eq!(path, candidate); - assert_eq!(source.kind(), io::ErrorKind::NotFound); - } - other => panic!("expected canonicalize error, got {other:?}"), - } - } - - #[cfg(unix)] - #[test] - fn canonicalizable_directory_entry_fails_during_read() { - let root = unique_temp_path("directory-config"); - fs::create_dir(&root).expect("temporary root should be created"); - let entity = root.join("config-directory"); - fs::create_dir(&entity).expect("directory entity should be created"); - let entry = root.join(".dot.toml"); - std::os::unix::fs::symlink(&entity, &entry) - .expect("configuration symlink should be created"); - - let result = load_config(path_request(entry.clone())); - - fs::remove_file(&entry).expect("temporary symlink should be removed"); - fs::remove_dir(&entity).expect("directory entity should be removed"); - fs::remove_dir(&root).expect("temporary root should be removed"); - - let error = result.expect_err("directory configuration should fail during read"); - let immediate_source = - Error::source(&error).expect("read error should have an immediate source"); - assert!( - immediate_source.downcast_ref::().is_some(), - "read error source should be an io::Error" - ); - match error { - ConfigLoadError::Read { path, source } => { - assert_eq!(path, entry); - assert_ne!(path, entity); - assert_eq!(source.kind(), io::ErrorKind::IsADirectory); - } - other => panic!("expected read error, got {other:?}"), - } - } - - #[test] - fn static_document_loads_config_and_absolute_path_metadata() { - let invocation_cwd = env::current_dir().expect("test should have a current directory"); - let expected_path = fixture_path("dot.toml"); - let expected_real_path = - fs::canonicalize(&expected_path).expect("fixture path should canonicalize"); - - let loaded = load_config(path_request(expected_path.clone())).expect("fixture should load"); - - assert_eq!(loaded.config().targets.len(), 6); - assert_eq!(loaded.config_dir(), expected_path.parent().unwrap()); - assert!(loaded.config_dir().is_absolute()); - assert_eq!( - loaded.real_config_dir(), - expected_real_path - .parent() - .expect("canonical fixture should have a parent") - ); - assert_eq!(loaded.cwd(), invocation_cwd); - } - - #[cfg(unix)] - #[test] - fn static_document_distinguishes_a_symlink_entry_from_the_real_config() { - let directory = unique_temp_path("config-symlink"); - fs::create_dir(&directory).expect("temporary directory should be created"); - let entry = directory.join(".dot.toml"); - let real = fs::canonicalize(fixture_path("dot.toml")).expect("fixture should canonicalize"); - std::os::unix::fs::symlink(&real, &entry).expect("configuration symlink should be created"); - - let result = load_config(path_request(entry.clone())); - - fs::remove_file(&entry).expect("temporary symlink should be removed"); - fs::remove_dir(&directory).expect("temporary directory should be removed"); - - let loaded = result.expect("configuration symlink should load"); - assert_eq!(loaded.config_dir(), directory); - assert_eq!(loaded.real_config_dir(), real.parent().unwrap()); - } - - #[test] - fn relative_paths_are_made_absolute_against_the_invocation_directory() { - let invocation_cwd = Path::new("/work"); - - assert_eq!( - absolute_path(Path::new("config/dot.toml"), invocation_cwd), - invocation_cwd.join("config/dot.toml") - ); - } - - #[test] - fn relative_manifest_loads_with_absolute_protocol_context() { - let invocation_cwd = env::current_dir().expect("test should have a current directory"); - let nonce = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .expect("system clock should be after the Unix epoch") - .as_nanos(); - let relative_dir = PathBuf::from("target").join(format!( - "dot-relative-config-{}-{nonce}", - std::process::id() - )); - let absolute_dir = invocation_cwd.join(&relative_dir); - fs::create_dir_all(&absolute_dir).expect("temporary directory should be created"); - let relative_path = relative_dir.join("dot.toml"); - let absolute_path = absolute_dir.join("dot.toml"); - fs::write( - &absolute_path, - r#"[targets.machine] -platform = { os = ["linux", "macos", "windows"] } -"#, - ) - .expect("test manifest should be written"); - - let result = load_config(path_request(relative_path)); - let real_dir = - fs::canonicalize(&absolute_dir).expect("temporary directory should canonicalize"); - fs::remove_dir_all(&absolute_dir).expect("temporary directory should be removed"); - - let loaded = result.expect("relative manifest should load"); - assert_eq!(loaded.config_dir(), absolute_dir); - assert_eq!(loaded.real_config_dir(), real_dir); - assert_eq!(loaded.cwd(), invocation_cwd); - } - - #[test] - fn missing_manifest_reports_the_requested_absolute_entry_path() { - let missing = unique_temp_path("missing-manifest"); - - let error = - load_config(path_request(missing.clone())).expect_err("missing manifest should fail"); - - match &error { - ConfigLoadError::Canonicalize { path, source } => { - assert_eq!(path, &missing); - assert_eq!(source.kind(), io::ErrorKind::NotFound); - } - other => panic!("expected canonicalize error, got {other:?}"), - } - assert!( - error - .to_string() - .contains(missing.to_string_lossy().as_ref()) - ); - assert!(Error::source(&error).is_some()); - } - - #[test] - fn invalid_documents_and_manifests_report_the_requested_absolute_path() { - let invalid_document = fixture_path("config/invalid-syntax.toml"); - let parse_error = load_config(path_request(invalid_document.clone())) - .expect_err("invalid TOML should fail"); - assert!(matches!( - parse_error, - ConfigLoadError::Parse { ref path, .. } if path == &invalid_document - )); - - let invalid_manifest = fixture_path("manifest/invalid-duplicate-profile-name.toml"); - let validation_error = load_config(path_request(invalid_manifest.clone())) - .expect_err("invalid manifest should fail"); - assert!(matches!( - validation_error, - ConfigLoadError::Validation { ref path, .. } if path == &invalid_manifest - )); - } - - fn test_validation_error() -> ConfigValidationError { - ConfigValidationError { - target: SelectorIdentifier::new("target").expect("test target should be valid"), - profile: None, - job: Some(ConfigValidationJob::Provider( - Identifier::new("provider").expect("test provider should be valid"), - )), - field: Some("field".to_owned()), - kind: ConfigValidationErrorKind::Expression(InterpolationError::UnclosedResolver { - offset: 0, - }), - } - } - - #[test] - fn load_errors_preserve_their_typed_immediate_sources() { - let io_errors = [ - ConfigLoadError::CurrentDirectory { - source: io::Error::other("test I/O failure"), - }, - ConfigLoadError::Canonicalize { - path: PathBuf::from("dot.toml"), - source: io::Error::other("test I/O failure"), - }, - ConfigLoadError::Read { - path: PathBuf::from("dot.toml"), - source: io::Error::other("test I/O failure"), - }, - ]; - for error in &io_errors { - assert!( - Error::source(error) - .and_then(|source| source.downcast_ref::()) - .is_some() - ); - } - - let parse = ConfigLoadError::Parse { - path: PathBuf::from("dot.toml"), - source: toml::from_str::("invalid = [") - .expect_err("test TOML should be invalid"), - }; - assert!( - Error::source(&parse) - .and_then(|source| source.downcast_ref::()) - .is_some() - ); - - let validation = ConfigLoadError::Validation { - path: PathBuf::from("dot.toml"), - source: test_validation_error(), - }; - let validation_source = Error::source(&validation) - .and_then(|source| source.downcast_ref::()) - .expect("validation error should be the immediate source"); - assert!( - Error::source(validation_source) - .and_then(|source| source.downcast_ref::()) - .is_some() - ); - - let protocol = ConfigLoadError::ConfigFile(ConfigFileError::RelativeCwd { - path: PathBuf::from("relative"), - }); - assert!( - Error::source(&protocol) - .and_then(|source| source.downcast_ref::()) - .is_some() + UserConfigRoot::Roaming(roaming.clone()).manifest_path(), + roaming.join("dot").join(".dot.toml") ); } } diff --git a/dot-cli/src/main.rs b/dot-cli/src/main.rs index 874a7d6..28c2aa0 100644 --- a/dot-cli/src/main.rs +++ b/dot-cli/src/main.rs @@ -370,39 +370,9 @@ fn main() -> ExitCode { #[cfg(test)] mod tests { - #[cfg(unix)] - use std::ffi::OsString; use std::io; - #[cfg(unix)] - use std::os::unix::ffi::OsStringExt; - #[cfg(unix)] - use std::path::PathBuf; - - #[cfg(unix)] - use clap::Parser; use super::normalize_list_output; - #[cfg(unix)] - use super::{Cli, ConfigSource}; - - #[cfg(unix)] - #[test] - fn config_argument_preserves_non_utf8_native_paths() { - let source = OsString::from_vec(b"config-\xff.toml".to_vec()); - let parsed = Cli::try_parse_from([ - OsString::from("dot"), - OsString::from("--config"), - source.clone(), - OsString::from("list"), - OsString::from("targets"), - ]) - .expect("non-UTF-8 native path should parse"); - - assert_eq!( - parsed.config, - Some(ConfigSource::Path(PathBuf::from(source))) - ); - } #[test] fn list_output_ignores_only_broken_pipe() { diff --git a/dot-cli/tests/config_path_command.rs b/dot-cli/tests/config_path_command.rs new file mode 100644 index 0000000..216f82e --- /dev/null +++ b/dot-cli/tests/config_path_command.rs @@ -0,0 +1,140 @@ +//! End-to-end local configuration path behavior. + +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{self, Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_WORKSPACE: AtomicU64 = AtomicU64::new(0); + +const PATH_MANIFEST: &str = r#"[targets.current] +platform = { os = ["linux", "macos", "windows"] } + +[targets.current.actions.entry] +source = "https://example.com/entry" +target = "${dot:config_dir}/entry.txt" + +[targets.current.actions.real] +source = "https://example.com/real" +target = "${dot:real_config_dir}/real.txt" +"#; + +struct TempWorkspace { + root: PathBuf, +} + +impl TempWorkspace { + fn new() -> Self { + let sequence = NEXT_WORKSPACE.fetch_add(1, Ordering::Relaxed); + let temp_root = if cfg!(unix) { + PathBuf::from("/tmp") + } else { + env::temp_dir() + }; + let root = temp_root.join(format!( + "dot-config-path-command-{}-{sequence}", + process::id() + )); + fs::create_dir(&root).expect("temporary workspace should be created"); + let root = fs::canonicalize(root).expect("temporary workspace should canonicalize"); + Self { root } + } + + fn directory(&self, name: &str) -> PathBuf { + let directory = self.root.join(name); + fs::create_dir(&directory).expect("test directory should be created"); + directory + } + + fn run(&self, source: impl AsRef) -> Output { + Command::new(env!("CARGO_BIN_EXE_dot")) + .arg("--config") + .arg(source.as_ref()) + .arg("dry-run") + .current_dir(&self.root) + .output() + .expect("dot should start") + } +} + +impl Drop for TempWorkspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +#[test] +fn relative_config_paths_produce_absolute_protocol_directories() { + let workspace = TempWorkspace::new(); + let config_dir = workspace.directory("config"); + fs::write(config_dir.join(".dot.toml"), PATH_MANIFEST) + .expect("test manifest should be written"); + + let output = workspace.run(Path::new("config").join(".dot.toml")); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + output.status.success(), + "stdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains(config_dir.join("entry.txt").to_string_lossy().as_ref()), + "{stdout}" + ); + assert!( + stdout.contains(config_dir.join("real.txt").to_string_lossy().as_ref()), + "{stdout}" + ); +} + +#[cfg(unix)] +#[test] +fn symlinked_config_keeps_entry_and_real_directories_distinct() { + let workspace = TempWorkspace::new(); + let entry_dir = workspace.directory("entry"); + let real_dir = workspace.directory("real"); + let real_manifest = real_dir.join(".dot.toml"); + fs::write(&real_manifest, PATH_MANIFEST).expect("real manifest should be written"); + std::os::unix::fs::symlink(&real_manifest, entry_dir.join(".dot.toml")) + .expect("manifest symlink should be created"); + + let output = workspace.run(Path::new("entry").join(".dot.toml")); + let stdout = String::from_utf8_lossy(&output.stdout); + + assert!( + output.status.success(), + "stdout:\n{stdout}\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + assert!( + stdout.contains(entry_dir.join("entry.txt").to_string_lossy().as_ref()), + "{stdout}" + ); + assert!( + stdout.contains(real_dir.join("real.txt").to_string_lossy().as_ref()), + "{stdout}" + ); +} + +#[test] +fn missing_config_error_names_the_requested_absolute_path() { + let workspace = TempWorkspace::new(); + let relative = Path::new("missing").join(".dot.toml"); + let expected = workspace.root.join(&relative); + + let output = workspace.run(&relative); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert!( + stderr.contains("failed to canonicalize configuration"), + "{stderr}" + ); + assert!( + stderr.contains(expected.to_string_lossy().as_ref()), + "{stderr}" + ); +} diff --git a/src/config.rs b/src/config.rs index 49201d4..1575f77 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,6 +1,6 @@ #![expect( clippy::result_large_err, - reason = "direct typed error sources preserve Error::source downcasts" + reason = "typed configuration errors preserve precise context without boxing" )] use crate::schema::Config; diff --git a/src/interpolation/mod.rs b/src/interpolation/mod.rs index 269b5d3..4eb04a9 100644 --- a/src/interpolation/mod.rs +++ b/src/interpolation/mod.rs @@ -716,601 +716,3 @@ pub enum InterpolationError { #[error("package resolver requires a provider package batch")] MissingPackageContext, } - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - use std::env; - use std::fs; - use std::path::{Path, PathBuf}; - - use directories::{BaseDirs, UserDirs}; - - use crate::ConfigFile; - use crate::native::NativeRuntime; - use crate::schema::{ - Config, EnvironmentName, EnvironmentPatch, ExecAction, FlatListPart, ListType, - LiteralStringSource, OneOrMany, ParsedStringForm, ProviderInstallArgSource, ResolvedString, - StringExpression, StringExpressionSource, StringTemplatePart, StringType, TypedVariable, - }; - - use super::{ - DotPaths, ExecutionEnvironment, InterpolationError, PackageContext, ResolveContext, - XdgPath, XdgPaths, evaluate_provider_install_args, promote_literal_string, - promote_provider_install_arg, promote_provider_install_args, promote_string_expression, - resolve_environment_patch, resolve_exec_action, resolve_literal_string, - resolve_provider_install_action, resolve_string_expression, - }; - - fn environment(variables: &[(&str, &str)]) -> ExecutionEnvironment { - ExecutionEnvironment::from_variables(variables.iter().copied()) - } - - fn dot_paths() -> DotPaths<'static> { - DotPaths::new( - Path::new("/repo"), - Path::new("/canonical/repo"), - Path::new("/work"), - ) - } - - fn xdg_paths(entries: &[(XdgPath, &str)]) -> XdgPaths { - XdgPaths { - values: entries - .iter() - .map(|(key, value)| (*key, PathBuf::from(value))) - .collect(), - } - } - - #[test] - fn promotes_an_exact_string_resolver_to_a_variable_node() { - let promoted = promote_string_expression(&StringExpressionSource::from("${env:HOME}")) - .expect("the environment resolver produces a string"); - - let StringExpression::Variable(variable) = promoted else { - panic!("an exact variable must retain its syntax node"); - }; - assert_eq!(variable.reference().resolver(), "env"); - assert_eq!(variable.reference().payload(), "HOME"); - } - - #[test] - fn promotes_an_exact_list_resolver_to_a_many_part() { - let promoted = - promote_provider_install_arg(&ProviderInstallArgSource::from("${package:names}")) - .expect("package names produce a string list"); - - let FlatListPart::Many(variable) = promoted else { - panic!("an exact list variable must expand as a many part"); - }; - assert_eq!(variable.reference().resolver(), "package"); - assert_eq!(variable.reference().payload(), "names"); - } - - #[test] - fn promotes_literal_and_template_provider_args_to_one_parts() { - let literal = promote_provider_install_arg(&ProviderInstallArgSource::from("install")) - .expect("a literal provider argument is one string"); - assert!(matches!( - literal, - FlatListPart::One(StringExpression::Literal(value)) if value.value() == "install" - )); - - let template = - promote_provider_install_arg(&ProviderInstallArgSource::from("--root=${env:HOME}")) - .expect("a string template provider argument is one string"); - let FlatListPart::One(StringExpression::Template(template)) = template else { - panic!("a template provider argument must remain a single string expression"); - }; - assert_eq!(template.parts().len(), 2); - } - - #[test] - fn rejects_a_list_variable_inside_a_string_template() { - let error = promote_provider_install_arg(&ProviderInstallArgSource::from( - "prefix-${package:names}", - )) - .expect_err("a list variable cannot be embedded in one string"); - - assert_eq!( - error, - InterpolationError::ListResolverMustOccupyArgument { - resolver: "package".into(), - } - ); - } - - #[test] - fn reports_stored_syntax_errors_only_during_promotion() { - let source = StringExpressionSource::from("prefix-${env:HOME"); - assert!(matches!(source.parsed(), ParsedStringForm::Malformed(_))); - - assert_eq!( - promote_string_expression(&source), - Err(InterpolationError::UnclosedResolver { offset: 7 }) - ); - } - - #[test] - fn reports_unknown_resolvers_during_promotion() { - assert_eq!( - promote_string_expression(&StringExpressionSource::from("${future:value}")), - Err(InterpolationError::UnknownResolver { - name: "future".into(), - }) - ); - } - - #[test] - fn reports_invalid_resolver_payloads_during_promotion() { - assert_eq!( - promote_string_expression(&StringExpressionSource::from("${xdg:repository}")), - Err(InterpolationError::InvalidResolverPayload { - resolver: "xdg".into(), - payload: "repository".into(), - }) - ); - } - - #[test] - fn package_resolver_is_unavailable_in_a_scalar_role() { - assert_eq!( - promote_string_expression(&StringExpressionSource::from("${package:names}")), - Err(InterpolationError::ResolverUnavailable { - resolver: "package".into(), - }) - ); - } - - #[test] - fn exact_scalar_and_list_variables_have_distinct_typed_nodes() { - fn assert_string_variable(_: &TypedVariable) {} - fn assert_string_list_variable(_: &TypedVariable>) {} - - let scalar = promote_provider_install_arg(&ProviderInstallArgSource::from("${env:HOME}")) - .expect("an exact scalar resolver is one provider argument"); - let FlatListPart::One(StringExpression::Variable(variable)) = scalar else { - panic!("the exact scalar variable must be a one part"); - }; - assert_string_variable(&variable); - - let list = - promote_provider_install_arg(&ProviderInstallArgSource::from("${package:names}")) - .expect("an exact list resolver is a many provider argument"); - let FlatListPart::Many(variable) = list else { - panic!("the exact list variable must be a many part"); - }; - assert_string_list_variable(&variable); - } - - #[test] - fn string_templates_contain_only_string_typed_variables() { - fn assert_string_variable(_: &TypedVariable) {} - - let promoted = - promote_string_expression(&StringExpressionSource::from("${env:HOME}/${dot:cwd}")) - .expect("both variables produce strings"); - let StringExpression::Template(template) = promoted else { - panic!("literal surroundings and multiple variables form a template"); - }; - - for part in template.parts() { - if let StringTemplatePart::Variable(variable) = part { - assert_string_variable(variable); - } - } - } - - #[test] - fn promotes_validated_unescaped_literal_values() { - let promoted = promote_literal_string(&LiteralStringSource::from(r"prefix-\${literal}")) - .expect("escaped resolver syntax is literal"); - - assert_eq!(promoted.value(), "prefix-${literal}"); - } - - #[test] - fn literal_source_promotion_rejects_resolvers() { - assert_eq!( - promote_literal_string(&LiteralStringSource::from("${env:HOME}")), - Err(InterpolationError::ResolverInLiteralString { - resolver: "env".into(), - }) - ); - } - - #[test] - fn promotes_provider_arg_vectors_into_one_flat_list() { - let sources = [ - ProviderInstallArgSource::from("install"), - ProviderInstallArgSource::from("${package:names}"), - ]; - let promoted = - promote_provider_install_args(&sources).expect("both provider arguments are valid"); - - assert!(matches!( - promoted.parts(), - [FlatListPart::One(_), FlatListPart::Many(_)] - )); - } - - #[test] - fn literal_strings_unescape_syntax_but_reject_resolvers() { - assert_eq!( - resolve_literal_string(&LiteralStringSource::from(r"prefix-\${literal}")) - .expect("escaped interpolation syntax should be literal") - .value(), - "prefix-${literal}", - ); - assert!( - resolve_literal_string(&LiteralStringSource::from("${env:HOME}")) - .expect_err("literal strings must reject resolvers") - .to_string() - .contains("literal string") - ); - } - - #[test] - fn dot_resolver_produces_configuration_directories_and_invocation_path() { - let environment = environment(&[]); - let xdg = xdg_paths(&[]); - let context = ResolveContext::new(&environment, dot_paths(), &xdg); - let template = - StringExpressionSource::from("${dot:config_dir}|${dot:real_config_dir}|${dot:cwd}"); - - assert_eq!( - resolve_string_expression(&template, &context) - .expect("template should resolve") - .value(), - "/repo|/canonical/repo|/work" - ); - } - - #[test] - fn removed_dot_file_path_payloads_are_rejected() { - for payload in ["config", "real_config"] { - assert_eq!( - promote_string_expression(&StringExpressionSource::from(format!( - "${{dot:{payload}}}" - ))), - Err(InterpolationError::InvalidResolverPayload { - resolver: "dot".into(), - payload: payload.into(), - }) - ); - } - } - - #[test] - fn dot_paths_from_config_file_exposes_the_canonical_directory() { - let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dot.toml"); - let source = fs::read_to_string(&fixture).expect("fixture should be readable"); - let parsed = Config::parse(&source).expect("fixture should parse"); - let real_fixture = fs::canonicalize(&fixture).expect("fixture should canonicalize"); - let config = ConfigFile::new( - parsed, - fixture - .parent() - .expect("fixture should have a parent") - .to_owned(), - real_fixture - .parent() - .expect("canonical fixture should have a parent") - .to_owned(), - env::current_dir().expect("test should have a current directory"), - ) - .expect("fixture context should be absolute"); - let runtime = NativeRuntime::detect(); - let xdg = xdg_paths(&[]); - let context = ResolveContext::new(runtime.environment(), DotPaths::from(&config), &xdg); - - let real_config_dir = resolve_string_expression( - &StringExpressionSource::from("${dot:real_config_dir}"), - &context, - ) - .expect("real config directory should resolve"); - - assert_eq!( - PathBuf::from(real_config_dir.value()), - config.real_config_dir() - ); - } - - #[cfg(windows)] - #[test] - fn real_config_dir_preserves_a_verbatim_windows_path_with_backslash_suffixes() { - let environment = environment(&[]); - let xdg = xdg_paths(&[]); - let dot = DotPaths::new( - Path::new(r"C:\entry"), - Path::new(r"\\?\C:\repo"), - Path::new(r"C:\work"), - ); - let context = ResolveContext::new(&environment, dot, &xdg); - - let resolved = resolve_string_expression( - &StringExpressionSource::from(r"${dot:real_config_dir}\home\.config\nvim"), - &context, - ) - .expect("verbatim path should resolve"); - - assert_eq!( - PathBuf::from(resolved.value()), - PathBuf::from(r"\\?\C:\repo\home\.config\nvim") - ); - } - - #[test] - fn environment_patch_resolves_every_value_against_one_context() { - let environment = environment(&[("ROOT", "/opt/tools")]); - let xdg = xdg_paths(&[(XdgPath::Executable, "/home/tester/.local/bin")]); - let context = ResolveContext::new(&environment, dot_paths(), &xdg); - let patch = EnvironmentPatch { - path_prepend: Some(OneOrMany::Many(vec![ - "${env:ROOT}/bin".into(), - "${dot:config_dir}/bin".into(), - ])), - path_append: Some(OneOrMany::One("${xdg:executable}".into())), - variables: BTreeMap::from([( - EnvironmentName::new("TOOL_HOME").expect("test name should be valid"), - "${env:ROOT}".into(), - )]), - }; - - let resolved = resolve_environment_patch(&patch, &context).expect("patch should resolve"); - - assert_eq!( - resolved.path_prepend, - Some(OneOrMany::Many(vec![ - "/opt/tools/bin".into(), - "/repo/bin".into(), - ])) - ); - assert_eq!( - resolved.path_append, - Some(OneOrMany::One("/home/tester/.local/bin".into())) - ); - assert_eq!(resolved.variables["TOOL_HOME"].value(), "/opt/tools"); - } - - #[test] - fn exec_action_resolves_all_process_fields_and_its_environment() { - let environment = environment(&[("PROBE", "probe-program"), ("ROOT", "/opt/tools")]); - let xdg = xdg_paths(&[(XdgPath::Documents, "/home/tester/Documents")]); - let context = ResolveContext::new(&environment, dot_paths(), &xdg); - let action = ExecAction { - program: "${env:PROBE}".into(), - args: vec![ - "--config-dir=${dot:config_dir}".into(), - "${xdg:documents}".into(), - ], - cwd: Some("${dot:cwd}".into()), - env: Some(EnvironmentPatch { - path_prepend: None, - path_append: None, - variables: BTreeMap::from([( - EnvironmentName::new("TOOL_HOME").expect("test name should be valid"), - "${env:ROOT}".into(), - )]), - }), - }; - - let resolved = resolve_exec_action(&action, &context).expect("action should resolve"); - - assert_eq!(resolved.program.value(), "probe-program"); - assert_eq!( - resolved - .args - .iter() - .map(ResolvedString::value) - .collect::>(), - vec!["--config-dir=/repo", "/home/tester/Documents"] - ); - assert_eq!(resolved.cwd.as_ref().unwrap().value(), "/work"); - assert_eq!( - resolved.env.as_ref().unwrap().variables["TOOL_HOME"].value(), - "/opt/tools" - ); - } - - #[test] - fn xdg_resolver_produces_cross_platform_standard_paths() { - let environment = environment(&[]); - let xdg = xdg_paths(&[ - (XdgPath::Home, "/home/tester"), - (XdgPath::Config, "/home/tester/.config"), - (XdgPath::Documents, "/home/tester/Documents"), - ]); - let context = ResolveContext::new(&environment, dot_paths(), &xdg); - let template = StringExpressionSource::from("${xdg:home}:${xdg:config}:${xdg:documents}"); - - assert_eq!( - resolve_string_expression(&template, &context) - .expect("template should resolve") - .value(), - "/home/tester:/home/tester/.config:/home/tester/Documents" - ); - } - - #[test] - fn xdg_detection_snapshots_the_platform_directories() { - let detected = XdgPaths::detect(); - let base = BaseDirs::new().expect("the test process should have a home directory"); - - assert_eq!(detected.get(XdgPath::Home), Some(base.home_dir())); - assert_eq!(detected.get(XdgPath::Config), Some(base.config_dir())); - assert_eq!(detected.get(XdgPath::Data), Some(base.data_dir())); - assert_eq!(detected.get(XdgPath::Cache), Some(base.cache_dir())); - - let expected_documents = UserDirs::new() - .and_then(|directories| directories.document_dir().map(Path::to_path_buf)); - assert_eq!( - detected.get(XdgPath::Documents), - expected_documents.as_deref() - ); - } - - #[test] - fn every_declared_xdg_payload_is_valid() { - for payload in [ - "home", - "config", - "config_local", - "data", - "data_local", - "cache", - "state", - "runtime", - "executable", - "documents", - ] { - let template = StringExpressionSource::from(format!("${{xdg:{payload}}}")); - promote_string_expression(&template) - .unwrap_or_else(|error| panic!("xdg payload `{payload}` should be valid: {error}")); - } - } - - #[test] - fn old_path_resolver_is_not_registered() { - let template = StringExpressionSource::from("${path:cwd}"); - - assert_eq!( - promote_string_expression(&template), - Err(InterpolationError::UnknownResolver { - name: "path".into() - }) - ); - } - - #[test] - fn validation_rejects_unknown_resolvers_without_evaluating_them() { - let template = StringExpressionSource::from("${command:output}"); - - assert_eq!( - promote_string_expression(&template), - Err(InterpolationError::UnknownResolver { - name: "command".into() - }) - ); - } - - #[test] - fn resolver_definitions_validate_their_own_payloads() { - let template = StringExpressionSource::from("${xdg:repository}"); - - assert_eq!( - promote_string_expression(&template), - Err(InterpolationError::InvalidResolverPayload { - resolver: "xdg".into(), - payload: "repository".into(), - }) - ); - } - - #[test] - fn package_resolvers_are_available_only_to_provider_install_arguments() { - let scalar = StringExpressionSource::from("${package:names}"); - let install_arg = ProviderInstallArgSource::from("${package:names}"); - - assert_eq!( - promote_string_expression(&scalar), - Err(InterpolationError::ResolverUnavailable { - resolver: "package".into() - }) - ); - promote_provider_install_arg(&install_arg) - .expect("package resolver should be valid for provider install"); - } - - #[test] - fn provider_install_list_resolvers_expand_one_complete_argument() { - let environment = environment(&[]); - let xdg = xdg_paths(&[]); - let names = ["ripgrep".into(), "zoxide".into()]; - let provider_args = ["--locked".into()]; - let packages = PackageContext::new(&names, &provider_args); - let context = ResolveContext::new(&environment, dot_paths(), &xdg).with_package(packages); - - let expression = promote_provider_install_args(&[ - ProviderInstallArgSource::from("${package:names}"), - ProviderInstallArgSource::from("${package:provider_args}"), - ]) - .expect("provider arguments should promote"); - let resolved = evaluate_provider_install_args(&expression, &context) - .expect("package values should resolve"); - - assert_eq!( - resolved - .iter() - .map(ResolvedString::value) - .collect::>(), - vec!["ripgrep", "zoxide", "--locked"] - ); - } - - #[test] - fn provider_install_action_expands_package_lists_into_argv() { - let environment = environment(&[("PROVIDER", "brew")]); - let xdg = xdg_paths(&[]); - let names = ["font-one".into(), "font-two".into()]; - let provider_args = ["--cask".into(), "--force".into()]; - let context = ResolveContext::new(&environment, dot_paths(), &xdg) - .with_package(PackageContext::new(&names, &provider_args)); - let action = ExecAction:: { - program: "${env:PROVIDER}".into(), - args: vec![ - "install".into(), - "${package:provider_args}".into(), - "--config-dir=${dot:config_dir}".into(), - "${package:names}".into(), - ], - cwd: Some("${dot:cwd}".into()), - env: None, - }; - - let resolved = - resolve_provider_install_action(&action, &context).expect("action should resolve"); - - assert_eq!(resolved.program.value(), "brew"); - assert_eq!( - resolved - .args - .iter() - .map(ResolvedString::value) - .collect::>(), - vec![ - "install", - "--cask", - "--force", - "--config-dir=/repo", - "font-one", - "font-two", - ] - ); - assert_eq!(resolved.cwd.as_ref().unwrap().value(), "/work"); - } - - #[test] - fn provider_install_action_reports_an_embedded_list_resolver() { - let environment = environment(&[]); - let xdg = xdg_paths(&[]); - let names = ["ripgrep".into()]; - let provider_args = Vec::new(); - let context = ResolveContext::new(&environment, dot_paths(), &xdg) - .with_package(PackageContext::new(&names, &provider_args)); - let action = ExecAction:: { - program: "install".into(), - args: vec!["prefix-${package:names}".into()], - cwd: None, - env: None, - }; - - assert_eq!( - resolve_provider_install_action(&action, &context), - Err(InterpolationError::ListResolverMustOccupyArgument { - resolver: "package".into() - }) - ); - } -} diff --git a/src/interpolation/resolver.rs b/src/interpolation/resolver.rs index dd28c27..8a599ef 100644 --- a/src/interpolation/resolver.rs +++ b/src/interpolation/resolver.rs @@ -110,65 +110,3 @@ fn build_resolver_registry() -> ResolverRegistry { pub(super) fn lookup_resolver(namespace: &str) -> Option<&'static ResolverEntry> { RESOLVERS.get(namespace) } - -#[cfg(test)] -mod tests { - use crate::schema::{ListType, SchemaTypeMarker, StringType}; - - use super::{ResolverAvailability, build_resolver_registry, lookup_resolver}; - - #[test] - fn registry_declares_builtin_types_and_availability() { - let registry = build_resolver_registry(); - - assert_eq!( - registry.keys().copied().collect::>(), - ["dot", "env", "package", "xdg"] - ); - for namespace in ["env", "dot", "xdg"] { - assert_eq!( - registry[namespace].output_type(), - &StringType::schema_type() - ); - assert_eq!( - registry[namespace].availability(), - ResolverAvailability::Everywhere - ); - } - assert_eq!( - registry["package"].output_type(), - &ListType::::schema_type() - ); - assert_eq!( - registry["package"].availability(), - ResolverAvailability::ProviderInstallOnly - ); - } - - #[test] - fn builtin_payload_validation_is_schema_only() { - let registry = build_resolver_registry(); - - assert!(registry["env"].validate_payload("HOME")); - assert!(!registry["env"].validate_payload("")); - for payload in ["config_dir", "real_config_dir", "cwd"] { - assert!(registry["dot"].validate_payload(payload)); - } - assert!(!registry["dot"].validate_payload("config")); - assert!(!registry["dot"].validate_payload("real_config")); - assert!(!registry["dot"].validate_payload("home")); - assert!(registry["xdg"].validate_payload("executable")); - assert!(!registry["xdg"].validate_payload("repository")); - assert!(registry["package"].validate_payload("names")); - assert!(registry["package"].validate_payload("provider_args")); - assert!(!registry["package"].validate_payload("name")); - } - - #[test] - fn lookup_rejects_unknown_namespaces() { - for namespace in ["env", "dot", "xdg", "package"] { - assert!(lookup_resolver(namespace).is_some()); - } - assert!(lookup_resolver("unknown").is_none()); - } -} diff --git a/src/native/apply.rs b/src/native/apply.rs index ec0e1f8..92bd94a 100644 --- a/src/native/apply.rs +++ b/src/native/apply.rs @@ -460,149 +460,12 @@ fn captured_text(output: Option<&[u8]>) -> Option { #[cfg(test)] mod tests { - use std::env; - use std::fs; use std::io; - use std::path::{Path, PathBuf}; - use std::process; - use std::sync::atomic::{AtomicU64, Ordering}; + use std::path::Path; - use super::*; + use super::link_error_evidence; use crate::native::diagnostic::Operation; use crate::native::link::LinkError; - use crate::selection::{ProfileSelection, ScopeSelection}; - - static NEXT_WORKSPACE: AtomicU64 = AtomicU64::new(0); - - fn run(path: &Path, selection: &ExecutionSelection) -> Result { - let source = fs::read_to_string(path).expect("test config should be readable"); - let parsed = crate::schema::Config::parse(&source).expect("test config should parse"); - let config_dir = path - .parent() - .expect("test config should have a parent") - .to_owned(); - let real_path = fs::canonicalize(path).expect("test config should canonicalize"); - let real_config_dir = real_path - .parent() - .expect("canonical test config should have a parent") - .to_owned(); - let cwd = env::current_dir().expect("test should have a current directory"); - let config = ConfigFile::new(parsed, config_dir, real_config_dir, cwd) - .expect("test config context should be absolute"); - let runtime = NativeRuntime::detect(); - apply(&config, &runtime, selection) - } - - struct TempWorkspace { - directory: PathBuf, - } - - impl TempWorkspace { - fn new() -> Self { - let sequence = NEXT_WORKSPACE.fetch_add(1, Ordering::Relaxed); - let directory = - env::temp_dir().join(format!("dot-apply-report-{}-{sequence}", process::id())); - fs::create_dir(&directory).expect("temporary workspace should be created"); - Self { directory } - } - - fn write_manifest(&self, contents: &str) -> PathBuf { - let path = self.directory.join("dot.toml"); - fs::write(&path, render_manifest(contents)).expect("test manifest should be written"); - path - } - - fn write_source(&self, name: &str) { - fs::write(self.directory.join(name), name).expect("link source should be written"); - } - - fn path(&self, name: &str) -> PathBuf { - self.directory.join(name) - } - } - - impl Drop for TempWorkspace { - fn drop(&mut self) { - let _ = fs::remove_dir_all(&self.directory); - } - } - - #[derive(Clone, Copy, Debug, PartialEq, Eq)] - enum SubjectKind { - Provider, - ProviderPackageSingle, - ProviderPackageBatch, - ManualPackage, - Action, - Link, - } - - #[test] - fn apply_projects_the_complete_selected_plan_in_typed_job_order() { - let workspace = TempWorkspace::new(); - workspace.write_source("source.txt"); - let contents = read_fixture("apply/valid-complete-plan-template.toml") - .replace("__PROBE__", &helper_exec("probe-ready")) - .replace("__INSTALL__", &helper_exec("install-ready")) - .replace("__MANUAL__", &helper_exec("manual-ok")) - .replace("__ACTION__", &helper_exec("action-ok")); - let manifest = workspace.write_manifest(&contents); - - let report = run(&manifest, &request()).expect("complete apply should produce a report"); - - assert_eq!(report.status, ReportStatus::Succeeded); - assert!(report.diagnostics.is_empty()); - assert_eq!( - item_sequence(&report), - [ - ("ready", SubjectKind::Provider), - ("manual-tool", SubjectKind::ManualPackage), - ("tool", SubjectKind::ProviderPackageSingle), - ("cli-tools", SubjectKind::ProviderPackageBatch), - ("configure", SubjectKind::Action), - ("config", SubjectKind::Link), - ] - ); - } - - #[test] - fn apply_projects_a_duplicate_link_phase_to_typed_blocked_items_and_one_diagnostic() { - let workspace = TempWorkspace::new(); - workspace.write_source("first.txt"); - workspace.write_source("second.txt"); - let contents = read_fixture("apply/invalid-duplicate-link-target-template.toml") - .replace("__ACTION__", &helper_exec("action-ok")); - let manifest = workspace.write_manifest(&contents); - - let report = - run(&manifest, &request()).expect("link phase failure should produce a report"); - - assert_eq!(report.status, ReportStatus::Failed); - assert_eq!( - item_sequence(&report), - [ - ("configure", SubjectKind::Action), - ("first", SubjectKind::Link), - ("second", SubjectKind::Link), - ] - ); - assert_eq!(report.diagnostics.len(), 1); - let diagnostic = &report.diagnostics[0]; - assert_eq!(diagnostic.level, DiagnosticLevel::Error); - for item in &report.items[1..] { - assert_eq!(item.status, ItemStatus::Blocked); - assert_eq!(item.evidence.len(), 1); - assert_eq!(item.evidence[0].stage, EvidenceStage::Link); - assert_eq!( - item.evidence[0].message.as_deref(), - Some(diagnostic.message.as_str()) - ); - } - assert!( - !workspace.path("linked.txt").exists(), - "duplicate-target preflight must not create the normalized target" - ); - } #[test] fn link_evidence_keeps_the_native_error_and_structured_hint() { @@ -624,98 +487,4 @@ mod tests { assert_eq!(evidence.hints.len(), 1); assert_eq!(evidence.hints[0].code, "windows.symlink.privilege-required"); } - - #[test] - fn helper_process() { - let Ok(mode) = env::var("DOT_APPLY_REPORT_HELPER") else { - return; - }; - - if matches!(mode.as_str(), "probe-ready" | "install-ready") { - assert_eq!( - env::var("DOT_APPLY_PROVIDER_ACTIVE").as_deref(), - Ok("yes"), - "provider child process should receive activate environment" - ); - } else { - assert!( - env::var_os("DOT_APPLY_PROVIDER_ACTIVE").is_none(), - "manual packages and actions must not receive provider environment" - ); - } - - match mode.as_str() { - "probe-ready" | "install-ready" | "manual-ok" => {} - "action-ok" => { - let link = PathBuf::from( - env::var_os("DOT_APPLY_REPORT_LINK") - .expect("apply report link path should be present"), - ); - assert!(!link.exists(), "links must run after global actions"); - } - unknown => panic!("unknown apply report helper mode: {unknown}"), - } - } - - fn request() -> ExecutionSelection { - ExecutionSelection { - scope: ScopeSelection { - target: None, - profile: ProfileSelection::Root, - }, - jobs: crate::job::JobSelection::All, - } - } - - fn item_sequence(report: &CommandReport) -> Vec<(&str, SubjectKind)> { - report - .items - .iter() - .map(|item| { - let kind = match &item.subject { - ReportSubject::Provider(_) => SubjectKind::Provider, - ReportSubject::Package(PackageItem { - source: PackageSource::Provider(ProviderPackageSource::Single { .. }), - }) => SubjectKind::ProviderPackageSingle, - ReportSubject::Package(PackageItem { - source: PackageSource::Provider(ProviderPackageSource::Batch { .. }), - }) => SubjectKind::ProviderPackageBatch, - ReportSubject::Package(PackageItem { - source: PackageSource::Manual { .. }, - }) => SubjectKind::ManualPackage, - ReportSubject::Action(_) => SubjectKind::Action, - ReportSubject::Link(_) => SubjectKind::Link, - }; - (item.id.as_str(), kind) - }) - .collect() - } - - fn read_fixture(name: &str) -> String { - let path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("tests/fixtures") - .join(name); - fs::read_to_string(path).expect("apply fixture should be readable") - } - - fn render_manifest(contents: &str) -> String { - contents - .replace("__OS__", env::consts::OS) - .replace("__PROGRAM__", &helper_program_toml()) - } - - fn helper_program_toml() -> String { - format!( - "{:?}", - env::current_exe() - .expect("test executable should have a path") - .to_string_lossy() - ) - } - - fn helper_exec(mode: &str) -> String { - format!( - r#"{{ program = __PROGRAM__, args = ["--exact", "app::apply::tests::helper_process", "--nocapture"], env = {{ variables = {{ DOT_APPLY_REPORT_HELPER = "{mode}", DOT_APPLY_REPORT_LINK = "${{dot:config_dir}}/linked.txt" }} }} }}"# - ) - } } diff --git a/src/native/fetch_content.rs b/src/native/fetch_content.rs index fc31467..ca35b1b 100644 --- a/src/native/fetch_content.rs +++ b/src/native/fetch_content.rs @@ -431,7 +431,6 @@ fn validate_final_status(status: u16) -> Result<(), FetchTransportError> { #[cfg(test)] mod tests { use std::cell::{Cell, RefCell}; - use std::error::Error as _; use std::fs; use std::io::{self, Write}; use std::path::{Path, PathBuf}; @@ -610,7 +609,6 @@ mod tests { &target, "existing regular file conflicts with error policy", ); - assert!(error.source().is_none()); assert_eq!(transport.calls.get(), 0); assert_eq!(fs::read(target).expect("target should be readable"), b"old"); } @@ -738,7 +736,6 @@ mod tests { &target, "directory cannot be materialized", ); - assert!(error.source().is_none()); assert_eq!(transport.calls.get(), 0); assert!(target.is_dir()); } @@ -764,7 +761,6 @@ mod tests { &target, "special filesystem entry cannot be materialized", ); - assert!(error.source().is_none()); assert_eq!(transport.calls.get(), 0); assert!(fs::symlink_metadata(&target).is_ok()); drop(listener); @@ -796,7 +792,6 @@ mod tests { assert_eq!(transport.calls.get(), 1); assert_eq!(fs::read(target).expect("target should be readable"), b"old"); assert_eq!(directory_entries(directory.path()), before); - assert!(error.source().is_some()); } #[test] @@ -817,11 +812,6 @@ mod tests { "failed to create target parent directories", ); assert_eq!(transport.calls.get(), 0); - assert!( - error - .source() - .is_some_and(|source| source.is::()) - ); } #[test] @@ -869,7 +859,6 @@ mod tests { target.display() ) ); - assert!(error.source().is_none()); assert_eq!( fs::read(target.join("sentinel")).expect("sentinel should be readable"), b"safe" @@ -877,51 +866,6 @@ mod tests { assert_eq!(directory_entries(directory.path()).len(), 2); } - #[test] - fn error_sources_are_preserved_only_for_sourceful_errors() { - let directory = tempdir().expect("temporary directory should be created"); - let target = directory.path().join("content"); - fs::write(&target, b"old").expect("existing target should be written"); - let conflict = run( - &target, - FetchContentConflict::Error, - &FakeTransport::bytes(b"new".to_vec()), - ) - .expect_err("existing target should conflict"); - assert!(conflict.source().is_none()); - - fs::remove_file(&target).expect("target should be removed"); - let transfer = run( - &target, - FetchContentConflict::Error, - &FakeTransport::error(io::ErrorKind::BrokenPipe), - ) - .expect_err("transfer should fail"); - let transport_source = transfer - .source() - .expect("transfer should retain its source"); - assert!(transport_source.is::()); - assert!( - transport_source - .source() - .is_some_and(|source| source.is::()) - ); - - let ureq_transfer = FetchContentError::transfer( - &action(&target, FetchContentConflict::Error), - FetchTransportError::from_ureq(ureq::Error::ConnectionFailed), - ); - let transport_source = ureq_transfer - .source() - .expect("transfer should retain its transport source"); - assert!(transport_source.is::()); - assert!( - transport_source - .source() - .is_some_and(|source| source.is::()) - ); - } - #[test] fn production_agent_uses_strict_https_redirect_and_status_policy() { let transport = UreqHttpsTransport::new(); @@ -942,11 +886,9 @@ mod tests { &require_https, FetchTransportError::RequireHttpsOnly )); - assert!(require_https.source().is_none()); let redirects = FetchTransportError::from_ureq(ureq::Error::TooManyRedirects); assert!(matches!(&redirects, FetchTransportError::TooManyRedirects)); - assert!(redirects.source().is_none()); for status in [400, 500] { let error = FetchTransportError::from_ureq(ureq::Error::StatusCode(status)); @@ -954,7 +896,6 @@ mod tests { &error, FetchTransportError::HttpStatus(actual) if *actual == status )); - assert!(error.source().is_none()); } let error = FetchTransportError::from_ureq(ureq::Error::Io(io::Error::new( @@ -962,19 +903,11 @@ mod tests { "network stopped", ))); assert!(matches!(&error, FetchTransportError::TransportIo(_))); - assert!( - error - .source() - .is_some_and(|source| source.is::()) - ); + assert!(error.to_string().contains("network stopped")); let error = FetchTransportError::from_ureq(ureq::Error::ConnectionFailed); assert!(matches!(&error, FetchTransportError::TransportUreq(_))); - assert!( - error - .source() - .is_some_and(|source| source.is::()) - ); + assert!(error.to_string().contains("connection failed")); } #[test] diff --git a/src/native/job_execution.rs b/src/native/job_execution.rs index c0d6266..0d77a86 100644 --- a/src/native/job_execution.rs +++ b/src/native/job_execution.rs @@ -311,37 +311,3 @@ impl<'a> JobRunner<'a> { } } } - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use super::{assert_link_projection, assert_result_count, insert_unique_result}; - - #[test] - #[should_panic(expected = "link result count mismatch")] - fn link_projection_rejects_a_result_count_mismatch() { - assert_link_projection(&["first"], &["first", "second"]); - } - - #[test] - #[should_panic(expected = "link result identity mismatch at index 0")] - fn link_projection_rejects_results_in_the_wrong_order() { - assert_link_projection(&["first", "second"], &["second", "first"]); - } - - #[test] - #[should_panic(expected = "duplicate job result")] - fn unique_result_insertion_rejects_a_duplicate_key() { - let mut results = BTreeMap::new(); - - insert_unique_result(&mut results, "duplicate", 1); - insert_unique_result(&mut results, "duplicate", 2); - } - - #[test] - #[should_panic(expected = "job result count mismatch")] - fn result_count_rejects_an_incomplete_report() { - assert_result_count(2, 1); - } -} diff --git a/src/native/link.rs b/src/native/link.rs index 7265101..b921d90 100644 --- a/src/native/link.rs +++ b/src/native/link.rs @@ -500,27 +500,3 @@ pub enum LinkPhaseError { )] DuplicateTarget { target: PathBuf, links: Vec }, } - -#[cfg(test)] -mod tests { - use super::*; - use crate::native::diagnostic::Operation; - - #[test] - fn link_io_error_retains_typed_diagnostic_context() { - let error = LinkError::io_with_diagnostic( - "create symbolic link", - Path::new("target"), - Operation::CreateSymbolicLink, - io::Error::from_raw_os_error(1314), - ); - - let (operation, source) = error - .diagnostic_context() - .expect("a diagnosed I/O error should expose its context"); - - assert_eq!(operation, Operation::CreateSymbolicLink); - assert_eq!(source.raw_os_error(), Some(1314)); - assert!(error.to_string().contains("os error 1314")); - } -} diff --git a/src/platform.rs b/src/platform.rs index 1834f13..7a99e96 100644 --- a/src/platform.rs +++ b/src/platform.rs @@ -169,92 +169,12 @@ fn classify_environments(is_wsl: bool, is_container: bool) -> BTreeSet { mod tests { use std::collections::BTreeSet; - use super::*; - use crate::schema::{Identifier, OneOrMany, PlatformConstraint}; + use super::{classify_environments, parse_os_release}; fn strings(values: &[&str]) -> BTreeSet { values.iter().map(|value| (*value).to_owned()).collect() } - fn identifier(value: &str) -> Identifier { - Identifier::new(value).expect("test identifier should be valid") - } - - fn linux_platform() -> PlatformInfo { - PlatformInfo { - os: "linux".into(), - arch: "x86_64".into(), - distro: Some("ubuntu".into()), - distro_families: strings(&["debian"]), - environments: strings(&["container", "wsl"]), - } - } - - #[test] - fn matches_allowed_values_across_all_constrained_fields() { - let constraint = PlatformConstraint { - os: OneOrMany::Many(vec![identifier("linux"), identifier("macos")]), - arch: Some(OneOrMany::One(identifier("x86_64"))), - distro: Some(OneOrMany::Many(vec![ - identifier("fedora"), - identifier("ubuntu"), - ])), - distro_family: Some(OneOrMany::One(identifier("debian"))), - environment: Some(OneOrMany::Many(vec![ - identifier("native"), - identifier("wsl"), - ])), - }; - - assert!(constraint.matches(&linux_platform())); - } - - #[test] - fn ignores_optional_constraints_that_are_not_declared() { - let constraint = PlatformConstraint { - os: OneOrMany::One(identifier("linux")), - arch: None, - distro: None, - distro_family: None, - environment: None, - }; - - assert!(constraint.matches(&linux_platform())); - } - - #[test] - fn rejects_a_mismatch_in_any_declared_field() { - let constraint = PlatformConstraint { - os: OneOrMany::One(identifier("linux")), - arch: Some(OneOrMany::One(identifier("aarch64"))), - distro: None, - distro_family: None, - environment: None, - }; - - assert!(!constraint.matches(&linux_platform())); - } - - #[test] - fn rejects_a_declared_optional_fact_when_detection_has_no_value() { - let constraint = PlatformConstraint { - os: OneOrMany::One(identifier("macos")), - arch: None, - distro: Some(OneOrMany::One(identifier("ubuntu"))), - distro_family: None, - environment: None, - }; - let actual = PlatformInfo { - os: "macos".into(), - arch: "aarch64".into(), - distro: None, - distro_families: BTreeSet::new(), - environments: strings(&["native"]), - }; - - assert!(!constraint.matches(&actual)); - } - #[test] fn parses_linux_distribution_facts_from_os_release() { let release = r#" @@ -277,13 +197,4 @@ mod tests { ); assert_eq!(classify_environments(false, false), strings(&["native"])); } - - #[test] - fn detects_the_rust_runtime_target() { - let actual = PlatformInfo::detect(); - - assert_eq!(actual.os, std::env::consts::OS); - assert_eq!(actual.arch, std::env::consts::ARCH); - assert!(!actual.environments.is_empty()); - } } diff --git a/src/validation.rs b/src/validation.rs index e3542c1..5218ca5 100644 --- a/src/validation.rs +++ b/src/validation.rs @@ -1,6 +1,6 @@ #![expect( clippy::result_large_err, - reason = "direct typed error sources preserve Error::source downcasts" + reason = "typed validation errors preserve precise context without boxing" )] use std::collections::BTreeSet; diff --git a/tests/action_runner.rs b/tests/action_runner.rs index bbf11c5..98e42c3 100644 --- a/tests/action_runner.rs +++ b/tests/action_runner.rs @@ -1,6 +1,5 @@ use std::collections::BTreeMap; use std::env; -use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; use std::process; @@ -184,7 +183,6 @@ fn rejects_an_initial_check_exit_other_than_zero_or_one() { error.exit_result().and_then(|result| result.code()), Some(23) ); - assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["check"]); } @@ -205,7 +203,6 @@ fn stops_before_post_check_when_exec_fails() { error.exit_result().and_then(|result| result.code()), Some(17) ); - assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["check", "exec"]); } @@ -226,7 +223,6 @@ fn fails_when_post_check_is_not_satisfied() { error.exit_result().and_then(|result| result.code()), Some(1) ); - assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["check", "exec", "check"]); } diff --git a/tests/config.rs b/tests/config.rs index 350be5b..d75382d 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,6 +1,5 @@ mod support; -use std::error::Error; use std::fs; use dot_core::ConfigFile; @@ -8,7 +7,6 @@ use dot_core::config::ConfigParseError; use dot_core::native::NativeRuntime; use dot_core::platform::PlatformInfo; use dot_core::schema::Config; -use dot_core::validation::ConfigValidationError; use support::fixture; #[test] @@ -129,22 +127,10 @@ fn distinguishes_deserialization_and_validation_errors_when_parsing_from_memory( deserialization_error, ConfigParseError::Deserialize { .. } )); - assert!( - deserialization_error - .source() - .and_then(|source| source.downcast_ref::()) - .is_some() - ); assert!(matches!( validation_error, ConfigParseError::Validation { .. } )); - assert!( - validation_error - .source() - .and_then(|source| source.downcast_ref::()) - .is_some() - ); } #[test] diff --git a/tests/error_contract.rs b/tests/error_contract.rs deleted file mode 100644 index 509e372..0000000 --- a/tests/error_contract.rs +++ /dev/null @@ -1,477 +0,0 @@ -use std::env; -use std::error::Error; -use std::ffi::OsString; -use std::io; -use std::path::PathBuf; - -use dot_core::interpolation::InterpolationError; -use dot_core::job::{JobSelector, JobSelectorParseError}; -use dot_core::manifest::ManifestError; -use dot_core::native::command_action::{ActionStage, CommandActionRunError}; -use dot_core::native::diagnostic::Operation; -use dot_core::native::link::{LinkError, LinkPhaseError}; -use dot_core::native::plan::{JobSelectionError, PlanningError}; -use dot_core::native::process::{CommandPreparationError, ExecutionError}; -use dot_core::native::provider::{ProviderError, ProviderInstallError, ProviderStage}; -use dot_core::native::provider_check::ProviderProbeError; -use dot_core::platform::PlatformInfo; -use dot_core::schema::{ - Identifier, OneOrMany, PlatformConstraint, SchemaType, SelectorIdentifier, - SelectorIdentifierError, -}; -use dot_core::validation::{ConfigValidationError, ConfigValidationErrorKind, ConfigValidationJob}; - -fn assert_no_source(error: &(dyn Error + 'static)) { - assert!( - error.source().is_none(), - "expected `{error}` to have no source" - ); -} - -fn assert_source_is<'a, T: Error + 'static>(error: &'a (dyn Error + 'static)) -> &'a T { - let source = error - .source() - .unwrap_or_else(|| panic!("expected `{error}` to have an immediate source")); - source.downcast_ref::().unwrap_or_else(|| { - panic!( - "expected immediate source of `{error}` to be `{}`, but it was `{source}`", - std::any::type_name::() - ) - }) -} - -fn io_error() -> io::Error { - io::Error::other("test I/O failure") -} - -fn join_paths_error() -> env::JoinPathsError { - #[cfg(not(windows))] - let invalid_path = "invalid:path"; - #[cfg(windows)] - let invalid_path = "invalid\"path"; - - env::join_paths([invalid_path]).expect_err("test path should be invalid in PATH") -} - -fn identifier(value: &str) -> Identifier { - Identifier::new(value).expect("test identifier should be valid") -} - -fn selector_identifier(value: &str) -> SelectorIdentifier { - SelectorIdentifier::new(value).expect("test selector identifier should be valid") -} - -fn selector_identifier_error() -> SelectorIdentifierError { - SelectorIdentifier::new("").expect_err("empty selector identifier should be invalid") -} - -fn interpolation_error() -> InterpolationError { - InterpolationError::UnclosedResolver { offset: 0 } -} - -fn preparation_error() -> CommandPreparationError { - CommandPreparationError::InvalidPathEnvironment { - source: join_paths_error(), - } -} - -fn execution_error() -> ExecutionError { - ExecutionError::Spawn { - program: OsString::from("test-command"), - source: io_error(), - } -} - -fn validation_error(kind: ConfigValidationErrorKind) -> ConfigValidationError { - ConfigValidationError { - target: selector_identifier("target"), - profile: Some(selector_identifier("profile")), - job: Some(ConfigValidationJob::Provider(identifier("provider"))), - field: Some("field".to_owned()), - kind, - } -} - -#[test] -fn command_preparation_error_exposes_join_paths_error() { - assert_source_is::(&preparation_error()); -} - -#[test] -fn execution_errors_expose_io_errors() { - let errors = [ - ExecutionError::Spawn { - program: OsString::from("spawn"), - source: io_error(), - }, - ExecutionError::Wait { - program: OsString::from("wait"), - source: io_error(), - }, - ]; - - for error in &errors { - assert_source_is::(error); - } -} - -#[test] -fn action_run_errors_expose_their_wrapper_errors() { - let preparation = CommandActionRunError::Preparation { - stage: ActionStage::Exec, - source: preparation_error(), - }; - let execution = CommandActionRunError::Execution { - stage: ActionStage::Exec, - source: execution_error(), - }; - - assert_source_is::(&preparation); - assert_source_is::(&execution); -} - -#[test] -fn provider_check_errors_expose_their_wrapper_errors() { - let errors = [ - ProviderProbeError::ActivateInterpolation(interpolation_error()), - ProviderProbeError::ProbeInterpolation(interpolation_error()), - ]; - for error in &errors { - assert_source_is::(error); - } - - let errors = [ - ProviderProbeError::ActivatePreparation(preparation_error()), - ProviderProbeError::ProbePreparation(preparation_error()), - ]; - for error in &errors { - assert_source_is::(error); - } - - let converted = ProviderProbeError::from(execution_error()); - assert!(matches!(&converted, ProviderProbeError::Execution(_))); - assert_source_is::(&converted); -} - -#[test] -fn selector_parse_error_exposes_selector_identifier_error() { - let error = JobSelectorParseError::InvalidIdentifier(selector_identifier_error()); - - let source = assert_source_is::(&error); - assert_no_source(source); -} - -#[test] -fn link_io_error_exposes_io_error() { - let error = LinkError::Io { - operation: "inspect", - path: PathBuf::from("target"), - source: io_error(), - diagnostic_operation: Some(Operation::CreateSymbolicLink), - }; - - assert_source_is::(&error); -} - -#[test] -fn planning_errors_expose_their_wrapper_errors() { - let interpolation = PlanningError::Interpolation { - context: "test field".to_owned(), - source: interpolation_error(), - }; - let environment_patch = PlanningError::EnvironmentPatch { - provider: "provider".to_owned(), - source: preparation_error(), - }; - let invalid_fetch_content_source = PlanningError::InvalidFetchContentSourceUrl { - action: "fetch".to_owned(), - source: url::Url::parse("https://[::1").expect_err("test URL should be invalid"), - }; - - assert_source_is::(&interpolation); - assert_source_is::(&environment_patch); - assert_source_is::(&invalid_fetch_content_source); -} - -#[test] -fn provider_errors_expose_their_wrapper_errors() { - let environment = ProviderError::Environment { - stage: ProviderStage::Activate, - source: preparation_error(), - }; - let preparation = ProviderError::Preparation { - stage: ProviderStage::InitialProbe, - source: preparation_error(), - }; - let execution = ProviderError::Execution { - stage: ProviderStage::InitialProbe, - source: execution_error(), - }; - - assert_source_is::(&environment); - assert_source_is::(&preparation); - assert_source_is::(&execution); -} - -#[test] -fn provider_install_errors_expose_their_wrapper_errors() { - let preparation = ProviderInstallError::Preparation { - source: preparation_error(), - }; - let execution = ProviderInstallError::Execution { - source: execution_error(), - }; - - assert_source_is::(&preparation); - assert_source_is::(&execution); -} - -#[test] -fn validation_error_kinds_expose_their_wrapper_errors() { - let expression = ConfigValidationErrorKind::Expression(interpolation_error()); - let manifest = ConfigValidationErrorKind::Manifest(ManifestError::NoCompatibleTargets { - available: Vec::new(), - }); - - assert_source_is::(&expression); - assert_source_is::(&manifest); -} - -#[test] -fn validation_error_exposes_its_kind() { - let error = validation_error(ConfigValidationErrorKind::EmptyPackageBatch { - package: selector_identifier("package"), - }); - - assert_source_is::(&error); -} - -#[test] -fn every_interpolation_error_has_no_source() { - let errors = [ - InterpolationError::UnclosedResolver { offset: 0 }, - InterpolationError::MissingPayloadSeparator { offset: 0 }, - InterpolationError::NestedResolver { offset: 0 }, - InterpolationError::UnknownResolver { - name: "resolver".to_owned(), - }, - InterpolationError::InvalidResolverPayload { - resolver: "resolver".to_owned(), - payload: "payload".to_owned(), - }, - InterpolationError::ResolverUnavailable { - resolver: "resolver".to_owned(), - }, - InterpolationError::ResolverTypeMismatch { - resolver: "resolver".to_owned(), - expected: SchemaType::String, - actual: SchemaType::Integer, - }, - InterpolationError::ResolverContractViolation { - resolver: "resolver".to_owned(), - expected: SchemaType::String, - actual: SchemaType::Integer, - }, - InterpolationError::ResolverInLiteralString { - resolver: "resolver".to_owned(), - }, - InterpolationError::ListResolverMustOccupyArgument { - resolver: "resolver".to_owned(), - }, - InterpolationError::MissingEnvironmentVariable { - name: "VARIABLE".to_owned(), - }, - InterpolationError::NonUnicodeEnvironmentVariable { - name: "VARIABLE".to_owned(), - }, - InterpolationError::UnavailablePath { - name: "path".to_owned(), - }, - InterpolationError::NonUnicodePath { - name: "path".to_owned(), - }, - InterpolationError::MissingPackageContext, - ]; - - for error in &errors { - assert_no_source(error); - } -} - -#[test] -fn source_less_selector_errors_have_no_source() { - let errors = [ - JobSelectorParseError::MissingKind, - JobSelectorParseError::UnknownKind("unknown".to_owned()), - JobSelectorParseError::ProviderNotSelectable, - ]; - - for error in &errors { - assert_no_source(error); - } -} - -#[test] -fn source_less_link_errors_have_no_source() { - let errors = [ - LinkError::UnsupportedSourceType { - path: PathBuf::from("source"), - }, - LinkError::ExistingNonLink { - target: PathBuf::from("target"), - }, - LinkError::Conflict { - target: PathBuf::from("target"), - destination: PathBuf::from("destination"), - }, - LinkError::InvalidTarget { - target: PathBuf::from("target"), - }, - LinkError::ParentNotDirectory { - parent: PathBuf::from("parent"), - }, - LinkError::VerificationMismatch { - target: PathBuf::from("target"), - expected: PathBuf::from("expected"), - actual: Some(PathBuf::from("actual")), - }, - ]; - - for error in &errors { - assert_no_source(error); - } - - assert_no_source(&LinkPhaseError::DuplicateTarget { - target: PathBuf::from("target"), - links: vec!["first".to_owned(), "second".to_owned()], - }); -} - -#[test] -fn every_manifest_error_has_no_source() { - let errors = [ - ManifestError::NoCompatibleTargets { - available: Vec::new(), - }, - ManifestError::TargetRequired { - available: vec!["target".to_owned()], - }, - ManifestError::UnknownTarget { - requested: "requested".to_owned(), - available: vec!["target".to_owned()], - }, - ManifestError::IncompatiblePlatform { - target: "target".to_owned(), - expected: Box::new(PlatformConstraint { - os: OneOrMany::One(identifier("test-os")), - arch: None, - distro: None, - distro_family: None, - environment: None, - }), - actual: Box::new(PlatformInfo::detect()), - }, - ManifestError::DuplicateProfile { - target: "target".to_owned(), - profile: "profile".to_owned(), - first_path: "first".to_owned(), - second_path: "second".to_owned(), - }, - ManifestError::UnknownProfile { - target: "target".to_owned(), - requested: "requested".to_owned(), - available: vec!["profile".to_owned()], - }, - ]; - - for error in &errors { - assert_no_source(error); - } -} - -#[test] -fn source_less_planning_errors_have_no_source() { - let errors = [ - PlanningError::UnknownProvider { - package: "package".to_owned(), - provider: "provider".to_owned(), - }, - PlanningError::ProviderArgsResolverCount { - package: "package".to_owned(), - provider: "provider".to_owned(), - actual: 0, - }, - PlanningError::EmptyPackageBatch { - package: "package".to_owned(), - }, - PlanningError::DuplicatePackageBatchName { - package: "package".to_owned(), - name: "name".to_owned(), - }, - PlanningError::UnsupportedFetchContentSource { - action: "remote-config".to_owned(), - }, - PlanningError::AuthenticatedFetchContentSource { - action: "remote-config".to_owned(), - }, - PlanningError::UnsupportedFetchContentTarget { - action: "remote-config".to_owned(), - }, - PlanningError::RelativeLinkTarget { - link: "link".to_owned(), - target: PathBuf::from("relative"), - }, - ]; - - for error in &errors { - assert_no_source(error); - } -} - -#[test] -fn job_selection_errors_have_no_source() { - let errors = [ - JobSelectionError::Unknown(JobSelector::Package(selector_identifier("package"))), - JobSelectionError::MissingProvider { - package: selector_identifier("package"), - provider: identifier("provider"), - }, - ]; - - for error in &errors { - assert_no_source(error); - } -} - -#[test] -fn provider_mismatch_has_no_source() { - assert_no_source(&ProviderInstallError::ProviderMismatch { - expected: "expected".to_owned(), - actual: "actual".to_owned(), - }); -} - -#[test] -fn source_less_validation_error_kinds_have_no_source() { - let errors = [ - ConfigValidationErrorKind::UnknownProvider { - package: selector_identifier("package"), - provider: identifier("provider"), - }, - ConfigValidationErrorKind::EmptyPackageBatch { - package: selector_identifier("package"), - }, - ConfigValidationErrorKind::DuplicatePackageBatchName { - package: selector_identifier("package"), - name: identifier("name"), - }, - ConfigValidationErrorKind::ProviderArgsResolverCount { - provider: identifier("provider"), - actual: 0, - }, - ]; - - for error in &errors { - assert_no_source(error); - } -} diff --git a/tests/fixture_support.rs b/tests/fixture_support.rs deleted file mode 100644 index 322dba6..0000000 --- a/tests/fixture_support.rs +++ /dev/null @@ -1,26 +0,0 @@ -mod support; - -use std::path::Path; - -use support::fixture; - -#[test] -fn resolves_and_reads_a_fixture_below_the_fixture_root() { - let path = fixture::path("support/readable.txt"); - - assert!(path.is_absolute()); - assert!(path.ends_with(Path::new("tests/fixtures/support/readable.txt"))); - assert_eq!(fixture::read("support/readable.txt"), "fixture contents\n"); -} - -#[test] -#[should_panic(expected = "fixture path must be relative")] -fn rejects_an_absolute_fixture_path() { - fixture::path("/outside.txt"); -} - -#[test] -#[should_panic(expected = "fixture path must not contain `..`")] -fn rejects_fixture_parent_traversal() { - fixture::path("schema/../../outside.toml"); -} diff --git a/tests/interpolation.rs b/tests/interpolation.rs new file mode 100644 index 0000000..f1b3777 --- /dev/null +++ b/tests/interpolation.rs @@ -0,0 +1,259 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use dot_core::interpolation::{ + DotPaths, ExecutionEnvironment, InterpolationError, PackageContext, ResolveContext, XdgPaths, + promote_string_expression, resolve_environment_patch, resolve_exec_action, + resolve_literal_string, resolve_provider_install_action, resolve_string_expression, +}; +use dot_core::schema::{ + EnvironmentName, EnvironmentPatch, ExecAction, LiteralStringSource, OneOrMany, + ProviderInstallArgSource, ResolvedString, SourceExecAction, StringExpressionSource, +}; + +fn dot_paths() -> DotPaths<'static> { + DotPaths::new( + Path::new("config-dir"), + Path::new("real-config-dir"), + Path::new("working-dir"), + ) +} + +#[test] +fn resolves_environment_and_dot_values_across_an_action() { + let environment = + ExecutionEnvironment::from_variables([("PROGRAM", "tool"), ("ROOT", "/opt/tools")]); + let xdg = XdgPaths::detect(); + let context = ResolveContext::new(&environment, dot_paths(), &xdg); + let action: SourceExecAction = ExecAction { + program: "${env:PROGRAM}".into(), + args: vec![ + "--config=${dot:config_dir}".into(), + "--real=${dot:real_config_dir}".into(), + ], + cwd: Some("${dot:cwd}".into()), + env: Some(EnvironmentPatch { + path_prepend: Some(OneOrMany::One("${env:ROOT}/bin".into())), + path_append: None, + variables: BTreeMap::from([( + EnvironmentName::new("TOOL_HOME").expect("test name should be valid"), + "${env:ROOT}".into(), + )]), + }), + }; + + let resolved = resolve_exec_action(&action, &context).expect("action should resolve"); + + assert_eq!(resolved.program.value(), "tool"); + assert_eq!( + resolved + .args + .iter() + .map(ResolvedString::value) + .collect::>(), + ["--config=config-dir", "--real=real-config-dir"] + ); + assert_eq!(resolved.cwd.as_ref().unwrap().value(), "working-dir"); + let resolved_environment = resolved.env.expect("environment should resolve"); + assert_eq!( + resolved_environment + .path_prepend + .as_ref() + .and_then(|values| match values { + OneOrMany::One(value) => Some(value.value()), + OneOrMany::Many(_) => None, + }), + Some("/opt/tools/bin") + ); + assert_eq!( + resolved_environment.variables["TOOL_HOME"].value(), + "/opt/tools" + ); +} + +#[test] +fn literal_strings_unescape_syntax_and_reject_resolvers() { + assert_eq!( + resolve_literal_string(&LiteralStringSource::from(r"prefix-\${literal}")) + .expect("escaped resolver syntax should remain literal") + .value(), + "prefix-${literal}" + ); + assert_eq!( + resolve_literal_string(&LiteralStringSource::from("${env:HOME}")), + Err(InterpolationError::ResolverInLiteralString { + resolver: "env".into(), + }) + ); +} + +#[test] +fn rejects_unknown_removed_invalid_and_unavailable_resolvers() { + let cases = [ + ( + "${future:value}", + InterpolationError::UnknownResolver { + name: "future".into(), + }, + ), + ( + "${path:cwd}", + InterpolationError::UnknownResolver { + name: "path".into(), + }, + ), + ( + "${dot:config}", + InterpolationError::InvalidResolverPayload { + resolver: "dot".into(), + payload: "config".into(), + }, + ), + ( + "${xdg:repository}", + InterpolationError::InvalidResolverPayload { + resolver: "xdg".into(), + payload: "repository".into(), + }, + ), + ( + "${package:names}", + InterpolationError::ResolverUnavailable { + resolver: "package".into(), + }, + ), + ]; + + for (source, expected) in cases { + assert_eq!( + promote_string_expression(&StringExpressionSource::from(source)), + Err(expected), + "source: {source}" + ); + } +} + +#[test] +fn reports_malformed_resolver_syntax_when_the_value_is_consumed() { + assert_eq!( + promote_string_expression(&StringExpressionSource::from("prefix-${env:HOME")), + Err(InterpolationError::UnclosedResolver { offset: 7 }) + ); +} + +#[test] +fn every_documented_xdg_payload_is_accepted() { + for payload in [ + "home", + "config", + "config_local", + "data", + "data_local", + "cache", + "state", + "runtime", + "executable", + "documents", + ] { + let source = StringExpressionSource::from(format!("${{xdg:{payload}}}")); + promote_string_expression(&source) + .unwrap_or_else(|error| panic!("xdg payload `{payload}` should be valid: {error}")); + } +} + +#[test] +fn provider_install_expands_package_lists_into_arguments() { + let environment = ExecutionEnvironment::from_variables([("PROVIDER", "brew")]); + let xdg = XdgPaths::detect(); + let names = vec!["font-one".to_owned(), "font-two".to_owned()]; + let provider_args = vec!["--cask".to_owned(), "--force".to_owned()]; + let context = ResolveContext::new(&environment, dot_paths(), &xdg) + .with_package(PackageContext::new(&names, &provider_args)); + let action = ExecAction:: { + program: "${env:PROVIDER}".into(), + args: vec![ + "install".into(), + "${package:provider_args}".into(), + "${package:names}".into(), + ], + cwd: None, + env: None, + }; + + let resolved = + resolve_provider_install_action(&action, &context).expect("install should resolve"); + + assert_eq!(resolved.program.value(), "brew"); + assert_eq!( + resolved + .args + .iter() + .map(ResolvedString::value) + .collect::>(), + ["install", "--cask", "--force", "font-one", "font-two"] + ); +} + +#[test] +fn package_list_resolvers_must_occupy_a_complete_argument() { + let environment = ExecutionEnvironment::empty(); + let xdg = XdgPaths::detect(); + let names = vec!["ripgrep".to_owned()]; + let provider_args = Vec::new(); + let context = ResolveContext::new(&environment, dot_paths(), &xdg) + .with_package(PackageContext::new(&names, &provider_args)); + let action = ExecAction:: { + program: "install".into(), + args: vec!["prefix-${package:names}".into()], + cwd: None, + env: None, + }; + + assert_eq!( + resolve_provider_install_action(&action, &context), + Err(InterpolationError::ListResolverMustOccupyArgument { + resolver: "package".into(), + }) + ); +} + +#[test] +fn environment_patch_resolves_every_value_against_one_context() { + let environment = ExecutionEnvironment::from_variables([("ROOT", "/opt/tools")]); + let xdg = XdgPaths::detect(); + let context = ResolveContext::new(&environment, dot_paths(), &xdg); + let patch = EnvironmentPatch { + path_prepend: Some(OneOrMany::Many(vec![ + "${env:ROOT}/bin".into(), + "${dot:config_dir}/bin".into(), + ])), + path_append: None, + variables: BTreeMap::new(), + }; + + let resolved = resolve_environment_patch(&patch, &context).expect("patch should resolve"); + + let Some(OneOrMany::Many(values)) = resolved.path_prepend else { + panic!("path prepend should preserve its list form"); + }; + assert_eq!( + values.iter().map(ResolvedString::value).collect::>(), + ["/opt/tools/bin", "config-dir/bin"] + ); +} + +#[test] +fn dot_resolver_returns_each_protocol_directory() { + let environment = ExecutionEnvironment::empty(); + let xdg = XdgPaths::detect(); + let context = ResolveContext::new(&environment, dot_paths(), &xdg); + let source = + StringExpressionSource::from("${dot:config_dir}|${dot:real_config_dir}|${dot:cwd}"); + + assert_eq!( + resolve_string_expression(&source, &context) + .expect("dot paths should resolve") + .value(), + "config-dir|real-config-dir|working-dir" + ); +} diff --git a/tests/job.rs b/tests/job.rs index 8df8d86..e284d6d 100644 --- a/tests/job.rs +++ b/tests/job.rs @@ -1,7 +1,6 @@ mod support; use std::collections::BTreeSet; -use std::error::Error; use std::path::Path; use dot_core::interpolation::{DotPaths, ExecutionEnvironment, XdgPaths}; @@ -12,7 +11,7 @@ use dot_core::native::plan::{ PlanningError, }; use dot_core::platform::PlatformInfo; -use dot_core::schema::{Config, Identifier, SelectorIdentifier, SelectorIdentifierError}; +use dot_core::schema::{Config, Identifier, SelectorIdentifier}; use support::fixture; #[cfg(not(windows))] @@ -90,17 +89,6 @@ fn job_identity_is_scoped_by_kind() { assert_eq!(package.name(), "shared"); } -#[test] -fn exact_selection_keeps_its_typed_selector() { - let selection = JobSelection::only(JobSelector::Package(selector_id("cli-tools"))); - - assert!(matches!( - selection, - JobSelection::Only(ref selectors) - if selectors.contains(&JobSelector::Package(selector_id("cli-tools"))) - )); -} - #[test] fn job_selectors_round_trip_the_canonical_spelling() { for spelling in ["package:editors", "action:setup", "link:nvim"] { @@ -140,26 +128,6 @@ fn job_selector_parse_errors_distinguish_invalid_inputs() { ); } -#[test] -fn job_selector_parse_error_precedence_is_stable() { - for (spelling, expected) in [ - ( - "package:editors:extra", - JobSelectorParseError::InvalidIdentifier(SelectorIdentifierError), - ), - ( - "service:bad/id", - JobSelectorParseError::UnknownKind("service".into()), - ), - ( - "provider:bad/id", - JobSelectorParseError::ProviderNotSelectable, - ), - ] { - assert_eq!(spelling.parse::(), Err(expected), "{spelling}"); - } -} - #[test] fn job_ids_display_the_canonical_spelling() { for (job, spelling) in [ @@ -393,17 +361,6 @@ fn unknown_typed_selector_fails_before_planning() { )); } -#[test] -fn execution_plan_error_exposes_its_contained_error_as_the_immediate_source() { - let error = ExecutionPlanError::from(JobSelectionError::Unknown(JobSelector::Action( - selector_id("missing"), - ))); - - let source = Error::source(&error).expect("wrapper error should expose its contained error"); - - assert!(source.is::()); -} - #[test] fn unknown_selector_rejects_the_complete_set_before_runtime_evaluation() { let path = fixture::path("selection/valid-selected-runtime-isolation.toml"); @@ -513,40 +470,3 @@ fn selected_interpolation_failure_discards_a_valid_planned_prefix() { "failed to resolve selected job `action:setup-editor` field `exec.program`: environment variable `DOT_INTENTIONALLY_MISSING` is not defined" ); } - -#[test] -fn multiple_unknown_selectors_are_rejected_before_planning() { - let input = fixture::read("dry-run/valid-human-readable-plan.toml"); - let config: Config = toml::from_str(&input).expect("test config should deserialize"); - let platform = platform(); - let target = selector_id("machine"); - let manifest = EffectiveManifest::select_for_execution(&config, &platform, Some(&target), None) - .expect("test manifest should select"); - let environment = environment(); - let xdg = XdgPaths::detect(); - let planner = ExecutionPlanner::new( - &environment, - DotPaths::new( - Path::new(TEST_CONFIG_DIR), - Path::new(TEST_CONFIG_DIR), - Path::new(TEST_CWD), - ), - &xdg, - &platform, - ); - let selection = JobSelection::Only(BTreeSet::from([ - JobSelector::Action(selector_id("z-missing")), - JobSelector::Action(selector_id("a-missing")), - ])); - - let error = planner - .plan(&manifest, &selection) - .expect_err("the complete selection should be rejected"); - - assert!(matches!( - error, - ExecutionPlanError::Selection(JobSelectionError::Unknown( - JobSelector::Action(ref id) - )) if id.as_str() == "a-missing" - )); -} diff --git a/tests/platform.rs b/tests/platform.rs new file mode 100644 index 0000000..7652fba --- /dev/null +++ b/tests/platform.rs @@ -0,0 +1,91 @@ +use std::collections::BTreeSet; + +use dot_core::platform::PlatformInfo; +use dot_core::schema::{Identifier, OneOrMany, PlatformConstraint}; + +fn strings(values: &[&str]) -> BTreeSet { + values.iter().map(|value| (*value).to_owned()).collect() +} + +fn identifier(value: &str) -> Identifier { + Identifier::new(value).expect("test identifier should be valid") +} + +fn linux_platform() -> PlatformInfo { + PlatformInfo { + os: "linux".into(), + arch: "x86_64".into(), + distro: Some("ubuntu".into()), + distro_families: strings(&["debian"]), + environments: strings(&["container", "wsl"]), + } +} + +#[test] +fn constraints_match_any_allowed_value_in_every_declared_field() { + let constraint = PlatformConstraint { + os: OneOrMany::Many(vec![identifier("linux"), identifier("macos")]), + arch: Some(OneOrMany::One(identifier("x86_64"))), + distro: Some(OneOrMany::Many(vec![ + identifier("fedora"), + identifier("ubuntu"), + ])), + distro_family: Some(OneOrMany::One(identifier("debian"))), + environment: Some(OneOrMany::Many(vec![ + identifier("native"), + identifier("wsl"), + ])), + }; + + assert!(constraint.matches(&linux_platform())); +} + +#[test] +fn undeclared_optional_constraints_do_not_reject_a_platform() { + let constraint = PlatformConstraint { + os: OneOrMany::One(identifier("linux")), + arch: None, + distro: None, + distro_family: None, + environment: None, + }; + + assert!(constraint.matches(&linux_platform())); +} + +#[test] +fn any_declared_mismatch_rejects_a_platform() { + let wrong_arch = PlatformConstraint { + os: OneOrMany::One(identifier("linux")), + arch: Some(OneOrMany::One(identifier("aarch64"))), + distro: None, + distro_family: None, + environment: None, + }; + let unavailable_distro = PlatformConstraint { + os: OneOrMany::One(identifier("macos")), + arch: None, + distro: Some(OneOrMany::One(identifier("ubuntu"))), + distro_family: None, + environment: None, + }; + let macos = PlatformInfo { + os: "macos".into(), + arch: "aarch64".into(), + distro: None, + distro_families: BTreeSet::new(), + environments: strings(&["native"]), + }; + + assert!(!wrong_arch.matches(&linux_platform())); + assert!(!unavailable_distro.matches(&macos)); +} + +#[test] +fn detected_platform_uses_the_rust_runtime_target() { + let actual = PlatformInfo::detect(); + + assert_eq!(actual.os, std::env::consts::OS); + assert_eq!(actual.arch, std::env::consts::ARCH); + assert!(!actual.environments.is_empty()); +} diff --git a/tests/provider.rs b/tests/provider.rs index 7275345..86d9b04 100644 --- a/tests/provider.rs +++ b/tests/provider.rs @@ -1,6 +1,5 @@ use std::collections::BTreeMap; use std::env; -use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; use std::process; @@ -285,7 +284,6 @@ fn reports_a_failed_probe_when_no_ensure_is_declared() { error.exit_result().and_then(|result| result.code()), Some(1) ); - assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["probe"]); } @@ -316,7 +314,6 @@ fn stops_the_ensure_list_at_the_first_failure() { error.exit_result().and_then(|result| result.code()), Some(19) ); - assert!(error.source().is_none()); assert_eq!( state.recorded_events(), ["probe", "ensure-first", "ensure-fail"] @@ -346,7 +343,6 @@ fn requires_the_final_probe_to_succeed() { error.exit_result().and_then(|result| result.code()), Some(1) ); - assert!(error.source().is_none()); assert_eq!(state.recorded_events(), ["probe", "ensure-first", "probe"]); } diff --git a/tests/provider_installs.rs b/tests/provider_installs.rs index 0ea1091..d05460d 100644 --- a/tests/provider_installs.rs +++ b/tests/provider_installs.rs @@ -1,6 +1,5 @@ use std::collections::BTreeMap; use std::env; -use std::error::Error; use std::fs; use std::path::{Path, PathBuf}; use std::process; @@ -428,7 +427,6 @@ fn a_failed_install_unit_does_not_stop_an_unrelated_unit() { error, ProviderInstallError::UnsuccessfulExit { result } if result.code() == Some(23) )); - assert!(error.source().is_none()); assert!(matches!( execution.statuses()[1].outcome(), Ok(ProviderInstallOutcome::Executed { install }) if install.code() == Some(0) diff --git a/tests/report_schema.rs b/tests/report_schema.rs deleted file mode 100644 index 92b8b16..0000000 --- a/tests/report_schema.rs +++ /dev/null @@ -1,182 +0,0 @@ -use std::collections::BTreeSet; -use std::path::{Path, PathBuf}; - -use dot_core::platform::PlatformInfo; -use dot_core::report::{ - ActionInfo, ActionItem, CommandActionInfo, CommandInfo, CommandReport, ErrorHint, Evidence, - EvidenceStage, ItemStatus, LinkItem, PackageItem, PackageSource, ProviderItem, - ProviderPackageSource, ReportCommand, ReportContext, ReportItem, ReportStatus, ReportSubject, -}; -use dot_core::schema::{ - FetchContentConflict, LinkConflict, LinkMissingParent, ResolvedExecAction, SourceExecAction, -}; - -fn command(program: &str, args: &[&str]) -> CommandInfo { - CommandInfo { - program: program.to_owned(), - args: args.iter().map(|arg| (*arg).to_owned()).collect(), - cwd: None, - } -} - -#[test] -fn command_info_distinguishes_source_spelling_from_resolved_values() { - let source = SourceExecAction { - program: "${env:PROGRAM}".into(), - args: vec!["--root=${env:HOME}".into()], - cwd: Some("${dot:cwd}".into()), - env: None, - }; - let resolved = ResolvedExecAction { - program: "tool".into(), - args: vec!["--root=/home/tester".into()], - cwd: Some("/work".into()), - env: None, - }; - - let source_info = CommandInfo::from_source(&source); - let resolved_info = CommandInfo::from_resolved(&resolved); - - assert_eq!(source_info.program, "${env:PROGRAM}"); - assert_eq!(source_info.args, ["--root=${env:HOME}"]); - assert_eq!(source_info.cwd, Some(PathBuf::from("${dot:cwd}"))); - assert_eq!(resolved_info.program, "tool"); - assert_eq!(resolved_info.args, ["--root=/home/tester"]); - assert_eq!(resolved_info.cwd, Some(PathBuf::from("/work"))); -} - -fn context() -> ReportContext { - ReportContext { - target: "arch".to_owned(), - profile: Some("laptop".to_owned()), - platform: PlatformInfo { - os: "linux".to_owned(), - arch: "x86_64".to_owned(), - distro: Some("arch".to_owned()), - distro_families: BTreeSet::new(), - environments: BTreeSet::from(["native".to_owned()]), - }, - } -} - -#[test] -fn report_represents_each_logical_item_as_one_entry() { - let items = vec![ - ReportItem { - id: "pacman".to_owned(), - status: ItemStatus::Ready, - subject: ReportSubject::Provider(ProviderItem { - probe: command("pacman", &["--version"]), - ensure: Vec::new(), - has_activation: false, - }), - evidence: Vec::new(), - }, - ReportItem { - id: "ripgrep".to_owned(), - status: ItemStatus::Installed, - subject: ReportSubject::Package(PackageItem { - source: PackageSource::Provider(ProviderPackageSource::Single { - provider: "pacman".to_owned(), - provider_args: vec!["--needed".to_owned()], - }), - }), - evidence: Vec::new(), - }, - ReportItem { - id: "cli-tools".to_owned(), - status: ItemStatus::Installed, - subject: ReportSubject::Package(PackageItem { - source: PackageSource::Provider(ProviderPackageSource::Batch { - provider: "pacman".to_owned(), - names: vec!["bat".to_owned(), "fd".to_owned(), "fzf".to_owned()], - provider_args: Vec::new(), - }), - }), - evidence: Vec::new(), - }, - ReportItem { - id: "setup-shell".to_owned(), - status: ItemStatus::Executed, - subject: ReportSubject::Action(ActionItem { - action: ActionInfo::Command(CommandActionInfo { - check: None, - exec: command("sh", &["setup.sh"]), - }), - }), - evidence: Vec::new(), - }, - ReportItem { - id: "nvim".to_owned(), - status: ItemStatus::Created, - subject: ReportSubject::Link(LinkItem { - source: PathBuf::from("/repo/nvim"), - target: PathBuf::from("/home/user/.config/nvim"), - on_conflict: LinkConflict::ReplaceLink, - on_missing_parent: LinkMissingParent::Create, - }), - evidence: Vec::new(), - }, - ]; - let report = CommandReport { - command: ReportCommand::Apply, - context: context(), - status: ReportStatus::Succeeded, - items, - diagnostics: Vec::new(), - }; - - assert_eq!(report.items.len(), 5); - assert!(matches!( - &report.items[1].subject, - ReportSubject::Package(PackageItem { - source: PackageSource::Provider(ProviderPackageSource::Single { provider, .. }), - }) if provider == "pacman" - )); - assert!(matches!( - &report.items[2].subject, - ReportSubject::Package(PackageItem { - source: PackageSource::Provider(ProviderPackageSource::Batch { names, .. }), - }) if names == &["bat", "fd", "fzf"] - )); -} - -#[test] -fn action_info_represents_resolved_fetch_content_facts() { - let action = ActionInfo::FetchContent { - source: "https://example.com/config.toml".to_owned(), - target: PathBuf::from("/resolved/configs/app.toml"), - on_conflict: FetchContentConflict::Replace, - }; - - assert!(matches!( - action, - ActionInfo::FetchContent { - source, - target, - on_conflict: FetchContentConflict::Replace, - } if source == "https://example.com/config.toml" - && target == Path::new("/resolved/configs/app.toml") - )); -} - -#[test] -fn evidence_keeps_process_results_structured() { - let evidence = Evidence { - stage: EvidenceStage::Probe, - exit_code: Some(1), - message: Some("provider probe returned a non-zero status".to_owned()), - stdout: Some(String::new()), - stderr: Some("not found".to_owned()), - hints: vec![ErrorHint { - code: "test.provider.not-found".to_owned(), - summary: "provider program is unavailable".to_owned(), - suggestion: "install the provider before retrying".to_owned(), - }], - }; - - assert_eq!(evidence.stage, EvidenceStage::Probe); - assert_eq!(evidence.exit_code, Some(1)); - assert_eq!(evidence.stderr.as_deref(), Some("not found")); - assert_eq!(evidence.hints[0].code, "test.provider.not-found"); -} diff --git a/tests/schema.rs b/tests/schema.rs index ff8fef5..6802983 100644 --- a/tests/schema.rs +++ b/tests/schema.rs @@ -1,62 +1,14 @@ mod support; use dot_core::schema::{ - Action, Config, EnvironmentName, EnvironmentPatch, ExecAction, ExpressionParseError, - FetchContentConflict, Identifier, LinkConflict, LinkMissingParent, ListType, - LiteralStringSource, OneOrMany, Package, ParsedStringForm, ParsedTemplatePart, - ProviderInstallArgSource, ProviderPackage, RecordTypeId, ResolvedEnvironmentPatch, - ResolvedString, SchemaType, SchemaTypeMarker, SelectorIdentifier, StringExpressionSource, - StringKeyType, StringRefinementTypeId, StringType, + Action, Config, EnvironmentName, ExecAction, ExpressionParseError, FetchContentConflict, + Identifier, LinkConflict, LinkMissingParent, LiteralStringSource, OneOrMany, Package, + ParsedStringForm, ParsedTemplatePart, ProviderInstallArgSource, ProviderPackage, + SelectorIdentifier, StringExpressionSource, }; use support::fixture; -#[test] -fn describes_every_toml_literal_and_nested_schema_type() { - let primitives = [ - SchemaType::String, - SchemaType::Integer, - SchemaType::Float, - SchemaType::Boolean, - SchemaType::OffsetDateTime, - SchemaType::LocalDateTime, - SchemaType::LocalDate, - SchemaType::LocalTime, - ]; - assert_eq!(primitives.len(), 8); - - let nested = SchemaType::List(Box::new(SchemaType::Map( - StringKeyType::Refinement(StringRefinementTypeId::new("environment_name")), - Box::new(SchemaType::Record(RecordTypeId::new("exec_action"))), - ))); - assert_eq!( - nested, - SchemaType::List(Box::new(SchemaType::Map( - StringKeyType::Refinement(StringRefinementTypeId::new("environment_name")), - Box::new(SchemaType::Record(RecordTypeId::new("exec_action"))), - ))) - ); -} - -#[test] -fn logical_markers_have_runtime_schema_signatures() { - assert_eq!(StringType::schema_type(), SchemaType::String); - assert_eq!( - ListType::::schema_type(), - SchemaType::List(Box::new(SchemaType::String)) - ); -} - -#[test] -fn logical_markers_define_their_resolved_value_types() { - let owned: ::Resolved = String::from("owned").into(); - let borrowed: ResolvedString = "borrowed".into(); - let list: as SchemaTypeMarker>::Resolved = vec![owned, borrowed]; - - assert_eq!(list[0].value(), "owned"); - assert_eq!(list[1].value(), "borrowed"); -} - #[test] fn selector_identifiers_use_the_cli_safe_grammar() { for valid in ["a", "A1", "0root", "_root", "arch-personal", "tool.v2"] { @@ -84,19 +36,6 @@ fn selector_identifiers_use_the_cli_safe_grammar() { } } -#[test] -fn source_and_resolved_environment_patches_have_empty_defaults() { - let source = EnvironmentPatch::::default(); - let resolved = ResolvedEnvironmentPatch::default(); - - assert!(source.path_prepend.is_none()); - assert!(source.path_append.is_none()); - assert!(source.variables.is_empty()); - assert!(resolved.path_prepend.is_none()); - assert!(resolved.path_append.is_none()); - assert!(resolved.variables.is_empty()); -} - #[test] fn classifies_literal_template_variable_and_malformed_sources() { #[derive(serde::Deserialize)] diff --git a/tests/validation.rs b/tests/validation.rs index 10ca868..80187ea 100644 --- a/tests/validation.rs +++ b/tests/validation.rs @@ -1,7 +1,5 @@ mod support; -use std::error::Error; - use dot_core::config::ConfigParseError; use dot_core::interpolation::InterpolationError; use dot_core::schema::Config; @@ -35,8 +33,6 @@ fn rejects_a_static_expression_error_in_an_unselected_target() { name }) if name == "unknown" )); - assert!(error.source().is_some()); - assert!(source.source().is_some()); } #[test] From 6b86e34266cd7c0019c529dcb065a3927667c464 Mon Sep 17 00:00:00 2001 From: Shuoliu Yang Date: Fri, 7 Aug 2026 14:57:56 +0800 Subject: [PATCH 2/2] make config path tests portable --- dot-cli/tests/config_path_command.rs | 30 +++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/dot-cli/tests/config_path_command.rs b/dot-cli/tests/config_path_command.rs index 216f82e..8361bcc 100644 --- a/dot-cli/tests/config_path_command.rs +++ b/dot-cli/tests/config_path_command.rs @@ -64,6 +64,15 @@ impl Drop for TempWorkspace { } } +fn normalized_path_output(value: &str) -> String { + value + .replace(r"\\?\", "") + .replace('\\', "/") + .chars() + .filter(|character| !character.is_whitespace() && !matches!(character, '│' | '┆')) + .collect() +} + #[test] fn relative_config_paths_produce_absolute_protocol_directories() { let workspace = TempWorkspace::new(); @@ -73,6 +82,7 @@ fn relative_config_paths_produce_absolute_protocol_directories() { let output = workspace.run(Path::new("config").join(".dot.toml")); let stdout = String::from_utf8_lossy(&output.stdout); + let normalized = normalized_path_output(&stdout); assert!( output.status.success(), @@ -80,11 +90,15 @@ fn relative_config_paths_produce_absolute_protocol_directories() { String::from_utf8_lossy(&output.stderr) ); assert!( - stdout.contains(config_dir.join("entry.txt").to_string_lossy().as_ref()), + normalized.contains(&normalized_path_output( + config_dir.join("entry.txt").to_string_lossy().as_ref() + )), "{stdout}" ); assert!( - stdout.contains(config_dir.join("real.txt").to_string_lossy().as_ref()), + normalized.contains(&normalized_path_output( + config_dir.join("real.txt").to_string_lossy().as_ref() + )), "{stdout}" ); } @@ -102,6 +116,7 @@ fn symlinked_config_keeps_entry_and_real_directories_distinct() { let output = workspace.run(Path::new("entry").join(".dot.toml")); let stdout = String::from_utf8_lossy(&output.stdout); + let normalized = normalized_path_output(&stdout); assert!( output.status.success(), @@ -109,11 +124,15 @@ fn symlinked_config_keeps_entry_and_real_directories_distinct() { String::from_utf8_lossy(&output.stderr) ); assert!( - stdout.contains(entry_dir.join("entry.txt").to_string_lossy().as_ref()), + normalized.contains(&normalized_path_output( + entry_dir.join("entry.txt").to_string_lossy().as_ref() + )), "{stdout}" ); assert!( - stdout.contains(real_dir.join("real.txt").to_string_lossy().as_ref()), + normalized.contains(&normalized_path_output( + real_dir.join("real.txt").to_string_lossy().as_ref() + )), "{stdout}" ); } @@ -126,6 +145,7 @@ fn missing_config_error_names_the_requested_absolute_path() { let output = workspace.run(&relative); let stderr = String::from_utf8_lossy(&output.stderr); + let normalized = normalized_path_output(&stderr); assert_eq!(output.status.code(), Some(1)); assert!(output.stdout.is_empty()); @@ -134,7 +154,7 @@ fn missing_config_error_names_the_requested_absolute_path() { "{stderr}" ); assert!( - stderr.contains(expected.to_string_lossy().as_ref()), + normalized.contains(&normalized_path_output(expected.to_string_lossy().as_ref())), "{stderr}" ); }