Skip to main content

shape_wire/
any_error.rs

1//! Structured AnyError parsing and rendering.
2//!
3//! This module decodes runtime `AnyError` payloads from `WireValue` and renders
4//! them for different targets (plain text, ANSI terminal, HTML).
5
6use crate::WireValue;
7
8/// Parsed stack frame in an AnyError trace.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct AnyErrorFrame {
11    pub function: Option<String>,
12    pub file: Option<String>,
13    pub line: Option<usize>,
14    pub column: Option<usize>,
15    pub ip: Option<usize>,
16}
17
18/// Parsed AnyError chain node.
19#[derive(Debug, Clone, PartialEq)]
20pub struct AnyError {
21    pub category: String,
22    pub message: String,
23    pub code: Option<String>,
24    pub payload: WireValue,
25    pub frames: Vec<AnyErrorFrame>,
26    pub cause: Option<Box<AnyError>>,
27}
28
29impl AnyError {
30    /// Decode an `AnyError` from a `WireValue`.
31    pub fn from_wire(value: &WireValue) -> Option<Self> {
32        let obj = value.as_object()?;
33        let category = obj.get("category").and_then(WireValue::as_str)?.to_string();
34        if category != "AnyError" {
35            return None;
36        }
37
38        let payload = obj.get("payload").cloned().unwrap_or(WireValue::Null);
39        let message = obj
40            .get("message")
41            .and_then(WireValue::as_str)
42            .map(ToString::to_string)
43            .unwrap_or_else(|| brief_value(&payload));
44        let code = obj
45            .get("code")
46            .and_then(WireValue::as_str)
47            .map(str::to_string);
48
49        let frames = obj
50            .get("trace_info")
51            .map(parse_trace_info)
52            .unwrap_or_default();
53
54        let cause = obj
55            .get("cause")
56            .filter(|v| !v.is_null())
57            .and_then(Self::from_wire)
58            .map(Box::new);
59
60        Some(Self {
61            category,
62            message,
63            code,
64            payload,
65            frames,
66            cause,
67        })
68    }
69
70    /// Best-effort primary source location from the top frame.
71    pub fn primary_location(&self) -> Option<AnyErrorFrame> {
72        self.frames.first().cloned().or_else(|| {
73            self.cause
74                .as_deref()
75                .and_then(|cause| cause.primary_location())
76        })
77    }
78}
79
80/// Renderer contract for AnyError output targets.
81pub trait AnyErrorRenderer {
82    fn render(&self, error: &AnyError) -> String;
83}
84
85/// Plain-text AnyError renderer.
86#[derive(Debug, Default, Clone, Copy)]
87pub struct PlainAnyErrorRenderer;
88
89impl AnyErrorRenderer for PlainAnyErrorRenderer {
90    fn render(&self, error: &AnyError) -> String {
91        let mut out = String::from("Uncaught exception:");
92        render_plain_node(error, true, &mut out);
93        out
94    }
95}
96
97/// ANSI terminal AnyError renderer.
98#[derive(Debug, Default, Clone, Copy)]
99pub struct AnsiAnyErrorRenderer;
100
101impl AnyErrorRenderer for AnsiAnyErrorRenderer {
102    fn render(&self, error: &AnyError) -> String {
103        let mut out = String::new();
104        out.push_str("\x1b[1;31mUncaught exception:\x1b[0m");
105        render_ansi_node(error, true, &mut out);
106        out
107    }
108}
109
110/// HTML AnyError renderer for server/UI targets.
111#[derive(Debug, Default, Clone, Copy)]
112pub struct HtmlAnyErrorRenderer;
113
114impl AnyErrorRenderer for HtmlAnyErrorRenderer {
115    fn render(&self, error: &AnyError) -> String {
116        let mut out = String::new();
117        out.push_str("<div class=\"shape-error\">");
118        out.push_str("<div class=\"shape-error-header\">Uncaught exception:</div>");
119        render_html_node(error, true, &mut out);
120        out.push_str("</div>");
121        out
122    }
123}
124
125/// Render an AnyError from wire value using a renderer.
126pub fn render_any_error_with<R: AnyErrorRenderer>(
127    value: &WireValue,
128    renderer: &R,
129) -> Option<String> {
130    AnyError::from_wire(value).map(|err| renderer.render(&err))
131}
132
133/// Plain rendering helper.
134pub fn render_any_error_plain(value: &WireValue) -> Option<String> {
135    render_any_error_with(value, &PlainAnyErrorRenderer)
136}
137
138/// ANSI rendering helper.
139pub fn render_any_error_ansi(value: &WireValue) -> Option<String> {
140    render_any_error_with(value, &AnsiAnyErrorRenderer)
141}
142
143/// HTML rendering helper.
144pub fn render_any_error_html(value: &WireValue) -> Option<String> {
145    render_any_error_with(value, &HtmlAnyErrorRenderer)
146}
147
148/// Render AnyError for terminal output using environment-aware capabilities.
149///
150/// Prefers ANSI output when color/ANSI appears supported. Falls back to plain
151/// text for `NO_COLOR`, non-interactive/dumb terminals, or explicitly disabled
152/// color settings.
153pub fn render_any_error_terminal(value: &WireValue) -> Option<String> {
154    if terminal_supports_ansi() {
155        render_any_error_ansi(value)
156    } else {
157        render_any_error_plain(value)
158    }
159}
160
161fn terminal_supports_ansi() -> bool {
162    if std::env::var_os("NO_COLOR").is_some() {
163        return false;
164    }
165
166    if std::env::var("CLICOLOR").ok().as_deref() == Some("0") {
167        return false;
168    }
169
170    if std::env::var_os("FORCE_COLOR").is_some()
171        || std::env::var("CLICOLOR_FORCE")
172            .map(|v| v != "0")
173            .unwrap_or(false)
174    {
175        return true;
176    }
177
178    match std::env::var("TERM") {
179        Ok(term) if !term.is_empty() && term != "dumb" => true,
180        _ => false,
181    }
182}
183
184fn render_plain_node(error: &AnyError, root: bool, out: &mut String) {
185    if root {
186        if let Some(code) = &error.code {
187            out.push_str(&format!("\nError [{}]: {}", code, error.message));
188        } else {
189            out.push_str(&format!("\nError: {}", error.message));
190        }
191    } else if let Some(code) = &error.code {
192        out.push_str(&format!("\nCaused by [{}]: {}", code, error.message));
193    } else {
194        out.push_str(&format!("\nCaused by: {}", error.message));
195    }
196
197    for frame in &error.frames {
198        out.push_str("\n  at ");
199        out.push_str(frame.function.as_deref().unwrap_or("<anonymous>"));
200        if frame.file.is_some() || frame.line.is_some() || frame.column.is_some() {
201            out.push_str(" (");
202            match (&frame.file, frame.line, frame.column) {
203                (Some(file), Some(line), Some(column)) => {
204                    out.push_str(&format!("{file}:{line}:{column}"))
205                }
206                (Some(file), Some(line), None) => out.push_str(&format!("{file}:{line}")),
207                (Some(file), None, _) => out.push_str(file),
208                (None, Some(line), Some(column)) => out.push_str(&format!("line {line}:{column}")),
209                (None, Some(line), None) => out.push_str(&format!("line {line}")),
210                (None, None, Some(column)) => out.push_str(&format!("column {column}")),
211                (None, None, None) => {}
212            }
213            out.push(')');
214        }
215        if let Some(ip) = frame.ip {
216            out.push_str(&format!(" [ip {}]", ip));
217        }
218    }
219
220    if let Some(cause) = &error.cause {
221        render_plain_node(cause, false, out);
222    }
223}
224
225fn render_ansi_node(error: &AnyError, root: bool, out: &mut String) {
226    if root {
227        if let Some(code) = &error.code {
228            out.push_str(&format!(
229                "\n\x1b[1;31mError [{}]\x1b[0m: {}",
230                code, error.message
231            ));
232        } else {
233            out.push_str(&format!("\n\x1b[1;31mError\x1b[0m: {}", error.message));
234        }
235    } else if let Some(code) = &error.code {
236        out.push_str(&format!(
237            "\n\x1b[33mCaused by [{}]\x1b[0m: {}",
238            code, error.message
239        ));
240    } else {
241        out.push_str(&format!("\n\x1b[33mCaused by\x1b[0m: {}", error.message));
242    }
243
244    for frame in &error.frames {
245        out.push_str("\n  \x1b[36mat\x1b[0m ");
246        out.push_str(frame.function.as_deref().unwrap_or("<anonymous>"));
247        if frame.file.is_some() || frame.line.is_some() || frame.column.is_some() {
248            out.push_str(" (\x1b[2m");
249            match (&frame.file, frame.line, frame.column) {
250                (Some(file), Some(line), Some(column)) => {
251                    out.push_str(&format!("{file}:{line}:{column}"))
252                }
253                (Some(file), Some(line), None) => out.push_str(&format!("{file}:{line}")),
254                (Some(file), None, _) => out.push_str(file),
255                (None, Some(line), Some(column)) => out.push_str(&format!("line {line}:{column}")),
256                (None, Some(line), None) => out.push_str(&format!("line {line}")),
257                (None, None, Some(column)) => out.push_str(&format!("column {column}")),
258                (None, None, None) => {}
259            }
260            out.push_str("\x1b[0m)");
261        }
262        if let Some(ip) = frame.ip {
263            out.push_str(&format!(" [ip {}]", ip));
264        }
265    }
266
267    if let Some(cause) = &error.cause {
268        render_ansi_node(cause, false, out);
269    }
270}
271
272fn render_html_node(error: &AnyError, root: bool, out: &mut String) {
273    if root {
274        out.push_str("<div class=\"shape-error-main\">");
275        if let Some(code) = &error.code {
276            out.push_str(&format!(
277                "<span class=\"shape-error-label\">Error [{}]</span>: <span class=\"shape-error-message\">{}</span>",
278                escape_html(code),
279                escape_html(&error.message),
280            ));
281        } else {
282            out.push_str(&format!(
283                "<span class=\"shape-error-label\">Error</span>: <span class=\"shape-error-message\">{}</span>",
284                escape_html(&error.message),
285            ));
286        }
287        out.push_str("</div>");
288    } else {
289        out.push_str("<div class=\"shape-error-cause\">");
290        if let Some(code) = &error.code {
291            out.push_str(&format!(
292                "<span class=\"shape-error-cause-label\">Caused by [{}]</span>: <span class=\"shape-error-message\">{}</span>",
293                escape_html(code),
294                escape_html(&error.message),
295            ));
296        } else {
297            out.push_str(&format!(
298                "<span class=\"shape-error-cause-label\">Caused by</span>: <span class=\"shape-error-message\">{}</span>",
299                escape_html(&error.message),
300            ));
301        }
302        out.push_str("</div>");
303    }
304
305    for frame in &error.frames {
306        out.push_str("<div class=\"shape-error-frame\">");
307        out.push_str("<span class=\"shape-error-at\">at</span> ");
308        out.push_str(&escape_html(
309            frame.function.as_deref().unwrap_or("<anonymous>"),
310        ));
311        if frame.file.is_some() || frame.line.is_some() || frame.column.is_some() {
312            out.push_str(" <span class=\"shape-error-loc\">(");
313            match (&frame.file, frame.line, frame.column) {
314                (Some(file), Some(line), Some(column)) => {
315                    out.push_str(&escape_html(&format!("{file}:{line}:{column}")))
316                }
317                (Some(file), Some(line), None) => {
318                    out.push_str(&escape_html(&format!("{file}:{line}")))
319                }
320                (Some(file), None, _) => out.push_str(&escape_html(file)),
321                (None, Some(line), Some(column)) => {
322                    out.push_str(&escape_html(&format!("line {line}:{column}")))
323                }
324                (None, Some(line), None) => out.push_str(&escape_html(&format!("line {line}"))),
325                (None, None, Some(column)) => {
326                    out.push_str(&escape_html(&format!("column {column}")))
327                }
328                (None, None, None) => {}
329            }
330            out.push_str(")</span>");
331        }
332        if let Some(ip) = frame.ip {
333            out.push_str(&format!(
334                " <span class=\"shape-error-ip\">[ip {}]</span>",
335                ip
336            ));
337        }
338        out.push_str("</div>");
339    }
340
341    if let Some(cause) = &error.cause {
342        render_html_node(cause, false, out);
343    }
344}
345
346fn parse_trace_info(value: &WireValue) -> Vec<AnyErrorFrame> {
347    let Some(obj) = value.as_object() else {
348        return Vec::new();
349    };
350    let kind = obj
351        .get("kind")
352        .and_then(WireValue::as_str)
353        .unwrap_or("full");
354    if kind == "single" {
355        obj.get("frame")
356            .and_then(parse_trace_frame)
357            .into_iter()
358            .collect()
359    } else {
360        match obj.get("frames") {
361            Some(WireValue::Array(frames)) => frames.iter().filter_map(parse_trace_frame).collect(),
362            _ => Vec::new(),
363        }
364    }
365}
366
367fn parse_trace_frame(value: &WireValue) -> Option<AnyErrorFrame> {
368    let obj = value.as_object()?;
369    Some(AnyErrorFrame {
370        function: obj
371            .get("function")
372            .and_then(WireValue::as_str)
373            .map(str::to_string),
374        file: obj
375            .get("file")
376            .and_then(WireValue::as_str)
377            .map(str::to_string),
378        line: obj.get("line").and_then(as_usize),
379        column: obj.get("column").and_then(as_usize),
380        ip: obj.get("ip").and_then(as_usize),
381    })
382}
383
384fn as_usize(value: &WireValue) -> Option<usize> {
385    match value {
386        WireValue::Integer(i) if *i >= 0 => Some(*i as usize),
387        WireValue::Number(n) if *n >= 0.0 => Some(*n as usize),
388        WireValue::I8(v) if *v >= 0 => Some(*v as usize),
389        WireValue::U8(v) => Some(*v as usize),
390        WireValue::I16(v) if *v >= 0 => Some(*v as usize),
391        WireValue::U16(v) => Some(*v as usize),
392        WireValue::I32(v) if *v >= 0 => Some(*v as usize),
393        WireValue::U32(v) => Some(*v as usize),
394        WireValue::I64(v) if *v >= 0 => Some(*v as usize),
395        WireValue::U64(v) => usize::try_from(*v).ok(),
396        WireValue::Isize(v) if *v >= 0 => Some(*v as usize),
397        WireValue::Usize(v) => usize::try_from(*v).ok(),
398        WireValue::Ptr(v) => usize::try_from(*v).ok(),
399        WireValue::F32(v) if *v >= 0.0 => Some(*v as usize),
400        _ => None,
401    }
402}
403
404fn brief_value(value: &WireValue) -> String {
405    match value {
406        WireValue::Null => "null".to_string(),
407        WireValue::Bool(v) => v.to_string(),
408        WireValue::Integer(v) => v.to_string(),
409        WireValue::Number(v) => v.to_string(),
410        WireValue::I8(v) => v.to_string(),
411        WireValue::U8(v) => v.to_string(),
412        WireValue::I16(v) => v.to_string(),
413        WireValue::U16(v) => v.to_string(),
414        WireValue::I32(v) => v.to_string(),
415        WireValue::U32(v) => v.to_string(),
416        WireValue::I64(v) => v.to_string(),
417        WireValue::U64(v) => v.to_string(),
418        WireValue::Isize(v) => v.to_string(),
419        WireValue::Usize(v) => v.to_string(),
420        WireValue::Ptr(v) => format!("0x{v:x}"),
421        WireValue::F32(v) => v.to_string(),
422        WireValue::String(v) => v.clone(),
423        WireValue::Result { ok, value } => {
424            if *ok {
425                format!("Ok({})", brief_value(value))
426            } else {
427                format!("Err({})", brief_value(value))
428            }
429        }
430        WireValue::Object(_) => "{object}".to_string(),
431        WireValue::Array(v) => format!("[array:{}]", v.len()),
432        WireValue::FunctionRef { name } => format!("fn {}", name),
433        WireValue::Timestamp(ts) => format!("ts({})", ts),
434        WireValue::Duration { value, unit } => format!("{value:?}{unit:?}"),
435        WireValue::Range { .. } => "<range>".to_string(),
436        WireValue::Table(t) => format!("<table {}x{}>", t.row_count, t.column_count),
437        WireValue::PrintResult(pr) => pr.rendered.clone(),
438        WireValue::Content(node) => format!("<content:{}>", node),
439    }
440}
441
442fn escape_html(text: &str) -> String {
443    text.chars()
444        .flat_map(|c| match c {
445            '&' => "&amp;".chars().collect::<Vec<_>>(),
446            '<' => "&lt;".chars().collect::<Vec<_>>(),
447            '>' => "&gt;".chars().collect::<Vec<_>>(),
448            '"' => "&quot;".chars().collect::<Vec<_>>(),
449            '\'' => "&#39;".chars().collect::<Vec<_>>(),
450            _ => vec![c],
451        })
452        .collect()
453}
454
455trait WireValueObjectExt {
456    fn as_object(&self) -> Option<&std::collections::BTreeMap<String, WireValue>>;
457}
458
459impl WireValueObjectExt for WireValue {
460    fn as_object(&self) -> Option<&std::collections::BTreeMap<String, WireValue>> {
461        if let WireValue::Object(obj) = self {
462            Some(obj)
463        } else {
464            None
465        }
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472    use std::collections::BTreeMap;
473
474    fn trace_frame(function: &str, file: &str, line: i64, column: i64, ip: i64) -> WireValue {
475        let mut obj = BTreeMap::new();
476        obj.insert(
477            "function".to_string(),
478            WireValue::String(function.to_string()),
479        );
480        obj.insert("file".to_string(), WireValue::String(file.to_string()));
481        obj.insert("line".to_string(), WireValue::Integer(line));
482        obj.insert("column".to_string(), WireValue::Integer(column));
483        obj.insert("ip".to_string(), WireValue::Integer(ip));
484        WireValue::Object(obj)
485    }
486
487    fn trace_info_single(frame: WireValue) -> WireValue {
488        let mut obj = BTreeMap::new();
489        obj.insert("kind".to_string(), WireValue::String("single".to_string()));
490        obj.insert("frame".to_string(), frame);
491        WireValue::Object(obj)
492    }
493
494    fn any_error(
495        message: &str,
496        code: Option<&str>,
497        cause: Option<WireValue>,
498        trace_info: WireValue,
499    ) -> WireValue {
500        let mut obj = BTreeMap::new();
501        obj.insert(
502            "category".to_string(),
503            WireValue::String("AnyError".to_string()),
504        );
505        obj.insert(
506            "payload".to_string(),
507            WireValue::String(message.to_string()),
508        );
509        obj.insert("cause".to_string(), cause.unwrap_or(WireValue::Null));
510        obj.insert("trace_info".to_string(), trace_info);
511        obj.insert(
512            "message".to_string(),
513            WireValue::String(message.to_string()),
514        );
515        obj.insert(
516            "code".to_string(),
517            code.map(|c| WireValue::String(c.to_string()))
518                .unwrap_or(WireValue::Null),
519        );
520        WireValue::Object(obj)
521    }
522
523    #[test]
524    fn parse_and_render_plain() {
525        let root = any_error(
526            "low level",
527            None,
528            None,
529            trace_info_single(trace_frame("read_file", "cfg.shape", 3, 10, 11)),
530        );
531        let outer = any_error(
532            "high level",
533            Some("OPTION_NONE"),
534            Some(root),
535            trace_info_single(trace_frame("load_config", "cfg.shape", 7, 12, 29)),
536        );
537
538        let parsed = AnyError::from_wire(&outer).expect("should parse anyerror");
539        assert_eq!(parsed.code.as_deref(), Some("OPTION_NONE"));
540        assert_eq!(parsed.frames[0].line, Some(7));
541        assert_eq!(parsed.frames[0].column, Some(12));
542
543        let rendered = PlainAnyErrorRenderer.render(&parsed);
544        assert!(rendered.contains("Uncaught exception:"));
545        assert!(rendered.contains("Error [OPTION_NONE]: high level"));
546        assert!(rendered.contains("cfg.shape:7:12"));
547        assert!(rendered.contains("Caused by: low level"));
548    }
549
550    #[test]
551    fn render_ansi_and_html() {
552        let err = any_error(
553            "boom <bad>",
554            Some("E_TEST"),
555            None,
556            trace_info_single(trace_frame("run", "main.shape", 1, 2, 3)),
557        );
558        let ansi = render_any_error_ansi(&err).expect("ansi render");
559        assert!(ansi.contains("\x1b[1;31m"));
560        assert!(ansi.contains("E_TEST"));
561
562        let html = render_any_error_html(&err).expect("html render");
563        assert!(html.contains("shape-error"));
564        assert!(html.contains("E_TEST"));
565        assert!(html.contains("&lt;bad&gt;"));
566    }
567}