marque_engine/output.rs
1// SPDX-FileCopyrightText: 2026 Knitli Inc.
2//
3// SPDX-License-Identifier: LicenseRef-MarqueLicense-1.0
4
5//! Output types returned by the engine's synchronous API surface.
6
7use marque_rules::{AppliedFix, Diagnostic};
8
9/// Result of a lint pass — diagnostics without source modification.
10///
11/// `#[non_exhaustive]` ensures future lint-time observations (per-rule
12/// timing histograms, decoder posterior quartiles, etc.) can be added
13/// without further breaking downstream callers. Adding the attribute
14/// itself in spec 005 IS a one-time breaking change for external
15/// callers that previously brace-constructed or exhaustively
16/// pattern-matched `LintResult`; from this version on, external
17/// callers MUST construct via `Default::default()` plus public field
18/// assignment (struct-update syntax is only allowed in-crate):
19///
20/// ```
21/// use marque_engine::LintResult;
22/// let mut result = LintResult::default();
23/// result.diagnostics.clear();
24/// ```
25///
26/// Spec 005 added `truncated`, `candidates_processed`, and
27/// `candidates_total` to surface deadline-driven cooperative
28/// cancellation.
29///
30/// **Phase 1 status (current build):** deadline enforcement is not
31/// wired yet. Lint passes run to completion regardless of
32/// `LintOptions::deadline`, so `truncated` is always `false` and
33/// both candidate-count fields are always `0`. The semantics below
34/// describe the Phase 2 behavior that lands in tasks T007–T009.
35///
36/// Once Phase 2 wiring lands: a fully completed pass reports
37/// `truncated: false` with `candidates_processed ==
38/// candidates_total`. An already-expired deadline returns
39/// immediately with `truncated: true` and both counts at `0`.
40/// Mid-document expiry produces `truncated: true` with
41/// `0 < candidates_processed < candidates_total`.
42#[non_exhaustive]
43#[derive(Debug, Default)]
44pub struct LintResult {
45 pub diagnostics: Vec<Diagnostic>,
46 /// `true` when the lint pass aborted before processing every
47 /// scanner-emitted candidate due to deadline expiry. The
48 /// `diagnostics` vector contains every diagnostic produced from
49 /// candidates that *were* processed before the abort. Spec §R3.
50 pub truncated: bool,
51 /// Number of scanner-emitted candidates the engine started
52 /// processing past the per-candidate deadline check before
53 /// returning. Counted at the top of each candidate iteration
54 /// (after the deadline check, before any per-candidate work),
55 /// so it includes every iteration that survived the cancellation
56 /// boundary — fully-rule-evaluated candidates AND structural
57 /// "early-continue" candidates such as page-break resets,
58 /// empty-span skips, and ambiguous-recognition skips. This
59 /// definition is what makes `candidates_processed ==
60 /// candidates_total` hold on a non-truncated pass; if the
61 /// counter only fired on the rule-loop completion path,
62 /// page-break candidates would silently break that invariant
63 /// on multi-page documents. On a truncated pass,
64 /// `candidates_processed < candidates_total` and the delta is
65 /// the count of candidates the deadline preempted.
66 pub candidates_processed: usize,
67 /// Total number of scanner-emitted candidates (the
68 /// post-scanner, pre-rule-loop count). Populated from the
69 /// scanner output regardless of whether the pass completed.
70 pub candidates_total: usize,
71}
72
73impl LintResult {
74 pub fn is_clean(&self) -> bool {
75 self.diagnostics.is_empty()
76 }
77
78 pub fn error_count(&self) -> usize {
79 use marque_rules::Severity;
80 self.diagnostics
81 .iter()
82 .filter(|d| d.severity == Severity::Error)
83 .count()
84 }
85
86 pub fn warn_count(&self) -> usize {
87 use marque_rules::Severity;
88 self.diagnostics
89 .iter()
90 .filter(|d| d.severity == Severity::Warn)
91 .count()
92 }
93
94 /// Number of diagnostics at `Severity::Info` — visible, but not
95 /// counted toward either the error/fix exit gate
96 /// (`EX_DIAG_ERROR`) or the warn exit gate (`EX_DIAG_WARN`). See
97 /// `Severity` docs for the tonal distinction (`Info` = "probably
98 /// intentional, worth surfacing"; `Warn` = "this might be wrong").
99 pub fn info_count(&self) -> usize {
100 use marque_rules::Severity;
101 self.diagnostics
102 .iter()
103 .filter(|d| d.severity == Severity::Info)
104 .count()
105 }
106
107 /// Number of diagnostics at `Severity::Suggest` — the
108 /// suggest-don't-fix channel. Visible in lint output but the
109 /// engine never auto-applies the attached fix (issue #235 / #186
110 /// PR-3). Like `Info`, contributes to neither exit-code gate.
111 pub fn suggest_count(&self) -> usize {
112 use marque_rules::Severity;
113 self.diagnostics
114 .iter()
115 .filter(|d| d.severity == Severity::Suggest)
116 .count()
117 }
118
119 /// Number of diagnostics that are configured at `Severity::Fix` AND
120 /// carry an actual `FixProposal`. A diagnostic at `Fix` severity but
121 /// with `fix: None` is not counted, since it cannot produce an
122 /// `AppliedFix` downstream.
123 pub fn fix_count(&self) -> usize {
124 use marque_rules::Severity;
125 self.diagnostics
126 .iter()
127 .filter(|d| d.severity == Severity::Fix && d.fix.is_some())
128 .count()
129 }
130}
131
132/// Result of a fix pass — modified source and audit trail.
133#[derive(Debug)]
134pub struct FixResult {
135 /// Fixed source bytes. Preserves UTF-8 validity: the input is UTF-8, and every
136 /// replacement is a valid UTF-8 `String`, so the result is always valid UTF-8.
137 pub source: Vec<u8>,
138 /// Audit records for every fix that was applied.
139 pub applied: Vec<AppliedFix>,
140 /// Diagnostics that could not be auto-fixed (below confidence threshold,
141 /// or require human judgment).
142 pub remaining_diagnostics: Vec<Diagnostic>,
143}
144
145#[cfg(test)]
146#[cfg_attr(coverage_nightly, coverage(off))]
147mod tests {
148 use super::*;
149 use marque_core::Span;
150 use marque_rules::{Diagnostic, RuleId, Severity};
151
152 #[test]
153 fn is_clean_returns_true_when_no_diagnostics() {
154 let clean_result = LintResult {
155 diagnostics: vec![],
156 ..Default::default()
157 };
158 assert!(clean_result.is_clean());
159 }
160
161 #[test]
162 fn is_clean_returns_false_when_has_diagnostics() {
163 let dirty_result = LintResult {
164 diagnostics: vec![Diagnostic::new(
165 RuleId::new("E001"),
166 Severity::Error,
167 Span::new(0, 0),
168 "test",
169 "test",
170 None,
171 )],
172 ..Default::default()
173 };
174 assert!(!dirty_result.is_clean());
175 }
176
177 #[test]
178 fn info_count_isolates_info_from_error_and_warn() {
179 // T035c-2: `Severity::Info` diagnostics count in `info_count()`
180 // only — they do NOT contribute to `error_count()` or
181 // `warn_count()`. Critical because the CLI has two non-zero
182 // exit gates: `error_count() > 0 || fix_count() > 0` maps to
183 // EX_DIAG_ERROR (exit 1), and `warn_count() > 0` maps to
184 // EX_DIAG_WARN (exit 2). Info must land in neither bucket so
185 // that a rule configured at Info keeps the CLI exit code at
186 // 0 — that's the whole point of the severity between Off and
187 // Warn.
188 let result = LintResult {
189 diagnostics: vec![
190 Diagnostic::new(
191 RuleId::new("W034"),
192 Severity::Info,
193 Span::new(0, 0),
194 "info one",
195 "test",
196 None,
197 ),
198 Diagnostic::new(
199 RuleId::new("W034"),
200 Severity::Info,
201 Span::new(0, 0),
202 "info two",
203 "test",
204 None,
205 ),
206 Diagnostic::new(
207 RuleId::new("W003"),
208 Severity::Warn,
209 Span::new(0, 0),
210 "warn",
211 "test",
212 None,
213 ),
214 Diagnostic::new(
215 RuleId::new("E001"),
216 Severity::Error,
217 Span::new(0, 0),
218 "err",
219 "test",
220 None,
221 ),
222 ],
223 ..Default::default()
224 };
225 assert_eq!(result.info_count(), 2);
226 assert_eq!(result.warn_count(), 1);
227 assert_eq!(result.error_count(), 1);
228 assert_eq!(result.fix_count(), 0);
229 }
230}