Skip to main content

atproto_devtool/common/
diagnostics.rs

1//! Shared miette configuration and `NamedSource` helpers.
2
3use std::sync::Arc;
4
5use miette::{GraphicalTheme, MietteHandlerOpts, NamedSource};
6
7// Re-exports so stages can import `LabeledSpan` / `SourceSpan` from a single
8// path.
9pub use miette::{LabeledSpan, SourceSpan};
10
11/// Install the miette panic hook and graphical report handler.
12///
13/// Honours `NO_COLOR=1` by dropping to an unstyled theme. Call this exactly once
14/// from `main` before any `miette::Result`-returning code runs.
15pub fn install_miette_handler(no_color: bool) -> miette::Result<()> {
16    // `NO_COLOR` is also respected automatically by miette when set in the
17    // environment; passing an explicit theme here covers the `--no-color` flag
18    // path without having to touch process-wide env vars (which is `unsafe` in
19    // Rust 2024).
20    miette::set_hook(Box::new(move |_| {
21        let theme = if no_color {
22            GraphicalTheme::unicode_nocolor()
23        } else {
24            GraphicalTheme::unicode()
25        };
26        Box::new(
27            MietteHandlerOpts::new()
28                .graphical_theme(theme)
29                .context_lines(3)
30                .build(),
31        )
32    }))?;
33
34    // Install miette's panic hook so panics render through the same handler.
35    miette::set_panic_hook();
36
37    Ok(())
38}
39
40/// Build a `NamedSource` from a name and raw bytes.
41///
42/// The bytes are cloned into an `Arc<[u8]>` via miette's constructor,
43/// so callers may drop the original slice after this returns.
44pub fn named_source_from_bytes(name: impl AsRef<str>, bytes: &[u8]) -> NamedSource<Arc<[u8]>> {
45    NamedSource::new(name, Arc::<[u8]>::from(bytes))
46}
47
48/// Build a `NamedSource` from a name and a UTF-8 string slice.
49pub fn named_source_from_str(name: impl AsRef<str>, text: &str) -> NamedSource<String> {
50    NamedSource::new(name, text.to_string())
51}
52
53/// Pretty-print `body` as JSON for display in a `NamedSource`.
54///
55/// Real atproto servers routinely emit JSON as a single enormous line, which
56/// makes miette's source-span visualization illegible. Any diagnostic that
57/// embeds a JSON payload should run it through this helper first so that
58/// line-based caret rendering lands somewhere readable.
59///
60/// If `body` parses as JSON, returns an `Arc<[u8]>` holding the pretty-printed
61/// form. If it does not parse, or re-serialization fails, returns an `Arc<[u8]>`
62/// containing the original bytes unchanged so callers can still hand something
63/// to `NamedSource`.
64///
65/// Spans computed against the returned bytes must be derived from those same
66/// bytes — do not mix a pretty body with a span calculated against the raw
67/// body, or vice versa.
68pub fn pretty_json_for_display(body: &[u8]) -> Arc<[u8]> {
69    match serde_json::from_slice::<serde_json::Value>(body) {
70        Ok(value) => match serde_json::to_vec_pretty(&value) {
71            Ok(pretty) => Arc::from(pretty),
72            Err(_) => Arc::from(body),
73        },
74        Err(_) => Arc::from(body),
75    }
76}
77
78/// Convert a 1-based `(line, column)` pair (as produced by `serde_json::Error`)
79/// into a `SourceSpan` pointing at that byte inside `body`.
80///
81/// The returned span has length 1 so miette renders a caret at the exact
82/// failure site. `line == 0` is the `serde_json` sentinel for "unknown
83/// location" and produces a 1-byte span at the last byte of `body`. If the
84/// column runs past the end of the matched line, the span is clamped to the
85/// last byte of that line.
86pub fn span_at_line_column(body: &[u8], line: usize, column: usize) -> SourceSpan {
87    if body.is_empty() {
88        return SourceSpan::new(0.into(), 0);
89    }
90    if line == 0 {
91        let end = body.len().saturating_sub(1);
92        return SourceSpan::new(end.into(), 1);
93    }
94    let mut current_line = 1usize;
95    let mut line_start = 0usize;
96    for (offset, &byte) in body.iter().enumerate() {
97        if current_line == line {
98            let line_end = body[line_start..]
99                .iter()
100                .position(|&b| b == b'\n')
101                .map(|rel| line_start + rel)
102                .unwrap_or(body.len());
103            let column_offset = column.saturating_sub(1);
104            let span_start = line_start + column_offset;
105            if span_start < line_end {
106                return SourceSpan::new(span_start.into(), 1);
107            } else {
108                let len = line_end.saturating_sub(line_start).max(1);
109                return SourceSpan::new(line_start.into(), len);
110            }
111        }
112        if byte == b'\n' {
113            current_line += 1;
114            line_start = offset + 1;
115        }
116    }
117    // Requested line is past the end of the body — clamp to the last byte.
118    let end = body.len().saturating_sub(1);
119    SourceSpan::new(end.into(), 1)
120}
121
122/// Find the span of a JSON quoted literal (key or string value) inside `bytes`.
123///
124/// Scans for the literal `"<literal>"` pattern and returns the span covering
125/// the entire quoted string including the quotes. This works for both JSON
126/// keys and string values since both are quoted identically in JSON. Returns
127/// `None` if the literal is not present.
128///
129/// A substring search is acceptable here because the payloads we render are
130/// always small (DID documents, single records, query responses) and we only
131/// invoke this to highlight an already-extracted key or value — so the risk
132/// of a false match inside an unrelated string is negligible in practice.
133pub fn span_for_quoted_literal(bytes: &[u8], literal: &str) -> Option<SourceSpan> {
134    let search = format!("\"{literal}\"");
135    bytes
136        .windows(search.len())
137        .position(|w| w == search.as_bytes())
138        .map(|pos| SourceSpan::new(pos.into(), search.len()))
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn pretty_json_for_display_wraps_compact_body() {
147        let compact = br#"{"a":1,"b":[2,3]}"#;
148        let pretty = pretty_json_for_display(compact);
149        let text = std::str::from_utf8(&pretty).unwrap();
150        assert!(
151            text.contains('\n'),
152            "pretty-printed body should be multi-line"
153        );
154        assert!(text.contains("\"a\""));
155    }
156
157    #[test]
158    fn pretty_json_for_display_passes_non_json_through() {
159        let garbage = b"not valid json <<<";
160        let out = pretty_json_for_display(garbage);
161        assert_eq!(out.as_ref(), garbage);
162    }
163
164    #[test]
165    fn span_at_line_column_known_location() {
166        let body = b"line1\nline2\nline3";
167        let span = span_at_line_column(body, 2, 3);
168        // "line2" starts at byte 6, column 3 (1-based) is byte 8 ('n').
169        assert_eq!(span.offset(), 8);
170        assert_eq!(span.len(), 1);
171    }
172
173    #[test]
174    fn span_at_line_column_unknown_line_sentinel() {
175        let body = b"abc";
176        let span = span_at_line_column(body, 0, 0);
177        assert_eq!(span.offset(), 2);
178        assert_eq!(span.len(), 1);
179    }
180
181    #[test]
182    fn span_at_line_column_empty_body() {
183        let span = span_at_line_column(b"", 1, 1);
184        assert_eq!(span.offset(), 0);
185        assert_eq!(span.len(), 0);
186    }
187
188    #[test]
189    fn span_at_line_column_column_past_end_of_line() {
190        let body = b"ab\ncd\n";
191        let span = span_at_line_column(body, 1, 99);
192        // Clamped to the remainder of line 1.
193        assert_eq!(span.offset(), 0);
194        assert_eq!(span.len(), 2);
195    }
196
197    #[test]
198    fn span_for_quoted_literal_finds_key() {
199        let json = br#"{"service": [], "other": 123}"#;
200        let span = span_for_quoted_literal(json, "service").unwrap();
201        assert_eq!(
202            &json[span.offset()..span.offset() + span.len()],
203            b"\"service\""
204        );
205    }
206
207    #[test]
208    fn span_for_quoted_literal_finds_value() {
209        let json = br#"{"serviceEndpoint": "https://example.com"}"#;
210        let span = span_for_quoted_literal(json, "https://example.com").unwrap();
211        assert_eq!(
212            &json[span.offset()..span.offset() + span.len()],
213            b"\"https://example.com\""
214        );
215    }
216
217    #[test]
218    fn span_for_quoted_literal_missing_returns_none() {
219        let json = br#"{"other": 123}"#;
220        assert!(span_for_quoted_literal(json, "service").is_none());
221    }
222}