Skip to main content

git_worktree_manager/
config.rs

1/// Configuration management for git-worktree-manager.
2///
3/// Mirrors src/git_worktree_manager/config.py (596 lines).
4/// Supports multiple AI coding assistants with customizable commands.
5/// Configuration stored in ~/.config/git-worktree-manager/config.json.
6use std::collections::HashMap;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::constants::{launch_method_aliases, LaunchMethod, MAX_SESSION_NAME_LENGTH};
13use crate::error::{CwError, Result};
14
15/// Typed configuration structure matching the JSON schema.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Config {
18    pub ai_tool: AiToolConfig,
19    pub launch: LaunchConfig,
20    pub git: GitConfig,
21    pub update: UpdateConfig,
22    pub shell_completion: ShellCompletionConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AiToolConfig {
27    pub command: String,
28    pub args: Vec<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct LaunchConfig {
33    pub method: Option<String>,
34    pub tmux_session_prefix: String,
35    pub wezterm_ready_timeout: f64,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct GitConfig {
40    pub default_base_branch: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct UpdateConfig {
45    pub auto_check: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ShellCompletionConfig {
50    pub prompted: bool,
51    pub installed: bool,
52}
53
54impl Default for Config {
55    fn default() -> Self {
56        Self {
57            ai_tool: AiToolConfig {
58                command: "claude".to_string(),
59                args: Vec::new(),
60            },
61            launch: LaunchConfig {
62                method: None,
63                tmux_session_prefix: "gw".to_string(),
64                wezterm_ready_timeout: 5.0,
65            },
66            git: GitConfig {
67                default_base_branch: "main".to_string(),
68            },
69            update: UpdateConfig { auto_check: true },
70            shell_completion: ShellCompletionConfig {
71                prompted: false,
72                installed: false,
73            },
74        }
75    }
76}
77
78/// AI tool presets: preset name -> command parts.
79pub fn ai_tool_presets() -> HashMap<&'static str, Vec<&'static str>> {
80    HashMap::from([
81        ("no-op", vec![]),
82        ("claude", vec!["claude"]),
83        (
84            "claude-yolo",
85            vec!["claude", "--dangerously-skip-permissions"],
86        ),
87        ("claude-remote", vec!["claude", "/remote-control"]),
88        (
89            "claude-yolo-remote",
90            vec![
91                "claude",
92                "--dangerously-skip-permissions",
93                "/remote-control",
94            ],
95        ),
96        ("codex", vec!["codex"]),
97        (
98            "codex-yolo",
99            vec!["codex", "--dangerously-bypass-approvals-and-sandbox"],
100        ),
101    ])
102}
103
104/// AI tool resume presets.
105pub fn ai_tool_resume_presets() -> HashMap<&'static str, Vec<&'static str>> {
106    HashMap::from([
107        ("claude", vec!["claude", "--continue"]),
108        (
109            "claude-yolo",
110            vec!["claude", "--dangerously-skip-permissions", "--continue"],
111        ),
112        (
113            "claude-remote",
114            vec!["claude", "--continue", "/remote-control"],
115        ),
116        (
117            "claude-yolo-remote",
118            vec![
119                "claude",
120                "--dangerously-skip-permissions",
121                "--continue",
122                "/remote-control",
123            ],
124        ),
125        ("codex", vec!["codex", "resume", "--last"]),
126        (
127            "codex-yolo",
128            vec![
129                "codex",
130                "resume",
131                "--dangerously-bypass-approvals-and-sandbox",
132                "--last",
133            ],
134        ),
135    ])
136}
137
138/// Merge preset configuration.
139#[derive(Debug)]
140pub struct MergePreset {
141    pub base_override: Option<Vec<&'static str>>,
142    pub flags: Vec<&'static str>,
143    pub prompt_position: PromptPosition,
144}
145
146#[derive(Debug)]
147pub enum PromptPosition {
148    End,
149    Index(usize),
150}
151
152/// AI tool merge presets.
153pub fn ai_tool_merge_presets() -> HashMap<&'static str, MergePreset> {
154    HashMap::from([
155        (
156            "claude",
157            MergePreset {
158                base_override: None,
159                flags: vec!["--print", "--tools=default"],
160                prompt_position: PromptPosition::End,
161            },
162        ),
163        (
164            "claude-yolo",
165            MergePreset {
166                base_override: None,
167                flags: vec!["--print", "--tools=default"],
168                prompt_position: PromptPosition::End,
169            },
170        ),
171        (
172            "claude-remote",
173            MergePreset {
174                base_override: Some(vec!["claude"]),
175                flags: vec!["--print", "--tools=default"],
176                prompt_position: PromptPosition::End,
177            },
178        ),
179        (
180            "claude-yolo-remote",
181            MergePreset {
182                base_override: Some(vec!["claude", "--dangerously-skip-permissions"]),
183                flags: vec!["--print", "--tools=default"],
184                prompt_position: PromptPosition::End,
185            },
186        ),
187        (
188            "codex",
189            MergePreset {
190                base_override: None,
191                flags: vec!["--non-interactive"],
192                prompt_position: PromptPosition::End,
193            },
194        ),
195        (
196            "codex-yolo",
197            MergePreset {
198                base_override: None,
199                flags: vec!["--non-interactive"],
200                prompt_position: PromptPosition::End,
201            },
202        ),
203    ])
204}
205
206/// Set of Claude-based preset names.
207pub fn claude_preset_names() -> Vec<&'static str> {
208    ai_tool_presets()
209        .iter()
210        .filter(|(_, v)| v.first().map(|&s| s == "claude").unwrap_or(false))
211        .map(|(&k, _)| k)
212        .collect()
213}
214
215// ---------------------------------------------------------------------------
216// Config file I/O
217// ---------------------------------------------------------------------------
218
219/// Get the path to the configuration file.
220pub fn get_config_path() -> PathBuf {
221    let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
222    home.join(".config")
223        .join("git-worktree-manager")
224        .join("config.json")
225}
226
227/// Deep merge: override takes precedence, nested dicts merged recursively.
228fn deep_merge(base: Value, over: Value) -> Value {
229    match (base, over) {
230        (Value::Object(mut base_map), Value::Object(over_map)) => {
231            for (key, over_val) in over_map {
232                let merged = if let Some(base_val) = base_map.remove(&key) {
233                    deep_merge(base_val, over_val)
234                } else {
235                    over_val
236                };
237                base_map.insert(key, merged);
238            }
239            Value::Object(base_map)
240        }
241        (_, over) => over,
242    }
243}
244
245/// Load configuration from file, deep-merged with defaults.
246pub fn load_config() -> Result<Config> {
247    let config_path = get_config_path();
248
249    if !config_path.exists() {
250        return Ok(Config::default());
251    }
252
253    let content = std::fs::read_to_string(&config_path).map_err(|e| {
254        CwError::Config(format!(
255            "Failed to load config from {}: {}",
256            config_path.display(),
257            e
258        ))
259    })?;
260
261    let file_value: Value = serde_json::from_str(&content).map_err(|e| {
262        CwError::Config(format!(
263            "Failed to parse config from {}: {}",
264            config_path.display(),
265            e
266        ))
267    })?;
268
269    let default_value = serde_json::to_value(Config::default())?;
270    let merged = deep_merge(default_value, file_value);
271
272    serde_json::from_value(merged).map_err(|e| {
273        CwError::Config(format!(
274            "Failed to deserialize config from {}: {}",
275            config_path.display(),
276            e
277        ))
278    })
279}
280
281/// Save configuration to file.
282pub fn save_config(config: &Config) -> Result<()> {
283    let config_path = get_config_path();
284    if let Some(parent) = config_path.parent() {
285        std::fs::create_dir_all(parent)?;
286    }
287
288    let content = serde_json::to_string_pretty(config)?;
289    std::fs::write(&config_path, content).map_err(|e| {
290        CwError::Config(format!(
291            "Failed to save config to {}: {}",
292            config_path.display(),
293            e
294        ))
295    })
296}
297
298/// Get the AI tool command to execute.
299///
300/// Priority: CW_AI_TOOL env > config file > default ("claude").
301pub fn get_ai_tool_command() -> Result<Vec<String>> {
302    // Check environment variable first
303    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
304        if env_tool.trim().is_empty() {
305            return Ok(Vec::new());
306        }
307        return Ok(env_tool.split_whitespace().map(String::from).collect());
308    }
309
310    let config = load_config()?;
311    let command = &config.ai_tool.command;
312    let args = &config.ai_tool.args;
313
314    let presets = ai_tool_presets();
315    if let Some(base_cmd) = presets.get(command.as_str()) {
316        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
317        cmd.extend(args.iter().cloned());
318        return Ok(cmd);
319    }
320
321    if command.trim().is_empty() {
322        return Ok(Vec::new());
323    }
324
325    let mut cmd = vec![command.clone()];
326    cmd.extend(args.iter().cloned());
327    Ok(cmd)
328}
329
330/// Get the AI tool resume command.
331pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
332    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
333        if env_tool.trim().is_empty() {
334            return Ok(Vec::new());
335        }
336        let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
337        parts.push("--resume".to_string());
338        return Ok(parts);
339    }
340
341    let config = load_config()?;
342    let command = &config.ai_tool.command;
343    let args = &config.ai_tool.args;
344
345    if command.trim().is_empty() {
346        return Ok(Vec::new());
347    }
348
349    let resume_presets = ai_tool_resume_presets();
350    if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
351        let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
352        cmd.extend(args.iter().cloned());
353        return Ok(cmd);
354    }
355
356    let presets = ai_tool_presets();
357    if let Some(base_cmd) = presets.get(command.as_str()) {
358        if base_cmd.is_empty() {
359            return Ok(Vec::new());
360        }
361        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
362        cmd.extend(args.iter().cloned());
363        cmd.push("--resume".to_string());
364        return Ok(cmd);
365    }
366
367    let mut cmd = vec![command.clone()];
368    cmd.extend(args.iter().cloned());
369    cmd.push("--resume".to_string());
370    Ok(cmd)
371}
372
373/// Get the AI tool merge command.
374pub fn get_ai_tool_merge_command(prompt: &str) -> Result<Vec<String>> {
375    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
376        if env_tool.trim().is_empty() {
377            return Ok(Vec::new());
378        }
379        let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
380        parts.push(prompt.to_string());
381        return Ok(parts);
382    }
383
384    let config = load_config()?;
385    let command = &config.ai_tool.command;
386    let args = &config.ai_tool.args;
387
388    if command.trim().is_empty() {
389        return Ok(Vec::new());
390    }
391
392    let merge_presets = ai_tool_merge_presets();
393    if let Some(preset) = merge_presets.get(command.as_str()) {
394        let base_cmd: Vec<String> = if let Some(ref base_override) = preset.base_override {
395            base_override.iter().map(|s| s.to_string()).collect()
396        } else {
397            let presets = ai_tool_presets();
398            presets
399                .get(command.as_str())
400                .map(|v| v.iter().map(|s| s.to_string()).collect())
401                .unwrap_or_else(|| vec![command.clone()])
402        };
403
404        let mut cmd_parts = base_cmd;
405        cmd_parts.extend(args.iter().cloned());
406        cmd_parts.extend(preset.flags.iter().map(|s| s.to_string()));
407
408        match preset.prompt_position {
409            PromptPosition::End => cmd_parts.push(prompt.to_string()),
410            PromptPosition::Index(i) => cmd_parts.insert(i, prompt.to_string()),
411        }
412
413        return Ok(cmd_parts);
414    }
415
416    let presets = ai_tool_presets();
417    if let Some(base_cmd) = presets.get(command.as_str()) {
418        if base_cmd.is_empty() {
419            return Ok(Vec::new());
420        }
421        let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
422        cmd.extend(args.iter().cloned());
423        cmd.push(prompt.to_string());
424        return Ok(cmd);
425    }
426
427    let mut cmd = vec![command.clone()];
428    cmd.extend(args.iter().cloned());
429    cmd.push(prompt.to_string());
430    Ok(cmd)
431}
432
433/// Check if the currently configured AI tool is Claude-based.
434pub fn is_claude_tool() -> Result<bool> {
435    if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
436        let first_word = env_tool.split_whitespace().next().unwrap_or("");
437        return Ok(first_word == "claude");
438    }
439    let config = load_config()?;
440    Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
441}
442
443/// Set the AI tool command in configuration.
444pub fn set_ai_tool(tool: &str, args: Option<Vec<String>>) -> Result<()> {
445    let mut config = load_config()?;
446    config.ai_tool.command = tool.to_string();
447    config.ai_tool.args = args.unwrap_or_default();
448    save_config(&config)
449}
450
451/// Use a predefined AI tool preset.
452pub fn use_preset(preset_name: &str) -> Result<()> {
453    let presets = ai_tool_presets();
454    if !presets.contains_key(preset_name) {
455        let available: Vec<&str> = presets.keys().copied().collect();
456        return Err(CwError::Config(format!(
457            "Unknown preset: {}. Available: {}",
458            preset_name,
459            available.join(", ")
460        )));
461    }
462    set_ai_tool(preset_name, None)
463}
464
465/// Reset configuration to defaults.
466pub fn reset_config() -> Result<()> {
467    save_config(&Config::default())
468}
469
470/// Set a configuration value by dot-separated key path.
471pub fn set_config_value(key_path: &str, value: &str) -> Result<()> {
472    let mut config = load_config()?;
473    let mut json = serde_json::to_value(&config)?;
474
475    let keys: Vec<&str> = key_path.split('.').collect();
476
477    // Convert string boolean values
478    let json_value: Value = match value.to_lowercase().as_str() {
479        "true" => Value::Bool(true),
480        "false" => Value::Bool(false),
481        _ => {
482            // Try to parse as number
483            if let Ok(n) = value.parse::<f64>() {
484                serde_json::Number::from_f64(n)
485                    .map(Value::Number)
486                    .unwrap_or(Value::String(value.to_string()))
487            } else {
488                Value::String(value.to_string())
489            }
490        }
491    };
492
493    // Navigate to parent and set value
494    let mut current = &mut json;
495    for &key in &keys[..keys.len() - 1] {
496        if !current.is_object() {
497            return Err(CwError::Config(format!(
498                "Invalid config path: {}",
499                key_path
500            )));
501        }
502        current = current
503            .as_object_mut()
504            .unwrap()
505            .entry(key)
506            .or_insert(Value::Object(serde_json::Map::new()));
507    }
508
509    if let Some(obj) = current.as_object_mut() {
510        obj.insert(keys[keys.len() - 1].to_string(), json_value);
511    } else {
512        return Err(CwError::Config(format!(
513            "Invalid config path: {}",
514            key_path
515        )));
516    }
517
518    // Deserialize back to Config and save
519    config = serde_json::from_value(json)
520        .map_err(|e| CwError::Config(format!("Invalid config value: {}", e)))?;
521    save_config(&config)
522}
523
524/// Get a formatted string of the current configuration.
525pub fn show_config() -> Result<String> {
526    let config = load_config()?;
527    let mut lines = Vec::new();
528
529    lines.push("Current configuration:".to_string());
530    lines.push(String::new());
531    lines.push(format!("  AI Tool: {}", config.ai_tool.command));
532
533    if !config.ai_tool.args.is_empty() {
534        lines.push(format!("    Args: {}", config.ai_tool.args.join(" ")));
535    }
536
537    let cmd = get_ai_tool_command()?;
538    lines.push(format!("    Effective command: {}", cmd.join(" ")));
539    lines.push(String::new());
540
541    if let Some(ref method) = config.launch.method {
542        lines.push(format!("  Launch method: {}", method));
543    } else {
544        lines.push("  Launch method: foreground (default)".to_string());
545    }
546
547    lines.push(format!(
548        "  Default base branch: {}",
549        config.git.default_base_branch
550    ));
551    lines.push(String::new());
552    lines.push(format!("Config file: {}", get_config_path().display()));
553
554    Ok(lines.join("\n"))
555}
556
557/// Get a formatted list of available presets.
558pub fn list_presets() -> String {
559    let presets = ai_tool_presets();
560    let mut lines = vec!["Available AI tool presets:".to_string(), String::new()];
561
562    let mut preset_names: Vec<&str> = presets.keys().copied().collect();
563    preset_names.sort();
564
565    for name in preset_names {
566        let cmd = presets[name].join(" ");
567        lines.push(format!("  {:<20} -> {}", name, cmd));
568    }
569
570    lines.join("\n")
571}
572
573// ---------------------------------------------------------------------------
574// Launch method configuration
575// ---------------------------------------------------------------------------
576
577/// Resolve launch method alias to full name.
578pub fn resolve_launch_alias(value: &str) -> String {
579    let deprecated: HashMap<&str, &str> =
580        HashMap::from([("bg", "detach"), ("background", "detach")]);
581    let aliases = launch_method_aliases();
582
583    // Handle session name suffix (e.g., "t:mysession")
584    if let Some((prefix, suffix)) = value.split_once(':') {
585        let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
586            eprintln!(
587                "Warning: '{}' is deprecated. Use '{}' instead.",
588                prefix, new
589            );
590            new.to_string()
591        } else {
592            aliases
593                .get(prefix)
594                .map(|s| s.to_string())
595                .unwrap_or_else(|| prefix.to_string())
596        };
597        return format!("{}:{}", resolved_prefix, suffix);
598    }
599
600    if let Some(&new) = deprecated.get(value) {
601        eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
602        return new.to_string();
603    }
604
605    aliases
606        .get(value)
607        .map(|s| s.to_string())
608        .unwrap_or_else(|| value.to_string())
609}
610
611/// Parse --term option value.
612///
613/// Returns (LaunchMethod, optional_session_name).
614pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
615    let term_value = match term_value {
616        Some(v) => v,
617        None => return Ok((get_default_launch_method()?, None)),
618    };
619
620    let resolved = resolve_launch_alias(term_value);
621
622    if let Some((method_str, session_name)) = resolved.split_once(':') {
623        let method = LaunchMethod::from_str_opt(method_str)
624            .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
625
626        if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
627            if session_name.len() > MAX_SESSION_NAME_LENGTH {
628                return Err(CwError::Config(format!(
629                    "Session name too long (max {} chars): {}",
630                    MAX_SESSION_NAME_LENGTH, session_name
631                )));
632            }
633            return Ok((method, Some(session_name.to_string())));
634        } else {
635            return Err(CwError::Config(format!(
636                "Session name not supported for {}",
637                method_str
638            )));
639        }
640    }
641
642    let method = LaunchMethod::from_str_opt(&resolved)
643        .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
644    Ok((method, None))
645}
646
647/// Get default launch method from config or environment.
648pub fn get_default_launch_method() -> Result<LaunchMethod> {
649    // 1. Environment variable
650    if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
651        let resolved = resolve_launch_alias(&env_val);
652        if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
653            return Ok(method);
654        }
655    }
656
657    // 2. Config file
658    let config = load_config()?;
659    if let Some(ref method) = config.launch.method {
660        let resolved = resolve_launch_alias(method);
661        if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
662            return Ok(m);
663        }
664    }
665
666    Ok(LaunchMethod::Foreground)
667}
668
669#[cfg(test)]
670mod tests {
671    use super::*;
672
673    #[test]
674    fn test_default_config() {
675        let config = Config::default();
676        assert_eq!(config.ai_tool.command, "claude");
677        assert!(config.ai_tool.args.is_empty());
678        assert_eq!(config.git.default_base_branch, "main");
679        assert!(config.update.auto_check);
680    }
681
682    #[test]
683    fn test_resolve_launch_alias() {
684        assert_eq!(resolve_launch_alias("fg"), "foreground");
685        assert_eq!(resolve_launch_alias("t"), "tmux");
686        assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
687        assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
688        assert_eq!(resolve_launch_alias("foreground"), "foreground");
689    }
690
691    #[test]
692    fn test_parse_term_option() {
693        let (method, session) = parse_term_option(Some("t")).unwrap();
694        assert_eq!(method, LaunchMethod::Tmux);
695        assert!(session.is_none());
696
697        let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
698        assert_eq!(method, LaunchMethod::Tmux);
699        assert_eq!(session.unwrap(), "mywork");
700
701        let (method, session) = parse_term_option(Some("i-t")).unwrap();
702        assert_eq!(method, LaunchMethod::ItermTab);
703        assert!(session.is_none());
704    }
705
706    #[test]
707    fn test_preset_names() {
708        let presets = ai_tool_presets();
709        assert!(presets.contains_key("claude"));
710        assert!(presets.contains_key("no-op"));
711        assert!(presets.contains_key("codex"));
712        assert_eq!(presets["no-op"].len(), 0);
713        assert_eq!(presets["claude"], vec!["claude"]);
714    }
715
716    #[test]
717    fn test_list_presets_format() {
718        let output = list_presets();
719        assert!(output.contains("Available AI tool presets:"));
720        assert!(output.contains("claude"));
721        assert!(output.contains("no-op"));
722    }
723}