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/// A build file with its warnings-as-errors policy relaxed.
913///
914/// A project is free to fail its build on any warning, and several good ones
915/// do. The instrumented copy contains code that project never wrote and never
916/// agreed a style for, so its own policy would reject it -- gson's Error Prone
917/// configuration rejects a fully-qualified name, and nothing Supercov can emit
918/// satisfies every such rule. The Rust frontend caps lints for the same reason
919/// and in the same place: the copy, never the tree the author keeps.
920///
921/// Only the escalation is removed. The warnings are still emitted, the
922/// compiler still compiles exactly what it would have, and the author's own
923/// build is untouched.
924fn without_warnings_as_errors(build_file: &str) -> Option<String> {
925    let mut updated = build_file.to_owned();
926    for (from, to) in [
927        (
928            "<failOnWarning>true</failOnWarning>",
929            "<failOnWarning>false</failOnWarning>",
930        ),
931        (
932            "<failOnWarnings>true</failOnWarnings>",
933            "<failOnWarnings>false</failOnWarnings>",
934        ),
935        ("<arg>-Werror</arg>", ""),
936        ("<compilerArgument>-Werror</compilerArgument>", ""),
937        ("options.compilerArgs << '-Werror'", ""),
938        ("allWarningsAsErrors = true", "allWarningsAsErrors = false"),
939    ] {
940        updated = updated.replace(from, to);
941    }
942    updated = without_error_prone(&updated);
943    (updated != build_file).then_some(updated)
944}
945
946/// The same build file with Error Prone switched off.
947///
948/// Relaxing warnings is not enough on its own: Error Prone has checks that
949/// fail at error severity, and some are about the shape of a method rather
950/// than its meaning — an `@InlineMe` method must hold exactly one statement,
951/// and a probe makes two. No instrumentation can satisfy a rule like that,
952/// because the rule is about source the author wrote and the copy holds source
953/// they did not.
954///
955/// Switching the analyser off in the copy costs nothing: it says nothing about
956/// whether the tests pass, and the author's own build still runs it in full.
957fn without_error_prone(build_file: &str) -> String {
958    let Some(start) = build_file.find("<arg>-Xplugin:ErrorProne") else {
959        return build_file.to_owned();
960    };
961    let Some(end) = build_file[start..].find("</arg>") else {
962        return build_file.to_owned();
963    };
964    let mut updated = build_file.to_owned();
965    updated.replace_range(start..start + end + "</arg>".len(), "");
966    updated
967}
968
969/// Make the build run its tests again rather than reporting a cached result.
970///
971/// A test that does not run records nothing, and a run that measured half a
972/// suite without saying so is worse than one that took longer.
973fn command_with_fresh_results(
974    build: JvmBuild,
975    command: &[String],
976) -> (Vec<String>, Option<String>) {
977    let mut updated = command.to_vec();
978    match build {
979        JvmBuild::Gradle => {
980            if updated
981                .iter()
982                .any(|argument| argument == "--rerun-tasks" || argument == "--rerun")
983            {
984                return (updated, None);
985            }
986            updated.push("--rerun-tasks".into());
987            (
988                updated,
989                Some(
990                    "added --rerun-tasks: Gradle skips a test task it considers up to date, and a task that does not run records no coverage"
991                        .into(),
992                ),
993            )
994        }
995        // Maven's default lifecycle re-runs surefire every time, and Gradle's
996        // build cache has no Maven equivalent worth defeating here.
997        JvmBuild::Maven | JvmBuild::Plain => (updated, None),
998    }
999}
1000
1001/// One module of the build, and where its evidence lands.
1002///
1003/// A single-project build has exactly one of these, rooted at the workspace.
1004/// A multi-module build has one per module, because each module compiles only
1005/// its own source set and forks its own JVM to run its tests: a runtime
1006/// written once at the top would be invisible to every module, and one
1007/// evidence path shared by every module's JVM would be overwritten by
1008/// whichever finished last.
1009#[derive(Debug, Clone)]
1010struct JvmModule {
1011    /// Relative to the workspace, `/`-separated; `.` for the build root.
1012    directory: String,
1013    /// Where this module's JVMs write. A directory rather than a file: a build
1014    /// may fork more than one to run tests in parallel, and each writes under
1015    /// a name of its own so none overwrites another.
1016    evidence: PathBuf,
1017    has_tests: bool,
1018}
1019
1020struct InstrumentedWorkspace {
1021    project: PreparedJvmProject,
1022    build: JvmBuild,
1023    modules: Vec<JvmModule>,
1024    /// Which file declared each test class, so a result can point at a source.
1025    declared_in: BTreeMap<String, String>,
1026    /// The build file the launcher dependency was added to, if it was.
1027    added_launcher: Option<&'static str>,
1028    /// Modules whose own configuration asked for parallel execution and had it
1029    /// turned off in the copy, so the user hears that their suite ran a
1030    /// different way rather than discovering it from a test that now hangs.
1031    serialised: BTreeSet<String>,
1032    /// Modules left running their tests at once, where what overlaps cannot be
1033    /// credited to either test that produced it.
1034    concurrent: BTreeSet<String>,
1035    /// Whether a JUnit 4 module was given the engine that runs it on the
1036    /// platform, so the user hears that their suite ran a different way.
1037    added_vintage: bool,
1038    /// What had to be relaxed in the copy's build files for instrumented code
1039    /// to compile, named so the user knows rather than infers.
1040    relaxed: Vec<&'static str>,
1041    /// The build checks every file for a license header (Apache RAT), and
1042    /// the copy holds Supercov's own runtime and listeners, which carry none.
1043    license_check: bool,
1044    /// Modules Supercov instrumented but cannot attribute, and why they were
1045    /// left without a listener rather than broken by one.
1046    unmeasurable: Vec<String>,
1047    /// Modules whose tests are a named JPMS module, which cannot take a listener.
1048    modular: Vec<String>,
1049}
1050
1051/// The module a `src/main/...` or `src/test/...` path belongs to.
1052///
1053/// The build root for a single-project build, and the subdirectory holding
1054/// that source set otherwise. Derived from the paths themselves rather than
1055/// from the build file, because Maven's `<modules>` and Gradle's
1056/// `settings.gradle` say the same thing in two languages and the layout says
1057/// it in one.
1058fn module_of(relative: &str) -> String {
1059    match relative.find("src/") {
1060        Some(0) | None => ".".to_owned(),
1061        Some(at) => relative[..at].trim_end_matches('/').to_owned(),
1062    }
1063}
1064
1065/// The class a test's reported name belongs to, as JUnit names it:
1066/// `CalculatorTest#zeroIsNamed()`, or a DSL framework's own wording.
1067fn class_of(test_name: &str) -> Option<&str> {
1068    test_name.split('#').next().filter(|name| !name.is_empty())
1069}
1070
1071/// The test file that declares a class the framework named in full
1072/// (`com.example.FooTest`, `com.example.FooTest$Nested`): the file whose path
1073/// ends in the class's package path, else the only one with its name.
1074///
1075/// Keyed on the bare file name alone, no reported class ever matched -- JUnit
1076/// names classes in full -- so no JVM test had a file, and `tests affected`
1077/// had no tests to answer about.
1078fn declaring_file(declared_in: &BTreeMap<String, String>, class: &str) -> Option<String> {
1079    let top = class.split('$').next().unwrap_or(class);
1080    let path = top.replace('.', "/");
1081    let simple = top.rsplit('.').next().unwrap_or(top);
1082    let by_path = declared_in.values().find(|file| {
1083        file.rsplit_once('.').is_some_and(|(stem, _)| {
1084            stem.ends_with(&path) && stem[..stem.len() - path.len()].ends_with('/') || stem == path
1085        })
1086    });
1087    by_path.or_else(|| declared_in.get(simple)).cloned()
1088}
1089
1090fn instrument_workspace(
1091    workspace: &Path,
1092    evidence_directory: &Path,
1093    exact_attribution: bool,
1094    roots: Option<&crate::source_discovery::ExplicitSourceRoots>,
1095) -> Result<InstrumentedWorkspace, String> {
1096    let project = prepare_jvm_project(workspace, roots)?;
1097    let build = detect_build(workspace);
1098
1099    for (relative, instrumented) in &project.instrumented {
1100        write(&workspace.join(relative), instrumented)?;
1101    }
1102
1103    let probe_count = project
1104        .probes
1105        .keys()
1106        .max()
1107        .map_or(0, |highest| *highest as usize + 1);
1108
1109    // Which frameworks a module runs is a question about that module. A
1110    // repository can hold a JUnit 4 module beside a JUnit 5 one, and asking
1111    // the whole tree at once answers neither: the platform listener would go
1112    // into the JUnit 4 module, where it cannot work, and the launcher with it,
1113    // where it makes the build choose a provider that finds no engine.
1114    let catalog = version_catalog(workspace);
1115    let frameworks_of = |directory: &str| -> Frameworks {
1116        let names = ["pom.xml", "build.gradle.kts", "build.gradle"];
1117        let mut text = names
1118            .iter()
1119            .find_map(|name| fs::read_to_string(workspace.join(name)).ok())
1120            .unwrap_or_default();
1121        for name in names {
1122            if let Ok(own) = fs::read_to_string(workspace.join(directory).join(name)) {
1123                text.push('\n');
1124                text.push_str(&own);
1125                break;
1126            }
1127        }
1128        frameworks(&with_catalog(&text, &catalog))
1129    };
1130    let mut unmeasurable: Vec<String> = Vec::new();
1131    let mut modular: Vec<String> = Vec::new();
1132
1133    // One entry per module that has a main or a test source set, keyed by
1134    // directory so a module contributing both is listed once.
1135    let mut modules: BTreeMap<String, bool> = BTreeMap::new();
1136    for (relative, _) in &project.files.sources {
1137        modules.entry(module_of(relative)).or_insert(false);
1138    }
1139    for (relative, _) in &project.files.tests {
1140        *modules.entry(module_of(relative)).or_default() = true;
1141    }
1142    // A module's tests may be in a language Supercov does not parse -- Spock
1143    // writes them in Groovy, and Supercov measures the Java and Kotlin they
1144    // exercise rather than the specification itself. Those files are not in
1145    // `files.tests`, so the source sets are asked directly: a module judged to
1146    // have no tests gets no listener, and a run with no listener records
1147    // nothing at all.
1148    for module in modules.keys().cloned().collect::<Vec<_>>() {
1149        if workspace.join(&module).join("src/test").is_dir() {
1150            modules.insert(module, true);
1151        }
1152    }
1153    if modules.is_empty() {
1154        modules.insert(".".to_owned(), true);
1155    }
1156
1157    let modules = modules
1158        .into_iter()
1159        .map(|(directory, has_tests)| JvmModule {
1160            evidence: evidence_directory.join(
1161                directory
1162                    .chars()
1163                    .map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
1164                    .collect::<String>(),
1165            ),
1166            directory,
1167            has_tests,
1168        })
1169        .collect::<Vec<_>>();
1170
1171    for module in &modules {
1172        let frameworks = frameworks_of(&module.directory);
1173        let at = |source_set: &str| {
1174            workspace
1175                .join(&module.directory)
1176                .join(source_root(source_set))
1177                .join(PACKAGE_DIRECTORY)
1178        };
1179        // The runtime goes in every module's main source set: instrumented
1180        // product code stores into its array, and a module compiles only its
1181        // own sources. The class is identical everywhere, and each module's
1182        // tests fork a JVM that loads exactly one of them.
1183        write(
1184            &at("main").join("Supercov.java"),
1185            &runtime_source(probe_count),
1186        )?;
1187        if !module.has_tests {
1188            continue;
1189        }
1190        if frameworks.junit4 && build == JvmBuild::Plain {
1191            // Maven's and Gradle's copies get Vintage added below; a plain
1192            // tree has no build to add it to, so the module keeps its probes
1193            // and gets no listener, because attributing it is impossible and
1194            // trying would break it.
1195            unmeasurable.push(module.directory.clone());
1196            continue;
1197        }
1198        // A test source set with a module-info.java is a named JPMS module,
1199        // and a named module is closed: every package it holds is its own and
1200        // every dependency has to be declared in that file. A listener written
1201        // into it imports org.junit.platform, which the module does not
1202        // require, and reads the runtime from a package its main module does
1203        // not export -- so the module stops compiling and takes the build with
1204        // it. gson's test-jpms is exactly this, and it tests module boundaries
1205        // rather than product logic, so leaving it alone costs the report
1206        // nothing it could have had.
1207        if workspace
1208            .join(&module.directory)
1209            .join(source_root("test"))
1210            .join("module-info.java")
1211            .exists()
1212        {
1213            modular.push(module.directory.clone());
1214            continue;
1215        }
1216        // The listeners and their configuration go in the test source set,
1217        // because only the test classpath has the frameworks to listen to.
1218        let test = at("test");
1219        write(
1220            &test.join("SupercovConfig.java"),
1221            &configuration(probe_count, &project.decision_widths, &module.evidence),
1222        )?;
1223        if frameworks.platform || frameworks.junit4 {
1224            write(&test.join("SupercovListener.java"), LISTENER_SOURCE)?;
1225        }
1226        if frameworks.testng {
1227            write(
1228                &test.join("SupercovTestNGListener.java"),
1229                TESTNG_LISTENER_SOURCE,
1230            )?;
1231        }
1232    }
1233
1234    // A project's warning policy applies to code it wrote. The copy holds code
1235    // it did not.
1236    let mut relaxed: Vec<&'static str> = Vec::new();
1237    for name in ["pom.xml", "build.gradle.kts", "build.gradle"] {
1238        let path = workspace.join(name);
1239        let Ok(existing) = fs::read_to_string(&path) else {
1240            continue;
1241        };
1242        let Some(updated) = without_warnings_as_errors(&existing) else {
1243            continue;
1244        };
1245        // Named separately, because switching a static analyser off is a
1246        // bigger thing than not failing on a warning and the user should hear
1247        // it said rather than work it out.
1248        if !relaxed.contains(&"stopped the build failing on warnings")
1249            && updated.contains("<failOnWarning>false</failOnWarning>")
1250                != existing.contains("<failOnWarning>false</failOnWarning>")
1251            || existing.contains("-Werror") && !updated.contains("-Werror")
1252        {
1253            relaxed.push("stopped the build failing on warnings");
1254        }
1255        if existing.contains("Xplugin:ErrorProne") && !updated.contains("Xplugin:ErrorProne") {
1256            relaxed.push("switched Error Prone off");
1257        }
1258        write(&path, &updated)?;
1259    }
1260    relaxed.dedup();
1261    let license_check = build == JvmBuild::Maven && checks_license_headers(workspace);
1262
1263    // The platform listener is compiled from the project's own test sources,
1264    // so the launcher API it implements has to be on the compile classpath. A
1265    // TestNG-only project needs none of that: it already depends on the
1266    // framework its own listener implements.
1267    let mut added_launcher = None;
1268    let mut serialised: BTreeSet<String> = BTreeSet::new();
1269    let mut concurrent: BTreeSet<String> = BTreeSet::new();
1270    let mut added_vintage = false;
1271    match build {
1272        // Per module, not once at the top: the module's own pom is where its
1273        // JUnit version is in scope, and a module that runs JUnit 4 must not
1274        // get the launcher at all.
1275        JvmBuild::Maven => {
1276            for module in modules
1277                .iter()
1278                .filter(|module| {
1279                    module.has_tests
1280                        && !unmeasurable.contains(&module.directory)
1281                        && !modular.contains(&module.directory)
1282                })
1283                // A TestNG module needs none of this: it already depends on
1284                // the framework its own listener implements, and the launcher
1285                // would only change which provider the build chooses.
1286                .filter(|module| {
1287                    let frameworks = frameworks_of(&module.directory);
1288                    frameworks.platform || frameworks.junit4
1289                })
1290            {
1291                let pom = workspace.join(&module.directory).join("pom.xml");
1292                let Ok(existing) = fs::read_to_string(&pom) else {
1293                    continue;
1294                };
1295                // A JUnit 4 module also needs the engine that runs JUnit 4
1296                // tests on the platform; without it the launcher would find no
1297                // engine at all.
1298                let platform =
1299                    maven_launcher_version(&workspace.join(&module.directory), &existing);
1300                let updated = if frameworks_of(&module.directory).junit4 {
1301                    added_vintage = true;
1302                    maven_with_vintage(&existing, platform)
1303                } else {
1304                    maven_with_launcher(&existing, platform)
1305                };
1306                if let Some(updated) = updated {
1307                    write(&pom, &updated)?;
1308                    added_launcher = Some("pom.xml");
1309                }
1310            }
1311        }
1312        JvmBuild::Gradle
1313            if !modules.iter().any(|module| {
1314                let frameworks = frameworks_of(&module.directory);
1315                module.has_tests && (frameworks.platform || frameworks.junit4)
1316            }) => {}
1317        JvmBuild::Gradle => {
1318            // A module on TestNG as well keeps Gradle's TestNG framework: its
1319            // own listener attributes it.
1320            let junit4 = modules
1321                .iter()
1322                .filter(|module| {
1323                    let frameworks = frameworks_of(&module.directory);
1324                    module.has_tests
1325                        && frameworks.junit4
1326                        && !frameworks.testng
1327                        && !modular.contains(&module.directory)
1328                })
1329                .map(|module| module.directory.clone())
1330                .collect::<Vec<_>>();
1331            for name in ["build.gradle.kts", "build.gradle"] {
1332                let path = workspace.join(name);
1333                let Ok(existing) = fs::read_to_string(&path) else {
1334                    continue;
1335                };
1336                let catalog = fs::read_to_string(workspace.join("gradle/libs.versions.toml"))
1337                    .unwrap_or_default();
1338                if let Some(updated) =
1339                    gradle_with_launcher(&existing, name.ends_with(".kts"), &catalog, &junit4)
1340                {
1341                    added_vintage |= !junit4.is_empty();
1342                    write(&path, &updated)?;
1343                    added_launcher = Some(if name.ends_with(".kts") {
1344                        "build.gradle.kts"
1345                    } else {
1346                        "build.gradle"
1347                    });
1348                }
1349                break;
1350            }
1351        }
1352        // Nothing resolves dependencies for a plain tree; whoever compiles it
1353        // supplies the classpath.
1354        JvmBuild::Plain => {}
1355    }
1356
1357    for module in modules.iter().filter(|module| {
1358        module.has_tests
1359            && !unmeasurable.contains(&module.directory)
1360            && !modular.contains(&module.directory)
1361    }) {
1362        let frameworks = frameworks_of(&module.directory);
1363        let resources = workspace.join(&module.directory).join("src/test/resources");
1364        if frameworks.platform || frameworks.junit4 {
1365            write(
1366                &resources.join(SERVICES_FILE),
1367                &format!("{LISTENER_CLASS}\n"),
1368            )?;
1369            let properties = resources.join("junit-platform.properties");
1370            let existing = fs::read_to_string(&properties).ok();
1371            if !exact_attribution {
1372                // The suite runs the way its author wrote it. Where that means
1373                // tests run at once the runtime notices, says what it could
1374                // not credit, and the run records it -- which is a smaller
1375                // price than several times the suite's wall time, and not one
1376                // Supercov gets to spend on somebody's behalf.
1377                if asks_for_parallel_execution(existing.as_deref()) {
1378                    concurrent.insert(module.directory.clone());
1379                }
1380                continue;
1381            }
1382            // Said out loud, because it changes how the suite runs rather than
1383            // how it is measured. Every other thing Supercov does to the
1384            // workspace is announced -- the isolated copy, the added launcher,
1385            // `-count=1` over in Go -- and this is the one with a chance of
1386            // changing an outcome: a test that waits on another test's thread
1387            // passes under the project's own command and hangs under this one.
1388            if asks_for_parallel_execution(existing.as_deref()) {
1389                serialised.insert(module.directory.clone());
1390            }
1391            write(&properties, &sequential_properties(existing.as_deref()))?;
1392        }
1393        if frameworks.testng {
1394            write(
1395                &resources.join(TESTNG_SERVICES_FILE),
1396                &format!("{TESTNG_LISTENER_CLASS}\n"),
1397            )?;
1398        }
1399    }
1400
1401    // A test class's file, so a result can name where it came from. Matched on
1402    // the class rather than the test, because the name a framework reports for
1403    // a test is its own and need not be a method at all.
1404    let mut declared_in = BTreeMap::new();
1405    for (relative, _) in &project.files.tests {
1406        if let Some(stem) = relative
1407            .rsplit('/')
1408            .next()
1409            .and_then(|name| name.split('.').next())
1410        {
1411            declared_in.insert(stem.to_owned(), relative.clone());
1412        }
1413    }
1414
1415    Ok(InstrumentedWorkspace {
1416        project,
1417        build,
1418        modules,
1419        declared_in,
1420        added_launcher,
1421        serialised,
1422        concurrent,
1423        added_vintage,
1424        relaxed,
1425        license_check,
1426        unmeasurable,
1427        modular,
1428    })
1429}
1430
1431/// The fingerprint a later query compares against the stored run.
1432pub fn current_jvm_integrity(
1433    root: &Path,
1434    command: &[String],
1435    source_roots: Option<&[String]>,
1436) -> Result<crate::run_store::RunIntegrity, String> {
1437    let root = canonicalize_simplified(root).map_err(|error| error.to_string())?;
1438    let files = crate::jvm_project::discover_jvm_files(&root)?;
1439    let mut inputs = jvm_integrity_inputs(&files, command);
1440    crate::source_discovery::fold_roots_into_configuration(
1441        &mut inputs.execution_configuration,
1442        source_roots,
1443    );
1444    create_explicit_run_integrity(&root, &inputs, &FrontendIntegrityInputs::embedded_jvm())
1445        .map_err(|error| error.to_string())
1446}
1447
1448pub fn run_direct_jvm(
1449    request: &DirectJvmRunRequest,
1450    diagnostics: &mut dyn Write,
1451) -> Result<DirectJvmRunResult, String> {
1452    if request.command.is_empty() {
1453        return Err("test command must not be empty".into());
1454    }
1455    let total_started = Instant::now();
1456    let initialization_started = Instant::now();
1457    let root = canonicalize_simplified(&request.root)
1458        .map_err(|error| format!("{}: {error}", request.root.display()))?;
1459    let mut lock = ProjectLock::acquire(&root, &request.run_id, &request.started_at)
1460        .map_err(|error| error.to_string())?;
1461    let initialization_ms = elapsed_ms(initialization_started);
1462    let work_directory = root.join(".supercov/work").join(&request.run_id);
1463    // Whether the tests were measured. A run that failed before that has no
1464    // evidence worth keeping; one that failed after has evidence that cost the
1465    // suite its wall clock.
1466    let measured = std::cell::Cell::new(false);
1467    let result = (|| {
1468        let recovered_runs = recover_abandoned_runs(&root, &request.started_at)
1469            .map_err(|error| error.to_string())?;
1470        if !recovered_runs.is_empty() {
1471            writeln!(
1472                diagnostics,
1473                "[supercov] recovered abandoned run(s): {}",
1474                recovered_runs.join(", ")
1475            )
1476            .map_err(|error| error.to_string())?;
1477        }
1478
1479        let adapter_started = Instant::now();
1480        let files = crate::jvm_project::discover_jvm_files(&root)?;
1481        let source_roots = crate::source_discovery::configured_source_roots(
1482            &std::env::vars().collect::<std::collections::BTreeMap<_, _>>(),
1483        );
1484        let mut integrity_inputs = jvm_integrity_inputs(&files, &request.command);
1485        crate::source_discovery::fold_roots_into_configuration(
1486            &mut integrity_inputs.execution_configuration,
1487            source_roots.as_deref(),
1488        );
1489        let source_inputs =
1490            crate::source_capture::capture(&root, "jvm", integrity_inputs.assertion_paths())?;
1491        let integrity = create_explicit_run_integrity(
1492            &root,
1493            &integrity_inputs,
1494            &FrontendIntegrityInputs::embedded_jvm(),
1495        )
1496        .map_err(|error| error.to_string())?;
1497
1498        let workspace_started = Instant::now();
1499        recover_cached_workspace(&root, &lock).map_err(|error| error.to_string())?;
1500        let workspace =
1501            prepare_cached_workspace(&root, &lock, &[]).map_err(|error| error.to_string())?;
1502        let evidence_directory = work_directory.join("jvm");
1503        fs::create_dir_all(&evidence_directory).map_err(|error| error.to_string())?;
1504        let ambient = std::env::vars().collect::<std::collections::BTreeMap<_, _>>();
1505        let roots = crate::source_discovery::ExplicitSourceRoots::from_environment_in(
1506            &root, &workspace, &ambient,
1507        )
1508        .map_err(|error| error.to_string())?;
1509        let instrumented = instrument_workspace(
1510            &workspace,
1511            &evidence_directory,
1512            request.exact_attribution,
1513            roots.as_ref(),
1514        )?;
1515        let workspace_preparation_ms = elapsed_ms(workspace_started);
1516        let adapter_setup_ms = (elapsed_ms(adapter_started) - workspace_preparation_ms).max(0.0);
1517        writeln!(
1518            diagnostics,
1519            "[supercov] detected {}; instrumenting {} source file(s) in isolated workspace {}",
1520            match instrumented.build {
1521                JvmBuild::Maven => "a Maven project",
1522                JvmBuild::Gradle => "a Gradle project",
1523                JvmBuild::Plain => "Java/Kotlin sources",
1524            },
1525            instrumented.project.instrumented.len(),
1526            workspace.display()
1527        )
1528        .map_err(|error| error.to_string())?;
1529        if let Some(build_file) = instrumented.added_launcher {
1530            writeln!(
1531                diagnostics,
1532                "[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."
1533            )
1534            .map_err(|error| error.to_string())?;
1535        }
1536        if !instrumented.serialised.is_empty() {
1537            writeln!(
1538                diagnostics,
1539                "[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.",
1540                instrumented
1541                    .serialised
1542                    .iter()
1543                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1544                    .collect::<Vec<_>>()
1545                    .join(", ")
1546            )
1547            .map_err(|error| error.to_string())?;
1548        }
1549        if !instrumented.concurrent.is_empty() {
1550            writeln!(
1551                diagnostics,
1552                "[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.",
1553                instrumented
1554                    .concurrent
1555                    .iter()
1556                    .map(|module| if module.is_empty() { "." } else { module.as_str() })
1557                    .collect::<Vec<_>>()
1558                    .join(", ")
1559            )
1560            .map_err(|error| error.to_string())?;
1561        }
1562        if !instrumented.relaxed.is_empty() {
1563            writeln!(
1564                diagnostics,
1565                "[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.",
1566                instrumented.relaxed.join("; ")
1567            )
1568            .map_err(|error| error.to_string())?;
1569        }
1570        if instrumented.added_vintage {
1571            writeln!(
1572                diagnostics,
1573                "[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."
1574            )
1575            .map_err(|error| error.to_string())?;
1576        }
1577        if !instrumented.modular.is_empty() {
1578            writeln!(
1579                diagnostics,
1580                "[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.",
1581                instrumented.modular.len(),
1582                instrumented.modular.join(", ")
1583            )
1584            .map_err(|error| error.to_string())?;
1585        }
1586        if !instrumented.unmeasurable.is_empty() {
1587            writeln!(
1588                diagnostics,
1589                "[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.",
1590                instrumented.unmeasurable.len(),
1591                instrumented.unmeasurable.join(", ")
1592            )
1593            .map_err(|error| error.to_string())?;
1594        }
1595        for (file, reason) in &instrumented.project.unparseable {
1596            writeln!(
1597                diagnostics,
1598                "[supercov] could not parse {file}: {reason}; it carries no obligations"
1599            )
1600            .map_err(|error| error.to_string())?;
1601        }
1602
1603        let (mut command, note) = command_with_fresh_results(instrumented.build, &request.command);
1604        // Apache RAT fails the build on a file without a license header, and
1605        // Supercov's runtime and listeners in the copy are such files: every
1606        // Apache Commons project stopped before its first test.
1607        if instrumented.license_check && !command.iter().any(|a| a.contains("rat.skip")) {
1608            command.push("-Drat.skip=true".into());
1609            writeln!(
1610                diagnostics,
1611                "[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."
1612            )
1613            .map_err(|error| error.to_string())?;
1614        }
1615        if let Some(note) = note {
1616            writeln!(diagnostics, "[supercov] {note}").map_err(|error| error.to_string())?;
1617        }
1618        let test_started = Instant::now();
1619        let plan = ExecutionPlan {
1620            preparation: Vec::new(),
1621            test: ExecutionPhase {
1622                name: "test".into(),
1623                kind: PhaseKind::Test,
1624                command: CommandSpec {
1625                    program: command[0].clone().into(),
1626                    arguments: command[1..].iter().map(OsString::from).collect(),
1627                    cwd: workspace.clone(),
1628                    environment: None,
1629                    captured_output: None,
1630                    console: Some(crate::workspace::console_names(&root, &workspace)),
1631                },
1632            },
1633        };
1634        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1635        // What is in the copy now is Supercov's; what the command adds to
1636        // it or changes is the command's, and belongs in the project.
1637        let before = crate::workspace::workspace_output_baseline(&workspace)
1638            .map_err(|error| error.to_string())?;
1639        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1640            .map_err(|error| error.to_string())?;
1641        let test_command_ms = elapsed_ms(test_started);
1642        // `mvn test` left no `target/surefire-reports` in the project and
1643        // Gradle no `build/test-results`: a step after the tests that
1644        // publishes them found none. The reports flow back; what the build
1645        // compiled from the rewritten sources, and its own state, stay.
1646        let rewritten = instrumented
1647            .project
1648            .files
1649            .sources
1650            .iter()
1651            .chain(&instrumented.project.files.tests)
1652            .map(|(file, _)| PathBuf::from(file))
1653            .collect::<BTreeSet<_>>();
1654        let outputs = crate::workspace::sync_outputs_of(
1655            crate::workspace::CommandOutputs::Jvm,
1656            &root,
1657            &workspace,
1658            &before,
1659            &rewritten,
1660        )
1661        .map_err(|error| error.to_string())?;
1662        // What stayed is every class the build compiled: a count of them
1663        // tells nobody anything, so only what flowed back is said.
1664        for line in crate::workspace::describe_command_outputs(&outputs)
1665            .into_iter()
1666            .filter(|line| !line.contains("stayed in the isolated workspace"))
1667        {
1668            writeln!(diagnostics, "{line}").map_err(|error| error.to_string())?;
1669        }
1670        if let Some(signal) = execution.interrupted_signal {
1671            return Err(format!(
1672                "the test command was interrupted by {signal:?}; no run was published"
1673            ));
1674        }
1675        let exit_code = execution.exit_code;
1676
1677        let publication_started = Instant::now();
1678        // One JVM per module, so one evidence file per module.
1679        let mut parts = Vec::new();
1680        let mut outcomes = Vec::new();
1681        let mut silent = Vec::new();
1682        let named = named_modules(instrumented.build, &request.command);
1683        for module in instrumented
1684            .modules
1685            .iter()
1686            .filter(|module| module.has_tests)
1687        {
1688            // Every JVM the build forked for this module wrote its own file.
1689            let mut written = fs::read_dir(&module.evidence)
1690                .map(|entries| {
1691                    entries
1692                        .flatten()
1693                        .map(|entry| entry.path())
1694                        .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1695                        .collect::<Vec<_>>()
1696                })
1697                .unwrap_or_default();
1698            written.sort();
1699            if written.is_empty() {
1700                // A module the command never asked to run has nothing to
1701                // say for itself.
1702                if named
1703                    .as_ref()
1704                    .is_none_or(|named| named.contains(&module.directory))
1705                {
1706                    silent.push(module.directory.clone());
1707                }
1708                continue;
1709            }
1710            let mut forked = Vec::new();
1711            for path in &written {
1712                let bytes =
1713                    fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1714                forked.push(
1715                    read_evidence(&bytes)
1716                        .map_err(|error| format!("{}: {error}", path.display()))?,
1717                );
1718            }
1719            let evidence = merge_evidence(forked);
1720            for test in &evidence.tests {
1721                outcomes.push(OwnedTestOutcome {
1722                    name: test.name.clone(),
1723                    runner: test.runner.clone(),
1724                    // The module is the unit that forked a JVM of its own, so
1725                    // it is what a worker identity means here. The class is
1726                    // already in the name the framework reported.
1727                    package: module.directory.clone(),
1728                    file: class_of(&test.name)
1729                        .and_then(|class| declaring_file(&instrumented.declared_in, class)),
1730                    status: test.status.clone(),
1731                    // True while the suite runs its tests one at a time, which
1732                    // is what `--exact-attribution` asks for. Left to run as
1733                    // written, JUnit may run them at once; the runtime says so
1734                    // per record, and a test that overlapped is credited with
1735                    // nothing rather than with a neighbour's work.
1736                    attributed: !test.unattributed,
1737                });
1738            }
1739            parts.push(evidence);
1740        }
1741        if !silent.is_empty() {
1742            writeln!(
1743                diagnostics,
1744                "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1745                silent.len(),
1746                silent.join(", ")
1747            )
1748            .map_err(|error| error.to_string())?;
1749        }
1750        if outcomes.is_empty() {
1751            return Err(format!(
1752                "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."
1753            ));
1754        }
1755        let evidence = merge_evidence(parts);
1756        measured.set(true);
1757        let run = build_frontend_run(OwnedRunInputs {
1758            declaration: jvm_declaration(),
1759            environment: "jvm",
1760            manifest: &instrumented.project.manifest,
1761            probes: &instrumented.project.probes,
1762            evidence: &evidence,
1763            outcomes: &outcomes,
1764            run_id: &request.run_id,
1765            generated_at: &request.started_at,
1766            test_exit_code: exit_code,
1767            coverage_model: jvm_coverage_model(),
1768        })
1769        .map_err(|error| error.to_string())?;
1770        validate_frontend_report_request(&run.declaration, &run.request)
1771            .map_err(|error| error.to_string())?;
1772        let archive_path = work_directory.join("evidence.raw.gz");
1773        let raw = write_archive(
1774            crate::source_capture::append(
1775                run.archive_entries().map_err(|error| error.to_string())?,
1776                &source_inputs,
1777            )?,
1778            &archive_path,
1779        )
1780        .map_err(|error| error.to_string())?;
1781        let evidence_publication_ms = elapsed_ms(publication_started);
1782
1783        let timings = RunTimings {
1784            initialization_ms,
1785            workspace_preparation_ms,
1786            adapter_setup_ms,
1787            instrumented_build_ms: 0.0,
1788            test_command_ms,
1789            evidence_publication_ms,
1790        };
1791        let metadata = RunMetadata {
1792            id: request.run_id.clone(),
1793            started_at: request.started_at.clone(),
1794            duration_ms: elapsed_ms(total_started),
1795            command: request.command.clone(),
1796            test_exit_code: Some(exit_code),
1797            integrity,
1798            raw_evidence: RawEvidenceMetadata {
1799                schema_version: raw.schema_version,
1800                format: raw.format.into(),
1801                file: raw.file.into(),
1802                files: raw.files,
1803                uncompressed_bytes: raw.uncompressed_bytes,
1804                compressed_bytes: raw.compressed_bytes,
1805            },
1806            isolated_build: Some(true),
1807            instrumented_build_cache: None,
1808            timings: Some(timings),
1809            merged: None,
1810            parents: None,
1811            source_roots: source_roots.clone(),
1812            copy: Some(lock.copy()),
1813        };
1814        let run_directory =
1815            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1816        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1817        Ok(DirectJvmRunResult {
1818            run_id: request.run_id.clone(),
1819            run_directory,
1820            exit_code,
1821            tests: outcomes
1822                .iter()
1823                .map(|outcome| outcome.name.as_str())
1824                .collect::<BTreeSet<_>>()
1825                .len(),
1826            source_files: instrumented.project.instrumented.len(),
1827            modules: instrumented.modules.len(),
1828            build: instrumented.build,
1829            recovered_runs,
1830            metadata,
1831        })
1832    })();
1833    let result = result.map_err(|error| {
1834        if !measured.get() {
1835            return error;
1836        }
1837        note_kept_evidence(&root, &work_directory.join("jvm"), &request.run_id, error)
1838    });
1839    if result.is_err() {
1840        let _ = remove_stored_tree_deferred(&root, &work_directory);
1841    }
1842    let release = lock.release().map_err(|error| error.to_string());
1843    match (result, release) {
1844        (Ok(result), Ok(())) => Ok(result),
1845        (Err(error), _) => Err(error),
1846        (Ok(_), Err(error)) => Err(error),
1847    }
1848}
1849
1850#[cfg(test)]
1851mod tests {
1852    use super::*;
1853
1854    #[test]
1855    fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
1856        // The line is worth printing when something was taken away and noise
1857        // when nothing was: most projects never enable it, and a diagnostic on
1858        // every run is one nobody reads by the time it matters.
1859        assert!(asks_for_parallel_execution(Some(
1860            "junit.jupiter.execution.parallel.enabled=true\n"
1861        )));
1862        assert!(asks_for_parallel_execution(Some(
1863            "  junit.jupiter.execution.parallel.enabled = True  \n"
1864        )));
1865        assert!(!asks_for_parallel_execution(Some(
1866            "junit.jupiter.execution.parallel.enabled=false\n"
1867        )));
1868        // Naming the mode without enabling it leaves the suite sequential, so
1869        // nothing was taken away.
1870        assert!(!asks_for_parallel_execution(Some(
1871            "junit.jupiter.execution.parallel.mode.default=concurrent\n"
1872        )));
1873        assert!(!asks_for_parallel_execution(None));
1874        assert!(!asks_for_parallel_execution(Some("")));
1875    }
1876
1877    #[test]
1878    fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1879        // Two tests running at once share one probe array, so neither can be
1880        // credited with what it reached. The runtime notices and says so; this
1881        // is the half that means it never has to.
1882        let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1883                        junit.jupiter.execution.parallel.enabled=true\n\
1884                        junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1885        let updated = sequential_properties(Some(existing));
1886        assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1887        assert!(
1888            !updated.contains("parallel.enabled=true"),
1889            "the project's own setting must not survive:\n{updated}"
1890        );
1891        // Everything the project chose for reasons of its own stays.
1892        assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1893        assert!(updated.contains("displayname.generator.default"));
1894
1895        // And a project with no properties at all still gets the setting.
1896        assert!(
1897            sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1898        );
1899    }
1900
1901    #[test]
1902    fn gradle_is_told_to_run_the_tests_again() {
1903        // Gradle skips a test task it considers up to date, and a task that
1904        // does not run records nothing.
1905        let (command, note) = command_with_fresh_results(
1906            JvmBuild::Gradle,
1907            &["./gradlew".to_owned(), "test".to_owned()],
1908        );
1909        assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1910        assert!(note.is_some());
1911
1912        // An author who already said so means it.
1913        let (command, note) = command_with_fresh_results(
1914            JvmBuild::Gradle,
1915            &[
1916                "./gradlew".to_owned(),
1917                "test".to_owned(),
1918                "--rerun".to_owned(),
1919            ],
1920        );
1921        assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1922        assert!(note.is_none());
1923
1924        // Maven's lifecycle re-runs surefire every time; nothing to defeat.
1925        let (command, note) =
1926            command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1927        assert_eq!(command, ["mvn", "test"]);
1928        assert!(note.is_none());
1929    }
1930
1931    #[test]
1932    fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1933        // A Windows path is full of backslashes, and one of them landing
1934        // before a `t` or a `"` would change the path or end the literal.
1935        let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1936        assert!(
1937            configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1938            "{configuration}"
1939        );
1940        assert!(configuration.contains("PROBES = 7"));
1941        assert!(configuration.contains("new int[] {2, 3}"));
1942    }
1943
1944    #[test]
1945    fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1946        // JUnit reports `CalculatorTest#zeroIsNamed()`; a DSL framework
1947        // reports its own wording under the same class.
1948        assert_eq!(
1949            class_of("CalculatorTest#zeroIsNamed()"),
1950            Some("CalculatorTest")
1951        );
1952        assert_eq!(
1953            class_of("CalculatorSpec#a sum adds its parts"),
1954            Some("CalculatorSpec")
1955        );
1956        assert_eq!(class_of("Standalone"), Some("Standalone"));
1957        assert_eq!(class_of(""), None);
1958    }
1959
1960    #[test]
1961    fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1962        // JUnit numbers the platform 1.N alongside Jupiter 5.N, so a pinned
1963        // Jupiter says exactly which launcher agrees with the engine that will
1964        // run. Guessing instead could pair a launcher with an engine it does
1965        // not understand.
1966        assert_eq!(
1967            launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1968                .as_deref(),
1969            Some("1.10.2")
1970        );
1971        assert_eq!(
1972            launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1973                .as_deref(),
1974            Some("1.13.1")
1975        );
1976        // A project using the BOM has already said how every JUnit artifact is
1977        // versioned; naming one would override the answer it gave.
1978        assert_eq!(
1979            launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1980            None
1981        );
1982        // And one that says nothing gets a launcher new enough to drive an
1983        // older engine, which is the direction that works.
1984        assert_eq!(
1985            launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1986            Some(DEFAULT_LAUNCHER_VERSION)
1987        );
1988        // Including a file that names no JUnit artifact at all -- an
1989        // aggregating parent, say, whose children each declare their own.
1990        // Reading that as "a BOM manages it" writes a versionless dependency
1991        // into a pom with no BOM to resolve it, and the build stops before a
1992        // single test runs.
1993        assert_eq!(
1994            launcher_version("<artifactId>parent</artifactId>").as_deref(),
1995            Some(DEFAULT_LAUNCHER_VERSION)
1996        );
1997        assert_eq!(
1998            launcher_version("").as_deref(),
1999            Some(DEFAULT_LAUNCHER_VERSION)
2000        );
2001    }
2002
2003    #[test]
2004    fn junit_is_versioned_through_parents_catalogs_and_junit_6() {
2005        // JUnit 6 numbers every artifact alike.
2006        assert_eq!(
2007            launcher_version("<artifactId>junit-jupiter</artifactId><version>6.0.1</version>")
2008                .as_deref(),
2009            Some("6.0.1")
2010        );
2011        assert_eq!(engine_version_of_platform("6.0.1"), "6.0.1");
2012        // A version is read from the Jupiter dependency, not from whatever
2013        // `5.` comes next in the file.
2014        assert_eq!(
2015            launcher_version("<artifactId>junit-jupiter</artifactId><version>${junit}</version><plugin><version>5.2.1</version></plugin>")
2016                .as_deref(),
2017            Some(DEFAULT_LAUNCHER_VERSION)
2018        );
2019        // A version catalog, by reference.
2020        let catalog = "[versions]\njunit = \"5.11.3\"\n\n[libraries]\njunit-jupiter = { module = \"org.junit.jupiter:junit-jupiter\", version.ref = \"junit\" }\n";
2021        let updated = gradle_with_launcher("plugins { id 'java' }\n", false, catalog, &[]).unwrap();
2022        assert!(
2023            updated.contains("junit-platform-launcher:1.11.3"),
2024            "{updated}"
2025        );
2026
2027        // A parent on disk that imports the BOM, and one that sets a property.
2028        let root = std::env::temp_dir().join(format!(
2029            "supercov-junit-parents-{}-{}",
2030            std::process::id(),
2031            std::time::SystemTime::now()
2032                .duration_since(std::time::UNIX_EPOCH)
2033                .unwrap()
2034                .as_nanos()
2035        ));
2036        let module = root.join("module");
2037        fs::create_dir_all(&module).unwrap();
2038        let child = "<project><parent><groupId>g</groupId><artifactId>p</artifactId><version>1</version></parent><dependencies><dependency><artifactId>junit-jupiter-api</artifactId></dependency></dependencies></project>";
2039        fs::write(
2040            root.join("pom.xml"),
2041            "<project><artifactId>p</artifactId><dependencyManagement><dependencies><dependency><artifactId>junit-bom</artifactId><version>${junit.version}</version></dependency></dependencies></dependencyManagement></project>",
2042        )
2043        .unwrap();
2044        assert_eq!(maven_launcher_version(&module, child), None);
2045        fs::write(
2046            root.join("pom.xml"),
2047            "<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>",
2048        )
2049        .unwrap();
2050        assert_eq!(
2051            maven_launcher_version(&module, child).as_deref(),
2052            Some("1.14.4")
2053        );
2054        // A parent that cannot be read manages the version it left out.
2055        fs::remove_file(root.join("pom.xml")).unwrap();
2056        let unknown = child.replace(
2057            "<artifactId>p</artifactId>",
2058            "<artifactId>not-downloaded</artifactId>",
2059        );
2060        assert_eq!(maven_launcher_version(&module, &unknown), None);
2061        fs::remove_dir_all(&root).ok();
2062    }
2063
2064    #[test]
2065    fn the_launcher_is_added_once_and_only_where_it_is_missing() {
2066        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";
2067        let updated =
2068            maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2069        assert!(updated.contains("junit-platform-launcher"), "{updated}");
2070        assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
2071        // Inside the existing block, not after it.
2072        assert!(
2073            updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
2074            "{updated}"
2075        );
2076        // A project that already has it is left exactly as it is.
2077        assert_eq!(
2078            maven_with_launcher(&updated, launcher_version(&updated)),
2079            None
2080        );
2081
2082        // And one with no dependencies block at all still gets a valid pom.
2083        let bare = "<project>\n  <artifactId>demo</artifactId>\n</project>\n";
2084        let updated =
2085            maven_with_launcher(bare, launcher_version(bare)).expect("a block is created");
2086        assert!(updated.contains("<dependencies>"), "{updated}");
2087        assert!(
2088            updated.find("</dependencies>") < updated.find("</project>"),
2089            "{updated}"
2090        );
2091    }
2092
2093    #[test]
2094    fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
2095        let groovy =
2096            "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
2097        let updated =
2098            gradle_with_launcher(groovy, false, "", &[]).expect("the launcher is missing");
2099        assert!(
2100            updated
2101                .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
2102            "{updated}"
2103        );
2104        // The project's own block survives: Gradle merges what we append.
2105        assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
2106        assert_eq!(gradle_with_launcher(&updated, false, "", &[]), None);
2107
2108        // Gradle 9 makes every project declare the launcher, and the
2109        // configuration its own documentation recommends is testRuntimeOnly,
2110        // which the tests run with but do not compile against. A project
2111        // following that advice has the artifact and still cannot compile a
2112        // listener, so it gets a compile-visible declaration alongside.
2113        let runtime_only = "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
2114        let updated = gradle_with_launcher(runtime_only, false, "", &[])
2115            .expect("a runtime-only declaration does not reach the compiler");
2116        assert!(
2117            updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
2118            "{updated}"
2119        );
2120        assert!(
2121            updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
2122            "the project's own declaration stays:\n{updated}"
2123        );
2124
2125        let kotlin = "dependencies {\n    testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
2126        let updated = gradle_with_launcher(kotlin, true, "", &[]).expect("the launcher is missing");
2127        assert!(
2128            updated.contains(
2129                "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
2130            ),
2131            "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
2132        );
2133        assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
2134    }
2135
2136    /// apache/commons-cli takes RAT from commons-parent, and RAT failed the
2137    /// copy on Supercov's own files before any test ran.
2138    #[test]
2139    fn a_test_class_named_in_full_finds_its_file() {
2140        let declared_in = BTreeMap::from([
2141            (
2142                "AnnotationsTest".to_owned(),
2143                "src/test/java/com/fasterxml/classmate/AnnotationsTest.java".to_owned(),
2144            ),
2145            (
2146                "CalculatorSpec".to_owned(),
2147                "src/test/kotlin/app/CalculatorSpec.kt".to_owned(),
2148            ),
2149        ]);
2150        assert_eq!(
2151            declaring_file(&declared_in, "com.fasterxml.classmate.AnnotationsTest").as_deref(),
2152            Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2153        );
2154        assert_eq!(
2155            declaring_file(
2156                &declared_in,
2157                "com.fasterxml.classmate.AnnotationsTest$Nested"
2158            )
2159            .as_deref(),
2160            Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2161        );
2162        assert_eq!(
2163            declaring_file(&declared_in, "app.CalculatorSpec").as_deref(),
2164            Some("src/test/kotlin/app/CalculatorSpec.kt")
2165        );
2166        // A class reported without its package still finds its file by name.
2167        assert_eq!(
2168            declaring_file(&declared_in, "CalculatorSpec").as_deref(),
2169            Some("src/test/kotlin/app/CalculatorSpec.kt")
2170        );
2171        assert_eq!(declaring_file(&declared_in, "org.other.Missing"), None);
2172    }
2173
2174    #[test]
2175    fn a_license_header_check_is_found_through_the_parent_pom() {
2176        let root = std::env::temp_dir().join(format!(
2177            "supercov-rat-{}-{}",
2178            std::process::id(),
2179            std::time::SystemTime::now()
2180                .duration_since(std::time::UNIX_EPOCH)
2181                .unwrap()
2182                .as_nanos()
2183        ));
2184        fs::create_dir_all(root.join("parent")).unwrap();
2185        fs::write(
2186            root.join("parent/pom.xml"),
2187            "<project><artifactId>parent</artifactId><build><plugins><plugin><groupId>org.apache.rat</groupId><artifactId>apache-rat-plugin</artifactId></plugin></plugins></build></project>",
2188        )
2189        .unwrap();
2190        fs::write(
2191            root.join("pom.xml"),
2192            "<project><parent><groupId>g</groupId><artifactId>parent</artifactId><version>1</version><relativePath>parent/pom.xml</relativePath></parent></project>",
2193        )
2194        .unwrap();
2195        assert!(checks_license_headers(&root));
2196        fs::write(
2197            root.join("pom.xml"),
2198            "<project><artifactId>plain</artifactId></project>",
2199        )
2200        .unwrap();
2201        assert!(!checks_license_headers(&root));
2202        fs::remove_dir_all(&root).ok();
2203    }
2204
2205    #[test]
2206    fn only_the_modules_a_command_names_are_expected_to_write_evidence() {
2207        let words = |c: &str| c.split(' ').map(str::to_owned).collect::<Vec<_>>();
2208        assert_eq!(
2209            named_modules(
2210                JvmBuild::Gradle,
2211                &words("./gradlew :moshi:test --tests *Reader*")
2212            ),
2213            Some(BTreeSet::from(["moshi".to_owned()]))
2214        );
2215        assert_eq!(
2216            named_modules(
2217                JvmBuild::Gradle,
2218                &words("gradle :moshi:records-tests:test :test")
2219            ),
2220            Some(BTreeSet::from([
2221                "moshi/records-tests".to_owned(),
2222                ".".to_owned()
2223            ]))
2224        );
2225        assert_eq!(named_modules(JvmBuild::Gradle, &words("gradle test")), None);
2226        assert_eq!(
2227            named_modules(JvmBuild::Gradle, &words("gradle test -x :slow:test")),
2228            None
2229        );
2230        assert_eq!(
2231            named_modules(JvmBuild::Maven, &words("mvn -pl core,api/ test")),
2232            Some(BTreeSet::from(["core".to_owned(), "api".to_owned()]))
2233        );
2234        assert_eq!(
2235            named_modules(JvmBuild::Maven, &words("mvn -pl :core test")),
2236            None
2237        );
2238        assert_eq!(named_modules(JvmBuild::Maven, &words("mvn test")), None);
2239    }
2240
2241    #[test]
2242    fn gradle_moves_junit_4_projects_onto_the_platform_through_vintage() {
2243        let kotlin = "dependencies {\n    testImplementation(\"junit:junit:4.13.2\")\n}\n";
2244        let updated = gradle_with_launcher(kotlin, true, "", &[".".into(), "moshi".into()])
2245            .expect("a JUnit 4 project needs Vintage");
2246        assert!(
2247            updated
2248                .contains("\"testRuntimeOnly\"(\"org.junit.vintage:junit-vintage-engine:5.10.2\")"),
2249            "{updated}"
2250        );
2251        assert!(updated.contains("in listOf(\"\", \"moshi\")"), "{updated}");
2252        assert!(
2253            updated.contains("if (options is JUnitOptions) useJUnitPlatform()"),
2254            "only a task still on Gradle's JUnit 4 framework is switched:\n{updated}"
2255        );
2256
2257        // Already compiling against the launcher, Vintage is still added.
2258        let groovy = "dependencies {\n    testImplementation 'junit:junit:4.13.2'\n    testImplementation 'org.junit.platform:junit-platform-launcher:1.13.1'\n}\n";
2259        let updated = gradle_with_launcher(groovy, false, "", &["core".into()])
2260            .expect("a JUnit 4 project needs Vintage");
2261        assert!(
2262            updated.contains("testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.2'"),
2263            "{updated}"
2264        );
2265        assert!(updated.contains("in [\"core\"]"), "{updated}");
2266        assert!(
2267            updated.contains("tasks.withType(Test).configureEach"),
2268            "{updated}"
2269        );
2270        assert!(
2271            !updated.contains("\n            testImplementation 'org.junit.platform"),
2272            "the launcher is already there:\n{updated}"
2273        );
2274    }
2275
2276    #[test]
2277    fn only_the_listeners_a_project_can_compile_are_written() {
2278        // Each listener is compiled from the project's own test sources, so
2279        // one whose framework is absent would fail on imports the project
2280        // never asked for.
2281        let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
2282        assert!(junit.platform && !junit.testng);
2283
2284        let testng = frameworks("<artifactId>testng</artifactId>");
2285        assert!(testng.testng && !testng.platform);
2286
2287        // A migration in progress runs both, and both listeners fire in one
2288        // JVM against one runtime.
2289        let both = frameworks("testng ... junit-jupiter");
2290        assert!(both.platform && both.testng);
2291
2292        // Kotest and Spock are platform engines, so the platform listener
2293        // reports their tests without either being named.
2294        assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
2295        assert!(frameworks("org.spockframework:spock-core").platform);
2296
2297        // And a project naming nothing recognisable gets the platform, which
2298        // is what nearly every JVM suite runs on.
2299        let unknown = frameworks("<artifactId>demo</artifactId>");
2300        assert!(unknown.platform && !unknown.testng);
2301    }
2302
2303    #[test]
2304    fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
2305        // Surefire picks its provider from what is on the classpath. Adding
2306        // the platform launcher to a JUnit 4 project makes it choose the
2307        // platform provider, find no engine there, and fail the suite --
2308        // Supercov breaking a build it was asked to measure. The word "junit"
2309        // appears in both, so the artifact is what tells them apart.
2310        let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
2311        assert!(four.junit4 && !four.platform && !four.testng);
2312
2313        let five = frameworks("<artifactId>junit-jupiter</artifactId>");
2314        assert!(five.platform && !five.junit4);
2315
2316        // Vintage runs JUnit 4 tests on the platform, so a project with both
2317        // is a platform project.
2318        let both = frameworks(
2319            "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
2320        );
2321        assert!(both.platform && !both.junit4);
2322
2323        // Gradle spells its dependencies differently and means the same.
2324        assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
2325        assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
2326        assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
2327    }
2328
2329    #[test]
2330    fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
2331        // gson's test-jpms declares `module com.google.gson.jpms_test`, which
2332        // requires com.google.gson, junit and truth and nothing else. A
2333        // listener written into it imports org.junit.platform -- not visible
2334        // -- and reads a runtime from a package the main module does not
2335        // export. The module stops compiling and takes the reactor with it,
2336        // for tests that check module boundaries rather than product logic.
2337        let root = std::env::temp_dir().join(format!(
2338            "supercov-jpms-{}-{}",
2339            std::process::id(),
2340            std::time::SystemTime::now()
2341                .duration_since(std::time::UNIX_EPOCH)
2342                .unwrap()
2343                .as_nanos()
2344        ));
2345        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();
2346        for module in ["lib", "boundaries"] {
2347            write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
2348            write(
2349                &root.join(module).join("src/main/java/app/Api.java"),
2350                "package app;\npublic class Api { public int one() { return 1; } }\n",
2351            )
2352            .unwrap();
2353            write(
2354                &root.join(module).join("src/test/java/app/ApiTest.java"),
2355                "package app;\nclass ApiTest { void t() {} }\n",
2356            )
2357            .unwrap();
2358        }
2359        write(
2360            &root.join("boundaries/src/test/java/module-info.java"),
2361            "module app.boundaries {\n  requires app.lib;\n}\n",
2362        )
2363        .unwrap();
2364
2365        let evidence = root.join("evidence");
2366        let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
2367        assert_eq!(instrumented.modular, ["boundaries"]);
2368
2369        // The listener goes into the ordinary module and not the named one.
2370        assert!(
2371            root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
2372                .exists()
2373        );
2374        for name in ["SupercovListener.java", "SupercovConfig.java"] {
2375            assert!(
2376                !root
2377                    .join("boundaries/src/test/java/com/supercorp/supercov")
2378                    .join(name)
2379                    .exists(),
2380                "{name} must not be written into a named module"
2381            );
2382        }
2383        assert!(
2384            !root
2385                .join("boundaries/src/test/resources")
2386                .join(SERVICES_FILE)
2387                .exists(),
2388            "and nothing registers a listener that is not there"
2389        );
2390        // Its product code is still instrumented, and the runtime it stores
2391        // into is a package of the module's own -- which a named module may
2392        // hold without exporting.
2393        assert!(
2394            root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
2395                .exists()
2396        );
2397
2398        fs::remove_dir_all(root).ok();
2399    }
2400
2401    #[test]
2402    fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
2403        // moshi is JUnit 4 and writes `testImplementation(libs.junit)`. Read
2404        // without the catalog the build file names no framework at all, the
2405        // fallback takes it for a platform project, and a listener goes in
2406        // that nothing will ever call: the suite passes and records nothing.
2407        let root = std::env::temp_dir().join(format!(
2408            "supercov-catalog-{}-{}",
2409            std::process::id(),
2410            std::time::SystemTime::now()
2411                .duration_since(std::time::UNIX_EPOCH)
2412                .unwrap()
2413                .as_nanos()
2414        ));
2415        write(
2416            &root.join("gradle/libs.versions.toml"),
2417            "[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",
2418        )
2419        .unwrap();
2420        let catalog = version_catalog(&root);
2421
2422        let junit4 = frameworks(&with_catalog(
2423            "dependencies { testImplementation(libs.junit) }",
2424            &catalog,
2425        ));
2426        assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
2427
2428        // An accessor is a prefix of a longer one, and must not answer for it.
2429        let reflect = frameworks(&with_catalog(
2430            "dependencies { testImplementation(libs.kotlin.reflect) }",
2431            &catalog,
2432        ));
2433        assert!(!reflect.junit4, "{reflect:?}");
2434
2435        let jupiter = frameworks(&with_catalog(
2436            "dependencies { testImplementation(libs.jupiter) }",
2437            &catalog,
2438        ));
2439        assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
2440
2441        // A bundle stands for every library in it.
2442        let bundle = frameworks(&with_catalog(
2443            "dependencies { testImplementation(libs.bundles.unit) }",
2444            &catalog,
2445        ));
2446        assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
2447
2448        // A build that names no accessor is unchanged by a catalog.
2449        let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
2450        assert_eq!(with_catalog(plain, &catalog), plain);
2451
2452        fs::remove_dir_all(root).ok();
2453    }
2454
2455    #[test]
2456    fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
2457        // A pom's <dependencyManagement> holds a <dependencies> too, and so
2458        // does every profile and plugin. A dependency added there is a version
2459        // for something else to ask for rather than something the project
2460        // depends on: the module compiles exactly as before and the listener
2461        // still cannot find the API it implements.
2462        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";
2463        let updated =
2464            maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2465        let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
2466        let managed_end = updated
2467            .find("</dependencyManagement>")
2468            .expect("managed block");
2469        let build_start = updated.find("<build>").expect("build block");
2470        assert!(
2471            at > managed_end,
2472            "not among the managed versions:\n{updated}"
2473        );
2474        assert!(at < build_start, "nor among a plugin's own:\n{updated}");
2475        // The BOM manages every JUnit artifact, so naming a version would
2476        // override the answer the project already gave.
2477        assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
2478    }
2479
2480    #[test]
2481    fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
2482        // A project is free to fail its build on any warning, and good ones
2483        // do. The copy holds code that project never wrote and never agreed a
2484        // style for -- and Error Prone goes further, failing at error severity
2485        // on rules about the shape of a method that no instrumentation can
2486        // satisfy.
2487        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";
2488        let updated = without_warnings_as_errors(pom).expect("a policy to relax");
2489        assert!(
2490            updated.contains("<failOnWarning>false</failOnWarning>"),
2491            "{updated}"
2492        );
2493        assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
2494        // Only the escalation goes: the compiler still compiles what it would
2495        // have, and everything else the project configured is untouched.
2496        assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
2497
2498        // A project with no such policy is left exactly as it is.
2499        assert_eq!(without_warnings_as_errors("<project></project>"), None);
2500
2501        // Failing on warnings and running a static analyser are separate
2502        // things, and a project may do either without the other.
2503        let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
2504        let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
2505        assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
2506
2507        let only_analyser =
2508            "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
2509        let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
2510        assert!(!updated.contains("ErrorProne"), "{updated}");
2511    }
2512
2513    #[test]
2514    fn vintage_carries_the_engine_version_not_the_platform_one() {
2515        // JUnit numbers the engines 5.N and the platform 1.N, so asking for
2516        // vintage 1.N asks for something that was never published and the
2517        // build stops at dependency resolution.
2518        assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
2519        assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
2520
2521        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";
2522        let updated =
2523            maven_with_vintage(pom, launcher_version(pom)).expect("a JUnit 4 project needs both");
2524        assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
2525        assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
2526        assert!(
2527            updated.contains("<groupId>org.junit.vintage</groupId>"),
2528            "{updated}"
2529        );
2530        // The launcher takes the platform version and the engine the Jupiter
2531        // one, in the same pom.
2532        assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
2533        assert!(
2534            updated.contains(&format!(
2535                "<version>{}</version>",
2536                engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
2537            )),
2538            "{updated}"
2539        );
2540
2541        // A project that already has both is left alone.
2542        assert_eq!(
2543            maven_with_vintage(&updated, launcher_version(&updated)),
2544            None
2545        );
2546    }
2547}