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 fn apply_style_tags(output: &str, styles: &Styles, mode: OutputMode) -> String {
110 let transform = output_mode_to_transform(mode);
111 let resolved_styles = styles.to_resolved_map();
112 let parser =
113 BBParser::new(resolved_styles, transform).unknown_behavior(UnknownTagBehavior::Passthrough);
114 parser.parse(output)
115}
116
117#[derive(Debug, Clone)]
123pub struct RenderResult {
124 pub formatted: String,
126 pub raw: String,
130}
131
132impl RenderResult {
133 pub fn new(formatted: String, raw: String) -> Self {
135 Self { formatted, raw }
136 }
137
138 pub fn plain(text: String) -> Self {
142 Self {
143 formatted: text.clone(),
144 raw: text,
145 }
146 }
147}
148
149pub fn validate_template<T: Serialize>(
195 template: &str,
196 data: &T,
197 theme: &Theme,
198) -> Result<(), Box<dyn std::error::Error>> {
199 let color_mode = detect_color_mode();
200 let styles = theme.resolve_styles(Some(color_mode));
201
202 let engine = MiniJinjaEngine::new();
204 let data_value = serde_json::to_value(data)?;
205 let minijinja_output = engine.render_template(template, &data_value)?;
206
207 let resolved_styles = styles.to_resolved_map();
209 let parser = BBParser::new(resolved_styles, TagTransform::Remove);
210 parser.validate(&minijinja_output)?;
211
212 Ok(())
213}
214
215pub fn render<T: Serialize>(
245 template: &str,
246 data: &T,
247 theme: &Theme,
248) -> Result<String, RenderError> {
249 render_with_output(template, data, theme, OutputMode::Auto)
250}
251
252pub fn render_with_output<T: Serialize>(
296 template: &str,
297 data: &T,
298 theme: &Theme,
299 mode: OutputMode,
300) -> Result<String, RenderError> {
301 let color_mode = detect_color_mode();
303 render_with_mode(template, data, theme, mode, color_mode)
304}
305
306pub fn render_with_mode<T: Serialize>(
356 template: &str,
357 data: &T,
358 theme: &Theme,
359 output_mode: OutputMode,
360 color_mode: ColorMode,
361) -> Result<String, RenderError> {
362 theme
364 .validate()
365 .map_err(|e| RenderError::StyleError(e.to_string()))?;
366
367 let styles = theme.resolve_styles(Some(color_mode));
369
370 let engine = MiniJinjaEngine::new();
372 let data_value = serde_json::to_value(data)?;
373 let template_output = engine.render_template(template, &data_value)?;
374
375 let final_output = apply_style_tags(&template_output, &styles, output_mode);
377
378 Ok(final_output)
379}
380
381pub fn render_with_vars<T, K, V, I>(
422 template: &str,
423 data: &T,
424 theme: &Theme,
425 mode: OutputMode,
426 vars: I,
427) -> Result<String, RenderError>
428where
429 T: Serialize,
430 K: AsRef<str>,
431 V: Into<serde_json::Value>,
432 I: IntoIterator<Item = (K, V)>,
433{
434 let color_mode = detect_color_mode();
435 let styles = theme.resolve_styles(Some(color_mode));
436
437 styles
439 .validate()
440 .map_err(|e| RenderError::StyleError(e.to_string()))?;
441
442 let mut context: HashMap<String, serde_json::Value> = HashMap::new();
444 for (key, value) in vars {
445 context.insert(key.as_ref().to_string(), value.into());
446 }
447
448 let engine = MiniJinjaEngine::new();
450 let data_value = serde_json::to_value(data)?;
451 let template_output = engine.render_with_context(template, &data_value, context)?;
452
453 let final_output = apply_style_tags(&template_output, &styles, mode);
455
456 Ok(final_output)
457}
458
459pub fn render_auto<T: Serialize>(
506 template: &str,
507 data: &T,
508 theme: &Theme,
509 mode: OutputMode,
510) -> Result<String, RenderError> {
511 if mode.is_structured() {
512 match mode {
513 OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
514 OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
515 OutputMode::Xml => Ok(quick_xml::se::to_string(data)?),
516 OutputMode::Csv => {
517 let value = serde_json::to_value(data)?;
518 let (headers, rows) = crate::util::flatten_json_for_csv(&value);
519
520 let mut wtr = csv::Writer::from_writer(Vec::new());
521 wtr.write_record(&headers)?;
522 for row in rows {
523 wtr.write_record(&row)?;
524 }
525 let bytes = wtr.into_inner()?;
526 Ok(String::from_utf8(bytes)?)
527 }
528 _ => unreachable!("is_structured() returned true for non-structured mode"),
529 }
530 } else {
531 render_with_output(template, data, theme, mode)
532 }
533}
534
535pub fn render_auto_with_spec<T: Serialize>(
549 template: &str,
550 data: &T,
551 theme: &Theme,
552 mode: OutputMode,
553 spec: Option<&FlatDataSpec>,
554) -> Result<String, RenderError> {
555 if mode.is_structured() {
556 match mode {
557 OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
558 OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
559 OutputMode::Xml => Ok(quick_xml::se::to_string(data)?),
560 OutputMode::Csv => {
561 let value = serde_json::to_value(data)?;
562
563 let (headers, rows) = if let Some(s) = spec {
564 let headers = s.extract_header();
566 let rows: Vec<Vec<String>> = match value {
567 serde_json::Value::Array(items) => {
568 items.iter().map(|item| s.extract_row(item)).collect()
569 }
570 _ => vec![s.extract_row(&value)],
571 };
572 (headers, rows)
573 } else {
574 crate::util::flatten_json_for_csv(&value)
576 };
577
578 let mut wtr = csv::Writer::from_writer(Vec::new());
579 wtr.write_record(&headers)?;
580 for row in rows {
581 wtr.write_record(&row)?;
582 }
583 let bytes = wtr.into_inner()?;
584 Ok(String::from_utf8(bytes)?)
585 }
586 _ => unreachable!("is_structured() returned true for non-structured mode"),
587 }
588 } else {
589 render_with_output(template, data, theme, mode)
590 }
591}
592
593pub fn render_with_context<T: Serialize>(
658 template: &str,
659 data: &T,
660 theme: &Theme,
661 mode: OutputMode,
662 context_registry: &ContextRegistry,
663 render_context: &RenderContext,
664 template_registry: Option<&super::TemplateRegistry>,
665) -> Result<String, RenderError> {
666 let color_mode = detect_color_mode();
667 let styles = theme.resolve_styles(Some(color_mode));
668
669 styles
671 .validate()
672 .map_err(|e| RenderError::StyleError(e.to_string()))?;
673
674 let mut engine = MiniJinjaEngine::new();
675
676 let template_content = if let Some(registry) = template_registry {
680 if let Ok(content) = registry.get_content(template) {
681 content
682 } else {
683 template.to_string()
684 }
685 } else {
686 template.to_string()
687 };
688
689 if let Some(registry) = template_registry {
691 for name in registry.names() {
692 if let Ok(content) = registry.get_content(name) {
693 engine.add_template(name, &content)?;
694 }
695 }
696 }
697
698 let context = build_combined_context(data, context_registry, render_context)?;
701
702 let data_value = serde_json::to_value(data)?;
704 let template_output = engine.render_with_context(&template_content, &data_value, context)?;
705
706 let final_output = apply_style_tags(&template_output, &styles, mode);
708
709 Ok(final_output)
710}
711
712pub fn render_auto_with_context<T: Serialize>(
779 template: &str,
780 data: &T,
781 theme: &Theme,
782 mode: OutputMode,
783 context_registry: &ContextRegistry,
784 render_context: &RenderContext,
785 template_registry: Option<&super::TemplateRegistry>,
786) -> Result<String, RenderError> {
787 if mode.is_structured() {
788 match mode {
789 OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
790 OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
791 OutputMode::Xml => Ok(quick_xml::se::to_string(data)?),
792 OutputMode::Csv => {
793 let value = serde_json::to_value(data)?;
794 let (headers, rows) = crate::util::flatten_json_for_csv(&value);
795
796 let mut wtr = csv::Writer::from_writer(Vec::new());
797 wtr.write_record(&headers)?;
798 for row in rows {
799 wtr.write_record(&row)?;
800 }
801 let bytes = wtr.into_inner()?;
802 Ok(String::from_utf8(bytes)?)
803 }
804 _ => unreachable!("is_structured() returned true for non-structured mode"),
805 }
806 } else {
807 render_with_context(
808 template,
809 data,
810 theme,
811 mode,
812 context_registry,
813 render_context,
814 template_registry,
815 )
816 }
817}
818
819fn build_combined_context<T: Serialize>(
823 data: &T,
824 context_registry: &ContextRegistry,
825 render_context: &RenderContext,
826) -> Result<HashMap<String, serde_json::Value>, RenderError> {
827 let context_values = context_registry.resolve(render_context);
829
830 let data_value = serde_json::to_value(data)?;
832
833 let mut combined: HashMap<String, serde_json::Value> = HashMap::new();
834
835 for (key, value) in context_values {
837 let json_val =
841 serde_json::to_value(value).map_err(|e| RenderError::ContextError(e.to_string()))?;
842 combined.insert(key, json_val);
843 }
844
845 if let Some(obj) = data_value.as_object() {
847 for (key, value) in obj {
848 combined.insert(key.clone(), value.clone());
849 }
850 }
851
852 Ok(combined)
853}
854
855pub fn render_auto_with_engine(
860 engine: &dyn super::TemplateEngine,
861 template: &str,
862 data: &serde_json::Value,
863 theme: &Theme,
864 mode: OutputMode,
865 context_registry: &ContextRegistry,
866 render_context: &RenderContext,
867) -> Result<String, RenderError> {
868 if mode.is_structured() {
869 match mode {
870 OutputMode::Json => Ok(serde_json::to_string_pretty(data)?),
871 OutputMode::Yaml => Ok(serde_yaml::to_string(data)?),
872 OutputMode::Xml => Ok(quick_xml::se::to_string(data)?),
873 OutputMode::Csv => {
874 let (headers, rows) = crate::util::flatten_json_for_csv(data);
875
876 let mut wtr = csv::Writer::from_writer(Vec::new());
877 wtr.write_record(&headers)?;
878 for row in rows {
879 wtr.write_record(&row)?;
880 }
881 let bytes = wtr.into_inner()?;
882 Ok(String::from_utf8(bytes)?)
883 }
884 _ => unreachable!("is_structured() returned true for non-structured mode"),
885 }
886 } else {
887 let color_mode = detect_color_mode();
888 let styles = theme.resolve_styles(Some(color_mode));
889
890 styles
892 .validate()
893 .map_err(|e| RenderError::StyleError(e.to_string()))?;
894
895 let context_map = build_combined_context(data, context_registry, render_context)?;
899
900 let combined_value = serde_json::Value::Object(context_map.into_iter().collect());
902
903 let template_output = if engine.has_template(template) {
905 engine.render_named(template, &combined_value)?
906 } else {
907 engine.render_template(template, &combined_value)?
908 };
909
910 let final_output = apply_style_tags(&template_output, &styles, mode);
912
913 Ok(final_output)
914 }
915}
916
917pub fn render_auto_with_engine_split(
927 engine: &dyn super::TemplateEngine,
928 template: &str,
929 data: &serde_json::Value,
930 theme: &Theme,
931 mode: OutputMode,
932 context_registry: &ContextRegistry,
933 render_context: &RenderContext,
934) -> Result<RenderResult, RenderError> {
935 if mode.is_structured() {
936 let output = match mode {
938 OutputMode::Json => serde_json::to_string_pretty(data)?,
939 OutputMode::Yaml => serde_yaml::to_string(data)?,
940 OutputMode::Xml => quick_xml::se::to_string(data)?,
941 OutputMode::Csv => {
942 let (headers, rows) = crate::util::flatten_json_for_csv(data);
943
944 let mut wtr = csv::Writer::from_writer(Vec::new());
945 wtr.write_record(&headers)?;
946 for row in rows {
947 wtr.write_record(&row)?;
948 }
949 let bytes = wtr.into_inner()?;
950 String::from_utf8(bytes)?
951 }
952 _ => unreachable!("is_structured() returned true for non-structured mode"),
953 };
954 Ok(RenderResult::plain(output))
955 } else {
956 let color_mode = detect_color_mode();
957 let styles = theme.resolve_styles(Some(color_mode));
958
959 styles
961 .validate()
962 .map_err(|e| RenderError::StyleError(e.to_string()))?;
963
964 let context_map = build_combined_context(data, context_registry, render_context)?;
966
967 let combined_value = serde_json::Value::Object(context_map.into_iter().collect());
969
970 let raw_output = if engine.has_template(template) {
972 engine.render_named(template, &combined_value)?
973 } else {
974 engine.render_template(template, &combined_value)?
975 };
976
977 let formatted_output = apply_style_tags(&raw_output, &styles, mode);
979
980 let stripped_output = apply_style_tags(&raw_output, &styles, OutputMode::Text);
982
983 Ok(RenderResult::new(formatted_output, stripped_output))
984 }
985}
986
987#[cfg(test)]
988mod tests {
989 use super::*;
990 use crate::tabular::{Column, FlatDataSpec, Width};
991 use crate::Theme;
992 use console::Style;
993 use minijinja::Value;
994 use serde::Serialize;
995 use serde_json::json;
996
997 #[derive(Serialize)]
998 struct SimpleData {
999 message: String,
1000 }
1001
1002 #[derive(Serialize)]
1003 struct ListData {
1004 items: Vec<String>,
1005 count: usize,
1006 }
1007
1008 #[test]
1009 fn test_render_with_output_text_no_ansi() {
1010 let theme = Theme::new().add("red", Style::new().red());
1011 let data = SimpleData {
1012 message: "test".into(),
1013 };
1014
1015 let output = render_with_output(
1016 r#"[red]{{ message }}[/red]"#,
1017 &data,
1018 &theme,
1019 OutputMode::Text,
1020 )
1021 .unwrap();
1022
1023 assert_eq!(output, "test");
1024 assert!(!output.contains("\x1b["));
1025 }
1026
1027 #[test]
1028 fn test_render_with_output_term_has_ansi() {
1029 let theme = Theme::new().add("green", Style::new().green().force_styling(true));
1030 let data = SimpleData {
1031 message: "success".into(),
1032 };
1033
1034 let output = render_with_output(
1035 r#"[green]{{ message }}[/green]"#,
1036 &data,
1037 &theme,
1038 OutputMode::Term,
1039 )
1040 .unwrap();
1041
1042 assert!(output.contains("success"));
1043 assert!(output.contains("\x1b["));
1044 }
1045
1046 #[test]
1047 fn test_render_unknown_style_shows_indicator() {
1048 let theme = Theme::new();
1049 let data = SimpleData {
1050 message: "hello".into(),
1051 };
1052
1053 let output = render_with_output(
1054 r#"[unknown]{{ message }}[/unknown]"#,
1055 &data,
1056 &theme,
1057 OutputMode::Term,
1058 )
1059 .unwrap();
1060
1061 assert_eq!(output, "[unknown?]hello[/unknown?]");
1063 }
1064
1065 #[test]
1066 fn test_render_unknown_style_stripped_in_text_mode() {
1067 let theme = Theme::new();
1068 let data = SimpleData {
1069 message: "hello".into(),
1070 };
1071
1072 let output = render_with_output(
1073 r#"[unknown]{{ message }}[/unknown]"#,
1074 &data,
1075 &theme,
1076 OutputMode::Text,
1077 )
1078 .unwrap();
1079
1080 assert_eq!(output, "hello");
1082 }
1083
1084 #[test]
1085 fn test_render_template_with_loop() {
1086 let theme = Theme::new().add("item", Style::new().cyan());
1087 let data = ListData {
1088 items: vec!["one".into(), "two".into()],
1089 count: 2,
1090 };
1091
1092 let template = r#"{% for item in items %}[item]{{ item }}[/item]
1093{% endfor %}"#;
1094
1095 let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1096 assert_eq!(output, "one\ntwo\n");
1097 }
1098
1099 #[test]
1100 fn test_render_mixed_styled_and_plain() {
1101 let theme = Theme::new().add("count", Style::new().bold());
1102 let data = ListData {
1103 items: vec![],
1104 count: 42,
1105 };
1106
1107 let template = r#"Total: [count]{{ count }}[/count] items"#;
1108 let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1109
1110 assert_eq!(output, "Total: 42 items");
1111 }
1112
1113 #[test]
1114 fn test_render_literal_string_styled() {
1115 let theme = Theme::new().add("header", Style::new().bold());
1116
1117 #[derive(Serialize)]
1118 struct Empty {}
1119
1120 let output = render_with_output(
1121 r#"[header]Header[/header]"#,
1122 &Empty {},
1123 &theme,
1124 OutputMode::Text,
1125 )
1126 .unwrap();
1127
1128 assert_eq!(output, "Header");
1129 }
1130
1131 #[test]
1132 fn test_empty_template() {
1133 let theme = Theme::new();
1134
1135 #[derive(Serialize)]
1136 struct Empty {}
1137
1138 let output = render_with_output("", &Empty {}, &theme, OutputMode::Text).unwrap();
1139 assert_eq!(output, "");
1140 }
1141
1142 #[test]
1143 fn test_template_syntax_error() {
1144 let theme = Theme::new();
1145
1146 #[derive(Serialize)]
1147 struct Empty {}
1148
1149 let result = render_with_output("{{ unclosed", &Empty {}, &theme, OutputMode::Text);
1150 assert!(result.is_err());
1151 }
1152
1153 #[test]
1154 fn test_style_tag_with_nested_data() {
1155 #[derive(Serialize)]
1156 struct Item {
1157 name: String,
1158 value: i32,
1159 }
1160
1161 #[derive(Serialize)]
1162 struct Container {
1163 items: Vec<Item>,
1164 }
1165
1166 let theme = Theme::new().add("name", Style::new().bold());
1167 let data = Container {
1168 items: vec![
1169 Item {
1170 name: "foo".into(),
1171 value: 1,
1172 },
1173 Item {
1174 name: "bar".into(),
1175 value: 2,
1176 },
1177 ],
1178 };
1179
1180 let template = r#"{% for item in items %}[name]{{ item.name }}[/name]={{ item.value }}
1181{% endfor %}"#;
1182
1183 let output = render_with_output(template, &data, &theme, OutputMode::Text).unwrap();
1184 assert_eq!(output, "foo=1\nbar=2\n");
1185 }
1186
1187 #[test]
1188 fn test_render_with_output_term_debug() {
1189 let theme = Theme::new()
1190 .add("title", Style::new().bold())
1191 .add("count", Style::new().cyan());
1192
1193 #[derive(Serialize)]
1194 struct Data {
1195 name: String,
1196 value: usize,
1197 }
1198
1199 let data = Data {
1200 name: "Test".into(),
1201 value: 42,
1202 };
1203
1204 let output = render_with_output(
1205 r#"[title]{{ name }}[/title]: [count]{{ value }}[/count]"#,
1206 &data,
1207 &theme,
1208 OutputMode::TermDebug,
1209 )
1210 .unwrap();
1211
1212 assert_eq!(output, "[title]Test[/title]: [count]42[/count]");
1213 }
1214
1215 #[test]
1216 fn test_render_with_output_term_debug_preserves_tags() {
1217 let theme = Theme::new().add("known", Style::new().bold());
1218
1219 #[derive(Serialize)]
1220 struct Data {
1221 message: String,
1222 }
1223
1224 let data = Data {
1225 message: "hello".into(),
1226 };
1227
1228 let output = render_with_output(
1230 r#"[unknown]{{ message }}[/unknown]"#,
1231 &data,
1232 &theme,
1233 OutputMode::TermDebug,
1234 )
1235 .unwrap();
1236
1237 assert_eq!(output, "[unknown]hello[/unknown]");
1238
1239 let output = render_with_output(
1241 r#"[known]{{ message }}[/known]"#,
1242 &data,
1243 &theme,
1244 OutputMode::TermDebug,
1245 )
1246 .unwrap();
1247
1248 assert_eq!(output, "[known]hello[/known]");
1249 }
1250
1251 #[test]
1252 fn test_render_auto_json_mode() {
1253 use serde_json::json;
1254
1255 let theme = Theme::new();
1256 let data = json!({"name": "test", "count": 42});
1257
1258 let output = render_auto("unused template", &data, &theme, OutputMode::Json).unwrap();
1259
1260 assert!(output.contains("\"name\": \"test\""));
1261 assert!(output.contains("\"count\": 42"));
1262 }
1263
1264 #[test]
1265 fn test_render_auto_text_mode_uses_template() {
1266 use serde_json::json;
1267
1268 let theme = Theme::new();
1269 let data = json!({"name": "test"});
1270
1271 let output = render_auto("Name: {{ name }}", &data, &theme, OutputMode::Text).unwrap();
1272
1273 assert_eq!(output, "Name: test");
1274 }
1275
1276 #[test]
1277 fn test_render_auto_term_mode_uses_template() {
1278 use serde_json::json;
1279
1280 let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1281 let data = json!({"name": "test"});
1282
1283 let output = render_auto(
1284 r#"[bold]{{ name }}[/bold]"#,
1285 &data,
1286 &theme,
1287 OutputMode::Term,
1288 )
1289 .unwrap();
1290
1291 assert!(output.contains("\x1b[1m"));
1292 assert!(output.contains("test"));
1293 }
1294
1295 #[test]
1296 fn test_render_auto_json_with_struct() {
1297 #[derive(Serialize)]
1298 struct Report {
1299 title: String,
1300 items: Vec<String>,
1301 }
1302
1303 let theme = Theme::new();
1304 let data = Report {
1305 title: "Summary".into(),
1306 items: vec!["one".into(), "two".into()],
1307 };
1308
1309 let output = render_auto("unused", &data, &theme, OutputMode::Json).unwrap();
1310
1311 assert!(output.contains("\"title\": \"Summary\""));
1312 assert!(output.contains("\"items\""));
1313 assert!(output.contains("\"one\""));
1314 }
1315
1316 #[test]
1317 fn test_render_with_alias() {
1318 let theme = Theme::new()
1319 .add("base", Style::new().bold())
1320 .add("alias", "base");
1321
1322 let output = render_with_output(
1323 r#"[alias]text[/alias]"#,
1324 &serde_json::json!({}),
1325 &theme,
1326 OutputMode::Text,
1327 )
1328 .unwrap();
1329
1330 assert_eq!(output, "text");
1331 }
1332
1333 #[test]
1334 fn test_render_with_alias_chain() {
1335 let theme = Theme::new()
1336 .add("muted", Style::new().dim())
1337 .add("disabled", "muted")
1338 .add("timestamp", "disabled");
1339
1340 let output = render_with_output(
1341 r#"[timestamp]12:00[/timestamp]"#,
1342 &serde_json::json!({}),
1343 &theme,
1344 OutputMode::Text,
1345 )
1346 .unwrap();
1347
1348 assert_eq!(output, "12:00");
1349 }
1350
1351 #[test]
1352 fn test_render_fails_with_dangling_alias() {
1353 let theme = Theme::new().add("orphan", "missing");
1354
1355 let result = render_with_output(
1356 r#"[orphan]text[/orphan]"#,
1357 &serde_json::json!({}),
1358 &theme,
1359 OutputMode::Text,
1360 );
1361
1362 assert!(result.is_err());
1363 let err = result.unwrap_err();
1364 assert!(err.to_string().contains("orphan"));
1365 assert!(err.to_string().contains("missing"));
1366 }
1367
1368 #[test]
1369 fn test_render_fails_with_cycle() {
1370 let theme = Theme::new().add("a", "b").add("b", "a");
1371
1372 let result = render_with_output(
1373 r#"[a]text[/a]"#,
1374 &serde_json::json!({}),
1375 &theme,
1376 OutputMode::Text,
1377 );
1378
1379 assert!(result.is_err());
1380 assert!(result.unwrap_err().to_string().contains("cycle"));
1381 }
1382
1383 #[test]
1384 fn test_three_layer_styling_pattern() {
1385 let theme = Theme::new()
1386 .add("dim_style", Style::new().dim())
1387 .add("cyan_bold", Style::new().cyan().bold())
1388 .add("yellow_bg", Style::new().on_yellow())
1389 .add("muted", "dim_style")
1390 .add("accent", "cyan_bold")
1391 .add("highlighted", "yellow_bg")
1392 .add("timestamp", "muted")
1393 .add("title", "accent")
1394 .add("selected_item", "highlighted");
1395
1396 assert!(theme.validate().is_ok());
1397
1398 let output = render_with_output(
1399 r#"[timestamp]{{ time }}[/timestamp] - [title]{{ name }}[/title]"#,
1400 &serde_json::json!({"time": "12:00", "name": "Report"}),
1401 &theme,
1402 OutputMode::Text,
1403 )
1404 .unwrap();
1405
1406 assert_eq!(output, "12:00 - Report");
1407 }
1408
1409 #[test]
1414 fn test_render_auto_yaml_mode() {
1415 use serde_json::json;
1416
1417 let theme = Theme::new();
1418 let data = json!({"name": "test", "count": 42});
1419
1420 let output = render_auto("unused template", &data, &theme, OutputMode::Yaml).unwrap();
1421
1422 assert!(output.contains("name: test"));
1423 assert!(output.contains("count: 42"));
1424 }
1425
1426 #[test]
1427 fn test_render_auto_xml_mode() {
1428 let theme = Theme::new();
1429
1430 #[derive(Serialize)]
1431 #[serde(rename = "root")]
1432 struct Data {
1433 name: String,
1434 count: usize,
1435 }
1436
1437 let data = Data {
1438 name: "test".into(),
1439 count: 42,
1440 };
1441
1442 let output = render_auto("unused template", &data, &theme, OutputMode::Xml).unwrap();
1443
1444 assert!(output.contains("<root>"));
1445 assert!(output.contains("<name>test</name>"));
1446 }
1447
1448 #[test]
1449 fn test_render_auto_csv_mode_auto_flatten() {
1450 use serde_json::json;
1451
1452 let theme = Theme::new();
1453 let data = json!([
1454 {"name": "Alice", "stats": {"score": 10}},
1455 {"name": "Bob", "stats": {"score": 20}}
1456 ]);
1457
1458 let output = render_auto("unused", &data, &theme, OutputMode::Csv).unwrap();
1459
1460 assert!(output.contains("name,stats.score"));
1461 assert!(output.contains("Alice,10"));
1462 assert!(output.contains("Bob,20"));
1463 }
1464
1465 #[test]
1466 fn test_render_auto_csv_mode_with_spec() {
1467 let theme = Theme::new();
1468 let data = json!([
1469 {"name": "Alice", "meta": {"age": 30, "role": "admin"}},
1470 {"name": "Bob", "meta": {"age": 25, "role": "user"}}
1471 ]);
1472
1473 let spec = FlatDataSpec::builder()
1474 .column(Column::new(Width::Fixed(10)).key("name"))
1475 .column(
1476 Column::new(Width::Fixed(10))
1477 .key("meta.role")
1478 .header("Role"),
1479 )
1480 .build();
1481
1482 let output =
1483 render_auto_with_spec("unused", &data, &theme, OutputMode::Csv, Some(&spec)).unwrap();
1484
1485 let lines: Vec<&str> = output.lines().collect();
1486 assert_eq!(lines[0], "name,Role");
1487 assert!(lines.contains(&"Alice,admin"));
1488 assert!(lines.contains(&"Bob,user"));
1489 assert!(!output.contains("30"));
1490 }
1491
1492 #[test]
1497 fn test_render_with_context_basic() {
1498 use crate::context::{ContextRegistry, RenderContext};
1499
1500 #[derive(Serialize)]
1501 struct Data {
1502 name: String,
1503 }
1504
1505 let theme = Theme::new();
1506 let data = Data {
1507 name: "Alice".into(),
1508 };
1509 let json_data = serde_json::to_value(&data).unwrap();
1510
1511 let mut registry = ContextRegistry::new();
1512 registry.add_static("version", Value::from("1.0.0"));
1513
1514 let render_ctx = RenderContext::new(OutputMode::Text, Some(80), &theme, &json_data);
1515
1516 let output = render_with_context(
1517 "{{ name }} (v{{ version }})",
1518 &data,
1519 &theme,
1520 OutputMode::Text,
1521 ®istry,
1522 &render_ctx,
1523 None,
1524 )
1525 .unwrap();
1526
1527 assert_eq!(output, "Alice (v1.0.0)");
1528 }
1529
1530 #[test]
1531 fn test_render_with_context_dynamic_provider() {
1532 use crate::context::{ContextRegistry, RenderContext};
1533
1534 #[derive(Serialize)]
1535 struct Data {
1536 message: String,
1537 }
1538
1539 let theme = Theme::new();
1540 let data = Data {
1541 message: "Hello".into(),
1542 };
1543 let json_data = serde_json::to_value(&data).unwrap();
1544
1545 let mut registry = ContextRegistry::new();
1546 registry.add_provider("terminal_width", |ctx: &RenderContext| {
1547 Value::from(ctx.terminal_width.unwrap_or(80))
1548 });
1549
1550 let render_ctx = RenderContext::new(OutputMode::Text, Some(120), &theme, &json_data);
1551
1552 let output = render_with_context(
1553 "{{ message }} (width={{ terminal_width }})",
1554 &data,
1555 &theme,
1556 OutputMode::Text,
1557 ®istry,
1558 &render_ctx,
1559 None,
1560 )
1561 .unwrap();
1562
1563 assert_eq!(output, "Hello (width=120)");
1564 }
1565
1566 #[test]
1567 fn test_render_with_context_data_takes_precedence() {
1568 use crate::context::{ContextRegistry, RenderContext};
1569
1570 #[derive(Serialize)]
1571 struct Data {
1572 value: String,
1573 }
1574
1575 let theme = Theme::new();
1576 let data = Data {
1577 value: "from_data".into(),
1578 };
1579 let json_data = serde_json::to_value(&data).unwrap();
1580
1581 let mut registry = ContextRegistry::new();
1582 registry.add_static("value", Value::from("from_context"));
1583
1584 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1585
1586 let output = render_with_context(
1587 "{{ value }}",
1588 &data,
1589 &theme,
1590 OutputMode::Text,
1591 ®istry,
1592 &render_ctx,
1593 None,
1594 )
1595 .unwrap();
1596
1597 assert_eq!(output, "from_data");
1598 }
1599
1600 #[test]
1601 fn test_render_with_context_empty_registry() {
1602 use crate::context::{ContextRegistry, RenderContext};
1603
1604 #[derive(Serialize)]
1605 struct Data {
1606 name: String,
1607 }
1608
1609 let theme = Theme::new();
1610 let data = Data {
1611 name: "Test".into(),
1612 };
1613 let json_data = serde_json::to_value(&data).unwrap();
1614
1615 let registry = ContextRegistry::new();
1616 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1617
1618 let output = render_with_context(
1619 "{{ name }}",
1620 &data,
1621 &theme,
1622 OutputMode::Text,
1623 ®istry,
1624 &render_ctx,
1625 None,
1626 )
1627 .unwrap();
1628
1629 assert_eq!(output, "Test");
1630 }
1631
1632 #[test]
1633 fn test_render_auto_with_context_json_mode() {
1634 use crate::context::{ContextRegistry, RenderContext};
1635
1636 #[derive(Serialize)]
1637 struct Data {
1638 count: usize,
1639 }
1640
1641 let theme = Theme::new();
1642 let data = Data { count: 42 };
1643 let json_data = serde_json::to_value(&data).unwrap();
1644
1645 let mut registry = ContextRegistry::new();
1646 registry.add_static("extra", Value::from("ignored"));
1647
1648 let render_ctx = RenderContext::new(OutputMode::Json, None, &theme, &json_data);
1649
1650 let output = render_auto_with_context(
1651 "unused template {{ extra }}",
1652 &data,
1653 &theme,
1654 OutputMode::Json,
1655 ®istry,
1656 &render_ctx,
1657 None,
1658 )
1659 .unwrap();
1660
1661 assert!(output.contains("\"count\": 42"));
1662 assert!(!output.contains("ignored"));
1663 }
1664
1665 #[test]
1666 fn test_render_auto_with_context_text_mode() {
1667 use crate::context::{ContextRegistry, RenderContext};
1668
1669 #[derive(Serialize)]
1670 struct Data {
1671 count: usize,
1672 }
1673
1674 let theme = Theme::new();
1675 let data = Data { count: 42 };
1676 let json_data = serde_json::to_value(&data).unwrap();
1677
1678 let mut registry = ContextRegistry::new();
1679 registry.add_static("label", Value::from("Items"));
1680
1681 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1682
1683 let output = render_auto_with_context(
1684 "{{ label }}: {{ count }}",
1685 &data,
1686 &theme,
1687 OutputMode::Text,
1688 ®istry,
1689 &render_ctx,
1690 None,
1691 )
1692 .unwrap();
1693
1694 assert_eq!(output, "Items: 42");
1695 }
1696
1697 #[test]
1698 fn test_render_with_context_provider_uses_output_mode() {
1699 use crate::context::{ContextRegistry, RenderContext};
1700
1701 #[derive(Serialize)]
1702 struct Data {}
1703
1704 let theme = Theme::new();
1705 let data = Data {};
1706 let json_data = serde_json::to_value(&data).unwrap();
1707
1708 let mut registry = ContextRegistry::new();
1709 registry.add_provider("mode", |ctx: &RenderContext| {
1710 Value::from(format!("{:?}", ctx.output_mode))
1711 });
1712
1713 let render_ctx = RenderContext::new(OutputMode::Term, None, &theme, &json_data);
1714
1715 let output = render_with_context(
1716 "Mode: {{ mode }}",
1717 &data,
1718 &theme,
1719 OutputMode::Term,
1720 ®istry,
1721 &render_ctx,
1722 None,
1723 )
1724 .unwrap();
1725
1726 assert_eq!(output, "Mode: Term");
1727 }
1728
1729 #[test]
1730 fn test_render_with_context_nested_data() {
1731 use crate::context::{ContextRegistry, RenderContext};
1732
1733 #[derive(Serialize)]
1734 struct Item {
1735 name: String,
1736 }
1737
1738 #[derive(Serialize)]
1739 struct Data {
1740 items: Vec<Item>,
1741 }
1742
1743 let theme = Theme::new();
1744 let data = Data {
1745 items: vec![Item { name: "one".into() }, Item { name: "two".into() }],
1746 };
1747 let json_data = serde_json::to_value(&data).unwrap();
1748
1749 let mut registry = ContextRegistry::new();
1750 registry.add_static("prefix", Value::from("- "));
1751
1752 let render_ctx = RenderContext::new(OutputMode::Text, None, &theme, &json_data);
1753
1754 let output = render_with_context(
1755 "{% for item in items %}{{ prefix }}{{ item.name }}\n{% endfor %}",
1756 &data,
1757 &theme,
1758 OutputMode::Text,
1759 ®istry,
1760 &render_ctx,
1761 None,
1762 )
1763 .unwrap();
1764
1765 assert_eq!(output, "- one\n- two\n");
1766 }
1767
1768 #[test]
1769 fn test_render_with_mode_forces_color_mode() {
1770 use console::Style;
1771
1772 #[derive(Serialize)]
1773 struct Data {
1774 status: String,
1775 }
1776
1777 let theme = Theme::new().add_adaptive(
1780 "status",
1781 Style::new(), Some(Style::new().black().force_styling(true)), Some(Style::new().white().force_styling(true)), );
1785
1786 let data = Data {
1787 status: "test".into(),
1788 };
1789
1790 let dark_output = render_with_mode(
1792 r#"[status]{{ status }}[/status]"#,
1793 &data,
1794 &theme,
1795 OutputMode::Term,
1796 ColorMode::Dark,
1797 )
1798 .unwrap();
1799
1800 let light_output = render_with_mode(
1802 r#"[status]{{ status }}[/status]"#,
1803 &data,
1804 &theme,
1805 OutputMode::Term,
1806 ColorMode::Light,
1807 )
1808 .unwrap();
1809
1810 assert_ne!(dark_output, light_output);
1812
1813 assert!(
1815 dark_output.contains("\x1b[37"),
1816 "Expected white (37) in dark mode"
1817 );
1818
1819 assert!(
1821 light_output.contains("\x1b[30"),
1822 "Expected black (30) in light mode"
1823 );
1824 }
1825
1826 #[test]
1831 fn test_tag_syntax_text_mode() {
1832 let theme = Theme::new().add("title", Style::new().bold());
1833
1834 #[derive(Serialize)]
1835 struct Data {
1836 name: String,
1837 }
1838
1839 let output = render_with_output(
1840 "[title]{{ name }}[/title]",
1841 &Data {
1842 name: "Hello".into(),
1843 },
1844 &theme,
1845 OutputMode::Text,
1846 )
1847 .unwrap();
1848
1849 assert_eq!(output, "Hello");
1851 }
1852
1853 #[test]
1854 fn test_tag_syntax_term_mode() {
1855 let theme = Theme::new().add("bold", Style::new().bold().force_styling(true));
1856
1857 #[derive(Serialize)]
1858 struct Data {
1859 name: String,
1860 }
1861
1862 let output = render_with_output(
1863 "[bold]{{ name }}[/bold]",
1864 &Data {
1865 name: "Hello".into(),
1866 },
1867 &theme,
1868 OutputMode::Term,
1869 )
1870 .unwrap();
1871
1872 assert!(output.contains("\x1b[1m"));
1874 assert!(output.contains("Hello"));
1875 }
1876
1877 #[test]
1878 fn test_tag_syntax_debug_mode() {
1879 let theme = Theme::new().add("title", Style::new().bold());
1880
1881 #[derive(Serialize)]
1882 struct Data {
1883 name: String,
1884 }
1885
1886 let output = render_with_output(
1887 "[title]{{ name }}[/title]",
1888 &Data {
1889 name: "Hello".into(),
1890 },
1891 &theme,
1892 OutputMode::TermDebug,
1893 )
1894 .unwrap();
1895
1896 assert_eq!(output, "[title]Hello[/title]");
1898 }
1899
1900 #[test]
1901 fn test_tag_syntax_unknown_tag_passthrough() {
1902 let theme = Theme::new().add("known", Style::new().bold());
1904
1905 #[derive(Serialize)]
1906 struct Data {
1907 name: String,
1908 }
1909
1910 let output = render_with_output(
1912 "[unknown]{{ name }}[/unknown]",
1913 &Data {
1914 name: "Hello".into(),
1915 },
1916 &theme,
1917 OutputMode::Term,
1918 )
1919 .unwrap();
1920
1921 assert!(output.contains("[unknown?]"));
1923 assert!(output.contains("[/unknown?]"));
1924 assert!(output.contains("Hello"));
1925
1926 let text_output = render_with_output(
1928 "[unknown]{{ name }}[/unknown]",
1929 &Data {
1930 name: "Hello".into(),
1931 },
1932 &theme,
1933 OutputMode::Text,
1934 )
1935 .unwrap();
1936
1937 assert_eq!(text_output, "Hello");
1939 }
1940
1941 #[test]
1942 fn test_tag_syntax_nested() {
1943 let theme = Theme::new()
1944 .add("bold", Style::new().bold().force_styling(true))
1945 .add("red", Style::new().red().force_styling(true));
1946
1947 #[derive(Serialize)]
1948 struct Data {
1949 word: String,
1950 }
1951
1952 let output = render_with_output(
1953 "[bold][red]{{ word }}[/red][/bold]",
1954 &Data {
1955 word: "test".into(),
1956 },
1957 &theme,
1958 OutputMode::Term,
1959 )
1960 .unwrap();
1961
1962 assert!(output.contains("\x1b[1m")); assert!(output.contains("\x1b[31m")); assert!(output.contains("test"));
1966 }
1967
1968 #[test]
1969 fn test_tag_syntax_multiple_styles() {
1970 let theme = Theme::new()
1971 .add("title", Style::new().bold())
1972 .add("count", Style::new().cyan());
1973
1974 #[derive(Serialize)]
1975 struct Data {
1976 name: String,
1977 num: usize,
1978 }
1979
1980 let output = render_with_output(
1981 r#"[title]{{ name }}[/title]: [count]{{ num }}[/count]"#,
1982 &Data {
1983 name: "Items".into(),
1984 num: 42,
1985 },
1986 &theme,
1987 OutputMode::Text,
1988 )
1989 .unwrap();
1990
1991 assert_eq!(output, "Items: 42");
1992 }
1993
1994 #[test]
1995 fn test_tag_syntax_in_loop() {
1996 let theme = Theme::new().add("item", Style::new().cyan());
1997
1998 #[derive(Serialize)]
1999 struct Data {
2000 items: Vec<String>,
2001 }
2002
2003 let output = render_with_output(
2004 "{% for item in items %}[item]{{ item }}[/item]\n{% endfor %}",
2005 &Data {
2006 items: vec!["one".into(), "two".into()],
2007 },
2008 &theme,
2009 OutputMode::Text,
2010 )
2011 .unwrap();
2012
2013 assert_eq!(output, "one\ntwo\n");
2014 }
2015
2016 #[test]
2017 fn test_tag_syntax_literal_brackets() {
2018 let theme = Theme::new();
2020
2021 #[derive(Serialize)]
2022 struct Data {
2023 msg: String,
2024 }
2025
2026 let output = render_with_output(
2027 "Array: [1, 2, 3] and {{ msg }}",
2028 &Data { msg: "done".into() },
2029 &theme,
2030 OutputMode::Text,
2031 )
2032 .unwrap();
2033
2034 assert_eq!(output, "Array: [1, 2, 3] and done");
2036 }
2037
2038 #[test]
2043 fn test_validate_template_all_known_tags() {
2044 let theme = Theme::new()
2045 .add("title", Style::new().bold())
2046 .add("count", Style::new().cyan());
2047
2048 #[derive(Serialize)]
2049 struct Data {
2050 name: String,
2051 }
2052
2053 let result = validate_template(
2054 "[title]{{ name }}[/title]",
2055 &Data {
2056 name: "Hello".into(),
2057 },
2058 &theme,
2059 );
2060
2061 assert!(result.is_ok());
2062 }
2063
2064 #[test]
2065 fn test_validate_template_unknown_tag_fails() {
2066 let theme = Theme::new().add("known", Style::new().bold());
2067
2068 #[derive(Serialize)]
2069 struct Data {
2070 name: String,
2071 }
2072
2073 let result = validate_template(
2074 "[unknown]{{ name }}[/unknown]",
2075 &Data {
2076 name: "Hello".into(),
2077 },
2078 &theme,
2079 );
2080
2081 assert!(result.is_err());
2082 let err = result.unwrap_err();
2083 let errors = err
2084 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2085 .expect("Expected UnknownTagErrors");
2086 assert_eq!(errors.len(), 2); }
2088
2089 #[test]
2090 fn test_validate_template_multiple_unknown_tags() {
2091 let theme = Theme::new().add("known", Style::new().bold());
2092
2093 #[derive(Serialize)]
2094 struct Data {
2095 a: String,
2096 b: String,
2097 }
2098
2099 let result = validate_template(
2100 "[foo]{{ a }}[/foo] and [bar]{{ b }}[/bar]",
2101 &Data {
2102 a: "x".into(),
2103 b: "y".into(),
2104 },
2105 &theme,
2106 );
2107
2108 assert!(result.is_err());
2109 let err = result.unwrap_err();
2110 let errors = err
2111 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2112 .expect("Expected UnknownTagErrors");
2113 assert_eq!(errors.len(), 4); }
2115
2116 #[test]
2117 fn test_validate_template_plain_text_passes() {
2118 let theme = Theme::new();
2119
2120 #[derive(Serialize)]
2121 struct Data {
2122 msg: String,
2123 }
2124
2125 let result = validate_template("Just plain {{ msg }}", &Data { msg: "hi".into() }, &theme);
2126
2127 assert!(result.is_ok());
2128 }
2129
2130 #[test]
2131 fn test_validate_template_mixed_known_and_unknown() {
2132 let theme = Theme::new().add("known", Style::new().bold());
2133
2134 #[derive(Serialize)]
2135 struct Data {
2136 a: String,
2137 b: String,
2138 }
2139
2140 let result = validate_template(
2141 "[known]{{ a }}[/known] [unknown]{{ b }}[/unknown]",
2142 &Data {
2143 a: "x".into(),
2144 b: "y".into(),
2145 },
2146 &theme,
2147 );
2148
2149 assert!(result.is_err());
2150 let err = result.unwrap_err();
2151 let errors = err
2152 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2153 .expect("Expected UnknownTagErrors");
2154 assert_eq!(errors.len(), 2);
2156 assert!(errors.errors.iter().any(|e| e.tag == "unknown"));
2157 }
2158
2159 #[test]
2160 fn test_validate_template_syntax_error_fails() {
2161 let theme = Theme::new();
2162 #[derive(Serialize)]
2163 struct Data {}
2164
2165 let result = validate_template("{{ unclosed", &Data {}, &theme);
2167 assert!(result.is_err());
2168
2169 let err = result.unwrap_err();
2170 assert!(err
2172 .downcast_ref::<standout_bbparser::UnknownTagErrors>()
2173 .is_none());
2174 let msg = err.to_string();
2176 assert!(
2177 msg.contains("syntax error") || msg.contains("unexpected"),
2178 "Got: {}",
2179 msg
2180 );
2181 }
2182
2183 #[test]
2184 fn test_render_auto_with_context_yaml_mode() {
2185 use crate::context::{ContextRegistry, RenderContext};
2186 use serde_json::json;
2187
2188 let theme = Theme::new();
2189 let data = json!({"name": "test", "count": 42});
2190
2191 let registry = ContextRegistry::new();
2193 let render_ctx = RenderContext::new(OutputMode::Yaml, Some(80), &theme, &data);
2194
2195 let output = render_auto_with_context(
2197 "unused template",
2198 &data,
2199 &theme,
2200 OutputMode::Yaml,
2201 ®istry,
2202 &render_ctx,
2203 None,
2204 )
2205 .unwrap();
2206
2207 assert!(output.contains("name: test"));
2208 assert!(output.contains("count: 42"));
2209 }
2210}