lihaaf 0.1.2

Fast compile-fail and compile-pass test harness for Rust proc macros; a faster trybuild-style workflow
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
//! Phase 3 + Phase 4 of compat mode (issues #8 + #9) — argv-only
//! baseline command runner with conservative libtest-output parsing.
//!
//! Spawns the baseline `cargo test` invocation that fork CI compares
//! against. The exact argv vector is supplied by the caller (the
//! `--compat-cargo-test-argv` flag, parsed in Phase 1 and bundled into
//! [`crate::compat::cli::CompatArgs::compat_cargo_test_argv`]); this
//! module never tokenizes a string and never invokes a shell.
//!
//! ## Security invariant — no shell, ever
//!
//! The argv vector is handed directly to
//! [`std::process::Command::new`] + [`std::process::Command::args`], so
//! shell metacharacters (`$HOME`, `;`, `&&`, single quotes, backticks,
//! …) are passed through as literal bytes to the spawned program.
//! There is no path that constructs a single command-line string and
//! hands it to `sh -c`, `bash -c`, or `cmd /c`. The same guarantee
//! holds on Windows: [`std::process::Command`] dispatches via
//! `CreateProcess` directly rather than going through `cmd.exe`, and
//! `std`'s argv-joining round-trip uses the documented Microsoft C
//! runtime quoting so the child sees argv entries verbatim.
//!
//! See `docs/compatibility-plan.md` §3.1 — "no shell command line" is a
//! locked v0.1 invariant.
//!
//! ## Phase 3 scope vs. Phase 4 scope
//!
//! - **Phase 3** ([`run_baseline`]): captures the **coarse baseline**
//!   — argv, exit code, wall-clock, raw stdout / stderr bytes. The
//!   [`BaselineResult::pass`] and [`BaselineResult::fail`] fields stay
//!   [`None`]; [`BaselineResult::unknown_count`] stays `0`. Emits the
//!   sidecar at `schema_version == 1`.
//! - **Phase 4** ([`run_baseline_with_recognized_fixtures`]): runs the
//!   same capture, then funnels the libtest output through
//!   [`parse_libtest_output`] against a caller-supplied recognized-
//!   fixture set (Phase 6 will populate this from a syn AST walk of the
//!   target crate). Populates `pass` / `fail` / `unknown_count` /
//!   `mismatch_entries` per the `docs/compatibility-plan.md` §1
//!   conservatism rule: fixture-level pass/fail is only reported when
//!   the libtest test name correlates to an explicitly recognized
//!   fixture; otherwise the line goes to `unknown_count`. Emits the
//!   sidecar at `schema_version == 2`.
//!
//! ### Sidecar JSON shape
//!
//! v1 (Phase 3 only):
//!
//! ```json
//! {
//!   "schema_version": 1,
//!   "argv": ["cargo", "test", "..."],
//!   "exit_code": 0,
//!   "stdout": "<raw stdout text>",
//!   "stderr": "<raw stderr text>"
//! }
//! ```
//!
//! v2 (Phase 4, additive over v1):
//!
//! ```json
//! {
//!   "schema_version": 2,
//!   "argv": ["cargo", "test", "..."],
//!   "exit_code": 0,
//!   "stdout": "<raw stdout text>",
//!   "stderr": "<raw stderr text>",
//!   "pass": 120,
//!   "fail": 5,
//!   "unknown_count": 0,
//!   "mismatch_entries": [
//!     {"fixture": "tests/foo.rs", "baseline_verdict": "pass"},
//!     {"fixture": "tests/bar.rs", "baseline_verdict": "fail"}
//!   ]
//! }
//! ```
//!
//! `pass` and `fail` are `null` rather than absent when the parser ran
//! against an empty recognized-fixture set — `null` documents "the
//! parser ran but produced no recognized verdict", which is distinct
//! from "the parser was never invoked".
//!
//! ## Conservatism rule (§1)
//!
//! > Baseline extraction is intentionally conservative. Compat mode
//! > records the original `cargo test` command result as the coarse
//! > baseline. Fixture-level baseline status may only be reported when
//! > it is derived from explicitly recognized trybuild invocations and
//! > stable path matches; otherwise the fixture baseline is `unknown`
//! > and the report must say why. The v0.1 §5 pilot gate enforces the
//! > §3.3 envelope's mismatch ceiling and per-side exit-code rule; it
//! > does NOT enforce `unknown_count == 0` (the libtest wrapper line
//! > alone produces `unknown_count >= 1` on every adopter run).
//! > `results.baseline.unknown_count` remains a diagnostic field;
//! > the implementation must not infer fixture-level truth from
//! > arbitrary libtest output.
//!
//! Practically: [`parse_libtest_output`] emits `Some(pass)` / `Some(fail)`
//! counts whenever the caller supplied at least one recognized fixture.
//! Both counts are `0` when no libtest line correlates to any recognized
//! fixture (rather than `None`); uncorrelated libtest lines and recognized
//! fixtures absent from output both bump `unknown_count`.
//! Every other libtest line (unrecognized test names, recognized
//! fixtures absent from output, garbled lines) is `unknown_count++`.
//! Empty recognized-fixture set ⇒ `pass.is_none() && fail.is_none()`,
//! and every parsed line counts as unknown. The `unknown_count`
//! itself is kept for diagnostic visibility in the §3.3 envelope's
//! `results.baseline.unknown_count` — the §5 pilot gate does NOT
//! key off `unknown_count` (every adopter run produces a wrapper
//! libtest line so `unknown_count >= 1` is the steady state).

use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::Instant;

use crate::error::Error;
use crate::util;

