use serde::Serialize;
use standout_bbparser::{BBParser, TagTransform, UnknownTagBehavior};
use std::collections::HashMap;
use super::engine::{MiniJinjaEngine, TemplateEngine};
use crate::context::{ContextRegistry, RenderContext};
use crate::error::RenderError;
use crate::output::OutputMode;
use crate::style::Styles;
use crate::tabular::FlatDataSpec;
use crate::theme::{detect_color_mode, detect_icon_mode, ColorMode, Theme};
fn output_mode_to_transform(mode: OutputMode) -> TagTransform {
match mode {
OutputMode::Auto => {
if mode.should_use_color() {
TagTransform::Apply
} else {
TagTransform::Remove
}
}
OutputMode::Term => TagTransform::Apply,
OutputMode::Text => TagTransform::Remove,
OutputMode::TermDebug => TagTransform::Keep,
OutputMode::Json | OutputMode::Yaml | OutputMode::Xml | OutputMode::Csv => {
TagTransform::Remove
}
}
}
pub fn apply_style_tags(output: &str, styles: &Styles, mode: OutputMode) -> String {
let transform = output_mode_to_transform(mode);
let resolved_styles = styles.to_resolved_map();
let parser =
BBParser::new(resolved_styles, transform).unknown_behavior(UnknownTagBehavior::Passthrough);
parser.parse(output)
}
#[derive(Debug, Clone)]
pub struct RenderResult {
pub formatted: String,
pub raw: String,
}
impl RenderResult {
pub fn new(formatted: String, raw: String) -> Self {
Self { formatted, raw }
}
pub fn plain(text: String) -> Self {
Self {
formatted: text.clone(),
raw: text,
}
}
}
pub fn validate_template<T: Serialize>(
template: &str,
data: &T,
theme: &Theme,
) -> Result<(), Box<dyn std::error::Error>> {
let color_mode = detect_color_mode();
let styles = theme.resolve_styles(Some(color_mode));
let engine = MiniJinjaEngine::new();
let data_value = serde_json::to_value(data)?;
let minijinja_output = engine.render_template(template, &data_value)?;
let resolved_styles = styles.to_resolved_map();
let parser = BBParser::new(resolved_styles, TagTransform::Remove);
parser.validate(&minijinja_output)?;
Ok(())
}
pub fn render<T: Serialize>(
template: &str,
data: &T,
theme: &Theme,
) -> Result<String, RenderError> {
render_with_output(template, data, theme, OutputMode::Auto)
}
pub fn render_with_output<T: Serialize>(
template: &str,
data: &T,
theme: &Theme,
mode: OutputMode,
) -> Result<String, RenderError> {
let color_mode = detect_color_mode();
render_with_mode(template, data, theme, mode, color_mode)
}
pub fn render_with_mode<T: Serialize>(
template: &str,
data: &T,
theme: &Theme,
output_mode: OutputMode,
color_mode: ColorMode,
) -> Result<String, RenderError> {
theme
.validate()
.map_err(|e| RenderError::StyleError(e.to_string()))?;
let styles = theme.resolve_styles(Some(color_mode));
let engine = MiniJinjaEngine::new();
let data_value = serde_json::to_value(data)?;
let icon_context = build_icon_context(theme);
let template_output = if icon_context.is_empty() {
engine.render_template(template, &data_value)?
} else {
engine.render_with_context(template, &data_value, icon_context)?
};
let final_output = apply_style_tags(&template_output, &styles, output_mode);
Ok(final_output)
}
pub fn render_with_vars<T, K, V, I>(
template: &str,
data: &T,
theme: &Theme,
mode: OutputMode,
vars: I,
) -> Result<String, RenderError>
where
T: Serialize,
K: AsRef<str>,
V: Into<serde_json::Value>,
I: IntoIterator<Item = (K, V)>,
{
let color_mode = detect_color_mode();
let styles = theme.resolve_styles(Some(color_mode));
styles
.validate()
.map_err(|e| RenderError::StyleError(e.to_string()))?;
let mut context: HashMap<String, serde_json::Value> = build_icon_context(theme);
for (key, value) in vars {
context.insert(key.as_ref().to_string(), value.into());
}
let engine = MiniJinjaEngine::new();
let data_value = serde_json::to_value(data)?;
let template_output = engine.render_with_context(template, &data_value, context)?;
let final_output = apply_style_tags(&template_output, &styles, mode);
Ok(final_output)
}
pub fn render_auto<T: Serialize>(
template: &str,
data: &T,
theme: &Theme,
mode: OutputMode,
) -> Result<String, RenderError> {
if mode.is_structured() {
match mode {
OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
OutputMode::Xml => Ok(crate::util::serialize_to_xml(data)?),
OutputMode::Csv => {
let value = serde_json::to_value(data)?;
let (headers, rows) = crate::util::flatten_json_for_csv(&value);
let mut wtr = csv::Writer::from_writer(Vec::new());
wtr.write_record(&headers)?;
for row in rows {
wtr.write_record(&row)?;
}
let bytes = wtr.into_inner()?;
Ok(String::from_utf8(bytes)?)
}
_ => unreachable!("is_structured() returned true for non-structured mode"),
}
} else {
render_with_output(template, data, theme, mode)
}
}
pub fn render_auto_with_spec<T: Serialize>(
template: &str,
data: &T,
theme: &Theme,
mode: OutputMode,
spec: Option<&FlatDataSpec>,
) -> Result<String, RenderError> {
if mode.is_structured() {
match mode {
OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
OutputMode::Xml => Ok(crate::util::serialize_to_xml(data)?),
OutputMode::Csv => {
let value = serde_json::to_value(data)?;
let (headers, rows) = if let Some(s) = spec {
let headers = s.extract_header();
let rows: Vec<Vec<String>> = match value {
serde_json::Value::Array(items) => {
items.iter().map(|item| s.extract_row(item)).collect()
}
_ => vec![s.extract_row(&value)],
};
(headers, rows)
} else {
crate::util::flatten_json_for_csv(&value)
};
let mut wtr = csv::Writer::from_writer(Vec::new());
wtr.write_record(&headers)?;
for row in rows {
wtr.write_record(&row)?;
}
let bytes = wtr.into_inner()?;
Ok(String::from_utf8(bytes)?)
}
_ => unreachable!("is_structured() returned true for non-structured mode"),
}
} else {
render_with_output(template, data, theme, mode)
}
}
pub fn render_with_context<T: Serialize>(
template: &str,
data: &T,
theme: &Theme,
mode: OutputMode,
context_registry: &ContextRegistry,
render_context: &RenderContext,
template_registry: Option<&super::TemplateRegistry>,
) -> Result<String, RenderError> {
let color_mode = detect_color_mode();
let styles = theme.resolve_styles(Some(color_mode));
styles
.validate()
.map_err(|e| RenderError::StyleError(e.to_string()))?;
let mut engine = MiniJinjaEngine::new();
let template_content = if let Some(registry) = template_registry {
if let Ok(content) = registry.get_content(template) {
content
} else {
template.to_string()
}
} else {
template.to_string()
};
if let Some(registry) = template_registry {
for name in registry.names() {
if let Ok(content) = registry.get_content(name) {
engine.add_template(name, &content)?;
}
}
}
let icon_context = build_icon_context(theme);
let context = build_combined_context(data, context_registry, render_context, icon_context)?;
let data_value = serde_json::to_value(data)?;
let template_output = engine.render_with_context(&template_content, &data_value, context)?;
let final_output = apply_style_tags(&template_output, &styles, mode);
Ok(final_output)
}
pub fn render_auto_with_context<T: Serialize>(
template: &str,
data: &T,
theme: &Theme,
mode: OutputMode,
context_registry: &ContextRegistry,
render_context: &RenderContext,
template_registry: Option<&super::TemplateRegistry>,
) -> Result<String, RenderError> {
if mode.is_structured() {
match mode {
OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
OutputMode::Xml => Ok(crate::util::serialize_to_xml(data)?),
OutputMode::Csv => {
let value = serde_json::to_value(data)?;
let (headers, rows) = crate::util::flatten_json_for_csv(&value);
let mut wtr = csv::Writer::from_writer(Vec::new());
wtr.write_record(&headers)?;
for row in rows {
wtr.write_record(&row)?;
}
let bytes = wtr.into_inner()?;
Ok(String::from_utf8(bytes)?)
}
_ => unreachable!("is_structured() returned true for non-structured mode"),
}
} else {
render_with_context(
template,
data,
theme,
mode,
context_registry,
render_context,
template_registry,
)
}
}
fn build_icon_context(theme: &Theme) -> HashMap<String, serde_json::Value> {
if theme.icons().is_empty() {
return HashMap::new();
}
let icon_mode = detect_icon_mode();
let resolved = theme.resolve_icons(icon_mode);
let mut ctx = HashMap::new();
ctx.insert("icons".to_string(), serde_json::to_value(resolved).unwrap());
ctx
}
fn build_combined_context<T: Serialize>(
data: &T,
context_registry: &ContextRegistry,
render_context: &RenderContext,
icon_context: HashMap<String, serde_json::Value>,
) -> Result<HashMap<String, serde_json::Value>, RenderError> {
let context_values = context_registry.resolve(render_context);
let data_value = serde_json::to_value(data)?;
let mut combined: HashMap<String, serde_json::Value> = icon_context;
for (key, value) in context_values {
let json_val =
serde_json::to_value(value).map_err(|e| RenderError::ContextError(e.to_string()))?;
combined.insert(key, json_val);
}
if let Some(obj) = data_value.as_object() {
for (key, value) in obj {
combined.insert(key.clone(), value.clone());
}
}
Ok(combined)
}
pub fn render_auto_with_engine(
engine: &dyn super::TemplateEngine,
template: &str,
data: &serde_json::Value,
theme: &Theme,
mode: OutputMode,
context_registry: &ContextRegistry,
render_context: &RenderContext,
) -> Result<String, RenderError> {
if mode.is_structured() {
match mode {
OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
OutputMode::Xml => Ok(crate::util::serialize_to_xml(data)?),
OutputMode::Csv => {
let (headers, rows) = crate::util::flatten_json_for_csv(data);
let mut wtr = csv::Writer::from_writer(Vec::new());
wtr.write_record(&headers)?;
for row in rows {
wtr.write_record(&row)?;
}
let bytes = wtr.into_inner()?;
Ok(String::from_utf8(bytes)?)
}
_ => unreachable!("is_structured() returned true for non-structured mode"),
}
} else {
let color_mode = detect_color_mode();
let styles = theme.resolve_styles(Some(color_mode));
styles
.validate()
.map_err(|e| RenderError::StyleError(e.to_string()))?;
let icon_context = build_icon_context(theme);
let context_map =
build_combined_context(data, context_registry, render_context, icon_context)?;
let combined_value = serde_json::Value::Object(context_map.into_iter().collect());
let template_output = if engine.has_template(template) {
engine.render_named(template, &combined_value)?
} else {
engine.render_template(template, &combined_value)?
};
let final_output = apply_style_tags(&template_output, &styles, mode);
Ok(final_output)
}
}
pub fn render_auto_with_engine_split(
engine: &dyn super::TemplateEngine,
template: &str,
data: &serde_json::Value,
theme: &Theme,
mode: OutputMode,
context_registry: &ContextRegistry,
render_context: &RenderContext,
) -> Result<RenderResult, RenderError> {
if mode.is_structured() {
let output = match mode {
OutputMode::Json => serde_json::to_string_pretty(data)?,
OutputMode::Yaml => serde_yaml::to_string(data)?,
OutputMode::Xml => crate::util::serialize_to_xml(data)?,
OutputMode::Csv => {
let (headers, rows) = crate::util::flatten_json_for_csv(data);
let mut wtr = csv::Writer::from_writer(Vec::new());
wtr.write_record(&headers)?;
for row in rows {
wtr.write_record(&row)?;
}
let bytes = wtr.into_inner()?;
String::from_utf8(bytes)?
}
_ => unreachable!("is_structured() returned true for non-structured mode"),
};
Ok(RenderResult::plain(output))
} else {
let color_mode = detect_color_mode();
let styles = theme.resolve_styles(Some(color_mode));
styles
.validate()
.map_err(|e| RenderError::StyleError(e.to_string()))?;
let icon_context = build_icon_context(theme);
let context_map =
build_combined_context(data, context_registry, render_context, icon_context)?;
let combined_value = serde_json::Value::Object(context_map.into_iter().collect());
let raw_output = if engine.has_template(template) {
engine.render_named(template, &combined_value)?
} else {
engine.render_template(template, &combined_value)?
};
let formatted_output = apply_style_tags(&raw_output, &styles, mode);
let stripped_output = apply_style_tags(&raw_output, &styles, OutputMode::Text);
Ok(RenderResult::new(formatted_output, stripped_output))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tabular::{Column, FlatDataSpec, Width};
use crate::Theme;
use console::Style;
use minijinja::Value;
use serde::Serialize;
use serde_json::json;
#[derive(Serialize)]
struct SimpleData {
message: String,
}
#[derive(Serialize)]
struct ListData {
items: Vec<String>,
count: usize,
}
#[test]
fn test_render_with_output_text_no_ansi() {
let theme = Theme::new().add("red", Style::new().red());
let data = SimpleData {
message: "test".into(),
};
let output = render_with_output(
r#"[red]{{ message }}[/red]"#,
&data,
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "test");
assert!(!output.contains("\x1b["));
}
#[test]
fn test_render_with_output_term_has_ansi() {
let theme = Theme::new().add("green", Style::new().green().force_styling(true));
let data = SimpleData {
message: "success".into(),
};
let output = render_with_output(
r#"[green]{{ message }}[/green]"#,
&data,
&theme,
OutputMode::Term,
)
.unwrap();
assert!(output.contains("success"));
assert!(output.contains("\x1b["));
}
#[test]
fn test_render_unknown_style_shows_indicator() {
let theme = Theme::new();
let data = SimpleData {
message: "hello".into(),
};
let output = render_with_output(
r#"[unknown]{{ message }}[/unknown]"#,
&data,
&theme,
OutputMode::Term,
)
.unwrap();
assert_eq!(output, "[unknown?]hello[/unknown?]");
}
#[test]
fn test_render_unknown_style_stripped_in_text_mode() {
let theme = Theme::new();
let data = SimpleData {
message: "hello".into(),
};
let output = render_with_output(
r#"[unknown]{{ message }}[/unknown]"#,
&data,
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "hello");
}
#[test]
fn test_render_template_with_loop() {
let theme = Theme::new().add("item", Style::new().cyan());
let data = ListData {
items: vec!["one".into(), "two".into()],
count: 2,
};
let template = r#"{% for item in items %}[item]{{ item }}[/item]
{% endfor %}"#;
let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
assert_eq!(output, "one\ntwo\n");
}
#[test]
fn test_render_mixed_styled_and_plain() {
let theme = Theme::new().add("count", Style::new().bold());
let data = ListData {
items: vec![],
count: 42,
};
let template = r#"Total: [count]{{ count }}[/count] items"#;
let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
assert_eq!(output, "Total: 42 items");
}
#[test]
fn test_render_literal_string_styled() {
let theme = Theme::new().add("header", Style::new().bold());
#[derive(Serialize)]
struct Empty {}
let output = render_with_output(
r#"[header]Header[/header]"#,
&Empty {},
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "Header");
}
#[test]
fn test_empty_template() {
let theme = Theme::new();
#[derive(Serialize)]
struct Empty {}
let output = render_with_output("", &Empty {}, &theme, OutputMode::Text).unwrap();
assert_eq!(output, "");
}
#[test]
fn test_template_syntax_error() {
let theme = Theme::new();
#[derive(Serialize)]
struct Empty {}
let result = render_with_output("{{ unclosed", &Empty {}, &theme, OutputMode::Text);
assert!(result.is_err());
}
#[test]
fn test_style_tag_with_nested_data() {
#[derive(Serialize)]
struct Item {
name: String,
value: i32,
}
#[derive(Serialize)]
struct Container {
items: Vec<Item>,
}
let theme = Theme::new().add("name", Style::new().bold());
let data = Container {
items: vec![
Item {
name: "foo".into(),
value: 1,
},
Item {
name: "bar".into(),
value: 2,
},
],
};
let template = r#"{% for item in items %}[name]{{ item.name }}[/name]={{ item.value }}
{% endfor %}"#;
let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
assert_eq!(output, "foo=1\nbar=2\n");
}
#[test]
fn test_render_with_output_term_debug() {
let theme = Theme::new()
.add("title", Style::new().bold())
.add("count", Style::new().cyan());
#[derive(Serialize)]
struct Data {
name: String,
value: usize,
}
let data = Data {
name: "Test".into(),
value: 42,
};
let output = render_with_output(
r#"[title]{{ name }}[/title]: [count]{{ value }}[/count]"#,
&data,
&theme,
OutputMode::TermDebug,
)
.unwrap();
assert_eq!(output, "[title]Test[/title]: [count]42[/count]");
}
#[test]
fn test_render_with_output_term_debug_preserves_tags() {
let theme = Theme::new().add("known", Style::new().bold());
#[derive(Serialize)]
struct Data {
message: String,
}
let data = Data {
message: "hello".into(),
};
let output = render_with_output(
r#"[unknown]{{ message }}[/unknown]"#,
&data,
&theme,
OutputMode::TermDebug,
)
.unwrap();
assert_eq!(output, "[unknown]hello[/unknown]");
let output = render_with_output(
r#"[known]{{ message }}[/known]"#,
&data,
&theme,
OutputMode::TermDebug,
)
.unwrap();
assert_eq!(output, "[known]hello[/known]");
}
#[test]
fn test_render_auto_json_mode() {
use serde_json::json;
let theme = Theme::new();
let data = json!({"name": "test", "count": 42});
let output = render_auto("unused template", &data, &theme, OutputMode::Json).unwrap();
assert!(output.contains("\"name\": \"test\""));
assert!(output.contains("\"count\": 42"));
}
#[test]
fn test_render_auto_text_mode_uses_template() {
use serde_json::json;
let theme = Theme::new();
let data = json!({"name": "test"});
let output = render_auto("Name: {{ name }}", &data, &theme, OutputMode::Text).unwrap();
assert_eq!(output, "Name: test");
}
#[test]
fn test_render_auto_term_mode_uses_template() {
use serde_json::json;
let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
let data = json!({"name": "test"});
let output = render_auto(
r#"[bold]{{ name }}[/bold]"#,
&data,
&theme,
OutputMode::Term,
)
.unwrap();
assert!(output.contains("\x1b[1m"));
assert!(output.contains("test"));
}
#[test]
fn test_render_auto_json_with_struct() {
#[derive(Serialize)]
struct Report {
title: String,
items: Vec<String>,
}
let theme = Theme::new();
let data = Report {
title: "Summary".into(),
items: vec!["one".into(), "two".into()],
};
let output = render_auto("unused", &data, &theme, OutputMode::Json).unwrap();
assert!(output.contains("\"title\": \"Summary\""));
assert!(output.contains("\"items\""));
assert!(output.contains("\"one\""));
}
#[test]
fn test_render_with_alias() {
let theme = Theme::new()
.add("base", Style::new().bold())
.add("alias", "base");
let output = render_with_output(
r#"[alias]text[/alias]"#,
&serde_json::json!({}),
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "text");
}
#[test]
fn test_render_with_alias_chain() {
let theme = Theme::new()
.add("muted", Style::new().dim())
.add("disabled", "muted")
.add("timestamp", "disabled");
let output = render_with_output(
r#"[timestamp]12:00[/timestamp]"#,
&serde_json::json!({}),
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "12:00");
}
#[test]
fn test_render_fails_with_dangling_alias() {
let theme = Theme::new().add("orphan", "missing");
let result = render_with_output(
r#"[orphan]text[/orphan]"#,
&serde_json::json!({}),
&theme,
OutputMode::Text,
);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("orphan"));
assert!(err.to_string().contains("missing"));
}
#[test]
fn test_render_fails_with_cycle() {
let theme = Theme::new().add("a", "b").add("b", "a");
let result = render_with_output(
r#"[a]text[/a]"#,
&serde_json::json!({}),
&theme,
OutputMode::Text,
);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cycle"));
}
#[test]
fn test_three_layer_styling_pattern() {
let theme = Theme::new()
.add("dim_style", Style::new().dim())
.add("cyan_bold", Style::new().cyan().bold())
.add("yellow_bg", Style::new().on_yellow())
.add("muted", "dim_style")
.add("accent", "cyan_bold")
.add("highlighted", "yellow_bg")
.add("timestamp", "muted")
.add("title", "accent")
.add("selected_item", "highlighted");
assert!(theme.validate().is_ok());
let output = render_with_output(
r#"[timestamp]{{ time }}[/timestamp] - [title]{{ name }}[/title]"#,
&serde_json::json!({"time": "12:00", "name": "Report"}),
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "12:00 - Report");
}
#[test]
fn test_render_auto_yaml_mode() {
use serde_json::json;
let theme = Theme::new();
let data = json!({"name": "test", "count": 42});
let output = render_auto("unused template", &data, &theme, OutputMode::Yaml).unwrap();
assert!(output.contains("name: test"));
assert!(output.contains("count: 42"));
}
#[test]
fn test_render_auto_xml_mode_named_struct() {
let theme = Theme::new();
#[derive(Serialize)]
#[serde(rename = "root")]
struct Data {
name: String,
count: usize,
}
let data = Data {
name: "test".into(),
count: 42,
};
let output = render_auto("unused template", &data, &theme, OutputMode::Xml).unwrap();
assert!(output.contains("<root>"));
assert!(output.contains("<name>test</name>"));
}
#[test]
fn test_render_auto_xml_mode_json_map() {
use serde_json::json;
let theme = Theme::new();
let data = json!({"name": "test", "count": 42});
let output = render_auto("unused template", &data, &theme, OutputMode::Xml).unwrap();
assert!(output.contains("<data>"));
assert!(output.contains("<name>test</name>"));
assert!(output.contains("<count>42</count>"));
}
#[test]
fn test_render_auto_xml_mode_nested_map() {
use serde_json::json;
let theme = Theme::new();
let data = json!({"user": {"name": "Alice", "age": 30}});
let output = render_auto("unused template", &data, &theme, OutputMode::Xml).unwrap();
assert!(output.contains("<data>"));
assert!(output.contains("<user>"));
assert!(output.contains("<name>Alice</name>"));
}
#[test]
fn test_render_auto_xml_mode_with_array() {
use serde_json::json;
let theme = Theme::new();
let data = json!({"items": ["a", "b", "c"]});
let output = render_auto("unused template", &data, &theme, OutputMode::Xml).unwrap();
assert!(output.contains("<data>"));
assert!(output.contains("<items>a</items>"));
}
#[test]
fn test_render_auto_csv_mode_auto_flatten() {
use serde_json::json;
let theme = Theme::new();
let data = json!([
{"name": "Alice", "stats": {"score": 10}},
{"name": "Bob", "stats": {"score": 20}}
]);
let output = render_auto("unused", &data, &theme, OutputMode::Csv).unwrap();
assert!(output.contains("name,stats.score"));
assert!(output.contains("Alice,10"));
assert!(output.contains("Bob,20"));
}
#[test]
fn test_render_auto_csv_mode_with_spec() {
let theme = Theme::new();
let data = json!([
{"name": "Alice", "meta": {"age": 30, "role": "admin"}},
{"name": "Bob", "meta": {"age": 25, "role": "user"}}
]);
let spec = FlatDataSpec::builder()
.column(Column::new(Width::Fixed(10)).key("name"))
.column(
Column::new(Width::Fixed(10))
.key("meta.role")
.header("Role"),
)
.build();
let output =
render_auto_with_spec("unused", &data, &theme, OutputMode::Csv, Some(&spec)).unwrap();
let lines: Vec<&str> = output.lines().collect();
assert_eq!(lines[0], "name,Role");
assert!(lines.contains(&"Alice,admin"));
assert!(lines.contains(&"Bob,user"));
assert!(!output.contains("30"));
}
#[test]
fn test_render_auto_csv_mode_with_array_field() {
use serde_json::json;
let theme = Theme::new();
let data = json!([
{"name": "Alice", "tags": ["admin", "user"]},
{"name": "Bob", "tags": ["user"]}
]);
let output = render_auto("unused", &data, &theme, OutputMode::Csv).unwrap();
assert!(output.contains("tags.0"));
assert!(output.contains("tags.1"));
assert!(output.contains("admin"));
assert!(output.contains("user"));
assert!(!output.contains("[\""));
}
#[test]
fn test_render_with_context_basic() {
use crate::context::{ContextRegistry, RenderContext};
#[derive(Serialize)]
struct Data {
name: String,
}
let theme = Theme::new();
let data = Data {
name: "Alice".into(),
};
let json_data = serde_json::to_value(&data).unwrap();
let mut registry = ContextRegistry::new();
registry.add_static("version", Value::from("1.0.0"));
let render_ctx = RenderContext::new(OutputMode::Text, Some(80), &theme, &json_data);
let output = render_with_context(
"{{ name }} (v{{ version }})",
&data,
&theme,
OutputMode::Text,
®istry,
&render_ctx,
None,
)
.unwrap();
assert_eq!(output, "Alice (v1.0.0)");
}
#[test]
fn test_render_with_context_dynamic_provider() {
use crate::context::{ContextRegistry, RenderContext};
#[derive(Serialize)]
struct Data {
message: String,
}
let theme = Theme::new();
let data = Data {
message: "Hello".into(),
};
let json_data = serde_json::to_value(&data).unwrap();
let mut registry = ContextRegistry::new();
registry.add_provider("terminal_width", |ctx: &RenderContext| {
Value::from(ctx.terminal_width.unwrap_or(80))
});
let render_ctx = RenderContext::new(OutputMode::Text, Some(120), &theme, &json_data);
let output = render_with_context(
"{{ message }} (width={{ terminal_width }})",
&data,
&theme,
OutputMode::Text,
®istry,
&render_ctx,
None,
)
.unwrap();
assert_eq!(output, "Hello (width=120)");
}
#[test]
fn test_render_with_context_data_takes_precedence() {
use crate::context::{ContextRegistry, RenderContext};
#[derive(Serialize)]
struct Data {
value: String,
}
let theme = Theme::new();
let data = Data {
value: "from_data".into(),
};
let json_data = serde_json::to_value(&data).unwrap();
let mut registry = ContextRegistry::new();
registry.add_static("value", Value::from("from_context"));
let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
let output = render_with_context(
"{{ value }}",
&data,
&theme,
OutputMode::Text,
®istry,
&render_ctx,
None,
)
.unwrap();
assert_eq!(output, "from_data");
}
#[test]
fn test_render_with_context_empty_registry() {
use crate::context::{ContextRegistry, RenderContext};
#[derive(Serialize)]
struct Data {
name: String,
}
let theme = Theme::new();
let data = Data {
name: "Test".into(),
};
let json_data = serde_json::to_value(&data).unwrap();
let registry = ContextRegistry::new();
let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
let output = render_with_context(
"{{ name }}",
&data,
&theme,
OutputMode::Text,
®istry,
&render_ctx,
None,
)
.unwrap();
assert_eq!(output, "Test");
}
#[test]
fn test_render_auto_with_context_json_mode() {
use crate::context::{ContextRegistry, RenderContext};
#[derive(Serialize)]
struct Data {
count: usize,
}
let theme = Theme::new();
let data = Data { count: 42 };
let json_data = serde_json::to_value(&data).unwrap();
let mut registry = ContextRegistry::new();
registry.add_static("extra", Value::from("ignored"));
let render_ctx = RenderContext::new(OutputMode::Json, None, &theme, &json_data);
let output = render_auto_with_context(
"unused template {{ extra }}",
&data,
&theme,
OutputMode::Json,
®istry,
&render_ctx,
None,
)
.unwrap();
assert!(output.contains("\"count\": 42"));
assert!(!output.contains("ignored"));
}
#[test]
fn test_render_auto_with_context_text_mode() {
use crate::context::{ContextRegistry, RenderContext};
#[derive(Serialize)]
struct Data {
count: usize,
}
let theme = Theme::new();
let data = Data { count: 42 };
let json_data = serde_json::to_value(&data).unwrap();
let mut registry = ContextRegistry::new();
registry.add_static("label", Value::from("Items"));
let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
let output = render_auto_with_context(
"{{ label }}: {{ count }}",
&data,
&theme,
OutputMode::Text,
®istry,
&render_ctx,
None,
)
.unwrap();
assert_eq!(output, "Items: 42");
}
#[test]
fn test_render_with_context_provider_uses_output_mode() {
use crate::context::{ContextRegistry, RenderContext};
#[derive(Serialize)]
struct Data {}
let theme = Theme::new();
let data = Data {};
let json_data = serde_json::to_value(&data).unwrap();
let mut registry = ContextRegistry::new();
registry.add_provider("mode", |ctx: &RenderContext| {
Value::from(format!("{:?}", ctx.output_mode))
});
let render_ctx = RenderContext::new(OutputMode::Term, None, &theme, &json_data);
let output = render_with_context(
"Mode: {{ mode }}",
&data,
&theme,
OutputMode::Term,
®istry,
&render_ctx,
None,
)
.unwrap();
assert_eq!(output, "Mode: Term");
}
#[test]
fn test_render_with_context_nested_data() {
use crate::context::{ContextRegistry, RenderContext};
#[derive(Serialize)]
struct Item {
name: String,
}
#[derive(Serialize)]
struct Data {
items: Vec<Item>,
}
let theme = Theme::new();
let data = Data {
items: vec![Item { name: "one".into() }, Item { name: "two".into() }],
};
let json_data = serde_json::to_value(&data).unwrap();
let mut registry = ContextRegistry::new();
registry.add_static("prefix", Value::from("- "));
let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
let output = render_with_context(
"{% for item in items %}{{ prefix }}{{ item.name }}\n{% endfor %}",
&data,
&theme,
OutputMode::Text,
®istry,
&render_ctx,
None,
)
.unwrap();
assert_eq!(output, "- one\n- two\n");
}
#[test]
fn test_render_with_mode_forces_color_mode() {
use console::Style;
#[derive(Serialize)]
struct Data {
status: String,
}
let theme = Theme::new().add_adaptive(
"status",
Style::new(), Some(Style::new().black().force_styling(true)), Some(Style::new().white().force_styling(true)), );
let data = Data {
status: "test".into(),
};
let dark_output = render_with_mode(
r#"[status]{{ status }}[/status]"#,
&data,
&theme,
OutputMode::Term,
ColorMode::Dark,
)
.unwrap();
let light_output = render_with_mode(
r#"[status]{{ status }}[/status]"#,
&data,
&theme,
OutputMode::Term,
ColorMode::Light,
)
.unwrap();
assert_ne!(dark_output, light_output);
assert!(
dark_output.contains("\x1b[37"),
"Expected white (37) in dark mode"
);
assert!(
light_output.contains("\x1b[30"),
"Expected black (30) in light mode"
);
}
#[test]
fn test_tag_syntax_text_mode() {
let theme = Theme::new().add("title", Style::new().bold());
#[derive(Serialize)]
struct Data {
name: String,
}
let output = render_with_output(
"[title]{{ name }}[/title]",
&Data {
name: "Hello".into(),
},
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "Hello");
}
#[test]
fn test_tag_syntax_term_mode() {
let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
#[derive(Serialize)]
struct Data {
name: String,
}
let output = render_with_output(
"[bold]{{ name }}[/bold]",
&Data {
name: "Hello".into(),
},
&theme,
OutputMode::Term,
)
.unwrap();
assert!(output.contains("\x1b[1m"));
assert!(output.contains("Hello"));
}
#[test]
fn test_tag_syntax_debug_mode() {
let theme = Theme::new().add("title", Style::new().bold());
#[derive(Serialize)]
struct Data {
name: String,
}
let output = render_with_output(
"[title]{{ name }}[/title]",
&Data {
name: "Hello".into(),
},
&theme,
OutputMode::TermDebug,
)
.unwrap();
assert_eq!(output, "[title]Hello[/title]");
}
#[test]
fn test_tag_syntax_unknown_tag_passthrough() {
let theme = Theme::new().add("known", Style::new().bold());
#[derive(Serialize)]
struct Data {
name: String,
}
let output = render_with_output(
"[unknown]{{ name }}[/unknown]",
&Data {
name: "Hello".into(),
},
&theme,
OutputMode::Term,
)
.unwrap();
assert!(output.contains("[unknown?]"));
assert!(output.contains("[/unknown?]"));
assert!(output.contains("Hello"));
let text_output = render_with_output(
"[unknown]{{ name }}[/unknown]",
&Data {
name: "Hello".into(),
},
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(text_output, "Hello");
}
#[test]
fn test_tag_syntax_nested() {
let theme = Theme::new()
.add("bold", Style::new().bold().force_styling(true))
.add("red", Style::new().red().force_styling(true));
#[derive(Serialize)]
struct Data {
word: String,
}
let output = render_with_output(
"[bold][red]{{ word }}[/red][/bold]",
&Data {
word: "test".into(),
},
&theme,
OutputMode::Term,
)
.unwrap();
assert!(output.contains("\x1b[1m")); assert!(output.contains("\x1b[31m")); assert!(output.contains("test"));
}
#[test]
fn test_tag_syntax_multiple_styles() {
let theme = Theme::new()
.add("title", Style::new().bold())
.add("count", Style::new().cyan());
#[derive(Serialize)]
struct Data {
name: String,
num: usize,
}
let output = render_with_output(
r#"[title]{{ name }}[/title]: [count]{{ num }}[/count]"#,
&Data {
name: "Items".into(),
num: 42,
},
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "Items: 42");
}
#[test]
fn test_tag_syntax_in_loop() {
let theme = Theme::new().add("item", Style::new().cyan());
#[derive(Serialize)]
struct Data {
items: Vec<String>,
}
let output = render_with_output(
"{% for item in items %}[item]{{ item }}[/item]\n{% endfor %}",
&Data {
items: vec!["one".into(), "two".into()],
},
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "one\ntwo\n");
}
#[test]
fn test_tag_syntax_literal_brackets() {
let theme = Theme::new();
#[derive(Serialize)]
struct Data {
msg: String,
}
let output = render_with_output(
"Array: [1, 2, 3] and {{ msg }}",
&Data { msg: "done".into() },
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "Array: [1, 2, 3] and done");
}
#[test]
fn test_validate_template_all_known_tags() {
let theme = Theme::new()
.add("title", Style::new().bold())
.add("count", Style::new().cyan());
#[derive(Serialize)]
struct Data {
name: String,
}
let result = validate_template(
"[title]{{ name }}[/title]",
&Data {
name: "Hello".into(),
},
&theme,
);
assert!(result.is_ok());
}
#[test]
fn test_validate_template_unknown_tag_fails() {
let theme = Theme::new().add("known", Style::new().bold());
#[derive(Serialize)]
struct Data {
name: String,
}
let result = validate_template(
"[unknown]{{ name }}[/unknown]",
&Data {
name: "Hello".into(),
},
&theme,
);
assert!(result.is_err());
let err = result.unwrap_err();
let errors = err
.downcast_ref::<standout_bbparser::UnknownTagErrors>()
.expect("Expected UnknownTagErrors");
assert_eq!(errors.len(), 2); }
#[test]
fn test_validate_template_multiple_unknown_tags() {
let theme = Theme::new().add("known", Style::new().bold());
#[derive(Serialize)]
struct Data {
a: String,
b: String,
}
let result = validate_template(
"[foo]{{ a }}[/foo] and [bar]{{ b }}[/bar]",
&Data {
a: "x".into(),
b: "y".into(),
},
&theme,
);
assert!(result.is_err());
let err = result.unwrap_err();
let errors = err
.downcast_ref::<standout_bbparser::UnknownTagErrors>()
.expect("Expected UnknownTagErrors");
assert_eq!(errors.len(), 4); }
#[test]
fn test_validate_template_plain_text_passes() {
let theme = Theme::new();
#[derive(Serialize)]
struct Data {
msg: String,
}
let result = validate_template("Just plain {{ msg }}", &Data { msg: "hi".into() }, &theme);
assert!(result.is_ok());
}
#[test]
fn test_validate_template_mixed_known_and_unknown() {
let theme = Theme::new().add("known", Style::new().bold());
#[derive(Serialize)]
struct Data {
a: String,
b: String,
}
let result = validate_template(
"[known]{{ a }}[/known] [unknown]{{ b }}[/unknown]",
&Data {
a: "x".into(),
b: "y".into(),
},
&theme,
);
assert!(result.is_err());
let err = result.unwrap_err();
let errors = err
.downcast_ref::<standout_bbparser::UnknownTagErrors>()
.expect("Expected UnknownTagErrors");
assert_eq!(errors.len(), 2);
assert!(errors.errors.iter().any(|e| e.tag == "unknown"));
}
#[test]
fn test_validate_template_syntax_error_fails() {
let theme = Theme::new();
#[derive(Serialize)]
struct Data {}
let result = validate_template("{{ unclosed", &Data {}, &theme);
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err
.downcast_ref::<standout_bbparser::UnknownTagErrors>()
.is_none());
let msg = err.to_string();
assert!(
msg.contains("syntax error") || msg.contains("unexpected"),
"Got: {}",
msg
);
}
#[test]
fn test_render_auto_with_context_yaml_mode() {
use crate::context::{ContextRegistry, RenderContext};
use serde_json::json;
let theme = Theme::new();
let data = json!({"name": "test", "count": 42});
let registry = ContextRegistry::new();
let render_ctx = RenderContext::new(OutputMode::Yaml, Some(80), &theme, &data);
let output = render_auto_with_context(
"unused template",
&data,
&theme,
OutputMode::Yaml,
®istry,
&render_ctx,
None,
)
.unwrap();
assert!(output.contains("name: test"));
assert!(output.contains("count: 42"));
}
#[test]
#[serial_test::serial]
fn test_render_with_icons_classic() {
use crate::{set_icon_detector, IconDefinition, IconMode};
set_icon_detector(|| IconMode::Classic);
let theme = Theme::new()
.add_icon(
"check",
IconDefinition::new("[ok]").with_nerdfont("\u{f00c}"),
)
.add_icon("arrow", IconDefinition::new(">>"));
let data = SimpleData {
message: "done".into(),
};
let output = render_with_output(
"{{ icons.check }} {{ message }} {{ icons.arrow }}",
&data,
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "[ok] done >>");
}
#[test]
#[serial_test::serial]
fn test_render_with_icons_nerdfont() {
use crate::{set_icon_detector, IconDefinition, IconMode};
set_icon_detector(|| IconMode::NerdFont);
let theme = Theme::new().add_icon(
"check",
IconDefinition::new("[ok]").with_nerdfont("\u{f00c}"),
);
let data = SimpleData {
message: "done".into(),
};
let output = render_with_output(
"{{ icons.check }} {{ message }}",
&data,
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "\u{f00c} done");
set_icon_detector(|| IconMode::Classic);
}
#[test]
fn test_render_without_icons_no_overhead() {
let theme = Theme::new();
let data = SimpleData {
message: "hello".into(),
};
let output = render_with_output("{{ message }}", &data, &theme, OutputMode::Text).unwrap();
assert_eq!(output, "hello");
}
#[test]
#[serial_test::serial]
fn test_render_with_icons_and_styles() {
use crate::{set_icon_detector, IconDefinition, IconMode};
set_icon_detector(|| IconMode::Classic);
let theme = Theme::new()
.add("title", Style::new().bold())
.add_icon("bullet", IconDefinition::new("-"));
let data = SimpleData {
message: "item".into(),
};
let output = render_with_output(
"{{ icons.bullet }} [title]{{ message }}[/title]",
&data,
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "- item");
}
#[test]
#[serial_test::serial]
fn test_render_with_vars_includes_icons() {
use crate::{set_icon_detector, IconDefinition, IconMode};
set_icon_detector(|| IconMode::Classic);
let theme = Theme::new().add_icon("star", IconDefinition::new("*"));
let data = SimpleData {
message: "hello".into(),
};
let vars = std::collections::HashMap::from([("version", "1.0")]);
let output = render_with_vars(
"{{ icons.star }} {{ message }} v{{ version }}",
&data,
&theme,
OutputMode::Text,
vars,
)
.unwrap();
assert_eq!(output, "* hello v1.0");
}
#[test]
#[serial_test::serial]
fn test_render_with_context_includes_icons() {
use crate::context::{ContextRegistry, RenderContext};
use crate::{set_icon_detector, IconDefinition, IconMode};
set_icon_detector(|| IconMode::Classic);
let theme = Theme::new().add_icon("dot", IconDefinition::new("."));
let data = SimpleData {
message: "test".into(),
};
let mut registry = ContextRegistry::new();
registry.add_static("extra", Value::from("ctx"));
let json_data = serde_json::to_value(&data).unwrap();
let render_ctx = RenderContext::new(OutputMode::Text, Some(80), &theme, &json_data);
let output = render_with_context(
"{{ icons.dot }} {{ message }} {{ extra }}",
&data,
&theme,
OutputMode::Text,
®istry,
&render_ctx,
None,
)
.unwrap();
assert_eq!(output, ". test ctx");
}
#[test]
#[serial_test::serial]
fn test_render_yaml_from_theme_with_icons() {
use crate::{set_icon_detector, IconMode};
set_icon_detector(|| IconMode::Classic);
let theme = Theme::from_yaml(
r#"
title:
fg: cyan
bold: true
icons:
check:
classic: "[ok]"
nerdfont: "nf"
"#,
)
.unwrap();
let data = SimpleData {
message: "done".into(),
};
let output = render_with_output(
"{{ icons.check }} [title]{{ message }}[/title]",
&data,
&theme,
OutputMode::Text,
)
.unwrap();
assert_eq!(output, "[ok] done");
}
}