mrrc 0.8.1

A Rust library for reading, writing, and manipulating MARC bibliographic records in ISO 2709 binary format
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
//! Error-handling coverage harness.
//!
//! Reads `tests/error_coverage.toml` and runs one assertion bundle per
//! `[[case]]` entry. For each `wired = true` case whose `trigger_kind`
//! the harness supports, asserts that the documented
//! [`MarcError`](mrrc::MarcError) variant, code, slug, and positional
//! context fire when the parser exercises the trigger. For each
//! `wired = false` case (or one whose `trigger_kind` the harness does
//! not yet implement), prints a skip reason so the gap between
//! documentation and implementation is visible in CI output.
//!
//! The harness emits `wired: X/Y (skipped: Z)` on every run. The
//! numbers are monotonic over time: when detection lands for a
//! previously-unwired trigger, flipping `wired = false` →
//! `wired = true` in the manifest is sufficient and the assertion
//! picks up the new case automatically.
//!
//! Currently supported `trigger_kind` values:
//!   * `parse_iso2709` — feed bytes to [`MarcReader`](mrrc::MarcReader)
//!     in strict mode and capture the first error
//!   * `parse_holdings` — feed bytes to
//!     [`HoldingsMarcReader`](mrrc::HoldingsMarcReader) in strict mode
//!     and capture the first error (for holdings-reader-only fire sites)
//!   * `parse_authority` — feed bytes to
//!     [`AuthorityMarcReader`](mrrc::AuthorityMarcReader) in strict
//!     mode and capture the first error (for authority-reader-only
//!     fire sites)
//!   * `parse_marcxml` — feed UTF-8 text to
//!     [`mrrc::marcxml::marcxml_to_record`] and capture the error
//!   * `accessor` — parse the fixture cleanly, then call a hard-coded
//!     accessor whose lookup is documented to raise the case's variant
//!     (currently only `e105_field_not_found` →
//!     `Record::get_field_or_err("999")`)
//!   * `io_error` — wrap a `Read` impl that returns `std::io::Error`
//!     from the first read in a [`MarcReader`](mrrc::MarcReader) and
//!     capture the resulting [`MarcError::IoError`](mrrc::MarcError).
//!     This is the raw-io / leader-boundary path: the failure precedes
//!     any record positioning, so the error carries no positional context
//!   * `io_error_parse_path` — read one complete record, then fail the
//!     underlying source while reading the next record's data area, so
//!     the [`MarcError::IoError`](mrrc::MarcError) is enriched with the
//!     in-progress record's `record_index`, `byte_offset`, and
//!     `source_name`
//!   * `parse_iso2709_lenient` — feed bytes to
//!     [`MarcReader`](mrrc::MarcReader) in lenient mode (no recovery
//!     cap) and return the first error in a yielded record's `errors`
//!     [`Arc<Vec<MarcError>>`](mrrc::MarcError) whose `code()` matches
//!     the case. Used for fire sites that surface via `record.errors`
//!     in lenient/permissive rather than as raised `Err`.
//!   * `recovery_cap` — drive a stream of malformed records past
//!     [`MarcReader::with_max_errors`](mrrc::MarcReader::with_max_errors)
//!     in lenient mode and capture the
//!     [`MarcError::FatalReaderError`](mrrc::MarcError) that fires
//!     when the cap trips
//!   * `writer` — construct a record whose serialized length exceeds
//!     the ISO 2709 99999-byte limit and call
//!     [`MarcWriter::write_record`](mrrc::MarcWriter::write_record),
//!     capturing the resulting
//!     [`MarcError::WriterError`](mrrc::MarcError)
//!   * `programmatic_validator` — construct constrained `Leader` /
//!     `Record` state programmatically and invoke
//!     [`RecordStructureValidator`](mrrc::RecordStructureValidator)
//!     directly. Used to exercise defensive checks guarding values
//!     that the byte-parse path cannot produce (e.g.,
//!     `data_base_address > 99999` from a 5-digit ASCII field).
//!   * `programmatic_writer_check` — invoke
//!     [`mrrc::iso2709::check_iso2709_size`] directly with crafted
//!     arguments. Used to exercise defensive branches in the writer's
//!     size-check helper that the writer's normal control flow does
//!     not reach (e.g., `base_address > 99999` while `record_length
//!     ≤ 99999`).
//!
//! The remaining kind (`parse_marcjson`) skips with a per-kind reason
//! — there is no public Rust `str → Record` API for MARCJSON, so the
//! case is exercised on the Python side instead.

