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