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, Default, Serialize, Deserialize)]
40pub struct GitConfig {
41 }
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 update: UpdateConfig { auto_check: true },
69 shell_completion: ShellCompletionConfig {
70 prompted: false,
71 installed: false,
72 },
73 }
74 }
75}
76
77pub fn ai_tool_presets() -> HashMap<&'static str, Vec<&'static str>> {
79 HashMap::from([
80 ("no-op", vec![]),
81 ("claude", vec!["claude"]),
82 (
83 "claude-yolo",
84 vec!["claude", "--dangerously-skip-permissions"],
85 ),
86 ("claude-remote", vec!["claude", "/remote-control"]),
87 (
88 "claude-yolo-remote",
89 vec![
90 "claude",
91 "--dangerously-skip-permissions",
92 "/remote-control",
93 ],
94 ),
95 ("codex", vec!["codex"]),
96 (
97 "codex-yolo",
98 vec!["codex", "--dangerously-bypass-approvals-and-sandbox"],
99 ),
100 ])
101}
102
103pub fn ai_tool_resume_presets() -> HashMap<&'static str, Vec<&'static str>> {
105 HashMap::from([
106 ("claude", vec!["claude", "--continue"]),
107 (
108 "claude-yolo",
109 vec!["claude", "--dangerously-skip-permissions", "--continue"],
110 ),
111 (
112 "claude-remote",
113 vec!["claude", "--continue", "/remote-control"],
114 ),
115 (
116 "claude-yolo-remote",
117 vec![
118 "claude",
119 "--dangerously-skip-permissions",
120 "--continue",
121 "/remote-control",
122 ],
123 ),
124 ("codex", vec!["codex", "resume", "--last"]),
125 (
126 "codex-yolo",
127 vec![
128 "codex",
129 "resume",
130 "--dangerously-bypass-approvals-and-sandbox",
131 "--last",
132 ],
133 ),
134 ])
135}
136
137#[derive(Debug)]
139pub struct MergePreset {
140 pub base_override: Option<Vec<&'static str>>,
141 pub flags: Vec<&'static str>,
142 pub prompt_position: PromptPosition,
143}
144
145#[derive(Debug)]
146pub enum PromptPosition {
147 End,
148 Index(usize),
149}
150
151pub fn ai_tool_merge_presets() -> HashMap<&'static str, MergePreset> {
153 HashMap::from([
154 (
155 "claude",
156 MergePreset {
157 base_override: None,
158 flags: vec!["--print", "--tools=default"],
159 prompt_position: PromptPosition::End,
160 },
161 ),
162 (
163 "claude-yolo",
164 MergePreset {
165 base_override: None,
166 flags: vec!["--print", "--tools=default"],
167 prompt_position: PromptPosition::End,
168 },
169 ),
170 (
171 "claude-remote",
172 MergePreset {
173 base_override: Some(vec!["claude"]),
174 flags: vec!["--print", "--tools=default"],
175 prompt_position: PromptPosition::End,
176 },
177 ),
178 (
179 "claude-yolo-remote",
180 MergePreset {
181 base_override: Some(vec!["claude", "--dangerously-skip-permissions"]),
182 flags: vec!["--print", "--tools=default"],
183 prompt_position: PromptPosition::End,
184 },
185 ),
186 (
187 "codex",
188 MergePreset {
189 base_override: None,
190 flags: vec!["--non-interactive"],
191 prompt_position: PromptPosition::End,
192 },
193 ),
194 (
195 "codex-yolo",
196 MergePreset {
197 base_override: None,
198 flags: vec!["--non-interactive"],
199 prompt_position: PromptPosition::End,
200 },
201 ),
202 ])
203}
204
205pub fn claude_preset_names() -> Vec<&'static str> {
207 ai_tool_presets()
208 .iter()
209 .filter(|(_, v)| v.first().map(|&s| s == "claude").unwrap_or(false))
210 .map(|(&k, _)| k)
211 .collect()
212}
213
214pub fn get_config_path() -> PathBuf {
220 let home = home_dir_or_fallback();
221 home.join(".config")
222 .join("git-worktree-manager")
223 .join("config.json")
224}
225
226fn deep_merge(base: Value, over: Value) -> Value {
228 match (base, over) {
229 (Value::Object(mut base_map), Value::Object(over_map)) => {
230 for (key, over_val) in over_map {
231 let merged = if let Some(base_val) = base_map.remove(&key) {
232 deep_merge(base_val, over_val)
233 } else {
234 over_val
235 };
236 base_map.insert(key, merged);
237 }
238 Value::Object(base_map)
239 }
240 (_, over) => over,
241 }
242}
243
244fn get_legacy_config_path() -> PathBuf {
246 let home = home_dir_or_fallback();
247 home.join(".config")
248 .join("claude-worktree")
249 .join("config.json")
250}
251
252pub fn load_config() -> Result<Config> {
255 let config_path = get_config_path();
256
257 let config_path = if config_path.exists() {
258 config_path
259 } else {
260 let legacy = get_legacy_config_path();
261 if legacy.exists() {
262 legacy
263 } else {
264 return Ok(Config::default());
265 }
266 };
267
268 let content = std::fs::read_to_string(&config_path).map_err(|e| {
269 CwError::Config(format!(
270 "Failed to load config from {}: {}",
271 config_path.display(),
272 e
273 ))
274 })?;
275
276 let file_value: Value = serde_json::from_str(&content).map_err(|e| {
277 CwError::Config(format!(
278 "Failed to parse config from {}: {}",
279 config_path.display(),
280 e
281 ))
282 })?;
283
284 let default_value = serde_json::to_value(Config::default())?;
285 let merged = deep_merge(default_value, file_value);
286
287 serde_json::from_value(merged).map_err(|e| {
288 CwError::Config(format!(
289 "Failed to deserialize config from {}: {}",
290 config_path.display(),
291 e
292 ))
293 })
294}
295
296pub fn save_config(config: &Config) -> Result<()> {
298 let config_path = get_config_path();
299 if let Some(parent) = config_path.parent() {
300 std::fs::create_dir_all(parent)?;
301 }
302
303 let content = serde_json::to_string_pretty(config)?;
304 std::fs::write(&config_path, content).map_err(|e| {
305 CwError::Config(format!(
306 "Failed to save config to {}: {}",
307 config_path.display(),
308 e
309 ))
310 })
311}
312
313pub fn get_ai_tool_command() -> Result<Vec<String>> {
317 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
319 if env_tool.trim().is_empty() {
320 return Ok(Vec::new());
321 }
322 return Ok(env_tool.split_whitespace().map(String::from).collect());
323 }
324
325 let config = load_config()?;
326 let command = &config.ai_tool.command;
327 let args = &config.ai_tool.args;
328
329 let presets = ai_tool_presets();
330 if let Some(base_cmd) = presets.get(command.as_str()) {
331 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
332 cmd.extend(args.iter().cloned());
333 return Ok(cmd);
334 }
335
336 if command.trim().is_empty() {
337 return Ok(Vec::new());
338 }
339
340 let mut cmd = vec![command.clone()];
341 cmd.extend(args.iter().cloned());
342 Ok(cmd)
343}
344
345pub fn get_ai_tool_resume_command() -> Result<Vec<String>> {
347 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
348 if env_tool.trim().is_empty() {
349 return Ok(Vec::new());
350 }
351 let mut parts: Vec<String> = env_tool.split_whitespace().map(String::from).collect();
352 parts.push("--resume".to_string());
353 return Ok(parts);
354 }
355
356 let config = load_config()?;
357 let command = &config.ai_tool.command;
358 let args = &config.ai_tool.args;
359
360 if command.trim().is_empty() {
361 return Ok(Vec::new());
362 }
363
364 let resume_presets = ai_tool_resume_presets();
365 if let Some(resume_cmd) = resume_presets.get(command.as_str()) {
366 let mut cmd: Vec<String> = resume_cmd.iter().map(|s| s.to_string()).collect();
367 cmd.extend(args.iter().cloned());
368 return Ok(cmd);
369 }
370
371 let presets = ai_tool_presets();
372 if let Some(base_cmd) = presets.get(command.as_str()) {
373 if base_cmd.is_empty() {
374 return Ok(Vec::new());
375 }
376 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
377 cmd.extend(args.iter().cloned());
378 cmd.push("--resume".to_string());
379 return Ok(cmd);
380 }
381
382 let mut cmd = vec![command.clone()];
383 cmd.extend(args.iter().cloned());
384 cmd.push("--resume".to_string());
385 Ok(cmd)
386}
387
388pub fn get_ai_tool_merge_command(prompt: &str) -> 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(prompt.to_string());
396 return Ok(parts);
397 }
398
399 let config = load_config()?;
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 merge_presets = ai_tool_merge_presets();
408 if let Some(preset) = merge_presets.get(command.as_str()) {
409 let base_cmd: Vec<String> = if let Some(ref base_override) = preset.base_override {
410 base_override.iter().map(|s| s.to_string()).collect()
411 } else {
412 let presets = ai_tool_presets();
413 presets
414 .get(command.as_str())
415 .map(|v| v.iter().map(|s| s.to_string()).collect())
416 .unwrap_or_else(|| vec![command.clone()])
417 };
418
419 let mut cmd_parts = base_cmd;
420 cmd_parts.extend(args.iter().cloned());
421 cmd_parts.extend(preset.flags.iter().map(|s| s.to_string()));
422
423 match preset.prompt_position {
424 PromptPosition::End => cmd_parts.push(prompt.to_string()),
425 PromptPosition::Index(i) => cmd_parts.insert(i, prompt.to_string()),
426 }
427
428 return Ok(cmd_parts);
429 }
430
431 let presets = ai_tool_presets();
432 if let Some(base_cmd) = presets.get(command.as_str()) {
433 if base_cmd.is_empty() {
434 return Ok(Vec::new());
435 }
436 let mut cmd: Vec<String> = base_cmd.iter().map(|s| s.to_string()).collect();
437 cmd.extend(args.iter().cloned());
438 cmd.push(prompt.to_string());
439 return Ok(cmd);
440 }
441
442 let mut cmd = vec![command.clone()];
443 cmd.extend(args.iter().cloned());
444 cmd.push(prompt.to_string());
445 Ok(cmd)
446}
447
448pub fn is_claude_tool() -> Result<bool> {
450 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
451 let first_word = env_tool.split_whitespace().next().unwrap_or("");
452 return Ok(first_word == "claude");
453 }
454 let config = load_config()?;
455 Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
456}
457
458pub fn set_ai_tool(tool: &str, args: Option<Vec<String>>) -> Result<()> {
460 let mut config = load_config()?;
461 config.ai_tool.command = tool.to_string();
462 config.ai_tool.args = args.unwrap_or_default();
463 save_config(&config)
464}
465
466pub fn use_preset(preset_name: &str) -> Result<()> {
468 let presets = ai_tool_presets();
469 if !presets.contains_key(preset_name) {
470 let available: Vec<&str> = presets.keys().copied().collect();
471 return Err(CwError::Config(format!(
472 "Unknown preset: {}. Available: {}",
473 preset_name,
474 available.join(", ")
475 )));
476 }
477 set_ai_tool(preset_name, None)
478}
479
480pub fn reset_config() -> Result<()> {
482 save_config(&Config::default())
483}
484
485pub fn resolve_launch_display_name(method: &str) -> String {
487 let aliases = launch_method_aliases();
488 let canonical = aliases.get(method).copied().unwrap_or(method);
489 LaunchMethod::from_str_opt(canonical)
490 .map(|m| format!("{} ({})", m.display_name(), method))
491 .unwrap_or_else(|| method.to_string())
492}
493
494pub const CONFIG_KEYS: &[(&str, &str)] = &[
496 (
497 "ai_tool.command",
498 "AI tool command name (e.g., claude, codex)",
499 ),
500 ("ai_tool.args", "Additional arguments passed to AI tool"),
501 (
502 "launch.method",
503 "Terminal launch method (foreground, tmux, wezterm, ...)",
504 ),
505 (
506 "launch.tmux_session_prefix",
507 "Prefix for tmux session names",
508 ),
509 (
510 "launch.wezterm_ready_timeout",
511 "Timeout (seconds) waiting for WezTerm",
512 ),
513 (
514 "update.auto_check",
515 "Automatically check for updates on startup",
516 ),
517 (
518 "shell_completion.prompted",
519 "Whether shell completion setup was prompted",
520 ),
521 (
522 "shell_completion.installed",
523 "Whether shell completion is installed",
524 ),
525];
526
527pub fn list_config() -> Result<()> {
529 use console::style;
530
531 let config = load_config()?;
532 let json = serde_json::to_value(&config)?;
533
534 println!();
535 println!(
536 " {:<35} {:<25} {}",
537 style("KEY").dim(),
538 style("VALUE").dim(),
539 style("DESCRIPTION").dim(),
540 );
541 println!(" {}", style("─".repeat(90)).dim());
542
543 for (key, desc) in CONFIG_KEYS {
544 let keys: Vec<&str> = key.split('.').collect();
545 let mut current = &json;
546 let mut found = true;
547 for &k in &keys {
548 match current.get(k) {
549 Some(v) => current = v,
550 None => {
551 found = false;
552 break;
553 }
554 }
555 }
556
557 let value_str = if !found {
558 style("(unset)".to_string()).dim().to_string()
559 } else {
560 let raw = match current {
561 serde_json::Value::String(s) => s.clone(),
562 serde_json::Value::Bool(b) => b.to_string(),
563 serde_json::Value::Number(n) => n.to_string(),
564 serde_json::Value::Null => "null".to_string(),
565 serde_json::Value::Array(a) => {
566 if a.is_empty() {
567 "[]".to_string()
568 } else {
569 serde_json::to_string(a).unwrap_or_default()
570 }
571 }
572 other => serde_json::to_string(other).unwrap_or_default(),
573 };
574 if *key == "launch.method" && raw != "null" {
576 resolve_launch_display_name(&raw)
577 } else {
578 raw
579 }
580 };
581
582 println!(
583 " {:<35} {:<25} {}",
584 style(key).bold(),
585 value_str,
586 style(desc).dim(),
587 );
588 }
589 println!();
590
591 Ok(())
592}
593
594pub fn get_config_value(key_path: &str) -> Result<()> {
596 let config = load_config()?;
597 let json = serde_json::to_value(&config)?;
598
599 let keys: Vec<&str> = key_path.split('.').collect();
600 let mut current = &json;
601 for &key in &keys {
602 current = current
603 .get(key)
604 .ok_or_else(|| CwError::Config(format!("Unknown config key: {}", key_path)))?;
605 }
606
607 match current {
608 serde_json::Value::String(s) => println!("{}", s),
609 serde_json::Value::Bool(b) => println!("{}", b),
610 serde_json::Value::Number(n) => println!("{}", n),
611 serde_json::Value::Null => println!("null"),
612 other => println!(
613 "{}",
614 serde_json::to_string_pretty(other).unwrap_or_default()
615 ),
616 }
617
618 Ok(())
619}
620
621pub fn set_config_value(key_path: &str, value: &str) -> Result<()> {
627 if key_path == "ai_tool" {
629 let presets = ai_tool_presets();
630 if presets.contains_key(value) {
631 return use_preset(value);
632 }
633 return set_ai_tool(value, None);
635 }
636
637 if key_path == "launch.method" {
639 let aliases = launch_method_aliases();
640 let canonical = aliases.get(value).copied().unwrap_or(value);
641 if value != "null"
643 && LaunchMethod::from_str_opt(canonical).is_none()
644 && LaunchMethod::from_str_opt(value).is_none()
645 {
646 return Err(CwError::Config(format!(
647 "Unknown launch method: '{}'. Use 'gw config list-presets' or 'gw --help' for options.",
648 value
649 )));
650 }
651 }
652
653 let mut config = load_config()?;
654 let mut json = serde_json::to_value(&config)?;
655
656 let keys: Vec<&str> = key_path.split('.').collect();
657
658 let json_value: Value = match value.to_lowercase().as_str() {
660 "true" => Value::Bool(true),
661 "false" => Value::Bool(false),
662 _ => {
663 if let Ok(n) = value.parse::<f64>() {
665 serde_json::Number::from_f64(n)
666 .map(Value::Number)
667 .unwrap_or(Value::String(value.to_string()))
668 } else {
669 Value::String(value.to_string())
670 }
671 }
672 };
673
674 let mut current = &mut json;
676 for &key in &keys[..keys.len() - 1] {
677 if !current.is_object() {
678 return Err(CwError::Config(format!(
679 "Invalid config path: {}",
680 key_path
681 )));
682 }
683 current = current
684 .as_object_mut()
685 .ok_or_else(|| CwError::Config(format!("Invalid config path: {}", key_path)))?
686 .entry(key)
687 .or_insert(Value::Object(serde_json::Map::new()));
688 }
689
690 if let Some(obj) = current.as_object_mut() {
691 obj.insert(keys[keys.len() - 1].to_string(), json_value);
692 } else {
693 return Err(CwError::Config(format!(
694 "Invalid config path: {}",
695 key_path
696 )));
697 }
698
699 config = serde_json::from_value(json)
701 .map_err(|e| CwError::Config(format!("Invalid config value: {}", e)))?;
702 save_config(&config)
703}
704
705pub fn show_config() -> Result<String> {
707 let config = load_config()?;
708 let mut lines = Vec::new();
709
710 lines.push("Current configuration:".to_string());
711 lines.push(String::new());
712 lines.push(format!(" AI Tool: {}", config.ai_tool.command));
713
714 if !config.ai_tool.args.is_empty() {
715 lines.push(format!(" Args: {}", config.ai_tool.args.join(" ")));
716 }
717
718 let cmd = get_ai_tool_command()?;
719 lines.push(format!(" Effective command: {}", cmd.join(" ")));
720 lines.push(String::new());
721
722 if let Some(ref method) = config.launch.method {
723 let display = resolve_launch_display_name(method);
724 lines.push(format!(" Launch method: {}", display));
725 } else {
726 lines.push(" Launch method: Foreground (default)".to_string());
727 }
728
729 let detected = crate::git::detect_default_branch(None);
731 lines.push(format!(
732 " Default base branch: {} (auto-detected)",
733 detected,
734 ));
735 lines.push(String::new());
736 lines.push(format!("Config file: {}", get_config_path().display()));
737
738 Ok(lines.join("\n"))
739}
740
741pub fn list_presets() -> String {
743 let presets = ai_tool_presets();
744 let mut lines = vec!["Available AI tool presets:".to_string(), String::new()];
745
746 let mut preset_names: Vec<&str> = presets.keys().copied().collect();
747 preset_names.sort();
748
749 for name in preset_names {
750 let cmd = presets[name].join(" ");
751 lines.push(format!(" {:<20} -> {}", name, cmd));
752 }
753
754 lines.join("\n")
755}
756
757fn is_shell_integration_installed() -> bool {
763 let home = home_dir_or_fallback();
764 let shell_env = std::env::var("SHELL").unwrap_or_default();
765
766 let profile_path = if shell_env.contains("zsh") {
767 home.join(".zshrc")
768 } else if shell_env.contains("bash") {
769 home.join(".bashrc")
770 } else if shell_env.contains("fish") {
771 home.join(".config").join("fish").join("config.fish")
772 } else {
773 return false;
774 };
775
776 if let Ok(content) = std::fs::read_to_string(&profile_path) {
777 content.contains("gw _shell-function") || content.contains("gw-cd")
778 } else {
779 false
780 }
781}
782
783pub fn prompt_shell_completion_setup() {
791 let config = match load_config() {
792 Ok(c) => c,
793 Err(_) => return,
794 };
795
796 if config.shell_completion.prompted || config.shell_completion.installed {
797 return;
798 }
799
800 if is_shell_integration_installed() {
801 let mut config = config;
803 config.shell_completion.prompted = true;
804 config.shell_completion.installed = true;
805 let _ = save_config(&config);
806 return;
807 }
808
809 eprintln!(
811 "\n{} Shell integration (gw-cd + tab completion) is not set up.",
812 console::style("Tip:").cyan().bold()
813 );
814 eprintln!(
815 " Run {} to enable directory navigation and completions.\n",
816 console::style("gw shell-setup").cyan()
817 );
818
819 let mut config = config;
821 config.shell_completion.prompted = true;
822 let _ = save_config(&config);
823}
824
825pub fn resolve_launch_alias(value: &str) -> String {
831 let deprecated: HashMap<&str, &str> =
832 HashMap::from([("bg", "detach"), ("background", "detach")]);
833 let aliases = launch_method_aliases();
834
835 if let Some((prefix, suffix)) = value.split_once(':') {
837 let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
838 eprintln!(
839 "Warning: '{}' is deprecated. Use '{}' instead.",
840 prefix, new
841 );
842 new.to_string()
843 } else {
844 aliases
845 .get(prefix)
846 .map(|s| s.to_string())
847 .unwrap_or_else(|| prefix.to_string())
848 };
849 return format!("{}:{}", resolved_prefix, suffix);
850 }
851
852 if let Some(&new) = deprecated.get(value) {
853 eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
854 return new.to_string();
855 }
856
857 aliases
858 .get(value)
859 .map(|s| s.to_string())
860 .unwrap_or_else(|| value.to_string())
861}
862
863pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
867 let term_value = match term_value {
868 Some(v) => v,
869 None => return Ok((get_default_launch_method()?, None)),
870 };
871
872 let resolved = resolve_launch_alias(term_value);
873
874 if let Some((method_str, session_name)) = resolved.split_once(':') {
875 let method = LaunchMethod::from_str_opt(method_str)
876 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
877
878 if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
879 if session_name.len() > MAX_SESSION_NAME_LENGTH {
880 return Err(CwError::Config(format!(
881 "Session name too long (max {} chars): {}",
882 MAX_SESSION_NAME_LENGTH, session_name
883 )));
884 }
885 return Ok((method, Some(session_name.to_string())));
886 } else {
887 return Err(CwError::Config(format!(
888 "Session name not supported for {}",
889 method_str
890 )));
891 }
892 }
893
894 let method = LaunchMethod::from_str_opt(&resolved)
895 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
896 Ok((method, None))
897}
898
899pub fn get_default_launch_method() -> Result<LaunchMethod> {
901 if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
903 let resolved = resolve_launch_alias(&env_val);
904 if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
905 return Ok(method);
906 }
907 }
908
909 let config = load_config()?;
911 if let Some(ref method) = config.launch.method {
912 let resolved = resolve_launch_alias(method);
913 if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
914 return Ok(m);
915 }
916 }
917
918 Ok(LaunchMethod::Foreground)
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924
925 #[test]
926 fn test_default_config() {
927 let config = Config::default();
928 assert_eq!(config.ai_tool.command, "claude");
929 assert!(config.ai_tool.args.is_empty());
930 assert!(config.update.auto_check);
931 }
932
933 #[test]
934 fn test_resolve_launch_alias() {
935 assert_eq!(resolve_launch_alias("fg"), "foreground");
936 assert_eq!(resolve_launch_alias("t"), "tmux");
937 assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
938 assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
939 assert_eq!(resolve_launch_alias("foreground"), "foreground");
940 }
941
942 #[test]
943 fn test_parse_term_option() {
944 let (method, session) = parse_term_option(Some("t")).unwrap();
945 assert_eq!(method, LaunchMethod::Tmux);
946 assert!(session.is_none());
947
948 let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
949 assert_eq!(method, LaunchMethod::Tmux);
950 assert_eq!(session.unwrap(), "mywork");
951
952 let (method, session) = parse_term_option(Some("i-t")).unwrap();
953 assert_eq!(method, LaunchMethod::ItermTab);
954 assert!(session.is_none());
955 }
956
957 #[test]
958 fn test_preset_names() {
959 let presets = ai_tool_presets();
960 assert!(presets.contains_key("claude"));
961 assert!(presets.contains_key("no-op"));
962 assert!(presets.contains_key("codex"));
963 assert_eq!(presets["no-op"].len(), 0);
964 assert_eq!(presets["claude"], vec!["claude"]);
965 }
966
967 #[test]
968 fn test_list_presets_format() {
969 let output = list_presets();
970 assert!(output.contains("Available AI tool presets:"));
971 assert!(output.contains("claude"));
972 assert!(output.contains("no-op"));
973 }
974}