1use 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#[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
79pub 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
105pub 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#[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
153pub 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
207pub 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
216pub 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
228fn 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
246fn 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
254pub 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
298pub 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
315pub fn get_ai_tool_command() -> Result<Vec<String>> {
319 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
347pub 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
390pub 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
450pub 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
460pub 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
468pub 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
482pub fn reset_config() -> Result<()> {
484 save_config(&Config::default())
485}
486
487pub 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
514pub 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 let json_value: Value = match value.to_lowercase().as_str() {
523 "true" => Value::Bool(true),
524 "false" => Value::Bool(false),
525 _ => {
526 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 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 config = serde_json::from_value(json)
564 .map_err(|e| CwError::Config(format!("Invalid config value: {}", e)))?;
565 save_config(&config)
566}
567
568pub 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
601pub 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
617fn 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
643pub 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 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 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 let mut config = config;
681 config.shell_completion.prompted = true;
682 let _ = save_config(&config);
683}
684
685pub 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 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
723pub 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
759pub fn get_default_launch_method() -> Result<LaunchMethod> {
761 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 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}