1use minijinja::{Environment, Error, Value};
75use serde::Serialize;
76use standout_bbparser::{BBParser, TagTransform, UnknownTagBehavior};
77use std::collections::HashMap;
78
79use super::filters::register_filters;
80use crate::context::{ContextRegistry, RenderContext};
81use crate::output::OutputMode;
82use crate::style::Styles;
83use crate::tabular::FlatDataSpec;
84use crate::theme::{detect_color_mode, ColorMode, Theme};
85
86fn 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 OutputMode::Json | OutputMode::Yaml | OutputMode::Xml | OutputMode::Csv => {
101 TagTransform::Remove
102 }
103 }
104}
105
106pub(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
117pub 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 let mut env = Environment::new();
172 register_filters(&mut env);
173
174 env.add_template_owned("_inline".to_string(), template.to_string())?;
175
176 let tmpl = env.get_template("_inline")?;
177 let minijinja_output = tmpl.render(data)?;
178
179 let resolved_styles = styles.to_resolved_map();
181 let parser = BBParser::new(resolved_styles, TagTransform::Remove);
182 parser.validate(&minijinja_output)?;
183
184 Ok(())
185}
186
187pub fn render<T: Serialize>(template: &str, data: &T, theme: &Theme) -> Result<String, Error> {
217 render_with_output(template, data, theme, OutputMode::Auto)
218}
219
220pub fn render_with_output<T: Serialize>(
264 template: &str,
265 data: &T,
266 theme: &Theme,
267 mode: OutputMode,
268) -> Result<String, Error> {
269 let color_mode = detect_color_mode();
271 render_with_mode(template, data, theme, mode, color_mode)
272}
273
274pub fn render_with_mode<T: Serialize>(
324 template: &str,
325 data: &T,
326 theme: &Theme,
327 output_mode: OutputMode,
328 color_mode: ColorMode,
329) -> Result<String, Error> {
330 theme
332 .validate()
333 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
334
335 let styles = theme.resolve_styles(Some(color_mode));
337
338 let mut env = Environment::new();
339 register_filters(&mut env);
340
341 env.add_template_owned("_inline".to_string(), template.to_string())?;
342 let tmpl = env.get_template("_inline")?;
343
344 let minijinja_output = tmpl.render(data)?;
346
347 let final_output = apply_style_tags(&minijinja_output, &styles, output_mode);
349
350 Ok(final_output)
351}
352
353pub fn render_with_vars<T, K, V, I>(
394 template: &str,
395 data: &T,
396 theme: &Theme,
397 mode: OutputMode,
398 vars: I,
399) -> Result<String, Error>
400where
401 T: Serialize,
402 K: AsRef<str>,
403 V: Into<Value>,
404 I: IntoIterator<Item = (K, V)>,
405{
406 let color_mode = detect_color_mode();
407 let styles = theme.resolve_styles(Some(color_mode));
408
409 styles
411 .validate()
412 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
413
414 let mut env = Environment::new();
415 register_filters(&mut env);
416
417 env.add_template_owned("_inline".to_string(), template.to_string())?;
418 let tmpl = env.get_template("_inline")?;
419
420 let data_json = serde_json::to_value(data)
422 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
423
424 let mut context: HashMap<String, Value> = HashMap::new();
426
427 for (key, value) in vars {
429 context.insert(key.as_ref().to_string(), value.into());
430 }
431
432 if let serde_json::Value::Object(map) = data_json {
434 for (key, value) in map {
435 context.insert(key, Value::from_serialize(&value));
436 }
437 }
438
439 let minijinja_output = tmpl.render(&context)?;
441
442 let final_output = apply_style_tags(&minijinja_output, &styles, mode);
444
445 Ok(final_output)
446}
447
448pub fn render_auto<T: Serialize>(
495 template: &str,
496 data: &T,
497 theme: &Theme,
498 mode: OutputMode,
499) -> Result<String, Error> {
500 if mode.is_structured() {
501 match mode {
502 OutputMode::Json => serde_json::to_string_pretty(data)
503 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
504 OutputMode::Yaml => serde_yaml::to_string(data)
505 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
506 OutputMode::Xml => quick_xml::se::to_string(data)
507 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
508 OutputMode::Csv => {
509 let value = serde_json::to_value(data).map_err(|e| {
510 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
511 })?;
512 let (headers, rows) = crate::util::flatten_json_for_csv(&value);
513
514 let mut wtr = csv::Writer::from_writer(Vec::new());
515 wtr.write_record(&headers).map_err(|e| {
516 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
517 })?;
518 for row in rows {
519 wtr.write_record(&row).map_err(|e| {
520 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
521 })?;
522 }
523 let bytes = wtr.into_inner().map_err(|e| {
524 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
525 })?;
526 String::from_utf8(bytes)
527 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))
528 }
529 _ => unreachable!("is_structured() returned true for non-structured mode"),
530 }
531 } else {
532 render_with_output(template, data, theme, mode)
533 }
534}
535
536pub fn render_auto_with_spec<T: Serialize>(
550 template: &str,
551 data: &T,
552 theme: &Theme,
553 mode: OutputMode,
554 spec: Option<&FlatDataSpec>,
555) -> Result<String, Error> {
556 if mode.is_structured() {
557 match mode {
558 OutputMode::Json => serde_json::to_string_pretty(data)
559 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
560 OutputMode::Yaml => serde_yaml::to_string(data)
561 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
562 OutputMode::Xml => quick_xml::se::to_string(data)
563 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
564 OutputMode::Csv => {
565 let value = serde_json::to_value(data).map_err(|e| {
566 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
567 })?;
568
569 let (headers, rows) = if let Some(s) = spec {
570 let headers = s.extract_header();
572 let rows: Vec<Vec<String>> = match value {
573 serde_json::Value::Array(items) => {
574 items.iter().map(|item| s.extract_row(item)).collect()
575 }
576 _ => vec![s.extract_row(&value)],
577 };
578 (headers, rows)
579 } else {
580 crate::util::flatten_json_for_csv(&value)
582 };
583
584 let mut wtr = csv::Writer::from_writer(Vec::new());
585 wtr.write_record(&headers).map_err(|e| {
586 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
587 })?;
588 for row in rows {
589 wtr.write_record(&row).map_err(|e| {
590 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
591 })?;
592 }
593 let bytes = wtr.into_inner().map_err(|e| {
594 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
595 })?;
596 String::from_utf8(bytes)
597 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))
598 }
599 _ => unreachable!("is_structured() returned true for non-structured mode"),
600 }
601 } else {
602 render_with_output(template, data, theme, mode)
603 }
604}
605
606pub fn render_with_context<T: Serialize>(
671 template: &str,
672 data: &T,
673 theme: &Theme,
674 mode: OutputMode,
675 context_registry: &ContextRegistry,
676 render_context: &RenderContext,
677 template_registry: Option<&super::TemplateRegistry>,
678) -> Result<String, Error> {
679 let color_mode = detect_color_mode();
680 let styles = theme.resolve_styles(Some(color_mode));
681
682 styles
684 .validate()
685 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
686
687 let mut env = Environment::new();
688 register_filters(&mut env);
689
690 let template_content = if let Some(registry) = template_registry {
694 if let Ok(content) = registry.get_content(template) {
695 content
696 } else {
697 template.to_string()
698 }
699 } else {
700 template.to_string()
701 };
702
703 env.add_template_owned("_inline".to_string(), template_content)?;
704
705 if let Some(registry) = template_registry {
707 for name in registry.names() {
708 if let Ok(content) = registry.get_content(name) {
709 if name != "_inline" {
711 env.add_template_owned(name.to_string(), content)?;
712 }
713 }
714 }
715 }
716
717 let tmpl = env.get_template("_inline")?;
718
719 let combined = build_combined_context(data, context_registry, render_context)?;
722
723 let minijinja_output = tmpl.render(&combined)?;
725
726 let final_output = apply_style_tags(&minijinja_output, &styles, mode);
728
729 Ok(final_output)
730}
731
732pub fn render_auto_with_context<T: Serialize>(
799 template: &str,
800 data: &T,
801 theme: &Theme,
802 mode: OutputMode,
803 context_registry: &ContextRegistry,
804 render_context: &RenderContext,
805 template_registry: Option<&super::TemplateRegistry>,
806) -> Result<String, Error> {
807 if mode.is_structured() {
808 match mode {
809 OutputMode::Json => serde_json::to_string_pretty(data)
810 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
811 OutputMode::Yaml => serde_yaml::to_string(data)
812 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
813 OutputMode::Xml => quick_xml::se::to_string(data)
814 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())),
815 OutputMode::Csv => {
816 let value = serde_json::to_value(data).map_err(|e| {
817 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
818 })?;
819 let (headers, rows) = crate::util::flatten_json_for_csv(&value);
820
821 let mut wtr = csv::Writer::from_writer(Vec::new());
822 wtr.write_record(&headers).map_err(|e| {
823 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
824 })?;
825 for row in rows {
826 wtr.write_record(&row).map_err(|e| {
827 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
828 })?;
829 }
830 let bytes = wtr.into_inner().map_err(|e| {
831 Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string())
832 })?;
833 String::from_utf8(bytes)
834 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))
835 }
836 _ => unreachable!("is_structured() returned true for non-structured mode"),
837 }
838 } else {
839 render_with_context(
840 template,
841 data,
842 theme,
843 mode,
844 context_registry,
845 render_context,
846 template_registry,
847 )
848 }
849}
850
851fn build_combined_context<T: Serialize>(
855 data: &T,
856 context_registry: &ContextRegistry,
857 render_context: &RenderContext,
858) -> Result<HashMap<String, Value>, Error> {
859 let context_values = context_registry.resolve(render_context);
861
862 let data_value = serde_json::to_value(data)
864 .map_err(|e| Error::new(minijinja::ErrorKind::InvalidOperation, e.to_string()))?;
865
866 let mut combined: HashMap<String, Value> = HashMap::new();
867
868 for (key, value) in context_values {
870 combined.insert(key, value);
871 }
872
873 if let Some(obj) = data_value.as_object() {
875 for (key, value) in obj {
876 let minijinja_value = json_to_minijinja(value);
877 combined.insert(key.clone(), minijinja_value);
878 }
879 }
880
881 Ok(combined)
882}
883
884fn json_to_minijinja(json: &serde_json::Value) -> Value {
886 match json {
887 serde_json::Value::Null => Value::from(()),
888 serde_json::Value::Bool(b) => Value::from(*b),
889 serde_json::Value::Number(n) => {
890 if let Some(i) = n.as_i64() {
891 Value::from(i)
892 } else if let Some(f) = n.as_f64() {
893 Value::from(f)
894 } else {
895 Value::from(n.to_string())
896 }
897 }
898 serde_json::Value::String(s) => Value::from(s.clone()),
899 serde_json::Value::Array(arr) => {
900 let items: Vec<Value> = arr.iter().map(json_to_minijinja).collect();
901 Value::from(items)
902 }
903 serde_json::Value::Object(obj) => {
904 let map: HashMap<String, Value> = obj
905 .iter()
906 .map(|(k, v)| (k.clone(), json_to_minijinja(v)))
907 .collect();
908 Value::from_iter(map)
909 }
910 }
911}
912
913#[cfg(test)]
914mod tests {
915 use super::*;
916 use crate::tabular::{Column, FlatDataSpec, Width};
917 use crate::Theme;
918 use console::Style;
919 use serde::Serialize;
920 use serde_json::json;
921
922 #[derive(Serialize)]
923 struct SimpleData {
924 message: String,
925 }
926
927 #[derive(Serialize)]
928 struct ListData {
929 items: Vec<String>,
930 count: usize,
931 }
932
933 #[test]
934 fn test_render_with_output_text_no_ansi() {
935 let theme = Theme::new().add("red", Style::new().red());
936 let data = SimpleData {
937 message: "test".into(),
938 };
939
940 let output = render_with_output(
941 r#"[red]{{ message }}[/red]"#,
942 &data,
943 &theme,
944 OutputMode::Text,
945 )
946 .unwrap();
947
948 assert_eq!(output, "test");
949 assert!(!output.contains("\x1b["));
950 }
951
952 #[test]
953 fn test_render_with_output_term_has_ansi() {
954 let theme = Theme::new().add("green", Style::new().green().force_styling(true));
955 let data = SimpleData {
956 message: "success".into(),
957 };
958
959 let output = render_with_output(
960 r#"[green]{{ message }}[/green]"#,
961 &data,
962 &theme,
963 OutputMode::Term,
964 )
965 .unwrap();
966
967 assert!(output.contains("success"));
968 assert!(output.contains("\x1b["));
969 }
970
971 #[test]
972 fn test_render_unknown_style_shows_indicator() {
973 let theme = Theme::new();
974 let data = SimpleData {
975 message: "hello".into(),
976 };
977
978 let output = render_with_output(
979 r#"[unknown]{{ message }}[/unknown]"#,
980 &data,
981 &theme,
982 OutputMode::Term,
983 )
984 .unwrap();
985
986 assert_eq!(output, "[unknown?]hello[/unknown?]");
988 }
989
990 #[test]
991 fn test_render_unknown_style_stripped_in_text_mode() {
992 let theme = Theme::new();
993 let data = SimpleData {
994 message: "hello".into(),
995 };
996
997 let output = render_with_output(
998 r#"[unknown]{{ message }}[/unknown]"#,
999 &data,
1000 &theme,
1001 OutputMode::Text,
1002 )
1003 .unwrap();
1004
1005 assert_eq!(output, "hello");
1007 }
1008
1009 #[test]
1010 fn test_render_template_with_loop() {
1011 let theme = Theme::new().add("item", Style::new().cyan());
1012 let data = ListData {
1013 items: vec!["one".into(), "two".into()],
1014 count: 2,
1015 };
1016
1017 let template = r#"{% for item in items %}[item]{{ item }}[/item]
1018{% endfor %}"#;
1019
1020 let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1021 assert_eq!(output, "one\ntwo\n");
1022 }
1023
1024 #[test]
1025 fn test_render_mixed_styled_and_plain() {
1026 let theme = Theme::new().add("count", Style::new().bold());
1027 let data = ListData {
1028 items: vec![],
1029 count: 42,
1030 };
1031
1032 let template = r#"Total: [count]{{ count }}[/count] items"#;
1033 let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1034
1035 assert_eq!(output, "Total: 42 items");
1036 }
1037
1038 #[test]
1039 fn test_render_literal_string_styled() {
1040 let theme = Theme::new().add("header", Style::new().bold());
1041
1042 #[derive(Serialize)]
1043 struct Empty {}
1044
1045 let output = render_with_output(
1046 r#"[header]Header[/header]"#,
1047 &Empty {},
1048 &theme,
1049 OutputMode::Text,
1050 )
1051 .unwrap();
1052
1053 assert_eq!(output, "Header");
1054 }
1055
1056 #[test]
1057 fn test_empty_template() {
1058 let theme = Theme::new();
1059
1060 #[derive(Serialize)]
1061 struct Empty {}
1062
1063 let output = render_with_output("", &Empty {}, &theme, OutputMode::Text).unwrap();
1064 assert_eq!(output, "");
1065 }
1066
1067 #[test]
1068 fn test_template_syntax_error() {
1069 let theme = Theme::new();
1070
1071 #[derive(Serialize)]
1072 struct Empty {}
1073
1074 let result = render_with_output("{{ unclosed", &Empty {}, &theme, OutputMode::Text);
1075 assert!(result.is_err());
1076 }
1077
1078 #[test]
1079 fn test_style_tag_with_nested_data() {
1080 #[derive(Serialize)]
1081 struct Item {
1082 name: String,
1083 value: i32,
1084 }
1085
1086 #[derive(Serialize)]
1087 struct Container {
1088 items: Vec<Item>,
1089 }
1090
1091 let theme = Theme::new().add("name", Style::new().bold());
1092 let data = Container {
1093 items: vec![
1094 Item {
1095 name: "foo".into(),
1096 value: 1,
1097 },
1098 Item {
1099 name: "bar".into(),
1100 value: 2,
1101 },
1102 ],
1103 };
1104
1105 let template = r#"{% for item in items %}[name]{{ item.name }}[/name]={{ item.value }}
1106{% endfor %}"#;
1107
1108 let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1109 assert_eq!(output, "foo=1\nbar=2\n");
1110 }
1111
1112 #[test]
1113 fn test_render_with_output_term_debug() {
1114 let theme = Theme::new()
1115 .add("title", Style::new().bold())
1116 .add("count", Style::new().cyan());
1117
1118 #[derive(Serialize)]
1119 struct Data {
1120 name: String,
1121 value: usize,
1122 }
1123
1124 let data = Data {
1125 name: "Test".into(),
1126 value: 42,
1127 };
1128
1129 let output = render_with_output(
1130 r#"[title]{{ name }}[/title]: [count]{{ value }}[/count]"#,
1131 &data,
1132 &theme,
1133 OutputMode::TermDebug,
1134 )
1135 .unwrap();
1136
1137 assert_eq!(output, "[title]Test[/title]: [count]42[/count]");
1138 }
1139
1140 #[test]
1141 fn test_render_with_output_term_debug_preserves_tags() {
1142 let theme = Theme::new().add("known", Style::new().bold());
1143
1144 #[derive(Serialize)]
1145 struct Data {
1146 message: String,
1147 }
1148
1149 let data = Data {
1150 message: "hello".into(),
1151 };
1152
1153 let output = render_with_output(
1155 r#"[unknown]{{ message }}[/unknown]"#,
1156 &data,
1157 &theme,
1158 OutputMode::TermDebug,
1159 )
1160 .unwrap();
1161
1162 assert_eq!(output, "[unknown]hello[/unknown]");
1163
1164 let output = render_with_output(
1166 r#"[known]{{ message }}[/known]"#,
1167 &data,
1168 &theme,
1169 OutputMode::TermDebug,
1170 )
1171 .unwrap();
1172
1173 assert_eq!(output, "[known]hello[/known]");
1174 }
1175
1176 #[test]
1177 fn test_render_auto_json_mode() {
1178 use serde_json::json;
1179
1180 let theme = Theme::new();
1181 let data = json!({"name": "test", "count": 42});
1182
1183 let output = render_auto("unused template", &data, &theme, OutputMode::Json).unwrap();
1184
1185 assert!(output.contains("\"name\": \"test\""));
1186 assert!(output.contains("\"count\": 42"));
1187 }
1188
1189 #[test]
1190 fn test_render_auto_text_mode_uses_template() {
1191 use serde_json::json;
1192
1193 let theme = Theme::new();
1194 let data = json!({"name": "test"});
1195
1196 let output = render_auto("Name: {{ name }}", &data, &theme, OutputMode::Text).unwrap();
1197
1198 assert_eq!(output, "Name: test");
1199 }
1200
1201 #[test]
1202 fn test_render_auto_term_mode_uses_template() {
1203 use serde_json::json;
1204
1205 let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1206 let data = json!({"name": "test"});
1207
1208 let output = render_auto(
1209 r#"[bold]{{ name }}[/bold]"#,
1210 &data,
1211 &theme,
1212 OutputMode::Term,
1213 )
1214 .unwrap();
1215
1216 assert!(output.contains("\x1b[1m"));
1217 assert!(output.contains("test"));
1218 }
1219
1220 #[test]
1221 fn test_render_auto_json_with_struct() {
1222 #[derive(Serialize)]
1223 struct Report {
1224 title: String,
1225 items: Vec<String>,
1226 }
1227
1228 let theme = Theme::new();
1229 let data = Report {
1230 title: "Summary".into(),
1231 items: vec!["one".into(), "two".into()],
1232 };
1233
1234 let output = render_auto("unused", &data, &theme, OutputMode::Json).unwrap();
1235
1236 assert!(output.contains("\"title\": \"Summary\""));
1237 assert!(output.contains("\"items\""));
1238 assert!(output.contains("\"one\""));
1239 }
1240
1241 #[test]
1242 fn test_render_with_alias() {
1243 let theme = Theme::new()
1244 .add("base", Style::new().bold())
1245 .add("alias", "base");
1246
1247 let output = render_with_output(
1248 r#"[alias]text[/alias]"#,
1249 &serde_json::json!({}),
1250 &theme,
1251 OutputMode::Text,
1252 )
1253 .unwrap();
1254
1255 assert_eq!(output, "text");
1256 }
1257
1258 #[test]
1259 fn test_render_with_alias_chain() {
1260 let theme = Theme::new()
1261 .add("muted", Style::new().dim())
1262 .add("disabled", "muted")
1263 .add("timestamp", "disabled");
1264
1265 let output = render_with_output(
1266 r#"[timestamp]12:00[/timestamp]"#,
1267 &serde_json::json!({}),
1268 &theme,
1269 OutputMode::Text,
1270 )
1271 .unwrap();
1272
1273 assert_eq!(output, "12:00");
1274 }
1275
1276 #[test]
1277 fn test_render_fails_with_dangling_alias() {
1278 let theme = Theme::new().add("orphan", "missing");
1279
1280 let result = render_with_output(
1281 r#"[orphan]text[/orphan]"#,
1282 &serde_json::json!({}),
1283 &theme,
1284 OutputMode::Text,
1285 );
1286
1287 assert!(result.is_err());
1288 let err = result.unwrap_err();
1289 assert!(err.to_string().contains("orphan"));
1290 assert!(err.to_string().contains("missing"));
1291 }
1292
1293 #[test]
1294 fn test_render_fails_with_cycle() {
1295 let theme = Theme::new().add("a", "b").add("b", "a");
1296
1297 let result = render_with_output(
1298 r#"[a]text[/a]"#,
1299 &serde_json::json!({}),
1300 &theme,
1301 OutputMode::Text,
1302 );
1303
1304 assert!(result.is_err());
1305 assert!(result.unwrap_err().to_string().contains("cycle"));
1306 }
1307
1308 #[test]
1309 fn test_three_layer_styling_pattern() {
1310 let theme = Theme::new()
1311 .add("dim_style", Style::new().dim())
1312 .add("cyan_bold", Style::new().cyan().bold())
1313 .add("yellow_bg", Style::new().on_yellow())
1314 .add("muted", "dim_style")
1315 .add("accent", "cyan_bold")
1316 .add("highlighted", "yellow_bg")
1317 .add("timestamp", "muted")
1318 .add("title", "accent")
1319 .add("selected_item", "highlighted");
1320
1321 assert!(theme.validate().is_ok());
1322
1323 let output = render_with_output(
1324 r#"[timestamp]{{ time }}[/timestamp] - [title]{{ name }}[/title]"#,
1325 &serde_json::json!({"time": "12:00", "name": "Report"}),
1326 &theme,
1327 OutputMode::Text,
1328 )
1329 .unwrap();
1330
1331 assert_eq!(output, "12:00 - Report");
1332 }
1333
1334 #[test]
1339 fn test_render_auto_yaml_mode() {
1340 use serde_json::json;
1341
1342 let theme = Theme::new();
1343 let data = json!({"name": "test", "count": 42});
1344
1345 let output = render_auto("unused template", &data, &theme, OutputMode::Yaml).unwrap();
1346
1347 assert!(output.contains("name: test"));
1348 assert!(output.contains("count: 42"));
1349 }
1350
1351 #[test]
1352 fn test_render_auto_xml_mode() {
1353 let theme = Theme::new();
1354
1355 #[derive(Serialize)]
1356 #[serde(rename = "root")]
1357 struct Data {
1358 name: String,
1359 count: usize,
1360 }
1361
1362 let data = Data {
1363 name: "test".into(),
1364 count: 42,
1365 };
1366
1367 let output = render_auto("unused template", &data, &theme, OutputMode::Xml).unwrap();
1368
1369 assert!(output.contains("<root>"));
1370 assert!(output.contains("<name>test</name>"));
1371 }
1372
1373 #[test]
1374 fn test_render_auto_csv_mode_auto_flatten() {
1375 use serde_json::json;
1376
1377 let theme = Theme::new();
1378 let data = json!([
1379 {"name": "Alice", "stats": {"score": 10}},
1380 {"name": "Bob", "stats": {"score": 20}}
1381 ]);
1382
1383 let output = render_auto("unused", &data, &theme, OutputMode::Csv).unwrap();
1384
1385 assert!(output.contains("name,stats.score"));
1386 assert!(output.contains("Alice,10"));
1387 assert!(output.contains("Bob,20"));
1388 }
1389
1390 #[test]
1391 fn test_render_auto_csv_mode_with_spec() {
1392 let theme = Theme::new();
1393 let data = json!([
1394 {"name": "Alice", "meta": {"age": 30, "role": "admin"}},
1395 {"name": "Bob", "meta": {"age": 25, "role": "user"}}
1396 ]);
1397
1398 let spec = FlatDataSpec::builder()
1399 .column(Column::new(Width::Fixed(10)).key("name"))
1400 .column(
1401 Column::new(Width::Fixed(10))
1402 .key("meta.role")
1403 .header("Role"),
1404 )
1405 .build();
1406
1407 let output =
1408 render_auto_with_spec("unused", &data, &theme, OutputMode::Csv, Some(&spec)).unwrap();
1409
1410 let lines: Vec<&str> = output.lines().collect();
1411 assert_eq!(lines[0], "name,Role");
1412 assert!(lines.contains(&"Alice,admin"));
1413 assert!(lines.contains(&"Bob,user"));
1414 assert!(!output.contains("30"));
1415 }
1416
1417 #[test]
1422 fn test_render_with_context_basic() {
1423 use crate::context::{ContextRegistry, RenderContext};
1424
1425 #[derive(Serialize)]
1426 struct Data {
1427 name: String,
1428 }
1429
1430 let theme = Theme::new();
1431 let data = Data {
1432 name: "Alice".into(),
1433 };
1434 let json_data = serde_json::to_value(&data).unwrap();
1435
1436 let mut registry = ContextRegistry::new();
1437 registry.add_static("version", Value::from("1.0.0"));
1438
1439 let render_ctx = RenderContext::new(OutputMode::Text, Some(80), &theme, &json_data);
1440
1441 let output = render_with_context(
1442 "{{ name }} (v{{ version }})",
1443 &data,
1444 &theme,
1445 OutputMode::Text,
1446 ®istry,
1447 &render_ctx,
1448 None,
1449 )
1450 .unwrap();
1451
1452 assert_eq!(output, "Alice (v1.0.0)");
1453 }
1454
1455 #[test]
1456 fn test_render_with_context_dynamic_provider() {
1457 use crate::context::{ContextRegistry, RenderContext};
1458
1459 #[derive(Serialize)]
1460 struct Data {
1461 message: String,
1462 }
1463
1464 let theme = Theme::new();
1465 let data = Data {
1466 message: "Hello".into(),
1467 };
1468 let json_data = serde_json::to_value(&data).unwrap();
1469
1470 let mut registry = ContextRegistry::new();
1471 registry.add_provider("terminal_width", |ctx: &RenderContext| {
1472 Value::from(ctx.terminal_width.unwrap_or(80))
1473 });
1474
1475 let render_ctx = RenderContext::new(OutputMode::Text, Some(120), &theme, &json_data);
1476
1477 let output = render_with_context(
1478 "{{ message }} (width={{ terminal_width }})",
1479 &data,
1480 &theme,
1481 OutputMode::Text,
1482 ®istry,
1483 &render_ctx,
1484 None,
1485 )
1486 .unwrap();
1487
1488 assert_eq!(output, "Hello (width=120)");
1489 }
1490
1491 #[test]
1492 fn test_render_with_context_data_takes_precedence() {
1493 use crate::context::{ContextRegistry, RenderContext};
1494
1495 #[derive(Serialize)]
1496 struct Data {
1497 value: String,
1498 }
1499
1500 let theme = Theme::new();
1501 let data = Data {
1502 value: "from_data".into(),
1503 };
1504 let json_data = serde_json::to_value(&data).unwrap();
1505
1506 let mut registry = ContextRegistry::new();
1507 registry.add_static("value", Value::from("from_context"));
1508
1509 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1510
1511 let output = render_with_context(
1512 "{{ value }}",
1513 &data,
1514 &theme,
1515 OutputMode::Text,
1516 ®istry,
1517 &render_ctx,
1518 None,
1519 )
1520 .unwrap();
1521
1522 assert_eq!(output, "from_data");
1523 }
1524
1525 #[test]
1526 fn test_render_with_context_empty_registry() {
1527 use crate::context::{ContextRegistry, RenderContext};
1528
1529 #[derive(Serialize)]
1530 struct Data {
1531 name: String,
1532 }
1533
1534 let theme = Theme::new();
1535 let data = Data {
1536 name: "Test".into(),
1537 };
1538 let json_data = serde_json::to_value(&data).unwrap();
1539
1540 let registry = ContextRegistry::new();
1541 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1542
1543 let output = render_with_context(
1544 "{{ name }}",
1545 &data,
1546 &theme,
1547 OutputMode::Text,
1548 ®istry,
1549 &render_ctx,
1550 None,
1551 )
1552 .unwrap();
1553
1554 assert_eq!(output, "Test");
1555 }
1556
1557 #[test]
1558 fn test_render_auto_with_context_json_mode() {
1559 use crate::context::{ContextRegistry, RenderContext};
1560
1561 #[derive(Serialize)]
1562 struct Data {
1563 count: usize,
1564 }
1565
1566 let theme = Theme::new();
1567 let data = Data { count: 42 };
1568 let json_data = serde_json::to_value(&data).unwrap();
1569
1570 let mut registry = ContextRegistry::new();
1571 registry.add_static("extra", Value::from("ignored"));
1572
1573 let render_ctx = RenderContext::new(OutputMode::Json, None, &theme, &json_data);
1574
1575 let output = render_auto_with_context(
1576 "unused template {{ extra }}",
1577 &data,
1578 &theme,
1579 OutputMode::Json,
1580 ®istry,
1581 &render_ctx,
1582 None,
1583 )
1584 .unwrap();
1585
1586 assert!(output.contains("\"count\": 42"));
1587 assert!(!output.contains("ignored"));
1588 }
1589
1590 #[test]
1591 fn test_render_auto_with_context_text_mode() {
1592 use crate::context::{ContextRegistry, RenderContext};
1593
1594 #[derive(Serialize)]
1595 struct Data {
1596 count: usize,
1597 }
1598
1599 let theme = Theme::new();
1600 let data = Data { count: 42 };
1601 let json_data = serde_json::to_value(&data).unwrap();
1602
1603 let mut registry = ContextRegistry::new();
1604 registry.add_static("label", Value::from("Items"));
1605
1606 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1607
1608 let output = render_auto_with_context(
1609 "{{ label }}: {{ count }}",
1610 &data,
1611 &theme,
1612 OutputMode::Text,
1613 ®istry,
1614 &render_ctx,
1615 None,
1616 )
1617 .unwrap();
1618
1619 assert_eq!(output, "Items: 42");
1620 }
1621
1622 #[test]
1623 fn test_render_with_context_provider_uses_output_mode() {
1624 use crate::context::{ContextRegistry, RenderContext};
1625
1626 #[derive(Serialize)]
1627 struct Data {}
1628
1629 let theme = Theme::new();
1630 let data = Data {};
1631 let json_data = serde_json::to_value(&data).unwrap();
1632
1633 let mut registry = ContextRegistry::new();
1634 registry.add_provider("mode", |ctx: &RenderContext| {
1635 Value::from(format!("{:?}", ctx.output_mode))
1636 });
1637
1638 let render_ctx = RenderContext::new(OutputMode::Term, None, &theme, &json_data);
1639
1640 let output = render_with_context(
1641 "Mode: {{ mode }}",
1642 &data,
1643 &theme,
1644 OutputMode::Term,
1645 ®istry,
1646 &render_ctx,
1647 None,
1648 )
1649 .unwrap();
1650
1651 assert_eq!(output, "Mode: Term");
1652 }
1653
1654 #[test]
1655 fn test_render_with_context_nested_data() {
1656 use crate::context::{ContextRegistry, RenderContext};
1657
1658 #[derive(Serialize)]
1659 struct Item {
1660 name: String,
1661 }
1662
1663 #[derive(Serialize)]
1664 struct Data {
1665 items: Vec<Item>,
1666 }
1667
1668 let theme = Theme::new();
1669 let data = Data {
1670 items: vec![Item { name: "one".into() }, Item { name: "two".into() }],
1671 };
1672 let json_data = serde_json::to_value(&data).unwrap();
1673
1674 let mut registry = ContextRegistry::new();
1675 registry.add_static("prefix", Value::from("- "));
1676
1677 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1678
1679 let output = render_with_context(
1680 "{% for item in items %}{{ prefix }}{{ item.name }}\n{% endfor %}",
1681 &data,
1682 &theme,
1683 OutputMode::Text,
1684 ®istry,
1685 &render_ctx,
1686 None,
1687 )
1688 .unwrap();
1689
1690 assert_eq!(output, "- one\n- two\n");
1691 }
1692
1693 #[test]
1694 fn test_render_with_mode_forces_color_mode() {
1695 use console::Style;
1696
1697 #[derive(Serialize)]
1698 struct Data {
1699 status: String,
1700 }
1701
1702 let theme = Theme::new().add_adaptive(
1705 "status",
1706 Style::new(), Some(Style::new().black().force_styling(true)), Some(Style::new().white().force_styling(true)), );
1710
1711 let data = Data {
1712 status: "test".into(),
1713 };
1714
1715 let dark_output = render_with_mode(
1717 r#"[status]{{ status }}[/status]"#,
1718 &data,
1719 &theme,
1720 OutputMode::Term,
1721 ColorMode::Dark,
1722 )
1723 .unwrap();
1724
1725 let light_output = render_with_mode(
1727 r#"[status]{{ status }}[/status]"#,
1728 &data,
1729 &theme,
1730 OutputMode::Term,
1731 ColorMode::Light,
1732 )
1733 .unwrap();
1734
1735 assert_ne!(dark_output, light_output);
1737
1738 assert!(
1740 dark_output.contains("\x1b[37"),
1741 "Expected white (37) in dark mode"
1742 );
1743
1744 assert!(
1746 light_output.contains("\x1b[30"),
1747 "Expected black (30) in light mode"
1748 );
1749 }
1750
1751 #[test]
1756 fn test_tag_syntax_text_mode() {
1757 let theme = Theme::new().add("title", Style::new().bold());
1758
1759 #[derive(Serialize)]
1760 struct Data {
1761 name: String,
1762 }
1763
1764 let output = render_with_output(
1765 "[title]{{ name }}[/title]",
1766 &Data {
1767 name: "Hello".into(),
1768 },
1769 &theme,
1770 OutputMode::Text,
1771 )
1772 .unwrap();
1773
1774 assert_eq!(output, "Hello");
1776 }
1777
1778 #[test]
1779 fn test_tag_syntax_term_mode() {
1780 let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1781
1782 #[derive(Serialize)]
1783 struct Data {
1784 name: String,
1785 }
1786
1787 let output = render_with_output(
1788 "[bold]{{ name }}[/bold]",
1789 &Data {
1790 name: "Hello".into(),
1791 },
1792 &theme,
1793 OutputMode::Term,
1794 )
1795 .unwrap();
1796
1797 assert!(output.contains("\x1b[1m"));
1799 assert!(output.contains("Hello"));
1800 }
1801
1802 #[test]
1803 fn test_tag_syntax_debug_mode() {
1804 let theme = Theme::new().add("title", Style::new().bold());
1805
1806 #[derive(Serialize)]
1807 struct Data {
1808 name: String,
1809 }
1810
1811 let output = render_with_output(
1812 "[title]{{ name }}[/title]",
1813 &Data {
1814 name: "Hello".into(),
1815 },
1816 &theme,
1817 OutputMode::TermDebug,
1818 )
1819 .unwrap();
1820
1821 assert_eq!(output, "[title]Hello[/title]");
1823 }
1824
1825 #[test]
1826 fn test_tag_syntax_unknown_tag_passthrough() {
1827 let theme = Theme::new().add("known", Style::new().bold());
1829
1830 #[derive(Serialize)]
1831 struct Data {
1832 name: String,
1833 }
1834
1835 let output = render_with_output(
1837 "[unknown]{{ name }}[/unknown]",
1838 &Data {
1839 name: "Hello".into(),
1840 },
1841 &theme,
1842 OutputMode::Term,
1843 )
1844 .unwrap();
1845
1846 assert!(output.contains("[unknown?]"));
1848 assert!(output.contains("[/unknown?]"));
1849 assert!(output.contains("Hello"));
1850
1851 let text_output = render_with_output(
1853 "[unknown]{{ name }}[/unknown]",
1854 &Data {
1855 name: "Hello".into(),
1856 },
1857 &theme,
1858 OutputMode::Text,
1859 )
1860 .unwrap();
1861
1862 assert_eq!(text_output, "Hello");
1864 }
1865
1866 #[test]
1867 fn test_tag_syntax_nested() {
1868 let theme = Theme::new()
1869 .add("bold", Style::new().bold().force_styling(true))
1870 .add("red", Style::new().red().force_styling(true));
1871
1872 #[derive(Serialize)]
1873 struct Data {
1874 word: String,
1875 }
1876
1877 let output = render_with_output(
1878 "[bold][red]{{ word }}[/red][/bold]",
1879 &Data {
1880 word: "test".into(),
1881 },
1882 &theme,
1883 OutputMode::Term,
1884 )
1885 .unwrap();
1886
1887 assert!(output.contains("\x1b[1m")); assert!(output.contains("\x1b[31m")); assert!(output.contains("test"));
1891 }
1892
1893 #[test]
1894 fn test_tag_syntax_multiple_styles() {
1895 let theme = Theme::new()
1896 .add("title", Style::new().bold())
1897 .add("count", Style::new().cyan());
1898
1899 #[derive(Serialize)]
1900 struct Data {
1901 name: String,
1902 num: usize,
1903 }
1904
1905 let output = render_with_output(
1906 r#"[title]{{ name }}[/title]: [count]{{ num }}[/count]"#,
1907 &Data {
1908 name: "Items".into(),
1909 num: 42,
1910 },
1911 &theme,
1912 OutputMode::Text,
1913 )
1914 .unwrap();
1915
1916 assert_eq!(output, "Items: 42");
1917 }
1918
1919 #[test]
1920 fn test_tag_syntax_in_loop() {
1921 let theme = Theme::new().add("item", Style::new().cyan());
1922
1923 #[derive(Serialize)]
1924 struct Data {
1925 items: Vec<String>,
1926 }
1927
1928 let output = render_with_output(
1929 "{% for item in items %}[item]{{ item }}[/item]\n{% endfor %}",
1930 &Data {
1931 items: vec!["one".into(), "two".into()],
1932 },
1933 &theme,
1934 OutputMode::Text,
1935 )
1936 .unwrap();
1937
1938 assert_eq!(output, "one\ntwo\n");
1939 }
1940
1941 #[test]
1942 fn test_tag_syntax_literal_brackets() {
1943 let theme = Theme::new();
1945
1946 #[derive(Serialize)]
1947 struct Data {
1948 msg: String,
1949 }
1950
1951 let output = render_with_output(
1952 "Array: [1, 2, 3] and {{ msg }}",
1953 &Data { msg: "done".into() },
1954 &theme,
1955 OutputMode::Text,
1956 )
1957 .unwrap();
1958
1959 assert_eq!(output, "Array: [1, 2, 3] and done");
1961 }
1962
1963 #[test]
1968 fn test_validate_template_all_known_tags() {
1969 let theme = Theme::new()
1970 .add("title", Style::new().bold())
1971 .add("count", Style::new().cyan());
1972
1973 #[derive(Serialize)]
1974 struct Data {
1975 name: String,
1976 }
1977
1978 let result = validate_template(
1979 "[title]{{ name }}[/title]",
1980 &Data {
1981 name: "Hello".into(),
1982 },
1983 &theme,
1984 );
1985
1986 assert!(result.is_ok());
1987 }
1988
1989 #[test]
1990 fn test_validate_template_unknown_tag_fails() {
1991 let theme = Theme::new().add("known", Style::new().bold());
1992
1993 #[derive(Serialize)]
1994 struct Data {
1995 name: String,
1996 }
1997
1998 let result = validate_template(
1999 "[unknown]{{ name }}[/unknown]",
2000 &Data {
2001 name: "Hello".into(),
2002 },
2003 &theme,
2004 );
2005
2006 assert!(result.is_err());
2007 let err = result.unwrap_err();
2008 let errors = err
2009 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2010 .expect("Expected UnknownTagErrors");
2011 assert_eq!(errors.len(), 2); }
2013
2014 #[test]
2015 fn test_validate_template_multiple_unknown_tags() {
2016 let theme = Theme::new().add("known", Style::new().bold());
2017
2018 #[derive(Serialize)]
2019 struct Data {
2020 a: String,
2021 b: String,
2022 }
2023
2024 let result = validate_template(
2025 "[foo]{{ a }}[/foo] and [bar]{{ b }}[/bar]",
2026 &Data {
2027 a: "x".into(),
2028 b: "y".into(),
2029 },
2030 &theme,
2031 );
2032
2033 assert!(result.is_err());
2034 let err = result.unwrap_err();
2035 let errors = err
2036 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2037 .expect("Expected UnknownTagErrors");
2038 assert_eq!(errors.len(), 4); }
2040
2041 #[test]
2042 fn test_validate_template_plain_text_passes() {
2043 let theme = Theme::new();
2044
2045 #[derive(Serialize)]
2046 struct Data {
2047 msg: String,
2048 }
2049
2050 let result = validate_template("Just plain {{ msg }}", &Data { msg: "hi".into() }, &theme);
2051
2052 assert!(result.is_ok());
2053 }
2054
2055 #[test]
2056 fn test_validate_template_mixed_known_and_unknown() {
2057 let theme = Theme::new().add("known", Style::new().bold());
2058
2059 #[derive(Serialize)]
2060 struct Data {
2061 a: String,
2062 b: String,
2063 }
2064
2065 let result = validate_template(
2066 "[known]{{ a }}[/known] [unknown]{{ b }}[/unknown]",
2067 &Data {
2068 a: "x".into(),
2069 b: "y".into(),
2070 },
2071 &theme,
2072 );
2073
2074 assert!(result.is_err());
2075 let err = result.unwrap_err();
2076 let errors = err
2077 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2078 .expect("Expected UnknownTagErrors");
2079 assert_eq!(errors.len(), 2);
2081 assert!(errors.errors.iter().any(|e| e.tag == "unknown"));
2082 }
2083
2084 #[test]
2085 fn test_validate_template_syntax_error_fails() {
2086 let theme = Theme::new();
2087 #[derive(Serialize)]
2088 struct Data {}
2089
2090 let result = validate_template("{{ unclosed", &Data {}, &theme);
2092 assert!(result.is_err());
2093
2094 let err = result.unwrap_err();
2095 assert!(err
2097 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2098 .is_none());
2099 let msg = err.to_string();
2101 assert!(
2102 msg.contains("syntax error") || msg.contains("unexpected"),
2103 "Got: {}",
2104 msg
2105 );
2106 }
2107
2108 #[test]
2109 fn test_render_auto_with_context_yaml_mode() {
2110 use crate::context::{ContextRegistry, RenderContext};
2111 use serde_json::json;
2112
2113 let theme = Theme::new();
2114 let data = json!({"name": "test", "count": 42});
2115
2116 let registry = ContextRegistry::new();
2118 let render_ctx = RenderContext::new(OutputMode::Yaml, Some(80), &theme, &data);
2119
2120 let output = render_auto_with_context(
2122 "unused template",
2123 &data,
2124 &theme,
2125 OutputMode::Yaml,
2126 ®istry,
2127 &render_ctx,
2128 None,
2129 )
2130 .unwrap();
2131
2132 assert!(output.contains("name: test"));
2133 assert!(output.contains("count: 42"));
2134 }
2135}