/// One captured baseline run.
///
/// `pub` (with the parent module pinned at `pub(crate)`) so the crate
/// root can [`#[doc(hidden)]`] re-export this for the integration test
/// crate. Not part of any v0.1 stability contract — the supported entry
/// to compat mode is `cargo lihaaf --compat`, not the Rust API.
///
/// Fields are documented in `docs/compatibility-plan.md` §3.3 (the
/// `results.baseline` subset) plus the §3.3 envelope's
/// `commands.baseline` field for `argv`.
#[derive(Debug)]
// The §3.3 envelope writer reads these when wiring `results.baseline`
// and `commands.baseline`. The fields are carried as part of the
// envelope schema and the integration tests in
// `tests/compat/argv_baseline_no_shell.rs` assert the captured shape.
#[allow(dead_code)]
pub struct BaselineResult {
    /// Number of fixtures libtest reported as passing. Populated only
    /// when fixture-level baseline output is recognized. The basic
    /// [`run_baseline`] entry point always returns `None`;
    /// [`run_baseline_with_recognized_fixtures`] returns `Some(n)` only
    /// when at least one recognized fixture correlated to a libtest
    /// verdict.
    pub pass: Option<u32>,
    /// Number of fixtures libtest reported as failing. Same nullable
    /// rule as [`Self::pass`].
    pub fail: Option<u32>,
    /// Number of fixtures whose libtest output didn't match a
    /// recognized trybuild invocation. Always populated. The basic
    /// [`run_baseline`] entry point always returns `0`.
    /// [`run_baseline_with_recognized_fixtures`] increments this for
    /// every unrecognized libtest line, every recognized fixture absent
    /// from libtest output, and every garbled verdict line — see
    /// [`parse_libtest_output`] for the full classification rule.
    pub unknown_count: u32,
    /// Exit code from the child process. On a signal-terminated child
    /// (no real exit code; `ExitStatus::code()` returns [`None`]) this
    /// is `-1` — the §3.3 envelope renders the signal in `errors[]`
    /// rather than overloading the exit code field.
    pub exit_code: i32,
    /// Wall-clock for the baseline run, milliseconds. EXCLUDED from
    /// determinism checks per §3.3 (timing is not byte-stable across
    /// machines).
    pub dur_ms: u64,
    /// Path to the libtest output sidecar JSON the runner wrote.
    /// Always populated even on a non-zero exit so the §3.3 envelope
    /// writer can point adopters at the raw bytes for diagnosis.
    pub sidecar_path: PathBuf,
    /// Resolved argv that was actually executed. Recorded so the §3.3
    /// envelope's `commands.baseline` field can render the exact
    /// invocation. This is a byte-for-byte copy of the input slice —
    /// no quoting, no shell-escape normalization.
    pub argv: Vec<String>,
    /// Per-fixture mismatch records produced by [`parse_libtest_output`]
    /// in Phase 4. Always sorted by `fixture` (forward-slash ASCII
    /// byte order). Empty in the Phase 3 entry point ([`run_baseline`])
    /// and in any Phase 4 call where no recognized fixture correlated
    /// to a libtest verdict; the §3.3 envelope writer (Phase 8)
    /// transforms these into the `mismatch_examples` array.
    pub mismatch_entries: Vec<BaselineMismatch>,
}

/// Sidecar JSON schema version emitted by the Phase 3 entry point
/// [`run_baseline`]. Adopters parsing the v1 sidecar should gate on
/// this exact integer; the v2 schema is additive over v1 (no field is
/// renamed or retyped) but the bump is the explicit hook adopters
/// switch on.
const SIDECAR_SCHEMA_VERSION_V1: u32 = 1;

/// Sidecar JSON schema version emitted by the Phase 4 entry point
/// [`run_baseline_with_recognized_fixtures`]. Layers `pass`, `fail`,
/// `unknown_count`, and `mismatch_entries` over the v1 shape. Bumping
/// here keeps the Phase 3 sidecar's v1 stamp untouched and lets the
/// Phase 8 envelope writer key off the version without inspecting
/// fields.
const SIDECAR_SCHEMA_VERSION_V2: u32 = 2;

/// Sentinel exit code used when the child terminated via a signal and
/// no real OS-level exit code is available. `-1` is chosen because
/// every real POSIX exit code is in `0..=255`, and Windows
/// [`std::process::ExitStatus::code`] only returns `None` on a
/// signal-style termination (rare on that platform).
const SIGNAL_TERMINATED_EXIT_SENTINEL: i32 = -1;

/// One trybuild fixture that Phase 6 (syn AST discovery) recognized in
/// the target crate's test sources. Passed into
/// [`run_baseline_with_recognized_fixtures`] and
/// [`parse_libtest_output`] so the parser knows which fixtures are
/// authorized to contribute to fixture-level pass/fail counts.
///
/// In Phase 4 callers (the conservative-baseline integration tests)
/// construct this directly. Phase 6 produces a richer
/// `DiscoveredFixture` type with the originating `(call_site,
/// fixture_path)` pair; this type carries only the repo-relative
/// fixture path because that is the single field
/// [`parse_libtest_output`] needs.
///
/// `repo_relative_path` is stored as a [`PathBuf`] (platform-native
/// separators) but [`parse_libtest_output`] internally compares
/// against a forward-slash projection so Windows checkouts work the
/// same as POSIX.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FixtureId {
    /// Repo-relative path to the fixture file, e.g.
    /// `tests/trybuild/compile_fail/foo.rs`. Comparison is
    /// forward-slash-normalized before matching against libtest
    /// output so a path that happens to use `\` separators on
    /// Windows still correlates correctly.
    pub repo_relative_path: PathBuf,
}

/// The §3.3 envelope's `MismatchExample` raw input for one recognized
/// fixture. The Phase 8 envelope writer sorts these by `fixture` and
/// transforms them into the final `mismatch_examples` array; this
/// module produces them unsorted (Phase 4) and exposes them on
/// [`BaselineResult::mismatch_entries`] for that downstream step.
///
/// One `BaselineMismatch` is emitted per recognized fixture for which
/// the parser saw a libtest verdict line. The parser carries the raw
/// libtest verdict (`Pass` / `Fail`) plus the fixture path; the §3.3
/// envelope assembly joins this with the lihaaf-side outcome to
/// decide the `mismatch_type` (`baseline_only_fail`,
/// `lihaaf_only_fail`, etc.).
///
/// `baseline_verdict` is the libtest reading; the parser deliberately
/// does **not** synthesize a comparison against lihaaf here (that is
/// the envelope-assembly step's job).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BaselineMismatch {
    /// Repo-relative, forward-slash path to the fixture. Sorted on
    /// this field by the §3.3 envelope writer.
    pub fixture: String,
    /// What libtest said about this fixture, classified into the
    /// conservative `Pass` / `Fail` bucket. Unrecognized verdict
    /// shapes never reach this struct — they go to
    /// [`BaselineResult::unknown_count`] instead.
    pub baseline_verdict: BaselineVerdict,
}

