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 get_ai_tool_delegate_command(prompt: &str) -> Result<Vec<String>> {
453 let mut cmd = get_ai_tool_command()?;
454 if cmd.is_empty() {
455 return Ok(cmd);
456 }
457 cmd.push(prompt.to_string());
458 Ok(cmd)
459}
460
461pub fn is_claude_tool() -> Result<bool> {
463 if let Ok(env_tool) = std::env::var("CW_AI_TOOL") {
464 let first_word = env_tool.split_whitespace().next().unwrap_or("");
465 return Ok(first_word == "claude");
466 }
467 let config = load_config()?;
468 Ok(claude_preset_names().contains(&config.ai_tool.command.as_str()))
469}
470
471pub fn set_ai_tool(tool: &str, args: Option<Vec<String>>) -> Result<()> {
473 let mut config = load_config()?;
474 config.ai_tool.command = tool.to_string();
475 config.ai_tool.args = args.unwrap_or_default();
476 save_config(&config)
477}
478
479pub fn use_preset(preset_name: &str) -> Result<()> {
481 let presets = ai_tool_presets();
482 if !presets.contains_key(preset_name) {
483 let available: Vec<&str> = presets.keys().copied().collect();
484 return Err(CwError::Config(format!(
485 "Unknown preset: {}. Available: {}",
486 preset_name,
487 available.join(", ")
488 )));
489 }
490 set_ai_tool(preset_name, None)
491}
492
493pub fn reset_config() -> Result<()> {
495 save_config(&Config::default())
496}
497
498pub fn resolve_launch_display_name(method: &str) -> String {
500 let aliases = launch_method_aliases();
501 let canonical = aliases.get(method).copied().unwrap_or(method);
502 LaunchMethod::from_str_opt(canonical)
503 .map(|m| format!("{} ({})", m.display_name(), method))
504 .unwrap_or_else(|| method.to_string())
505}
506
507pub const CONFIG_KEYS: &[(&str, &str)] = &[
509 (
510 "ai_tool.command",
511 "AI tool command name (e.g., claude, codex)",
512 ),
513 ("ai_tool.args", "Additional arguments passed to AI tool"),
514 (
515 "launch.method",
516 "Terminal launch method (foreground, tmux, wezterm, ...)",
517 ),
518 (
519 "launch.tmux_session_prefix",
520 "Prefix for tmux session names",
521 ),
522 (
523 "launch.wezterm_ready_timeout",
524 "Timeout (seconds) waiting for WezTerm",
525 ),
526 (
527 "update.auto_check",
528 "Automatically check for updates on startup",
529 ),
530 (
531 "shell_completion.prompted",
532 "Whether shell completion setup was prompted",
533 ),
534 (
535 "shell_completion.installed",
536 "Whether shell completion is installed",
537 ),
538];
539
540pub fn list_config() -> Result<()> {
542 use console::style;
543
544 let config = load_config()?;
545 let json = serde_json::to_value(&config)?;
546
547 println!();
548 println!(
549 " {:<35} {:<25} {}",
550 style("KEY").dim(),
551 style("VALUE").dim(),
552 style("DESCRIPTION").dim(),
553 );
554 println!(" {}", style("─".repeat(90)).dim());
555
556 for (key, desc) in CONFIG_KEYS {
557 let keys: Vec<&str> = key.split('.').collect();
558 let mut current = &json;
559 let mut found = true;
560 for &k in &keys {
561 match current.get(k) {
562 Some(v) => current = v,
563 None => {
564 found = false;
565 break;
566 }
567 }
568 }
569
570 let value_str = if !found {
571 style("(unset)".to_string()).dim().to_string()
572 } else {
573 let raw = match current {
574 serde_json::Value::String(s) => s.clone(),
575 serde_json::Value::Bool(b) => b.to_string(),
576 serde_json::Value::Number(n) => n.to_string(),
577 serde_json::Value::Null => "null".to_string(),
578 serde_json::Value::Array(a) => {
579 if a.is_empty() {
580 "[]".to_string()
581 } else {
582 serde_json::to_string(a).unwrap_or_default()
583 }
584 }
585 other => serde_json::to_string(other).unwrap_or_default(),
586 };
587 if *key == "launch.method" && raw != "null" {
589 resolve_launch_display_name(&raw)
590 } else {
591 raw
592 }
593 };
594
595 println!(
596 " {:<35} {:<25} {}",
597 style(key).bold(),
598 value_str,
599 style(desc).dim(),
600 );
601 }
602 println!();
603
604 Ok(())
605}
606
607pub fn get_config_value(key_path: &str) -> Result<()> {
609 let config = load_config()?;
610 let json = serde_json::to_value(&config)?;
611
612 let keys: Vec<&str> = key_path.split('.').collect();
613 let mut current = &json;
614 for &key in &keys {
615 current = current
616 .get(key)
617 .ok_or_else(|| CwError::Config(format!("Unknown config key: {}", key_path)))?;
618 }
619
620 match current {
621 serde_json::Value::String(s) => println!("{}", s),
622 serde_json::Value::Bool(b) => println!("{}", b),
623 serde_json::Value::Number(n) => println!("{}", n),
624 serde_json::Value::Null => println!("null"),
625 other => println!(
626 "{}",
627 serde_json::to_string_pretty(other).unwrap_or_default()
628 ),
629 }
630
631 Ok(())
632}
633
634pub fn set_config_value(key_path: &str, value: &str) -> Result<()> {
640 if key_path == "ai_tool" {
642 let presets = ai_tool_presets();
643 if presets.contains_key(value) {
644 return use_preset(value);
645 }
646 return set_ai_tool(value, None);
648 }
649
650 if key_path == "launch.method" {
652 let aliases = launch_method_aliases();
653 let canonical = aliases.get(value).copied().unwrap_or(value);
654 if value != "null"
656 && LaunchMethod::from_str_opt(canonical).is_none()
657 && LaunchMethod::from_str_opt(value).is_none()
658 {
659 return Err(CwError::Config(format!(
660 "Unknown launch method: '{}'. Use 'gw config list-presets' or 'gw --help' for options.",
661 value
662 )));
663 }
664 }
665
666 let mut config = load_config()?;
667 let mut json = serde_json::to_value(&config)?;
668
669 let keys: Vec<&str> = key_path.split('.').collect();
670
671 let json_value: Value = match value.to_lowercase().as_str() {
673 "true" => Value::Bool(true),
674 "false" => Value::Bool(false),
675 _ => {
676 if let Ok(n) = value.parse::<f64>() {
678 serde_json::Number::from_f64(n)
679 .map(Value::Number)
680 .unwrap_or(Value::String(value.to_string()))
681 } else {
682 Value::String(value.to_string())
683 }
684 }
685 };
686
687 let mut current = &mut json;
689 for &key in &keys[..keys.len() - 1] {
690 if !current.is_object() {
691 return Err(CwError::Config(format!(
692 "Invalid config path: {}",
693 key_path
694 )));
695 }
696 current = current
697 .as_object_mut()
698 .ok_or_else(|| CwError::Config(format!("Invalid config path: {}", key_path)))?
699 .entry(key)
700 .or_insert(Value::Object(serde_json::Map::new()));
701 }
702
703 if let Some(obj) = current.as_object_mut() {
704 obj.insert(keys[keys.len() - 1].to_string(), json_value);
705 } else {
706 return Err(CwError::Config(format!(
707 "Invalid config path: {}",
708 key_path
709 )));
710 }
711
712 config = serde_json::from_value(json)
714 .map_err(|e| CwError::Config(format!("Invalid config value: {}", e)))?;
715 save_config(&config)
716}
717
718pub fn show_config() -> Result<String> {
720 let config = load_config()?;
721 let mut lines = Vec::new();
722
723 lines.push("Current configuration:".to_string());
724 lines.push(String::new());
725 lines.push(format!(" AI Tool: {}", config.ai_tool.command));
726
727 if !config.ai_tool.args.is_empty() {
728 lines.push(format!(" Args: {}", config.ai_tool.args.join(" ")));
729 }
730
731 let cmd = get_ai_tool_command()?;
732 lines.push(format!(" Effective command: {}", cmd.join(" ")));
733 lines.push(String::new());
734
735 if let Some(ref method) = config.launch.method {
736 let display = resolve_launch_display_name(method);
737 lines.push(format!(" Launch method: {}", display));
738 } else {
739 lines.push(" Launch method: Foreground (default)".to_string());
740 }
741
742 let detected = crate::git::detect_default_branch(None);
744 lines.push(format!(
745 " Default base branch: {} (auto-detected)",
746 detected,
747 ));
748 lines.push(String::new());
749 lines.push(format!("Config file: {}", get_config_path().display()));
750
751 Ok(lines.join("\n"))
752}
753
754pub fn list_presets() -> String {
756 let presets = ai_tool_presets();
757 let mut lines = vec!["Available AI tool presets:".to_string(), String::new()];
758
759 let mut preset_names: Vec<&str> = presets.keys().copied().collect();
760 preset_names.sort();
761
762 for name in preset_names {
763 let cmd = presets[name].join(" ");
764 lines.push(format!(" {:<20} -> {}", name, cmd));
765 }
766
767 lines.join("\n")
768}
769
770fn is_shell_integration_installed() -> bool {
776 let home = home_dir_or_fallback();
777 let shell_env = std::env::var("SHELL").unwrap_or_default();
778
779 let profile_path = if shell_env.contains("zsh") {
780 home.join(".zshrc")
781 } else if shell_env.contains("bash") {
782 home.join(".bashrc")
783 } else if shell_env.contains("fish") {
784 home.join(".config").join("fish").join("config.fish")
785 } else {
786 return false;
787 };
788
789 if let Ok(content) = std::fs::read_to_string(&profile_path) {
790 content.contains("gw _shell-function") || content.contains("gw-cd")
791 } else {
792 false
793 }
794}
795
796pub fn prompt_shell_completion_setup() {
804 let config = match load_config() {
805 Ok(c) => c,
806 Err(_) => return,
807 };
808
809 if config.shell_completion.prompted || config.shell_completion.installed {
810 return;
811 }
812
813 if is_shell_integration_installed() {
814 let mut config = config;
816 config.shell_completion.prompted = true;
817 config.shell_completion.installed = true;
818 let _ = save_config(&config);
819 return;
820 }
821
822 eprintln!(
824 "\n{} Shell integration (gw-cd + tab completion) is not set up.",
825 console::style("Tip:").cyan().bold()
826 );
827 eprintln!(
828 " Run {} to enable directory navigation and completions.\n",
829 console::style("gw shell-setup").cyan()
830 );
831
832 let mut config = config;
834 config.shell_completion.prompted = true;
835 let _ = save_config(&config);
836}
837
838pub fn resolve_launch_alias(value: &str) -> String {
844 let deprecated: HashMap<&str, &str> =
845 HashMap::from([("bg", "detach"), ("background", "detach")]);
846 let aliases = launch_method_aliases();
847
848 if let Some((prefix, suffix)) = value.split_once(':') {
850 let resolved_prefix = if let Some(&new) = deprecated.get(prefix) {
851 eprintln!(
852 "Warning: '{}' is deprecated. Use '{}' instead.",
853 prefix, new
854 );
855 new.to_string()
856 } else {
857 aliases
858 .get(prefix)
859 .map(|s| s.to_string())
860 .unwrap_or_else(|| prefix.to_string())
861 };
862 return format!("{}:{}", resolved_prefix, suffix);
863 }
864
865 if let Some(&new) = deprecated.get(value) {
866 eprintln!("Warning: '{}' is deprecated. Use '{}' instead.", value, new);
867 return new.to_string();
868 }
869
870 aliases
871 .get(value)
872 .map(|s| s.to_string())
873 .unwrap_or_else(|| value.to_string())
874}
875
876pub fn parse_term_option(term_value: Option<&str>) -> Result<(LaunchMethod, Option<String>)> {
880 let term_value = match term_value {
881 Some(v) => v,
882 None => return Ok((get_default_launch_method()?, None)),
883 };
884
885 let resolved = resolve_launch_alias(term_value);
886
887 if let Some((method_str, session_name)) = resolved.split_once(':') {
888 let method = LaunchMethod::from_str_opt(method_str)
889 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", method_str)))?;
890
891 if matches!(method, LaunchMethod::Tmux | LaunchMethod::Zellij) {
892 if session_name.len() > MAX_SESSION_NAME_LENGTH {
893 return Err(CwError::Config(format!(
894 "Session name too long (max {} chars): {}",
895 MAX_SESSION_NAME_LENGTH, session_name
896 )));
897 }
898 return Ok((method, Some(session_name.to_string())));
899 } else {
900 return Err(CwError::Config(format!(
901 "Session name not supported for {}",
902 method_str
903 )));
904 }
905 }
906
907 let method = LaunchMethod::from_str_opt(&resolved)
908 .ok_or_else(|| CwError::Config(format!("Invalid launch method: {}", term_value)))?;
909 Ok((method, None))
910}
911
912pub fn get_default_launch_method() -> Result<LaunchMethod> {
914 if let Ok(env_val) = std::env::var("CW_LAUNCH_METHOD") {
916 let resolved = resolve_launch_alias(&env_val);
917 if let Some(method) = LaunchMethod::from_str_opt(&resolved) {
918 return Ok(method);
919 }
920 }
921
922 let config = load_config()?;
924 if let Some(ref method) = config.launch.method {
925 let resolved = resolve_launch_alias(method);
926 if let Some(m) = LaunchMethod::from_str_opt(&resolved) {
927 return Ok(m);
928 }
929 }
930
931 Ok(LaunchMethod::Foreground)
932}
933
934#[cfg(test)]
935mod tests {
936 use super::*;
937
938 #[test]
939 fn test_default_config() {
940 let config = Config::default();
941 assert_eq!(config.ai_tool.command, "claude");
942 assert!(config.ai_tool.args.is_empty());
943 assert!(config.update.auto_check);
944 }
945
946 #[test]
947 fn test_resolve_launch_alias() {
948 assert_eq!(resolve_launch_alias("fg"), "foreground");
949 assert_eq!(resolve_launch_alias("t"), "tmux");
950 assert_eq!(resolve_launch_alias("z-t"), "zellij-tab");
951 assert_eq!(resolve_launch_alias("t:mywork"), "tmux:mywork");
952 assert_eq!(resolve_launch_alias("foreground"), "foreground");
953 }
954
955 #[test]
956 fn test_parse_term_option() {
957 let (method, session) = parse_term_option(Some("t")).unwrap();
958 assert_eq!(method, LaunchMethod::Tmux);
959 assert!(session.is_none());
960
961 let (method, session) = parse_term_option(Some("t:mywork")).unwrap();
962 assert_eq!(method, LaunchMethod::Tmux);
963 assert_eq!(session.unwrap(), "mywork");
964
965 let (method, session) = parse_term_option(Some("i-t")).unwrap();
966 assert_eq!(method, LaunchMethod::ItermTab);
967 assert!(session.is_none());
968 }
969
970 #[test]
971 fn test_preset_names() {
972 let presets = ai_tool_presets();
973 assert!(presets.contains_key("claude"));
974 assert!(presets.contains_key("no-op"));
975 assert!(presets.contains_key("codex"));
976 assert_eq!(presets["no-op"].len(), 0);
977 assert_eq!(presets["claude"], vec!["claude"]);
978 }
979
980 #[test]
981 fn test_list_presets_format() {
982 let output = list_presets();
983 assert!(output.contains("Available AI tool presets:"));
984 assert!(output.contains("claude"));
985 assert!(output.contains("no-op"));
986 }
987}