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                },
1631            },
1632        };
1633        let options = SupervisionOptions::from_environment().map_err(|error| error.to_string())?;
1634        let execution = execute_plan(&plan, options, diagnostics, |_, _| Ok(()))
1635            .map_err(|error| error.to_string())?;
1636        let test_command_ms = elapsed_ms(test_started);
1637        if let Some(signal) = execution.interrupted_signal {
1638            return Err(format!(
1639                "the test command was interrupted by {signal:?}; no run was published"
1640            ));
1641        }
1642        let exit_code = execution.exit_code;
1643
1644        let publication_started = Instant::now();
1645        // One JVM per module, so one evidence file per module.
1646        let mut parts = Vec::new();
1647        let mut outcomes = Vec::new();
1648        let mut silent = Vec::new();
1649        let named = named_modules(instrumented.build, &request.command);
1650        for module in instrumented
1651            .modules
1652            .iter()
1653            .filter(|module| module.has_tests)
1654        {
1655            // Every JVM the build forked for this module wrote its own file.
1656            let mut written = fs::read_dir(&module.evidence)
1657                .map(|entries| {
1658                    entries
1659                        .flatten()
1660                        .map(|entry| entry.path())
1661                        .filter(|path| path.extension().is_some_and(|kind| kind == "bin"))
1662                        .collect::<Vec<_>>()
1663                })
1664                .unwrap_or_default();
1665            written.sort();
1666            if written.is_empty() {
1667                // A module the command never asked to run has nothing to
1668                // say for itself.
1669                if named
1670                    .as_ref()
1671                    .is_none_or(|named| named.contains(&module.directory))
1672                {
1673                    silent.push(module.directory.clone());
1674                }
1675                continue;
1676            }
1677            let mut forked = Vec::new();
1678            for path in &written {
1679                let bytes =
1680                    fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
1681                forked.push(
1682                    read_evidence(&bytes)
1683                        .map_err(|error| format!("{}: {error}", path.display()))?,
1684                );
1685            }
1686            let evidence = merge_evidence(forked);
1687            for test in &evidence.tests {
1688                outcomes.push(OwnedTestOutcome {
1689                    name: test.name.clone(),
1690                    runner: test.runner.clone(),
1691                    // The module is the unit that forked a JVM of its own, so
1692                    // it is what a worker identity means here. The class is
1693                    // already in the name the framework reported.
1694                    package: module.directory.clone(),
1695                    file: class_of(&test.name)
1696                        .and_then(|class| declaring_file(&instrumented.declared_in, class)),
1697                    status: test.status.clone(),
1698                    // True while the suite runs its tests one at a time, which
1699                    // is what `--exact-attribution` asks for. Left to run as
1700                    // written, JUnit may run them at once; the runtime says so
1701                    // per record, and a test that overlapped is credited with
1702                    // nothing rather than with a neighbour's work.
1703                    attributed: !test.unattributed,
1704                });
1705            }
1706            parts.push(evidence);
1707        }
1708        if !silent.is_empty() {
1709            writeln!(
1710                diagnostics,
1711                "[supercov] {} module(s) wrote no evidence and are absent from this run: {}",
1712                silent.len(),
1713                silent.join(", ")
1714            )
1715            .map_err(|error| error.to_string())?;
1716        }
1717        if outcomes.is_empty() {
1718            return Err(format!(
1719                "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."
1720            ));
1721        }
1722        let evidence = merge_evidence(parts);
1723        measured.set(true);
1724        let run = build_frontend_run(OwnedRunInputs {
1725            declaration: jvm_declaration(),
1726            environment: "jvm",
1727            manifest: &instrumented.project.manifest,
1728            probes: &instrumented.project.probes,
1729            evidence: &evidence,
1730            outcomes: &outcomes,
1731            run_id: &request.run_id,
1732            generated_at: &request.started_at,
1733            test_exit_code: exit_code,
1734            coverage_model: jvm_coverage_model(),
1735        })
1736        .map_err(|error| error.to_string())?;
1737        validate_frontend_report_request(&run.declaration, &run.request)
1738            .map_err(|error| error.to_string())?;
1739        let archive_path = work_directory.join("evidence.raw.gz");
1740        let raw = write_archive(
1741            crate::source_capture::append(
1742                run.archive_entries().map_err(|error| error.to_string())?,
1743                &source_inputs,
1744            )?,
1745            &archive_path,
1746        )
1747        .map_err(|error| error.to_string())?;
1748        let evidence_publication_ms = elapsed_ms(publication_started);
1749
1750        let timings = RunTimings {
1751            initialization_ms,
1752            workspace_preparation_ms,
1753            adapter_setup_ms,
1754            instrumented_build_ms: 0.0,
1755            test_command_ms,
1756            evidence_publication_ms,
1757        };
1758        let metadata = RunMetadata {
1759            id: request.run_id.clone(),
1760            started_at: request.started_at.clone(),
1761            duration_ms: elapsed_ms(total_started),
1762            command: request.command.clone(),
1763            test_exit_code: Some(exit_code),
1764            integrity,
1765            raw_evidence: RawEvidenceMetadata {
1766                schema_version: raw.schema_version,
1767                format: raw.format.into(),
1768                file: raw.file.into(),
1769                files: raw.files,
1770                uncompressed_bytes: raw.uncompressed_bytes,
1771                compressed_bytes: raw.compressed_bytes,
1772            },
1773            isolated_build: Some(true),
1774            instrumented_build_cache: None,
1775            timings: Some(timings),
1776            merged: None,
1777            parents: None,
1778            source_roots: source_roots.clone(),
1779        };
1780        let run_directory =
1781            publish_run(&root, &metadata, &archive_path).map_err(|error| error.to_string())?;
1782        finalize_published_run(&root, &request.run_id).map_err(|error| error.to_string())?;
1783        Ok(DirectJvmRunResult {
1784            run_id: request.run_id.clone(),
1785            run_directory,
1786            exit_code,
1787            tests: outcomes
1788                .iter()
1789                .map(|outcome| outcome.name.as_str())
1790                .collect::<BTreeSet<_>>()
1791                .len(),
1792            source_files: instrumented.project.instrumented.len(),
1793            modules: instrumented.modules.len(),
1794            build: instrumented.build,
1795            recovered_runs,
1796            metadata,
1797        })
1798    })();
1799    let result = result.map_err(|error| {
1800        if !measured.get() {
1801            return error;
1802        }
1803        note_kept_evidence(&root, &work_directory.join("jvm"), &request.run_id, error)
1804    });
1805    if result.is_err() {
1806        let _ = remove_stored_tree_deferred(&root, &work_directory);
1807    }
1808    let release = lock.release().map_err(|error| error.to_string());
1809    match (result, release) {
1810        (Ok(result), Ok(())) => Ok(result),
1811        (Err(error), _) => Err(error),
1812        (Ok(_), Err(error)) => Err(error),
1813    }
1814}
1815
1816#[cfg(test)]
1817mod tests {
1818    use super::*;
1819
1820    #[test]
1821    fn only_a_project_that_asked_for_parallel_execution_is_told_it_lost_it() {
1822        // The line is worth printing when something was taken away and noise
1823        // when nothing was: most projects never enable it, and a diagnostic on
1824        // every run is one nobody reads by the time it matters.
1825        assert!(asks_for_parallel_execution(Some(
1826            "junit.jupiter.execution.parallel.enabled=true\n"
1827        )));
1828        assert!(asks_for_parallel_execution(Some(
1829            "  junit.jupiter.execution.parallel.enabled = True  \n"
1830        )));
1831        assert!(!asks_for_parallel_execution(Some(
1832            "junit.jupiter.execution.parallel.enabled=false\n"
1833        )));
1834        // Naming the mode without enabling it leaves the suite sequential, so
1835        // nothing was taken away.
1836        assert!(!asks_for_parallel_execution(Some(
1837            "junit.jupiter.execution.parallel.mode.default=concurrent\n"
1838        )));
1839        assert!(!asks_for_parallel_execution(None));
1840        assert!(!asks_for_parallel_execution(Some("")));
1841    }
1842
1843    #[test]
1844    fn parallel_execution_is_turned_off_without_discarding_what_else_was_set() {
1845        // Two tests running at once share one probe array, so neither can be
1846        // credited with what it reached. The runtime notices and says so; this
1847        // is the half that means it never has to.
1848        let existing = "junit.jupiter.testinstance.lifecycle.default=per_class\n\
1849                        junit.jupiter.execution.parallel.enabled=true\n\
1850                        junit.jupiter.displayname.generator.default=org.junit.jupiter.api.DisplayNameGenerator$ReplaceUnderscores\n";
1851        let updated = sequential_properties(Some(existing));
1852        assert!(updated.contains("junit.jupiter.execution.parallel.enabled=false"));
1853        assert!(
1854            !updated.contains("parallel.enabled=true"),
1855            "the project's own setting must not survive:\n{updated}"
1856        );
1857        // Everything the project chose for reasons of its own stays.
1858        assert!(updated.contains("testinstance.lifecycle.default=per_class"));
1859        assert!(updated.contains("displayname.generator.default"));
1860
1861        // And a project with no properties at all still gets the setting.
1862        assert!(
1863            sequential_properties(None).contains("junit.jupiter.execution.parallel.enabled=false")
1864        );
1865    }
1866
1867    #[test]
1868    fn gradle_is_told_to_run_the_tests_again() {
1869        // Gradle skips a test task it considers up to date, and a task that
1870        // does not run records nothing.
1871        let (command, note) = command_with_fresh_results(
1872            JvmBuild::Gradle,
1873            &["./gradlew".to_owned(), "test".to_owned()],
1874        );
1875        assert_eq!(command, ["./gradlew", "test", "--rerun-tasks"]);
1876        assert!(note.is_some());
1877
1878        // An author who already said so means it.
1879        let (command, note) = command_with_fresh_results(
1880            JvmBuild::Gradle,
1881            &[
1882                "./gradlew".to_owned(),
1883                "test".to_owned(),
1884                "--rerun".to_owned(),
1885            ],
1886        );
1887        assert_eq!(command, ["./gradlew", "test", "--rerun"]);
1888        assert!(note.is_none());
1889
1890        // Maven's lifecycle re-runs surefire every time; nothing to defeat.
1891        let (command, note) =
1892            command_with_fresh_results(JvmBuild::Maven, &["mvn".to_owned(), "test".to_owned()]);
1893        assert_eq!(command, ["mvn", "test"]);
1894        assert!(note.is_none());
1895    }
1896
1897    #[test]
1898    fn a_configuration_literal_survives_a_path_java_would_have_read_as_escapes() {
1899        // A Windows path is full of backslashes, and one of them landing
1900        // before a `t` or a `"` would change the path or end the literal.
1901        let configuration = configuration(7, &[2, 3], Path::new(r"C:\tmp\runs\evidence.bin"));
1902        assert!(
1903            configuration.contains(r#""C:\\tmp\\runs\\evidence.bin""#),
1904            "{configuration}"
1905        );
1906        assert!(configuration.contains("PROBES = 7"));
1907        assert!(configuration.contains("new int[] {2, 3}"));
1908    }
1909
1910    #[test]
1911    fn a_tests_class_is_read_from_the_name_the_framework_chose() {
1912        // JUnit reports `CalculatorTest#zeroIsNamed()`; a DSL framework
1913        // reports its own wording under the same class.
1914        assert_eq!(
1915            class_of("CalculatorTest#zeroIsNamed()"),
1916            Some("CalculatorTest")
1917        );
1918        assert_eq!(
1919            class_of("CalculatorSpec#a sum adds its parts"),
1920            Some("CalculatorSpec")
1921        );
1922        assert_eq!(class_of("Standalone"), Some("Standalone"));
1923        assert_eq!(class_of(""), None);
1924    }
1925
1926    #[test]
1927    fn the_launcher_version_follows_whatever_junit_the_project_chose() {
1928        // JUnit numbers the platform 1.N alongside Jupiter 5.N, so a pinned
1929        // Jupiter says exactly which launcher agrees with the engine that will
1930        // run. Guessing instead could pair a launcher with an engine it does
1931        // not understand.
1932        assert_eq!(
1933            launcher_version("<artifactId>junit-jupiter</artifactId><version>5.10.2</version>")
1934                .as_deref(),
1935            Some("1.10.2")
1936        );
1937        assert_eq!(
1938            launcher_version("testImplementation 'org.junit.jupiter:junit-jupiter:5.13.1'")
1939                .as_deref(),
1940            Some("1.13.1")
1941        );
1942        // A project using the BOM has already said how every JUnit artifact is
1943        // versioned; naming one would override the answer it gave.
1944        assert_eq!(
1945            launcher_version("<artifactId>junit-bom</artifactId><version>5.11.0</version>"),
1946            None
1947        );
1948        // And one that says nothing gets a launcher new enough to drive an
1949        // older engine, which is the direction that works.
1950        assert_eq!(
1951            launcher_version("<artifactId>junit-jupiter</artifactId>").as_deref(),
1952            Some(DEFAULT_LAUNCHER_VERSION)
1953        );
1954        // Including a file that names no JUnit artifact at all -- an
1955        // aggregating parent, say, whose children each declare their own.
1956        // Reading that as "a BOM manages it" writes a versionless dependency
1957        // into a pom with no BOM to resolve it, and the build stops before a
1958        // single test runs.
1959        assert_eq!(
1960            launcher_version("<artifactId>parent</artifactId>").as_deref(),
1961            Some(DEFAULT_LAUNCHER_VERSION)
1962        );
1963        assert_eq!(
1964            launcher_version("").as_deref(),
1965            Some(DEFAULT_LAUNCHER_VERSION)
1966        );
1967    }
1968
1969    #[test]
1970    fn junit_is_versioned_through_parents_catalogs_and_junit_6() {
1971        // JUnit 6 numbers every artifact alike.
1972        assert_eq!(
1973            launcher_version("<artifactId>junit-jupiter</artifactId><version>6.0.1</version>")
1974                .as_deref(),
1975            Some("6.0.1")
1976        );
1977        assert_eq!(engine_version_of_platform("6.0.1"), "6.0.1");
1978        // A version is read from the Jupiter dependency, not from whatever
1979        // `5.` comes next in the file.
1980        assert_eq!(
1981            launcher_version("<artifactId>junit-jupiter</artifactId><version>${junit}</version><plugin><version>5.2.1</version></plugin>")
1982                .as_deref(),
1983            Some(DEFAULT_LAUNCHER_VERSION)
1984        );
1985        // A version catalog, by reference.
1986        let catalog = "[versions]\njunit = \"5.11.3\"\n\n[libraries]\njunit-jupiter = { module = \"org.junit.jupiter:junit-jupiter\", version.ref = \"junit\" }\n";
1987        let updated = gradle_with_launcher("plugins { id 'java' }\n", false, catalog, &[]).unwrap();
1988        assert!(
1989            updated.contains("junit-platform-launcher:1.11.3"),
1990            "{updated}"
1991        );
1992
1993        // A parent on disk that imports the BOM, and one that sets a property.
1994        let root = std::env::temp_dir().join(format!(
1995            "supercov-junit-parents-{}-{}",
1996            std::process::id(),
1997            std::time::SystemTime::now()
1998                .duration_since(std::time::UNIX_EPOCH)
1999                .unwrap()
2000                .as_nanos()
2001        ));
2002        let module = root.join("module");
2003        fs::create_dir_all(&module).unwrap();
2004        let child = "<project><parent><groupId>g</groupId><artifactId>p</artifactId><version>1</version></parent><dependencies><dependency><artifactId>junit-jupiter-api</artifactId></dependency></dependencies></project>";
2005        fs::write(
2006            root.join("pom.xml"),
2007            "<project><artifactId>p</artifactId><dependencyManagement><dependencies><dependency><artifactId>junit-bom</artifactId><version>${junit.version}</version></dependency></dependencies></dependencyManagement></project>",
2008        )
2009        .unwrap();
2010        assert_eq!(maven_launcher_version(&module, child), None);
2011        fs::write(
2012            root.join("pom.xml"),
2013            "<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>",
2014        )
2015        .unwrap();
2016        assert_eq!(
2017            maven_launcher_version(&module, child).as_deref(),
2018            Some("1.14.4")
2019        );
2020        // A parent that cannot be read manages the version it left out.
2021        fs::remove_file(root.join("pom.xml")).unwrap();
2022        let unknown = child.replace(
2023            "<artifactId>p</artifactId>",
2024            "<artifactId>not-downloaded</artifactId>",
2025        );
2026        assert_eq!(maven_launcher_version(&module, &unknown), None);
2027        fs::remove_dir_all(&root).ok();
2028    }
2029
2030    #[test]
2031    fn the_launcher_is_added_once_and_only_where_it_is_missing() {
2032        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";
2033        let updated =
2034            maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2035        assert!(updated.contains("junit-platform-launcher"), "{updated}");
2036        assert!(updated.contains("<version>1.10.2</version>"), "{updated}");
2037        // Inside the existing block, not after it.
2038        assert!(
2039            updated.find("junit-platform-launcher") < updated.find("</dependencies>"),
2040            "{updated}"
2041        );
2042        // A project that already has it is left exactly as it is.
2043        assert_eq!(
2044            maven_with_launcher(&updated, launcher_version(&updated)),
2045            None
2046        );
2047
2048        // And one with no dependencies block at all still gets a valid pom.
2049        let bare = "<project>\n  <artifactId>demo</artifactId>\n</project>\n";
2050        let updated =
2051            maven_with_launcher(bare, launcher_version(bare)).expect("a block is created");
2052        assert!(updated.contains("<dependencies>"), "{updated}");
2053        assert!(
2054            updated.find("</dependencies>") < updated.find("</project>"),
2055            "{updated}"
2056        );
2057    }
2058
2059    #[test]
2060    fn gradle_gets_the_launcher_in_the_dialect_its_script_is_written_in() {
2061        let groovy =
2062            "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n}\n";
2063        let updated =
2064            gradle_with_launcher(groovy, false, "", &[]).expect("the launcher is missing");
2065        assert!(
2066            updated
2067                .contains("testImplementation 'org.junit.platform:junit-platform-launcher:1.10.2'"),
2068            "{updated}"
2069        );
2070        // The project's own block survives: Gradle merges what we append.
2071        assert!(updated.contains("junit-jupiter:5.10.2"), "{updated}");
2072        assert_eq!(gradle_with_launcher(&updated, false, "", &[]), None);
2073
2074        // Gradle 9 makes every project declare the launcher, and the
2075        // configuration its own documentation recommends is testRuntimeOnly,
2076        // which the tests run with but do not compile against. A project
2077        // following that advice has the artifact and still cannot compile a
2078        // listener, so it gets a compile-visible declaration alongside.
2079        let runtime_only = "dependencies {\n    testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'\n    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'\n}\n";
2080        let updated = gradle_with_launcher(runtime_only, false, "", &[])
2081            .expect("a runtime-only declaration does not reach the compiler");
2082        assert!(
2083            updated.contains("testImplementation 'org.junit.platform:junit-platform-launcher"),
2084            "{updated}"
2085        );
2086        assert!(
2087            updated.contains("testRuntimeOnly 'org.junit.platform:junit-platform-launcher'"),
2088            "the project's own declaration stays:\n{updated}"
2089        );
2090
2091        let kotlin = "dependencies {\n    testImplementation(\"org.junit.jupiter:junit-jupiter:5.10.2\")\n}\n";
2092        let updated = gradle_with_launcher(kotlin, true, "", &[]).expect("the launcher is missing");
2093        assert!(
2094            updated.contains(
2095                "\"testImplementation\"(\"org.junit.platform:junit-platform-launcher:1.10.2\")"
2096            ),
2097            "the Kotlin DSL has no typed accessor inside allprojects:\n{updated}"
2098        );
2099        assert!(updated.contains("plugins.withId(\"java\")"), "{updated}");
2100    }
2101
2102    /// apache/commons-cli takes RAT from commons-parent, and RAT failed the
2103    /// copy on Supercov's own files before any test ran.
2104    #[test]
2105    fn a_test_class_named_in_full_finds_its_file() {
2106        let declared_in = BTreeMap::from([
2107            (
2108                "AnnotationsTest".to_owned(),
2109                "src/test/java/com/fasterxml/classmate/AnnotationsTest.java".to_owned(),
2110            ),
2111            (
2112                "CalculatorSpec".to_owned(),
2113                "src/test/kotlin/app/CalculatorSpec.kt".to_owned(),
2114            ),
2115        ]);
2116        assert_eq!(
2117            declaring_file(&declared_in, "com.fasterxml.classmate.AnnotationsTest").as_deref(),
2118            Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2119        );
2120        assert_eq!(
2121            declaring_file(
2122                &declared_in,
2123                "com.fasterxml.classmate.AnnotationsTest$Nested"
2124            )
2125            .as_deref(),
2126            Some("src/test/java/com/fasterxml/classmate/AnnotationsTest.java")
2127        );
2128        assert_eq!(
2129            declaring_file(&declared_in, "app.CalculatorSpec").as_deref(),
2130            Some("src/test/kotlin/app/CalculatorSpec.kt")
2131        );
2132        // A class reported without its package still finds its file by name.
2133        assert_eq!(
2134            declaring_file(&declared_in, "CalculatorSpec").as_deref(),
2135            Some("src/test/kotlin/app/CalculatorSpec.kt")
2136        );
2137        assert_eq!(declaring_file(&declared_in, "org.other.Missing"), None);
2138    }
2139
2140    #[test]
2141    fn a_license_header_check_is_found_through_the_parent_pom() {
2142        let root = std::env::temp_dir().join(format!(
2143            "supercov-rat-{}-{}",
2144            std::process::id(),
2145            std::time::SystemTime::now()
2146                .duration_since(std::time::UNIX_EPOCH)
2147                .unwrap()
2148                .as_nanos()
2149        ));
2150        fs::create_dir_all(root.join("parent")).unwrap();
2151        fs::write(
2152            root.join("parent/pom.xml"),
2153            "<project><artifactId>parent</artifactId><build><plugins><plugin><groupId>org.apache.rat</groupId><artifactId>apache-rat-plugin</artifactId></plugin></plugins></build></project>",
2154        )
2155        .unwrap();
2156        fs::write(
2157            root.join("pom.xml"),
2158            "<project><parent><groupId>g</groupId><artifactId>parent</artifactId><version>1</version><relativePath>parent/pom.xml</relativePath></parent></project>",
2159        )
2160        .unwrap();
2161        assert!(checks_license_headers(&root));
2162        fs::write(
2163            root.join("pom.xml"),
2164            "<project><artifactId>plain</artifactId></project>",
2165        )
2166        .unwrap();
2167        assert!(!checks_license_headers(&root));
2168        fs::remove_dir_all(&root).ok();
2169    }
2170
2171    #[test]
2172    fn only_the_modules_a_command_names_are_expected_to_write_evidence() {
2173        let words = |c: &str| c.split(' ').map(str::to_owned).collect::<Vec<_>>();
2174        assert_eq!(
2175            named_modules(
2176                JvmBuild::Gradle,
2177                &words("./gradlew :moshi:test --tests *Reader*")
2178            ),
2179            Some(BTreeSet::from(["moshi".to_owned()]))
2180        );
2181        assert_eq!(
2182            named_modules(
2183                JvmBuild::Gradle,
2184                &words("gradle :moshi:records-tests:test :test")
2185            ),
2186            Some(BTreeSet::from([
2187                "moshi/records-tests".to_owned(),
2188                ".".to_owned()
2189            ]))
2190        );
2191        assert_eq!(named_modules(JvmBuild::Gradle, &words("gradle test")), None);
2192        assert_eq!(
2193            named_modules(JvmBuild::Gradle, &words("gradle test -x :slow:test")),
2194            None
2195        );
2196        assert_eq!(
2197            named_modules(JvmBuild::Maven, &words("mvn -pl core,api/ test")),
2198            Some(BTreeSet::from(["core".to_owned(), "api".to_owned()]))
2199        );
2200        assert_eq!(
2201            named_modules(JvmBuild::Maven, &words("mvn -pl :core test")),
2202            None
2203        );
2204        assert_eq!(named_modules(JvmBuild::Maven, &words("mvn test")), None);
2205    }
2206
2207    #[test]
2208    fn gradle_moves_junit_4_projects_onto_the_platform_through_vintage() {
2209        let kotlin = "dependencies {\n    testImplementation(\"junit:junit:4.13.2\")\n}\n";
2210        let updated = gradle_with_launcher(kotlin, true, "", &[".".into(), "moshi".into()])
2211            .expect("a JUnit 4 project needs Vintage");
2212        assert!(
2213            updated
2214                .contains("\"testRuntimeOnly\"(\"org.junit.vintage:junit-vintage-engine:5.10.2\")"),
2215            "{updated}"
2216        );
2217        assert!(updated.contains("in listOf(\"\", \"moshi\")"), "{updated}");
2218        assert!(
2219            updated.contains("if (options is JUnitOptions) useJUnitPlatform()"),
2220            "only a task still on Gradle's JUnit 4 framework is switched:\n{updated}"
2221        );
2222
2223        // Already compiling against the launcher, Vintage is still added.
2224        let groovy = "dependencies {\n    testImplementation 'junit:junit:4.13.2'\n    testImplementation 'org.junit.platform:junit-platform-launcher:1.13.1'\n}\n";
2225        let updated = gradle_with_launcher(groovy, false, "", &["core".into()])
2226            .expect("a JUnit 4 project needs Vintage");
2227        assert!(
2228            updated.contains("testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.2'"),
2229            "{updated}"
2230        );
2231        assert!(updated.contains("in [\"core\"]"), "{updated}");
2232        assert!(
2233            updated.contains("tasks.withType(Test).configureEach"),
2234            "{updated}"
2235        );
2236        assert!(
2237            !updated.contains("\n            testImplementation 'org.junit.platform"),
2238            "the launcher is already there:\n{updated}"
2239        );
2240    }
2241
2242    #[test]
2243    fn only_the_listeners_a_project_can_compile_are_written() {
2244        // Each listener is compiled from the project's own test sources, so
2245        // one whose framework is absent would fail on imports the project
2246        // never asked for.
2247        let junit = frameworks("<artifactId>junit-jupiter</artifactId>");
2248        assert!(junit.platform && !junit.testng);
2249
2250        let testng = frameworks("<artifactId>testng</artifactId>");
2251        assert!(testng.testng && !testng.platform);
2252
2253        // A migration in progress runs both, and both listeners fire in one
2254        // JVM against one runtime.
2255        let both = frameworks("testng ... junit-jupiter");
2256        assert!(both.platform && both.testng);
2257
2258        // Kotest and Spock are platform engines, so the platform listener
2259        // reports their tests without either being named.
2260        assert!(frameworks("io.kotest:kotest-runner-junit5").platform);
2261        assert!(frameworks("org.spockframework:spock-core").platform);
2262
2263        // And a project naming nothing recognisable gets the platform, which
2264        // is what nearly every JVM suite runs on.
2265        let unknown = frameworks("<artifactId>demo</artifactId>");
2266        assert!(unknown.platform && !unknown.testng);
2267    }
2268
2269    #[test]
2270    fn junit_four_is_not_the_platform_and_is_not_treated_as_it() {
2271        // Surefire picks its provider from what is on the classpath. Adding
2272        // the platform launcher to a JUnit 4 project makes it choose the
2273        // platform provider, find no engine there, and fail the suite --
2274        // Supercov breaking a build it was asked to measure. The word "junit"
2275        // appears in both, so the artifact is what tells them apart.
2276        let four = frameworks("<groupId>junit</groupId><artifactId>junit</artifactId>");
2277        assert!(four.junit4 && !four.platform && !four.testng);
2278
2279        let five = frameworks("<artifactId>junit-jupiter</artifactId>");
2280        assert!(five.platform && !five.junit4);
2281
2282        // Vintage runs JUnit 4 tests on the platform, so a project with both
2283        // is a platform project.
2284        let both = frameworks(
2285            "<artifactId>junit</artifactId><artifactId>junit-vintage-engine</artifactId>",
2286        );
2287        assert!(both.platform && !both.junit4);
2288
2289        // Gradle spells its dependencies differently and means the same.
2290        assert!(frameworks("testImplementation 'junit:junit:4.13.2'").junit4);
2291        assert!(frameworks("testImplementation(\"junit:junit:4.13.2\")").junit4);
2292        assert!(!frameworks("testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2'").junit4);
2293    }
2294
2295    #[test]
2296    fn a_module_whose_tests_are_a_java_module_is_left_to_run_as_it_did() {
2297        // gson's test-jpms declares `module com.google.gson.jpms_test`, which
2298        // requires com.google.gson, junit and truth and nothing else. A
2299        // listener written into it imports org.junit.platform -- not visible
2300        // -- and reads a runtime from a package the main module does not
2301        // export. The module stops compiling and takes the reactor with it,
2302        // for tests that check module boundaries rather than product logic.
2303        let root = std::env::temp_dir().join(format!(
2304            "supercov-jpms-{}-{}",
2305            std::process::id(),
2306            std::time::SystemTime::now()
2307                .duration_since(std::time::UNIX_EPOCH)
2308                .unwrap()
2309                .as_nanos()
2310        ));
2311        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();
2312        for module in ["lib", "boundaries"] {
2313            write(&root.join(module).join("pom.xml"), "<project/>").unwrap();
2314            write(
2315                &root.join(module).join("src/main/java/app/Api.java"),
2316                "package app;\npublic class Api { public int one() { return 1; } }\n",
2317            )
2318            .unwrap();
2319            write(
2320                &root.join(module).join("src/test/java/app/ApiTest.java"),
2321                "package app;\nclass ApiTest { void t() {} }\n",
2322            )
2323            .unwrap();
2324        }
2325        write(
2326            &root.join("boundaries/src/test/java/module-info.java"),
2327            "module app.boundaries {\n  requires app.lib;\n}\n",
2328        )
2329        .unwrap();
2330
2331        let evidence = root.join("evidence");
2332        let instrumented = instrument_workspace(&root, &evidence, false, None).expect("instrument");
2333        assert_eq!(instrumented.modular, ["boundaries"]);
2334
2335        // The listener goes into the ordinary module and not the named one.
2336        assert!(
2337            root.join("lib/src/test/java/com/supercorp/supercov/SupercovListener.java")
2338                .exists()
2339        );
2340        for name in ["SupercovListener.java", "SupercovConfig.java"] {
2341            assert!(
2342                !root
2343                    .join("boundaries/src/test/java/com/supercorp/supercov")
2344                    .join(name)
2345                    .exists(),
2346                "{name} must not be written into a named module"
2347            );
2348        }
2349        assert!(
2350            !root
2351                .join("boundaries/src/test/resources")
2352                .join(SERVICES_FILE)
2353                .exists(),
2354            "and nothing registers a listener that is not there"
2355        );
2356        // Its product code is still instrumented, and the runtime it stores
2357        // into is a package of the module's own -- which a named module may
2358        // hold without exporting.
2359        assert!(
2360            root.join("boundaries/src/main/java/com/supercorp/supercov/Supercov.java")
2361                .exists()
2362        );
2363
2364        fs::remove_dir_all(root).ok();
2365    }
2366
2367    #[test]
2368    fn a_framework_declared_through_a_version_catalog_is_still_recognised() {
2369        // moshi is JUnit 4 and writes `testImplementation(libs.junit)`. Read
2370        // without the catalog the build file names no framework at all, the
2371        // fallback takes it for a platform project, and a listener goes in
2372        // that nothing will ever call: the suite passes and records nothing.
2373        let root = std::env::temp_dir().join(format!(
2374            "supercov-catalog-{}-{}",
2375            std::process::id(),
2376            std::time::SystemTime::now()
2377                .duration_since(std::time::UNIX_EPOCH)
2378                .unwrap()
2379                .as_nanos()
2380        ));
2381        write(
2382            &root.join("gradle/libs.versions.toml"),
2383            "[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",
2384        )
2385        .unwrap();
2386        let catalog = version_catalog(&root);
2387
2388        let junit4 = frameworks(&with_catalog(
2389            "dependencies { testImplementation(libs.junit) }",
2390            &catalog,
2391        ));
2392        assert!(junit4.junit4 && !junit4.platform, "{junit4:?}");
2393
2394        // An accessor is a prefix of a longer one, and must not answer for it.
2395        let reflect = frameworks(&with_catalog(
2396            "dependencies { testImplementation(libs.kotlin.reflect) }",
2397            &catalog,
2398        ));
2399        assert!(!reflect.junit4, "{reflect:?}");
2400
2401        let jupiter = frameworks(&with_catalog(
2402            "dependencies { testImplementation(libs.jupiter) }",
2403            &catalog,
2404        ));
2405        assert!(jupiter.platform && !jupiter.junit4, "{jupiter:?}");
2406
2407        // A bundle stands for every library in it.
2408        let bundle = frameworks(&with_catalog(
2409            "dependencies { testImplementation(libs.bundles.unit) }",
2410            &catalog,
2411        ));
2412        assert!(bundle.junit4 && !bundle.platform, "{bundle:?}");
2413
2414        // A build that names no accessor is unchanged by a catalog.
2415        let plain = "dependencies { testImplementation(\"org.testng:testng:7.10.2\") }";
2416        assert_eq!(with_catalog(plain, &catalog), plain);
2417
2418        fs::remove_dir_all(root).ok();
2419    }
2420
2421    #[test]
2422    fn the_launcher_joins_the_projects_dependencies_not_its_managed_versions() {
2423        // A pom's <dependencyManagement> holds a <dependencies> too, and so
2424        // does every profile and plugin. A dependency added there is a version
2425        // for something else to ask for rather than something the project
2426        // depends on: the module compiles exactly as before and the listener
2427        // still cannot find the API it implements.
2428        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";
2429        let updated =
2430            maven_with_launcher(pom, launcher_version(pom)).expect("the launcher is missing");
2431        let at = updated.find(LAUNCHER_ARTIFACT).expect("added");
2432        let managed_end = updated
2433            .find("</dependencyManagement>")
2434            .expect("managed block");
2435        let build_start = updated.find("<build>").expect("build block");
2436        assert!(
2437            at > managed_end,
2438            "not among the managed versions:\n{updated}"
2439        );
2440        assert!(at < build_start, "nor among a plugin's own:\n{updated}");
2441        // The BOM manages every JUnit artifact, so naming a version would
2442        // override the answer the project already gave.
2443        assert!(!updated[at..at + 200].contains("<version>"), "{updated}");
2444    }
2445
2446    #[test]
2447    fn a_projects_warning_policy_does_not_apply_to_code_it_never_wrote() {
2448        // A project is free to fail its build on any warning, and good ones
2449        // do. The copy holds code that project never wrote and never agreed a
2450        // style for -- and Error Prone goes further, failing at error severity
2451        // on rules about the shape of a method that no instrumentation can
2452        // satisfy.
2453        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";
2454        let updated = without_warnings_as_errors(pom).expect("a policy to relax");
2455        assert!(
2456            updated.contains("<failOnWarning>false</failOnWarning>"),
2457            "{updated}"
2458        );
2459        assert!(!updated.contains("Xplugin:ErrorProne"), "{updated}");
2460        // Only the escalation goes: the compiler still compiles what it would
2461        // have, and everything else the project configured is untouched.
2462        assert!(updated.contains("-XDcompilePolicy=simple"), "{updated}");
2463
2464        // A project with no such policy is left exactly as it is.
2465        assert_eq!(without_warnings_as_errors("<project></project>"), None);
2466
2467        // Failing on warnings and running a static analyser are separate
2468        // things, and a project may do either without the other.
2469        let only_warnings = "<project><failOnWarning>true</failOnWarning></project>";
2470        let updated = without_warnings_as_errors(only_warnings).expect("a policy to relax");
2471        assert!(updated.contains("<failOnWarning>false</failOnWarning>"));
2472
2473        let only_analyser =
2474            "<project><compilerArgs><arg>-Xplugin:ErrorProne</arg></compilerArgs></project>";
2475        let updated = without_warnings_as_errors(only_analyser).expect("an analyser to switch off");
2476        assert!(!updated.contains("ErrorProne"), "{updated}");
2477    }
2478
2479    #[test]
2480    fn vintage_carries_the_engine_version_not_the_platform_one() {
2481        // JUnit numbers the engines 5.N and the platform 1.N, so asking for
2482        // vintage 1.N asks for something that was never published and the
2483        // build stops at dependency resolution.
2484        assert_eq!(engine_version_of_platform("1.10.2"), "5.10.2");
2485        assert_eq!(engine_version_of_platform("1.13.1"), "5.13.1");
2486
2487        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";
2488        let updated =
2489            maven_with_vintage(pom, launcher_version(pom)).expect("a JUnit 4 project needs both");
2490        assert!(updated.contains("<artifactId>junit-platform-launcher</artifactId>"));
2491        assert!(updated.contains("<artifactId>junit-vintage-engine</artifactId>"));
2492        assert!(
2493            updated.contains("<groupId>org.junit.vintage</groupId>"),
2494            "{updated}"
2495        );
2496        // The launcher takes the platform version and the engine the Jupiter
2497        // one, in the same pom.
2498        assert!(updated.contains(&format!("<version>{DEFAULT_LAUNCHER_VERSION}</version>")));
2499        assert!(
2500            updated.contains(&format!(
2501                "<version>{}</version>",
2502                engine_version_of_platform(DEFAULT_LAUNCHER_VERSION)
2503            )),
2504            "{updated}"
2505        );
2506
2507        // A project that already has both is left alone.
2508        assert_eq!(
2509            maven_with_vintage(&updated, launcher_version(&updated)),
2510            None
2511        );
2512    }
2513}