Skip to main content

standout_render/
context.rs

1//! Context injection for template rendering.
2//!
3//! This module provides types for injecting additional context objects into templates
4//! beyond the handler's serialized data. This enables templates to access utilities,
5//! formatters, and runtime-computed values that cannot be represented as JSON.
6//!
7//! # Overview
8//!
9//! The context injection system has two main components:
10//!
11//! 1. [`RenderContext`]: Information available at render time (output mode, terminal
12//!    width, theme, etc.)
13//! 2. [`ContextProvider`]: Trait for objects that can produce context values, either
14//!    statically or dynamically based on `RenderContext`
15//!
16//! # Use Cases
17//!
18//! - Table formatters: Inject `TabularFormatter` instances with resolved terminal width
19//! - Terminal info: Provide `terminal.width`, `terminal.is_tty` to templates
20//! - Environment: Expose environment variables or paths
21//! - User preferences: Date formats, timezone, locale
22//! - Utilities: Custom formatters, validators callable from templates
23//!
24//! # Example
25//!
26//! ```rust,ignore
27//! use standout_render::context::{RenderContext, ContextProvider};
28//! use minijinja::value::Object;
29//! use std::sync::Arc;
30//!
31//! // A simple context object
32//! struct TerminalInfo {
33//!     width: usize,
34//!     is_tty: bool,
35//! }
36//!
37//! impl Object for TerminalInfo {
38//!     fn get_value(self: &Arc<Self>, key: &minijinja::Value) -> Option<minijinja::Value> {
39//!         match key.as_str()? {
40//!             "width" => Some(minijinja::Value::from(self.width)),
41//!             "is_tty" => Some(minijinja::Value::from(self.is_tty)),
42//!             _ => None,
43//!         }
44//!     }
45//! }
46//!
47//! // Create a dynamic provider using a closure
48//! let provider = |ctx: &RenderContext| TerminalInfo {
49//!     width: ctx.terminal_width.unwrap_or(80),
50//!     is_tty: ctx.output_mode == OutputMode::Term,
51//! };
52//! ```
53
54use super::output::OutputMode;
55use super::theme::Theme;
56use minijinja::Value;
57use std::collections::HashMap;
58use std::fmt::Debug;
59use std::rc::Rc;
60
61/// Information available at render time for dynamic context providers.
62///
63/// This struct is passed to [`ContextProvider::provide`] to allow context objects
64/// to be configured based on runtime conditions.
65///
66/// # Fields
67///
68/// - `output_mode`: The current output mode (Term, Text, Json, etc.)
69/// - `terminal_width`: Terminal width in columns, if known
70/// - `theme`: The theme being used for rendering
71/// - `data`: The handler's output data as a JSON value
72/// - `extras`: Additional string key-value pairs for extension
73///
74/// # Example
75///
76/// ```rust
77/// use standout_render::context::RenderContext;
78/// use standout_render::{OutputMode, Theme};
79///
80/// let ctx = RenderContext {
81///     output_mode: OutputMode::Term,
82///     terminal_width: Some(120),
83///     theme: &Theme::new(),
84///     data: &serde_json::json!({"count": 42}),
85///     extras: std::collections::HashMap::new(),
86/// };
87///
88/// // Use context to configure a formatter
89/// let width = ctx.terminal_width.unwrap_or(80);
90/// ```
91#[derive(Debug, Clone)]
92pub struct RenderContext<'a> {
93    /// The output mode for rendering (Term, Text, Json, etc.)
94    pub output_mode: OutputMode,
95
96    /// Terminal width in columns, if available.
97    ///
98    /// This is `None` when:
99    /// - Output is not to a terminal (piped, redirected)
100    /// - Terminal width cannot be determined
101    /// - Running in a non-TTY environment
102    pub terminal_width: Option<usize>,
103
104    /// The theme being used for rendering.
105    pub theme: &'a Theme,
106
107    /// The handler's output data, serialized as JSON.
108    ///
109    /// This allows context providers to inspect the data being rendered
110    /// and adjust their behavior accordingly.
111    pub data: &'a serde_json::Value,
112
113    /// Additional string key-value pairs for extension.
114    ///
115    /// This allows passing arbitrary metadata to context providers
116    /// without modifying the struct definition.
117    pub extras: HashMap<String, String>,
118}
119
120impl<'a> RenderContext<'a> {
121    /// Creates a new render context with the given parameters.
122    pub fn new(
123        output_mode: OutputMode,
124        terminal_width: Option<usize>,
125        theme: &'a Theme,
126        data: &'a serde_json::Value,
127    ) -> Self {
128        Self {
129            output_mode,
130            terminal_width,
131            theme,
132            data,
133            extras: HashMap::new(),
134        }
135    }
136
137    /// Adds an extra key-value pair to the context.
138    pub fn with_extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
139        self.extras.insert(key.into(), value.into());
140        self
141    }
142
143    /// Gets an extra value by key.
144    pub fn get_extra(&self, key: &str) -> Option<&str> {
145        self.extras.get(key).map(|s| s.as_str())
146    }
147}
148
149/// Trait for types that can provide context objects for template rendering.
150///
151/// Context providers are called at render time to produce objects that will
152/// be available in templates. They receive a [`RenderContext`] with information
153/// about the current render environment.
154///
155/// # Static vs Dynamic Providers
156///
157/// - Static providers: Return the same object regardless of context
158/// - Dynamic providers: Use context to configure the returned object
159///
160/// # Implementing for Closures
161///
162/// A blanket implementation is provided for closures, making it easy to
163/// create dynamic providers:
164///
165/// ```rust,ignore
166/// use standout_render::context::{RenderContext, ContextProvider};
167///
168/// // Closure-based provider
169/// let provider = |ctx: &RenderContext| MyObject {
170///     width: ctx.terminal_width.unwrap_or(80),
171/// };
172/// ```
173///
174/// # Single-Threaded Design
175///
176/// CLI applications are single-threaded, so context providers don't require
177/// `Send + Sync` bounds.
178pub trait ContextProvider {
179    /// Produce a context object for the given render context.
180    ///
181    /// The returned value will be made available in templates under the
182    /// name specified when registering the provider.
183    fn provide(&self, ctx: &RenderContext) -> Value;
184}
185
186/// Blanket implementation for closures that return values convertible to minijinja::Value.
187impl<F> ContextProvider for F
188where
189    F: Fn(&RenderContext) -> Value,
190{
191    fn provide(&self, ctx: &RenderContext) -> Value {
192        (self)(ctx)
193    }
194}
195
196/// A static context provider that always returns the same value.
197///
198/// This is used internally for `.context(name, value)` calls where
199/// the value doesn't depend on render context.
200#[derive(Debug, Clone)]
201pub struct StaticProvider {
202    value: Value,
203}
204
205impl StaticProvider {
206    /// Creates a new static provider with the given value.
207    pub fn new(value: Value) -> Self {
208        Self { value }
209    }
210}
211
212impl ContextProvider for StaticProvider {
213    fn provide(&self, _ctx: &RenderContext) -> Value {
214        self.value.clone()
215    }
216}
217
218/// Storage for context entries, supporting both static and dynamic providers.
219///
220/// `ContextRegistry` is cheap to clone since it stores providers as `Rc`.
221#[derive(Default, Clone)]
222pub struct ContextRegistry {
223    providers: HashMap<String, Rc<dyn ContextProvider>>,
224}
225
226impl ContextRegistry {
227    /// Creates a new empty context registry.
228    pub fn new() -> Self {
229        Self::default()
230    }
231
232    /// Registers a static context value.
233    ///
234    /// The value will be available in templates under the given name.
235    pub fn add_static(&mut self, name: impl Into<String>, value: Value) {
236        self.providers
237            .insert(name.into(), Rc::new(StaticProvider::new(value)));
238    }
239
240    /// Registers a dynamic context provider.
241    ///
242    /// The provider will be called at render time to produce a value.
243    pub fn add_provider<P: ContextProvider + 'static>(
244        &mut self,
245        name: impl Into<String>,
246        provider: P,
247    ) {
248        self.providers.insert(name.into(), Rc::new(provider));
249    }
250
251    /// Returns true if the registry has no entries.
252    pub fn is_empty(&self) -> bool {
253        self.providers.is_empty()
254    }
255
256    /// Returns the number of registered context entries.
257    pub fn len(&self) -> usize {
258        self.providers.len()
259    }
260
261    /// Resolves all context providers into values for the given render context.
262    ///
263    /// Returns a map of names to values that can be merged into the template context.
264    pub fn resolve(&self, ctx: &RenderContext) -> HashMap<String, Value> {
265        self.providers
266            .iter()
267            .map(|(name, provider)| (name.clone(), provider.provide(ctx)))
268            .collect()
269    }
270
271    /// Gets the names of all registered context entries.
272    pub fn names(&self) -> impl Iterator<Item = &str> {
273        self.providers.keys().map(|s| s.as_str())
274    }
275}
276
277impl std::fmt::Debug for ContextRegistry {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        f.debug_struct("ContextRegistry")
280            .field("providers", &self.providers.keys().collect::<Vec<_>>())
281            .finish()
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288    use crate::Theme;
289
290    fn test_context() -> (Theme, serde_json::Value) {
291        (Theme::new(), serde_json::json!({"test": true}))
292    }
293
294    #[test]
295    fn render_context_new() {
296        let (theme, data) = test_context();
297        let ctx = RenderContext::new(OutputMode::Term, Some(80), &theme, &data);
298
299        assert_eq!(ctx.output_mode, OutputMode::Term);
300        assert_eq!(ctx.terminal_width, Some(80));
301        assert!(ctx.extras.is_empty());
302    }
303
304    #[test]
305    fn render_context_with_extras() {
306        let (theme, data) = test_context();
307        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data)
308            .with_extra("key1", "value1")
309            .with_extra("key2", "value2");
310
311        assert_eq!(ctx.get_extra("key1"), Some("value1"));
312        assert_eq!(ctx.get_extra("key2"), Some("value2"));
313        assert_eq!(ctx.get_extra("missing"), None);
314    }
315
316    #[test]
317    fn static_provider() {
318        let (theme, data) = test_context();
319        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data);
320
321        let provider = StaticProvider::new(Value::from(42));
322        let result = provider.provide(&ctx);
323
324        assert_eq!(result, Value::from(42));
325    }
326
327    #[test]
328    fn closure_provider() {
329        let (theme, data) = test_context();
330        let ctx = RenderContext::new(OutputMode::Term, Some(120), &theme, &data);
331
332        let provider =
333            |ctx: &RenderContext| -> Value { Value::from(ctx.terminal_width.unwrap_or(80)) };
334
335        let result = provider.provide(&ctx);
336        assert_eq!(result, Value::from(120));
337    }
338
339    #[test]
340    fn context_registry_add_static() {
341        let (theme, data) = test_context();
342        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data);
343
344        let mut registry = ContextRegistry::new();
345        registry.add_static("version", Value::from("1.0.0"));
346
347        let resolved = registry.resolve(&ctx);
348        assert_eq!(resolved.get("version"), Some(&Value::from("1.0.0")));
349    }
350
351    #[test]
352    fn context_registry_add_provider() {
353        let (theme, data) = test_context();
354        let ctx = RenderContext::new(OutputMode::Term, Some(100), &theme, &data);
355
356        let mut registry = ContextRegistry::new();
357        registry.add_provider("width", |ctx: &RenderContext| {
358            Value::from(ctx.terminal_width.unwrap_or(80))
359        });
360
361        let resolved = registry.resolve(&ctx);
362        assert_eq!(resolved.get("width"), Some(&Value::from(100)));
363    }
364
365    #[test]
366    fn context_registry_multiple_entries() {
367        let (theme, data) = test_context();
368        let ctx = RenderContext::new(OutputMode::Term, Some(120), &theme, &data);
369
370        let mut registry = ContextRegistry::new();
371        registry.add_static("app", Value::from("myapp"));
372        registry.add_provider("terminal_width", |ctx: &RenderContext| {
373            Value::from(ctx.terminal_width.unwrap_or(80))
374        });
375
376        assert_eq!(registry.len(), 2);
377        assert!(!registry.is_empty());
378
379        let resolved = registry.resolve(&ctx);
380        assert_eq!(resolved.get("app"), Some(&Value::from("myapp")));
381        assert_eq!(resolved.get("terminal_width"), Some(&Value::from(120)));
382    }
383
384    #[test]
385    fn context_registry_names() {
386        let mut registry = ContextRegistry::new();
387        registry.add_static("foo", Value::from(1));
388        registry.add_static("bar", Value::from(2));
389
390        let names: Vec<&str> = registry.names().collect();
391        assert!(names.contains(&"foo"));
392        assert!(names.contains(&"bar"));
393    }
394
395    #[test]
396    fn context_registry_empty() {
397        let registry = ContextRegistry::new();
398        assert!(registry.is_empty());
399        assert_eq!(registry.len(), 0);
400    }
401
402    #[test]
403    fn provider_uses_output_mode() {
404        let (theme, data) = test_context();
405
406        let provider =
407            |ctx: &RenderContext| -> Value { Value::from(format!("{:?}", ctx.output_mode)) };
408
409        let ctx_term = RenderContext::new(OutputMode::Term, None, &theme, &data);
410        assert_eq!(provider.provide(&ctx_term), Value::from("Term"));
411
412        let ctx_text = RenderContext::new(OutputMode::Text, None, &theme, &data);
413        assert_eq!(provider.provide(&ctx_text), Value::from("Text"));
414    }
415
416    #[test]
417    fn provider_uses_data() {
418        let theme = Theme::new();
419        let data = serde_json::json!({"count": 42});
420        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data);
421
422        let provider = |ctx: &RenderContext| -> Value {
423            let count = ctx.data.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
424            Value::from(count * 2)
425        };
426
427        assert_eq!(provider.provide(&ctx), Value::from(84));
428    }
429}