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