diff --git a/README.md b/README.md index f5dcd01..8804f41 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ The binary is written to `target/release/dot` (`dot.exe` on Windows). ## Quick start -Create `dot.toml` in the directory containing the files you want to manage: +Create `.dot.toml` in the directory containing the files you want to manage: ```toml [targets.workstation] @@ -79,9 +79,13 @@ Apply the environment: dot apply --target workstation ``` -The default configuration path is `./dot.toml`; use `--config PATH` to select -another file. `--target` may be omitted when exactly one configured target is -compatible with the current platform. +Without `--config`, `dot` checks `./.dot.toml` first, then +`~/.config/dot/.dot.toml` on Linux and macOS or +`%APPDATA%\dot\.dot.toml` on Windows. The first candidate whose filesystem +entry exists is selected; load, parse, or validation errors for that path do +not fall through to another candidate. An explicit `--config PATH` bypasses +discovery and may use any filename. `--target` may be omitted when exactly one +configured target is compatible with the current platform. ## Command line diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index eaf746b..4205728 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -6,6 +6,22 @@ configuration. This document is the human-facing explanation of that schema; boundaries. When they differ, update `SCHEMA.txt` first and then synchronize this reference. +## Configuration discovery + +`dot` chooses one configuration using this exact precedence: + +1. the path from an explicit `--config PATH`, when supplied; +2. `.dot.toml` in the current working directory; +3. `~/.config/dot/.dot.toml` on Linux and macOS, or + `%APPDATA%\dot\.dot.toml` on Windows. + +An explicit path is used as given, may have any filename, and bypasses the +remaining discovery candidates. Among the automatic candidates, the first +whose filesystem entry exists is chosen. Read, parse, or validation failures +for the chosen path are reported immediately; `dot` does not fall back to +another candidate. It does not search parent directories recursively, merge +configuration files, or recognize `dot.toml` as a legacy default. + ## Type index - [Foundational types](#foundational-types): [`string`](#string), diff --git a/docs/DESIGN.txt b/docs/DESIGN.txt index 54244fc..388f1c1 100644 --- a/docs/DESIGN.txt +++ b/docs/DESIGN.txt @@ -652,7 +652,16 @@ dot list jobs ``` There is no default operation. `dot` without a subcommand prints help and is an -error. `--config PATH` is global and defaults to `./dot.toml`. +error. `--config PATH` is global. The CLI represents its presence or absence as +a typed ConfigRequest, and the central application facade resolves that request +exactly once before dispatching an operation. An explicit path bypasses +discovery and may use any filename. Otherwise dot checks `./.dot.toml`, then +`~/.config/dot/.dot.toml` on Linux and macOS or +`%APPDATA%\dot\.dot.toml` on Windows. The first existing filesystem entry is +selected; load, parse, and validation failures do not fall through. Discovery +does not recursively search, merge files, or recognize `dot.toml` as a legacy +default. It uses native host paths and is not affected by an injected +PlatformInfo. Target and profile options belong to `apply`, `dry-run`, `check providers`, and `list jobs`. `list profiles` accepts only the target option. `list targets` diff --git a/docs/JOB_EXECUTION.md b/docs/JOB_EXECUTION.md index b4113c6..42cbd01 100644 --- a/docs/JOB_EXECUTION.md +++ b/docs/JOB_EXECUTION.md @@ -103,7 +103,7 @@ from `SCHEMA.txt`. Provider IDs remain broad identifiers. The command pipeline is: ```text -dot.toml +.dot.toml | | parse + whole-configuration static validation v diff --git a/src/app.rs b/src/app.rs index eaa3886..c00b4f5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -26,6 +26,11 @@ pub fn run(dispatch: Dispatch) -> ExitCode { platform_override, } = dispatch; + let config = match config.resolve() { + Ok(path) => path, + Err(error) => return command_error(error), + }; + if platform_override.is_some() { print_platform_warning(&operation); } diff --git a/src/app/command.rs b/src/app/command.rs index e4ab1b8..ed8d229 100644 --- a/src/app/command.rs +++ b/src/app/command.rs @@ -1,6 +1,6 @@ use std::fmt; -use std::path::PathBuf; +use crate::config::ConfigRequest; use crate::job::JobSelection; use crate::platform::PlatformInfo; use crate::schema::{SelectorIdentifier, SelectorIdentifierError}; @@ -60,7 +60,7 @@ pub enum Operation { #[derive(Clone, Debug, PartialEq, Eq)] pub struct Dispatch { - pub config: PathBuf, + pub config: ConfigRequest, pub operation: Operation, pub platform_override: Option, } diff --git a/src/cli.rs b/src/cli.rs index d904e45..5376bd5 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -6,6 +6,7 @@ use clap::error::ErrorKind; use clap::{Args, CommandFactory, Error, Parser, Subcommand}; use crate::app::{Dispatch, ExecutionRequest, Operation, ProfileSelection, ScopeSelection}; +use crate::config::ConfigRequest; use crate::job::{JobSelection, JobSelector}; use crate::schema::SelectorIdentifier; @@ -31,15 +32,9 @@ where subcommand_required = true )] struct Cli { - /// Path to the TOML manifest - #[arg( - short, - long, - global = true, - value_name = "PATH", - default_value = "./dot.toml" - )] - config: PathBuf, + /// Path to the TOML manifest; defaults to ./.dot.toml, then the user fallback + #[arg(short, long, global = true, value_name = "PATH")] + config: Option, /// Inject PlatformInfo for development-time compatibility selection; host environment, XDG /// paths, commands, and filesystem state remain unchanged @@ -85,7 +80,9 @@ impl Cli { }; Ok(Dispatch { - config: self.config, + config: self + .config + .map_or(ConfigRequest::Discover, ConfigRequest::Explicit), operation, platform_override, }) diff --git a/src/config.rs b/src/config.rs index cd21edc..be22c45 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,10 +5,165 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; +use directories::BaseDirs; + use crate::action::ExecutionEnvironment; use crate::schema::Config; use crate::validation::{ConfigValidationError, validate_config}; +const DEFAULT_CONFIG_FILENAME: &str = ".dot.toml"; + +fn path_entry_exists(path: &Path) -> io::Result { + match fs::symlink_metadata(path) { + Ok(_) => Ok(true), + Err(source) if source.kind() == io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(source), + } +} + +fn detect_user_config_root() -> Option { + let base_directories = BaseDirs::new()?; + + #[cfg(windows)] + { + Some(UserConfigRoot::Roaming( + base_directories.config_dir().to_path_buf(), + )) + } + + #[cfg(not(windows))] + { + Some(UserConfigRoot::Home( + base_directories.home_dir().to_path_buf(), + )) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ConfigRequest { + Explicit(PathBuf), + Discover, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum UserConfigRoot { + // Keep both variants available so path construction stays unit-testable on either host. + #[cfg_attr(windows, allow(dead_code))] + Home(PathBuf), + #[cfg_attr(not(windows), allow(dead_code))] + Roaming(PathBuf), +} + +impl UserConfigRoot { + fn manifest_path(self) -> PathBuf { + match self { + Self::Home(root) => root + .join(".config") + .join("dot") + .join(DEFAULT_CONFIG_FILENAME), + Self::Roaming(root) => root.join("dot").join(DEFAULT_CONFIG_FILENAME), + } + } +} + +#[derive(Debug)] +pub enum ConfigDiscoveryError { + CurrentDirectory { source: io::Error }, + UserDirectoryUnavailable, + Inspect { path: PathBuf, source: io::Error }, + NotFound { local: PathBuf, user: PathBuf }, +} + +impl ConfigRequest { + pub(crate) fn resolve(self) -> Result { + self.resolve_with(env::current_dir, detect_user_config_root, path_entry_exists) + } + + fn resolve_with( + self, + current_dir: CurrentDir, + user_root: UserRoot, + mut present: Present, + ) -> Result + where + CurrentDir: FnOnce() -> io::Result, + UserRoot: FnOnce() -> Option, + Present: FnMut(&Path) -> io::Result, + { + match self { + Self::Explicit(path) => Ok(path), + Self::Discover => { + let invocation_cwd = current_dir() + .map_err(|source| ConfigDiscoveryError::CurrentDirectory { source })?; + let local = invocation_cwd.join(DEFAULT_CONFIG_FILENAME); + + if present(&local).map_err(|source| ConfigDiscoveryError::Inspect { + path: local.clone(), + source, + })? { + Ok(local) + } else { + let user = user_root() + .ok_or(ConfigDiscoveryError::UserDirectoryUnavailable)? + .manifest_path(); + + if present(&user).map_err(|source| ConfigDiscoveryError::Inspect { + path: user.clone(), + source, + })? { + Ok(user) + } else { + Err(ConfigDiscoveryError::NotFound { local, user }) + } + } + } + } + } +} + +impl fmt::Display for ConfigDiscoveryError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::CurrentDirectory { source } => { + write!( + formatter, + "failed to determine the invocation directory: {source}" + ) + } + Self::UserDirectoryUnavailable => { + write!( + formatter, + "failed to determine the user configuration directory" + ) + } + Self::Inspect { path, source } => { + write!( + formatter, + "failed to inspect configuration candidate `{}`: {source}", + path.display() + ) + } + Self::NotFound { local, user } => { + write!( + formatter, + "configuration not found; checked `{}` then `{}`; use --config PATH to select another file", + local.display(), + user.display() + ) + } + } + } +} + +impl Error for ConfigDiscoveryError { + fn source(&self) -> Option<&(dyn Error + 'static)> { + match self { + Self::CurrentDirectory { source } | Self::Inspect { source, .. } => Some(source), + Self::UserDirectoryUnavailable | Self::NotFound { .. } => None, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LoadedConfigDocument { config: Config, @@ -177,6 +332,306 @@ impl Error for ConfigLoadError { mod tests { use super::*; + 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())) + } + + #[test] + fn explicit_request_bypasses_discovery() { + let requested = PathBuf::from("relative/config.toml"); + + let resolved = ConfigRequest::Explicit(requested.clone()) + .resolve_with( + || -> io::Result { panic!("current directory must not be queried") }, + || panic!("user root must not be queried"), + |_| -> io::Result { panic!("candidate must not be inspected") }, + ) + .expect("explicit request should resolve"); + + assert_eq!(resolved, requested); + } + + #[test] + fn explicit_resolve_preserves_the_requested_path() { + let requested = PathBuf::from("relative/config.toml"); + + let resolved = ConfigRequest::Explicit(requested.clone()) + .resolve() + .expect("explicit request should resolve"); + + assert_eq!(resolved, 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( + || Ok(invocation_cwd), + || panic!("user root must not be queried"), + |candidate| { + assert_eq!(candidate, expected); + Ok(true) + }, + ) + .expect("local candidate should resolve"); + + assert_eq!(resolved, 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( + || Ok(invocation_cwd), + || Some(UserConfigRoot::Home(home)), + |candidate| { + inspected.push(candidate.to_path_buf()); + Ok(candidate == user) + }, + ) + .expect("user candidate should resolve"); + + assert_eq!(resolved, user); + 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( + || Ok(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( + || Ok(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 current_directory_error_is_typed() { + let error = ConfigRequest::Discover + .resolve_with( + || { + Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "cwd unavailable", + )) + }, + || panic!("user root must not be queried"), + |_| -> io::Result { panic!("candidate must not be inspected") }, + ) + .expect_err("current-directory lookup should fail"); + + match error { + ConfigDiscoveryError::CurrentDirectory { source } => { + assert_eq!(source.kind(), io::ErrorKind::PermissionDenied); + } + other => panic!("expected current-directory error, got {other:?}"), + } + } + + #[test] + fn missing_candidates_report_both_paths_in_probe_order() { + 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 error = ConfigRequest::Discover + .resolve_with( + || Ok(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 PATH to select another file", + local.display(), + user.display() + ) + ); + assert!(error.to_string().contains("--config PATH")); + } + + #[test] + fn discovery_errors_have_exact_messages() { + let current_directory = ConfigDiscoveryError::CurrentDirectory { + source: io::Error::new(io::ErrorKind::PermissionDenied, "cwd blocked"), + }; + assert_eq!( + current_directory.to_string(), + "failed to determine the invocation directory: cwd blocked" + ); + + let unavailable = ConfigDiscoveryError::UserDirectoryUnavailable; + 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_error_sources_expose_only_io_errors() { + let current_directory = ConfigDiscoveryError::CurrentDirectory { + source: io::Error::new(io::ErrorKind::PermissionDenied, "cwd blocked"), + }; + assert_eq!( + Error::source(¤t_directory) + .expect("current-directory error should have a source") + .to_string(), + "cwd blocked" + ); + + 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_subsequent_read_fails() { + 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 = + LoadedConfigDocument::load(&candidate).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::Read { path, source } => { + assert_eq!(path, candidate); + assert_eq!(source.kind(), io::ErrorKind::NotFound); + } + 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"); diff --git a/tests/cli.rs b/tests/cli.rs index 812fa2e..d76597e 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -5,6 +5,7 @@ use std::process::Command; use clap::error::ErrorKind; use dot::app::{Dispatch, ExecutionRequest, Operation, ProfileSelection, ScopeSelection}; use dot::cli; +use dot::config::ConfigRequest; use dot::job::{JobSelection, JobSelector}; #[cfg(feature = "dev-platform-override")] use dot::platform::PlatformInfo; @@ -49,7 +50,7 @@ fn parses_explicit_apply_with_root_scope_and_all_jobs() { assert_eq!( dispatch, Dispatch { - config: PathBuf::from("./dot.toml"), + config: ConfigRequest::Discover, operation: Operation::Apply(ExecutionRequest { scope: root_scope(None), jobs: JobSelection::All, @@ -77,7 +78,10 @@ fn parses_dry_run_selection_and_repeatable_jobs() { ]) .expect("dry-run arguments should parse"); - assert_eq!(dispatch.config, PathBuf::from("config/dev.toml")); + assert_eq!( + dispatch.config, + ConfigRequest::Explicit(PathBuf::from("config/dev.toml")) + ); assert_eq!( dispatch.operation, Operation::DryRun(ExecutionRequest { @@ -194,7 +198,10 @@ fn parses_check_and_list_operations() { "laptop", ]) .expect("check providers should parse"); - assert_eq!(check.config, PathBuf::from("config/dev.toml")); + assert_eq!( + check.config, + ConfigRequest::Explicit(PathBuf::from("config/dev.toml")) + ); assert_eq!( check.operation, Operation::CheckProviders(ScopeSelection { @@ -369,3 +376,22 @@ fn exposes_standard_help_and_version_flags() { assert_eq!(help.kind(), ErrorKind::DisplayHelp); assert_eq!(version.kind(), ErrorKind::DisplayVersion); } + +#[test] +fn help_documents_config_discovery() { + let output = Command::new(env!("CARGO_BIN_EXE_dot")) + .arg("--help") + .output() + .expect("dot should start"); + let stdout = String::from_utf8(output.stdout).expect("help stdout should be UTF-8"); + let normalized_help = stdout.split_whitespace().collect::>().join(" "); + + assert!(output.status.success(), "{stdout}"); + assert!(normalized_help.contains("--config "), "{stdout}"); + assert!(stdout.contains("./.dot.toml"), "{stdout}"); + assert!(normalized_help.contains("user fallback"), "{stdout}"); + assert!( + !normalized_help.contains("[default: ./dot.toml]"), + "{stdout}" + ); +} diff --git a/tests/config_discovery_command.rs b/tests/config_discovery_command.rs new file mode 100644 index 0000000..ff2454b --- /dev/null +++ b/tests/config_discovery_command.rs @@ -0,0 +1,188 @@ +use std::env; +use std::fs; +use std::path::PathBuf; +use std::process::{self, Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_WORKSPACE: AtomicU64 = AtomicU64::new(0); + +const SIDE_EFFECT_FREE_MANIFEST: &str = r#"[targets.current] +platform = { os = ["linux", "macos", "windows"] } +"#; + +struct TempWorkspace { + root: PathBuf, + cwd: PathBuf, + #[cfg(unix)] + home: PathBuf, +} + +impl TempWorkspace { + fn new() -> Self { + let sequence = NEXT_WORKSPACE.fetch_add(1, Ordering::Relaxed); + let root = env::temp_dir().join(format!( + "dot-config-discovery-command-{}-{sequence}", + process::id() + )); + let cwd = root.join("cwd"); + let home = root.join("home"); + fs::create_dir_all(&cwd).expect("temporary working directory should be created"); + fs::create_dir(&home).expect("temporary home directory should be created"); + let root = + fs::canonicalize(root).expect("temporary workspace should have an absolute path"); + let cwd = root.join("cwd"); + #[cfg(unix)] + let home = root.join("home"); + Self { + root, + cwd, + #[cfg(unix)] + home, + } + } + + fn write_local(&self, contents: &str) { + fs::write(self.cwd.join(".dot.toml"), contents) + .expect("local test manifest should be written"); + } + + #[cfg(unix)] + fn write_legacy_local(&self, contents: &str) { + fs::write(self.cwd.join("dot.toml"), contents) + .expect("legacy local test manifest should be written"); + } + + #[cfg(unix)] + fn write_user(&self, contents: &str) { + let directory = self.home.join(".config").join("dot"); + fs::create_dir_all(&directory).expect("user configuration directory should be created"); + fs::write(directory.join(".dot.toml"), contents) + .expect("user test manifest should be written"); + } + + fn command(&self, args: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_dot")); + command.args(args).current_dir(&self.cwd); + #[cfg(unix)] + command.env("HOME", &self.home); + command.output().expect("dot should start") + } +} + +impl Drop for TempWorkspace { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.root); + } +} + +fn assert_success(args: &[&str], output: &Output) { + assert!( + output.status.success(), + "command arguments: {args:?}\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn every_operation_uses_the_local_default_manifest() { + let workspace = TempWorkspace::new(); + workspace.write_local(SIDE_EFFECT_FREE_MANIFEST); + + for args in [ + &["apply"][..], + &["dry-run"][..], + &["check", "providers"][..], + &["list", "targets"][..], + &["list", "profiles"][..], + &["list", "jobs"][..], + ] { + let output = workspace.command(args); + assert_success(args, &output); + } +} + +#[cfg(unix)] +#[test] +fn missing_local_manifest_uses_the_user_manifest() { + let workspace = TempWorkspace::new(); + workspace.write_user(SIDE_EFFECT_FREE_MANIFEST); + + let args = ["list", "targets"]; + let output = workspace.command(&args); + assert_success(&args, &output); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("current\tcompatible"), "{stdout}"); +} + +#[cfg(unix)] +#[test] +fn invalid_local_manifest_does_not_fall_back_to_valid_user_manifest() { + let workspace = TempWorkspace::new(); + workspace.write_local("[targets"); + workspace.write_user(SIDE_EFFECT_FREE_MANIFEST); + + let output = workspace.command(&["list", "targets"]); + let stderr = String::from_utf8_lossy(&output.stderr); + let local = workspace.cwd.join(".dot.toml"); + + assert!(!output.status.success(), "stderr:\n{stderr}"); + assert!(local.is_absolute(), "{}", local.display()); + assert!( + stderr.contains(&format!("`{}`", local.display())), + "{stderr}" + ); + assert!(stderr.contains("failed to parse configuration"), "{stderr}"); +} + +#[cfg(unix)] +#[test] +fn legacy_dot_toml_is_not_discovered() { + let workspace = TempWorkspace::new(); + workspace.write_legacy_local(SIDE_EFFECT_FREE_MANIFEST); + + let output = workspace.command(&["list", "targets"]); + let stderr = String::from_utf8_lossy(&output.stderr); + let local_candidate = workspace.cwd.join(".dot.toml").display().to_string(); + let user_candidate = workspace + .home + .join(".config") + .join("dot") + .join(".dot.toml") + .display() + .to_string(); + + assert!(!output.status.success(), "stderr:\n{stderr}"); + assert!(stderr.contains("configuration not found"), "{stderr}"); + let local_offset = stderr + .find(&local_candidate) + .unwrap_or_else(|| panic!("missing local candidate `{local_candidate}`:\n{stderr}")); + let user_offset = stderr + .find(&user_candidate) + .unwrap_or_else(|| panic!("missing user candidate `{user_candidate}`:\n{stderr}")); + assert!( + local_offset < user_offset, + "local candidate must precede user candidate:\n{stderr}" + ); +} + +#[cfg(all(feature = "dev-platform-override", unix))] +#[test] +fn platform_override_does_not_change_the_user_fallback_path() { + let workspace = TempWorkspace::new(); + workspace.write_user(SIDE_EFFECT_FREE_MANIFEST); + + let args = [ + "--platform", + r#"{ os = "windows", arch = "x86_64" }"#, + "list", + "targets", + "--all", + ]; + let output = workspace.command(&args); + assert_success(&args, &output); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("current\tcompatible"), "{stdout}"); +}