Skip to main content

supercov_engine/
jvm_run.rs

1//! Public, isolated JVM coverage run lifecycle, for Java and Kotlin.
2//!
3//! Supercov owns the probes, so measuring a project means rewriting its
4//! sources. That happens on a copy in an isolated workspace; the tree the
5//! author edits is never touched.
6//!
7//! The build system runs the tests, and Supercov puts three things where that
8//! build will find them without being reconfigured:
9//!
10//! - `Supercov` in the main source set, because instrumented product code
11//!   stores into its probe array;
12//! - `SupercovListener` and a generated `SupercovConfig` in the test source
13//!   set, because attribution comes from the JUnit Platform and only the test
14//!   classpath has it;
15//! - a services file registering the listener, and a
16//!   `junit-platform.properties` that turns parallel execution off.
17//!
18//! That last one is prevention rather than detection. Two tests running at
19//! once share one probe array, so the runtime cannot attribute either, and it
20//! notices and says so — but the better outcome is that it never happens, and
21//! Supercov owns the workspace, so it can simply make sure of it.
22
23use std::{
24    collections::{BTreeMap, BTreeSet},
25    ffi::OsString,
26    fs,
27    io::Write,
28    path::{Path, PathBuf},
29    time::Instant,
30};
31
32use serde::{Deserialize, Serialize};
33
34use crate::{
35    evidence_archive::write_archive,
36    frontend_protocol::validate_frontend_report_request,
37    integrity::{FrontendIntegrityInputs, create_explicit_run_integrity},
38    jvm_project::{
39        JvmBuild, PreparedJvmProject, detect_build, jvm_integrity_inputs, prepare_jvm_project,
40    },
41    lifecycle::{
42        ProjectLock, finalize_published_run, publish_run, recover_abandoned_runs,
43        remove_stored_tree_deferred,
44    },
45    orchestration::{ExecutionPhase, ExecutionPlan, PhaseKind, execute_plan},
46    owned_evidence::{
47        OwnedRunInputs, OwnedTestOutcome, build_frontend_run, jvm_coverage_model, jvm_declaration,
48        merge_evidence, read_evidence,
49    },
50    process_supervision::{CommandSpec, SupervisionOptions},
51    run_store::{RawEvidenceMetadata, RunMetadata, RunTimings},
52    workspace::{canonicalize_simplified, prepare_cached_workspace, recover_cached_workspace},
53};
54
55const RUNTIME_SOURCE: &str =
56    include_str!("../runtime-assets/jvm/com/supercorp/supercov/Supercov.java");
57const LISTENER_SOURCE: &str =
58    include_str!("../runtime-assets/jvm/com/supercorp/supercov/SupercovListener.java");
59
60const TESTNG_LISTENER_SOURCE: &str =
61    include_str!("../runtime-assets/jvm/com/supercorp/supercov/SupercovTestNGListener.java");
62
63const PACKAGE_DIRECTORY: &str = "com/supercorp/supercov";
64
65/// Where the JUnit Platform looks for listeners to register.
66const SERVICES_FILE: &str = "META-INF/services/org.junit.platform.launcher.TestExecutionListener";
67
68const LISTENER_CLASS: &str = "com.supercorp.supercov.SupercovListener";
69
70/// Where TestNG looks for listeners to register.
71const TESTNG_SERVICES_FILE: &str = "META-INF/services/org.testng.ITestNGListener";
72
73const TESTNG_LISTENER_CLASS: &str = "com.supercorp.supercov.SupercovTestNGListener";
74
75/// Which test frameworks a project actually depends on.
76///
77/// This decides which listeners are written, and it has to: each is compiled
78/// from the project's own test sources, so one whose framework is absent would
79/// fail on imports the project never asked for. A project that names neither
80/// gets the platform listener, which is what nearly every JVM suite runs on
81/// and what Kotest and Spock report through.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83struct Frameworks {
84    platform: bool,
85    testng: bool,
86    /// JUnit 4 with no platform engine beside it. Supercov cannot attribute
87    /// such a suite, and must not try: see `frameworks`.
88    junit4: bool,
89}
90
91/// A Gradle version catalog, read as the accessors a build file writes.
92///
93/// `testImplementation(libs.junit)` says nothing about which framework that is.
94/// `gradle/libs.versions.toml` says `junit = "junit:junit:4.13.2"`. A catalog
95/// is how Gradle builds are written now, and to a reader that does not open one
96/// every dependency declared through it is invisible: moshi's suite is JUnit 4,
97/// was taken for a platform one because nothing said otherwise, and recorded
98/// nothing at all while its 25 test classes passed.
99///
100/// An alias is addressed with dots where it is declared with dashes, so
101/// `kotlin-reflect` is written `libs.kotlin.reflect`. Coordinates come back
102/// quoted, the shape they would have had written inline, because that is what
103/// `frameworks` reads.
104fn version_catalog(workspace: &Path) -> BTreeMap<String, String> {
105    let mut resolved = BTreeMap::new();
106    let Ok(text) = fs::read_to_string(workspace.join("gradle/libs.versions.toml")) else {
107        return resolved;
108    };
109    let Ok(catalog) = text.parse::<toml::Table>() else {
110        return resolved;
111    };
112    let accessor = |alias: &str| alias.replace(['-', '_'], ".");
113    if let Some(libraries) = catalog.get("libraries").and_then(toml::Value::as_table) {
114        for (alias, value) in libraries {
115            let coordinates = match value {
116                toml::Value::String(coordinates) => coordinates.clone(),
117                toml::Value::Table(table) => {
118                    match table.get("module").and_then(toml::Value::as_str) {
119                        Some(module) => module.to_owned(),
120                        None => match (
121                            table.get("group").and_then(toml::Value::as_str),
122                            table.get("name").and_then(toml::Value::as_str),
123                        ) {
124                            (Some(group), Some(name)) => format!("{group}:{name}"),
125                            _ => continue,
126                        },
127                    }
128                }
129                _ => continue,
130            };
131            resolved.insert(accessor(alias), format!("\"{coordinates}\""));
132        }
133    }
134    // Naming a bundle depends on every library in it. A bundle is addressed
135    // under `libs.bundles.`, a library directly under `libs.`.
136    if let Some(bundles) = catalog.get("bundles").and_then(toml::Value::as_table) {
137        let libraries = resolved.clone();
138        for (alias, value) in bundles {
139            let Some(members) = value.as_array() else {
140                continue;
141            };
142            let expanded = members
143                .iter()
144                .filter_map(toml::Value::as_str)
145                .filter_map(|member| libraries.get(&accessor(member)).cloned())
146                .collect::<Vec<_>>()
147                .join(" ");
148            resolved.insert(format!("bundles.{}", accessor(alias)), expanded);
149        }
150    }
151    resolved
152}
153
154/// The build text with the coordinates of every catalog accessor it names.
155fn with_catalog(text: &str, catalog: &BTreeMap<String, String>) -> String {
156    let mut out = text.to_owned();
157    for (accessor, coordinates) in catalog {
158        // `libs.kotlin` must not answer for `libs.kotlin.reflect`.
159        let needle = format!("libs.{accessor}");
160        let named = text.match_indices(&needle).any(|(at, _)| {
161            text[at + needle.len()..]
162                .chars()
163                .next()
164                .is_none_or(|next| !next.is_alphanumeric() && !matches!(next, '.' | '_' | '-'))
165        });
166        if named {
167            out.push('\n');
168            out.push_str(coordinates);
169        }
170    }
171    out
172}
173
174fn frameworks(build_file: &str) -> Frameworks {
175    let testng = build_file.contains("testng");
176    // The platform is what Jupiter, Vintage, Kotest and Spock all run on.
177    let platform = [
178        "junit-jupiter",
179        "junit-platform",
180        "junit-vintage",
181        "kotest",
182        "spock",
183    ]
184    .iter()
185    .any(|name| build_file.contains(name));
186    // JUnit 4 is not a platform engine and does not run on one. Surefire runs
187    // it through a provider of its own, and choosing that provider is decided
188    // by what is on the classpath -- so adding the launcher to a JUnit 4
189    // project makes surefire switch to the platform provider, find no engine
190    // there, and fail the suite outright.
191    let junit4 = !platform
192        && (build_file.contains("<groupId>junit</groupId>") || build_file.contains("'junit:junit"))
193        || build_file.contains("\"junit:junit");
194    Frameworks {
195        // A TestNG-only or JUnit-4-only project would fail on a platform
196        // listener, so the fallback applies only when nothing was recognised.
197        platform: platform || !(testng || junit4),
198        testng,
199        junit4: junit4 && !platform,
200    }
201}
202
203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[serde(rename_all = "camelCase", deny_unknown_fields)]
205pub struct DirectJvmRunRequest {
206    pub root: PathBuf,
207    pub command: Vec<String>,
208    pub run_id: String,
209    pub started_at: String,
210}
211
212#[derive(Debug, Clone, PartialEq)]
213pub struct DirectJvmRunResult {
214    pub run_id: String,
215    pub run_directory: PathBuf,
216    pub exit_code: i32,
217    pub tests: usize,
218    pub source_files: usize,
219    pub modules: usize,
220    pub build: JvmBuild,
221    pub recovered_runs: Vec<String>,
222    pub metadata: RunMetadata,
223}
224
225fn elapsed_ms(started: Instant) -> f64 {
226    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
227}
228
229fn write(path: &Path, contents: &str) -> Result<(), String> {
230    if let Some(parent) = path.parent() {
231        fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
232    }
233    fs::write(path, contents).map_err(|error| format!("{}: {error}", path.display()))
234}
235
236/// The source root Supercov's own Java lands in for a given source set.
237///
238/// Maven, Gradle and a plain tree have meant the same thing by these paths for
239/// long enough that the convention is more reliable than reading a build file,
240/// and a Kotlin project compiles Java from here too.
241fn source_root(source_set: &str) -> String {
242    format!("src/{source_set}/java")
243}
244
245/// Java source escaping for a string literal, so a Windows path or a name with
246/// a quote in it cannot end the literal early.
247fn java_literal(value: &str) -> String {
248    let mut out = String::with_capacity(value.len() + 2);
249    out.push('"');
250    for character in value.chars() {
251        match character {
252            '"' => out.push_str("\\\""),
253            '\\' => out.push_str("\\\\"),
254            '\n' => out.push_str("\\n"),
255            '\r' => out.push_str("\\r"),
256            other => out.push(other),
257        }
258    }
259    out.push('"');
260    out
261}
262
263/// The runtime, sized for this project.
264///
265/// A probe is a bare store into the array with nothing checking its bounds,
266/// which is what makes it cost one instruction. So the array has to be the
267/// right size before any product code runs, rather than only once a listener
268/// arms it: a run where no listener starts -- a suite in a language Supercov
269/// does not parse, a build that never reaches the test task -- would otherwise
270/// throw on the first instrumented line, and Supercov would have turned a
271/// passing suite into a failing one.
272fn runtime_source(probe_count: usize) -> String {
273    let marker = "static final int PROBE_COUNT = 0; // supercov:probe-count";
274    debug_assert!(
275        RUNTIME_SOURCE.contains(marker),
276        "the runtime no longer declares the probe count Supercov substitutes"
277    );
278    RUNTIME_SOURCE.replace(
279        marker,
280        &format!("static final int PROBE_COUNT = {probe_count}; // supercov:probe-count"),
281    )
282}
283
284/// What the listener reads to know the shape of the run it is recording.
285fn configuration(probe_count: usize, widths: &[u8], evidence: &Path) -> String {
286    let widths = widths
287        .iter()
288        .map(u8::to_string)
289        .collect::<Vec<_>>()
290        .join(", ");
291    format!(
292        "// Code generated by Supercov. DO NOT EDIT.\npackage com.supercorp.supercov;\n\npublic final class SupercovConfig {{\n  private SupercovConfig() {{}}\n\n  public static final int PROBES = {probe_count};\n  public static final int[] WIDTHS = new int[] {{{widths}}};\n  public static final String EVIDENCE = {};\n}}\n",
293        java_literal(&evidence.to_string_lossy())
294    )
295}
296
297/// The project's JUnit Platform configuration with parallel execution turned
298/// off, keeping whatever else it already said.
299///
300/// Attribution is a sweep of one shared probe array at each test boundary, so
301/// two tests running at once cannot both be credited. The runtime notices and
302/// degrades honestly; this is the half that means it never has to.
303fn sequential_properties(existing: Option<&str>) -> String {
304    let mut out = String::new();
305    for line in existing.unwrap_or("").lines() {
306        if line
307            .trim_start()
308            .starts_with("junit.jupiter.execution.parallel.enabled")
309        {
310            continue;
311        }
312        out.push_str(line);
313        out.push('\n');
314    }
315    out.push_str(
316        "# Set by Supercov: probes are a store into one shared array, so two tests running\n\
317         # at once cannot both be credited with what they reached.\n\
318         junit.jupiter.execution.parallel.enabled=false\n",
319    );
320    out
321}
322
323/// The JUnit Platform artifact the listener is written against.
324///
325/// `junit-jupiter` does not bring it: Maven's surefire and Gradle's test task
326/// both put it on the test *runtime* classpath because they need it
327/// themselves, but neither offers it at compile time, so a listener declared
328/// in the project's own test sources will not compile without it. Supercov
329/// owns the workspace copy, so it adds the dependency there — the same line a
330/// person would add, in a tree the author never sees.
331const LAUNCHER_ARTIFACT: &str = "junit-platform-launcher";
332
333/// The launcher version to ask for.
334///
335/// JUnit numbers the platform 1.N alongside Jupiter 5.N, so a project that
336/// pins its Jupiter version tells us exactly which launcher agrees with the
337/// engine it will run on. A project using the BOM has already said how to
338/// version every JUnit artifact, and naming a version there would override the
339/// answer it gave.
340fn launcher_version(build_file: &str) -> Option<String> {
341    // The one case where naming no version is right: a project using the BOM
342    // has already said how every JUnit artifact is versioned, and naming one
343    // would override the answer it gave. Every other path must produce a
344    // version -- a versionless dependency in a pom with no BOM to manage it is
345    // one Maven cannot resolve, and the build stops before any test runs.
346    if build_file.contains("junit-bom") {
347        return None;
348    }
349    Some(jupiter_version(build_file).unwrap_or_else(|| DEFAULT_LAUNCHER_VERSION.to_owned()))
350}
351
352/// The platform version matching whatever Jupiter this file pins, if it pins
353/// one. JUnit numbers the platform 1.N alongside Jupiter 5.N.
354fn jupiter_version(build_file: &str) -> Option<String> {
355    let jupiter = build_file.find("junit-jupiter")?;
356    let rest = &build_file[jupiter..];
357    rest.match_indices("5.").find_map(|(at, _)| {
358        let tail = &rest[at + 2..];
359        let minor = tail
360            .chars()
361            .take_while(char::is_ascii_digit)
362            .collect::<String>();
363        let patch = tail[minor.len()..]
364            .strip_prefix('.')?
365            .chars()
366            .take_while(char::is_ascii_digit)
367            .collect::<String>();
368        (!minor.is_empty() && !patch.is_empty()).then(|| format!("1.{minor}.{patch}"))
369    })
370}
371
372const DEFAULT_LAUNCHER_VERSION: &str = "1.10.2";
373
374/// The engine version matching a platform version: 1.N.P becomes 5.N.P.
375fn engine_version_of_platform(platform: &str) -> String {
376    match platform.strip_prefix("1.") {
377        Some(rest) => format!("5.{rest}"),
378        None => platform.to_owned(),
379    }
380}
381
382/// `pom.xml` with the launcher among its test dependencies.
383fn maven_with_launcher(pom: &str) -> Option<String> {
384    maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT])
385}
386
387/// The engine that runs JUnit 4 tests on the JUnit Platform.
388///
389/// A JUnit 4 suite is not a platform one and cannot be attributed as it
390/// stands. Vintage is the platform's own answer: it discovers and runs exactly
391/// the same JUnit 4 tests, through the lifecycle Supercov listens to. Adding
392/// it to the copy turns a suite Supercov could only decline into one it can
393/// measure, and the author's own build still runs JUnit 4 as before.
394const VINTAGE_ARTIFACT: &str = "junit-vintage-engine";
395
396fn maven_with_vintage(pom: &str) -> Option<String> {
397    maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT, VINTAGE_ARTIFACT])
398}
399
400fn maven_with_test_artifacts(pom: &str, artifacts: &[&str]) -> Option<String> {
401    let missing = artifacts
402        .iter()
403        .filter(|artifact| !pom.contains(**artifact))
404        .collect::<Vec<_>>();
405    if missing.is_empty() {
406        return None;
407    }
408    let platform = launcher_version(pom);
409    let dependency = missing
410        .iter()
411        .map(|artifact| {
412            // Vintage is versioned with Jupiter, not with the platform: JUnit
413            // numbers the engines 5.N and the platform 1.N, and asking for
414            // vintage 1.N asks for something that was never published.
415            let (group, version) = if **artifact == VINTAGE_ARTIFACT {
416                (
417                    "org.junit.vintage",
418                    platform.as_deref().map(engine_version_of_platform),
419                )
420            } else {
421                ("org.junit.platform", platform.clone())
422            };
423            let version = version
424                .map(|version| format!("\n      <version>{version}</version>"))
425                .unwrap_or_default();
426            format!(
427                "    <dependency>\n      <groupId>{group}</groupId>\n      <artifactId>{artifact}</artifactId>{version}\n      <scope>test</scope>\n    </dependency>\n"
428            )
429        })
430        .collect::<String>();
431    match project_dependencies_end(pom) {
432        Some(at) => Some(format!("{}{dependency}{}", &pom[..at], &pom[at..])),
433        // A project with no dependencies block of its own still needs one.
434        None => pom.rfind("</project>").map(|at| {
435            format!(
436                "{}  <dependencies>\n{dependency}  </dependencies>\n{}",
437                &pom[..at],
438                &pom[at..]
439            )
440        }),
441    }
442}
443
444/// Where the project's own `<dependencies>` ends.
445///
446/// Not simply the last one. A pom's `<dependencyManagement>` holds a
447/// `<dependencies>` too, and so does every `<profile>` and every `<plugin>`;
448/// a dependency added inside `<dependencyManagement>` is a version for
449/// something else to ask for rather than something the project depends on, so
450/// the module compiles exactly as it did before and the listener still cannot
451/// find the API it implements. Depth is what tells them apart.
452fn project_dependencies_end(pom: &str) -> Option<usize> {
453    const NESTED: [&str; 4] = ["dependencyManagement", "profiles", "build", "reporting"];
454    let bytes = pom.as_bytes();
455    let mut depth = 0usize;
456    let mut at = 0usize;
457    while at < bytes.len() {
458        let Some(open) = pom[at..].find('<') else {
459            break;
460        };
461        let start = at + open;
462        let Some(close) = pom[start..].find('>') else {
463            break;
464        };
465        let tag = &pom[start + 1..start + close];
466        at = start + close + 1;
467        let name = tag.trim_start_matches('/').trim_end_matches('/').trim();
468        let name = name.split_whitespace().next().unwrap_or_default();
469        if NESTED.contains(&name) {
470            if tag.starts_with('/') {
471                depth = depth.saturating_sub(1);
472            } else if !tag.ends_with('/') {
473                depth += 1;
474            }
475        } else if name == "dependencies" && tag.starts_with('/') && depth == 0 {
476            return Some(start);
477        }
478    }
479    None
480}
481
482/// Whether a Gradle script already puts the launcher where test *sources* can
483/// see it.
484///
485/// Presence is not enough. Gradle 9 requires every project to declare the
486/// launcher itself, and the configuration its own documentation recommends is
487/// `testRuntimeOnly` — which puts the artifact on the classpath the tests run
488/// with and not the one they compile against. A project following that advice
489/// has the artifact and still cannot compile a listener, so the question is
490/// which configuration declares it, not whether one does.
491fn declares_launcher_for_compilation(build_file: &str) -> bool {
492    build_file.lines().any(|line| {
493        line.contains(LAUNCHER_ARTIFACT)
494            && ["testImplementation", "testCompileOnly", "testApi"]
495                .iter()
496                .any(|configuration| line.contains(configuration))
497    })
498}
499
500/// A Gradle build file with the launcher among its test dependencies.
501///
502/// Appended as its own `dependencies` block rather than edited into the
503/// existing one: Gradle merges them, and finding the right brace in a Groovy
504/// or Kotlin script by hand is the kind of parsing that works until it does
505/// not.
506fn gradle_with_launcher(build_file: &str, kotlin: bool) -> Option<String> {
507    if declares_launcher_for_compilation(build_file) {
508        return None;
509    }
510    let coordinate = match launcher_version(build_file) {
511        Some(version) => format!("org.junit.platform:{LAUNCHER_ARTIFACT}:{version}"),
512        None => format!("org.junit.platform:{LAUNCHER_ARTIFACT}"),
513    };
514    // `allprojects` rather than a bare `dependencies` block, because a
515    // multi-project build compiles each subproject's test sources against that
516    // subproject's own classpath and a declaration in the root reaches none of
517    // them. Guarded by the java plugin so a root that only aggregates — which
518    // has no test source set and no configurations to add to — is left alone.
519    // In the Kotlin DSL the typed accessor does not exist inside `allprojects`,
520    // so the configuration is named as a string.
521    let line = if kotlin {
522        format!("            \"testImplementation\"(\"{coordinate}\")")
523    } else {
524        format!("            testImplementation '{coordinate}'")
525    };
526    let plugin = if kotlin { "\"java\"" } else { "'java'" };
527    Some(format!(
528        "{build_file}\n// Added by Supercov: the JUnit Platform listener that attributes coverage\n// to each test is compiled from each project's own test sources, and the\n// launcher API it implements is on the test runtime classpath but not the\n// compile one.\nallprojects {{\n    plugins.withId({plugin}) {{\n        dependencies {{\n{line}\n        }}\n    }}\n}}\n"
529    ))
530}
531
532/// A build file with its warnings-as-errors policy relaxed.
533///
534/// A project is free to fail its build on any warning, and several good ones
535/// do. The instrumented copy contains code that project never wrote and never
536/// agreed a style for, so its own policy would reject it -- gson's Error Prone
537/// configuration rejects a fully-qualified name, and nothing Supercov can emit
538/// satisfies every such rule. The Rust frontend caps lints for the same reason
539/// and in the same place: the copy, never the tree the author keeps.
540///
541/// Only the escalation is removed. The warnings are still emitted, the
542/// compiler still compiles exactly what it would have, and the author's own
543/// build is untouched.
544fn without_warnings_as_errors(build_file: &str) -> Option<String> {
545    let mut updated = build_file.to_owned();
546    for (from, to) in [
547        (
548            "<failOnWarning>true</failOnWarning>",
549            "<failOnWarning>false</failOnWarning>",
550        ),
551        (
552            "<failOnWarnings>true</failOnWarnings>",
553            "<failOnWarnings>false</failOnWarnings>",
554        ),
555        ("<arg>-Werror</arg>", ""),
556        ("<compilerArgument>-Werror</compilerArgument>", ""),
557        ("options.compilerArgs << '-Werror'", ""),
558        ("allWarningsAsErrors = true", "allWarningsAsErrors = false"),
559    ] {
560        updated = updated.replace(from, to);
561    }
562    updated = without_error_prone(&updated);
563    (updated != build_file).then_some(updated)
564}
565
566/// The same build file with Error Prone switched off.
567///
568/// Relaxing warnings is not enough on its own: Error Prone has checks that
569/// fail at error severity, and some are about the shape of a method rather
570/// than its meaning — an `@InlineMe` method must hold exactly one statement,
571/// and a probe makes two. No instrumentation can satisfy a rule like that,
572/// because the rule is about source the author wrote and the copy holds source
573/// they did not.
574///
575/// Switching the analyser off in the copy costs nothing: it says nothing about
576/// whether the tests pass, and the author's own build still runs it in full.
577fn without_error_prone(build_file: &str) -> String {
578    let Some(start) = build_file.find("<arg>-Xplugin:ErrorProne") else {
579        return build_file.to_owned();
580    };
581    let Some(end) = build_file[start..].find("</arg>") else {
582        return build_file.to_owned();
583    };
584    let mut updated = build_file.to_owned();
585    updated.replace_range(start..start + end + "</arg>".len(), "");
586    updated
587}
588
589/// Make the build run its tests again rather than reporting a cached result.
590///
591/// A test that does not run records nothing, and a run that measured half a
592/// suite without saying so is worse than one that took longer.
593fn command_with_fresh_results(
594    build: JvmBuild,
595    command: &[String],
596) -> (Vec<String>, Option<String>) {
597    let mut updated = command.to_vec();
598    match build {
599        JvmBuild::Gradle => {
600            if updated
601                .iter()
602                .any(|argument| argument == "--rerun-tasks" || argument == "--rerun")
603            {
604                return (updated, None);
605            }
606            updated.push("--rerun-tasks".into());
607            (
608                updated,
609                Some(
610                    "added --rerun-tasks: Gradle skips a test task it considers up to date, and a task that does not run records no coverage"
611                        .into(),
612                ),
613            )
614        }
615        // Maven's default lifecycle re-runs surefire every time, and Gradle's
616        // build cache has no Maven equivalent worth defeating here.
617        JvmBuild::Maven | JvmBuild::Plain => (updated, None),
618    }
619}
620
621/// One module of the build, and where its evidence lands.
622///
623/// A single-project build has exactly one of these, rooted at the workspace.
624/// A multi-module build has one per module, because each module compiles only
625/// its own source set and forks its own JVM to run its tests: a runtime
626/// written once at the top would be invisible to every module, and one
627/// evidence path shared by every module's JVM would be overwritten by
628/// whichever finished last.
629#[derive(Debug, Clone)]
630struct JvmModule {
631    /// Relative to the workspace, `/`-separated; `.` for the build root.
632    directory: String,
633    /// Where this module's JVMs write. A directory rather than a file: a build
634    /// may fork more than one to run tests in parallel, and each writes under
635    /// a name of its own so none overwrites another.
636    evidence: PathBuf,
637    has_tests: bool,
638}
639
640struct InstrumentedWorkspace {
641    project: PreparedJvmProject,
642    build: JvmBuild,
643    modules: Vec<JvmModule>,
644    /// Which file declared each test class, so a result can point at a source.
645    declared_in: BTreeMap<String, String>,
646    /// The build file the launcher dependency was added to, if it was.
647    added_launcher: Option<&'static str>,
648    /// Whether a JUnit 4 module was given the engine that runs it on the
649    /// platform, so the user hears that their suite ran a different way.
650    added_vintage: bool,
651    /// What had to be relaxed in the copy's build files for instrumented code
652    /// to compile, named so the user knows rather than infers.
653    relaxed: Vec<&'static str>,
654    /// Modules Supercov instrumented but cannot attribute, and why they were
655    /// left without a listener rather than broken by one.
656    unmeasurable: Vec<String>,
657    /// Modules whose tests are a named JPMS module, which cannot take a listener.
658    modular: Vec<String>,
659}
660
661/// The module a `src/main/...` or `src/test/...` path belongs to.
662///
663/// The build root for a single-project build, and the subdirectory holding
664/// that source set otherwise. Derived from the paths themselves rather than
665/// from the build file, because Maven's `<modules>` and Gradle's
666/// `settings.gradle` say the same thing in two languages and the layout says
667/// it in one.
668fn module_of(relative: &str) -> String {
669    match relative.find("src/") {
670        Some(0) | None => ".".to_owned(),
671        Some(at) => relative[..at].trim_end_matches('/').to_owned(),
672    }
673}
674
675/// The class a test's reported name belongs to, as JUnit names it:
676/// `CalculatorTest#zeroIsNamed()`, or a DSL framework's own wording.
677fn class_of(test_name: &str) -> Option<&str> {
678    test_name.split('#').next().filter(|name| !name.is_empty())
679}
680
681fn instrument_workspace(
682    workspace: &Path,
683    evidence_directory: &Path,
684) -> Result<InstrumentedWorkspace, String> {
685    let project = prepare_jvm_project(workspace)?;
686    let build = detect_build(workspace);
687
688    for (relative, instrumented) in &project.instrumented {
689        write(&workspace.join(relative), instrumented)?;
690    }
691
692    let probe_count = project
693        .probes
694        .keys()
695        .max()
696        .map_or(0, |highest| *highest as usize + 1);
697
698    // Which frameworks a module runs is a question about that module. A
699    // repository can hold a JUnit 4 module beside a JUnit 5 one, and asking
700    // the whole tree at once answers neither: the platform listener would go
701    // into the JUnit 4 module, where it cannot work, and the launcher with it,
702    // where it makes the build choose a provider that finds no engine.
703    let catalog = version_catalog(workspace);
704    let frameworks_of = |directory: &str| -> Frameworks {
705        let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
706        let mut text = names
707            .iter()
708            .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
709            .unwrap_or_default();
710        for name in names {
711            if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
712                text.push('\n');
713                text.push_str(&own);
714                break;
715            }
716        }
717        frameworks(&with_catalog(&text, &catalog))
718    };
719    let mut unmeasurable: Vec<String> = Vec::new();
720    let mut modular: Vec<String> = Vec::new();
721
722    // One entry per module that has a main or a test source set, keyed by
723    // directory so a module contributing both is listed once.
724    let mut modules: BTreeMap<String, bool> = BTreeMap::new();
725    for (relative, _) in &project.files.sources {
726        modules.entry(module_of(relative)).or_insert(false);
727    }
728    for (relative, _) in &project.files.tests {
729        *modules.entry(module_of(relative)).or_default() = true;
730    }
731    // A module's tests may be in a language Supercov does not parse -- Spock
732    // writes them in Groovy, and Supercov measures the Java and Kotlin they
733    // exercise rather than the specification itself. Those files are not in
734    // `files.tests`, so the source sets are asked directly: a module judged to
735    // have no tests gets no listener, and a run with no listener records
736    // nothing at all.
737    for module in modules.keys().cloned().collect::<Vec<_>>() {
738        if workspace.join(&module).join("src/test").is_dir() {
739            modules.insert(module, true);
740        }
741    }
742    if modules.is_empty() {
743        modules.insert(".".to_owned(), true);
744    }
745
746    let modules = modules
747        .into_iter()
748        .map(|(directory, has_tests)| JvmModule {
749            evidence: evidence_directory.join(
750                directory
751                    .chars()
752                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
753                    .collect::<String>(),
754            ),
755            directory,
756            has_tests,
757        })
758        .collect::<Vec<_>>();
759
760    for module in &modules {
761        let frameworks = frameworks_of(&module.directory);
762        let at = |source_set: &str| {
763            workspace
764                .join(&module.directory)
765                .join(source_root(source_set))
766                .join(PACKAGE_DIRECTORY)
767        };
768        // The runtime goes in every module's main source set: instrumented
769        // product code stores into its array, and a module compiles only its
770        // own sources. The class is identical everywhere, and each module's
771        // tests fork a JVM that loads exactly one of them.
772        write(
773            &at("main").join("Supercov.java"),
774            &runtime_source(probe_count),
775        )?;
776        if !module.has_tests {
777            continue;
778        }
779        if frameworks.junit4 && build != JvmBuild::Maven {
780            // Only Maven's copy gets Vintage added below; elsewhere the module
781            // keeps its probes and gets no listener, because attributing it is
782            // impossible and trying would break it.
783            unmeasurable.push(module.directory.clone());
784            continue;
785        }
786        // A test source set with a module-info.java is a named JPMS module,
787        // and a named module is closed: every package it holds is its own and
788        // every dependency has to be declared in that file. A listener written
789        // into it imports org.junit.platform, which the module does not
790        // require, and reads the runtime from a package its main module does
791        // not export -- so the module stops compiling and takes the build with
792        // it. gson's test-jpms is exactly this, and it tests module boundaries
793        // rather than product logic, so leaving it alone costs the report
794        // nothing it could have had.
795        if workspace
796            .join(&module.directory)
797            .join(source_root("test"))
798            .join("module-info.java")
799            .exists()
800        {
801            modular.push(module.directory.clone());
802            continue;
803        }
804        // The listeners and their configuration go in the test source set,
805        // because only the test classpath has the frameworks to listen to.
806        let test = at("test");
807        write(
808            &test.join("SupercovConfig.java"),
809            &configuration(probe_count, &project.decision_widths, &module.evidence),
810        )?;
811        if frameworks.platform || frameworks.junit4 {
812            write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
813        }
814        if frameworks.testng {
815            write(
816                &test.join("SupercovTestNGListener.java"),
817                TESTNG_LISTENER_SOURCE,
818            )?;
819        }
820    }
821
822    // A project's warning policy applies to code it wrote. The copy holds code
823    // it did not.
824    let mut relaxed: Vec<&'static str> = Vec::new();
825    for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
826        let path = workspace.join(name);
827        let Ok(existing) = fs::read_to_string(&path) else {
828            continue;
829        };
830        let Some(updated) = without_warnings_as_errors(&existing) else {
831            continue;
832        };
833        // Named separately, because switching a static analyser off is a
834        // bigger thing than not failing on a warning and the user should hear
835        // it said rather than work it out.
836        if !relaxed.contains(&"stopped the build failing on warnings")
837            && updated.contains("<failOnWarning>false</failOnWarning>")
838                != existing.contains("<failOnWarning>false</failOnWarning>")
839            || existing.contains("-Werror") && !updated.contains("-Werror")
840        {
841            relaxed.push("stopped the build failing on warnings");
842        }
843        if existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne") {
844            relaxed.push("switched Error Prone off");
845        }
846        write(&path, &updated)?;
847    }
848    relaxed.dedup();
849
850    // The platform listener is compiled from the project's own test sources,
851    // so the launcher API it implements has to be on the compile classpath. A
852    // TestNG-only project needs none of that: it already depends on the
853    // framework its own listener implements.
854    let mut added_launcher = None;
855    let mut added_vintage = false;
856    match build {
857        // Per module, not once at the top: the module's own pom is where its
858        // JUnit version is in scope, and a module that runs JUnit 4 must not
859        // get the launcher at all.
860        JvmBuild::Maven => {
861            for module in modules
862                .iter()
863                .filter(|module| {
864                    module.has_tests
865                        && !unmeasurable.contains(&module.directory)
866                        && !modular.contains(&module.directory)
867                })
868                // A TestNG module needs none of this: it already depends on
869                // the framework its own listener implements, and the launcher
870                // would only change which provider the build chooses.
871                .filter(|module| {
872                    let frameworks = frameworks_of(&module.directory);
873                    frameworks.platform || frameworks.junit4
874                })
875            {
876                let pom = workspace.join(&module.directory).join("pom.xml");
877                let Ok(existing) = fs::read_to_string(&pom) else {
878                    continue;
879                };
880                // A JUnit 4 module also needs the engine that runs JUnit 4
881                // tests on the platform; without it the launcher would find no
882                // engine at all.
883                let updated = if frameworks_of(&module.directory).junit4 {
884                    added_vintage = true;
885                    maven_with_vintage(&existing)
886                } else {
887                    maven_with_launcher(&existing)
888                };
889                if let Some(updated) = updated {
890                    write(&pom, &updated)?;
891                    added_launcher = Some("pom.xml");
892                }
893            }
894        }
895        JvmBuild::Gradle
896            if !modules
897                .iter()
898                .any(|module| module.has_tests && frameworks_of(&module.directory).platform) => {}
899        JvmBuild::Gradle => {
900            for name in ["build.gradle.kts", "build.gradle"] {
901                let path = workspace.join(name);
902                let Ok(existing) = fs::read_to_string(&path) else {
903                    continue;
904                };
905                if let Some(updated) = gradle_with_launcher(&existing, name.ends_with(".kts")) {
906                    write(&path, &updated)?;
907                    added_launcher = Some(if name.ends_with(".kts") {
908                        "build.gradle.kts"
909                    } else {
910                        "build.gradle"
911                    });
912                }
913                break;
914            }
915        }
916        // Nothing resolves dependencies for a plain tree; whoever compiles it
917        // supplies the classpath.
918        JvmBuild::Plain => {}
919    }
920
921    for module in modules.iter().filter(|module| {
922        module.has_tests
923            && !unmeasurable.contains(&module.directory)
924            && !modular.contains(&module.directory)
925    }) {
926        let frameworks = frameworks_of(&module.directory);
927        let resources = workspace.join(&module.directory).join("src/test/resources");
928        if frameworks.platform || frameworks.junit4 {
929            write(
930                &resources.join(SERVICES_FILE),
931                &format!("{LISTENER_CLASS}\n"),
932            )?;
933            let properties = resources.join("junit-platform.properties");
934            let existing = fs::read_to_string(&properties).ok();
935            write(&properties, &sequential_properties(existing.as_deref()))?;
936        }
937        if frameworks.testng {
938            write(
939                &resources.join(TESTNG_SERVICES_FILE),
940                &format!("{TESTNG_LISTENER_CLASS}\n"),
941            )?;
942        }
943    }
944
945    // A test class's file, so a result can name where it came from. Matched on
946    // the class rather than the test, because the name a framework reports for
947    // a test is its own and need not be a method at all.
948    let mut declared_in = BTreeMap::new();
949    for (relative, _) in &project.files.tests {
950        if let Some(stem) = relative
951            .rsplit('/')
952            .next()
953            .and_then(|name| name.split('.').next())
954        {
955            declared_in.insert(stem.to_owned(), relative.clone());
956        }
957    }
958
959    Ok(InstrumentedWorkspace {
960        project,
961        build,
962        modules,
963        declared_in,
964        added_launcher,
965        added_vintage,
966        relaxed,
967        unmeasurable,
968        modular,
969    })
970}
971
972/// The fingerprint a later query compares against the stored run.
973pub fn current_jvm_integrity(
974    root: &Path,
975    command: &[String],
976) -> Result<crate::run_store::RunIntegrity, String> {
977    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
978    let files = crate::jvm_project::discover_jvm_files(&root)?;
979    create_explicit_run_integrity(
980        &root,
981        &jvm_integrity_inputs(&files, command),
982        &FrontendIntegrityInputs::embedded_jvm(),
983    )
984    .map_err(|error| error.to_string())
985}
986
987pub fn run_direct_jvm(
988    request: &DirectJvmRunRequest,
989    diagnostics: &mut dyn Write,
990) -> Result<DirectJvmRunResult, String> {
991    if request.command.is_empty() {
992        return Err("test command must not be empty".into());
993    }
994    let total_started = Instant::now();
995    let initialization_started = Instant::now();
996    let root = canonicalize_simplified(&request.root)
997        .map_err(|error| format!("{}: {error}", request.root.display()))?;
998    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
999        .map_err(|error| error.to_string())?;
1000    let initialization_ms = elapsed_ms(initialization_started);
1001    let work_directory = root.join(".supercov/work").join(&request.run_id);
1002    let result = (|| {
1003        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1004            .map_err(|error| error.to_string())?;
1005        if !recovered_runs.is_empty() {
1006            writeln!(
1007                diagnostics,
1008                "[supercov] recovered abandoned run(s): {}",
1009                recovered_runs.join(", ")
1010            )
1011            .map_err(|error| error.to_string())?;
1012        }
1013
1014        let adapter_started = Instant::now();
1015        let files = crate::jvm_project::discover_jvm_files(&root)?;
1016        let integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1017        let assertion_inputs =
1018            crate::assertion_inputs::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1019        let integrity = create_explicit_run_integrity(
1020            &root,
1021            &integrity_inputs,
1022            &FrontendIntegrityInputs::embedded_jvm(),
1023        )
1024        .map_err(|error| error.to_string())?;
1025
1026        let workspace_started = Instant::now();
1027        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1028        let workspace =
1029            prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1030        let evidence_directory = work_directory.join("jvm");
1031        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1032        let instrumented = instrument_workspace(&workspace, &evidence_directory)?;
1033        let workspace_preparation_ms = elapsed_ms(workspace_started);
1034        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1035        writeln!(
1036            diagnostics,
1037            "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1038            match instrumented.build {
1039                JvmBuild::Maven => "a Maven project",
1040                JvmBuild::Gradle => "a Gradle project",
1041                JvmBuild::Plain => "Java/Kotlin sources",
1042            },
1043            instrumented.project.instrumented.len(),
1044            workspace.display()
1045        )
1046        .map_err(|error| error.to_string())?;
1047        if let Some(build_file) = instrumented.added_launcher {
1048            writeln!(
1049                diagnostics,
1050                "[supercov] added a test-scoped {LAUNCHER_ARTIFACT} to the workspace's {build_file}: per-test attribution comes from a JUnit Platform listener, and the API it implements is on the test runtime classpath but not the compile one. Your own {build_file} is untouched."
1051            )
1052            .map_err(|error| error.to_string())?;
1053        }
1054        if !instrumented.relaxed.is_empty() {
1055            writeln!(
1056                diagnostics,
1057                "[supercov] in the workspace copy only: {}. The copy holds instrumented code your project never wrote a policy for, and a rule about the shape of a method is one no instrumentation can satisfy. Warnings are still reported, your build file is untouched, and your own build still runs every check in full.",
1058                instrumented.relaxed.join("; ")
1059            )
1060            .map_err(|error| error.to_string())?;
1061        }
1062        if instrumented.added_vintage {
1063            writeln!(
1064                diagnostics,
1065                "[supercov] added junit-vintage-engine to the workspace copy: JUnit 4 is not a JUnit Platform engine, and Vintage is the platform's own way of running exactly these tests through the lifecycle Supercov listens to. Your own build still runs JUnit 4 as it did."
1066            )
1067            .map_err(|error| error.to_string())?;
1068        }
1069        if !instrumented.modular.is_empty() {
1070            writeln!(
1071                diagnostics,
1072                "[supercov] {} module(s) declare their tests as a Java module and are not attributed: {}. A named module names every package it holds and every dependency it may use, in its own module-info.java, so a listener added to it would not compile -- and neither would the module. Supercov leaves those tests to run exactly as they did.",
1073                instrumented.modular.len(),
1074                instrumented.modular.join(", ")
1075            )
1076            .map_err(|error| error.to_string())?;
1077        }
1078        if !instrumented.unmeasurable.is_empty() {
1079            writeln!(
1080                diagnostics,
1081                "[supercov] {} module(s) run JUnit 4, which is not a JUnit Platform engine, so they are not attributed: {}. Supercov listens through the platform's own lifecycle, and putting the platform on a JUnit 4 classpath makes the build choose a provider that finds no engine -- so it leaves those modules alone rather than break them. Adding junit-vintage-engine runs the same tests on the platform, and Supercov measures them.",
1082                instrumented.unmeasurable.len(),
1083                instrumented.unmeasurable.join(", ")
1084            )
1085            .map_err(|error| error.to_string())?;
1086        }
1087        for (file, reason) in &instrumented.project.unparseable {
1088            writeln!(
1089                diagnostics,
1090                "[supercov] could not parse {file}: {reason}; it carries no obligations"
1091            )
1092            .map_err(|error| error.to_string())?;
1093        }
1094
1095        let (command, note) = command_with_fresh_results(instrumented.build, &request.command);
1096        if let Some(note) = note {
1097            writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1098        }
1099        let test_started = Instant::now();
1100        let plan = ExecutionPlan {
1101            preparation: Vec::new(),
1102            test: ExecutionPhase {
1103                name: "test".into(),
1104                kind: PhaseKind::Test,
1105                command: CommandSpec {
1106                    program: command[0].clone().into(),
1107                    arguments: command[1..].iter().map(OsString::from).collect(),
1108                    cwd: workspace.clone(),
1109                    environment: None,
1110                    captured_output: None,
1111                },
1112            },
1113        };
1114        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1115        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1116            .map_err(|error| error.to_string())?;
1117        let test_command_ms = elapsed_ms(test_started);
1118        if let Some(signal) = execution.interrupted_signal {
1119            return Err(format!(
1120                "the test command was interrupted by {signal:?}; no run was published"
1121            ));
1122        }
1123        let exit_code = execution.exit_code;
1124
1125        let publication_started = Instant::now();
1126        // One JVM per module, so one evidence file per module.
1127        let mut parts = Vec::new();
1128        let mut outcomes = Vec::new();
1129        let mut silent = Vec::new();
1130        for module in instrumented
1131            .modules
1132            .iter()
1133            .filter(|module| module.has_tests)
1134        {
1135            // Every JVM the build forked for this module wrote its own file.
1136            let mut written = fs::read_dir(&module.evidence)
1137                .map(|entries| {
1138                    entries
1139                        .flatten()
1140                        .map(|entry| entry.path())
1141                        .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1142                        .collect::<Vec<_>>()
1143                })
1144                .unwrap_or_default();
1145            written.sort();
1146            if written.is_empty() {
1147                silent.push(module.directory.clone());
1148                continue;
1149            }
1150            let mut forked = Vec::new();
1151            for path in &written {
1152                let bytes =
1153                    fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1154                forked.push(
1155                    read_evidence(&bytes)
1156                        .map_err(|error| format!("{}: {error}", path.display()))?,
1157                );
1158            }
1159            let evidence = merge_evidence(forked);
1160            for test in &evidence.tests {
1161                outcomes.push(OwnedTestOutcome {
1162                    name: test.name.clone(),
1163                    runner: test.runner.clone(),
1164                    // The module is the unit that forked a JVM of its own, so
1165                    // it is what a worker identity means here. The class is
1166                    // already in the name the framework reported.
1167                    package: module.directory.clone(),
1168                    file: class_of(&test.name)
1169                        .and_then(|class| instrumented.declared_in.get(class))
1170                        .cloned(),
1171                    status: test.status.clone(),
1172                });
1173            }
1174            parts.push(evidence);
1175        }
1176        if !silent.is_empty() {
1177            writeln!(
1178                diagnostics,
1179                "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1180                silent.len(),
1181                silent.join(", ")
1182            )
1183            .map_err(|error| error.to_string())?;
1184        }
1185        if outcomes.is_empty() {
1186            return Err(format!(
1187                "the test run wrote no coverage evidence (the command exited {exit_code}). Supercov attributes through each framework's own lifecycle, so the suite has to run on the JUnit Platform or TestNG."
1188            ));
1189        }
1190        let evidence = merge_evidence(parts);
1191        let run = build_frontend_run(OwnedRunInputs {
1192            declaration: jvm_declaration(),
1193            environment: "jvm",
1194            manifest: &instrumented.project.manifest,
1195            probes: &instrumented.project.probes,
1196            evidence: &evidence,
1197            outcomes: &outcomes,
1198            run_id: &request.run_id,
1199            generated_at: &request.started_at,
1200            test_exit_code: exit_code,
1201            coverage_model: jvm_coverage_model(),
1202        })
1203        .map_err(|error| error.to_string())?;
1204        validate_frontend_report_request(&run.declaration, &run.request)
1205            .map_err(|error| error.to_string())?;
1206        let archive_path = work_directory.join("evidence.raw.gz");
1207        let raw = write_archive(
1208            crate::assertion_inputs::append(
1209                run.archive_entries().map_err(|error| error.to_string())?,
1210                &assertion_inputs,
1211            )?,
1212            &archive_path,
1213        )
1214        .map_err(|error| error.to_string())?;
1215        let evidence_publication_ms = elapsed_ms(publication_started);
1216
1217        let timings = RunTimings {
1218            initialization_ms,
1219            workspace_preparation_ms,
1220            adapter_setup_ms,
1221            instrumented_build_ms: 0.0,
1222            test_command_ms,
1223            evidence_publication_ms,
1224        };
1225        let metadata = RunMetadata {
1226            id: request.run_id.clone(),
1227            started_at: request.started_at.clone(),
1228            duration_ms: elapsed_ms(total_started),
1229            command: request.command.clone(),
1230            test_exit_code: Some(exit_code),
1231            integrity,
1232            raw_evidence: RawEvidenceMetadata {
1233                schema_version: raw.schema_version,
1234                format: raw.format.into(),
1235                file: raw.file.into(),
1236                files: raw.files,
1237                uncompressed_bytes: raw.uncompressed_bytes,
1238                compressed_bytes: raw.compressed_bytes,
1239            },
1240            isolated_build: Some(true),
1241            instrumented_build_cache: None,
1242            timings: Some(timings),
1243            merged: None,
1244            parents: None,
1245        };
1246        let run_directory =
1247            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1248        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1249        Ok(DirectJvmRunResult {
1250            run_id: request.run_id.clone(),
1251            run_directory,
1252            exit_code,
1253            tests: outcomes
1254                .iter()
1255                .map(|outcome| outcome.name.as_str())
1256                .collect::<BTreeSet<_>>()
1257                .len(),
1258            source_files: instrumented.project.instrumented.len(),
1259            modules: instrumented.modules.len(),
1260            build: instrumented.build,
1261            recovered_runs,
1262            metadata,
1263        })
1264    })();
1265    if result.is_err() {
1266        let _ = remove_stored_tree_deferred(&root, &work_directory);
1267    }
1268    let release = lock.release().map_err(|error| error.to_string());
1269    match (result, release) {
1270        (Ok(result), Ok(())) => Ok(result),
1271        (Err(error), _) => Err(error),
1272        (Ok(_), Err(error)) => Err(error),
1273    }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278    use super::*;
1279
1280    #[test]
1281    fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1282        // Two tests running at once share one probe array, so neither can be
1283        // credited with what it reached. The runtime notices and says so; this
1284        // is the half that means it never has to.
1285        let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1286                        junit.jupiter.execution.parallel.enabled=true\n\
1287                        junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1288        let updated = sequential_properties(Some(existing));
1289        assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1290        assert!(
1291            !updated.contains("parallel.enabled=true"),
1292            "the project's own setting must not survive:\n{updated}"
1293        );
1294        // Everything the project chose for reasons of its own stays.
1295        assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1296        assert!(updated.contains("displayname.generator.default"));
1297
1298        // And a project with no properties at all still gets the setting.
1299        assert!(
1300            sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1301        );
1302    }
1303
1304    #[test]
1305    fn gradle_is_told_to_run_the_tests_again() {
1306        // Gradle skips a test task it considers up to date, and a task that
1307        // does not run records nothing.
1308        let (command, note) = command_with_fresh_results(
1309            JvmBuild::Gradle,
1310            &["./gradlew".to_owned(), "test".to_owned()],
1311        );
1312        assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1313        assert!(note.is_some());
1314
1315        // An author who already said so means it.
1316        let (command, note) = command_with_fresh_results(
1317            JvmBuild::Gradle,
1318            &[
1319                "./gradlew".to_owned(),
1320                "test".to_owned(),
1321                "--rerun".to_owned(),
1322            ],
1323        );
1324        assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1325        assert!(note.is_none());
1326
1327        // Maven's lifecycle re-runs surefire every time; nothing to defeat.
1328        let (command, note) =
1329            command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1330        assert_eq!(command, ["mvn", "test"]);
1331        assert!(note.is_none());
1332    }
1333
1334    #[test]
1335    fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1336        // A Windows path is full of backslashes, and one of them landing
1337        // before a `t` or a `"` would change the path or end the literal.
1338        let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1339        assert!(
1340            configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1341            "{configuration}"
1342        );
1343        assert!(configuration.contains("PROBES = 7"));
1344        assert!(configuration.contains("new int[] {2, 3}"));
1345    }
1346
1347    #[test]
1348    fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1349        // JUnit reports `CalculatorTest#zeroIsNamed()`; a DSL framework
1350        // reports its own wording under the same class.
1351        assert_eq!(
1352            class_of("CalculatorTest#zeroIsNamed()"),
1353            Some("CalculatorTest")
1354        );
1355        assert_eq!(
1356            class_of("CalculatorSpec#a sum adds its parts"),
1357            Some("CalculatorSpec")
1358        );
1359        assert_eq!(class_of("Standalone"), Some("Standalone"));
1360        assert_eq!(class_of(""), None);
1361    }
1362
1363    #[test]
1364    fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1365        // JUnit numbers the platform 1.N alongside Jupiter 5.N, so a pinned
1366        // Jupiter says exactly which launcher agrees with the engine that will
1367        // run. Guessing instead could pair a launcher with an engine it does
1368        // not understand.
1369        assert_eq!(
1370            launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1371                .as_deref(),
1372            Some("1.10.2")
1373        );
1374        assert_eq!(
1375            launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1376                .as_deref(),
1377            Some("1.13.1")
1378        );
1379        // A project using the BOM has already said how every JUnit artifact is
1380        // versioned; naming one would override the answer it gave.
1381        assert_eq!(
1382            launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1383            None
1384        );
1385        // And one that says nothing gets a launcher new enough to drive an
1386        // older engine, which is the direction that works.
1387        assert_eq!(
1388            launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1389            Some(DEFAULT_LAUNCHER_VERSION)
1390        );
1391        // Including a file that names no JUnit artifact at all -- an
1392        // aggregating parent, say, whose children each declare their own.
1393        // Reading that as "a BOM manages it" writes a versionless dependency
1394        // into a pom with no BOM to resolve it, and the build stops before a
1395        // single test runs.
1396        assert_eq!(
1397            launcher_version("<artifactId>parent</artifactId>").as_deref(),
1398            Some(DEFAULT_LAUNCHER_VERSION)
1399        );
1400        assert_eq!(
1401            launcher_version("").as_deref(),
1402            Some(DEFAULT_LAUNCHER_VERSION)
1403        );
1404    }
1405
1406    #[test]
1407    fn the_launcher_is_added_once_and_only_where_it_is_missing() {
1408        let pom = "<project>\n  <dependencies>\n    <dependency>\n      <groupId>org.junit.jupiter</groupId>\n      <artifactId>junit-jupiter</artifactId>\n      <version>5.10.2</version>\n      <scope>test</scope>\n    </dependency>\n  </dependencies>\n</project>\n";
1409        let updated = maven_with_launcher(pom).expect("the launcher is missing");
1410        assert!(updated.contains("junit-platform-launcher"), "{updated}");
1411        assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
1412        // Inside the existing block, not after it.
1413        assert!(
1414            updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
1415            "{updated}"
1416        );
1417        // A project that already has it is left exactly as it is.
1418        assert_eq!(maven_with_launcher(&updated), None);
1419
1420        // And one with no dependencies block at all still gets a valid pom.
1421        let bare = "<project>\n  <artifactId>demo</artifactId>\n</project>\n";
1422        let updated = maven_with_launcher(bare).expect("a block is created");
1423        assert!(updated.contains("<dependencies>"), "{updated}");
1424        assert!(
1425            updated.find("</dependencies>") < updated.find("</project>"),
1426            "{updated}"
1427        );
1428    }
1429
1430    #[test]
1431    fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
1432        let groovy =
1433            "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
1434        let updated = gradle_with_launcher(groovy, false).expect("the launcher is missing");
1435        assert!(
1436            updated
1437                .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
1438            "{updated}"
1439        );
1440        // The project's own block survives: Gradle merges what we append.
1441        assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
1442        assert_eq!(gradle_with_launcher(&updated, false), None);
1443
1444        // Gradle 9 makes every project declare the launcher, and the
1445        // configuration its own documentation recommends is testRuntimeOnly,
1446        // which the tests run with but do not compile against. A project
1447        // following that advice has the artifact and still cannot compile a
1448        // listener, so it gets a compile-visible declaration alongside.
1449        let runtime_only = "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
1450        let updated = gradle_with_launcher(runtime_only, false)
1451            .expect("a runtime-only declaration does not reach the compiler");
1452        assert!(
1453            updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
1454            "{updated}"
1455        );
1456        assert!(
1457            updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
1458            "the project's own declaration stays:\n{updated}"
1459        );
1460
1461        let kotlin = "dependencies {\n    testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
1462        let updated = gradle_with_launcher(kotlin, true).expect("the launcher is missing");
1463        assert!(
1464            updated.contains(
1465                "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
1466            ),
1467            "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
1468        );
1469        assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
1470    }
1471
1472    #[test]
1473    fn only_the_listeners_a_project_can_compile_are_written() {
1474        // Each listener is compiled from the project's own test sources, so
1475        // one whose framework is absent would fail on imports the project
1476        // never asked for.
1477        let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
1478        assert!(junit.platform && !junit.testng);
1479
1480        let testng = frameworks("<artifactId>testng</artifactId>");
1481        assert!(testng.testng && !testng.platform);
1482
1483        // A migration in progress runs both, and both listeners fire in one
1484        // JVM against one runtime.
1485        let both = frameworks("testng ... junit-jupiter");
1486        assert!(both.platform && both.testng);
1487
1488        // Kotest and Spock are platform engines, so the platform listener
1489        // reports their tests without either being named.
1490        assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
1491        assert!(frameworks("org.spockframework:spock-core").platform);
1492
1493        // And a project naming nothing recognisable gets the platform, which
1494        // is what nearly every JVM suite runs on.
1495        let unknown = frameworks("<artifactId>demo</artifactId>");
1496        assert!(unknown.platform && !unknown.testng);
1497    }
1498
1499    #[test]
1500    fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
1501        // Surefire picks its provider from what is on the classpath. Adding
1502        // the platform launcher to a JUnit 4 project makes it choose the
1503        // platform provider, find no engine there, and fail the suite --
1504        // Supercov breaking a build it was asked to measure. The word "junit"
1505        // appears in both, so the artifact is what tells them apart.
1506        let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
1507        assert!(four.junit4 && !four.platform && !four.testng);
1508
1509        let five = frameworks("<artifactId>junit-jupiter</artifactId>");
1510        assert!(five.platform && !five.junit4);
1511
1512        // Vintage runs JUnit 4 tests on the platform, so a project with both
1513        // is a platform project.
1514        let both = frameworks(
1515            "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
1516        );
1517        assert!(both.platform && !both.junit4);
1518
1519        // Gradle spells its dependencies differently and means the same.
1520        assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
1521        assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
1522        assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
1523    }
1524
1525    #[test]
1526    fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
1527        // gson's test-jpms declares `module com.google.gson.jpms_test`, which
1528        // requires com.google.gson, junit and truth and nothing else. A
1529        // listener written into it imports org.junit.platform -- not visible
1530        // -- and reads a runtime from a package the main module does not
1531        // export. The module stops compiling and takes the reactor with it,
1532        // for tests that check module boundaries rather than product logic.
1533        let root = std::env::temp_dir().join(format!(
1534            "supercov-jpms-{}-{}",
1535            std::process::id(),
1536            std::time::SystemTime::now()
1537                .duration_since(std::time::UNIX_EPOCH)
1538                .unwrap()
1539                .as_nanos()
1540        ));
1541        write(&root.join("pom.xml"), "<project>\n  <modules>\n    <module>lib</module>\n    <module>boundaries</module>\n  </modules>\n  <dependencies>\n    <dependency>\n      <groupId>org.junit.jupiter</groupId>\n      <artifactId>junit-jupiter</artifactId>\n    </dependency>\n  </dependencies>\n</project>\n").unwrap();
1542        for module in ["lib", "boundaries"] {
1543            write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
1544            write(
1545                &root.join(module).join("src/main/java/app/Api.java"),
1546                "package app;\npublic class Api { public int one() { return 1; } }\n",
1547            )
1548            .unwrap();
1549            write(
1550                &root.join(module).join("src/test/java/app/ApiTest.java"),
1551                "package app;\nclass ApiTest { void t() {} }\n",
1552            )
1553            .unwrap();
1554        }
1555        write(
1556            &root.join("boundaries/src/test/java/module-info.java"),
1557            "module app.boundaries {\n  requires app.lib;\n}\n",
1558        )
1559        .unwrap();
1560
1561        let evidence = root.join("evidence");
1562        let instrumented = instrument_workspace(&root, &evidence).expect("instrument");
1563        assert_eq!(instrumented.modular, ["boundaries"]);
1564
1565        // The listener goes into the ordinary module and not the named one.
1566        assert!(
1567            root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
1568                .exists()
1569        );
1570        for name in ["SupercovListener.java", "SupercovConfig.java"] {
1571            assert!(
1572                !root
1573                    .join("boundaries/src/test/java/com/supercorp/supercov")
1574                    .join(name)
1575                    .exists(),
1576                "{name} must not be written into a named module"
1577            );
1578        }
1579        assert!(
1580            !root
1581                .join("boundaries/src/test/resources")
1582                .join(SERVICES_FILE)
1583                .exists(),
1584            "and nothing registers a listener that is not there"
1585        );
1586        // Its product code is still instrumented, and the runtime it stores
1587        // into is a package of the module's own -- which a named module may
1588        // hold without exporting.
1589        assert!(
1590            root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
1591                .exists()
1592        );
1593
1594        fs::remove_dir_all(root).ok();
1595    }
1596
1597    #[test]
1598    fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
1599        // moshi is JUnit 4 and writes `testImplementation(libs.junit)`. Read
1600        // without the catalog the build file names no framework at all, the
1601        // fallback takes it for a platform project, and a listener goes in
1602        // that nothing will ever call: the suite passes and records nothing.
1603        let root = std::env::temp_dir().join(format!(
1604            "supercov-catalog-{}-{}",
1605            std::process::id(),
1606            std::time::SystemTime::now()
1607                .duration_since(std::time::UNIX_EPOCH)
1608                .unwrap()
1609                .as_nanos()
1610        ));
1611        write(
1612            &root.join("gradle/libs.versions.toml"),
1613            "[versions]\nkotlin = \"2.0.0\"\n\n[libraries]\njunit = \"junit:junit:4.13.2\"\nkotlin-reflect = { module = \"org.jetbrains.kotlin:kotlin-reflect\", version.ref = \"kotlin\" }\njupiter = { group = \"org.junit.jupiter\", name = \"junit-jupiter\" }\n\n[bundles]\nunit = [\"junit\", \"kotlin-reflect\"]\n",
1614        )
1615        .unwrap();
1616        let catalog = version_catalog(&root);
1617
1618        let junit4 = frameworks(&with_catalog(
1619            "dependencies { testImplementation(libs.junit) }",
1620            &catalog,
1621        ));
1622        assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
1623
1624        // An accessor is a prefix of a longer one, and must not answer for it.
1625        let reflect = frameworks(&with_catalog(
1626            "dependencies { testImplementation(libs.kotlin.reflect) }",
1627            &catalog,
1628        ));
1629        assert!(!reflect.junit4, "{reflect:?}");
1630
1631        let jupiter = frameworks(&with_catalog(
1632            "dependencies { testImplementation(libs.jupiter) }",
1633            &catalog,
1634        ));
1635        assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
1636
1637        // A bundle stands for every library in it.
1638        let bundle = frameworks(&with_catalog(
1639            "dependencies { testImplementation(libs.bundles.unit) }",
1640            &catalog,
1641        ));
1642        assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
1643
1644        // A build that names no accessor is unchanged by a catalog.
1645        let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
1646        assert_eq!(with_catalog(plain, &catalog), plain);
1647
1648        fs::remove_dir_all(root).ok();
1649    }
1650
1651    #[test]
1652    fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
1653        // A pom's <dependencyManagement> holds a <dependencies> too, and so
1654        // does every profile and plugin. A dependency added there is a version
1655        // for something else to ask for rather than something the project
1656        // depends on: the module compiles exactly as before and the listener
1657        // still cannot find the API it implements.
1658        let pom = "<project>\n  <dependencyManagement>\n    <dependencies>\n      <dependency>\n        <groupId>org.junit</groupId>\n        <artifactId>junit-bom</artifactId>\n        <version>5.10.2</version>\n      </dependency>\n    </dependencies>\n  </dependencyManagement>\n  <dependencies>\n    <dependency>\n      <groupId>org.junit.jupiter</groupId>\n      <artifactId>junit-jupiter</artifactId>\n    </dependency>\n  </dependencies>\n  <build>\n    <plugins>\n      <plugin>\n        <dependencies>\n          <dependency><groupId>x</groupId></dependency>\n        </dependencies>\n      </plugin>\n    </plugins>\n  </build>\n</project>\n";
1659        let updated = maven_with_launcher(pom).expect("the launcher is missing");
1660        let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
1661        let managed_end = updated
1662            .find("</dependencyManagement>")
1663            .expect("managed block");
1664        let build_start = updated.find("<build>").expect("build block");
1665        assert!(
1666            at > managed_end,
1667            "not among the managed versions:\n{updated}"
1668        );
1669        assert!(at < build_start, "nor among a plugin's own:\n{updated}");
1670        // The BOM manages every JUnit artifact, so naming a version would
1671        // override the answer the project already gave.
1672        assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
1673    }
1674
1675    #[test]
1676    fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
1677        // A project is free to fail its build on any warning, and good ones
1678        // do. The copy holds code that project never wrote and never agreed a
1679        // style for -- and Error Prone goes further, failing at error severity
1680        // on rules about the shape of a method that no instrumentation can
1681        // satisfy.
1682        let pom = "<project>\n  <failOnWarning>true</failOnWarning>\n  <compilerArgs>\n    <arg>-XDcompilePolicy=simple</arg>\n    <arg>-Xplugin:ErrorProne\n      -Xep:NotJavadoc:OFF\n    </arg>\n  </compilerArgs>\n</project>\n";
1683        let updated = without_warnings_as_errors(pom).expect("a policy to relax");
1684        assert!(
1685            updated.contains("<failOnWarning>false</failOnWarning>"),
1686            "{updated}"
1687        );
1688        assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
1689        // Only the escalation goes: the compiler still compiles what it would
1690        // have, and everything else the project configured is untouched.
1691        assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
1692
1693        // A project with no such policy is left exactly as it is.
1694        assert_eq!(without_warnings_as_errors("<project></project>"), None);
1695
1696        // Failing on warnings and running a static analyser are separate
1697        // things, and a project may do either without the other.
1698        let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
1699        let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
1700        assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
1701
1702        let only_analyser =
1703            "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
1704        let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
1705        assert!(!updated.contains("ErrorProne"), "{updated}");
1706    }
1707
1708    #[test]
1709    fn vintage_carries_the_engine_version_not_the_platform_one() {
1710        // JUnit numbers the engines 5.N and the platform 1.N, so asking for
1711        // vintage 1.N asks for something that was never published and the
1712        // build stops at dependency resolution.
1713        assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
1714        assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
1715
1716        let pom = "<project>\n  <dependencies>\n    <dependency>\n      <groupId>junit</groupId>\n      <artifactId>junit</artifactId>\n      <version>4.13.2</version>\n    </dependency>\n  </dependencies>\n</project>\n";
1717        let updated = maven_with_vintage(pom).expect("a JUnit 4 project needs both");
1718        assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
1719        assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
1720        assert!(
1721            updated.contains("<groupId>org.junit.vintage</groupId>"),
1722            "{updated}"
1723        );
1724        // The launcher takes the platform version and the engine the Jupiter
1725        // one, in the same pom.
1726        assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
1727        assert!(
1728            updated.contains(&format!(
1729                "<version>{}</version>",
1730                engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
1731            )),
1732            "{updated}"
1733        );
1734
1735        // A project that already has both is left alone.
1736        assert_eq!(maven_with_vintage(&updated), None);
1737    }
1738}