1use serde::{Deserialize, Serialize};
25use std::collections::HashSet;
26
27pub mod vocab;
28pub use vocab::{
29 AGG_FNS, ALL_DIRECTIVES, AXIS_SCALES, Directive, DirectiveTarget, KindMask, LINE_STYLES,
30 MARKER_SHAPES, PALETTE_NAMES, ValueProvider, YamlForm, cli_flags_for, directive_by_cli_flag,
31 directive_by_yaml_keyword, directives_for,
32};
33
34#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36pub struct Report {
37 #[serde(default)]
40 pub defaults: Style,
41 #[serde(default)]
44 pub groups: Vec<ReportGroup>,
45}
46
47impl Report {
48 pub fn items(&self) -> impl Iterator<Item = &ReportItem> {
50 self.groups.iter().flat_map(|g| g.items.iter())
51 }
52
53 pub fn find(&self, name: &str) -> Option<&ReportItem> {
57 self.items().find(|i| i.name == name)
58 }
59
60 pub fn effective_style(&self, group: &ReportGroup, item: &ReportItem) -> Style {
63 let mut s = self.defaults.clone();
64 s.merge_from(&group.defaults);
65 s.merge_from(&item.style);
66 s
67 }
68}
69
70#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct ReportGroup {
74 pub name: String,
77 #[serde(default)]
81 pub defaults: Style,
82 #[serde(default)]
84 pub items: Vec<ReportItem>,
85}
86
87#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct ReportItem {
90 pub kind: Kind,
92 pub name: String,
94 #[serde(default)]
97 pub label: Option<String>,
98 #[serde(default)]
101 pub as_stem: Option<String>,
102 #[serde(default)]
104 pub style: Style,
105 #[serde(default)]
111 pub body: String,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub target_file: Option<String>,
119 #[serde(skip)]
127 pub order: Option<usize>,
128 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
141 pub with_table: bool,
142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub with_tables: Vec<String>,
155}
156
157#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169#[derive(Default)]
170pub enum Kind {
171 #[default]
172 Plot,
173 Table,
174 Text,
175 File,
176 Details,
177}
178
179impl Kind {
180 pub fn as_str(&self) -> &'static str {
181 match self {
182 Kind::Plot => "plot",
183 Kind::Table => "table",
184 Kind::Text => "text",
185 Kind::File => "file",
186 Kind::Details => "details",
187 }
188 }
189
190 pub fn is_figure(&self) -> bool {
193 matches!(self, Kind::Plot | Kind::Table)
194 }
195}
196
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "lowercase")]
206pub enum Destination {
207 SessionDir,
210 Stdout,
213 Stderr,
215 None,
217}
218
219impl Destination {
220 pub fn parse(s: &str) -> Option<Self> {
225 Some(match s.trim().to_ascii_lowercase().as_str() {
226 "sessiondir" | "session" | "session_dir" => Self::SessionDir,
227 "stdout" => Self::Stdout,
228 "stderr" => Self::Stderr,
229 "none" | "off" => Self::None,
230 _ => return None,
231 })
232 }
233
234 pub fn as_str(self) -> &'static str {
235 match self {
236 Self::SessionDir => "sessiondir",
237 Self::Stdout => "stdout",
238 Self::Stderr => "stderr",
239 Self::None => "none",
240 }
241 }
242
243 pub fn parse_list(s: &str) -> Result<Vec<Self>, String> {
247 let mut out: Vec<Self> = Vec::new();
248 for tok in s.split([',', ' ', '\t']).filter(|t| !t.trim().is_empty()) {
249 let d = Self::parse(tok).ok_or_else(|| {
250 format!(
251 "unknown destination '{}' — expected one of: {}",
252 tok.trim(),
253 vocab::DESTINATION_NAMES.join(", ")
254 )
255 })?;
256 if d == Self::None {
257 return Ok(vec![Self::None]);
258 }
259 if !out.contains(&d) {
260 out.push(d);
261 }
262 }
263 if out.is_empty() {
264 return Err(format!(
265 "`to` needs at least one destination — expected one of: {}",
266 vocab::DESTINATION_NAMES.join(", ")
267 ));
268 }
269 Ok(out)
270 }
271}
272
273#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276pub struct Style {
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub palette: Option<String>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub line: Option<String>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub width: Option<f32>,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub marker: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub size: Option<f32>,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub color: Option<String>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub figure_width: Option<u32>,
300 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub figure_height: Option<u32>,
303 #[serde(default, skip_serializing_if = "Vec::is_empty")]
308 pub series: Vec<SeriesOverride>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub destinations: Option<Vec<Destination>>,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct SeriesOverride {
320 pub key: String,
322 pub value: String,
324 pub style: Style,
327}
328
329impl ReportItem {
330 pub fn to_yaml_directive_string(&self) -> String {
353 let mut out = String::new();
354 out.push_str(self.kind.as_str());
358 out.push(' ');
359 out.push_str(&self.name);
360 if matches!(self.kind, Kind::Text) {
366 out.push(':');
367 }
368 out.push('\n');
369
370 if let Some(stem) = &self.as_stem {
374 out.push_str(" as ");
375 out.push_str(stem);
376 out.push('\n');
377 }
378 if let Some(label) = &self.label {
379 out.push_str(" label \"");
380 out.push_str(&label.replace('\\', "\\\\").replace('"', "\\\""));
381 out.push_str("\"\n");
382 }
383 if let Some(target) = &self.target_file {
384 out.push_str(" target ");
385 out.push_str(target);
386 out.push('\n');
387 }
388 if let Some(dests) = &self.style.destinations {
392 out.push_str(" to ");
393 out.push_str(
394 &dests
395 .iter()
396 .map(|d| d.as_str())
397 .collect::<Vec<_>>()
398 .join(", "),
399 );
400 out.push('\n');
401 }
402 if let Some(order) = self.order {
403 out.push_str(" order ");
404 out.push_str(&order.to_string());
405 out.push('\n');
406 }
407 if self.with_table {
408 out.push_str(" with-table true\n");
409 }
410 if !self.with_tables.is_empty() {
411 out.push_str(" with-tables [");
412 out.push_str(&self.with_tables.join(", "));
413 out.push_str("]\n");
414 }
415
416 for line in self.style.scalar_directive_lines() {
421 out.push_str(" ");
422 out.push_str(&line);
423 out.push('\n');
424 }
425
426 if !self.body.trim().is_empty() {
430 for line in self.body.split('\n') {
431 if line.trim().is_empty() {
432 continue;
433 }
434 out.push_str(" ");
435 out.push_str(line.trim_start());
436 out.push('\n');
437 }
438 }
439
440 for s in &self.style.series {
448 out.push_str(" style ");
449 out.push_str(&s.key);
450 out.push('=');
451 out.push_str(&s.value);
452 for line in s.style.scalar_directive_lines() {
453 out.push(' ');
454 out.push_str(&line);
455 }
456 out.push('\n');
457 }
458
459 out
460 }
461}
462
463impl Style {
464 pub fn scalar_directive_lines(&self) -> Vec<String> {
474 let mut out = Vec::new();
475 for d in vocab::ALL_DIRECTIVES {
476 if !matches!(d.target, vocab::DirectiveTarget::StyleField) {
477 continue;
478 }
479 let value: Option<String> = match d.yaml_directive {
480 "palette" => self.palette.clone(),
481 "line" => self.line.clone(),
482 "width" => self.width.map(|v| v.to_string()),
483 "marker" => self.marker.clone(),
484 "size" => self.size.map(|v| v.to_string()),
485 "color" => self.color.clone(),
486 "figure_width" => self.figure_width.map(|v| v.to_string()),
487 "figure_height" => self.figure_height.map(|v| v.to_string()),
488 _ => None,
489 };
490 if let Some(v) = value {
491 out.push(format!("{}={}", d.yaml_directive, v));
492 }
493 }
494 out
495 }
496
497 pub fn merge_from(&mut self, other: &Style) {
501 if other.palette.is_some() {
502 self.palette = other.palette.clone();
503 }
504 if other.line.is_some() {
505 self.line = other.line.clone();
506 }
507 if other.width.is_some() {
508 self.width = other.width;
509 }
510 if other.marker.is_some() {
511 self.marker = other.marker.clone();
512 }
513 if other.size.is_some() {
514 self.size = other.size;
515 }
516 if other.color.is_some() {
517 self.color = other.color.clone();
518 }
519 if other.figure_width.is_some() {
520 self.figure_width = other.figure_width;
521 }
522 if other.figure_height.is_some() {
523 self.figure_height = other.figure_height;
524 }
525 if !other.series.is_empty() {
526 for s in &other.series {
527 self.series
528 .retain(|t| !(t.key == s.key && t.value == s.value));
529 self.series.push(s.clone());
530 }
531 }
532 if other.destinations.is_some() {
536 self.destinations = other.destinations.clone();
537 }
538 }
539
540 pub fn destinations_or(&self, default_to: &[Destination]) -> Vec<Destination> {
546 match &self.destinations {
547 Some(d) => d.clone(),
548 None => default_to.to_vec(),
549 }
550 }
551}
552
553const STYLE_DIRECTIVE_KEYWORDS: &[&str] = &[
563 "palette",
564 "line",
565 "width",
566 "marker",
567 "size",
568 "color",
569 "figure_width",
570 "figure_height",
571 "style",
572 "label",
573 "as",
574 "to",
575];
576
577const ITEM_KIND_KEYWORDS: &[&str] = &["plot", "table", "text", "file"];
578
579const ALL_RESERVED_DIRECTIVES: &[&str] = &[
580 "defaults",
581 "plot",
582 "table",
583 "text",
584 "file",
585 "palette",
586 "line",
587 "width",
588 "marker",
589 "size",
590 "color",
591 "figure_width",
592 "figure_height",
593 "style",
594 "label",
595 "as",
596 "to",
597];
598
599pub fn parse_report(value: &serde_json::Value) -> Result<ParsedReport, String> {
606 let map = value
607 .as_object()
608 .ok_or_else(|| "report: must be a mapping".to_string())?;
609
610 let mut report = Report::default();
611 let mut warnings: Vec<String> = Vec::new();
612 let mut seen_names: HashSet<String> = HashSet::new();
613 let mut text_counter: usize = 0;
617
618 for (key, v) in map {
619 let key: &str = key.as_str();
620 if key == "defaults" {
621 report.defaults =
622 parse_style_mapping(v).map_err(|e| format!("report.defaults: {e}"))?;
623 continue;
624 }
625 if STYLE_DIRECTIVE_KEYWORDS.contains(&key) {
626 warnings.push(format!(
627 "report.{key}: bare directive keyword used as a group name; \
628 nest under `defaults:` to set as a default, or rename the group"
629 ));
630 }
631
632 let body = match v {
633 serde_json::Value::String(s) => s.clone(),
634 serde_json::Value::Null => String::new(),
635 _ => {
636 return Err(format!(
637 "report.{key}: must be a string (single-line or block scalar) \
638 of directive lines starting with `plot` / `table`"
639 ));
640 }
641 };
642
643 let group = parse_group(key, &body, &mut warnings, &mut text_counter)?;
644 for it in &group.items {
645 if !seen_names.insert(it.name.clone()) {
646 return Err(format!(
647 "duplicate report item name '{}' (within scope: workload root)",
648 it.name
649 ));
650 }
651 }
652 if group.items.is_empty() {
653 warnings.push(format!(
654 "report.{key}: empty group (no `plot` or `table` items)"
655 ));
656 }
657 report.groups.push(group);
658 }
659
660 let mut ordinal: usize = 0;
665 for group in &mut report.groups {
666 for item in &mut group.items {
667 item.order = Some(ordinal);
668 ordinal += 1;
669 }
670 }
671
672 Ok(ParsedReport { report, warnings })
673}
674
675#[derive(Debug, Clone, Default)]
679pub struct ParsedReport {
680 pub report: Report,
681 pub warnings: Vec<String>,
682}
683
684pub fn parse_persisted_item(body: &str) -> Result<ReportItem, String> {
690 let mut warnings: Vec<String> = Vec::new();
691 let mut text_counter: usize = 0;
692 let group = parse_group("__persisted__", body, &mut warnings, &mut text_counter)?;
693 group
694 .items
695 .into_iter()
696 .next()
697 .ok_or_else(|| "persisted report item body did not yield any item".to_string())
698}
699
700fn parse_group(
701 name: &str,
702 body: &str,
703 warnings: &mut Vec<String>,
704 text_counter: &mut usize,
705) -> Result<ReportGroup, String> {
706 let mut group = ReportGroup {
707 name: name.to_string(),
708 ..Default::default()
709 };
710 let mut current: Option<PartialItem> = None;
711 let mut current_file: Option<String> = None;
715
716 let emit = |partial: PartialItem,
717 target_file: &Option<String>,
718 group: &mut ReportGroup,
719 warnings: &mut Vec<String>|
720 -> Result<(), String> {
721 let mut item = partial.finalize(warnings)?;
722 if item.target_file.is_none() {
723 item.target_file = target_file.clone();
724 }
725 group.items.push(item);
726 Ok(())
727 };
728
729 for (lineno, raw_line) in body.lines().enumerate() {
730 let stripped = strip_line_comment(raw_line);
734 let line = stripped.trim();
735 if line.is_empty() {
736 continue;
737 }
738 let line_no = lineno + 1;
739
740 if let Some(rest) = strip_directive_keyword(line, "defaults") {
746 if current.is_some() {
747 return Err(format!(
748 "report.{name}:{line_no}: `defaults` must precede the first \
749 item in the group"
750 ));
751 }
752 apply_directives_to_style(rest, &mut group.defaults, name, line_no)?;
753 continue;
754 }
755
756 if let Some((kind, rest)) = strip_kind_keyword(line) {
758 if let Some(prev) = current.take() {
759 emit(prev, ¤t_file, &mut group, warnings)?;
760 }
761 match kind {
762 Kind::Plot | Kind::Table => {
763 let mut tokens = rest.splitn(2, char::is_whitespace);
764 let raw_item_name =
765 tokens.next().filter(|s| !s.is_empty()).ok_or_else(|| {
766 format!(
767 "report.{name}:{line_no}: `{}` must be followed by a name",
768 kind.as_str()
769 )
770 })?;
771 let item_name = raw_item_name.strip_suffix(':').unwrap_or(raw_item_name);
778 if item_name.is_empty() {
779 return Err(format!(
780 "report.{name}:{line_no}: `{}` must be followed by a name",
781 kind.as_str()
782 ));
783 }
784 if ALL_RESERVED_DIRECTIVES.contains(&item_name) {
785 return Err(format!(
786 "report.{name}:{line_no}: item name '{item_name}' \
787 collides with a reserved directive keyword"
788 ));
789 }
790 let trailing = tokens.next().unwrap_or("");
791 let mut p = PartialItem::new(kind, item_name, name.to_string(), line_no);
792 if !trailing.trim().is_empty() {
793 p.directives.push(trailing.to_string());
794 }
795 current = Some(p);
796 }
797 Kind::Text => {
798 let rest = rest.trim();
810 let body = rest.strip_suffix(':').unwrap_or(rest).trim_end();
815 let header_has_colon = body.len() < rest.len();
816 let mut tokens = body.splitn(2, char::is_whitespace);
817 let first = tokens.next().unwrap_or("").trim();
818 let after = tokens.next().unwrap_or("").trim();
819 let has_as_alias = strip_directive_keyword(after, "as").is_some();
829 let looks_named = !first.is_empty()
830 && is_bare_text_name(first)
831 && (header_has_colon || has_as_alias);
832 let (item_name, alias) = if looks_named {
833 let alias = strip_directive_keyword(after, "as").map(parse_quoted_or_bare);
834 (first.to_string(), alias)
835 } else {
836 *text_counter += 1;
840 let auto = format!("text_{:03}", *text_counter);
841 (auto, None)
842 };
843 let mut p = PartialItem::new(Kind::Text, &item_name, name.to_string(), line_no);
844 if let Some(label) = alias {
845 p.directives.push(format!("label {label}"));
846 } else if !looks_named && !rest.is_empty() {
847 p.directives.push(rest.to_string());
850 }
851 current = Some(p);
852 }
853 Kind::File => {
854 let mut tokens = rest.splitn(2, char::is_whitespace);
860 let filename = tokens
861 .next()
862 .filter(|s| !s.is_empty())
863 .ok_or_else(|| {
864 format!(
865 "report.{name}:{line_no}: `file` must be followed by a filename"
866 )
867 })?
868 .to_string();
869 let trailing = tokens.next().unwrap_or("");
870 let mut p = PartialItem::new(Kind::File, &filename, name.to_string(), line_no);
871 if !trailing.trim().is_empty() {
876 let trailing = trailing.trim();
879 if let Some(rest) = strip_directive_keyword(trailing, "as") {
880 p.directives.push(format!("label {rest}"));
881 } else {
882 p.directives.push(trailing.to_string());
883 }
884 }
885 current_file = Some(filename);
886 current = Some(p);
887 }
888 Kind::Details => {
889 let mut p =
896 PartialItem::new(Kind::Details, "details", name.to_string(), line_no);
897 if !rest.trim().is_empty() {
898 p.directives.push(rest.to_string());
899 }
900 current = Some(p);
901 }
902 }
903 continue;
904 }
905
906 match current.as_mut() {
908 Some(p) => p.directives.push(line.to_string()),
909 None => {
910 return Err(format!(
911 "report.{name}:{line_no}: directive `{line}` precedes any \
912 kind keyword (plot / table / text / file)"
913 ));
914 }
915 }
916 }
917
918 if let Some(p) = current.take() {
919 emit(p, ¤t_file, &mut group, warnings)?;
920 }
921 Ok(group)
922}
923
924struct PartialItem {
925 kind: Kind,
926 name: String,
927 group: String,
928 line_no: usize,
929 directives: Vec<String>,
930}
931
932impl PartialItem {
933 fn new(kind: Kind, name: &str, group: String, line_no: usize) -> Self {
934 Self {
935 kind,
936 name: name.to_string(),
937 group,
938 line_no,
939 directives: Vec::new(),
940 }
941 }
942
943 fn finalize(self, warnings: &mut Vec<String>) -> Result<ReportItem, String> {
947 let mut item = ReportItem {
948 kind: self.kind,
949 name: self.name.clone(),
950 label: None,
951 as_stem: None,
952 style: Style::default(),
953 body: String::new(),
954 target_file: None,
955 order: None,
956 with_table: false,
957 with_tables: Vec::new(),
958 };
959
960 if matches!(self.kind, Kind::Text) {
971 let mut body_lines: Vec<String> = Vec::new();
972 for line in &self.directives {
973 let trimmed = line.trim();
974 if trimmed.is_empty() {
975 body_lines.push(String::new());
976 continue;
977 }
978 if let Some(rest) = strip_directive_keyword(trimmed, "label") {
979 item.label = Some(parse_quoted_or_bare(rest));
980 continue;
981 }
982 if let Some(rest) = strip_directive_keyword(trimmed, "to") {
983 item.style.destinations = Some(
984 Destination::parse_list(rest)
985 .map_err(|e| format!("text '{}': {e}", self.name))?,
986 );
987 continue;
988 }
989 if let Some(rest) = strip_directive_keyword(trimmed, "target") {
990 let rest = rest.trim();
991 if !rest.is_empty() && !rest.contains(char::is_whitespace) {
992 item.target_file = Some(rest.to_string());
993 continue;
994 }
995 }
996 if let Some(rest) = strip_directive_keyword(trimmed, "order") {
997 if let Ok(n) = rest.trim().parse::<usize>() {
998 item.order = Some(n);
999 continue;
1000 }
1001 }
1002 body_lines.push(line.clone());
1003 }
1004 item.body = body_lines.join("\n");
1005 return Ok(item);
1006 }
1007
1008 let mut residual: Vec<String> = Vec::new();
1009 for line in &self.directives {
1010 let line = line.trim();
1011 if line.is_empty() {
1012 continue;
1013 }
1014
1015 if let Some(rest) = strip_directive_keyword(line, "label") {
1016 item.label = Some(parse_quoted_or_bare(rest));
1017 continue;
1018 }
1019 if let Some(rest) = strip_directive_keyword(line, "to") {
1020 item.style.destinations = Some(
1021 Destination::parse_list(rest)
1022 .map_err(|e| format!("{} '{}': {e}", self.kind.as_str(), self.name))?,
1023 );
1024 continue;
1025 }
1026 if let Some(rest) = strip_directive_keyword(line, "as") {
1027 item.as_stem = Some(rest.trim().to_string());
1028 continue;
1029 }
1030 if let Some(rest) = strip_directive_keyword(line, "target") {
1037 item.target_file = Some(rest.trim().to_string());
1038 continue;
1039 }
1040 if let Some(rest) = strip_directive_keyword(line, "order") {
1044 if let Ok(n) = rest.trim().parse::<usize>() {
1045 item.order = Some(n);
1046 continue;
1047 }
1048 }
1049 if let Some(rest) = strip_directive_keyword(line, "with-tables") {
1056 let trimmed = rest.trim().trim_matches(':').trim();
1057 let inner = trimmed.strip_prefix('[')
1058 .and_then(|s| s.strip_suffix(']'))
1059 .ok_or_else(|| format!(
1060 "report.{}:{} `with-tables`: expected `[label1, label2, …]`, got `{trimmed}`",
1061 self.group, self.line_no,
1062 ))?;
1063 let labels: Vec<String> = inner
1064 .split(',')
1065 .map(|s| s.trim().trim_matches('"').trim_matches('\'').to_string())
1066 .filter(|s| !s.is_empty())
1067 .collect();
1068 if labels.is_empty() {
1069 return Err(format!(
1070 "report.{}:{} `with-tables`: list is empty — drop the directive \
1071 or list one or more label keys",
1072 self.group, self.line_no,
1073 ));
1074 }
1075 if !matches!(self.kind, Kind::Plot) {
1076 warnings.push(format!(
1077 "report.{}:{} item '{}' uses `with-tables` but is a {}; \
1078 only plots can have a faceted companion table.",
1079 self.group,
1080 self.line_no,
1081 item.name,
1082 item.kind.as_str(),
1083 ));
1084 } else {
1085 item.with_tables = labels;
1086 }
1087 continue;
1088 }
1089 if let Some(rest) = strip_directive_keyword(line, "with-table") {
1096 let v = rest.trim().trim_matches(':').trim();
1097 let truthy = matches!(
1098 v.to_ascii_lowercase().as_str(),
1099 "true" | "yes" | "on" | "1" | "",
1100 );
1101 let falsy = matches!(
1102 v.to_ascii_lowercase().as_str(),
1103 "false" | "no" | "off" | "0",
1104 );
1105 if !truthy && !falsy {
1106 return Err(format!(
1107 "report.{}:{} `with-table`: expected true/false, got '{v}'",
1108 self.group, self.line_no,
1109 ));
1110 }
1111 item.with_table = truthy;
1112 if item.with_table && !matches!(self.kind, Kind::Plot) {
1113 warnings.push(format!(
1114 "report.{}:{} item '{}' uses `with-table` but is a {}; \
1115 only plots can have a companion table.",
1116 self.group,
1117 self.line_no,
1118 item.name,
1119 item.kind.as_str(),
1120 ));
1121 item.with_table = false;
1122 }
1123 continue;
1124 }
1125 if let Some(rest) = strip_directive_keyword(line, "style") {
1126 let so = parse_series_override(rest).map_err(|e| {
1127 format!("report.{}:{} `style`: {}", self.group, self.line_no, e,)
1128 })?;
1129 item.style.series.push(so);
1130 continue;
1131 }
1132
1133 if let Some(applied) = try_apply_style_directives(line, &mut item.style)? {
1134 if !applied {
1135 residual.push(line.to_string());
1136 } else {
1137 if !style_directive_applies_to_kind(line, item.kind) {
1140 warnings.push(format!(
1141 "report.{}:{} item '{}' is a {} but uses \
1142 directive `{}`, which has no effect on this kind",
1143 self.group,
1144 self.line_no,
1145 item.name,
1146 item.kind.as_str(),
1147 line
1148 ));
1149 }
1150 }
1151 continue;
1152 }
1153
1154 residual.push(line.to_string());
1155 }
1156 item.body = residual.join("\n");
1157 Ok(item)
1158 }
1159}
1160
1161fn strip_directive_keyword<'a>(line: &'a str, kw: &str) -> Option<&'a str> {
1162 let line = line.trim_start();
1163 if let Some(rest) = line.strip_prefix(kw) {
1164 let next = rest.chars().next();
1165 if next.is_none()
1166 || next == Some(' ')
1167 || next == Some('\t')
1168 || next == Some('=')
1169 || next == Some(':')
1170 {
1171 return Some(
1172 rest.trim_start_matches(|c: char| c == ' ' || c == '\t' || c == '=' || c == ':'),
1173 );
1174 }
1175 }
1176 None
1177}
1178
1179fn strip_kind_keyword(line: &str) -> Option<(Kind, &str)> {
1180 for kw in ITEM_KIND_KEYWORDS {
1181 if let Some(rest) = line.strip_prefix(kw)
1182 && let Some(next) = rest.chars().next()
1183 && next.is_whitespace()
1184 {
1185 let kind = match *kw {
1186 "plot" => Kind::Plot,
1187 "table" => Kind::Table,
1188 "text" => Kind::Text,
1189 "file" => Kind::File,
1190 _ => unreachable!(),
1191 };
1192 return Some((kind, rest.trim_start()));
1193 }
1194 }
1195 None
1196}
1197
1198fn is_bare_text_name(s: &str) -> bool {
1204 let mut chars = s.chars();
1205 match chars.next() {
1206 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
1207 _ => return false,
1208 }
1209 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
1210}
1211
1212fn parse_quoted_or_bare(s: &str) -> String {
1213 let s = s.trim();
1214 if let Some(stripped) = s.strip_prefix('"').and_then(|x| x.strip_suffix('"')) {
1215 return stripped.to_string();
1216 }
1217 if let Some(stripped) = s.strip_prefix('\'').and_then(|x| x.strip_suffix('\'')) {
1218 return stripped.to_string();
1219 }
1220 s.to_string()
1221}
1222
1223fn try_apply_style_directives(line: &str, style: &mut Style) -> Result<Option<bool>, String> {
1231 if !line.contains('=') && !line_starts_with_any(line, STYLE_DIRECTIVE_KEYWORDS) {
1232 return Ok(None);
1233 }
1234 let mut all_consumed = true;
1235 for token in tokenize_directive_line(line) {
1236 if let Some((k, v)) = token.split_once('=') {
1237 if STYLE_DIRECTIVE_KEYWORDS.contains(&k) {
1238 apply_one_style_kv(k, v, style)?;
1239 } else {
1240 all_consumed = false;
1241 }
1242 } else if STYLE_DIRECTIVE_KEYWORDS.contains(&token.as_str()) {
1243 return Err(format!("style directive `{token}` requires a value"));
1245 } else {
1246 all_consumed = false;
1247 }
1248 }
1249 Ok(Some(all_consumed))
1250}
1251
1252fn apply_one_style_kv(k: &str, v: &str, style: &mut Style) -> Result<(), String> {
1253 let v = v.trim().trim_matches('"').trim_matches('\'');
1254 match k {
1255 "palette" => style.palette = Some(v.to_string()),
1256 "line" => style.line = Some(v.to_string()),
1257 "width" => {
1258 style.width = Some(
1259 v.parse()
1260 .map_err(|_| format!("`width={v}` is not a number"))?,
1261 )
1262 }
1263 "marker" => style.marker = Some(v.to_string()),
1264 "size" => {
1265 style.size = Some(
1266 v.parse()
1267 .map_err(|_| format!("`size={v}` is not a number"))?,
1268 )
1269 }
1270 "color" => style.color = Some(v.to_string()),
1271 "figure_width" => {
1272 style.figure_width = Some(
1273 v.parse()
1274 .map_err(|_| format!("`figure_width={v}` is not an integer"))?,
1275 )
1276 }
1277 "figure_height" => {
1278 style.figure_height = Some(
1279 v.parse()
1280 .map_err(|_| format!("`figure_height={v}` is not an integer"))?,
1281 )
1282 }
1283 "to" => style.destinations = Some(Destination::parse_list(v)?),
1286 _ => return Err(format!("unknown style directive `{k}`")),
1287 }
1288 Ok(())
1289}
1290
1291fn tokenize_directive_line(line: &str) -> Vec<String> {
1292 let mut out: Vec<String> = Vec::new();
1296 let mut cur = String::new();
1297 let mut quote: Option<char> = None;
1298 for ch in line.chars() {
1299 match quote {
1300 Some(q) if ch == q => {
1301 quote = None;
1302 cur.push(ch);
1303 }
1304 Some(_) => cur.push(ch),
1305 None => match ch {
1306 '"' | '\'' => {
1307 quote = Some(ch);
1308 cur.push(ch);
1309 }
1310 ' ' | '\t' | ',' => {
1311 if !cur.is_empty() {
1312 out.push(std::mem::take(&mut cur));
1313 }
1314 }
1315 _ => cur.push(ch),
1316 },
1317 }
1318 }
1319 if !cur.is_empty() {
1320 out.push(cur);
1321 }
1322 out
1323}
1324
1325fn line_starts_with_any(line: &str, kws: &[&str]) -> bool {
1326 let trimmed = line.trim_start();
1327 kws.iter().any(|kw| {
1328 trimmed.starts_with(kw)
1329 && trimmed
1330 .as_bytes()
1331 .get(kw.len())
1332 .is_none_or(|c| matches!(*c, b' ' | b'\t' | b'='))
1333 })
1334}
1335
1336fn style_directive_applies_to_kind(line: &str, kind: Kind) -> bool {
1337 let head = tokenize_directive_line(line)
1338 .first()
1339 .map(|s| {
1340 s.split_once('=')
1341 .map(|(k, _)| k.to_string())
1342 .unwrap_or_else(|| s.clone())
1343 })
1344 .unwrap_or_default();
1345 !matches!(
1346 (kind, head.as_str()),
1347 (Kind::Table, "line" | "width" | "marker" | "size")
1348 )
1349}
1350
1351fn parse_series_override(s: &str) -> Result<SeriesOverride, String> {
1352 let s = s.trim();
1358 let first_colon = s.find(':');
1362 let first_ws = s.find(char::is_whitespace);
1363 let split_at = match (first_colon, first_ws) {
1364 (Some(c), Some(w)) if c < w => Some((c, 1)), (Some(c), None) => Some((c, 1)),
1366 (_, Some(w)) => Some((w, 1)),
1367 (None, None) => None,
1368 };
1369 let (head, rest) = match split_at {
1370 Some((idx, n)) => (&s[..idx], s[idx + n..].trim_start()),
1371 None => (s, ""),
1372 };
1373 let (key, value) = head
1374 .split_once('=')
1375 .ok_or_else(|| format!("series discriminator must be <key>=<value>, got `{head}`"))?;
1376
1377 let mut style = Style::default();
1378 let rest = rest.trim();
1379
1380 if rest.starts_with('{') {
1381 if !rest.ends_with('}') {
1383 return Err("JSON sub-block must close with `}` on the same line".to_string());
1384 }
1385 let json: serde_json::Value =
1386 serde_json::from_str(rest).map_err(|e| format!("JSON sub-block parse error: {e}"))?;
1387 apply_json_to_style(&json, &mut style)?;
1388 } else if !rest.is_empty() {
1389 try_apply_style_directives(rest, &mut style)?;
1391 }
1392
1393 Ok(SeriesOverride {
1394 key: key.trim().to_string(),
1395 value: value
1396 .trim()
1397 .trim_matches('"')
1398 .trim_matches('\'')
1399 .to_string(),
1400 style,
1401 })
1402}
1403
1404fn apply_json_to_style(v: &serde_json::Value, style: &mut Style) -> Result<(), String> {
1405 let map = v
1406 .as_object()
1407 .ok_or_else(|| "series JSON sub-block must be an object".to_string())?;
1408 for (k, val) in map {
1409 let v_str = match val {
1410 serde_json::Value::String(s) => s.clone(),
1411 serde_json::Value::Number(n) => n.to_string(),
1412 serde_json::Value::Bool(b) => b.to_string(),
1413 _ => {
1414 return Err(format!(
1415 "series JSON sub-block: value for `{k}` must be a string, number, or bool"
1416 ));
1417 }
1418 };
1419 apply_one_style_kv(k, &v_str, style)?;
1420 }
1421 Ok(())
1422}
1423
1424fn apply_directives_to_style(
1425 line: &str,
1426 style: &mut Style,
1427 group: &str,
1428 line_no: usize,
1429) -> Result<(), String> {
1430 match try_apply_style_directives(line, style) {
1431 Ok(_) => Ok(()),
1432 Err(e) => Err(format!("report.{group}:{line_no}: {e}")),
1433 }
1434}
1435
1436fn strip_line_comment(line: &str) -> &str {
1442 let mut quote: Option<char> = None;
1443 let mut prev_ws = true; for (i, ch) in line.char_indices() {
1445 match quote {
1446 Some(q) if ch == q => {
1447 quote = None;
1448 prev_ws = false;
1449 }
1450 Some(_) => {
1451 prev_ws = false;
1452 }
1453 None => match ch {
1454 '"' | '\'' => {
1455 quote = Some(ch);
1456 prev_ws = false;
1457 }
1458 '#' if prev_ws => return &line[..i],
1459 c if c.is_whitespace() => {
1460 prev_ws = true;
1461 }
1462 _ => {
1463 prev_ws = false;
1464 }
1465 },
1466 }
1467 }
1468 line
1469}
1470
1471fn parse_style_mapping(v: &serde_json::Value) -> Result<Style, String> {
1472 let map = v
1473 .as_object()
1474 .ok_or_else(|| "must be a mapping of style directives".to_string())?;
1475 let mut style = Style::default();
1476 for (key, val) in map {
1477 let key = key.as_str();
1478 let value: String = match val {
1479 serde_json::Value::String(s) => s.clone(),
1480 serde_json::Value::Number(n) => n.to_string(),
1481 serde_json::Value::Bool(b) => b.to_string(),
1482 serde_json::Value::Null => continue,
1483 _ => {
1484 return Err(format!(
1485 "value for `{key}` must be a scalar (string, number, or bool)"
1486 ));
1487 }
1488 };
1489 apply_one_style_kv(key, &value, &mut style)?;
1490 }
1491 Ok(style)
1492}
1493
1494#[cfg(test)]
1495mod tests {
1496 use super::*;
1497
1498 fn parse(yaml: &str) -> ParsedReport {
1499 let v: serde_json::Value = serde_yaml::from_str(yaml).unwrap();
1500 parse_report(&v).unwrap()
1501 }
1502
1503 #[test]
1504 fn single_plot_minimal() {
1505 let p = parse(
1506 r#"
1507recall_block: |
1508 plot recall_at_k10
1509 over limit by profile
1510 label "Recall@10 vs k limit"
1511"#,
1512 );
1513 assert_eq!(p.report.groups.len(), 1);
1514 let g = &p.report.groups[0];
1515 assert_eq!(g.name, "recall_block");
1516 assert_eq!(g.items.len(), 1);
1517 let item = &g.items[0];
1518 assert_eq!(item.kind, Kind::Plot);
1519 assert_eq!(item.name, "recall_at_k10");
1520 assert_eq!(item.label.as_deref(), Some("Recall@10 vs k limit"));
1521 assert!(item.body.contains("over limit"));
1522 }
1523
1524 #[test]
1525 fn defaults_at_root_and_group() {
1526 let p = parse(
1527 r#"
1528defaults:
1529 palette: wong
1530 width: 1024
1531
1532recall_block: |
1533 defaults palette=tol_muted
1534 plot recall_at_k10 over limit
1535"#,
1536 );
1537 assert_eq!(p.report.defaults.palette.as_deref(), Some("wong"));
1538 assert_eq!(p.report.defaults.width, Some(1024.0));
1539 let g = &p.report.groups[0];
1540 assert_eq!(g.defaults.palette.as_deref(), Some("tol_muted"));
1541 let s = p.report.effective_style(g, &g.items[0]);
1543 assert_eq!(s.palette.as_deref(), Some("tol_muted"));
1544 assert_eq!(s.width, Some(1024.0));
1545 }
1546
1547 #[test]
1548 fn plots_and_tables_in_one_group() {
1549 let p = parse(
1550 r#"
1551combo: |
1552 plot recall_at_k10 over limit
1553 table recall_summary metric=recall@.* group_by=profile
1554"#,
1555 );
1556 let items = &p.report.groups[0].items;
1557 assert_eq!(items.len(), 2);
1558 assert_eq!(items[0].kind, Kind::Plot);
1559 assert_eq!(items[0].name, "recall_at_k10");
1560 assert_eq!(items[1].kind, Kind::Table);
1561 assert_eq!(items[1].name, "recall_summary");
1562 }
1563
1564 #[test]
1565 fn style_json_sub_block() {
1566 let p = parse(
1567 r#"
1568g: |
1569 plot p1 over x
1570 style profile=hnsw {"line": "dashed", "marker": "triangle"}
1571"#,
1572 );
1573 let item = &p.report.groups[0].items[0];
1574 assert_eq!(item.style.series.len(), 1);
1575 let so = &item.style.series[0];
1576 assert_eq!(so.key, "profile");
1577 assert_eq!(so.value, "hnsw");
1578 assert_eq!(so.style.line.as_deref(), Some("dashed"));
1579 assert_eq!(so.style.marker.as_deref(), Some("triangle"));
1580 }
1581
1582 #[test]
1583 fn style_directive_form() {
1584 let p = parse(
1585 r#"
1586g: |
1587 plot p1 over x
1588 style profile=ivf line=dotted color=#117733
1589"#,
1590 );
1591 let so = &p.report.groups[0].items[0].style.series[0];
1592 assert_eq!(so.value, "ivf");
1593 assert_eq!(so.style.line.as_deref(), Some("dotted"));
1594 assert_eq!(so.style.color.as_deref(), Some("#117733"));
1595 }
1596
1597 #[test]
1598 fn duplicate_name_is_error() {
1599 let v: serde_json::Value = serde_yaml::from_str(
1600 r#"
1601g1: "plot dup over x"
1602g2: "plot dup over y"
1603"#,
1604 )
1605 .unwrap();
1606 assert!(parse_report(&v).is_err());
1607 }
1608
1609 #[test]
1610 fn empty_group_warns() {
1611 let p = parse(
1612 r#"
1613empty_block: ""
1614"#,
1615 );
1616 assert_eq!(p.report.groups.len(), 1);
1617 assert!(p.warnings.iter().any(|w| w.contains("empty_block")));
1618 }
1619
1620 #[test]
1621 fn persisted_text_item_round_trips_name_target_and_order() {
1622 let item = ReportItem {
1630 kind: Kind::Text,
1631 name: "key_metrics_overview".to_string(),
1632 label: Some("Overview".to_string()),
1633 target_file: Some("key_metrics.md".to_string()),
1634 order: Some(3),
1635 body: "First prose line.\ntarget audience is prose, not a directive.".to_string(),
1636 ..Default::default()
1637 };
1638 let persisted = item.to_yaml_directive_string();
1639 let back = parse_persisted_item(&persisted).expect("round-trip parse");
1640 assert_eq!(back.kind, Kind::Text);
1641 assert_eq!(back.name, "key_metrics_overview");
1642 assert_eq!(back.label.as_deref(), Some("Overview"));
1643 assert_eq!(back.target_file.as_deref(), Some("key_metrics.md"));
1644 assert_eq!(back.order, Some(3));
1645 assert_eq!(back.body, item.body);
1648 }
1649
1650 #[test]
1651 fn destination_list_parses_spellings_and_rejects_junk() {
1652 use Destination::*;
1653 assert_eq!(Destination::parse_list("stdout").unwrap(), vec![Stdout]);
1654 assert_eq!(
1657 Destination::parse_list("stdout, sessiondir").unwrap(),
1658 vec![Stdout, SessionDir]
1659 );
1660 assert_eq!(
1661 Destination::parse_list("session_dir stderr").unwrap(),
1662 vec![SessionDir, Stderr]
1663 );
1664 assert_eq!(
1665 Destination::parse_list("session").unwrap(),
1666 vec![SessionDir]
1667 );
1668 assert_eq!(
1670 Destination::parse_list("stdout,stdout").unwrap(),
1671 vec![Stdout]
1672 );
1673 assert_eq!(
1675 Destination::parse_list("stdout, none, sessiondir").unwrap(),
1676 vec![None]
1677 );
1678 let err = Destination::parse_list("syslog").unwrap_err();
1680 assert!(err.contains("syslog"), "{err}");
1681 assert!(err.contains("sessiondir"), "names the vocabulary: {err}");
1682 assert!(Destination::parse_list(" ").is_err());
1684 }
1685
1686 #[test]
1687 fn to_directive_parses_and_cascades_replacing_outer() {
1688 let yaml: serde_json::Value = serde_yaml::from_str(
1689 r#"
1690defaults:
1691 to: sessiondir
1692routed: |
1693 table alpha:
1694 to stdout
1695 query: v: avg(x)
1696 table beta:
1697 query: v: avg(y)
1698"#,
1699 )
1700 .expect("yaml");
1701 let parsed = parse_report(&yaml).expect("parse report");
1702 let group = &parsed.report.groups[0];
1703 let alpha = parsed.report.find("alpha").expect("alpha");
1704 let beta = parsed.report.find("beta").expect("beta");
1705
1706 assert_eq!(
1710 parsed.report.effective_style(group, alpha).destinations,
1711 Some(vec![Destination::Stdout])
1712 );
1713 assert_eq!(
1715 parsed.report.effective_style(group, beta).destinations,
1716 Some(vec![Destination::SessionDir])
1717 );
1718 }
1719
1720 #[test]
1721 fn undeclared_routing_stays_none_so_the_caller_supplies_the_default() {
1722 let yaml: serde_json::Value = serde_yaml::from_str(
1728 r#"
1729plain: |
1730 table alpha:
1731 query: v: avg(x)
1732"#,
1733 )
1734 .expect("yaml");
1735 let parsed = parse_report(&yaml).expect("parse report");
1736 let group = &parsed.report.groups[0];
1737 let alpha = parsed.report.find("alpha").expect("alpha");
1738 assert_eq!(
1739 parsed.report.effective_style(group, alpha).destinations,
1740 Option::None
1741 );
1742 assert_eq!(
1743 parsed
1744 .report
1745 .effective_style(group, alpha)
1746 .destinations_or(&[Destination::SessionDir]),
1747 vec![Destination::SessionDir]
1748 );
1749 }
1750
1751 #[test]
1752 fn routing_round_trips_through_the_persisted_form() {
1753 let yaml: serde_json::Value = serde_yaml::from_str(
1757 r#"
1758routed: |
1759 table alpha:
1760 to stdout, sessiondir
1761 query: v: avg(x)
1762"#,
1763 )
1764 .expect("yaml");
1765 let parsed = parse_report(&yaml).expect("parse report");
1766 let item = parsed.report.find("alpha").expect("alpha");
1767 let persisted = item.to_yaml_directive_string();
1768 assert!(
1769 persisted.contains("to stdout, sessiondir"),
1770 "persisted form carries routing: {persisted}"
1771 );
1772 let back = parse_persisted_item(&persisted).expect("round-trip");
1773 assert_eq!(
1774 back.style.destinations,
1775 Some(vec![Destination::Stdout, Destination::SessionDir])
1776 );
1777 }
1778
1779 #[test]
1780 fn parse_report_stamps_declaration_ordinals() {
1781 let yaml: serde_json::Value = serde_yaml::from_str(
1782 r#"
1783section_a: |
1784 table alpha:
1785 query: v: avg(x)
1786 text zeta as "Z":
1787 prose
1788section_b: |
1789 table beta:
1790 query: v: avg(y)
1791"#,
1792 )
1793 .expect("yaml");
1794 let parsed = parse_report(&yaml).expect("parse report");
1795 let orders: Vec<(String, Option<usize>)> = parsed
1796 .report
1797 .items()
1798 .map(|i| (i.name.clone(), i.order))
1799 .collect();
1800 assert_eq!(
1801 orders,
1802 vec![
1803 ("alpha".to_string(), Some(0)),
1804 ("zeta".to_string(), Some(1)),
1805 ("beta".to_string(), Some(2)),
1806 ]
1807 );
1808 }
1809
1810 #[test]
1811 fn parse_persisted_item_strips_with_table_from_body() {
1812 let body = "plot recall_1_mean
1819label \"ALL 1-recall@R, oracles & PVS\"
1820target oracles_report.md
1821legend: br
1822x: r
1823y1: avg(recall_mean{k=\"1\"}) by (k,r)
1824y-ranges: [[0.0,1.0]]
1825with-table: true";
1826 let item = parse_persisted_item(body).expect("parse_persisted_item should succeed");
1827 assert_eq!(item.name, "recall_1_mean");
1828 assert!(item.with_table, "with_table should be set");
1829 assert!(
1830 !item.body.contains("with-table"),
1831 "body should not contain with-table line; got: {:?}",
1832 item.body,
1833 );
1834 }
1835
1836 #[test]
1837 fn with_table_colon_form_consumed_not_in_body() {
1838 let p = parse(
1844 r#"
1845g: |
1846 plot p1
1847 over limit
1848 with-table: true
1849"#,
1850 );
1851 let item = &p.report.groups[0].items[0];
1852 assert!(item.with_table, "with-table flag should be set");
1853 assert!(
1854 !item.body.contains("with-table"),
1855 "with-table line should be stripped from body, got: {:?}",
1856 item.body,
1857 );
1858 }
1859
1860 #[test]
1861 fn unknown_directive_falls_into_body() {
1862 let p = parse(
1863 r#"
1864g: |
1865 plot p1
1866 over limit
1867 where dataset=example
1868 custom_directive=foo
1869"#,
1870 );
1871 let item = &p.report.groups[0].items[0];
1872 assert!(item.body.contains("over limit"));
1873 assert!(item.body.contains("custom_directive=foo"));
1874 }
1875
1876 #[test]
1877 fn label_with_quotes() {
1878 let p = parse(
1879 r#"
1880g: |
1881 plot p1 over x
1882 label 'p99 latency'
1883"#,
1884 );
1885 assert_eq!(
1886 p.report.groups[0].items[0].label.as_deref(),
1887 Some("p99 latency")
1888 );
1889 }
1890
1891 #[test]
1892 fn item_name_collides_with_directive_keyword_errors() {
1893 let v: serde_json::Value = serde_yaml::from_str(
1894 r#"
1895g: "plot palette over x"
1896"#,
1897 )
1898 .unwrap();
1899 assert!(parse_report(&v).is_err());
1900 }
1901
1902 #[test]
1903 fn declaration_order_preserved_across_groups() {
1904 let p = parse(
1905 r#"
1906zzz_block: "plot z over x"
1907aaa_block: "plot a over x"
1908mmm_block: "plot m over x"
1909"#,
1910 );
1911 let names: Vec<_> = p.report.groups.iter().map(|g| g.name.clone()).collect();
1912 assert_eq!(names, vec!["zzz_block", "aaa_block", "mmm_block"]);
1913 }
1914
1915 #[test]
1916 fn style_cascade_root_to_item() {
1917 let p = parse(
1918 r#"
1919defaults:
1920 palette: wong
1921 width: 1024
1922
1923g: |
1924 defaults palette=tol_muted
1925 plot p1 over x
1926 palette=ibm
1927"#,
1928 );
1929 let g = &p.report.groups[0];
1930 let item = &g.items[0];
1931 let s = p.report.effective_style(g, item);
1932 assert_eq!(s.palette.as_deref(), Some("ibm"));
1933 assert_eq!(s.width, Some(1024.0));
1934 }
1935
1936 #[test]
1937 fn text_kind_keeps_body_verbatim() {
1938 let p = parse(
1939 r#"
1940intro: |
1941 text Welcome to the report.
1942 Multi-line prose continues here
1943 with arbitrary text.
1944"#,
1945 );
1946 let item = &p.report.groups[0].items[0];
1947 assert_eq!(item.kind, Kind::Text);
1948 assert_eq!(item.name, "text_001");
1949 assert!(item.body.contains("Welcome to the report"));
1950 assert!(item.body.contains("arbitrary text"));
1951 }
1952
1953 #[test]
1954 fn file_directive_scopes_following_items() {
1955 let p = parse(
1956 r#"
1957sections: |
1958 file my_report.md as 'Markdown Report'
1959 text Intro paragraph
1960 plot recall_at_k10 over limit
1961 table summary metric=recall@.* group_by=p
1962"#,
1963 );
1964 let items = &p.report.groups[0].items;
1965 assert_eq!(items.len(), 4);
1966 assert_eq!(items[0].kind, Kind::File);
1967 assert_eq!(items[0].name, "my_report.md");
1968 assert_eq!(items[0].label.as_deref(), Some("Markdown Report"));
1969 for it in &items[1..] {
1971 assert_eq!(
1972 it.target_file.as_deref(),
1973 Some("my_report.md"),
1974 "item {} should target my_report.md, got {:?}",
1975 it.name,
1976 it.target_file
1977 );
1978 }
1979 }
1980
1981 #[test]
1982 fn file_switch_resets_scope() {
1983 let p = parse(
1984 r#"
1985two_files: |
1986 file alpha.md as 'Alpha'
1987 plot p1 over x
1988 file beta.md
1989 plot p2 over y
1990"#,
1991 );
1992 let items = &p.report.groups[0].items;
1993 assert_eq!(items[0].name, "alpha.md");
1995 assert_eq!(items[1].target_file.as_deref(), Some("alpha.md"));
1996 assert_eq!(items[2].name, "beta.md");
1997 assert_eq!(items[3].target_file.as_deref(), Some("beta.md"));
1998 }
1999
2000 #[test]
2001 fn items_before_any_file_have_no_target() {
2002 let p = parse(
2003 r#"
2004mixed: |
2005 plot orphan over x
2006 file r.md
2007 plot inside over x
2008"#,
2009 );
2010 let items = &p.report.groups[0].items;
2011 assert_eq!(items[0].name, "orphan");
2012 assert_eq!(items[0].target_file, None);
2013 assert_eq!(items[2].name, "inside");
2014 assert_eq!(items[2].target_file.as_deref(), Some("r.md"));
2015 }
2016
2017 #[test]
2018 fn hash_line_comments_stripped() {
2019 let p = parse(
2020 r#"
2021g: |
2022 # comment line — ignored
2023 plot p1 # trailing comment
2024 over limit # also stripped
2025 label "Mean recall" # don't strip inside quotes
2026"#,
2027 );
2028 let item = &p.report.groups[0].items[0];
2029 assert_eq!(item.kind, Kind::Plot);
2030 assert_eq!(item.name, "p1");
2031 assert!(
2032 item.body.contains("over limit"),
2033 "body kept: {:?}",
2034 item.body
2035 );
2036 assert!(
2037 !item.body.contains("trailing"),
2038 "comment leaked: {:?}",
2039 item.body
2040 );
2041 assert_eq!(item.label.as_deref(), Some("Mean recall"));
2042 }
2043
2044 #[test]
2045 fn auto_text_names_unique_across_document() {
2046 let p = parse(
2047 r#"
2048g1: |
2049 text First
2050 text Second
2051g2: |
2052 text Third
2053"#,
2054 );
2055 let g1 = &p.report.groups[0];
2056 assert_eq!(g1.items[0].name, "text_001");
2057 assert_eq!(g1.items[1].name, "text_002");
2058 let g2 = &p.report.groups[1];
2061 assert_eq!(g2.items[0].name, "text_003");
2062 }
2063
2064 #[test]
2065 fn label_directive_strips_quotes() {
2066 let p = parse(
2067 r#"
2068g: |
2069 plot p1 over x
2070 label "My label"
2071 plot p2 over x
2072 label 'Another'
2073"#,
2074 );
2075 let items = &p.report.groups[0].items;
2076 assert_eq!(items[0].label.as_deref(), Some("My label"));
2077 assert_eq!(items[1].label.as_deref(), Some("Another"));
2078 }
2079
2080 fn round_trip_via_group(item: &ReportItem) -> ReportItem {
2090 let group_body = item.to_yaml_directive_string();
2091 let yaml = format!(
2092 "g: |\n{}",
2093 group_body
2094 .lines()
2095 .map(|l| format!(" {l}"))
2096 .collect::<Vec<_>>()
2097 .join("\n")
2098 );
2099 let parsed = parse(&yaml);
2100 assert_eq!(
2101 parsed.report.groups.len(),
2102 1,
2103 "round-trip should yield one group, got: {parsed:#?}"
2104 );
2105 let items = &parsed.report.groups[0].items;
2106 assert_eq!(
2107 items.len(),
2108 1,
2109 "round-trip should yield one item, got: {items:#?}"
2110 );
2111 items[0].clone()
2112 }
2113
2114 fn assert_round_trip_eq(item: ReportItem) {
2115 let recovered = round_trip_via_group(&item);
2116 assert_eq!(recovered.kind, item.kind);
2118 assert_eq!(recovered.name, item.name);
2119 assert_eq!(recovered.label, item.label);
2120 assert_eq!(recovered.as_stem, item.as_stem);
2121 assert_eq!(recovered.style.palette, item.style.palette);
2122 assert_eq!(recovered.style.line, item.style.line);
2123 assert_eq!(recovered.style.width, item.style.width);
2124 assert_eq!(recovered.style.marker, item.style.marker);
2125 assert_eq!(recovered.style.size, item.style.size);
2126 assert_eq!(recovered.style.color, item.style.color);
2127 assert_eq!(recovered.style.figure_width, item.style.figure_width);
2128 assert_eq!(recovered.style.figure_height, item.style.figure_height);
2129 assert_eq!(recovered.style.series.len(), item.style.series.len());
2130 for (a, b) in recovered.style.series.iter().zip(&item.style.series) {
2131 assert_eq!(a.key, b.key);
2132 assert_eq!(a.value, b.value);
2133 }
2134 let normalize = |s: &str| {
2138 s.split('\n')
2139 .map(|l| l.trim())
2140 .filter(|l| !l.is_empty())
2141 .collect::<Vec<_>>()
2142 .join("\n")
2143 };
2144 assert_eq!(normalize(&recovered.body), normalize(&item.body));
2145 }
2146
2147 #[test]
2148 fn round_trip_minimal_plot() {
2149 let item = ReportItem {
2150 kind: Kind::Plot,
2151 name: "demo".to_string(),
2152 body: "over cycle\nmetric=throughput".to_string(),
2153 ..Default::default()
2154 };
2155 assert_round_trip_eq(item);
2156 }
2157
2158 #[test]
2159 fn round_trip_plot_with_label_and_palette() {
2160 let mut item = ReportItem {
2161 kind: Kind::Plot,
2162 name: "recall".to_string(),
2163 label: Some("Recall@10".to_string()),
2164 body: "over limit\nby profile".to_string(),
2165 ..Default::default()
2166 };
2167 item.style.palette = Some("wong".to_string());
2168 assert_round_trip_eq(item);
2169 }
2170
2171 #[test]
2172 fn round_trip_plot_full_style() {
2173 let mut item = ReportItem {
2174 kind: Kind::Plot,
2175 name: "full".to_string(),
2176 label: Some("Full".to_string()),
2177 as_stem: Some("plot_full".to_string()),
2178 body: "over limit\nby profile\nwhere dataset=example\nagg=mean".to_string(),
2179 ..Default::default()
2180 };
2181 item.style.palette = Some("tol_muted".to_string());
2182 item.style.line = Some("dashed".to_string());
2183 item.style.width = Some(2.0);
2184 item.style.marker = Some("circle".to_string());
2185 item.style.size = Some(4.0);
2186 item.style.color = Some("#117733".to_string());
2187 item.style.figure_width = Some(800);
2188 item.style.figure_height = Some(600);
2189 assert_round_trip_eq(item);
2190 }
2191
2192 #[test]
2193 fn round_trip_table() {
2194 let mut item = ReportItem {
2195 kind: Kind::Table,
2196 name: "summary".to_string(),
2197 label: Some("Summary".to_string()),
2198 body: "metric=recall@.*\ngroup_by=profile".to_string(),
2199 ..Default::default()
2200 };
2201 item.style.palette = Some("wong".to_string());
2202 assert_round_trip_eq(item);
2203 }
2204
2205 #[test]
2206 fn round_trip_with_series_overrides() {
2207 let mut item = ReportItem {
2208 kind: Kind::Plot,
2209 name: "perseries".to_string(),
2210 body: "over limit\nby profile".to_string(),
2211 ..Default::default()
2212 };
2213 let s1 = Style {
2214 line: Some("dashed".to_string()),
2215 marker: Some("triangle".to_string()),
2216 ..Default::default()
2217 };
2218 item.style.series.push(SeriesOverride {
2219 key: "profile".to_string(),
2220 value: "hnsw".to_string(),
2221 style: s1,
2222 });
2223 let s2 = Style {
2224 line: Some("solid".to_string()),
2225 ..Default::default()
2226 };
2227 item.style.series.push(SeriesOverride {
2228 key: "profile".to_string(),
2229 value: "ivf".to_string(),
2230 style: s2,
2231 });
2232 let recovered = round_trip_via_group(&item);
2233 assert_eq!(recovered.style.series.len(), 2);
2234 assert_eq!(recovered.style.series[0].key, "profile");
2235 assert_eq!(recovered.style.series[0].value, "hnsw");
2236 assert_eq!(
2237 recovered.style.series[0].style.line.as_deref(),
2238 Some("dashed")
2239 );
2240 assert_eq!(
2241 recovered.style.series[0].style.marker.as_deref(),
2242 Some("triangle")
2243 );
2244 assert_eq!(recovered.style.series[1].value, "ivf");
2245 assert_eq!(
2246 recovered.style.series[1].style.line.as_deref(),
2247 Some("solid")
2248 );
2249 }
2250
2251 #[test]
2252 fn round_trip_label_with_internal_quotes_is_escaped() {
2253 let item = ReportItem {
2254 kind: Kind::Plot,
2255 name: "tricky".to_string(),
2256 label: Some(r#"He said "hi""#.to_string()),
2257 body: "over cycle".to_string(),
2258 ..Default::default()
2259 };
2260 let group_body = item.to_yaml_directive_string();
2265 assert!(
2266 group_body.contains(r#"label "He said \"hi\"""#),
2267 "emitter should escape inner quotes; got:\n{group_body}"
2268 );
2269 }
2270
2271 #[test]
2272 fn emitter_orders_directives_canonically() {
2273 let mut item = ReportItem {
2279 kind: Kind::Plot,
2280 name: "ordered".to_string(),
2281 label: Some("L".to_string()),
2282 as_stem: Some("S".to_string()),
2283 body: "over cycle".to_string(),
2284 ..Default::default()
2285 };
2286 item.style.palette = Some("wong".to_string());
2287 item.style.series.push(SeriesOverride {
2288 key: "k".to_string(),
2289 value: "v".to_string(),
2290 style: Style::default(),
2291 });
2292 let s = item.to_yaml_directive_string();
2293 let pos = |needle: &str| {
2294 s.find(needle)
2295 .unwrap_or_else(|| panic!("missing {needle} in:\n{s}"))
2296 };
2297 let pos_as = pos("as S");
2298 let pos_label = pos("label \"L\"");
2299 let pos_style = pos("palette=wong");
2300 let pos_body = pos("over cycle");
2301 let pos_per_series = pos("style k=v");
2302 assert!(pos_as < pos_label, "as before label");
2303 assert!(pos_label < pos_style, "label before style");
2304 assert!(pos_style < pos_body, "style before body");
2305 assert!(
2306 pos_body < pos_per_series,
2307 "body before per-series style overrides"
2308 );
2309 }
2310}