1use std::{collections::BTreeSet, fs, path::Path};
9
10use serde::{Deserialize, Serialize};
11
12use crate::project_discovery::expanded_command;
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
15#[serde(rename_all = "lowercase")]
16pub enum FrontendLanguage {
17 JavaScript,
18 Python,
19 Ruby,
20 Rust,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct FrontendEvidence {
26 pub language: FrontendLanguage,
27 pub reason: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase", deny_unknown_fields)]
32pub struct FrontendDetection {
33 pub frontends: Vec<FrontendLanguage>,
34 pub evidence: Vec<FrontendEvidence>,
35}
36
37fn tokens(value: &str) -> Vec<String> {
38 value
39 .to_ascii_lowercase()
40 .split(|character: char| {
41 !character.is_ascii_alphanumeric() && !matches!(character, '-' | '_' | '.')
42 })
43 .filter(|value| !value.is_empty())
44 .map(|value| {
45 Path::new(value)
46 .file_name()
47 .and_then(|name| name.to_str())
48 .unwrap_or(value)
49 .trim_end_matches(".exe")
50 .trim_end_matches(".cmd")
51 .to_owned()
52 })
53 .collect()
54}
55
56fn has_sequence(tokens: &[String], sequence: &[&str]) -> bool {
57 tokens.windows(sequence.len()).any(|window| {
58 window
59 .iter()
60 .map(String::as_str)
61 .eq(sequence.iter().copied())
62 })
63}
64
65fn regular_file(path: &Path) -> bool {
66 fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file())
67}
68
69fn supported_by_command(command_tokens: &[String]) -> Vec<(FrontendLanguage, &'static str)> {
70 let mut launched = Vec::new();
71 let rust_command = has_sequence(command_tokens, &["cargo", "test"])
72 || has_sequence(command_tokens, &["cargo", "nextest"])
73 || has_sequence(command_tokens, &["cargo-nextest", "run"])
74 || has_sequence(command_tokens, &["cross", "test"]);
75 if rust_command {
76 launched.push((
77 FrontendLanguage::Rust,
78 "the expanded test command launches Cargo's test pipeline",
79 ));
80 }
81
82 let python_command = command_tokens.iter().any(|token| {
83 matches!(
84 token.as_str(),
85 "pytest" | "py.test" | "unittest" | "tox" | "nox"
86 )
87 });
88 if python_command {
89 launched.push((
90 FrontendLanguage::Python,
91 "the expanded test command launches a Python test runner",
92 ));
93 }
94 let ruby_command = command_tokens
95 .iter()
96 .any(|token| matches!(token.as_str(), "rspec" | "minitest" | "cucumber" | "m"))
97 || has_sequence(command_tokens, &["rake", "spec"])
98 || has_sequence(command_tokens, &["rake", "test"])
99 || has_sequence(command_tokens, &["rails", "test"])
100 || (command_tokens.iter().any(|token| token == "ruby")
101 && command_tokens.iter().any(|token| {
102 token.ends_with("_spec.rb")
103 || token.ends_with("_test.rb")
104 || token.starts_with("-itest")
105 || token.starts_with("-ispec")
106 }));
107 if ruby_command {
108 launched.push((
109 FrontendLanguage::Ruby,
110 "the expanded test command launches a Ruby test runner",
111 ));
112 }
113
114 let javascript_command = command_tokens.iter().any(|token| {
115 matches!(
116 token.as_str(),
117 "playwright" | "vitest" | "jest" | "node" | "tsx" | "ts-node"
118 )
119 }) || command_tokens
120 .iter()
121 .any(|token| token.starts_with("playwright-"));
122 if javascript_command {
123 launched.push((
124 FrontendLanguage::JavaScript,
125 "the expanded test command launches a JavaScript test/runtime process",
126 ));
127 }
128 launched
129}
130
131pub fn detect_frontends(root: &Path, command: &[String]) -> FrontendDetection {
132 let expanded = expanded_command(root, command);
133 let command_tokens = tokens(&expanded);
134 let mut selected = BTreeSet::new();
135 let mut evidence = Vec::new();
136
137 for (language, reason) in supported_by_command(&command_tokens) {
138 selected.insert(language);
139 evidence.push(FrontendEvidence {
140 language,
141 reason: reason.into(),
142 });
143 }
144
145 if selected.is_empty() {
150 let candidates = [
151 (
152 FrontendLanguage::Rust,
153 regular_file(&root.join("Cargo.toml")),
154 "Cargo.toml exists and the test command is opaque",
155 ),
156 (
157 FrontendLanguage::Ruby,
158 ["Gemfile", ".rspec", "Rakefile"]
159 .iter()
160 .any(|name| root.join(name).is_file())
161 && (root.join("spec").is_dir() || root.join("test").is_dir()),
162 "Ruby project/test metadata exists and the test command is opaque",
163 ),
164 (
165 FrontendLanguage::Python,
166 ["pyproject.toml", "pytest.ini", "tox.ini"]
167 .iter()
168 .any(|name| regular_file(&root.join(name))),
169 "Python project/test metadata exists and the test command is opaque",
170 ),
171 (
172 FrontendLanguage::JavaScript,
173 regular_file(&root.join("package.json")),
174 "package.json exists and the test command is opaque",
175 ),
176 ];
177 for (language, present, reason) in candidates {
178 if present {
179 selected.insert(language);
180 evidence.push(FrontendEvidence {
181 language,
182 reason: reason.into(),
183 });
184 }
185 }
186 }
187
188 FrontendDetection {
189 frontends: selected.into_iter().collect(),
190 evidence,
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct UnsupportedEcosystem {
200 pub language: &'static str,
201 pub evidence: String,
202 pub from_command: bool,
207}
208
209pub fn command_launches_supported_frontend(root: &Path, command: &[String]) -> bool {
213 let expanded = expanded_command(root, command);
214 let command_tokens = tokens(&expanded);
215 supported_by_command(&command_tokens)
216 .into_iter()
217 .next()
218 .is_some()
219}
220
221pub fn detect_unsupported_ecosystem(
222 root: &Path,
223 command: &[String],
224) -> Option<UnsupportedEcosystem> {
225 let expanded = expanded_command(root, command);
226 let command_tokens = tokens(&expanded);
227 let by_command: &[(&str, &[&str])] = &[
228 ("Go", &["go"]),
229 ("Java/Kotlin", &["mvn", "maven", "gradle", "gradlew"]),
230 ("PHP", &["phpunit", "pest"]),
231 (".NET", &["dotnet"]),
232 ("Elixir", &["mix"]),
233 ("Swift", &["swift"]),
234 ("Dart/Flutter", &["flutter", "dart"]),
235 ];
236 for (language, runners) in by_command {
237 for runner in *runners {
238 let launches = if matches!(*runner, "go" | "swift" | "mix" | "dotnet") {
242 has_sequence(&command_tokens, &[runner, "test"])
243 } else {
244 command_tokens.iter().any(|token| token == runner)
245 };
246 if launches {
247 return Some(UnsupportedEcosystem {
248 language,
249 evidence: format!("the test command runs `{runner}`"),
250 from_command: true,
251 });
252 }
253 }
254 }
255 let by_manifest: &[(&str, &[&str])] = &[
256 ("Go", &["go.mod"]),
257 (
258 "Java/Kotlin",
259 &["pom.xml", "build.gradle", "build.gradle.kts"],
260 ),
261 ("PHP", &["composer.json"]),
262 ("Elixir", &["mix.exs"]),
263 ("Swift", &["Package.swift"]),
264 ("Dart/Flutter", &["pubspec.yaml"]),
265 ];
266 for (language, manifests) in by_manifest {
267 for manifest in *manifests {
268 if regular_file(&root.join(manifest)) {
269 return Some(UnsupportedEcosystem {
270 language,
271 evidence: format!("{manifest} is present"),
272 from_command: false,
273 });
274 }
275 }
276 }
277 None
278}
279
280#[cfg(test)]
281mod tests {
282 use std::{fs, path::PathBuf};
283
284 use super::*;
285
286 fn fixture(name: &str) -> PathBuf {
287 let root =
288 std::env::temp_dir().join(format!("supercov-detection-{}-{name}", std::process::id()));
289 if root.exists() {
290 fs::remove_dir_all(&root).unwrap();
291 }
292 fs::create_dir(&root).unwrap();
293 root
294 }
295
296 #[test]
297 fn direct_cargo_is_authoritative_even_in_a_polyglot_repository() {
298 let root = fixture("cargo");
299 fs::write(
300 root.join("Cargo.toml"),
301 "[package]\nname='fixture'\nversion='0.0.0'\n",
302 )
303 .unwrap();
304 fs::write(
305 root.join("package.json"),
306 r#"{"scripts":{"test":"vitest"}}"#,
307 )
308 .unwrap();
309 let detected = detect_frontends(&root, &["cargo".into(), "test".into()]);
310 assert_eq!(detected.frontends, [FrontendLanguage::Rust]);
311 fs::remove_dir_all(root).unwrap();
312 }
313
314 #[test]
315 fn package_script_expansion_finds_cargo_without_hardcoding_the_repository() {
316 let root = fixture("npm-cargo");
317 fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
318 fs::write(
319 root.join("package.json"),
320 r#"{"scripts":{"test:rust":"cargo test --workspace"}}"#,
321 )
322 .unwrap();
323 let detected = detect_frontends(&root, &["npm".into(), "run".into(), "test:rust".into()]);
324 assert_eq!(detected.frontends, [FrontendLanguage::Rust]);
325 fs::remove_dir_all(root).unwrap();
326 }
327
328 #[test]
329 fn mixed_shell_commands_select_both_real_frontends() {
330 let root = fixture("mixed");
331 let detected = detect_frontends(
332 &root,
333 &[
334 "sh".into(),
335 "-c".into(),
336 "cargo test && npx vitest run".into(),
337 ],
338 );
339 assert_eq!(
340 detected.frontends,
341 [FrontendLanguage::JavaScript, FrontendLanguage::Rust]
342 );
343 fs::remove_dir_all(root).unwrap();
344 }
345
346 #[test]
347 fn a_known_unsupported_runner_is_named_from_the_command() {
348 let root = fixture("go-command");
349 let detected = detect_frontends(&root, &["go".into(), "test".into(), "./...".into()]);
350 assert_eq!(detected.frontends, []);
351 let ecosystem =
352 detect_unsupported_ecosystem(&root, &["go".into(), "test".into(), "./...".into()])
353 .unwrap();
354 assert_eq!(ecosystem.language, "Go");
355 assert_eq!(ecosystem.evidence, "the test command runs `go`");
356 fs::remove_dir_all(root).unwrap();
357 }
358
359 #[test]
360 fn a_known_unsupported_manifest_is_named_when_the_command_is_opaque() {
361 let root = fixture("gomod");
362 fs::write(root.join("go.mod"), "module example.com/app\n").unwrap();
363 let detected = detect_frontends(&root, &["make".into(), "test".into()]);
364 assert_eq!(detected.frontends, []);
365 let ecosystem =
366 detect_unsupported_ecosystem(&root, &["make".into(), "test".into()]).unwrap();
367 assert_eq!(ecosystem.language, "Go");
368 assert_eq!(ecosystem.evidence, "go.mod is present");
369 fs::remove_dir_all(root).unwrap();
370 }
371
372 #[test]
373 fn ruby_runners_and_manifests_select_the_ruby_frontend() {
374 let root = fixture("ruby");
375 fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap();
376 fs::create_dir_all(root.join("spec")).unwrap();
377 for command in [
378 vec!["rspec".to_string()],
379 vec!["bundle".into(), "exec".into(), "rspec".into()],
380 vec!["ruby".into(), "-Itest".into(), "test/app_test.rb".into()],
381 vec!["bin/rails".into(), "test".into()],
382 vec!["make".into(), "test".into()],
383 ] {
384 let detected = detect_frontends(&root, &command);
385 assert_eq!(detected.frontends, [FrontendLanguage::Ruby], "{command:?}");
386 }
387 assert!(detect_unsupported_ecosystem(&root, &["make".into(), "test".into()]).is_none());
388 fs::remove_dir_all(root).unwrap();
389 }
390
391 #[test]
392 fn an_explicit_unsupported_command_is_authoritative_over_manifests() {
393 let root = fixture("go-with-package-json");
394 fs::write(root.join("package.json"), "{}").unwrap();
395 fs::write(root.join("go.mod"), "module example.com/x\n").unwrap();
396 let command = vec!["go".into(), "test".into(), "./...".into()];
397 let ecosystem = detect_unsupported_ecosystem(&root, &command).unwrap();
398 assert!(ecosystem.from_command);
399 assert_eq!(ecosystem.language, "Go");
400 assert!(!command_launches_supported_frontend(&root, &command));
401 let mixed = vec!["sh".into(), "-c".into(), "go test && npx vitest run".into()];
404 assert!(command_launches_supported_frontend(&root, &mixed));
405 fs::remove_dir_all(root).unwrap();
406 }
407
408 #[test]
409 fn supported_and_unknown_projects_stay_unnamed() {
410 let root = fixture("unknown");
411 assert_eq!(
412 detect_unsupported_ecosystem(&root, &["cargo".into(), "test".into()]),
413 None
414 );
415 assert_eq!(
416 detect_unsupported_ecosystem(&root, &["./scripts/test".into()]),
417 None
418 );
419 fs::remove_dir_all(root).unwrap();
420 }
421
422 #[test]
423 fn opaque_commands_prepare_all_manifest_backed_frontends() {
424 let root = fixture("opaque");
425 fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
426 fs::write(root.join("package.json"), "{}").unwrap();
427 fs::write(root.join("pyproject.toml"), "[project]\nname='fixture'\n").unwrap();
428 let detected = detect_frontends(&root, &["make".into(), "test".into()]);
429 assert_eq!(
430 detected.frontends,
431 [
432 FrontendLanguage::JavaScript,
433 FrontendLanguage::Python,
434 FrontendLanguage::Rust
435 ]
436 );
437 fs::remove_dir_all(root).unwrap();
438 }
439}