use std::fs;
use std::io::{self, Cursor, Read};
use std::path::{Path, PathBuf};

use mrrc::{
    AuthorityMarcReader, Field, HoldingsMarcReader, Leader, MarcReader, MarcWriter, Record,
    RecordStructureValidator, RecoveryMode, Subfield, ValidationLevel,
};
use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Manifest {
    #[allow(dead_code)]
    schema_version: u32,
    case: Vec<Case>,
}

#[derive(Debug, Deserialize)]
struct Case {
    id: String,
    code: String,
    variant: String,
    slug: String,
    #[serde(default = "default_trigger_kind")]
    trigger_kind: String,
    #[serde(default)]
    trigger_fixture: Option<String>,
    #[allow(dead_code)]
    description: String,
    expected_context: Vec<String>,
    recovery_modes: Vec<String>,
    /// Validation level the harness should use when exercising the
    /// trigger. Optional; defaults to `"structural"` (mrrc's
    /// `MarcReader` default). Cases that require strict-MARC byte
    /// validation (E201, E202, E301) should set this to
    /// `"strict_marc"`.
    #[serde(default)]
    validation_level: Option<String>,
    wired: bool,
    #[serde(default)]
    skip_reason: Option<String>,
}

fn parse_validation_level(case: &Case) -> ValidationLevel {
    match case.validation_level.as_deref() {
        None | Some("structural") => ValidationLevel::Structural,
        Some("strict_marc") => ValidationLevel::StrictMarc,
        Some(other) => panic!(
            "case {}: unknown validation_level {:?} (expected \"structural\" or \"strict_marc\")",
            case.id, other
        ),
    }
}

fn default_trigger_kind() -> String {
    "parse_iso2709".to_string()
}

fn manifest_path() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/error_coverage.toml")
}

fn load_manifest() -> Manifest {
    let text = fs::read_to_string(manifest_path()).expect("read error_coverage.toml");
    toml::from_str(&text).expect("parse error_coverage.toml")
}

fn fixture_path(case: &Case) -> PathBuf {
    let rel = case.trigger_fixture.as_ref().unwrap_or_else(|| {
        panic!(
            "case {} has trigger_kind requiring a fixture but none set",
            case.id
        )
    });
    Path::new(env!("CARGO_MANIFEST_DIR")).join(rel)
}

fn fixture_bytes(case: &Case) -> Vec<u8> {
    let path = fixture_path(case);
    fs::read(&path).unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()))
}

fn fixture_text(case: &Case) -> String {
    let path = fixture_path(case);
    fs::read_to_string(&path).unwrap_or_else(|e| panic!("read fixture {}: {e}", path.display()))
}

/// Outcome of attempting to fire a case's documented trigger:
/// either an error fired (and we can assert against it) or the
/// harness chose not to exercise this trigger and produced a reason
/// to record as a skip.
enum TriggerOutcome {
    Fired(mrrc::MarcError),
    NoError,
    UnsupportedKind(String),
}

