diff --git a/crates/sandcage/src/acp/mod.rs b/crates/sandcage/src/acp/mod.rs index 21ea17e..de0bf6f 100644 --- a/crates/sandcage/src/acp/mod.rs +++ b/crates/sandcage/src/acp/mod.rs @@ -69,8 +69,8 @@ pub async fn handle( let global_config = home.join(".sandcage").join("config.toml"); let config_src = crate::config_source::resolve_config_source(&workspace); let config_root = config_src.as_ref().map(|s| s.root.clone()).unwrap_or_else(|| workspace.clone()); - let project_config = config_root.join(".sandcage").join("sandcage.yml"); - let local_config = config_root.join(".sandcage").join("local").join("sandcage.local.yml"); + let project_config = config_root.join(".sandcage").join("config.toml"); + let local_config = config_root.join(".sandcage").join("local").join("config.local.toml"); let cfg = crate::config::resolve_config( Some(&global_config), Some(&project_config), diff --git a/crates/sandcage/src/setup.rs b/crates/sandcage/src/setup.rs index 1e17d36..fd53486 100644 --- a/crates/sandcage/src/setup.rs +++ b/crates/sandcage/src/setup.rs @@ -6,7 +6,7 @@ use thiserror::Error; use figment::providers::Format as _; -use crate::config::{SandcageConfig, SshKeyEntry}; +use crate::config::SandcageConfig; use crate::ssh_config::{self, SshHostBlock}; #[derive(Debug, Error, Diagnostic)] @@ -19,11 +19,11 @@ pub enum SetupError { #[diagnostic(code(sandcage::setup::read_dir_failed))] ReadDirFailed(PathBuf, #[source] std::io::Error), - #[error("No .sandcage/sandcage.yml found. Run `sandcage init` first, use `--global`, or set SANDCAGE_CONFIG_DIR.")] + #[error("No .sandcage/config.toml found. Run `sandcage init` first, use `--global`, or set SANDCAGE_CONFIG_DIR.")] #[diagnostic( code(sandcage::setup::no_project_config), help("In a git worktree, .sandcage/ is loaded from the main worktree root. \ - You can also use a redirect file (sandcage.yml with redirect: key).") + You can also use a redirect file (config.toml with redirect key).") )] NoProjectConfig, @@ -104,115 +104,6 @@ pub fn check_existing_mount(config: &SandcageConfig, mount: &str) -> bool { .is_some_and(|m| m.iter().any(|entry| entry == mount)) } -pub fn add_mount_to_yaml(path: &Path, mount: &str) -> Result<()> { - let content = std::fs::read_to_string(path) - .map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))?; - - let mut config: SandcageConfig = if content.trim().is_empty() { - SandcageConfig::default() - } else { - serde_yaml::from_str(&content) - .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))? - }; - - let mounts = config.mounts.get_or_insert_with(Vec::new); - mounts.push(mount.to_string()); - - let yaml = serde_yaml::to_string(&config) - .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?; - - let output = rehydrate_yaml_comments(&content, &yaml); - - std::fs::write(path, output) - .map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?; - - Ok(()) -} - -fn rehydrate_yaml_comments(original: &str, generated: &str) -> String { - use std::collections::HashMap; - - let mut key_comments: Vec<(Option, Vec)> = Vec::new(); - let mut pending: Vec = Vec::new(); - - for line in original.lines() { - let trimmed = line.trim(); - let is_blank = trimmed.is_empty(); - let is_top_comment = - trimmed.starts_with('#') && !line.starts_with(' ') && !line.starts_with('\t'); - let is_top_level_key = !is_blank - && !is_top_comment - && !line.starts_with(' ') - && !line.starts_with('\t') - && !line.starts_with('-') - && line.contains(':'); - - if is_top_level_key { - let key = line.split(':').next().unwrap().trim().to_string(); - key_comments.push((Some(key), std::mem::take(&mut pending))); - } else if is_blank || is_top_comment { - pending.push(line.to_string()); - } - } - if !pending.is_empty() { - key_comments.push((None, pending)); - } - - let comment_map: HashMap<&str, &[String]> = key_comments - .iter() - .filter_map(|(k, v)| k.as_deref().map(|k| (k, v.as_slice()))) - .collect(); - - let preamble = key_comments - .first() - .and_then(|(k, v)| if k.is_some() { Some(v.as_slice()) } else { None }); - - let trailer = key_comments - .last() - .and_then(|(k, v)| if k.is_none() { Some(v.as_slice()) } else { None }); - - let mut result = String::new(); - let mut first_key = true; - - for line in generated.lines() { - let trimmed = line.trim(); - let is_top_level_key = !trimmed.is_empty() - && !line.starts_with(' ') - && !line.starts_with('\t') - && !line.starts_with('-') - && line.contains(':'); - - if is_top_level_key { - let key = line.split(':').next().unwrap().trim(); - if first_key { - if let Some(pre) = preamble { - for c in pre { - result.push_str(c); - result.push('\n'); - } - } - first_key = false; - } else if let Some(comments) = comment_map.get(key) { - for c in *comments { - result.push_str(c); - result.push('\n'); - } - } - } - result.push_str(line); - result.push('\n'); - } - - if let Some(trail) = trailer { - for c in trail { - result.push_str(c); - result.push('\n'); - } - } - - result -} - pub fn add_mount_to_toml(path: &Path, mount: &str) -> Result<()> { let content = if path.exists() { std::fs::read_to_string(path) @@ -345,54 +236,6 @@ pub fn has_legacy_ssh_mount(config: &SandcageConfig) -> bool { config.ssh_mode.is_none() && check_existing_mount(config, SSH_MOUNT) } -pub fn write_ssh_config_to_yaml(path: &Path, entries: &[SshDiscoveryEntry]) -> Result<()> { - let content = if path.exists() { - std::fs::read_to_string(path) - .map_err(|e| SetupError::ConfigReadFailed(path.to_path_buf(), e))? - } else { - String::new() - }; - - let mut config: SandcageConfig = if content.trim().is_empty() { - SandcageConfig::default() - } else { - serde_yaml::from_str(&content) - .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))? - }; - - config.ssh_mode = Some("volume".to_string()); - config.ssh_keys = Some( - entries - .iter() - .map(|e| SshKeyEntry { - host: e - .ssh_host_alias - .clone() - .unwrap_or_else(|| e.display_host.clone()), - identity_file: e.identity_file.clone().unwrap_or_default(), - }) - .collect(), - ); - - // Remove old bind mount if present - if let Some(ref mut mounts) = config.mounts { - mounts.retain(|m| m != SSH_MOUNT); - if mounts.is_empty() { - config.mounts = None; - } - } - - let yaml = serde_yaml::to_string(&config) - .map_err(|e| SetupError::ConfigParseFailed(path.to_path_buf(), e.to_string()))?; - - let output = rehydrate_yaml_comments(&content, &yaml); - - std::fs::write(path, output) - .map_err(|e| SetupError::ConfigWriteFailed(path.to_path_buf(), e))?; - - Ok(()) -} - pub fn write_ssh_config_to_toml(path: &Path, entries: &[SshDiscoveryEntry]) -> Result<()> { let content = if path.exists() { std::fs::read_to_string(path) @@ -455,7 +298,7 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu } else { let cwd = std::env::current_dir() .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; - cwd.join(".sandcage").join("sandcage.yml") + cwd.join(".sandcage").join("config.toml") }; if config_path_for_check.exists() { @@ -478,31 +321,15 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu .interact() .unwrap_or(false); if !migrate { - // Set explicit bind mode so we don't ask again - if global { - let mut doc: toml_edit::DocumentMut = - std::fs::read_to_string(&config_path_for_check) - .unwrap_or_default() - .parse() - .unwrap_or_default(); - doc["ssh_mode"] = toml_edit::value("bind"); - std::fs::write(&config_path_for_check, doc.to_string()).map_err( - |e| SetupError::ConfigWriteFailed(config_path_for_check, e), - )?; - } else { - let content = std::fs::read_to_string(&config_path_for_check) - .map_err(|e| { - SetupError::ConfigReadFailed(config_path_for_check.clone(), e) - })?; - let mut cfg: SandcageConfig = - serde_yaml::from_str(&content).unwrap_or_default(); - cfg.ssh_mode = Some("bind".to_string()); - let yaml = serde_yaml::to_string(&cfg).unwrap_or_default(); - let output = rehydrate_yaml_comments(&content, &yaml); - std::fs::write(&config_path_for_check, output).map_err(|e| { - SetupError::ConfigWriteFailed(config_path_for_check, e) - })?; - } + let mut doc: toml_edit::DocumentMut = + std::fs::read_to_string(&config_path_for_check) + .unwrap_or_default() + .parse() + .unwrap_or_default(); + doc["ssh_mode"] = toml_edit::value("bind"); + std::fs::write(&config_path_for_check, doc.to_string()).map_err( + |e| SetupError::ConfigWriteFailed(config_path_for_check, e), + )?; eprintln!("sandcage: keeping bind mount, set ssh_mode: bind"); return Ok(()); } @@ -602,7 +429,7 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu } else { let cwd = std::env::current_dir() .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; - (cwd.join(".sandcage").join("local").join("sandcage.local.yml"), ".sandcage/local/sandcage.local.yml", false) + (cwd.join(".sandcage").join("local").join("config.local.toml"), ".sandcage/local/config.local.toml", false) }; // Write config @@ -613,7 +440,7 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu } write_ssh_config_to_toml(&config_path, &selected_entries)?; } else { - write_ssh_config_to_yaml(&config_path, &selected_entries)?; + write_ssh_config_to_toml(&config_path, &selected_entries)?; } // Populate volume @@ -628,13 +455,19 @@ pub fn run_ssh_setup(global: bool, yes: bool, refresh: bool, bind: bool) -> Resu fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> { let home = dirs::home_dir().ok_or(SetupError::NoHomeDir)?; + let global_config = home.join(".sandcage").join("config.toml"); let config_path = if global { - home.join(".sandcage").join("config.toml") + global_config.clone() } else { let cwd = std::env::current_dir() .map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; - cwd.join(".sandcage").join("local").join("sandcage.local.yml") + let local = cwd.join(".sandcage").join("local").join("config.local.toml"); + if local.exists() { + local + } else { + global_config.clone() + } }; if !config_path.exists() { @@ -644,7 +477,7 @@ fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> { )); } - let cfg = if global { + let cfg = { let figment = figment::Figment::from( figment::providers::Serialized::defaults(SandcageConfig::default()), ) @@ -652,9 +485,6 @@ fn run_ssh_refresh(global: bool, ssh_dir: &Path) -> Result<()> { figment .extract::() .map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))? - } else { - crate::config::load(&config_path) - .map_err(|e| SetupError::ConfigParseFailed(config_path.clone(), e.to_string()))? }; let ssh_keys = cfg.ssh_keys.unwrap_or_default(); @@ -727,11 +557,11 @@ fn run_ssh_setup_bind(global: bool, yes: bool) -> Result<()> { } else { let cwd = std::env::current_dir().map_err(|e| SetupError::ReadDirFailed(PathBuf::from("."), e))?; - let project_config = cwd.join(".sandcage").join("sandcage.yml"); + let project_config = cwd.join(".sandcage").join("config.toml"); if !project_config.exists() { return Err(SetupError::NoProjectConfig); } - (project_config, ".sandcage/sandcage.yml".to_string()) + (project_config, ".sandcage/config.toml".to_string()) }; // Check if already configured @@ -783,7 +613,7 @@ fn run_ssh_setup_bind(global: bool, yes: bool) -> Result<()> { } add_mount_to_toml(&config_path, SSH_MOUNT)?; } else { - add_mount_to_yaml(&config_path, SSH_MOUNT)?; + add_mount_to_toml(&config_path, SSH_MOUNT)?; } eprintln!("sandcage: added SSH mount to {config_label}"); @@ -1018,8 +848,7 @@ pub fn populate_ssh_volume( #[cfg(test)] mod tests { use super::*; - use std::io::Write; - use tempfile::{NamedTempFile, TempDir}; + use tempfile::TempDir; fn touch(dir: &Path, name: &str) { std::fs::write(dir.join(name), "").expect("touch file"); @@ -1108,81 +937,6 @@ mod tests { assert!(!check_existing_mount(&config, "~/.ssh:/home/agent/.ssh:ro")); } - // -- add_mount_to_yaml tests -- - - #[test] - fn add_mount_to_yaml_with_existing_mounts() { - let yaml = "env:\n FOO: bar\nmounts:\n - /data:/mnt\n"; - let mut tmp = NamedTempFile::new().unwrap(); - write!(tmp, "{yaml}").unwrap(); - - add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); - - let content = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); - assert!(content.contains("/data:/mnt")); - assert!(content.contains("FOO")); - } - - #[test] - fn add_mount_to_yaml_no_existing_mounts() { - let yaml = "env:\n FOO: bar\n"; - let mut tmp = NamedTempFile::new().unwrap(); - write!(tmp, "{yaml}").unwrap(); - - add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); - - let content = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); - assert!(content.contains("FOO")); - } - - #[test] - fn add_mount_to_yaml_empty_file() { - let mut tmp = NamedTempFile::new().unwrap(); - write!(tmp, "").unwrap(); - - add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); - - let content = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); - } - - #[test] - fn add_mount_to_yaml_preserves_comments() { - let yaml = "# Environment\nenv:\n FOO: bar\n\n# Mount points\nmounts:\n- /data:/mnt\n"; - let mut tmp = NamedTempFile::new().unwrap(); - write!(tmp, "{yaml}").unwrap(); - - add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); - - let content = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(content.contains("# Environment"), "preamble comment preserved"); - assert!(content.contains("# Mount points"), "inline comment preserved"); - assert!(content.contains("~/.ssh:/home/agent/.ssh:ro")); - assert!(content.contains("/data:/mnt")); - } - - #[test] - fn add_mount_to_yaml_preserves_trailing_comments() { - let yaml = "mounts:\n- /data:/mnt\n\n# end of file\n"; - let mut tmp = NamedTempFile::new().unwrap(); - write!(tmp, "{yaml}").unwrap(); - - add_mount_to_yaml(tmp.path(), "~/.ssh:/home/agent/.ssh:ro").unwrap(); - - let content = std::fs::read_to_string(tmp.path()).unwrap(); - assert!(content.contains("# end of file"), "trailing comment preserved"); - } - - #[test] - fn rehydrate_preserves_blank_lines_between_keys() { - let original = "env:\n A: 1\n\nmounts:\n- /data:/mnt\n"; - let generated = "env:\n A: '1'\nmounts:\n- /data:/mnt\n- /new:/new\n"; - let result = rehydrate_yaml_comments(original, generated); - assert!(result.contains("\n\nmounts:"), "blank line before mounts preserved"); - } - // -- add_mount_to_toml tests -- #[test]