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 set_config_value(key_path: &str, value: &str) -> Result<()> {
489 let mut config = load_config()?;
490 let mut json = serde_json::to_value(&config)?;
491
492 let keys: Vec<&str> = key_path.split('.').collect();
493
494 let json_value: Value = match value.to_lowercase().as_str() {
496 "true" => Value::Bool(true),
497 "false" => Value::Bool(false),
498 _ => {
499 if let Ok(n) = value.parse::<f64>() {
501 serde_json::Number::from_f64(n)
502 .map(Value::Number)
503 .unwrap_or(Value::String(value.to_string()))
504 } else {
505 Value::String(value.to_string())
506 }
507 }
508 };
509
510 let mut current = &mut json;
512 for &key in &keys[..keys.len() - 1] {
513 if !current.is_object() {
514 return Err(CwError::Config(format!(
515 "Invalid config path: {}",
516 key_path
517 )));
518 }
519 current = current
520 .as_object_mut()
521 .ok_or_else(|| CwError::Config(format!("Invalid config path: {}", key_path)))?
522 .entry(key)
523 .or_insert(Value::Object(serde_json::Map::new()));
524 }
525
526 if let Some(obj) = current.as_object_mut() {
527 obj.insert(keys[keys.len() - 1].to_string(), json_value);
528 } else {
529 return Err(CwError::Config(format!(
530 "Invalid config path: {}",
531 key_path
532 )));
533 }
534
535 config = serde_json::from_value(json)
537 .map_err(|e| CwError::Config(format!("Invalid config value: {}", e)))?;
538 save_config(&config)
539}
540
541pub fn show_config() -> Result<String> {
543 let config = load_config()?;
544 let mut lines = Vec::new();
545
546 lines.push("Current configuration:".to_string());
547 lines.push(String::new());
548 lines.push(format!(" AI Tool: {}", config.ai_tool.command));
549
550 if !config.ai_tool.args.is_empty() {
551 lines.push(format!(" Args: {}", config.ai_tool.args.join(" ")));
552 }
553
554 let cmd = get_ai_tool_command()?;
555 lines.push(format!(" Effective command: {}", cmd.join(" ")));
556 lines.push(String::new());
557
558 if let Some(ref method) = config.launch.method {
559 lines.push(format!(" Launch method: {}", method));
560 } else {
561 lines.push(" Launch method: foreground (default)".to_string());
562 }
563
564 lines.push(format!(
565 " Default base branch: {}",
566 config.git.default_base_branch
567 ));
568 lines.push(String::new());
569 lines.push(format!("Config file: {}", get_config_path().display()));
570
571 Ok(lines.join("\n"))
572}
573
574pub fn list_presets() -> String {
576 let presets = ai_tool_presets();
577 let mut lines = vec!["Available AI tool presets:".to_string(), String::new()];
578
579 let mut preset_names: Vec<&str> = presets.keys().copied().collect();
580 preset_names.sort();
581
582 for name in preset_names {
583 let cmd = presets[name].join(" ");
584 lines.push(format!(" {:<20} -> {}", name, cmd));
585 }
586
587 lines.join("\n")
588}
589
590fn is_shell_integration_installed() -> bool {
596 let home = home_dir_or_fallback();
597 let shell_env = std::env::var("SHELL").unwrap_or_default();
598
599 let profile_path = if shell_env.contains("zsh") {
600 home.join(".zshrc")
601 } else if shell_env.contains("bash") {
602 home.join(".bashrc")
603 } else if shell_env.contains("fish") {
604 home.join(".config").join("fish").join("config.fish")
605 } else {
606 return false;
607 };
608
609 if let Ok(content) = std::fs::read_to_string(&profile_path) {
610 content.contains("gw _shell-function") || content.contains("gw-cd")
611 } else {
612 false
613 }
614}
615
616pub fn prompt_shell_completion_setup() {
624 let config = match load_config() {
625 Ok(c) => c,
626 Err(_) => return,
627 };
628
629 if config.shell_completion.prompted || config.shell_completion.installed {
630 return;
631 }
632
633 if is_shell_integration_installed() {
634 let mut config = config;
636 config.shell_completion.prompted = true;
637 config.shell_completion.installed = true;
638 let _ = save_config(&config);
639 return;
640 }
641
642 eprintln!(
644 "\n{} Shell integration (gw-cd + tab completion) is not set up.",
645 console::style("Tip:").cyan().bold()
646 );
647 eprintln!(
648 " Run {} to enable directory navigation and completions.\n",
649 console::style("gw shell-setup").cyan()
650 );
651
652 let mut config = config;
654 config.shell_completion.prompted = true;
655 let _ = save_config(&config);
656}
657
658pub fn resolve_launch_alias(value: &str) -> String {
664 let deprecated: HashMap<&str, &str> =
665 HashMap::from([("bg", "detach"), ("background", "detach")]);
666 let aliases = launch_method_aliases();
667
668 if let Some((prefix, suffix)) = value.split_once(':') {
670 let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
671 eprintln!(
672 "Warning: '{}' is deprecated. Use '{}' instead.",
673 prefix, new
674 );
675 new.to_string()
676 } else {
677 aliases
678 .get(prefix)
679 .map(|s| s.to_string())
680 .unwrap_or_else(|| prefix.to_string())
681 };
682 return format!("{}:{}", resolved_prefix, suffix);
683 }
684
685 if let Some(&new) = deprecated.get(value) {
686 eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
687 return new.to_string();
688 }
689
690 aliases
691 .get(value)
692 .map(|s| s.to_string())
693 .unwrap_or_else(|| value.to_string())
694}
695
696pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
700 let term_value = match term_value {
701 Some(v) => v,
702 None => return Ok((get_default_launch_method()?, None)),
703 };
704
705 let resolved = resolve_launch_alias(term_value);
706
707 if let Some((method_str, session_name)) = resolved.split_once(':') {
708 let method = LaunchMethod::from_str_opt(method_str)
709 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
710
711 if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
712 if session_name.len() > MAX_SESSION_NAME_LENGTH {
713 return Err(CwError::Config(format!(
714 "Session name too long (max {} chars): {}",
715 MAX_SESSION_NAME_LENGTH, session_name
716 )));
717 }
718 return Ok((method, Some(session_name.to_string())));
719 } else {
720 return Err(CwError::Config(format!(
721 "Session name not supported for {}",
722 method_str
723 )));
724 }
725 }
726
727 let method = LaunchMethod::from_str_opt(&resolved)
728 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
729 Ok((method, None))
730}
731
732pub fn get_default_launch_method() -> 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_config()?;
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_eq!(config.git.default_base_branch, "main");
764 assert!(config.update.auto_check);
765 }
766
767 #[test]
768 fn test_resolve_launch_alias() {
769 assert_eq!(resolve_launch_alias("fg"), "foreground");
770 assert_eq!(resolve_launch_alias("t"), "tmux");
771 assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
772 assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
773 assert_eq!(resolve_launch_alias("foreground"), "foreground");
774 }
775
776 #[test]
777 fn test_parse_term_option() {
778 let (method, session) = parse_term_option(Some("t")).unwrap();
779 assert_eq!(method, LaunchMethod::Tmux);
780 assert!(session.is_none());
781
782 let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
783 assert_eq!(method, LaunchMethod::Tmux);
784 assert_eq!(session.unwrap(), "mywork");
785
786 let (method, session) = parse_term_option(Some("i-t")).unwrap();
787 assert_eq!(method, LaunchMethod::ItermTab);
788 assert!(session.is_none());
789 }
790
791 #[test]
792 fn test_preset_names() {
793 let presets = ai_tool_presets();
794 assert!(presets.contains_key("claude"));
795 assert!(presets.contains_key("no-op"));
796 assert!(presets.contains_key("codex"));
797 assert_eq!(presets["no-op"].len(), 0);
798 assert_eq!(presets["claude"], vec!["claude"]);
799 }
800
801 #[test]
802 fn test_list_presets_format() {
803 let output = list_presets();
804 assert!(output.contains("Available AI tool presets:"));
805 assert!(output.contains("claude"));
806 assert!(output.contains("no-op"));
807 }
808}