Skip to main content

marque_engine/
errors.rs

1// SPDX-FileCopyrightText: 2026 Knitli Inc.
2//
3// SPDX-License-Identifier: LicenseRef-MarqueLicense-1.0
4
5//! Engine error surfaces — both build-time and runtime.
6//!
7//! This module defines two intentionally separate enums so callers
8//! can match on the surface they actually expect to see:
9//!
10//! - [`EngineConstructionError`] — build-time configuration defects
11//!   surfaced by `Engine::new` (rewrite cycles, unannotated custom
12//!   axes, unknown / conflicting rule overrides). The integrator
13//!   resolves these before shipping; runtime lint / fix never emits
14//!   them.
15//!
16//! - [`EngineError`] — runtime conditions raised by
17//!   `Engine::lint_with_options` / `Engine::fix_with_options` (spec
18//!   005). Variants: `DeadlineExceeded { partial_lint }` and
19//!   `InvalidThreshold(_)`. `#[non_exhaustive]` so future runtime
20//!   conditions (memory budgets, per-rule deadlines, cancellation
21//!   tokens) can land non-breaking. **Phase 1 status:** the type
22//!   surface ships, but `DeadlineExceeded` cannot currently fire —
23//!   `fix_with_options` ignores `opts.deadline` until Phase 2
24//!   wiring lands (tasks T010–T012). Only `InvalidThreshold` is
25//!   observable today.
26//!
27//! Keeping the two enums separate means matching on one does not
28//! force callers to pattern against variants they could never
29//! encounter at the corresponding lifecycle stage.
30//!
31//! `EngineConstructionError`'s `RewriteCycle` and
32//! `UnannotatedCustomAxes` variants are emitted by the Phase 3
33//! scheduler (`Engine::new` runs Kahn's algorithm over
34//! `PageRewrite::reads` / `writes`); `UnknownRuleOverride` and
35//! `ConflictingRuleOverride` come from the rule-override
36//! canonicalization pass that runs immediately afterward.
37
38use crate::engine::InvalidThreshold;
39use crate::output::LintResult;
40use marque_scheme::{CategoryId, RewriteId};
41
42/// Errors that will be raised while constructing an `Engine`.
43///
44/// Every variant is intended to be a **hard** failure — the Phase 3
45/// `Engine::new` implementation will return `Err` rather than
46/// silently degrading. Runtime lint / fix never emits these; they are
47/// build-time configuration errors the integrator is expected to
48/// resolve before shipping.
49///
50/// Until that constructor path lands, this enum documents the planned
51/// engine-construction error surface for downstream tooling.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum EngineConstructionError {
54    /// A read/write cycle exists among the declared page rewrites.
55    ///
56    /// `axis` is one category in the cycle (there may be several — the
57    /// engine reports the first one it hits during the topological
58    /// sort). `members` names **every** rewrite participating in the
59    /// cycle.
60    ///
61    /// The variable-length slice form (not `[RewriteId; 2]`) is
62    /// deliberate: cycles of length ≥ 3 are a real failure mode —
63    /// foundational-plan line 1066 notes the JOINT/FGI/REL-TO
64    /// interaction as one that could plausibly trip this path if
65    /// authored incorrectly.
66    ///
67    /// The list is owned (`Box<[RewriteId]>`, not `&'static [...]`)
68    /// because cycle membership is computed at engine-construction
69    /// time from the declared rewrite graph, not borrowed from a
70    /// static table. Owning it here avoids the memory-leak /
71    /// lifetime-gymnastics tradeoff a `'static` slice would force on
72    /// the Phase 3 scheduler. `RewriteId` is itself `&'static str`,
73    /// so the per-entry payload is still `'static`; only the
74    /// container is heap-allocated.
75    ///
76    /// Fired by the Phase 3 scheduler when `Engine::new` runs Kahn's
77    /// algorithm over the rewrite graph (tasks T031–T032).
78    RewriteCycle {
79        axis: CategoryId,
80        members: Box<[RewriteId]>,
81    },
82    /// A `PageRewrite::custom` was declared without explicit
83    /// `reads` / `writes` (or with empty slices).
84    ///
85    /// The `declarative` constructor derives these from the variant
86    /// shapes; `custom` uses function pointers so the engine cannot
87    /// derive them. Failing closed forces the rewrite author to
88    /// annotate the dataflow explicitly — an un-annotated `custom`
89    /// rewrite could not be scheduled relative to other rewrites.
90    UnannotatedCustomAxes { rewrite: RewriteId },
91    /// A `[rules]` entry in the merged config references a key that is
92    /// neither a known rule ID (e.g., `E001`) nor a known rule name
93    /// (e.g., `portion-mark-in-banner`) across the registered rule sets.
94    ///
95    /// `key` is the unknown string as the user wrote it. `did_you_mean`
96    /// is a best-effort suggestion based on edit distance against the
97    /// union of known IDs and names — `None` when no candidate is close
98    /// enough to be useful.
99    ///
100    /// Fired by `Engine::new` / `Engine::with_clock` when canonicalizing
101    /// the config's severity overrides against the registered rules.
102    /// This is a user-config error, not an internal invariant violation;
103    /// `exit_code()` maps it to `EX_DATAERR` (65).
104    UnknownRuleOverride {
105        key: String,
106        did_you_mean: Option<String>,
107    },
108    /// The user specified the same rule two different ways in the merged
109    /// config (e.g., `E001 = "warn"` and `portion-mark-in-banner = "error"`)
110    /// and the two entries resolved to different severity strings.
111    ///
112    /// Duplicate forms with the *same* severity are silently accepted —
113    /// only a genuine value conflict hard-fails.
114    ///
115    /// `rule_id` is the canonical ID both keys resolved to. `keys`
116    /// contains the two source keys as the user wrote them; `severities`
117    /// contains the two conflicting severity strings, index-aligned with
118    /// `keys`.
119    ConflictingRuleOverride {
120        rule_id: String,
121        keys: Box<[String]>,
122        severities: Box<[String]>,
123    },
124}
125
126impl EngineConstructionError {
127    /// Exit code for this error per `contracts/cli.md`.
128    ///
129    /// - `UnknownRuleOverride` / `ConflictingRuleOverride` → `EX_DATAERR`
130    ///   (65). These are user-config defects — the `.marque.toml` refers
131    ///   to a rule that doesn't exist, or contradicts itself — and the
132    ///   user fixes them by editing their config.
133    /// - `RewriteCycle` / `UnannotatedCustomAxes` → `EX_UNAVAILABLE`
134    ///   (69). These are defects in the declarative scheme the engine
135    ///   was built against (developer / rule-author errors, not
136    ///   user-config errors), so the tool can't honor the request until
137    ///   the developer ships a corrected build.
138    pub fn exit_code(&self) -> i32 {
139        match self {
140            Self::RewriteCycle { .. } | Self::UnannotatedCustomAxes { .. } => 69,
141            Self::UnknownRuleOverride { .. } | Self::ConflictingRuleOverride { .. } => 65,
142        }
143    }
144}
145
146impl std::fmt::Display for EngineConstructionError {
147    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
148        match self {
149            Self::RewriteCycle { axis, members } => {
150                write!(f, "page-rewrite cycle on category {axis:?}: {members:?}")
151            }
152            Self::UnannotatedCustomAxes { rewrite } => write!(
153                f,
154                "custom page-rewrite {rewrite:?} was declared without explicit reads/writes"
155            ),
156            Self::UnknownRuleOverride { key, did_you_mean } => {
157                write!(
158                    f,
159                    "unknown rule {key:?} in [rules] — no registered rule has this ID or name"
160                )?;
161                if let Some(hint) = did_you_mean {
162                    write!(f, " (did you mean {hint:?}?)")?;
163                }
164                Ok(())
165            }
166            Self::ConflictingRuleOverride {
167                rule_id,
168                keys,
169                severities,
170            } => {
171                write!(f, "conflicting severity overrides for rule {rule_id}: ")?;
172                let mut first = true;
173                for (k, s) in keys.iter().zip(severities.iter()) {
174                    if !first {
175                        write!(f, ", ")?;
176                    }
177                    write!(f, "{k:?} = {s:?}")?;
178                    first = false;
179                }
180                write!(
181                    f,
182                    " — specify only one form (either the rule ID or the rule name), not both with different severities"
183                )
184            }
185        }
186    }
187}
188
189impl std::error::Error for EngineConstructionError {}
190
191// ---------------------------------------------------------------------------
192// Runtime engine errors (spec 005)
193// ---------------------------------------------------------------------------
194
195/// Runtime errors from `Engine::lint_with_options` /
196/// `Engine::fix_with_options` (spec 005).
197///
198/// Distinct from [`EngineConstructionError`] by design — construction
199/// errors are build-time configuration defects the integrator fixes
200/// before shipping; `EngineError` reports runtime conditions (a
201/// per-call deadline expired, a per-call threshold override is
202/// out of range) the caller can react to. Keeping the two enums
203/// separate means matching on one does not force callers to pattern
204/// against build-time variants they could never encounter at
205/// request time.
206///
207/// `#[non_exhaustive]` so future runtime conditions (memory budget
208/// exceeded, per-rule deadline expired, cancellation token tripped)
209/// can land without a semver-breaking change.
210///
211/// Spec §R5 (asymmetric response shape): the lint path does not
212/// return `EngineError::DeadlineExceeded` on its own — partial lint
213/// results are surfaced through `LintResult.truncated` instead, so
214/// the caller can render whatever diagnostics were produced before
215/// the abort. Only `fix_with_options` raises `DeadlineExceeded`,
216/// because a partial `FixResult` would commit half a fix to the
217/// audit stream (Constitution V Principle V).
218#[non_exhaustive]
219#[derive(Debug)]
220pub enum EngineError {
221    /// `fix_with_options` aborted before applying every fix because
222    /// the call's deadline expired. `partial_lint` is the
223    /// `LintResult` that the lint pass produced before the abort —
224    /// callers can render its diagnostics to the user even though no
225    /// fixes were committed. `partial_lint.truncated` indicates
226    /// whether the lint pass itself was also truncated (deadline
227    /// expired during scanning) versus the fix-application loop
228    /// (lint pass completed, fixes did not).
229    ///
230    /// Carries the lint result by value (not boxed) because the
231    /// happy path returns `Ok(FixResult)` and the size penalty on
232    /// the error variant is paid only on the cold path.
233    DeadlineExceeded { partial_lint: LintResult },
234    /// `fix_with_options` rejected the per-call confidence
235    /// threshold override. Wraps the existing standalone
236    /// [`InvalidThreshold`] struct so `Engine::fix_with_threshold`
237    /// can keep its `Result<FixResult, InvalidThreshold>` public
238    /// signature unchanged while internally routing through
239    /// `fix_with_options`.
240    InvalidThreshold(InvalidThreshold),
241}
242
243impl std::fmt::Display for EngineError {
244    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245        match self {
246            Self::DeadlineExceeded { partial_lint } => write!(
247                f,
248                "engine deadline exceeded after processing {}/{} candidates",
249                partial_lint.candidates_processed, partial_lint.candidates_total
250            ),
251            Self::InvalidThreshold(it) => it.fmt(f),
252        }
253    }
254}
255
256impl std::error::Error for EngineError {
257    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
258        match self {
259            // `DeadlineExceeded` is not caused by an inner error — it
260            // reports a runtime condition (the deadline elapsed) with
261            // no underlying failure to chain.
262            Self::DeadlineExceeded { .. } => None,
263            Self::InvalidThreshold(it) => Some(it),
264        }
265    }
266}
267
268impl From<InvalidThreshold> for EngineError {
269    fn from(value: InvalidThreshold) -> Self {
270        Self::InvalidThreshold(value)
271    }
272}
273
274#[cfg(test)]
275#[cfg_attr(coverage_nightly, coverage(off))]
276mod tests {
277    use super::*;
278    use marque_scheme::CategoryId;
279
280    // -----------------------------------------------------------------------
281    // EngineConstructionError::exit_code — completes coverage of all four
282    // variants. `engine.rs` already covers UnknownRuleOverride,
283    // ConflictingRuleOverride, and RewriteCycle; the unannotated-custom case
284    // is exercised here.
285    // -----------------------------------------------------------------------
286
287    #[test]
288    fn unannotated_custom_axes_exit_code_is_unavailable() {
289        let err = EngineConstructionError::UnannotatedCustomAxes { rewrite: "bad" };
290        assert_eq!(
291            err.exit_code(),
292            69,
293            "scheme defects (not user-config) → EX_UNAVAILABLE"
294        );
295    }
296
297    // -----------------------------------------------------------------------
298    // EngineConstructionError::Display — round-trip every variant. Smoke
299    // checks key strings appear so the message stays useful when a
300    // contributor refactors the format string.
301    // -----------------------------------------------------------------------
302
303    #[test]
304    fn rewrite_cycle_display_names_axis_and_members() {
305        let err = EngineConstructionError::RewriteCycle {
306            axis: CategoryId(0),
307            members: Box::new(["alpha", "beta"]),
308        };
309        let msg = err.to_string();
310        assert!(msg.contains("page-rewrite cycle"), "got: {msg}");
311        assert!(msg.contains("alpha"), "got: {msg}");
312        assert!(msg.contains("beta"), "got: {msg}");
313    }
314
315    #[test]
316    fn unannotated_custom_axes_display_names_rewrite() {
317        let err = EngineConstructionError::UnannotatedCustomAxes {
318            rewrite: "noforn-clears-rel-to",
319        };
320        let msg = err.to_string();
321        assert!(msg.contains("noforn-clears-rel-to"), "got: {msg}");
322        assert!(msg.contains("explicit reads/writes"), "got: {msg}");
323    }
324
325    #[test]
326    fn unknown_rule_override_display_with_suggestion() {
327        let err = EngineConstructionError::UnknownRuleOverride {
328            key: "E00l".into(),
329            did_you_mean: Some("E001".into()),
330        };
331        let msg = err.to_string();
332        assert!(msg.contains("E00l"), "got: {msg}");
333        assert!(msg.contains("E001"), "suggestion missing: {msg}");
334        assert!(msg.contains("did you mean"), "got: {msg}");
335    }
336
337    #[test]
338    fn unknown_rule_override_display_without_suggestion_omits_did_you_mean() {
339        let err = EngineConstructionError::UnknownRuleOverride {
340            key: "totally-unknown".into(),
341            did_you_mean: None,
342        };
343        let msg = err.to_string();
344        assert!(msg.contains("totally-unknown"), "got: {msg}");
345        assert!(
346            !msg.contains("did you mean"),
347            "no suggestion → no hint phrase: {msg}"
348        );
349    }
350
351    #[test]
352    fn conflicting_rule_override_display_lists_all_keys_and_severities() {
353        let err = EngineConstructionError::ConflictingRuleOverride {
354            rule_id: "E001".into(),
355            keys: Box::new(["E001".into(), "portion-mark-in-banner".into()]),
356            severities: Box::new(["warn".into(), "error".into()]),
357        };
358        let msg = err.to_string();
359        assert!(msg.contains("E001"), "got: {msg}");
360        assert!(msg.contains("portion-mark-in-banner"), "got: {msg}");
361        assert!(msg.contains("warn"), "got: {msg}");
362        assert!(msg.contains("error"), "got: {msg}");
363    }
364
365    // -----------------------------------------------------------------------
366    // EngineConstructionError as `dyn Error` — confirms the trait impl
367    // exists and `source()` returns `None` (none of these wrap an inner
368    // error today).
369    // -----------------------------------------------------------------------
370
371    #[test]
372    fn engine_construction_error_has_no_source() {
373        let err = EngineConstructionError::UnannotatedCustomAxes { rewrite: "bad" };
374        let as_error: &dyn std::error::Error = &err;
375        assert!(as_error.source().is_none());
376    }
377
378    // -----------------------------------------------------------------------
379    // EngineError — Phase 1 type. Display, Error::source, From.
380    // -----------------------------------------------------------------------
381
382    fn lint_result_with_counts(processed: usize, total: usize) -> LintResult {
383        // In-crate construction MAY use struct-update syntax even with
384        // `#[non_exhaustive]`. The fields stay public so external callers
385        // can read counts off the partial_lint after a DeadlineExceeded.
386        LintResult {
387            diagnostics: Vec::new(),
388            truncated: true,
389            candidates_processed: processed,
390            candidates_total: total,
391            ..Default::default()
392        }
393    }
394
395    #[test]
396    fn deadline_exceeded_display_carries_processed_over_total() {
397        let err = EngineError::DeadlineExceeded {
398            partial_lint: lint_result_with_counts(7, 42),
399        };
400        let msg = err.to_string();
401        assert!(msg.contains("deadline exceeded"), "got: {msg}");
402        assert!(msg.contains("7/42"), "counts must appear as N/M: got {msg}");
403    }
404
405    #[test]
406    fn deadline_exceeded_with_zero_counts_renders_zero_over_zero() {
407        // Pre-pass abort path (deadline already expired before scanner)
408        // produces 0/0 counts. The Display message should still be
409        // legible — no division-by-zero artifacts, no empty fields.
410        let err = EngineError::DeadlineExceeded {
411            partial_lint: lint_result_with_counts(0, 0),
412        };
413        let msg = err.to_string();
414        assert!(msg.contains("0/0"), "got: {msg}");
415    }
416
417    #[test]
418    fn invalid_threshold_display_delegates_to_inner() {
419        // `EngineError::InvalidThreshold` Display must produce the SAME
420        // message as the wrapped `InvalidThreshold` — Phase 1 routes
421        // `Engine::fix_with_threshold` errors through `EngineError` and
422        // unwraps them at the boundary, so the user-visible string must
423        // not drift between the two paths.
424        let inner = InvalidThreshold(1.5);
425        let wrapped = EngineError::InvalidThreshold(InvalidThreshold(1.5));
426        assert_eq!(inner.to_string(), wrapped.to_string());
427    }
428
429    #[test]
430    fn invalid_threshold_display_renders_nan() {
431        // The wrapped Display must still produce something meaningful for
432        // NaN — the underlying impl uses `{}` on f32 which prints "NaN".
433        let err = EngineError::InvalidThreshold(InvalidThreshold(f32::NAN));
434        let msg = err.to_string();
435        assert!(msg.contains("NaN"), "got: {msg}");
436    }
437
438    #[test]
439    fn deadline_exceeded_source_is_none() {
440        // `DeadlineExceeded` reports a runtime condition with no
441        // underlying failure — `source()` MUST be None so callers
442        // walking the error chain don't trip on a phantom inner error.
443        let err = EngineError::DeadlineExceeded {
444            partial_lint: lint_result_with_counts(0, 0),
445        };
446        let as_error: &dyn std::error::Error = &err;
447        assert!(as_error.source().is_none());
448    }
449
450    #[test]
451    fn invalid_threshold_source_chains_to_inner() {
452        // `InvalidThreshold(_)` MUST expose the wrapped error through
453        // `source()` so callers can downcast / display the inner error
454        // directly. The inner is the same `InvalidThreshold` struct
455        // that `Engine::fix_with_threshold` returns directly to its
456        // callers, so a chain walker sees a stable type.
457        let err = EngineError::InvalidThreshold(InvalidThreshold(2.0));
458        let as_error: &dyn std::error::Error = &err;
459        let source = as_error.source().expect("InvalidThreshold has a source");
460        // The inner Display matches the bare InvalidThreshold's Display.
461        assert_eq!(source.to_string(), InvalidThreshold(2.0).to_string());
462    }
463
464    #[test]
465    fn from_invalid_threshold_constructs_invalid_threshold_variant() {
466        // `From<InvalidThreshold> for EngineError` is the conversion
467        // `Engine::fix_with_options` uses internally; verifying it
468        // produces the InvalidThreshold variant (not DeadlineExceeded
469        // by mistake) pins the impl.
470        let it = InvalidThreshold(-0.5);
471        let err: EngineError = it.into();
472        match err {
473            EngineError::InvalidThreshold(inner) => {
474                assert!(inner.0 == -0.5 || inner.0.is_nan());
475            }
476            other => panic!("expected InvalidThreshold variant, got {other:?}"),
477        }
478    }
479}