Skip to main content

standout_render/template/
functions.rs

1//! Core rendering functions.
2//!
3//! # Function Hierarchy
4//!
5//! The render functions form a layered hierarchy, from simple to fully explicit:
6//!
7//! ## Basic Rendering (template → styled string)
8//!
9//! | Function | Output Mode | Color Mode | Use When |
10//! |----------|-------------|------------|----------|
11//! | [`render`] | Auto-detect | Auto-detect | Simple cases, let Standout decide |
12//! | [`render_with_output`] | Explicit | Auto-detect | Honoring `--output` CLI flag |
13//! | [`render_with_mode`] | Explicit | Explicit | Tests, or forcing light/dark mode |
14//!
15//! ## Auto-Dispatch (render or serialize based on mode)
16//!
17//! For structured modes (Json, Yaml, Csv, Xml), these skip templating and
18//! serialize data directly. For text modes, they render the template.
19//!
20//! | Function | Extra Features |
21//! |----------|----------------|
22//! | [`render_auto`] | Basic auto-dispatch |
23//! | [`render_auto_with_spec`] | CSV column specification |
24//! | [`render_auto_with_context`] | Context injection |
25//!
26//! ## With Context Injection
27//!
28//! Inject additional values (beyond handler data) into templates:
29//!
30//! | Function | Structured Output |
31//! |----------|-------------------|
32//! | [`render_with_context`] | No (template only) |
33//! | [`render_auto_with_context`] | Yes (auto-dispatch) |
34//!
35//! # Two-Pass Rendering
36//!
37//! Templates use tag-based syntax for styling: `[name]content[/name]`
38//!
39//! The rendering process works in two passes:
40//! 1. MiniJinja pass: Variable substitution and template logic
41//! 2. BBParser pass: Style tag processing (`[tag]...[/tag]`)
42//!
43//! This allows templates like:
44//! ```text
45//! [title]{{ data.title }}[/title]: [count]{{ items | length }}[/count] items
46//! ```
47//!
48//! # Feature Support Matrix
49//!
50//! Different rendering approaches support different features:
51//!
52//! | Approach | Includes | Per-call Mode | Styles | Use Case |
53//! |----------|----------|---------------|--------|----------|
54//! | [`Renderer`] | ✓ | ✓* | ✓ | Pre-compiled templates, hot reload |
55//! | [`App::render`] | ✓ | ✓ | ✓ | CLI apps with embedded templates |
56//! | [`render`] / [`render_auto`] | ✗ | ✓ | ✓ | One-off template strings |
57//!
58//! *Use [`Renderer::set_output_mode`] to change mode between renders.
59//!
60//! ## Template Includes
61//!
62//! Template includes (`{% include "partial" %}`) require a template registry.
63//! The standalone `render*` functions take a template string, not a name,
64//! so they cannot resolve includes to other templates.
65//!
66//! For includes, use either:
67//! - [`Renderer`] with [`add_template`](Renderer::add_template) or
68//!   [`with_embedded_source`](Renderer::with_embedded_source)
69//! - `standout::cli::App` with embedded templates via the builder (requires `standout` crate)
70//!
71//! [`Renderer`]: super::renderer::Renderer
72//! [`Renderer::set_output_mode`]: super::renderer::Renderer::set_output_mode
73
74use minijinja::{Environment, Error, Value};
75use serde::Serialize;
76use standout_bbparser::{BBParser, TagTransform, UnknownTagBehavior};
77use std::collections::HashMap;
78
79use super::filters::register_filters;
80use crate::context::{ContextRegistry, RenderContext};
81use crate::output::OutputMode;
82use crate::style::Styles;
83use crate::tabular::FlatDataSpec;
84use crate::theme::{detect_color_mode, ColorMode, Theme};
85
86/// Maps OutputMode to BBParser's TagTransform.
87fn output_mode_to_transform(mode: OutputMode) -> TagTransform {
88    match mode {
89        OutputMode::Auto => {
90            if mode.should_use_color() {
91                TagTransform::Apply
92            } else {
93                TagTransform::Remove
94            }
95        }
96        OutputMode::Term => TagTransform::Apply,
97        OutputMode::Text => TagTransform::Remove,
98        OutputMode::TermDebug => TagTransform::Keep,
99        // Structured modes shouldn't reach here (filtered out before)
100        OutputMode::Json | OutputMode::Yaml | OutputMode::Xml | OutputMode::Csv => {
101            TagTransform::Remove
102        }
103    }
104}
105
106/// Post-processes rendered output with BBParser to apply style tags.
107///
108/// This is the second pass of the two-pass rendering system.
109pub(crate) fn apply_style_tags(output: &str, styles: &Styles, mode: OutputMode) -> String {
110    let transform = output_mode_to_transform(mode);
111    let resolved_styles = styles.to_resolved_map();
112    let parser =
113        BBParser::new(resolved_styles, transform).unknown_behavior(UnknownTagBehavior::Passthrough);
114    parser.parse(output)
115}
116
117/// Validates a template for unknown style tags.
118///
119/// This function renders the template (performing variable substitution) and then
120/// checks for any style tags that are not defined in the theme. Use this during
121/// development or CI to catch typos in templates.
122///
123/// # Arguments
124///
125/// * `template` - A minijinja template string
126/// * `data` - Any serializable data to pass to the template
127/// * `theme` - Theme definitions that define valid style names
128///
129/// # Returns
130///
131/// Returns `Ok(())` if all style tags in the template are defined in the theme.
132/// Returns `Err` with the list of unknown tags if any are found.
133///
134/// # Example
135///
136/// ```rust
137/// use standout::{validate_template, Theme};
138/// use console::Style;
139/// use serde::Serialize;
140///
141/// #[derive(Serialize)]
142/// struct Data { name: String }
143///
144/// let theme = Theme::new().add("title", Style::new().bold());
145///
146/// // Valid template passes
147/// let result = validate_template(
148///     "[title]{{ name }}[/title]",
149///     &Data { name: "Hello".into() },
150///     &theme,
151/// );
152/// assert!(result.is_ok());
153///
154/// // Unknown tag fails validation
155/// let result = validate_template(
156///     "[unknown]{{ name }}[/unknown]",
157///     &Data { name: "Hello".into() },
158///     &theme,
159/// );
160/// assert!(result.is_err());
161/// ```
162pub fn validate_template<T: Serialize>(
163    template: &str,
164    data: &T,
165    theme: &Theme,
166) -> Result<(), Box<dyn std::error::Error>> {
167    let color_mode = detect_color_mode();
168    let styles = theme.resolve_styles(Some(color_mode));
169
170    // First render with MiniJinja to get the final output
171    let mut env = Environment::new();
172    register_filters(&mut env);
173
174    env.add_template_owned("_inline".to_string(), template.to_string())?;
175
176    let tmpl = env.get_template("_inline")?;
177    let minijinja_output = tmpl.render(data)?;
178
179    // Now validate the style tags
180    let resolved_styles = styles.to_resolved_map();
181    let parser = BBParser::new(resolved_styles, TagTransform::Remove);
182    parser.validate(&minijinja_output)?;
183
184    Ok(())
185}
186
187/// Renders a template with automatic terminal color detection.
188///
189/// This is the simplest way to render styled output. It automatically detects
190/// whether stdout supports colors and applies styles accordingly. Color mode
191/// (light/dark) is detected from OS settings.
192///
193/// # Arguments
194///
195/// * `template` - A minijinja template string
196/// * `data` - Any serializable data to pass to the template
197/// * `theme` - Theme definitions to use for the `style` filter
198///
199/// # Example
200///
201/// ```rust
202/// use standout::{render, Theme};
203/// use console::Style;
204/// use serde::Serialize;
205///
206/// #[derive(Serialize)]
207/// struct Data { message: String }
208///
209/// let theme = Theme::new().add("ok", Style::new().green());
210/// let output = render(
211///     r#"[ok]{{ message }}[/ok]"#,
212///     &Data { message: "Success!".into() },
213///     &theme,
214/// ).unwrap();
215/// ```
216pub fn render<T: Serialize>(template: &str, data: &T, theme: &Theme) -> Result<String, Error> {
217    render_with_output(template, data, theme, OutputMode::Auto)
218}
219
220/// Renders a template with explicit output mode control.
221///
222/// Use this when you need to override automatic terminal detection,
223/// for example when honoring a `--output=text` CLI flag. Color mode
224/// (light/dark) is detected from OS settings.
225///
226/// # Arguments
227///
228/// * `template` - A minijinja template string
229/// * `data` - Any serializable data to pass to the template
230/// * `theme` - Theme definitions to use for styling
231/// * `mode` - Output mode: `Auto`, `Term`, or `Text`
232///
233/// # Example
234///
235/// ```rust
236/// use standout::{render_with_output, Theme, OutputMode};
237/// use console::Style;
238/// use serde::Serialize;
239///
240/// #[derive(Serialize)]
241/// struct Data { status: String }
242///
243/// let theme = Theme::new().add("ok", Style::new().green());
244///
245/// // Force plain text output
246/// let plain = render_with_output(
247///     r#"[ok]{{ status }}[/ok]"#,
248///     &Data { status: "done".into() },
249///     &theme,
250///     OutputMode::Text,
251/// ).unwrap();
252/// assert_eq!(plain, "done"); // No ANSI codes
253///
254/// // Force terminal output (with ANSI codes)
255/// let term = render_with_output(
256///     r#"[ok]{{ status }}[/ok]"#,
257///     &Data { status: "done".into() },
258///     &theme,
259///     OutputMode::Term,
260/// ).unwrap();
261/// // Contains ANSI codes for green
262/// ```
263pub fn render_with_output<T: Serialize>(
264    template: &str,
265    data: &T,
266    theme: &Theme,
267    mode: OutputMode,
268) -> Result<String, Error> {
269    // Detect color mode and render with explicit mode
270    let color_mode = detect_color_mode();
271    render_with_mode(template, data, theme, mode, color_mode)
272}
273
274/// Renders a template with explicit output mode and color mode control.
275///
276/// Use this when you need to force a specific color mode (light/dark),
277/// for example in tests or when honoring user preferences.
278///
279/// # Arguments
280///
281/// * `template` - A minijinja template string
282/// * `data` - Any serializable data to pass to the template
283/// * `theme` - Theme definitions to use for the `style` filter
284/// * `output_mode` - Output mode: `Auto`, `Term`, `Text`, etc.
285/// * `color_mode` - Color mode: `Light` or `Dark`
286///
287/// # Example
288///
289/// ```rust
290/// use standout::{render_with_mode, Theme, OutputMode, ColorMode};
291/// use console::Style;
292/// use serde::Serialize;
293///
294/// #[derive(Serialize)]
295/// struct Data { status: String }
296///
297/// let theme = Theme::new()
298///     .add_adaptive(
299///         "panel",
300///         Style::new(),
301///         Some(Style::new().black()),  // Light mode
302///         Some(Style::new().white()),  // Dark mode
303///     );
304///
305/// // Force dark mode rendering
306/// let dark = render_with_mode(
307///     r#"[panel]{{ status }}[/panel]"#,
308///     &Data { status: "test".into() },
309///     &theme,
310///     OutputMode::Term,
311///     ColorMode::Dark,
312/// ).unwrap();
313///
314/// // Force light mode rendering
315/// let light = render_with_mode(
316///     r#"[panel]{{ status }}[/panel]"#,
317///     &Data { status: "test".into() },
318///     &theme,
319///     OutputMode::Term,
320///     ColorMode::Light,
321/// ).unwrap();
322/// ```
323pub fn render_with_mode<T: Serialize>(
324    template: &str,
325    data: &T,
326    theme: &Theme,
327    output_mode: OutputMode,
328    color_mode: ColorMode,
329) -> Result<String, Error> {
330    // Validate style aliases before rendering
331    theme
332        .validate()
333        .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
334
335    // Resolve styles for the specified color mode
336    let styles = theme.resolve_styles(Some(color_mode));
337
338    let mut env = Environment::new();
339    register_filters(&mut env);
340
341    env.add_template_owned("_inline".to_string(), template.to_string())?;
342    let tmpl = env.get_template("_inline")?;
343
344    // Pass 1: MiniJinja template rendering
345    let minijinja_output = tmpl.render(data)?;
346
347    // Pass 2: BBParser style tag processing
348    let final_output = apply_style_tags(&minijinja_output, &styles, output_mode);
349
350    Ok(final_output)
351}
352
353/// Renders a template with additional variables injected into the context.
354///
355/// This is a convenience function for adding simple key-value pairs to the template
356/// context without the complexity of the full [`ContextRegistry`] system. The data
357/// fields take precedence over the injected variables.
358///
359/// # Arguments
360///
361/// * `template` - A minijinja template string
362/// * `data` - The primary serializable data to render
363/// * `theme` - Theme definitions for style tag processing
364/// * `vars` - Additional variables to inject into the template context
365///
366/// # Example
367///
368/// ```rust
369/// use standout::{render_with_vars, Theme, OutputMode};
370/// use serde::Serialize;
371/// use std::collections::HashMap;
372///
373/// #[derive(Serialize)]
374/// struct User { name: String }
375///
376/// let theme = Theme::new();
377/// let user = User { name: "Alice".into() };
378///
379/// let mut vars = HashMap::new();
380/// vars.insert("version", "1.0.0");
381/// vars.insert("app_name", "MyApp");
382///
383/// let output = render_with_vars(
384///     "{{ name }} - {{ app_name }} v{{ version }}",
385///     &user,
386///     &theme,
387///     OutputMode::Text,
388///     vars,
389/// ).unwrap();
390///
391/// assert_eq!(output, "Alice - MyApp v1.0.0");
392/// ```
393pub fn render_with_vars<T, K, V, I>(
394    template: &str,
395    data: &T,
396    theme: &Theme,
397    mode: OutputMode,
398    vars: I,
399) -> Result<String, Error>
400where
401    T: Serialize,
402    K: AsRef<str>,
403    V: Into<Value>,
404    I: IntoIterator<Item = (K, V)>,
405{
406    let color_mode = detect_color_mode();
407    let styles = theme.resolve_styles(Some(color_mode));
408
409    // Validate style aliases before rendering
410    styles
411        .validate()
412        .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
413
414    let mut env = Environment::new();
415    register_filters(&mut env);
416
417    env.add_template_owned("_inline".to_string(), template.to_string())?;
418    let tmpl = env.get_template("_inline")?;
419
420    // Convert data to JSON value for merging
421    let data_json = serde_json::to_value(data)
422        .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
423
424    // Build combined context: vars first, then data (data wins on conflict)
425    let mut context: HashMap<String, Value> = HashMap::new();
426
427    // Add injected vars
428    for (key, value) in vars {
429        context.insert(key.as_ref().to_string(), value.into());
430    }
431
432    // Add data fields (overwriting any conflicting vars)
433    if let serde_json::Value::Object(map) = data_json {
434        for (key, value) in map {
435            context.insert(key, Value::from_serialize(&value));
436        }
437    }
438
439    // Pass 1: MiniJinja template rendering
440    let minijinja_output = tmpl.render(&context)?;
441
442    // Pass 2: BBParser style tag processing
443    let final_output = apply_style_tags(&minijinja_output, &styles, mode);
444
445    Ok(final_output)
446}
447
448/// Auto-dispatches between template rendering and direct serialization.
449///
450/// This is the recommended function when you want to support both human-readable
451/// output (terminal, text) and machine-readable output (JSON, YAML, etc.). For
452/// structured modes like `Json`, the data is serialized directly, skipping
453/// template rendering entirely.
454///
455/// # Arguments
456///
457/// * `template` - A minijinja template string (ignored for structured modes)
458/// * `data` - Any serializable data to render or serialize
459/// * `theme` - Theme definitions for the `style` filter (ignored for structured modes)
460/// * `mode` - Output mode determining the output format
461///
462/// # Example
463///
464/// ```rust
465/// use standout::{render_auto, Theme, OutputMode};
466/// use console::Style;
467/// use serde::Serialize;
468///
469/// #[derive(Serialize)]
470/// struct Report { title: String, count: usize }
471///
472/// let theme = Theme::new().add("title", Style::new().bold());
473/// let data = Report { title: "Summary".into(), count: 42 };
474///
475/// // Terminal output uses the template
476/// let term = render_auto(
477///     r#"[title]{{ title }}[/title]: {{ count }}"#,
478///     &data,
479///     &theme,
480///     OutputMode::Text,
481/// ).unwrap();
482/// assert_eq!(term, "Summary: 42");
483///
484/// // JSON output serializes directly
485/// let json = render_auto(
486///     r#"[title]{{ title }}[/title]: {{ count }}"#,
487///     &data,
488///     &theme,
489///     OutputMode::Json,
490/// ).unwrap();
491/// assert!(json.contains("\"title\": \"Summary\""));
492/// assert!(json.contains("\"count\": 42"));
493/// ```
494pub fn render_auto<T: Serialize>(
495    template: &str,
496    data: &T,
497    theme: &Theme,
498    mode: OutputMode,
499) -> Result<String, Error> {
500    if mode.is_structured() {
501        match mode {
502            OutputMode::Json => serde_json::to_string_pretty(data)
503                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
504            OutputMode::Yaml => serde_yaml::to_string(data)
505                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
506            OutputMode::Xml => quick_xml::se::to_string(data)
507                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
508            OutputMode::Csv => {
509                let value = serde_json::to_value(data).map_err(|e| {
510                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
511                })?;
512                let (headers, rows) = crate::util::flatten_json_for_csv(&value);
513
514                let mut wtr = csv::Writer::from_writer(Vec::new());
515                wtr.write_record(&headers).map_err(|e| {
516                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
517                })?;
518                for row in rows {
519                    wtr.write_record(&row).map_err(|e| {
520                        Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
521                    })?;
522                }
523                let bytes = wtr.into_inner().map_err(|e| {
524                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
525                })?;
526                String::from_utf8(bytes)
527                    .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))
528            }
529            _ => unreachable!("is_structured() returned true for non-structured mode"),
530        }
531    } else {
532        render_with_output(template, data, theme, mode)
533    }
534}
535
536/// Auto-dispatches with granular control over structured output.
537///
538/// Similar to `render_auto`, but allows passing an optional `FlatDataSpec`.
539/// This is particularly useful for controlling CSV output structure (columns, headers)
540/// instead of relying on automatic JSON flattening.
541///
542/// # Arguments
543///
544/// * `template` - A minijinja template string
545/// * `data` - Any serializable data to render or serialize
546/// * `theme` - Theme definitions for the `style` filter
547/// * `mode` - Output mode determining the output format
548/// * `spec` - Optional `FlatDataSpec` for defining CSV/Table structure
549pub fn render_auto_with_spec<T: Serialize>(
550    template: &str,
551    data: &T,
552    theme: &Theme,
553    mode: OutputMode,
554    spec: Option<&FlatDataSpec>,
555) -> Result<String, Error> {
556    if mode.is_structured() {
557        match mode {
558            OutputMode::Json => serde_json::to_string_pretty(data)
559                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
560            OutputMode::Yaml => serde_yaml::to_string(data)
561                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
562            OutputMode::Xml => quick_xml::se::to_string(data)
563                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
564            OutputMode::Csv => {
565                let value = serde_json::to_value(data).map_err(|e| {
566                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
567                })?;
568
569                let (headers, rows) = if let Some(s) = spec {
570                    // Use the spec for explicit extraction
571                    let headers = s.extract_header();
572                    let rows: Vec<Vec<String>> = match value {
573                        serde_json::Value::Array(items) => {
574                            items.iter().map(|item| s.extract_row(item)).collect()
575                        }
576                        _ => vec![s.extract_row(&value)],
577                    };
578                    (headers, rows)
579                } else {
580                    // Use automatic flattening
581                    crate::util::flatten_json_for_csv(&value)
582                };
583
584                let mut wtr = csv::Writer::from_writer(Vec::new());
585                wtr.write_record(&headers).map_err(|e| {
586                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
587                })?;
588                for row in rows {
589                    wtr.write_record(&row).map_err(|e| {
590                        Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
591                    })?;
592                }
593                let bytes = wtr.into_inner().map_err(|e| {
594                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
595                })?;
596                String::from_utf8(bytes)
597                    .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))
598            }
599            _ => unreachable!("is_structured() returned true for non-structured mode"),
600        }
601    } else {
602        render_with_output(template, data, theme, mode)
603    }
604}
605
606/// Renders a template with additional context objects injected.
607///
608/// This is the most flexible rendering function, allowing you to inject
609/// additional objects into the template context beyond the serialized data.
610/// Use this when templates need access to utilities, formatters, or runtime
611/// values that cannot be represented as JSON.
612///
613/// # Arguments
614///
615/// * `template` - A minijinja template string
616/// * `data` - Any serializable data to pass to the template
617/// * `theme` - Theme definitions for the `style` filter
618/// * `mode` - Output mode: `Auto`, `Term`, `Text`, etc.
619/// * `context_registry` - Additional context objects to inject
620/// * `render_context` - Information about the render environment
621///
622/// # Context Resolution
623///
624/// Context objects are resolved from the registry using the provided
625/// `RenderContext`. Each registered provider is called to produce a value,
626/// which is then merged into the template context.
627///
628/// If a context key conflicts with a data field, the data field wins.
629/// Context is supplementary to the handler's data, not a replacement.
630///
631/// # Example
632///
633/// ```rust
634/// use standout::{render_with_context, Theme, OutputMode};
635/// use standout::context::{RenderContext, ContextRegistry};
636/// use minijinja::Value;
637/// use serde::Serialize;
638///
639/// #[derive(Serialize)]
640/// struct Data { name: String }
641///
642/// let theme = Theme::new();
643/// let data = Data { name: "Alice".into() };
644///
645/// // Create context with a static value
646/// let mut registry = ContextRegistry::new();
647/// registry.add_static("version", Value::from("1.0.0"));
648///
649/// // Create render context
650/// let json_data = serde_json::to_value(&data).unwrap();
651/// let render_ctx = RenderContext::new(
652///     OutputMode::Text,
653///     Some(80),
654///     &theme,
655///     &json_data,
656/// );
657///
658/// let output = render_with_context(
659///     "{{ name }} (v{{ version }})",
660///     &data,
661///     &theme,
662///     OutputMode::Text,
663///     &registry,
664///     &render_ctx,
665///     None,
666/// ).unwrap();
667///
668/// assert_eq!(output, "Alice (v1.0.0)");
669/// ```
670pub fn render_with_context<T: Serialize>(
671    template: &str,
672    data: &T,
673    theme: &Theme,
674    mode: OutputMode,
675    context_registry: &ContextRegistry,
676    render_context: &RenderContext,
677    template_registry: Option<&super::TemplateRegistry>,
678) -> Result<String, Error> {
679    let color_mode = detect_color_mode();
680    let styles = theme.resolve_styles(Some(color_mode));
681
682    // Validate style aliases before rendering
683    styles
684        .validate()
685        .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
686
687    let mut env = Environment::new();
688    register_filters(&mut env);
689
690    // Check if template is a registry key (name) or inline content.
691    // If the registry contains a template with this name, use its content.
692    // Otherwise, treat the template string as inline content.
693    let template_content = if let Some(registry) = template_registry {
694        if let Ok(content) = registry.get_content(template) {
695            content
696        } else {
697            template.to_string()
698        }
699    } else {
700        template.to_string()
701    };
702
703    env.add_template_owned("_inline".to_string(), template_content)?;
704
705    // Load all templates from registry if available (enables {% include %})
706    if let Some(registry) = template_registry {
707        for name in registry.names() {
708            if let Ok(content) = registry.get_content(name) {
709                // Ensure we don't overwrite the main template if named same as a registry template
710                if name != "_inline" {
711                    env.add_template_owned(name.to_string(), content)?;
712                }
713            }
714        }
715    }
716
717    let tmpl = env.get_template("_inline")?;
718
719    // Build the combined context: data + injected context
720    // Data fields take precedence over context fields
721    let combined = build_combined_context(data, context_registry, render_context)?;
722
723    // Pass 1: MiniJinja template rendering
724    let minijinja_output = tmpl.render(&combined)?;
725
726    // Pass 2: BBParser style tag processing
727    let final_output = apply_style_tags(&minijinja_output, &styles, mode);
728
729    Ok(final_output)
730}
731
732/// Auto-dispatches with context injection support.
733///
734/// This combines `render_with_context` with JSON serialization support.
735/// For structured modes like `Json`, the data is serialized directly,
736/// skipping template rendering (and context injection).
737///
738/// # Arguments
739///
740/// * `template` - A minijinja template string (ignored for structured modes)
741/// * `data` - Any serializable data to render or serialize
742/// * `theme` - Theme definitions for the `style` filter
743/// * `mode` - Output mode determining the output format
744/// * `context_registry` - Additional context objects to inject
745/// * `render_context` - Information about the render environment
746///
747/// # Example
748///
749/// ```rust
750/// use standout::{render_auto_with_context, Theme, OutputMode};
751/// use standout::context::{RenderContext, ContextRegistry};
752/// use minijinja::Value;
753/// use serde::Serialize;
754///
755/// #[derive(Serialize)]
756/// struct Report { title: String, count: usize }
757///
758/// let theme = Theme::new();
759/// let data = Report { title: "Summary".into(), count: 42 };
760///
761/// let mut registry = ContextRegistry::new();
762/// registry.add_provider("terminal_width", |ctx: &RenderContext| {
763///     Value::from(ctx.terminal_width.unwrap_or(80))
764/// });
765///
766/// let json_data = serde_json::to_value(&data).unwrap();
767/// let render_ctx = RenderContext::new(
768///     OutputMode::Text,
769///     Some(120),
770///     &theme,
771///     &json_data,
772/// );
773///
774/// // Text mode uses the template with context
775/// let text = render_auto_with_context(
776///     "{{ title }} (width={{ terminal_width }}): {{ count }}",
777///     &data,
778///     &theme,
779///     OutputMode::Text,
780///     &registry,
781///     &render_ctx,
782///     None,
783/// ).unwrap();
784/// assert_eq!(text, "Summary (width=120): 42");
785///
786/// // JSON mode ignores template and context, serializes data directly
787/// let json = render_auto_with_context(
788///     "unused",
789///     &data,
790///     &theme,
791///     OutputMode::Json,
792///     &registry,
793///     &render_ctx,
794///     None,
795/// ).unwrap();
796/// assert!(json.contains("\"title\": \"Summary\""));
797/// ```
798pub fn render_auto_with_context<T: Serialize>(
799    template: &str,
800    data: &T,
801    theme: &Theme,
802    mode: OutputMode,
803    context_registry: &ContextRegistry,
804    render_context: &RenderContext,
805    template_registry: Option<&super::TemplateRegistry>,
806) -> Result<String, Error> {
807    if mode.is_structured() {
808        match mode {
809            OutputMode::Json => serde_json::to_string_pretty(data)
810                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
811            OutputMode::Yaml => serde_yaml::to_string(data)
812                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
813            OutputMode::Xml => quick_xml::se::to_string(data)
814                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
815            OutputMode::Csv => {
816                let value = serde_json::to_value(data).map_err(|e| {
817                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
818                })?;
819                let (headers, rows) = crate::util::flatten_json_for_csv(&value);
820
821                let mut wtr = csv::Writer::from_writer(Vec::new());
822                wtr.write_record(&headers).map_err(|e| {
823                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
824                })?;
825                for row in rows {
826                    wtr.write_record(&row).map_err(|e| {
827                        Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
828                    })?;
829                }
830                let bytes = wtr.into_inner().map_err(|e| {
831                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
832                })?;
833                String::from_utf8(bytes)
834                    .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))
835            }
836            _ => unreachable!("is_structured() returned true for non-structured mode"),
837        }
838    } else {
839        render_with_context(
840            template,
841            data,
842            theme,
843            mode,
844            context_registry,
845            render_context,
846            template_registry,
847        )
848    }
849}
850
851/// Builds a combined context from data and injected context.
852///
853/// Data fields take precedence over context fields.
854fn build_combined_context<T: Serialize>(
855    data: &T,
856    context_registry: &ContextRegistry,
857    render_context: &RenderContext,
858) -> Result<HashMap<String, Value>, Error> {
859    // First, resolve all context providers
860    let context_values = context_registry.resolve(render_context);
861
862    // Convert data to a map of values
863    let data_value = serde_json::to_value(data)
864        .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
865
866    let mut combined: HashMap<String, Value> = HashMap::new();
867
868    // Add context values first (lower priority)
869    for (key, value) in context_values {
870        combined.insert(key, value);
871    }
872
873    // Add data values (higher priority - overwrites context)
874    if let Some(obj) = data_value.as_object() {
875        for (key, value) in obj {
876            let minijinja_value = json_to_minijinja(value);
877            combined.insert(key.clone(), minijinja_value);
878        }
879    }
880
881    Ok(combined)
882}
883
884/// Converts a serde_json::Value to a minijinja::Value.
885fn json_to_minijinja(json: &serde_json::Value) -> Value {
886    match json {
887        serde_json::Value::Null => Value::from(()),
888        serde_json::Value::Bool(b) => Value::from(*b),
889        serde_json::Value::Number(n) => {
890            if let Some(i) = n.as_i64() {
891                Value::from(i)
892            } else if let Some(f) = n.as_f64() {
893                Value::from(f)
894            } else {
895                Value::from(n.to_string())
896            }
897        }
898        serde_json::Value::String(s) => Value::from(s.clone()),
899        serde_json::Value::Array(arr) => {
900            let items: Vec<Value> = arr.iter().map(json_to_minijinja).collect();
901            Value::from(items)
902        }
903        serde_json::Value::Object(obj) => {
904            let map: HashMap<String, Value> = obj
905                .iter()
906                .map(|(k, v)| (k.clone(), json_to_minijinja(v)))
907                .collect();
908            Value::from_iter(map)
909        }
910    }
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916    use crate::tabular::{Column, FlatDataSpec, Width};
917    use crate::Theme;
918    use console::Style;
919    use serde::Serialize;
920    use serde_json::json;
921
922    #[derive(Serialize)]
923    struct SimpleData {
924        message: String,
925    }
926
927    #[derive(Serialize)]
928    struct ListData {
929        items: Vec<String>,
930        count: usize,
931    }
932
933    #[test]
934    fn test_render_with_output_text_no_ansi() {
935        let theme = Theme::new().add("red", Style::new().red());
936        let data = SimpleData {
937            message: "test".into(),
938        };
939
940        let output = render_with_output(
941            r#"[red]{{ message }}[/red]"#,
942            &data,
943            &theme,
944            OutputMode::Text,
945        )
946        .unwrap();
947
948        assert_eq!(output, "test");
949        assert!(!output.contains("\x1b["));
950    }
951
952    #[test]
953    fn test_render_with_output_term_has_ansi() {
954        let theme = Theme::new().add("green", Style::new().green().force_styling(true));
955        let data = SimpleData {
956            message: "success".into(),
957        };
958
959        let output = render_with_output(
960            r#"[green]{{ message }}[/green]"#,
961            &data,
962            &theme,
963            OutputMode::Term,
964        )
965        .unwrap();
966
967        assert!(output.contains("success"));
968        assert!(output.contains("\x1b["));
969    }
970
971    #[test]
972    fn test_render_unknown_style_shows_indicator() {
973        let theme = Theme::new();
974        let data = SimpleData {
975            message: "hello".into(),
976        };
977
978        let output = render_with_output(
979            r#"[unknown]{{ message }}[/unknown]"#,
980            &data,
981            &theme,
982            OutputMode::Term,
983        )
984        .unwrap();
985
986        // Unknown tags in passthrough mode get ? marker on both open and close tags
987        assert_eq!(output, "[unknown?]hello[/unknown?]");
988    }
989
990    #[test]
991    fn test_render_unknown_style_stripped_in_text_mode() {
992        let theme = Theme::new();
993        let data = SimpleData {
994            message: "hello".into(),
995        };
996
997        let output = render_with_output(
998            r#"[unknown]{{ message }}[/unknown]"#,
999            &data,
1000            &theme,
1001            OutputMode::Text,
1002        )
1003        .unwrap();
1004
1005        // In text mode (Remove), unknown tags are stripped like known tags
1006        assert_eq!(output, "hello");
1007    }
1008
1009    #[test]
1010    fn test_render_template_with_loop() {
1011        let theme = Theme::new().add("item", Style::new().cyan());
1012        let data = ListData {
1013            items: vec!["one".into(), "two".into()],
1014            count: 2,
1015        };
1016
1017        let template = r#"{% for item in items %}[item]{{ item }}[/item]
1018{% endfor %}"#;
1019
1020        let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1021        assert_eq!(output, "one\ntwo\n");
1022    }
1023
1024    #[test]
1025    fn test_render_mixed_styled_and_plain() {
1026        let theme = Theme::new().add("count", Style::new().bold());
1027        let data = ListData {
1028            items: vec![],
1029            count: 42,
1030        };
1031
1032        let template = r#"Total: [count]{{ count }}[/count] items"#;
1033        let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1034
1035        assert_eq!(output, "Total: 42 items");
1036    }
1037
1038    #[test]
1039    fn test_render_literal_string_styled() {
1040        let theme = Theme::new().add("header", Style::new().bold());
1041
1042        #[derive(Serialize)]
1043        struct Empty {}
1044
1045        let output = render_with_output(
1046            r#"[header]Header[/header]"#,
1047            &Empty {},
1048            &theme,
1049            OutputMode::Text,
1050        )
1051        .unwrap();
1052
1053        assert_eq!(output, "Header");
1054    }
1055
1056    #[test]
1057    fn test_empty_template() {
1058        let theme = Theme::new();
1059
1060        #[derive(Serialize)]
1061        struct Empty {}
1062
1063        let output = render_with_output("", &Empty {}, &theme, OutputMode::Text).unwrap();
1064        assert_eq!(output, "");
1065    }
1066
1067    #[test]
1068    fn test_template_syntax_error() {
1069        let theme = Theme::new();
1070
1071        #[derive(Serialize)]
1072        struct Empty {}
1073
1074        let result = render_with_output("{{ unclosed", &Empty {}, &theme, OutputMode::Text);
1075        assert!(result.is_err());
1076    }
1077
1078    #[test]
1079    fn test_style_tag_with_nested_data() {
1080        #[derive(Serialize)]
1081        struct Item {
1082            name: String,
1083            value: i32,
1084        }
1085
1086        #[derive(Serialize)]
1087        struct Container {
1088            items: Vec<Item>,
1089        }
1090
1091        let theme = Theme::new().add("name", Style::new().bold());
1092        let data = Container {
1093            items: vec![
1094                Item {
1095                    name: "foo".into(),
1096                    value: 1,
1097                },
1098                Item {
1099                    name: "bar".into(),
1100                    value: 2,
1101                },
1102            ],
1103        };
1104
1105        let template = r#"{% for item in items %}[name]{{ item.name }}[/name]={{ item.value }}
1106{% endfor %}"#;
1107
1108        let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1109        assert_eq!(output, "foo=1\nbar=2\n");
1110    }
1111
1112    #[test]
1113    fn test_render_with_output_term_debug() {
1114        let theme = Theme::new()
1115            .add("title", Style::new().bold())
1116            .add("count", Style::new().cyan());
1117
1118        #[derive(Serialize)]
1119        struct Data {
1120            name: String,
1121            value: usize,
1122        }
1123
1124        let data = Data {
1125            name: "Test".into(),
1126            value: 42,
1127        };
1128
1129        let output = render_with_output(
1130            r#"[title]{{ name }}[/title]: [count]{{ value }}[/count]"#,
1131            &data,
1132            &theme,
1133            OutputMode::TermDebug,
1134        )
1135        .unwrap();
1136
1137        assert_eq!(output, "[title]Test[/title]: [count]42[/count]");
1138    }
1139
1140    #[test]
1141    fn test_render_with_output_term_debug_preserves_tags() {
1142        let theme = Theme::new().add("known", Style::new().bold());
1143
1144        #[derive(Serialize)]
1145        struct Data {
1146            message: String,
1147        }
1148
1149        let data = Data {
1150            message: "hello".into(),
1151        };
1152
1153        // In TermDebug (Keep mode), unknown tags are preserved as-is
1154        let output = render_with_output(
1155            r#"[unknown]{{ message }}[/unknown]"#,
1156            &data,
1157            &theme,
1158            OutputMode::TermDebug,
1159        )
1160        .unwrap();
1161
1162        assert_eq!(output, "[unknown]hello[/unknown]");
1163
1164        // Known tags are also preserved as-is in debug mode
1165        let output = render_with_output(
1166            r#"[known]{{ message }}[/known]"#,
1167            &data,
1168            &theme,
1169            OutputMode::TermDebug,
1170        )
1171        .unwrap();
1172
1173        assert_eq!(output, "[known]hello[/known]");
1174    }
1175
1176    #[test]
1177    fn test_render_auto_json_mode() {
1178        use serde_json::json;
1179
1180        let theme = Theme::new();
1181        let data = json!({"name": "test", "count": 42});
1182
1183        let output = render_auto("unused template", &data, &theme, OutputMode::Json).unwrap();
1184
1185        assert!(output.contains("\"name\": \"test\""));
1186        assert!(output.contains("\"count\": 42"));
1187    }
1188
1189    #[test]
1190    fn test_render_auto_text_mode_uses_template() {
1191        use serde_json::json;
1192
1193        let theme = Theme::new();
1194        let data = json!({"name": "test"});
1195
1196        let output = render_auto("Name: {{ name }}", &data, &theme, OutputMode::Text).unwrap();
1197
1198        assert_eq!(output, "Name: test");
1199    }
1200
1201    #[test]
1202    fn test_render_auto_term_mode_uses_template() {
1203        use serde_json::json;
1204
1205        let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1206        let data = json!({"name": "test"});
1207
1208        let output = render_auto(
1209            r#"[bold]{{ name }}[/bold]"#,
1210            &data,
1211            &theme,
1212            OutputMode::Term,
1213        )
1214        .unwrap();
1215
1216        assert!(output.contains("\x1b[1m"));
1217        assert!(output.contains("test"));
1218    }
1219
1220    #[test]
1221    fn test_render_auto_json_with_struct() {
1222        #[derive(Serialize)]
1223        struct Report {
1224            title: String,
1225            items: Vec<String>,
1226        }
1227
1228        let theme = Theme::new();
1229        let data = Report {
1230            title: "Summary".into(),
1231            items: vec!["one".into(), "two".into()],
1232        };
1233
1234        let output = render_auto("unused", &data, &theme, OutputMode::Json).unwrap();
1235
1236        assert!(output.contains("\"title\": \"Summary\""));
1237        assert!(output.contains("\"items\""));
1238        assert!(output.contains("\"one\""));
1239    }
1240
1241    #[test]
1242    fn test_render_with_alias() {
1243        let theme = Theme::new()
1244            .add("base", Style::new().bold())
1245            .add("alias", "base");
1246
1247        let output = render_with_output(
1248            r#"[alias]text[/alias]"#,
1249            &serde_json::json!({}),
1250            &theme,
1251            OutputMode::Text,
1252        )
1253        .unwrap();
1254
1255        assert_eq!(output, "text");
1256    }
1257
1258    #[test]
1259    fn test_render_with_alias_chain() {
1260        let theme = Theme::new()
1261            .add("muted", Style::new().dim())
1262            .add("disabled", "muted")
1263            .add("timestamp", "disabled");
1264
1265        let output = render_with_output(
1266            r#"[timestamp]12:00[/timestamp]"#,
1267            &serde_json::json!({}),
1268            &theme,
1269            OutputMode::Text,
1270        )
1271        .unwrap();
1272
1273        assert_eq!(output, "12:00");
1274    }
1275
1276    #[test]
1277    fn test_render_fails_with_dangling_alias() {
1278        let theme = Theme::new().add("orphan", "missing");
1279
1280        let result = render_with_output(
1281            r#"[orphan]text[/orphan]"#,
1282            &serde_json::json!({}),
1283            &theme,
1284            OutputMode::Text,
1285        );
1286
1287        assert!(result.is_err());
1288        let err = result.unwrap_err();
1289        assert!(err.to_string().contains("orphan"));
1290        assert!(err.to_string().contains("missing"));
1291    }
1292
1293    #[test]
1294    fn test_render_fails_with_cycle() {
1295        let theme = Theme::new().add("a", "b").add("b", "a");
1296
1297        let result = render_with_output(
1298            r#"[a]text[/a]"#,
1299            &serde_json::json!({}),
1300            &theme,
1301            OutputMode::Text,
1302        );
1303
1304        assert!(result.is_err());
1305        assert!(result.unwrap_err().to_string().contains("cycle"));
1306    }
1307
1308    #[test]
1309    fn test_three_layer_styling_pattern() {
1310        let theme = Theme::new()
1311            .add("dim_style", Style::new().dim())
1312            .add("cyan_bold", Style::new().cyan().bold())
1313            .add("yellow_bg", Style::new().on_yellow())
1314            .add("muted", "dim_style")
1315            .add("accent", "cyan_bold")
1316            .add("highlighted", "yellow_bg")
1317            .add("timestamp", "muted")
1318            .add("title", "accent")
1319            .add("selected_item", "highlighted");
1320
1321        assert!(theme.validate().is_ok());
1322
1323        let output = render_with_output(
1324            r#"[timestamp]{{ time }}[/timestamp] - [title]{{ name }}[/title]"#,
1325            &serde_json::json!({"time": "12:00", "name": "Report"}),
1326            &theme,
1327            OutputMode::Text,
1328        )
1329        .unwrap();
1330
1331        assert_eq!(output, "12:00 - Report");
1332    }
1333
1334    // ============================================================================
1335    // YAML/XML/CSV Output Tests
1336    // ============================================================================
1337
1338    #[test]
1339    fn test_render_auto_yaml_mode() {
1340        use serde_json::json;
1341
1342        let theme = Theme::new();
1343        let data = json!({"name": "test", "count": 42});
1344
1345        let output = render_auto("unused template", &data, &theme, OutputMode::Yaml).unwrap();
1346
1347        assert!(output.contains("name: test"));
1348        assert!(output.contains("count: 42"));
1349    }
1350
1351    #[test]
1352    fn test_render_auto_xml_mode() {
1353        let theme = Theme::new();
1354
1355        #[derive(Serialize)]
1356        #[serde(rename = "root")]
1357        struct Data {
1358            name: String,
1359            count: usize,
1360        }
1361
1362        let data = Data {
1363            name: "test".into(),
1364            count: 42,
1365        };
1366
1367        let output = render_auto("unused template", &data, &theme, OutputMode::Xml).unwrap();
1368
1369        assert!(output.contains("<root>"));
1370        assert!(output.contains("<name>test</name>"));
1371    }
1372
1373    #[test]
1374    fn test_render_auto_csv_mode_auto_flatten() {
1375        use serde_json::json;
1376
1377        let theme = Theme::new();
1378        let data = json!([
1379            {"name": "Alice", "stats": {"score": 10}},
1380            {"name": "Bob", "stats": {"score": 20}}
1381        ]);
1382
1383        let output = render_auto("unused", &data, &theme, OutputMode::Csv).unwrap();
1384
1385        assert!(output.contains("name,stats.score"));
1386        assert!(output.contains("Alice,10"));
1387        assert!(output.contains("Bob,20"));
1388    }
1389
1390    #[test]
1391    fn test_render_auto_csv_mode_with_spec() {
1392        let theme = Theme::new();
1393        let data = json!([
1394            {"name": "Alice", "meta": {"age": 30, "role": "admin"}},
1395            {"name": "Bob", "meta": {"age": 25, "role": "user"}}
1396        ]);
1397
1398        let spec = FlatDataSpec::builder()
1399            .column(Column::new(Width::Fixed(10)).key("name"))
1400            .column(
1401                Column::new(Width::Fixed(10))
1402                    .key("meta.role")
1403                    .header("Role"),
1404            )
1405            .build();
1406
1407        let output =
1408            render_auto_with_spec("unused", &data, &theme, OutputMode::Csv, Some(&spec)).unwrap();
1409
1410        let lines: Vec<&str> = output.lines().collect();
1411        assert_eq!(lines[0], "name,Role");
1412        assert!(lines.contains(&"Alice,admin"));
1413        assert!(lines.contains(&"Bob,user"));
1414        assert!(!output.contains("30"));
1415    }
1416
1417    // ============================================================================
1418    // Context Injection Tests
1419    // ============================================================================
1420
1421    #[test]
1422    fn test_render_with_context_basic() {
1423        use crate::context::{ContextRegistry, RenderContext};
1424
1425        #[derive(Serialize)]
1426        struct Data {
1427            name: String,
1428        }
1429
1430        let theme = Theme::new();
1431        let data = Data {
1432            name: "Alice".into(),
1433        };
1434        let json_data = serde_json::to_value(&data).unwrap();
1435
1436        let mut registry = ContextRegistry::new();
1437        registry.add_static("version", Value::from("1.0.0"));
1438
1439        let render_ctx = RenderContext::new(OutputMode::Text, Some(80), &theme, &json_data);
1440
1441        let output = render_with_context(
1442            "{{ name }} (v{{ version }})",
1443            &data,
1444            &theme,
1445            OutputMode::Text,
1446            &registry,
1447            &render_ctx,
1448            None,
1449        )
1450        .unwrap();
1451
1452        assert_eq!(output, "Alice (v1.0.0)");
1453    }
1454
1455    #[test]
1456    fn test_render_with_context_dynamic_provider() {
1457        use crate::context::{ContextRegistry, RenderContext};
1458
1459        #[derive(Serialize)]
1460        struct Data {
1461            message: String,
1462        }
1463
1464        let theme = Theme::new();
1465        let data = Data {
1466            message: "Hello".into(),
1467        };
1468        let json_data = serde_json::to_value(&data).unwrap();
1469
1470        let mut registry = ContextRegistry::new();
1471        registry.add_provider("terminal_width", |ctx: &RenderContext| {
1472            Value::from(ctx.terminal_width.unwrap_or(80))
1473        });
1474
1475        let render_ctx = RenderContext::new(OutputMode::Text, Some(120), &theme, &json_data);
1476
1477        let output = render_with_context(
1478            "{{ message }} (width={{ terminal_width }})",
1479            &data,
1480            &theme,
1481            OutputMode::Text,
1482            &registry,
1483            &render_ctx,
1484            None,
1485        )
1486        .unwrap();
1487
1488        assert_eq!(output, "Hello (width=120)");
1489    }
1490
1491    #[test]
1492    fn test_render_with_context_data_takes_precedence() {
1493        use crate::context::{ContextRegistry, RenderContext};
1494
1495        #[derive(Serialize)]
1496        struct Data {
1497            value: String,
1498        }
1499
1500        let theme = Theme::new();
1501        let data = Data {
1502            value: "from_data".into(),
1503        };
1504        let json_data = serde_json::to_value(&data).unwrap();
1505
1506        let mut registry = ContextRegistry::new();
1507        registry.add_static("value", Value::from("from_context"));
1508
1509        let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1510
1511        let output = render_with_context(
1512            "{{ value }}",
1513            &data,
1514            &theme,
1515            OutputMode::Text,
1516            &registry,
1517            &render_ctx,
1518            None,
1519        )
1520        .unwrap();
1521
1522        assert_eq!(output, "from_data");
1523    }
1524
1525    #[test]
1526    fn test_render_with_context_empty_registry() {
1527        use crate::context::{ContextRegistry, RenderContext};
1528
1529        #[derive(Serialize)]
1530        struct Data {
1531            name: String,
1532        }
1533
1534        let theme = Theme::new();
1535        let data = Data {
1536            name: "Test".into(),
1537        };
1538        let json_data = serde_json::to_value(&data).unwrap();
1539
1540        let registry = ContextRegistry::new();
1541        let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1542
1543        let output = render_with_context(
1544            "{{ name }}",
1545            &data,
1546            &theme,
1547            OutputMode::Text,
1548            &registry,
1549            &render_ctx,
1550            None,
1551        )
1552        .unwrap();
1553
1554        assert_eq!(output, "Test");
1555    }
1556
1557    #[test]
1558    fn test_render_auto_with_context_json_mode() {
1559        use crate::context::{ContextRegistry, RenderContext};
1560
1561        #[derive(Serialize)]
1562        struct Data {
1563            count: usize,
1564        }
1565
1566        let theme = Theme::new();
1567        let data = Data { count: 42 };
1568        let json_data = serde_json::to_value(&data).unwrap();
1569
1570        let mut registry = ContextRegistry::new();
1571        registry.add_static("extra", Value::from("ignored"));
1572
1573        let render_ctx = RenderContext::new(OutputMode::Json, None, &theme, &json_data);
1574
1575        let output = render_auto_with_context(
1576            "unused template {{ extra }}",
1577            &data,
1578            &theme,
1579            OutputMode::Json,
1580            &registry,
1581            &render_ctx,
1582            None,
1583        )
1584        .unwrap();
1585
1586        assert!(output.contains("\"count\": 42"));
1587        assert!(!output.contains("ignored"));
1588    }
1589
1590    #[test]
1591    fn test_render_auto_with_context_text_mode() {
1592        use crate::context::{ContextRegistry, RenderContext};
1593
1594        #[derive(Serialize)]
1595        struct Data {
1596            count: usize,
1597        }
1598
1599        let theme = Theme::new();
1600        let data = Data { count: 42 };
1601        let json_data = serde_json::to_value(&data).unwrap();
1602
1603        let mut registry = ContextRegistry::new();
1604        registry.add_static("label", Value::from("Items"));
1605
1606        let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1607
1608        let output = render_auto_with_context(
1609            "{{ label }}: {{ count }}",
1610            &data,
1611            &theme,
1612            OutputMode::Text,
1613            &registry,
1614            &render_ctx,
1615            None,
1616        )
1617        .unwrap();
1618
1619        assert_eq!(output, "Items: 42");
1620    }
1621
1622    #[test]
1623    fn test_render_with_context_provider_uses_output_mode() {
1624        use crate::context::{ContextRegistry, RenderContext};
1625
1626        #[derive(Serialize)]
1627        struct Data {}
1628
1629        let theme = Theme::new();
1630        let data = Data {};
1631        let json_data = serde_json::to_value(&data).unwrap();
1632
1633        let mut registry = ContextRegistry::new();
1634        registry.add_provider("mode", |ctx: &RenderContext| {
1635            Value::from(format!("{:?}", ctx.output_mode))
1636        });
1637
1638        let render_ctx = RenderContext::new(OutputMode::Term, None, &theme, &json_data);
1639
1640        let output = render_with_context(
1641            "Mode: {{ mode }}",
1642            &data,
1643            &theme,
1644            OutputMode::Term,
1645            &registry,
1646            &render_ctx,
1647            None,
1648        )
1649        .unwrap();
1650
1651        assert_eq!(output, "Mode: Term");
1652    }
1653
1654    #[test]
1655    fn test_render_with_context_nested_data() {
1656        use crate::context::{ContextRegistry, RenderContext};
1657
1658        #[derive(Serialize)]
1659        struct Item {
1660            name: String,
1661        }
1662
1663        #[derive(Serialize)]
1664        struct Data {
1665            items: Vec<Item>,
1666        }
1667
1668        let theme = Theme::new();
1669        let data = Data {
1670            items: vec![Item { name: "one".into() }, Item { name: "two".into() }],
1671        };
1672        let json_data = serde_json::to_value(&data).unwrap();
1673
1674        let mut registry = ContextRegistry::new();
1675        registry.add_static("prefix", Value::from("- "));
1676
1677        let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1678
1679        let output = render_with_context(
1680            "{% for item in items %}{{ prefix }}{{ item.name }}\n{% endfor %}",
1681            &data,
1682            &theme,
1683            OutputMode::Text,
1684            &registry,
1685            &render_ctx,
1686            None,
1687        )
1688        .unwrap();
1689
1690        assert_eq!(output, "- one\n- two\n");
1691    }
1692
1693    #[test]
1694    fn test_render_with_mode_forces_color_mode() {
1695        use console::Style;
1696
1697        #[derive(Serialize)]
1698        struct Data {
1699            status: String,
1700        }
1701
1702        // Create an adaptive theme with different colors for light/dark
1703        // Note: force_styling(true) is needed in tests since there's no TTY
1704        let theme = Theme::new().add_adaptive(
1705            "status",
1706            Style::new(),                                   // Base
1707            Some(Style::new().black().force_styling(true)), // Light mode
1708            Some(Style::new().white().force_styling(true)), // Dark mode
1709        );
1710
1711        let data = Data {
1712            status: "test".into(),
1713        };
1714
1715        // Force dark mode
1716        let dark_output = render_with_mode(
1717            r#"[status]{{ status }}[/status]"#,
1718            &data,
1719            &theme,
1720            OutputMode::Term,
1721            ColorMode::Dark,
1722        )
1723        .unwrap();
1724
1725        // Force light mode
1726        let light_output = render_with_mode(
1727            r#"[status]{{ status }}[/status]"#,
1728            &data,
1729            &theme,
1730            OutputMode::Term,
1731            ColorMode::Light,
1732        )
1733        .unwrap();
1734
1735        // They should be different (different colors applied)
1736        assert_ne!(dark_output, light_output);
1737
1738        // Dark mode should use white (ANSI 37)
1739        assert!(
1740            dark_output.contains("\x1b[37"),
1741            "Expected white (37) in dark mode"
1742        );
1743
1744        // Light mode should use black (ANSI 30)
1745        assert!(
1746            light_output.contains("\x1b[30"),
1747            "Expected black (30) in light mode"
1748        );
1749    }
1750
1751    // ============================================================================
1752    // BBParser Tag Syntax Tests
1753    // ============================================================================
1754
1755    #[test]
1756    fn test_tag_syntax_text_mode() {
1757        let theme = Theme::new().add("title", Style::new().bold());
1758
1759        #[derive(Serialize)]
1760        struct Data {
1761            name: String,
1762        }
1763
1764        let output = render_with_output(
1765            "[title]{{ name }}[/title]",
1766            &Data {
1767                name: "Hello".into(),
1768            },
1769            &theme,
1770            OutputMode::Text,
1771        )
1772        .unwrap();
1773
1774        // Tags should be stripped in text mode
1775        assert_eq!(output, "Hello");
1776    }
1777
1778    #[test]
1779    fn test_tag_syntax_term_mode() {
1780        let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1781
1782        #[derive(Serialize)]
1783        struct Data {
1784            name: String,
1785        }
1786
1787        let output = render_with_output(
1788            "[bold]{{ name }}[/bold]",
1789            &Data {
1790                name: "Hello".into(),
1791            },
1792            &theme,
1793            OutputMode::Term,
1794        )
1795        .unwrap();
1796
1797        // Should contain ANSI bold codes
1798        assert!(output.contains("\x1b[1m"));
1799        assert!(output.contains("Hello"));
1800    }
1801
1802    #[test]
1803    fn test_tag_syntax_debug_mode() {
1804        let theme = Theme::new().add("title", Style::new().bold());
1805
1806        #[derive(Serialize)]
1807        struct Data {
1808            name: String,
1809        }
1810
1811        let output = render_with_output(
1812            "[title]{{ name }}[/title]",
1813            &Data {
1814                name: "Hello".into(),
1815            },
1816            &theme,
1817            OutputMode::TermDebug,
1818        )
1819        .unwrap();
1820
1821        // Tags should be preserved in debug mode
1822        assert_eq!(output, "[title]Hello[/title]");
1823    }
1824
1825    #[test]
1826    fn test_tag_syntax_unknown_tag_passthrough() {
1827        // Passthrough with ? marker only applies in Apply mode (Term)
1828        let theme = Theme::new().add("known", Style::new().bold());
1829
1830        #[derive(Serialize)]
1831        struct Data {
1832            name: String,
1833        }
1834
1835        // In Term mode, unknown tags get ? marker
1836        let output = render_with_output(
1837            "[unknown]{{ name }}[/unknown]",
1838            &Data {
1839                name: "Hello".into(),
1840            },
1841            &theme,
1842            OutputMode::Term,
1843        )
1844        .unwrap();
1845
1846        // Unknown tags get ? marker in passthrough mode
1847        assert!(output.contains("[unknown?]"));
1848        assert!(output.contains("[/unknown?]"));
1849        assert!(output.contains("Hello"));
1850
1851        // In Text mode, all tags are stripped (Remove transform)
1852        let text_output = render_with_output(
1853            "[unknown]{{ name }}[/unknown]",
1854            &Data {
1855                name: "Hello".into(),
1856            },
1857            &theme,
1858            OutputMode::Text,
1859        )
1860        .unwrap();
1861
1862        // Text mode strips all tags
1863        assert_eq!(text_output, "Hello");
1864    }
1865
1866    #[test]
1867    fn test_tag_syntax_nested() {
1868        let theme = Theme::new()
1869            .add("bold", Style::new().bold().force_styling(true))
1870            .add("red", Style::new().red().force_styling(true));
1871
1872        #[derive(Serialize)]
1873        struct Data {
1874            word: String,
1875        }
1876
1877        let output = render_with_output(
1878            "[bold][red]{{ word }}[/red][/bold]",
1879            &Data {
1880                word: "test".into(),
1881            },
1882            &theme,
1883            OutputMode::Term,
1884        )
1885        .unwrap();
1886
1887        // Should contain both bold and red ANSI codes
1888        assert!(output.contains("\x1b[1m")); // Bold
1889        assert!(output.contains("\x1b[31m")); // Red
1890        assert!(output.contains("test"));
1891    }
1892
1893    #[test]
1894    fn test_tag_syntax_multiple_styles() {
1895        let theme = Theme::new()
1896            .add("title", Style::new().bold())
1897            .add("count", Style::new().cyan());
1898
1899        #[derive(Serialize)]
1900        struct Data {
1901            name: String,
1902            num: usize,
1903        }
1904
1905        let output = render_with_output(
1906            r#"[title]{{ name }}[/title]: [count]{{ num }}[/count]"#,
1907            &Data {
1908                name: "Items".into(),
1909                num: 42,
1910            },
1911            &theme,
1912            OutputMode::Text,
1913        )
1914        .unwrap();
1915
1916        assert_eq!(output, "Items: 42");
1917    }
1918
1919    #[test]
1920    fn test_tag_syntax_in_loop() {
1921        let theme = Theme::new().add("item", Style::new().cyan());
1922
1923        #[derive(Serialize)]
1924        struct Data {
1925            items: Vec<String>,
1926        }
1927
1928        let output = render_with_output(
1929            "{% for item in items %}[item]{{ item }}[/item]\n{% endfor %}",
1930            &Data {
1931                items: vec!["one".into(), "two".into()],
1932            },
1933            &theme,
1934            OutputMode::Text,
1935        )
1936        .unwrap();
1937
1938        assert_eq!(output, "one\ntwo\n");
1939    }
1940
1941    #[test]
1942    fn test_tag_syntax_literal_brackets() {
1943        // Tags that don't match our pattern should pass through
1944        let theme = Theme::new();
1945
1946        #[derive(Serialize)]
1947        struct Data {
1948            msg: String,
1949        }
1950
1951        let output = render_with_output(
1952            "Array: [1, 2, 3] and {{ msg }}",
1953            &Data { msg: "done".into() },
1954            &theme,
1955            OutputMode::Text,
1956        )
1957        .unwrap();
1958
1959        // Non-tag brackets preserved
1960        assert_eq!(output, "Array: [1, 2, 3] and done");
1961    }
1962
1963    // ============================================================================
1964    // Template Validation Tests
1965    // ============================================================================
1966
1967    #[test]
1968    fn test_validate_template_all_known_tags() {
1969        let theme = Theme::new()
1970            .add("title", Style::new().bold())
1971            .add("count", Style::new().cyan());
1972
1973        #[derive(Serialize)]
1974        struct Data {
1975            name: String,
1976        }
1977
1978        let result = validate_template(
1979            "[title]{{ name }}[/title]",
1980            &Data {
1981                name: "Hello".into(),
1982            },
1983            &theme,
1984        );
1985
1986        assert!(result.is_ok());
1987    }
1988
1989    #[test]
1990    fn test_validate_template_unknown_tag_fails() {
1991        let theme = Theme::new().add("known", Style::new().bold());
1992
1993        #[derive(Serialize)]
1994        struct Data {
1995            name: String,
1996        }
1997
1998        let result = validate_template(
1999            "[unknown]{{ name }}[/unknown]",
2000            &Data {
2001                name: "Hello".into(),
2002            },
2003            &theme,
2004        );
2005
2006        assert!(result.is_err());
2007        let err = result.unwrap_err();
2008        let errors = err
2009            .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2010            .expect("Expected UnknownTagErrors");
2011        assert_eq!(errors.len(), 2); // open and close tags
2012    }
2013
2014    #[test]
2015    fn test_validate_template_multiple_unknown_tags() {
2016        let theme = Theme::new().add("known", Style::new().bold());
2017
2018        #[derive(Serialize)]
2019        struct Data {
2020            a: String,
2021            b: String,
2022        }
2023
2024        let result = validate_template(
2025            "[foo]{{ a }}[/foo] and [bar]{{ b }}[/bar]",
2026            &Data {
2027                a: "x".into(),
2028                b: "y".into(),
2029            },
2030            &theme,
2031        );
2032
2033        assert!(result.is_err());
2034        let err = result.unwrap_err();
2035        let errors = err
2036            .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2037            .expect("Expected UnknownTagErrors");
2038        assert_eq!(errors.len(), 4); // foo open/close + bar open/close
2039    }
2040
2041    #[test]
2042    fn test_validate_template_plain_text_passes() {
2043        let theme = Theme::new();
2044
2045        #[derive(Serialize)]
2046        struct Data {
2047            msg: String,
2048        }
2049
2050        let result = validate_template("Just plain {{ msg }}", &Data { msg: "hi".into() }, &theme);
2051
2052        assert!(result.is_ok());
2053    }
2054
2055    #[test]
2056    fn test_validate_template_mixed_known_and_unknown() {
2057        let theme = Theme::new().add("known", Style::new().bold());
2058
2059        #[derive(Serialize)]
2060        struct Data {
2061            a: String,
2062            b: String,
2063        }
2064
2065        let result = validate_template(
2066            "[known]{{ a }}[/known] [unknown]{{ b }}[/unknown]",
2067            &Data {
2068                a: "x".into(),
2069                b: "y".into(),
2070            },
2071            &theme,
2072        );
2073
2074        assert!(result.is_err());
2075        let err = result.unwrap_err();
2076        let errors = err
2077            .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2078            .expect("Expected UnknownTagErrors");
2079        // Only unknown tags should be reported
2080        assert_eq!(errors.len(), 2);
2081        assert!(errors.errors.iter().any(|e| e.tag == "unknown"));
2082    }
2083
2084    #[test]
2085    fn test_validate_template_syntax_error_fails() {
2086        let theme = Theme::new();
2087        #[derive(Serialize)]
2088        struct Data {}
2089
2090        // Missing closing braces
2091        let result = validate_template("{{ unclosed", &Data {}, &theme);
2092        assert!(result.is_err());
2093
2094        let err = result.unwrap_err();
2095        // Should NOT be UnknownTagErrors
2096        assert!(err
2097            .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2098            .is_none());
2099        // Should be a minijinja error
2100        let msg = err.to_string();
2101        assert!(
2102            msg.contains("syntax error") || msg.contains("unexpected"),
2103            "Got: {}",
2104            msg
2105        );
2106    }
2107
2108    #[test]
2109    fn test_render_auto_with_context_yaml_mode() {
2110        use crate::context::{ContextRegistry, RenderContext};
2111        use serde_json::json;
2112
2113        let theme = Theme::new();
2114        let data = json!({"name": "test", "count": 42});
2115
2116        // Setup context registry (though strictly not used for structured output)
2117        let registry = ContextRegistry::new();
2118        let render_ctx = RenderContext::new(OutputMode::Yaml, Some(80), &theme, &data);
2119
2120        // This call previously panicked
2121        let output = render_auto_with_context(
2122            "unused template",
2123            &data,
2124            &theme,
2125            OutputMode::Yaml,
2126            &registry,
2127            &render_ctx,
2128            None,
2129        )
2130        .unwrap();
2131
2132        assert!(output.contains("name: test"));
2133        assert!(output.contains("count: 42"));
2134    }
2135}