Skip to main content

fallow_output/
mutation.rs

1//! Independent JSON contracts for mutation testing.
2//!
3//! A mutation report never contains source text, so a report can leave the
4//! machine without the code of the project. A mutant is identified by its id, file, position and operator.
5//! The `change` of a mutant uses a fixed vocabulary of operator tokens,
6//! literals and placeholders, never project text.
7//! The `inspect` command shows the code of one mutant.
8
9use fallow_types::envelope::{ElapsedMs, ToolVersion};
10use serde::{Deserialize, Serialize};
11
12use crate::audit_test_reach::TestReachClass;
13use crate::root_envelopes::serialize_named_json_output;
14
15/// Current mutation report schema version.
16pub const MUTATION_SCHEMA_VERSION: u32 = 1;
17/// Current mutation plan schema version.
18pub const MUTATION_PLAN_SCHEMA_VERSION: u32 = 1;
19/// Current mutation inspect schema version.
20pub const MUTATION_INSPECT_SCHEMA_VERSION: u32 = 1;
21
22/// Version singleton for a mutation report.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
24#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
25pub enum MutationSchemaVersion {
26    /// Initial independent contract.
27    #[serde(rename = "1")]
28    V1,
29}
30
31/// Version singleton for a mutation plan.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
33#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
34pub enum MutationPlanSchemaVersion {
35    /// Initial independent contract.
36    #[serde(rename = "1")]
37    V1,
38}
39
40/// The result of one mutant.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
42#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
43#[serde(rename_all = "snake_case")]
44pub enum MutantStatus {
45    /// A covering test failed.
46    Killed,
47    /// The run passed its timeout. A retry in a new companion with twice the
48    /// time also timed out, when the time budget allowed the retry. The
49    /// mutant counts as detected.
50    Timeout,
51    /// Each covering test passed, also in a retry in a new companion when
52    /// the time budget allowed the retry.
53    Survived,
54    /// No test reaches the mutant.
55    NoCoverage,
56    /// The runner failed for a reason outside the tests.
57    RuntimeError,
58    /// The second run in a new companion changed whether the tests detect
59    /// the mutant.
60    Flaky,
61    /// A static rule removed the mutant.
62    Pruned,
63    /// The mutant did not run: the mutant limit or the time budget ended first.
64    Pending,
65    /// A verdict marks the mutant as equivalent to the original code: no
66    /// test can detect it.
67    Equivalent,
68    /// Each covering test passed, but the mutated file does not type-check.
69    /// Only a run with the type check of survivors gives this status. The
70    /// mutant does not count in the score.
71    CompileError,
72}
73
74/// The number of mutants for each status, and the mutation score.
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
76#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
77pub struct MutationStatusCounts {
78    /// Each mutant.
79    pub total: usize,
80    /// Killed mutants.
81    pub killed: usize,
82    /// Mutants that passed their timeout.
83    pub timeout: usize,
84    /// Mutants that each covering test let pass.
85    pub survived: usize,
86    /// Mutants that no test reaches.
87    pub no_coverage: usize,
88    /// Mutants with a runner fault.
89    pub runtime_error: usize,
90    /// Mutants where the second run in a new companion changed whether the
91    /// tests detect the mutant.
92    pub flaky: usize,
93    /// Mutants that a static rule removed.
94    pub pruned: usize,
95    /// Mutants that did not run.
96    pub pending: usize,
97    /// Mutants that a verdict marks as equivalent.
98    pub equivalent: usize,
99    /// Survived mutants that do not type-check. A report from a version
100    /// before `--typecheck` has no such field, so it reads as 0.
101    #[serde(default)]
102    pub compile_error: usize,
103}
104
105impl MutationStatusCounts {
106    /// The mutants that ran against the tests: `killed`, `timeout`,
107    /// `survived`, `runtime_error`, `flaky` and `compile_error`.
108    #[must_use]
109    pub const fn executed(&self) -> usize {
110        self.killed
111            + self.timeout
112            + self.survived
113            + self.runtime_error
114            + self.flaky
115            + self.compile_error
116    }
117
118    /// Adds one mutant with `status`.
119    pub const fn add(&mut self, status: MutantStatus) {
120        self.total += 1;
121        match status {
122            MutantStatus::Killed => self.killed += 1,
123            MutantStatus::Timeout => self.timeout += 1,
124            MutantStatus::Survived => self.survived += 1,
125            MutantStatus::NoCoverage => self.no_coverage += 1,
126            MutantStatus::RuntimeError => self.runtime_error += 1,
127            MutantStatus::Flaky => self.flaky += 1,
128            MutantStatus::Pruned => self.pruned += 1,
129            MutantStatus::Pending => self.pending += 1,
130            MutantStatus::Equivalent => self.equivalent += 1,
131            MutantStatus::CompileError => self.compile_error += 1,
132        }
133    }
134
135    /// Removes one mutant with `status`. The count never goes below zero.
136    pub const fn remove(&mut self, status: MutantStatus) {
137        let count = match status {
138            MutantStatus::Killed => &mut self.killed,
139            MutantStatus::Timeout => &mut self.timeout,
140            MutantStatus::Survived => &mut self.survived,
141            MutantStatus::NoCoverage => &mut self.no_coverage,
142            MutantStatus::RuntimeError => &mut self.runtime_error,
143            MutantStatus::Flaky => &mut self.flaky,
144            MutantStatus::Pruned => &mut self.pruned,
145            MutantStatus::Pending => &mut self.pending,
146            MutantStatus::Equivalent => &mut self.equivalent,
147            MutantStatus::CompileError => &mut self.compile_error,
148        };
149        if *count > 0 {
150            *count -= 1;
151            self.total -= 1;
152        }
153    }
154
155    /// The mutation score over the executed mutants: `(killed + timeout) /
156    /// (killed + timeout + survived)`, from 0 to 1. `None` when no mutant
157    /// ran to a tested result.
158    ///
159    /// `no_coverage` mutants stay out of the score;
160    /// [`MutationSummary::coverage`] reports them. Runtime errors, flaky, pruned,
161    /// pending, equivalent and compile-error mutants never count.
162    #[must_use]
163    #[expect(
164        clippy::cast_precision_loss,
165        reason = "mutant counts are far below 2^52"
166    )]
167    pub fn score(&self) -> Option<f64> {
168        let detected = self.killed + self.timeout;
169        let executed = detected + self.survived;
170        (executed > 0).then(|| detected as f64 / executed as f64)
171    }
172}
173
174/// Whether the result is conclusive.
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
177#[serde(rename_all = "snake_case")]
178pub enum MutationCompletionStatus {
179    /// Each planned mutant has a final status. An empty survivor list is
180    /// conclusive.
181    Complete,
182    /// At least one planned mutant has no final status.
183    Partial,
184}
185
186/// Why a run is partial.
187#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
188#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
189#[serde(rename_all = "kebab-case")]
190pub enum MutationIncompleteReason {
191    /// At least one mutant has the status `runtime_error`.
192    RuntimeError,
193    /// The mutant limit left covered mutants `pending`.
194    MutantLimit,
195    /// The time budget ended before each mutant ran.
196    TimeBudget,
197    /// The plan did not instrument at least one selected file.
198    SkippedFiles,
199    /// The runner never loaded at least one instrumented file that has a
200    /// planned mutant and a static test file.
201    UnloadedFiles,
202    /// The runner does not list at least one static test file, so its tests
203    /// never ran. A mutant that only such a file reaches is `no_coverage` or
204    /// `survived` without proof. The `errors` entry
205    /// `test-files-not-discovered` names the files.
206    TestFilesNotDiscovered,
207    /// A test or a test file failed before any mutant was active.
208    BaselineFailure,
209    /// The run changed the git worktree.
210    WorktreeChanged,
211    /// The type check of the survivors failed, so a survivor can be a
212    /// mutant that does not type-check.
213    TypecheckFailed,
214    /// The end-of-run worktree check failed, so stray writes are unknown.
215    WorktreeUnchecked,
216}
217
218/// The completion status of a run.
219#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
220#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
221pub struct MutationCompletion {
222    /// `complete` or `partial`.
223    pub status: MutationCompletionStatus,
224    /// Why the run is partial, sorted. Empty for a complete run.
225    pub reasons: Vec<MutationIncompleteReason>,
226}
227
228impl MutationCompletion {
229    /// A completion with `reasons`. The status is `complete` only when the
230    /// list is empty.
231    #[must_use]
232    pub fn from_reasons(mut reasons: Vec<MutationIncompleteReason>) -> Self {
233        reasons.sort_unstable();
234        reasons.dedup();
235        let status = if reasons.is_empty() {
236            MutationCompletionStatus::Complete
237        } else {
238            MutationCompletionStatus::Partial
239        };
240        Self { status, reasons }
241    }
242}
243
244/// Which code a run mutates.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
246#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
247#[serde(rename_all = "snake_case")]
248pub enum MutationScopeKind {
249    /// The added lines against a base ref.
250    Changed,
251    /// Each source file of the project.
252    All,
253    /// The files at the given paths.
254    Paths,
255}
256
257/// The scope of a plan or a run.
258#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
259#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
260pub struct MutationScopeOutput {
261    /// The kind of scope.
262    pub kind: MutationScopeKind,
263    /// The base ref of a `changed` scope.
264    #[serde(default, skip_serializing_if = "Option::is_none")]
265    pub base_ref: Option<String>,
266}
267
268/// The JavaScript runtime that ran the companion.
269#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
270#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
271pub struct MutationRuntimeOutput {
272    /// `node` or `bun`.
273    pub name: String,
274    /// The version that the runtime reports.
275    pub version: String,
276}
277
278/// The test runner of a run.
279#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
280#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
281pub struct MutationRunnerOutput {
282    /// The runner name, for example `vitest`.
283    pub name: String,
284    /// The runner version from the project.
285    pub version: String,
286    /// The wire protocol version of the companion.
287    pub protocol_version: u32,
288    /// The runtime of the companion.
289    pub runtime: MutationRuntimeOutput,
290    /// The number of companion processes that ran mutants.
291    pub concurrency: usize,
292    /// Facts of the runner setup that limit the result, kebab-case.
293    /// `typecheck-not-run`: the project config enables the Vitest type-check
294    /// pass, and the run did not execute it, so a type-level kill is not
295    /// counted. Absent when the runner states no fact.
296    #[serde(default, skip_serializing_if = "Vec::is_empty")]
297    pub notes: Vec<String>,
298}
299
300/// The source position of a mutant. No source text.
301#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
302#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
303pub struct MutantLocation {
304    /// The stable mutant id.
305    pub mutant_id: String,
306    /// The root-relative path, with forward slashes.
307    pub file: String,
308    /// The 1-based line.
309    pub line: u32,
310    /// The 1-based column.
311    pub column: u32,
312    /// The byte span in the original file.
313    pub span: (u32, u32),
314    /// The operator name, kebab-case.
315    pub operator: String,
316    /// The name of the enclosing function.
317    #[serde(default, skip_serializing_if = "Option::is_none")]
318    pub function: Option<String>,
319    /// The 1-based first line of the enclosing function.
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub function_line: Option<u32>,
322    /// The 1-based execution rank of a planned mutant.
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub rank: Option<u32>,
325    /// The edit of the mutant, without project text. Absent in a report
326    /// that an older version wrote.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub change: Option<MutantChange>,
329}
330
331/// The edit of a mutant, without project text.
332///
333/// Each side is an operator token (for example `??` or `<=`), a fixed literal
334/// (`true`, `undefined`, `""`, `"fallow"`, `void 0`), a regular expression
335/// token (`^`, `$`, `[`, `[^`, `\d`), or a placeholder for a side that is
336/// project code: `<condition>`, `<call>`, `<expression>` or `<string>`. An
337/// empty side means that the edit removes the token.
338#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
339#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
340pub struct MutantChange {
341    /// The token or placeholder before the edit.
342    pub from: String,
343    /// The token or placeholder after the edit.
344    pub to: String,
345}
346
347/// One mutant of a run.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
349#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
350pub struct MutantResult {
351    /// The mutant and its position.
352    #[serde(flatten)]
353    pub location: MutantLocation,
354    /// The final status.
355    pub status: MutantStatus,
356    /// The static rule of a `pruned` mutant, kebab-case.
357    #[serde(default, skip_serializing_if = "Option::is_none")]
358    pub prune_reason: Option<String>,
359    /// The test that killed the mutant in this run. Absent for a kill by an
360    /// error outside a test.
361    ///
362    /// This is evidence of one run, not a stable fact. A run stops at the
363    /// first failing test, and it runs the fastest covering test first, so a
364    /// second run on the same code can name another covering test. No
365    /// fingerprint or baseline comparison uses this field.
366    ///
367    /// The retry of a survivor also runs the other tests of its test files,
368    /// so this test can be absent from `covering_tests`.
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub killed_by: Option<String>,
371    /// The tests that reached the mutant in the dry run, sorted by test id.
372    #[serde(default, skip_serializing_if = "Vec::is_empty")]
373    pub covering_tests: Vec<String>,
374    /// True when the mutant runs while a module loads, outside each test.
375    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
376    pub static_mutant: bool,
377    /// The duration of one run in milliseconds. A `killed` mutant after a
378    /// retry reports the retry. Each other status reports the first run.
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub duration_ms: Option<u64>,
381    /// How tests reach the code of the mutant in the module graph. Only a
382    /// `no_coverage` mutant has this field.
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub test_reach: Option<MutantTestReach>,
385    /// True when the status comes from a first run and a retry in a new
386    /// companion. A `survived` or `timeout` mutant without this field got no
387    /// retry, because the time budget ended first.
388    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
389    pub retried: bool,
390    /// The runs that gave the status of a `retried` mutant: the first run,
391    /// then the retry. Absent when the mutant got no retry, and absent for a
392    /// `cached` mutant. Each duration is evidence of one run, so no
393    /// fingerprint or baseline comparison uses this field.
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub attempts: Vec<MutantAttempt>,
396    /// True when the status comes from the result cache of an earlier run.
397    /// The mutant did not run in this run. `--no-cache` runs each mutant.
398    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
399    pub cached: bool,
400    /// True when the status comes from the journal of an interrupted run
401    /// (`--resume`). The mutant did not run in this run.
402    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
403    pub resumed: bool,
404    /// The TypeScript diagnostic codes of the new errors of a
405    /// `compile_error` mutant, sorted, at most 8.
406    #[serde(default, skip_serializing_if = "Vec::is_empty")]
407    pub type_error_codes: Vec<u32>,
408}
409
410/// One run of a retried mutant.
411#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
412#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
413pub struct MutantAttempt {
414    /// The status of this run alone.
415    pub status: MutantStatus,
416    /// The duration of this run in milliseconds. A watchdog timeout reports
417    /// the wait of the watchdog.
418    #[serde(default, skip_serializing_if = "Option::is_none")]
419    pub duration_ms: Option<u64>,
420    /// The test that killed the mutant in this run.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub killed_by: Option<String>,
423    /// How the run detected the mutant. Absent for a run that did not detect
424    /// it, and absent when the runner did not state the kind.
425    #[serde(default, skip_serializing_if = "Option::is_none")]
426    pub failure_kind: Option<MutantFailureKind>,
427    /// The first line of the reason of a `runtime_error` run.
428    #[serde(default, skip_serializing_if = "Option::is_none")]
429    pub detail: Option<String>,
430}
431
432/// How a run detected a mutant.
433#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
434#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
435#[serde(rename_all = "snake_case")]
436pub enum MutantFailureKind {
437    /// A test assertion failed.
438    Assertion,
439    /// A test or the module load threw an error that is not an assertion.
440    Error,
441    /// A test or the run passed its time limit.
442    Timeout,
443}
444
445impl MutantFailureKind {
446    /// The kind of a `failure_kind` value of the companion protocol. An
447    /// unknown value gives `None`.
448    #[must_use]
449    pub fn from_wire(value: &str) -> Option<Self> {
450        match value {
451            "assertion" => Some(Self::Assertion),
452            "error" => Some(Self::Error),
453            "timeout" => Some(Self::Timeout),
454            _ => None,
455        }
456    }
457}
458
459/// How tests reach the code of one mutant in the module graph. The class
460/// gives a static reason why no test covers the mutant.
461///
462/// A mutant in the top-level statement that declares a value export gets the
463/// class of that export. A mutant outside each such statement gets the class
464/// of its file: `none-file` when no test loads the file, `mocked-only` when no
465/// test loads the file and a mock explains an export of the file, and `none`
466/// when a test loads the file.
467#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
468#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
469pub struct MutantTestReach {
470    /// The reach class. The same open set as the test reach of `audit`.
471    pub reach: TestReachClass,
472    /// The export whose declaration holds the mutant. Absent for a mutant
473    /// outside each value export declaration.
474    #[serde(default, skip_serializing_if = "Option::is_none")]
475    pub export_name: Option<String>,
476    /// For `mocked-only`: the test files whose references a mock removed,
477    /// sorted.
478    #[serde(default, skip_serializing_if = "Vec::is_empty")]
479    pub tests: Vec<String>,
480    /// For `mocked-only`: the mocked modules on the reference paths, sorted.
481    /// A mocked module can be a barrel in front of the file of the mutant.
482    #[serde(default, skip_serializing_if = "Vec::is_empty")]
483    pub mocked_targets: Vec<String>,
484}
485
486/// The result cache facts of a run.
487#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
488#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
489pub struct MutationCacheOutput {
490    /// The mutants whose status came from the cache. They did not run.
491    pub hits: usize,
492    /// The results that this run added to the cache.
493    pub stored: usize,
494    /// The test inputs that the cache key does not cover: modules that a
495    /// test loads through a dynamic import that the module graph does not
496    /// resolve (`dynamic-imports`), and files that a test reads
497    /// (`file-reads`). A change to them does not invalidate a cached result.
498    pub not_keyed: Vec<String>,
499}
500
501/// The resume facts of a `--resume` run.
502#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
503#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
504pub struct MutationResumeOutput {
505    /// The path of the outcome journal, relative to the project root. When
506    /// the cache directory is outside the root, the path is absolute: the
507    /// report has no other cache path, so no other rule applies. The file
508    /// stays only when the run did not complete. A run with
509    /// `completion.status: complete` removes it.
510    pub journal: String,
511    /// The mutants whose status came from the journal. They did not run.
512    pub reused: usize,
513    /// The journal results whose key does not match this run, because an
514    /// input of the mutant changed. These results were not reused from the
515    /// journal. The mutant ran again, or its status came from the result
516    /// cache.
517    pub rejected: usize,
518    /// Why the run did not read a journal and started from the beginning,
519    /// for example because no journal exists. Absent when the run read the
520    /// journal.
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub note: Option<String>,
523}
524
525/// The results of the mutants of one function.
526#[derive(Debug, Clone, PartialEq, Serialize)]
527#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
528pub struct MutationFunctionResult {
529    /// The root-relative path, with forward slashes.
530    pub file: String,
531    /// The function name. `null` for code outside each function, and for an
532    /// anonymous function.
533    pub name: Option<String>,
534    /// The 1-based first line. `null` for code outside each function.
535    pub line: Option<u32>,
536    /// The 1-based last line. `null` for code outside each function.
537    pub end_line: Option<u32>,
538    /// True when an added line of the diff is in the function.
539    pub in_diff: bool,
540    /// The cyclomatic complexity, when the analysis measured the function.
541    #[serde(default, skip_serializing_if = "Option::is_none")]
542    pub cyclomatic: Option<u16>,
543    /// The number of mutants for each status.
544    pub counts: MutationStatusCounts,
545    /// The mutation score of the function.
546    pub score: Option<f64>,
547    /// The ids of the survived mutants, in rank order.
548    #[serde(default, skip_serializing_if = "Vec::is_empty")]
549    pub survived: Vec<String>,
550}
551
552/// The totals of a run.
553#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
554#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
555pub struct MutationSummary {
556    /// The number of mutants for each status.
557    pub counts: MutationStatusCounts,
558    /// The mutation score over the executed mutants, a fraction from 0 to 1:
559    /// `(killed + timeout) / (killed + timeout + survived)`. `null` when no
560    /// mutant ran to a tested result. `--min-score` and `mutation.minScore`
561    /// compare this value in percent.
562    pub score: Option<f64>,
563    /// The share of mutants that a test reaches, a fraction from 0 to 1:
564    /// `covered / (covered + no_coverage)` over the whole plan. `covered`
565    /// counts each executed mutant with a covering test and each `pending`
566    /// mutant that a passing dry-run test covers. A `pending` mutant that no
567    /// dry-run test covers is `no_coverage`, so a run that `--max-mutants`
568    /// caps reports the coverage of the full run. `equivalent` and `pruned`
569    /// mutants never count. `null` when no mutant is covered or unreached.
570    pub coverage: Option<f64>,
571}
572
573impl MutationSummary {
574    /// The totals, the score and the coverage of `results`.
575    #[must_use]
576    pub fn from_results(results: &[MutantResult]) -> Self {
577        let mut counts = MutationStatusCounts::default();
578        let (mut covered, mut uncovered) = (0usize, 0usize);
579        for result in results {
580            counts.add(result.status);
581            match result.status {
582                MutantStatus::NoCoverage => uncovered += 1,
583                MutantStatus::Pending => covered += 1,
584                MutantStatus::Killed
585                | MutantStatus::Timeout
586                | MutantStatus::Survived
587                | MutantStatus::RuntimeError
588                | MutantStatus::Flaky
589                | MutantStatus::CompileError
590                    if !result.covering_tests.is_empty() || result.static_mutant =>
591                {
592                    covered += 1;
593                }
594                _ => {}
595            }
596        }
597        Self {
598            counts,
599            score: counts.score(),
600            coverage: ratio(covered, covered + uncovered),
601        }
602    }
603}
604
605#[expect(
606    clippy::cast_precision_loss,
607    reason = "mutant counts are far below 2^52"
608)]
609fn ratio(part: usize, whole: usize) -> Option<f64> {
610    (whole > 0).then(|| part as f64 / whole as f64)
611}
612
613/// A test or a test file that failed with no active mutant. Its tests do not
614/// decide a mutant status.
615#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
616#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
617pub struct MutationBaselineFailure {
618    /// The test file, root-relative. `null` for an error outside each test
619    /// file.
620    pub file: Option<String>,
621    /// The test id, for a failed test.
622    #[serde(default, skip_serializing_if = "Option::is_none")]
623    pub test_id: Option<String>,
624    /// The error message, for an error outside a test, or the reason for a
625    /// test that did not pass in the dry run of a restarted companion.
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub message: Option<String>,
628    /// The mutants that ran without this test, because the test did not pass
629    /// in the dry run of a restarted companion. Each of them ran with its
630    /// other covering tests. Absent for a failure of the first dry run.
631    #[serde(default, skip_serializing_if = "Vec::is_empty")]
632    pub mutant_ids: Vec<String>,
633}
634
635/// A selected file that the plan did not instrument.
636#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
637#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
638pub struct MutationSkippedFile {
639    /// The root-relative path, with forward slashes.
640    pub path: String,
641    /// Why the plan skipped the file, kebab-case.
642    pub reason: String,
643}
644
645/// The git worktree check of a run: a run refuses a dirty worktree unless
646/// `--allow-dirty` is set, and it compares the worktree after the run.
647#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
648#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
649pub struct MutationWorktreeCheck {
650    /// False when the root is not in a git worktree, so no check ran.
651    pub checked: bool,
652    /// True when the caller allowed a dirty worktree.
653    pub allow_dirty: bool,
654    /// The paths that were dirty before the run, sorted.
655    #[serde(default, skip_serializing_if = "Vec::is_empty")]
656    pub dirty_at_start: Vec<String>,
657    /// The paths that the run changed, sorted. A non-empty list is an error.
658    pub changed_paths: Vec<String>,
659}
660
661/// An error of a run that did not stop the run.
662#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
663#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
664pub struct MutationRunIssue {
665    /// A stable kebab-case code: `cache-write-failed`,
666    /// `journal-write-failed`, `runtime-error`, `static-map-miss`,
667    /// `test-files-not-discovered`, `typecheck-failed`, `worktree-changed`
668    /// or `worktree-check-failed`.
669    pub code: String,
670    /// A description.
671    pub message: String,
672    /// The mutant of the issue.
673    #[serde(default, skip_serializing_if = "Option::is_none")]
674    pub mutant_id: Option<String>,
675    /// The last part of the companion stderr.
676    #[serde(default, skip_serializing_if = "Option::is_none")]
677    pub stderr_tail: Option<String>,
678}
679
680/// The wall time of each phase, in milliseconds.
681#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
682#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
683pub struct MutationTimings {
684    /// The analysis and the plan.
685    pub plan_ms: u64,
686    /// The companion install, the run directory, and the first `init`.
687    pub setup_ms: u64,
688    /// The dry run.
689    pub dry_run_ms: u64,
690    /// The mutant runs.
691    pub mutants_ms: u64,
692    /// The second runs of survived and timed-out mutants.
693    pub retry_ms: u64,
694    /// The type check of the survivors. Zero when the run did not check
695    /// them.
696    pub typecheck_ms: u64,
697}
698
699/// Why the type check did not check a survivor.
700#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
701#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
702#[serde(rename_all = "kebab-case")]
703pub enum MutationTypecheckSkipReason {
704    /// No TypeScript project includes the file.
705    NoProject,
706    /// The checker does not check the file: a `// @ts-nocheck` comment, or a
707    /// JavaScript file without `checkJs`.
708    NotTypeChecked,
709    /// The file changed after the plan.
710    SourceChanged,
711    /// The file does not exist.
712    FileMissing,
713    /// The checker cannot read the file type: a Vue, Svelte or Astro
714    /// component file.
715    UnsupportedFile,
716}
717
718/// The number of survivors with one skip reason.
719#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
720#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
721pub struct MutationTypecheckSkip {
722    /// Why the type check did not check these survivors.
723    pub reason: MutationTypecheckSkipReason,
724    /// The number of survivors.
725    pub count: usize,
726}
727
728/// The type check of the survivors (`--typecheck`).
729#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
730#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
731pub struct MutationTypecheckOutput {
732    /// True when the type check gave a result for each survivor that it
733    /// received. False when the companion failed; the survivors then keep
734    /// the status `survived`.
735    pub completed: bool,
736    /// The survivors that the type check received.
737    pub survivors: usize,
738    /// The survivors that the checker checked.
739    pub checked: usize,
740    /// The checked survivors that do not type-check. They have the status
741    /// `compile_error`.
742    pub compile_errors: usize,
743    /// The survivors that the checker did not check, by reason.
744    #[serde(default, skip_serializing_if = "Vec::is_empty")]
745    pub skipped: Vec<MutationTypecheckSkip>,
746}
747
748/// The envelope of `fallow mutation`.
749#[derive(Debug, Clone, PartialEq, Serialize)]
750#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
751pub struct MutationReport {
752    /// Independent schema version.
753    pub schema_version: MutationSchemaVersion,
754    /// The Fallow version.
755    pub version: ToolVersion,
756    /// The total wall time.
757    pub elapsed_ms: ElapsedMs,
758    /// Whether the result is conclusive.
759    pub completion: MutationCompletion,
760    /// The mutated code.
761    pub scope: MutationScopeOutput,
762    /// The runner. Absent when no companion started, for example when no
763    /// test file reaches a mutant.
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub runner: Option<MutationRunnerOutput>,
766    /// The result cache. Absent with `--no-cache`, and when no companion
767    /// started.
768    #[serde(default, skip_serializing_if = "Option::is_none")]
769    pub cache: Option<MutationCacheOutput>,
770    /// The resume facts. Absent without `--resume`.
771    #[serde(default, skip_serializing_if = "Option::is_none")]
772    pub resume: Option<MutationResumeOutput>,
773    /// The totals.
774    pub summary: MutationSummary,
775    /// Each mutant, ranked mutants first.
776    pub mutants: Vec<MutantResult>,
777    /// The results per enclosing function, by file and line.
778    pub functions: Vec<MutationFunctionResult>,
779    /// True when the project has no test files.
780    pub no_test_files: bool,
781    /// The tests and test files that failed with no active mutant.
782    pub baseline_failures: Vec<MutationBaselineFailure>,
783    /// The selected files that the plan did not instrument.
784    pub skipped_files: Vec<MutationSkippedFile>,
785    /// The paths of a `paths` scope that select no file.
786    pub unmatched_paths: Vec<String>,
787    /// The instrumented files that the runner never loaded.
788    pub unloaded_paths: Vec<String>,
789    /// The git worktree check.
790    pub worktree: MutationWorktreeCheck,
791    /// The errors that did not stop the run.
792    pub errors: Vec<MutationRunIssue>,
793    /// The wall time of each phase.
794    pub timings: MutationTimings,
795    /// The mutated files with their SHA-256 digest at plan time, sorted by
796    /// path. `fallow mutation inspect` compares the digest with the current
797    /// file.
798    pub files: Vec<MutationPlanFile>,
799    /// The join with a verdict document. Absent when the run read no verdict
800    /// document (`--verdicts` not set).
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    pub verdicts: Option<MutationVerdictJoin>,
803    /// The type check of the survivors. Absent when the run did not check
804    /// them (`--typecheck` not set).
805    #[serde(default, skip_serializing_if = "Option::is_none")]
806    pub typecheck: Option<MutationTypecheckOutput>,
807}
808
809/// Version singleton for a mutation verdict document.
810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
811#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
812pub enum MutationVerdictSchemaVersion {
813    /// Initial independent contract.
814    #[serde(rename = "1")]
815    V1,
816}
817
818/// The judgment of a mutation verdict.
819#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
820#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
821#[serde(rename_all = "kebab-case")]
822pub enum MutationVerdictKind {
823    /// The mutant behaves the same as the original code, so no test can kill
824    /// it. It gets the status `equivalent` and leaves the score.
825    Equivalent,
826}
827
828/// One reviewed mutant.
829#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
830#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
831#[serde(deny_unknown_fields)]
832pub struct MutationVerdict {
833    /// The review key of the mutant. It is the `mutant_id`: the id hashes the
834    /// path, the text of the enclosing function, the relative span, the
835    /// operator and the replacement, so it stays the same until the function
836    /// changes.
837    pub review_key: String,
838    /// The judgment.
839    pub verdict: MutationVerdictKind,
840    /// A short reason, for human review.
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub note: Option<String>,
843}
844
845/// A versioned verdict document, authored by a person or an agent, for
846/// `fallow mutation --verdicts <file>`.
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
848#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
849#[serde(deny_unknown_fields)]
850pub struct MutationVerdictInput {
851    /// Independent verdict document schema version.
852    pub schema_version: MutationVerdictSchemaVersion,
853    /// The verdicts. Each `review_key` occurs one time.
854    pub verdicts: Vec<MutationVerdict>,
855}
856
857/// The result of the join of a run with a verdict document.
858#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
859#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
860pub struct MutationVerdictJoin {
861    /// The SHA-256 digest of the verdict document, lowercase hex.
862    pub verdicts_sha256: String,
863    /// The mutants that got the status `equivalent`, sorted.
864    pub applied: Vec<String>,
865    /// The verdicts that a test contradicts, sorted: the mutant was killed or
866    /// timed out, so it is not equivalent. The status stays.
867    pub contradicted: Vec<String>,
868    /// The verdicts for mutants with no tested result (`pending`,
869    /// `runtime_error` or `pruned`), sorted. The status stays.
870    pub deferred: Vec<String>,
871    /// The verdicts that match no mutant of this run, sorted. A verdict
872    /// document can hold keys for code outside the scope of the run, and an
873    /// edit of a function gives its mutants new keys.
874    pub unmatched: Vec<String>,
875}
876
877/// The status of a mutant in a plan.
878#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
879#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
880#[serde(rename_all = "snake_case")]
881pub enum MutationPlanDisposition {
882    /// A run executes the mutant.
883    Planned,
884    /// The mutant is after the mutant limit. The plan limit comes before
885    /// the coverage is known: a run executes a pending mutant in the place
886    /// of a planned mutant that no test covers.
887    Pending,
888    /// No test file reaches the file of the mutant.
889    NoCoverage,
890    /// A static rule removes the mutant.
891    Pruned,
892}
893
894/// One mutant of a plan.
895#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
896#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
897pub struct MutationPlanMutant {
898    /// The mutant and its position.
899    #[serde(flatten)]
900    pub location: MutantLocation,
901    /// The status in the plan.
902    pub disposition: MutationPlanDisposition,
903    /// The static rule of a `pruned` mutant, kebab-case.
904    #[serde(default, skip_serializing_if = "Option::is_none")]
905    pub prune_reason: Option<String>,
906    /// How tests reach the code of the mutant in the module graph. Only a
907    /// `no_coverage` mutant has this field.
908    #[serde(default, skip_serializing_if = "Option::is_none")]
909    pub test_reach: Option<MutantTestReach>,
910}
911
912/// One instrumented file of a plan.
913#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
914#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
915pub struct MutationPlanFile {
916    /// The root-relative path, with forward slashes.
917    pub path: String,
918    /// The SHA-256 digest of the file, lowercase hex.
919    pub source_sha256: String,
920    /// The test files that reach the file in the module graph, sorted.
921    pub static_test_files: Vec<String>,
922}
923
924/// The totals of a plan.
925#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
926#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
927pub struct MutationPlanCountsOutput {
928    /// Each mutant.
929    pub total: usize,
930    /// Planned mutants.
931    pub planned: usize,
932    /// Mutants after the mutant limit.
933    pub pending: usize,
934    /// Mutants that no test file reaches.
935    pub no_coverage: usize,
936    /// Mutants in unused files.
937    pub pruned_dead_code: usize,
938    /// Mutants in generated files.
939    pub pruned_generated: usize,
940    /// Mutants near a file system, process, network or database sink.
941    pub pruned_side_effect_sink: usize,
942    /// Mutants in framework config exports.
943    pub pruned_framework_config: usize,
944}
945
946/// The envelope of `fallow mutation --plan`. It runs no user code.
947#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
948#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
949pub struct MutationPlanOutput {
950    /// Independent schema version.
951    pub schema_version: MutationPlanSchemaVersion,
952    /// The Fallow version.
953    pub version: ToolVersion,
954    /// The total wall time.
955    pub elapsed_ms: ElapsedMs,
956    /// The mutated code.
957    pub scope: MutationScopeOutput,
958    /// The totals.
959    pub counts: MutationPlanCountsOutput,
960    /// Each mutant, ranked mutants first.
961    pub mutants: Vec<MutationPlanMutant>,
962    /// The instrumented files.
963    pub files: Vec<MutationPlanFile>,
964    /// The union of the static test files, sorted.
965    pub test_files: Vec<String>,
966    /// True when the project has no test files.
967    pub no_test_files: bool,
968    /// True when the plan applied the `framework-config` prune rule.
969    pub framework_config_checked: bool,
970    /// The selected files that the plan did not instrument.
971    pub skipped_files: Vec<MutationSkippedFile>,
972    /// The paths of a `paths` scope that select no file.
973    pub unmatched_paths: Vec<String>,
974}
975
976/// Version singleton for a mutation inspect packet.
977#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
978#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
979pub enum MutationInspectSchemaVersion {
980    /// Initial independent contract.
981    #[serde(rename = "1")]
982    V1,
983}
984
985/// Whether the saved report still describes the current file.
986#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
987#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
988#[serde(rename_all = "snake_case")]
989pub enum MutationInspectStatus {
990    /// The file has the digest of the report, and the mutant is reproduced.
991    Fresh,
992    /// The file changed, is missing, or no longer gives the mutant. The
993    /// packet then has no code.
994    Stale,
995}
996
997/// Why an inspect packet is stale.
998#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
999#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1000#[serde(rename_all = "kebab-case")]
1001pub enum MutationStaleReason {
1002    /// The file does not exist or cannot be read.
1003    FileMissing,
1004    /// The report has no digest for the file.
1005    NoDigest,
1006    /// The SHA-256 digest of the file differs from the report.
1007    SourceChanged,
1008    /// The file has the digest of the report, but the instrumentation does
1009    /// not give the mutant.
1010    MutantNotReproduced,
1011}
1012
1013/// The code of one mutant, from the current file.
1014#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1015#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1016pub struct MutationInspectCode {
1017    /// The original text of the span.
1018    pub original: String,
1019    /// The text that replaces the span.
1020    pub replacement: String,
1021    /// The source lines that contain the span, before the mutation.
1022    pub original_lines: String,
1023    /// The same lines with the replacement applied.
1024    pub mutated_lines: String,
1025}
1026
1027/// The envelope of `fallow mutation inspect`. It runs no user code.
1028#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1029#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1030pub struct MutationInspectOutput {
1031    /// Independent schema version.
1032    pub schema_version: MutationInspectSchemaVersion,
1033    /// The Fallow version.
1034    pub version: ToolVersion,
1035    /// Whether the report still describes the current file.
1036    pub status: MutationInspectStatus,
1037    /// Why the packet is stale. Absent for a fresh packet.
1038    #[serde(default, skip_serializing_if = "Option::is_none")]
1039    pub stale_reason: Option<MutationStaleReason>,
1040    /// The mutant and its position, from the report.
1041    #[serde(flatten)]
1042    pub location: MutantLocation,
1043    /// The status of the mutant in the report. Absent for a plan.
1044    #[serde(default, skip_serializing_if = "Option::is_none")]
1045    pub report_status: Option<MutantStatus>,
1046    /// The SHA-256 digest of the file in the report.
1047    pub report_sha256: Option<String>,
1048    /// The SHA-256 digest of the current file. `null` when the file is
1049    /// missing.
1050    pub current_sha256: Option<String>,
1051    /// The original and mutated code. `null` for a stale packet.
1052    pub code: Option<MutationInspectCode>,
1053    /// The tests that reached the mutant in the dry run, sorted.
1054    pub covering_tests: Vec<String>,
1055    /// The test files that reach the file in the module graph, sorted.
1056    pub static_test_files: Vec<String>,
1057    /// The test that killed the mutant in the run of the report.
1058    pub killed_by: Option<String>,
1059}
1060
1061/// Serialize a mutation report with its root discriminator.
1062///
1063/// # Errors
1064///
1065/// Returns a serde error when the envelope cannot be converted to JSON.
1066pub fn serialize_mutation_json_output(
1067    output: MutationReport,
1068) -> Result<serde_json::Value, serde_json::Error> {
1069    serialize_named_json_output(output, "mutation")
1070}
1071
1072/// Serialize a mutation plan with its root discriminator.
1073///
1074/// # Errors
1075///
1076/// Returns a serde error when the envelope cannot be converted to JSON.
1077pub fn serialize_mutation_plan_json_output(
1078    output: MutationPlanOutput,
1079) -> Result<serde_json::Value, serde_json::Error> {
1080    serialize_named_json_output(output, "mutation-plan")
1081}
1082
1083/// Serialize a mutation inspect packet with its root discriminator.
1084///
1085/// # Errors
1086///
1087/// Returns a serde error when the envelope cannot be converted to JSON.
1088pub fn serialize_mutation_inspect_json_output(
1089    output: MutationInspectOutput,
1090) -> Result<serde_json::Value, serde_json::Error> {
1091    serialize_named_json_output(output, "mutation-inspect")
1092}
1093
1094#[cfg(test)]
1095mod tests {
1096    use super::*;
1097
1098    fn counts(statuses: &[MutantStatus]) -> MutationStatusCounts {
1099        let mut counts = MutationStatusCounts::default();
1100        for status in statuses {
1101            counts.add(*status);
1102        }
1103        counts
1104    }
1105
1106    #[test]
1107    fn the_score_counts_executed_mutants_and_coverage_counts_reached_mutants() {
1108        let counts = counts(&[
1109            MutantStatus::Killed,
1110            MutantStatus::Killed,
1111            MutantStatus::Timeout,
1112            MutantStatus::Survived,
1113            MutantStatus::NoCoverage,
1114            MutantStatus::NoCoverage,
1115            MutantStatus::RuntimeError,
1116            MutantStatus::Flaky,
1117            MutantStatus::Pruned,
1118            MutantStatus::Pending,
1119        ]);
1120        assert_eq!(counts.total, 10);
1121        assert_eq!(counts.killed, 2);
1122        assert_eq!(counts.timeout, 1);
1123        assert_eq!(counts.survived, 1);
1124        assert_eq!(counts.no_coverage, 2);
1125        assert_eq!(counts.runtime_error, 1);
1126        assert_eq!(counts.flaky, 1);
1127        assert_eq!(counts.pruned, 1);
1128        assert_eq!(counts.pending, 1);
1129        assert_eq!(counts.score(), Some(0.75));
1130    }
1131
1132    fn result(status: MutantStatus, covering_tests: &[&str]) -> MutantResult {
1133        MutantResult {
1134            location: MutantLocation {
1135                mutant_id: "m".to_owned(),
1136                file: "src/a.ts".to_owned(),
1137                line: 1,
1138                column: 1,
1139                span: (0, 1),
1140                operator: "arithmetic-operator".to_owned(),
1141                function: None,
1142                function_line: None,
1143                rank: None,
1144                change: None,
1145            },
1146            status,
1147            prune_reason: None,
1148            killed_by: None,
1149            covering_tests: covering_tests
1150                .iter()
1151                .map(|test| (*test).to_owned())
1152                .collect(),
1153            static_mutant: false,
1154            duration_ms: None,
1155            test_reach: None,
1156            retried: false,
1157            attempts: Vec::new(),
1158            cached: false,
1159            resumed: false,
1160            type_error_codes: Vec::new(),
1161        }
1162    }
1163
1164    #[test]
1165    fn coverage_counts_executed_mutants_with_a_test_and_pending_mutants_as_covered() {
1166        let summary = MutationSummary::from_results(&[
1167            result(MutantStatus::Killed, &["t1"]),
1168            result(MutantStatus::Survived, &["t1"]),
1169            result(MutantStatus::RuntimeError, &["t2"]),
1170            result(MutantStatus::Pending, &[]),
1171            result(MutantStatus::NoCoverage, &[]),
1172            result(MutantStatus::Equivalent, &["t1"]),
1173            result(MutantStatus::Pruned, &[]),
1174        ]);
1175        assert_eq!(summary.score, Some(0.5));
1176        assert_eq!(summary.coverage, Some(0.8));
1177        assert_eq!(
1178            MutationSummary::from_results(&[result(MutantStatus::Pruned, &[])]).coverage,
1179            None
1180        );
1181    }
1182
1183    #[test]
1184    fn a_capped_run_reports_the_coverage_of_the_full_run() {
1185        let full = MutationSummary::from_results(&[
1186            result(MutantStatus::Killed, &["t1"]),
1187            result(MutantStatus::Survived, &["t1"]),
1188            result(MutantStatus::NoCoverage, &[]),
1189            result(MutantStatus::NoCoverage, &[]),
1190        ]);
1191        let capped = MutationSummary::from_results(&[
1192            result(MutantStatus::Killed, &["t1"]),
1193            result(MutantStatus::Pending, &[]),
1194            result(MutantStatus::NoCoverage, &[]),
1195            result(MutantStatus::NoCoverage, &[]),
1196        ]);
1197        assert_eq!(full.coverage, Some(0.5));
1198        assert_eq!(capped.coverage, Some(0.5));
1199        assert_eq!(full.score, Some(0.5));
1200        assert_eq!(capped.score, Some(1.0));
1201    }
1202
1203    #[test]
1204    fn equivalent_mutants_leave_the_score() {
1205        let mut counts = counts(&[
1206            MutantStatus::Killed,
1207            MutantStatus::Survived,
1208            MutantStatus::Survived,
1209        ]);
1210        assert_eq!(counts.score(), Some(1.0 / 3.0));
1211        counts.remove(MutantStatus::Survived);
1212        counts.add(MutantStatus::Equivalent);
1213        assert_eq!(counts.total, 3);
1214        assert_eq!(counts.survived, 1);
1215        assert_eq!(counts.equivalent, 1);
1216        assert_eq!(counts.score(), Some(0.5));
1217        counts.remove(MutantStatus::Timeout);
1218        assert_eq!(counts.total, 3, "removing an absent status changes nothing");
1219    }
1220
1221    #[test]
1222    fn verdict_documents_reject_unknown_fields_and_verdicts() {
1223        let valid: MutationVerdictInput = serde_json::from_value(serde_json::json!({
1224            "schema_version": "1",
1225            "verdicts": [{ "review_key": "abc", "verdict": "equivalent", "note": "same result" }]
1226        }))
1227        .expect("valid document");
1228        assert_eq!(valid.verdicts[0].verdict, MutationVerdictKind::Equivalent);
1229        for invalid in [
1230            serde_json::json!({ "schema_version": "1", "verdicts": [{ "review_key": "a", "verdict": "killed" }] }),
1231            serde_json::json!({ "schema_version": "1", "verdicts": [{ "review_key": "a", "verdict": "equivalent", "extra": 1 }] }),
1232            serde_json::json!({ "schema_version": "2", "verdicts": [] }),
1233            serde_json::json!({ "verdicts": [] }),
1234        ] {
1235            assert!(
1236                serde_json::from_value::<MutationVerdictInput>(invalid.clone()).is_err(),
1237                "{invalid}"
1238            );
1239        }
1240    }
1241
1242    #[test]
1243    fn the_score_is_null_when_no_mutant_counts() {
1244        let excluded = counts(&[
1245            MutantStatus::RuntimeError,
1246            MutantStatus::Pruned,
1247            MutantStatus::Flaky,
1248        ]);
1249        assert_eq!(excluded.score(), None);
1250        assert_eq!(counts(&[MutantStatus::Survived]).score(), Some(0.0));
1251        assert_eq!(counts(&[MutantStatus::Timeout]).score(), Some(1.0));
1252    }
1253
1254    #[test]
1255    fn completion_is_partial_only_with_a_reason() {
1256        let complete = MutationCompletion::from_reasons(Vec::new());
1257        assert_eq!(complete.status, MutationCompletionStatus::Complete);
1258        let partial = MutationCompletion::from_reasons(vec![
1259            MutationIncompleteReason::TimeBudget,
1260            MutationIncompleteReason::RuntimeError,
1261            MutationIncompleteReason::TimeBudget,
1262        ]);
1263        assert_eq!(partial.status, MutationCompletionStatus::Partial);
1264        assert_eq!(
1265            partial.reasons,
1266            vec![
1267                MutationIncompleteReason::RuntimeError,
1268                MutationIncompleteReason::TimeBudget
1269            ]
1270        );
1271    }
1272
1273    #[test]
1274    fn a_mutant_result_serializes_flat_with_snake_case_status_and_no_source_text() {
1275        let result = MutantResult {
1276            location: MutantLocation {
1277                mutant_id: "m1".to_owned(),
1278                file: "src/math.ts".to_owned(),
1279                line: 2,
1280                column: 10,
1281                span: (40, 45),
1282                operator: "arithmetic".to_owned(),
1283                function: Some("add".to_owned()),
1284                function_line: Some(1),
1285                rank: Some(1),
1286                change: None,
1287            },
1288            status: MutantStatus::NoCoverage,
1289            prune_reason: None,
1290            killed_by: None,
1291            covering_tests: Vec::new(),
1292            static_mutant: false,
1293            duration_ms: None,
1294            test_reach: None,
1295            retried: false,
1296            attempts: Vec::new(),
1297            cached: false,
1298            resumed: false,
1299            type_error_codes: Vec::new(),
1300        };
1301        let value = serde_json::to_value(&result).expect("serializes");
1302        assert_eq!(
1303            value,
1304            serde_json::json!({
1305                "mutant_id": "m1",
1306                "file": "src/math.ts",
1307                "line": 2,
1308                "column": 10,
1309                "span": [40, 45],
1310                "operator": "arithmetic",
1311                "function": "add",
1312                "function_line": 1,
1313                "rank": 1,
1314                "status": "no_coverage"
1315            })
1316        );
1317    }
1318    #[test]
1319    fn a_retried_mutant_serializes_each_attempt_in_run_order() {
1320        let attempt = |status, duration_ms, failure_kind| MutantAttempt {
1321            status,
1322            duration_ms: Some(duration_ms),
1323            killed_by: None,
1324            failure_kind,
1325            detail: None,
1326        };
1327        let result = MutantResult {
1328            location: MutantLocation {
1329                mutant_id: "m1".to_owned(),
1330                file: "src/math.ts".to_owned(),
1331                line: 2,
1332                column: 10,
1333                span: (40, 45),
1334                operator: "arithmetic".to_owned(),
1335                function: None,
1336                function_line: None,
1337                rank: None,
1338                change: None,
1339            },
1340            status: MutantStatus::Flaky,
1341            prune_reason: None,
1342            killed_by: None,
1343            covering_tests: Vec::new(),
1344            static_mutant: false,
1345            duration_ms: Some(5000),
1346            test_reach: None,
1347            retried: true,
1348            attempts: vec![
1349                attempt(
1350                    MutantStatus::Timeout,
1351                    5000,
1352                    Some(MutantFailureKind::Timeout),
1353                ),
1354                attempt(MutantStatus::Survived, 12, None),
1355            ],
1356            cached: false,
1357            resumed: false,
1358            type_error_codes: Vec::new(),
1359        };
1360        let value = serde_json::to_value(&result).expect("serializes");
1361        assert_eq!(
1362            value["attempts"],
1363            serde_json::json!([
1364                {"status": "timeout", "duration_ms": 5000, "failure_kind": "timeout"},
1365                {"status": "survived", "duration_ms": 12}
1366            ])
1367        );
1368        assert_eq!(value["retried"], serde_json::json!(true));
1369    }
1370}