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}