//! Global config management: path resolution, `config path` display, //! `config init-global` scaffold. use crate::config::default_config_dir; use std::path::PathBuf; /// Global config template written by `config init-global`. const GLOBAL_TEMPLATE: &str = r#"# Global sm121 config (created by `sm121 config init-global`). # Used when ./config.toml is absent, so `sm121` works from any cwd. # `ssh_user` / `ssh_key_path` may instead be provided by a cluster source. # `~` is expanded. # # recipe_sources = ["/path/to/recipes"] # extra recipe dirs # cluster_sources = ["/path/to/clusters.toml"] # extra cluster def files "#; /// Resolve the path to the config file. /// /// Search order: /// 1. `--config ` from CLI (always wins; error if missing) /// 2. `./config.toml` in cwd /// 3. `/config.toml` /// /// Returns the explicit path if nothing is found; the loader reports a /// clean error. pub fn resolve_config_path(cli_config: &str) -> PathBuf { let explicit = PathBuf::from(cli_config); // User passed a non-default path: use it as-is. if cli_config != "config.toml" { return explicit; } // Default value (or no flag): try cwd first, then default folder. if explicit.exists() { return explicit; } if let Some(dir) = default_config_dir() { let candidate = dir.join("config.toml"); if candidate.exists() { return candidate; } } explicit } /// Resolve + read the config in one step. A missing file must not surface /// as a bare "No such file or directory (os error 2)" — the error names /// every location that was searched. pub async fn read_config(cli_config: &str) -> anyhow::Result<(PathBuf, String)> { let path = resolve_config_path(cli_config); match tokio::fs::read_to_string(&path).await { Ok(raw) => Ok((path, raw)), Err(e) => { let msg = if path == std::path::Path::new("config.toml") { // resolve_config_path's "found nowhere" sentinel let mut m = String::from("config not found; searched: ./config.toml"); if let Some(dir) = default_config_dir() { m.push_str(&format!(", {}/config.toml", dir.display())); } m.push_str(" — pass --config or run `sm121 config init-global`"); m } else { format!("cannot read config {}", path.display()) }; anyhow::bail!("{msg} ({e})") } } } /// Entry point for `sm121 config ...` subcommands (dispatched before /// cluster_sources merging so they work even with broken sources). pub fn run( cmd: &crate::cli::ConfigCommands, cli_config: &str, dry_run: bool, ) -> anyhow::Result<()> { match cmd { crate::cli::ConfigCommands::Path => show_paths(cli_config), crate::cli::ConfigCommands::InitGlobal => init_global(dry_run)?, } Ok(()) } /// `sm121 config path`: print the active config and the global config /// location (the part that is not transparent — it lives in the /// platform config dir, e.g. `~/Library/Application Support/sm121` on macOS). pub fn show_paths(cli_config: &str) { let active = resolve_config_path(cli_config); let origin = if cli_config != "config.toml" { "--config" } else if std::path::Path::new("config.toml").exists() { "./config.toml (cwd)" } else { "global config dir" }; println!("active config: {} [{}]", active.display(), origin); if let Some(dir) = default_config_dir() { let global = dir.join("config.toml"); let state = if global.exists() { "exists" } else { "missing — `sm121 config init-global` creates a template" }; println!("global config: {} [{}]", global.display(), state); } } /// `sm121 config init-global`: create the global config (template) if /// missing. Never overwrites an existing file. pub fn init_global(dry_run: bool) -> anyhow::Result<()> { let dir = default_config_dir().ok_or_else(|| { anyhow::anyhow!("cannot determine the platform config dir (dirs::config_dir)") })?; let global = dir.join("config.toml"); if global.exists() { println!("global config already exists: {}", global.display()); return Ok(()); } if dry_run { println!("would write: {}", global.display()); return Ok(()); } std::fs::create_dir_all(&dir)?; std::fs::write(&global, GLOBAL_TEMPLATE)?; println!("created global config template: {}", global.display()); Ok(()) } #[cfg(test)] mod tests { use super::resolve_config_path; #[tokio::test] async fn missing_config_error_names_the_paths_searched() { let err = super::read_config("/nonexistent/sm121-config.toml") .await .unwrap_err() .to_string(); assert!(err.contains("/nonexistent/sm121-config.toml"), "{err}"); } #[test] fn explicit_config_path_wins() { assert_eq!( resolve_config_path("/tmp/explicit-sm121.toml"), std::path::PathBuf::from("/tmp/explicit-sm121.toml") ); } #[test] fn default_config_dir_is_some() { assert!(crate::config::default_config_dir().is_some()); } }