standout-render 7.2.0

Styled terminal rendering with templates, themes, and adaptive color support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
//! Context injection for template rendering.
//!
//! This module provides types for injecting additional context objects into templates
//! beyond the handler's serialized data. This enables templates to access utilities,
//! formatters, and runtime-computed values that cannot be represented as JSON.
//!
//! # Overview
//!
//! The context injection system has two main components:
//!
//! 1. [`RenderContext`]: Information available at render time (output mode, terminal
//!    width, theme, etc.)
//! 2. [`ContextProvider`]: Trait for objects that can produce context values, either
//!    statically or dynamically based on `RenderContext`
//!
//! # Use Cases
//!
//! - Table formatters: Inject `TabularFormatter` instances with resolved terminal width
//! - Terminal info: Provide `terminal.width`, `terminal.is_tty` to templates
//! - Environment: Expose environment variables or paths
//! - User preferences: Date formats, timezone, locale
//! - Utilities: Custom formatters, validators callable from templates
//!
//! # Example
//!
//! ```rust,ignore
//! use standout_render::context::{RenderContext, ContextProvider};
//! use minijinja::value::Object;
//! use std::sync::Arc;
//!
//! // A simple context object
//! struct TerminalInfo {
//!     width: usize,
//!     is_tty: bool,
//! }
//!
//! impl Object for TerminalInfo {
//!     fn get_value(self: &Arc<Self>, key: &minijinja::Value) -> Option<minijinja::Value> {
//!         match key.as_str()? {
//!             "width" => Some(minijinja::Value::from(self.width)),
//!             "is_tty" => Some(minijinja::Value::from(self.is_tty)),
//!             _ => None,
//!         }
//!     }
//! }
//!
//! // Create a dynamic provider using a closure
//! let provider = |ctx: &RenderContext| TerminalInfo {
//!     width: ctx.terminal_width.unwrap_or(80),
//!     is_tty: ctx.output_mode == OutputMode::Term,
//! };
//! ```

use super::output::OutputMode;
use super::theme::Theme;
use minijinja::Value;
use std::collections::HashMap;
use std::fmt::Debug;
use std::rc::Rc;

/// Information available at render time for dynamic context providers.
///
/// This struct is passed to [`ContextProvider::provide`] to allow context objects
/// to be configured based on runtime conditions.
///
/// # Fields
///
/// - `output_mode`: The current output mode (Term, Text, Json, etc.)
/// - `terminal_width`: Terminal width in columns, if known
/// - `theme`: The theme being used for rendering
/// - `data`: The handler's output data as a JSON value
/// - `extras`: Additional string key-value pairs for extension
///
/// # Example
///
/// ```rust
/// use standout_render::context::RenderContext;
/// use standout_render::{OutputMode, Theme};
///
/// let ctx = RenderContext {
///     output_mode: OutputMode::Term,
///     terminal_width: Some(120),
///     theme: &Theme::new(),
///     data: &serde_json::json!({"count": 42}),
///     extras: std::collections::HashMap::new(),
/// };
///
/// // Use context to configure a formatter
/// let width = ctx.terminal_width.unwrap_or(80);
/// ```
#[derive(Debug, Clone)]
pub struct RenderContext<'a> {
    /// The output mode for rendering (Term, Text, Json, etc.)
    pub output_mode: OutputMode,

    /// Terminal width in columns, if available.
    ///
    /// This is `None` when:
    /// - Output is not to a terminal (piped, redirected)
    /// - Terminal width cannot be determined
    /// - Running in a non-TTY environment
    pub terminal_width: Option<usize>,

    /// The theme being used for rendering.
    pub theme: &'a Theme,

    /// The handler's output data, serialized as JSON.
    ///
    /// This allows context providers to inspect the data being rendered
    /// and adjust their behavior accordingly.
    pub data: &'a serde_json::Value,

    /// Additional string key-value pairs for extension.
    ///
    /// This allows passing arbitrary metadata to context providers
    /// without modifying the struct definition.
    pub extras: HashMap<String, String>,
}

impl<'a> RenderContext<'a> {
    /// Creates a new render context with the given parameters.
    pub fn new(
        output_mode: OutputMode,
        terminal_width: Option<usize>,
        theme: &'a Theme,
        data: &'a serde_json::Value,
    ) -> Self {
        Self {
            output_mode,
            terminal_width,
            theme,
            data,
            extras: HashMap::new(),
        }
    }