/// Conservative classification of a single libtest verdict line.
/// Only two variants are possible because the parser is
/// fail-closed: anything that does not match a documented verdict
/// shape (`... ok`, `... FAILED`) is dropped into `unknown_count`
/// rather than coerced into one of these buckets.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BaselineVerdict {
    /// Libtest line ended with `... ok`.
    Pass,
    /// Libtest line ended with `... FAILED`.
    Fail,
}

impl BaselineVerdict {
    /// Stable string form for the v2 sidecar JSON. Stays lowercase
    /// so the on-disk shape is `jq`-friendly and matches the §3.3
    /// envelope's lowercase enum convention.
    fn as_str(self) -> &'static str {
        match self {
            BaselineVerdict::Pass => "pass",
            BaselineVerdict::Fail => "fail",
        }
    }
}

/// Output of [`parse_libtest_output`]. Mirrors the four Phase 4 fields
/// of [`BaselineResult`] (`pass`, `fail`, `unknown_count`,
/// `mismatch_entries`) but is a standalone return type so the parser
/// is unit-testable without spawning a child process.
///
/// Determinism: `mismatch_entries` is sorted by `fixture` (forward-
/// slash ASCII byte order) before being returned. Phase 8's envelope
/// writer asserts the same ordering on its own output; producing it
/// sorted here means the Phase 8 sort is a no-op against this input,
/// and downstream consumers reading the sidecar JSON see a stable
/// layout.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedBaseline {
    /// Number of recognized fixtures the parser saw passing in
    /// libtest output. `None` when the recognized-fixture set was
    /// empty AND no recognized matches happened — that case is
    /// distinct from `Some(0)` (recognized fixtures were supplied
    /// but all of them either failed or were absent from output).
    pub pass: Option<u32>,
    /// Number of recognized fixtures the parser saw failing. Same
    /// nullable rule as [`Self::pass`].
    pub fail: Option<u32>,
    /// Number of libtest lines that could not be assigned a
    /// fixture-level verdict. Always populated. Increments for:
    ///
    /// 1. Every verdict line when the recognized-fixture set is empty.
    /// 2. Verdict lines whose libtest test name does not equal any
    ///    recognized fixture's stem (forward-slash form of
    ///    `repo_relative_path` minus its `.rs` extension; exact
    ///    match — substring matching would let fixture
    ///    `tests/ui/foo.rs` collide with libtest line
    ///    `tests/ui/foo_extra`).
    /// 3. Recognized fixtures the parser never saw in output (the
    ///    "absence of evidence is not pass" rule).
    /// 4. Verdict lines with malformed shape after ANSI stripping
    ///    (truncated, unexpected token order, etc.).
    pub unknown_count: u32,
    /// Per-fixture entries the §3.3 envelope writer (Phase 8) will
    /// turn into `mismatch_examples`. Sorted by `fixture` (forward-
    /// slash ASCII byte order). Populated only for recognized
    /// fixtures the parser correlated to a libtest verdict.
    pub mismatch_entries: Vec<BaselineMismatch>,
}

/// Strip ANSI CSI escape sequences from `s` in place, returning a new
/// owned [`String`]. Used by [`parse_libtest_output`] so a libtest
/// build that emits `\x1b[31mFAILED\x1b[0m` (color-on terminal) is
/// indistinguishable from the CI/cargo-test-with-`--color=never`
/// form.
///
/// Implementation is byte-level (per §6.1 — no regex). Walks the
/// input forward, skipping any run that starts with `\x1b[` and
/// continues until the first byte in `0x40..=0x7e` (the SGR final
/// byte range per ECMA-48 §5.4), inclusive of that byte. Lone
/// `\x1b` not followed by `[` are passed through verbatim — they are
/// not CSI escapes and may be legitimate output content.
///
/// The function is `pub(crate)` so the test module can unit-test it.
pub(crate) fn strip_ansi(s: &str) -> String {
    let bytes = s.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        // CSI: ESC '[' ... <final byte 0x40..=0x7e>.
        if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'[' {
            // Skip past ESC '['.
            i += 2;
            // Eat parameter and intermediate bytes, then the final
            // byte. ECMA-48 final-byte range is 0x40..=0x7e.
            while i < bytes.len() {
                let b = bytes[i];
                i += 1;
                if (0x40..=0x7e).contains(&b) {
                    break;
                }
            }
            continue;
        }
        out.push(bytes[i]);
        i += 1;
    }
    // `bytes` started life as valid UTF-8; we only ever dropped a
    // pure-ASCII subsequence (`ESC [ ... final`) which itself is
    // 7-bit-clean, so what remains is still valid UTF-8. The
    // `from_utf8_unchecked` path is tempting but `from_utf8` is the
    // safe call and the cost is a single linear scan.
    String::from_utf8(out).unwrap_or_default()
}

/// Canonicalize a libtest test-name or a fixture path into a single
/// comparable form. The transformations are:
///
/// 1. Forward-slash normalize (`\` → `/`) so Windows-built test
///    binaries that emit `tests\ui\foo` match the same fixture as
///    `tests/ui/foo`.
/// 2. Replace `::` with `/` so the libtest module-path shape
///    `tests::ui::foo` correlates to `tests/ui/foo`.
/// 3. Strip a trailing `.rs` extension so fixture paths
///    (`tests/ui/foo.rs`) and libtest names that preserve the
///    extension (`tests/ui/foo.rs`) collapse to the same canonical
///    form as the extension-less libtest shape (`tests/ui/foo`).
///
/// Applied to BOTH the recognized-fixture path and the libtest
/// test-name before exact-match comparison. Order matters: `::` → `/`
/// must precede `.rs`-stripping so a name like `tests::foo.rs` (rare
/// but possible) folds to `tests/foo` rather than `tests::foo`.
fn canonical_test_name(s: &str) -> String {
    let forward = util::to_forward_slash(s);
    let no_colons = forward.replace("::", "/");
    no_colons
        .strip_suffix(".rs")
        .map(str::to_string)
        .unwrap_or(no_colons)
}

