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