use gwm::config::{
expand_placeholders, resolved_rows, review_tool_preset, BranchTypesSource, ClipboardMode, Config, ConfigRow,
ConfigSource, MacroOpenMode, SidebarOrientation, SidebarPosition, TuiOpenMode, WorktreeConfig, CONFIG_FILE,
};
use tempfile::TempDir;
fn env_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
#[cfg(unix)]
fn toml_absolute_path(unix: &'static str, _windows: &'static str) -> &'static str {
unix
}
#[cfg(windows)]
fn toml_absolute_path(_unix: &'static str, windows: &'static str) -> &'static str {
windows
}
#[test]
fn labels_default_is_empty() {
let cfg = Config::default();
assert!(cfg.labels.is_empty());
}
#[test]
fn labels_section_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[labels]]
name = "bug"
description = "Something isn't working"
color = "d73a4a"
[[labels]]
name = "enhancement"
description = "New feature or request"
[[labels]]
name = "good first issue"
description = "Good for newcomers"
color = "7057ff"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.labels.len(), 3);
assert_eq!(cfg.labels[0].name, "bug");
assert_eq!(cfg.labels[0].description.as_deref(), Some("Something isn't working"));
assert_eq!(cfg.labels[0].color.as_deref(), Some("d73a4a"));
assert_eq!(cfg.labels[1].name, "enhancement");
assert_eq!(cfg.labels[1].color, None);
assert_eq!(cfg.labels[2].name, "good first issue");
}
#[test]
fn labels_section_minimal_only_name_is_valid() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[labels]]
name = "wip"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.labels.len(), 1);
assert_eq!(cfg.labels[0].name, "wip");
assert_eq!(cfg.labels[0].description, None);
assert_eq!(cfg.labels[0].color, None);
}
#[test]
fn labels_load_rejects_leading_dash_name_at_load_time() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[worktree]
base = "/tmp/wt/{repo}"
path_pattern = "{type}-{issue}-{desc}"
branch_pattern = "{type}/#{issue}-{desc}"
[[labels]]
name = "-h"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).unwrap_err();
let msg = format!("{}", err);
assert!(
msg.contains("labels[0]"),
"error must surface the offending entry index; got: {}",
msg
);
assert!(
msg.contains("'-'") || msg.contains("- ") || msg.contains("\"-h\""),
"error must explain the leading-dash refusal; got: {}",
msg
);
}
#[test]
fn labels_section_absent_keeps_empty_vec() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[worktree]
base = "/tmp/wt/{repo}"
path_pattern = "{type}-{issue}-{desc}"
branch_pattern = "{type}/#{issue}-{desc}"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert!(cfg.labels.is_empty());
}
#[test]
fn milestones_default_is_empty() {
let cfg = Config::default();
assert!(cfg.milestones.is_empty());
}
#[test]
fn milestones_section_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[milestones]]
title = "v0.7.0"
description = "Configurability sprint"
due_on = "2026-07-15"
state = "open"
[[milestones]]
title = "v0.8.0"
due_on = "2026-10-01T17:00:00Z"
[[milestones]]
title = "v0.6.0"
state = "closed"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.milestones.len(), 3);
assert_eq!(cfg.milestones[0].title, "v0.7.0");
assert_eq!(cfg.milestones[0].description.as_deref(), Some("Configurability sprint"));
assert_eq!(cfg.milestones[0].due_on.as_deref(), Some("2026-07-15"));
assert_eq!(cfg.milestones[0].state.as_deref(), Some("open"));
assert_eq!(cfg.milestones[1].title, "v0.8.0");
assert_eq!(cfg.milestones[1].description, None);
assert_eq!(cfg.milestones[1].due_on.as_deref(), Some("2026-10-01T17:00:00Z"));
assert_eq!(cfg.milestones[1].state, None);
assert_eq!(cfg.milestones[2].title, "v0.6.0");
assert_eq!(cfg.milestones[2].state.as_deref(), Some("closed"));
}
#[test]
fn milestones_section_minimal_only_title_is_valid() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[milestones]]
title = "Backlog"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.milestones.len(), 1);
assert_eq!(cfg.milestones[0].title, "Backlog");
assert_eq!(cfg.milestones[0].description, None);
assert_eq!(cfg.milestones[0].due_on, None);
assert_eq!(cfg.milestones[0].state, None);
}
#[test]
fn milestones_section_absent_keeps_empty_vec() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[worktree]
base = "/tmp/wt/{repo}"
path_pattern = "{type}-{issue}-{desc}"
branch_pattern = "{type}/#{issue}-{desc}"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert!(cfg.milestones.is_empty());
}
#[test]
fn defaults_are_sane() {
let cfg = Config::default();
assert_eq!(cfg.worktree.branch_pattern, "{type}/#{issue}-{desc}");
assert_eq!(cfg.worktree.path_pattern, "{type}-{issue}-{desc}");
assert!(cfg.bootstrap.copy.is_empty());
assert!(cfg.bootstrap.guard.is_empty());
assert!(cfg.bootstrap.command.is_empty());
}
#[test]
fn doctor_section_defaults_to_dev_and_main() {
let cfg = Config::default();
assert_eq!(cfg.doctor.trunks, vec!["dev".to_string(), "main".to_string()]);
}
#[test]
fn doctor_section_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[doctor]
trunks = ["master", "release-3.x", "release-4.x"]
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(
cfg.doctor.trunks,
vec![
"master".to_string(),
"release-3.x".to_string(),
"release-4.x".to_string()
]
);
}
#[test]
fn doctor_section_absent_keeps_defaults() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[worktree]
base = "/tmp/wt/{repo}"
path_pattern = "{type}-{issue}-{desc}"
branch_pattern = "{type}/#{issue}-{desc}"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.doctor.trunks, vec!["dev".to_string(), "main".to_string()]);
}
#[test]
fn doctor_section_empty_trunks_means_no_filter() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[doctor]
trunks = []
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert!(cfg.doctor.trunks.is_empty());
}
#[test]
fn placeholders_expand() {
let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
let out = expand_placeholders(
"{home}/cc-worktree/{repo}/{type}-{issue}-{desc}",
"my-repo",
Some("feat"),
Some("123"),
Some("foo"),
None,
)
.unwrap();
assert!(out.ends_with("/cc-worktree/my-repo/feat-123-foo"));
assert!(!out.contains("{home}"));
assert!(!out.contains("{repo}"));
}
#[test]
fn placeholders_no_optional_args_leave_repo_only() {
let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
let out = expand_placeholders("{home}/{repo}", "x", None, None, None, None).unwrap();
assert!(out.ends_with("/x"));
}
#[test]
fn placeholders_expand_repo_path_and_parent() {
let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
let repo_path = std::path::Path::new("/Users/me/Projects/Perso/gwm-cli");
let parent = repo_path.parent().unwrap().to_string_lossy();
let full_dir = repo_path.to_string_lossy();
let out = expand_placeholders(
"{repo_parent}/worktrees/{repo}-{type}-{issue}",
"gwm-cli",
Some("feat"),
Some("42"),
None,
Some(repo_path),
)
.unwrap();
assert_eq!(out, format!("{parent}/worktrees/gwm-cli-feat-42"));
assert!(!out.contains("{repo_parent}"));
let full = expand_placeholders("{repo_path}/.worktrees", "gwm-cli", None, None, None, Some(repo_path)).unwrap();
assert_eq!(full, format!("{full_dir}/.worktrees"));
assert!(!full.contains("{repo_path}"));
}
#[test]
fn placeholders_repo_path_tokens_left_literal_without_path() {
let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
let out = expand_placeholders("{repo_parent}/x", "r", None, None, None, None).unwrap();
assert_eq!(out, "{repo_parent}/x");
}
#[test]
fn load_returns_defaults_when_no_file() {
let dir = TempDir::new().unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.worktree.branch_pattern, WorktreeConfig::default().branch_pattern);
}
#[test]
fn load_parses_repo_config() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[worktree]
base = "/tmp/wt/{repo}"
path_pattern = "{type}_{issue}_{desc}"
branch_pattern = "{type}/{issue}-{desc}"
[[bootstrap.copy]]
from = ".env"
to = ".env"
required = false
guards = ["safe-env"]
[[bootstrap.guard]]
name = "safe-env"
deny_patterns = ["secret"]
on_match = "abort"
[[bootstrap.command]]
name = "echo"
run = "echo hi"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.worktree.base, "/tmp/wt/{repo}");
assert_eq!(cfg.bootstrap.copy.len(), 1);
assert_eq!(cfg.bootstrap.guard.len(), 1);
assert_eq!(cfg.bootstrap.command.len(), 1);
assert_eq!(cfg.bootstrap.guard[0].on_match, "abort");
assert!(cfg.guard_by_name("safe-env").is_some());
assert!(cfg.guard_by_name("nope").is_none());
}
#[test]
fn write_default_creates_file() {
let dir = TempDir::new().unwrap();
let path = Config::write_default(dir.path()).unwrap();
assert!(path.exists());
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("[worktree]"));
}
#[test]
fn write_default_refuses_overwrite() {
let dir = TempDir::new().unwrap();
Config::write_default(dir.path()).unwrap();
assert!(Config::write_default(dir.path()).is_err());
}
#[test]
fn malformed_config_returns_error() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(CONFIG_FILE), "not valid toml [[[").unwrap();
let res = Config::load_layered(dir.path(), None);
assert!(res.is_err());
}
#[test]
fn tui_section_defaults_to_three_second_countdown() {
let cfg = Config::default();
assert_eq!(cfg.tui.confirm_countdown_secs, 3);
assert_eq!(cfg.tui.effective_confirm_countdown_secs(), 3);
assert_eq!(
cfg.tui.auto_refresh_secs, 60,
"TUI auto-refresh defaults to once per minute"
);
}
#[test]
fn tui_section_absent_keeps_defaults() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[worktree]
base = "/tmp/wt/{repo}"
path_pattern = "{type}-{issue}-{desc}"
branch_pattern = "{type}/#{issue}-{desc}"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.effective_confirm_countdown_secs(), 3);
}
#[test]
fn tui_sidebar_position_defaults_to_right() {
let cfg = Config::default();
assert_eq!(cfg.tui.sidebar_position, SidebarPosition::Right);
}
#[test]
fn tui_sidebar_position_absent_keeps_right() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
confirm_countdown_secs = 2
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.sidebar_position, SidebarPosition::Right);
}
#[test]
fn tui_sidebar_position_parses_left() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
sidebar_position = "left"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.sidebar_position, SidebarPosition::Left);
assert!(cfg.tui.sidebar_position.is_left());
}
#[test]
fn tui_sidebar_orientation_defaults_to_stacked() {
let cfg = Config::default();
assert_eq!(cfg.tui.sidebar_orientation, SidebarOrientation::Stacked);
}
#[test]
fn tui_sidebar_orientation_absent_keeps_stacked() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
confirm_countdown_secs = 2
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.sidebar_orientation, SidebarOrientation::Stacked);
}
#[test]
fn tui_sidebar_orientation_parses_side_by_side() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
sidebar_orientation = "side-by-side"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.sidebar_orientation, SidebarOrientation::SideBySide);
}
#[test]
fn tui_sidebar_orientation_parses_auto() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
sidebar_orientation = "auto"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.sidebar_orientation, SidebarOrientation::Auto);
}
#[test]
fn tui_sidebar_orientation_serialises_back_to_its_label() {
for orientation in [
SidebarOrientation::Auto,
SidebarOrientation::SideBySide,
SidebarOrientation::Stacked,
] {
let serialised = toml::Value::try_from(orientation).unwrap();
assert_eq!(
serialised.as_str(),
Some(orientation.label()),
"{orientation:?} must serialise as its status-bar label"
);
}
}
#[test]
fn tui_sidebar_orientation_invalid_value_errors_at_parse_time() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
sidebar_orientation = "diagonal"
"#,
)
.unwrap();
assert!(Config::load_layered(dir.path(), None).is_err());
}
#[test]
fn tui_section_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
confirm_countdown_secs = 2
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.confirm_countdown_secs, 2);
assert_eq!(cfg.tui.effective_confirm_countdown_secs(), 2);
}
#[test]
fn tui_countdown_zero_disables_countdown() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
confirm_countdown_secs = 0
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.effective_confirm_countdown_secs(), 0);
}
#[test]
fn tui_auto_refresh_zero_disables_periodic_refresh() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
auto_refresh_secs = 0
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.auto_refresh_secs, 0);
}
#[test]
fn tui_auto_refresh_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
auto_refresh_secs = 15
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.auto_refresh_secs, 15);
}
#[test]
fn tui_countdown_clamped_to_five_seconds() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
confirm_countdown_secs = 30
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.confirm_countdown_secs, 30);
assert_eq!(cfg.tui.effective_confirm_countdown_secs(), 5);
}
#[test]
fn git_tui_section_defaults_to_lazygit_preserving_legacy_behaviour() {
let cfg = Config::default();
let r = cfg.git_tui.resolved();
assert_eq!(r.command, "lazygit -p {path}");
assert!(r.fullscreen, "lazygit is a TUI tool, gwm must suspend itself");
}
#[test]
fn git_tui_section_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[git_tui]
command = "gitui -d {path}"
fullscreen = true
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let r = cfg.git_tui.resolved();
assert_eq!(r.command, "gitui -d {path}");
assert!(r.fullscreen);
}
#[test]
fn git_tui_can_opt_out_of_fullscreen() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[git_tui]
command = "code {path}"
fullscreen = false
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let r = cfg.git_tui.resolved();
assert_eq!(r.command, "code {path}");
assert!(!r.fullscreen);
}
#[test]
fn review_section_defaults_to_disabled_with_skip_true() {
let cfg = Config::default();
assert!(
cfg.review.resolved().is_none(),
"default review must be inert until configured"
);
assert!(cfg.review.skip_when_no_changes);
assert!(cfg.review.default_base.is_none());
assert!(!cfg.review.has_shadowed_tool());
}
#[test]
fn review_section_explicit_command_wins() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[review]
command = "my-review --base {base} --head {head}"
fullscreen = true
skip_when_no_changes = false
default_base = "trunk"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let r = cfg.review.resolved().expect("explicit command must resolve");
assert_eq!(r.command, "my-review --base {base} --head {head}");
assert!(r.fullscreen);
assert!(!cfg.review.skip_when_no_changes);
assert_eq!(cfg.review.default_base.as_deref(), Some("trunk"));
}
#[test]
fn review_section_tool_preset_lumen_resolves_to_fullscreen_diff() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[review]
tool = "lumen"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let r = cfg.review.resolved().expect("lumen preset must resolve");
assert_eq!(r.command, "lumen diff {base}..{head}");
assert!(r.fullscreen, "lumen is a TUI — gwm must suspend itself");
}
#[test]
fn review_tool_preset_table_covers_documented_set() {
assert_eq!(review_tool_preset("lumen"), Some(("lumen diff {base}..{head}", true)));
assert_eq!(
review_tool_preset("claude"),
Some(("claude --print 'review the diff {base}..{head}'", false))
);
assert_eq!(
review_tool_preset("codex"),
Some(("codex review {base}..{head}", false))
);
assert_eq!(
review_tool_preset("aider"),
Some(("aider --message 'review {base}..{head}'", true))
);
assert_eq!(review_tool_preset("gh"), Some(("gh pr view --web", false)));
assert_eq!(review_tool_preset("unknown"), None);
}
#[test]
fn review_unknown_tool_resolves_to_none() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[review]
tool = "made-up"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert!(
cfg.review.resolved().is_none(),
"unknown preset must not silently fall back to a real tool"
);
}
#[test]
fn review_command_overrides_tool() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[review]
tool = "lumen"
command = "my-bot --diff-file {diff}"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let r = cfg.review.resolved().unwrap();
assert_eq!(
r.command, "my-bot --diff-file {diff}",
"`command` must override `tool` when both are set"
);
assert!(cfg.review.has_shadowed_tool());
}
#[test]
fn review_fullscreen_overrides_preset_default() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[review]
tool = "lumen"
fullscreen = false
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let r = cfg.review.resolved().unwrap();
assert!(
!r.fullscreen,
"explicit fullscreen=false must override the preset default"
);
}
#[test]
fn tui_countdown_value_above_u8_max_still_clamps() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui]
confirm_countdown_secs = 300
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).expect("300 must parse, not error");
assert_eq!(cfg.tui.effective_confirm_countdown_secs(), 5);
}
#[test]
fn tui_open_section_defaults_to_shell_mode() {
let cfg = Config::default();
assert_eq!(cfg.tui.open.mode, TuiOpenMode::Shell);
assert!(cfg.tui.open.shell_cmd.is_none());
assert!(cfg.tui.open.editor_cmd.is_none());
}
#[test]
fn tui_open_section_absent_keeps_defaults() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[worktree]
base = "/tmp/wt/{repo}"
path_pattern = "{type}-{issue}-{desc}"
branch_pattern = "{type}/#{issue}-{desc}"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.open.mode, TuiOpenMode::Shell);
}
#[test]
fn tui_open_mode_editor_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.open]
mode = "editor"
editor_cmd = "hx"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.open.mode, TuiOpenMode::Editor);
assert_eq!(cfg.tui.open.editor_cmd.as_deref(), Some("hx"));
}
#[test]
fn tui_open_mode_finder_preserves_legacy_behaviour() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.open]
mode = "finder"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.open.mode, TuiOpenMode::Finder);
}
#[test]
fn tui_open_mode_shell_with_custom_cmd_round_trips() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.open]
mode = "shell"
shell_cmd = "/usr/bin/fish"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.open.mode, TuiOpenMode::Shell);
assert_eq!(cfg.tui.open.shell_cmd.as_deref(), Some("/usr/bin/fish"));
}
#[test]
fn tui_open_mode_invalid_value_errors_at_parse_time() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.open]
mode = "neovim"
"#,
)
.unwrap();
assert!(Config::load_layered(dir.path(), None).is_err());
}
#[test]
fn branch_types_absent_falls_back_to_built_in_defaults() {
let cfg = Config::default();
let resolved = cfg.resolved_branch_types();
assert_eq!(resolved.source, BranchTypesSource::Default);
assert!(
resolved.types.iter().any(|t| t.name == "feat"),
"default list must include the legacy `feat` entry"
);
assert!(
resolved.types.iter().any(|t| t.name == "hotfix"),
"default list must include `hotfix` (regression guard against partial defaults)"
);
}
#[test]
fn branch_types_parsed_from_toml_replaces_defaults() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[branch_types]]
name = "feat"
description = "New feature implementation"
[[branch_types]]
name = "fix"
description = "Bug fix"
[[branch_types]]
name = "migration"
description = "Database migration"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let resolved = cfg.resolved_branch_types();
assert_eq!(resolved.source, BranchTypesSource::Config);
let names: Vec<_> = resolved.types.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["feat", "fix", "migration"]);
assert!(!names.contains(&"hotfix"));
assert!(!names.contains(&"chore"));
}
#[test]
fn branch_types_empty_block_treated_as_absent() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
branch_types = []
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let resolved = cfg.resolved_branch_types();
assert_eq!(resolved.source, BranchTypesSource::Default);
assert!(!resolved.types.is_empty());
}
#[test]
fn branch_types_source_label_is_user_facing() {
assert_eq!(BranchTypesSource::Default.label(), "built-in defaults");
assert_eq!(BranchTypesSource::Config.label(), ".gwm.toml");
}
#[test]
fn branch_types_empty_name_is_rejected_at_load() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[branch_types]]
name = ""
description = "Whoops"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("branch_types"), "{msg}");
assert!(msg.contains("empty"), "{msg}");
}
#[test]
fn branch_types_invalid_name_format_is_rejected_at_load() {
for bad in ["Feat", "feat-1", "wip task", "fix!", "1fix", ""].iter() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
format!(
r#"
[[branch_types]]
name = "{}"
description = "x"
"#,
bad
),
)
.unwrap();
assert!(
Config::load_layered(dir.path(), None).is_err(),
"name = {:?} must be rejected at load",
bad
);
}
}
#[test]
fn branch_types_duplicate_name_is_rejected_at_load() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[branch_types]]
name = "feat"
description = "Feature"
[[branch_types]]
name = "feat"
description = "Different description for the same name"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("duplicate"), "{msg}");
assert!(msg.contains("feat"), "{msg}");
}
#[test]
fn branch_types_valid_names_load_successfully() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[branch_types]]
name = "feat"
description = "Feature"
[[branch_types]]
name = "migration"
description = "Database migration"
[[branch_types]]
name = "wip"
description = "Work in progress"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).expect("valid config must load");
let names: Vec<_> = cfg.branch_types.iter().map(|t| t.name.as_str()).collect();
assert_eq!(names, vec!["feat", "migration", "wip"]);
}
#[test]
fn load_rejects_traversal_in_copy_to() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[bootstrap.copy]]
from = "Cargo.toml"
to = "../../OWNED"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("traversal must be rejected at load");
let msg = format!("{}", err);
assert!(
msg.contains("bootstrap.copy") && msg.contains("to"),
"error must name the offending field, got: {}",
msg
);
assert!(
msg.contains("..") || msg.contains("traversal") || msg.contains("outside"),
"error must explain WHY (../traversal/outside), got: {}",
msg
);
}
#[test]
fn load_rejects_absolute_path_in_copy_to() {
let dir = TempDir::new().unwrap();
let absolute_path = toml_absolute_path("/etc/passwd", r#"C:\\Windows\\win.ini"#);
std::fs::write(
dir.path().join(CONFIG_FILE),
format!(
r#"
[[bootstrap.copy]]
from = ".env"
to = "{absolute_path}"
"#,
),
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("absolute path must be rejected at load");
let msg = format!("{}", err);
assert!(
msg.contains("bootstrap.copy") && msg.contains("to"),
"error must name the offending field, got: {}",
msg
);
assert!(
msg.contains("absolute") || msg.contains(absolute_path),
"error must explain absolute path rejection, got: {}",
msg
);
}
#[test]
fn load_rejects_traversal_in_guard_example_file() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[bootstrap.guard]]
name = "leaky"
deny_patterns = ["amazonaws"]
on_match = "seed-from-example"
example_file = "../../../etc/passwd"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("traversal in example_file must be rejected");
let msg = format!("{}", err);
assert!(
msg.contains("guard") && msg.contains("example_file"),
"error must name the offending field, got: {}",
msg
);
assert!(
msg.contains("..") || msg.contains("traversal") || msg.contains("outside"),
"error must explain WHY, got: {}",
msg
);
}
#[test]
fn load_rejects_absolute_path_in_guard_example_file() {
let dir = TempDir::new().unwrap();
let absolute_path = toml_absolute_path("/etc/shadow", r#"C:\\Windows\\System32\\config\\SAM"#);
std::fs::write(
dir.path().join(CONFIG_FILE),
format!(
r#"
[[bootstrap.guard]]
name = "leaky"
deny_patterns = ["amazonaws"]
on_match = "seed-from-example"
example_file = "{absolute_path}"
"#,
),
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("absolute example_file must be rejected");
let msg = format!("{}", err);
assert!(
msg.contains("guard") && msg.contains("example_file"),
"error must name the offending field, got: {}",
msg
);
assert!(
msg.contains("absolute") || msg.contains(absolute_path),
"error must explain absolute path rejection, got: {}",
msg
);
}
#[test]
fn load_rejects_traversal_in_fallback_target() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[bootstrap.fallback.env_testing]
target = "../../OWNED"
content = "FOO=bar"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("traversal in fallback.target must be rejected");
let msg = format!("{}", err);
assert!(
msg.contains("fallback") && msg.contains("target"),
"error must name the offending field, got: {}",
msg
);
}
#[cfg(windows)]
#[test]
fn load_rejects_windows_drive_prefix_in_copy_to() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[bootstrap.copy]]
from = ".env"
to = "C:foo"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("drive-prefixed path must be rejected at load");
let msg = format!("{}", err);
assert!(
msg.contains("bootstrap.copy") && msg.contains("to"),
"error must name the offending field, got: {}",
msg
);
assert!(
msg.contains("drive") || msg.contains("prefix") || msg.contains("C:foo"),
"error must explain Windows drive prefix rejection, got: {}",
msg
);
}
#[test]
fn load_accepts_benign_relative_paths_in_bootstrap_fields() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[bootstrap.copy]]
from = ".env"
to = "config/local.env"
[[bootstrap.guard]]
name = "no-aws"
deny_patterns = ["amazonaws\\.com"]
on_match = "seed-from-example"
example_file = "config/local.env.example"
[bootstrap.fallback.env_testing]
target = "config/local.env"
content = "X=1"
"#,
)
.unwrap();
Config::load_layered(dir.path(), None).expect("benign relative paths must load");
}
#[test]
fn load_rejects_invalid_deny_pattern_in_guard() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[bootstrap.guard]]
name = "no-secrets"
deny_patterns = ["[+", "AWS_SECRET_ACCESS_KEY"]
on_match = "abort"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("invalid deny_patterns must be rejected at load");
let msg = format!("{}", err);
assert!(
msg.contains("no-secrets"),
"error must name the offending guard, got: {}",
msg
);
assert!(
msg.contains("[+"),
"error must quote the offending pattern, got: {}",
msg
);
assert!(
msg.contains("deny_pattern") || msg.contains("regex"),
"error must explain WHY (regex/deny_pattern), got: {}",
msg
);
}
#[test]
fn load_rejects_invalid_deny_pattern_when_only_pattern_in_guard() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[bootstrap.guard]]
name = "broken"
deny_patterns = ["*foo"]
on_match = "abort"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("invalid sole deny pattern must be rejected at load");
let msg = format!("{}", err);
assert!(
msg.contains("broken") && msg.contains("*foo"),
"error must name guard + pattern, got: {}",
msg
);
}
#[test]
fn load_accepts_valid_deny_patterns() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[bootstrap.guard]]
name = "no-aws"
deny_patterns = ["amazonaws\\.com", "AKIA[0-9A-Z]{16}", "(?i)aws_secret"]
on_match = "abort"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).expect("valid patterns must load");
assert_eq!(cfg.bootstrap.guard.len(), 1);
assert_eq!(cfg.bootstrap.guard[0].deny_patterns.len(), 3);
}
#[test]
fn validate_bootstrap_guards_directly_rejects_invalid_pattern_without_load_for_repo() {
use gwm::config::{BootstrapConfig, Guard};
let mut cfg = Config {
bootstrap: BootstrapConfig {
guard: vec![Guard {
name: "direct-test".into(),
deny_patterns: vec!["(unclosed-group".into()],
on_match: "abort".into(),
example_file: None,
}],
..Default::default()
},
..Default::default()
};
let err = cfg
.validate_bootstrap_guards()
.expect_err("direct call on hand-built Config with invalid pattern must Err");
let msg = format!("{}", err);
assert!(
msg.contains("direct-test") && msg.contains("(unclosed-group"),
"error must name guard + pattern, got: {}",
msg
);
cfg.bootstrap.guard[0].deny_patterns = vec!["AKIA[0-9A-Z]{16}".into(), "(?i)secret".into()];
cfg
.validate_bootstrap_guards()
.expect("valid patterns must pass direct validation");
}
#[test]
fn load_rejects_invalid_deny_pattern_in_second_guard() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[[bootstrap.guard]]
name = "guard-one"
deny_patterns = ["amazonaws\\.com"]
on_match = "abort"
[[bootstrap.guard]]
name = "guard-two"
deny_patterns = ["[unclosed"]
on_match = "abort"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("invalid pattern in second guard must be rejected");
let msg = format!("{}", err);
assert!(
msg.contains("guard-two") && msg.contains("[unclosed"),
"error must name the offending guard + pattern, got: {}",
msg
);
assert!(
msg.contains("bootstrap.guard[1]") && msg.contains("deny_patterns[0]"),
"error must locate the failing entry by index, got: {}",
msg
);
}
#[test]
fn pr_template_defaults_are_empty() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(CONFIG_FILE), "").unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert!(cfg.pr_template.default.is_none());
assert!(cfg.pr_template.by_type.is_empty());
}
#[test]
fn pr_template_section_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r###"
[pr_template]
default = ".github/pull_request_template.md"
[pr_template.by_type]
feat = { path = ".github/pr-templates/feat.md" }
fix = { path = ".github/pr-templates/fix.md" }
[pr_template.by_type.chore]
body = "## Summary\n{desc}\n\nCloses #{issue}\n"
"###,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(
cfg.pr_template.default.as_deref(),
Some(".github/pull_request_template.md")
);
let feat = cfg.pr_template.by_type.get("feat").expect("feat entry");
assert_eq!(feat.path.as_deref(), Some(".github/pr-templates/feat.md"));
assert_eq!(feat.body, None);
let chore = cfg.pr_template.by_type.get("chore").expect("chore entry");
assert_eq!(chore.path, None);
assert_eq!(chore.body.as_deref(), Some("## Summary\n{desc}\n\nCloses #{issue}\n"));
}
#[test]
fn pr_template_unknown_root_field_is_rejected() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[pr_template]
default = ".github/pull_request_template.md"
mystery = "boom"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("unknown field must reject");
let msg = format!("{}", err);
assert!(msg.contains("mystery"), "{msg}");
}
#[test]
fn pr_template_unknown_per_type_field_is_rejected() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[pr_template.by_type.feat]
path = ".github/pr-templates/feat.md"
bogus = true
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("unknown per-type field must reject");
let msg = format!("{}", err);
assert!(msg.contains("bogus"), "{msg}");
}
#[test]
fn tui_keys_default_is_empty_map() {
let cfg = Config::default();
assert!(cfg.tui.keys.raw.is_empty());
}
#[test]
fn tui_keys_section_round_trips_through_toml() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys]
down = ["j", "Ctrl+n"]
up = ["k", "Ctrl+p"]
top = ["g g"]
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let chords = |slug: &str| -> Vec<String> {
cfg
.tui
.keys
.raw
.get(slug)
.and_then(|v| v.as_array())
.map(|a| a.iter().filter_map(|x| x.as_str().map(str::to_string)).collect())
.unwrap_or_default()
};
assert_eq!(chords("down"), vec!["j".to_string(), "Ctrl+n".to_string()]);
assert_eq!(chords("up"), vec!["k".to_string(), "Ctrl+p".to_string()]);
assert_eq!(chords("top"), vec!["g g".to_string()]);
}
#[test]
fn tui_keys_rejects_unknown_action_at_load_time() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys]
gallop = ["g"]
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("unknown action must reject");
let msg = format!("{}", err).to_lowercase();
assert!(
msg.contains("gallop"),
"expected message to name the bad action, got: {msg}"
);
assert!(
msg.contains("unknown"),
"expected message to flag it as unknown, got: {msg}"
);
}
#[test]
fn tui_keys_rejects_invalid_key_string() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys]
down = ["Foobar"]
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("invalid key string must reject");
let msg = format!("{}", err).to_lowercase();
assert!(
msg.contains("foobar"),
"expected message to name the bad key, got: {msg}"
);
}
#[test]
fn tui_keys_rejects_chord_that_is_strict_prefix() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys]
terminal_fullscreen = ["g"]
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("prefix collision must reject");
let msg = format!("{}", err).to_lowercase();
assert!(msg.contains("prefix"), "expected prefix error, got: {msg}");
}
#[test]
fn tui_keys_rejects_chord_conflict_across_actions() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys]
down = ["x"]
up = ["x"]
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("conflict must reject");
let msg = format!("{}", err).to_lowercase();
assert!(msg.contains("conflict"), "expected conflict error, got: {msg}");
}
#[test]
fn tui_keys_empty_binding_list_unbinds_action() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys]
down = []
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let km = cfg.tui.keys.resolved_keymap().unwrap();
use gwm::tui::keymap::{Action, ChordResolution, KeyStroke};
let j = KeyStroke::parse_chord("j").unwrap();
assert!(matches!(km.lookup(&j), ChordResolution::NoMatch));
let k = KeyStroke::parse_chord("k").unwrap();
assert!(matches!(km.lookup(&k), ChordResolution::Matched(Action::Up)));
}
#[test]
fn theme_default_is_pre_issue_33_scheme() {
use ratatui::style::Color;
let cfg = Config::default();
let theme = cfg.theme.resolve().unwrap();
assert_eq!(theme.focus, Color::Cyan);
assert_eq!(theme.branch, Color::Green);
}
#[test]
fn theme_preset_is_applied_at_load() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[theme]
preset = "catppuccin"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let theme = cfg.theme.resolve().unwrap();
use gwm::tui::theme::Theme;
let default = Theme::default();
assert_ne!(
theme.focus, default.focus,
"preset must override the default focus colour"
);
}
#[test]
fn theme_per_role_overrides_win_over_preset() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[theme]
preset = "catppuccin"
focus = "red"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let theme = cfg.theme.resolve().unwrap();
use ratatui::style::Color;
assert_eq!(theme.focus, Color::Red, "explicit override must win over the preset");
}
#[test]
fn theme_rejects_unknown_preset() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[theme]
preset = "does-not-exist"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("unknown preset must reject");
let msg = format!("{}", err).to_lowercase();
assert!(msg.contains("does-not-exist"), "got: {msg}");
}
#[test]
fn theme_rejects_unknown_role() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[theme]
phantom = "red"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("unknown role must reject");
let msg = format!("{}", err).to_lowercase();
assert!(msg.contains("phantom"), "got: {msg}");
}
#[test]
fn theme_rejects_bad_color_value() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[theme]
focus = "not_a_color"
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("invalid color must reject");
let msg = format!("{}", err).to_lowercase();
assert!(msg.contains("not_a_color"), "got: {msg}");
}
fn row_for<'a>(rows: &'a [ConfigRow], key: &str) -> &'a ConfigRow {
rows
.iter()
.find(|r| r.key == key)
.unwrap_or_else(|| panic!("key {key:?} missing from resolved rows"))
}
#[test]
fn resolved_rows_attributes_each_key_to_its_winning_layer() {
let repo = TempDir::new().unwrap();
let global_dir = TempDir::new().unwrap();
let global_path = global_dir.path().join("config.toml");
std::fs::write(
&global_path,
"[worktree]\nbase = \"/tmp/global-wt\"\n[tui]\nconfirm_countdown_secs = 7\n",
)
.unwrap();
std::fs::write(repo.path().join(CONFIG_FILE), "[worktree]\nbase = \"/tmp/repo-wt\"\n").unwrap();
let rows = resolved_rows(repo.path(), Some(&global_path)).unwrap();
let base = row_for(&rows, "worktree.base");
assert_eq!(base.source, ConfigSource::Repo);
assert_eq!(base.value, "\"/tmp/repo-wt\"");
let countdown = row_for(&rows, "tui.confirm_countdown_secs");
assert_eq!(countdown.source, ConfigSource::User);
assert_eq!(countdown.value, "7");
assert_eq!(row_for(&rows, "worktree.path_pattern").source, ConfigSource::Default);
}
#[test]
fn resolved_rows_with_no_files_marks_everything_default() {
let repo = TempDir::new().unwrap();
let rows = resolved_rows(repo.path(), None).unwrap();
assert!(!rows.is_empty(), "defaults still produce rows");
assert!(
rows.iter().all(|r| r.source == ConfigSource::Default),
"with no repo/global config every row is a built-in default"
);
}
#[test]
fn resolved_rows_attributes_array_of_tables_entries() {
let repo = TempDir::new().unwrap();
std::fs::write(
repo.path().join(CONFIG_FILE),
"[[labels]]\nname = \"bug\"\ncolor = \"d73a4a\"\n\n[[branch_types]]\nname = \"feat\"\ndescription = \"a feature\"\n",
)
.unwrap();
let rows = resolved_rows(repo.path(), None).unwrap();
assert_eq!(row_for(&rows, "labels[0].name").source, ConfigSource::Repo);
assert_eq!(row_for(&rows, "labels[0].name").value, "\"bug\"");
assert_eq!(row_for(&rows, "labels[0].color").source, ConfigSource::Repo);
assert_eq!(row_for(&rows, "branch_types[0].name").source, ConfigSource::Repo);
assert!(
!rows.iter().any(|r| r.key == "labels[0].description"),
"unset optional sub-fields must not surface as default rows"
);
}
#[test]
fn config_source_labels_are_stable() {
assert_eq!(ConfigSource::Repo.label(), "repo");
assert_eq!(ConfigSource::User.label(), "user");
assert_eq!(ConfigSource::Default.label(), "default");
}
#[test]
fn macro_open_in_accepts_documented_pty_and_mux_pane_values() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.macro1]
command = "make test"
open_in = "mux_pane"
[tui.macro2]
command = "codex"
open_in = "pty"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let m1 = cfg.tui.macro1.expect("macro1 must parse");
assert_eq!(m1.command, "make test");
assert_eq!(m1.open_in, MacroOpenMode::MuxPane, "\"mux_pane\" must map to MuxPane");
let m2 = cfg.tui.macro2.expect("macro2 must parse");
assert_eq!(m2.open_in, MacroOpenMode::Pty, "\"pty\" must map to Pty");
}
#[test]
fn macro_open_in_defaults_to_pty_when_omitted() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.macro1]
command = "lazygit"
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let m1 = cfg.tui.macro1.expect("macro1 must parse");
assert_eq!(m1.open_in, MacroOpenMode::Pty);
}
use crossterm::event::{KeyCode, KeyModifiers};
use gwm::tui::keymap::KeyStroke;
use gwm::tui::modal_keymap::{KeyContext, ModalAction};
fn ks(code: KeyCode) -> KeyStroke {
KeyStroke::new(code, KeyModifiers::empty())
}
fn kc(c: char) -> KeyStroke {
KeyStroke::new(KeyCode::Char(c), KeyModifiers::empty())
}
#[test]
fn modal_keys_nested_context_resolves() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys.modal.confirm]
confirm = ["o"]
cancel = ["n", "Esc"]
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let mk = cfg.tui.keys.resolved_modal_keymap().unwrap();
assert_eq!(
mk.resolve(KeyContext::Confirm, &kc('o')),
Some(ModalAction::ConfirmConfirm)
);
assert_eq!(mk.resolve(KeyContext::Confirm, &kc('y')), None);
}
#[test]
fn modal_keys_link_stage_uses_dotted_table() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys.modal.link.choose_target]
issue = ["x"]
[tui.keys.modal.link.input_number]
submit = ["Right"]
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let mk = cfg.tui.keys.resolved_modal_keymap().unwrap();
assert_eq!(
mk.resolve(KeyContext::LinkChooseTarget, &kc('x')),
Some(ModalAction::LinkChooseIssue)
);
assert_eq!(
mk.resolve(KeyContext::LinkInputNumber, &ks(KeyCode::Right)),
Some(ModalAction::LinkInputSubmit)
);
}
#[test]
fn modal_keys_config_edit_substage_resolves() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys.modal.config]
close = ["q"]
[tui.keys.modal.config.edit]
cancel = ["Tab"]
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let mk = cfg.tui.keys.resolved_modal_keymap().unwrap();
assert_eq!(mk.resolve(KeyContext::Config, &kc('q')), Some(ModalAction::ConfigClose));
assert_eq!(
mk.resolve(KeyContext::ConfigEdit, &ks(KeyCode::Tab)),
Some(ModalAction::ConfigEditCancel)
);
}
#[test]
fn modal_keys_global_and_contextual_coexist() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys]
quit = ["Q"]
[tui.keys.modal.confirm]
confirm = ["o"]
"#,
)
.unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
let km = cfg.tui.keys.resolved_keymap().unwrap();
assert_eq!(km.primary_chord(gwm::tui::keymap::Action::Quit).as_deref(), Some("Q"));
let mk = cfg.tui.keys.resolved_modal_keymap().unwrap();
assert_eq!(
mk.resolve(KeyContext::Confirm, &kc('o')),
Some(ModalAction::ConfirmConfirm)
);
}
#[test]
fn modal_namespace_does_not_collide_with_a_same_named_global_action() {
let global = TempDir::new().unwrap();
let global_path = global.path().join("global.toml");
std::fs::write(&global_path, "[tui.keys]\ncreate = [\"c\"]\n").unwrap();
let repo = TempDir::new().unwrap();
std::fs::write(
repo.path().join(CONFIG_FILE),
"[tui.keys.modal.create]\nnext_type = [\"x\"]\n",
)
.unwrap();
let cfg = Config::load_layered(repo.path(), Some(&global_path)).unwrap();
let km = cfg.tui.keys.resolved_keymap().unwrap();
assert_eq!(
km.primary_chord(gwm::tui::keymap::Action::Create).as_deref(),
Some("c"),
"the global create override must survive a same-named modal context"
);
let mk = cfg.tui.keys.resolved_modal_keymap().unwrap();
assert_eq!(
mk.resolve(KeyContext::Create, &kc('x')),
Some(ModalAction::CreateNextType)
);
}
#[test]
fn modal_keys_reject_unknown_context() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys.modal.confrm]
confirm = ["y"]
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("typo'd context must reject");
assert!(err.to_string().contains("unknown modal context"), "{err}");
}
#[test]
fn modal_keys_reject_unknown_verb() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys.modal.confirm]
gallop = ["y"]
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("unknown verb must reject");
assert!(err.to_string().contains("unknown verb"), "{err}");
}
#[test]
fn modal_keys_reject_multistroke_chord() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys.modal.confirm]
confirm = ["g g"]
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("modal chords must be single strokes");
assert!(err.to_string().contains("single keystroke"), "{err}");
}
#[test]
fn modal_keys_reject_in_context_conflict() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys.modal.confirm]
confirm = ["x"]
cancel = ["x"]
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("two verbs sharing a key must conflict");
assert!(err.to_string().contains("conflict"), "{err}");
}
#[test]
fn modal_keys_reject_array_directly_under_group() {
let dir = TempDir::new().unwrap();
std::fs::write(
dir.path().join(CONFIG_FILE),
r#"
[tui.keys.modal.link]
issue = ["i"]
"#,
)
.unwrap();
let err = Config::load_layered(dir.path(), None).expect_err("link needs a stage");
assert!(err.to_string().contains("context group"), "{err}");
}
fn load_toml(body: &str) -> gwm::error::Result<Config> {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(CONFIG_FILE), body).unwrap();
Config::load_layered(dir.path(), None)
}
#[test]
fn exec_and_clean_default_to_no_profiles() {
let cfg = Config::default();
assert!(cfg.exec.profiles.is_empty());
assert!(cfg.clean.profiles.is_empty());
}
#[test]
fn exec_profiles_parse_command_as_an_argv_array() {
let cfg = load_toml(
r#"
[exec.profiles.test]
command = ["cargo", "test"]
[exec.profiles.fmt]
command = ["cargo", "fmt", "--all"]
"#,
)
.expect("exec profiles parse");
assert_eq!(
cfg.exec.profiles["test"].command,
vec!["cargo".to_string(), "test".to_string()]
);
assert_eq!(
cfg.exec.profiles["fmt"].command,
vec!["cargo".to_string(), "fmt".to_string(), "--all".to_string()]
);
}
#[test]
fn exec_jobs_parse_global_and_per_profile() {
let cfg = load_toml(
r#"
[exec]
jobs = 4
[exec.profiles.fmt]
command = ["cargo", "fmt"]
jobs = 2
"#,
)
.expect("jobs parse");
assert_eq!(cfg.exec.jobs, Some(4), "global [exec] jobs");
assert_eq!(cfg.exec.profiles["fmt"].jobs, Some(2), "per-profile jobs");
}
#[test]
fn exec_jobs_default_honors_global_even_without_a_repo() {
let global_dir = TempDir::new().unwrap();
let global = global_dir.path().join("config.toml");
std::fs::write(&global, "[exec]\njobs = 4\n").unwrap();
assert_eq!(
Config::load_exec_jobs_default_layered(Some(&global), None).unwrap(),
Some(4),
"bare repo still honours the global [exec] jobs"
);
let repo_dir = TempDir::new().unwrap();
std::fs::write(repo_dir.path().join(CONFIG_FILE), "[exec]\njobs = 2\n").unwrap();
assert_eq!(
Config::load_exec_jobs_default_layered(Some(&global), Some(repo_dir.path())).unwrap(),
Some(2),
"repo [exec] jobs overrides the global"
);
assert_eq!(Config::load_exec_jobs_default_layered(None, None).unwrap(), None);
}
#[test]
fn exec_jobs_default_to_none() {
let cfg = load_toml(
r#"
[exec.profiles.test]
command = ["cargo", "test"]
"#,
)
.expect("parse");
assert!(cfg.exec.jobs.is_none(), "no [exec] jobs ⇒ None (sequential)");
assert!(cfg.exec.profiles["test"].jobs.is_none());
}
#[test]
fn clean_profiles_parse_dirs_as_a_complete_set() {
let cfg = load_toml(
r#"
[clean.profiles.default]
dirs = ["target", "node_modules", "dist", "build", "coverage", ".turbo"]
[clean.profiles.deep]
dirs = ["target", ".cache", ".venv"]
"#,
)
.expect("clean profiles parse");
assert_eq!(
cfg.clean.profiles["default"].dirs,
vec!["target", "node_modules", "dist", "build", "coverage", ".turbo"]
);
assert_eq!(cfg.clean.profiles["deep"].dirs, vec!["target", ".cache", ".venv"]);
}
#[test]
fn exec_profile_without_command_is_a_load_error() {
let err = load_toml(
r#"
[exec.profiles.broken]
"#,
)
.expect_err("missing command must error");
assert!(
err.to_string().contains("command"),
"error should name the missing field: {err}"
);
}
#[test]
fn clean_profile_without_dirs_is_a_load_error() {
let err = load_toml(
r#"
[clean.profiles.broken]
"#,
)
.expect_err("missing dirs must error");
assert!(
err.to_string().contains("dirs"),
"error should name the missing field: {err}"
);
}
#[test]
fn exec_profile_rejects_unknown_fields() {
let err = load_toml(
r#"
[exec.profiles.test]
command = ["cargo", "test"]
nonsense = true
"#,
)
.expect_err("unknown field must error");
assert!(
err.to_string().contains("nonsense") || err.to_string().contains("unknown"),
"{err}"
);
}
#[test]
fn exec_profile_with_an_empty_command_fails_validation() {
let err = load_toml(
r#"
[exec.profiles.empty]
command = []
"#,
)
.expect_err("empty command must fail validation");
assert!(err.to_string().contains("empty `command`"), "{err}");
}
#[test]
fn clean_profile_with_an_escaping_dir_fails_validation() {
let err = load_toml(
r#"
[clean.profiles.default]
dirs = [".."]
"#,
)
.expect_err("escaping dir must fail validation");
assert!(err.to_string().contains(".."), "{err}");
}
#[test]
fn tui_clipboard_defaults_to_auto() {
assert_eq!(Config::default().tui.clipboard, ClipboardMode::Auto);
}
#[test]
fn tui_clipboard_absent_keeps_auto() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(CONFIG_FILE), "[tui]\nconfirm_countdown_secs = 2\n").unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.clipboard, ClipboardMode::Auto);
}
#[test]
fn tui_clipboard_parses_every_mode() {
for (text, expected) in [
("auto", ClipboardMode::Auto),
("osc52", ClipboardMode::Osc52),
("tools", ClipboardMode::Tools),
] {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(CONFIG_FILE), format!("[tui]\nclipboard = \"{text}\"\n")).unwrap();
let cfg = Config::load_layered(dir.path(), None).unwrap();
assert_eq!(cfg.tui.clipboard, expected, "{text:?} parses");
assert_eq!(
cfg.tui.clipboard.label(),
text,
"label round-trips to the TOML spelling"
);
}
}
#[test]
fn tui_clipboard_invalid_value_errors_at_parse_time() {
let dir = TempDir::new().unwrap();
std::fs::write(dir.path().join(CONFIG_FILE), "[tui]\nclipboard = \"osc-52\"\n").unwrap();
assert!(Config::load_layered(dir.path(), None).is_err());
}
#[test]
fn a_token_produced_by_expanding_the_repo_name_stays_literal() {
let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
let out = expand_placeholders(
"{repo}/{desc}",
"api-{type}",
Some("fix"),
Some("42"),
Some("foo"),
None,
)
.unwrap();
assert_eq!(
out, "api-{type}/foo",
"the repo name is a value; the `{{type}}` inside it is not a placeholder"
);
for token in ["{type}", "{issue}", "{desc}", "{repo}", "{home}"] {
let out = expand_placeholders(
"{repo}-x",
&format!("api-{token}"),
Some("fix"),
Some("42"),
Some("foo"),
None,
)
.unwrap();
assert_eq!(
out,
format!("api-{token}-x"),
"`{}` arrived through the repo name, so it is data",
token
);
}
}
#[cfg(unix)]
#[test]
fn a_token_produced_by_expanding_home_stays_literal() {
let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
let previous = std::env::var("HOME").ok();
unsafe { std::env::set_var("HOME", "/tmp/h-{issue}") };
let out = expand_placeholders("{home}/wt/{desc}", "r", Some("fix"), Some("42"), Some("foo"), None);
match previous {
Some(v) => unsafe { std::env::set_var("HOME", v) },
None => unsafe { std::env::remove_var("HOME") },
}
assert_eq!(out.unwrap(), "/tmp/h-{issue}/wt/foo");
}
#[test]
fn a_token_with_no_value_survives_the_single_pass_verbatim() {
let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
assert_eq!(
expand_placeholders("{repo_parent}/{type}/{issue}/{desc}", "r", None, None, None, None).unwrap(),
"{repo_parent}/{type}/{issue}/{desc}"
);
assert_eq!(
expand_placeholders("{nope}/{repo}", "r", None, None, None, None).unwrap(),
"{nope}/r"
);
assert_eq!(
expand_placeholders("{repo}/{unclosed", "r", None, None, None, None).unwrap(),
"r/{unclosed"
);
}
#[test]
fn a_repo_path_without_a_parent_resolves_only_the_path_token() {
let _guard = env_lock().lock().unwrap_or_else(|e| e.into_inner());
let root = std::path::Path::new("/");
assert!(root.parent().is_none(), "the fixture must actually have no parent");
let out = expand_placeholders("{repo_path}|{repo_parent}", "r", None, None, None, Some(root)).unwrap();
assert_eq!(out, "/|{repo_parent}");
}