/// Conservative parser for libtest stdout. See module-level docs for
/// the conservatism rule (§1).
///
/// Inputs:
///
/// - `stdout`: the raw stdout text captured from the baseline
///   `cargo test` run. ANSI escape sequences are stripped before
///   parsing so color-on and color-off output produce identical
///   results.
/// - `recognized_fixtures`: the Phase 6 discovery output, identifying
///   which trybuild fixtures the parser is authorized to assign
///   fixture-level verdicts to. An empty slice triggers the maximally
///   conservative path: every verdict line becomes
///   `unknown_count++`, and `pass` / `fail` stay [`None`].
///
/// Output: [`ParsedBaseline`]. See its docs for the field semantics.
///
/// ### Parsing shape
///
/// Libtest emits one verdict per test in the shape:
///
/// ```text
/// test <test_name> ... ok
/// test <test_name> ... FAILED
/// ```
///
/// Lines not starting with `"test "` are ignored unconditionally
/// (this skips `running N tests`, `test result: ...`, `failures:`,
/// stack traces, and every other shape libtest emits around the
/// per-test lines). Lines starting with `"test "` but missing the
/// `" ... "` separator are counted as `unknown_count` — they are
/// either malformed or a libtest variant the parser does not
/// recognize, and the safe answer is "unknown" per §1.
///
/// ### Fixture-to-test-name correlation
///
/// Libtest reports the **test function name** (e.g. `tests::trybuild`),
/// not the individual fixture path. Phase 6's discovery output maps
/// `(call_site, fixture_path)` pairs; the parser here uses an
/// **exact match** against the canonical form of `repo_relative_path`.
/// Both sides are run through `canonical_test_name` before
/// comparison: `::` is folded to `/`, trailing `.rs` is stripped, and
/// path separators are forward-slashed. This accepts every libtest
/// test-name shape (`tests/ui/foo`, `tests/ui/foo.rs`,
/// `tests::ui::foo`) while preserving the prefix-collision guard — a
/// recognized fixture `tests/ui/foo.rs` must not also match a
/// libtest line `test tests/ui/foo_extra ... ok`.
pub fn parse_libtest_output(stdout: &str, recognized_fixtures: &[FixtureId]) -> ParsedBaseline {
    // Pre-compute the canonical form of every recognized fixture.
    // `iter().find` is O(n) per verdict line, but n is bounded by the
    // recognized-fixture count which is the syn AST walk's output
    // (typically tens to hundreds of fixtures per crate; constant-time
    // lookup adds complexity that buys little).
    let normalized: Vec<(String, &FixtureId)> = recognized_fixtures
        .iter()
        .map(|fid| {
            let raw = fid.repo_relative_path.to_string_lossy().into_owned();
            (canonical_test_name(&raw), fid)
        })
        .collect();

    // Strip ANSI before line splitting — color codes can wrap the
    // entire verdict (`\x1b[32mtest foo ... ok\x1b[0m`) or just the
    // verdict word (`test foo ... \x1b[31mFAILED\x1b[0m`); stripping
    // once at the front means the line-parser sees plain bytes
    // regardless of which shape cargo emitted.
    let cleaned = strip_ansi(stdout);

    let mut pass_count: u32 = 0;
    let mut fail_count: u32 = 0;
    let mut unknown_count: u32 = 0;
    // Track which recognized fixtures were correlated so we can
    // count the ones libtest never named.
    let mut matched_indices: Vec<bool> = vec![false; normalized.len()];
    let mut mismatch_entries: Vec<BaselineMismatch> = Vec::new();

    for raw_line in cleaned.lines() {
        let line = raw_line.trim_start();
        // Only `test ` (with the single trailing space) is a verdict
        // line. The `tests::` Rust module path would be `test `
        // too, but libtest prefixes the line with the literal word
        // `test` followed by the test name; the `s` would be part
        // of the test name token, not the prefix.
        if !line.starts_with("test ") {
            continue;
        }

        // Find the ` ... ` separator. `split_once` is `O(n)` per
        // line; libtest verdict lines are short (typically < 200
        // bytes) so this is fine. No regex (§6.1).
        let after_prefix = &line["test ".len()..];
        let Some((test_name, verdict_part)) = after_prefix.split_once(" ... ") else {
            // Malformed verdict line. Could be `test result: ok.
            // 12 passed; ...` — that starts with `test ` but lacks
            // the ` ... ` separator. Conservative answer: unknown.
            // We deliberately don't bump unknown_count for these
            // summary lines (they are NOT per-test verdicts), so
            // distinguish via the additional check below.
            //
            // Heuristic: if the candidate test_name contains `:`
            // (e.g. `test result:`) or starts with `result`, this
            // is a summary line, not a per-test verdict. Skip.
            if after_prefix.starts_with("result") || after_prefix.starts_with("result:") {
                continue;
            }
            // Otherwise this is a malformed per-test verdict. Per
            // the conservatism rule, count as unknown.
            unknown_count = unknown_count.saturating_add(1);
            continue;
        };

        // Verdict classification. Libtest emits exactly one of
        // `ok`, `FAILED`, `ignored`, or `bench` (the last two are
        // not relevant for trybuild output, but we tolerate them by
        // dropping into unknown). The verdict token may be followed
        // by additional text (timing info on `--report-time`).
        let verdict_token = verdict_part.split_whitespace().next().unwrap_or("");
        let verdict = match verdict_token {
            "ok" => BaselineVerdict::Pass,
            "FAILED" => BaselineVerdict::Fail,
            // `ignored`, `bench`, unknown verdict words — count as
            // unknown rather than coercing into pass/fail.
            _ => {
                unknown_count = unknown_count.saturating_add(1);
                continue;
            }
        };

        // Correlate the test name against the recognized fixtures.
        // Empty recognized set ⇒ every verdict line is unknown
        // (the conservatism rule's strongest form).
        if normalized.is_empty() {
            unknown_count = unknown_count.saturating_add(1);
            continue;
        }

        // Exact match against the fixture's canonical form. Both
        // sides go through `canonical_test_name` so `tests/ui/foo`,
        // `tests/ui/foo.rs`, and `tests::ui::foo` all correlate to a
        // recognized `tests/ui/foo.rs` fixture, while preserving the
        // prefix-collision guard — `tests/ui/foo_extra` canonicalizes
        // distinctly from `tests/ui/foo`.
        let test_name_canon = canonical_test_name(test_name);
        let matched: Option<usize> = normalized
            .iter()
            .position(|(stem, _)| test_name_canon == stem.as_str());

        let Some(idx) = matched else {
            // Libtest named a test the parser couldn't correlate
            // to a recognized fixture. Conservative answer: unknown.
            unknown_count = unknown_count.saturating_add(1);
            continue;
        };

        matched_indices[idx] = true;
        match verdict {
            BaselineVerdict::Pass => pass_count = pass_count.saturating_add(1),
            BaselineVerdict::Fail => fail_count = fail_count.saturating_add(1),
        }
        // Use the ORIGINAL `repo_relative_path` (forward-slash projected)
        // rather than reconstructing from the canonical form + `.rs`.
        // The canonical form folds `::` to `/` and strips `.rs`, so
        // reconstruction is lossy for any fixture whose original path
        // diverges from its canonical stem (and the hard-coded `.rs`
        // suffix is fragile for any future non-`.rs` fixture).
        let original =
            util::to_forward_slash(&normalized[idx].1.repo_relative_path.to_string_lossy());
        mismatch_entries.push(BaselineMismatch {
            fixture: original,
            baseline_verdict: verdict,
        });
    }

    // Recognized fixtures the parser never saw a verdict for: the
    // "absence of evidence is not pass" rule. No mismatch entry is
    // emitted for these — there's nothing for the §3.3 envelope to
    // mismatch against (the baseline verdict is genuinely unknown).
    for seen in &matched_indices {
        if !*seen {
            unknown_count = unknown_count.saturating_add(1);
        }
    }

    // Determinism: sort by fixture forward-slash ASCII byte order.
    mismatch_entries.sort_by(|a, b| a.fixture.cmp(&b.fixture));

    // `pass` / `fail` populated only when the parser had a recognized
    // set to work with. Empty recognized set ⇒ both `None` even if
    // `unknown_count > 0`. Parser-internal invariant: `Some(0)`
    // (recognized fixtures present, none passed) is meaningfully
    // different from `None` (no fixtures recognized) — the §3.3
    // envelope writer keys off this distinction when rendering the
    // baseline counts.
    let (pass, fail) = if normalized.is_empty() {
        (None, None)
    } else {
        (Some(pass_count), Some(fail_count))
    };

    ParsedBaseline {
        pass,
        fail,
        unknown_count,
        mismatch_entries,
    }
}

