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    /// The TypeScript diagnostic codes of the new errors of a
401    /// `compile_error` mutant, sorted, at most 8.
402    #[serde(default, skip_serializing_if = "Vec::is_empty")]
403    pub type_error_codes: Vec<u32>,
404}
405
406/// One run of a retried mutant.
407#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
408#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
409pub struct MutantAttempt {
410    /// The status of this run alone.
411    pub status: MutantStatus,
412    /// The duration of this run in milliseconds. A watchdog timeout reports
413    /// the wait of the watchdog.
414    #[serde(default, skip_serializing_if = "Option::is_none")]
415    pub duration_ms: Option<u64>,
416    /// The test that killed the mutant in this run.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub killed_by: Option<String>,
419    /// How the run detected the mutant. Absent for a run that did not detect
420    /// it, and absent when the runner did not state the kind.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub failure_kind: Option<MutantFailureKind>,
423    /// The first line of the reason of a `runtime_error` run.
424    #[serde(default, skip_serializing_if = "Option::is_none")]
425    pub detail: Option<String>,
426}
427
428/// How a run detected a mutant.
429#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
430#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
431#[serde(rename_all = "snake_case")]
432pub enum MutantFailureKind {
433    /// A test assertion failed.
434    Assertion,
435    /// A test or the module load threw an error that is not an assertion.
436    Error,
437    /// A test or the run passed its time limit.
438    Timeout,
439}
440
441impl MutantFailureKind {
442    /// The kind of a `failure_kind` value of the companion protocol. An
443    /// unknown value gives `None`.
444    #[must_use]
445    pub fn from_wire(value: &str) -> Option<Self> {
446        match value {
447            "assertion" => Some(Self::Assertion),
448            "error" => Some(Self::Error),
449            "timeout" => Some(Self::Timeout),
450            _ => None,
451        }
452    }
453}
454
455/// How tests reach the code of one mutant in the module graph. The class
456/// gives a static reason why no test covers the mutant.
457///
458/// A mutant in the top-level statement that declares a value export gets the
459/// class of that export. A mutant outside each such statement gets the class
460/// of its file: `none-file` when no test loads the file, `mocked-only` when no
461/// test loads the file and a mock explains an export of the file, and `none`
462/// when a test loads the file.
463#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
464#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
465pub struct MutantTestReach {
466    /// The reach class. The same open set as the test reach of `audit`.
467    pub reach: TestReachClass,
468    /// The export whose declaration holds the mutant. Absent for a mutant
469    /// outside each value export declaration.
470    #[serde(default, skip_serializing_if = "Option::is_none")]
471    pub export_name: Option<String>,
472    /// For `mocked-only`: the test files whose references a mock removed,
473    /// sorted.
474    #[serde(default, skip_serializing_if = "Vec::is_empty")]
475    pub tests: Vec<String>,
476    /// For `mocked-only`: the mocked modules on the reference paths, sorted.
477    /// A mocked module can be a barrel in front of the file of the mutant.
478    #[serde(default, skip_serializing_if = "Vec::is_empty")]
479    pub mocked_targets: Vec<String>,
480}
481
482/// The result cache facts of a run.
483#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
484#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
485pub struct MutationCacheOutput {
486    /// The mutants whose status came from the cache. They did not run.
487    pub hits: usize,
488    /// The results that this run added to the cache.
489    pub stored: usize,
490    /// The test inputs that the cache key does not cover: modules that a
491    /// test loads through a dynamic import that the module graph does not
492    /// resolve (`dynamic-imports`), and files that a test reads
493    /// (`file-reads`). A change to them does not invalidate a cached result.
494    pub not_keyed: Vec<String>,
495}
496
497/// The results of the mutants of one function.
498#[derive(Debug, Clone, PartialEq, Serialize)]
499#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
500pub struct MutationFunctionResult {
501    /// The root-relative path, with forward slashes.
502    pub file: String,
503    /// The function name. `null` for code outside each function, and for an
504    /// anonymous function.
505    pub name: Option<String>,
506    /// The 1-based first line. `null` for code outside each function.
507    pub line: Option<u32>,
508    /// The 1-based last line. `null` for code outside each function.
509    pub end_line: Option<u32>,
510    /// True when an added line of the diff is in the function.
511    pub in_diff: bool,
512    /// The cyclomatic complexity, when the analysis measured the function.
513    #[serde(default, skip_serializing_if = "Option::is_none")]
514    pub cyclomatic: Option<u16>,
515    /// The number of mutants for each status.
516    pub counts: MutationStatusCounts,
517    /// The mutation score of the function.
518    pub score: Option<f64>,
519    /// The ids of the survived mutants, in rank order.
520    #[serde(default, skip_serializing_if = "Vec::is_empty")]
521    pub survived: Vec<String>,
522}
523
524/// The totals of a run.
525#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
526#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
527pub struct MutationSummary {
528    /// The number of mutants for each status.
529    pub counts: MutationStatusCounts,
530    /// The mutation score over the executed mutants, a fraction from 0 to 1:
531    /// `(killed + timeout) / (killed + timeout + survived)`. `null` when no
532    /// mutant ran to a tested result. `--min-score` and `mutation.minScore`
533    /// compare this value in percent.
534    pub score: Option<f64>,
535    /// The share of mutants that a test reaches, a fraction from 0 to 1:
536    /// `covered / (covered + no_coverage)` over the whole plan. `covered`
537    /// counts each executed mutant with a covering test and each `pending`
538    /// mutant that a passing dry-run test covers. A `pending` mutant that no
539    /// dry-run test covers is `no_coverage`, so a run that `--max-mutants`
540    /// caps reports the coverage of the full run. `equivalent` and `pruned`
541    /// mutants never count. `null` when no mutant is covered or unreached.
542    pub coverage: Option<f64>,
543}
544
545impl MutationSummary {
546    /// The totals, the score and the coverage of `results`.
547    #[must_use]
548    pub fn from_results(results: &[MutantResult]) -> Self {
549        let mut counts = MutationStatusCounts::default();
550        let (mut covered, mut uncovered) = (0usize, 0usize);
551        for result in results {
552            counts.add(result.status);
553            match result.status {
554                MutantStatus::NoCoverage => uncovered += 1,
555                MutantStatus::Pending => covered += 1,
556                MutantStatus::Killed
557                | MutantStatus::Timeout
558                | MutantStatus::Survived
559                | MutantStatus::RuntimeError
560                | MutantStatus::Flaky
561                | MutantStatus::CompileError
562                    if !result.covering_tests.is_empty() || result.static_mutant =>
563                {
564                    covered += 1;
565                }
566                _ => {}
567            }
568        }
569        Self {
570            counts,
571            score: counts.score(),
572            coverage: ratio(covered, covered + uncovered),
573        }
574    }
575}
576
577#[expect(
578    clippy::cast_precision_loss,
579    reason = "mutant counts are far below 2^52"
580)]
581fn ratio(part: usize, whole: usize) -> Option<f64> {
582    (whole > 0).then(|| part as f64 / whole as f64)
583}
584
585/// A test or a test file that failed with no active mutant. Its tests do not
586/// decide a mutant status.
587#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
588#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
589pub struct MutationBaselineFailure {
590    /// The test file, root-relative. `null` for an error outside each test
591    /// file.
592    pub file: Option<String>,
593    /// The test id, for a failed test.
594    #[serde(default, skip_serializing_if = "Option::is_none")]
595    pub test_id: Option<String>,
596    /// The error message, for an error outside a test, or the reason for a
597    /// test that did not pass in the dry run of a restarted companion.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub message: Option<String>,
600    /// The mutants that ran without this test, because the test did not pass
601    /// in the dry run of a restarted companion. Each of them ran with its
602    /// other covering tests. Absent for a failure of the first dry run.
603    #[serde(default, skip_serializing_if = "Vec::is_empty")]
604    pub mutant_ids: Vec<String>,
605}
606
607/// A selected file that the plan did not instrument.
608#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
609#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
610pub struct MutationSkippedFile {
611    /// The root-relative path, with forward slashes.
612    pub path: String,
613    /// Why the plan skipped the file, kebab-case.
614    pub reason: String,
615}
616
617/// The git worktree check of a run: a run refuses a dirty worktree unless
618/// `--allow-dirty` is set, and it compares the worktree after the run.
619#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
620#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
621pub struct MutationWorktreeCheck {
622    /// False when the root is not in a git worktree, so no check ran.
623    pub checked: bool,
624    /// True when the caller allowed a dirty worktree.
625    pub allow_dirty: bool,
626    /// The paths that were dirty before the run, sorted.
627    #[serde(default, skip_serializing_if = "Vec::is_empty")]
628    pub dirty_at_start: Vec<String>,
629    /// The paths that the run changed, sorted. A non-empty list is an error.
630    pub changed_paths: Vec<String>,
631}
632
633/// An error of a run that did not stop the run.
634#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
635#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
636pub struct MutationRunIssue {
637    /// A stable kebab-case code: `runtime-error`, `static-map-miss`,
638    /// `test-files-not-discovered`, `typecheck-failed`, `worktree-changed`
639    /// or `worktree-check-failed`.
640    pub code: String,
641    /// A description.
642    pub message: String,
643    /// The mutant of the issue.
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub mutant_id: Option<String>,
646    /// The last part of the companion stderr.
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub stderr_tail: Option<String>,
649}
650
651/// The wall time of each phase, in milliseconds.
652#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
653#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
654pub struct MutationTimings {
655    /// The analysis and the plan.
656    pub plan_ms: u64,
657    /// The companion install, the run directory, and the first `init`.
658    pub setup_ms: u64,
659    /// The dry run.
660    pub dry_run_ms: u64,
661    /// The mutant runs.
662    pub mutants_ms: u64,
663    /// The second runs of survived and timed-out mutants.
664    pub retry_ms: u64,
665    /// The type check of the survivors. Zero when the run did not check
666    /// them.
667    pub typecheck_ms: u64,
668}
669
670/// Why the type check did not check a survivor.
671#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
672#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
673#[serde(rename_all = "kebab-case")]
674pub enum MutationTypecheckSkipReason {
675    /// No TypeScript project includes the file.
676    NoProject,
677    /// The checker does not check the file: a `// @ts-nocheck` comment, or a
678    /// JavaScript file without `checkJs`.
679    NotTypeChecked,
680    /// The file changed after the plan.
681    SourceChanged,
682    /// The file does not exist.
683    FileMissing,
684    /// The checker cannot read the file type: a Vue, Svelte or Astro
685    /// component file.
686    UnsupportedFile,
687}
688
689/// The number of survivors with one skip reason.
690#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
691#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
692pub struct MutationTypecheckSkip {
693    /// Why the type check did not check these survivors.
694    pub reason: MutationTypecheckSkipReason,
695    /// The number of survivors.
696    pub count: usize,
697}
698
699/// The type check of the survivors (`--typecheck`).
700#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
701#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
702pub struct MutationTypecheckOutput {
703    /// True when the type check gave a result for each survivor that it
704    /// received. False when the companion failed; the survivors then keep
705    /// the status `survived`.
706    pub completed: bool,
707    /// The survivors that the type check received.
708    pub survivors: usize,
709    /// The survivors that the checker checked.
710    pub checked: usize,
711    /// The checked survivors that do not type-check. They have the status
712    /// `compile_error`.
713    pub compile_errors: usize,
714    /// The survivors that the checker did not check, by reason.
715    #[serde(default, skip_serializing_if = "Vec::is_empty")]
716    pub skipped: Vec<MutationTypecheckSkip>,
717}
718
719/// The envelope of `fallow mutation`.
720#[derive(Debug, Clone, PartialEq, Serialize)]
721#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
722pub struct MutationReport {
723    /// Independent schema version.
724    pub schema_version: MutationSchemaVersion,
725    /// The Fallow version.
726    pub version: ToolVersion,
727    /// The total wall time.
728    pub elapsed_ms: ElapsedMs,
729    /// Whether the result is conclusive.
730    pub completion: MutationCompletion,
731    /// The mutated code.
732    pub scope: MutationScopeOutput,
733    /// The runner. Absent when no companion started, for example when no
734    /// test file reaches a mutant.
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub runner: Option<MutationRunnerOutput>,
737    /// The result cache. Absent with `--no-cache`, and when no companion
738    /// started.
739    #[serde(default, skip_serializing_if = "Option::is_none")]
740    pub cache: Option<MutationCacheOutput>,
741    /// The totals.
742    pub summary: MutationSummary,
743    /// Each mutant, ranked mutants first.
744    pub mutants: Vec<MutantResult>,
745    /// The results per enclosing function, by file and line.
746    pub functions: Vec<MutationFunctionResult>,
747    /// True when the project has no test files.
748    pub no_test_files: bool,
749    /// The tests and test files that failed with no active mutant.
750    pub baseline_failures: Vec<MutationBaselineFailure>,
751    /// The selected files that the plan did not instrument.
752    pub skipped_files: Vec<MutationSkippedFile>,
753    /// The paths of a `paths` scope that select no file.
754    pub unmatched_paths: Vec<String>,
755    /// The instrumented files that the runner never loaded.
756    pub unloaded_paths: Vec<String>,
757    /// The git worktree check.
758    pub worktree: MutationWorktreeCheck,
759    /// The errors that did not stop the run.
760    pub errors: Vec<MutationRunIssue>,
761    /// The wall time of each phase.
762    pub timings: MutationTimings,
763    /// The mutated files with their SHA-256 digest at plan time, sorted by
764    /// path. `fallow mutation inspect` compares the digest with the current
765    /// file.
766    pub files: Vec<MutationPlanFile>,
767    /// The join with a verdict document. Absent when the run read no verdict
768    /// document (`--verdicts` not set).
769    #[serde(default, skip_serializing_if = "Option::is_none")]
770    pub verdicts: Option<MutationVerdictJoin>,
771    /// The type check of the survivors. Absent when the run did not check
772    /// them (`--typecheck` not set).
773    #[serde(default, skip_serializing_if = "Option::is_none")]
774    pub typecheck: Option<MutationTypecheckOutput>,
775}
776
777/// Version singleton for a mutation verdict document.
778#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
779#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
780pub enum MutationVerdictSchemaVersion {
781    /// Initial independent contract.
782    #[serde(rename = "1")]
783    V1,
784}
785
786/// The judgment of a mutation verdict.
787#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
788#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
789#[serde(rename_all = "kebab-case")]
790pub enum MutationVerdictKind {
791    /// The mutant behaves the same as the original code, so no test can kill
792    /// it. It gets the status `equivalent` and leaves the score.
793    Equivalent,
794}
795
796/// One reviewed mutant.
797#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
798#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
799#[serde(deny_unknown_fields)]
800pub struct MutationVerdict {
801    /// The review key of the mutant. It is the `mutant_id`: the id hashes the
802    /// path, the text of the enclosing function, the relative span, the
803    /// operator and the replacement, so it stays the same until the function
804    /// changes.
805    pub review_key: String,
806    /// The judgment.
807    pub verdict: MutationVerdictKind,
808    /// A short reason, for human review.
809    #[serde(default, skip_serializing_if = "Option::is_none")]
810    pub note: Option<String>,
811}
812
813/// A versioned verdict document, authored by a person or an agent, for
814/// `fallow mutation --verdicts <file>`.
815#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
816#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
817#[serde(deny_unknown_fields)]
818pub struct MutationVerdictInput {
819    /// Independent verdict document schema version.
820    pub schema_version: MutationVerdictSchemaVersion,
821    /// The verdicts. Each `review_key` occurs one time.
822    pub verdicts: Vec<MutationVerdict>,
823}
824
825/// The result of the join of a run with a verdict document.
826#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
827#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
828pub struct MutationVerdictJoin {
829    /// The SHA-256 digest of the verdict document, lowercase hex.
830    pub verdicts_sha256: String,
831    /// The mutants that got the status `equivalent`, sorted.
832    pub applied: Vec<String>,
833    /// The verdicts that a test contradicts, sorted: the mutant was killed or
834    /// timed out, so it is not equivalent. The status stays.
835    pub contradicted: Vec<String>,
836    /// The verdicts for mutants with no tested result (`pending`,
837    /// `runtime_error` or `pruned`), sorted. The status stays.
838    pub deferred: Vec<String>,
839    /// The verdicts that match no mutant of this run, sorted. A verdict
840    /// document can hold keys for code outside the scope of the run, and an
841    /// edit of a function gives its mutants new keys.
842    pub unmatched: Vec<String>,
843}
844
845/// The status of a mutant in a plan.
846#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
847#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
848#[serde(rename_all = "snake_case")]
849pub enum MutationPlanDisposition {
850    /// A run executes the mutant.
851    Planned,
852    /// The mutant is after the mutant limit. The plan limit comes before
853    /// the coverage is known: a run executes a pending mutant in the place
854    /// of a planned mutant that no test covers.
855    Pending,
856    /// No test file reaches the file of the mutant.
857    NoCoverage,
858    /// A static rule removes the mutant.
859    Pruned,
860}
861
862/// One mutant of a plan.
863#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
864#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
865pub struct MutationPlanMutant {
866    /// The mutant and its position.
867    #[serde(flatten)]
868    pub location: MutantLocation,
869    /// The status in the plan.
870    pub disposition: MutationPlanDisposition,
871    /// The static rule of a `pruned` mutant, kebab-case.
872    #[serde(default, skip_serializing_if = "Option::is_none")]
873    pub prune_reason: Option<String>,
874    /// How tests reach the code of the mutant in the module graph. Only a
875    /// `no_coverage` mutant has this field.
876    #[serde(default, skip_serializing_if = "Option::is_none")]
877    pub test_reach: Option<MutantTestReach>,
878}
879
880/// One instrumented file of a plan.
881#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
882#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
883pub struct MutationPlanFile {
884    /// The root-relative path, with forward slashes.
885    pub path: String,
886    /// The SHA-256 digest of the file, lowercase hex.
887    pub source_sha256: String,
888    /// The test files that reach the file in the module graph, sorted.
889    pub static_test_files: Vec<String>,
890}
891
892/// The totals of a plan.
893#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize)]
894#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
895pub struct MutationPlanCountsOutput {
896    /// Each mutant.
897    pub total: usize,
898    /// Planned mutants.
899    pub planned: usize,
900    /// Mutants after the mutant limit.
901    pub pending: usize,
902    /// Mutants that no test file reaches.
903    pub no_coverage: usize,
904    /// Mutants in unused files.
905    pub pruned_dead_code: usize,
906    /// Mutants in generated files.
907    pub pruned_generated: usize,
908    /// Mutants near a file system, process, network or database sink.
909    pub pruned_side_effect_sink: usize,
910    /// Mutants in framework config exports.
911    pub pruned_framework_config: usize,
912}
913
914/// The envelope of `fallow mutation --plan`. It runs no user code.
915#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
916#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
917pub struct MutationPlanOutput {
918    /// Independent schema version.
919    pub schema_version: MutationPlanSchemaVersion,
920    /// The Fallow version.
921    pub version: ToolVersion,
922    /// The total wall time.
923    pub elapsed_ms: ElapsedMs,
924    /// The mutated code.
925    pub scope: MutationScopeOutput,
926    /// The totals.
927    pub counts: MutationPlanCountsOutput,
928    /// Each mutant, ranked mutants first.
929    pub mutants: Vec<MutationPlanMutant>,
930    /// The instrumented files.
931    pub files: Vec<MutationPlanFile>,
932    /// The union of the static test files, sorted.
933    pub test_files: Vec<String>,
934    /// True when the project has no test files.
935    pub no_test_files: bool,
936    /// True when the plan applied the `framework-config` prune rule.
937    pub framework_config_checked: bool,
938    /// The selected files that the plan did not instrument.
939    pub skipped_files: Vec<MutationSkippedFile>,
940    /// The paths of a `paths` scope that select no file.
941    pub unmatched_paths: Vec<String>,
942}
943
944/// Version singleton for a mutation inspect packet.
945#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
946#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
947pub enum MutationInspectSchemaVersion {
948    /// Initial independent contract.
949    #[serde(rename = "1")]
950    V1,
951}
952
953/// Whether the saved report still describes the current file.
954#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
955#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
956#[serde(rename_all = "snake_case")]
957pub enum MutationInspectStatus {
958    /// The file has the digest of the report, and the mutant is reproduced.
959    Fresh,
960    /// The file changed, is missing, or no longer gives the mutant. The
961    /// packet then has no code.
962    Stale,
963}
964
965/// Why an inspect packet is stale.
966#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
967#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
968#[serde(rename_all = "kebab-case")]
969pub enum MutationStaleReason {
970    /// The file does not exist or cannot be read.
971    FileMissing,
972    /// The report has no digest for the file.
973    NoDigest,
974    /// The SHA-256 digest of the file differs from the report.
975    SourceChanged,
976    /// The file has the digest of the report, but the instrumentation does
977    /// not give the mutant.
978    MutantNotReproduced,
979}
980
981/// The code of one mutant, from the current file.
982#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
983#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
984pub struct MutationInspectCode {
985    /// The original text of the span.
986    pub original: String,
987    /// The text that replaces the span.
988    pub replacement: String,
989    /// The source lines that contain the span, before the mutation.
990    pub original_lines: String,
991    /// The same lines with the replacement applied.
992    pub mutated_lines: String,
993}
994
995/// The envelope of `fallow mutation inspect`. It runs no user code.
996#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
997#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
998pub struct MutationInspectOutput {
999    /// Independent schema version.
1000    pub schema_version: MutationInspectSchemaVersion,
1001    /// The Fallow version.
1002    pub version: ToolVersion,
1003    /// Whether the report still describes the current file.
1004    pub status: MutationInspectStatus,
1005    /// Why the packet is stale. Absent for a fresh packet.
1006    #[serde(default, skip_serializing_if = "Option::is_none")]
1007    pub stale_reason: Option<MutationStaleReason>,
1008    /// The mutant and its position, from the report.
1009    #[serde(flatten)]
1010    pub location: MutantLocation,
1011    /// The status of the mutant in the report. Absent for a plan.
1012    #[serde(default, skip_serializing_if = "Option::is_none")]
1013    pub report_status: Option<MutantStatus>,
1014    /// The SHA-256 digest of the file in the report.
1015    pub report_sha256: Option<String>,
1016    /// The SHA-256 digest of the current file. `null` when the file is
1017    /// missing.
1018    pub current_sha256: Option<String>,
1019    /// The original and mutated code. `null` for a stale packet.
1020    pub code: Option<MutationInspectCode>,
1021    /// The tests that reached the mutant in the dry run, sorted.
1022    pub covering_tests: Vec<String>,
1023    /// The test files that reach the file in the module graph, sorted.
1024    pub static_test_files: Vec<String>,
1025    /// The test that killed the mutant in the run of the report.
1026    pub killed_by: Option<String>,
1027}
1028
1029/// Serialize a mutation report with its root discriminator.
1030///
1031/// # Errors
1032///
1033/// Returns a serde error when the envelope cannot be converted to JSON.
1034pub fn serialize_mutation_json_output(
1035    output: MutationReport,
1036) -> Result<serde_json::Value, serde_json::Error> {
1037    serialize_named_json_output(output, "mutation")
1038}
1039
1040/// Serialize a mutation plan with its root discriminator.
1041///
1042/// # Errors
1043///
1044/// Returns a serde error when the envelope cannot be converted to JSON.
1045pub fn serialize_mutation_plan_json_output(
1046    output: MutationPlanOutput,
1047) -> Result<serde_json::Value, serde_json::Error> {
1048    serialize_named_json_output(output, "mutation-plan")
1049}
1050
1051/// Serialize a mutation inspect packet with its root discriminator.
1052///
1053/// # Errors
1054///
1055/// Returns a serde error when the envelope cannot be converted to JSON.
1056pub fn serialize_mutation_inspect_json_output(
1057    output: MutationInspectOutput,
1058) -> Result<serde_json::Value, serde_json::Error> {
1059    serialize_named_json_output(output, "mutation-inspect")
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064    use super::*;
1065
1066    fn counts(statuses: &[MutantStatus]) -> MutationStatusCounts {
1067        let mut counts = MutationStatusCounts::default();
1068        for status in statuses {
1069            counts.add(*status);
1070        }
1071        counts
1072    }
1073
1074    #[test]
1075    fn the_score_counts_executed_mutants_and_coverage_counts_reached_mutants() {
1076        let counts = counts(&[
1077            MutantStatus::Killed,
1078            MutantStatus::Killed,
1079            MutantStatus::Timeout,
1080            MutantStatus::Survived,
1081            MutantStatus::NoCoverage,
1082            MutantStatus::NoCoverage,
1083            MutantStatus::RuntimeError,
1084            MutantStatus::Flaky,
1085            MutantStatus::Pruned,
1086            MutantStatus::Pending,
1087        ]);
1088        assert_eq!(counts.total, 10);
1089        assert_eq!(counts.killed, 2);
1090        assert_eq!(counts.timeout, 1);
1091        assert_eq!(counts.survived, 1);
1092        assert_eq!(counts.no_coverage, 2);
1093        assert_eq!(counts.runtime_error, 1);
1094        assert_eq!(counts.flaky, 1);
1095        assert_eq!(counts.pruned, 1);
1096        assert_eq!(counts.pending, 1);
1097        assert_eq!(counts.score(), Some(0.75));
1098    }
1099
1100    fn result(status: MutantStatus, covering_tests: &[&str]) -> MutantResult {
1101        MutantResult {
1102            location: MutantLocation {
1103                mutant_id: "m".to_owned(),
1104                file: "src/a.ts".to_owned(),
1105                line: 1,
1106                column: 1,
1107                span: (0, 1),
1108                operator: "arithmetic-operator".to_owned(),
1109                function: None,
1110                function_line: None,
1111                rank: None,
1112                change: None,
1113            },
1114            status,
1115            prune_reason: None,
1116            killed_by: None,
1117            covering_tests: covering_tests
1118                .iter()
1119                .map(|test| (*test).to_owned())
1120                .collect(),
1121            static_mutant: false,
1122            duration_ms: None,
1123            test_reach: None,
1124            retried: false,
1125            attempts: Vec::new(),
1126            cached: false,
1127            type_error_codes: Vec::new(),
1128        }
1129    }
1130
1131    #[test]
1132    fn coverage_counts_executed_mutants_with_a_test_and_pending_mutants_as_covered() {
1133        let summary = MutationSummary::from_results(&[
1134            result(MutantStatus::Killed, &["t1"]),
1135            result(MutantStatus::Survived, &["t1"]),
1136            result(MutantStatus::RuntimeError, &["t2"]),
1137            result(MutantStatus::Pending, &[]),
1138            result(MutantStatus::NoCoverage, &[]),
1139            result(MutantStatus::Equivalent, &["t1"]),
1140            result(MutantStatus::Pruned, &[]),
1141        ]);
1142        assert_eq!(summary.score, Some(0.5));
1143        assert_eq!(summary.coverage, Some(0.8));
1144        assert_eq!(
1145            MutationSummary::from_results(&[result(MutantStatus::Pruned, &[])]).coverage,
1146            None
1147        );
1148    }
1149
1150    #[test]
1151    fn a_capped_run_reports_the_coverage_of_the_full_run() {
1152        let full = MutationSummary::from_results(&[
1153            result(MutantStatus::Killed, &["t1"]),
1154            result(MutantStatus::Survived, &["t1"]),
1155            result(MutantStatus::NoCoverage, &[]),
1156            result(MutantStatus::NoCoverage, &[]),
1157        ]);
1158        let capped = MutationSummary::from_results(&[
1159            result(MutantStatus::Killed, &["t1"]),
1160            result(MutantStatus::Pending, &[]),
1161            result(MutantStatus::NoCoverage, &[]),
1162            result(MutantStatus::NoCoverage, &[]),
1163        ]);
1164        assert_eq!(full.coverage, Some(0.5));
1165        assert_eq!(capped.coverage, Some(0.5));
1166        assert_eq!(full.score, Some(0.5));
1167        assert_eq!(capped.score, Some(1.0));
1168    }
1169
1170    #[test]
1171    fn equivalent_mutants_leave_the_score() {
1172        let mut counts = counts(&[
1173            MutantStatus::Killed,
1174            MutantStatus::Survived,
1175            MutantStatus::Survived,
1176        ]);
1177        assert_eq!(counts.score(), Some(1.0 / 3.0));
1178        counts.remove(MutantStatus::Survived);
1179        counts.add(MutantStatus::Equivalent);
1180        assert_eq!(counts.total, 3);
1181        assert_eq!(counts.survived, 1);
1182        assert_eq!(counts.equivalent, 1);
1183        assert_eq!(counts.score(), Some(0.5));
1184        counts.remove(MutantStatus::Timeout);
1185        assert_eq!(counts.total, 3, "removing an absent status changes nothing");
1186    }
1187
1188    #[test]
1189    fn verdict_documents_reject_unknown_fields_and_verdicts() {
1190        let valid: MutationVerdictInput = serde_json::from_value(serde_json::json!({
1191            "schema_version": "1",
1192            "verdicts": [{ "review_key": "abc", "verdict": "equivalent", "note": "same result" }]
1193        }))
1194        .expect("valid document");
1195        assert_eq!(valid.verdicts[0].verdict, MutationVerdictKind::Equivalent);
1196        for invalid in [
1197            serde_json::json!({ "schema_version": "1", "verdicts": [{ "review_key": "a", "verdict": "killed" }] }),
1198            serde_json::json!({ "schema_version": "1", "verdicts": [{ "review_key": "a", "verdict": "equivalent", "extra": 1 }] }),
1199            serde_json::json!({ "schema_version": "2", "verdicts": [] }),
1200            serde_json::json!({ "verdicts": [] }),
1201        ] {
1202            assert!(
1203                serde_json::from_value::<MutationVerdictInput>(invalid.clone()).is_err(),
1204                "{invalid}"
1205            );
1206        }
1207    }
1208
1209    #[test]
1210    fn the_score_is_null_when_no_mutant_counts() {
1211        let excluded = counts(&[
1212            MutantStatus::RuntimeError,
1213            MutantStatus::Pruned,
1214            MutantStatus::Flaky,
1215        ]);
1216        assert_eq!(excluded.score(), None);
1217        assert_eq!(counts(&[MutantStatus::Survived]).score(), Some(0.0));
1218        assert_eq!(counts(&[MutantStatus::Timeout]).score(), Some(1.0));
1219    }
1220
1221    #[test]
1222    fn completion_is_partial_only_with_a_reason() {
1223        let complete = MutationCompletion::from_reasons(Vec::new());
1224        assert_eq!(complete.status, MutationCompletionStatus::Complete);
1225        let partial = MutationCompletion::from_reasons(vec![
1226            MutationIncompleteReason::TimeBudget,
1227            MutationIncompleteReason::RuntimeError,
1228            MutationIncompleteReason::TimeBudget,
1229        ]);
1230        assert_eq!(partial.status, MutationCompletionStatus::Partial);
1231        assert_eq!(
1232            partial.reasons,
1233            vec![
1234                MutationIncompleteReason::RuntimeError,
1235                MutationIncompleteReason::TimeBudget
1236            ]
1237        );
1238    }
1239
1240    #[test]
1241    fn a_mutant_result_serializes_flat_with_snake_case_status_and_no_source_text() {
1242        let result = MutantResult {
1243            location: MutantLocation {
1244                mutant_id: "m1".to_owned(),
1245                file: "src/math.ts".to_owned(),
1246                line: 2,
1247                column: 10,
1248                span: (40, 45),
1249                operator: "arithmetic".to_owned(),
1250                function: Some("add".to_owned()),
1251                function_line: Some(1),
1252                rank: Some(1),
1253                change: None,
1254            },
1255            status: MutantStatus::NoCoverage,
1256            prune_reason: None,
1257            killed_by: None,
1258            covering_tests: Vec::new(),
1259            static_mutant: false,
1260            duration_ms: None,
1261            test_reach: None,
1262            retried: false,
1263            attempts: Vec::new(),
1264            cached: false,
1265            type_error_codes: Vec::new(),
1266        };
1267        let value = serde_json::to_value(&result).expect("serializes");
1268        assert_eq!(
1269            value,
1270            serde_json::json!({
1271                "mutant_id": "m1",
1272                "file": "src/math.ts",
1273                "line": 2,
1274                "column": 10,
1275                "span": [40, 45],
1276                "operator": "arithmetic",
1277                "function": "add",
1278                "function_line": 1,
1279                "rank": 1,
1280                "status": "no_coverage"
1281            })
1282        );
1283    }
1284    #[test]
1285    fn a_retried_mutant_serializes_each_attempt_in_run_order() {
1286        let attempt = |status, duration_ms, failure_kind| MutantAttempt {
1287            status,
1288            duration_ms: Some(duration_ms),
1289            killed_by: None,
1290            failure_kind,
1291            detail: None,
1292        };
1293        let result = MutantResult {
1294            location: MutantLocation {
1295                mutant_id: "m1".to_owned(),
1296                file: "src/math.ts".to_owned(),
1297                line: 2,
1298                column: 10,
1299                span: (40, 45),
1300                operator: "arithmetic".to_owned(),
1301                function: None,
1302                function_line: None,
1303                rank: None,
1304                change: None,
1305            },
1306            status: MutantStatus::Flaky,
1307            prune_reason: None,
1308            killed_by: None,
1309            covering_tests: Vec::new(),
1310            static_mutant: false,
1311            duration_ms: Some(5000),
1312            test_reach: None,
1313            retried: true,
1314            attempts: vec![
1315                attempt(
1316                    MutantStatus::Timeout,
1317                    5000,
1318                    Some(MutantFailureKind::Timeout),
1319                ),
1320                attempt(MutantStatus::Survived, 12, None),
1321            ],
1322            cached: false,
1323            type_error_codes: Vec::new(),
1324        };
1325        let value = serde_json::to_value(&result).expect("serializes");
1326        assert_eq!(
1327            value["attempts"],
1328            serde_json::json!([
1329                {"status": "timeout", "duration_ms": 5000, "failure_kind": "timeout"},
1330                {"status": "survived", "duration_ms": 12}
1331            ])
1332        );
1333        assert_eq!(value["retried"], serde_json::json!(true));
1334    }
1335}