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 {
183 if let Ok(override_dir) = std::env::var("GW_CONFIG_HOME") {
184 if !override_dir.is_empty() {
185 return PathBuf::from(override_dir).join("config.json");
186 }
187 }
188 let home = home_dir_or_fallback();
189 home.join(".config")
190 .join("git-worktree-manager")
191 .join("config.json")
192}
193
194fn deep_merge(base: Value, over: Value) -> Value {
196 match (base, over) {
197 (Value::Object(mut base_map), Value::Object(over_map)) => {
198 for (key, over_val) in over_map {
199 let merged = if let Some(base_val) = base_map.remove(&key) {
200 deep_merge(base_val, over_val)
201 } else {
202 over_val
203 };
204 base_map.insert(key, merged);
205 }
206 Value::Object(base_map)
207 }
208 (_, over) => over,
209 }
210}
211
212fn get_legacy_config_path() -> PathBuf {
214 let home = home_dir_or_fallback();
215 home.join(".config")
216 .join("claude-worktree")
217 .join("config.json")
218}
219
220pub fn load_config() -> Result<Config> {
223 let config_path = get_config_path();
224
225 let config_path = if config_path.exists() {
226 config_path
227 } else {
228 let legacy = get_legacy_config_path();
229 if legacy.exists() {
230 legacy
231 } else {
232 return Ok(Config::default());
233 }
234 };
235
236 let content = std::fs::read_to_string(&config_path).map_err(|e| {
237 CwError::Config(format!(
238 "Failed to load config from {}: {}",
239 config_path.display(),
240 e
241 ))
242 })?;
243
244 let file_value: Value = serde_json::from_str(&content).map_err(|e| {
245 CwError::Config(format!(
246 "Failed to parse config from {}: {}",
247 config_path.display(),
248 e
249 ))
250 })?;
251
252 let default_value = serde_json::to_value(Config::default())?;
253 let merged = deep_merge(default_value, file_value);
254
255 serde_json::from_value(merged).map_err(|e| {
256 CwError::Config(format!(
257 "Failed to deserialize config from {}: {}",
258 config_path.display(),
259 e
260 ))
261 })
262}
263
264pub fn load_effective_config(cwd: &Path) -> Result<Config> {
273 load_effective_config_with_global(cwd, &get_config_path())
274}
275
276pub fn load_effective_config_with_global(cwd: &Path, global_path: &Path) -> Result<Config> {
279 let mut merged = serde_json::to_value(Config::default())?;
280
281 let global_value = if global_path.exists() {
283 Some(read_json_value(global_path)?)
284 } else if global_path == get_config_path().as_path() {
285 let legacy = get_legacy_config_path();
289 if legacy.exists() {
290 Some(read_json_value(&legacy)?)
291 } else {
292 None
293 }
294 } else {
295 None
296 };
297 if let Some(v) = global_value {
298 merged = deep_merge(merged, v);
299 }
300
301 if let Some(repo_value) = crate::repo_config::load_repo_config(cwd)? {
303 merged = deep_merge(merged, repo_value);
304 }
305
306 serde_json::from_value(merged)
307 .map_err(|e| CwError::Config(format!("invalid effective config: {}", e)))
308}
309
310fn read_json_value(path: &Path) -> Result<serde_json::Value> {
311 let content = std::fs::read_to_string(path).map_err(|e| {
312 CwError::Config(format!(
313 "failed to load config from {}: {}",
314 path.display(),
315 e
316 ))
317 })?;
318 serde_json::from_str(&content).map_err(|e| {
319 CwError::Config(format!(
320 "failed to parse config from {}: {}",
321 path.display(),
322 e
323 ))
324 })
325}
326
327pub fn save_config(config: &Config) -> Result<()> {
329 let config_path = get_config_path();
330 if let Some(parent) = config_path.parent() {
331 std::fs::create_dir_all(parent)?;
332 }
333
334 let content = serde_json::to_string_pretty(config)?;
335 std::fs::write(&config_path, content).map_err(|e| {
336 CwError::Config(format!(
337 "Failed to save config to {}: {}",
338 config_path.display(),
339 e
340 ))
341 })
342}
343
344pub fn get_ai_tool_command() -> Result<Vec<String>> {
348 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
349 get_ai_tool_command_for_cwd(&cwd)
350}
351
352pub fn get_ai_tool_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
354 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
356 if env_tool.trim().is_empty() {
357 return Ok(Vec::new());
358 }
359 return Ok(env_tool.split_whitespace().map(String::from).collect());
360 }
361
362 let config = load_effective_config(cwd)?;
363 let command = &config.ai_tool.command;
364 let args = &config.ai_tool.args;
365
366 let presets = ai_tool_presets();
367 if let Some(base_cmd) = presets.get(command.as_str()) {
368 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
369 cmd.extend(args.iter().cloned());
370 return Ok(cmd);
371 }
372
373 if command.trim().is_empty() {
374 return Ok(Vec::new());
375 }
376
377 let mut cmd = vec![command.clone()];
378 cmd.extend(args.iter().cloned());
379 Ok(cmd)
380}
381
382pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
384 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
385 get_ai_tool_resume_command_for_cwd(&cwd)
386}
387
388pub fn get_ai_tool_resume_command_for_cwd(cwd: &Path) -> Result<Vec<String>> {
390 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
391 if env_tool.trim().is_empty() {
392 return Ok(Vec::new());
393 }
394 let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
395 parts.push("--resume".to_string());
396 return Ok(parts);
397 }
398
399 let config = load_effective_config(cwd)?;
400 let command = &config.ai_tool.command;
401 let args = &config.ai_tool.args;
402
403 if command.trim().is_empty() {
404 return Ok(Vec::new());
405 }
406
407 let resume_presets = ai_tool_resume_presets();
408 if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
409 let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
410 cmd.extend(args.iter().cloned());
411 return Ok(cmd);
412 }
413
414 let presets = ai_tool_presets();
415 if let Some(base_cmd) = presets.get(command.as_str()) {
416 if base_cmd.is_empty() {
417 return Ok(Vec::new());
418 }
419 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
420 cmd.extend(args.iter().cloned());
421 cmd.push("--resume".to_string());
422 return Ok(cmd);
423 }
424
425 let mut cmd = vec![command.clone()];
426 cmd.extend(args.iter().cloned());
427 cmd.push("--resume".to_string());
428 Ok(cmd)
429}
430
431pub fn is_claude_tool() -> Result<bool> {
433 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
434 is_claude_tool_for_cwd(&cwd)
435}
436
437pub fn is_claude_tool_for_cwd(cwd: &Path) -> Result<bool> {
439 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
440 let first_word = env_tool.split_whitespace().next().unwrap_or("");
441 return Ok(first_word == "claude");
442 }
443 let config = load_effective_config(cwd)?;
444 Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
445}
446
447pub fn resolve_launch_display_name(method: &str) -> String {
449 let aliases = launch_method_aliases();
450 let canonical = aliases.get(method).copied().unwrap_or(method);
451 LaunchMethod::from_str_opt(canonical)
452 .map(|m| format!("{} ({})", m.display_name(), method))
453 .unwrap_or_else(|| method.to_string())
454}
455
456fn is_shell_integration_installed() -> bool {
462 let home = home_dir_or_fallback();
463 let shell_env = std::env::var("SHELL").unwrap_or_default();
464
465 let profile_path = if shell_env.contains("zsh") {
466 home.join(".zshrc")
467 } else if shell_env.contains("bash") {
468 home.join(".bashrc")
469 } else if shell_env.contains("fish") {
470 home.join(".config").join("fish").join("config.fish")
471 } else {
472 return false;
473 };
474
475 if let Ok(content) = std::fs::read_to_string(&profile_path) {
476 content.contains("gw _shell-function") || content.contains("gw-cd")
477 } else {
478 false
479 }
480}
481
482pub fn prompt_shell_completion_setup() {
490 let config = match load_config() {
491 Ok(c) => c,
492 Err(_) => return,
493 };
494
495 if config.shell_completion.prompted || config.shell_completion.installed {
496 return;
497 }
498
499 if is_shell_integration_installed() {
500 let mut config = config;
502 config.shell_completion.prompted = true;
503 config.shell_completion.installed = true;
504 let _ = save_config(&config);
505 return;
506 }
507
508 eprintln!(
510 "\n{} Shell integration (gw-cd + tab completion) is not set up.",
511 console::style("Tip:").cyan().bold()
512 );
513 eprintln!(
514 " Run {} to enable directory navigation and completions.\n",
515 console::style("gw shell-setup").cyan()
516 );
517
518 let mut config = config;
520 config.shell_completion.prompted = true;
521 let _ = save_config(&config);
522}
523
524pub fn resolve_launch_alias(value: &str) -> String {
530 let deprecated: HashMap<&str, &str> =
531 HashMap::from([("bg", "detach"), ("background", "detach")]);
532 let aliases = launch_method_aliases();
533
534 if let Some((prefix, suffix)) = value.split_once(':') {
536 let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
537 eprintln!(
538 "Warning: '{}' is deprecated. Use '{}' instead.",
539 prefix, new
540 );
541 new.to_string()
542 } else {
543 aliases
544 .get(prefix)
545 .map(|s| s.to_string())
546 .unwrap_or_else(|| prefix.to_string())
547 };
548 return format!("{}:{}", resolved_prefix, suffix);
549 }
550
551 if let Some(&new) = deprecated.get(value) {
552 eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
553 return new.to_string();
554 }
555
556 aliases
557 .get(value)
558 .map(|s| s.to_string())
559 .unwrap_or_else(|| value.to_string())
560}
561
562pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
566 let term_value = match term_value {
567 Some(v) => v,
568 None => return Ok((get_default_launch_method()?, None)),
569 };
570
571 let resolved = resolve_launch_alias(term_value);
572
573 if let Some((method_str, session_name)) = resolved.split_once(':') {
574 let method = LaunchMethod::from_str_opt(method_str)
575 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
576
577 if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
578 if session_name.len() > MAX_SESSION_NAME_LENGTH {
579 return Err(CwError::Config(format!(
580 "Session name too long (max {} chars): {}",
581 MAX_SESSION_NAME_LENGTH, session_name
582 )));
583 }
584 return Ok((method, Some(session_name.to_string())));
585 } else {
586 return Err(CwError::Config(format!(
587 "Session name not supported for {}",
588 method_str
589 )));
590 }
591 }
592
593 let method = LaunchMethod::from_str_opt(&resolved)
594 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
595 Ok((method, None))
596}
597
598pub fn get_default_launch_method() -> Result<LaunchMethod> {
600 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
601 get_default_launch_method_for_cwd(&cwd)
602}
603
604pub fn get_default_launch_method_for_cwd(cwd: &Path) -> Result<LaunchMethod> {
606 if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
608 let resolved = resolve_launch_alias(&env_val);
609 if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
610 return Ok(method);
611 }
612 }
613
614 let config = load_effective_config(cwd)?;
616 if let Some(ref method) = config.launch.method {
617 let resolved = resolve_launch_alias(method);
618 if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
619 return Ok(m);
620 }
621 }
622
623 Ok(LaunchMethod::Foreground)
624}
625
626pub fn resolve_term_option(
641 term_override: Option<&str>,
642 cwd: &Path,
643) -> Result<(LaunchMethod, Option<String>)> {
644 if let Some(value) = term_override {
645 return parse_term_option(Some(value));
650 }
651 let method = get_default_launch_method_for_cwd(cwd)?;
652 Ok((method, None))
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
660 fn test_default_config() {
661 let config = Config::default();
662 assert_eq!(config.ai_tool.command, "claude");
663 assert!(config.ai_tool.args.is_empty());
664 assert!(config.update.auto_check);
665 }
666
667 #[test]
668 fn test_resolve_launch_alias() {
669 assert_eq!(resolve_launch_alias("fg"), "foreground");
670 assert_eq!(resolve_launch_alias("t"), "tmux");
671 assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
672 assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
673 assert_eq!(resolve_launch_alias("foreground"), "foreground");
674 }
675
676 #[test]
677 fn test_parse_term_option() {
678 let (method, session) = parse_term_option(Some("t")).unwrap();
679 assert_eq!(method, LaunchMethod::Tmux);
680 assert!(session.is_none());
681
682 let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
683 assert_eq!(method, LaunchMethod::Tmux);
684 assert_eq!(session.unwrap(), "mywork");
685
686 let (method, session) = parse_term_option(Some("i-t")).unwrap();
687 assert_eq!(method, LaunchMethod::ItermTab);
688 assert!(session.is_none());
689 }
690
691 #[test]
692 fn test_preset_names() {
693 let presets = ai_tool_presets();
694 assert!(presets.contains_key("claude"));
695 assert!(presets.contains_key("no-op"));
696 assert!(presets.contains_key("codex"));
697 assert_eq!(presets["no-op"].len(), 0);
698 assert_eq!(presets["claude"], vec!["claude"]);
699 }
700
701 #[test]
707 fn interactive_presets_have_no_print_flag() {
708 let presets = ai_tool_presets();
709 for (name, args) in &presets {
710 for forbidden in ["--print", "--tools=default", "--non-interactive"] {
711 assert!(
712 !args.contains(&forbidden),
713 "interactive preset {:?} must not carry {} (would force \
714 headless mode for `gw new --prompt`); got {:?}",
715 name,
716 forbidden,
717 args
718 );
719 }
720 }
721 }
722}