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    env.add_template_owned("_inline".to_string(), template.to_string())?;
691
692    // Load all templates from registry if available (enables {% include %})
693    if let Some(registry) = template_registry {
694        for name in registry.names() {
695            if let Ok(content) = registry.get_content(name) {
696                // Ensure we don't overwrite the main template if named same as a registry template
697                if name != "_inline" {
698                    env.add_template_owned(name.to_string(), content)?;
699                }
700            }
701        }
702    }
703
704    let tmpl = env.get_template("_inline")?;
705
706    // Build the combined context: data + injected context
707    // Data fields take precedence over context fields
708    let combined = build_combined_context(data, context_registry, render_context)?;
709
710    // Pass 1: MiniJinja template rendering
711    let minijinja_output = tmpl.render(&combined)?;
712
713    // Pass 2: BBParser style tag processing
714    let final_output = apply_style_tags(&minijinja_output, &styles, mode);
715
716    Ok(final_output)
717}
718
719/// Auto-dispatches with context injection support.
720///
721/// This combines `render_with_context` with JSON serialization support.
722/// For structured modes like `Json`, the data is serialized directly,
723/// skipping template rendering (and context injection).
724///
725/// # Arguments
726///
727/// * `template` - A minijinja template string (ignored for structured modes)
728/// * `data` - Any serializable data to render or serialize
729/// * `theme` - Theme definitions for the `style` filter
730/// * `mode` - Output mode determining the output format
731/// * `context_registry` - Additional context objects to inject
732/// * `render_context` - Information about the render environment
733///
734/// # Example
735///
736/// ```rust
737/// use standout::{render_auto_with_context, Theme, OutputMode};
738/// use standout::context::{RenderContext, ContextRegistry};
739/// use minijinja::Value;
740/// use serde::Serialize;
741///
742/// #[derive(Serialize)]
743/// struct Report { title: String, count: usize }
744///
745/// let theme = Theme::new();
746/// let data = Report { title: "Summary".into(), count: 42 };
747///
748/// let mut registry = ContextRegistry::new();
749/// registry.add_provider("terminal_width", |ctx: &RenderContext| {
750///     Value::from(ctx.terminal_width.unwrap_or(80))
751/// });
752///
753/// let json_data = serde_json::to_value(&data).unwrap();
754/// let render_ctx = RenderContext::new(
755///     OutputMode::Text,
756///     Some(120),
757///     &theme,
758///     &json_data,
759/// );
760///
761/// // Text mode uses the template with context
762/// let text = render_auto_with_context(
763///     "{{ title }} (width={{ terminal_width }}): {{ count }}",
764///     &data,
765///     &theme,
766///     OutputMode::Text,
767///     &registry,
768///     &render_ctx,
769///     None,
770/// ).unwrap();
771/// assert_eq!(text, "Summary (width=120): 42");
772///
773/// // JSON mode ignores template and context, serializes data directly
774/// let json = render_auto_with_context(
775///     "unused",
776///     &data,
777///     &theme,
778///     OutputMode::Json,
779///     &registry,
780///     &render_ctx,
781///     None,
782/// ).unwrap();
783/// assert!(json.contains("\"title\": \"Summary\""));
784/// ```
785pub fn render_auto_with_context<T: Serialize>(
786    template: &str,
787    data: &T,
788    theme: &Theme,
789    mode: OutputMode,
790    context_registry: &ContextRegistry,
791    render_context: &RenderContext,
792    template_registry: Option<&super::TemplateRegistry>,
793) -> Result<String, Error> {
794    if mode.is_structured() {
795        match mode {
796            OutputMode::Json => serde_json::to_string_pretty(data)
797                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
798            OutputMode::Yaml => serde_yaml::to_string(data)
799                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
800            OutputMode::Xml => quick_xml::se::to_string(data)
801                .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
802            OutputMode::Csv => {
803                let value = serde_json::to_value(data).map_err(|e| {
804                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
805                })?;
806                let (headers, rows) = crate::util::flatten_json_for_csv(&value);
807
808                let mut wtr = csv::Writer::from_writer(Vec::new());
809                wtr.write_record(&headers).map_err(|e| {
810                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
811                })?;
812                for row in rows {
813                    wtr.write_record(&row).map_err(|e| {
814                        Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
815                    })?;
816                }
817                let bytes = wtr.into_inner().map_err(|e| {
818                    Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
819                })?;
820                String::from_utf8(bytes)
821                    .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))
822            }
823            _ => unreachable!("is_structured() returned true for non-structured mode"),
824        }
825    } else {
826        render_with_context(
827            template,
828            data,
829            theme,
830            mode,
831            context_registry,
832            render_context,
833            template_registry,
834        )
835    }
836}
837
838/// Builds a combined context from data and injected context.
839///
840/// Data fields take precedence over context fields.
841fn build_combined_context<T: Serialize>(
842    data: &T,
843    context_registry: &ContextRegistry,
844    render_context: &RenderContext,
845) -> Result<HashMap<String, Value>, Error> {
846    // First, resolve all context providers
847    let context_values = context_registry.resolve(render_context);
848
849    // Convert data to a map of values
850    let data_value = serde_json::to_value(data)
851        .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
852
853    let mut combined: HashMap<String, Value> = HashMap::new();
854
855    // Add context values first (lower priority)
856    for (key, value) in context_values {
857        combined.insert(key, value);
858    }
859
860    // Add data values (higher priority - overwrites context)
861    if let Some(obj) = data_value.as_object() {
862        for (key, value) in obj {
863            let minijinja_value = json_to_minijinja(value);
864            combined.insert(key.clone(), minijinja_value);
865        }
866    }
867
868    Ok(combined)
869}
870
871/// Converts a serde_json::Value to a minijinja::Value.
872fn json_to_minijinja(json: &serde_json::Value) -> Value {
873    match json {
874        serde_json::Value::Null => Value::from(()),
875        serde_json::Value::Bool(b) => Value::from(*b),
876        serde_json::Value::Number(n) => {
877            if let Some(i) = n.as_i64() {
878                Value::from(i)
879            } else if let Some(f) = n.as_f64() {
880                Value::from(f)
881            } else {
882                Value::from(n.to_string())
883            }
884        }
885        serde_json::Value::String(s) => Value::from(s.clone()),
886        serde_json::Value::Array(arr) => {
887            let items: Vec<Value> = arr.iter().map(json_to_minijinja).collect();
888            Value::from(items)
889        }
890        serde_json::Value::Object(obj) => {
891            let map: HashMap<String, Value> = obj
892                .iter()
893                .map(|(k, v)| (k.clone(), json_to_minijinja(v)))
894                .collect();
895            Value::from_iter(map)
896        }
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903    use crate::tabular::{Column, FlatDataSpec, Width};
904    use crate::Theme;
905    use console::Style;
906    use serde::Serialize;
907    use serde_json::json;
908
909    #[derive(Serialize)]
910    struct SimpleData {
911        message: String,
912    }
913
914    #[derive(Serialize)]
915    struct ListData {
916        items: Vec<String>,
917        count: usize,
918    }
919
920    #[test]
921    fn test_render_with_output_text_no_ansi() {
922        let theme = Theme::new().add("red", Style::new().red());
923        let data = SimpleData {
924            message: "test".into(),
925        };
926
927        let output = render_with_output(
928            r#"[red]{{ message }}[/red]"#,
929            &data,
930            &theme,
931            OutputMode::Text,
932        )
933        .unwrap();
934
935        assert_eq!(output, "test");
936        assert!(!output.contains("\x1b["));
937    }
938
939    #[test]
940    fn test_render_with_output_term_has_ansi() {
941        let theme = Theme::new().add("green", Style::new().green().force_styling(true));
942        let data = SimpleData {
943            message: "success".into(),
944        };
945
946        let output = render_with_output(
947            r#"[green]{{ message }}[/green]"#,
948            &data,
949            &theme,
950            OutputMode::Term,
951        )
952        .unwrap();
953
954        assert!(output.contains("success"));
955        assert!(output.contains("\x1b["));
956    }
957
958    #[test]
959    fn test_render_unknown_style_shows_indicator() {
960        let theme = Theme::new();
961        let data = SimpleData {
962            message: "hello".into(),
963        };
964
965        let output = render_with_output(
966            r#"[unknown]{{ message }}[/unknown]"#,
967            &data,
968            &theme,
969            OutputMode::Term,
970        )
971        .unwrap();
972
973        // Unknown tags in passthrough mode get ? marker on both open and close tags
974        assert_eq!(output, "[unknown?]hello[/unknown?]");
975    }
976
977    #[test]
978    fn test_render_unknown_style_stripped_in_text_mode() {
979        let theme = Theme::new();
980        let data = SimpleData {
981            message: "hello".into(),
982        };
983
984        let output = render_with_output(
985            r#"[unknown]{{ message }}[/unknown]"#,
986            &data,
987            &theme,
988            OutputMode::Text,
989        )
990        .unwrap();
991
992        // In text mode (Remove), unknown tags are stripped like known tags
993        assert_eq!(output, "hello");
994    }
995
996    #[test]
997    fn test_render_template_with_loop() {
998        let theme = Theme::new().add("item", Style::new().cyan());
999        let data = ListData {
1000            items: vec!["one".into(), "two".into()],
1001            count: 2,
1002        };
1003
1004        let template = r#"{% for item in items %}[item]{{ item }}[/item]
1005{% endfor %}"#;
1006
1007        let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1008        assert_eq!(output, "one\ntwo\n");
1009    }
1010
1011    #[test]
1012    fn test_render_mixed_styled_and_plain() {
1013        let theme = Theme::new().add("count", Style::new().bold());
1014        let data = ListData {
1015            items: vec![],
1016            count: 42,
1017        };
1018
1019        let template = r#"Total: [count]{{ count }}[/count] items"#;
1020        let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1021
1022        assert_eq!(output, "Total: 42 items");
1023    }
1024
1025    #[test]
1026    fn test_render_literal_string_styled() {
1027        let theme = Theme::new().add("header", Style::new().bold());
1028
1029        #[derive(Serialize)]
1030        struct Empty {}
1031
1032        let output = render_with_output(
1033            r#"[header]Header[/header]"#,
1034            &Empty {},
1035            &theme,
1036            OutputMode::Text,
1037        )
1038        .unwrap();
1039
1040        assert_eq!(output, "Header");
1041    }
1042
1043    #[test]
1044    fn test_empty_template() {
1045        let theme = Theme::new();
1046
1047        #[derive(Serialize)]
1048        struct Empty {}
1049
1050        let output = render_with_output("", &Empty {}, &theme, OutputMode::Text).unwrap();
1051        assert_eq!(output, "");
1052    }
1053
1054    #[test]
1055    fn test_template_syntax_error() {
1056        let theme = Theme::new();
1057
1058        #[derive(Serialize)]
1059        struct Empty {}
1060
1061        let result = render_with_output("{{ unclosed", &Empty {}, &theme, OutputMode::Text);
1062        assert!(result.is_err());
1063    }
1064
1065    #[test]
1066    fn test_style_tag_with_nested_data() {
1067        #[derive(Serialize)]
1068        struct Item {
1069            name: String,
1070            value: i32,
1071        }
1072
1073        #[derive(Serialize)]
1074        struct Container {
1075            items: Vec<Item>,
1076        }
1077
1078        let theme = Theme::new().add("name", Style::new().bold());
1079        let data = Container {
1080            items: vec![
1081                Item {
1082                    name: "foo".into(),
1083                    value: 1,
1084                },
1085                Item {
1086                    name: "bar".into(),
1087                    value: 2,
1088                },
1089            ],
1090        };
1091
1092        let template = r#"{% for item in items %}[name]{{ item.name }}[/name]={{ item.value }}
1093{% endfor %}"#;
1094
1095        let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1096        assert_eq!(output, "foo=1\nbar=2\n");
1097    }
1098
1099    #[test]
1100    fn test_render_with_output_term_debug() {
1101        let theme = Theme::new()
1102            .add("title", Style::new().bold())
1103            .add("count", Style::new().cyan());
1104
1105        #[derive(Serialize)]
1106        struct Data {
1107            name: String,
1108            value: usize,
1109        }
1110
1111        let data = Data {
1112            name: "Test".into(),
1113            value: 42,
1114        };
1115
1116        let output = render_with_output(
1117            r#"[title]{{ name }}[/title]: [count]{{ value }}[/count]"#,
1118            &data,
1119            &theme,
1120            OutputMode::TermDebug,
1121        )
1122        .unwrap();
1123
1124        assert_eq!(output, "[title]Test[/title]: [count]42[/count]");
1125    }
1126
1127    #[test]
1128    fn test_render_with_output_term_debug_preserves_tags() {
1129        let theme = Theme::new().add("known", Style::new().bold());
1130
1131        #[derive(Serialize)]
1132        struct Data {
1133            message: String,
1134        }
1135
1136        let data = Data {
1137            message: "hello".into(),
1138        };
1139
1140        // In TermDebug (Keep mode), unknown tags are preserved as-is
1141        let output = render_with_output(
1142            r#"[unknown]{{ message }}[/unknown]"#,
1143            &data,
1144            &theme,
1145            OutputMode::TermDebug,
1146        )
1147        .unwrap();
1148
1149        assert_eq!(output, "[unknown]hello[/unknown]");
1150
1151        // Known tags are also preserved as-is in debug mode
1152        let output = render_with_output(
1153            r#"[known]{{ message }}[/known]"#,
1154            &data,
1155            &theme,
1156            OutputMode::TermDebug,
1157        )
1158        .unwrap();
1159
1160        assert_eq!(output, "[known]hello[/known]");
1161    }
1162
1163    #[test]
1164    fn test_render_auto_json_mode() {
1165        use serde_json::json;
1166
1167        let theme = Theme::new();
1168        let data = json!({"name": "test", "count": 42});
1169
1170        let output = render_auto("unused template", &data, &theme, OutputMode::Json).unwrap();
1171
1172        assert!(output.contains("\"name\": \"test\""));
1173        assert!(output.contains("\"count\": 42"));
1174    }
1175
1176    #[test]
1177    fn test_render_auto_text_mode_uses_template() {
1178        use serde_json::json;
1179
1180        let theme = Theme::new();
1181        let data = json!({"name": "test"});
1182
1183        let output = render_auto("Name: {{ name }}", &data, &theme, OutputMode::Text).unwrap();
1184
1185        assert_eq!(output, "Name: test");
1186    }
1187
1188    #[test]
1189    fn test_render_auto_term_mode_uses_template() {
1190        use serde_json::json;
1191
1192        let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1193        let data = json!({"name": "test"});
1194
1195        let output = render_auto(
1196            r#"[bold]{{ name }}[/bold]"#,
1197            &data,
1198            &theme,
1199            OutputMode::Term,
1200        )
1201        .unwrap();
1202
1203        assert!(output.contains("\x1b[1m"));
1204        assert!(output.contains("test"));
1205    }
1206
1207    #[test]
1208    fn test_render_auto_json_with_struct() {
1209        #[derive(Serialize)]
1210        struct Report {
1211            title: String,
1212            items: Vec<String>,
1213        }
1214
1215        let theme = Theme::new();
1216        let data = Report {
1217            title: "Summary".into(),
1218            items: vec!["one".into(), "two".into()],
1219        };
1220
1221        let output = render_auto("unused", &data, &theme, OutputMode::Json).unwrap();
1222
1223        assert!(output.contains("\"title\": \"Summary\""));
1224        assert!(output.contains("\"items\""));
1225        assert!(output.contains("\"one\""));
1226    }
1227
1228    #[test]
1229    fn test_render_with_alias() {
1230        let theme = Theme::new()
1231            .add("base", Style::new().bold())
1232            .add("alias", "base");
1233
1234        let output = render_with_output(
1235            r#"[alias]text[/alias]"#,
1236            &serde_json::json!({}),
1237            &theme,
1238            OutputMode::Text,
1239        )
1240        .unwrap();
1241
1242        assert_eq!(output, "text");
1243    }
1244
1245    #[test]
1246    fn test_render_with_alias_chain() {
1247        let theme = Theme::new()
1248            .add("muted", Style::new().dim())
1249            .add("disabled", "muted")
1250            .add("timestamp", "disabled");
1251
1252        let output = render_with_output(
1253            r#"[timestamp]12:00[/timestamp]"#,
1254            &serde_json::json!({}),
1255            &theme,
1256            OutputMode::Text,
1257        )
1258        .unwrap();
1259
1260        assert_eq!(output, "12:00");
1261    }
1262
1263    #[test]
1264    fn test_render_fails_with_dangling_alias() {
1265        let theme = Theme::new().add("orphan", "missing");
1266
1267        let result = render_with_output(
1268            r#"[orphan]text[/orphan]"#,
1269            &serde_json::json!({}),
1270            &theme,
1271            OutputMode::Text,
1272        );
1273
1274        assert!(result.is_err());
1275        let err = result.unwrap_err();
1276        assert!(err.to_string().contains("orphan"));
1277        assert!(err.to_string().contains("missing"));
1278    }
1279
1280    #[test]
1281    fn test_render_fails_with_cycle() {
1282        let theme = Theme::new().add("a", "b").add("b", "a");
1283
1284        let result = render_with_output(
1285            r#"[a]text[/a]"#,
1286            &serde_json::json!({}),
1287            &theme,
1288            OutputMode::Text,
1289        );
1290
1291        assert!(result.is_err());
1292        assert!(result.unwrap_err().to_string().contains("cycle"));
1293    }
1294
1295    #[test]
1296    fn test_three_layer_styling_pattern() {
1297        let theme = Theme::new()
1298            .add("dim_style", Style::new().dim())
1299            .add("cyan_bold", Style::new().cyan().bold())
1300            .add("yellow_bg", Style::new().on_yellow())
1301            .add("muted", "dim_style")
1302            .add("accent", "cyan_bold")
1303            .add("highlighted", "yellow_bg")
1304            .add("timestamp", "muted")
1305            .add("title", "accent")
1306            .add("selected_item", "highlighted");
1307
1308        assert!(theme.validate().is_ok());
1309
1310        let output = render_with_output(
1311            r#"[timestamp]{{ time }}[/timestamp] - [title]{{ name }}[/title]"#,
1312            &serde_json::json!({"time": "12:00", "name": "Report"}),
1313            &theme,
1314            OutputMode::Text,
1315        )
1316        .unwrap();
1317
1318        assert_eq!(output, "12:00 - Report");
1319    }
1320
1321    // ============================================================================
1322    // YAML/XML/CSV Output Tests
1323    // ============================================================================
1324
1325    #[test]
1326    fn test_render_auto_yaml_mode() {
1327        use serde_json::json;
1328
1329        let theme = Theme::new();
1330        let data = json!({"name": "test", "count": 42});
1331
1332        let output = render_auto("unused template", &data, &theme, OutputMode::Yaml).unwrap();
1333
1334        assert!(output.contains("name: test"));
1335        assert!(output.contains("count: 42"));
1336    }
1337
1338    #[test]
1339    fn test_render_auto_xml_mode() {
1340        let theme = Theme::new();
1341
1342        #[derive(Serialize)]
1343        #[serde(rename = "root")]
1344        struct Data {
1345            name: String,
1346            count: usize,
1347        }
1348
1349        let data = Data {
1350            name: "test".into(),
1351            count: 42,
1352        };
1353
1354        let output = render_auto("unused template", &data, &theme, OutputMode::Xml).unwrap();
1355
1356        assert!(output.contains("<root>"));
1357        assert!(output.contains("<name>test</name>"));
1358    }
1359
1360    #[test]
1361    fn test_render_auto_csv_mode_auto_flatten() {
1362        use serde_json::json;
1363
1364        let theme = Theme::new();
1365        let data = json!([
1366            {"name": "Alice", "stats": {"score": 10}},
1367            {"name": "Bob", "stats": {"score": 20}}
1368        ]);
1369
1370        let output = render_auto("unused", &data, &theme, OutputMode::Csv).unwrap();
1371
1372        assert!(output.contains("name,stats.score"));
1373        assert!(output.contains("Alice,10"));
1374        assert!(output.contains("Bob,20"));
1375    }
1376
1377    #[test]
1378    fn test_render_auto_csv_mode_with_spec() {
1379        let theme = Theme::new();
1380        let data = json!([
1381            {"name": "Alice", "meta": {"age": 30, "role": "admin"}},
1382            {"name": "Bob", "meta": {"age": 25, "role": "user"}}
1383        ]);
1384
1385        let spec = FlatDataSpec::builder()
1386            .column(Column::new(Width::Fixed(10)).key("name"))
1387            .column(
1388                Column::new(Width::Fixed(10))
1389                    .key("meta.role")
1390                    .header("Role"),
1391            )
1392            .build();
1393
1394        let output =
1395            render_auto_with_spec("unused", &data, &theme, OutputMode::Csv, Some(&spec)).unwrap();
1396
1397        let lines: Vec<&str> = output.lines().collect();
1398        assert_eq!(lines[0], "name,Role");
1399        assert!(lines.contains(&"Alice,admin"));
1400        assert!(lines.contains(&"Bob,user"));
1401        assert!(!output.contains("30"));
1402    }
1403
1404    // ============================================================================
1405    // Context Injection Tests
1406    // ============================================================================
1407
1408    #[test]
1409    fn test_render_with_context_basic() {
1410        use crate::context::{ContextRegistry, RenderContext};
1411
1412        #[derive(Serialize)]
1413        struct Data {
1414            name: String,
1415        }
1416
1417        let theme = Theme::new();
1418        let data = Data {
1419            name: "Alice".into(),
1420        };
1421        let json_data = serde_json::to_value(&data).unwrap();
1422
1423        let mut registry = ContextRegistry::new();
1424        registry.add_static("version", Value::from("1.0.0"));
1425
1426        let render_ctx = RenderContext::new(OutputMode::Text, Some(80), &theme, &json_data);
1427
1428        let output = render_with_context(
1429            "{{ name }} (v{{ version }})",
1430            &data,
1431            &theme,
1432            OutputMode::Text,
1433            &registry,
1434            &render_ctx,
1435            None,
1436        )
1437        .unwrap();
1438
1439        assert_eq!(output, "Alice (v1.0.0)");
1440    }
1441
1442    #[test]
1443    fn test_render_with_context_dynamic_provider() {
1444        use crate::context::{ContextRegistry, RenderContext};
1445
1446        #[derive(Serialize)]
1447        struct Data {
1448            message: String,
1449        }
1450
1451        let theme = Theme::new();
1452        let data = Data {
1453            message: "Hello".into(),
1454        };
1455        let json_data = serde_json::to_value(&data).unwrap();
1456
1457        let mut registry = ContextRegistry::new();
1458        registry.add_provider("terminal_width", |ctx: &RenderContext| {
1459            Value::from(ctx.terminal_width.unwrap_or(80))
1460        });
1461
1462        let render_ctx = RenderContext::new(OutputMode::Text, Some(120), &theme, &json_data);
1463
1464        let output = render_with_context(
1465            "{{ message }} (width={{ terminal_width }})",
1466            &data,
1467            &theme,
1468            OutputMode::Text,
1469            &registry,
1470            &render_ctx,
1471            None,
1472        )
1473        .unwrap();
1474
1475        assert_eq!(output, "Hello (width=120)");
1476    }
1477
1478    #[test]
1479    fn test_render_with_context_data_takes_precedence() {
1480        use crate::context::{ContextRegistry, RenderContext};
1481
1482        #[derive(Serialize)]
1483        struct Data {
1484            value: String,
1485        }
1486
1487        let theme = Theme::new();
1488        let data = Data {
1489            value: "from_data".into(),
1490        };
1491        let json_data = serde_json::to_value(&data).unwrap();
1492
1493        let mut registry = ContextRegistry::new();
1494        registry.add_static("value", Value::from("from_context"));
1495
1496        let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1497
1498        let output = render_with_context(
1499            "{{ value }}",
1500            &data,
1501            &theme,
1502            OutputMode::Text,
1503            &registry,
1504            &render_ctx,
1505            None,
1506        )
1507        .unwrap();
1508
1509        assert_eq!(output, "from_data");
1510    }
1511
1512    #[test]
1513    fn test_render_with_context_empty_registry() {
1514        use crate::context::{ContextRegistry, RenderContext};
1515
1516        #[derive(Serialize)]
1517        struct Data {
1518            name: String,
1519        }
1520
1521        let theme = Theme::new();
1522        let data = Data {
1523            name: "Test".into(),
1524        };
1525        let json_data = serde_json::to_value(&data).unwrap();
1526
1527        let registry = ContextRegistry::new();
1528        let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1529
1530        let output = render_with_context(
1531            "{{ name }}",
1532            &data,
1533            &theme,
1534            OutputMode::Text,
1535            &registry,
1536            &render_ctx,
1537            None,
1538        )
1539        .unwrap();
1540
1541        assert_eq!(output, "Test");
1542    }
1543
1544    #[test]
1545    fn test_render_auto_with_context_json_mode() {
1546        use crate::context::{ContextRegistry, RenderContext};
1547
1548        #[derive(Serialize)]
1549        struct Data {
1550            count: usize,
1551        }
1552
1553        let theme = Theme::new();
1554        let data = Data { count: 42 };
1555        let json_data = serde_json::to_value(&data).unwrap();
1556
1557        let mut registry = ContextRegistry::new();
1558        registry.add_static("extra", Value::from("ignored"));
1559
1560        let render_ctx = RenderContext::new(OutputMode::Json, None, &theme, &json_data);
1561
1562        let output = render_auto_with_context(
1563            "unused template {{ extra }}",
1564            &data,
1565            &theme,
1566            OutputMode::Json,
1567            &registry,
1568            &render_ctx,
1569            None,
1570        )
1571        .unwrap();
1572
1573        assert!(output.contains("\"count\": 42"));
1574        assert!(!output.contains("ignored"));
1575    }
1576
1577    #[test]
1578    fn test_render_auto_with_context_text_mode() {
1579        use crate::context::{ContextRegistry, RenderContext};
1580
1581        #[derive(Serialize)]
1582        struct Data {
1583            count: usize,
1584        }
1585
1586        let theme = Theme::new();
1587        let data = Data { count: 42 };
1588        let json_data = serde_json::to_value(&data).unwrap();
1589
1590        let mut registry = ContextRegistry::new();
1591        registry.add_static("label", Value::from("Items"));
1592
1593        let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1594
1595        let output = render_auto_with_context(
1596            "{{ label }}: {{ count }}",
1597            &data,
1598            &theme,
1599            OutputMode::Text,
1600            &registry,
1601            &render_ctx,
1602            None,
1603        )
1604        .unwrap();
1605
1606        assert_eq!(output, "Items: 42");
1607    }
1608
1609    #[test]
1610    fn test_render_with_context_provider_uses_output_mode() {
1611        use crate::context::{ContextRegistry, RenderContext};
1612
1613        #[derive(Serialize)]
1614        struct Data {}
1615
1616        let theme = Theme::new();
1617        let data = Data {};
1618        let json_data = serde_json::to_value(&data).unwrap();
1619
1620        let mut registry = ContextRegistry::new();
1621        registry.add_provider("mode", |ctx: &RenderContext| {
1622            Value::from(format!("{:?}", ctx.output_mode))
1623        });
1624
1625        let render_ctx = RenderContext::new(OutputMode::Term, None, &theme, &json_data);
1626
1627        let output = render_with_context(
1628            "Mode: {{ mode }}",
1629            &data,
1630            &theme,
1631            OutputMode::Term,
1632            &registry,
1633            &render_ctx,
1634            None,
1635        )
1636        .unwrap();
1637
1638        assert_eq!(output, "Mode: Term");
1639    }
1640
1641    #[test]
1642    fn test_render_with_context_nested_data() {
1643        use crate::context::{ContextRegistry, RenderContext};
1644
1645        #[derive(Serialize)]
1646        struct Item {
1647            name: String,
1648        }
1649
1650        #[derive(Serialize)]
1651        struct Data {
1652            items: Vec<Item>,
1653        }
1654
1655        let theme = Theme::new();
1656        let data = Data {
1657            items: vec![Item { name: "one".into() }, Item { name: "two".into() }],
1658        };
1659        let json_data = serde_json::to_value(&data).unwrap();
1660
1661        let mut registry = ContextRegistry::new();
1662        registry.add_static("prefix", Value::from("- "));
1663
1664        let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1665
1666        let output = render_with_context(
1667            "{% for item in items %}{{ prefix }}{{ item.name }}\n{% endfor %}",
1668            &data,
1669            &theme,
1670            OutputMode::Text,
1671            &registry,
1672            &render_ctx,
1673            None,
1674        )
1675        .unwrap();
1676
1677        assert_eq!(output, "- one\n- two\n");
1678    }
1679
1680    #[test]
1681    fn test_render_with_mode_forces_color_mode() {
1682        use console::Style;
1683
1684        #[derive(Serialize)]
1685        struct Data {
1686            status: String,
1687        }
1688
1689        // Create an adaptive theme with different colors for light/dark
1690        // Note: force_styling(true) is needed in tests since there's no TTY
1691        let theme = Theme::new().add_adaptive(
1692            "status",
1693            Style::new(),                                   // Base
1694            Some(Style::new().black().force_styling(true)), // Light mode
1695            Some(Style::new().white().force_styling(true)), // Dark mode
1696        );
1697
1698        let data = Data {
1699            status: "test".into(),
1700        };
1701
1702        // Force dark mode
1703        let dark_output = render_with_mode(
1704            r#"[status]{{ status }}[/status]"#,
1705            &data,
1706            &theme,
1707            OutputMode::Term,
1708            ColorMode::Dark,
1709        )
1710        .unwrap();
1711
1712        // Force light mode
1713        let light_output = render_with_mode(
1714            r#"[status]{{ status }}[/status]"#,
1715            &data,
1716            &theme,
1717            OutputMode::Term,
1718            ColorMode::Light,
1719        )
1720        .unwrap();
1721
1722        // They should be different (different colors applied)
1723        assert_ne!(dark_output, light_output);
1724
1725        // Dark mode should use white (ANSI 37)
1726        assert!(
1727            dark_output.contains("\x1b[37"),
1728            "Expected white (37) in dark mode"
1729        );
1730
1731        // Light mode should use black (ANSI 30)
1732        assert!(
1733            light_output.contains("\x1b[30"),
1734            "Expected black (30) in light mode"
1735        );
1736    }
1737
1738    // ============================================================================
1739    // BBParser Tag Syntax Tests
1740    // ============================================================================
1741
1742    #[test]
1743    fn test_tag_syntax_text_mode() {
1744        let theme = Theme::new().add("title", Style::new().bold());
1745
1746        #[derive(Serialize)]
1747        struct Data {
1748            name: String,
1749        }
1750
1751        let output = render_with_output(
1752            "[title]{{ name }}[/title]",
1753            &Data {
1754                name: "Hello".into(),
1755            },
1756            &theme,
1757            OutputMode::Text,
1758        )
1759        .unwrap();
1760
1761        // Tags should be stripped in text mode
1762        assert_eq!(output, "Hello");
1763    }
1764
1765    #[test]
1766    fn test_tag_syntax_term_mode() {
1767        let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1768
1769        #[derive(Serialize)]
1770        struct Data {
1771            name: String,
1772        }
1773
1774        let output = render_with_output(
1775            "[bold]{{ name }}[/bold]",
1776            &Data {
1777                name: "Hello".into(),
1778            },
1779            &theme,
1780            OutputMode::Term,
1781        )
1782        .unwrap();
1783
1784        // Should contain ANSI bold codes
1785        assert!(output.contains("\x1b[1m"));
1786        assert!(output.contains("Hello"));
1787    }
1788
1789    #[test]
1790    fn test_tag_syntax_debug_mode() {
1791        let theme = Theme::new().add("title", Style::new().bold());
1792
1793        #[derive(Serialize)]
1794        struct Data {
1795            name: String,
1796        }
1797
1798        let output = render_with_output(
1799            "[title]{{ name }}[/title]",
1800            &Data {
1801                name: "Hello".into(),
1802            },
1803            &theme,
1804            OutputMode::TermDebug,
1805        )
1806        .unwrap();
1807
1808        // Tags should be preserved in debug mode
1809        assert_eq!(output, "[title]Hello[/title]");
1810    }
1811
1812    #[test]
1813    fn test_tag_syntax_unknown_tag_passthrough() {
1814        // Passthrough with ? marker only applies in Apply mode (Term)
1815        let theme = Theme::new().add("known", Style::new().bold());
1816
1817        #[derive(Serialize)]
1818        struct Data {
1819            name: String,
1820        }
1821
1822        // In Term mode, unknown tags get ? marker
1823        let output = render_with_output(
1824            "[unknown]{{ name }}[/unknown]",
1825            &Data {
1826                name: "Hello".into(),
1827            },
1828            &theme,
1829            OutputMode::Term,
1830        )
1831        .unwrap();
1832
1833        // Unknown tags get ? marker in passthrough mode
1834        assert!(output.contains("[unknown?]"));
1835        assert!(output.contains("[/unknown?]"));
1836        assert!(output.contains("Hello"));
1837
1838        // In Text mode, all tags are stripped (Remove transform)
1839        let text_output = render_with_output(
1840            "[unknown]{{ name }}[/unknown]",
1841            &Data {
1842                name: "Hello".into(),
1843            },
1844            &theme,
1845            OutputMode::Text,
1846        )
1847        .unwrap();
1848
1849        // Text mode strips all tags
1850        assert_eq!(text_output, "Hello");
1851    }
1852
1853    #[test]
1854    fn test_tag_syntax_nested() {
1855        let theme = Theme::new()
1856            .add("bold", Style::new().bold().force_styling(true))
1857            .add("red", Style::new().red().force_styling(true));
1858
1859        #[derive(Serialize)]
1860        struct Data {
1861            word: String,
1862        }
1863
1864        let output = render_with_output(
1865            "[bold][red]{{ word }}[/red][/bold]",
1866            &Data {
1867                word: "test".into(),
1868            },
1869            &theme,
1870            OutputMode::Term,
1871        )
1872        .unwrap();
1873
1874        // Should contain both bold and red ANSI codes
1875        assert!(output.contains("\x1b[1m")); // Bold
1876        assert!(output.contains("\x1b[31m")); // Red
1877        assert!(output.contains("test"));
1878    }
1879
1880    #[test]
1881    fn test_tag_syntax_multiple_styles() {
1882        let theme = Theme::new()
1883            .add("title", Style::new().bold())
1884            .add("count", Style::new().cyan());
1885
1886        #[derive(Serialize)]
1887        struct Data {
1888            name: String,
1889            num: usize,
1890        }
1891
1892        let output = render_with_output(
1893            r#"[title]{{ name }}[/title]: [count]{{ num }}[/count]"#,
1894            &Data {
1895                name: "Items".into(),
1896                num: 42,
1897            },
1898            &theme,
1899            OutputMode::Text,
1900        )
1901        .unwrap();
1902
1903        assert_eq!(output, "Items: 42");
1904    }
1905
1906    #[test]
1907    fn test_tag_syntax_in_loop() {
1908        let theme = Theme::new().add("item", Style::new().cyan());
1909
1910        #[derive(Serialize)]
1911        struct Data {
1912            items: Vec<String>,
1913        }
1914
1915        let output = render_with_output(
1916            "{% for item in items %}[item]{{ item }}[/item]\n{% endfor %}",
1917            &Data {
1918                items: vec!["one".into(), "two".into()],
1919            },
1920            &theme,
1921            OutputMode::Text,
1922        )
1923        .unwrap();
1924
1925        assert_eq!(output, "one\ntwo\n");
1926    }
1927
1928    #[test]
1929    fn test_tag_syntax_literal_brackets() {
1930        // Tags that don't match our pattern should pass through
1931        let theme = Theme::new();
1932
1933        #[derive(Serialize)]
1934        struct Data {
1935            msg: String,
1936        }
1937
1938        let output = render_with_output(
1939            "Array: [1, 2, 3] and {{ msg }}",
1940            &Data { msg: "done".into() },
1941            &theme,
1942            OutputMode::Text,
1943        )
1944        .unwrap();
1945
1946        // Non-tag brackets preserved
1947        assert_eq!(output, "Array: [1, 2, 3] and done");
1948    }
1949
1950    // ============================================================================
1951    // Template Validation Tests
1952    // ============================================================================
1953
1954    #[test]
1955    fn test_validate_template_all_known_tags() {
1956        let theme = Theme::new()
1957            .add("title", Style::new().bold())
1958            .add("count", Style::new().cyan());
1959
1960        #[derive(Serialize)]
1961        struct Data {
1962            name: String,
1963        }
1964
1965        let result = validate_template(
1966            "[title]{{ name }}[/title]",
1967            &Data {
1968                name: "Hello".into(),
1969            },
1970            &theme,
1971        );
1972
1973        assert!(result.is_ok());
1974    }
1975
1976    #[test]
1977    fn test_validate_template_unknown_tag_fails() {
1978        let theme = Theme::new().add("known", Style::new().bold());
1979
1980        #[derive(Serialize)]
1981        struct Data {
1982            name: String,
1983        }
1984
1985        let result = validate_template(
1986            "[unknown]{{ name }}[/unknown]",
1987            &Data {
1988                name: "Hello".into(),
1989            },
1990            &theme,
1991        );
1992
1993        assert!(result.is_err());
1994        let err = result.unwrap_err();
1995        let errors = err
1996            .downcast_ref::<standout_bbparser::UnknownTagErrors>()
1997            .expect("Expected UnknownTagErrors");
1998        assert_eq!(errors.len(), 2); // open and close tags
1999    }
2000
2001    #[test]
2002    fn test_validate_template_multiple_unknown_tags() {
2003        let theme = Theme::new().add("known", Style::new().bold());
2004
2005        #[derive(Serialize)]
2006        struct Data {
2007            a: String,
2008            b: String,
2009        }
2010
2011        let result = validate_template(
2012            "[foo]{{ a }}[/foo] and [bar]{{ b }}[/bar]",
2013            &Data {
2014                a: "x".into(),
2015                b: "y".into(),
2016            },
2017            &theme,
2018        );
2019
2020        assert!(result.is_err());
2021        let err = result.unwrap_err();
2022        let errors = err
2023            .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2024            .expect("Expected UnknownTagErrors");
2025        assert_eq!(errors.len(), 4); // foo open/close + bar open/close
2026    }
2027
2028    #[test]
2029    fn test_validate_template_plain_text_passes() {
2030        let theme = Theme::new();
2031
2032        #[derive(Serialize)]
2033        struct Data {
2034            msg: String,
2035        }
2036
2037        let result = validate_template("Just plain {{ msg }}", &Data { msg: "hi".into() }, &theme);
2038
2039        assert!(result.is_ok());
2040    }
2041
2042    #[test]
2043    fn test_validate_template_mixed_known_and_unknown() {
2044        let theme = Theme::new().add("known", Style::new().bold());
2045
2046        #[derive(Serialize)]
2047        struct Data {
2048            a: String,
2049            b: String,
2050        }
2051
2052        let result = validate_template(
2053            "[known]{{ a }}[/known] [unknown]{{ b }}[/unknown]",
2054            &Data {
2055                a: "x".into(),
2056                b: "y".into(),
2057            },
2058            &theme,
2059        );
2060
2061        assert!(result.is_err());
2062        let err = result.unwrap_err();
2063        let errors = err
2064            .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2065            .expect("Expected UnknownTagErrors");
2066        // Only unknown tags should be reported
2067        assert_eq!(errors.len(), 2);
2068        assert!(errors.errors.iter().any(|e| e.tag == "unknown"));
2069    }
2070
2071    #[test]
2072    fn test_validate_template_syntax_error_fails() {
2073        let theme = Theme::new();
2074        #[derive(Serialize)]
2075        struct Data {}
2076
2077        // Missing closing braces
2078        let result = validate_template("{{ unclosed", &Data {}, &theme);
2079        assert!(result.is_err());
2080
2081        let err = result.unwrap_err();
2082        // Should NOT be UnknownTagErrors
2083        assert!(err
2084            .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2085            .is_none());
2086        // Should be a minijinja error
2087        let msg = err.to_string();
2088        assert!(
2089            msg.contains("syntax error") || msg.contains("unexpected"),
2090            "Got: {}",
2091            msg
2092        );
2093    }
2094
2095    #[test]
2096    fn test_render_auto_with_context_yaml_mode() {
2097        use crate::context::{ContextRegistry, RenderContext};
2098        use serde_json::json;
2099
2100        let theme = Theme::new();
2101        let data = json!({"name": "test", "count": 42});
2102
2103        // Setup context registry (though strictly not used for structured output)
2104        let registry = ContextRegistry::new();
2105        let render_ctx = RenderContext::new(OutputMode::Yaml, Some(80), &theme, &data);
2106
2107        // This call previously panicked
2108        let output = render_auto_with_context(
2109            "unused template",
2110            &data,
2111            &theme,
2112            OutputMode::Yaml,
2113            &registry,
2114            &render_ctx,
2115            None,
2116        )
2117        .unwrap();
2118
2119        assert!(output.contains("name: test"));
2120        assert!(output.contains("count: 42"));
2121    }
2122}