Skip to main content

standout_dispatch/
render.rs

1//! Render function abstraction.
2//!
3//! This module defines the contract between dispatch and renderers. The key design
4//! principle is that dispatch is render-agnostic: it doesn't know about templates,
5//! themes, output formats, or any rendering implementation details.
6//!
7//! # Design Rationale
8//!
9//! The render handler is a pluggable callback that the consuming framework provides.
10//! This separation exists because:
11//!
12//! 1. Flexibility: Different applications may use different renderers (or none at all)
13//! 2. Separation of concerns: Business logic (handlers) shouldn't know about presentation
14//! 3. Runtime configuration: Format/theme decisions happen at runtime (from CLI args),
15//!    not at compile time
16//!
17//! # The Closure Pattern
18//!
19//! Render handlers capture their context (format, theme, etc.) in a closure:
20//!
21//! ```rust,ignore
22//! // At runtime, after parsing --output=json:
23//! let format = extract_output_mode(&matches);
24//! let theme = &app.theme;
25//! let templates = &app.templates;
26//!
27//! // Create render handler with context baked in
28//! let render_handler = from_fn(move |data| {
29//!     render_with_format(templates, theme, format, data)
30//! });
31//! ```
32//!
33//! Dispatch calls `render_handler(data)` without knowing what's inside the closure.
34//! All format/theme/template logic lives in the closure, created by the framework layer.
35//!
36//! # Single-Threaded Design
37//!
38//! CLI applications are single-threaded, so render functions use `Rc<RefCell>`
39//! and accept `FnMut` closures for flexible mutable state handling.
40
41use std::cell::RefCell;
42use std::rc::Rc;
43
44/// The render function signature.
45///
46/// Takes handler data (as JSON) and returns formatted output. The render function
47/// is a closure that captures all rendering context (format, theme, templates, etc.)
48/// so dispatch doesn't need to know about any of it.
49///
50/// Uses `Rc<RefCell>` since CLI applications are single-threaded, and accepts
51/// `FnMut` closures for flexible mutable state handling.
52///
53/// # Example
54///
55/// ```rust,ignore
56/// // Framework creates render handler with context captured
57/// let render_handler = from_fn(move |data| {
58///     match format {
59///         Format::Json => serde_json::to_string_pretty(data),
60///         Format::Term => render_template(template, data, theme),
61///         // ...
62///     }
63/// });
64/// ```
65pub type RenderFn = Rc<RefCell<dyn FnMut(&serde_json::Value) -> Result<String, RenderError>>>;
66
67/// Errors that can occur during rendering.
68#[derive(Debug, thiserror::Error)]
69pub enum RenderError {
70    /// Template rendering failed
71    #[error("render error: {0}")]
72    Render(String),
73
74    /// Data serialization failed
75    #[error("serialization error: {0}")]
76    Serialization(String),
77
78    /// Other error
79    #[error("{0}")]
80    Other(String),
81}
82
83impl From<serde_json::Error> for RenderError {
84    fn from(e: serde_json::Error) -> Self {
85        RenderError::Serialization(e.to_string())
86    }
87}
88
89/// Creates a render function from a closure.
90///
91/// This is the primary way to provide custom rendering logic. The closure
92/// should capture any context it needs (format, theme, templates, etc.).
93///
94/// Accepts `FnMut` closures, allowing mutable state in the render handler.
95///
96/// # Example
97///
98/// ```rust
99/// use standout_dispatch::{from_fn, RenderError};
100///
101/// let render = from_fn(|data| {
102///     Ok(serde_json::to_string_pretty(data)?)
103/// });
104/// ```
105pub fn from_fn<F>(f: F) -> RenderFn
106where
107    F: FnMut(&serde_json::Value) -> Result<String, RenderError> + 'static,
108{
109    Rc::new(RefCell::new(f))
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serde_json::json;
116
117    #[test]
118    fn test_from_fn_json() {
119        let render = from_fn(|data| {
120            serde_json::to_string_pretty(data)
121                .map_err(|e| RenderError::Serialization(e.to_string()))
122        });
123
124        let data = json!({"name": "test"});
125        let result = render.borrow_mut()(&data).unwrap();
126        assert!(result.contains("\"name\": \"test\""));
127    }
128
129    #[test]
130    fn test_from_fn_custom() {
131        let render = from_fn(|data| {
132            let name = data
133                .get("name")
134                .and_then(|v| v.as_str())
135                .unwrap_or("unknown");
136            Ok(format!("Hello, {}!", name))
137        });
138
139        let data = json!({"name": "world"});
140        let result = render.borrow_mut()(&data).unwrap();
141        assert_eq!(result, "Hello, world!");
142    }
143
144    #[test]
145    fn test_from_fn_mutable_state() {
146        let mut call_count = 0;
147        let render = from_fn(move |data| {
148            call_count += 1;
149            Ok(format!("Call {}: {}", call_count, data))
150        });
151
152        let data = json!({"key": "value"});
153        let result1 = render.borrow_mut()(&data).unwrap();
154        let result2 = render.borrow_mut()(&data).unwrap();
155        assert!(result1.contains("Call 1"));
156        assert!(result2.contains("Call 2"));
157    }
158
159    #[test]
160    fn test_render_error_from_serde() {
161        let err: RenderError = serde_json::from_str::<serde_json::Value>("invalid")
162            .unwrap_err()
163            .into();
164        assert!(matches!(err, RenderError::Serialization(_)));
165    }
166}