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}