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/// Get the AI tool command with an initial prompt for interactive delegation.
449///
450/// Appends the prompt as a positional argument so the AI tool starts in interactive mode
451/// with the given task. For Claude Code: `claude "<prompt>"` starts interactive with initial prompt.
452pub fn get_ai_tool_delegate_command(prompt: &str) -> Result<Vec<String>> {
453    let mut cmd = get_ai_tool_command()?;
454    if cmd.is_empty() {
455        return Ok(cmd);
456    }
457    cmd.push(prompt.to_string());
458    Ok(cmd)
459}
460
461/// Check if the currently configured AI tool is Claude-based.
462pub fn is_claude_tool() -> Result<bool> {
463    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
464        let first_word = env_tool.split_whitespace().next().unwrap_or("");
465        return Ok(first_word == "claude");
466    }
467    let config = load_config()?;
468    Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
469}
470
471/// Set the AI tool command in configuration.
472pub fn set_ai_tool(tool: &str, args: Option<Vec<String>>) -> Result<()> {
473    let mut config = load_config()?;
474    config.ai_tool.command = tool.to_string();
475    config.ai_tool.args = args.unwrap_or_default();
476    save_config(&config)
477}
478
479/// Use a predefined AI tool preset.
480pub fn use_preset(preset_name: &str) -> Result<()> {
481    let presets = ai_tool_presets();
482    if !presets.contains_key(preset_name) {
483        let available: Vec<&str> = presets.keys().copied().collect();
484        return Err(CwError::Config(format!(
485            "Unknown preset: {}. Available: {}",
486            preset_name,
487            available.join(", ")
488        )));
489    }
490    set_ai_tool(preset_name, None)
491}
492
493/// Reset configuration to defaults.
494pub fn reset_config() -> Result<()> {
495    save_config(&Config::default())
496}
497
498/// Resolve a launch method value (possibly an alias) to its display name.
499pub fn resolve_launch_display_name(method: &str) -> String {
500    let aliases = launch_method_aliases();
501    let canonical = aliases.get(method).copied().unwrap_or(method);
502    LaunchMethod::from_str_opt(canonical)
503        .map(|m| format!("{} ({})", m.display_name(), method))
504        .unwrap_or_else(|| method.to_string())
505}
506
507/// All known configuration keys with descriptions.
508pub const CONFIG_KEYS: &[(&str, &str)] = &[
509    (
510        "ai_tool.command",
511        "AI tool command name (e.g., claude, codex)",
512    ),
513    ("ai_tool.args", "Additional arguments passed to AI tool"),
514    (
515        "launch.method",
516        "Terminal launch method (foreground, tmux, wezterm, ...)",
517    ),
518    (
519        "launch.tmux_session_prefix",
520        "Prefix for tmux session names",
521    ),
522    (
523        "launch.wezterm_ready_timeout",
524        "Timeout (seconds) waiting for WezTerm",
525    ),
526    (
527        "update.auto_check",
528        "Automatically check for updates on startup",
529    ),
530    (
531        "shell_completion.prompted",
532        "Whether shell completion setup was prompted",
533    ),
534    (
535        "shell_completion.installed",
536        "Whether shell completion is installed",
537    ),
538];
539
540/// List all configuration keys with their current values and descriptions.
541pub fn list_config() -> Result<()> {
542    use console::style;
543
544    let config = load_config()?;
545    let json = serde_json::to_value(&config)?;
546
547    println!();
548    println!(
549        "  {:<35} {:<25} {}",
550        style("KEY").dim(),
551        style("VALUE").dim(),
552        style("DESCRIPTION").dim(),
553    );
554    println!("  {}", style("─".repeat(90)).dim());
555
556    for (key, desc) in CONFIG_KEYS {
557        let keys: Vec<&str> = key.split('.').collect();
558        let mut current = &json;
559        let mut found = true;
560        for &k in &keys {
561            match current.get(k) {
562                Some(v) => current = v,
563                None => {
564                    found = false;
565                    break;
566                }
567            }
568        }
569
570        let value_str = if !found {
571            style("(unset)".to_string()).dim().to_string()
572        } else {
573            let raw = match current {
574                serde_json::Value::String(s) => s.clone(),
575                serde_json::Value::Bool(b) => b.to_string(),
576                serde_json::Value::Number(n) => n.to_string(),
577                serde_json::Value::Null => "null".to_string(),
578                serde_json::Value::Array(a) => {
579                    if a.is_empty() {
580                        "[]".to_string()
581                    } else {
582                        serde_json::to_string(a).unwrap_or_default()
583                    }
584                }
585                other => serde_json::to_string(other).unwrap_or_default(),
586            };
587            // Show display name for launch.method
588            if *key == "launch.method" && raw != "null" {
589                resolve_launch_display_name(&raw)
590            } else {
591                raw
592            }
593        };
594
595        println!(
596            "  {:<35} {:<25} {}",
597            style(key).bold(),
598            value_str,
599            style(desc).dim(),
600        );
601    }
602    println!();
603
604    Ok(())
605}
606
607/// Get a configuration value by dot-separated key path.
608pub fn get_config_value(key_path: &str) -> Result<()> {
609    let config = load_config()?;
610    let json = serde_json::to_value(&config)?;
611
612    let keys: Vec<&str> = key_path.split('.').collect();
613    let mut current = &json;
614    for &key in &keys {
615        current = current
616            .get(key)
617            .ok_or_else(|| CwError::Config(format!("Unknown config key: {}", key_path)))?;
618    }
619
620    match current {
621        serde_json::Value::String(s) => println!("{}", s),
622        serde_json::Value::Bool(b) => println!("{}", b),
623        serde_json::Value::Number(n) => println!("{}", n),
624        serde_json::Value::Null => println!("null"),
625        other => println!(
626            "{}",
627            serde_json::to_string_pretty(other).unwrap_or_default()
628        ),
629    }
630
631    Ok(())
632}
633
634/// Set a configuration value by dot-separated key path.
635///
636/// Special handling:
637/// - `ai_tool <preset>` — sets command + args from preset (e.g., `ai_tool claude-yolo`)
638/// - `launch.method <alias>` — resolves alias (e.g., `w-t` → `wezterm-tab`)
639pub fn set_config_value(key_path: &str, value: &str) -> Result<()> {
640    // Shortcut: "ai_tool <preset_name>" applies the preset
641    if key_path == "ai_tool" {
642        let presets = ai_tool_presets();
643        if presets.contains_key(value) {
644            return use_preset(value);
645        }
646        // Not a preset — treat as raw command name
647        return set_ai_tool(value, None);
648    }
649
650    // Shortcut: resolve launch method aliases
651    if key_path == "launch.method" {
652        let aliases = launch_method_aliases();
653        let canonical = aliases.get(value).copied().unwrap_or(value);
654        // Validate
655        if value != "null"
656            && LaunchMethod::from_str_opt(canonical).is_none()
657            && LaunchMethod::from_str_opt(value).is_none()
658        {
659            return Err(CwError::Config(format!(
660                "Unknown launch method: '{}'. Use 'gw config list-presets' or 'gw --help' for options.",
661                value
662            )));
663        }
664    }
665
666    let mut config = load_config()?;
667    let mut json = serde_json::to_value(&config)?;
668
669    let keys: Vec<&str> = key_path.split('.').collect();
670
671    // Convert string boolean values
672    let json_value: Value = match value.to_lowercase().as_str() {
673        "true" => Value::Bool(true),
674        "false" => Value::Bool(false),
675        _ => {
676            // Try to parse as number
677            if let Ok(n) = value.parse::<f64>() {
678                serde_json::Number::from_f64(n)
679                    .map(Value::Number)
680                    .unwrap_or(Value::String(value.to_string()))
681            } else {
682                Value::String(value.to_string())
683            }
684        }
685    };
686
687    // Navigate to parent and set value
688    let mut current = &mut json;
689    for &key in &keys[..keys.len() - 1] {
690        if !current.is_object() {
691            return Err(CwError::Config(format!(
692                "Invalid config path: {}",
693                key_path
694            )));
695        }
696        current = current
697            .as_object_mut()
698            .ok_or_else(|| CwError::Config(format!("Invalid config path: {}", key_path)))?
699            .entry(key)
700            .or_insert(Value::Object(serde_json::Map::new()));
701    }
702
703    if let Some(obj) = current.as_object_mut() {
704        obj.insert(keys[keys.len() - 1].to_string(), json_value);
705    } else {
706        return Err(CwError::Config(format!(
707            "Invalid config path: {}",
708            key_path
709        )));
710    }
711
712    // Deserialize back to Config and save
713    config = serde_json::from_value(json)
714        .map_err(|e| CwError::Config(format!("Invalid config value: {}", e)))?;
715    save_config(&config)
716}
717
718/// Get a formatted string of the current configuration.
719pub fn show_config() -> Result<String> {
720    let config = load_config()?;
721    let mut lines = Vec::new();
722
723    lines.push("Current configuration:".to_string());
724    lines.push(String::new());
725    lines.push(format!("  AI Tool: {}", config.ai_tool.command));
726
727    if !config.ai_tool.args.is_empty() {
728        lines.push(format!("    Args: {}", config.ai_tool.args.join(" ")));
729    }
730
731    let cmd = get_ai_tool_command()?;
732    lines.push(format!("    Effective command: {}", cmd.join(" ")));
733    lines.push(String::new());
734
735    if let Some(ref method) = config.launch.method {
736        let display = resolve_launch_display_name(method);
737        lines.push(format!("  Launch method: {}", display));
738    } else {
739        lines.push("  Launch method: Foreground (default)".to_string());
740    }
741
742    // Show auto-detected default branch for current repo
743    let detected = crate::git::detect_default_branch(None);
744    lines.push(format!(
745        "  Default base branch: {} (auto-detected)",
746        detected,
747    ));
748    lines.push(String::new());
749    lines.push(format!("Config file: {}", get_config_path().display()));
750
751    Ok(lines.join("\n"))
752}
753
754/// Get a formatted list of available presets.
755pub fn list_presets() -> String {
756    let presets = ai_tool_presets();
757    let mut lines = vec!["Available AI tool presets:".to_string(), String::new()];
758
759    let mut preset_names: Vec<&str> = presets.keys().copied().collect();
760    preset_names.sort();
761
762    for name in preset_names {
763        let cmd = presets[name].join(" ");
764        lines.push(format!("  {:<20} -> {}", name, cmd));
765    }
766
767    lines.join("\n")
768}
769
770// ---------------------------------------------------------------------------
771// Shell completion prompt
772// ---------------------------------------------------------------------------
773
774/// Check if shell integration (gw-cd) is already installed in the user's profile.
775fn is_shell_integration_installed() -> bool {
776    let home = home_dir_or_fallback();
777    let shell_env = std::env::var("SHELL").unwrap_or_default();
778
779    let profile_path = if shell_env.contains("zsh") {
780        home.join(".zshrc")
781    } else if shell_env.contains("bash") {
782        home.join(".bashrc")
783    } else if shell_env.contains("fish") {
784        home.join(".config").join("fish").join("config.fish")
785    } else {
786        return false;
787    };
788
789    if let Ok(content) = std::fs::read_to_string(&profile_path) {
790        content.contains("gw _shell-function") || content.contains("gw-cd")
791    } else {
792        false
793    }
794}
795
796/// Prompt user to set up shell integration on first run.
797///
798/// Shows a one-time hint if:
799/// - Shell integration is not already installed
800/// - User has not been prompted before
801///
802/// Updates `shell_completion.prompted` in config after showing.
803pub fn prompt_shell_completion_setup() {
804    let config = match load_config() {
805        Ok(c) => c,
806        Err(_) => return,
807    };
808
809    if config.shell_completion.prompted || config.shell_completion.installed {
810        return;
811    }
812
813    if is_shell_integration_installed() {
814        // Already installed — mark both flags and skip
815        let mut config = config;
816        config.shell_completion.prompted = true;
817        config.shell_completion.installed = true;
818        let _ = save_config(&config);
819        return;
820    }
821
822    // Show one-time hint
823    eprintln!(
824        "\n{} Shell integration (gw-cd + tab completion) is not set up.",
825        console::style("Tip:").cyan().bold()
826    );
827    eprintln!(
828        "     Run {} to enable directory navigation and completions.\n",
829        console::style("gw shell-setup").cyan()
830    );
831
832    // Mark as prompted
833    let mut config = config;
834    config.shell_completion.prompted = true;
835    let _ = save_config(&config);
836}
837
838// ---------------------------------------------------------------------------
839// Launch method configuration
840// ---------------------------------------------------------------------------
841
842/// Resolve launch method alias to full name.
843pub fn resolve_launch_alias(value: &str) -> String {
844    let deprecated: HashMap<&str, &str> =
845        HashMap::from([("bg", "detach"), ("background", "detach")]);
846    let aliases = launch_method_aliases();
847
848    // Handle session name suffix (e.g., "t:mysession")
849    if let Some((prefix, suffix)) = value.split_once(':') {
850        let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
851            eprintln!(
852                "Warning: '{}' is deprecated. Use '{}' instead.",
853                prefix, new
854            );
855            new.to_string()
856        } else {
857            aliases
858                .get(prefix)
859                .map(|s| s.to_string())
860                .unwrap_or_else(|| prefix.to_string())
861        };
862        return format!("{}:{}", resolved_prefix, suffix);
863    }
864
865    if let Some(&new) = deprecated.get(value) {
866        eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
867        return new.to_string();
868    }
869
870    aliases
871        .get(value)
872        .map(|s| s.to_string())
873        .unwrap_or_else(|| value.to_string())
874}
875
876/// Parse --term option value.
877///
878/// Returns (LaunchMethod, optional_session_name).
879pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
880    let term_value = match term_value {
881        Some(v) => v,
882        None => return Ok((get_default_launch_method()?, None)),
883    };
884
885    let resolved = resolve_launch_alias(term_value);
886
887    if let Some((method_str, session_name)) = resolved.split_once(':') {
888        let method = LaunchMethod::from_str_opt(method_str)
889            .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
890
891        if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
892            if session_name.len() > MAX_SESSION_NAME_LENGTH {
893                return Err(CwError::Config(format!(
894                    "Session name too long (max {} chars): {}",
895                    MAX_SESSION_NAME_LENGTH, session_name
896                )));
897            }
898            return Ok((method, Some(session_name.to_string())));
899        } else {
900            return Err(CwError::Config(format!(
901                "Session name not supported for {}",
902                method_str
903            )));
904        }
905    }
906
907    let method = LaunchMethod::from_str_opt(&resolved)
908        .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
909    Ok((method, None))
910}
911
912/// Get default launch method from config or environment.
913pub fn get_default_launch_method() -> Result<LaunchMethod> {
914    // 1. Environment variable
915    if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
916        let resolved = resolve_launch_alias(&env_val);
917        if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
918            return Ok(method);
919        }
920    }
921
922    // 2. Config file
923    let config = load_config()?;
924    if let Some(ref method) = config.launch.method {
925        let resolved = resolve_launch_alias(method);
926        if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
927            return Ok(m);
928        }
929    }
930
931    Ok(LaunchMethod::Foreground)
932}
933
934#[cfg(test)]
935mod tests {
936    use super::*;
937
938    #[test]
939    fn test_default_config() {
940        let config = Config::default();
941        assert_eq!(config.ai_tool.command, "claude");
942        assert!(config.ai_tool.args.is_empty());
943        assert!(config.update.auto_check);
944    }
945
946    #[test]
947    fn test_resolve_launch_alias() {
948        assert_eq!(resolve_launch_alias("fg"), "foreground");
949        assert_eq!(resolve_launch_alias("t"), "tmux");
950        assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
951        assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
952        assert_eq!(resolve_launch_alias("foreground"), "foreground");
953    }
954
955    #[test]
956    fn test_parse_term_option() {
957        let (method, session) = parse_term_option(Some("t")).unwrap();
958        assert_eq!(method, LaunchMethod::Tmux);
959        assert!(session.is_none());
960
961        let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
962        assert_eq!(method, LaunchMethod::Tmux);
963        assert_eq!(session.unwrap(), "mywork");
964
965        let (method, session) = parse_term_option(Some("i-t")).unwrap();
966        assert_eq!(method, LaunchMethod::ItermTab);
967        assert!(session.is_none());
968    }
969
970    #[test]
971    fn test_preset_names() {
972        let presets = ai_tool_presets();
973        assert!(presets.contains_key("claude"));
974        assert!(presets.contains_key("no-op"));
975        assert!(presets.contains_key("codex"));
976        assert_eq!(presets["no-op"].len(), 0);
977        assert_eq!(presets["claude"], vec!["claude"]);
978    }
979
980    #[test]
981    fn test_list_presets_format() {
982        let output = list_presets();
983        assert!(output.contains("Available AI tool presets:"));
984        assert!(output.contains("claude"));
985        assert!(output.contains("no-op"));
986    }
987}