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