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 {
173 let home = home_dir_or_fallback();
174 home.join(".config")
175 .join("git-worktree-manager")
176 .join("config.json")
177}
178
179fn deep_merge(base: Value, over: Value) -> Value {
181 match (base, over) {
182 (Value::Object(mut base_map), Value::Object(over_map)) => {
183 for (key, over_val) in over_map {
184 let merged = if let Some(base_val) = base_map.remove(&key) {
185 deep_merge(base_val, over_val)
186 } else {
187 over_val
188 };
189 base_map.insert(key, merged);
190 }
191 Value::Object(base_map)
192 }
193 (_, over) => over,
194 }
195}
196
197fn get_legacy_config_path() -> PathBuf {
199 let home = home_dir_or_fallback();
200 home.join(".config")
201 .join("claude-worktree")
202 .join("config.json")
203}
204
205pub fn load_config() -> Result<Config> {
208 let config_path = get_config_path();
209
210 let config_path = if config_path.exists() {
211 config_path
212 } else {
213 let legacy = get_legacy_config_path();
214 if legacy.exists() {
215 legacy
216 } else {
217 return Ok(Config::default());
218 }
219 };
220
221 let content = std::fs::read_to_string(&config_path).map_err(|e| {
222 CwError::Config(format!(
223 "Failed to load config from {}: {}",
224 config_path.display(),
225 e
226 ))
227 })?;
228
229 let file_value: Value = serde_json::from_str(&content).map_err(|e| {
230 CwError::Config(format!(
231 "Failed to parse config from {}: {}",
232 config_path.display(),
233 e
234 ))
235 })?;
236
237 let default_value = serde_json::to_value(Config::default())?;
238 let merged = deep_merge(default_value, file_value);
239
240 serde_json::from_value(merged).map_err(|e| {
241 CwError::Config(format!(
242 "Failed to deserialize config from {}: {}",
243 config_path.display(),
244 e
245 ))
246 })
247}
248
249pub fn load_effective_config(cwd: &Path) -> Result<Config> {
258 load_effective_config_with_global(cwd, &get_config_path())
259}
260
261pub fn load_effective_config_with_global(cwd: &Path, global_path: &Path) -> Result<Config> {
264 let mut merged = serde_json::to_value(Config::default())?;
265
266 let global_value = if global_path.exists() {
268 Some(read_json_value(global_path)?)
269 } else if global_path == get_config_path().as_path() {
270 let legacy = get_legacy_config_path();
274 if legacy.exists() {
275 Some(read_json_value(&legacy)?)
276 } else {
277 None
278 }
279 } else {
280 None
281 };
282 if let Some(v) = global_value {
283 merged = deep_merge(merged, v);
284 }
285
286 if let Some(repo_value) = crate::repo_config::load_repo_config(cwd)? {
288 merged = deep_merge(merged, repo_value);
289 }
290
291 serde_json::from_value(merged)
292 .map_err(|e| CwError::Config(format!("invalid effective config: {}", e)))
293}
294
295fn read_json_value(path: &Path) -> Result<serde_json::Value> {
296 let content = std::fs::read_to_string(path).map_err(|e| {
297 CwError::Config(format!(
298 "failed to load config from {}: {}",
299 path.display(),
300 e
301 ))
302 })?;
303 serde_json::from_str(&content).map_err(|e| {
304 CwError::Config(format!(
305 "failed to parse config from {}: {}",
306 path.display(),
307 e
308 ))
309 })
310}
311
312pub fn save_config(config: &Config) -> Result<()> {
314 let config_path = get_config_path();
315 if let Some(parent) = config_path.parent() {
316 std::fs::create_dir_all(parent)?;
317 }
318
319 let content = serde_json::to_string_pretty(config)?;
320 std::fs::write(&config_path, content).map_err(|e| {
321 CwError::Config(format!(
322 "Failed to save config to {}: {}",
323 config_path.display(),
324 e
325 ))
326 })
327}
328
329pub fn get_ai_tool_command() -> Result<Vec<String>> {
333 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
334 get_ai_tool_command_for_cwd(&cwd)
335}
336
337pub fn get_ai_tool_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
339 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
341 if env_tool.trim().is_empty() {
342 return Ok(Vec::new());
343 }
344 return Ok(env_tool.split_whitespace().map(String::from).collect());
345 }
346
347 let config = load_effective_config(cwd)?;
348 let command = &config.ai_tool.command;
349 let args = &config.ai_tool.args;
350
351 let presets = ai_tool_presets();
352 if let Some(base_cmd) = presets.get(command.as_str()) {
353 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
354 cmd.extend(args.iter().cloned());
355 return Ok(cmd);
356 }
357
358 if command.trim().is_empty() {
359 return Ok(Vec::new());
360 }
361
362 let mut cmd = vec![command.clone()];
363 cmd.extend(args.iter().cloned());
364 Ok(cmd)
365}
366
367pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
369 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
370 get_ai_tool_resume_command_for_cwd(&cwd)
371}
372
373pub fn get_ai_tool_resume_command_for_cwd(cwd: &Path) -> 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("--resume".to_string());
381 return Ok(parts);
382 }
383
384 let config = load_effective_config(cwd)?;
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 resume_presets = ai_tool_resume_presets();
393 if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
394 let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
395 cmd.extend(args.iter().cloned());
396 return Ok(cmd);
397 }
398
399 let presets = ai_tool_presets();
400 if let Some(base_cmd) = presets.get(command.as_str()) {
401 if base_cmd.is_empty() {
402 return Ok(Vec::new());
403 }
404 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
405 cmd.extend(args.iter().cloned());
406 cmd.push("--resume".to_string());
407 return Ok(cmd);
408 }
409
410 let mut cmd = vec![command.clone()];
411 cmd.extend(args.iter().cloned());
412 cmd.push("--resume".to_string());
413 Ok(cmd)
414}
415
416pub fn is_claude_tool() -> Result<bool> {
418 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
419 is_claude_tool_for_cwd(&cwd)
420}
421
422pub fn is_claude_tool_for_cwd(cwd: &Path) -> Result<bool> {
424 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
425 let first_word = env_tool.split_whitespace().next().unwrap_or("");
426 return Ok(first_word == "claude");
427 }
428 let config = load_effective_config(cwd)?;
429 Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
430}
431
432pub fn resolve_launch_display_name(method: &str) -> String {
434 let aliases = launch_method_aliases();
435 let canonical = aliases.get(method).copied().unwrap_or(method);
436 LaunchMethod::from_str_opt(canonical)
437 .map(|m| format!("{} ({})", m.display_name(), method))
438 .unwrap_or_else(|| method.to_string())
439}
440
441fn is_shell_integration_installed() -> bool {
447 let home = home_dir_or_fallback();
448 let shell_env = std::env::var("SHELL").unwrap_or_default();
449
450 let profile_path = if shell_env.contains("zsh") {
451 home.join(".zshrc")
452 } else if shell_env.contains("bash") {
453 home.join(".bashrc")
454 } else if shell_env.contains("fish") {
455 home.join(".config").join("fish").join("config.fish")
456 } else {
457 return false;
458 };
459
460 if let Ok(content) = std::fs::read_to_string(&profile_path) {
461 content.contains("gw _shell-function") || content.contains("gw-cd")
462 } else {
463 false
464 }
465}
466
467pub fn prompt_shell_completion_setup() {
475 let config = match load_config() {
476 Ok(c) => c,
477 Err(_) => return,
478 };
479
480 if config.shell_completion.prompted || config.shell_completion.installed {
481 return;
482 }
483
484 if is_shell_integration_installed() {
485 let mut config = config;
487 config.shell_completion.prompted = true;
488 config.shell_completion.installed = true;
489 let _ = save_config(&config);
490 return;
491 }
492
493 eprintln!(
495 "\n{} Shell integration (gw-cd + tab completion) is not set up.",
496 console::style("Tip:").cyan().bold()
497 );
498 eprintln!(
499 " Run {} to enable directory navigation and completions.\n",
500 console::style("gw shell-setup").cyan()
501 );
502
503 let mut config = config;
505 config.shell_completion.prompted = true;
506 let _ = save_config(&config);
507}
508
509pub fn resolve_launch_alias(value: &str) -> String {
515 let deprecated: HashMap<&str, &str> =
516 HashMap::from([("bg", "detach"), ("background", "detach")]);
517 let aliases = launch_method_aliases();
518
519 if let Some((prefix, suffix)) = value.split_once(':') {
521 let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
522 eprintln!(
523 "Warning: '{}' is deprecated. Use '{}' instead.",
524 prefix, new
525 );
526 new.to_string()
527 } else {
528 aliases
529 .get(prefix)
530 .map(|s| s.to_string())
531 .unwrap_or_else(|| prefix.to_string())
532 };
533 return format!("{}:{}", resolved_prefix, suffix);
534 }
535
536 if let Some(&new) = deprecated.get(value) {
537 eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
538 return new.to_string();
539 }
540
541 aliases
542 .get(value)
543 .map(|s| s.to_string())
544 .unwrap_or_else(|| value.to_string())
545}
546
547pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
551 let term_value = match term_value {
552 Some(v) => v,
553 None => return Ok((get_default_launch_method()?, None)),
554 };
555
556 let resolved = resolve_launch_alias(term_value);
557
558 if let Some((method_str, session_name)) = resolved.split_once(':') {
559 let method = LaunchMethod::from_str_opt(method_str)
560 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
561
562 if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
563 if session_name.len() > MAX_SESSION_NAME_LENGTH {
564 return Err(CwError::Config(format!(
565 "Session name too long (max {} chars): {}",
566 MAX_SESSION_NAME_LENGTH, session_name
567 )));
568 }
569 return Ok((method, Some(session_name.to_string())));
570 } else {
571 return Err(CwError::Config(format!(
572 "Session name not supported for {}",
573 method_str
574 )));
575 }
576 }
577
578 let method = LaunchMethod::from_str_opt(&resolved)
579 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
580 Ok((method, None))
581}
582
583pub fn get_default_launch_method() -> Result<LaunchMethod> {
585 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
586 get_default_launch_method_for_cwd(&cwd)
587}
588
589pub fn get_default_launch_method_for_cwd(cwd: &Path) -> Result<LaunchMethod> {
591 if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
593 let resolved = resolve_launch_alias(&env_val);
594 if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
595 return Ok(method);
596 }
597 }
598
599 let config = load_effective_config(cwd)?;
601 if let Some(ref method) = config.launch.method {
602 let resolved = resolve_launch_alias(method);
603 if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
604 return Ok(m);
605 }
606 }
607
608 Ok(LaunchMethod::Foreground)
609}
610
611pub fn resolve_term_option(
626 term_override: Option<&str>,
627 cwd: &Path,
628) -> Result<(LaunchMethod, Option<String>)> {
629 if let Some(value) = term_override {
630 return parse_term_option(Some(value));
635 }
636 let method = get_default_launch_method_for_cwd(cwd)?;
637 Ok((method, None))
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 #[test]
645 fn test_default_config() {
646 let config = Config::default();
647 assert_eq!(config.ai_tool.command, "claude");
648 assert!(config.ai_tool.args.is_empty());
649 assert!(config.update.auto_check);
650 }
651
652 #[test]
653 fn test_resolve_launch_alias() {
654 assert_eq!(resolve_launch_alias("fg"), "foreground");
655 assert_eq!(resolve_launch_alias("t"), "tmux");
656 assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
657 assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
658 assert_eq!(resolve_launch_alias("foreground"), "foreground");
659 }
660
661 #[test]
662 fn test_parse_term_option() {
663 let (method, session) = parse_term_option(Some("t")).unwrap();
664 assert_eq!(method, LaunchMethod::Tmux);
665 assert!(session.is_none());
666
667 let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
668 assert_eq!(method, LaunchMethod::Tmux);
669 assert_eq!(session.unwrap(), "mywork");
670
671 let (method, session) = parse_term_option(Some("i-t")).unwrap();
672 assert_eq!(method, LaunchMethod::ItermTab);
673 assert!(session.is_none());
674 }
675
676 #[test]
677 fn test_preset_names() {
678 let presets = ai_tool_presets();
679 assert!(presets.contains_key("claude"));
680 assert!(presets.contains_key("no-op"));
681 assert!(presets.contains_key("codex"));
682 assert_eq!(presets["no-op"].len(), 0);
683 assert_eq!(presets["claude"], vec!["claude"]);
684 }
685
686 #[test]
692 fn interactive_presets_have_no_print_flag() {
693 let presets = ai_tool_presets();
694 for (name, args) in &presets {
695 for forbidden in ["--print", "--tools=default", "--non-interactive"] {
696 assert!(
697 !args.contains(&forbidden),
698 "interactive preset {:?} must not carry {} (would force \
699 headless mode for `gw new --prompt`); got {:?}",
700 name,
701 forbidden,
702 args
703 );
704 }
705 }
706 }
707}