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| {
109 matches!(
110 token.as_str(),
111 "pytest" | "py.test" | "unittest" | "tox" | "nox"
112 )
113 }) || has_sequence(command_tokens, &["manage.py", "test"]);
114 if python_command {
115 launched.push((
116 FrontendLanguage::Python,
117 "the expanded test command launches a Python test runner",
118 ));
119 }
120 let ruby_command = command_tokens
121 .iter()
122 .any(|token| matches!(token.as_str(), "rspec" | "minitest" | "cucumber" | "m"))
123 || has_sequence(command_tokens, &["rake", "spec"])
124 || has_sequence(command_tokens, &["rake", "test"])
125 || has_sequence(command_tokens, &["rails", "test"])
126 || (command_tokens.iter().any(|token| token == "ruby")
127 && command_tokens.iter().any(|token| {
128 token.ends_with("_spec.rb")
129 || token.ends_with("_test.rb")
130 || token.starts_with("-itest")
131 || token.starts_with("-ispec")
132 }));
133 if ruby_command {
134 launched.push((
135 FrontendLanguage::Ruby,
136 "the expanded test command launches a Ruby test runner",
137 ));
138 }
139
140 let javascript_command = command_tokens.iter().any(|token| {
141 matches!(
142 token.as_str(),
143 "playwright" | "vitest" | "jest" | "node" | "tsx" | "ts-node"
144 )
145 }) || command_tokens
146 .iter()
147 .any(|token| token.starts_with("playwright-"));
148 if javascript_command {
149 launched.push((
150 FrontendLanguage::JavaScript,
151 "the expanded test command launches a JavaScript test/runtime process",
152 ));
153 }
154 launched
155}
156
157pub fn detect_frontends(root: &Path, command: &[String]) -> FrontendDetection {
158 let expanded = expanded_command(root, command);
159 let command_tokens = tokens(&expanded);
160 let mut selected = BTreeSet::new();
161 let mut evidence = Vec::new();
162
163 for (language, reason) in supported_by_command(&command_tokens) {
164 selected.insert(language);
165 evidence.push(FrontendEvidence {
166 language,
167 reason: reason.into(),
168 });
169 }
170
171 if selected.is_empty() {
176 let candidates = [
177 (
178 FrontendLanguage::Go,
179 regular_file(&root.join("go.mod")),
180 "go.mod exists and the test command is opaque",
181 ),
182 (
183 FrontendLanguage::Jvm,
184 ["pom.xml", "build.gradle", "build.gradle.kts"]
185 .iter()
186 .any(|name| regular_file(&root.join(name))),
187 "a Maven or Gradle build file exists and the test command is opaque",
188 ),
189 (
190 FrontendLanguage::Rust,
191 regular_file(&root.join("Cargo.toml")),
192 "Cargo.toml exists and the test command is opaque",
193 ),
194 (
195 FrontendLanguage::Ruby,
196 ["Gemfile", ".rspec", "Rakefile"]
197 .iter()
198 .any(|name| root.join(name).is_file())
199 && (root.join("spec").is_dir() || root.join("test").is_dir()),
200 "Ruby project/test metadata exists and the test command is opaque",
201 ),
202 (
203 FrontendLanguage::Python,
204 [
205 "pyproject.toml",
206 "pytest.ini",
207 "tox.ini",
208 "setup.py",
209 "setup.cfg",
210 "requirements.txt",
211 "manage.py",
212 ]
213 .iter()
214 .any(|name| regular_file(&root.join(name))),
215 "Python project/test metadata exists and the test command is opaque",
216 ),
217 (
218 FrontendLanguage::JavaScript,
219 regular_file(&root.join("package.json")),
220 "package.json exists and the test command is opaque",
221 ),
222 ];
223 for (language, present, reason) in candidates {
224 if present {
225 selected.insert(language);
226 evidence.push(FrontendEvidence {
227 language,
228 reason: reason.into(),
229 });
230 }
231 }
232 }
233
234 FrontendDetection {
235 frontends: selected.into_iter().collect(),
236 evidence,
237 }
238}
239
240#[derive(Debug, Clone, PartialEq, Eq)]
245pub struct UnsupportedEcosystem {
246 pub language: &'static str,
247 pub evidence: String,
248 pub from_command: bool,
253}
254
255pub fn command_launches_supported_frontend(root: &Path, command: &[String]) -> bool {
259 let expanded = expanded_command(root, command);
260 let command_tokens = tokens(&expanded);
261 supported_by_command(&command_tokens)
262 .into_iter()
263 .next()
264 .is_some()
265}
266
267pub fn detect_unsupported_ecosystem(
268 root: &Path,
269 command: &[String],
270) -> Option<UnsupportedEcosystem> {
271 let expanded = expanded_command(root, command);
272 let command_tokens = tokens(&expanded);
273 let by_command: &[(&str, &[&str])] = &[
274 ("PHP", &["phpunit", "pest"]),
275 (".NET", &["dotnet"]),
276 ("Elixir", &["mix"]),
277 ("Swift", &["swift"]),
278 ("Dart/Flutter", &["flutter", "dart"]),
279 ];
280 for (language, runners) in by_command {
281 for runner in *runners {
282 let launches = if matches!(*runner, "go" | "swift" | "mix" | "dotnet") {
286 has_sequence(&command_tokens, &[runner, "test"])
287 } else {
288 command_tokens.iter().any(|token| token == runner)
289 };
290 if launches {
291 return Some(UnsupportedEcosystem {
292 language,
293 evidence: format!("the test command runs `{runner}`"),
294 from_command: true,
295 });
296 }
297 }
298 }
299 let by_manifest: &[(&str, &[&str])] = &[
300 ("PHP", &["composer.json"]),
301 ("Elixir", &["mix.exs"]),
302 ("Swift", &["Package.swift"]),
303 ("Dart/Flutter", &["pubspec.yaml"]),
304 ];
305 for (language, manifests) in by_manifest {
306 for manifest in *manifests {
307 if regular_file(&root.join(manifest)) {
308 return Some(UnsupportedEcosystem {
309 language,
310 evidence: format!("{manifest} is present"),
311 from_command: false,
312 });
313 }
314 }
315 }
316 None
317}
318
319#[cfg(test)]
320mod tests {
321 use std::{fs, path::PathBuf};
322
323 use super::*;
324
325 fn fixture(name: &str) -> PathBuf {
326 let root =
327 std::env::temp_dir().join(format!("supercov-detection-{}-{name}", std::process::id()));
328 if root.exists() {
329 fs::remove_dir_all(&root).unwrap();
330 }
331 fs::create_dir(&root).unwrap();
332 root
333 }
334
335 #[test]
338 fn a_django_project_is_python_by_its_command_or_its_files() {
339 let root = fixture("django");
340 fs::write(root.join("manage.py"), "import django\n").unwrap();
341 fs::write(root.join("requirements.txt"), "django\n").unwrap();
342 let command = |line: &str| line.split(' ').map(str::to_owned).collect::<Vec<_>>();
343 assert_eq!(
344 detect_frontends(&root, &command("python manage.py test shop")).frontends,
345 [FrontendLanguage::Python]
346 );
347 assert_eq!(
349 detect_frontends(&root, &command("make test")).frontends,
350 [FrontendLanguage::Python]
351 );
352 assert!(!command_launches_supported_frontend(
354 &root,
355 &command("python manage.py migrate")
356 ));
357 fs::remove_dir_all(root).unwrap();
358 }
359
360 #[test]
361 fn direct_cargo_is_authoritative_even_in_a_polyglot_repository() {
362 let root = fixture("cargo");
363 fs::write(
364 root.join("Cargo.toml"),
365 "[package]\nname='fixture'\nversion='0.0.0'\n",
366 )
367 .unwrap();
368 fs::write(
369 root.join("package.json"),
370 r#"{"scripts":{"test":"vitest"}}"#,
371 )
372 .unwrap();
373 let detected = detect_frontends(&root, &["cargo".into(), "test".into()]);
374 assert_eq!(detected.frontends, [FrontendLanguage::Rust]);
375 fs::remove_dir_all(root).unwrap();
376 }
377
378 #[test]
379 fn package_script_expansion_finds_cargo_without_hardcoding_the_repository() {
380 let root = fixture("npm-cargo");
381 fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
382 fs::write(
383 root.join("package.json"),
384 r#"{"scripts":{"test:rust":"cargo test --workspace"}}"#,
385 )
386 .unwrap();
387 let detected = detect_frontends(&root, &["npm".into(), "run".into(), "test:rust".into()]);
388 assert_eq!(detected.frontends, [FrontendLanguage::Rust]);
389 fs::remove_dir_all(root).unwrap();
390 }
391
392 #[test]
393 fn mixed_shell_commands_select_both_real_frontends() {
394 let root = fixture("mixed");
395 let detected = detect_frontends(
396 &root,
397 &[
398 "sh".into(),
399 "-c".into(),
400 "cargo test && npx vitest run".into(),
401 ],
402 );
403 assert_eq!(
404 detected.frontends,
405 [FrontendLanguage::JavaScript, FrontendLanguage::Rust]
406 );
407 fs::remove_dir_all(root).unwrap();
408 }
409
410 #[test]
411 fn a_known_unsupported_runner_is_named_from_the_command() {
412 let root = fixture("swift-command");
413 let detected = detect_frontends(&root, &["swift".into(), "test".into()]);
414 assert_eq!(detected.frontends, []);
415 let ecosystem =
416 detect_unsupported_ecosystem(&root, &["swift".into(), "test".into()]).unwrap();
417 assert_eq!(ecosystem.language, "Swift");
418 assert_eq!(ecosystem.evidence, "the test command runs `swift`");
419 fs::remove_dir_all(root).unwrap();
420 }
421
422 #[test]
423 fn a_known_unsupported_manifest_is_named_when_the_command_is_opaque() {
424 let root = fixture("swiftpm");
425 fs::write(root.join("Package.swift"), "// swift-tools-version:5.9\n").unwrap();
426 let detected = detect_frontends(&root, &["make".into(), "test".into()]);
427 assert_eq!(detected.frontends, []);
428 let ecosystem =
429 detect_unsupported_ecosystem(&root, &["make".into(), "test".into()]).unwrap();
430 assert_eq!(ecosystem.language, "Swift");
431 assert_eq!(ecosystem.evidence, "Package.swift is present");
432 fs::remove_dir_all(root).unwrap();
433 }
434
435 #[test]
436 fn maven_and_gradle_projects_select_the_jvm_frontend() {
437 let root = fixture("jvm");
438 fs::write(root.join("pom.xml"), "<project></project>\n").unwrap();
439 for command in [
440 vec!["mvn".to_string(), "test".into()],
441 vec!["./mvnw".into(), "verify".into()],
442 vec!["./gradlew".into(), "test".into()],
444 vec!["gradle".into(), "check".into()],
445 vec!["make".into(), "test".into()],
447 ] {
448 let detected = detect_frontends(&root, &command);
449 assert_eq!(detected.frontends, [FrontendLanguage::Jvm], "{command:?}");
450 }
451 assert!(detect_unsupported_ecosystem(&root, &["make".into(), "test".into()]).is_none());
452 fs::remove_dir_all(root).unwrap();
453 }
454
455 #[test]
456 fn go_runners_and_manifests_select_the_go_frontend() {
457 let root = fixture("go");
458 fs::write(root.join("go.mod"), "module example.com/app\n").unwrap();
459 for command in [
460 vec!["go".to_string(), "test".into(), "./...".into()],
461 vec!["make".into(), "test".into()],
464 ] {
465 let detected = detect_frontends(&root, &command);
466 assert_eq!(detected.frontends, [FrontendLanguage::Go], "{command:?}");
467 }
468 assert_eq!(
470 detect_frontends(&root, &["go".into(), "build".into(), "./...".into()]).frontends,
471 [FrontendLanguage::Go],
472 "the manifest still decides when the command says nothing"
473 );
474 assert!(detect_unsupported_ecosystem(&root, &["make".into(), "test".into()]).is_none());
475 fs::remove_dir_all(root).unwrap();
476 }
477
478 #[test]
479 fn ruby_runners_and_manifests_select_the_ruby_frontend() {
480 let root = fixture("ruby");
481 fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap();
482 fs::create_dir_all(root.join("spec")).unwrap();
483 for command in [
484 vec!["rspec".to_string()],
485 vec!["bundle".into(), "exec".into(), "rspec".into()],
486 vec!["ruby".into(), "-Itest".into(), "test/app_test.rb".into()],
487 vec!["bin/rails".into(), "test".into()],
488 vec!["make".into(), "test".into()],
489 ] {
490 let detected = detect_frontends(&root, &command);
491 assert_eq!(detected.frontends, [FrontendLanguage::Ruby], "{command:?}");
492 }
493 assert!(detect_unsupported_ecosystem(&root, &["make".into(), "test".into()]).is_none());
494 fs::remove_dir_all(root).unwrap();
495 }
496
497 #[test]
498 fn an_explicit_unsupported_command_is_authoritative_over_manifests() {
499 let root = fixture("swift-with-package-json");
500 fs::write(root.join("package.json"), "{}").unwrap();
501 fs::write(root.join("Package.swift"), "// swift-tools-version:5.9\n").unwrap();
502 let command = vec!["swift".into(), "test".into()];
503 let ecosystem = detect_unsupported_ecosystem(&root, &command).unwrap();
504 assert!(ecosystem.from_command);
505 assert_eq!(ecosystem.language, "Swift");
506 assert!(!command_launches_supported_frontend(&root, &command));
507 let mixed = vec![
510 "sh".into(),
511 "-c".into(),
512 "swift test && npx vitest run".into(),
513 ];
514 assert!(command_launches_supported_frontend(&root, &mixed));
515 fs::remove_dir_all(root).unwrap();
516 }
517
518 #[test]
519 fn supported_and_unknown_projects_stay_unnamed() {
520 let root = fixture("unknown");
521 assert_eq!(
522 detect_unsupported_ecosystem(&root, &["cargo".into(), "test".into()]),
523 None
524 );
525 assert_eq!(
526 detect_unsupported_ecosystem(&root, &["./scripts/test".into()]),
527 None
528 );
529 fs::remove_dir_all(root).unwrap();
530 }
531
532 #[test]
533 fn opaque_commands_prepare_all_manifest_backed_frontends() {
534 let root = fixture("opaque");
535 fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
536 fs::write(root.join("package.json"), "{}").unwrap();
537 fs::write(root.join("pyproject.toml"), "[project]\nname='fixture'\n").unwrap();
538 let detected = detect_frontends(&root, &["make".into(), "test".into()]);
539 assert_eq!(
540 detected.frontends,
541 [
542 FrontendLanguage::JavaScript,
543 FrontendLanguage::Python,
544 FrontendLanguage::Rust
545 ]
546 );
547 fs::remove_dir_all(root).unwrap();
548 }
549}