    /// Adds an extra key-value pair to the context.
    pub fn with_extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
        self.extras.insert(key.into(), value.into());
        self
    }

    /// Gets an extra value by key.
    pub fn get_extra(&self, key: &str) -> Option<&str> {
        self.extras.get(key).map(|s| s.as_str())
    }
}

/// Trait for types that can provide context objects for template rendering.
///
/// Context providers are called at render time to produce objects that will
/// be available in templates. They receive a [`RenderContext`] with information
/// about the current render environment.
///
/// # Static vs Dynamic Providers
///
/// - Static providers: Return the same object regardless of context
/// - Dynamic providers: Use context to configure the returned object
///
/// # Implementing for Closures
///
/// A blanket implementation is provided for closures, making it easy to
/// create dynamic providers:
///
/// ```rust,ignore
/// use standout_render::context::{RenderContext, ContextProvider};
///
/// // Closure-based provider
/// let provider = |ctx: &RenderContext| MyObject {
///     width: ctx.terminal_width.unwrap_or(80),
/// };
/// ```
///
/// # Single-Threaded Design
///
/// CLI applications are single-threaded, so context providers don't require
/// `Send + Sync` bounds.
pub trait ContextProvider {
    /// Produce a context object for the given render context.
    ///
    /// The returned value will be made available in templates under the
    /// name specified when registering the provider.
    fn provide(&self, ctx: &RenderContext) -> Value;
}

/// Blanket implementation for closures that return values convertible to minijinja::Value.
impl<F> ContextProvider for F
where
    F: Fn(&RenderContext) -> Value,
{
    fn provide(&self, ctx: &RenderContext) -> Value {
        (self)(ctx)
    }
}

/// A static context provider that always returns the same value.
///
/// This is used internally for `.context(name, value)` calls where
/// the value doesn't depend on render context.
#[derive(Debug, Clone)]
pub struct StaticProvider {
    value: Value,
}

impl StaticProvider {
    /// Creates a new static provider with the given value.
    pub fn new(value: Value) -> Self {
        Self { value }
    }
}

impl ContextProvider for StaticProvider {
    fn provide(&self, _ctx: &RenderContext) -> Value {
        self.value.clone()
    }
}

/// Storage for context entries, supporting both static and dynamic providers.
///
/// `ContextRegistry` is cheap to clone since it stores providers as `Rc`.
#[derive(Default, Clone)]
pub struct ContextRegistry {
    providers: HashMap<String, Rc<dyn ContextProvider>>,
}

impl ContextRegistry {
    /// Creates a new empty context registry.
    pub fn new() -> Self {
        Self::default()
    }

    /// Registers a static context value.
    ///
    /// The value will be available in templates under the given name.
    pub fn add_static(&mut self, name: impl Into<String>, value: Value) {
        self.providers
            .insert(name.into(), Rc::new(StaticProvider::new(value)));
    }

    /// Registers a dynamic context provider.
    ///
    /// The provider will be called at render time to produce a value.
    pub fn add_provider<P: ContextProvider + 'static>(
        &mut self,
        name: impl Into<String>,
        provider: P,
    ) {
        self.providers.insert(name.into(), Rc::new(provider));
    }

    /// Returns true if the registry has no entries.
    pub fn is_empty(&self) -> bool {
        self.providers.is_empty()
    }

    /// Returns the number of registered context entries.
    pub fn len(&self) -> usize {
        self.providers.len()
    }

    /// Resolves all context providers into values for the given render context.
    ///
    /// Returns a map of names to values that can be merged into the template context.
    pub fn resolve(&self, ctx: &RenderContext) -> HashMap<String, Value> {
        self.providers
            .iter()
            .map(|(name, provider)| (name.clone(), provider.provide(ctx)))
            .collect()
    }

    /// Gets the names of all registered context entries.
    pub fn names(&self) -> impl Iterator<Item = &str> {
        self.providers.keys().map(|s| s.as_str())
    }
}

impl std::fmt::Debug for ContextRegistry {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ContextRegistry")
            .field("providers", &self.providers.keys().collect::<Vec<_>>())
            .finish()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::Theme;

    fn test_context() -> (Theme, serde_json::Value) {
        (Theme::new(), serde_json::json!({"test": true}))
    }

