ferro-hgvs 1.0.0

HGVS variant normalizer - part of the ferro bioinformatics toolkit
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
//! Normalization configuration options

use crate::error_handling::{ErrorConfig, ErrorMode, ErrorOverride, ErrorType, ResolvedAction};
use serde::{Deserialize, Serialize};

/// Direction for variant shuffling during normalization.
///
/// **This is an internal differential-testing instrument, not a supported
/// option.** No ferro entry point lets a caller select a direction: the CLI has
/// no `--direction` flag, the Python bindings take no `direction=` keyword, and
/// the web service has no `shuffle_direction` key. Every shipped path
/// normalizes 3', which is the only direction the HGVS recommendations
/// describe. `README.md` rule 6 — "there are no user options for normalization
/// form" — is what removed them: a direction is not orthogonal to the form,
/// because it selects the frame every other rule is evaluated in.
///
/// [`FivePrime`](ShuffleDirection::FivePrime) survives because 3'/5' *disagreement*
/// is an oracle nothing else replaces. It has twice caught a defect in the
/// shipped 3' output that every other check passed — see #1542 / PR #1840,
/// where 7 of 8 `FERRO_PARTITION` x direction configurations agreed and only
/// `live`/3' diverged, and `tests/it/cis_confluence_axis.rs`'s `enclosing_exon`
/// off-by-one, which only the 5' walk reached. The type therefore stays `pub`
/// so the integration-test suite in `tests/` can drive it, and is
/// `#[doc(hidden)]` so it is not published as API. Do not reintroduce a
/// user-facing way to reach it.
///
/// Marked `#[non_exhaustive]` so a future shuffling direction is additive. It
/// is carried as a `pub` field of [`crate::normalize::NormalizationInfo`]'s
/// `ShuffleApplied` variant, which is already `#[non_exhaustive]` — without
/// this the protection there was only half applied, since downstream still had
/// to match the direction exhaustively.
#[doc(hidden)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub enum ShuffleDirection {
    /// Shuffle towards the 3' end. The only direction ferro ships.
    #[default]
    ThreePrime,
    /// Shuffle towards the 5' end.
    ///
    /// **Reachable only from ferro's own tests.** The doc comment here read
    /// "for VCF compatibility" from the initial commit until it was removed as
    /// false: no VCF path has ever selected it. VCF left-alignment in this
    /// crate is the anchor-base rules in `src/vcf/from_hgvs.rs` (#261/#81 K2),
    /// which choose which base to attach rather than which end to shift to.
    FivePrime,
}

impl std::fmt::Display for ShuffleDirection {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            ShuffleDirection::ThreePrime => write!(f, "3prime"),
            ShuffleDirection::FivePrime => write!(f, "5prime"),
        }
    }
}

impl std::str::FromStr for ShuffleDirection {
    type Err = String;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "3prime" | "3'" | "three_prime" => Ok(ShuffleDirection::ThreePrime),
            "5prime" | "5'" | "five_prime" => Ok(ShuffleDirection::FivePrime),
            _ => Err(format!("Invalid shuffle direction: {}", s)),
        }
    }
}

