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
110fn 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 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 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 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 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 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 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 ")); assert!(command_uses("npm run dev", "npm "));
416 assert!(!command_uses("cargo build", "go ")); assert!(!command_uses("gocardless pay", "go ")); }
419}