Skip to main content

shape_wire/
render.rs

1//! Unified wire-value rendering with extensible adapters.
2//!
3//! The default renderer handles AnyError values and chooses ANSI/plain output
4//! based on terminal capabilities.
5
6use crate::{
7    WireValue, render_any_error_ansi, render_any_error_html, render_any_error_plain,
8    render_any_error_terminal,
9};
10
11/// Output capabilities for terminal rendering.
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub struct TerminalRenderCaps {
14    pub ansi: bool,
15}
16
17impl TerminalRenderCaps {
18    pub fn detect() -> Self {
19        Self {
20            ansi: terminal_supports_ansi(),
21        }
22    }
23}
24
25/// Adapter trait for rendering specific wire-value families (errors/content/etc).
26///
27/// Implement this trait to extend the unified render path for custom error
28/// object shapes.
29pub trait WireRenderAdapter: Send + Sync {
30    fn render_terminal(&self, value: &WireValue, caps: TerminalRenderCaps) -> Option<String>;
31
32    fn render_html(&self, value: &WireValue) -> Option<String> {
33        let _ = value;
34        None
35    }
36}
37
38/// Built-in adapter for AnyError wire objects.
39#[derive(Debug, Default, Clone, Copy)]
40pub struct AnyErrorWireRenderAdapter;
41
42impl WireRenderAdapter for AnyErrorWireRenderAdapter {
43    fn render_terminal(&self, value: &WireValue, caps: TerminalRenderCaps) -> Option<String> {
44        if caps.ansi {
45            render_any_error_ansi(value)
46        } else {
47            render_any_error_plain(value)
48        }
49    }
50
51    fn render_html(&self, value: &WireValue) -> Option<String> {
52        render_any_error_html(value)
53    }
54}
55
56/// Unified renderer that dispatches through registered adapters.
57#[derive(Default)]
58pub struct WireRenderer {
59    adapters: Vec<Box<dyn WireRenderAdapter>>,
60}
61
62impl WireRenderer {
63    pub fn with_default_adapters() -> Self {
64        let mut renderer = Self::default();
65        renderer.adapters.push(Box::new(AnyErrorWireRenderAdapter));
66        renderer
67    }
68
69    pub fn register_adapter<A: WireRenderAdapter + 'static>(&mut self, adapter: A) {
70        self.adapters.push(Box::new(adapter));
71    }
72
73    pub fn render_terminal(&self, value: &WireValue) -> Option<String> {
74        let caps = TerminalRenderCaps::detect();
75        for adapter in &self.adapters {
76            if let Some(rendered) = adapter.render_terminal(value, caps) {
77                return Some(rendered);
78            }
79        }
80        None
81    }
82
83    pub fn render_html(&self, value: &WireValue) -> Option<String> {
84        for adapter in &self.adapters {
85            if let Some(rendered) = adapter.render_html(value) {
86                return Some(rendered);
87            }
88        }
89        None
90    }
91}
92
93/// Render a wire value to terminal text using built-in adapters.
94pub fn render_wire_terminal(value: &WireValue) -> Option<String> {
95    // Keep backward-compatible behavior for AnyError while routing through
96    // adapter-based dispatch for extensibility.
97    if let Some(rendered) = render_any_error_terminal(value) {
98        return Some(rendered);
99    }
100    WireRenderer::with_default_adapters().render_terminal(value)
101}
102
103/// Render a wire value to HTML using built-in adapters.
104pub fn render_wire_html(value: &WireValue) -> Option<String> {
105    WireRenderer::with_default_adapters().render_html(value)
106}
107
108fn terminal_supports_ansi() -> bool {
109    if std::env::var_os("NO_COLOR").is_some() {
110        return false;
111    }
112
113    if std::env::var("CLICOLOR").ok().as_deref() == Some("0") {
114        return false;
115    }
116
117    if std::env::var_os("FORCE_COLOR").is_some()
118        || std::env::var("CLICOLOR_FORCE")
119            .map(|v| v != "0")
120            .unwrap_or(false)
121    {
122        return true;
123    }
124
125    matches!(std::env::var("TERM"), Ok(term) if !term.is_empty() && term != "dumb")
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use std::collections::BTreeMap;
132
133    #[derive(Debug, Default, Clone, Copy)]
134    struct CustomAdapter;
135
136    impl WireRenderAdapter for CustomAdapter {
137        fn render_terminal(&self, value: &WireValue, _caps: TerminalRenderCaps) -> Option<String> {
138            match value {
139                WireValue::Object(obj) => obj
140                    .get("kind")
141                    .and_then(WireValue::as_str)
142                    .filter(|kind| *kind == "CustomError")
143                    .map(|_| "custom-rendered".to_string()),
144                _ => None,
145            }
146        }
147    }
148
149    #[test]
150    fn custom_adapter_extends_terminal_render_path() {
151        let mut renderer = WireRenderer::with_default_adapters();
152        renderer.register_adapter(CustomAdapter);
153
154        let mut obj = BTreeMap::new();
155        obj.insert(
156            "kind".to_string(),
157            WireValue::String("CustomError".to_string()),
158        );
159        let value = WireValue::Object(obj);
160
161        let rendered = renderer.render_terminal(&value);
162        assert_eq!(rendered.as_deref(), Some("custom-rendered"));
163    }
164
165    #[test]
166    fn default_renderer_handles_anyerror_html() {
167        let mut payload = BTreeMap::new();
168        payload.insert(
169            "category".to_string(),
170            WireValue::String("AnyError".to_string()),
171        );
172        payload.insert("message".to_string(), WireValue::String("boom".to_string()));
173        payload.insert("payload".to_string(), WireValue::String("boom".to_string()));
174        payload.insert("trace_info".to_string(), WireValue::Null);
175        payload.insert("cause".to_string(), WireValue::Null);
176
177        let html = render_wire_html(&WireValue::Object(payload)).expect("expected html");
178        assert!(html.contains("shape-error"));
179        assert!(html.contains("boom"));
180    }
181}