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::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}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct AiToolConfig {
28    pub command: String,
29    pub args: Vec<String>,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct LaunchConfig {
34    pub method: Option<String>,
35    pub tmux_session_prefix: String,
36    pub wezterm_ready_timeout: f64,
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
40pub struct GitConfig {
41    // default_base_branch removed — auto-detected per repo via git::detect_default_branch()
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct UpdateConfig {
46    pub auto_check: bool,
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
50pub struct ShellCompletionConfig {
51    pub prompted: bool,
52    pub installed: bool,
53}
54
55impl Default for Config {
56    fn default() -> Self {
57        Self {
58            ai_tool: AiToolConfig {
59                command: "claude".to_string(),
60                args: Vec::new(),
61            },
62            launch: LaunchConfig {
63                method: None,
64                tmux_session_prefix: "gw".to_string(),
65                wezterm_ready_timeout: 5.0,
66            },
67            git: GitConfig {},
68            update: UpdateConfig { auto_check: true },
69            shell_completion: ShellCompletionConfig {
70                prompted: false,
71                installed: false,
72            },
73        }
74    }
75}
76
77/// AI tool presets: preset name -> command parts.
78pub fn ai_tool_presets() -> HashMap<&'static str, Vec<&'static str>> {
79    HashMap::from([
80        ("no-op", vec![]),
81        ("claude", vec!["claude"]),
82        (
83            "claude-yolo",
84            vec!["claude", "--dangerously-skip-permissions"],
85        ),
86        ("claude-remote", vec!["claude", "/remote-control"]),
87        (
88            "claude-yolo-remote",
89            vec![
90                "claude",
91                "--dangerously-skip-permissions",
92                "/remote-control",
93            ],
94        ),
95        ("codex", vec!["codex"]),
96        (
97            "codex-yolo",
98            vec!["codex", "--dangerously-bypass-approvals-and-sandbox"],
99        ),
100    ])
101}
102
103/// AI tool resume presets.
104pub fn ai_tool_resume_presets() -> HashMap<&'static str, Vec<&'static str>> {
105    HashMap::from([
106        ("claude", vec!["claude", "--continue"]),
107        (
108            "claude-yolo",
109            vec!["claude", "--dangerously-skip-permissions", "--continue"],
110        ),
111        (
112            "claude-remote",
113            vec!["claude", "--continue", "/remote-control"],
114        ),
115        (
116            "claude-yolo-remote",
117            vec![
118                "claude",
119                "--dangerously-skip-permissions",
120                "--continue",
121                "/remote-control",
122            ],
123        ),
124        ("codex", vec!["codex", "resume", "--last"]),
125        (
126            "codex-yolo",
127            vec![
128                "codex",
129                "resume",
130                "--dangerously-bypass-approvals-and-sandbox",
131                "--last",
132            ],
133        ),
134    ])
135}
136
137/// Merge preset configuration.
138#[derive(Debug)]
139pub struct MergePreset {
140    pub base_override: Option<Vec<&'static str>>,
141    pub flags: Vec<&'static str>,
142    pub prompt_position: PromptPosition,
143}
144
145#[derive(Debug)]
146pub enum PromptPosition {
147    End,
148    Index(usize),
149}
150
151/// AI tool merge presets.
152pub fn ai_tool_merge_presets() -> HashMap<&'static str, MergePreset> {
153    HashMap::from([
154        (
155            "claude",
156            MergePreset {
157                base_override: None,
158                flags: vec!["--print", "--tools=default"],
159                prompt_position: PromptPosition::End,
160            },
161        ),
162        (
163            "claude-yolo",
164            MergePreset {
165                base_override: None,
166                flags: vec!["--print", "--tools=default"],
167                prompt_position: PromptPosition::End,
168            },
169        ),
170        (
171            "claude-remote",
172            MergePreset {
173                base_override: Some(vec!["claude"]),
174                flags: vec!["--print", "--tools=default"],
175                prompt_position: PromptPosition::End,
176            },
177        ),
178        (
179            "claude-yolo-remote",
180            MergePreset {
181                base_override: Some(vec!["claude", "--dangerously-skip-permissions"]),
182                flags: vec!["--print", "--tools=default"],
183                prompt_position: PromptPosition::End,
184            },
185        ),
186        (
187            "codex",
188            MergePreset {
189                base_override: None,
190                flags: vec!["--non-interactive"],
191                prompt_position: PromptPosition::End,
192            },
193        ),
194        (
195            "codex-yolo",
196            MergePreset {
197                base_override: None,
198                flags: vec!["--non-interactive"],
199                prompt_position: PromptPosition::End,
200            },
201        ),
202    ])
203}
204
205/// Set of Claude-based preset names.
206pub fn claude_preset_names() -> Vec<&'static str> {
207    ai_tool_presets()
208        .iter()
209        .filter(|(_, v)| v.first().map(|&s| s == "claude").unwrap_or(false))
210        .map(|(&k, _)| k)
211        .collect()
212}
213
214// ---------------------------------------------------------------------------
215// Config file I/O
216// ---------------------------------------------------------------------------
217
218/// Get the path to the configuration file.
219pub fn get_config_path() -> PathBuf {
220    let home = home_dir_or_fallback();
221    home.join(".config")
222        .join("git-worktree-manager")
223        .join("config.json")
224}
225
226/// Deep merge: override takes precedence, nested dicts merged recursively.
227fn deep_merge(base: Value, over: Value) -> Value {
228    match (base, over) {
229        (Value::Object(mut base_map), Value::Object(over_map)) => {
230            for (key, over_val) in over_map {
231                let merged = if let Some(base_val) = base_map.remove(&key) {
232                    deep_merge(base_val, over_val)
233                } else {
234                    over_val
235                };
236                base_map.insert(key, merged);
237            }
238            Value::Object(base_map)
239        }
240        (_, over) => over,
241    }
242}
243
244/// Get the path to the legacy Python configuration file.
245fn get_legacy_config_path() -> PathBuf {
246    let home = home_dir_or_fallback();
247    home.join(".config")
248        .join("claude-worktree")
249        .join("config.json")
250}
251
252/// Load configuration from file, deep-merged with defaults.
253/// Falls back to legacy Python config path if the new path doesn't exist.
254pub fn load_config() -> Result<Config> {
255    let config_path = get_config_path();
256
257    let config_path = if config_path.exists() {
258        config_path
259    } else {
260        let legacy = get_legacy_config_path();
261        if legacy.exists() {
262            legacy
263        } else {
264            return Ok(Config::default());
265        }
266    };
267
268    let content = std::fs::read_to_string(&config_path).map_err(|e| {
269        CwError::Config(format!(
270            "Failed to load config from {}: {}",
271            config_path.display(),
272            e
273        ))
274    })?;
275
276    let file_value: Value = serde_json::from_str(&content).map_err(|e| {
277        CwError::Config(format!(
278            "Failed to parse config from {}: {}",
279            config_path.display(),
280            e
281        ))
282    })?;
283
284    let default_value = serde_json::to_value(Config::default())?;
285    let merged = deep_merge(default_value, file_value);
286
287    serde_json::from_value(merged).map_err(|e| {
288        CwError::Config(format!(
289            "Failed to deserialize config from {}: {}",
290            config_path.display(),
291            e
292        ))
293    })
294}
295
296/// Save configuration to file.
297pub fn save_config(config: &Config) -> Result<()> {
298    let config_path = get_config_path();
299    if let Some(parent) = config_path.parent() {
300        std::fs::create_dir_all(parent)?;
301    }
302
303    let content = serde_json::to_string_pretty(config)?;
304    std::fs::write(&config_path, content).map_err(|e| {
305        CwError::Config(format!(
306            "Failed to save config to {}: {}",
307            config_path.display(),
308            e
309        ))
310    })
311}
312
313/// Get the AI tool command to execute.
314///
315/// Priority: CW_AI_TOOL env > config file > default ("claude").
316pub fn get_ai_tool_command() -> Result<Vec<String>> {
317    // Check environment variable first
318    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
319        if env_tool.trim().is_empty() {
320            return Ok(Vec::new());
321        }
322        return Ok(env_tool.split_whitespace().map(String::from).collect());
323    }
324
325    let config = load_config()?;
326    let command = &config.ai_tool.command;
327    let args = &config.ai_tool.args;
328
329    let presets = ai_tool_presets();
330    if let Some(base_cmd) = presets.get(command.as_str()) {
331        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
332        cmd.extend(args.iter().cloned());
333        return Ok(cmd);
334    }
335
336    if command.trim().is_empty() {
337        return Ok(Vec::new());
338    }
339
340    let mut cmd = vec![command.clone()];
341    cmd.extend(args.iter().cloned());
342    Ok(cmd)
343}
344
345/// Get the AI tool resume command.
346pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
347    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
348        if env_tool.trim().is_empty() {
349            return Ok(Vec::new());
350        }
351        let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
352        parts.push("--resume".to_string());
353        return Ok(parts);
354    }
355
356    let config = load_config()?;
357    let command = &config.ai_tool.command;
358    let args = &config.ai_tool.args;
359
360    if command.trim().is_empty() {
361        return Ok(Vec::new());
362    }
363
364    let resume_presets = ai_tool_resume_presets();
365    if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
366        let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
367        cmd.extend(args.iter().cloned());
368        return Ok(cmd);
369    }
370
371    let presets = ai_tool_presets();
372    if let Some(base_cmd) = presets.get(command.as_str()) {
373        if base_cmd.is_empty() {
374            return Ok(Vec::new());
375        }
376        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
377        cmd.extend(args.iter().cloned());
378        cmd.push("--resume".to_string());
379        return Ok(cmd);
380    }
381
382    let mut cmd = vec![command.clone()];
383    cmd.extend(args.iter().cloned());
384    cmd.push("--resume".to_string());
385    Ok(cmd)
386}
387
388/// Get the AI tool merge command.
389pub fn get_ai_tool_merge_command(prompt: &str) -> Result<Vec<String>> {
390    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
391        if env_tool.trim().is_empty() {
392            return Ok(Vec::new());
393        }
394        let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
395        parts.push(prompt.to_string());
396        return Ok(parts);
397    }
398
399    let config = load_config()?;
400    let command = &config.ai_tool.command;
401    let args = &config.ai_tool.args;
402
403    if command.trim().is_empty() {
404        return Ok(Vec::new());
405    }
406
407    let merge_presets = ai_tool_merge_presets();
408    if let Some(preset) = merge_presets.get(command.as_str()) {
409        let base_cmd: Vec<String> = if let Some(ref base_override) = preset.base_override {
410            base_override.iter().map(|s| s.to_string()).collect()
411        } else {
412            let presets = ai_tool_presets();
413            presets
414                .get(command.as_str())
415                .map(|v| v.iter().map(|s| s.to_string()).collect())
416                .unwrap_or_else(|| vec![command.clone()])
417        };
418
419        let mut cmd_parts = base_cmd;
420        cmd_parts.extend(args.iter().cloned());
421        cmd_parts.extend(preset.flags.iter().map(|s| s.to_string()));
422
423        match preset.prompt_position {
424            PromptPosition::End => cmd_parts.push(prompt.to_string()),
425            PromptPosition::Index(i) => cmd_parts.insert(i, prompt.to_string()),
426        }
427
428        return Ok(cmd_parts);
429    }
430
431    let presets = ai_tool_presets();
432    if let Some(base_cmd) = presets.get(command.as_str()) {
433        if base_cmd.is_empty() {
434            return Ok(Vec::new());
435        }
436        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
437        cmd.extend(args.iter().cloned());
438        cmd.push(prompt.to_string());
439        return Ok(cmd);
440    }
441
442    let mut cmd = vec![command.clone()];
443    cmd.extend(args.iter().cloned());
444    cmd.push(prompt.to_string());
445    Ok(cmd)
446}
447
448/// Check if the currently configured AI tool is Claude-based.
449pub fn is_claude_tool() -> Result<bool> {
450    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
451        let first_word = env_tool.split_whitespace().next().unwrap_or("");
452        return Ok(first_word == "claude");
453    }
454    let config = load_config()?;
455    Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
456}
457
458/// Set the AI tool command in configuration.
459pub fn set_ai_tool(tool: &str, args: Option<Vec<String>>) -> Result<()> {
460    let mut config = load_config()?;
461    config.ai_tool.command = tool.to_string();
462    config.ai_tool.args = args.unwrap_or_default();
463    save_config(&config)
464}
465
466/// Use a predefined AI tool preset.
467pub fn use_preset(preset_name: &str) -> Result<()> {
468    let presets = ai_tool_presets();
469    if !presets.contains_key(preset_name) {
470        let available: Vec<&str> = presets.keys().copied().collect();
471        return Err(CwError::Config(format!(
472            "Unknown preset: {}. Available: {}",
473            preset_name,
474            available.join(", ")
475        )));
476    }
477    set_ai_tool(preset_name, None)
478}
479
480/// Reset configuration to defaults.
481pub fn reset_config() -> Result<()> {
482    save_config(&Config::default())
483}
484
485/// Resolve a launch method value (possibly an alias) to its display name.
486pub fn resolve_launch_display_name(method: &str) -> String {
487    let aliases = launch_method_aliases();
488    let canonical = aliases.get(method).copied().unwrap_or(method);
489    LaunchMethod::from_str_opt(canonical)
490        .map(|m| format!("{} ({})", m.display_name(), method))
491        .unwrap_or_else(|| method.to_string())
492}
493
494/// All known configuration keys with descriptions.
495pub const CONFIG_KEYS: &[(&str, &str)] = &[
496    (
497        "ai_tool.command",
498        "AI tool command name (e.g., claude, codex)",
499    ),
500    ("ai_tool.args", "Additional arguments passed to AI tool"),
501    (
502        "launch.method",
503        "Terminal launch method (foreground, tmux, wezterm, ...)",
504    ),
505    (
506        "launch.tmux_session_prefix",
507        "Prefix for tmux session names",
508    ),
509    (
510        "launch.wezterm_ready_timeout",
511        "Timeout (seconds) waiting for WezTerm",
512    ),
513    (
514        "update.auto_check",
515        "Automatically check for updates on startup",
516    ),
517    (
518        "shell_completion.prompted",
519        "Whether shell completion setup was prompted",
520    ),
521    (
522        "shell_completion.installed",
523        "Whether shell completion is installed",
524    ),
525];
526
527/// List all configuration keys with their current values and descriptions.
528pub fn list_config() -> Result<()> {
529    use console::style;
530
531    let config = load_config()?;
532    let json = serde_json::to_value(&config)?;
533
534    println!();
535    println!(
536        "  {:<35} {:<25} {}",
537        style("KEY").dim(),
538        style("VALUE").dim(),
539        style("DESCRIPTION").dim(),
540    );
541    println!("  {}", style("─".repeat(90)).dim());
542
543    for (key, desc) in CONFIG_KEYS {
544        let keys: Vec<&str> = key.split('.').collect();
545        let mut current = &json;
546        let mut found = true;
547        for &k in &keys {
548            match current.get(k) {
549                Some(v) => current = v,
550                None => {
551                    found = false;
552                    break;
553                }
554            }
555        }
556
557        let value_str = if !found {
558            style("(unset)".to_string()).dim().to_string()
559        } else {
560            let raw = match current {
561                serde_json::Value::String(s) => s.clone(),
562                serde_json::Value::Bool(b) => b.to_string(),
563                serde_json::Value::Number(n) => n.to_string(),
564                serde_json::Value::Null => "null".to_string(),
565                serde_json::Value::Array(a) => {
566                    if a.is_empty() {
567                        "[]".to_string()
568                    } else {
569                        serde_json::to_string(a).unwrap_or_default()
570                    }
571                }
572                other => serde_json::to_string(other).unwrap_or_default(),
573            };
574            // Show display name for launch.method
575            if *key == "launch.method" && raw != "null" {
576                resolve_launch_display_name(&raw)
577            } else {
578                raw
579            }
580        };
581
582        println!(
583            "  {:<35} {:<25} {}",
584            style(key).bold(),
585            value_str,
586            style(desc).dim(),
587        );
588    }
589    println!();
590
591    Ok(())
592}
593
594/// Get a configuration value by dot-separated key path.
595pub fn get_config_value(key_path: &str) -> Result<()> {
596    let config = load_config()?;
597    let json = serde_json::to_value(&config)?;
598
599    let keys: Vec<&str> = key_path.split('.').collect();
600    let mut current = &json;
601    for &key in &keys {
602        current = current
603            .get(key)
604            .ok_or_else(|| CwError::Config(format!("Unknown config key: {}", key_path)))?;
605    }
606
607    match current {
608        serde_json::Value::String(s) => println!("{}", s),
609        serde_json::Value::Bool(b) => println!("{}", b),
610        serde_json::Value::Number(n) => println!("{}", n),
611        serde_json::Value::Null => println!("null"),
612        other => println!(
613            "{}",
614            serde_json::to_string_pretty(other).unwrap_or_default()
615        ),
616    }
617
618    Ok(())
619}
620
621/// Set a configuration value by dot-separated key path.
622///
623/// Special handling:
624/// - `ai_tool <preset>` — sets command + args from preset (e.g., `ai_tool claude-yolo`)
625/// - `launch.method <alias>` — resolves alias (e.g., `w-t` → `wezterm-tab`)
626pub fn set_config_value(key_path: &str, value: &str) -> Result<()> {
627    // Shortcut: "ai_tool <preset_name>" applies the preset
628    if key_path == "ai_tool" {
629        let presets = ai_tool_presets();
630        if presets.contains_key(value) {
631            return use_preset(value);
632        }
633        // Not a preset — treat as raw command name
634        return set_ai_tool(value, None);
635    }
636
637    // Shortcut: resolve launch method aliases
638    if key_path == "launch.method" {
639        let aliases = launch_method_aliases();
640        let canonical = aliases.get(value).copied().unwrap_or(value);
641        // Validate
642        if value != "null"
643            && LaunchMethod::from_str_opt(canonical).is_none()
644            && LaunchMethod::from_str_opt(value).is_none()
645        {
646            return Err(CwError::Config(format!(
647                "Unknown launch method: '{}'. Use 'gw config list-presets' or 'gw --help' for options.",
648                value
649            )));
650        }
651    }
652
653    let mut config = load_config()?;
654    let mut json = serde_json::to_value(&config)?;
655
656    let keys: Vec<&str> = key_path.split('.').collect();
657
658    // Convert string boolean values
659    let json_value: Value = match value.to_lowercase().as_str() {
660        "true" => Value::Bool(true),
661        "false" => Value::Bool(false),
662        _ => {
663            // Try to parse as number
664            if let Ok(n) = value.parse::<f64>() {
665                serde_json::Number::from_f64(n)
666                    .map(Value::Number)
667                    .unwrap_or(Value::String(value.to_string()))
668            } else {
669                Value::String(value.to_string())
670            }
671        }
672    };
673
674    // Navigate to parent and set value
675    let mut current = &mut json;
676    for &key in &keys[..keys.len() - 1] {
677        if !current.is_object() {
678            return Err(CwError::Config(format!(
679                "Invalid config path: {}",
680                key_path
681            )));
682        }
683        current = current
684            .as_object_mut()
685            .ok_or_else(|| CwError::Config(format!("Invalid config path: {}", key_path)))?
686            .entry(key)
687            .or_insert(Value::Object(serde_json::Map::new()));
688    }
689
690    if let Some(obj) = current.as_object_mut() {
691        obj.insert(keys[keys.len() - 1].to_string(), json_value);
692    } else {
693        return Err(CwError::Config(format!(
694            "Invalid config path: {}",
695            key_path
696        )));
697    }
698
699    // Deserialize back to Config and save
700    config = serde_json::from_value(json)
701        .map_err(|e| CwError::Config(format!("Invalid config value: {}", e)))?;
702    save_config(&config)
703}
704
705/// Get a formatted string of the current configuration.
706pub fn show_config() -> Result<String> {
707    let config = load_config()?;
708    let mut lines = Vec::new();
709
710    lines.push("Current configuration:".to_string());
711    lines.push(String::new());
712    lines.push(format!("  AI Tool: {}", config.ai_tool.command));
713
714    if !config.ai_tool.args.is_empty() {
715        lines.push(format!("    Args: {}", config.ai_tool.args.join(" ")));
716    }
717
718    let cmd = get_ai_tool_command()?;
719    lines.push(format!("    Effective command: {}", cmd.join(" ")));
720    lines.push(String::new());
721
722    if let Some(ref method) = config.launch.method {
723        let display = resolve_launch_display_name(method);
724        lines.push(format!("  Launch method: {}", display));
725    } else {
726        lines.push("  Launch method: Foreground (default)".to_string());
727    }
728
729    // Show auto-detected default branch for current repo
730    let detected = crate::git::detect_default_branch(None);
731    lines.push(format!(
732        "  Default base branch: {} (auto-detected)",
733        detected,
734    ));
735    lines.push(String::new());
736    lines.push(format!("Config file: {}", get_config_path().display()));
737
738    Ok(lines.join("\n"))
739}
740
741/// Get a formatted list of available presets.
742pub fn list_presets() -> String {
743    let presets = ai_tool_presets();
744    let mut lines = vec!["Available AI tool presets:".to_string(), String::new()];
745
746    let mut preset_names: Vec<&str> = presets.keys().copied().collect();
747    preset_names.sort();
748
749    for name in preset_names {
750        let cmd = presets[name].join(" ");
751        lines.push(format!("  {:<20} -> {}", name, cmd));
752    }
753
754    lines.join("\n")
755}
756
757// ---------------------------------------------------------------------------
758// Shell completion prompt
759// ---------------------------------------------------------------------------
760
761/// Check if shell integration (gw-cd) is already installed in the user's profile.
762fn is_shell_integration_installed() -> bool {
763    let home = home_dir_or_fallback();
764    let shell_env = std::env::var("SHELL").unwrap_or_default();
765
766    let profile_path = if shell_env.contains("zsh") {
767        home.join(".zshrc")
768    } else if shell_env.contains("bash") {
769        home.join(".bashrc")
770    } else if shell_env.contains("fish") {
771        home.join(".config").join("fish").join("config.fish")
772    } else {
773        return false;
774    };
775
776    if let Ok(content) = std::fs::read_to_string(&profile_path) {
777        content.contains("gw _shell-function") || content.contains("gw-cd")
778    } else {
779        false
780    }
781}
782
783/// Prompt user to set up shell integration on first run.
784///
785/// Shows a one-time hint if:
786/// - Shell integration is not already installed
787/// - User has not been prompted before
788///
789/// Updates `shell_completion.prompted` in config after showing.
790pub fn prompt_shell_completion_setup() {
791    let config = match load_config() {
792        Ok(c) => c,
793        Err(_) => return,
794    };
795
796    if config.shell_completion.prompted || config.shell_completion.installed {
797        return;
798    }
799
800    if is_shell_integration_installed() {
801        // Already installed — mark both flags and skip
802        let mut config = config;
803        config.shell_completion.prompted = true;
804        config.shell_completion.installed = true;
805        let _ = save_config(&config);
806        return;
807    }
808
809    // Show one-time hint
810    eprintln!(
811        "\n{} Shell integration (gw-cd + tab completion) is not set up.",
812        console::style("Tip:").cyan().bold()
813    );
814    eprintln!(
815        "     Run {} to enable directory navigation and completions.\n",
816        console::style("gw shell-setup").cyan()
817    );
818
819    // Mark as prompted
820    let mut config = config;
821    config.shell_completion.prompted = true;
822    let _ = save_config(&config);
823}
824
825// ---------------------------------------------------------------------------
826// Launch method configuration
827// ---------------------------------------------------------------------------
828
829/// Resolve launch method alias to full name.
830pub fn resolve_launch_alias(value: &str) -> String {
831    let deprecated: HashMap<&str, &str> =
832        HashMap::from([("bg", "detach"), ("background", "detach")]);
833    let aliases = launch_method_aliases();
834
835    // Handle session name suffix (e.g., "t:mysession")
836    if let Some((prefix, suffix)) = value.split_once(':') {
837        let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
838            eprintln!(
839                "Warning: '{}' is deprecated. Use '{}' instead.",
840                prefix, new
841            );
842            new.to_string()
843        } else {
844            aliases
845                .get(prefix)
846                .map(|s| s.to_string())
847                .unwrap_or_else(|| prefix.to_string())
848        };
849        return format!("{}:{}", resolved_prefix, suffix);
850    }
851
852    if let Some(&new) = deprecated.get(value) {
853        eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
854        return new.to_string();
855    }
856
857    aliases
858        .get(value)
859        .map(|s| s.to_string())
860        .unwrap_or_else(|| value.to_string())
861}
862
863/// Parse --term option value.
864///
865/// Returns (LaunchMethod, optional_session_name).
866pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
867    let term_value = match term_value {
868        Some(v) => v,
869        None => return Ok((get_default_launch_method()?, None)),
870    };
871
872    let resolved = resolve_launch_alias(term_value);
873
874    if let Some((method_str, session_name)) = resolved.split_once(':') {
875        let method = LaunchMethod::from_str_opt(method_str)
876            .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
877
878        if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
879            if session_name.len() > MAX_SESSION_NAME_LENGTH {
880                return Err(CwError::Config(format!(
881                    "Session name too long (max {} chars): {}",
882                    MAX_SESSION_NAME_LENGTH, session_name
883                )));
884            }
885            return Ok((method, Some(session_name.to_string())));
886        } else {
887            return Err(CwError::Config(format!(
888                "Session name not supported for {}",
889                method_str
890            )));
891        }
892    }
893
894    let method = LaunchMethod::from_str_opt(&resolved)
895        .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
896    Ok((method, None))
897}
898
899/// Get default launch method from config or environment.
900pub fn get_default_launch_method() -> Result<LaunchMethod> {
901    // 1. Environment variable
902    if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
903        let resolved = resolve_launch_alias(&env_val);
904        if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
905            return Ok(method);
906        }
907    }
908
909    // 2. Config file
910    let config = load_config()?;
911    if let Some(ref method) = config.launch.method {
912        let resolved = resolve_launch_alias(method);
913        if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
914            return Ok(m);
915        }
916    }
917
918    Ok(LaunchMethod::Foreground)
919}
920
921#[cfg(test)]
922mod tests {
923    use super::*;
924
925    #[test]
926    fn test_default_config() {
927        let config = Config::default();
928        assert_eq!(config.ai_tool.command, "claude");
929        assert!(config.ai_tool.args.is_empty());
930        assert!(config.update.auto_check);
931    }
932
933    #[test]
934    fn test_resolve_launch_alias() {
935        assert_eq!(resolve_launch_alias("fg"), "foreground");
936        assert_eq!(resolve_launch_alias("t"), "tmux");
937        assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
938        assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
939        assert_eq!(resolve_launch_alias("foreground"), "foreground");
940    }
941
942    #[test]
943    fn test_parse_term_option() {
944        let (method, session) = parse_term_option(Some("t")).unwrap();
945        assert_eq!(method, LaunchMethod::Tmux);
946        assert!(session.is_none());
947
948        let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
949        assert_eq!(method, LaunchMethod::Tmux);
950        assert_eq!(session.unwrap(), "mywork");
951
952        let (method, session) = parse_term_option(Some("i-t")).unwrap();
953        assert_eq!(method, LaunchMethod::ItermTab);
954        assert!(session.is_none());
955    }
956
957    #[test]
958    fn test_preset_names() {
959        let presets = ai_tool_presets();
960        assert!(presets.contains_key("claude"));
961        assert!(presets.contains_key("no-op"));
962        assert!(presets.contains_key("codex"));
963        assert_eq!(presets["no-op"].len(), 0);
964        assert_eq!(presets["claude"], vec!["claude"]);
965    }
966
967    #[test]
968    fn test_list_presets_format() {
969        let output = list_presets();
970        assert!(output.contains("Available AI tool presets:"));
971        assert!(output.contains("claude"));
972        assert!(output.contains("no-op"));
973    }
974}