    #[test]
    fn render_context_new() {
        let (theme, data) = test_context();
        let ctx = RenderContext::new(OutputMode::Term, Some(80), &theme, &data);

        assert_eq!(ctx.output_mode, OutputMode::Term);
        assert_eq!(ctx.terminal_width, Some(80));
        assert!(ctx.extras.is_empty());
    }

    #[test]
    fn render_context_with_extras() {
        let (theme, data) = test_context();
        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data)
            .with_extra("key1", "value1")
            .with_extra("key2", "value2");

        assert_eq!(ctx.get_extra("key1"), Some("value1"));
        assert_eq!(ctx.get_extra("key2"), Some("value2"));
        assert_eq!(ctx.get_extra("missing"), None);
    }

    #[test]
    fn static_provider() {
        let (theme, data) = test_context();
        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data);

        let provider = StaticProvider::new(Value::from(42));
        let result = provider.provide(&ctx);

        assert_eq!(result, Value::from(42));
    }

    #[test]
    fn closure_provider() {
        let (theme, data) = test_context();
        let ctx = RenderContext::new(OutputMode::Term, Some(120), &theme, &data);

        let provider =
            |ctx: &RenderContext| -> Value { Value::from(ctx.terminal_width.unwrap_or(80)) };

        let result = provider.provide(&ctx);
        assert_eq!(result, Value::from(120));
    }

    #[test]
    fn context_registry_add_static() {
        let (theme, data) = test_context();
        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data);

        let mut registry = ContextRegistry::new();
        registry.add_static("version", Value::from("1.0.0"));

        let resolved = registry.resolve(&ctx);
        assert_eq!(resolved.get("version"), Some(&Value::from("1.0.0")));
    }

    #[test]
    fn context_registry_add_provider() {
        let (theme, data) = test_context();
        let ctx = RenderContext::new(OutputMode::Term, Some(100), &theme, &data);

        let mut registry = ContextRegistry::new();
        registry.add_provider("width", |ctx: &RenderContext| {
            Value::from(ctx.terminal_width.unwrap_or(80))
        });

        let resolved = registry.resolve(&ctx);
        assert_eq!(resolved.get("width"), Some(&Value::from(100)));
    }

    #[test]
    fn context_registry_multiple_entries() {
        let (theme, data) = test_context();
        let ctx = RenderContext::new(OutputMode::Term, Some(120), &theme, &data);

        let mut registry = ContextRegistry::new();
        registry.add_static("app", Value::from("myapp"));
        registry.add_provider("terminal_width", |ctx: &RenderContext| {
            Value::from(ctx.terminal_width.unwrap_or(80))
        });

        assert_eq!(registry.len(), 2);
        assert!(!registry.is_empty());

        let resolved = registry.resolve(&ctx);
        assert_eq!(resolved.get("app"), Some(&Value::from("myapp")));
        assert_eq!(resolved.get("terminal_width"), Some(&Value::from(120)));
    }

    #[test]
    fn context_registry_names() {
        let mut registry = ContextRegistry::new();
        registry.add_static("foo", Value::from(1));
        registry.add_static("bar", Value::from(2));

        let names: Vec<&str> = registry.names().collect();
        assert!(names.contains(&"foo"));
        assert!(names.contains(&"bar"));
    }

    #[test]
    fn context_registry_empty() {
        let registry = ContextRegistry::new();
        assert!(registry.is_empty());
        assert_eq!(registry.len(), 0);
    }

    #[test]
    fn provider_uses_output_mode() {
        let (theme, data) = test_context();

        let provider =
            |ctx: &RenderContext| -> Value { Value::from(format!("{:?}", ctx.output_mode)) };

        let ctx_term = RenderContext::new(OutputMode::Term, None, &theme, &data);
        assert_eq!(provider.provide(&ctx_term), Value::from("Term"));

        let ctx_text = RenderContext::new(OutputMode::Text, None, &theme, &data);
        assert_eq!(provider.provide(&ctx_text), Value::from("Text"));
    }

    #[test]
    fn provider_uses_data() {
        let theme = Theme::new();
        let data = serde_json::json!({"count": 42});
        let ctx = RenderContext::new(OutputMode::Text, None, &theme, &data);

        let provider = |ctx: &RenderContext| -> Value {
            let count = ctx.data.get("count").and_then(|v| v.as_i64()).unwrap_or(0);
            Value::from(count * 2)
        };

        assert_eq!(provider.provide(&ctx), Value::from(84));
    }
}