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