1use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use crate::constants::{
12 home_dir_or_fallback, launch_method_aliases, LaunchMethod, MAX_SESSION_NAME_LENGTH,
13};
14use crate::error::{CwError, Result};
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct Config {
19 pub ai_tool: AiToolConfig,
20 pub launch: LaunchConfig,
21 pub git: GitConfig,
22 pub update: UpdateConfig,
23 pub shell_completion: ShellCompletionConfig,
24 #[serde(default)]
25 pub hooks: HookConfig,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct AiToolConfig {
30 pub command: String,
31 pub args: Vec<String>,
32 #[serde(default = "default_guard")]
36 pub guard: bool,
37}
38
39fn default_guard() -> bool {
40 true
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct LaunchConfig {
45 pub method: Option<String>,
46 pub tmux_session_prefix: String,
47 pub wezterm_ready_timeout: f64,
48}
49
50#[derive(Debug, Clone, Default, Serialize, Deserialize)]
51pub struct GitConfig {
52 }
54
55#[derive(Debug, Clone, Default, Serialize, Deserialize)]
56pub struct HookConfig {
57 #[serde(default)]
58 pub post_new: Option<String>,
59 #[serde(default)]
60 pub pre_rm: Option<String>,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct UpdateConfig {
65 pub auto_check: bool,
66}
67
68#[derive(Debug, Clone, Serialize, Deserialize)]
69pub struct ShellCompletionConfig {
70 pub prompted: bool,
71 pub installed: bool,
72}
73
74impl Default for Config {
75 fn default() -> Self {
76 Self {
77 ai_tool: AiToolConfig {
78 command: "claude".to_string(),
79 args: Vec::new(),
80 guard: true,
81 },
82 launch: LaunchConfig {
83 method: None,
84 tmux_session_prefix: "gw".to_string(),
85 wezterm_ready_timeout: 5.0,
86 },
87 git: GitConfig {},
88 update: UpdateConfig { auto_check: true },
89 shell_completion: ShellCompletionConfig {
90 prompted: false,
91 installed: false,
92 },
93 hooks: HookConfig::default(),
94 }
95 }
96}
97
98pub fn ai_tool_presets() -> HashMap<&'static str, Vec<&'static str>> {
100 HashMap::from([
101 ("no-op", vec![]),
102 ("claude", vec!["claude"]),
103 (
104 "claude-yolo",
105 vec!["claude", "--dangerously-skip-permissions"],
106 ),
107 ("claude-remote", vec!["claude", "/remote-control"]),
108 (
109 "claude-yolo-remote",
110 vec![
111 "claude",
112 "--dangerously-skip-permissions",
113 "/remote-control",
114 ],
115 ),
116 ("codex", vec!["codex"]),
117 (
118 "codex-yolo",
119 vec!["codex", "--dangerously-bypass-approvals-and-sandbox"],
120 ),
121 ])
122}
123
124pub fn ai_tool_resume_presets() -> HashMap<&'static str, Vec<&'static str>> {
126 HashMap::from([
127 ("claude", vec!["claude", "--continue"]),
128 (
129 "claude-yolo",
130 vec!["claude", "--dangerously-skip-permissions", "--continue"],
131 ),
132 (
133 "claude-remote",
134 vec!["claude", "--continue", "/remote-control"],
135 ),
136 (
137 "claude-yolo-remote",
138 vec![
139 "claude",
140 "--dangerously-skip-permissions",
141 "--continue",
142 "/remote-control",
143 ],
144 ),
145 ("codex", vec!["codex", "resume", "--last"]),
146 (
147 "codex-yolo",
148 vec![
149 "codex",
150 "resume",
151 "--dangerously-bypass-approvals-and-sandbox",
152 "--last",
153 ],
154 ),
155 ])
156}
157
158pub fn claude_preset_names() -> Vec<&'static str> {
160 ai_tool_presets()
161 .iter()
162 .filter(|(_, v)| v.first().map(|&s| s == "claude").unwrap_or(false))
163 .map(|(&k, _)| k)
164 .collect()
165}
166
167pub fn get_config_path() -> PathBuf {
183 if let Ok(override_dir) = std::env::var("GW_CONFIG_HOME") {
184 if !override_dir.is_empty() {
185 return PathBuf::from(override_dir).join("config.json");
186 }
187 }
188 let home = home_dir_or_fallback();
189 home.join(".config")
190 .join("git-worktree-manager")
191 .join("config.json")
192}
193
194fn deep_merge(base: Value, over: Value) -> Value {
196 match (base, over) {
197 (Value::Object(mut base_map), Value::Object(over_map)) => {
198 for (key, over_val) in over_map {
199 let merged = if let Some(base_val) = base_map.remove(&key) {
200 deep_merge(base_val, over_val)
201 } else {
202 over_val
203 };
204 base_map.insert(key, merged);
205 }
206 Value::Object(base_map)
207 }
208 (_, over) => over,
209 }
210}
211
212fn get_legacy_config_path() -> PathBuf {
214 let home = home_dir_or_fallback();
215 home.join(".config")
216 .join("claude-worktree")
217 .join("config.json")
218}
219
220pub fn load_config() -> Result<Config> {
223 let config_path = get_config_path();
224
225 let config_path = if config_path.exists() {
226 config_path
227 } else {
228 let legacy = get_legacy_config_path();
229 if legacy.exists() {
230 legacy
231 } else {
232 return Ok(Config::default());
233 }
234 };
235
236 let content = std::fs::read_to_string(&config_path).map_err(|e| {
237 CwError::Config(format!(
238 "Failed to load config from {}: {}",
239 config_path.display(),
240 e
241 ))
242 })?;
243
244 let file_value: Value = serde_json::from_str(&content).map_err(|e| {
245 CwError::Config(format!(
246 "Failed to parse config from {}: {}",
247 config_path.display(),
248 e
249 ))
250 })?;
251
252 let default_value = serde_json::to_value(Config::default())?;
253 let merged = deep_merge(default_value, file_value);
254
255 serde_json::from_value(merged).map_err(|e| {
256 CwError::Config(format!(
257 "Failed to deserialize config from {}: {}",
258 config_path.display(),
259 e
260 ))
261 })
262}
263
264pub fn load_effective_config(cwd: &Path) -> Result<Config> {
273 load_effective_config_with_global(cwd, &get_config_path())
274}
275
276pub fn load_effective_config_with_global(cwd: &Path, global_path: &Path) -> Result<Config> {
279 let mut merged = serde_json::to_value(Config::default())?;
280
281 let default_path = get_config_path();
285
286 let global_value = if global_path.exists() {
288 Some(read_json_value(global_path)?)
289 } else if global_path == default_path.as_path() {
290 let legacy = get_legacy_config_path();
294 if legacy.exists() {
295 Some(read_json_value(&legacy)?)
296 } else {
297 None
298 }
299 } else {
300 None
301 };
302 if let Some(v) = global_value {
303 merged = deep_merge(merged, v);
304 }
305
306 if let Some(repo_value) = crate::repo_config::load_repo_config(cwd)? {
308 merged = deep_merge(merged, repo_value);
309 }
310
311 serde_json::from_value(merged)
312 .map_err(|e| CwError::Config(format!("invalid effective config: {}", e)))
313}
314
315fn read_json_value(path: &Path) -> Result<serde_json::Value> {
316 let content = std::fs::read_to_string(path).map_err(|e| {
317 CwError::Config(format!(
318 "failed to load config from {}: {}",
319 path.display(),
320 e
321 ))
322 })?;
323 serde_json::from_str(&content).map_err(|e| {
324 CwError::Config(format!(
325 "failed to parse config from {}: {}",
326 path.display(),
327 e
328 ))
329 })
330}
331
332pub fn save_config(config: &Config) -> Result<()> {
334 let config_path = get_config_path();
335 if let Some(parent) = config_path.parent() {
336 std::fs::create_dir_all(parent)?;
337 }
338
339 let content = serde_json::to_string_pretty(config)?;
340 std::fs::write(&config_path, content).map_err(|e| {
341 CwError::Config(format!(
342 "Failed to save config to {}: {}",
343 config_path.display(),
344 e
345 ))
346 })
347}
348
349pub fn get_ai_tool_command() -> Result<Vec<String>> {
353 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
354 get_ai_tool_command_for_cwd(&cwd)
355}
356
357pub fn get_ai_tool_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
359 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
361 if env_tool.trim().is_empty() {
362 return Ok(Vec::new());
363 }
364 return Ok(env_tool.split_whitespace().map(String::from).collect());
365 }
366
367 let config = load_effective_config(cwd)?;
368 let command = &config.ai_tool.command;
369 let args = &config.ai_tool.args;
370
371 let presets = ai_tool_presets();
372 if let Some(base_cmd) = presets.get(command.as_str()) {
373 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
374 cmd.extend(args.iter().cloned());
375 return Ok(cmd);
376 }
377
378 if command.trim().is_empty() {
379 return Ok(Vec::new());
380 }
381
382 let mut cmd = vec![command.clone()];
383 cmd.extend(args.iter().cloned());
384 Ok(cmd)
385}
386
387pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
389 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
390 get_ai_tool_resume_command_for_cwd(&cwd)
391}
392
393pub fn get_ai_tool_resume_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
395 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
396 if env_tool.trim().is_empty() {
397 return Ok(Vec::new());
398 }
399 let first_word = env_tool.split_whitespace().next().unwrap_or("");
403 let resume_presets = ai_tool_resume_presets();
404 if let Some(preset_cmd) = resume_presets.get(first_word) {
405 return Ok(preset_cmd.iter().map(|s| s.to_string()).collect());
406 }
407 let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
409 parts.push("--resume".to_string());
410 return Ok(parts);
411 }
412
413 let config = load_effective_config(cwd)?;
414 let command = &config.ai_tool.command;
415 let args = &config.ai_tool.args;
416
417 if command.trim().is_empty() {
418 return Ok(Vec::new());
419 }
420
421 let resume_presets = ai_tool_resume_presets();
422 if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
423 let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
424 cmd.extend(args.iter().cloned());
425 return Ok(cmd);
426 }
427
428 let presets = ai_tool_presets();
429 if let Some(base_cmd) = presets.get(command.as_str()) {
430 if base_cmd.is_empty() {
431 return Ok(Vec::new());
432 }
433 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
434 cmd.extend(args.iter().cloned());
435 cmd.push("--resume".to_string());
436 return Ok(cmd);
437 }
438
439 let mut cmd = vec![command.clone()];
440 cmd.extend(args.iter().cloned());
441 cmd.push("--resume".to_string());
442 Ok(cmd)
443}
444
445pub fn is_claude_tool() -> Result<bool> {
447 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
448 is_claude_tool_for_cwd(&cwd)
449}
450
451pub fn is_claude_tool_for_cwd(cwd: &Path) -> Result<bool> {
453 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
454 let first_word = env_tool.split_whitespace().next().unwrap_or("");
455 return Ok(first_word == "claude");
456 }
457 let config = load_effective_config(cwd)?;
458 Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
459}
460
461pub fn resolve_launch_display_name(method: &str) -> String {
463 let aliases = launch_method_aliases();
464 let canonical = aliases.get(method).copied().unwrap_or(method);
465 LaunchMethod::from_str_opt(canonical)
466 .map(|m| format!("{} ({})", m.display_name(), method))
467 .unwrap_or_else(|| method.to_string())
468}
469
470fn is_shell_integration_installed() -> bool {
476 let home = home_dir_or_fallback();
477 let shell_env = std::env::var("SHELL").unwrap_or_default();
478
479 let profile_path = if shell_env.contains("zsh") {
480 home.join(".zshrc")
481 } else if shell_env.contains("bash") {
482 home.join(".bashrc")
483 } else if shell_env.contains("fish") {
484 home.join(".config").join("fish").join("config.fish")
485 } else {
486 return false;
487 };
488
489 if let Ok(content) = std::fs::read_to_string(&profile_path) {
490 content.contains("gw _shell-function") || content.contains("gw-cd")
491 } else {
492 false
493 }
494}
495
496pub fn prompt_shell_completion_setup() {
504 let config = match load_config() {
505 Ok(c) => c,
506 Err(_) => return,
507 };
508
509 if config.shell_completion.prompted || config.shell_completion.installed {
510 return;
511 }
512
513 if is_shell_integration_installed() {
514 let mut config = config;
516 config.shell_completion.prompted = true;
517 config.shell_completion.installed = true;
518 let _ = save_config(&config);
519 return;
520 }
521
522 eprintln!(
524 "\n{} Shell integration (gw-cd + tab completion) is not set up.",
525 console::style("Tip:").cyan().bold()
526 );
527 eprintln!(
528 " Run {} to enable directory navigation and completions.\n",
529 console::style("gw shell-setup").cyan()
530 );
531
532 let mut config = config;
534 config.shell_completion.prompted = true;
535 let _ = save_config(&config);
536}
537
538pub fn resolve_launch_alias(value: &str) -> String {
544 let deprecated: HashMap<&str, &str> =
545 HashMap::from([("bg", "detach"), ("background", "detach")]);
546 let aliases = launch_method_aliases();
547
548 if let Some((prefix, suffix)) = value.split_once(':') {
550 let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
551 eprintln!(
552 "Warning: '{}' is deprecated. Use '{}' instead.",
553 prefix, new
554 );
555 new.to_string()
556 } else {
557 aliases
558 .get(prefix)
559 .map(|s| s.to_string())
560 .unwrap_or_else(|| prefix.to_string())
561 };
562 return format!("{}:{}", resolved_prefix, suffix);
563 }
564
565 if let Some(&new) = deprecated.get(value) {
566 eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
567 return new.to_string();
568 }
569
570 aliases
571 .get(value)
572 .map(|s| s.to_string())
573 .unwrap_or_else(|| value.to_string())
574}
575
576pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
580 let term_value = match term_value {
581 Some(v) => v,
582 None => return Ok((get_default_launch_method()?, None)),
583 };
584
585 let resolved = resolve_launch_alias(term_value);
586
587 if let Some((method_str, session_name)) = resolved.split_once(':') {
588 let method = LaunchMethod::from_str_opt(method_str)
589 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
590
591 if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
592 if session_name.len() > MAX_SESSION_NAME_LENGTH {
593 return Err(CwError::Config(format!(
594 "Session name too long (max {} chars): {}",
595 MAX_SESSION_NAME_LENGTH, session_name
596 )));
597 }
598 return Ok((method, Some(session_name.to_string())));
599 } else {
600 return Err(CwError::Config(format!(
601 "Session name not supported for {}",
602 method_str
603 )));
604 }
605 }
606
607 let method = LaunchMethod::from_str_opt(&resolved)
608 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
609 Ok((method, None))
610}
611
612pub fn get_default_launch_method() -> Result<LaunchMethod> {
614 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
615 get_default_launch_method_for_cwd(&cwd)
616}
617
618pub fn get_default_launch_method_for_cwd(cwd: &Path) -> Result<LaunchMethod> {
620 if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
622 let resolved = resolve_launch_alias(&env_val);
623 if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
624 return Ok(method);
625 }
626 }
627
628 let config = load_effective_config(cwd)?;
630 if let Some(ref method) = config.launch.method {
631 let resolved = resolve_launch_alias(method);
632 if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
633 return Ok(m);
634 }
635 }
636
637 Ok(LaunchMethod::Foreground)
638}
639
640pub fn resolve_term_option(
655 term_override: Option<&str>,
656 cwd: &Path,
657) -> Result<(LaunchMethod, Option<String>)> {
658 if let Some(value) = term_override {
659 return parse_term_option(Some(value));
664 }
665 let method = get_default_launch_method_for_cwd(cwd)?;
666 Ok((method, None))
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!(config.update.auto_check);
679 }
680
681 #[test]
682 fn test_resolve_launch_alias() {
683 assert_eq!(resolve_launch_alias("fg"), "foreground");
684 assert_eq!(resolve_launch_alias("t"), "tmux");
685 assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
686 assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
687 assert_eq!(resolve_launch_alias("foreground"), "foreground");
688 }
689
690 #[test]
691 fn test_parse_term_option() {
692 let (method, session) = parse_term_option(Some("t")).unwrap();
693 assert_eq!(method, LaunchMethod::Tmux);
694 assert!(session.is_none());
695
696 let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
697 assert_eq!(method, LaunchMethod::Tmux);
698 assert_eq!(session.unwrap(), "mywork");
699
700 let (method, session) = parse_term_option(Some("i-t")).unwrap();
701 assert_eq!(method, LaunchMethod::ItermTab);
702 assert!(session.is_none());
703 }
704
705 #[test]
706 fn test_preset_names() {
707 let presets = ai_tool_presets();
708 assert!(presets.contains_key("claude"));
709 assert!(presets.contains_key("no-op"));
710 assert!(presets.contains_key("codex"));
711 assert_eq!(presets["no-op"].len(), 0);
712 assert_eq!(presets["claude"], vec!["claude"]);
713 }
714
715 #[test]
721 fn interactive_presets_have_no_print_flag() {
722 let presets = ai_tool_presets();
723 for (name, args) in &presets {
724 for forbidden in ["--print", "--tools=default", "--non-interactive"] {
725 assert!(
726 !args.contains(&forbidden),
727 "interactive preset {:?} must not carry {} (would force \
728 headless mode for `gw new --prompt`); got {:?}",
729 name,
730 forbidden,
731 args
732 );
733 }
734 }
735 }
736}