/// Capture from one argv-only child process spawn. Shared by the
/// Phase 3 and Phase 4 entry points; the only behavioral divergence
/// between them is the post-capture parse + sidecar shape.
struct SpawnCapture {
    exit_code: i32,
    dur_ms: u64,
    stdout: String,
    stderr: String,
}

/// Argv-only child spawn + capture. Centralizes the empty-argv guard,
/// the `Command::new().args().current_dir()` build, the piped I/O
/// setup, the wall-clock measurement, and the
/// signal-terminated-exit-code sentinel handling.
///
/// No shell, no `sh -c`, no `cmd /c` — the first argv element is the
/// program; the remaining elements are direct argv entries the OS
/// hands to the child without interpretation.
fn spawn_and_capture(argv: &[String], cwd: &Path) -> Result<SpawnCapture, Error> {
    if argv.is_empty() {
        return Err(Error::Cli {
            clap_exit_code: 2,
            message: "error: `--compat-cargo-test-argv` must contain at least one argument \
                      (the program to spawn, e.g. `\"cargo\"`)"
                .to_string(),
        });
    }

    let program = &argv[0];
    let args = &argv[1..];

    let started = Instant::now();
    // Capture stdout/stderr so the sidecar can record them. Inherit env
    // by default — the child needs PATH so `cargo` / `rustc` resolve,
    // RUSTUP_TOOLCHAIN so the +toolchain selector continues to work,
    // and CARGO_HOME so adopters with non-default cargo state get the
    // same view their `cargo test` sees outside lihaaf.
    let output = Command::new(program)
        .args(args)
        .current_dir(cwd)
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .output()
        .map_err(|e| Error::SubprocessSpawn {
            program: program.clone(),
            source: e,
        })?;

    let dur_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);

    // `ExitStatus::code()` is `None` on Unix signal-terminated children
    // (`SIGKILL`, `SIGTERM`, …). The §3.3 envelope's `errors[]` field
    // is where signal detail belongs; the integer exit-code slot uses
    // a sentinel so adopters consuming only the bare integer still see
    // a non-zero value rather than a misleading 0.
    let exit_code = output
        .status
        .code()
        .unwrap_or(SIGNAL_TERMINATED_EXIT_SENTINEL);

    // Libtest stdout is well-formed UTF-8 in practice (cargo + rustc
    // emit it as such, and the `--format=json` mode if used would
    // round-trip cleanly). Lossy decode tolerates a binary fixture
    // that emits non-UTF-8 noise — those bytes are lost in the
    // sidecar but the rest of the capture stays readable. The
    // alternative (base64) would add a dependency the v0.1 surface
    // does not need.
    let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
    let stderr = String::from_utf8_lossy(&output.stderr).into_owned();

    Ok(SpawnCapture {
        exit_code,
        dur_ms,
        stdout,
        stderr,
    })
}

