Skip to main content

supercov_engine/
frontend_detection.rs

1//! Language/frontend selection for the zero-configuration public command.
2//!
3//! The command launch intent is authoritative; project manifests provide a
4//! fallback for opaque wrappers. Multiple frontends may be selected for a
5//! genuinely mixed test command. No language is guessed from source extension
6//! alone.
7
8use 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    // `go` alone is too common a word in shell text to mean anything; the
86    // launch shape is what identifies a test run.
87    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    // Maven and Gradle name themselves unambiguously, so the word is enough;
95    // a wrapper script is how most projects invoke Gradle.
96    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    // Django's `manage.py test` runs unittest without naming it.
109    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    // Opaque wrappers such as `make test` or `./scripts/test` do not reveal
173    // their child graph before launch. Instrument every strongly evidenced
174    // project frontend in that case; the eventual launch observer validates
175    // which prepared frontend actually ran.
176    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/// A language Supercov recognizes but cannot measure yet. Naming it turns
242/// "could not determine a supported test language" into an answer the user
243/// can act on: what was detected, from which signal, and where to ask for
244/// (or contribute) support.
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct UnsupportedEcosystem {
247    pub language: &'static str,
248    pub evidence: String,
249    /// True when the test command itself launches the unsupported runner.
250    /// Command intent is authoritative: `supercov -- go test` deserves the
251    /// Go answer even in a repository that also carries a package.json,
252    /// because the manifest fallback exists only for opaque commands.
253    pub from_command: bool,
254}
255
256/// Whether the expanded test command itself launches a runner Supercov
257/// supports. Used to keep command-authoritative unsupported detection from
258/// shadowing genuinely mixed commands like `sh -c "go test && vitest run"`.
259pub 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            // `go test`, `swift test`, `mix test`, `dotnet test`, `gradle
284            // test`: the runner word alone is too common in shell text, so
285            // require the test-launch shape for single-word runners.
286            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    /// Django's runner is unittest under another name, in projects that
337    /// often declare themselves only by `manage.py` and `requirements.txt`.
338    #[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        // A wrapper that names no runner still finds the project's Python.
349        assert_eq!(
350            detect_frontends(&root, &command("make test")).frontends,
351            [FrontendLanguage::Python]
352        );
353        // `manage.py` running something else is not a test run.
354        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            // A wrapper script is how most projects invoke Gradle.
444            vec!["./gradlew".into(), "test".into()],
445            vec!["gradle".into(), "check".into()],
446            // An opaque wrapper reveals nothing, so the build file decides.
447            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            // Opaque wrappers reveal nothing before launch, so the manifest
463            // decides.
464            vec!["make".into(), "test".into()],
465        ] {
466            let detected = detect_frontends(&root, &command);
467            assert_eq!(detected.frontends, [FrontendLanguage::Go], "{command:?}");
468        }
469        // The word on its own is too common in shell text to mean a test run.
470        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        // A genuinely mixed command that also launches a supported runner
509        // must keep running.
510        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}