Skip to main content

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}