/// Run the baseline `cargo test` invocation.
///
/// **Argv-only.** No shell, no `sh -c`, no `cmd /c`. The first element
/// of `argv` is the program; the remaining elements are direct argv
/// entries the OS hands to the child without interpretation.
///
/// **Errors.** Returns:
///
/// - [`Error::Cli`] when `argv` is empty. The diagnostic names the
///   `--compat-cargo-test-argv` flag so the adopter knows which input
///   was malformed even when this function is called through the
///   default `["cargo", "test"]` path.
/// - [`Error::SubprocessSpawn`] when the OS refuses to spawn the
///   program (binary not found, permission denied, …). Distinct from
///   a non-zero exit, which is a normal session outcome captured in
///   [`BaselineResult::exit_code`].
/// - [`Error::Io`] on failure to wait on the child or to write the
///   sidecar JSON.
/// - [`Error::JsonParse`] when the sidecar JSON cannot be serialized.
///   In practice this is unreachable — the input is `String`s,
///   integers, and a vector of `String`s, all of which `serde_json`
///   serializes infallibly — but the error path is wired in defensively
///   so a future schema bump can fail loudly rather than panicking.
///
/// **Side effects.** Writes the sidecar JSON to `sidecar_path` via
/// `crate::util::write_file_atomic`. Creates the sidecar's parent
/// directory if it doesn't exist (matching the atomic-write helper's
/// own semantics).
pub fn run_baseline(
    argv: &[String],
    cwd: &Path,
    sidecar_path: &Path,
) -> Result<BaselineResult, Error> {
    let SpawnCapture {
        exit_code,
        dur_ms,
        stdout,
        stderr,
    } = spawn_and_capture(argv, cwd)?;

    write_sidecar(sidecar_path, argv, exit_code, &stdout, &stderr)?;

    Ok(BaselineResult {
        pass: None,
        fail: None,
        unknown_count: 0,
        exit_code,
        dur_ms,
        sidecar_path: sidecar_path.to_path_buf(),
        argv: argv.to_vec(),
        mismatch_entries: Vec::new(),
    })
}

/// Run the baseline `cargo test` invocation with conservative
/// fixture-level libtest parsing layered on top.
///
/// Same argv-only / no-shell guarantees as [`run_baseline`]. After
/// the capture completes, [`parse_libtest_output`] is called against
/// `stdout` with `recognized_fixtures`; the parsed counts and
/// mismatch entries are written into the returned [`BaselineResult`].
///
/// Side effects: writes the v2 sidecar JSON (see module-level docs)
/// to `sidecar_path`.
///
/// **Conservatism.** When `recognized_fixtures.is_empty()`,
/// [`BaselineResult::pass`] and [`BaselineResult::fail`] stay [`None`]
/// and every verdict line in `stdout` increments
/// [`BaselineResult::unknown_count`]. This is the §1 rule — the
/// envelope's `results.baseline.{pass,fail}` are honest about
/// fixture-level absence rather than inferring counts from arbitrary
/// libtest output.
///
/// Errors: same shape as [`run_baseline`] — `Cli` on empty argv,
/// `SubprocessSpawn` on OS spawn refusal, `Io` on wait/write, and
/// `JsonParse` on sidecar serialization failure.
pub fn run_baseline_with_recognized_fixtures(
    argv: &[String],
    cwd: &Path,
    sidecar_path: &Path,
    recognized_fixtures: &[FixtureId],
) -> Result<BaselineResult, Error> {
    let SpawnCapture {
        exit_code,
        dur_ms,
        stdout,
        stderr,
    } = spawn_and_capture(argv, cwd)?;

    // Phase 4: parse libtest output conservatively.
    let parsed = parse_libtest_output(&stdout, recognized_fixtures);

    write_sidecar_v2(sidecar_path, argv, exit_code, &stdout, &stderr, &parsed)?;

    Ok(BaselineResult {
        pass: parsed.pass,
        fail: parsed.fail,
        unknown_count: parsed.unknown_count,
        exit_code,
        dur_ms,
        sidecar_path: sidecar_path.to_path_buf(),
        argv: argv.to_vec(),
        mismatch_entries: parsed.mismatch_entries,
    })
}

/// Build the v1-shaped envelope keys: `schema_version`, `argv`,
/// `exit_code`, `stdout`, `stderr`, in that insertion order. The v2
/// writer appends additional keys on top of this base.
///
/// Insertion order is preserved by the `preserve_order` feature on
/// `serde_json` (enabled at the crate level), so adopters reading the
/// file with `jq` see a stable shape across runs.
fn build_v1_envelope(
    schema_version: u32,
    argv: &[String],
    exit_code: i32,
    stdout: &str,
    stderr: &str,
) -> serde_json::Map<String, serde_json::Value> {
    let mut envelope = serde_json::Map::new();
    envelope.insert(
        "schema_version".to_string(),
        serde_json::Value::from(schema_version),
    );
    envelope.insert(
        "argv".to_string(),
        serde_json::Value::Array(
            argv.iter()
                .map(|s| serde_json::Value::String(s.clone()))
                .collect(),
        ),
    );
    envelope.insert("exit_code".to_string(), serde_json::Value::from(exit_code));
    envelope.insert(
        "stdout".to_string(),
        serde_json::Value::String(stdout.to_string()),
    );
    envelope.insert(
        "stderr".to_string(),
        serde_json::Value::String(stderr.to_string()),
    );
    envelope
}

/// Serialize the v1 sidecar JSON and write it atomically. Used by
/// the Phase 3 entry point [`run_baseline`]; the Phase 4 entry point
/// calls [`write_sidecar_v2`] instead.
///
/// Split out from [`run_baseline`] for testability — the inline unit
/// test in this module exercises the serializer shape without
/// spawning a child.
fn write_sidecar(
    sidecar_path: &Path,
    argv: &[String],
    exit_code: i32,
    stdout: &str,
    stderr: &str,
) -> Result<(), Error> {
    let envelope = build_v1_envelope(SIDECAR_SCHEMA_VERSION_V1, argv, exit_code, stdout, stderr);

    let mut bytes =
        serde_json::to_vec_pretty(&serde_json::Value::Object(envelope)).map_err(|e| {
            Error::JsonParse {
                context: "serializing compat baseline sidecar".into(),
                message: e.to_string(),
            }
        })?;
    // Trailing newline so `cat` output reads cleanly.
    bytes.push(b'\n');

    util::write_file_atomic(sidecar_path, &bytes)
}

