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}