use super::*;
use crate::config::HooksConfig;
use crate::git::HookType;
use crate::testing::TestRepo;
fn test_repo() -> TestRepo {
TestRepo::new()
}
#[test]
fn test_default_config_path_returns_platform_path() {
let path = default_config_path();
assert!(path.is_some(), "default_config_path should return Some");
let path = path.unwrap();
assert!(
path.ends_with("worktrunk/config.toml") || path.ends_with(r"worktrunk\config.toml"),
"Expected path ending in worktrunk/config.toml, got: {path:?}"
);
}
#[test]
fn test_config_path_falls_through_to_default() {
let default = default_config_path().unwrap();
let resolved = config_path().unwrap();
assert_eq!(
resolved, default,
"config_path() should match default_config_path() when no overrides are set"
);
}
#[test]
fn test_compute_unknown_tree_empty() {
let content = r#"
worktree-path = "../{{ main_worktree }}.{{ branch }}"
"#;
let tree = crate::config::compute_unknown_tree::<UserConfig>(content)
.warn_tree()
.cloned()
.unwrap();
assert!(tree.is_empty(), "expected no unknowns, got {tree:?}");
}
#[test]
fn test_compute_unknown_tree_with_unknown() {
let content = r#"
worktree-path = "../{{ main_worktree }}.{{ branch }}"
unknown-key = "value"
another-unknown = 42
"#;
let tree = crate::config::compute_unknown_tree::<UserConfig>(content)
.warn_tree()
.cloned()
.unwrap();
assert!(tree.keys.contains("unknown-key"));
assert!(tree.keys.contains("another-unknown"));
}
#[test]
fn test_compute_unknown_tree_known_sections() {
let content = r#"
worktree-path = "../{{ main_worktree }}.{{ branch }}"
[list]
full = true
[commit]
stage = "all"
[commit.generation]
command = "llm"
[merge]
squash = true
[step.copy-ignored]
exclude = [".conductor/"]
[post-create]
run = "npm install"
[post-start]
run = "npm run build"
[post-switch]
rename-tab = "echo 'switched'"
"#;
let tree = crate::config::compute_unknown_tree::<UserConfig>(content)
.warn_tree()
.cloned()
.unwrap();
assert!(tree.is_empty());
}
#[test]
fn test_commit_generation_config_is_configured_empty() {
let config = CommitGenerationConfig::default();
assert!(!config.is_configured());
}
#[test]
fn test_commit_generation_config_is_configured_with_command() {
let config = CommitGenerationConfig {
command: Some("llm".to_string()),
..Default::default()
};
assert!(config.is_configured());
}
#[test]
fn test_commit_generation_config_is_configured_with_whitespace_only() {
let config = CommitGenerationConfig {
command: Some(" ".to_string()),
..Default::default()
};
assert!(!config.is_configured());
}
#[test]
fn test_commit_generation_config_is_configured_with_empty_string() {
let config = CommitGenerationConfig {
command: Some("".to_string()),
..Default::default()
};
assert!(!config.is_configured());
}
#[test]
fn test_stage_mode_default() {
assert_eq!(StageMode::default(), StageMode::All);
}
#[test]
fn test_stage_mode_serde() {
let all_json = serde_json::to_string(&StageMode::All).unwrap();
assert_eq!(all_json, "\"all\"");
let tracked_json = serde_json::to_string(&StageMode::Tracked).unwrap();
assert_eq!(tracked_json, "\"tracked\"");
let none_json = serde_json::to_string(&StageMode::None).unwrap();
assert_eq!(none_json, "\"none\"");
let all: StageMode = serde_json::from_str("\"all\"").unwrap();
assert_eq!(all, StageMode::All);
let tracked: StageMode = serde_json::from_str("\"tracked\"").unwrap();
assert_eq!(tracked, StageMode::Tracked);
let none: StageMode = serde_json::from_str("\"none\"").unwrap();
assert_eq!(none, StageMode::None);
}
#[test]
fn test_user_project_config_default() {
let config = UserProjectOverrides::default();
assert!(config.worktree_path.is_none());
assert!(config.approved_commands.is_empty());
}
#[test]
fn test_user_project_config_with_worktree_path_serde() {
let config = UserProjectOverrides {
worktree_path: Some(".worktrees/{{ branch | sanitize }}".to_string()),
approved_commands: vec!["npm install".to_string()],
..Default::default()
};
let toml = toml::to_string(&config).unwrap();
insta::assert_snapshot!(toml, @r#"
approved-commands = ["npm install"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
"#);
let parsed: UserProjectOverrides = toml::from_str(&toml).unwrap();
assert_eq!(
parsed.worktree_path,
Some(".worktrees/{{ branch | sanitize }}".to_string())
);
assert_eq!(parsed.approved_commands, vec!["npm install".to_string()]);
}
#[test]
fn test_worktree_path_for_project_uses_project_specific() {
let mut config = UserConfig::default();
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
worktree_path: Some(".worktrees/{{ branch | sanitize }}".to_string()),
..Default::default()
},
);
assert_eq!(
config.worktree_path_for_project("github.com/user/repo"),
".worktrees/{{ branch | sanitize }}"
);
}
#[test]
fn test_worktree_path_for_project_falls_back_to_global() {
let mut config = UserConfig {
worktree_path: Some("../{{ repo }}-{{ branch | sanitize }}".to_string()),
..Default::default()
};
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
worktree_path: None, approved_commands: vec!["npm install".to_string()],
..Default::default()
},
);
assert_eq!(
config.worktree_path_for_project("github.com/user/repo"),
"../{{ repo }}-{{ branch | sanitize }}"
);
}
#[test]
fn test_worktree_path_for_project_falls_back_to_default() {
let config = UserConfig::default();
assert_eq!(
config.worktree_path_for_project("github.com/unknown/project"),
"{{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}"
);
}
#[test]
fn test_format_path_with_project_override() {
let test = test_repo();
let mut config = UserConfig {
worktree_path: Some("../{{ repo }}.{{ branch | sanitize }}".to_string()),
..Default::default()
};
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
worktree_path: Some(".worktrees/{{ branch | sanitize }}".to_string()),
..Default::default()
},
);
let path = config
.format_path(
"myrepo",
"feature/branch",
&test.repo,
Some("github.com/user/repo"),
)
.unwrap();
assert_eq!(path, ".worktrees/feature-branch");
let path = config
.format_path("myrepo", "feature/branch", &test.repo, None)
.unwrap();
assert_eq!(path, "../myrepo.feature-branch");
}
#[test]
fn test_list_config_serde() {
let config = ListConfig {
full: Some(true),
branches: Some(false),
remotes: None,
summary: None,
task_timeout_ms: Some(500),
timeout_ms: None,
};
let json = serde_json::to_string(&config).unwrap();
let parsed: ListConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.full, Some(true));
assert_eq!(parsed.branches, Some(false));
assert_eq!(parsed.remotes, None);
assert_eq!(parsed.summary, None);
assert_eq!(parsed.task_timeout_ms, Some(500));
assert_eq!(parsed.timeout_ms, None);
}
#[test]
fn test_commit_config_default() {
let config = CommitConfig::default();
assert!(config.stage.is_none());
}
#[test]
fn test_worktrunk_config_default() {
let config = UserConfig::default();
assert!(config.worktree_path.is_none());
assert_eq!(
config.worktree_path(),
"{{ repo_path }}/../{{ repo }}.{{ branch | sanitize }}"
);
assert!(config.projects.is_empty());
assert_eq!(config.list, ListConfig::default());
assert_eq!(config.commit, CommitConfig::default());
assert_eq!(config.merge, MergeConfig::default());
assert!(!config.skip_shell_integration_prompt);
}
#[test]
fn test_worktrunk_config_format_path() {
let test = test_repo();
let config = UserConfig::default();
let path = config
.format_path("myrepo", "feature/branch", &test.repo, None)
.unwrap();
assert!(
path.contains("myrepo.feature-branch"),
"Expected path containing 'myrepo.feature-branch', got: {path}"
);
assert!(
path.contains("/..") || path.contains(r"\.."),
"Expected path containing parent navigation, got: {path}"
);
let repo_path = test.repo.repo_path().unwrap().to_string_lossy();
assert!(
path.starts_with(repo_path.as_ref()),
"Expected path starting with repo path '{repo_path}', got: {path}"
);
}
#[test]
fn test_worktrunk_config_format_path_custom_template() {
let test = test_repo();
let config = UserConfig {
worktree_path: Some(".worktrees/{{ branch }}".to_string()),
..Default::default()
};
let path = config
.format_path("myrepo", "feature", &test.repo, None)
.unwrap();
assert_eq!(path, ".worktrees/feature");
}
#[test]
fn test_worktrunk_config_format_path_repo_path_variable() {
let test = test_repo();
let config = UserConfig {
worktree_path: Some("{{ repo_path }}/worktrees/{{ branch | sanitize }}".to_string()),
..Default::default()
};
let path = config
.format_path("myrepo", "feature/branch", &test.repo, None)
.unwrap();
assert!(
path.contains("worktrees") && path.contains("feature-branch"),
"Expected path containing 'worktrees' and 'feature-branch', got: {path}"
);
let repo_path = test.repo.repo_path().unwrap().to_string_lossy();
assert!(
path.starts_with(repo_path.as_ref()),
"Expected path starting with repo path '{repo_path}', got: {path}"
);
assert!(
std::path::Path::new(&path).is_absolute() || path.starts_with('/'),
"Expected absolute path, got: {path}"
);
}
#[test]
fn test_worktrunk_config_format_path_tilde_expansion() {
let test = test_repo();
let config = UserConfig {
worktree_path: Some("~/worktrees/{{ repo }}/{{ branch | sanitize }}".to_string()),
..Default::default()
};
let path = config
.format_path("myrepo", "feature/branch", &test.repo, None)
.unwrap();
assert!(
!path.starts_with('~'),
"Tilde should be expanded, got: {path}"
);
assert!(
path.contains("worktrees") && path.contains("myrepo") && path.contains("feature-branch"),
"Expected path containing 'worktrees/myrepo/feature-branch', got: {path}"
);
assert!(
std::path::Path::new(&path).is_absolute(),
"Expected absolute path after tilde expansion, got: {path}"
);
}
#[test]
fn test_worktrunk_config_format_path_owner_variable() {
let mut test = TestRepo::with_initial_commit();
test.setup_remote("main");
test.run_git(&[
"remote",
"set-url",
"origin",
"git@github.com:max-sixty/worktrunk.git",
]);
let config = UserConfig {
worktree_path: Some("{{ owner }}/{{ repo }}/{{ branch }}".to_string()),
..Default::default()
};
let path = config
.format_path("myrepo", "feature/branch", &test.repo, None)
.unwrap();
assert_eq!(path, "max-sixty/myrepo/feature/branch");
}
#[test]
fn test_worktrunk_config_format_path_owner_uses_full_namespace() {
let mut test = TestRepo::with_initial_commit();
test.setup_remote("main");
test.run_git(&[
"remote",
"set-url",
"origin",
"git@gitlab.com:group/subgroup/project.git",
]);
let config = UserConfig {
worktree_path: Some("{{ owner }}/{{ repo }}/{{ branch }}".to_string()),
..Default::default()
};
let path = config
.format_path("myrepo", "feature/branch", &test.repo, None)
.unwrap();
assert_eq!(path, "group/subgroup/myrepo/feature/branch");
}
#[test]
fn test_merge_config_serde() {
let config = MergeConfig {
squash: Some(true),
commit: Some(true),
rebase: Some(false),
remove: Some(true),
verify: Some(true),
ff: None,
};
let json = serde_json::to_string(&config).unwrap();
let parsed: MergeConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.squash, Some(true));
assert_eq!(parsed.rebase, Some(false));
}
#[test]
fn test_skip_shell_integration_prompt_default_false() {
let config = UserConfig::default();
assert!(!config.skip_shell_integration_prompt);
}
#[test]
fn test_skip_shell_integration_prompt_serde_roundtrip() {
let config = UserConfig {
skip_shell_integration_prompt: true,
..UserConfig::default()
};
let toml = toml::to_string(&config).unwrap();
assert!(toml.contains("skip-shell-integration-prompt = true"));
let parsed: UserConfig = toml::from_str(&toml).unwrap();
assert!(parsed.skip_shell_integration_prompt);
}
#[test]
fn test_skip_shell_integration_prompt_skipped_when_false() {
let config = UserConfig::default();
let toml = toml::to_string(&config).unwrap();
assert!(!toml.contains("skip-shell-integration-prompt"));
}
#[test]
fn test_skip_shell_integration_prompt_parsed_from_toml() {
let content = r#"
worktree-path = "../{{ main_worktree }}.{{ branch }}"
skip-shell-integration-prompt = true
"#;
let config: UserConfig = toml::from_str(content).unwrap();
assert!(config.skip_shell_integration_prompt);
}
#[test]
fn test_skip_shell_integration_prompt_defaults_when_missing() {
let content = r#"
worktree-path = "../{{ main_worktree }}.{{ branch }}"
"#;
let config: UserConfig = toml::from_str(content).unwrap();
assert!(!config.skip_shell_integration_prompt);
}
#[test]
fn test_set_project_worktree_path() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "# empty config\n").unwrap();
let mut config = UserConfig::default();
config
.set_project_worktree_path(
"github.com/user/repo",
"../{{ branch | sanitize }}".to_string(),
Some(&config_path),
)
.unwrap();
assert_eq!(
config.worktree_path_for_project("github.com/user/repo"),
"../{{ branch | sanitize }}"
);
let content = std::fs::read_to_string(&config_path).unwrap();
assert!(content.contains("[projects.\"github.com/user/repo\"]"));
assert!(content.contains("worktree-path"));
}
#[test]
fn test_merge_list_config() {
let base = ListConfig {
full: Some(true),
branches: Some(false),
remotes: None,
summary: Some(true),
task_timeout_ms: Some(1000),
timeout_ms: Some(2000),
};
let override_config = ListConfig {
full: None, branches: Some(true), remotes: Some(true), summary: None, task_timeout_ms: None, timeout_ms: None, };
let merged = base.merge_with(&override_config);
assert_eq!(merged.full, Some(true)); assert_eq!(merged.branches, Some(true)); assert_eq!(merged.remotes, Some(true)); assert_eq!(merged.summary, Some(true)); assert_eq!(merged.task_timeout_ms, Some(1000)); assert_eq!(merged.timeout_ms, Some(2000)); }
#[test]
fn test_merge_commit_config() {
let base = CommitConfig {
stage: Some(StageMode::All),
generation: None,
};
let override_config = CommitConfig {
stage: Some(StageMode::Tracked),
generation: None,
};
let merged = base.merge_with(&override_config);
assert_eq!(merged.stage, Some(StageMode::Tracked));
}
#[test]
fn test_merge_commit_config_generation_base_only() {
let base = CommitConfig {
stage: None,
generation: Some(CommitGenerationConfig {
command: Some("base-llm".to_string()),
..Default::default()
}),
};
let override_config = CommitConfig {
stage: None,
generation: None,
};
let merged = base.merge_with(&override_config);
assert_eq!(
merged.generation.as_ref().unwrap().command,
Some("base-llm".to_string())
);
}
#[test]
fn test_merge_commit_config_generation_override_only() {
let base = CommitConfig {
stage: None,
generation: None,
};
let override_config = CommitConfig {
stage: None,
generation: Some(CommitGenerationConfig {
command: Some("override-llm".to_string()),
..Default::default()
}),
};
let merged = base.merge_with(&override_config);
assert_eq!(
merged.generation.as_ref().unwrap().command,
Some("override-llm".to_string())
);
}
#[test]
fn test_merge_commit_config_generation_both() {
let base = CommitConfig {
stage: Some(StageMode::All),
generation: Some(CommitGenerationConfig {
command: Some("base-llm".to_string()),
template: Some("base-template".to_string()),
..Default::default()
}),
};
let override_config = CommitConfig {
stage: None, generation: Some(CommitGenerationConfig {
command: Some("override-llm".to_string()), template: None, ..Default::default()
}),
};
let merged = base.merge_with(&override_config);
assert_eq!(merged.stage, Some(StageMode::All));
let generation = merged.generation.as_ref().unwrap();
assert_eq!(generation.command, Some("override-llm".to_string()));
assert_eq!(generation.template, Some("base-template".to_string()));
}
#[test]
fn test_merge_merge_config() {
let base = MergeConfig {
squash: Some(true),
commit: Some(true),
rebase: Some(true),
remove: Some(true),
verify: Some(true),
ff: Some(true),
};
let override_config = MergeConfig {
squash: Some(false), commit: None, rebase: None, remove: Some(false), verify: None, ff: Some(false), };
let merged = base.merge_with(&override_config);
assert_eq!(merged.squash, Some(false));
assert_eq!(merged.commit, Some(true));
assert_eq!(merged.rebase, Some(true));
assert_eq!(merged.remove, Some(false));
assert_eq!(merged.verify, Some(true));
assert_eq!(merged.ff, Some(false));
}
#[test]
fn test_merge_commit_generation_config() {
let base = CommitGenerationConfig {
command: Some("llm -m claude-haiku-4.5".to_string()),
template: None,
template_file: Some("~/.config/template.txt".to_string()),
squash_template: None,
squash_template_file: None,
};
let override_config = CommitGenerationConfig {
command: Some("claude -p --model=haiku".to_string()), template: Some("custom".to_string()), template_file: None, squash_template: None,
squash_template_file: None,
};
let merged = base.merge_with(&override_config);
assert_eq!(merged.command, Some("claude -p --model=haiku".to_string()));
assert_eq!(merged.template, Some("custom".to_string()));
assert_eq!(merged.template_file, None);
}
#[test]
fn test_commit_generation_merge_mutual_exclusivity() {
let global = CommitGenerationConfig {
template_file: Some("~/.config/template.txt".to_string()),
..Default::default()
};
let project = CommitGenerationConfig {
template: Some("inline template".to_string()),
..Default::default()
};
let merged = global.merge_with(&project);
assert_eq!(merged.template, Some("inline template".to_string()));
assert_eq!(merged.template_file, None);
let global = CommitGenerationConfig {
template: Some("global template".to_string()),
..Default::default()
};
let project = CommitGenerationConfig {
template_file: Some("project-file.txt".to_string()),
..Default::default()
};
let merged = global.merge_with(&project);
assert_eq!(merged.template, None); assert_eq!(merged.template_file, Some("project-file.txt".to_string()));
let global = CommitGenerationConfig {
template: Some("global template".to_string()),
..Default::default()
};
let project = CommitGenerationConfig::default();
let merged = global.merge_with(&project);
assert_eq!(merged.template, Some("global template".to_string()));
assert_eq!(merged.template_file, None);
}
#[test]
fn test_commit_generation_merge_squash_template_mutual_exclusivity() {
let global = CommitGenerationConfig {
squash_template_file: Some("~/.config/squash.txt".to_string()),
..Default::default()
};
let project = CommitGenerationConfig {
squash_template: Some("inline squash".to_string()),
..Default::default()
};
let merged = global.merge_with(&project);
assert_eq!(merged.squash_template, Some("inline squash".to_string()));
assert_eq!(merged.squash_template_file, None);
let global = CommitGenerationConfig {
squash_template: Some("global squash".to_string()),
..Default::default()
};
let project = CommitGenerationConfig {
squash_template_file: Some("project-squash.txt".to_string()),
..Default::default()
};
let merged = global.merge_with(&project);
assert_eq!(merged.squash_template, None);
assert_eq!(
merged.squash_template_file,
Some("project-squash.txt".to_string())
);
}
#[test]
fn test_effective_commit_generation_no_project() {
let config = UserConfig {
commit: CommitConfig {
stage: None,
generation: Some(CommitGenerationConfig {
command: Some("global-llm".to_string()),
..Default::default()
}),
},
..Default::default()
};
let effective = config.commit_generation(None);
assert_eq!(effective.command, Some("global-llm".to_string()));
}
#[test]
fn test_effective_commit_generation_with_project_override() {
let mut config = UserConfig {
commit: CommitConfig {
stage: None,
generation: Some(CommitGenerationConfig {
command: Some("global-llm".to_string()),
..Default::default()
}),
},
..Default::default()
};
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
commit: CommitConfig {
stage: None,
generation: Some(CommitGenerationConfig {
command: Some("project-llm".to_string()),
..Default::default()
}),
},
..Default::default()
},
);
let effective = config.commit_generation(Some("github.com/user/repo"));
assert_eq!(effective.command, Some("project-llm".to_string()));
let effective = config.commit_generation(None);
assert_eq!(effective.command, Some("global-llm".to_string()));
let effective = config.commit_generation(Some("github.com/other/repo"));
assert_eq!(effective.command, Some("global-llm".to_string()));
}
#[test]
fn test_effective_merge_with_partial_override() {
let mut config = UserConfig {
merge: MergeConfig {
squash: Some(true),
commit: Some(true),
rebase: Some(true),
remove: Some(true),
verify: Some(true),
ff: Some(true),
},
..Default::default()
};
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
merge: MergeConfig {
squash: Some(false), commit: None,
rebase: None,
remove: None,
verify: None,
ff: None,
},
..Default::default()
},
);
let effective = config.merge(Some("github.com/user/repo"));
assert_eq!(effective.squash, Some(false)); assert_eq!(effective.commit, Some(true)); assert_eq!(effective.rebase, Some(true)); }
#[test]
fn test_effective_list_project_only() {
let mut config = UserConfig::default();
assert_eq!(config.list, ListConfig::default());
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
list: ListConfig {
full: Some(true),
..Default::default()
},
..Default::default()
},
);
let effective = config.list(Some("github.com/user/repo"));
assert_eq!(effective.full, Some(true));
assert!(effective.branches.is_none());
assert_eq!(
config.list(Some("github.com/other/repo")),
ListConfig::default()
);
}
#[test]
fn test_effective_commit_global_only() {
let config = UserConfig {
commit: CommitConfig {
stage: Some(StageMode::Tracked),
generation: None,
},
..Default::default()
};
let effective = config.commit(Some("github.com/any/project"));
assert_eq!(effective.stage, Some(StageMode::Tracked));
}
#[test]
fn test_list_config_accessor_methods_defaults() {
let config = ListConfig::default();
assert!(!config.full());
assert!(!config.branches());
assert!(!config.remotes());
assert!(config.task_timeout().is_none());
assert!(config.timeout().is_none());
}
#[test]
fn test_list_config_accessor_methods_with_values() {
let config = ListConfig {
full: Some(true),
branches: Some(true),
remotes: Some(false),
summary: Some(true),
task_timeout_ms: Some(5000),
timeout_ms: Some(3000),
};
assert!(config.full());
assert!(config.branches());
assert!(!config.remotes());
assert!(config.summary());
assert_eq!(
config.task_timeout(),
Some(std::time::Duration::from_millis(5000))
);
assert_eq!(
config.timeout(),
Some(std::time::Duration::from_millis(3000))
);
}
#[test]
fn test_merge_config_accessor_methods_defaults() {
let config = MergeConfig::default();
assert!(config.squash());
assert!(config.commit());
assert!(config.rebase());
assert!(config.remove());
assert!(config.verify());
assert!(config.ff());
}
#[test]
fn test_merge_config_accessor_methods_with_values() {
let config = MergeConfig {
squash: Some(false),
commit: Some(false),
rebase: Some(false),
remove: Some(false),
verify: Some(false),
ff: Some(false),
};
assert!(!config.squash());
assert!(!config.commit());
assert!(!config.rebase());
assert!(!config.remove());
assert!(!config.verify());
assert!(!config.ff());
}
#[test]
fn test_deprecated_no_ff_migrated_to_ff() {
let config = UserConfig::load_from_str("[merge]\nno-ff = true\n").unwrap();
assert!(!config.merge.ff());
}
#[test]
fn test_deprecated_no_ff_does_not_override_explicit_ff() {
let config = UserConfig::load_from_str("[merge]\nff = true\nno-ff = true\n").unwrap();
assert!(config.merge.ff());
}
#[test]
fn test_commit_config_accessor_methods() {
let config = CommitConfig::default();
assert_eq!(config.stage(), StageMode::All);
let config = CommitConfig {
stage: Some(StageMode::Tracked),
generation: None,
};
assert_eq!(config.stage(), StageMode::Tracked);
}
#[test]
fn test_switch_picker_config_accessor_methods() {
use crate::config::user::SwitchPickerConfig;
let config = SwitchPickerConfig::default();
assert!(config.pager().is_none());
let config = SwitchPickerConfig {
pager: Some("delta --paging=never".to_string()),
};
assert_eq!(config.pager(), Some("delta --paging=never"));
}
#[test]
fn test_switch_picker_config_parse_toml() {
let content = r#"
[switch.picker]
pager = "delta --paging=never"
"#;
let config: UserConfig = toml::from_str(content).unwrap();
let picker = config.switch.picker.as_ref().unwrap();
assert_eq!(picker.pager.as_deref(), Some("delta --paging=never"));
}
#[test]
fn test_switch_picker_merge() {
use crate::config::user::{Merge, SwitchPickerConfig};
let base = SwitchPickerConfig {
pager: Some("delta".to_string()),
};
let override_config = SwitchPickerConfig {
pager: None, };
let merged = base.merge_with(&override_config);
assert_eq!(merged.pager.as_deref(), Some("delta"));
}
#[test]
fn test_switch_config_merge() {
use crate::config::user::{Merge, SwitchConfig, SwitchPickerConfig};
let base = SwitchConfig {
picker: Some(SwitchPickerConfig {
pager: Some("delta".to_string()),
}),
..Default::default()
};
let other = SwitchConfig {
picker: Some(SwitchPickerConfig { pager: None }),
..Default::default()
};
let merged = base.merge_with(&other);
assert_eq!(
merged.picker.as_ref().unwrap().pager.as_deref(),
Some("delta")
);
let other_none = SwitchConfig::default();
let merged = base.merge_with(&other_none);
assert_eq!(
merged.picker.as_ref().unwrap().pager.as_deref(),
Some("delta")
);
let merged = SwitchConfig::default().merge_with(&other_none);
assert!(merged.picker.is_none());
}
#[test]
fn test_switch_config_cd_accessor() {
use crate::config::user::SwitchConfig;
let config = SwitchConfig::default();
assert!(config.cd());
let config = SwitchConfig {
cd: Some(true),
..Default::default()
};
assert!(config.cd());
let config = SwitchConfig {
cd: Some(false),
..Default::default()
};
assert!(!config.cd());
}
#[test]
fn test_switch_config_cd_merge() {
use crate::config::user::{Merge, SwitchConfig};
let base = SwitchConfig {
cd: Some(true),
..Default::default()
};
let other = SwitchConfig {
cd: Some(false),
..Default::default()
};
let merged = base.merge_with(&other);
assert!(!merged.cd());
let base = SwitchConfig {
cd: Some(false),
..Default::default()
};
let merged = base.merge_with(&SwitchConfig::default());
assert!(!merged.cd());
let merged = SwitchConfig::default().merge_with(&SwitchConfig::default());
assert!(merged.cd()); }
#[test]
fn test_switch_config_cd_from_toml() {
let toml = r#"
[switch]
cd = false
"#;
let config = UserConfig::load_from_str(toml).unwrap();
let switch = config.switch(None);
assert!(!switch.cd());
}
#[test]
fn test_switch_config_cd_resolved() {
let toml = r#"
[switch]
cd = false
"#;
let config = UserConfig::load_from_str(toml).unwrap();
let resolved = config.resolved(None);
assert!(!resolved.switch.cd());
}
#[test]
fn test_deprecated_no_cd_migrated_to_cd() {
let config = UserConfig::load_from_str("[switch]\nno-cd = true\n").unwrap();
assert!(!config.switch.cd());
}
#[test]
fn test_deprecated_no_cd_does_not_override_explicit_cd() {
let config = UserConfig::load_from_str("[switch]\ncd = true\nno-cd = true\n").unwrap();
assert!(config.switch.cd());
}
#[test]
fn test_switch_picker_fallback_from_select() {
let config = UserConfig::load_from_str(
r#"
[select]
pager = "bat"
"#,
)
.unwrap();
let picker = config.switch_picker(None);
assert_eq!(picker.pager.as_deref(), Some("bat"));
assert_eq!(
config
.switch
.picker
.as_ref()
.and_then(|picker| picker.pager.as_deref()),
Some("bat")
);
}
#[test]
fn test_switch_picker_prefers_new_over_select() {
let config = UserConfig::load_from_str(
r#"
[switch.picker]
pager = "delta"
[select]
pager = "bat"
"#,
)
.unwrap();
let picker = config.switch_picker(None);
assert_eq!(picker.pager.as_deref(), Some("delta"));
}
#[test]
fn test_switch_picker_project_override() {
use crate::config::user::{SwitchConfig, SwitchPickerConfig};
let mut config = UserConfig {
switch: SwitchConfig {
picker: Some(SwitchPickerConfig {
pager: Some("delta".to_string()),
}),
..Default::default()
},
..Default::default()
};
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
switch: SwitchConfig {
picker: Some(SwitchPickerConfig {
pager: Some("bat".to_string()),
}),
..Default::default()
},
..Default::default()
},
);
let picker = config.switch_picker(Some("github.com/user/repo"));
assert_eq!(picker.pager.as_deref(), Some("bat")); }
#[test]
fn test_switch_picker_project_fallback_from_select() {
let config = UserConfig::load_from_str(
r#"
[switch.picker]
pager = "delta"
[projects."github.com/user/repo".select]
pager = "bat"
"#,
)
.unwrap();
let picker = config.switch_picker(Some("github.com/user/repo"));
assert_eq!(picker.pager.as_deref(), Some("bat"));
assert!(
config
.projects
.get("github.com/user/repo")
.unwrap()
.switch
.picker
.as_ref()
.and_then(|p| p.pager.as_deref())
== Some("bat")
);
}
#[test]
fn test_resolved_config_for_project() {
use crate::config::user::SwitchConfig;
use crate::config::user::SwitchPickerConfig;
let config = UserConfig {
list: ListConfig {
full: Some(true),
..Default::default()
},
merge: MergeConfig {
squash: Some(false),
..Default::default()
},
commit: CommitConfig {
stage: Some(StageMode::None),
..Default::default()
},
switch: SwitchConfig {
picker: Some(SwitchPickerConfig {
pager: Some("less".to_string()),
}),
..Default::default()
},
..Default::default()
};
let resolved = config.resolved(None);
assert!(resolved.list.full());
assert!(!resolved.list.branches()); assert!(!resolved.merge.squash()); assert!(resolved.merge.commit()); assert_eq!(resolved.commit.stage(), StageMode::None);
assert_eq!(resolved.switch_picker.pager(), Some("less"));
assert!(resolved.switch.cd()); }
#[test]
fn test_user_project_config_with_nested_configs_serde() {
let config = UserProjectOverrides {
approved_commands: vec!["npm install".to_string()],
worktree_path: Some(".worktrees/{{ branch }}".to_string()),
list: ListConfig {
full: Some(true),
..Default::default()
},
commit: CommitConfig {
stage: Some(StageMode::Tracked),
generation: Some(CommitGenerationConfig {
command: Some("llm -m gpt-4".to_string()),
..Default::default()
}),
},
merge: MergeConfig {
squash: Some(false),
..Default::default()
},
..Default::default()
};
let toml = toml::to_string(&config).unwrap();
let parsed: UserProjectOverrides = toml::from_str(&toml).unwrap();
assert_eq!(
parsed.worktree_path,
Some(".worktrees/{{ branch }}".to_string())
);
assert_eq!(
parsed.commit.generation.as_ref().unwrap().command,
Some("llm -m gpt-4".to_string())
);
assert_eq!(parsed.list.full, Some(true));
assert_eq!(parsed.commit.stage, Some(StageMode::Tracked));
assert_eq!(parsed.merge.squash, Some(false));
}
#[test]
fn test_full_config_with_per_project_sections_serde() {
let content = r#"
worktree-path = "../{{ repo }}.{{ branch | sanitize }}"
[commit.generation]
command = "llm -m claude-haiku-4.5"
[projects."github.com/user/repo"]
worktree-path = ".worktrees/{{ branch | sanitize }}"
approved-commands = ["npm install"]
[projects."github.com/user/repo".commit.generation]
command = "claude -p --model opus"
[projects."github.com/user/repo".list]
full = true
[projects."github.com/user/repo".merge]
squash = false
"#;
let config: UserConfig = toml::from_str(content).unwrap();
assert_eq!(
config.worktree_path,
Some("../{{ repo }}.{{ branch | sanitize }}".to_string())
);
assert_eq!(
config.commit.generation.as_ref().unwrap().command,
Some("llm -m claude-haiku-4.5".to_string())
);
let project = config.projects.get("github.com/user/repo").unwrap();
assert_eq!(
project.worktree_path,
Some(".worktrees/{{ branch | sanitize }}".to_string())
);
assert_eq!(
project.commit.generation.as_ref().unwrap().command,
Some("claude -p --model opus".to_string())
);
assert_eq!(project.list.full, Some(true));
assert_eq!(project.merge.squash, Some(false));
let effective_cg = config.commit_generation(Some("github.com/user/repo"));
assert_eq!(
effective_cg.command,
Some("claude -p --model opus".to_string())
);
let effective_merge = config.merge(Some("github.com/user/repo"));
assert_eq!(effective_merge.squash, Some(false));
}
#[test]
fn test_copy_ignored_config_merges_global_and_project() {
let project_id = "github.com/user/repo";
let config = UserConfig::load_from_str(
r#"
[step.copy-ignored]
exclude = [".conductor/", ".entire/"]
[projects."github.com/user/repo".step.copy-ignored]
exclude = [".repo-local/", ".entire/"]
"#,
)
.unwrap();
let expected_global = vec![".conductor/".to_string(), ".entire/".to_string()];
let expected_merged = vec![
".conductor/".to_string(),
".entire/".to_string(),
".repo-local/".to_string(),
];
assert_eq!(config.copy_ignored(None).exclude, expected_global);
assert_eq!(
config.copy_ignored(Some(project_id)).exclude,
expected_merged.clone()
);
assert_eq!(
config
.resolved(Some(project_id))
.step
.copy_ignored()
.exclude,
expected_merged
);
}
#[test]
fn test_deprecated_commit_generation_migrated_on_load() {
let content = r#"
[commit-generation]
command = "llm -m claude-haiku-4.5"
[projects."github.com/user/repo".commit-generation]
command = "claude -p --model opus"
"#;
let config = UserConfig::load_from_str(content).unwrap();
assert_eq!(
config
.commit
.generation
.as_ref()
.and_then(|generation| generation.command.as_deref()),
Some("llm -m claude-haiku-4.5")
);
let project = config.projects.get("github.com/user/repo").unwrap();
assert_eq!(
project
.commit
.generation
.as_ref()
.and_then(|generation| generation.command.as_deref()),
Some("claude -p --model opus")
);
let effective_cg = config.commit_generation(Some("github.com/user/repo"));
assert_eq!(
effective_cg.command,
Some("claude -p --model opus".to_string())
);
}
#[test]
fn test_deprecated_commit_generation_with_args_field() {
let content = r#"
[commit-generation]
command = "llm"
args = ["-m", "claude-haiku-4.5"]
"#;
let config = UserConfig::load_from_str(content).unwrap();
assert_eq!(
config
.commit
.generation
.as_ref()
.and_then(|g| g.command.as_deref()),
Some("llm -m claude-haiku-4.5")
);
}
#[test]
fn test_validation_empty_worktree_path() {
let content = r#"worktree-path = """#;
let result = UserConfig::load_from_str(content);
let err = result.unwrap_err().to_string();
insta::assert_snapshot!(err, @"worktree-path cannot be empty");
}
#[test]
fn test_validation_absolute_worktree_path_allowed() {
let content = if cfg!(windows) {
r#"worktree-path = "C:\\worktrees\\{{ branch | sanitize }}""#
} else {
r#"worktree-path = "/worktrees/{{ branch | sanitize }}""#
};
let result = UserConfig::load_from_str(content);
assert!(
result.is_ok(),
"Absolute paths should be allowed: {:?}",
result.err()
);
}
#[test]
fn test_validation_project_empty_worktree_path() {
let content = r#"
[projects."github.com/user/repo"]
worktree-path = ""
"#;
let result = UserConfig::load_from_str(content);
let err = result.unwrap_err().to_string();
insta::assert_snapshot!(err, @"projects.github.com/user/repo.worktree-path cannot be empty");
}
#[test]
fn test_validation_project_absolute_worktree_path_allowed() {
let content = if cfg!(windows) {
r#"
[projects."github.com/user/repo"]
worktree-path = "C:\\worktrees\\{{ branch | sanitize }}"
"#
} else {
r#"
[projects."github.com/user/repo"]
worktree-path = "/worktrees/{{ branch | sanitize }}"
"#
};
let result = UserConfig::load_from_str(content);
assert!(
result.is_ok(),
"Absolute paths should be allowed: {:?}",
result.err()
);
}
#[test]
fn test_validation_template_mutual_exclusivity() {
let cases = [
("[commit-generation]\ntemplate = \"inline\"\ntemplate-file = \"path\""),
("[commit-generation]\nsquash-template = \"inline\"\nsquash-template-file = \"path\""),
("[projects.\"github.com/user/repo\".commit-generation]\ntemplate = \"inline\"\ntemplate-file = \"path\""),
("[projects.\"github.com/user/repo\".commit-generation]\nsquash-template = \"inline\"\nsquash-template-file = \"path\""),
("[commit.generation]\ntemplate = \"inline\"\ntemplate-file = \"path\""),
("[commit.generation]\nsquash-template = \"inline\"\nsquash-template-file = \"path\""),
("[projects.\"github.com/user/repo\".commit.generation]\ntemplate = \"inline\"\ntemplate-file = \"path\""),
("[projects.\"github.com/user/repo\".commit.generation]\nsquash-template = \"inline\"\nsquash-template-file = \"path\""),
];
for content in cases {
let err = UserConfig::load_from_str(content).unwrap_err().to_string();
assert!(
err.contains("mutually exclusive"),
"{content}: expected 'mutually exclusive', got: {err}"
);
}
}
#[test]
fn test_save_to_new_file_with_commit_generation() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let config = UserConfig {
commit: CommitConfig {
stage: None,
generation: Some(CommitGenerationConfig {
command: Some("llm -m haiku".to_string()),
..Default::default()
}),
},
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("[commit.generation]"),
"Should use new format: {saved}"
);
assert!(
saved.contains("command = \"llm -m haiku\""),
"Should contain command: {saved}"
);
assert!(
!saved.contains("[commit]\n"),
"Should not have standalone [commit] header when only generation is set: {saved}"
);
}
#[test]
fn test_save_to_new_file_commit_with_stage_and_generation() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let config = UserConfig {
commit: CommitConfig {
stage: Some(StageMode::Tracked),
generation: Some(CommitGenerationConfig {
command: Some("llm -m haiku".to_string()),
..Default::default()
}),
},
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("[commit]\n"),
"Should have [commit] header when stage is set: {saved}"
);
assert!(
saved.contains("stage = \"tracked\""),
"Should contain stage: {saved}"
);
assert!(
saved.contains("[commit.generation]"),
"Should have generation section: {saved}"
);
}
#[test]
fn test_save_to_new_file_with_skip_shell_integration() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let config = UserConfig {
skip_shell_integration_prompt: true,
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("skip-shell-integration-prompt = true"),
"Should contain flag: {saved}"
);
}
#[test]
fn test_save_to_new_file_with_worktree_path() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let config = UserConfig {
worktree_path: Some("../{{ repo }}.{{ branch }}".to_string()),
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("worktree-path = \"../{{ repo }}.{{ branch }}\""),
"Should contain worktree-path: {saved}"
);
}
#[test]
fn test_save_to_preserves_project_section_configs() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let initial = r#"
[projects."github.com/user/repo"]
worktree-path = ".wt/{{ branch | sanitize }}"
"#;
std::fs::write(&config_path, initial).unwrap();
let mut config = UserConfig::default();
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
worktree_path: Some(".wt/{{ branch | sanitize }}".to_string()),
merge: MergeConfig {
squash: Some(false),
..Default::default()
},
list: ListConfig {
full: Some(true),
..Default::default()
},
..Default::default()
},
);
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("squash = false"),
"Should serialize merge config: {saved}"
);
assert!(
saved.contains("full = true"),
"Should serialize list config: {saved}"
);
assert!(
!saved.contains("[projects.\"github.com/user/repo\".commit]"),
"Default commit section should not appear: {saved}"
);
assert!(
!saved.contains("[projects.\"github.com/user/repo\".switch]"),
"Default switch section should not appear: {saved}"
);
}
#[test]
fn test_save_to_removes_default_project_section() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[projects.\"github.com/u/r\".list]\nfull = true\n",
)
.unwrap();
let mut config =
UserConfig::load_from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
config.projects.get_mut("github.com/u/r").unwrap().list = ListConfig::default();
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
!saved.contains("[projects.\"github.com/u/r\".list]"),
"Default section should be removed: {saved}"
);
}
fn parse_hooks(toml_str: &str) -> HooksConfig {
toml::from_str(toml_str).unwrap()
}
#[test]
fn test_hooks_merge_append_semantics() {
let mut config = UserConfig {
hooks: parse_hooks("post-start = \"echo global\""),
..Default::default()
};
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
hooks: parse_hooks("post-start = \"echo project\""),
..Default::default()
},
);
let effective = config.hooks(Some("github.com/user/repo"));
let post_start = effective.post_start.unwrap();
let commands: Vec<_> = post_start.commands().collect();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].template, "echo global");
assert_eq!(commands[1].template, "echo project");
}
#[test]
fn test_hooks_no_project_override_uses_global() {
let config = UserConfig {
hooks: parse_hooks("post-start = \"echo global\""),
..Default::default()
};
let effective = config.hooks(Some("github.com/other/repo"));
let post_start = effective.post_start.unwrap();
let commands: Vec<_> = post_start.commands().collect();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].template, "echo global");
}
#[test]
fn test_hooks_project_only_no_global() {
let mut config = UserConfig::default();
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
hooks: parse_hooks("post-start = \"echo project\""),
..Default::default()
},
);
let effective = config.hooks(Some("github.com/user/repo"));
let post_start = effective.post_start.unwrap();
let commands: Vec<_> = post_start.commands().collect();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].template, "echo project");
}
#[test]
fn test_hooks_different_hook_types_not_merged() {
let mut config = UserConfig {
hooks: parse_hooks("post-start = \"echo global-start\""),
..Default::default()
};
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
hooks: parse_hooks("pre-commit = \"echo project-commit\""),
..Default::default()
},
);
let effective = config.hooks(Some("github.com/user/repo"));
let post_start = effective.post_start.unwrap();
let start_commands: Vec<_> = post_start.commands().collect();
assert_eq!(start_commands.len(), 1);
assert_eq!(start_commands[0].template, "echo global-start");
let pre_commit = effective.pre_commit.unwrap();
let commit_commands: Vec<_> = pre_commit.commands().collect();
assert_eq!(commit_commands.len(), 1);
assert_eq!(commit_commands[0].template, "echo project-commit");
}
#[test]
fn test_hooks_none_project_uses_global() {
let config = UserConfig {
hooks: parse_hooks("post-start = \"echo global\""),
..Default::default()
};
let effective = config.hooks(None);
let post_start = effective.post_start.unwrap();
let commands: Vec<_> = post_start.commands().collect();
assert_eq!(commands.len(), 1);
assert_eq!(commands[0].template, "echo global");
}
#[test]
fn test_valid_user_config_keys_includes_all_hook_types() {
use strum::IntoEnumIterator;
let valid_keys = valid_user_config_keys();
for hook_type in HookType::iter() {
let key = hook_type.to_string(); assert!(
valid_keys.contains(&key),
"HookType::{hook_type:?} ({key}) is missing from valid_user_config_keys()"
);
}
}
#[test]
fn test_valid_user_config_keys_all_deserialize() {
let valid_keys = valid_user_config_keys();
let mut scalar_lines = Vec::new();
let mut table_lines = Vec::new();
for key in &valid_keys {
match key.as_str() {
"projects" => continue, "skip-shell-integration-prompt" | "skip-commit-generation-prompt" => {
scalar_lines.push(format!("{key} = true"));
}
"worktree-path" => {
scalar_lines.push(format!("{key} = \"test-value\""));
}
"list" | "commit" | "merge" | "switch" | "step" | "select" | "commit-generation"
| "aliases" => {
table_lines.push(format!("[{key}]"));
}
_ => {
scalar_lines.push(format!("{key} = \"test-value\""));
}
};
}
scalar_lines.extend(table_lines);
let toml_content = scalar_lines.join("\n");
let result: Result<UserConfig, _> = toml::from_str(&toml_content);
assert!(
result.is_ok(),
"Failed to deserialize config with all valid keys:\n{toml_content}\nError: {:?}",
result.err()
);
}
#[test]
fn test_hooks_merge_mixed_formats_preserves_order() {
let global_hooks = parse_hooks(r#"post-start = "npm install""#);
let project_hooks = parse_hooks(
r#"
[post-start]
setup = "echo setup"
"#,
);
let mut config = UserConfig {
hooks: global_hooks,
..Default::default()
};
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
hooks: project_hooks,
..Default::default()
},
);
let effective = config.hooks(Some("github.com/user/repo"));
let commands: Vec<_> = effective.post_start.as_ref().unwrap().commands().collect();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].template, "npm install"); assert_eq!(commands[1].template, "echo setup"); }
#[test]
fn test_hooks_merge_same_names_both_run() {
let global_hooks = parse_hooks(
r#"
[post-start]
test = "cargo test"
"#,
);
let project_hooks = parse_hooks(
r#"
[post-start]
test = "npm test"
"#,
);
let mut config = UserConfig {
hooks: global_hooks,
..Default::default()
};
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
hooks: project_hooks,
..Default::default()
},
);
let effective = config.hooks(Some("github.com/user/repo"));
let commands: Vec<_> = effective.post_start.as_ref().unwrap().commands().collect();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].template, "cargo test");
assert_eq!(commands[1].template, "npm test");
}
#[test]
fn test_reload_from_invalid_toml() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "# Valid config\n").unwrap();
std::fs::write(&config_path, "this is not valid toml [[[").unwrap();
let mut config = UserConfig::default();
let result = config.set_skip_shell_integration_prompt(Some(&config_path));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Failed to parse config file"),
"Expected parse error, got: {err}"
);
assert!(
err.contains("config.toml"),
"Expected path in error, got: {err}"
);
}
#[test]
fn test_system_config_merged_with_user_config() {
let system_toml = r#"
[merge]
squash = false
rebase = false
[list]
full = true
"#;
let user_toml = r#"
[merge]
squash = true
"#;
let system_config = UserConfig::load_from_str(system_toml).unwrap();
let user_config = UserConfig::load_from_str(user_toml).unwrap();
assert_eq!(system_config.merge.squash, Some(false));
assert_eq!(system_config.merge.rebase, Some(false));
assert_eq!(system_config.list.full, Some(true));
assert_eq!(user_config.merge.squash, Some(true));
let merged = system_config.merge.merge_with(&user_config.merge);
assert_eq!(merged.squash, Some(true)); assert_eq!(merged.rebase, Some(false)); }
#[test]
fn test_system_config_worktree_path_overridden_by_user() {
let system_toml = r#"worktree-path = "/company/worktrees/{{ repo }}/{{ branch | sanitize }}""#;
let user_toml = r#"worktree-path = "../{{ repo }}.{{ branch | sanitize }}""#;
let system_config = UserConfig::load_from_str(system_toml).unwrap();
let user_config = UserConfig::load_from_str(user_toml).unwrap();
assert_eq!(
system_config.worktree_path(),
"/company/worktrees/{{ repo }}/{{ branch | sanitize }}"
);
assert_eq!(
user_config.worktree_path(),
"../{{ repo }}.{{ branch | sanitize }}"
);
}
#[test]
fn test_system_config_commit_generation_merged() {
let system_toml = r#"
[commit.generation]
command = "company-llm-tool"
template = "Company standard template: {{ git_diff }}"
"#;
let user_toml = r#"
[commit.generation]
command = "my-preferred-llm"
"#;
let system_config = UserConfig::load_from_str(system_toml).unwrap();
let user_config = UserConfig::load_from_str(user_toml).unwrap();
let system_gen = system_config.commit_generation(None);
assert_eq!(system_gen.command, Some("company-llm-tool".to_string()));
assert_eq!(
system_gen.template,
Some("Company standard template: {{ git_diff }}".to_string())
);
let user_gen = user_config.commit_generation(None);
assert_eq!(user_gen.command, Some("my-preferred-llm".to_string()));
}
#[test]
fn test_hooks_merge_trait_appends_for_global_project_merge() {
let global_hooks = parse_hooks("pre-merge = \"global-lint\"");
let project_hooks = parse_hooks("pre-merge = \"project-lint\"");
let merged = global_hooks.merge_with(&project_hooks);
let pre_merge = merged.pre_merge.unwrap();
let commands: Vec<_> = pre_merge.commands().collect();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].template, "global-lint"); assert_eq!(commands[1].template, "project-lint"); }
#[test]
fn test_hooks_merge_folds_post_create_into_pre_start() {
let user_hooks = parse_hooks("post-create = \"npm install\"");
let project_hooks = parse_hooks("pre-start = \"cargo test\"");
let merged = user_hooks.merge_with(&project_hooks);
let pre_start = merged
.get(HookType::PreStart)
.expect("should have pre-start");
let commands: Vec<_> = pre_start.commands().collect();
assert_eq!(commands.len(), 2, "Both hooks should be present");
assert_eq!(commands[0].template, "npm install"); assert_eq!(commands[1].template, "cargo test"); }
#[test]
fn test_hooks_merge_same_source_both_pre_start_and_post_create() {
let both = parse_hooks("pre-start = \"npm install\"\npost-create = \"cargo build\"");
let empty = HooksConfig::default();
let merged = both.merge_with(&empty);
let pre_start = merged
.get(HookType::PreStart)
.expect("should have pre-start");
let commands: Vec<_> = pre_start.commands().collect();
assert_eq!(
commands.len(),
2,
"Both commands from same source should be present"
);
assert_eq!(commands[0].template, "npm install"); assert_eq!(commands[1].template, "cargo build"); }
#[test]
fn test_hooks_merge_post_create_both_sides() {
let global = parse_hooks("post-create = \"npm install\"");
let project = parse_hooks("post-create = \"cargo build\"");
let merged = global.merge_with(&project);
let pre_start = merged
.get(HookType::PreStart)
.expect("should have pre-start");
let commands: Vec<_> = pre_start.commands().collect();
assert_eq!(commands.len(), 2);
assert_eq!(commands[0].template, "npm install");
assert_eq!(commands[1].template, "cargo build");
}
#[test]
fn test_aliases_accessor_appends_on_collision() {
let toml_str = r#"
[aliases]
shared = "global-cmd"
global-only = "only-global"
[projects."test-project".aliases]
shared = "project-cmd"
project-only = "only-project"
"#;
let config: UserConfig = toml::from_str(toml_str).unwrap();
let aliases = config.aliases(Some("test-project"));
assert_eq!(aliases["global-only"].commands().count(), 1);
assert_eq!(
aliases["global-only"].commands().next().unwrap().template,
"only-global"
);
assert_eq!(aliases["project-only"].commands().count(), 1);
assert_eq!(
aliases["project-only"].commands().next().unwrap().template,
"only-project"
);
let shared: Vec<_> = aliases["shared"].commands().collect();
assert_eq!(shared.len(), 2);
assert_eq!(shared[0].template, "global-cmd");
assert_eq!(shared[1].template, "project-cmd");
let global_only = config.aliases(None);
assert_eq!(global_only["shared"].commands().count(), 1);
assert_eq!(
global_only["shared"].commands().next().unwrap().template,
"global-cmd"
);
}
#[cfg(unix)]
#[test]
fn test_reload_from_permission_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "[projects]\n").unwrap();
let mut perms = std::fs::metadata(&config_path).unwrap().permissions();
perms.set_mode(0o000); std::fs::set_permissions(&config_path, perms).unwrap();
struct RestorePerms<'a>(&'a std::path::Path);
impl Drop for RestorePerms<'_> {
fn drop(&mut self) {
let mut perms = std::fs::metadata(self.0).unwrap().permissions();
perms.set_mode(0o644);
let _ = std::fs::set_permissions(self.0, perms);
}
}
let _guard = RestorePerms(&config_path);
if std::env::var("USER").as_deref() == Ok("root") {
return;
}
let mut config = UserConfig::default();
let result = config.set_skip_shell_integration_prompt(Some(&config_path));
assert!(result.is_err());
let err = result.unwrap_err().to_string();
assert!(
err.contains("Failed to read config file"),
"Expected read error, got: {err}"
);
assert!(
err.contains("config.toml"),
"Expected path in error, got: {err}"
);
}
#[test]
fn test_load_error_display_file() {
let toml_err = toml::from_str::<UserConfig>("[list]\nbranches = \"bad\"\n").unwrap_err();
let err = LoadError::File {
path: std::path::PathBuf::from("/tmp/config.toml"),
label: "User config",
err: Box::new(toml_err),
};
let msg = err.to_string();
assert!(msg.contains("User config at"), "{msg}");
assert!(msg.contains("failed to parse"), "{msg}");
assert!(msg.contains("line 2"), "{msg}");
}
#[test]
fn test_load_error_display_env() {
let err = LoadError::Env {
err: "invalid type".into(),
vars: vec![("WORKTRUNK__LIST__BRANCHES".into(), "not-a-bool".into())],
};
assert_eq!(err.to_string(), "invalid type");
}
#[test]
fn test_load_error_display_validation() {
let err = LoadError::Validation("bad".into());
assert_eq!(err.to_string(), "bad");
}
#[test]
fn test_try_parse_value() {
use super::try_parse_value;
assert_eq!(try_parse_value("true"), toml::Value::Boolean(true));
assert_eq!(try_parse_value("TRUE"), toml::Value::Boolean(true));
assert_eq!(try_parse_value("false"), toml::Value::Boolean(false));
assert_eq!(try_parse_value("42"), toml::Value::Integer(42));
assert_eq!(try_parse_value("0"), toml::Value::Integer(0));
assert_eq!(try_parse_value("1.5"), toml::Value::Float(1.5));
assert_eq!(
try_parse_value("hello"),
toml::Value::String("hello".into())
);
}
#[test]
fn test_finalize_with_undeserializable_table() {
let mut table = toml::Table::new();
table.insert("list".into(), toml::Value::String("not-a-table".into()));
let (config, warnings) = UserConfig::finalize(table, Vec::new());
assert_eq!(config.worktree_path, None); assert_eq!(warnings.len(), 1);
assert!(matches!(&warnings[0], LoadError::Validation(_)));
}
#[test]
fn test_save_to_existing_file_writes_project_sections() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "# user config\n").unwrap();
let mut config = UserConfig::default();
config.projects.insert(
"github.com/user/repo".to_string(),
UserProjectOverrides {
worktree_path: Some("../{{ branch | sanitize }}".to_string()),
list: ListConfig {
full: Some(true),
..Default::default()
},
commit: CommitConfig {
stage: Some(StageMode::Tracked),
generation: None,
},
merge: MergeConfig {
squash: Some(false),
..Default::default()
},
switch: SwitchConfig {
cd: Some(false),
picker: None,
},
..Default::default()
},
);
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(saved.contains("# user config"), "comment lost: {saved}");
assert!(
saved.contains("[projects.\"github.com/user/repo\".list]"),
"missing list section: {saved}"
);
assert!(saved.contains("full = true"), "missing list.full: {saved}");
assert!(
saved.contains("[projects.\"github.com/user/repo\".commit]"),
"missing commit section: {saved}"
);
assert!(
saved.contains("stage = \"tracked\""),
"missing commit.stage: {saved}"
);
assert!(
saved.contains("[projects.\"github.com/user/repo\".merge]"),
"missing merge section: {saved}"
);
assert!(
saved.contains("squash = false"),
"missing merge.squash: {saved}"
);
assert!(
saved.contains("[projects.\"github.com/user/repo\".switch]"),
"missing switch section: {saved}"
);
assert!(saved.contains("cd = false"), "missing switch.cd: {saved}");
let reparsed = UserConfig::load_from_str(&saved).unwrap();
let reloaded = reparsed.projects.get("github.com/user/repo").unwrap();
assert_eq!(
reloaded.worktree_path.as_deref(),
Some("../{{ branch | sanitize }}")
);
assert_eq!(reloaded.list.full, Some(true));
assert_eq!(reloaded.commit.stage, Some(StageMode::Tracked));
assert_eq!(reloaded.merge.squash, Some(false));
assert_eq!(reloaded.switch.cd, Some(false));
}
#[test]
fn test_save_to_existing_file_removes_stale_projects_and_sections() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"# keep me
[projects."keep"]
worktree-path = "keep-path"
[projects."keep".list]
full = true
[projects."drop"]
worktree-path = "drop-path"
"#,
)
.unwrap();
let mut config = UserConfig::default();
config.projects.insert(
"keep".to_string(),
UserProjectOverrides {
worktree_path: Some("keep-path".to_string()),
list: ListConfig::default(), ..Default::default()
},
);
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(saved.contains("# keep me"), "comment lost: {saved}");
assert!(
saved.contains("[projects.\"keep\"]") || saved.contains("\"keep\""),
"keep project lost: {saved}"
);
assert!(
!saved.contains("\"drop\""),
"stale project not removed: {saved}"
);
assert!(
!saved.contains("[projects.\"keep\".list]"),
"stale list section not removed: {saved}"
);
}
#[test]
fn test_save_to_existing_file_updates_commit_generation_command() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"# keep this comment
[commit.generation]
command = "old-llm"
template = "stays: {{ diff }}"
"#,
)
.unwrap();
let config = UserConfig {
commit: CommitConfig {
stage: None,
generation: Some(CommitGenerationConfig {
command: Some("new-llm".to_string()),
template: Some("stays: {{ diff }}".to_string()),
..Default::default()
}),
},
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("# keep this comment"),
"comment lost: {saved}"
);
assert!(
saved.contains("command = \"new-llm\""),
"command not updated: {saved}"
);
assert!(
!saved.contains("old-llm"),
"old command not removed: {saved}"
);
assert!(
saved.contains("template = \"stays: {{ diff }}\""),
"template not preserved: {saved}"
);
}
#[test]
fn test_save_to_existing_file_adds_commit_generation_to_plain_commit_table() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"[commit]
stage = "all"
"#,
)
.unwrap();
let config = UserConfig {
commit: CommitConfig {
stage: Some(StageMode::All),
generation: Some(CommitGenerationConfig {
command: Some("llm".to_string()),
..Default::default()
}),
},
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("[commit.generation]"),
"generation subtable missing: {saved}"
);
assert!(
saved.contains("command = \"llm\""),
"command missing: {saved}"
);
assert!(saved.contains("stage = \"all\""), "stage lost: {saved}");
}
#[test]
fn test_save_to_existing_file_replaces_non_table_project_entry() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"[projects]
bogus = "not-a-table"
[projects."real"]
worktree-path = "old"
"#,
)
.unwrap();
let mut config = UserConfig::default();
config
.projects
.insert("bogus".to_string(), UserProjectOverrides::default());
config.projects.insert(
"real".to_string(),
UserProjectOverrides {
worktree_path: Some("new".to_string()),
..Default::default()
},
);
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("worktree-path = \"new\""),
"real project not updated: {saved}"
);
assert!(
!saved.contains("bogus = \"not-a-table\""),
"malformed entry should be replaced: {saved}"
);
}
#[test]
fn test_save_to_existing_file_where_commit_is_scalar() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "commit = \"hand-edited-mistake\"\n").unwrap();
let config = UserConfig {
commit: CommitConfig {
stage: None,
generation: Some(CommitGenerationConfig {
command: Some("llm".to_string()),
..Default::default()
}),
},
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
!saved.contains("\"hand-edited-mistake\""),
"malformed entry should be replaced: {saved}"
);
assert!(
saved.contains("command = \"llm\""),
"commit generation should be written: {saved}"
);
}
#[test]
fn test_save_to_existing_file_where_commit_generation_is_scalar() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[commit]\nstage = \"tracked\"\ngeneration = \"oops\"\n",
)
.unwrap();
let config = UserConfig {
commit: CommitConfig {
stage: Some(StageMode::Tracked),
generation: Some(CommitGenerationConfig {
command: Some("llm".to_string()),
..Default::default()
}),
},
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
!saved.contains("generation = \"oops\""),
"malformed generation should be replaced: {saved}"
);
assert!(
saved.contains("command = \"llm\""),
"generation command should be written: {saved}"
);
assert!(saved.contains("stage = \"tracked\""), "stage lost: {saved}");
}
#[test]
fn test_save_to_existing_file_where_projects_is_scalar() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "projects = \"oops\"\n").unwrap();
let mut config = UserConfig::default();
config.projects.insert(
"repo".to_string(),
UserProjectOverrides {
worktree_path: Some("../x".to_string()),
..Default::default()
},
);
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
!saved.contains("projects = \"oops\""),
"malformed projects should be replaced: {saved}"
);
assert!(
saved.contains("worktree-path = \"../x\""),
"project worktree-path should be written: {saved}"
);
}
#[test]
fn test_save_to_existing_file_with_invalid_toml_returns_parse_error() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "this is not [[[ valid toml").unwrap();
let config = UserConfig::default();
let err = config.save_to(&config_path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Failed to parse config file"),
"expected parse error, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn test_save_to_existing_file_with_unreadable_file_returns_read_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "# valid\n").unwrap();
let mut perms = std::fs::metadata(&config_path).unwrap().permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&config_path, perms).unwrap();
struct RestorePerms<'a>(&'a std::path::Path);
impl Drop for RestorePerms<'_> {
fn drop(&mut self) {
let mut perms = std::fs::metadata(self.0).unwrap().permissions();
perms.set_mode(0o644);
let _ = std::fs::set_permissions(self.0, perms);
}
}
let _guard = RestorePerms(&config_path);
if std::env::var("USER").as_deref() == Ok("root") {
return;
}
let config = UserConfig::default();
let err = config.save_to(&config_path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Failed to read config file"),
"expected read error, got: {msg}"
);
}
#[test]
fn test_save_to_root_path_skips_parent_creation() {
let config = UserConfig::default();
let err = config.save_to(std::path::Path::new("/")).unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("Failed to create config directory"),
"should skip create_dir when parent is None, got: {msg}"
);
}
#[test]
fn test_save_to_fails_when_parent_is_a_file() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, "i am a file").unwrap();
let config_path = blocker.join("config.toml");
let config = UserConfig::default();
let err = config.save_to(&config_path).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Failed to create config directory"),
"expected create_dir error, got: {msg}"
);
}
#[test]
fn test_save_to_new_file_expands_nested_project_inline_tables() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let mut config = UserConfig::default();
config.projects.insert(
"repo".to_string(),
UserProjectOverrides {
list: ListConfig {
full: Some(true),
branches: Some(true),
..Default::default()
},
switch: SwitchConfig {
cd: Some(false),
picker: None,
},
..Default::default()
},
);
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("[projects.repo.list]"),
"list should be expanded to standard subtable: {saved}"
);
assert!(
saved.contains("[projects.repo.switch]"),
"switch should be expanded to standard subtable: {saved}"
);
assert!(
!saved.contains("list = {"),
"list should not be inline: {saved}"
);
assert!(
!saved.contains("switch = {"),
"switch should not be inline: {saved}"
);
let reparsed = UserConfig::load_from_str(&saved).unwrap();
assert_eq!(
reparsed.projects.get("repo").unwrap().list.branches,
Some(true)
);
}
#[test]
fn test_save_to_existing_file_preserves_integer_and_array_values() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"# keep comment
[list]
timeout-ms = 5000
full = true
[projects."repo"]
approved-commands = ["cargo test", "cargo build"]
"#,
)
.unwrap();
let config =
UserConfig::load_from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(saved.contains("# keep comment"), "comment lost: {saved}");
assert!(
saved.contains("timeout-ms = 5000"),
"integer value should be preserved: {saved}"
);
assert!(
saved.contains("full = true"),
"boolean value should be preserved: {saved}"
);
assert!(
saved.contains("cargo test") && saved.contains("cargo build"),
"array values should be preserved: {saved}"
);
}
#[test]
fn test_save_to_existing_file_replaces_changed_inline_table() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "post-start = { build = \"cargo build\" }\n").unwrap();
let mut config =
UserConfig::load_from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
config.hooks = toml::from_str("post-start = { build = \"cargo test\" }").unwrap();
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("cargo test"),
"changed value should be written: {saved}"
);
assert!(
!saved.contains("cargo build"),
"old value should be gone: {saved}"
);
}
#[test]
fn test_save_to_existing_file_preserves_unknown_keys() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"# A user comment
unknown-key = "keep me"
skip-shell-integration-prompt = true
"#,
)
.unwrap();
let config = UserConfig {
skip_shell_integration_prompt: true,
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("unknown-key = \"keep me\""),
"unknown key should be preserved: {saved}"
);
assert!(
saved.contains("# A user comment"),
"comment should be preserved: {saved}"
);
assert!(
saved.contains("skip-shell-integration-prompt = true"),
"known key should be preserved: {saved}"
);
}
#[test]
fn test_save_to_existing_file_preserves_nested_unknown_keys() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"[merge]
squash = false
future-option = true
"#,
)
.unwrap();
let config = UserConfig {
skip_shell_integration_prompt: true,
merge: MergeConfig {
squash: Some(false),
..Default::default()
},
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("future-option = true"),
"nested unknown key should be preserved: {saved}"
);
assert!(
saved.contains("squash = false"),
"known sibling should be preserved: {saved}"
);
assert!(
saved.contains("skip-shell-integration-prompt = true"),
"new top-level key should be written: {saved}"
);
}
#[test]
fn test_save_to_existing_file_preserves_section_with_only_unknown_fields() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"[merge]
future-option = true
"#,
)
.unwrap();
let config = UserConfig {
merge: MergeConfig {
squash: Some(false),
..Default::default()
},
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("future-option = true"),
"unknown key in otherwise-empty section should be preserved: {saved}"
);
assert!(
saved.contains("squash = false"),
"new known field should be written: {saved}"
);
}
#[test]
fn test_save_to_existing_file_preserves_deeply_nested_unknown_keys() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"[commit.generation]
command = "old-llm"
future-knob = "from-newer-wt"
"#,
)
.unwrap();
let config = UserConfig {
commit: CommitConfig {
stage: None,
generation: Some(CommitGenerationConfig {
command: Some("new-llm".to_string()),
..Default::default()
}),
},
..Default::default()
};
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains(r#"future-knob = "from-newer-wt""#),
"nested unknown key should be preserved: {saved}"
);
assert!(
saved.contains(r#"command = "new-llm""#),
"known field should be updated: {saved}"
);
assert!(!saved.contains("old-llm"), "old value not removed: {saved}");
}
#[test]
fn test_save_to_existing_file_preserves_unknown_keys_in_project_section() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
r#"[projects."repo"]
worktree-path = "../custom"
future-per-project = "value"
"#,
)
.unwrap();
let mut config = UserConfig::default();
config.projects.insert(
"repo".to_string(),
UserProjectOverrides {
worktree_path: Some("../custom".to_string()),
..Default::default()
},
);
config.skip_shell_integration_prompt = true;
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains(r#"future-per-project = "value""#),
"unknown key inside a project entry should be preserved: {saved}"
);
assert!(
saved.contains(r#"worktree-path = "../custom""#),
"known field should be preserved: {saved}"
);
}
#[test]
fn test_save_to_existing_file_preserves_inline_table_formatting() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
let original = "post-start = { build = \"cargo build\" }\n";
std::fs::write(&config_path, original).unwrap();
let config = UserConfig::load_from_str(original).unwrap();
config.save_to(&config_path).unwrap();
let saved = std::fs::read_to_string(&config_path).unwrap();
assert!(
saved.contains("post-start = { build = \"cargo build\" }"),
"inline table should be preserved: {saved}"
);
assert!(
!saved.contains("[post-start]"),
"should not be expanded to standard table: {saved}"
);
}
#[test]
fn test_set_project_worktree_path_noop_when_unchanged() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "# keep\n").unwrap();
let mut config = UserConfig::default();
config
.set_project_worktree_path("user/repo", "../custom".to_string(), Some(&config_path))
.unwrap();
let after_first = std::fs::read_to_string(&config_path).unwrap();
assert!(after_first.contains("../custom"), "{after_first}");
let mut config2 = UserConfig::default();
config2
.set_project_worktree_path("user/repo", "../custom".to_string(), Some(&config_path))
.unwrap();
let after_second = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(
after_first, after_second,
"unchanged value should not rewrite the file"
);
}
#[test]
fn test_set_skip_shell_integration_prompt_noop_on_second_call() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "# empty\n").unwrap();
let mut config = UserConfig::default();
config
.set_skip_shell_integration_prompt(Some(&config_path))
.unwrap();
let after_first = std::fs::read_to_string(&config_path).unwrap();
assert!(after_first.contains("skip-shell-integration-prompt = true"));
config
.set_skip_shell_integration_prompt(Some(&config_path))
.unwrap();
let after_second = std::fs::read_to_string(&config_path).unwrap();
assert_eq!(after_first, after_second);
}
#[test]
fn test_acquire_config_lock_handles_root_path() {
let mut config = UserConfig::default();
let err = config
.set_skip_shell_integration_prompt(Some(std::path::Path::new("/")))
.unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("Failed to create config directory"),
"should skip create_dir when parent is None, got: {msg}"
);
assert!(
msg.contains("Failed to open lock file"),
"expected open lock error, got: {msg}"
);
}
#[test]
fn test_acquire_config_lock_fails_when_parent_is_file() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, "i am a file").unwrap();
let config_path = blocker.join("config.toml");
let mut config = UserConfig::default();
let err = config
.set_skip_shell_integration_prompt(Some(&config_path))
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Failed to create config directory"),
"expected create_dir error, got: {msg}"
);
}
#[cfg(unix)]
#[test]
fn test_with_locked_mutation_propagates_save_error() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "# valid\n").unwrap();
struct RestorePerms<'a>(&'a std::path::Path);
impl Drop for RestorePerms<'_> {
fn drop(&mut self) {
if let Ok(meta) = std::fs::metadata(self.0) {
let mut perms = meta.permissions();
perms.set_mode(0o644);
let _ = std::fs::set_permissions(self.0, perms);
}
}
}
let _guard = RestorePerms(&config_path);
if std::env::var("USER").as_deref() == Ok("root") {
return;
}
let cfg_path_for_closure = config_path.clone();
let mut config = UserConfig::default();
let err = config
.with_locked_mutation(Some(&config_path), move |_config| {
let mut perms = std::fs::metadata(&cfg_path_for_closure)
.unwrap()
.permissions();
perms.set_mode(0o000);
std::fs::set_permissions(&cfg_path_for_closure, perms).unwrap();
true
})
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("Failed to read config file"),
"expected save-side read error, got: {msg}"
);
}