/// Serialize the v2 sidecar JSON and write it atomically. Used by
/// the Phase 4 entry point [`run_baseline_with_recognized_fixtures`].
/// Layers `pass`, `fail`, `unknown_count`, and `mismatch_entries`
/// over the v1 shape; the canonical key order is
/// `schema_version`, `argv`, `exit_code`, `stdout`, `stderr`,
/// `pass`, `fail`, `unknown_count`, `mismatch_entries`. v1 readers
/// that ignore unknown fields keep working against v2 sidecars; v2
/// readers gate on `schema_version == 2` and read the additional
/// fields.
fn write_sidecar_v2(
    sidecar_path: &Path,
    argv: &[String],
    exit_code: i32,
    stdout: &str,
    stderr: &str,
    parsed: &ParsedBaseline,
) -> Result<(), Error> {
    let mut envelope =
        build_v1_envelope(SIDECAR_SCHEMA_VERSION_V2, argv, exit_code, stdout, stderr);
    envelope.insert(
        "pass".to_string(),
        match parsed.pass {
            Some(n) => serde_json::Value::from(n),
            None => serde_json::Value::Null,
        },
    );
    envelope.insert(
        "fail".to_string(),
        match parsed.fail {
            Some(n) => serde_json::Value::from(n),
            None => serde_json::Value::Null,
        },
    );
    envelope.insert(
        "unknown_count".to_string(),
        serde_json::Value::from(parsed.unknown_count),
    );
    let mismatch_array: Vec<serde_json::Value> = parsed
        .mismatch_entries
        .iter()
        .map(|m| {
            let mut obj = serde_json::Map::new();
            obj.insert(
                "fixture".to_string(),
                serde_json::Value::String(m.fixture.clone()),
            );
            obj.insert(
                "baseline_verdict".to_string(),
                serde_json::Value::String(m.baseline_verdict.as_str().to_string()),
            );
            serde_json::Value::Object(obj)
        })
        .collect();
    envelope.insert(
        "mismatch_entries".to_string(),
        serde_json::Value::Array(mismatch_array),
    );

    let mut bytes =
        serde_json::to_vec_pretty(&serde_json::Value::Object(envelope)).map_err(|e| {
            Error::JsonParse {
                context: "serializing compat baseline sidecar (v2)".into(),
                message: e.to_string(),
            }
        })?;
    bytes.push(b'\n');

    util::write_file_atomic(sidecar_path, &bytes)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    /// The empty-argv guard fires before any process is spawned. The
    /// diagnostic must name `--compat-cargo-test-argv` so the adopter
    /// can find the flag in `cargo lihaaf --help`.
    #[test]
    fn empty_argv_is_rejected_with_directed_message() {
        let tmp = tempdir().unwrap();
        let sidecar = tmp.path().join("baseline_capture.json");
        let err = run_baseline(&[], tmp.path(), &sidecar).expect_err("empty argv must be rejected");
        match err {
            Error::Cli { message, .. } => {
                assert!(
                    message.contains("--compat-cargo-test-argv"),
                    "diagnostic must name the flag; got: {message}"
                );
                assert!(
                    message.contains("at least one argument"),
                    "diagnostic must spell out the requirement; got: {message}"
                );
            }
            other => panic!("expected Error::Cli, got {other:?}"),
        }
    }

    /// Sidecar JSON keys land in the documented order:
    /// `schema_version`, `argv`, `exit_code`, `stdout`, `stderr`. A
    /// reorder would silently break adopter `jq` pipelines that pull
    /// fields by position; the `preserve_order` feature on `serde_json`
    /// is the underlying guarantee.
    #[test]
    fn sidecar_shape_is_canonical() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("capture.json");
        let argv = vec!["foo".to_string(), "bar".to_string()];
        write_sidecar(&path, &argv, 0, "out", "err").unwrap();
        let bytes = std::fs::read(&path).unwrap();
        let text = std::str::from_utf8(&bytes).unwrap();

        let i_schema = text
            .find("\"schema_version\"")
            .expect("schema_version key must be present");
        let i_argv = text.find("\"argv\"").expect("argv key must be present");
        let i_exit = text
            .find("\"exit_code\"")
            .expect("exit_code key must be present");
        let i_stdout = text.find("\"stdout\"").expect("stdout key must be present");
        let i_stderr = text.find("\"stderr\"").expect("stderr key must be present");

        assert!(
            i_schema < i_argv && i_argv < i_exit && i_exit < i_stdout && i_stdout < i_stderr,
            "sidecar JSON keys must appear in canonical order: schema_version, argv, \
             exit_code, stdout, stderr; got:\n{text}"
        );
    }

    /// Sidecar schema_version is the documented integer (`1`). A bump
    /// requires a deliberate code change; the test bites if a refactor
    /// accidentally drops the version constant or flips its type.
    #[test]
    fn sidecar_schema_version_is_one() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("capture.json");
        write_sidecar(&path, &["x".to_string()], 0, "", "").unwrap();
        let text = std::fs::read_to_string(&path).unwrap();
        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            v.get("schema_version").and_then(serde_json::Value::as_u64),
            Some(1)
        );
    }

    /// `strip_ansi` removes a single CSI escape sequence and leaves
    /// surrounding bytes alone. Acid test for the byte-level walker;
    /// a regression that off-by-one's the final-byte detection would
    /// either leak the trailing letter (`m`, `K`, etc.) into output
    /// or consume part of the legitimate following text.
    #[test]
    fn strip_ansi_removes_single_csi_sequence() {
        assert_eq!(strip_ansi("\x1b[31mFAILED\x1b[0m"), "FAILED");
        assert_eq!(strip_ansi("plain text"), "plain text");
        assert_eq!(strip_ansi(""), "");
    }

    /// `strip_ansi` handles a CSI in the middle of a longer string
    /// without dropping bytes around it. Also confirms a `[`-only
    /// (without leading `\x1b`) is treated as literal text.
    #[test]
    fn strip_ansi_handles_mixed_content() {
        assert_eq!(
            strip_ansi("test foo ... \x1b[32mok\x1b[0m"),
            "test foo ... ok"
        );
        // A standalone `[` is not a CSI start.
        assert_eq!(strip_ansi("[bracketed]"), "[bracketed]");
    }

    /// `strip_ansi` does not panic on a truncated CSI (no final
    /// byte). The conservative answer is to consume to the end and
    /// emit nothing for the partial sequence — same effect as if the
    /// sequence had completed.
    #[test]
    fn strip_ansi_tolerates_truncated_csi() {
        // `\x1b[31` with no terminator — walker should consume to
        // the end of input and emit only the leading prefix.
        let s = "prefix\x1b[31";
        let out = strip_ansi(s);
        assert_eq!(out, "prefix");
    }

    /// `parse_libtest_output` on an empty recognized-fixture set
    /// counts every verdict line as unknown and leaves pass/fail
    /// `None`. This is the conservatism rule in its strongest form.
    #[test]
    fn parse_empty_recognized_set_yields_all_unknown() {
        let stdout = "test foo ... ok\ntest bar ... FAILED\n";
        let result = parse_libtest_output(stdout, &[]);
        assert_eq!(result.pass, None);
        assert_eq!(result.fail, None);
        assert_eq!(result.unknown_count, 2);
        assert!(result.mismatch_entries.is_empty());
    }

    /// `parse_libtest_output` correlates a libtest line whose
    /// test-name canonicalizes to the same form as a recognized
    /// fixture and bumps the right counter.
    #[test]
    fn parse_recognized_pass_correlates() {
        let recognized = vec![FixtureId {
            repo_relative_path: PathBuf::from("tests/foo.rs"),
        }];
        // The libtest test name `tests/foo` canonicalizes to the
        // same form as the recognized fixture `tests/foo.rs`.
        let stdout = "test tests/foo ... ok\n";
        let result = parse_libtest_output(stdout, &recognized);
        assert_eq!(result.pass, Some(1));
        assert_eq!(result.fail, Some(0));
        assert_eq!(result.unknown_count, 0);
        assert_eq!(result.mismatch_entries.len(), 1);
        assert_eq!(result.mismatch_entries[0].fixture, "tests/foo.rs");
        assert_eq!(
            result.mismatch_entries[0].baseline_verdict,
            BaselineVerdict::Pass
        );
    }

    /// `parse_libtest_output` ignores summary lines and stack
    /// traces — anything not starting with `"test "` plus a
    /// `" ... "` separator is dropped on the floor (not counted as
    /// unknown, not counted as pass/fail).
    #[test]
    fn parse_ignores_summary_and_non_verdict_lines() {
        let stdout = "\n\
            running 5 tests\n\
            test tests/foo ... ok\n\
            test result: ok. 1 passed; 0 failed; 0 ignored\n\
            \n\
            failures:\n\
            \n";
        let recognized = vec![FixtureId {
            repo_relative_path: PathBuf::from("tests/foo.rs"),
        }];
        let result = parse_libtest_output(stdout, &recognized);
        assert_eq!(result.pass, Some(1));
        assert_eq!(result.fail, Some(0));
        // Note: 0 unknowns here despite many non-verdict lines.
        assert_eq!(result.unknown_count, 0);
    }

    /// The v2 sidecar carries the documented field order and
    /// schema version. v1 readers can still parse it (they would
    /// ignore the additional fields); v2 readers gate on
    /// `schema_version == 2`.
    #[test]
    fn sidecar_v2_shape_is_canonical() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("capture.json");
        let parsed = ParsedBaseline {
            pass: Some(3),
            fail: Some(1),
            unknown_count: 0,
            mismatch_entries: vec![BaselineMismatch {
                fixture: "tests/a.rs".to_string(),
                baseline_verdict: BaselineVerdict::Pass,
            }],
        };
        write_sidecar_v2(
            &path,
            &["cargo".to_string(), "test".to_string()],
            0,
            "stdout",
            "stderr",
            &parsed,
        )
        .unwrap();
        let text = std::fs::read_to_string(&path).unwrap();
        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert_eq!(
            v.get("schema_version").and_then(serde_json::Value::as_u64),
            Some(2)
        );
        assert_eq!(v.get("pass").and_then(serde_json::Value::as_u64), Some(3));
        assert_eq!(v.get("fail").and_then(serde_json::Value::as_u64), Some(1));
        assert_eq!(
            v.get("unknown_count").and_then(serde_json::Value::as_u64),
            Some(0)
        );
        let entries = v
            .get("mismatch_entries")
            .and_then(serde_json::Value::as_array)
            .unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(
            entries[0]
                .get("fixture")
                .and_then(serde_json::Value::as_str),
            Some("tests/a.rs")
        );
        assert_eq!(
            entries[0]
                .get("baseline_verdict")
                .and_then(serde_json::Value::as_str),
            Some("pass")
        );

        // Key order: schema_version, argv, exit_code, stdout,
        // stderr, pass, fail, unknown_count, mismatch_entries.
        let i_schema = text.find("\"schema_version\"").unwrap();
        let i_argv = text.find("\"argv\"").unwrap();
        let i_exit = text.find("\"exit_code\"").unwrap();
        let i_stdout = text.find("\"stdout\"").unwrap();
        let i_stderr = text.find("\"stderr\"").unwrap();
        let i_pass = text.find("\"pass\"").unwrap();
        let i_fail = text.find("\"fail\"").unwrap();
        let i_unknown = text.find("\"unknown_count\"").unwrap();
        let i_mismatch = text.find("\"mismatch_entries\"").unwrap();
        assert!(
            i_schema < i_argv
                && i_argv < i_exit
                && i_exit < i_stdout
                && i_stdout < i_stderr
                && i_stderr < i_pass
                && i_pass < i_fail
                && i_fail < i_unknown
                && i_unknown < i_mismatch,
            "v2 sidecar JSON keys must appear in canonical order; got:\n{text}"
        );
    }

    /// v2 sidecar emits JSON `null` for `pass` and `fail` when the
    /// parser ran with an empty recognized set. This is the
    /// machine-readable signal Phase 8's envelope writer keys off
    /// to decide whether to emit fixture-level counts.
    #[test]
    fn sidecar_v2_pass_fail_null_when_recognition_empty() {
        let tmp = tempdir().unwrap();
        let path = tmp.path().join("capture.json");
        let parsed = ParsedBaseline {
            pass: None,
            fail: None,
            unknown_count: 5,
            mismatch_entries: Vec::new(),
        };
        write_sidecar_v2(&path, &["x".to_string()], 0, "", "", &parsed).unwrap();
        let text = std::fs::read_to_string(&path).unwrap();
        let v: serde_json::Value = serde_json::from_str(&text).unwrap();
        assert!(v.get("pass").is_some_and(serde_json::Value::is_null));
        assert!(v.get("fail").is_some_and(serde_json::Value::is_null));
        assert_eq!(
            v.get("unknown_count").and_then(serde_json::Value::as_u64),
            Some(5)
        );
    }
}