1use std::collections::HashMap;
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12use crate::constants::{launch_method_aliases, LaunchMethod, MAX_SESSION_NAME_LENGTH};
13use crate::error::{CwError, Result};
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct Config {
18 pub ai_tool: AiToolConfig,
19 pub launch: LaunchConfig,
20 pub git: GitConfig,
21 pub update: UpdateConfig,
22 pub shell_completion: ShellCompletionConfig,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct AiToolConfig {
27 pub command: String,
28 pub args: Vec<String>,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct LaunchConfig {
33 pub method: Option<String>,
34 pub tmux_session_prefix: String,
35 pub wezterm_ready_timeout: f64,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct GitConfig {
40 pub default_base_branch: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct UpdateConfig {
45 pub auto_check: bool,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct ShellCompletionConfig {
50 pub prompted: bool,
51 pub installed: bool,
52}
53
54impl Default for Config {
55 fn default() -> Self {
56 Self {
57 ai_tool: AiToolConfig {
58 command: "claude".to_string(),
59 args: Vec::new(),
60 },
61 launch: LaunchConfig {
62 method: None,
63 tmux_session_prefix: "gw".to_string(),
64 wezterm_ready_timeout: 5.0,
65 },
66 git: GitConfig {
67 default_base_branch: "main".to_string(),
68 },
69 update: UpdateConfig { auto_check: true },
70 shell_completion: ShellCompletionConfig {
71 prompted: false,
72 installed: false,
73 },
74 }
75 }
76}
77
78pub fn ai_tool_presets() -> HashMap<&'static str, Vec<&'static str>> {
80 HashMap::from([
81 ("no-op", vec![]),
82 ("claude", vec!["claude"]),
83 (
84 "claude-yolo",
85 vec!["claude", "--dangerously-skip-permissions"],
86 ),
87 ("claude-remote", vec!["claude", "/remote-control"]),
88 (
89 "claude-yolo-remote",
90 vec![
91 "claude",
92 "--dangerously-skip-permissions",
93 "/remote-control",
94 ],
95 ),
96 ("codex", vec!["codex"]),
97 (
98 "codex-yolo",
99 vec!["codex", "--dangerously-bypass-approvals-and-sandbox"],
100 ),
101 ])
102}
103
104pub fn ai_tool_resume_presets() -> HashMap<&'static str, Vec<&'static str>> {
106 HashMap::from([
107 ("claude", vec!["claude", "--continue"]),
108 (
109 "claude-yolo",
110 vec!["claude", "--dangerously-skip-permissions", "--continue"],
111 ),
112 (
113 "claude-remote",
114 vec!["claude", "--continue", "/remote-control"],
115 ),
116 (
117 "claude-yolo-remote",
118 vec![
119 "claude",
120 "--dangerously-skip-permissions",
121 "--continue",
122 "/remote-control",
123 ],
124 ),
125 ("codex", vec!["codex", "resume", "--last"]),
126 (
127 "codex-yolo",
128 vec![
129 "codex",
130 "resume",
131 "--dangerously-bypass-approvals-and-sandbox",
132 "--last",
133 ],
134 ),
135 ])
136}
137
138#[derive(Debug)]
140pub struct MergePreset {
141 pub base_override: Option<Vec<&'static str>>,
142 pub flags: Vec<&'static str>,
143 pub prompt_position: PromptPosition,
144}
145
146#[derive(Debug)]
147pub enum PromptPosition {
148 End,
149 Index(usize),
150}
151
152pub fn ai_tool_merge_presets() -> HashMap<&'static str, MergePreset> {
154 HashMap::from([
155 (
156 "claude",
157 MergePreset {
158 base_override: None,
159 flags: vec!["--print", "--tools=default"],
160 prompt_position: PromptPosition::End,
161 },
162 ),
163 (
164 "claude-yolo",
165 MergePreset {
166 base_override: None,
167 flags: vec!["--print", "--tools=default"],
168 prompt_position: PromptPosition::End,
169 },
170 ),
171 (
172 "claude-remote",
173 MergePreset {
174 base_override: Some(vec!["claude"]),
175 flags: vec!["--print", "--tools=default"],
176 prompt_position: PromptPosition::End,
177 },
178 ),
179 (
180 "claude-yolo-remote",
181 MergePreset {
182 base_override: Some(vec!["claude", "--dangerously-skip-permissions"]),
183 flags: vec!["--print", "--tools=default"],
184 prompt_position: PromptPosition::End,
185 },
186 ),
187 (
188 "codex",
189 MergePreset {
190 base_override: None,
191 flags: vec!["--non-interactive"],
192 prompt_position: PromptPosition::End,
193 },
194 ),
195 (
196 "codex-yolo",
197 MergePreset {
198 base_override: None,
199 flags: vec!["--non-interactive"],
200 prompt_position: PromptPosition::End,
201 },
202 ),
203 ])
204}
205
206pub fn claude_preset_names() -> Vec<&'static str> {
208 ai_tool_presets()
209 .iter()
210 .filter(|(_, v)| v.first().map(|&s| s == "claude").unwrap_or(false))
211 .map(|(&k, _)| k)
212 .collect()
213}
214
215pub fn get_config_path() -> PathBuf {
221 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
222 home.join(".config")
223 .join("git-worktree-manager")
224 .join("config.json")
225}
226
227fn deep_merge(base: Value, over: Value) -> Value {
229 match (base, over) {
230 (Value::Object(mut base_map), Value::Object(over_map)) => {
231 for (key, over_val) in over_map {
232 let merged = if let Some(base_val) = base_map.remove(&key) {
233 deep_merge(base_val, over_val)
234 } else {
235 over_val
236 };
237 base_map.insert(key, merged);
238 }
239 Value::Object(base_map)
240 }
241 (_, over) => over,
242 }
243}
244
245pub fn load_config() -> Result<Config> {
247 let config_path = get_config_path();
248
249 if !config_path.exists() {
250 return Ok(Config::default());
251 }
252
253 let content = std::fs::read_to_string(&config_path).map_err(|e| {
254 CwError::Config(format!(
255 "Failed to load config from {}: {}",
256 config_path.display(),
257 e
258 ))
259 })?;
260
261 let file_value: Value = serde_json::from_str(&content).map_err(|e| {
262 CwError::Config(format!(
263 "Failed to parse config from {}: {}",
264 config_path.display(),
265 e
266 ))
267 })?;
268
269 let default_value = serde_json::to_value(Config::default())?;
270 let merged = deep_merge(default_value, file_value);
271
272 serde_json::from_value(merged).map_err(|e| {
273 CwError::Config(format!(
274 "Failed to deserialize config from {}: {}",
275 config_path.display(),
276 e
277 ))
278 })
279}
280
281pub fn save_config(config: &Config) -> Result<()> {
283 let config_path = get_config_path();
284 if let Some(parent) = config_path.parent() {
285 std::fs::create_dir_all(parent)?;
286 }
287
288 let content = serde_json::to_string_pretty(config)?;
289 std::fs::write(&config_path, content).map_err(|e| {
290 CwError::Config(format!(
291 "Failed to save config to {}: {}",
292 config_path.display(),
293 e
294 ))
295 })
296}
297
298pub fn get_ai_tool_command() -> Result<Vec<String>> {
302 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
304 if env_tool.trim().is_empty() {
305 return Ok(Vec::new());
306 }
307 return Ok(env_tool.split_whitespace().map(String::from).collect());
308 }
309
310 let config = load_config()?;
311 let command = &config.ai_tool.command;
312 let args = &config.ai_tool.args;
313
314 let presets = ai_tool_presets();
315 if let Some(base_cmd) = presets.get(command.as_str()) {
316 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
317 cmd.extend(args.iter().cloned());
318 return Ok(cmd);
319 }
320
321 if command.trim().is_empty() {
322 return Ok(Vec::new());
323 }
324
325 let mut cmd = vec![command.clone()];
326 cmd.extend(args.iter().cloned());
327 Ok(cmd)
328}
329
330pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
332 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
333 if env_tool.trim().is_empty() {
334 return Ok(Vec::new());
335 }
336 let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
337 parts.push("--resume".to_string());
338 return Ok(parts);
339 }
340
341 let config = load_config()?;
342 let command = &config.ai_tool.command;
343 let args = &config.ai_tool.args;
344
345 if command.trim().is_empty() {
346 return Ok(Vec::new());
347 }
348
349 let resume_presets = ai_tool_resume_presets();
350 if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
351 let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
352 cmd.extend(args.iter().cloned());
353 return Ok(cmd);
354 }
355
356 let presets = ai_tool_presets();
357 if let Some(base_cmd) = presets.get(command.as_str()) {
358 if base_cmd.is_empty() {
359 return Ok(Vec::new());
360 }
361 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
362 cmd.extend(args.iter().cloned());
363 cmd.push("--resume".to_string());
364 return Ok(cmd);
365 }
366
367 let mut cmd = vec![command.clone()];
368 cmd.extend(args.iter().cloned());
369 cmd.push("--resume".to_string());
370 Ok(cmd)
371}
372
373pub fn get_ai_tool_merge_command(prompt: &str) -> 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(prompt.to_string());
381 return Ok(parts);
382 }
383
384 let config = load_config()?;
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 merge_presets = ai_tool_merge_presets();
393 if let Some(preset) = merge_presets.get(command.as_str()) {
394 let base_cmd: Vec<String> = if let Some(ref base_override) = preset.base_override {
395 base_override.iter().map(|s| s.to_string()).collect()
396 } else {
397 let presets = ai_tool_presets();
398 presets
399 .get(command.as_str())
400 .map(|v| v.iter().map(|s| s.to_string()).collect())
401 .unwrap_or_else(|| vec![command.clone()])
402 };
403
404 let mut cmd_parts = base_cmd;
405 cmd_parts.extend(args.iter().cloned());
406 cmd_parts.extend(preset.flags.iter().map(|s| s.to_string()));
407
408 match preset.prompt_position {
409 PromptPosition::End => cmd_parts.push(prompt.to_string()),
410 PromptPosition::Index(i) => cmd_parts.insert(i, prompt.to_string()),
411 }
412
413 return Ok(cmd_parts);
414 }
415
416 let presets = ai_tool_presets();
417 if let Some(base_cmd) = presets.get(command.as_str()) {
418 if base_cmd.is_empty() {
419 return Ok(Vec::new());
420 }
421 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
422 cmd.extend(args.iter().cloned());
423 cmd.push(prompt.to_string());
424 return Ok(cmd);
425 }
426
427 let mut cmd = vec![command.clone()];
428 cmd.extend(args.iter().cloned());
429 cmd.push(prompt.to_string());
430 Ok(cmd)
431}
432
433pub fn is_claude_tool() -> Result<bool> {
435 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
436 let first_word = env_tool.split_whitespace().next().unwrap_or("");
437 return Ok(first_word == "claude");
438 }
439 let config = load_config()?;
440 Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
441}
442
443pub fn set_ai_tool(tool: &str, args: Option<Vec<String>>) -> Result<()> {
445 let mut config = load_config()?;
446 config.ai_tool.command = tool.to_string();
447 config.ai_tool.args = args.unwrap_or_default();
448 save_config(&config)
449}
450
451pub fn use_preset(preset_name: &str) -> Result<()> {
453 let presets = ai_tool_presets();
454 if !presets.contains_key(preset_name) {
455 let available: Vec<&str> = presets.keys().copied().collect();
456 return Err(CwError::Config(format!(
457 "Unknown preset: {}. Available: {}",
458 preset_name,
459 available.join(", ")
460 )));
461 }
462 set_ai_tool(preset_name, None)
463}
464
465pub fn reset_config() -> Result<()> {
467 save_config(&Config::default())
468}
469
470pub fn set_config_value(key_path: &str, value: &str) -> Result<()> {
472 let mut config = load_config()?;
473 let mut json = serde_json::to_value(&config)?;
474
475 let keys: Vec<&str> = key_path.split('.').collect();
476
477 let json_value: Value = match value.to_lowercase().as_str() {
479 "true" => Value::Bool(true),
480 "false" => Value::Bool(false),
481 _ => {
482 if let Ok(n) = value.parse::<f64>() {
484 serde_json::Number::from_f64(n)
485 .map(Value::Number)
486 .unwrap_or(Value::String(value.to_string()))
487 } else {
488 Value::String(value.to_string())
489 }
490 }
491 };
492
493 let mut current = &mut json;
495 for &key in &keys[..keys.len() - 1] {
496 if !current.is_object() {
497 return Err(CwError::Config(format!(
498 "Invalid config path: {}",
499 key_path
500 )));
501 }
502 current = current
503 .as_object_mut()
504 .unwrap()
505 .entry(key)
506 .or_insert(Value::Object(serde_json::Map::new()));
507 }
508
509 if let Some(obj) = current.as_object_mut() {
510 obj.insert(keys[keys.len() - 1].to_string(), json_value);
511 } else {
512 return Err(CwError::Config(format!(
513 "Invalid config path: {}",
514 key_path
515 )));
516 }
517
518 config = serde_json::from_value(json)
520 .map_err(|e| CwError::Config(format!("Invalid config value: {}", e)))?;
521 save_config(&config)
522}
523
524pub fn show_config() -> Result<String> {
526 let config = load_config()?;
527 let mut lines = Vec::new();
528
529 lines.push("Current configuration:".to_string());
530 lines.push(String::new());
531 lines.push(format!(" AI Tool: {}", config.ai_tool.command));
532
533 if !config.ai_tool.args.is_empty() {
534 lines.push(format!(" Args: {}", config.ai_tool.args.join(" ")));
535 }
536
537 let cmd = get_ai_tool_command()?;
538 lines.push(format!(" Effective command: {}", cmd.join(" ")));
539 lines.push(String::new());
540
541 if let Some(ref method) = config.launch.method {
542 lines.push(format!(" Launch method: {}", method));
543 } else {
544 lines.push(" Launch method: foreground (default)".to_string());
545 }
546
547 lines.push(format!(
548 " Default base branch: {}",
549 config.git.default_base_branch
550 ));
551 lines.push(String::new());
552 lines.push(format!("Config file: {}", get_config_path().display()));
553
554 Ok(lines.join("\n"))
555}
556
557pub fn list_presets() -> String {
559 let presets = ai_tool_presets();
560 let mut lines = vec!["Available AI tool presets:".to_string(), String::new()];
561
562 let mut preset_names: Vec<&str> = presets.keys().copied().collect();
563 preset_names.sort();
564
565 for name in preset_names {
566 let cmd = presets[name].join(" ");
567 lines.push(format!(" {:<20} -> {}", name, cmd));
568 }
569
570 lines.join("\n")
571}
572
573pub fn resolve_launch_alias(value: &str) -> String {
579 let deprecated: HashMap<&str, &str> =
580 HashMap::from([("bg", "detach"), ("background", "detach")]);
581 let aliases = launch_method_aliases();
582
583 if let Some((prefix, suffix)) = value.split_once(':') {
585 let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
586 eprintln!(
587 "Warning: '{}' is deprecated. Use '{}' instead.",
588 prefix, new
589 );
590 new.to_string()
591 } else {
592 aliases
593 .get(prefix)
594 .map(|s| s.to_string())
595 .unwrap_or_else(|| prefix.to_string())
596 };
597 return format!("{}:{}", resolved_prefix, suffix);
598 }
599
600 if let Some(&new) = deprecated.get(value) {
601 eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
602 return new.to_string();
603 }
604
605 aliases
606 .get(value)
607 .map(|s| s.to_string())
608 .unwrap_or_else(|| value.to_string())
609}
610
611pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
615 let term_value = match term_value {
616 Some(v) => v,
617 None => return Ok((get_default_launch_method()?, None)),
618 };
619
620 let resolved = resolve_launch_alias(term_value);
621
622 if let Some((method_str, session_name)) = resolved.split_once(':') {
623 let method = LaunchMethod::from_str_opt(method_str)
624 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
625
626 if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
627 if session_name.len() > MAX_SESSION_NAME_LENGTH {
628 return Err(CwError::Config(format!(
629 "Session name too long (max {} chars): {}",
630 MAX_SESSION_NAME_LENGTH, session_name
631 )));
632 }
633 return Ok((method, Some(session_name.to_string())));
634 } else {
635 return Err(CwError::Config(format!(
636 "Session name not supported for {}",
637 method_str
638 )));
639 }
640 }
641
642 let method = LaunchMethod::from_str_opt(&resolved)
643 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
644 Ok((method, None))
645}
646
647pub fn get_default_launch_method() -> Result<LaunchMethod> {
649 if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
651 let resolved = resolve_launch_alias(&env_val);
652 if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
653 return Ok(method);
654 }
655 }
656
657 let config = load_config()?;
659 if let Some(ref method) = config.launch.method {
660 let resolved = resolve_launch_alias(method);
661 if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
662 return Ok(m);
663 }
664 }
665
666 Ok(LaunchMethod::Foreground)
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_eq!(config.git.default_base_branch, "main");
679 assert!(config.update.auto_check);
680 }
681
682 #[test]
683 fn test_resolve_launch_alias() {
684 assert_eq!(resolve_launch_alias("fg"), "foreground");
685 assert_eq!(resolve_launch_alias("t"), "tmux");
686 assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
687 assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
688 assert_eq!(resolve_launch_alias("foreground"), "foreground");
689 }
690
691 #[test]
692 fn test_parse_term_option() {
693 let (method, session) = parse_term_option(Some("t")).unwrap();
694 assert_eq!(method, LaunchMethod::Tmux);
695 assert!(session.is_none());
696
697 let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
698 assert_eq!(method, LaunchMethod::Tmux);
699 assert_eq!(session.unwrap(), "mywork");
700
701 let (method, session) = parse_term_option(Some("i-t")).unwrap();
702 assert_eq!(method, LaunchMethod::ItermTab);
703 assert!(session.is_none());
704 }
705
706 #[test]
707 fn test_preset_names() {
708 let presets = ai_tool_presets();
709 assert!(presets.contains_key("claude"));
710 assert!(presets.contains_key("no-op"));
711 assert!(presets.contains_key("codex"));
712 assert_eq!(presets["no-op"].len(), 0);
713 assert_eq!(presets["claude"], vec!["claude"]);
714 }
715
716 #[test]
717 fn test_list_presets_format() {
718 let output = list_presets();
719 assert!(output.contains("Available AI tool presets:"));
720 assert!(output.contains("claude"));
721 assert!(output.contains("no-op"));
722 }
723}