/// Configuration for variant normalization
///
/// Marked `#[non_exhaustive]` so adding a normalization knob is additive rather
/// than breaking. Build it with [`NormalizeConfig::new`] (or the
/// [`NormalizeConfig::strict`] / [`NormalizeConfig::lenient`] /
/// [`NormalizeConfig::silent`] presets) plus the `with_*` builders, or from
/// [`Default`], rather than a struct literal; the fields stay `pub`, so
/// anything the builders do not cover is still reachable by assigning to the
/// field directly. Mirrors the attribute on its result-side counterpart
/// [`crate::normalize::NormalizeResult`], which #1033 marked for the same
/// reason.
///
/// This is the type that carries the **error mode** — [`Default`] and
/// [`NormalizeConfig::lenient`] both give it `ErrorConfig::lenient()`. Do not
/// confuse it with [`crate::commands::NormalizeCommandConfig`], which
/// configures the batch run around a normalization (reference directory,
/// progress, workers) and has no error mode at all. The two used to share the
/// name `NormalizeConfig`, so `NormalizeConfig::default()` resolved to
/// whichever one a single `use` line had brought into scope.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct NormalizeConfig {
    /// Direction to shuffle variants. Always 3' on every shipped path.
    ///
    /// `pub` only so ferro's own integration tests can drive the 5' arm as a
    /// differential oracle; see [`ShuffleDirection`]. Not a supported knob.
    #[doc(hidden)]
    pub shuffle_direction: ShuffleDirection,

    /// Whether to allow crossing exon-intron boundaries
    pub cross_boundaries: bool,

    /// Error handling configuration (controls reference validation behavior)
    #[serde(skip)]
    pub error_config: ErrorConfig,

    /// Window size for reference sequence fetching
    pub window_size: u64,

    /// Vestigial overlap-prevention flag retained for source compatibility.
    ///
    /// Overlap is now prevented structurally: `normalize_allele` merges
    /// adjacent sub-variants before the per-variant pipeline runs, and
    /// `merge_consecutive_edits` uses a strict `prev.end + 1 == next.start`
    /// adjacency rule, so the normalizer cannot emit overlapping ranges
    /// from non-overlapping inputs. Detection of input-time overlap is
    /// handled unconditionally by `detect_overlap_conflicts`. This field
    /// has no effect and is preserved only so existing callers (notably
    /// `with_overlap_prevention`) keep compiling.
    pub prevent_overlap: bool,
}

impl Default for NormalizeConfig {
    fn default() -> Self {
        Self {
            shuffle_direction: ShuffleDirection::ThreePrime,
            cross_boundaries: false,
            // Default to Lenient mode for backwards compatibility
            // (previous behavior was to not validate at all)
            error_config: ErrorConfig::lenient(),
            window_size: 100,
            prevent_overlap: true,
        }
    }
}

impl PartialEq for NormalizeConfig {
    fn eq(&self, other: &Self) -> bool {
        self.shuffle_direction == other.shuffle_direction
            && self.cross_boundaries == other.cross_boundaries
            && self.window_size == other.window_size
            && self.prevent_overlap == other.prevent_overlap
    }
}

impl Eq for NormalizeConfig {}

impl NormalizeConfig {
    /// Create a new config with default values
    pub fn new() -> Self {
        Self::default()
    }

    /// Build the configuration for an **entry point** — a seam where an error
    /// mode arrives from outside the library (the CLI, the PyO3 bindings, the
    /// web service).
    ///
    /// The `direction` argument is **always
    /// [`ShuffleDirection::ThreePrime`] at every such seam**: no entry point
    /// accepts a direction from a caller any more.
    /// It is still a parameter so that the internal 3'/5' differential can
    /// build an entry-point-shaped config, and so that the `#1197` lint below
    /// keeps binding the *error* argument, which is what that constructor
    /// exists for.
    ///
    /// Both settings are required arguments, which is the whole point:
    /// `NormalizeConfig::default().with_direction(d)` silently fills in every
    /// field the caller does not name, and `error_config` defaults to
    /// [`ErrorConfig::lenient`]. That is the shape that produced #1181 — the
    /// CLI built an `ErrorConfig` from `--error-mode`, passed it into
    /// `run_normalize`, and then constructed the normalizer's config without
    /// it, so the flag (and `--ignore` / `--reject` with it) was inert from the
    /// initial commit through 678 commits and roughly five months. #1191 fixed
    /// that one call site; this constructor removes the shape (#1197).
    ///
    /// Forgetting the error configuration *in this call* cannot compile, just
    /// as it cannot in the web service's struct literal. Note the limit of that
    /// guarantee: it binds only callers who choose this constructor. `default`,
    /// `new`, the `lenient`/`strict`/`silent` presets and `Normalizer::new` all
    /// remain reachable and all supply an error configuration the caller never
    /// named, so a *new* entry point can still elide one and compile. What
    /// stops it is a lint, not the type system —
    /// `tests/it/issue_1197_required_error_config.rs` scans the entry-point
    /// seams for those shapes and fails the build.
    ///
    /// Prefer this over the builder chain at every entry point; the builder
    /// remains for library callers that deliberately want the lenient default.
    ///
    /// # Example
    ///
    /// ```
    /// use ferro_hgvs::error_handling::ErrorConfig;
    /// use ferro_hgvs::normalize::{NormalizeConfig, ShuffleDirection};
    ///
    /// let config =
    ///     NormalizeConfig::for_entry_point(ShuffleDirection::ThreePrime, ErrorConfig::strict());
    /// assert_eq!(config.shuffle_direction, ShuffleDirection::ThreePrime);
    /// assert!(config.should_reject_ref_mismatch());
    /// ```
    pub fn for_entry_point(direction: ShuffleDirection, error_config: ErrorConfig) -> Self {
        Self {
            shuffle_direction: direction,
            error_config,
            ..Default::default()
        }
    }

