casting_cli/
utils.rs

1/// Utility functions for tool binary and configuration management
2use std::path::PathBuf;
3use std::process::Command;
4
5/// Find the binary path of a tool using the `which` command
6pub fn find_tool_binary(tool_name: &str) -> anyhow::Result<String> {
7    let output = Command::new("which").arg(tool_name).output()?;
8
9    if !output.status.success() {
10        anyhow::bail!("Tool '{}' not found in PATH", tool_name);
11    }
12
13    let path = String::from_utf8(output.stdout)?.trim().to_string();
14
15    Ok(path)
16}
17
18/// Get help output from a tool by running `--help`
19pub fn get_tool_help(tool_name: &str) -> anyhow::Result<String> {
20    let output = Command::new(tool_name).arg("--help").output()?;
21
22    // --help は多くの場合 exit code 0 だが、一部のツールは 1 を返すこともある
23    // stdout が空なら stderr を試す
24    let mut help = String::from_utf8(output.stdout)?;
25
26    if help.is_empty() {
27        help = String::from_utf8(output.stderr)?;
28    }
29
30    if help.is_empty() {
31        anyhow::bail!("Failed to get help output from '{}'", tool_name);
32    }
33
34    Ok(help)
35}
36
37/// Get the configuration directory path for a specific tool
38pub fn get_tool_config_dir(tool_name: &str) -> anyhow::Result<PathBuf> {
39    let home = std::env::var("HOME")?;
40    let config_dir = PathBuf::from(home)
41        .join(".casting")
42        .join("tools")
43        .join(tool_name);
44    Ok(config_dir)
45}
46
47/// Generate aliases for a tool name (especially useful for Expert types with multi-word names)
48///
49/// For "UX Designer":
50/// - `ux_designer` (snake_case)
51/// - `uxd` (initials)
52pub fn generate_aliases(tool_name: &str) -> Vec<String> {
53    let mut aliases = Vec::new();
54
55    // Only generate aliases for multi-word names
56    if !tool_name.contains(' ') {
57        return aliases;
58    }
59
60    let words: Vec<&str> = tool_name.split_whitespace().collect();
61
62    // 1. snake_case version: "UX Designer" -> "ux_designer"
63    let snake_case: String = words
64        .iter()
65        .map(|w| w.to_lowercase())
66        .collect::<Vec<_>>()
67        .join("_");
68    aliases.push(snake_case);
69
70    // 2. initials version: "UX Designer" -> "uxd"
71    // For all-uppercase words (acronyms like "UX", "API"), use all chars
72    // For normal words, use first char only
73    let initials: String = words
74        .iter()
75        .flat_map(|w| {
76            let is_acronym = w.len() > 1 && w.chars().all(|c| c.is_uppercase());
77            if is_acronym {
78                // "UX" -> "ux"
79                w.to_lowercase().chars().collect::<Vec<_>>()
80            } else {
81                // "Designer" -> "d"
82                w.chars()
83                    .next()
84                    .map(|c| c.to_lowercase().collect::<Vec<_>>())
85                    .unwrap_or_default()
86            }
87        })
88        .collect();
89
90    // Only add initials if different from snake_case and at least 2 chars
91    if initials.len() >= 2 && !aliases.contains(&initials) {
92        aliases.push(initials);
93    }
94
95    aliases
96}
97
98/// Get all existing tool names and aliases to check for conflicts
99pub fn get_all_tool_identifiers() -> anyhow::Result<std::collections::HashSet<String>> {
100    use crate::tool_config::{setup_config_migrator, ToolConfig};
101    use std::fs;
102
103    let mut identifiers = std::collections::HashSet::new();
104
105    let home = std::env::var("HOME")?;
106    let tools_dir = PathBuf::from(home).join(".casting").join("tools");
107
108    if !tools_dir.exists() {
109        return Ok(identifiers);
110    }
111
112    let migrator = setup_config_migrator()?;
113
114    for entry in fs::read_dir(&tools_dir)? {
115        let entry = entry?;
116        let path = entry.path();
117
118        if path.is_dir() {
119            let config_path = path.join("config.json");
120            if config_path.exists() {
121                let config_str = fs::read_to_string(&config_path)?;
122                if let Ok(config) =
123                    migrator.load_with_fallback::<ToolConfig>("tool_config", &config_str)
124                {
125                    // Add tool name (lowercase for comparison)
126                    identifiers.insert(config.tool_name.to_lowercase());
127                    // Add all aliases
128                    for alias in config.aliases {
129                        identifiers.insert(alias.to_lowercase());
130                    }
131                }
132            }
133        }
134    }
135
136    Ok(identifiers)
137}
138
139/// Generate non-conflicting aliases for a tool name
140pub fn generate_unique_aliases(tool_name: &str) -> anyhow::Result<Vec<String>> {
141    let candidates = generate_aliases(tool_name);
142    let existing = get_all_tool_identifiers()?;
143
144    let unique: Vec<String> = candidates
145        .into_iter()
146        .filter(|alias| !existing.contains(&alias.to_lowercase()))
147        .collect();
148
149    Ok(unique)
150}
151
152/// Resolve a tool identifier (name or alias) to the actual tool name
153pub fn resolve_tool_name(identifier: &str) -> anyhow::Result<Option<String>> {
154    use crate::tool_config::{setup_config_migrator, ToolConfig};
155    use std::fs;
156
157    let home = std::env::var("HOME")?;
158    let tools_dir = PathBuf::from(home).join(".casting").join("tools");
159
160    if !tools_dir.exists() {
161        return Ok(None);
162    }
163
164    // First, check if it's a direct tool name (directory exists)
165    let direct_path = tools_dir.join(identifier);
166    if direct_path.exists() && direct_path.is_dir() {
167        return Ok(Some(identifier.to_string()));
168    }
169
170    // Otherwise, search through all configs for alias match
171    let migrator = setup_config_migrator()?;
172    let identifier_lower = identifier.to_lowercase();
173
174    for entry in fs::read_dir(&tools_dir)? {
175        let entry = entry?;
176        let path = entry.path();
177
178        if path.is_dir() {
179            let config_path = path.join("config.json");
180            if config_path.exists() {
181                let config_str = fs::read_to_string(&config_path)?;
182                if let Ok(config) =
183                    migrator.load_with_fallback::<ToolConfig>("tool_config", &config_str)
184                {
185                    // Check aliases
186                    for alias in &config.aliases {
187                        if alias.to_lowercase() == identifier_lower {
188                            return Ok(Some(config.tool_name));
189                        }
190                    }
191                }
192            }
193        }
194    }
195
196    Ok(None)
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn test_generate_aliases_multi_word() {
205        // "UX Designer" -> ["ux_designer", "uxd"]
206        let aliases = generate_aliases("UX Designer");
207        assert_eq!(aliases.len(), 2);
208        assert_eq!(aliases[0], "ux_designer");
209        assert_eq!(aliases[1], "uxd");
210    }
211
212    #[test]
213    fn test_generate_aliases_three_words() {
214        // "Senior Software Engineer" -> ["senior_software_engineer", "sse"]
215        let aliases = generate_aliases("Senior Software Engineer");
216        assert_eq!(aliases.len(), 2);
217        assert_eq!(aliases[0], "senior_software_engineer");
218        assert_eq!(aliases[1], "sse");
219    }
220
221    #[test]
222    fn test_generate_aliases_single_word_returns_empty() {
223        // Single word tools (like CLI tools) don't need aliases
224        let aliases = generate_aliases("git");
225        assert!(aliases.is_empty());
226    }
227
228    #[test]
229    fn test_generate_aliases_acronym_preserved() {
230        // "API Gateway" -> ["api_gateway", "apig"]
231        // API is an acronym (all uppercase), so all chars are used
232        let aliases = generate_aliases("API Gateway");
233        assert_eq!(aliases[0], "api_gateway");
234        assert_eq!(aliases[1], "apig");
235    }
236
237    #[test]
238    fn test_generate_aliases_handles_extra_whitespace() {
239        // Extra spaces should be handled by split_whitespace
240        let aliases = generate_aliases("UX  Designer");
241        assert_eq!(aliases.len(), 2);
242        assert_eq!(aliases[0], "ux_designer");
243        assert_eq!(aliases[1], "uxd");
244    }
245
246    #[test]
247    fn test_generate_aliases_multiple_acronyms() {
248        // "AI ML Expert" -> ["ai_ml_expert", "aimle"]
249        // AI and ML are acronyms (all uppercase), so all chars are used
250        let aliases = generate_aliases("AI ML Expert");
251        assert_eq!(aliases.len(), 2);
252        assert_eq!(aliases[0], "ai_ml_expert");
253        assert_eq!(aliases[1], "aimle");
254    }
255
256    #[test]
257    fn test_generate_aliases_long_name() {
258        // "Chief Technology Officer" -> ["chief_technology_officer", "cto"]
259        let aliases = generate_aliases("Chief Technology Officer");
260        assert_eq!(aliases.len(), 2);
261        assert_eq!(aliases[0], "chief_technology_officer");
262        assert_eq!(aliases[1], "cto");
263    }
264}