1use serde::Serialize;
75use standout_bbparser::{BBParser, TagTransform, UnknownTagBehavior};
76use std::collections::HashMap;
77
78use super::engine::{MiniJinjaEngine, TemplateEngine};
79use crate::context::{ContextRegistry, RenderContext};
80use crate::error::RenderError;
81use crate::output::OutputMode;
82use crate::style::Styles;
83use crate::tabular::FlatDataSpec;
84use crate::theme::{detect_color_mode, ColorMode, Theme};
85
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 engine = MiniJinjaEngine::new();
172 let data_value = serde_json::to_value(data)?;
173 let minijinja_output = engine.render_template(template, &data_value)?;
174
175 let resolved_styles = styles.to_resolved_map();
177 let parser = BBParser::new(resolved_styles, TagTransform::Remove);
178 parser.validate(&minijinja_output)?;
179
180 Ok(())
181}
182
183pub fn render<T: Serialize>(
213 template: &str,
214 data: &T,
215 theme: &Theme,
216) -> Result<String, RenderError> {
217 render_with_output(template, data, theme, OutputMode::Auto)
218}
219
220pub fn render_with_output<T: Serialize>(
264 template: &str,
265 data: &T,
266 theme: &Theme,
267 mode: OutputMode,
268) -> Result<String, RenderError> {
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, RenderError> {
330 theme
332 .validate()
333 .map_err(|e| RenderError::StyleError(e.to_string()))?;
334
335 let styles = theme.resolve_styles(Some(color_mode));
337
338 let engine = MiniJinjaEngine::new();
340 let data_value = serde_json::to_value(data)?;
341 let template_output = engine.render_template(template, &data_value)?;
342
343 let final_output = apply_style_tags(&template_output, &styles, output_mode);
345
346 Ok(final_output)
347}
348
349pub fn render_with_vars<T, K, V, I>(
390 template: &str,
391 data: &T,
392 theme: &Theme,
393 mode: OutputMode,
394 vars: I,
395) -> Result<String, RenderError>
396where
397 T: Serialize,
398 K: AsRef<str>,
399 V: Into<serde_json::Value>,
400 I: IntoIterator<Item = (K, V)>,
401{
402 let color_mode = detect_color_mode();
403 let styles = theme.resolve_styles(Some(color_mode));
404
405 styles
407 .validate()
408 .map_err(|e| RenderError::StyleError(e.to_string()))?;
409
410 let mut context: HashMap<String, serde_json::Value> = HashMap::new();
412 for (key, value) in vars {
413 context.insert(key.as_ref().to_string(), value.into());
414 }
415
416 let engine = MiniJinjaEngine::new();
418 let data_value = serde_json::to_value(data)?;
419 let template_output = engine.render_with_context(template, &data_value, context)?;
420
421 let final_output = apply_style_tags(&template_output, &styles, mode);
423
424 Ok(final_output)
425}
426
427pub fn render_auto<T: Serialize>(
474 template: &str,
475 data: &T,
476 theme: &Theme,
477 mode: OutputMode,
478) -> Result<String, RenderError> {
479 if mode.is_structured() {
480 match mode {
481 OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
482 OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
483 OutputMode::Xml => Ok(quick_xml::se::to_string(data)?),
484 OutputMode::Csv => {
485 let value = serde_json::to_value(data)?;
486 let (headers, rows) = crate::util::flatten_json_for_csv(&value);
487
488 let mut wtr = csv::Writer::from_writer(Vec::new());
489 wtr.write_record(&headers)?;
490 for row in rows {
491 wtr.write_record(&row)?;
492 }
493 let bytes = wtr.into_inner()?;
494 Ok(String::from_utf8(bytes)?)
495 }
496 _ => unreachable!("is_structured() returned true for non-structured mode"),
497 }
498 } else {
499 render_with_output(template, data, theme, mode)
500 }
501}
502
503pub fn render_auto_with_spec<T: Serialize>(
517 template: &str,
518 data: &T,
519 theme: &Theme,
520 mode: OutputMode,
521 spec: Option<&FlatDataSpec>,
522) -> Result<String, RenderError> {
523 if mode.is_structured() {
524 match mode {
525 OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
526 OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
527 OutputMode::Xml => Ok(quick_xml::se::to_string(data)?),
528 OutputMode::Csv => {
529 let value = serde_json::to_value(data)?;
530
531 let (headers, rows) = if let Some(s) = spec {
532 let headers = s.extract_header();
534 let rows: Vec<Vec<String>> = match value {
535 serde_json::Value::Array(items) => {
536 items.iter().map(|item| s.extract_row(item)).collect()
537 }
538 _ => vec![s.extract_row(&value)],
539 };
540 (headers, rows)
541 } else {
542 crate::util::flatten_json_for_csv(&value)
544 };
545
546 let mut wtr = csv::Writer::from_writer(Vec::new());
547 wtr.write_record(&headers)?;
548 for row in rows {
549 wtr.write_record(&row)?;
550 }
551 let bytes = wtr.into_inner()?;
552 Ok(String::from_utf8(bytes)?)
553 }
554 _ => unreachable!("is_structured() returned true for non-structured mode"),
555 }
556 } else {
557 render_with_output(template, data, theme, mode)
558 }
559}
560
561pub fn render_with_context<T: Serialize>(
626 template: &str,
627 data: &T,
628 theme: &Theme,
629 mode: OutputMode,
630 context_registry: &ContextRegistry,
631 render_context: &RenderContext,
632 template_registry: Option<&super::TemplateRegistry>,
633) -> Result<String, RenderError> {
634 let color_mode = detect_color_mode();
635 let styles = theme.resolve_styles(Some(color_mode));
636
637 styles
639 .validate()
640 .map_err(|e| RenderError::StyleError(e.to_string()))?;
641
642 let mut engine = MiniJinjaEngine::new();
643
644 let template_content = if let Some(registry) = template_registry {
648 if let Ok(content) = registry.get_content(template) {
649 content
650 } else {
651 template.to_string()
652 }
653 } else {
654 template.to_string()
655 };
656
657 if let Some(registry) = template_registry {
659 for name in registry.names() {
660 if let Ok(content) = registry.get_content(name) {
661 engine.add_template(name, &content)?;
662 }
663 }
664 }
665
666 let context = build_combined_context(data, context_registry, render_context)?;
669
670 let data_value = serde_json::to_value(data)?;
672 let template_output = engine.render_with_context(&template_content, &data_value, context)?;
673
674 let final_output = apply_style_tags(&template_output, &styles, mode);
676
677 Ok(final_output)
678}
679
680pub fn render_auto_with_context<T: Serialize>(
747 template: &str,
748 data: &T,
749 theme: &Theme,
750 mode: OutputMode,
751 context_registry: &ContextRegistry,
752 render_context: &RenderContext,
753 template_registry: Option<&super::TemplateRegistry>,
754) -> Result<String, RenderError> {
755 if mode.is_structured() {
756 match mode {
757 OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
758 OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
759 OutputMode::Xml => Ok(quick_xml::se::to_string(data)?),
760 OutputMode::Csv => {
761 let value = serde_json::to_value(data)?;
762 let (headers, rows) = crate::util::flatten_json_for_csv(&value);
763
764 let mut wtr = csv::Writer::from_writer(Vec::new());
765 wtr.write_record(&headers)?;
766 for row in rows {
767 wtr.write_record(&row)?;
768 }
769 let bytes = wtr.into_inner()?;
770 Ok(String::from_utf8(bytes)?)
771 }
772 _ => unreachable!("is_structured() returned true for non-structured mode"),
773 }
774 } else {
775 render_with_context(
776 template,
777 data,
778 theme,
779 mode,
780 context_registry,
781 render_context,
782 template_registry,
783 )
784 }
785}
786
787fn build_combined_context<T: Serialize>(
791 data: &T,
792 context_registry: &ContextRegistry,
793 render_context: &RenderContext,
794) -> Result<HashMap<String, serde_json::Value>, RenderError> {
795 let context_values = context_registry.resolve(render_context);
797
798 let data_value = serde_json::to_value(data)?;
800
801 let mut combined: HashMap<String, serde_json::Value> = HashMap::new();
802
803 for (key, value) in context_values {
805 let json_val =
809 serde_json::to_value(value).map_err(|e| RenderError::ContextError(e.to_string()))?;
810 combined.insert(key, json_val);
811 }
812
813 if let Some(obj) = data_value.as_object() {
815 for (key, value) in obj {
816 combined.insert(key.clone(), value.clone());
817 }
818 }
819
820 Ok(combined)
821}
822
823pub fn render_auto_with_engine(
828 engine: &dyn super::TemplateEngine,
829 template: &str,
830 data: &serde_json::Value,
831 theme: &Theme,
832 mode: OutputMode,
833 context_registry: &ContextRegistry,
834 render_context: &RenderContext,
835) -> Result<String, RenderError> {
836 if mode.is_structured() {
837 match mode {
838 OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
839 OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
840 OutputMode::Xml => Ok(quick_xml::se::to_string(data)?),
841 OutputMode::Csv => {
842 let (headers, rows) = crate::util::flatten_json_for_csv(data);
843
844 let mut wtr = csv::Writer::from_writer(Vec::new());
845 wtr.write_record(&headers)?;
846 for row in rows {
847 wtr.write_record(&row)?;
848 }
849 let bytes = wtr.into_inner()?;
850 Ok(String::from_utf8(bytes)?)
851 }
852 _ => unreachable!("is_structured() returned true for non-structured mode"),
853 }
854 } else {
855 let color_mode = detect_color_mode();
856 let styles = theme.resolve_styles(Some(color_mode));
857
858 styles
860 .validate()
861 .map_err(|e| RenderError::StyleError(e.to_string()))?;
862
863 let context_map = build_combined_context(data, context_registry, render_context)?;
867
868 let combined_value = serde_json::Value::Object(context_map.into_iter().collect());
870
871 let template_output = if engine.has_template(template) {
873 engine.render_named(template, &combined_value)?
874 } else {
875 engine.render_template(template, &combined_value)?
876 };
877
878 let final_output = apply_style_tags(&template_output, &styles, mode);
880
881 Ok(final_output)
882 }
883}
884
885#[cfg(test)]
886mod tests {
887 use super::*;
888 use crate::tabular::{Column, FlatDataSpec, Width};
889 use crate::Theme;
890 use console::Style;
891 use minijinja::Value;
892 use serde::Serialize;
893 use serde_json::json;
894
895 #[derive(Serialize)]
896 struct SimpleData {
897 message: String,
898 }
899
900 #[derive(Serialize)]
901 struct ListData {
902 items: Vec<String>,
903 count: usize,
904 }
905
906 #[test]
907 fn test_render_with_output_text_no_ansi() {
908 let theme = Theme::new().add("red", Style::new().red());
909 let data = SimpleData {
910 message: "test".into(),
911 };
912
913 let output = render_with_output(
914 r#"[red]{{ message }}[/red]"#,
915 &data,
916 &theme,
917 OutputMode::Text,
918 )
919 .unwrap();
920
921 assert_eq!(output, "test");
922 assert!(!output.contains("\x1b["));
923 }
924
925 #[test]
926 fn test_render_with_output_term_has_ansi() {
927 let theme = Theme::new().add("green", Style::new().green().force_styling(true));
928 let data = SimpleData {
929 message: "success".into(),
930 };
931
932 let output = render_with_output(
933 r#"[green]{{ message }}[/green]"#,
934 &data,
935 &theme,
936 OutputMode::Term,
937 )
938 .unwrap();
939
940 assert!(output.contains("success"));
941 assert!(output.contains("\x1b["));
942 }
943
944 #[test]
945 fn test_render_unknown_style_shows_indicator() {
946 let theme = Theme::new();
947 let data = SimpleData {
948 message: "hello".into(),
949 };
950
951 let output = render_with_output(
952 r#"[unknown]{{ message }}[/unknown]"#,
953 &data,
954 &theme,
955 OutputMode::Term,
956 )
957 .unwrap();
958
959 assert_eq!(output, "[unknown?]hello[/unknown?]");
961 }
962
963 #[test]
964 fn test_render_unknown_style_stripped_in_text_mode() {
965 let theme = Theme::new();
966 let data = SimpleData {
967 message: "hello".into(),
968 };
969
970 let output = render_with_output(
971 r#"[unknown]{{ message }}[/unknown]"#,
972 &data,
973 &theme,
974 OutputMode::Text,
975 )
976 .unwrap();
977
978 assert_eq!(output, "hello");
980 }
981
982 #[test]
983 fn test_render_template_with_loop() {
984 let theme = Theme::new().add("item", Style::new().cyan());
985 let data = ListData {
986 items: vec!["one".into(), "two".into()],
987 count: 2,
988 };
989
990 let template = r#"{% for item in items %}[item]{{ item }}[/item]
991{% endfor %}"#;
992
993 let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
994 assert_eq!(output, "one\ntwo\n");
995 }
996
997 #[test]
998 fn test_render_mixed_styled_and_plain() {
999 let theme = Theme::new().add("count", Style::new().bold());
1000 let data = ListData {
1001 items: vec![],
1002 count: 42,
1003 };
1004
1005 let template = r#"Total: [count]{{ count }}[/count] items"#;
1006 let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1007
1008 assert_eq!(output, "Total: 42 items");
1009 }
1010
1011 #[test]
1012 fn test_render_literal_string_styled() {
1013 let theme = Theme::new().add("header", Style::new().bold());
1014
1015 #[derive(Serialize)]
1016 struct Empty {}
1017
1018 let output = render_with_output(
1019 r#"[header]Header[/header]"#,
1020 &Empty {},
1021 &theme,
1022 OutputMode::Text,
1023 )
1024 .unwrap();
1025
1026 assert_eq!(output, "Header");
1027 }
1028
1029 #[test]
1030 fn test_empty_template() {
1031 let theme = Theme::new();
1032
1033 #[derive(Serialize)]
1034 struct Empty {}
1035
1036 let output = render_with_output("", &Empty {}, &theme, OutputMode::Text).unwrap();
1037 assert_eq!(output, "");
1038 }
1039
1040 #[test]
1041 fn test_template_syntax_error() {
1042 let theme = Theme::new();
1043
1044 #[derive(Serialize)]
1045 struct Empty {}
1046
1047 let result = render_with_output("{{ unclosed", &Empty {}, &theme, OutputMode::Text);
1048 assert!(result.is_err());
1049 }
1050
1051 #[test]
1052 fn test_style_tag_with_nested_data() {
1053 #[derive(Serialize)]
1054 struct Item {
1055 name: String,
1056 value: i32,
1057 }
1058
1059 #[derive(Serialize)]
1060 struct Container {
1061 items: Vec<Item>,
1062 }
1063
1064 let theme = Theme::new().add("name", Style::new().bold());
1065 let data = Container {
1066 items: vec![
1067 Item {
1068 name: "foo".into(),
1069 value: 1,
1070 },
1071 Item {
1072 name: "bar".into(),
1073 value: 2,
1074 },
1075 ],
1076 };
1077
1078 let template = r#"{% for item in items %}[name]{{ item.name }}[/name]={{ item.value }}
1079{% endfor %}"#;
1080
1081 let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1082 assert_eq!(output, "foo=1\nbar=2\n");
1083 }
1084
1085 #[test]
1086 fn test_render_with_output_term_debug() {
1087 let theme = Theme::new()
1088 .add("title", Style::new().bold())
1089 .add("count", Style::new().cyan());
1090
1091 #[derive(Serialize)]
1092 struct Data {
1093 name: String,
1094 value: usize,
1095 }
1096
1097 let data = Data {
1098 name: "Test".into(),
1099 value: 42,
1100 };
1101
1102 let output = render_with_output(
1103 r#"[title]{{ name }}[/title]: [count]{{ value }}[/count]"#,
1104 &data,
1105 &theme,
1106 OutputMode::TermDebug,
1107 )
1108 .unwrap();
1109
1110 assert_eq!(output, "[title]Test[/title]: [count]42[/count]");
1111 }
1112
1113 #[test]
1114 fn test_render_with_output_term_debug_preserves_tags() {
1115 let theme = Theme::new().add("known", Style::new().bold());
1116
1117 #[derive(Serialize)]
1118 struct Data {
1119 message: String,
1120 }
1121
1122 let data = Data {
1123 message: "hello".into(),
1124 };
1125
1126 let output = render_with_output(
1128 r#"[unknown]{{ message }}[/unknown]"#,
1129 &data,
1130 &theme,
1131 OutputMode::TermDebug,
1132 )
1133 .unwrap();
1134
1135 assert_eq!(output, "[unknown]hello[/unknown]");
1136
1137 let output = render_with_output(
1139 r#"[known]{{ message }}[/known]"#,
1140 &data,
1141 &theme,
1142 OutputMode::TermDebug,
1143 )
1144 .unwrap();
1145
1146 assert_eq!(output, "[known]hello[/known]");
1147 }
1148
1149 #[test]
1150 fn test_render_auto_json_mode() {
1151 use serde_json::json;
1152
1153 let theme = Theme::new();
1154 let data = json!({"name": "test", "count": 42});
1155
1156 let output = render_auto("unused template", &data, &theme, OutputMode::Json).unwrap();
1157
1158 assert!(output.contains("\"name\": \"test\""));
1159 assert!(output.contains("\"count\": 42"));
1160 }
1161
1162 #[test]
1163 fn test_render_auto_text_mode_uses_template() {
1164 use serde_json::json;
1165
1166 let theme = Theme::new();
1167 let data = json!({"name": "test"});
1168
1169 let output = render_auto("Name: {{ name }}", &data, &theme, OutputMode::Text).unwrap();
1170
1171 assert_eq!(output, "Name: test");
1172 }
1173
1174 #[test]
1175 fn test_render_auto_term_mode_uses_template() {
1176 use serde_json::json;
1177
1178 let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1179 let data = json!({"name": "test"});
1180
1181 let output = render_auto(
1182 r#"[bold]{{ name }}[/bold]"#,
1183 &data,
1184 &theme,
1185 OutputMode::Term,
1186 )
1187 .unwrap();
1188
1189 assert!(output.contains("\x1b[1m"));
1190 assert!(output.contains("test"));
1191 }
1192
1193 #[test]
1194 fn test_render_auto_json_with_struct() {
1195 #[derive(Serialize)]
1196 struct Report {
1197 title: String,
1198 items: Vec<String>,
1199 }
1200
1201 let theme = Theme::new();
1202 let data = Report {
1203 title: "Summary".into(),
1204 items: vec!["one".into(), "two".into()],
1205 };
1206
1207 let output = render_auto("unused", &data, &theme, OutputMode::Json).unwrap();
1208
1209 assert!(output.contains("\"title\": \"Summary\""));
1210 assert!(output.contains("\"items\""));
1211 assert!(output.contains("\"one\""));
1212 }
1213
1214 #[test]
1215 fn test_render_with_alias() {
1216 let theme = Theme::new()
1217 .add("base", Style::new().bold())
1218 .add("alias", "base");
1219
1220 let output = render_with_output(
1221 r#"[alias]text[/alias]"#,
1222 &serde_json::json!({}),
1223 &theme,
1224 OutputMode::Text,
1225 )
1226 .unwrap();
1227
1228 assert_eq!(output, "text");
1229 }
1230
1231 #[test]
1232 fn test_render_with_alias_chain() {
1233 let theme = Theme::new()
1234 .add("muted", Style::new().dim())
1235 .add("disabled", "muted")
1236 .add("timestamp", "disabled");
1237
1238 let output = render_with_output(
1239 r#"[timestamp]12:00[/timestamp]"#,
1240 &serde_json::json!({}),
1241 &theme,
1242 OutputMode::Text,
1243 )
1244 .unwrap();
1245
1246 assert_eq!(output, "12:00");
1247 }
1248
1249 #[test]
1250 fn test_render_fails_with_dangling_alias() {
1251 let theme = Theme::new().add("orphan", "missing");
1252
1253 let result = render_with_output(
1254 r#"[orphan]text[/orphan]"#,
1255 &serde_json::json!({}),
1256 &theme,
1257 OutputMode::Text,
1258 );
1259
1260 assert!(result.is_err());
1261 let err = result.unwrap_err();
1262 assert!(err.to_string().contains("orphan"));
1263 assert!(err.to_string().contains("missing"));
1264 }
1265
1266 #[test]
1267 fn test_render_fails_with_cycle() {
1268 let theme = Theme::new().add("a", "b").add("b", "a");
1269
1270 let result = render_with_output(
1271 r#"[a]text[/a]"#,
1272 &serde_json::json!({}),
1273 &theme,
1274 OutputMode::Text,
1275 );
1276
1277 assert!(result.is_err());
1278 assert!(result.unwrap_err().to_string().contains("cycle"));
1279 }
1280
1281 #[test]
1282 fn test_three_layer_styling_pattern() {
1283 let theme = Theme::new()
1284 .add("dim_style", Style::new().dim())
1285 .add("cyan_bold", Style::new().cyan().bold())
1286 .add("yellow_bg", Style::new().on_yellow())
1287 .add("muted", "dim_style")
1288 .add("accent", "cyan_bold")
1289 .add("highlighted", "yellow_bg")
1290 .add("timestamp", "muted")
1291 .add("title", "accent")
1292 .add("selected_item", "highlighted");
1293
1294 assert!(theme.validate().is_ok());
1295
1296 let output = render_with_output(
1297 r#"[timestamp]{{ time }}[/timestamp] - [title]{{ name }}[/title]"#,
1298 &serde_json::json!({"time": "12:00", "name": "Report"}),
1299 &theme,
1300 OutputMode::Text,
1301 )
1302 .unwrap();
1303
1304 assert_eq!(output, "12:00 - Report");
1305 }
1306
1307 #[test]
1312 fn test_render_auto_yaml_mode() {
1313 use serde_json::json;
1314
1315 let theme = Theme::new();
1316 let data = json!({"name": "test", "count": 42});
1317
1318 let output = render_auto("unused template", &data, &theme, OutputMode::Yaml).unwrap();
1319
1320 assert!(output.contains("name: test"));
1321 assert!(output.contains("count: 42"));
1322 }
1323
1324 #[test]
1325 fn test_render_auto_xml_mode() {
1326 let theme = Theme::new();
1327
1328 #[derive(Serialize)]
1329 #[serde(rename = "root")]
1330 struct Data {
1331 name: String,
1332 count: usize,
1333 }
1334
1335 let data = Data {
1336 name: "test".into(),
1337 count: 42,
1338 };
1339
1340 let output = render_auto("unused template", &data, &theme, OutputMode::Xml).unwrap();
1341
1342 assert!(output.contains("<root>"));
1343 assert!(output.contains("<name>test</name>"));
1344 }
1345
1346 #[test]
1347 fn test_render_auto_csv_mode_auto_flatten() {
1348 use serde_json::json;
1349
1350 let theme = Theme::new();
1351 let data = json!([
1352 {"name": "Alice", "stats": {"score": 10}},
1353 {"name": "Bob", "stats": {"score": 20}}
1354 ]);
1355
1356 let output = render_auto("unused", &data, &theme, OutputMode::Csv).unwrap();
1357
1358 assert!(output.contains("name,stats.score"));
1359 assert!(output.contains("Alice,10"));
1360 assert!(output.contains("Bob,20"));
1361 }
1362
1363 #[test]
1364 fn test_render_auto_csv_mode_with_spec() {
1365 let theme = Theme::new();
1366 let data = json!([
1367 {"name": "Alice", "meta": {"age": 30, "role": "admin"}},
1368 {"name": "Bob", "meta": {"age": 25, "role": "user"}}
1369 ]);
1370
1371 let spec = FlatDataSpec::builder()
1372 .column(Column::new(Width::Fixed(10)).key("name"))
1373 .column(
1374 Column::new(Width::Fixed(10))
1375 .key("meta.role")
1376 .header("Role"),
1377 )
1378 .build();
1379
1380 let output =
1381 render_auto_with_spec("unused", &data, &theme, OutputMode::Csv, Some(&spec)).unwrap();
1382
1383 let lines: Vec<&str> = output.lines().collect();
1384 assert_eq!(lines[0], "name,Role");
1385 assert!(lines.contains(&"Alice,admin"));
1386 assert!(lines.contains(&"Bob,user"));
1387 assert!(!output.contains("30"));
1388 }
1389
1390 #[test]
1395 fn test_render_with_context_basic() {
1396 use crate::context::{ContextRegistry, RenderContext};
1397
1398 #[derive(Serialize)]
1399 struct Data {
1400 name: String,
1401 }
1402
1403 let theme = Theme::new();
1404 let data = Data {
1405 name: "Alice".into(),
1406 };
1407 let json_data = serde_json::to_value(&data).unwrap();
1408
1409 let mut registry = ContextRegistry::new();
1410 registry.add_static("version", Value::from("1.0.0"));
1411
1412 let render_ctx = RenderContext::new(OutputMode::Text, Some(80), &theme, &json_data);
1413
1414 let output = render_with_context(
1415 "{{ name }} (v{{ version }})",
1416 &data,
1417 &theme,
1418 OutputMode::Text,
1419 ®istry,
1420 &render_ctx,
1421 None,
1422 )
1423 .unwrap();
1424
1425 assert_eq!(output, "Alice (v1.0.0)");
1426 }
1427
1428 #[test]
1429 fn test_render_with_context_dynamic_provider() {
1430 use crate::context::{ContextRegistry, RenderContext};
1431
1432 #[derive(Serialize)]
1433 struct Data {
1434 message: String,
1435 }
1436
1437 let theme = Theme::new();
1438 let data = Data {
1439 message: "Hello".into(),
1440 };
1441 let json_data = serde_json::to_value(&data).unwrap();
1442
1443 let mut registry = ContextRegistry::new();
1444 registry.add_provider("terminal_width", |ctx: &RenderContext| {
1445 Value::from(ctx.terminal_width.unwrap_or(80))
1446 });
1447
1448 let render_ctx = RenderContext::new(OutputMode::Text, Some(120), &theme, &json_data);
1449
1450 let output = render_with_context(
1451 "{{ message }} (width={{ terminal_width }})",
1452 &data,
1453 &theme,
1454 OutputMode::Text,
1455 ®istry,
1456 &render_ctx,
1457 None,
1458 )
1459 .unwrap();
1460
1461 assert_eq!(output, "Hello (width=120)");
1462 }
1463
1464 #[test]
1465 fn test_render_with_context_data_takes_precedence() {
1466 use crate::context::{ContextRegistry, RenderContext};
1467
1468 #[derive(Serialize)]
1469 struct Data {
1470 value: String,
1471 }
1472
1473 let theme = Theme::new();
1474 let data = Data {
1475 value: "from_data".into(),
1476 };
1477 let json_data = serde_json::to_value(&data).unwrap();
1478
1479 let mut registry = ContextRegistry::new();
1480 registry.add_static("value", Value::from("from_context"));
1481
1482 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1483
1484 let output = render_with_context(
1485 "{{ value }}",
1486 &data,
1487 &theme,
1488 OutputMode::Text,
1489 ®istry,
1490 &render_ctx,
1491 None,
1492 )
1493 .unwrap();
1494
1495 assert_eq!(output, "from_data");
1496 }
1497
1498 #[test]
1499 fn test_render_with_context_empty_registry() {
1500 use crate::context::{ContextRegistry, RenderContext};
1501
1502 #[derive(Serialize)]
1503 struct Data {
1504 name: String,
1505 }
1506
1507 let theme = Theme::new();
1508 let data = Data {
1509 name: "Test".into(),
1510 };
1511 let json_data = serde_json::to_value(&data).unwrap();
1512
1513 let registry = ContextRegistry::new();
1514 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1515
1516 let output = render_with_context(
1517 "{{ name }}",
1518 &data,
1519 &theme,
1520 OutputMode::Text,
1521 ®istry,
1522 &render_ctx,
1523 None,
1524 )
1525 .unwrap();
1526
1527 assert_eq!(output, "Test");
1528 }
1529
1530 #[test]
1531 fn test_render_auto_with_context_json_mode() {
1532 use crate::context::{ContextRegistry, RenderContext};
1533
1534 #[derive(Serialize)]
1535 struct Data {
1536 count: usize,
1537 }
1538
1539 let theme = Theme::new();
1540 let data = Data { count: 42 };
1541 let json_data = serde_json::to_value(&data).unwrap();
1542
1543 let mut registry = ContextRegistry::new();
1544 registry.add_static("extra", Value::from("ignored"));
1545
1546 let render_ctx = RenderContext::new(OutputMode::Json, None, &theme, &json_data);
1547
1548 let output = render_auto_with_context(
1549 "unused template {{ extra }}",
1550 &data,
1551 &theme,
1552 OutputMode::Json,
1553 ®istry,
1554 &render_ctx,
1555 None,
1556 )
1557 .unwrap();
1558
1559 assert!(output.contains("\"count\": 42"));
1560 assert!(!output.contains("ignored"));
1561 }
1562
1563 #[test]
1564 fn test_render_auto_with_context_text_mode() {
1565 use crate::context::{ContextRegistry, RenderContext};
1566
1567 #[derive(Serialize)]
1568 struct Data {
1569 count: usize,
1570 }
1571
1572 let theme = Theme::new();
1573 let data = Data { count: 42 };
1574 let json_data = serde_json::to_value(&data).unwrap();
1575
1576 let mut registry = ContextRegistry::new();
1577 registry.add_static("label", Value::from("Items"));
1578
1579 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1580
1581 let output = render_auto_with_context(
1582 "{{ label }}: {{ count }}",
1583 &data,
1584 &theme,
1585 OutputMode::Text,
1586 ®istry,
1587 &render_ctx,
1588 None,
1589 )
1590 .unwrap();
1591
1592 assert_eq!(output, "Items: 42");
1593 }
1594
1595 #[test]
1596 fn test_render_with_context_provider_uses_output_mode() {
1597 use crate::context::{ContextRegistry, RenderContext};
1598
1599 #[derive(Serialize)]
1600 struct Data {}
1601
1602 let theme = Theme::new();
1603 let data = Data {};
1604 let json_data = serde_json::to_value(&data).unwrap();
1605
1606 let mut registry = ContextRegistry::new();
1607 registry.add_provider("mode", |ctx: &RenderContext| {
1608 Value::from(format!("{:?}", ctx.output_mode))
1609 });
1610
1611 let render_ctx = RenderContext::new(OutputMode::Term, None, &theme, &json_data);
1612
1613 let output = render_with_context(
1614 "Mode: {{ mode }}",
1615 &data,
1616 &theme,
1617 OutputMode::Term,
1618 ®istry,
1619 &render_ctx,
1620 None,
1621 )
1622 .unwrap();
1623
1624 assert_eq!(output, "Mode: Term");
1625 }
1626
1627 #[test]
1628 fn test_render_with_context_nested_data() {
1629 use crate::context::{ContextRegistry, RenderContext};
1630
1631 #[derive(Serialize)]
1632 struct Item {
1633 name: String,
1634 }
1635
1636 #[derive(Serialize)]
1637 struct Data {
1638 items: Vec<Item>,
1639 }
1640
1641 let theme = Theme::new();
1642 let data = Data {
1643 items: vec![Item { name: "one".into() }, Item { name: "two".into() }],
1644 };
1645 let json_data = serde_json::to_value(&data).unwrap();
1646
1647 let mut registry = ContextRegistry::new();
1648 registry.add_static("prefix", Value::from("- "));
1649
1650 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1651
1652 let output = render_with_context(
1653 "{% for item in items %}{{ prefix }}{{ item.name }}\n{% endfor %}",
1654 &data,
1655 &theme,
1656 OutputMode::Text,
1657 ®istry,
1658 &render_ctx,
1659 None,
1660 )
1661 .unwrap();
1662
1663 assert_eq!(output, "- one\n- two\n");
1664 }
1665
1666 #[test]
1667 fn test_render_with_mode_forces_color_mode() {
1668 use console::Style;
1669
1670 #[derive(Serialize)]
1671 struct Data {
1672 status: String,
1673 }
1674
1675 let theme = Theme::new().add_adaptive(
1678 "status",
1679 Style::new(), Some(Style::new().black().force_styling(true)), Some(Style::new().white().force_styling(true)), );
1683
1684 let data = Data {
1685 status: "test".into(),
1686 };
1687
1688 let dark_output = render_with_mode(
1690 r#"[status]{{ status }}[/status]"#,
1691 &data,
1692 &theme,
1693 OutputMode::Term,
1694 ColorMode::Dark,
1695 )
1696 .unwrap();
1697
1698 let light_output = render_with_mode(
1700 r#"[status]{{ status }}[/status]"#,
1701 &data,
1702 &theme,
1703 OutputMode::Term,
1704 ColorMode::Light,
1705 )
1706 .unwrap();
1707
1708 assert_ne!(dark_output, light_output);
1710
1711 assert!(
1713 dark_output.contains("\x1b[37"),
1714 "Expected white (37) in dark mode"
1715 );
1716
1717 assert!(
1719 light_output.contains("\x1b[30"),
1720 "Expected black (30) in light mode"
1721 );
1722 }
1723
1724 #[test]
1729 fn test_tag_syntax_text_mode() {
1730 let theme = Theme::new().add("title", Style::new().bold());
1731
1732 #[derive(Serialize)]
1733 struct Data {
1734 name: String,
1735 }
1736
1737 let output = render_with_output(
1738 "[title]{{ name }}[/title]",
1739 &Data {
1740 name: "Hello".into(),
1741 },
1742 &theme,
1743 OutputMode::Text,
1744 )
1745 .unwrap();
1746
1747 assert_eq!(output, "Hello");
1749 }
1750
1751 #[test]
1752 fn test_tag_syntax_term_mode() {
1753 let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1754
1755 #[derive(Serialize)]
1756 struct Data {
1757 name: String,
1758 }
1759
1760 let output = render_with_output(
1761 "[bold]{{ name }}[/bold]",
1762 &Data {
1763 name: "Hello".into(),
1764 },
1765 &theme,
1766 OutputMode::Term,
1767 )
1768 .unwrap();
1769
1770 assert!(output.contains("\x1b[1m"));
1772 assert!(output.contains("Hello"));
1773 }
1774
1775 #[test]
1776 fn test_tag_syntax_debug_mode() {
1777 let theme = Theme::new().add("title", Style::new().bold());
1778
1779 #[derive(Serialize)]
1780 struct Data {
1781 name: String,
1782 }
1783
1784 let output = render_with_output(
1785 "[title]{{ name }}[/title]",
1786 &Data {
1787 name: "Hello".into(),
1788 },
1789 &theme,
1790 OutputMode::TermDebug,
1791 )
1792 .unwrap();
1793
1794 assert_eq!(output, "[title]Hello[/title]");
1796 }
1797
1798 #[test]
1799 fn test_tag_syntax_unknown_tag_passthrough() {
1800 let theme = Theme::new().add("known", Style::new().bold());
1802
1803 #[derive(Serialize)]
1804 struct Data {
1805 name: String,
1806 }
1807
1808 let output = render_with_output(
1810 "[unknown]{{ name }}[/unknown]",
1811 &Data {
1812 name: "Hello".into(),
1813 },
1814 &theme,
1815 OutputMode::Term,
1816 )
1817 .unwrap();
1818
1819 assert!(output.contains("[unknown?]"));
1821 assert!(output.contains("[/unknown?]"));
1822 assert!(output.contains("Hello"));
1823
1824 let text_output = render_with_output(
1826 "[unknown]{{ name }}[/unknown]",
1827 &Data {
1828 name: "Hello".into(),
1829 },
1830 &theme,
1831 OutputMode::Text,
1832 )
1833 .unwrap();
1834
1835 assert_eq!(text_output, "Hello");
1837 }
1838
1839 #[test]
1840 fn test_tag_syntax_nested() {
1841 let theme = Theme::new()
1842 .add("bold", Style::new().bold().force_styling(true))
1843 .add("red", Style::new().red().force_styling(true));
1844
1845 #[derive(Serialize)]
1846 struct Data {
1847 word: String,
1848 }
1849
1850 let output = render_with_output(
1851 "[bold][red]{{ word }}[/red][/bold]",
1852 &Data {
1853 word: "test".into(),
1854 },
1855 &theme,
1856 OutputMode::Term,
1857 )
1858 .unwrap();
1859
1860 assert!(output.contains("\x1b[1m")); assert!(output.contains("\x1b[31m")); assert!(output.contains("test"));
1864 }
1865
1866 #[test]
1867 fn test_tag_syntax_multiple_styles() {
1868 let theme = Theme::new()
1869 .add("title", Style::new().bold())
1870 .add("count", Style::new().cyan());
1871
1872 #[derive(Serialize)]
1873 struct Data {
1874 name: String,
1875 num: usize,
1876 }
1877
1878 let output = render_with_output(
1879 r#"[title]{{ name }}[/title]: [count]{{ num }}[/count]"#,
1880 &Data {
1881 name: "Items".into(),
1882 num: 42,
1883 },
1884 &theme,
1885 OutputMode::Text,
1886 )
1887 .unwrap();
1888
1889 assert_eq!(output, "Items: 42");
1890 }
1891
1892 #[test]
1893 fn test_tag_syntax_in_loop() {
1894 let theme = Theme::new().add("item", Style::new().cyan());
1895
1896 #[derive(Serialize)]
1897 struct Data {
1898 items: Vec<String>,
1899 }
1900
1901 let output = render_with_output(
1902 "{% for item in items %}[item]{{ item }}[/item]\n{% endfor %}",
1903 &Data {
1904 items: vec!["one".into(), "two".into()],
1905 },
1906 &theme,
1907 OutputMode::Text,
1908 )
1909 .unwrap();
1910
1911 assert_eq!(output, "one\ntwo\n");
1912 }
1913
1914 #[test]
1915 fn test_tag_syntax_literal_brackets() {
1916 let theme = Theme::new();
1918
1919 #[derive(Serialize)]
1920 struct Data {
1921 msg: String,
1922 }
1923
1924 let output = render_with_output(
1925 "Array: [1, 2, 3] and {{ msg }}",
1926 &Data { msg: "done".into() },
1927 &theme,
1928 OutputMode::Text,
1929 )
1930 .unwrap();
1931
1932 assert_eq!(output, "Array: [1, 2, 3] and done");
1934 }
1935
1936 #[test]
1941 fn test_validate_template_all_known_tags() {
1942 let theme = Theme::new()
1943 .add("title", Style::new().bold())
1944 .add("count", Style::new().cyan());
1945
1946 #[derive(Serialize)]
1947 struct Data {
1948 name: String,
1949 }
1950
1951 let result = validate_template(
1952 "[title]{{ name }}[/title]",
1953 &Data {
1954 name: "Hello".into(),
1955 },
1956 &theme,
1957 );
1958
1959 assert!(result.is_ok());
1960 }
1961
1962 #[test]
1963 fn test_validate_template_unknown_tag_fails() {
1964 let theme = Theme::new().add("known", Style::new().bold());
1965
1966 #[derive(Serialize)]
1967 struct Data {
1968 name: String,
1969 }
1970
1971 let result = validate_template(
1972 "[unknown]{{ name }}[/unknown]",
1973 &Data {
1974 name: "Hello".into(),
1975 },
1976 &theme,
1977 );
1978
1979 assert!(result.is_err());
1980 let err = result.unwrap_err();
1981 let errors = err
1982 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
1983 .expect("Expected UnknownTagErrors");
1984 assert_eq!(errors.len(), 2); }
1986
1987 #[test]
1988 fn test_validate_template_multiple_unknown_tags() {
1989 let theme = Theme::new().add("known", Style::new().bold());
1990
1991 #[derive(Serialize)]
1992 struct Data {
1993 a: String,
1994 b: String,
1995 }
1996
1997 let result = validate_template(
1998 "[foo]{{ a }}[/foo] and [bar]{{ b }}[/bar]",
1999 &Data {
2000 a: "x".into(),
2001 b: "y".into(),
2002 },
2003 &theme,
2004 );
2005
2006 assert!(result.is_err());
2007 let err = result.unwrap_err();
2008 let errors = err
2009 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2010 .expect("Expected UnknownTagErrors");
2011 assert_eq!(errors.len(), 4); }
2013
2014 #[test]
2015 fn test_validate_template_plain_text_passes() {
2016 let theme = Theme::new();
2017
2018 #[derive(Serialize)]
2019 struct Data {
2020 msg: String,
2021 }
2022
2023 let result = validate_template("Just plain {{ msg }}", &Data { msg: "hi".into() }, &theme);
2024
2025 assert!(result.is_ok());
2026 }
2027
2028 #[test]
2029 fn test_validate_template_mixed_known_and_unknown() {
2030 let theme = Theme::new().add("known", Style::new().bold());
2031
2032 #[derive(Serialize)]
2033 struct Data {
2034 a: String,
2035 b: String,
2036 }
2037
2038 let result = validate_template(
2039 "[known]{{ a }}[/known] [unknown]{{ b }}[/unknown]",
2040 &Data {
2041 a: "x".into(),
2042 b: "y".into(),
2043 },
2044 &theme,
2045 );
2046
2047 assert!(result.is_err());
2048 let err = result.unwrap_err();
2049 let errors = err
2050 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2051 .expect("Expected UnknownTagErrors");
2052 assert_eq!(errors.len(), 2);
2054 assert!(errors.errors.iter().any(|e| e.tag == "unknown"));
2055 }
2056
2057 #[test]
2058 fn test_validate_template_syntax_error_fails() {
2059 let theme = Theme::new();
2060 #[derive(Serialize)]
2061 struct Data {}
2062
2063 let result = validate_template("{{ unclosed", &Data {}, &theme);
2065 assert!(result.is_err());
2066
2067 let err = result.unwrap_err();
2068 assert!(err
2070 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2071 .is_none());
2072 let msg = err.to_string();
2074 assert!(
2075 msg.contains("syntax error") || msg.contains("unexpected"),
2076 "Got: {}",
2077 msg
2078 );
2079 }
2080
2081 #[test]
2082 fn test_render_auto_with_context_yaml_mode() {
2083 use crate::context::{ContextRegistry, RenderContext};
2084 use serde_json::json;
2085
2086 let theme = Theme::new();
2087 let data = json!({"name": "test", "count": 42});
2088
2089 let registry = ContextRegistry::new();
2091 let render_ctx = RenderContext::new(OutputMode::Yaml, Some(80), &theme, &data);
2092
2093 let output = render_auto_with_context(
2095 "unused template",
2096 &data,
2097 &theme,
2098 OutputMode::Yaml,
2099 ®istry,
2100 &render_ctx,
2101 None,
2102 )
2103 .unwrap();
2104
2105 assert!(output.contains("name: test"));
2106 assert!(output.contains("count: 42"));
2107 }
2108}