Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 94 additions & 10 deletions src/cortex-cli/src/workspace_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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");
Expand All @@ -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);
Expand All @@ -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::<toml_edit::DocumentMut>()?;
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");
Expand Down Expand Up @@ -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
// ==========================================================================
Expand Down
88 changes: 88 additions & 0 deletions src/cortex-cli/tests/workspace_unset.rs
Original file line number Diff line number Diff line change
@@ -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"));
}