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}