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}