Skip to main content

noyalib/
validated_miette.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! [`Spanned<T>`] + `garde` / `validator` → `miette::Report`
5//! bridge.
6//!
7//! Walks a validation error tree produced by `garde::Report` or
8//! `validator::ValidationErrors` and emits a single
9//! `miette::Report` whose source label points at the
10//! corresponding [`Spanned<T>`] range — enabling beautiful,
11//! line-and-column-precise output for declarative validation
12//! failures.
13//!
14//! Behind feature combinations:
15//! * `garde_errors_to_miette` requires `miette` + `garde`
16//! * `validator_errors_to_miette` requires `miette` + `validator`
17//!
18//! These functions are deliberately free-standing rather than
19//! impls on [`crate::Spanned`] to keep the dependency surface
20//! visible at the call site and avoid leaking miette into every
21//! `Spanned<T>` user.
22
23#[allow(unused_imports)]
24use crate::prelude::*;
25#[allow(unused_imports)]
26use crate::spanned::Spanned;
27
28/// Range covering the entire `Spanned<T>` payload, clamped to the
29/// supplied `source` length so a stale snippet never panics.
30///
31/// Only defined when `garde` or `validator` is on — those are the
32/// features that bring callers (`garde_errors_to_miette` /
33/// `validator_errors_to_miette`). Otherwise the body would be
34/// dead code on a `miette`-only build (e.g. enabled transitively
35/// by `noyalib-lsp`'s `validate-schema`).
36#[cfg(any(feature = "garde", feature = "validator"))]
37fn spanned_byte_range<T>(spanned: &Spanned<T>, source: &str) -> core::ops::Range<usize> {
38    let len = source.len();
39    let start = spanned.start.index().min(len);
40    let end = spanned.end.index().max(start).min(len);
41    // Ariadne / miette both expect a non-empty range to draw a label.
42    if end == start {
43        return start..(start + 1).min(len.max(start + 1));
44    }
45    start..end
46}
47
48/// What the bridge produces — boxed as `miette::Report` so callers
49/// can chain it through `Result<_, miette::Report>` workflows.
50///
51/// Implements [`miette::Diagnostic`] so it renders with full
52/// source-context, labelled span, and helpful one-line summary
53/// when fed through `miette::ReportHandler`. Display + Error
54/// impls are hand-rolled to keep `thiserror` out of the
55/// dependency closure (matches the policy in `error.rs`). Only
56/// compiled when at least one validator framework is enabled —
57/// otherwise the type would be dead code on a `miette`-only
58/// build.
59#[cfg(any(feature = "garde", feature = "validator"))]
60#[derive(Debug)]
61struct ValidationDiagnostic {
62    summary: String,
63    src: miette::NamedSource<String>,
64    span: miette::SourceSpan,
65}
66
67#[cfg(any(feature = "garde", feature = "validator"))]
68impl ValidationDiagnostic {
69    fn new<T>(spanned: &Spanned<T>, source: String, summary: String, name: String) -> Self {
70        let range = spanned_byte_range(spanned, &source);
71        let span: miette::SourceSpan = (range.start, range.end - range.start).into();
72        Self {
73            summary,
74            src: miette::NamedSource::new(name, source),
75            span,
76        }
77    }
78}
79
80#[cfg(any(feature = "garde", feature = "validator"))]
81impl core::fmt::Display for ValidationDiagnostic {
82    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
83        f.write_str(&self.summary)
84    }
85}
86
87#[cfg(any(feature = "garde", feature = "validator"))]
88impl std::error::Error for ValidationDiagnostic {}
89
90#[cfg(any(feature = "garde", feature = "validator"))]
91impl miette::Diagnostic for ValidationDiagnostic {
92    fn code<'a>(&'a self) -> Option<Box<dyn core::fmt::Display + 'a>> {
93        Some(Box::new("noyalib::validated"))
94    }
95    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
96        Some(&self.src)
97    }
98    fn labels(&self) -> Option<Box<dyn Iterator<Item = miette::LabeledSpan> + '_>> {
99        Some(Box::new(core::iter::once(
100            miette::LabeledSpan::new_with_span(
101                Some("validation failed here".to_string()),
102                self.span,
103            ),
104        )))
105    }
106}
107
108/// Render a [`garde::Report`] tree against a [`Spanned<T>`] payload
109/// as a [`miette::Report`].
110///
111/// The error message is the compact single-line summary garde's
112/// own walker produces (path → message pairs joined with `;`); the
113/// source label points at the supplied `Spanned<T>`'s byte range.
114/// `name` is the source identifier shown in the report header
115/// (typically a filename).
116///
117/// # Examples
118///
119/// ```ignore
120/// use noyalib::Spanned;
121/// use noyalib::validated_miette::garde_errors_to_miette;
122/// use garde::Validate;
123///
124/// #[derive(Debug, serde::Deserialize, Validate)]
125/// struct Cfg {
126///     #[garde(range(min = 1024))]
127///     port: u16,
128/// }
129///
130/// let yaml = "port: 22\n";
131/// let wrapped: Spanned<Cfg> = noyalib::from_str(yaml).unwrap();
132/// if let Err(errs) = wrapped.value.validate() {
133///     let report = garde_errors_to_miette(&wrapped, &errs, yaml, "config.yaml");
134///     // `report.to_string()` carries the rendered diagnostic.
135///     drop(report);
136/// }
137/// ```
138#[cfg(all(feature = "miette", feature = "garde"))]
139#[cfg_attr(docsrs, doc(cfg(all(feature = "miette", feature = "garde"))))]
140#[must_use]
141pub fn garde_errors_to_miette<T>(
142    spanned: &Spanned<T>,
143    errors: &garde::Report,
144    source: &str,
145    name: impl Into<String>,
146) -> miette::Report {
147    let summary = format_garde(errors);
148    let diag = ValidationDiagnostic::new(spanned, source.to_string(), summary, name.into());
149    miette::Report::new(diag)
150}
151
152/// Render a [`validator::ValidationErrors`] against a
153/// [`Spanned<T>`] payload as a [`miette::Report`].
154///
155/// Mirrors [`garde_errors_to_miette`] for the `validator` crate.
156///
157/// # Examples
158///
159/// ```ignore
160/// use noyalib::Spanned;
161/// use noyalib::validated_miette::validator_errors_to_miette;
162/// use validator::Validate;
163///
164/// #[derive(Debug, serde::Deserialize, Validate)]
165/// struct Cfg {
166///     #[validate(range(min = 1024))]
167///     port: u16,
168/// }
169///
170/// let yaml = "port: 22\n";
171/// let wrapped: Spanned<Cfg> = noyalib::from_str(yaml).unwrap();
172/// if let Err(errs) = wrapped.value.validate() {
173///     let report = validator_errors_to_miette(&wrapped, &errs, yaml, "config.yaml");
174///     drop(report);
175/// }
176/// ```
177#[cfg(all(feature = "miette", feature = "validator"))]
178#[cfg_attr(docsrs, doc(cfg(all(feature = "miette", feature = "validator"))))]
179#[must_use]
180pub fn validator_errors_to_miette<T>(
181    spanned: &Spanned<T>,
182    errors: &validator::ValidationErrors,
183    source: &str,
184    name: impl Into<String>,
185) -> miette::Report {
186    let summary = format_validator(errors);
187    let diag = ValidationDiagnostic::new(spanned, source.to_string(), summary, name.into());
188    miette::Report::new(diag)
189}
190
191#[cfg(feature = "garde")]
192fn format_garde(errors: &garde::Report) -> String {
193    use core::fmt::Write as _;
194    let mut out = String::with_capacity(64);
195    out.push_str("validation failed: ");
196    let mut first = true;
197    for (path, error) in errors.iter() {
198        if !first {
199            out.push_str("; ");
200        }
201        first = false;
202        let _ = write!(out, "{path}: {error}");
203    }
204    if first {
205        out.push_str("<no details>");
206    }
207    out
208}
209
210#[cfg(feature = "validator")]
211fn format_validator(errors: &validator::ValidationErrors) -> String {
212    use core::fmt::Write as _;
213    let mut out = String::with_capacity(64);
214    out.push_str("validation failed: ");
215    let mut first = true;
216    for (field, errs) in errors.field_errors() {
217        if !first {
218            out.push_str("; ");
219        }
220        first = false;
221        let _ = write!(out, "{field}: ");
222        let mut f_first = true;
223        for e in errs {
224            if !f_first {
225                out.push_str(", ");
226            }
227            f_first = false;
228            let _ = write!(out, "{e}");
229        }
230    }
231    if first {
232        out.push_str("<no details>");
233    }
234    out
235}
236
237/// Helper: clamp a span end so it never lands before its start.
238/// Exposed for [`crate::Spanned`] integrations that compute their
239/// own byte range and want to plug straight into a miette source
240/// span without re-implementing the clamp.
241///
242/// # Examples
243///
244/// ```
245/// # #[cfg(feature = "miette")]
246/// # {
247/// use noyalib::validated_miette::clamped_span;
248/// let span: miette::SourceSpan = clamped_span(10, 20, 100);
249/// assert_eq!(span.offset(), 10);
250/// assert_eq!(span.len(), 10);
251/// # }
252/// ```
253#[cfg(feature = "miette")]
254#[cfg_attr(docsrs, doc(cfg(feature = "miette")))]
255#[must_use]
256pub fn clamped_span(start: usize, end: usize, source_len: usize) -> miette::SourceSpan {
257    let s = start.min(source_len);
258    let e = end.max(s).min(source_len);
259    let e = if e == s {
260        (s + 1).min(source_len.max(s + 1))
261    } else {
262        e
263    };
264    (s, e - s).into()
265}