Skip to main content

git_worktree_manager/
config.rs

1/// Configuration management for git-worktree-manager.
2///
3/// Supports multiple AI coding assistants with customizable commands.
4/// Configuration stored in ~/.config/git-worktree-manager/config.json.
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::constants::{
12    home_dir_or_fallback, launch_method_aliases, LaunchMethod, MAX_SESSION_NAME_LENGTH,
13};
14use crate::error::{CwError, Result};
15
16/// Typed configuration structure matching the JSON schema.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Config {
19    pub ai_tool: AiToolConfig,
20    pub launch: LaunchConfig,
21    pub git: GitConfig,
22    pub update: UpdateConfig,
23    pub shell_completion: ShellCompletionConfig,
24    #[serde(default)]
25    pub hooks: HookConfig,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AiToolConfig {
30    pub command: String,
31    pub args: Vec<String>,
32    /// Inject the `gw guard` PreToolUse(Bash) hook into Claude sessions
33    /// launched by gw. Default true. Has no effect when the configured
34    /// AI tool isn't Claude.
35    #[serde(default = "default_guard")]
36    pub guard: bool,
37}
38
39fn default_guard() -> bool {
40    true
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct LaunchConfig {
45    pub method: Option<String>,
46    pub tmux_session_prefix: String,
47    pub wezterm_ready_timeout: f64,
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct GitConfig {
52    // default_base_branch removed — auto-detected per repo via git::detect_default_branch()
53}
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct HookConfig {
57    #[serde(default)]
58    pub post_new: Option<String>,
59    #[serde(default)]
60    pub pre_rm: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct UpdateConfig {
65    pub auto_check: bool,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ShellCompletionConfig {
70    pub prompted: bool,
71    pub installed: bool,
72}
73
74impl Default for Config {
75    fn default() -> Self {
76        Self {
77            ai_tool: AiToolConfig {
78                command: "claude".to_string(),
79                args: Vec::new(),
80                guard: true,
81            },
82            launch: LaunchConfig {
83                method: None,
84                tmux_session_prefix: "gw".to_string(),
85                wezterm_ready_timeout: 5.0,
86            },
87            git: GitConfig {},
88            update: UpdateConfig { auto_check: true },
89            shell_completion: ShellCompletionConfig {
90                prompted: false,
91                installed: false,
92            },
93            hooks: HookConfig::default(),
94        }
95    }
96}
97
98/// AI tool presets: preset name -> command parts.
99pub fn ai_tool_presets() -> HashMap<&'static str, Vec<&'static str>> {
100    HashMap::from([
101        ("no-op", vec![]),
102        ("claude", vec!["claude"]),
103        (
104            "claude-yolo",
105            vec!["claude", "--dangerously-skip-permissions"],
106        ),
107        ("claude-remote", vec!["claude", "/remote-control"]),
108        (
109            "claude-yolo-remote",
110            vec![
111                "claude",
112                "--dangerously-skip-permissions",
113                "/remote-control",
114            ],
115        ),
116        ("codex", vec!["codex"]),
117        (
118            "codex-yolo",
119            vec!["codex", "--dangerously-bypass-approvals-and-sandbox"],
120        ),
121    ])
122}
123
124/// AI tool resume presets.
125pub fn ai_tool_resume_presets() -> HashMap<&'static str, Vec<&'static str>> {
126    HashMap::from([
127        ("claude", vec!["claude", "--continue"]),
128        (
129            "claude-yolo",
130            vec!["claude", "--dangerously-skip-permissions", "--continue"],
131        ),
132        (
133            "claude-remote",
134            vec!["claude", "--continue", "/remote-control"],
135        ),
136        (
137            "claude-yolo-remote",
138            vec![
139                "claude",
140                "--dangerously-skip-permissions",
141                "--continue",
142                "/remote-control",
143            ],
144        ),
145        ("codex", vec!["codex", "resume", "--last"]),
146        (
147            "codex-yolo",
148            vec![
149                "codex",
150                "resume",
151                "--dangerously-bypass-approvals-and-sandbox",
152                "--last",
153            ],
154        ),
155    ])
156}
157
158/// Set of Claude-based preset names.
159pub fn claude_preset_names() -> Vec<&'static str> {
160    ai_tool_presets()
161        .iter()
162        .filter(|(_, v)| v.first().map(|&s| s == "claude").unwrap_or(false))
163        .map(|(&k, _)| k)
164        .collect()
165}
166
167// ---------------------------------------------------------------------------
168// Config file I/O
169// ---------------------------------------------------------------------------
170
171/// Get the path to the configuration file.
172pub fn get_config_path() -> PathBuf {
173    let home = home_dir_or_fallback();
174    home.join(".config")
175        .join("git-worktree-manager")
176        .join("config.json")
177}
178
179/// Deep merge: override takes precedence, nested dicts merged recursively.
180fn deep_merge(base: Value, over: Value) -> Value {
181    match (base, over) {
182        (Value::Object(mut base_map), Value::Object(over_map)) => {
183            for (key, over_val) in over_map {
184                let merged = if let Some(base_val) = base_map.remove(&key) {
185                    deep_merge(base_val, over_val)
186                } else {
187                    over_val
188                };
189                base_map.insert(key, merged);
190            }
191            Value::Object(base_map)
192        }
193        (_, over) => over,
194    }
195}
196
197/// Get the path to the legacy Python configuration file.
198fn get_legacy_config_path() -> PathBuf {
199    let home = home_dir_or_fallback();
200    home.join(".config")
201        .join("claude-worktree")
202        .join("config.json")
203}
204
205/// Load configuration from file, deep-merged with defaults.
206/// Falls back to legacy Python config path if the new path doesn't exist.
207pub fn load_config() -> Result<Config> {
208    let config_path = get_config_path();
209
210    let config_path = if config_path.exists() {
211        config_path
212    } else {
213        let legacy = get_legacy_config_path();
214        if legacy.exists() {
215            legacy
216        } else {
217            return Ok(Config::default());
218        }
219    };
220
221    let content = std::fs::read_to_string(&config_path).map_err(|e| {
222        CwError::Config(format!(
223            "Failed to load config from {}: {}",
224            config_path.display(),
225            e
226        ))
227    })?;
228
229    let file_value: Value = serde_json::from_str(&content).map_err(|e| {
230        CwError::Config(format!(
231            "Failed to parse config from {}: {}",
232            config_path.display(),
233            e
234        ))
235    })?;
236
237    let default_value = serde_json::to_value(Config::default())?;
238    let merged = deep_merge(default_value, file_value);
239
240    serde_json::from_value(merged).map_err(|e| {
241        CwError::Config(format!(
242            "Failed to deserialize config from {}: {}",
243            config_path.display(),
244            e
245        ))
246    })
247}
248
249/// Load configuration with the repo-local `.cwconfig.json` (if any) deep-merged
250/// over the global config. Layer order (lowest to highest precedence):
251///
252///   1. `Config::default()`
253///   2. Global `~/.config/git-worktree-manager/config.json` (or legacy path)
254///   3. Repo-local `.cwconfig.json` walked up from `cwd`
255///
256/// Returns `Config::default()` if no config files are found.
257pub fn load_effective_config(cwd: &Path) -> Result<Config> {
258    load_effective_config_with_global(cwd, &get_config_path())
259}
260
261/// Same as [`load_effective_config`] but accepts an explicit global config path.
262/// Carved out so tests can drive the global layer without setting `HOME`.
263pub fn load_effective_config_with_global(cwd: &Path, global_path: &Path) -> Result<Config> {
264    let mut merged = serde_json::to_value(Config::default())?;
265
266    // Layer 2: global file (or legacy fallback if explicit path absent).
267    let global_value = if global_path.exists() {
268        Some(read_json_value(global_path)?)
269    } else if global_path == get_config_path().as_path() {
270        // Only fall through to legacy when the caller passed the *default* path —
271        // tests that pass an explicit synthetic path should not pick up the user's
272        // real legacy file.
273        let legacy = get_legacy_config_path();
274        if legacy.exists() {
275            Some(read_json_value(&legacy)?)
276        } else {
277            None
278        }
279    } else {
280        None
281    };
282    if let Some(v) = global_value {
283        merged = deep_merge(merged, v);
284    }
285
286    // Layer 3: repo-local `.cwconfig.json` walked up from cwd.
287    if let Some(repo_value) = crate::repo_config::load_repo_config(cwd)? {
288        merged = deep_merge(merged, repo_value);
289    }
290
291    serde_json::from_value(merged)
292        .map_err(|e| CwError::Config(format!("invalid effective config: {}", e)))
293}
294
295fn read_json_value(path: &Path) -> Result<serde_json::Value> {
296    let content = std::fs::read_to_string(path).map_err(|e| {
297        CwError::Config(format!(
298            "failed to load config from {}: {}",
299            path.display(),
300            e
301        ))
302    })?;
303    serde_json::from_str(&content).map_err(|e| {
304        CwError::Config(format!(
305            "failed to parse config from {}: {}",
306            path.display(),
307            e
308        ))
309    })
310}
311
312/// Save configuration to file.
313pub fn save_config(config: &Config) -> Result<()> {
314    let config_path = get_config_path();
315    if let Some(parent) = config_path.parent() {
316        std::fs::create_dir_all(parent)?;
317    }
318
319    let content = serde_json::to_string_pretty(config)?;
320    std::fs::write(&config_path, content).map_err(|e| {
321        CwError::Config(format!(
322            "Failed to save config to {}: {}",
323            config_path.display(),
324            e
325        ))
326    })
327}
328
329/// Get the AI tool command to execute.
330///
331/// Priority: CW_AI_TOOL env > config file > default ("claude").
332pub fn get_ai_tool_command() -> Result<Vec<String>> {
333    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
334    get_ai_tool_command_for_cwd(&cwd)
335}
336
337/// Like [`get_ai_tool_command`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `ai_tool` block.
338pub fn get_ai_tool_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
339    // Check environment variable first
340    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
341        if env_tool.trim().is_empty() {
342            return Ok(Vec::new());
343        }
344        return Ok(env_tool.split_whitespace().map(String::from).collect());
345    }
346
347    let config = load_effective_config(cwd)?;
348    let command = &config.ai_tool.command;
349    let args = &config.ai_tool.args;
350
351    let presets = ai_tool_presets();
352    if let Some(base_cmd) = presets.get(command.as_str()) {
353        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
354        cmd.extend(args.iter().cloned());
355        return Ok(cmd);
356    }
357
358    if command.trim().is_empty() {
359        return Ok(Vec::new());
360    }
361
362    let mut cmd = vec![command.clone()];
363    cmd.extend(args.iter().cloned());
364    Ok(cmd)
365}
366
367/// Get the AI tool resume command.
368pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
369    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
370    get_ai_tool_resume_command_for_cwd(&cwd)
371}
372
373/// Like [`get_ai_tool_resume_command`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `ai_tool` block.
374pub fn get_ai_tool_resume_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
375    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
376        if env_tool.trim().is_empty() {
377            return Ok(Vec::new());
378        }
379        let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
380        parts.push("--resume".to_string());
381        return Ok(parts);
382    }
383
384    let config = load_effective_config(cwd)?;
385    let command = &config.ai_tool.command;
386    let args = &config.ai_tool.args;
387
388    if command.trim().is_empty() {
389        return Ok(Vec::new());
390    }
391
392    let resume_presets = ai_tool_resume_presets();
393    if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
394        let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
395        cmd.extend(args.iter().cloned());
396        return Ok(cmd);
397    }
398
399    let presets = ai_tool_presets();
400    if let Some(base_cmd) = presets.get(command.as_str()) {
401        if base_cmd.is_empty() {
402            return Ok(Vec::new());
403        }
404        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
405        cmd.extend(args.iter().cloned());
406        cmd.push("--resume".to_string());
407        return Ok(cmd);
408    }
409
410    let mut cmd = vec![command.clone()];
411    cmd.extend(args.iter().cloned());
412    cmd.push("--resume".to_string());
413    Ok(cmd)
414}
415
416/// Check if the currently configured AI tool is Claude-based.
417pub fn is_claude_tool() -> Result<bool> {
418    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
419    is_claude_tool_for_cwd(&cwd)
420}
421
422/// Like [`is_claude_tool`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `ai_tool` block.
423pub fn is_claude_tool_for_cwd(cwd: &Path) -> Result<bool> {
424    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
425        let first_word = env_tool.split_whitespace().next().unwrap_or("");
426        return Ok(first_word == "claude");
427    }
428    let config = load_effective_config(cwd)?;
429    Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
430}
431
432/// Resolve a launch method value (possibly an alias) to its display name.
433pub fn resolve_launch_display_name(method: &str) -> String {
434    let aliases = launch_method_aliases();
435    let canonical = aliases.get(method).copied().unwrap_or(method);
436    LaunchMethod::from_str_opt(canonical)
437        .map(|m| format!("{} ({})", m.display_name(), method))
438        .unwrap_or_else(|| method.to_string())
439}
440
441// ---------------------------------------------------------------------------
442// Shell completion prompt
443// ---------------------------------------------------------------------------
444
445/// Check if shell integration (gw-cd) is already installed in the user's profile.
446fn is_shell_integration_installed() -> bool {
447    let home = home_dir_or_fallback();
448    let shell_env = std::env::var("SHELL").unwrap_or_default();
449
450    let profile_path = if shell_env.contains("zsh") {
451        home.join(".zshrc")
452    } else if shell_env.contains("bash") {
453        home.join(".bashrc")
454    } else if shell_env.contains("fish") {
455        home.join(".config").join("fish").join("config.fish")
456    } else {
457        return false;
458    };
459
460    if let Ok(content) = std::fs::read_to_string(&profile_path) {
461        content.contains("gw _shell-function") || content.contains("gw-cd")
462    } else {
463        false
464    }
465}
466
467/// Prompt user to set up shell integration on first run.
468///
469/// Shows a one-time hint if:
470/// - Shell integration is not already installed
471/// - User has not been prompted before
472///
473/// Updates `shell_completion.prompted` in config after showing.
474pub fn prompt_shell_completion_setup() {
475    let config = match load_config() {
476        Ok(c) => c,
477        Err(_) => return,
478    };
479
480    if config.shell_completion.prompted || config.shell_completion.installed {
481        return;
482    }
483
484    if is_shell_integration_installed() {
485        // Already installed — mark both flags and skip
486        let mut config = config;
487        config.shell_completion.prompted = true;
488        config.shell_completion.installed = true;
489        let _ = save_config(&config);
490        return;
491    }
492
493    // Show one-time hint
494    eprintln!(
495        "\n{} Shell integration (gw-cd + tab completion) is not set up.",
496        console::style("Tip:").cyan().bold()
497    );
498    eprintln!(
499        "     Run {} to enable directory navigation and completions.\n",
500        console::style("gw shell-setup").cyan()
501    );
502
503    // Mark as prompted
504    let mut config = config;
505    config.shell_completion.prompted = true;
506    let _ = save_config(&config);
507}
508
509// ---------------------------------------------------------------------------
510// Launch method configuration
511// ---------------------------------------------------------------------------
512
513/// Resolve launch method alias to full name.
514pub fn resolve_launch_alias(value: &str) -> String {
515    let deprecated: HashMap<&str, &str> =
516        HashMap::from([("bg", "detach"), ("background", "detach")]);
517    let aliases = launch_method_aliases();
518
519    // Handle session name suffix (e.g., "t:mysession")
520    if let Some((prefix, suffix)) = value.split_once(':') {
521        let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
522            eprintln!(
523                "Warning: '{}' is deprecated. Use '{}' instead.",
524                prefix, new
525            );
526            new.to_string()
527        } else {
528            aliases
529                .get(prefix)
530                .map(|s| s.to_string())
531                .unwrap_or_else(|| prefix.to_string())
532        };
533        return format!("{}:{}", resolved_prefix, suffix);
534    }
535
536    if let Some(&new) = deprecated.get(value) {
537        eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
538        return new.to_string();
539    }
540
541    aliases
542        .get(value)
543        .map(|s| s.to_string())
544        .unwrap_or_else(|| value.to_string())
545}
546
547/// Parse --term option value.
548///
549/// Returns (LaunchMethod, optional_session_name).
550pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
551    let term_value = match term_value {
552        Some(v) => v,
553        None => return Ok((get_default_launch_method()?, None)),
554    };
555
556    let resolved = resolve_launch_alias(term_value);
557
558    if let Some((method_str, session_name)) = resolved.split_once(':') {
559        let method = LaunchMethod::from_str_opt(method_str)
560            .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
561
562        if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
563            if session_name.len() > MAX_SESSION_NAME_LENGTH {
564                return Err(CwError::Config(format!(
565                    "Session name too long (max {} chars): {}",
566                    MAX_SESSION_NAME_LENGTH, session_name
567                )));
568            }
569            return Ok((method, Some(session_name.to_string())));
570        } else {
571            return Err(CwError::Config(format!(
572                "Session name not supported for {}",
573                method_str
574            )));
575        }
576    }
577
578    let method = LaunchMethod::from_str_opt(&resolved)
579        .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
580    Ok((method, None))
581}
582
583/// Get default launch method from config or environment.
584pub fn get_default_launch_method() -> Result<LaunchMethod> {
585    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
586    get_default_launch_method_for_cwd(&cwd)
587}
588
589/// Like [`get_default_launch_method`] but resolves config from `cwd` so a repo-local `.cwconfig.json` can override the global `launch` block.
590pub fn get_default_launch_method_for_cwd(cwd: &Path) -> Result<LaunchMethod> {
591    // 1. Environment variable
592    if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
593        let resolved = resolve_launch_alias(&env_val);
594        if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
595            return Ok(method);
596        }
597    }
598
599    // 2. Config file
600    let config = load_effective_config(cwd)?;
601    if let Some(ref method) = config.launch.method {
602        let resolved = resolve_launch_alias(method);
603        if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
604            return Ok(m);
605        }
606    }
607
608    Ok(LaunchMethod::Foreground)
609}
610
611/// Resolve a launch method honoring an optional CLI override.
612///
613/// Resolution order:
614///   1. `term_override` (the `-T/--term` CLI flag), if `Some` — parsed via
615///      [`parse_term_option`], so it understands aliases and the
616///      `method:session-name` syntax.
617///   2. `CW_LAUNCH_METHOD` env var.
618///   3. `.cwconfig.json` (repo-local) `launch.method`.
619///   4. global `~/.config/git-worktree-manager/config.json` `launch.method`.
620///   5. [`LaunchMethod::Foreground`] (the default).
621///
622/// Returns `(method, session_name)` where `session_name` is `Some` only
623/// when the override used the `method:session-name` syntax (env / config
624/// paths can't carry a session name today).
625pub fn resolve_term_option(
626    term_override: Option<&str>,
627    cwd: &Path,
628) -> Result<(LaunchMethod, Option<String>)> {
629    if let Some(value) = term_override {
630        // Override path: parse_term_option handles aliases and
631        // method:session syntax. Falls through to the env/config chain
632        // below only when the override is None — that chain needs `cwd`,
633        // which parse_term_option does not take.
634        return parse_term_option(Some(value));
635    }
636    let method = get_default_launch_method_for_cwd(cwd)?;
637    Ok((method, None))
638}
639
640#[cfg(test)]
641mod tests {
642    use super::*;
643
644    #[test]
645    fn test_default_config() {
646        let config = Config::default();
647        assert_eq!(config.ai_tool.command, "claude");
648        assert!(config.ai_tool.args.is_empty());
649        assert!(config.update.auto_check);
650    }
651
652    #[test]
653    fn test_resolve_launch_alias() {
654        assert_eq!(resolve_launch_alias("fg"), "foreground");
655        assert_eq!(resolve_launch_alias("t"), "tmux");
656        assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
657        assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
658        assert_eq!(resolve_launch_alias("foreground"), "foreground");
659    }
660
661    #[test]
662    fn test_parse_term_option() {
663        let (method, session) = parse_term_option(Some("t")).unwrap();
664        assert_eq!(method, LaunchMethod::Tmux);
665        assert!(session.is_none());
666
667        let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
668        assert_eq!(method, LaunchMethod::Tmux);
669        assert_eq!(session.unwrap(), "mywork");
670
671        let (method, session) = parse_term_option(Some("i-t")).unwrap();
672        assert_eq!(method, LaunchMethod::ItermTab);
673        assert!(session.is_none());
674    }
675
676    #[test]
677    fn test_preset_names() {
678        let presets = ai_tool_presets();
679        assert!(presets.contains_key("claude"));
680        assert!(presets.contains_key("no-op"));
681        assert!(presets.contains_key("codex"));
682        assert_eq!(presets["no-op"].len(), 0);
683        assert_eq!(presets["claude"], vec!["claude"]);
684    }
685
686    /// Regression guard for the bug where `spawn_in_worktree(prompt=Some)`
687    /// mistakenly used the merge preset (which injects `--print
688    /// --tools=default`) and left users staring at a blank pane while claude
689    /// ran headlessly. The interactive presets must never carry `--print` or
690    /// any other non-interactive automation flag.
691    #[test]
692    fn interactive_presets_have_no_print_flag() {
693        let presets = ai_tool_presets();
694        for (name, args) in &presets {
695            for forbidden in ["--print", "--tools=default", "--non-interactive"] {
696                assert!(
697                    !args.contains(&forbidden),
698                    "interactive preset {:?} must not carry {} (would force \
699                     headless mode for `gw new --prompt`); got {:?}",
700                    name,
701                    forbidden,
702                    args
703                );
704            }
705        }
706    }
707}