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}
33
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct LaunchConfig {
36 pub method: Option<String>,
37 pub tmux_session_prefix: String,
38 pub wezterm_ready_timeout: f64,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct GitConfig {
43 }
45
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct HookConfig {
48 #[serde(default)]
49 pub post_new: Option<String>,
50 #[serde(default)]
51 pub pre_rm: Option<String>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct UpdateConfig {
56 pub auto_check: bool,
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct ShellCompletionConfig {
61 pub prompted: bool,
62 pub installed: bool,
63}
64
65impl Default for Config {
66 fn default() -> Self {
67 Self {
68 ai_tool: AiToolConfig {
69 command: "claude".to_string(),
70 args: Vec::new(),
71 },
72 launch: LaunchConfig {
73 method: None,
74 tmux_session_prefix: "gw".to_string(),
75 wezterm_ready_timeout: 5.0,
76 },
77 git: GitConfig {},
78 update: UpdateConfig { auto_check: true },
79 shell_completion: ShellCompletionConfig {
80 prompted: false,
81 installed: false,
82 },
83 hooks: HookConfig::default(),
84 }
85 }
86}
87
88pub fn ai_tool_presets() -> HashMap<&'static str, Vec<&'static str>> {
90 HashMap::from([
91 ("no-op", vec![]),
92 ("claude", vec!["claude"]),
93 (
94 "claude-yolo",
95 vec!["claude", "--dangerously-skip-permissions"],
96 ),
97 ("claude-remote", vec!["claude", "/remote-control"]),
98 (
99 "claude-yolo-remote",
100 vec![
101 "claude",
102 "--dangerously-skip-permissions",
103 "/remote-control",
104 ],
105 ),
106 ("codex", vec!["codex"]),
107 (
108 "codex-yolo",
109 vec!["codex", "--dangerously-bypass-approvals-and-sandbox"],
110 ),
111 ])
112}
113
114pub fn ai_tool_resume_presets() -> HashMap<&'static str, Vec<&'static str>> {
116 HashMap::from([
117 ("claude", vec!["claude", "--continue"]),
118 (
119 "claude-yolo",
120 vec!["claude", "--dangerously-skip-permissions", "--continue"],
121 ),
122 (
123 "claude-remote",
124 vec!["claude", "--continue", "/remote-control"],
125 ),
126 (
127 "claude-yolo-remote",
128 vec![
129 "claude",
130 "--dangerously-skip-permissions",
131 "--continue",
132 "/remote-control",
133 ],
134 ),
135 ("codex", vec!["codex", "resume", "--last"]),
136 (
137 "codex-yolo",
138 vec![
139 "codex",
140 "resume",
141 "--dangerously-bypass-approvals-and-sandbox",
142 "--last",
143 ],
144 ),
145 ])
146}
147
148#[derive(Debug)]
150pub struct MergePreset {
151 pub base_override: Option<Vec<&'static str>>,
152 pub flags: Vec<&'static str>,
153 pub prompt_position: PromptPosition,
154}
155
156#[derive(Debug)]
157pub enum PromptPosition {
158 End,
159 Index(usize),
160}
161
162pub fn ai_tool_merge_presets() -> HashMap<&'static str, MergePreset> {
164 HashMap::from([
165 (
166 "claude",
167 MergePreset {
168 base_override: None,
169 flags: vec!["--print", "--tools=default"],
170 prompt_position: PromptPosition::End,
171 },
172 ),
173 (
174 "claude-yolo",
175 MergePreset {
176 base_override: None,
177 flags: vec!["--print", "--tools=default"],
178 prompt_position: PromptPosition::End,
179 },
180 ),
181 (
182 "claude-remote",
183 MergePreset {
184 base_override: Some(vec!["claude"]),
185 flags: vec!["--print", "--tools=default"],
186 prompt_position: PromptPosition::End,
187 },
188 ),
189 (
190 "claude-yolo-remote",
191 MergePreset {
192 base_override: Some(vec!["claude", "--dangerously-skip-permissions"]),
193 flags: vec!["--print", "--tools=default"],
194 prompt_position: PromptPosition::End,
195 },
196 ),
197 (
198 "codex",
199 MergePreset {
200 base_override: None,
201 flags: vec!["--non-interactive"],
202 prompt_position: PromptPosition::End,
203 },
204 ),
205 (
206 "codex-yolo",
207 MergePreset {
208 base_override: None,
209 flags: vec!["--non-interactive"],
210 prompt_position: PromptPosition::End,
211 },
212 ),
213 ])
214}
215
216pub fn claude_preset_names() -> Vec<&'static str> {
218 ai_tool_presets()
219 .iter()
220 .filter(|(_, v)| v.first().map(|&s| s == "claude").unwrap_or(false))
221 .map(|(&k, _)| k)
222 .collect()
223}
224
225pub fn get_config_path() -> PathBuf {
231 let home = home_dir_or_fallback();
232 home.join(".config")
233 .join("git-worktree-manager")
234 .join("config.json")
235}
236
237fn deep_merge(base: Value, over: Value) -> Value {
239 match (base, over) {
240 (Value::Object(mut base_map), Value::Object(over_map)) => {
241 for (key, over_val) in over_map {
242 let merged = if let Some(base_val) = base_map.remove(&key) {
243 deep_merge(base_val, over_val)
244 } else {
245 over_val
246 };
247 base_map.insert(key, merged);
248 }
249 Value::Object(base_map)
250 }
251 (_, over) => over,
252 }
253}
254
255fn get_legacy_config_path() -> PathBuf {
257 let home = home_dir_or_fallback();
258 home.join(".config")
259 .join("claude-worktree")
260 .join("config.json")
261}
262
263pub fn load_config() -> Result<Config> {
266 let config_path = get_config_path();
267
268 let config_path = if config_path.exists() {
269 config_path
270 } else {
271 let legacy = get_legacy_config_path();
272 if legacy.exists() {
273 legacy
274 } else {
275 return Ok(Config::default());
276 }
277 };
278
279 let content = std::fs::read_to_string(&config_path).map_err(|e| {
280 CwError::Config(format!(
281 "Failed to load config from {}: {}",
282 config_path.display(),
283 e
284 ))
285 })?;
286
287 let file_value: Value = serde_json::from_str(&content).map_err(|e| {
288 CwError::Config(format!(
289 "Failed to parse config from {}: {}",
290 config_path.display(),
291 e
292 ))
293 })?;
294
295 let default_value = serde_json::to_value(Config::default())?;
296 let merged = deep_merge(default_value, file_value);
297
298 serde_json::from_value(merged).map_err(|e| {
299 CwError::Config(format!(
300 "Failed to deserialize config from {}: {}",
301 config_path.display(),
302 e
303 ))
304 })
305}
306
307pub fn load_effective_config(cwd: &Path) -> Result<Config> {
316 load_effective_config_with_global(cwd, &get_config_path())
317}
318
319pub fn load_effective_config_with_global(cwd: &Path, global_path: &Path) -> Result<Config> {
322 let mut merged = serde_json::to_value(Config::default())?;
323
324 let global_value = if global_path.exists() {
326 Some(read_json_value(global_path)?)
327 } else if global_path == get_config_path().as_path() {
328 let legacy = get_legacy_config_path();
332 if legacy.exists() {
333 Some(read_json_value(&legacy)?)
334 } else {
335 None
336 }
337 } else {
338 None
339 };
340 if let Some(v) = global_value {
341 merged = deep_merge(merged, v);
342 }
343
344 if let Some(repo_value) = crate::repo_config::load_repo_config(cwd)? {
346 merged = deep_merge(merged, repo_value);
347 }
348
349 serde_json::from_value(merged)
350 .map_err(|e| CwError::Config(format!("invalid effective config: {}", e)))
351}
352
353fn read_json_value(path: &Path) -> Result<serde_json::Value> {
354 let content = std::fs::read_to_string(path).map_err(|e| {
355 CwError::Config(format!(
356 "failed to load config from {}: {}",
357 path.display(),
358 e
359 ))
360 })?;
361 serde_json::from_str(&content).map_err(|e| {
362 CwError::Config(format!(
363 "failed to parse config from {}: {}",
364 path.display(),
365 e
366 ))
367 })
368}
369
370pub fn save_config(config: &Config) -> Result<()> {
372 let config_path = get_config_path();
373 if let Some(parent) = config_path.parent() {
374 std::fs::create_dir_all(parent)?;
375 }
376
377 let content = serde_json::to_string_pretty(config)?;
378 std::fs::write(&config_path, content).map_err(|e| {
379 CwError::Config(format!(
380 "Failed to save config to {}: {}",
381 config_path.display(),
382 e
383 ))
384 })
385}
386
387pub fn get_ai_tool_command() -> Result<Vec<String>> {
391 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
392 get_ai_tool_command_for_cwd(&cwd)
393}
394
395pub fn get_ai_tool_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
397 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
399 if env_tool.trim().is_empty() {
400 return Ok(Vec::new());
401 }
402 return Ok(env_tool.split_whitespace().map(String::from).collect());
403 }
404
405 let config = load_effective_config(cwd)?;
406 let command = &config.ai_tool.command;
407 let args = &config.ai_tool.args;
408
409 let presets = ai_tool_presets();
410 if let Some(base_cmd) = presets.get(command.as_str()) {
411 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
412 cmd.extend(args.iter().cloned());
413 return Ok(cmd);
414 }
415
416 if command.trim().is_empty() {
417 return Ok(Vec::new());
418 }
419
420 let mut cmd = vec![command.clone()];
421 cmd.extend(args.iter().cloned());
422 Ok(cmd)
423}
424
425pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
427 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
428 get_ai_tool_resume_command_for_cwd(&cwd)
429}
430
431pub fn get_ai_tool_resume_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
433 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
434 if env_tool.trim().is_empty() {
435 return Ok(Vec::new());
436 }
437 let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
438 parts.push("--resume".to_string());
439 return Ok(parts);
440 }
441
442 let config = load_effective_config(cwd)?;
443 let command = &config.ai_tool.command;
444 let args = &config.ai_tool.args;
445
446 if command.trim().is_empty() {
447 return Ok(Vec::new());
448 }
449
450 let resume_presets = ai_tool_resume_presets();
451 if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
452 let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
453 cmd.extend(args.iter().cloned());
454 return Ok(cmd);
455 }
456
457 let presets = ai_tool_presets();
458 if let Some(base_cmd) = presets.get(command.as_str()) {
459 if base_cmd.is_empty() {
460 return Ok(Vec::new());
461 }
462 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
463 cmd.extend(args.iter().cloned());
464 cmd.push("--resume".to_string());
465 return Ok(cmd);
466 }
467
468 let mut cmd = vec![command.clone()];
469 cmd.extend(args.iter().cloned());
470 cmd.push("--resume".to_string());
471 Ok(cmd)
472}
473
474pub fn get_ai_tool_merge_command(prompt: &str) -> Result<Vec<String>> {
476 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
477 get_ai_tool_merge_command_for_cwd(&cwd, prompt)
478}
479
480pub fn get_ai_tool_merge_command_for_cwd(cwd: &Path, prompt: &str) -> Result<Vec<String>> {
482 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
483 if env_tool.trim().is_empty() {
484 return Ok(Vec::new());
485 }
486 let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
487 parts.push(prompt.to_string());
488 return Ok(parts);
489 }
490
491 let config = load_effective_config(cwd)?;
492 let command = &config.ai_tool.command;
493 let args = &config.ai_tool.args;
494
495 if command.trim().is_empty() {
496 return Ok(Vec::new());
497 }
498
499 let merge_presets = ai_tool_merge_presets();
500 if let Some(preset) = merge_presets.get(command.as_str()) {
501 let base_cmd: Vec<String> = if let Some(ref base_override) = preset.base_override {
502 base_override.iter().map(|s| s.to_string()).collect()
503 } else {
504 let presets = ai_tool_presets();
505 presets
506 .get(command.as_str())
507 .map(|v| v.iter().map(|s| s.to_string()).collect())
508 .unwrap_or_else(|| vec![command.clone()])
509 };
510
511 let mut cmd_parts = base_cmd;
512 cmd_parts.extend(args.iter().cloned());
513 cmd_parts.extend(preset.flags.iter().map(|s| s.to_string()));
514
515 match preset.prompt_position {
516 PromptPosition::End => cmd_parts.push(prompt.to_string()),
517 PromptPosition::Index(i) => cmd_parts.insert(i, prompt.to_string()),
518 }
519
520 return Ok(cmd_parts);
521 }
522
523 let presets = ai_tool_presets();
524 if let Some(base_cmd) = presets.get(command.as_str()) {
525 if base_cmd.is_empty() {
526 return Ok(Vec::new());
527 }
528 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
529 cmd.extend(args.iter().cloned());
530 cmd.push(prompt.to_string());
531 return Ok(cmd);
532 }
533
534 let mut cmd = vec![command.clone()];
535 cmd.extend(args.iter().cloned());
536 cmd.push(prompt.to_string());
537 Ok(cmd)
538}
539
540pub fn get_ai_tool_delegate_command(prompt: &str) -> Result<Vec<String>> {
545 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
546 get_ai_tool_delegate_command_for_cwd(&cwd, prompt)
547}
548
549pub fn get_ai_tool_delegate_command_for_cwd(cwd: &Path, prompt: &str) -> Result<Vec<String>> {
551 let mut cmd = get_ai_tool_command_for_cwd(cwd)?;
552 if cmd.is_empty() {
553 return Ok(cmd);
554 }
555 cmd.push(prompt.to_string());
556 Ok(cmd)
557}
558
559pub fn is_claude_tool() -> Result<bool> {
561 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
562 is_claude_tool_for_cwd(&cwd)
563}
564
565pub fn is_claude_tool_for_cwd(cwd: &Path) -> Result<bool> {
567 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
568 let first_word = env_tool.split_whitespace().next().unwrap_or("");
569 return Ok(first_word == "claude");
570 }
571 let config = load_effective_config(cwd)?;
572 Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
573}
574
575pub fn resolve_launch_display_name(method: &str) -> String {
577 let aliases = launch_method_aliases();
578 let canonical = aliases.get(method).copied().unwrap_or(method);
579 LaunchMethod::from_str_opt(canonical)
580 .map(|m| format!("{} ({})", m.display_name(), method))
581 .unwrap_or_else(|| method.to_string())
582}
583
584fn is_shell_integration_installed() -> bool {
590 let home = home_dir_or_fallback();
591 let shell_env = std::env::var("SHELL").unwrap_or_default();
592
593 let profile_path = if shell_env.contains("zsh") {
594 home.join(".zshrc")
595 } else if shell_env.contains("bash") {
596 home.join(".bashrc")
597 } else if shell_env.contains("fish") {
598 home.join(".config").join("fish").join("config.fish")
599 } else {
600 return false;
601 };
602
603 if let Ok(content) = std::fs::read_to_string(&profile_path) {
604 content.contains("gw _shell-function") || content.contains("gw-cd")
605 } else {
606 false
607 }
608}
609
610pub fn prompt_shell_completion_setup() {
618 let config = match load_config() {
619 Ok(c) => c,
620 Err(_) => return,
621 };
622
623 if config.shell_completion.prompted || config.shell_completion.installed {
624 return;
625 }
626
627 if is_shell_integration_installed() {
628 let mut config = config;
630 config.shell_completion.prompted = true;
631 config.shell_completion.installed = true;
632 let _ = save_config(&config);
633 return;
634 }
635
636 eprintln!(
638 "\n{} Shell integration (gw-cd + tab completion) is not set up.",
639 console::style("Tip:").cyan().bold()
640 );
641 eprintln!(
642 " Run {} to enable directory navigation and completions.\n",
643 console::style("gw shell-setup").cyan()
644 );
645
646 let mut config = config;
648 config.shell_completion.prompted = true;
649 let _ = save_config(&config);
650}
651
652pub fn resolve_launch_alias(value: &str) -> String {
658 let deprecated: HashMap<&str, &str> =
659 HashMap::from([("bg", "detach"), ("background", "detach")]);
660 let aliases = launch_method_aliases();
661
662 if let Some((prefix, suffix)) = value.split_once(':') {
664 let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
665 eprintln!(
666 "Warning: '{}' is deprecated. Use '{}' instead.",
667 prefix, new
668 );
669 new.to_string()
670 } else {
671 aliases
672 .get(prefix)
673 .map(|s| s.to_string())
674 .unwrap_or_else(|| prefix.to_string())
675 };
676 return format!("{}:{}", resolved_prefix, suffix);
677 }
678
679 if let Some(&new) = deprecated.get(value) {
680 eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
681 return new.to_string();
682 }
683
684 aliases
685 .get(value)
686 .map(|s| s.to_string())
687 .unwrap_or_else(|| value.to_string())
688}
689
690pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
694 let term_value = match term_value {
695 Some(v) => v,
696 None => return Ok((get_default_launch_method()?, None)),
697 };
698
699 let resolved = resolve_launch_alias(term_value);
700
701 if let Some((method_str, session_name)) = resolved.split_once(':') {
702 let method = LaunchMethod::from_str_opt(method_str)
703 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
704
705 if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
706 if session_name.len() > MAX_SESSION_NAME_LENGTH {
707 return Err(CwError::Config(format!(
708 "Session name too long (max {} chars): {}",
709 MAX_SESSION_NAME_LENGTH, session_name
710 )));
711 }
712 return Ok((method, Some(session_name.to_string())));
713 } else {
714 return Err(CwError::Config(format!(
715 "Session name not supported for {}",
716 method_str
717 )));
718 }
719 }
720
721 let method = LaunchMethod::from_str_opt(&resolved)
722 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
723 Ok((method, None))
724}
725
726pub fn get_default_launch_method() -> Result<LaunchMethod> {
728 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
729 get_default_launch_method_for_cwd(&cwd)
730}
731
732pub fn get_default_launch_method_for_cwd(cwd: &Path) -> Result<LaunchMethod> {
734 if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
736 let resolved = resolve_launch_alias(&env_val);
737 if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
738 return Ok(method);
739 }
740 }
741
742 let config = load_effective_config(cwd)?;
744 if let Some(ref method) = config.launch.method {
745 let resolved = resolve_launch_alias(method);
746 if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
747 return Ok(m);
748 }
749 }
750
751 Ok(LaunchMethod::Foreground)
752}
753
754#[cfg(test)]
755mod tests {
756 use super::*;
757
758 #[test]
759 fn test_default_config() {
760 let config = Config::default();
761 assert_eq!(config.ai_tool.command, "claude");
762 assert!(config.ai_tool.args.is_empty());
763 assert!(config.update.auto_check);
764 }
765
766 #[test]
767 fn test_resolve_launch_alias() {
768 assert_eq!(resolve_launch_alias("fg"), "foreground");
769 assert_eq!(resolve_launch_alias("t"), "tmux");
770 assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
771 assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
772 assert_eq!(resolve_launch_alias("foreground"), "foreground");
773 }
774
775 #[test]
776 fn test_parse_term_option() {
777 let (method, session) = parse_term_option(Some("t")).unwrap();
778 assert_eq!(method, LaunchMethod::Tmux);
779 assert!(session.is_none());
780
781 let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
782 assert_eq!(method, LaunchMethod::Tmux);
783 assert_eq!(session.unwrap(), "mywork");
784
785 let (method, session) = parse_term_option(Some("i-t")).unwrap();
786 assert_eq!(method, LaunchMethod::ItermTab);
787 assert!(session.is_none());
788 }
789
790 #[test]
791 fn test_preset_names() {
792 let presets = ai_tool_presets();
793 assert!(presets.contains_key("claude"));
794 assert!(presets.contains_key("no-op"));
795 assert!(presets.contains_key("codex"));
796 assert_eq!(presets["no-op"].len(), 0);
797 assert_eq!(presets["claude"], vec!["claude"]);
798 }
799}