/// Exercise an accessor case: parse the fixture cleanly with the
/// appropriate reader type, then invoke the accessor whose lookup is
/// documented to fire the case's variant. New `accessor` cases need
/// their case-id branch wired here — accessor names, arguments, and
/// the reader type aren't yet expressed in the manifest schema.
fn exercise_accessor(case: &Case) -> TriggerOutcome {
    let bytes = fixture_bytes(case);

    match case.id.as_str() {
        "e105_field_not_found" => {
            let mut reader =
                MarcReader::new(Cursor::new(bytes)).with_recovery_mode(RecoveryMode::Strict);
            let Ok(Some(record)) = reader.read_record() else {
                return TriggerOutcome::UnsupportedKind(format!(
                    "{}: bibliographic fixture did not parse to a record",
                    case.id
                ));
            };
            match record.get_field_or_err("999") {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "e105_authority_field_not_found" => {
            let mut reader = AuthorityMarcReader::new(Cursor::new(bytes))
                .with_recovery_mode(RecoveryMode::Strict);
            let Ok(Some(record)) = reader.read_record() else {
                return TriggerOutcome::UnsupportedKind(format!(
                    "{}: authority fixture did not parse to a record",
                    case.id
                ));
            };
            match record.get_field_or_err("999") {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "e105_holdings_field_not_found" => {
            let mut reader = HoldingsMarcReader::new(Cursor::new(bytes))
                .with_recovery_mode(RecoveryMode::Strict);
            let Ok(Some(record)) = reader.read_record() else {
                return TriggerOutcome::UnsupportedKind(format!(
                    "{}: holdings fixture did not parse to a record",
                    case.id
                ));
            };
            match record.get_field_or_err("999") {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        other => TriggerOutcome::UnsupportedKind(format!(
            "trigger_kind=accessor: case {other} has no harness branch; add one in exercise_accessor"
        )),
    }
}

/// Drive `MarcReader` over `bytes` in `mode` at `level` and return
/// the first error encountered. Successful records and EOF
/// (`Ok(None)`) both resolve to `None`.
fn first_iso2709_error(
    bytes: &[u8],
    mode: RecoveryMode,
    level: ValidationLevel,
) -> Option<mrrc::MarcError> {
    let mut reader = MarcReader::new(Cursor::new(bytes))
        .with_recovery_mode(mode)
        .with_validation_level(level);
    loop {
        match reader.read_record() {
            Ok(Some(_)) => {},
            Ok(None) => return None,
            Err(e) => return Some(e),
        }
    }
}

/// Drive `HoldingsMarcReader` in strict mode and return the first
/// `Err`. Mirrors `first_iso2709_error` for the holdings reader type.
fn first_holdings_error(bytes: &[u8], level: ValidationLevel) -> Option<mrrc::MarcError> {
    let mut reader = HoldingsMarcReader::new(Cursor::new(bytes))
        .with_recovery_mode(RecoveryMode::Strict)
        .with_validation_level(level);
    loop {
        match reader.read_record() {
            Ok(Some(_)) => {},
            Ok(None) => return None,
            Err(e) => return Some(e),
        }
    }
}

/// Drive `AuthorityMarcReader` in strict mode and return the first
/// `Err`. Mirrors `first_iso2709_error` for the authority reader type.
fn first_authority_error(bytes: &[u8], level: ValidationLevel) -> Option<mrrc::MarcError> {
    let mut reader = AuthorityMarcReader::new(Cursor::new(bytes))
        .with_recovery_mode(RecoveryMode::Strict)
        .with_validation_level(level);
    loop {
        match reader.read_record() {
            Ok(Some(_)) => {},
            Ok(None) => return None,
            Err(e) => return Some(e),
        }
    }
}

/// `Read` impl that returns `std::io::Error` on the first read.
/// Used to exercise the parser's underlying-stream-failure path
/// for E007 `IoError` without touching the filesystem.
struct FailingReader;

impl Read for FailingReader {
    fn read(&mut self, _buf: &mut [u8]) -> io::Result<usize> {
        Err(io::Error::new(
            io::ErrorKind::Other,
            "synthetic read failure",
        ))
    }
}

/// Build a single record with a structurally invalid directory entry
/// (non-digit field-length bytes). In lenient mode each such record
/// increments the recovery error counter once; concatenating N+1 of
/// these and configuring `with_max_errors(N)` trips the cap on the
/// (N+1)th read. Mirrors `build_bad_record` in `src/reader.rs`'s
/// unit-test module.
fn build_bad_record() -> Vec<u8> {
    const FIELD_TERMINATOR: u8 = 0x1E;
    const RECORD_TERMINATOR: u8 = 0x1D;

    let mut directory = Vec::new();
    directory.extend_from_slice(b"245ABCD00000");
    directory.push(FIELD_TERMINATOR);

    let base_address = 24 + directory.len();
    let record_length = base_address + 1;

    let mut leader = Vec::new();
    leader.extend_from_slice(format!("{record_length:05}").as_bytes());
    leader.extend_from_slice(b"nam a22");
    leader.extend_from_slice(format!("{base_address:05}").as_bytes());
    leader.extend_from_slice(b" i 4500");

    let mut out = Vec::new();
    out.extend_from_slice(&leader);
    out.extend_from_slice(&directory);
    out.push(RECORD_TERMINATOR);
    out
}

/// Build a minimal structurally valid ISO 2709 record carrying a single
/// `001` control field. Used by the `io_error_parse_path` trigger to get
/// the parser past the leader read so the synthetic failure lands in
/// `read_record_data`'s context-carrying path rather than the leader
/// boundary.
fn build_valid_record(control_001: &str) -> Vec<u8> {
    const FIELD_TERMINATOR: u8 = 0x1E;
    const RECORD_TERMINATOR: u8 = 0x1D;

    let mut field_data = Vec::new();
    field_data.extend_from_slice(control_001.as_bytes());
    field_data.push(FIELD_TERMINATOR);

    let mut directory = Vec::new();
    directory.extend_from_slice(b"001");
    directory.extend_from_slice(format!("{:04}", field_data.len()).as_bytes());
    directory.extend_from_slice(b"00000");
    directory.push(FIELD_TERMINATOR);

    let base_address = 24 + directory.len();
    let record_length = base_address + field_data.len() + 1;

    let mut leader = Vec::new();
    leader.extend_from_slice(format!("{record_length:05}").as_bytes());
    leader.extend_from_slice(b"nam a22");
    leader.extend_from_slice(format!("{base_address:05}").as_bytes());
    leader.extend_from_slice(b" i 4500");

    let mut out = Vec::new();
    out.extend_from_slice(&leader);
    out.extend_from_slice(&directory);
    out.extend_from_slice(&field_data);
    out.push(RECORD_TERMINATOR);
    out
}

/// `Read` impl that serves a prepared buffer, then returns an `io::Error`
/// (rather than a clean `Ok(0)` EOF) once exhausted — driving the parser
/// into the underlying-read-failure path of `read_record_data`.
struct FailAfterBuffer {
    data: Vec<u8>,
    pos: usize,
}

impl Read for FailAfterBuffer {
    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
        if self.pos >= self.data.len() {
            return Err(io::Error::new(
                io::ErrorKind::Other,
                "synthetic mid-record read failure",
            ));
        }
        let n = std::cmp::min(buf.len(), self.data.len() - self.pos);
        buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]);
        self.pos += n;
        Ok(n)
    }
}

#[allow(clippy::too_many_lines)] // one branch per trigger_kind; grows linearly with coverage
fn fire_trigger(case: &Case) -> TriggerOutcome {
    match case.trigger_kind.as_str() {
        "parse_iso2709" => match first_iso2709_error(
            &fixture_bytes(case),
            RecoveryMode::Strict,
            parse_validation_level(case),
        ) {
            Some(e) => TriggerOutcome::Fired(e),
            None => TriggerOutcome::NoError,
        },
        "parse_iso2709_lenient" => {
            // Drive MarcReader in lenient with no recovery cap and return
            // the first error in a yielded record's `errors` Arc whose
            // `code()` matches `case.code`. Lenient mode accumulates
            // multiple errors per record (a truncated record with a
            // malformed directory entry pushes both E005 and E106), so
            // matching on the case's code lets one trigger drive multiple
            // cases over the same fixture without depending on push order.
            let bytes = fixture_bytes(case);
            let mut reader = MarcReader::new(Cursor::new(&bytes[..]))
                .with_recovery_mode(RecoveryMode::Lenient)
                .with_max_errors(0)
                .with_validation_level(parse_validation_level(case));
            loop {
                match reader.read_record() {
                    Ok(Some(record)) => {
                        if let Some(err) =
                            record.errors.iter().find(|e| e.code() == case.code)
                        {
                            return TriggerOutcome::Fired(err.clone());
                        }
                    },
                    Ok(None) => return TriggerOutcome::NoError,
                    // A lenient stream shouldn't raise — but if it does
                    // (e.g. fatal cap, unrecoverable structural failure),
                    // surface the error so the harness can compare codes.
                    Err(e) => return TriggerOutcome::Fired(e),
                }
            }
        },
        "parse_marcxml" => {
            let text = fixture_text(case);
            match mrrc::marcxml::marcxml_to_record(&text) {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "parse_marcjson" => TriggerOutcome::UnsupportedKind(
            "no public Rust str-to-Record API for MARCJSON; case is exercised in the Python harness".to_string(),
        ),
        "io_error" => {
            let mut reader = MarcReader::new(FailingReader).with_recovery_mode(RecoveryMode::Strict);
            match reader.read_record() {
                Ok(_) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "io_error_parse_path" => {
            // Complete record 1, then only the 24-byte leader of record 2.
            // The reader errors when the parser reads record 2's data area,
            // so the IoError is enriched with record 2's positional context
            // (vs. the raw-io leader-boundary From fallback that `io_error`
            // exercises).
            let rec1 = build_valid_record("rec1");
            let rec2 = build_valid_record("rec2");
            let mut stream = rec1;
            stream.extend_from_slice(&rec2[..24]);
            let mut reader = MarcReader::new(FailAfterBuffer {
                data: stream,
                pos: 0,
            })
            .with_recovery_mode(RecoveryMode::Strict)
            .with_source("synthetic-stream.mrc");
            match reader.read_record() {
                Ok(_) => match reader.read_record() {
                    Ok(_) => TriggerOutcome::NoError,
                    Err(e) => TriggerOutcome::Fired(e),
                },
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "recovery_cap" => {
            const CAP: usize = 1;
            let mut stream = Vec::new();
            for _ in 0..=(CAP + 1) {
                stream.extend_from_slice(&build_bad_record());
            }
            let mut reader = MarcReader::new(Cursor::new(stream))
                .with_recovery_mode(RecoveryMode::Lenient)
                .with_max_errors(CAP);
            loop {
                match reader.read_record() {
                    Ok(Some(_)) => {},
                    Ok(None) => return TriggerOutcome::NoError,
                    Err(e) => return TriggerOutcome::Fired(e),
                }
            }
        },
        "parse_holdings" => match first_holdings_error(
            &fixture_bytes(case),
            parse_validation_level(case),
        ) {
            Some(e) => TriggerOutcome::Fired(e),
            None => TriggerOutcome::NoError,
        },
        "parse_authority" => match first_authority_error(
            &fixture_bytes(case),
            parse_validation_level(case),
        ) {
            Some(e) => TriggerOutcome::Fired(e),
            None => TriggerOutcome::NoError,
        },
        "programmatic_validator" => exercise_programmatic_validator(case),
        "programmatic_writer_check" => exercise_programmatic_writer_check(case),
        "accessor" => exercise_accessor(case),
        "writer" => exercise_writer(case),
        other => TriggerOutcome::UnsupportedKind(format!("unknown trigger_kind {other:?}")),
    }
}

/// Dispatch by case id within the `programmatic_validator` trigger family.
///
/// Some defensive checks in `RecordStructureValidator` guard against
/// values that the binary parse path cannot produce — e.g.,
/// `data_base_address > 99999` is unreachable from a 5-digit ASCII
/// field but a caller constructing a `Leader` programmatically (public
/// struct, public fields) can supply that value. This dispatch
/// exercises each such defensive check by constructing the bad state
/// in-test and asserting the documented variant surfaces.
fn exercise_programmatic_validator(case: &Case) -> TriggerOutcome {
    let mut leader =
        Leader::from_bytes(b"00150nam a2200061   4500").expect("baseline leader parses");

    match case.id.as_str() {
        "e002_data_base_address_overflow_programmatic" => {
            leader.data_base_address = 100_000;
            match RecordStructureValidator::validate_leader(&leader) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        other => TriggerOutcome::UnsupportedKind(format!(
            "trigger_kind=programmatic_validator: case {other} has no harness branch; add one in exercise_programmatic_validator"
        )),
    }
}

/// Dispatch by case id within the `programmatic_writer_check` trigger
/// family.
///
/// Some writer-side defensive checks in `iso2709::check_iso2709_size`
/// guard against values that the writer's normal control flow cannot
/// produce (e.g., `base_address > 99999` while `record_length <=
/// 99999` is impossible because `base_address <= record_length`), but
/// the helper is a public function and direct callers can hit the
/// guard. This dispatch exercises each such defensive check by
/// invoking the helper directly with crafted arguments.
fn exercise_programmatic_writer_check(case: &Case) -> TriggerOutcome {
    match case.id.as_str() {
        "e404_check_iso2709_size_base_address_overflow_programmatic" => {
            // `record_length` and `base_address` are independent in the
            // helper's signature; choose values that pass the
            // record_length guard so the test isolates the base_address
            // defensive check.
            match mrrc::iso2709::check_iso2709_size(
                /* record_length */ 1,
                /* base_address */ 100_000,
                /* record_index */ Some(1),
                /* record_control_number */ None,
            ) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        other => TriggerOutcome::UnsupportedKind(format!(
            "trigger_kind=programmatic_writer_check: case {other} has no harness branch; add one in exercise_programmatic_writer_check"
        )),
    }
}

/// Dispatch by case id within the `writer` trigger family.
///
/// `MarcError::WriterError` (E404) has three distinct production fire
/// sites: the ISO 2709 99999-byte size cap, the
/// `validate_directory_tag` check on every field tag, and the
/// finished-writer reuse guard in `MarcWriter::write_record`. Each
/// gets its own manifest case under the same variant slug; this
/// dispatch picks the right driver.
fn exercise_writer(case: &Case) -> TriggerOutcome {
    let leader =
        Leader::from_bytes(b"00000nam a2200000 i 4500").expect("synthetic minimal leader parses");

    match case.id.as_str() {
        "e404_record_too_large_for_iso2709" => {
            let mut record = Record::new(leader);
            // ~100k subfield value forces the writer's serialized record
            // length past the ISO 2709 99999-byte ceiling.
            let big_value = "x".repeat(100_000);
            let field = Field {
                tag: "999".to_string(),
                indicator1: ' ',
                indicator2: ' ',
                subfields: smallvec::smallvec![Subfield {
                    code: 'a',
                    value: big_value,
                }],
            };
            record.add_field(field);

            let mut buf = Vec::new();
            let mut writer = MarcWriter::new(&mut buf);
            match writer.write_record(&record) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "e404_writer_non_ascii_tag" => {
            let mut record = Record::new(leader);
            // 2-byte tag fails validate_directory_tag's "exactly 3
            // ASCII bytes" check on serialization.
            let field = Field {
                tag: "12".to_string(),
                indicator1: ' ',
                indicator2: ' ',
                subfields: smallvec::smallvec![Subfield {
                    code: 'a',
                    value: "x".to_string(),
                }],
            };
            record.add_field(field);

            let mut buf = Vec::new();
            let mut writer = MarcWriter::new(&mut buf);
            match writer.write_record(&record) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        "e404_writer_finished_writer_reuse" => {
            let record = Record::new(leader);
            let mut buf = Vec::new();
            let mut writer = MarcWriter::new(&mut buf);
            if let Err(e) = writer.finish() {
                return TriggerOutcome::UnsupportedKind(format!(
                    "{}: finish() unexpectedly failed before reuse trigger could fire: {e}",
                    case.id
                ));
            }
            match writer.write_record(&record) {
                Ok(()) => TriggerOutcome::NoError,
                Err(e) => TriggerOutcome::Fired(e),
            }
        },
        other => TriggerOutcome::UnsupportedKind(format!(
            "trigger_kind=writer: case {other} has no harness branch; add one in exercise_writer"
        )),
    }
}

fn json_probe_key(field: &str) -> &str {
    match field {
        "bytes_near" => "bytes_near_hex",
        "found" => "found_hex",
        "source_name" => "source",
        other => other,
    }
}

/// Per-case outcome the harness reports up to the top-level test.
enum WiredOutcome {
    Asserted,
    SkippedByHarness(String),
    Failed(String),
}

/// Exercise one wired case. Returns the outcome so the caller can
/// distinguish assertion failures (manifest claims something the
/// parser doesn't deliver) from harness skips (this harness cannot
/// exercise the case's `trigger_kind`, but the wiring may still be
/// in place and exercised by another harness).
fn run_wired(case: &Case) -> WiredOutcome {
    // The harness's parse_iso2709/parse_marcxml/parse_marcjson/accessor
    // branches all drive the parser in strict mode. Triggers with their
    // own intrinsic mode requirements (recovery_cap drives lenient) carry
    // that mode inside the trigger branch and bypass this gate.
    let strict_only_trigger = !matches!(
        case.trigger_kind.as_str(),
        "recovery_cap" | "parse_iso2709_lenient"
    );
    if strict_only_trigger && !case.recovery_modes.iter().any(|m| m == "strict") {
        return WiredOutcome::SkippedByHarness(
            "case contract does not cover strict mode; non-strict assertions pending".to_string(),
        );
    }

    let err = match fire_trigger(case) {
        TriggerOutcome::Fired(e) => e,
        TriggerOutcome::NoError => {
            return WiredOutcome::Failed(format!(
                "{} ({} / {}): expected {} error, got success",
                case.id, case.code, case.variant, case.code
            ));
        },
        TriggerOutcome::UnsupportedKind(reason) => {
            return WiredOutcome::SkippedByHarness(reason);
        },
    };

    if err.code() != case.code {
        return WiredOutcome::Failed(format!(
            "{} ({}): expected code {}, got {} ({:?})",
            case.id,
            case.variant,
            case.code,
            err.code(),
            err
        ));
    }
    if err.slug() != case.slug {
        return WiredOutcome::Failed(format!(
            "{} ({}): expected slug {:?}, got {:?}",
            case.id,
            case.variant,
            case.slug,
            err.slug()
        ));
    }
    let dict = err.to_json_value();
    for field in &case.expected_context {
        let key = json_probe_key(field);
        let present = dict.get(key).is_some_and(|v| !v.is_null());
        if !present {
            return WiredOutcome::Failed(format!(
                "{} ({}): expected_context field {} not populated (probed via {:?}); error JSON: {}",
                case.id, case.variant, field, key, dict
            ));
        }
    }
    WiredOutcome::Asserted
}

#[test]
fn manifest_is_well_formed() {
    let manifest = load_manifest();
    assert_eq!(manifest.schema_version, 1, "schema_version drift");
    assert!(!manifest.case.is_empty(), "manifest has no cases");

    let mut ids = std::collections::HashSet::new();
    for case in &manifest.case {
        let inserted = ids.insert(case.id.clone());
        assert!(inserted, "duplicate case id {}", case.id);

        if matches!(
            case.trigger_kind.as_str(),
            "parse_iso2709" | "parse_marcxml" | "parse_marcjson" | "accessor"
        ) {
            assert!(
                case.trigger_fixture.is_some(),
                "case {}: trigger_kind {:?} requires a trigger_fixture",
                case.id,
                case.trigger_kind
            );
            let path = fixture_path(case);
            assert!(
                path.exists(),
                "case {}: fixture {} does not exist",
                case.id,
                path.display()
            );
        }

        if !case.wired {
            assert!(
                case.skip_reason.is_some(),
                "case {} is unwired but has no skip_reason",
                case.id
            );
        }
    }
}

#[test]
fn coverage_assertions() {
    let manifest = load_manifest();
    let total = manifest.case.len();
    let mut failures: Vec<String> = Vec::new();
    let mut wired_count = 0usize;
    let mut asserted = 0usize;
    let mut harness_skips: Vec<(String, String)> = Vec::new();
    let mut unwired_skips: Vec<(String, String)> = Vec::new();

    for case in &manifest.case {
        if case.wired {
            wired_count += 1;
            match run_wired(case) {
                WiredOutcome::Asserted => asserted += 1,
                WiredOutcome::SkippedByHarness(reason) => {
                    harness_skips.push((case.id.clone(), reason));
                },
                WiredOutcome::Failed(reason) => failures.push(reason),
            }
        } else {
            let reason = case
                .skip_reason
                .clone()
                .unwrap_or_else(|| "unwired (no reason provided)".into());
            unwired_skips.push((case.id.clone(), reason));
        }
    }

    for (id, reason) in &unwired_skips {
        eprintln!("[error_coverage] UNWIRED {id}: {reason}");
    }
    for (id, reason) in &harness_skips {
        eprintln!("[error_coverage] HARNESS-SKIP {id}: {reason}");
    }
    eprintln!(
        "[error_coverage] wired in manifest: {wired_count}/{total} \
         | harness asserted: {asserted}/{wired_count} \
         | harness skipped: {} (unwired: {}, harness limitations: {})",
        unwired_skips.len() + harness_skips.len(),
        unwired_skips.len(),
        harness_skips.len(),
    );

    assert!(
        failures.is_empty(),
        "wired-case failures:\n  - {}",
        failures.join("\n  - ")
    );
}