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