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::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::sync::Arc;
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::context::RenderContext;
78/// use standout::{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::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/// # Thread Safety
175///
176/// All context providers must be `Send + Sync` to support concurrent rendering.
177pub trait ContextProvider: Send + Sync {
178    /// Produce a context object for the given render context.
179    ///
180    /// The returned value will be made available in templates under the
181    /// name specified when registering the provider.
182    fn provide(&self, ctx: &RenderContext) -> Value;
183}
184
185/// Blanket implementation for closures that return values convertible to minijinja::Value.
186impl<F> ContextProvider for F
187where
188    F: Fn(&RenderContext) -> Value + Send + Sync,
189{
190    fn provide(&self, ctx: &RenderContext) -> Value {
191        (self)(ctx)
192    }
193}
194
195/// A static context provider that always returns the same value.
196///
197/// This is used internally for `.context(name, value)` calls where
198/// the value doesn't depend on render context.
199#[derive(Debug, Clone)]
200pub struct StaticProvider {
201    value: Value,
202}
203
204impl StaticProvider {
205    /// Creates a new static provider with the given value.
206    pub fn new(value: Value) -> Self {
207        Self { value }
208    }
209}
210
211impl ContextProvider for StaticProvider {
212    fn provide(&self, _ctx: &RenderContext) -> Value {
213        self.value.clone()
214    }
215}
216
217/// Storage for context entries, supporting both static and dynamic providers.
218///
219/// `ContextRegistry` is cheap to clone since it stores providers as `Arc`.
220#[derive(Default, Clone)]
221pub struct ContextRegistry {
222    providers: HashMap<String, Arc<dyn ContextProvider>>,
223}
224
225impl ContextRegistry {
226    /// Creates a new empty context registry.
227    pub fn new() -> Self {
228        Self::default()
229    }
230
231    /// Registers a static context value.
232    ///
233    /// The value will be available in templates under the given name.
234    pub fn add_static(&mut self, name: impl Into<String>, value: Value) {
235        self.providers
236            .insert(name.into(), Arc::new(StaticProvider::new(value)));
237    }
238
239    /// Registers a dynamic context provider.
240    ///
241    /// The provider will be called at render time to produce a value.
242    pub fn add_provider<P: ContextProvider + 'static>(
243        &mut self,
244        name: impl Into<String>,
245        provider: P,
246    ) {
247        self.providers.insert(name.into(), Arc::new(provider));
248    }
249
250    /// Returns true if the registry has no entries.
251    pub fn is_empty(&self) -> bool {
252        self.providers.is_empty()
253    }
254
255    /// Returns the number of registered context entries.
256    pub fn len(&self) -> usize {
257        self.providers.len()
258    }
259
260    /// Resolves all context providers into values for the given render context.
261    ///
262    /// Returns a map of names to values that can be merged into the template context.
263    pub fn resolve(&self, ctx: &RenderContext) -> HashMap<String, Value> {
264        self.providers
265            .iter()
266            .map(|(name, provider)| (name.clone(), provider.provide(ctx)))
267            .collect()
268    }
269
270    /// Gets the names of all registered context entries.
271    pub fn names(&self) -> impl Iterator<Item = &str> {
272        self.providers.keys().map(|s| s.as_str())
273    }
274}
275
276impl std::fmt::Debug for ContextRegistry {
277    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
278        f.debug_struct("ContextRegistry")
279            .field("providers", &self.providers.keys().collect::<Vec<_>>())
280            .finish()
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use crate::Theme;
288
289    fn test_context() -> (Theme, serde_json::Value) {
290        (Theme::new(), serde_json::json!({"test": true}))
291    }
292
293    #[test]
294    fn render_context_new() {
295        let (theme, data) = test_context();
296        let ctx = RenderContext::new(OutputMode::Term, Some(80), &theme, &data);
297
298        assert_eq!(ctx.output_mode, OutputMode::Term);
299        assert_eq!(ctx.terminal_width, Some(80));
300        assert!(ctx.extras.is_empty());
301    }
302
303    #[test]
304    fn render_context_with_extras() {
305        let (theme, data) = test_context();
306        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data)
307            .with_extra("key1", "value1")
308            .with_extra("key2", "value2");
309
310        assert_eq!(ctx.get_extra("key1"), Some("value1"));
311        assert_eq!(ctx.get_extra("key2"), Some("value2"));
312        assert_eq!(ctx.get_extra("missing"), None);
313    }
314
315    #[test]
316    fn static_provider() {
317        let (theme, data) = test_context();
318        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data);
319
320        let provider = StaticProvider::new(Value::from(42));
321        let result = provider.provide(&ctx);
322
323        assert_eq!(result, Value::from(42));
324    }
325
326    #[test]
327    fn closure_provider() {
328        let (theme, data) = test_context();
329        let ctx = RenderContext::new(OutputMode::Term, Some(120), &theme, &data);
330
331        let provider =
332            |ctx: &RenderContext| -> Value { Value::from(ctx.terminal_width.unwrap_or(80)) };
333
334        let result = provider.provide(&ctx);
335        assert_eq!(result, Value::from(120));
336    }
337
338    #[test]
339    fn context_registry_add_static() {
340        let (theme, data) = test_context();
341        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data);
342
343        let mut registry = ContextRegistry::new();
344        registry.add_static("version", Value::from("1.0.0"));
345
346        let resolved = registry.resolve(&ctx);
347        assert_eq!(resolved.get("version"), Some(&Value::from("1.0.0")));
348    }
349
350    #[test]
351    fn context_registry_add_provider() {
352        let (theme, data) = test_context();
353        let ctx = RenderContext::new(OutputMode::Term, Some(100), &theme, &data);
354
355        let mut registry = ContextRegistry::new();
356        registry.add_provider("width", |ctx: &RenderContext| {
357            Value::from(ctx.terminal_width.unwrap_or(80))
358        });
359
360        let resolved = registry.resolve(&ctx);
361        assert_eq!(resolved.get("width"), Some(&Value::from(100)));
362    }
363
364    #[test]
365    fn context_registry_multiple_entries() {
366        let (theme, data) = test_context();
367        let ctx = RenderContext::new(OutputMode::Term, Some(120), &theme, &data);
368
369        let mut registry = ContextRegistry::new();
370        registry.add_static("app", Value::from("myapp"));
371        registry.add_provider("terminal_width", |ctx: &RenderContext| {
372            Value::from(ctx.terminal_width.unwrap_or(80))
373        });
374
375        assert_eq!(registry.len(), 2);
376        assert!(!registry.is_empty());
377
378        let resolved = registry.resolve(&ctx);
379        assert_eq!(resolved.get("app"), Some(&Value::from("myapp")));
380        assert_eq!(resolved.get("terminal_width"), Some(&Value::from(120)));
381    }
382
383    #[test]
384    fn context_registry_names() {
385        let mut registry = ContextRegistry::new();
386        registry.add_static("foo", Value::from(1));
387        registry.add_static("bar", Value::from(2));
388
389        let names: Vec<&str> = registry.names().collect();
390        assert!(names.contains(&"foo"));
391        assert!(names.contains(&"bar"));
392    }
393
394    #[test]
395    fn context_registry_empty() {
396        let registry = ContextRegistry::new();
397        assert!(registry.is_empty());
398        assert_eq!(registry.len(), 0);
399    }
400
401    #[test]
402    fn provider_uses_output_mode() {
403        let (theme, data) = test_context();
404
405        let provider =
406            |ctx: &RenderContext| -> Value { Value::from(format!("{:?}", ctx.output_mode)) };
407
408        let ctx_term = RenderContext::new(OutputMode::Term, None, &theme, &data);
409        assert_eq!(provider.provide(&ctx_term), Value::from("Term"));
410
411        let ctx_text = RenderContext::new(OutputMode::Text, None, &theme, &data);
412        assert_eq!(provider.provide(&ctx_text), Value::from("Text"));
413    }
414
415    #[test]
416    fn provider_uses_data() {
417        let theme = Theme::new();
418        let data = serde_json::json!({"count": 42});
419        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data);
420
421        let provider = |ctx: &RenderContext| -> Value {
422            let count = ctx.data.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
423            Value::from(count * 2)
424        };
425
426        assert_eq!(provider.provide(&ctx), Value::from(84));
427    }
428}