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    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    // Opaque wrappers such as `make test` or `./scripts/test` do not reveal
171    // their child graph before launch. Instrument every strongly evidenced
172    // project frontend in that case; the eventual launch observer validates
173    // which prepared frontend actually ran.
174    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/// A language Supercov recognizes but cannot measure yet. Naming it turns
232/// "could not determine a supported test language" into an answer the user
233/// can act on: what was detected, from which signal, and where to ask for
234/// (or contribute) support.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub struct UnsupportedEcosystem {
237    pub language: &'static str,
238    pub evidence: String,
239    /// True when the test command itself launches the unsupported runner.
240    /// Command intent is authoritative: `supercov -- go test` deserves the
241    /// Go answer even in a repository that also carries a package.json,
242    /// because the manifest fallback exists only for opaque commands.
243    pub from_command: bool,
244}
245
246/// Whether the expanded test command itself launches a runner Supercov
247/// supports. Used to keep command-authoritative unsupported detection from
248/// shadowing genuinely mixed commands like `sh -c "go test && vitest run"`.
249pub 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            // `go test`, `swift test`, `mix test`, `dotnet test`, `gradle
274            // test`: the runner word alone is too common in shell text, so
275            // require the test-launch shape for single-word runners.
276            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            // A wrapper script is how most projects invoke Gradle.
409            vec!["./gradlew".into(), "test".into()],
410            vec!["gradle".into(), "check".into()],
411            // An opaque wrapper reveals nothing, so the build file decides.
412            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            // Opaque wrappers reveal nothing before launch, so the manifest
428            // decides.
429            vec!["make".into(), "test".into()],
430        ] {
431            let detected = detect_frontends(&root, &command);
432            assert_eq!(detected.frontends, [FrontendLanguage::Go], "{command:?}");
433        }
434        // The word on its own is too common in shell text to mean a test run.
435        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        // A genuinely mixed command that also launches a supported runner
474        // must keep running.
475        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}