    /// Create a config with strict error handling (reject reference mismatches)
    pub fn strict() -> Self {
        Self {
            error_config: ErrorConfig::strict(),
            ..Default::default()
        }
    }

    /// Create a config with lenient error handling (warn on reference mismatches)
    pub fn lenient() -> Self {
        Self {
            error_config: ErrorConfig::lenient(),
            ..Default::default()
        }
    }

    /// Create a config with silent error handling (ignore reference mismatches)
    pub fn silent() -> Self {
        Self {
            error_config: ErrorConfig::silent(),
            ..Default::default()
        }
    }

    /// Set the shuffle direction.
    ///
    /// **Internal test instrument, not a supported knob** — see
    /// [`ShuffleDirection`]. Kept `pub` because `tests/it/` is an external
    /// integration-test crate and roughly 130 call sites across 60 modules
    /// drive the 3'/5' differential through it.
    #[doc(hidden)]
    pub fn with_direction(mut self, direction: ShuffleDirection) -> Self {
        self.shuffle_direction = direction;
        self
    }

    /// Allow crossing boundaries
    pub fn allow_crossing_boundaries(mut self) -> Self {
        self.cross_boundaries = true;
        self
    }

    /// Set error handling mode
    pub fn with_error_mode(mut self, mode: ErrorMode) -> Self {
        self.error_config = ErrorConfig::new(mode);
        self
    }

    /// Replace the entire error-handling configuration.
    pub fn with_error_config(mut self, error_config: ErrorConfig) -> Self {
        self.error_config = error_config;
        self
    }

    /// Set a specific error type override
    pub fn with_error_override(mut self, error_type: ErrorType, action: ErrorOverride) -> Self {
        self.error_config = self.error_config.with_override(error_type, action);
        self
    }

