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