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    JavaScript,
18    Python,
19    Ruby,
20    Rust,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct FrontendEvidence {
26    pub language: FrontendLanguage,
27    pub reason: String,
28}
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[serde(rename_all = "camelCase", deny_unknown_fields)]
32pub struct FrontendDetection {
33    pub frontends: Vec<FrontendLanguage>,
34    pub evidence: Vec<FrontendEvidence>,
35}
36
37fn tokens(value: &str) -> Vec<String> {
38    value
39        .to_ascii_lowercase()
40        .split(|character: char| {
41            !character.is_ascii_alphanumeric() && !matches!(character, '-' | '_' | '.')
42        })
43        .filter(|value| !value.is_empty())
44        .map(|value| {
45            Path::new(value)
46                .file_name()
47                .and_then(|name| name.to_str())
48                .unwrap_or(value)
49                .trim_end_matches(".exe")
50                .trim_end_matches(".cmd")
51                .to_owned()
52        })
53        .collect()
54}
55
56fn has_sequence(tokens: &[String], sequence: &[&str]) -> bool {
57    tokens.windows(sequence.len()).any(|window| {
58        window
59            .iter()
60            .map(String::as_str)
61            .eq(sequence.iter().copied())
62    })
63}
64
65fn regular_file(path: &Path) -> bool {
66    fs::symlink_metadata(path).is_ok_and(|metadata| metadata.file_type().is_file())
67}
68
69fn supported_by_command(command_tokens: &[String]) -> Vec<(FrontendLanguage, &'static str)> {
70    let mut launched = Vec::new();
71    let rust_command = has_sequence(command_tokens, &["cargo", "test"])
72        || has_sequence(command_tokens, &["cargo", "nextest"])
73        || has_sequence(command_tokens, &["cargo-nextest", "run"])
74        || has_sequence(command_tokens, &["cross", "test"]);
75    if rust_command {
76        launched.push((
77            FrontendLanguage::Rust,
78            "the expanded test command launches Cargo's test pipeline",
79        ));
80    }
81
82    let python_command = command_tokens.iter().any(|token| {
83        matches!(
84            token.as_str(),
85            "pytest" | "py.test" | "unittest" | "tox" | "nox"
86        )
87    });
88    if python_command {
89        launched.push((
90            FrontendLanguage::Python,
91            "the expanded test command launches a Python test runner",
92        ));
93    }
94    let ruby_command = command_tokens
95        .iter()
96        .any(|token| matches!(token.as_str(), "rspec" | "minitest" | "cucumber" | "m"))
97        || has_sequence(command_tokens, &["rake", "spec"])
98        || has_sequence(command_tokens, &["rake", "test"])
99        || has_sequence(command_tokens, &["rails", "test"])
100        || (command_tokens.iter().any(|token| token == "ruby")
101            && command_tokens.iter().any(|token| {
102                token.ends_with("_spec.rb")
103                    || token.ends_with("_test.rb")
104                    || token.starts_with("-itest")
105                    || token.starts_with("-ispec")
106            }));
107    if ruby_command {
108        launched.push((
109            FrontendLanguage::Ruby,
110            "the expanded test command launches a Ruby test runner",
111        ));
112    }
113
114    let javascript_command = command_tokens.iter().any(|token| {
115        matches!(
116            token.as_str(),
117            "playwright" | "vitest" | "jest" | "node" | "tsx" | "ts-node"
118        )
119    }) || command_tokens
120        .iter()
121        .any(|token| token.starts_with("playwright-"));
122    if javascript_command {
123        launched.push((
124            FrontendLanguage::JavaScript,
125            "the expanded test command launches a JavaScript test/runtime process",
126        ));
127    }
128    launched
129}
130
131pub fn detect_frontends(root: &Path, command: &[String]) -> FrontendDetection {
132    let expanded = expanded_command(root, command);
133    let command_tokens = tokens(&expanded);
134    let mut selected = BTreeSet::new();
135    let mut evidence = Vec::new();
136
137    for (language, reason) in supported_by_command(&command_tokens) {
138        selected.insert(language);
139        evidence.push(FrontendEvidence {
140            language,
141            reason: reason.into(),
142        });
143    }
144
145    // Opaque wrappers such as `make test` or `./scripts/test` do not reveal
146    // their child graph before launch. Instrument every strongly evidenced
147    // project frontend in that case; the eventual launch observer validates
148    // which prepared frontend actually ran.
149    if selected.is_empty() {
150        let candidates = [
151            (
152                FrontendLanguage::Rust,
153                regular_file(&root.join("Cargo.toml")),
154                "Cargo.toml exists and the test command is opaque",
155            ),
156            (
157                FrontendLanguage::Ruby,
158                ["Gemfile", ".rspec", "Rakefile"]
159                    .iter()
160                    .any(|name| root.join(name).is_file())
161                    && (root.join("spec").is_dir() || root.join("test").is_dir()),
162                "Ruby project/test metadata exists and the test command is opaque",
163            ),
164            (
165                FrontendLanguage::Python,
166                ["pyproject.toml", "pytest.ini", "tox.ini"]
167                    .iter()
168                    .any(|name| regular_file(&root.join(name))),
169                "Python project/test metadata exists and the test command is opaque",
170            ),
171            (
172                FrontendLanguage::JavaScript,
173                regular_file(&root.join("package.json")),
174                "package.json exists and the test command is opaque",
175            ),
176        ];
177        for (language, present, reason) in candidates {
178            if present {
179                selected.insert(language);
180                evidence.push(FrontendEvidence {
181                    language,
182                    reason: reason.into(),
183                });
184            }
185        }
186    }
187
188    FrontendDetection {
189        frontends: selected.into_iter().collect(),
190        evidence,
191    }
192}
193
194/// A language Supercov recognizes but cannot measure yet. Naming it turns
195/// "could not determine a supported test language" into an answer the user
196/// can act on: what was detected, from which signal, and where to ask for
197/// (or contribute) support.
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct UnsupportedEcosystem {
200    pub language: &'static str,
201    pub evidence: String,
202    /// True when the test command itself launches the unsupported runner.
203    /// Command intent is authoritative: `supercov -- go test` deserves the
204    /// Go answer even in a repository that also carries a package.json,
205    /// because the manifest fallback exists only for opaque commands.
206    pub from_command: bool,
207}
208
209/// Whether the expanded test command itself launches a runner Supercov
210/// supports. Used to keep command-authoritative unsupported detection from
211/// shadowing genuinely mixed commands like `sh -c "go test && vitest run"`.
212pub fn command_launches_supported_frontend(root: &Path, command: &[String]) -> bool {
213    let expanded = expanded_command(root, command);
214    let command_tokens = tokens(&expanded);
215    supported_by_command(&command_tokens)
216        .into_iter()
217        .next()
218        .is_some()
219}
220
221pub fn detect_unsupported_ecosystem(
222    root: &Path,
223    command: &[String],
224) -> Option<UnsupportedEcosystem> {
225    let expanded = expanded_command(root, command);
226    let command_tokens = tokens(&expanded);
227    let by_command: &[(&str, &[&str])] = &[
228        ("Go", &["go"]),
229        ("Java/Kotlin", &["mvn", "maven", "gradle", "gradlew"]),
230        ("PHP", &["phpunit", "pest"]),
231        (".NET", &["dotnet"]),
232        ("Elixir", &["mix"]),
233        ("Swift", &["swift"]),
234        ("Dart/Flutter", &["flutter", "dart"]),
235    ];
236    for (language, runners) in by_command {
237        for runner in *runners {
238            // `go test`, `swift test`, `mix test`, `dotnet test`, `gradle
239            // test`: the runner word alone is too common in shell text, so
240            // require the test-launch shape for single-word runners.
241            let launches = if matches!(*runner, "go" | "swift" | "mix" | "dotnet") {
242                has_sequence(&command_tokens, &[runner, "test"])
243            } else {
244                command_tokens.iter().any(|token| token == runner)
245            };
246            if launches {
247                return Some(UnsupportedEcosystem {
248                    language,
249                    evidence: format!("the test command runs `{runner}`"),
250                    from_command: true,
251                });
252            }
253        }
254    }
255    let by_manifest: &[(&str, &[&str])] = &[
256        ("Go", &["go.mod"]),
257        (
258            "Java/Kotlin",
259            &["pom.xml", "build.gradle", "build.gradle.kts"],
260        ),
261        ("PHP", &["composer.json"]),
262        ("Elixir", &["mix.exs"]),
263        ("Swift", &["Package.swift"]),
264        ("Dart/Flutter", &["pubspec.yaml"]),
265    ];
266    for (language, manifests) in by_manifest {
267        for manifest in *manifests {
268            if regular_file(&root.join(manifest)) {
269                return Some(UnsupportedEcosystem {
270                    language,
271                    evidence: format!("{manifest} is present"),
272                    from_command: false,
273                });
274            }
275        }
276    }
277    None
278}
279
280#[cfg(test)]
281mod tests {
282    use std::{fs, path::PathBuf};
283
284    use super::*;
285
286    fn fixture(name: &str) -> PathBuf {
287        let root =
288            std::env::temp_dir().join(format!("supercov-detection-{}-{name}", std::process::id()));
289        if root.exists() {
290            fs::remove_dir_all(&root).unwrap();
291        }
292        fs::create_dir(&root).unwrap();
293        root
294    }
295
296    #[test]
297    fn direct_cargo_is_authoritative_even_in_a_polyglot_repository() {
298        let root = fixture("cargo");
299        fs::write(
300            root.join("Cargo.toml"),
301            "[package]\nname='fixture'\nversion='0.0.0'\n",
302        )
303        .unwrap();
304        fs::write(
305            root.join("package.json"),
306            r#"{"scripts":{"test":"vitest"}}"#,
307        )
308        .unwrap();
309        let detected = detect_frontends(&root, &["cargo".into(), "test".into()]);
310        assert_eq!(detected.frontends, [FrontendLanguage::Rust]);
311        fs::remove_dir_all(root).unwrap();
312    }
313
314    #[test]
315    fn package_script_expansion_finds_cargo_without_hardcoding_the_repository() {
316        let root = fixture("npm-cargo");
317        fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
318        fs::write(
319            root.join("package.json"),
320            r#"{"scripts":{"test:rust":"cargo test --workspace"}}"#,
321        )
322        .unwrap();
323        let detected = detect_frontends(&root, &["npm".into(), "run".into(), "test:rust".into()]);
324        assert_eq!(detected.frontends, [FrontendLanguage::Rust]);
325        fs::remove_dir_all(root).unwrap();
326    }
327
328    #[test]
329    fn mixed_shell_commands_select_both_real_frontends() {
330        let root = fixture("mixed");
331        let detected = detect_frontends(
332            &root,
333            &[
334                "sh".into(),
335                "-c".into(),
336                "cargo test && npx vitest run".into(),
337            ],
338        );
339        assert_eq!(
340            detected.frontends,
341            [FrontendLanguage::JavaScript, FrontendLanguage::Rust]
342        );
343        fs::remove_dir_all(root).unwrap();
344    }
345
346    #[test]
347    fn a_known_unsupported_runner_is_named_from_the_command() {
348        let root = fixture("go-command");
349        let detected = detect_frontends(&root, &["go".into(), "test".into(), "./...".into()]);
350        assert_eq!(detected.frontends, []);
351        let ecosystem =
352            detect_unsupported_ecosystem(&root, &["go".into(), "test".into(), "./...".into()])
353                .unwrap();
354        assert_eq!(ecosystem.language, "Go");
355        assert_eq!(ecosystem.evidence, "the test command runs `go`");
356        fs::remove_dir_all(root).unwrap();
357    }
358
359    #[test]
360    fn a_known_unsupported_manifest_is_named_when_the_command_is_opaque() {
361        let root = fixture("gomod");
362        fs::write(root.join("go.mod"), "module example.com/app\n").unwrap();
363        let detected = detect_frontends(&root, &["make".into(), "test".into()]);
364        assert_eq!(detected.frontends, []);
365        let ecosystem =
366            detect_unsupported_ecosystem(&root, &["make".into(), "test".into()]).unwrap();
367        assert_eq!(ecosystem.language, "Go");
368        assert_eq!(ecosystem.evidence, "go.mod is present");
369        fs::remove_dir_all(root).unwrap();
370    }
371
372    #[test]
373    fn ruby_runners_and_manifests_select_the_ruby_frontend() {
374        let root = fixture("ruby");
375        fs::write(root.join("Gemfile"), "source 'https://rubygems.org'\n").unwrap();
376        fs::create_dir_all(root.join("spec")).unwrap();
377        for command in [
378            vec!["rspec".to_string()],
379            vec!["bundle".into(), "exec".into(), "rspec".into()],
380            vec!["ruby".into(), "-Itest".into(), "test/app_test.rb".into()],
381            vec!["bin/rails".into(), "test".into()],
382            vec!["make".into(), "test".into()],
383        ] {
384            let detected = detect_frontends(&root, &command);
385            assert_eq!(detected.frontends, [FrontendLanguage::Ruby], "{command:?}");
386        }
387        assert!(detect_unsupported_ecosystem(&root, &["make".into(), "test".into()]).is_none());
388        fs::remove_dir_all(root).unwrap();
389    }
390
391    #[test]
392    fn an_explicit_unsupported_command_is_authoritative_over_manifests() {
393        let root = fixture("go-with-package-json");
394        fs::write(root.join("package.json"), "{}").unwrap();
395        fs::write(root.join("go.mod"), "module example.com/x\n").unwrap();
396        let command = vec!["go".into(), "test".into(), "./...".into()];
397        let ecosystem = detect_unsupported_ecosystem(&root, &command).unwrap();
398        assert!(ecosystem.from_command);
399        assert_eq!(ecosystem.language, "Go");
400        assert!(!command_launches_supported_frontend(&root, &command));
401        // A genuinely mixed command that also launches a supported runner
402        // must keep running.
403        let mixed = vec!["sh".into(), "-c".into(), "go test && npx vitest run".into()];
404        assert!(command_launches_supported_frontend(&root, &mixed));
405        fs::remove_dir_all(root).unwrap();
406    }
407
408    #[test]
409    fn supported_and_unknown_projects_stay_unnamed() {
410        let root = fixture("unknown");
411        assert_eq!(
412            detect_unsupported_ecosystem(&root, &["cargo".into(), "test".into()]),
413            None
414        );
415        assert_eq!(
416            detect_unsupported_ecosystem(&root, &["./scripts/test".into()]),
417            None
418        );
419        fs::remove_dir_all(root).unwrap();
420    }
421
422    #[test]
423    fn opaque_commands_prepare_all_manifest_backed_frontends() {
424        let root = fixture("opaque");
425        fs::write(root.join("Cargo.toml"), "[workspace]\n").unwrap();
426        fs::write(root.join("package.json"), "{}").unwrap();
427        fs::write(root.join("pyproject.toml"), "[project]\nname='fixture'\n").unwrap();
428        let detected = detect_frontends(&root, &["make".into(), "test".into()]);
429        assert_eq!(
430            detected.frontends,
431            [
432                FrontendLanguage::JavaScript,
433                FrontendLanguage::Python,
434                FrontendLanguage::Rust
435            ]
436        );
437        fs::remove_dir_all(root).unwrap();
438    }
439}