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