From 3e4b8da979e3bedc1f718732be155fd98d2014b8 Mon Sep 17 00:00:00 2001 From: Greyforge Admin Date: Wed, 20 May 2026 00:05:43 -0400 Subject: [PATCH] Add workspace unset command --- src/cortex-cli/src/workspace_cmd.rs | 104 +++++++++++++++++++++--- src/cortex-cli/tests/workspace_unset.rs | 88 ++++++++++++++++++++ 2 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 src/cortex-cli/tests/workspace_unset.rs diff --git a/src/cortex-cli/src/workspace_cmd.rs b/src/cortex-cli/src/workspace_cmd.rs index 1e716c458..844b2dd02 100644 --- a/src/cortex-cli/src/workspace_cmd.rs +++ b/src/cortex-cli/src/workspace_cmd.rs @@ -30,6 +30,9 @@ pub enum WorkspaceSubcommand { /// Set workspace settings Set(WorkspaceSetArgs), + /// Unset (remove) workspace settings + Unset(WorkspaceUnsetArgs), + /// Open workspace configuration in editor Edit(WorkspaceEditArgs), } @@ -64,6 +67,13 @@ pub struct WorkspaceSetArgs { pub value: String, } +/// Arguments for workspace unset command. +#[derive(Debug, Parser)] +pub struct WorkspaceUnsetArgs { + /// Configuration key to remove + pub key: String, +} + /// Arguments for workspace edit command. #[derive(Debug, Parser)] pub struct WorkspaceEditArgs { @@ -104,6 +114,7 @@ impl WorkspaceCli { Some(WorkspaceSubcommand::Show(args)) => run_show(args).await, Some(WorkspaceSubcommand::Init(args)) => run_init(args).await, Some(WorkspaceSubcommand::Set(args)) => run_set(args).await, + Some(WorkspaceSubcommand::Unset(args)) => run_unset(args).await, Some(WorkspaceSubcommand::Edit(args)) => run_edit(args).await, } } @@ -322,6 +333,19 @@ async fn run_init(args: WorkspaceInitArgs) -> Result<()> { Ok(()) } +fn workspace_config_key(key: &str) -> (&str, &str) { + match key { + "model" | "default_model" => ("model", "default"), + "sandbox" | "sandbox_mode" => ("sandbox", "mode"), + "approval" | "approval_mode" => ("approval", "mode"), + k if k.contains('.') => { + let parts: Vec<&str> = k.splitn(2, '.').collect(); + (parts[0], parts[1]) + } + _ => ("", key), + } +} + async fn run_set(args: WorkspaceSetArgs) -> Result<()> { let root = find_workspace_root(); let cortex_dir = root.join(".cortex"); @@ -345,16 +369,7 @@ async fn run_set(args: WorkspaceSetArgs) -> Result<()> { .unwrap_or_else(|_| toml_edit::DocumentMut::new()); // Map common keys to their TOML sections - let (section, actual_key) = match args.key.as_str() { - "model" | "default_model" => ("model", "default"), - "sandbox" | "sandbox_mode" => ("sandbox", "mode"), - "approval" | "approval_mode" => ("approval", "mode"), - k if k.contains('.') => { - let parts: Vec<&str> = k.splitn(2, '.').collect(); - (parts[0], parts[1]) - } - _ => ("", args.key.as_str()), - }; + let (section, actual_key) = workspace_config_key(&args.key); if section.is_empty() { doc[actual_key] = toml_edit::value(&args.value); @@ -373,6 +388,54 @@ async fn run_set(args: WorkspaceSetArgs) -> Result<()> { Ok(()) } +async fn run_unset(args: WorkspaceUnsetArgs) -> Result<()> { + let root = find_workspace_root(); + let cortex_dir = root.join(".cortex"); + let config_path = cortex_dir.join("config.toml"); + + if !config_path.exists() { + bail!( + "No workspace configuration file found at {}", + config_path.display() + ); + } + + let content = std::fs::read_to_string(&config_path)?; + let mut doc = content.parse::()?; + let (section, actual_key) = workspace_config_key(&args.key); + + let removed = if section.is_empty() { + doc.as_table_mut().remove(actual_key).is_some() + } else { + let mut remove_empty_section = false; + let removed = if let Some(table) = doc.get_mut(section).and_then(|item| item.as_table_mut()) + { + let removed = table.remove(actual_key).is_some(); + remove_empty_section = removed && table.iter().next().is_none(); + removed + } else { + false + }; + + if remove_empty_section { + doc.as_table_mut().remove(section); + } + + removed + }; + + if !removed { + bail!("Key '{}' not found in workspace configuration", args.key); + } + + std::fs::write(&config_path, doc.to_string())?; + + println!("Removed key: {}", args.key); + println!("Config saved to: {}", config_path.display()); + + Ok(()) +} + async fn run_edit(args: WorkspaceEditArgs) -> Result<()> { let root = find_workspace_root(); let cortex_dir = root.join(".cortex"); @@ -691,6 +754,27 @@ mod tests { assert_eq!(args.value, "full-access"); } + // ========================================================================== + // WorkspaceUnsetArgs tests + // ========================================================================== + + #[test] + fn test_workspace_unset_args_simple_key() { + let args = WorkspaceUnsetArgs { + key: "model".to_string(), + }; + + assert_eq!(args.key, "model"); + } + + #[test] + fn test_workspace_config_key_maps_aliases() { + assert_eq!(workspace_config_key("model"), ("model", "default")); + assert_eq!(workspace_config_key("sandbox_mode"), ("sandbox", "mode")); + assert_eq!(workspace_config_key("approval.mode"), ("approval", "mode")); + assert_eq!(workspace_config_key("custom_key"), ("", "custom_key")); + } + // ========================================================================== // WorkspaceEditArgs tests // ========================================================================== diff --git a/src/cortex-cli/tests/workspace_unset.rs b/src/cortex-cli/tests/workspace_unset.rs new file mode 100644 index 000000000..80677e5ba --- /dev/null +++ b/src/cortex-cli/tests/workspace_unset.rs @@ -0,0 +1,88 @@ +use std::fs; +use std::process::Command; + +use tempfile::tempdir; + +fn combined_output(output: &std::process::Output) -> String { + format!( + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) +} + +#[test] +fn workspace_unset_removes_keys_from_workspace_config() { + let repo = tempdir().unwrap(); + fs::create_dir(repo.path().join(".git")).unwrap(); + let cortex_dir = repo.path().join(".cortex"); + fs::create_dir_all(&cortex_dir).unwrap(); + let config_path = cortex_dir.join("config.toml"); + fs::write( + &config_path, + r#"remove_me = "gone" +keep_me = "stays" + +[model] +default = "gpt-4o" + +[sandbox] +mode = "workspace-write" +"#, + ) + .unwrap(); + + let model_output = Command::new(env!("CARGO_BIN_EXE_Cortex")) + .args(["workspace", "unset", "model"]) + .current_dir(repo.path()) + .output() + .unwrap(); + assert!( + model_output.status.success(), + "workspace unset model failed:\n{}", + combined_output(&model_output) + ); + + let content = fs::read_to_string(&config_path).unwrap(); + assert!(!content.contains("[model]")); + assert!(!content.contains("default =")); + assert!(content.contains("keep_me = \"stays\"")); + assert!(content.contains("[sandbox]")); + + let top_level_output = Command::new(env!("CARGO_BIN_EXE_Cortex")) + .args(["workspace", "unset", "remove_me"]) + .current_dir(repo.path()) + .output() + .unwrap(); + assert!( + top_level_output.status.success(), + "workspace unset remove_me failed:\n{}", + combined_output(&top_level_output) + ); + + let content = fs::read_to_string(&config_path).unwrap(); + assert!(!content.contains("remove_me")); + assert!(content.contains("keep_me = \"stays\"")); +} + +#[test] +fn workspace_unset_reports_missing_key() { + let repo = tempdir().unwrap(); + fs::create_dir(repo.path().join(".git")).unwrap(); + let cortex_dir = repo.path().join(".cortex"); + fs::create_dir_all(&cortex_dir).unwrap(); + fs::write(cortex_dir.join("config.toml"), "keep_me = \"stays\"\n").unwrap(); + + let output = Command::new(env!("CARGO_BIN_EXE_Cortex")) + .args(["workspace", "unset", "missing"]) + .current_dir(repo.path()) + .output() + .unwrap(); + + assert!( + !output.status.success(), + "workspace unset missing unexpectedly succeeded:\n{}", + combined_output(&output) + ); + assert!(combined_output(&output).contains("Key 'missing' not found")); +}