    /// Disable reference validation (sets RefSeqMismatch to SilentCorrect)
    #[deprecated(
        since = "0.2.0",
        note = "Use with_error_mode(ErrorMode::Silent) instead"
    )]
    pub fn skip_validation(mut self) -> Self {
        self.error_config = self
            .error_config
            .with_override(ErrorType::RefSeqMismatch, ErrorOverride::SilentCorrect);
        self
    }

    /// Set the vestigial `prevent_overlap` flag.
    ///
    /// This builder is retained only for source compatibility with existing
    /// callers. The flag has no runtime effect: overlap prevention is handled
    /// structurally by `normalize_allele`, `merge_consecutive_edits`, and
    /// `detect_overlap_conflicts`. See [`NormalizeConfig::prevent_overlap`]
    /// for details.
    pub fn with_overlap_prevention(mut self, prevent: bool) -> Self {
        self.prevent_overlap = prevent;
        self
    }

    /// Get the resolved action for reference sequence mismatch
    pub fn ref_mismatch_action(&self) -> ResolvedAction {
        self.error_config.action_for(ErrorType::RefSeqMismatch)
    }

    /// Returns true if reference mismatches should be rejected
    pub fn should_reject_ref_mismatch(&self) -> bool {
        self.ref_mismatch_action().should_reject()
    }

    /// Returns true if reference mismatches should emit warnings
    pub fn should_warn_ref_mismatch(&self) -> bool {
        self.ref_mismatch_action().should_warn()
    }

    /// Get the resolved action for `VariantExceedsReference` (W5003) —
    /// fires when the provider returns fewer bytes than the HGVS
    /// interval span (the input violates HGVS spec refseq.md §43).
    /// Closes-after: #355.
    pub fn variant_exceeds_reference_action(&self) -> ResolvedAction {
        self.error_config
            .action_for(ErrorType::VariantExceedsReference)
    }

    /// Returns true if `VariantExceedsReference` should be rejected
    /// (strict mode default).
    pub fn should_reject_variant_exceeds_reference(&self) -> bool {
        self.variant_exceeds_reference_action().should_reject()
    }

    /// Returns true if `VariantExceedsReference` should emit a warning
    /// (lenient mode default; silent mode suppresses).
    pub fn should_warn_variant_exceeds_reference(&self) -> bool {
        self.variant_exceeds_reference_action().should_warn()
    }

    /// Get the resolved action for `PositionPastEnd` (W4004).
    pub fn position_past_end_action(&self) -> ResolvedAction {
        self.error_config.action_for(ErrorType::PositionPastEnd)
    }

    /// Returns true if past-end positions should be rejected (strict mode).
    pub fn should_reject_position_past_end(&self) -> bool {
        self.position_past_end_action().should_reject()
    }

    /// Returns true if a reduced-capability (no-genomic-data) degradation
    /// should be rejected — i.e. strict mode. Unlike the registry-backed
    /// spec warnings, `ReducedCapabilityNoGenome` is an *environmental*
    /// limitation rather than an input defect, so it is not user-overridable
    /// per errors-axis; it is simply promoted to an error in strict mode and
    /// surfaced as a warning-plus-best-effort otherwise (#1012 item 2).
    pub fn should_reject_reduced_capability(&self) -> bool {
        // `ErrorMode::is_strict`, not `action_for(..)`: this is the documented
        // *non*-overridable case, so it asks the mode rather than a code (#1629).
        self.error_config.mode.is_strict()
    }

    /// Returns true if past-end positions should emit warnings (lenient mode).
    pub fn should_warn_position_past_end(&self) -> bool {
        self.position_past_end_action().should_warn()
    }

    /// Get the resolved action for `IntronicOnBareTranscript` (W4007) —
    /// fires when an intronic offset appears on a bare transcript reference
    /// (`NM_` c. / `NR_` n. with `genomic_context: None`). See #486 EINTRONIC.
    pub fn intronic_bare_transcript_action(&self) -> ResolvedAction {
        self.error_config
            .action_for(ErrorType::IntronicOnBareTranscript)
    }

    /// Returns true if an intronic offset on a bare transcript should be
    /// rejected (strict mode default / errors-axis override).
    pub fn should_reject_intronic_bare_transcript(&self) -> bool {
        self.intronic_bare_transcript_action().should_reject()
    }

    /// Returns true if an intronic offset on a bare transcript should emit a
    /// warning (lenient mode default; silent suppresses).
    pub fn should_warn_intronic_bare_transcript(&self) -> bool {
        self.intronic_bare_transcript_action().should_warn()
    }

    /// Get the resolved action for `OverlapConflictingEdits` (W5002).
    pub fn overlap_conflict_action(&self) -> ResolvedAction {
        self.error_config
            .action_for(ErrorType::OverlapConflictingEdits)
    }

    /// Returns true if cis-allele edits with coincident reference
    /// bounds should be rejected (strict mode default). Closes #395
    /// item 6 — previously the `overlap.rs:88` emit site unconditionally
    /// pushed the warning, bypassing the registry's
    /// `always_warn_if_not_rejected` policy table that declared
    /// Strict→Reject.
    pub fn should_reject_overlap_conflict(&self) -> bool {
        self.overlap_conflict_action().should_reject()
    }

    /// Get the resolved action for `UnresolvableCentromere` (W4005).
    pub fn unresolvable_centromere_action(&self) -> ResolvedAction {
        self.error_config
            .action_for(ErrorType::UnresolvableCentromere)
    }

    /// Returns true if an unresolvable `cen` position should be rejected
    /// (strict mode default). A centromere is an assembly-annotated region
    /// with no sequence-derivable base, so it cannot be normalized; strict
    /// mode promotes the `UnresolvableSpecialPosition` warning to an error
    /// rather than silently echoing the input. See #488.
    pub fn should_reject_unresolvable_centromere(&self) -> bool {
        self.unresolvable_centromere_action().should_reject()
    }

    /// Get the resolved action for `TranscriptFlankNotDescribable` (W4006).
    pub fn transcript_flank_action(&self) -> ResolvedAction {
        self.error_config
            .action_for(ErrorType::TranscriptFlankNotDescribable)
    }

    /// Returns true if a telomere marker resolving to a transcript-flank
    /// position on a genomic-reference c. should be rejected (strict default).
    pub fn should_reject_transcript_flank(&self) -> bool {
        self.transcript_flank_action().should_reject()
    }

    /// Get the resolved action for `IncompleteCdsStartReference` (W5004) —
    /// fires when a `c.`/`p.`/`r.` variant is described against a transcript
    /// whose 5' CDS is annotated incomplete (`cds_start_NF`). See #972 Task 5.
    pub fn incomplete_cds_start_action(&self) -> ResolvedAction {
        self.error_config
            .action_for(ErrorType::IncompleteCdsStartReference)
    }

    /// Returns true if a `c.`/`p.`/`r.` variant against a `cds_start_NF`
    /// transcript should be rejected (strict mode default).
    pub fn should_reject_incomplete_cds_start(&self) -> bool {
        self.incomplete_cds_start_action().should_reject()
    }

    /// Returns true if a `c.`/`p.`/`r.` variant against a `cds_start_NF`
    /// transcript should emit a warning (lenient mode default; silent
    /// suppresses).
    pub fn should_warn_incomplete_cds_start(&self) -> bool {
        self.incomplete_cds_start_action().should_warn()
    }

    /// Get the resolved action for `InitiatorMetCanonicalization` (W3022) —
    /// fires when the canonical protein duplication form covers position 1,
    /// the initiator methionine. See #92.
    pub fn initiator_met_canonicalization_action(&self) -> ResolvedAction {
        self.error_config
            .action_for(ErrorType::InitiatorMetCanonicalization)
    }

    /// Returns true if the `InitiatorMetCanonicalization` advisory should be
    /// surfaced as a warning.
    ///
    /// W3022 is *advisory about ferro's own output*, not a defect in the input:
    /// `p.Met1dup` is spec-canonical — HGVS prioritization requires a
    /// duplicating insertion to be described as a duplication
    /// (`general.md` §56-57, `protein/insertion.md` §20), and the initiator
    /// carve-outs are scoped to *substitutions* that destroy the ATG
    /// (`protein/substitution.md` §49, `checklist.md` §65), which a `dup` does
    /// not. The normalizer emits the warning whenever the final edit is a
    /// Met1-covering duplication, including when the input already was one and
    /// nothing was rewritten.
    ///
    /// So the base mode must never promote it to an error: strict mode would
    /// then refuse the very string strict mode emits, and normalization would
    /// stop being idempotent. `Reject` from the base mode therefore maps to
    /// "surface it", which also keeps strict from being *quieter* than lenient
    /// (`ResolvedAction::should_warn` is true only for `WarnCorrect`, i.e.
    /// lenient).
    ///
    /// Silent mode, and an override resolving to `Accept`/`SilentCorrect`
    /// (`--ignore W3022`), suppress it. Before #1196 the push was
    /// unconditional, so none of that worked.
    /// An explicit `Reject` override also surfaces it, because the rejection
    /// ladder in `normalize_core` promotes it *from* the warning list — the same
    /// `should_warn_* || should_reject_*` emission shape the `PositionPastEnd`
    /// and `IncompleteCdsStartReference` sites use.
    pub fn should_warn_initiator_met_canonicalization(&self) -> bool {
        let action = self.initiator_met_canonicalization_action();
        action.should_warn() || action.should_reject()
    }

    /// Returns true if the `InitiatorMetCanonicalization` advisory should be
    /// promoted to a hard error.
    ///
    /// Only an **explicit** `Reject` override does this — `--reject W3022` or
    /// `[error-handling] reject = ["W3022"]`. The base mode never does, for the
    /// reasons in [`Self::should_warn_initiator_met_canonicalization`].
    ///
    /// This is the one place the distinction matters. `action_for` collapses
    /// strict-mode `Default` and an explicit `Reject` into the same
    /// `ResolvedAction::Reject`, so without consulting
    /// [`ErrorConfig::explicit_override`] the `--reject` direction of the knob
    /// would be inert for this code — reintroducing, for W3022 alone, exactly
    /// the defect #1196 exists to remove.
    pub fn should_reject_initiator_met_canonicalization(&self) -> bool {
        matches!(
            self.error_config
                .explicit_override(ErrorType::InitiatorMetCanonicalization),
            Some(ErrorOverride::Reject)
        )
    }
}

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

    /// `for_entry_point` must carry the error configuration it is handed, and
    /// the builder chain it replaces must not — that contrast is the hazard
    /// #1197 removes. Asserting both halves keeps the test from passing merely
    /// because `default()` happens to agree with the requested mode.
    #[test]
    fn for_entry_point_carries_the_error_config_the_builder_chain_defaults_away() {
        let explicit =
            NormalizeConfig::for_entry_point(ShuffleDirection::FivePrime, ErrorConfig::strict());
        assert_eq!(explicit.shuffle_direction, ShuffleDirection::FivePrime);
        assert!(
            explicit.should_reject_ref_mismatch(),
            "the strict config handed in must arrive",
        );

        let defaulted = NormalizeConfig::default().with_direction(ShuffleDirection::FivePrime);
        assert!(
            !defaulted.should_reject_ref_mismatch(),
            "premise: the builder chain silently supplies a *lenient* error config, which is \
             the omission that made `--error-mode` inert for five months (#1181)",
        );
    }

    /// Every other field must keep its documented default, so the constructor
    /// is a narrowing of `default()` rather than a second source of truth.
    #[test]
    fn for_entry_point_leaves_other_fields_at_their_defaults() {
        let config =
            NormalizeConfig::for_entry_point(ShuffleDirection::ThreePrime, ErrorConfig::silent());
        let default = NormalizeConfig::default();
        assert_eq!(config.cross_boundaries, default.cross_boundaries);
        assert_eq!(config.window_size, default.window_size);
        assert_eq!(config.prevent_overlap, default.prevent_overlap);
    }

    #[test]
    fn test_default_config() {
        let config = NormalizeConfig::default();
        assert_eq!(config.shuffle_direction, ShuffleDirection::ThreePrime);
        assert!(!config.cross_boundaries);
        // Default is lenient (warn but don't reject)
        assert!(!config.should_reject_ref_mismatch());
        assert!(config.should_warn_ref_mismatch());
    }

    #[test]
    fn test_strict_config() {
        let config = NormalizeConfig::strict();
        assert!(config.should_reject_ref_mismatch());
        assert!(!config.should_warn_ref_mismatch());
    }

    #[test]
    fn test_lenient_config() {
        let config = NormalizeConfig::lenient();
        assert!(!config.should_reject_ref_mismatch());
        assert!(config.should_warn_ref_mismatch());
    }

    #[test]
    fn test_silent_config() {
        let config = NormalizeConfig::silent();
        assert!(!config.should_reject_ref_mismatch());
        assert!(!config.should_warn_ref_mismatch());
    }

    #[test]
    fn test_error_override() {
        // Start with lenient, override RefSeqMismatch to reject
        let config = NormalizeConfig::lenient()
            .with_error_override(ErrorType::RefSeqMismatch, ErrorOverride::Reject);
        assert!(config.should_reject_ref_mismatch());
    }

    #[test]
    fn test_direction_parsing() {
        assert_eq!(
            "3prime".parse::<ShuffleDirection>().unwrap(),
            ShuffleDirection::ThreePrime
        );
        assert_eq!(
            "5prime".parse::<ShuffleDirection>().unwrap(),
            ShuffleDirection::FivePrime
        );
    }

    // The three tests below carry the assertions that used to live on the two
    // string parsers deleted with the public direction surface —
    // `cli::parse_shuffle_direction` and `python_helpers::parse_direction`.
    // They are re-pointed at the retained internal [`FromStr`] impl rather than
    // dropped, so no coverage of direction-string handling is lost.

    #[test]
    fn test_parse_direction_three_prime() {
        for spelling in ["3prime", "3'", "three_prime"] {
            assert_eq!(
                spelling.parse::<ShuffleDirection>().unwrap(),
                ShuffleDirection::ThreePrime,
                "{spelling}"
            );
        }
    }

    #[test]
    fn test_parse_direction_five_prime() {
        for spelling in ["5prime", "5'", "five_prime"] {
            assert_eq!(
                spelling.parse::<ShuffleDirection>().unwrap(),
                ShuffleDirection::FivePrime,
                "{spelling}"
            );
        }
    }

    #[test]
    fn test_parse_direction_case_insensitive() {
        assert_eq!(
            "5PRIME".parse::<ShuffleDirection>().unwrap(),
            ShuffleDirection::FivePrime
        );
        assert_eq!(
            "5Prime".parse::<ShuffleDirection>().unwrap(),
            ShuffleDirection::FivePrime
        );
        assert_eq!(
            "3PRIME".parse::<ShuffleDirection>().unwrap(),
            ShuffleDirection::ThreePrime
        );
    }

    /// An unrecognized direction string must be an `Err`, never a silent 3'.
    ///
    /// This is the property #1016 fixed on the Python side and #1863 filed
    /// against the CLI: `cli::parse_shuffle_direction` answered
    /// `ThreePrime` for *any* unrecognized input, so a typo — or a script
    /// asking for a direction that no longer exists — 3'-shifted and reported
    /// success. That parser is now gone along with the flag that fed it, and
    /// this pins that the one remaining string->direction conversion in the
    /// crate refuses rather than guesses.
    #[test]
    fn test_parse_direction_unrecognized_is_err() {
        for spelling in ["unknown", "", "5prim", "3prine", "five", "5", "3", "banana"] {
            assert!(
                spelling.parse::<ShuffleDirection>().is_err(),
                "{spelling} must not silently resolve to a direction"
            );
        }
    }

    #[test]
    #[allow(deprecated)]
    fn test_skip_validation_deprecated() {
        let config = NormalizeConfig::default().skip_validation();
        // skip_validation sets RefSeqMismatch to SilentCorrect
        assert!(!config.should_reject_ref_mismatch());
        assert!(!config.should_warn_ref_mismatch());
    }
}