Skip to main content

robin/tools/
required_tools.rs

1use crate::config::RobinConfig;
2use anyhow::{Context, Result};
3use std::process::Command;
4
5#[derive(Debug, PartialEq)]
6pub struct RequiredTool {
7    pub name: &'static str,
8    pub command: &'static str,
9    pub version_arg: &'static str,
10    pub patterns: &'static [&'static str],
11}
12
13pub const KNOWN_TOOLS: &[RequiredTool] = &[
14    RequiredTool {
15        name: "Node.js",
16        command: "node",
17        version_arg: "--version",
18        patterns: &["node ", "npm ", "npx "],
19    },
20    RequiredTool {
21        name: "Python",
22        command: "python",
23        version_arg: "--version",
24        patterns: &["python ", "pip ", "python3 "],
25    },
26    RequiredTool {
27        name: "Ruby",
28        command: "ruby",
29        version_arg: "--version",
30        patterns: &["ruby ", "gem ", "bundle "],
31    },
32    RequiredTool {
33        name: "Fastlane",
34        command: "fastlane",
35        version_arg: "--version",
36        patterns: &["fastlane "],
37    },
38    RequiredTool {
39        name: "Flutter",
40        command: "flutter",
41        version_arg: "--version",
42        patterns: &["flutter "],
43    },
44    RequiredTool {
45        name: "Cargo",
46        command: "cargo",
47        version_arg: "--version",
48        patterns: &["cargo "],
49    },
50    RequiredTool {
51        name: "Go",
52        command: "go",
53        version_arg: "version",
54        patterns: &["go "],
55    },
56    RequiredTool {
57        name: "ADB",
58        command: "adb",
59        version_arg: "version",
60        patterns: &["adb "],
61    },
62    RequiredTool {
63        name: "Gradle",
64        command: "gradle",
65        version_arg: "--version",
66        patterns: &["gradle ", "./gradlew "],
67    },
68    RequiredTool {
69        name: "CocoaPods",
70        command: "pod",
71        version_arg: "--version",
72        patterns: &["pod ", "cocoapods "],
73    },
74    RequiredTool {
75        name: "Xcode CLI",
76        command: "xcrun",
77        version_arg: "--version",
78        patterns: &["xcrun ", "xcodebuild "],
79    },
80    RequiredTool {
81        name: "Docker",
82        command: "docker",
83        version_arg: "--version",
84        patterns: &["docker "],
85    },
86    RequiredTool {
87        name: "Git",
88        command: "git",
89        version_arg: "--version",
90        patterns: &["git "],
91    },
92    RequiredTool {
93        name: "Maven",
94        command: "mvn",
95        version_arg: "--version",
96        patterns: &["mvn ", "maven "],
97    },
98];
99
100fn check_script_contains(script: &serde_json::Value, pattern: &str) -> bool {
101    match script {
102        serde_json::Value::String(cmd) => command_uses(cmd, pattern),
103        serde_json::Value::Array(commands) => commands
104            .iter()
105            .any(|cmd| cmd.as_str().is_some_and(|s| command_uses(s, pattern))),
106        _ => false,
107    }
108}
109
110/// Returns true when `pattern` (an invocation prefix such as `"go "`) appears
111/// in `cmd` at a command boundary — i.e. at the start of the string or right
112/// after whitespace or a shell separator. This prevents false positives like
113/// `"cargo build"` matching the Go pattern `"go "` because of the trailing
114/// `go` in `cargo`.
115fn command_uses(cmd: &str, pattern: &str) -> bool {
116    cmd.match_indices(pattern).any(|(idx, _)| {
117        idx == 0
118            || cmd[..idx]
119                .chars()
120                .next_back()
121                .is_some_and(|prev| prev.is_whitespace() || matches!(prev, ';' | '&' | '|' | '('))
122    })
123}
124
125pub fn detect_required_tools(config: &RobinConfig) -> Vec<&'static RequiredTool> {
126    KNOWN_TOOLS
127        .iter()
128        .filter(|tool| {
129            config.scripts.values().any(|script| {
130                tool.patterns
131                    .iter()
132                    .any(|&pattern| check_script_contains(script, pattern))
133            })
134        })
135        .collect()
136}
137
138pub fn check_environment(
139    config: &RobinConfig,
140) -> Result<(bool, usize, usize, std::time::Duration)> {
141    let start_time = std::time::Instant::now();
142    let mut all_checks_passed = true;
143    let mut found_tools = 0;
144    let mut missing_tools = 0;
145
146    println!("šŸ” Checking development environment...\n");
147
148    // Check Required Tools
149    let required_tools = detect_required_tools(config);
150    if !required_tools.is_empty() {
151        println!("šŸ“¦ Required Tools:");
152        for tool in &required_tools {
153            match Command::new(tool.command).arg(tool.version_arg).output() {
154                Ok(output) if output.status.success() => {
155                    found_tools += 1;
156                    let stdout = String::from_utf8_lossy(&output.stdout);
157                    let version = stdout.lines().next().unwrap_or("").trim();
158                    println!("āœ… {}: {}", tool.name, version);
159                }
160                _ => {
161                    missing_tools += 1;
162                    all_checks_passed = false;
163                    println!("āŒ {} not found", tool.name);
164                }
165            }
166        }
167    }
168
169    // Check Environment Variables if needed tools are detected
170    let needs_android = required_tools.iter().any(|t| t.name == "Flutter");
171    let needs_java = needs_android
172        || config
173            .scripts
174            .values()
175            .any(|s| check_script_contains(s, "java ") || check_script_contains(s, "gradle "));
176
177    if needs_android || needs_java {
178        println!("\nšŸ”§ Environment Variables:");
179        if needs_android {
180            if std::env::var("ANDROID_HOME").is_ok() {
181                found_tools += 1;
182                println!("āœ… ANDROID_HOME is set");
183            } else {
184                missing_tools += 1;
185                all_checks_passed = false;
186                println!("āŒ ANDROID_HOME is not set");
187            }
188        }
189        if needs_java {
190            if std::env::var("JAVA_HOME").is_ok() {
191                found_tools += 1;
192                println!("āœ… JAVA_HOME is set");
193            } else {
194                missing_tools += 1;
195                all_checks_passed = false;
196                println!("āŒ JAVA_HOME is not set");
197            }
198        }
199    }
200
201    // Check Git Configuration if git commands are used
202    if config
203        .scripts
204        .values()
205        .any(|s| check_script_contains(s, "git "))
206    {
207        println!("\nšŸ” Git Configuration:");
208        for key in ["user.name", "user.email"].iter() {
209            match Command::new("git").args(["config", key]).output() {
210                Ok(output) if output.status.success() => {
211                    found_tools += 1;
212                    println!("āœ… Git {} is set", key);
213                }
214                _ => {
215                    missing_tools += 1;
216                    all_checks_passed = false;
217                    println!("āŒ Git {} is not set", key);
218                }
219            }
220        }
221    }
222
223    let duration = start_time.elapsed();
224    Ok((all_checks_passed, found_tools, missing_tools, duration))
225}
226
227pub fn update_tools(config: &RobinConfig) -> Result<(bool, Vec<String>)> {
228    let required_tools = detect_required_tools(config);
229    let mut updated_tools = Vec::new();
230    let mut all_success = true;
231
232    println!("šŸ”„ Updating development tools...\n");
233
234    for tool in required_tools {
235        match tool.name {
236            "Node.js" => {
237                if Command::new("npm").arg("--version").output().is_ok() {
238                    println!("Updating npm packages...");
239                    if !run_update_command("npm", &["update", "-g"])? {
240                        all_success = false;
241                    } else {
242                        updated_tools.push("npm packages".to_string());
243                    }
244                }
245            }
246            "Ruby" | "Fastlane" => {
247                if Command::new("gem").arg("--version").output().is_ok() {
248                    println!("Updating Fastlane...");
249                    if !run_update_command("gem", &["update", "fastlane"])? {
250                        all_success = false;
251                    } else {
252                        updated_tools.push("Fastlane".to_string());
253                    }
254                }
255            }
256            "Flutter" => {
257                if Command::new("flutter").arg("--version").output().is_ok() {
258                    println!("Updating Flutter...");
259                    if !run_update_command("flutter", &["upgrade"])? {
260                        all_success = false;
261                    } else {
262                        updated_tools.push("Flutter".to_string());
263                    }
264                }
265            }
266            "Cargo" => {
267                if Command::new("rustup").arg("--version").output().is_ok() {
268                    println!("Updating Rust toolchain...");
269                    if !run_update_command("rustup", &["update"])? {
270                        all_success = false;
271                    } else {
272                        updated_tools.push("Rust".to_string());
273                    }
274                }
275            }
276            "CocoaPods"
277                if cfg!(target_os = "macos")
278                    && Command::new("pod").arg("--version").output().is_ok() =>
279            {
280                println!("Updating CocoaPods repos...");
281                if !run_update_command("pod", &["repo", "update"])? {
282                    all_success = false;
283                } else {
284                    updated_tools.push("CocoaPods".to_string());
285                }
286            }
287            _ => {}
288        }
289    }
290
291    if updated_tools.is_empty() {
292        println!("No tools to update!");
293    } else {
294        println!("\nāœ… Update complete!");
295    }
296
297    Ok((all_success, updated_tools))
298}
299
300fn run_update_command(cmd: &str, args: &[&str]) -> Result<bool> {
301    let status = Command::new(cmd)
302        .args(args)
303        .status()
304        .with_context(|| format!("Failed to run {} update", cmd))?;
305
306    if !status.success() {
307        println!("āŒ {} update failed", cmd);
308    }
309    Ok(status.success())
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use serde_json::{Value, json};
316    use std::collections::HashMap;
317
318    fn config_with(scripts: &[(&str, Value)]) -> RobinConfig {
319        let mut map = HashMap::new();
320        for (name, script) in scripts {
321            map.insert((*name).to_string(), script.clone());
322        }
323        RobinConfig {
324            include: vec![],
325            scripts: map,
326        }
327    }
328
329    fn tool_names(tools: &[&'static RequiredTool]) -> Vec<&'static str> {
330        let mut names: Vec<_> = tools.iter().map(|t| t.name).collect();
331        names.sort_unstable();
332        names
333    }
334
335    #[test]
336    fn check_script_contains_matches_string() {
337        assert!(check_script_contains(
338            &json!("cargo build --release"),
339            "cargo "
340        ));
341        assert!(!check_script_contains(&json!("cargo build"), "npm "));
342    }
343
344    #[test]
345    fn check_script_contains_matches_any_array_element() {
346        let script = json!(["echo hi", "docker compose up"]);
347        assert!(check_script_contains(&script, "docker "));
348        assert!(!check_script_contains(&script, "gradle "));
349    }
350
351    #[test]
352    fn check_script_contains_ignores_non_string_types() {
353        assert!(!check_script_contains(&json!(42), "cargo "));
354        assert!(!check_script_contains(&json!([1, 2, 3]), "cargo "));
355    }
356
357    #[test]
358    fn detect_required_tools_finds_only_referenced_tools() {
359        let config = config_with(&[
360            ("build", json!("cargo build")),
361            ("web", json!("npm run dev")),
362        ]);
363        assert_eq!(
364            tool_names(&detect_required_tools(&config)),
365            vec!["Cargo", "Node.js"]
366        );
367    }
368
369    #[test]
370    fn detect_required_tools_deduplicates_across_scripts() {
371        // Two scripts both reference cargo; the tool must appear only once.
372        let config = config_with(&[
373            ("build", json!("cargo build")),
374            ("test", json!("cargo test")),
375        ]);
376        let tools = detect_required_tools(&config);
377        assert_eq!(tools.len(), 1);
378        assert_eq!(tools[0].name, "Cargo");
379    }
380
381    #[test]
382    fn detect_required_tools_matches_inside_arrays() {
383        let config = config_with(&[("release", json!(["cargo build", "docker push img"]))]);
384        assert_eq!(
385            tool_names(&detect_required_tools(&config)),
386            vec!["Cargo", "Docker"]
387        );
388    }
389
390    #[test]
391    fn detect_required_tools_returns_empty_when_nothing_matches() {
392        let config = config_with(&[("hello", json!("echo hello world"))]);
393        assert!(detect_required_tools(&config).is_empty());
394    }
395
396    #[test]
397    fn detect_required_tools_requires_trailing_space_boundary() {
398        // "gocardless" must not be mistaken for the Go toolchain ("go ").
399        let config = config_with(&[("pay", json!("gocardless charge"))]);
400        assert!(detect_required_tools(&config).is_empty());
401    }
402
403    #[test]
404    fn cargo_script_does_not_falsely_detect_go() {
405        // Regression: "cargo build" contains the substring "go " but must only
406        // detect Cargo, never the Go toolchain.
407        let config = config_with(&[("build", json!("cargo build --release"))]);
408        assert_eq!(tool_names(&detect_required_tools(&config)), vec!["Cargo"]);
409    }
410
411    #[test]
412    fn command_uses_respects_boundaries() {
413        assert!(command_uses("go build", "go "));
414        assert!(command_uses("cd svc && go test ./...", "go ")); // after "&& "
415        assert!(command_uses("npm run dev", "npm "));
416        assert!(!command_uses("cargo build", "go ")); // trailing "go" in cargo
417        assert!(!command_uses("gocardless pay", "go ")); // no boundary
418    }
419}