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, note_kept_evidence, publish_run,
43        recover_abandoned_runs, 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/// The modules a test command names, by directory, when it names any:
204/// Gradle's project-qualified tasks (`:moshi:test`), Maven's `-pl a,b`.
205/// Nothing when the command runs every module, as `gradle test` and
206/// `mvn test` do.
207fn named_modules(build: JvmBuild, command: &[String]) -> Option<BTreeSet<String>> {
208    let arguments = command.get(1..).unwrap_or_default();
209    match build {
210        JvmBuild::Gradle => {
211            let mut named = BTreeSet::new();
212            let mut skip = false;
213            for argument in arguments {
214                if std::mem::take(&mut skip) {
215                    continue;
216                }
217                if argument.starts_with('-') {
218                    // Flags whose value is the next argument; `-x :a:test`
219                    // excludes a task rather than asking for it.
220                    skip = matches!(
221                        argument.as_str(),
222                        "-x" | "--exclude-task"
223                            | "--tests"
224                            | "-p"
225                            | "--project-dir"
226                            | "-c"
227                            | "--settings-file"
228                            | "-I"
229                            | "--init-script"
230                            | "-g"
231                            | "--gradle-user-home"
232                    );
233                    continue;
234                }
235                // An unqualified task runs in every project; `:test` is the
236                // root project's own.
237                let qualified = argument.strip_prefix(':')?;
238                let project = qualified.rsplit_once(':').map_or("", |(p, _)| p);
239                named.insert(if project.is_empty() {
240                    ".".to_owned()
241                } else {
242                    project.replace(':', "/")
243                });
244            }
245            (!named.is_empty()).then_some(named)
246        }
247        JvmBuild::Maven => {
248            let at = arguments
249                .iter()
250                .position(|a| a == "-pl" || a == "--projects")?;
251            let list = arguments.get(at + 1)?;
252            let named = list
253                .split(',')
254                .map(|m| {
255                    m.trim()
256                        .trim_start_matches("./")
257                        .trim_end_matches('/')
258                        .to_owned()
259                })
260                .collect::<BTreeSet<_>>();
261            // `:artifactId` names a module by artifact, not by directory.
262            (!named
263                .iter()
264                .any(|m| m.starts_with(':') || m.starts_with('!')))
265            .then_some(named)
266        }
267        JvmBuild::Plain => None,
268    }
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
272#[serde(rename_all = "camelCase", deny_unknown_fields)]
273pub struct DirectJvmRunRequest {
274    pub root: PathBuf,
275    pub command: Vec<String>,
276    pub run_id: String,
277    pub started_at: String,
278    /// Credit every test with what it reached, by running the suite in order.
279    ///
280    /// Attribution is a sweep of one shared probe array at each test boundary,
281    /// so two tests running at once cannot both be credited. Turning JUnit's
282    /// parallel execution off is what buys that, and what it costs is the
283    /// suite's own parallelism -- 3.6x on sixteen I/O-bound tests across
284    /// fifteen cores. Supercov used to spend that without being asked.
285    #[serde(default)]
286    pub exact_attribution: bool,
287}
288
289#[derive(Debug, Clone, PartialEq)]
290pub struct DirectJvmRunResult {
291    pub run_id: String,
292    pub run_directory: PathBuf,
293    pub exit_code: i32,
294    pub tests: usize,
295    pub source_files: usize,
296    pub modules: usize,
297    pub build: JvmBuild,
298    pub recovered_runs: Vec<String>,
299    pub metadata: RunMetadata,
300}
301
302fn elapsed_ms(started: Instant) -> f64 {
303    (started.elapsed().as_secs_f64() * 10_000.0).round() / 10.0
304}
305
306fn write(path: &Path, contents: &str) -> Result<(), String> {
307    if let Some(parent) = path.parent() {
308        fs::create_dir_all(parent).map_err(|error| format!("{}: {error}", parent.display()))?;
309    }
310    fs::write(path, contents).map_err(|error| format!("{}: {error}", path.display()))
311}
312
313/// The source root Supercov's own Java lands in for a given source set.
314///
315/// Maven, Gradle and a plain tree have meant the same thing by these paths for
316/// long enough that the convention is more reliable than reading a build file,
317/// and a Kotlin project compiles Java from here too.
318fn source_root(source_set: &str) -> String {
319    format!("src/{source_set}/java")
320}
321
322/// Java source escaping for a string literal, so a Windows path or a name with
323/// a quote in it cannot end the literal early.
324fn java_literal(value: &str) -> String {
325    let mut out = String::with_capacity(value.len() + 2);
326    out.push('"');
327    for character in value.chars() {
328        match character {
329            '"' => out.push_str("\\\""),
330            '\\' => out.push_str("\\\\"),
331            '\n' => out.push_str("\\n"),
332            '\r' => out.push_str("\\r"),
333            other => out.push(other),
334        }
335    }
336    out.push('"');
337    out
338}
339
340/// The runtime, sized for this project.
341///
342/// A probe is a bare store into the array with nothing checking its bounds,
343/// which is what makes it cost one instruction. So the array has to be the
344/// right size before any product code runs, rather than only once a listener
345/// arms it: a run where no listener starts -- a suite in a language Supercov
346/// does not parse, a build that never reaches the test task -- would otherwise
347/// throw on the first instrumented line, and Supercov would have turned a
348/// passing suite into a failing one.
349fn runtime_source(probe_count: usize) -> String {
350    let marker = "static final int PROBE_COUNT = 0; // supercov:probe-count";
351    debug_assert!(
352        RUNTIME_SOURCE.contains(marker),
353        "the runtime no longer declares the probe count Supercov substitutes"
354    );
355    RUNTIME_SOURCE.replace(
356        marker,
357        &format!("static final int PROBE_COUNT = {probe_count}; // supercov:probe-count"),
358    )
359}
360
361/// What the listener reads to know the shape of the run it is recording.
362fn configuration(probe_count: usize, widths: &[u8], evidence: &Path) -> String {
363    let widths = widths
364        .iter()
365        .map(u8::to_string)
366        .collect::<Vec<_>>()
367        .join(", ");
368    format!(
369        "// 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",
370        java_literal(&evidence.to_string_lossy())
371    )
372}
373
374/// Whether the project's own configuration asked for parallel execution.
375///
376/// Only worth telling someone about when it was on: a project that never
377/// enabled it is not having anything taken away, and a line about every run
378/// is a line nobody reads.
379fn asks_for_parallel_execution(existing: Option<&str>) -> bool {
380    existing.unwrap_or("").lines().any(|line| {
381        let line = line.trim_start();
382        line.starts_with("junit.jupiter.execution.parallel.enabled")
383            && line
384                .split_once('=')
385                .is_some_and(|(_, value)| value.trim().eq_ignore_ascii_case("true"))
386    })
387}
388
389/// The project's JUnit Platform configuration with parallel execution turned
390/// off, keeping whatever else it already said.
391///
392/// Attribution is a sweep of one shared probe array at each test boundary, so
393/// two tests running at once cannot both be credited. The runtime notices and
394/// degrades honestly; this is the half that means it never has to.
395fn sequential_properties(existing: Option<&str>) -> String {
396    let mut out = String::new();
397    for line in existing.unwrap_or("").lines() {
398        if line
399            .trim_start()
400            .starts_with("junit.jupiter.execution.parallel.enabled")
401        {
402            continue;
403        }
404        out.push_str(line);
405        out.push('\n');
406    }
407    out.push_str(
408        "# Set by Supercov: probes are a store into one shared array, so two tests running\n\
409         # at once cannot both be credited with what they reached.\n\
410         junit.jupiter.execution.parallel.enabled=false\n",
411    );
412    out
413}
414
415/// The JUnit Platform artifact the listener is written against.
416///
417/// `junit-jupiter` does not bring it: Maven's surefire and Gradle's test task
418/// both put it on the test *runtime* classpath because they need it
419/// themselves, but neither offers it at compile time, so a listener declared
420/// in the project's own test sources will not compile without it. Supercov
421/// owns the workspace copy, so it adds the dependency there — the same line a
422/// person would add, in a tree the author never sees.
423const LAUNCHER_ARTIFACT: &str = "junit-platform-launcher";
424
425/// The launcher version to ask for.
426///
427/// JUnit numbers the platform 1.N alongside Jupiter 5.N, so a project that
428/// pins its Jupiter version tells us exactly which launcher agrees with the
429/// engine it will run on. A project using the BOM has already said how to
430/// version every JUnit artifact, and naming a version there would override the
431/// answer it gave.
432fn launcher_version(build_file: &str) -> Option<String> {
433    // The one case where naming no version is right: a project using the BOM
434    // has already said how every JUnit artifact is versioned, and naming one
435    // would override the answer it gave. Every other path must produce a
436    // version -- a versionless dependency in a pom with no BOM to manage it is
437    // one Maven cannot resolve, and the build stops before any test runs.
438    if build_file.contains("junit-bom") {
439        return None;
440    }
441    Some(jupiter_version(build_file).unwrap_or_else(|| DEFAULT_LAUNCHER_VERSION.to_owned()))
442}
443
444/// The launcher version for a Maven module, read through its parents.
445fn maven_launcher_version(directory: &Path, pom: &str) -> Option<String> {
446    let effective = maven_effective(directory, pom);
447    if effective.contains("junit-bom") {
448        return None;
449    }
450    if let Some(version) = jupiter_version(&effective) {
451        return Some(version);
452    }
453    // A Jupiter the pom names without a version, under a parent that could
454    // not be read (not yet downloaded): the parent manages it, and a pinned
455    // guess is what broke commons-cli.
456    let versionless = pom.contains("<artifactId>junit-jupiter") && jupiter_version(pom).is_none();
457    if pom.contains("<parent>") && versionless {
458        return None;
459    }
460    Some(DEFAULT_LAUNCHER_VERSION.to_owned())
461}
462
463/// The platform version matching whatever Jupiter this build pins, if it pins
464/// one. JUnit numbered the platform 1.N alongside Jupiter 5.N; from JUnit 6
465/// every artifact shares one version.
466fn jupiter_version(build_file: &str) -> Option<String> {
467    build_file
468        .match_indices("junit-jupiter")
469        .find_map(|(at, _)| {
470            let rest = &build_file[at..];
471            // Gradle: `org.junit.jupiter:junit-jupiter-api:5.13.1`.
472            let gradle = rest
473                .split_once(':')
474                .filter(|(artifact, _)| !artifact.contains(char::is_whitespace))
475                .map(|(_, tail)| tail);
476            // Maven: the `<version>` of the same `<dependency>`.
477            let maven = rest
478                .split("</dependency>")
479                .next()
480                .and_then(|dependency| dependency.split_once("<version>"))
481                .map(|(_, tail)| tail);
482            [gradle, maven]
483                .into_iter()
484                .flatten()
485                .find_map(platform_of_jupiter)
486        })
487}
488
489/// `5.13.1` -> `1.13.1`; `6.0.1` -> `6.0.1`; anything else (a property left
490/// unresolved, say) -> nothing.
491fn platform_of_jupiter(text: &str) -> Option<String> {
492    let version = text
493        .chars()
494        .take_while(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
495        .collect::<String>();
496    let mut parts = version.splitn(3, '.');
497    let (major, minor, patch) = (parts.next()?, parts.next()?, parts.next()?);
498    if minor.is_empty() || patch.is_empty() || !minor.chars().all(|c| c.is_ascii_digit()) {
499        return None;
500    }
501    match major {
502        "5" => Some(format!("1.{minor}.{patch}")),
503        m if m.parse::<u32>().is_ok_and(|m| m >= 6) => Some(version),
504        _ => None,
505    }
506}
507
508/// A pom as Maven will read it for JUnit's version: its own text, then each
509/// parent's (from its `relativePath`, else the local repository), with
510/// `${properties}` replaced by the nearest definition.
511///
512/// commons-parent declares JUnit for every Apache Commons project through a
513/// BOM, and the project's own pom names no version at all. Read alone, that
514/// pom looks unversioned, and the launcher added for it disagreed with the
515/// engine the build actually resolved: JUnit refused to discover a single test.
516fn maven_effective(directory: &Path, pom: &str) -> String {
517    let mut chain = vec![pom.to_owned()];
518    let mut here = directory.to_path_buf();
519    let mut current = pom.to_owned();
520    for _ in 0..10 {
521        let Some(parent) = xml_block(&current, "parent") else {
522            break;
523        };
524        let field = |name: &str| xml_block(&parent, name).map(|v| v.trim().to_owned());
525        let (Some(artifact), Some(version)) = (field("artifactId"), field("version")) else {
526            break;
527        };
528        let group = field("groupId").unwrap_or_default();
529        let relative = field("relativePath").unwrap_or_else(|| "../pom.xml".into());
530        let mut on_disk = here.join(&relative);
531        if on_disk.is_dir() {
532            on_disk = on_disk.join("pom.xml");
533        }
534        let local = fs::read_to_string(&on_disk)
535            .ok()
536            .filter(|text| xml_block(text, "artifactId").is_some_and(|a| a.trim() == artifact));
537        let (text, next) = match local {
538            Some(text) => (text, on_disk.parent().map(Path::to_path_buf)),
539            None => match local_repository().map(|repo| {
540                repo.join(group.replace('.', "/"))
541                    .join(&artifact)
542                    .join(&version)
543                    .join(format!("{artifact}-{version}.pom"))
544            }) {
545                Some(path) => match fs::read_to_string(&path) {
546                    Ok(text) => (text, path.parent().map(Path::to_path_buf)),
547                    Err(_) => break,
548                },
549                None => break,
550            },
551        };
552        chain.push(text.clone());
553        current = text;
554        match next {
555            Some(next) => here = next,
556            None => break,
557        }
558    }
559    let mut properties = BTreeMap::new();
560    for text in &chain {
561        if let Some(block) = xml_block(text, "properties") {
562            for (name, value) in xml_children(&block) {
563                properties.entry(name).or_insert(value);
564            }
565        }
566        for (name, value) in [("project.version", xml_block(text, "version"))] {
567            if let Some(value) = value {
568                properties
569                    .entry(name.to_owned())
570                    .or_insert(value.trim().to_owned());
571            }
572        }
573    }
574    let mut out = chain.join("\n");
575    for _ in 0..3 {
576        for (name, value) in &properties {
577            out = out.replace(&format!("${{{name}}}"), value);
578        }
579    }
580    out
581}
582
583/// The text inside the first `<name>...</name>`.
584fn xml_block(text: &str, name: &str) -> Option<String> {
585    let open = format!("<{name}>");
586    let start = text.find(&open)? + open.len();
587    let end = text[start..].find(&format!("</{name}>"))?;
588    Some(text[start..start + end].to_owned())
589}
590
591/// The simple `<name>value</name>` children of a block.
592fn xml_children(block: &str) -> Vec<(String, String)> {
593    let mut out = Vec::new();
594    let mut rest = block;
595    while let Some(open) = rest.find('<') {
596        rest = &rest[open + 1..];
597        let Some(close) = rest.find('>') else { break };
598        let name = &rest[..close];
599        if name.starts_with(['/', '!', '?']) || name.contains(char::is_whitespace) {
600            rest = &rest[close + 1..];
601            continue;
602        }
603        let end_tag = format!("</{name}>");
604        let body = &rest[close + 1..];
605        let Some(end) = body.find(&end_tag) else {
606            break;
607        };
608        out.push((name.to_owned(), body[..end].trim().to_owned()));
609        rest = &body[end + end_tag.len()..];
610    }
611    out
612}
613
614/// Maven's local repository: `settings.xml`'s `localRepository`, else
615/// `~/.m2/repository`.
616fn local_repository() -> Option<PathBuf> {
617    let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))?;
618    let m2 = PathBuf::from(home).join(".m2");
619    let configured = fs::read_to_string(m2.join("settings.xml"))
620        .ok()
621        .and_then(|text| xml_block(&text, "localRepository"))
622        .map(|p| PathBuf::from(p.trim()));
623    Some(configured.unwrap_or_else(|| m2.join("repository")))
624}
625
626const DEFAULT_LAUNCHER_VERSION: &str = "1.10.2";
627
628/// The engine version matching a platform version: 1.N.P becomes 5.N.P.
629fn engine_version_of_platform(platform: &str) -> String {
630    // From JUnit 6 the platform and the engines share one version.
631    match platform.strip_prefix("1.") {
632        Some(rest) => format!("5.{rest}"),
633        None => platform.to_owned(),
634    }
635}
636
637/// `pom.xml` with the launcher among its test dependencies.
638fn maven_with_launcher(pom: &str, platform: Option<String>) -> Option<String> {
639    maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT], platform)
640}
641
642/// The engine that runs JUnit 4 tests on the JUnit Platform.
643///
644/// A JUnit 4 suite is not a platform one and cannot be attributed as it
645/// stands. Vintage is the platform's own answer: it discovers and runs exactly
646/// the same JUnit 4 tests, through the lifecycle Supercov listens to. Adding
647/// it to the copy turns a suite Supercov could only decline into one it can
648/// measure, and the author's own build still runs JUnit 4 as before.
649const VINTAGE_ARTIFACT: &str = "junit-vintage-engine";
650
651fn maven_with_vintage(pom: &str, platform: Option<String>) -> Option<String> {
652    maven_with_test_artifacts(pom, &[LAUNCHER_ARTIFACT, VINTAGE_ARTIFACT], platform)
653}
654
655fn maven_with_test_artifacts(
656    pom: &str,
657    artifacts: &[&str],
658    platform: Option<String>,
659) -> Option<String> {
660    let missing = artifacts
661        .iter()
662        .filter(|artifact| !pom.contains(**artifact))
663        .collect::<Vec<_>>();
664    if missing.is_empty() {
665        return None;
666    }
667    let dependency = missing
668        .iter()
669        .map(|artifact| {
670            // Vintage is versioned with Jupiter, not with the platform: JUnit
671            // numbers the engines 5.N and the platform 1.N, and asking for
672            // vintage 1.N asks for something that was never published.
673            let (group, version) = if **artifact == VINTAGE_ARTIFACT {
674                (
675                    "org.junit.vintage",
676                    platform.as_deref().map(engine_version_of_platform),
677                )
678            } else {
679                ("org.junit.platform", platform.clone())
680            };
681            let version = version
682                .map(|version| format!("\n      <version>{version}</version>"))
683                .unwrap_or_default();
684            format!(
685                "    <dependency>\n      <groupId>{group}</groupId>\n      <artifactId>{artifact}</artifactId>{version}\n      <scope>test</scope>\n    </dependency>\n"
686            )
687        })
688        .collect::<String>();
689    match project_dependencies_end(pom) {
690        Some(at) => Some(format!("{}{dependency}{}", &pom[..at], &pom[at..])),
691        // A project with no dependencies block of its own still needs one.
692        None => pom.rfind("</project>").map(|at| {
693            format!(
694                "{}  <dependencies>\n{dependency}  </dependencies>\n{}",
695                &pom[..at],
696                &pom[at..]
697            )
698        }),
699    }
700}
701
702/// Where the project's own `<dependencies>` ends.
703///
704/// Not simply the last one. A pom's `<dependencyManagement>` holds a
705/// `<dependencies>` too, and so does every `<profile>` and every `<plugin>`;
706/// a dependency added inside `<dependencyManagement>` is a version for
707/// something else to ask for rather than something the project depends on, so
708/// the module compiles exactly as it did before and the listener still cannot
709/// find the API it implements. Depth is what tells them apart.
710fn project_dependencies_end(pom: &str) -> Option<usize> {
711    const NESTED: [&str; 4] = ["dependencyManagement", "profiles", "build", "reporting"];
712    let bytes = pom.as_bytes();
713    let mut depth = 0usize;
714    let mut at = 0usize;
715    while at < bytes.len() {
716        let Some(open) = pom[at..].find('<') else {
717            break;
718        };
719        let start = at + open;
720        let Some(close) = pom[start..].find('>') else {
721            break;
722        };
723        let tag = &pom[start + 1..start + close];
724        at = start + close + 1;
725        let name = tag.trim_start_matches('/').trim_end_matches('/').trim();
726        let name = name.split_whitespace().next().unwrap_or_default();
727        if NESTED.contains(&name) {
728            if tag.starts_with('/') {
729                depth = depth.saturating_sub(1);
730            } else if !tag.ends_with('/') {
731                depth += 1;
732            }
733        } else if name == "dependencies" && tag.starts_with('/') && depth == 0 {
734            return Some(start);
735        }
736    }
737    None
738}
739
740/// Whether a Gradle script already puts the launcher where test *sources* can
741/// see it.
742///
743/// Presence is not enough. Gradle 9 requires every project to declare the
744/// launcher itself, and the configuration its own documentation recommends is
745/// `testRuntimeOnly` — which puts the artifact on the classpath the tests run
746/// with and not the one they compile against. A project following that advice
747/// has the artifact and still cannot compile a listener, so the question is
748/// which configuration declares it, not whether one does.
749fn declares_launcher_for_compilation(build_file: &str) -> bool {
750    build_file.lines().any(|line| {
751        line.contains(LAUNCHER_ARTIFACT)
752            && ["testImplementation", "testCompileOnly", "testApi"]
753                .iter()
754                .any(|configuration| line.contains(configuration))
755    })
756}
757
758/// A Gradle build file with the launcher among its test dependencies, and
759/// every JUnit 4 project moved onto the JUnit Platform.
760///
761/// Appended as its own `allprojects` block rather than edited into the
762/// existing one: Gradle merges them, and finding the right brace in a Groovy
763/// or Kotlin script by hand is the kind of parsing that works until it does
764/// not.
765///
766/// `junit4` names the projects (by directory, relative to the root) whose
767/// tests are JUnit 4. Gradle runs those through a framework of its own, which
768/// no platform listener hears; Vintage runs exactly the same tests on the
769/// platform, as Maven's copy already gets. Only a task still on Gradle's JUnit
770/// 4 framework is switched, so one the build already put on the platform or on
771/// TestNG keeps it.
772fn gradle_with_launcher(
773    build_file: &str,
774    kotlin: bool,
775    catalog: &str,
776    junit4: &[String],
777) -> Option<String> {
778    let launcher = !declares_launcher_for_compilation(build_file);
779    if !launcher && junit4.is_empty() {
780        return None;
781    }
782    let platform = launcher_version(&format!("{build_file}\n{}", catalog_versions(catalog)));
783    let coordinate = |group: &str, artifact: &str, version: Option<String>| match version {
784        Some(version) => format!("{group}:{artifact}:{version}"),
785        None => format!("{group}:{artifact}"),
786    };
787    // In the Kotlin DSL the typed accessors do not exist inside
788    // `allprojects`, so the configurations are named as strings.
789    let dependency = |indent: &str, configuration: &str, coordinate: String| {
790        if kotlin {
791            format!("{indent}\"{configuration}\"(\"{coordinate}\")\n")
792        } else {
793            format!("{indent}{configuration} '{coordinate}'\n")
794        }
795    };
796    // `allprojects` rather than a bare `dependencies` block, because a
797    // multi-project build compiles each subproject's test sources against that
798    // subproject's own classpath and a declaration in the root reaches none of
799    // them. Guarded by the java plugin so a root that only aggregates -- which
800    // has no test source set and no configurations to add to -- is left alone.
801    let mut body = String::new();
802    if launcher {
803        body.push_str("        dependencies {\n");
804        body.push_str(&dependency(
805            "            ",
806            "testImplementation",
807            coordinate("org.junit.platform", LAUNCHER_ARTIFACT, platform.clone()),
808        ));
809        body.push_str("        }\n");
810    }
811    if !junit4.is_empty() {
812        // The root is `.` to Supercov and the empty path to Gradle.
813        let quote = |text: &str| {
814            let text = if text == "." { "" } else { text };
815            format!("\"{}\"", text.replace('\\', "/"))
816        };
817        let projects = junit4
818            .iter()
819            .map(|p| quote(p))
820            .collect::<Vec<_>>()
821            .join(", ");
822        // The project's directory relative to the root, with forward slashes
823        // on every OS: the same spelling `junit4` uses.
824        let relative =
825            "rootDir.toPath().relativize(projectDir.toPath()).toString().replace('\\\\', '/')";
826        let (list, test_tasks, still_junit4) = if kotlin {
827            (
828                format!("listOf({projects})"),
829                "tasks.withType<Test>().configureEach",
830                "options is JUnitOptions",
831            )
832        } else {
833            (
834                format!("[{projects}]"),
835                "tasks.withType(Test).configureEach",
836                "options instanceof JUnitOptions",
837            )
838        };
839        body.push_str(&format!("        if ({relative} in {list}) {{\n"));
840        body.push_str("            dependencies {\n");
841        // Vintage is versioned with Jupiter, not with the platform.
842        body.push_str(&dependency(
843            "                ",
844            "testRuntimeOnly",
845            coordinate(
846                "org.junit.vintage",
847                VINTAGE_ARTIFACT,
848                platform.as_deref().map(engine_version_of_platform),
849            ),
850        ));
851        body.push_str("            }\n");
852        body.push_str(&format!(
853            "            {test_tasks} {{\n                if ({still_junit4}) useJUnitPlatform()\n            }}\n"
854        ));
855        body.push_str("        }\n");
856    }
857    let plugin = if kotlin { "\"java\"" } else { "'java'" };
858    Some(format!(
859        "{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. JUnit 4 tests run on the platform through Vintage.\nallprojects {{\n    plugins.withId({plugin}) {{\n{body}    }}\n}}\n"
860    ))
861}
862
863/// A version catalog's JUnit entries in the form the build files use:
864/// `junit-bom` when it names the BOM, and
865/// `org.junit.jupiter:junit-jupiter:<version>` with `version.ref` resolved.
866fn catalog_versions(catalog: &str) -> String {
867    let versions = catalog
868        .split("[versions]")
869        .nth(1)
870        .map(|rest| rest.split("\n[").next().unwrap_or(""))
871        .unwrap_or("")
872        .lines()
873        .filter_map(|line| {
874            let (key, value) = line.split_once('=')?;
875            Some((
876                key.trim().to_owned(),
877                value.trim().trim_matches('"').to_owned(),
878            ))
879        })
880        .collect::<BTreeMap<_, _>>();
881    let mut out = String::new();
882    for line in catalog.lines().filter(|l| l.contains("org.junit")) {
883        if line.contains("junit-bom") {
884            out.push_str("junit-bom\n");
885        }
886        if !line.contains("org.junit.jupiter") {
887            continue;
888        }
889        let quoted = |key: &str| {
890            line.split(key)
891                .nth(1)
892                .and_then(|r| r.split('"').nth(1))
893                .map(str::to_owned)
894        };
895        let version = quoted("version.ref")
896            .and_then(|key| versions.get(&key).cloned())
897            .or_else(|| quoted("version ="))
898            .or_else(|| quoted("version="));
899        if let Some(version) = version {
900            out.push_str(&format!("org.junit.jupiter:junit-jupiter:{version}\n"));
901        }
902    }
903    out
904}
905
906/// Whether a Maven build, through its own pom or a parent's, runs Apache RAT.
907fn checks_license_headers(workspace: &Path) -> bool {
908    fs::read_to_string(workspace.join("pom.xml"))
909        .is_ok_and(|pom| maven_effective(workspace, &pom).contains("apache-rat"))
910}
911
912/// What relaxing one build file's warning policy changed, each said once
913/// however many build files changed it. Switching a static analyser off is
914/// named separately: it is a bigger thing than not failing on a warning, and
915/// the user should hear it said rather than work it out.
916fn note_relaxations(existing: &str, updated: &str, relaxed: &mut Vec<&'static str>) {
917    let fail_on_warning = "<failOnWarning>false</failOnWarning>";
918    let warnings = updated.contains(fail_on_warning) != existing.contains(fail_on_warning)
919        || existing.contains("-Werror") && !updated.contains("-Werror");
920    let error_prone =
921        existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne");
922    for (changed, note) in [
923        (warnings, "stopped the build failing on warnings"),
924        (error_prone, "switched Error Prone off"),
925    ] {
926        if changed && !relaxed.contains(&note) {
927            relaxed.push(note);
928        }
929    }
930}
931
932/// A build file with its warnings-as-errors policy relaxed.
933///
934/// A project is free to fail its build on any warning, and several good ones
935/// do. The instrumented copy contains code that project never wrote and never
936/// agreed a style for, so its own policy would reject it -- gson's Error Prone
937/// configuration rejects a fully-qualified name, and nothing Supercov can emit
938/// satisfies every such rule. The Rust frontend caps lints for the same reason
939/// and in the same place: the copy, never the tree the author keeps.
940///
941/// Only the escalation is removed. The warnings are still emitted, the
942/// compiler still compiles exactly what it would have, and the author's own
943/// build is untouched.
944fn without_warnings_as_errors(build_file: &str) -> Option<String> {
945    let mut updated = build_file.to_owned();
946    for (from, to) in [
947        (
948            "<failOnWarning>true</failOnWarning>",
949            "<failOnWarning>false</failOnWarning>",
950        ),
951        (
952            "<failOnWarnings>true</failOnWarnings>",
953            "<failOnWarnings>false</failOnWarnings>",
954        ),
955        ("<arg>-Werror</arg>", ""),
956        ("<compilerArgument>-Werror</compilerArgument>", ""),
957        ("options.compilerArgs << '-Werror'", ""),
958        ("allWarningsAsErrors = true", "allWarningsAsErrors = false"),
959    ] {
960        updated = updated.replace(from, to);
961    }
962    updated = without_error_prone(&updated);
963    (updated != build_file).then_some(updated)
964}
965
966/// The same build file with Error Prone switched off.
967///
968/// Relaxing warnings is not enough on its own: Error Prone has checks that
969/// fail at error severity, and some are about the shape of a method rather
970/// than its meaning — an `@InlineMe` method must hold exactly one statement,
971/// and a probe makes two. No instrumentation can satisfy a rule like that,
972/// because the rule is about source the author wrote and the copy holds source
973/// they did not.
974///
975/// Switching the analyser off in the copy costs nothing: it says nothing about
976/// whether the tests pass, and the author's own build still runs it in full.
977fn without_error_prone(build_file: &str) -> String {
978    let Some(start) = build_file.find("<arg>-Xplugin:ErrorProne") else {
979        return build_file.to_owned();
980    };
981    let Some(end) = build_file[start..].find("</arg>") else {
982        return build_file.to_owned();
983    };
984    let mut updated = build_file.to_owned();
985    updated.replace_range(start..start + end + "</arg>".len(), "");
986    updated
987}
988
989/// Make the build run its tests again rather than reporting a cached result.
990///
991/// A test that does not run records nothing, and a run that measured half a
992/// suite without saying so is worse than one that took longer.
993fn command_with_fresh_results(
994    build: JvmBuild,
995    command: &[String],
996) -> (Vec<String>, Option<String>) {
997    let mut updated = command.to_vec();
998    match build {
999        JvmBuild::Gradle => {
1000            if updated
1001                .iter()
1002                .any(|argument| argument == "--rerun-tasks" || argument == "--rerun")
1003            {
1004                return (updated, None);
1005            }
1006            updated.push("--rerun-tasks".into());
1007            (
1008                updated,
1009                Some(
1010                    "added --rerun-tasks: Gradle skips a test task it considers up to date, and a task that does not run records no coverage"
1011                        .into(),
1012                ),
1013            )
1014        }
1015        // Maven's default lifecycle re-runs surefire every time, and Gradle's
1016        // build cache has no Maven equivalent worth defeating here.
1017        JvmBuild::Maven | JvmBuild::Plain => (updated, None),
1018    }
1019}
1020
1021/// One module of the build, and where its evidence lands.
1022///
1023/// A single-project build has exactly one of these, rooted at the workspace.
1024/// A multi-module build has one per module, because each module compiles only
1025/// its own source set and forks its own JVM to run its tests: a runtime
1026/// written once at the top would be invisible to every module, and one
1027/// evidence path shared by every module's JVM would be overwritten by
1028/// whichever finished last.
1029#[derive(Debug, Clone)]
1030struct JvmModule {
1031    /// Relative to the workspace, `/`-separated; `.` for the build root.
1032    directory: String,
1033    /// Where this module's JVMs write. A directory rather than a file: a build
1034    /// may fork more than one to run tests in parallel, and each writes under
1035    /// a name of its own so none overwrites another.
1036    evidence: PathBuf,
1037    has_tests: bool,
1038}
1039
1040struct InstrumentedWorkspace {
1041    project: PreparedJvmProject,
1042    build: JvmBuild,
1043    modules: Vec<JvmModule>,
1044    /// Which file declared each test class, so a result can point at a source.
1045    declared_in: BTreeMap<String, String>,
1046    /// The build file the launcher dependency was added to, if it was.
1047    added_launcher: Option<&'static str>,
1048    /// Modules whose own configuration asked for parallel execution and had it
1049    /// turned off in the copy, so the user hears that their suite ran a
1050    /// different way rather than discovering it from a test that now hangs.
1051    serialised: BTreeSet<String>,
1052    /// Modules left running their tests at once, where what overlaps cannot be
1053    /// credited to either test that produced it.
1054    concurrent: BTreeSet<String>,
1055    /// Whether a JUnit 4 module was given the engine that runs it on the
1056    /// platform, so the user hears that their suite ran a different way.
1057    added_vintage: bool,
1058    /// What had to be relaxed in the copy's build files for instrumented code
1059    /// to compile, named so the user knows rather than infers.
1060    relaxed: Vec<&'static str>,
1061    /// The build checks every file for a license header (Apache RAT), and
1062    /// the copy holds Supercov's own runtime and listeners, which carry none.
1063    license_check: bool,
1064    /// Modules Supercov instrumented but cannot attribute, and why they were
1065    /// left without a listener rather than broken by one.
1066    unmeasurable: Vec<String>,
1067    /// Modules whose tests are a named JPMS module, which cannot take a listener.
1068    modular: Vec<String>,
1069}
1070
1071/// The module a `src/main/...` or `src/test/...` path belongs to.
1072///
1073/// The build root for a single-project build, and the subdirectory holding
1074/// that source set otherwise. Derived from the paths themselves rather than
1075/// from the build file, because Maven's `<modules>` and Gradle's
1076/// `settings.gradle` say the same thing in two languages and the layout says
1077/// it in one.
1078fn module_of(relative: &str) -> String {
1079    match relative.find("src/") {
1080        Some(0) | None => ".".to_owned(),
1081        Some(at) => relative[..at].trim_end_matches('/').to_owned(),
1082    }
1083}
1084
1085/// The class a test's reported name belongs to, as JUnit names it:
1086/// `CalculatorTest#zeroIsNamed()`, or a DSL framework's own wording.
1087fn class_of(test_name: &str) -> Option<&str> {
1088    test_name.split('#').next().filter(|name| !name.is_empty())
1089}
1090
1091/// The test file that declares a class the framework named in full
1092/// (`com.example.FooTest`, `com.example.FooTest$Nested`): the file whose path
1093/// ends in the class's package path, else the only one with its name.
1094///
1095/// Keyed on the bare file name alone, no reported class ever matched -- JUnit
1096/// names classes in full -- so no JVM test had a file, and `tests affected`
1097/// had no tests to answer about.
1098fn declaring_file(declared_in: &BTreeMap<String, String>, class: &str) -> Option<String> {
1099    let top = class.split('$').next().unwrap_or(class);
1100    let path = top.replace('.', "/");
1101    let simple = top.rsplit('.').next().unwrap_or(top);
1102    let by_path = declared_in.values().find(|file| {
1103        file.rsplit_once('.').is_some_and(|(stem, _)| {
1104            stem.ends_with(&path) && stem[..stem.len() - path.len()].ends_with('/') || stem == path
1105        })
1106    });
1107    by_path.or_else(|| declared_in.get(simple)).cloned()
1108}
1109
1110fn instrument_workspace(
1111    workspace: &Path,
1112    evidence_directory: &Path,
1113    exact_attribution: bool,
1114    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
1115) -> Result<InstrumentedWorkspace, String> {
1116    let project = prepare_jvm_project(workspace, roots)?;
1117    let build = detect_build(workspace);
1118
1119    for (relative, instrumented) in &project.instrumented {
1120        write(&workspace.join(relative), instrumented)?;
1121    }
1122
1123    let probe_count = project
1124        .probes
1125        .keys()
1126        .max()
1127        .map_or(0, |highest| *highest as usize + 1);
1128
1129    // Which frameworks a module runs is a question about that module. A
1130    // repository can hold a JUnit 4 module beside a JUnit 5 one, and asking
1131    // the whole tree at once answers neither: the platform listener would go
1132    // into the JUnit 4 module, where it cannot work, and the launcher with it,
1133    // where it makes the build choose a provider that finds no engine.
1134    let catalog = version_catalog(workspace);
1135    let frameworks_of = |directory: &str| -> Frameworks {
1136        let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
1137        let mut text = names
1138            .iter()
1139            .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
1140            .unwrap_or_default();
1141        for name in names {
1142            if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
1143                text.push('\n');
1144                text.push_str(&own);
1145                break;
1146            }
1147        }
1148        frameworks(&with_catalog(&text, &catalog))
1149    };
1150    let mut unmeasurable: Vec<String> = Vec::new();
1151    let mut modular: Vec<String> = Vec::new();
1152
1153    // One entry per module that has a main or a test source set, keyed by
1154    // directory so a module contributing both is listed once.
1155    let mut modules: BTreeMap<String, bool> = BTreeMap::new();
1156    for (relative, _) in &project.files.sources {
1157        modules.entry(module_of(relative)).or_insert(false);
1158    }
1159    for (relative, _) in &project.files.tests {
1160        *modules.entry(module_of(relative)).or_default() = true;
1161    }
1162    // A module's tests may be in a language Supercov does not parse -- Spock
1163    // writes them in Groovy, and Supercov measures the Java and Kotlin they
1164    // exercise rather than the specification itself. Those files are not in
1165    // `files.tests`, so the source sets are asked directly: a module judged to
1166    // have no tests gets no listener, and a run with no listener records
1167    // nothing at all.
1168    for module in modules.keys().cloned().collect::<Vec<_>>() {
1169        if workspace.join(&module).join("src/test").is_dir() {
1170            modules.insert(module, true);
1171        }
1172    }
1173    if modules.is_empty() {
1174        modules.insert(".".to_owned(), true);
1175    }
1176
1177    let modules = modules
1178        .into_iter()
1179        .map(|(directory, has_tests)| JvmModule {
1180            evidence: evidence_directory.join(
1181                directory
1182                    .chars()
1183                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1184                    .collect::<String>(),
1185            ),
1186            directory,
1187            has_tests,
1188        })
1189        .collect::<Vec<_>>();
1190
1191    for module in &modules {
1192        let frameworks = frameworks_of(&module.directory);
1193        let at = |source_set: &str| {
1194            workspace
1195                .join(&module.directory)
1196                .join(source_root(source_set))
1197                .join(PACKAGE_DIRECTORY)
1198        };
1199        // The runtime goes in every module's main source set: instrumented
1200        // product code stores into its array, and a module compiles only its
1201        // own sources. The class is identical everywhere, and each module's
1202        // tests fork a JVM that loads exactly one of them.
1203        write(
1204            &at("main").join("Supercov.java"),
1205            &runtime_source(probe_count),
1206        )?;
1207        if !module.has_tests {
1208            continue;
1209        }
1210        if frameworks.junit4 && build == JvmBuild::Plain {
1211            // Maven's and Gradle's copies get Vintage added below; a plain
1212            // tree has no build to add it to, so the module keeps its probes
1213            // and gets no listener, because attributing it is impossible and
1214            // trying would break it.
1215            unmeasurable.push(module.directory.clone());
1216            continue;
1217        }
1218        // A test source set with a module-info.java is a named JPMS module,
1219        // and a named module is closed: every package it holds is its own and
1220        // every dependency has to be declared in that file. A listener written
1221        // into it imports org.junit.platform, which the module does not
1222        // require, and reads the runtime from a package its main module does
1223        // not export -- so the module stops compiling and takes the build with
1224        // it. gson's test-jpms is exactly this, and it tests module boundaries
1225        // rather than product logic, so leaving it alone costs the report
1226        // nothing it could have had.
1227        if workspace
1228            .join(&module.directory)
1229            .join(source_root("test"))
1230            .join("module-info.java")
1231            .exists()
1232        {
1233            modular.push(module.directory.clone());
1234            continue;
1235        }
1236        // The listeners and their configuration go in the test source set,
1237        // because only the test classpath has the frameworks to listen to.
1238        let test = at("test");
1239        write(
1240            &test.join("SupercovConfig.java"),
1241            &configuration(probe_count, &project.decision_widths, &module.evidence),
1242        )?;
1243        if frameworks.platform || frameworks.junit4 {
1244            write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
1245        }
1246        if frameworks.testng {
1247            write(
1248                &test.join("SupercovTestNGListener.java"),
1249                TESTNG_LISTENER_SOURCE,
1250            )?;
1251        }
1252    }
1253
1254    // A project's warning policy applies to code it wrote. The copy holds code
1255    // it did not.
1256    let mut relaxed: Vec<&'static str> = Vec::new();
1257    for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
1258        let path = workspace.join(name);
1259        let Ok(existing) = fs::read_to_string(&path) else {
1260            continue;
1261        };
1262        let Some(updated) = without_warnings_as_errors(&existing) else {
1263            continue;
1264        };
1265        note_relaxations(&existing, &updated, &mut relaxed);
1266        write(&path, &updated)?;
1267    }
1268    let license_check = build == JvmBuild::Maven && checks_license_headers(workspace);
1269
1270    // The platform listener is compiled from the project's own test sources,
1271    // so the launcher API it implements has to be on the compile classpath. A
1272    // TestNG-only project needs none of that: it already depends on the
1273    // framework its own listener implements.
1274    let mut added_launcher = None;
1275    let mut serialised: BTreeSet<String> = BTreeSet::new();
1276    let mut concurrent: BTreeSet<String> = BTreeSet::new();
1277    let mut added_vintage = false;
1278    match build {
1279        // Per module, not once at the top: the module's own pom is where its
1280        // JUnit version is in scope, and a module that runs JUnit 4 must not
1281        // get the launcher at all.
1282        JvmBuild::Maven => {
1283            for module in modules
1284                .iter()
1285                .filter(|module| {
1286                    module.has_tests
1287                        && !unmeasurable.contains(&module.directory)
1288                        && !modular.contains(&module.directory)
1289                })
1290                // A TestNG module needs none of this: it already depends on
1291                // the framework its own listener implements, and the launcher
1292                // would only change which provider the build chooses.
1293                .filter(|module| {
1294                    let frameworks = frameworks_of(&module.directory);
1295                    frameworks.platform || frameworks.junit4
1296                })
1297            {
1298                let pom = workspace.join(&module.directory).join("pom.xml");
1299                let Ok(existing) = fs::read_to_string(&pom) else {
1300                    continue;
1301                };
1302                // A JUnit 4 module also needs the engine that runs JUnit 4
1303                // tests on the platform; without it the launcher would find no
1304                // engine at all.
1305                let platform =
1306                    maven_launcher_version(&workspace.join(&module.directory), &existing);
1307                let updated = if frameworks_of(&module.directory).junit4 {
1308                    added_vintage = true;
1309                    maven_with_vintage(&existing, platform)
1310                } else {
1311                    maven_with_launcher(&existing, platform)
1312                };
1313                if let Some(updated) = updated {
1314                    write(&pom, &updated)?;
1315                    added_launcher = Some("pom.xml");
1316                }
1317            }
1318        }
1319        JvmBuild::Gradle
1320            if !modules.iter().any(|module| {
1321                let frameworks = frameworks_of(&module.directory);
1322                module.has_tests && (frameworks.platform || frameworks.junit4)
1323            }) => {}
1324        JvmBuild::Gradle => {
1325            // A module on TestNG as well keeps Gradle's TestNG framework: its
1326            // own listener attributes it.
1327            let junit4 = modules
1328                .iter()
1329                .filter(|module| {
1330                    let frameworks = frameworks_of(&module.directory);
1331                    module.has_tests
1332                        && frameworks.junit4
1333                        && !frameworks.testng
1334                        && !modular.contains(&module.directory)
1335                })
1336                .map(|module| module.directory.clone())
1337                .collect::<Vec<_>>();
1338            for name in ["build.gradle.kts", "build.gradle"] {
1339                let path = workspace.join(name);
1340                let Ok(existing) = fs::read_to_string(&path) else {
1341                    continue;
1342                };
1343                let catalog = fs::read_to_string(workspace.join("gradle/libs.versions.toml"))
1344                    .unwrap_or_default();
1345                if let Some(updated) =
1346                    gradle_with_launcher(&existing, name.ends_with(".kts"), &catalog, &junit4)
1347                {
1348                    added_vintage |= !junit4.is_empty();
1349                    write(&path, &updated)?;
1350                    added_launcher = Some(if name.ends_with(".kts") {
1351                        "build.gradle.kts"
1352                    } else {
1353                        "build.gradle"
1354                    });
1355                }
1356                break;
1357            }
1358        }
1359        // Nothing resolves dependencies for a plain tree; whoever compiles it
1360        // supplies the classpath.
1361        JvmBuild::Plain => {}
1362    }
1363
1364    for module in modules.iter().filter(|module| {
1365        module.has_tests
1366            && !unmeasurable.contains(&module.directory)
1367            && !modular.contains(&module.directory)
1368    }) {
1369        let frameworks = frameworks_of(&module.directory);
1370        let resources = workspace.join(&module.directory).join("src/test/resources");
1371        if frameworks.platform || frameworks.junit4 {
1372            write(
1373                &resources.join(SERVICES_FILE),
1374                &format!("{LISTENER_CLASS}\n"),
1375            )?;
1376            let properties = resources.join("junit-platform.properties");
1377            let existing = fs::read_to_string(&properties).ok();
1378            if !exact_attribution {
1379                // The suite runs the way its author wrote it. Where that means
1380                // tests run at once the runtime notices, says what it could
1381                // not credit, and the run records it -- which is a smaller
1382                // price than several times the suite's wall time, and not one
1383                // Supercov gets to spend on somebody's behalf.
1384                if asks_for_parallel_execution(existing.as_deref()) {
1385                    concurrent.insert(module.directory.clone());
1386                }
1387                continue;
1388            }
1389            // Said out loud, because it changes how the suite runs rather than
1390            // how it is measured. Every other thing Supercov does to the
1391            // workspace is announced -- the isolated copy, the added launcher,
1392            // `-count=1` over in Go -- and this is the one with a chance of
1393            // changing an outcome: a test that waits on another test's thread
1394            // passes under the project's own command and hangs under this one.
1395            if asks_for_parallel_execution(existing.as_deref()) {
1396                serialised.insert(module.directory.clone());
1397            }
1398            write(&properties, &sequential_properties(existing.as_deref()))?;
1399        }
1400        if frameworks.testng {
1401            write(
1402                &resources.join(TESTNG_SERVICES_FILE),
1403                &format!("{TESTNG_LISTENER_CLASS}\n"),
1404            )?;
1405        }
1406    }
1407
1408    // A test class's file, so a result can name where it came from. Matched on
1409    // the class rather than the test, because the name a framework reports for
1410    // a test is its own and need not be a method at all.
1411    let mut declared_in = BTreeMap::new();
1412    for (relative, _) in &project.files.tests {
1413        if let Some(stem) = relative
1414            .rsplit('/')
1415            .next()
1416            .and_then(|name| name.split('.').next())
1417        {
1418            declared_in.insert(stem.to_owned(), relative.clone());
1419        }
1420    }
1421
1422    Ok(InstrumentedWorkspace {
1423        project,
1424        build,
1425        modules,
1426        declared_in,
1427        added_launcher,
1428        serialised,
1429        concurrent,
1430        added_vintage,
1431        relaxed,
1432        license_check,
1433        unmeasurable,
1434        modular,
1435    })
1436}
1437
1438/// The fingerprint a later query compares against the stored run.
1439pub fn current_jvm_integrity(
1440    root: &Path,
1441    command: &[String],
1442    source_roots: Option<&[String]>,
1443) -> Result<crate::run_store::RunIntegrity, String> {
1444    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
1445    let files = crate::jvm_project::discover_jvm_files(&root)?;
1446    let mut inputs = jvm_integrity_inputs(&files, command);
1447    crate::source_discovery::fold_roots_into_configuration(
1448        &mut inputs.execution_configuration,
1449        source_roots,
1450    );
1451    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_jvm())
1452        .map_err(|error| error.to_string())
1453}
1454
1455pub fn run_direct_jvm(
1456    request: &DirectJvmRunRequest,
1457    diagnostics: &mut dyn Write,
1458) -> Result<DirectJvmRunResult, String> {
1459    if request.command.is_empty() {
1460        return Err("test command must not be empty".into());
1461    }
1462    let total_started = Instant::now();
1463    let initialization_started = Instant::now();
1464    let root = canonicalize_simplified(&request.root)
1465        .map_err(|error| format!("{}: {error}", request.root.display()))?;
1466    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
1467        .map_err(|error| error.to_string())?;
1468    let initialization_ms = elapsed_ms(initialization_started);
1469    let work_directory = root.join(".supercov/work").join(&request.run_id);
1470    // Whether the tests were measured. A run that failed before that has no
1471    // evidence worth keeping; one that failed after has evidence that cost the
1472    // suite its wall clock.
1473    let measured = std::cell::Cell::new(false);
1474    let result = (|| {
1475        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1476            .map_err(|error| error.to_string())?;
1477        if !recovered_runs.is_empty() {
1478            writeln!(
1479                diagnostics,
1480                "[supercov] recovered abandoned run(s): {}",
1481                recovered_runs.join(", ")
1482            )
1483            .map_err(|error| error.to_string())?;
1484        }
1485
1486        let adapter_started = Instant::now();
1487        let files = crate::jvm_project::discover_jvm_files(&root)?;
1488        let source_roots = crate::source_discovery::configured_source_roots(
1489            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
1490        );
1491        let mut integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1492        crate::source_discovery::fold_roots_into_configuration(
1493            &mut integrity_inputs.execution_configuration,
1494            source_roots.as_deref(),
1495        );
1496        let source_inputs =
1497            crate::source_capture::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1498        let integrity = create_explicit_run_integrity(
1499            &root,
1500            &integrity_inputs,
1501            &FrontendIntegrityInputs::embedded_jvm(),
1502        )
1503        .map_err(|error| error.to_string())?;
1504
1505        let workspace_started = Instant::now();
1506        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1507        let workspace =
1508            prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1509        let evidence_directory = work_directory.join("jvm");
1510        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1511        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
1512        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1513            &root, &workspace, &ambient,
1514        )
1515        .map_err(|error| error.to_string())?;
1516        let instrumented = instrument_workspace(
1517            &workspace,
1518            &evidence_directory,
1519            request.exact_attribution,
1520            roots.as_ref(),
1521        )?;
1522        let workspace_preparation_ms = elapsed_ms(workspace_started);
1523        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1524        writeln!(
1525            diagnostics,
1526            "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1527            match instrumented.build {
1528                JvmBuild::Maven => "a Maven project",
1529                JvmBuild::Gradle => "a Gradle project",
1530                JvmBuild::Plain => "Java/Kotlin sources",
1531            },
1532            instrumented.project.instrumented.len(),
1533            workspace.display()
1534        )
1535        .map_err(|error| error.to_string())?;
1536        if let Some(build_file) = instrumented.added_launcher {
1537            writeln!(
1538                diagnostics,
1539                "[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."
1540            )
1541            .map_err(|error| error.to_string())?;
1542        }
1543        if !instrumented.serialised.is_empty() {
1544            writeln!(
1545                diagnostics,
1546                "[supercov] turned JUnit's parallel execution off in the workspace copy ({}): attribution is a sweep of one shared probe array at each test boundary, so two tests running at once cannot both be credited with what they reached. Your suite runs in order here and your own configuration is untouched.",
1547                instrumented
1548                    .serialised
1549                    .iter()
1550                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1551                    .collect::<Vec<_>>()
1552                    .join(", ")
1553            )
1554            .map_err(|error| error.to_string())?;
1555        }
1556        if !instrumented.concurrent.is_empty() {
1557            writeln!(
1558                diagnostics,
1559                "[supercov] JUnit runs this suite's tests at once ({}), so what they reach while they overlap is recorded against the run rather than against a test. `--exact-attribution` credits each of them instead, by running the suite in order.",
1560                instrumented
1561                    .concurrent
1562                    .iter()
1563                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1564                    .collect::<Vec<_>>()
1565                    .join(", ")
1566            )
1567            .map_err(|error| error.to_string())?;
1568        }
1569        if !instrumented.relaxed.is_empty() {
1570            writeln!(
1571                diagnostics,
1572                "[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.",
1573                instrumented.relaxed.join("; ")
1574            )
1575            .map_err(|error| error.to_string())?;
1576        }
1577        if instrumented.added_vintage {
1578            writeln!(
1579                diagnostics,
1580                "[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."
1581            )
1582            .map_err(|error| error.to_string())?;
1583        }
1584        if !instrumented.modular.is_empty() {
1585            writeln!(
1586                diagnostics,
1587                "[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.",
1588                instrumented.modular.len(),
1589                instrumented.modular.join(", ")
1590            )
1591            .map_err(|error| error.to_string())?;
1592        }
1593        if !instrumented.unmeasurable.is_empty() {
1594            writeln!(
1595                diagnostics,
1596                "[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.",
1597                instrumented.unmeasurable.len(),
1598                instrumented.unmeasurable.join(", ")
1599            )
1600            .map_err(|error| error.to_string())?;
1601        }
1602        for (file, reason) in &instrumented.project.unparseable {
1603            writeln!(
1604                diagnostics,
1605                "[supercov] could not parse {file}: {reason}; it carries no obligations"
1606            )
1607            .map_err(|error| error.to_string())?;
1608        }
1609
1610        let (mut command, note) = command_with_fresh_results(instrumented.build, &request.command);
1611        // Apache RAT fails the build on a file without a license header, and
1612        // Supercov's runtime and listeners in the copy are such files: every
1613        // Apache Commons project stopped before its first test.
1614        if instrumented.license_check && !command.iter().any(|a| a.contains("rat.skip")) {
1615            command.push("-Drat.skip=true".into());
1616            writeln!(
1617                diagnostics,
1618                "[supercov] added -Drat.skip=true: Apache RAT checks every file in the build for a license header, and the workspace copy holds Supercov's own runtime and listeners, which carry none. Your own build still runs the check."
1619            )
1620            .map_err(|error| error.to_string())?;
1621        }
1622        if let Some(note) = note {
1623            writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1624        }
1625        let test_started = Instant::now();
1626        let plan = ExecutionPlan {
1627            preparation: Vec::new(),
1628            test: ExecutionPhase {
1629                name: "test".into(),
1630                kind: PhaseKind::Test,
1631                command: CommandSpec {
1632                    program: command[0].clone().into(),
1633                    arguments: command[1..].iter().map(OsString::from).collect(),
1634                    cwd: workspace.clone(),
1635                    environment: None,
1636                    captured_output: None,
1637                    console: Some(crate::workspace::console_names(&root, &workspace)),
1638                },
1639            },
1640        };
1641        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1642        // What is in the copy now is Supercov's; what the command adds to
1643        // it or changes is the command's, and belongs in the project.
1644        let before = crate::workspace::workspace_output_baseline(&workspace)
1645            .map_err(|error| error.to_string())?;
1646        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1647            .map_err(|error| error.to_string())?;
1648        let test_command_ms = elapsed_ms(test_started);
1649        // `mvn test` left no `target/surefire-reports` in the project and
1650        // Gradle no `build/test-results`: a step after the tests that
1651        // publishes them found none. The reports flow back; what the build
1652        // compiled from the rewritten sources, and its own state, stay.
1653        let rewritten = instrumented
1654            .project
1655            .files
1656            .sources
1657            .iter()
1658            .chain(&instrumented.project.files.tests)
1659            .map(|(file, _)| PathBuf::from(file))
1660            .collect::<BTreeSet<_>>();
1661        let outputs = crate::workspace::sync_outputs_of(
1662            crate::workspace::CommandOutputs::Jvm,
1663            &root,
1664            &workspace,
1665            &before,
1666            &rewritten,
1667        )
1668        .map_err(|error| error.to_string())?;
1669        // What stayed is every class the build compiled: a count of them
1670        // tells nobody anything, so only what flowed back is said.
1671        for line in crate::workspace::describe_command_outputs(&outputs)
1672            .into_iter()
1673            .filter(|line| !line.contains("stayed in the isolated workspace"))
1674        {
1675            writeln!(diagnostics, "{line}").map_err(|error| error.to_string())?;
1676        }
1677        if let Some(signal) = execution.interrupted_signal {
1678            return Err(format!(
1679                "the test command was interrupted by {signal:?}; no run was published"
1680            ));
1681        }
1682        let exit_code = execution.exit_code;
1683
1684        let publication_started = Instant::now();
1685        // One JVM per module, so one evidence file per module.
1686        let mut parts = Vec::new();
1687        let mut outcomes = Vec::new();
1688        let mut silent = Vec::new();
1689        let named = named_modules(instrumented.build, &request.command);
1690        for module in instrumented
1691            .modules
1692            .iter()
1693            .filter(|module| module.has_tests)
1694        {
1695            // Every JVM the build forked for this module wrote its own file.
1696            let mut written = fs::read_dir(&module.evidence)
1697                .map(|entries| {
1698                    entries
1699                        .flatten()
1700                        .map(|entry| entry.path())
1701                        .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1702                        .collect::<Vec<_>>()
1703                })
1704                .unwrap_or_default();
1705            written.sort();
1706            if written.is_empty() {
1707                // A module the command never asked to run has nothing to
1708                // say for itself.
1709                if named
1710                    .as_ref()
1711                    .is_none_or(|named| named.contains(&module.directory))
1712                {
1713                    silent.push(module.directory.clone());
1714                }
1715                continue;
1716            }
1717            let mut forked = Vec::new();
1718            for path in &written {
1719                let bytes =
1720                    fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1721                forked.push(
1722                    read_evidence(&bytes)
1723                        .map_err(|error| format!("{}: {error}", path.display()))?,
1724                );
1725            }
1726            let evidence = merge_evidence(forked);
1727            for test in &evidence.tests {
1728                outcomes.push(OwnedTestOutcome {
1729                    name: test.name.clone(),
1730                    runner: test.runner.clone(),
1731                    // The module is the unit that forked a JVM of its own, so
1732                    // it is what a worker identity means here. The class is
1733                    // already in the name the framework reported.
1734                    package: module.directory.clone(),
1735                    file: class_of(&test.name)
1736                        .and_then(|class| declaring_file(&instrumented.declared_in, class)),
1737                    status: test.status.clone(),
1738                    // True while the suite runs its tests one at a time, which
1739                    // is what `--exact-attribution` asks for. Left to run as
1740                    // written, JUnit may run them at once; the runtime says so
1741                    // per record, and a test that overlapped is credited with
1742                    // nothing rather than with a neighbour's work.
1743                    attributed: !test.unattributed,
1744                });
1745            }
1746            parts.push(evidence);
1747        }
1748        if !silent.is_empty() {
1749            writeln!(
1750                diagnostics,
1751                "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1752                silent.len(),
1753                silent.join(", ")
1754            )
1755            .map_err(|error| error.to_string())?;
1756        }
1757        if outcomes.is_empty() {
1758            return Err(format!(
1759                "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."
1760            ));
1761        }
1762        let evidence = merge_evidence(parts);
1763        measured.set(true);
1764        let run = build_frontend_run(OwnedRunInputs {
1765            declaration: jvm_declaration(),
1766            environment: "jvm",
1767            manifest: &instrumented.project.manifest,
1768            probes: &instrumented.project.probes,
1769            evidence: &evidence,
1770            outcomes: &outcomes,
1771            run_id: &request.run_id,
1772            generated_at: &request.started_at,
1773            test_exit_code: exit_code,
1774            coverage_model: jvm_coverage_model(),
1775        })
1776        .map_err(|error| error.to_string())?;
1777        validate_frontend_report_request(&run.declaration, &run.request)
1778            .map_err(|error| error.to_string())?;
1779        let archive_path = work_directory.join("evidence.raw.gz");
1780        let raw = write_archive(
1781            crate::source_capture::append(
1782                run.archive_entries().map_err(|error| error.to_string())?,
1783                &source_inputs,
1784            )?,
1785            &archive_path,
1786        )
1787        .map_err(|error| error.to_string())?;
1788        let evidence_publication_ms = elapsed_ms(publication_started);
1789
1790        let timings = RunTimings {
1791            initialization_ms,
1792            workspace_preparation_ms,
1793            adapter_setup_ms,
1794            instrumented_build_ms: 0.0,
1795            test_command_ms,
1796            evidence_publication_ms,
1797        };
1798        let metadata = RunMetadata {
1799            id: request.run_id.clone(),
1800            started_at: request.started_at.clone(),
1801            duration_ms: elapsed_ms(total_started),
1802            command: request.command.clone(),
1803            test_exit_code: Some(exit_code),
1804            integrity,
1805            raw_evidence: RawEvidenceMetadata {
1806                schema_version: raw.schema_version,
1807                format: raw.format.into(),
1808                file: raw.file.into(),
1809                files: raw.files,
1810                uncompressed_bytes: raw.uncompressed_bytes,
1811                compressed_bytes: raw.compressed_bytes,
1812            },
1813            isolated_build: Some(true),
1814            instrumented_build_cache: None,
1815            timings: Some(timings),
1816            merged: None,
1817            parents: None,
1818            source_roots: source_roots.clone(),
1819            copy: Some(lock.copy()),
1820            merged_across: None,
1821            recorded_by: None,
1822            companions: None,
1823        };
1824        let run_directory =
1825            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1826        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1827        Ok(DirectJvmRunResult {
1828            run_id: request.run_id.clone(),
1829            run_directory,
1830            exit_code,
1831            tests: outcomes
1832                .iter()
1833                .map(|outcome| outcome.name.as_str())
1834                .collect::<BTreeSet<_>>()
1835                .len(),
1836            source_files: instrumented.project.instrumented.len(),
1837            modules: instrumented.modules.len(),
1838            build: instrumented.build,
1839            recovered_runs,
1840            metadata,
1841        })
1842    })();
1843    let result = result.map_err(|error| {
1844        if !measured.get() {
1845            return error;
1846        }
1847        note_kept_evidence(&root, &work_directory.join("jvm"), &request.run_id, error)
1848    });
1849    if result.is_err() {
1850        let _ = remove_stored_tree_deferred(&root, &work_directory);
1851    }
1852    let release = lock.release().map_err(|error| error.to_string());
1853    match (result, release) {
1854        (Ok(result), Ok(())) => Ok(result),
1855        (Err(error), _) => Err(error),
1856        (Ok(_), Err(error)) => Err(error),
1857    }
1858}
1859
1860/// The runtime for a server a browser suite starts: sized as for a test run,
1861/// and with the conditions per decision a listener would otherwise arm it
1862/// with, since a server has none.
1863fn server_runtime_source(probe_count: usize, widths: &[u8]) -> String {
1864    let marker = "static final int[] SERVER_WIDTHS = new int[0]; // supercov:server-widths";
1865    debug_assert!(
1866        RUNTIME_SOURCE.contains(marker),
1867        "the runtime no longer declares the server widths Supercov substitutes"
1868    );
1869    let widths = widths
1870        .iter()
1871        .map(u8::to_string)
1872        .collect::<Vec<_>>()
1873        .join(", ");
1874    runtime_source(probe_count).replace(
1875        marker,
1876        &format!(
1877            "static final int[] SERVER_WIDTHS = new int[] {{{widths}}}; // supercov:server-widths"
1878        ),
1879    )
1880}
1881
1882/// Java or Kotlin measured inside a browser suite's run: a server its tests
1883/// drive, built from the copy the suite runs in by the command's own build
1884/// (`./gradlew bootRun`, `mvn spring-boot:run`, `javac`). Its sources there
1885/// are rewritten with probes, and the runtime in each module's main source
1886/// set sweeps them on a clock into windows, credited by time; see
1887/// `owned_server`. A jar built before the run is not this one and is not
1888/// measured.
1889pub struct JvmServer {
1890    project: PreparedJvmProject,
1891    integrity: crate::run_store::RunIntegrity,
1892    source_inputs: crate::source_manifest::Inputs,
1893    source_roots: Option<Vec<String>>,
1894    directory: PathBuf,
1895}
1896
1897impl JvmServer {
1898    /// Instrument the copy's Java and Kotlin for a server, or None when the
1899    /// project has none.
1900    pub fn prepare(
1901        root: &Path,
1902        workspace: &Path,
1903        run_id: &str,
1904        command: &[String],
1905    ) -> Result<Option<Self>, String> {
1906        let files = crate::jvm_project::discover_jvm_files(root)?;
1907        if files.sources.is_empty() {
1908            return Ok(None);
1909        }
1910        let ambient = std::env::vars().collect::<BTreeMap<_, _>>();
1911        // Roots named for the JavaScript source may hold no Java at all.
1912        let Ok(roots) = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1913            root, workspace, &ambient,
1914        ) else {
1915            return Ok(None);
1916        };
1917        let source_roots = crate::source_discovery::configured_source_roots(&ambient);
1918        let mut integrity_inputs = jvm_integrity_inputs(&files, command);
1919        crate::source_discovery::fold_roots_into_configuration(
1920            &mut integrity_inputs.execution_configuration,
1921            source_roots.as_deref(),
1922        );
1923        let source_inputs =
1924            crate::source_capture::capture(root, "jvm", integrity_inputs.assertion_paths())?;
1925        let integrity = create_explicit_run_integrity(
1926            root,
1927            &integrity_inputs,
1928            &FrontendIntegrityInputs::embedded_jvm(),
1929        )
1930        .map_err(|error| error.to_string())?;
1931        let project = prepare_jvm_project(workspace, roots.as_ref())?;
1932        if project.instrumented.is_empty() {
1933            return Ok(None);
1934        }
1935        for (relative, instrumented) in &project.instrumented {
1936            write(&workspace.join(relative), instrumented)?;
1937        }
1938        let probe_count = project
1939            .probes
1940            .keys()
1941            .max()
1942            .map_or(0, |highest| *highest as usize + 1);
1943        let runtime = server_runtime_source(probe_count, &project.decision_widths);
1944        let modules = project
1945            .files
1946            .sources
1947            .iter()
1948            .map(|(relative, _)| module_of(relative))
1949            .collect::<BTreeSet<_>>();
1950        for module in &modules {
1951            write(
1952                &workspace
1953                    .join(module)
1954                    .join(source_root("main"))
1955                    .join(PACKAGE_DIRECTORY)
1956                    .join("Supercov.java"),
1957                &runtime,
1958            )?;
1959        }
1960        let directory = root.join(".supercov/work").join(run_id).join("jvm");
1961        fs::create_dir_all(directory.join("evidence")).map_err(|error| error.to_string())?;
1962        Ok(Some(Self {
1963            project,
1964            integrity,
1965            source_inputs,
1966            source_roots,
1967            directory,
1968        }))
1969    }
1970
1971    pub fn source_files(&self) -> usize {
1972        self.project.instrumented.len()
1973    }
1974
1975    /// What the suite's environment needs for a server it builds to write
1976    /// its windows.
1977    pub fn environment(&self) -> BTreeMap<String, String> {
1978        BTreeMap::from([(
1979            "SUPERCOV_JVM_SERVER_EVIDENCE_DIR".into(),
1980            self.directory.join("evidence").display().to_string(),
1981        )])
1982    }
1983
1984    pub fn publish(
1985        self,
1986        root: &Path,
1987        _evidence_run_id: &str,
1988        metadata: RunMetadata,
1989        served: &[crate::served_tests::ServedTest],
1990        diagnostics: &mut dyn Write,
1991    ) -> Result<Option<(PathBuf, RunMetadata)>, String> {
1992        crate::owned_server::publish(
1993            root,
1994            crate::owned_server::OwnedServer {
1995                language: "Java/Kotlin",
1996                declaration: jvm_declaration(),
1997                environment: "jvm",
1998                coverage_model: jvm_coverage_model(),
1999                manifest: &self.project.manifest,
2000                probes: &self.project.probes,
2001                widths: &self.project.decision_widths,
2002                integrity: &self.integrity,
2003                source_inputs: &self.source_inputs,
2004                source_roots: self.source_roots.clone(),
2005                directory: &self.directory,
2006                source_files: self.project.instrumented.len(),
2007            },
2008            metadata,
2009            served,
2010            diagnostics,
2011        )
2012    }
2013}
2014
2015#[cfg(test)]
2016mod tests {
2017    use super::*;
2018
2019    #[test]
2020    fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
2021        // The line is worth printing when something was taken away and noise
2022        // when nothing was: most projects never enable it, and a diagnostic on
2023        // every run is one nobody reads by the time it matters.
2024        assert!(asks_for_parallel_execution(Some(
2025            "junit.jupiter.execution.parallel.enabled=true\n"
2026        )));
2027        assert!(asks_for_parallel_execution(Some(
2028            "  junit.jupiter.execution.parallel.enabled = True  \n"
2029        )));
2030        assert!(!asks_for_parallel_execution(Some(
2031            "junit.jupiter.execution.parallel.enabled=false\n"
2032        )));
2033        // Naming the mode without enabling it leaves the suite sequential, so
2034        // nothing was taken away.
2035        assert!(!asks_for_parallel_execution(Some(
2036            "junit.jupiter.execution.parallel.mode.default=concurrent\n"
2037        )));
2038        assert!(!asks_for_parallel_execution(None));
2039        assert!(!asks_for_parallel_execution(Some("")));
2040    }
2041
2042    #[test]
2043    fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
2044        // Two tests running at once share one probe array, so neither can be
2045        // credited with what it reached. The runtime notices and says so; this
2046        // is the half that means it never has to.
2047        let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
2048                        junit.jupiter.execution.parallel.enabled=true\n\
2049                        junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
2050        let updated = sequential_properties(Some(existing));
2051        assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
2052        assert!(
2053            !updated.contains("parallel.enabled=true"),
2054            "the project's own setting must not survive:\n{updated}"
2055        );
2056        // Everything the project chose for reasons of its own stays.
2057        assert!(updated.contains("testinstance.lifecycle.default=per_class"));
2058        assert!(updated.contains("displayname.generator.default"));
2059
2060        // And a project with no properties at all still gets the setting.
2061        assert!(
2062            sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
2063        );
2064    }
2065
2066    #[test]
2067    fn gradle_is_told_to_run_the_tests_again() {
2068        // Gradle skips a test task it considers up to date, and a task that
2069        // does not run records nothing.
2070        let (command, note) = command_with_fresh_results(
2071            JvmBuild::Gradle,
2072            &["./gradlew".to_owned(), "test".to_owned()],
2073        );
2074        assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
2075        assert!(note.is_some());
2076
2077        // An author who already said so means it.
2078        let (command, note) = command_with_fresh_results(
2079            JvmBuild::Gradle,
2080            &[
2081                "./gradlew".to_owned(),
2082                "test".to_owned(),
2083                "--rerun".to_owned(),
2084            ],
2085        );
2086        assert_eq!(command, ["./gradlew", "test", "--rerun"]);
2087        assert!(note.is_none());
2088
2089        // Maven's lifecycle re-runs surefire every time; nothing to defeat.
2090        let (command, note) =
2091            command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
2092        assert_eq!(command, ["mvn", "test"]);
2093        assert!(note.is_none());
2094    }
2095
2096    #[test]
2097    fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
2098        // A Windows path is full of backslashes, and one of them landing
2099        // before a `t` or a `"` would change the path or end the literal.
2100        let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
2101        assert!(
2102            configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
2103            "{configuration}"
2104        );
2105        assert!(configuration.contains("PROBES = 7"));
2106        assert!(configuration.contains("new int[] {2, 3}"));
2107    }
2108
2109    #[test]
2110    fn a_tests_class_is_read_from_the_name_the_framework_chose() {
2111        // JUnit reports `CalculatorTest#zeroIsNamed()`; a DSL framework
2112        // reports its own wording under the same class.
2113        assert_eq!(
2114            class_of("CalculatorTest#zeroIsNamed()"),
2115            Some("CalculatorTest")
2116        );
2117        assert_eq!(
2118            class_of("CalculatorSpec#a sum adds its parts"),
2119            Some("CalculatorSpec")
2120        );
2121        assert_eq!(class_of("Standalone"), Some("Standalone"));
2122        assert_eq!(class_of(""), None);
2123    }
2124
2125    #[test]
2126    fn the_launcher_version_follows_whatever_junit_the_project_chose() {
2127        // JUnit numbers the platform 1.N alongside Jupiter 5.N, so a pinned
2128        // Jupiter says exactly which launcher agrees with the engine that will
2129        // run. Guessing instead could pair a launcher with an engine it does
2130        // not understand.
2131        assert_eq!(
2132            launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
2133                .as_deref(),
2134            Some("1.10.2")
2135        );
2136        assert_eq!(
2137            launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
2138                .as_deref(),
2139            Some("1.13.1")
2140        );
2141        // A project using the BOM has already said how every JUnit artifact is
2142        // versioned; naming one would override the answer it gave.
2143        assert_eq!(
2144            launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
2145            None
2146        );
2147        // And one that says nothing gets a launcher new enough to drive an
2148        // older engine, which is the direction that works.
2149        assert_eq!(
2150            launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
2151            Some(DEFAULT_LAUNCHER_VERSION)
2152        );
2153        // Including a file that names no JUnit artifact at all -- an
2154        // aggregating parent, say, whose children each declare their own.
2155        // Reading that as "a BOM manages it" writes a versionless dependency
2156        // into a pom with no BOM to resolve it, and the build stops before a
2157        // single test runs.
2158        assert_eq!(
2159            launcher_version("<artifactId>parent</artifactId>").as_deref(),
2160            Some(DEFAULT_LAUNCHER_VERSION)
2161        );
2162        assert_eq!(
2163            launcher_version("").as_deref(),
2164            Some(DEFAULT_LAUNCHER_VERSION)
2165        );
2166    }
2167
2168    #[test]
2169    fn junit_is_versioned_through_parents_catalogs_and_junit_6() {
2170        // JUnit 6 numbers every artifact alike.
2171        assert_eq!(
2172            launcher_version("<artifactId>junit-jupiter</artifactId><version>6.0.1</version>")
2173                .as_deref(),
2174            Some("6.0.1")
2175        );
2176        assert_eq!(engine_version_of_platform("6.0.1"), "6.0.1");
2177        // A version is read from the Jupiter dependency, not from whatever
2178        // `5.` comes next in the file.
2179        assert_eq!(
2180            launcher_version("<artifactId>junit-jupiter</artifactId><version>${junit}</version><plugin><version>5.2.1</version></plugin>")
2181                .as_deref(),
2182            Some(DEFAULT_LAUNCHER_VERSION)
2183        );
2184        // A version catalog, by reference.
2185        let catalog = "[versions]\njunit = \"5.11.3\"\n\n[libraries]\njunit-jupiter = { module = \"org.junit.jupiter:junit-jupiter\", version.ref = \"junit\" }\n";
2186        let updated = gradle_with_launcher("plugins { id 'java' }\n", false, catalog, &[]).unwrap();
2187        assert!(
2188            updated.contains("junit-platform-launcher:1.11.3"),
2189            "{updated}"
2190        );
2191
2192        // A parent on disk that imports the BOM, and one that sets a property.
2193        let root = std::env::temp_dir().join(format!(
2194            "supercov-junit-parents-{}-{}",
2195            std::process::id(),
2196            std::time::SystemTime::now()
2197                .duration_since(std::time::UNIX_EPOCH)
2198                .unwrap()
2199                .as_nanos()
2200        ));
2201        let module = root.join("module");
2202        fs::create_dir_all(&module).unwrap();
2203        let child = "<project><parent><groupId>g</groupId><artifactId>p</artifactId><version>1</version></parent><dependencies><dependency><artifactId>junit-jupiter-api</artifactId></dependency></dependencies></project>";
2204        fs::write(
2205            root.join("pom.xml"),
2206            "<project><artifactId>p</artifactId><dependencyManagement><dependencies><dependency><artifactId>junit-bom</artifactId><version>${junit.version}</version></dependency></dependencies></dependencyManagement></project>",
2207        )
2208        .unwrap();
2209        assert_eq!(maven_launcher_version(&module, child), None);
2210        fs::write(
2211            root.join("pom.xml"),
2212            "<project><artifactId>p</artifactId><properties><junit.version>5.14.4</junit.version></properties><dependencyManagement><dependencies><dependency><artifactId>junit-jupiter-api</artifactId><version>${junit.version}</version></dependency></dependencies></dependencyManagement></project>",
2213        )
2214        .unwrap();
2215        assert_eq!(
2216            maven_launcher_version(&module, child).as_deref(),
2217            Some("1.14.4")
2218        );
2219        // A parent that cannot be read manages the version it left out.
2220        fs::remove_file(root.join("pom.xml")).unwrap();
2221        let unknown = child.replace(
2222            "<artifactId>p</artifactId>",
2223            "<artifactId>not-downloaded</artifactId>",
2224        );
2225        assert_eq!(maven_launcher_version(&module, &unknown), None);
2226        fs::remove_dir_all(&root).ok();
2227    }
2228
2229    #[test]
2230    fn the_launcher_is_added_once_and_only_where_it_is_missing() {
2231        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";
2232        let updated =
2233            maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2234        assert!(updated.contains("junit-platform-launcher"), "{updated}");
2235        assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
2236        // Inside the existing block, not after it.
2237        assert!(
2238            updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
2239            "{updated}"
2240        );
2241        // A project that already has it is left exactly as it is.
2242        assert_eq!(
2243            maven_with_launcher(&updated, launcher_version(&updated)),
2244            None
2245        );
2246
2247        // And one with no dependencies block at all still gets a valid pom.
2248        let bare = "<project>\n  <artifactId>demo</artifactId>\n</project>\n";
2249        let updated =
2250            maven_with_launcher(bare, launcher_version(bare)).expect("a block is created");
2251        assert!(updated.contains("<dependencies>"), "{updated}");
2252        assert!(
2253            updated.find("</dependencies>") < updated.find("</project>"),
2254            "{updated}"
2255        );
2256    }
2257
2258    #[test]
2259    fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
2260        let groovy =
2261            "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
2262        let updated =
2263            gradle_with_launcher(groovy, false, "", &[]).expect("the launcher is missing");
2264        assert!(
2265            updated
2266                .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
2267            "{updated}"
2268        );
2269        // The project's own block survives: Gradle merges what we append.
2270        assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
2271        assert_eq!(gradle_with_launcher(&updated, false, "", &[]), None);
2272
2273        // Gradle 9 makes every project declare the launcher, and the
2274        // configuration its own documentation recommends is testRuntimeOnly,
2275        // which the tests run with but do not compile against. A project
2276        // following that advice has the artifact and still cannot compile a
2277        // listener, so it gets a compile-visible declaration alongside.
2278        let runtime_only = "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
2279        let updated = gradle_with_launcher(runtime_only, false, "", &[])
2280            .expect("a runtime-only declaration does not reach the compiler");
2281        assert!(
2282            updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
2283            "{updated}"
2284        );
2285        assert!(
2286            updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
2287            "the project's own declaration stays:\n{updated}"
2288        );
2289
2290        let kotlin = "dependencies {\n    testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
2291        let updated = gradle_with_launcher(kotlin, true, "", &[]).expect("the launcher is missing");
2292        assert!(
2293            updated.contains(
2294                "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
2295            ),
2296            "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
2297        );
2298        assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
2299    }
2300
2301    /// apache/commons-cli takes RAT from commons-parent, and RAT failed the
2302    /// copy on Supercov's own files before any test ran.
2303    #[test]
2304    fn a_test_class_named_in_full_finds_its_file() {
2305        let declared_in = BTreeMap::from([
2306            (
2307                "AnnotationsTest".to_owned(),
2308                "src/test/java/com/fasterxml/classmate/AnnotationsTest.java".to_owned(),
2309            ),
2310            (
2311                "CalculatorSpec".to_owned(),
2312                "src/test/kotlin/app/CalculatorSpec.kt".to_owned(),
2313            ),
2314        ]);
2315        assert_eq!(
2316            declaring_file(&declared_in, "com.fasterxml.classmate.AnnotationsTest").as_deref(),
2317            Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2318        );
2319        assert_eq!(
2320            declaring_file(
2321                &declared_in,
2322                "com.fasterxml.classmate.AnnotationsTest$Nested"
2323            )
2324            .as_deref(),
2325            Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2326        );
2327        assert_eq!(
2328            declaring_file(&declared_in, "app.CalculatorSpec").as_deref(),
2329            Some("src/test/kotlin/app/CalculatorSpec.kt")
2330        );
2331        // A class reported without its package still finds its file by name.
2332        assert_eq!(
2333            declaring_file(&declared_in, "CalculatorSpec").as_deref(),
2334            Some("src/test/kotlin/app/CalculatorSpec.kt")
2335        );
2336        assert_eq!(declaring_file(&declared_in, "org.other.Missing"), None);
2337    }
2338
2339    #[test]
2340    fn a_license_header_check_is_found_through_the_parent_pom() {
2341        let root = std::env::temp_dir().join(format!(
2342            "supercov-rat-{}-{}",
2343            std::process::id(),
2344            std::time::SystemTime::now()
2345                .duration_since(std::time::UNIX_EPOCH)
2346                .unwrap()
2347                .as_nanos()
2348        ));
2349        fs::create_dir_all(root.join("parent")).unwrap();
2350        fs::write(
2351            root.join("parent/pom.xml"),
2352            "<project><artifactId>parent</artifactId><build><plugins><plugin><groupId>org.apache.rat</groupId><artifactId>apache-rat-plugin</artifactId></plugin></plugins></build></project>",
2353        )
2354        .unwrap();
2355        fs::write(
2356            root.join("pom.xml"),
2357            "<project><parent><groupId>g</groupId><artifactId>parent</artifactId><version>1</version><relativePath>parent/pom.xml</relativePath></parent></project>",
2358        )
2359        .unwrap();
2360        assert!(checks_license_headers(&root));
2361        fs::write(
2362            root.join("pom.xml"),
2363            "<project><artifactId>plain</artifactId></project>",
2364        )
2365        .unwrap();
2366        assert!(!checks_license_headers(&root));
2367        fs::remove_dir_all(&root).ok();
2368    }
2369
2370    #[test]
2371    fn only_the_modules_a_command_names_are_expected_to_write_evidence() {
2372        let words = |c: &str| c.split(' ').map(str::to_owned).collect::<Vec<_>>();
2373        assert_eq!(
2374            named_modules(
2375                JvmBuild::Gradle,
2376                &words("./gradlew :moshi:test --tests *Reader*")
2377            ),
2378            Some(BTreeSet::from(["moshi".to_owned()]))
2379        );
2380        assert_eq!(
2381            named_modules(
2382                JvmBuild::Gradle,
2383                &words("gradle :moshi:records-tests:test :test")
2384            ),
2385            Some(BTreeSet::from([
2386                "moshi/records-tests".to_owned(),
2387                ".".to_owned()
2388            ]))
2389        );
2390        assert_eq!(named_modules(JvmBuild::Gradle, &words("gradle test")), None);
2391        assert_eq!(
2392            named_modules(JvmBuild::Gradle, &words("gradle test -x :slow:test")),
2393            None
2394        );
2395        assert_eq!(
2396            named_modules(JvmBuild::Maven, &words("mvn -pl core,api/ test")),
2397            Some(BTreeSet::from(["core".to_owned(), "api".to_owned()]))
2398        );
2399        assert_eq!(
2400            named_modules(JvmBuild::Maven, &words("mvn -pl :core test")),
2401            None
2402        );
2403        assert_eq!(named_modules(JvmBuild::Maven, &words("mvn test")), None);
2404        assert_eq!(
2405            named_modules(JvmBuild::Maven, &words("mvn --projects ./core test")),
2406            Some(BTreeSet::from(["core".to_owned()]))
2407        );
2408        // A module left out is no list of the ones that run.
2409        assert_eq!(
2410            named_modules(JvmBuild::Maven, &words("mvn -pl core,!slow test")),
2411            None
2412        );
2413    }
2414
2415    #[test]
2416    fn each_relaxation_is_said_once_however_many_build_files_needed_it() {
2417        let mut relaxed = Vec::new();
2418        // Two build files, each failing on warnings and running Error Prone.
2419        for _ in 0..2 {
2420            note_relaxations("-Werror -Xplugin:ErrorProne", "-Xlint:none", &mut relaxed);
2421        }
2422        assert_eq!(
2423            relaxed,
2424            [
2425                "stopped the build failing on warnings",
2426                "switched Error Prone off"
2427            ]
2428        );
2429        let mut maven = Vec::new();
2430        note_relaxations(
2431            "<failOnWarning>true</failOnWarning>",
2432            "<failOnWarning>false</failOnWarning>",
2433            &mut maven,
2434        );
2435        assert_eq!(maven, ["stopped the build failing on warnings"]);
2436        let mut unchanged = Vec::new();
2437        note_relaxations("-Xlint:all", "-Xlint:all", &mut unchanged);
2438        assert!(unchanged.is_empty());
2439    }
2440
2441    #[test]
2442    fn the_platform_version_follows_jupiter_s() {
2443        assert_eq!(platform_of_jupiter("5.13.1").as_deref(), Some("1.13.1"));
2444        assert_eq!(
2445            platform_of_jupiter("5.10.0-M1</version>").as_deref(),
2446            Some("1.10.0-M1")
2447        );
2448        assert_eq!(platform_of_jupiter("6.0.1").as_deref(), Some("6.0.1"));
2449        for unknown in [
2450            "${junit.version}",
2451            "5.10",
2452            "5..1",
2453            "5.x.1",
2454            "4.13.2",
2455            "five.1.0",
2456        ] {
2457            assert_eq!(platform_of_jupiter(unknown), None, "{unknown}");
2458        }
2459    }
2460
2461    #[test]
2462    fn gradle_moves_junit_4_projects_onto_the_platform_through_vintage() {
2463        let kotlin = "dependencies {\n    testImplementation(\"junit:junit:4.13.2\")\n}\n";
2464        let updated = gradle_with_launcher(kotlin, true, "", &[".".into(), "moshi".into()])
2465            .expect("a JUnit 4 project needs Vintage");
2466        assert!(
2467            updated
2468                .contains("\"testRuntimeOnly\"(\"org.junit.vintage:junit-vintage-engine:5.10.2\")"),
2469            "{updated}"
2470        );
2471        assert!(updated.contains("in listOf(\"\", \"moshi\")"), "{updated}");
2472        assert!(
2473            updated.contains("if (options is JUnitOptions) useJUnitPlatform()"),
2474            "only a task still on Gradle's JUnit 4 framework is switched:\n{updated}"
2475        );
2476
2477        // Already compiling against the launcher, Vintage is still added.
2478        let groovy = "dependencies {\n    testImplementation 'junit:junit:4.13.2'\n    testImplementation 'org.junit.platform:junit-platform-launcher:1.13.1'\n}\n";
2479        let updated = gradle_with_launcher(groovy, false, "", &["core".into()])
2480            .expect("a JUnit 4 project needs Vintage");
2481        assert!(
2482            updated.contains("testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.2'"),
2483            "{updated}"
2484        );
2485        assert!(updated.contains("in [\"core\"]"), "{updated}");
2486        assert!(
2487            updated.contains("tasks.withType(Test).configureEach"),
2488            "{updated}"
2489        );
2490        assert!(
2491            !updated.contains("\n            testImplementation 'org.junit.platform"),
2492            "the launcher is already there:\n{updated}"
2493        );
2494    }
2495
2496    #[test]
2497    fn only_the_listeners_a_project_can_compile_are_written() {
2498        // Each listener is compiled from the project's own test sources, so
2499        // one whose framework is absent would fail on imports the project
2500        // never asked for.
2501        let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
2502        assert!(junit.platform && !junit.testng);
2503
2504        let testng = frameworks("<artifactId>testng</artifactId>");
2505        assert!(testng.testng && !testng.platform);
2506
2507        // A migration in progress runs both, and both listeners fire in one
2508        // JVM against one runtime.
2509        let both = frameworks("testng ... junit-jupiter");
2510        assert!(both.platform && both.testng);
2511
2512        // Kotest and Spock are platform engines, so the platform listener
2513        // reports their tests without either being named.
2514        assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
2515        assert!(frameworks("org.spockframework:spock-core").platform);
2516
2517        // And a project naming nothing recognisable gets the platform, which
2518        // is what nearly every JVM suite runs on.
2519        let unknown = frameworks("<artifactId>demo</artifactId>");
2520        assert!(unknown.platform && !unknown.testng);
2521    }
2522
2523    #[test]
2524    fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
2525        // Surefire picks its provider from what is on the classpath. Adding
2526        // the platform launcher to a JUnit 4 project makes it choose the
2527        // platform provider, find no engine there, and fail the suite --
2528        // Supercov breaking a build it was asked to measure. The word "junit"
2529        // appears in both, so the artifact is what tells them apart.
2530        let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
2531        assert!(four.junit4 && !four.platform && !four.testng);
2532
2533        let five = frameworks("<artifactId>junit-jupiter</artifactId>");
2534        assert!(five.platform && !five.junit4);
2535
2536        // Vintage runs JUnit 4 tests on the platform, so a project with both
2537        // is a platform project.
2538        let both = frameworks(
2539            "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
2540        );
2541        assert!(both.platform && !both.junit4);
2542
2543        // Gradle spells its dependencies differently and means the same.
2544        assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
2545        assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
2546        assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
2547    }
2548
2549    #[test]
2550    fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
2551        // gson's test-jpms declares `module com.google.gson.jpms_test`, which
2552        // requires com.google.gson, junit and truth and nothing else. A
2553        // listener written into it imports org.junit.platform -- not visible
2554        // -- and reads a runtime from a package the main module does not
2555        // export. The module stops compiling and takes the reactor with it,
2556        // for tests that check module boundaries rather than product logic.
2557        let root = std::env::temp_dir().join(format!(
2558            "supercov-jpms-{}-{}",
2559            std::process::id(),
2560            std::time::SystemTime::now()
2561                .duration_since(std::time::UNIX_EPOCH)
2562                .unwrap()
2563                .as_nanos()
2564        ));
2565        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();
2566        for module in ["lib", "boundaries"] {
2567            write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
2568            write(
2569                &root.join(module).join("src/main/java/app/Api.java"),
2570                "package app;\npublic class Api { public int one() { return 1; } }\n",
2571            )
2572            .unwrap();
2573            write(
2574                &root.join(module).join("src/test/java/app/ApiTest.java"),
2575                "package app;\nclass ApiTest { void t() {} }\n",
2576            )
2577            .unwrap();
2578        }
2579        write(
2580            &root.join("boundaries/src/test/java/module-info.java"),
2581            "module app.boundaries {\n  requires app.lib;\n}\n",
2582        )
2583        .unwrap();
2584
2585        let evidence = root.join("evidence");
2586        let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
2587        assert_eq!(instrumented.modular, ["boundaries"]);
2588
2589        // The listener goes into the ordinary module and not the named one.
2590        assert!(
2591            root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
2592                .exists()
2593        );
2594        for name in ["SupercovListener.java", "SupercovConfig.java"] {
2595            assert!(
2596                !root
2597                    .join("boundaries/src/test/java/com/supercorp/supercov")
2598                    .join(name)
2599                    .exists(),
2600                "{name} must not be written into a named module"
2601            );
2602        }
2603        assert!(
2604            !root
2605                .join("boundaries/src/test/resources")
2606                .join(SERVICES_FILE)
2607                .exists(),
2608            "and nothing registers a listener that is not there"
2609        );
2610        // Its product code is still instrumented, and the runtime it stores
2611        // into is a package of the module's own -- which a named module may
2612        // hold without exporting.
2613        assert!(
2614            root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
2615                .exists()
2616        );
2617
2618        fs::remove_dir_all(root).ok();
2619    }
2620
2621    #[test]
2622    fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
2623        // moshi is JUnit 4 and writes `testImplementation(libs.junit)`. Read
2624        // without the catalog the build file names no framework at all, the
2625        // fallback takes it for a platform project, and a listener goes in
2626        // that nothing will ever call: the suite passes and records nothing.
2627        let root = std::env::temp_dir().join(format!(
2628            "supercov-catalog-{}-{}",
2629            std::process::id(),
2630            std::time::SystemTime::now()
2631                .duration_since(std::time::UNIX_EPOCH)
2632                .unwrap()
2633                .as_nanos()
2634        ));
2635        write(
2636            &root.join("gradle/libs.versions.toml"),
2637            "[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",
2638        )
2639        .unwrap();
2640        let catalog = version_catalog(&root);
2641
2642        let junit4 = frameworks(&with_catalog(
2643            "dependencies { testImplementation(libs.junit) }",
2644            &catalog,
2645        ));
2646        assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
2647
2648        // An accessor is a prefix of a longer one, and must not answer for it.
2649        let reflect = frameworks(&with_catalog(
2650            "dependencies { testImplementation(libs.kotlin.reflect) }",
2651            &catalog,
2652        ));
2653        assert!(!reflect.junit4, "{reflect:?}");
2654
2655        let jupiter = frameworks(&with_catalog(
2656            "dependencies { testImplementation(libs.jupiter) }",
2657            &catalog,
2658        ));
2659        assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
2660
2661        // A bundle stands for every library in it.
2662        let bundle = frameworks(&with_catalog(
2663            "dependencies { testImplementation(libs.bundles.unit) }",
2664            &catalog,
2665        ));
2666        assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
2667
2668        // A build that names no accessor is unchanged by a catalog.
2669        let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
2670        assert_eq!(with_catalog(plain, &catalog), plain);
2671
2672        fs::remove_dir_all(root).ok();
2673    }
2674
2675    #[test]
2676    fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
2677        // A pom's <dependencyManagement> holds a <dependencies> too, and so
2678        // does every profile and plugin. A dependency added there is a version
2679        // for something else to ask for rather than something the project
2680        // depends on: the module compiles exactly as before and the listener
2681        // still cannot find the API it implements.
2682        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";
2683        let updated =
2684            maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2685        let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
2686        let managed_end = updated
2687            .find("</dependencyManagement>")
2688            .expect("managed block");
2689        let build_start = updated.find("<build>").expect("build block");
2690        assert!(
2691            at > managed_end,
2692            "not among the managed versions:\n{updated}"
2693        );
2694        assert!(at < build_start, "nor among a plugin's own:\n{updated}");
2695        // The BOM manages every JUnit artifact, so naming a version would
2696        // override the answer the project already gave.
2697        assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
2698    }
2699
2700    #[test]
2701    fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
2702        // A project is free to fail its build on any warning, and good ones
2703        // do. The copy holds code that project never wrote and never agreed a
2704        // style for -- and Error Prone goes further, failing at error severity
2705        // on rules about the shape of a method that no instrumentation can
2706        // satisfy.
2707        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";
2708        let updated = without_warnings_as_errors(pom).expect("a policy to relax");
2709        assert!(
2710            updated.contains("<failOnWarning>false</failOnWarning>"),
2711            "{updated}"
2712        );
2713        assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
2714        // Only the escalation goes: the compiler still compiles what it would
2715        // have, and everything else the project configured is untouched.
2716        assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
2717
2718        // A project with no such policy is left exactly as it is.
2719        assert_eq!(without_warnings_as_errors("<project></project>"), None);
2720
2721        // Failing on warnings and running a static analyser are separate
2722        // things, and a project may do either without the other.
2723        let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
2724        let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
2725        assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
2726
2727        let only_analyser =
2728            "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
2729        let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
2730        assert!(!updated.contains("ErrorProne"), "{updated}");
2731    }
2732
2733    #[test]
2734    fn vintage_carries_the_engine_version_not_the_platform_one() {
2735        // JUnit numbers the engines 5.N and the platform 1.N, so asking for
2736        // vintage 1.N asks for something that was never published and the
2737        // build stops at dependency resolution.
2738        assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
2739        assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
2740
2741        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";
2742        let updated =
2743            maven_with_vintage(pom, launcher_version(pom)).expect("a JUnit 4 project needs both");
2744        assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
2745        assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
2746        assert!(
2747            updated.contains("<groupId>org.junit.vintage</groupId>"),
2748            "{updated}"
2749        );
2750        // The launcher takes the platform version and the engine the Jupiter
2751        // one, in the same pom.
2752        assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
2753        assert!(
2754            updated.contains(&format!(
2755                "<version>{}</version>",
2756                engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
2757            )),
2758            "{updated}"
2759        );
2760
2761        // A project that already has both is left alone.
2762        assert_eq!(
2763            maven_with_vintage(&updated, launcher_version(&updated)),
2764            None
2765        );
2766    }
2767}