Skip to main content

nmbrs_workload/
report.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Unified `report:` block — plots and tables under one schema.
5//!
6//! See SRD-46 (`docs/SRD/46_reports.md`) for the authoritative
7//! design. This module owns:
8//!
9//! - The data model: [`Report`] / [`ReportGroup`] / [`ReportItem`]
10//!   / [`Kind`] / [`Style`].
11//! - The directive-string tokenizer that splits a group's body
12//!   into items keyed by leading `plot <name>` / `table <name>`.
13//! - The JSON sub-block parser for `series ... {…}` style
14//!   overrides (strict JSON only — relies on the existing rule
15//!   that Polydat `{...}` is unambiguously not JSON).
16//! - Style cascade helpers: `defaults` at workload root,
17//!   `defaults` at group level, then per-item directives.
18//!
19//! The crate is parser-only. Renderers in `crates/nmbrs/src/plot_metrics.rs`
20//! and the corresponding table renderer consume `ReportItem.body`
21//! plus the resolved [`Style`] — they don't touch the raw YAML
22//! again.
23
24use 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/// Top-level `report:` block.
35#[derive(Debug, Clone, Default, Serialize, Deserialize)]
36pub struct Report {
37    /// Cross-cutting style + metadata defaults declared via the
38    /// reserved `defaults:` mapping. Cascade into every group.
39    #[serde(default)]
40    pub defaults: Style,
41    /// Groups in YAML declaration order. Each group's name becomes
42    /// a markdown section heading; items render in body order.
43    #[serde(default)]
44    pub groups: Vec<ReportGroup>,
45}
46
47impl Report {
48    /// Iterate every item across every group in declaration order.
49    pub fn items(&self) -> impl Iterator<Item = &ReportItem> {
50        self.groups.iter().flat_map(|g| g.items.iter())
51    }
52
53    /// Find an item by canonical name. Returns the first match in
54    /// declaration order. Names should be unique within a `Report`
55    /// (collisions are caught at parse time).
56    pub fn find(&self, name: &str) -> Option<&ReportItem> {
57        self.items().find(|i| i.name == name)
58    }
59
60    /// Resolve the effective style for one item, walking the
61    /// cascade: report defaults → group defaults → item directives.
62    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/// One named container in `report:`. Holds zero or more items
71/// plus group-level defaults.
72#[derive(Debug, Clone, Default, Serialize, Deserialize)]
73pub struct ReportGroup {
74    /// YAML key the group was declared under (e.g.
75    /// `recall_block`). Used as the markdown section heading.
76    pub name: String,
77    /// Group-level style defaults (from `defaults <directives>`
78    /// lines at the start of the group body). Cascade into every
79    /// item in this group.
80    #[serde(default)]
81    pub defaults: Style,
82    /// Items declared in this group, in body declaration order.
83    #[serde(default)]
84    pub items: Vec<ReportItem>,
85}
86
87/// One renderable report item — either a plot or a table.
88#[derive(Debug, Clone, Default, Serialize, Deserialize)]
89pub struct ReportItem {
90    /// `plot` or `table`.
91    pub kind: Kind,
92    /// Canonical name (the token after `plot` / `table`).
93    pub name: String,
94    /// Display label (`label "..."`). Falls back to a prettified
95    /// form of `name` when absent.
96    #[serde(default)]
97    pub label: Option<String>,
98    /// Output-filename stem (`as <stem>`). When absent, default
99    /// is `plot_<name>` / `table_<name>` derived at render time.
100    #[serde(default)]
101    pub as_stem: Option<String>,
102    /// Per-item style overrides parsed from the directive body.
103    #[serde(default)]
104    pub style: Style,
105    /// Raw spec body — the directive lines following the
106    /// `<kind> <name>` line, with `as`/`label`/style directives
107    /// stripped out (they live on the [`ReportItem`] / [`Style`]
108    /// fields). Renderers parse this exactly the way they parse
109    /// the legacy CLI spec strings.
110    #[serde(default)]
111    pub body: String,
112    /// Output file this item flows into when the markdown
113    /// assembler runs. `None` ⇒ default `summary.md`. Set by a
114    /// preceding `file <filename>` directive in the same group.
115    /// Resolved at parse time so consumers don't need to walk
116    /// the group order.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub target_file: Option<String>,
119    /// Declaration ordinal across the whole `report:` block,
120    /// stamped by [`parse_report`] and persisted as an
121    /// `order <n>` directive line, so a db-sourced render
122    /// (`report.<name>` metadata rows, which iterate in key
123    /// order) can restore declaration order. `serde(skip)`:
124    /// positional metadata derived at parse, not document
125    /// content.
126    #[serde(skip)]
127    pub order: Option<usize>,
128    /// Plot-only directive: when `true`, the renderer
129    /// emits a companion table immediately after the plot
130    /// in the same markdown file, sharing the plot's
131    /// underlying query data. The companion table reuses
132    /// the plot's name with a `_table` suffix for its
133    /// anchor and figure-numbering slot, so users can
134    /// link to either view independently.
135    ///
136    /// Set via `with-table: true` in the plot's body.
137    /// No-op for `Kind::Table` and other non-plot items.
138    /// Default `false` — plots without an explicit toggle
139    /// keep their existing single-image behaviour.
140    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
141    pub with_table: bool,
142    /// Plot-only directive: when non-empty, the renderer
143    /// emits one companion table per distinct value tuple
144    /// of the listed label keys. Each table is filtered to
145    /// rows whose labels match that tuple, and gets its own
146    /// markdown section / standalone file named with the
147    /// tuple values appended.
148    ///
149    /// Set via `with-tables: [label1, label2, …]` in the
150    /// plot body. Independent of (and composes with)
151    /// `with-table: true` — the singular form still emits
152    /// a single un-faceted table when set.
153    #[serde(default, skip_serializing_if = "Vec::is_empty")]
154    pub with_tables: Vec<String>,
155}
156
157/// Kind discriminator. Identifies which renderer owns the item.
158///
159/// `Plot` and `Table` are figures — they carry data and get a
160/// figure number. `Text` is markdown prose; `File` is a scope
161/// directive that switches subsequent items' output file.
162/// `Details` is an auto-injected session-context block (the
163/// runner emits one to every target markdown file at end-of-
164/// run; users can also declare it explicitly to control its
165/// position). Only Plot and Table participate in figure
166/// numbering.
167#[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    /// True for kinds that contribute a figure to the report
191    /// (plot, table). Used by the figure-numbering pass.
192    pub fn is_figure(&self) -> bool {
193        matches!(self, Kind::Plot | Kind::Table)
194    }
195}
196
197/// SRD-46 output destination for a rendered report item.
198///
199/// The set is declared with `to <dest>[, <dest>…]` and rides the
200/// style cascade (report defaults → group defaults → item), so a
201/// whole report can be routed in one line. An inner declaration
202/// REPLACES an outer one rather than unioning with it — routing
203/// you can't take back would make the outer default a trap.
204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "lowercase")]
206pub enum Destination {
207    /// The session directory: standalone artifact + markdown
208    /// upsert. The default when nothing is declared.
209    SessionDir,
210    /// The console form on stdout. Never implied — see
211    /// [`vocab::DESTINATION_NAMES`].
212    Stdout,
213    /// The console form on stderr.
214    Stderr,
215    /// Render nothing.
216    None,
217}
218
219impl Destination {
220    /// Parse one destination name. `session` and `session_dir`
221    /// are accepted spellings of `sessiondir` — the concept has
222    /// three natural spellings and rejecting two of them buys
223    /// nothing.
224    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    /// Parse a comma- (or whitespace-) separated destination
244    /// list. `none` anywhere in the list wins outright: it is a
245    /// suppression, not a peer.
246    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/// Style and figure-metadata bag. Every field optional; cascade
274/// is "first non-`None` wins outer-to-inner".
275#[derive(Debug, Clone, Default, Serialize, Deserialize)]
276pub struct Style {
277    /// Palette name or numeric index (`"wong"`, `"3"`).
278    #[serde(default, skip_serializing_if = "Option::is_none")]
279    pub palette: Option<String>,
280    /// Line-dash style: `solid`, `dashed`, `dotted`, `dashdot`,
281    /// `none`.
282    #[serde(default, skip_serializing_if = "Option::is_none")]
283    pub line: Option<String>,
284    /// Stroke width in pixels.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub width: Option<f32>,
287    /// Marker shape: `none`, `circle`, `square`, `triangle`,
288    /// `diamond`, `plus`, `cross`.
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub marker: Option<String>,
291    /// Marker radius in pixels.
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub size: Option<f32>,
294    /// Hex color override (`#RRGGBB`).
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub color: Option<String>,
297    /// Figure width in pixels.
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub figure_width: Option<u32>,
300    /// Figure height in pixels.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub figure_height: Option<u32>,
303    /// Per-series JSON sub-block overrides keyed by the
304    /// `<key>=<value>` discriminator after the `series` directive.
305    /// Values are arbitrary JSON objects; renderers consume the
306    /// fields they recognise.
307    #[serde(default, skip_serializing_if = "Vec::is_empty")]
308    pub series: Vec<SeriesOverride>,
309    /// SRD-46 output routing (`to stdout, sessiondir`). `None`
310    /// means "not declared at this level" — the cascade keeps
311    /// looking outward, and the render entry point supplies the
312    /// default when nothing declared it anywhere.
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub destinations: Option<Vec<Destination>>,
315}
316
317/// One `series <key>=<value> {...}` directive.
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct SeriesOverride {
320    /// Discriminator key (e.g. `profile`).
321    pub key: String,
322    /// Discriminator value (e.g. `hnsw`).
323    pub value: String,
324    /// Style overrides — strict JSON object verbatim, parsed
325    /// into a Style at apply time.
326    pub style: Style,
327}
328
329impl ReportItem {
330    /// Render this item back to its canonical YAML directive
331    /// form — the same string shape the workload-YAML parser
332    /// in [`parse_group`] consumes. Round-trips:
333    /// `parse_group(... item.to_yaml_directive_string() ...) == item`.
334    ///
335    /// The output is one or more lines:
336    ///
337    /// ```text
338    /// plot <name>
339    ///   as <stem>
340    ///   label "<label>"
341    ///   palette=<v>
342    ///   line=<v> width=<n> marker=<v> size=<n> color=<#hex>
343    ///   <body lines verbatim>
344    ///   series <key>=<val> {<json>}
345    /// ```
346    ///
347    /// Order matches [`vocab::ALL_DIRECTIVES`] so the
348    /// round-trip is stable. Style fields that are `None` are
349    /// omitted; the body is appended verbatim (it carries the
350    /// renderer-consumed `over` / `by` / `where` / `agg` /
351    /// `xlabel` / etc. lines).
352    pub fn to_yaml_directive_string(&self) -> String {
353        let mut out = String::new();
354        // Header line: `<kind> <name>`. `Details` items also
355        // round-trip; `File` directives use their target stem
356        // as the "name" (a `file <stem>` line).
357        out.push_str(self.kind.as_str());
358        out.push(' ');
359        out.push_str(&self.name);
360        // Text headers need the trailing colon: it is the parser's
361        // "named" signal (a bare `text <word>` line is the legacy
362        // anonymous single-line body form). Without it the
363        // round-trip anonymized the item and leaked the name into
364        // the rendered prose.
365        if matches!(self.kind, Kind::Text) {
366            out.push(':');
367        }
368        out.push('\n');
369
370        // `as <stem>` and `label "<text>"` come first when
371        // present — the reader expects identity directives
372        // before the per-renderer body.
373        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        // Routing travels with the item so a persisted item
389        // replayed by `nmbrs report` lands where it was declared
390        // to land, not where the replaying command defaults.
391        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        // Style scalars in declaration order from the vocab
417        // registry. Each is `<key>=<value>` on its own line —
418        // matches the existing parser's `apply_one_style_kv`
419        // contract.
420        for line in self.style.scalar_directive_lines() {
421            out.push_str("  ");
422            out.push_str(&line);
423            out.push('\n');
424        }
425
426        // Body lines (over / by / where / agg / xlabel / etc.)
427        // are passed through verbatim. The body is already in
428        // the canonical form the renderer expects.
429        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        // Per-series sub-blocks. Use the brace-free directive
441        // form (`series profile=hnsw line=dashed`) when only
442        // simple key=value scalars are involved; fall back to
443        // the JSON form for completeness when the series style
444        // carries series-style fields that don't survive the
445        // simple form (today they all do, but reserved for
446        // future extension).
447        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    /// Render the scalar (non-series) fields of this style
465    /// as a list of `key=value` directive lines, in the
466    /// canonical [`vocab::ALL_DIRECTIVES`] order. Used by
467    /// [`ReportItem::to_yaml_directive_string`] and by
468    /// the `series` sub-block renderer.
469    ///
470    /// `None` fields are skipped; only fields whose vocab
471    /// `target` is [`vocab::DirectiveTarget::StyleField`]
472    /// are emitted.
473    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    /// Merge `other` into `self`: every `Some(_)` field in `other`
498    /// overrides the corresponding field in `self`. Used to walk
499    /// the cascade outer → inner.
500    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        // Routing REPLACES rather than unions: an item declaring
533        // `to stdout` means stdout, not "stdout on top of
534        // whatever the group defaulted to".
535        if other.destinations.is_some() {
536            self.destinations = other.destinations.clone();
537        }
538    }
539
540    /// The declared destination set, or `default_to` when no
541    /// level of the cascade declared one. Callers pass the
542    /// default their entry point implies — files-only for
543    /// automatic end-of-run rendering, files+stdout for an
544    /// explicit `nmbrs report` invocation.
545    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
553// ---------------------------------------------------------------------------
554// Parsing
555// ---------------------------------------------------------------------------
556
557/// Reserved directive keywords. Used to:
558/// (1) reject group child keys that collide with directive names
559///     (warn / error in strict mode),
560/// (2) detect when a directive line is the start of a new item
561///     (`plot`, `table`) vs a continuation directive.
562const 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
599/// Parse a `report:` value (a YAML mapping) into a [`Report`].
600///
601/// Errors describe the offending input precisely; warnings (e.g.
602/// empty groups, kind-mismatched directives) are collected
603/// alongside so callers can decide whether to surface or promote
604/// to errors under strict mode.
605pub 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    // Global counter for anonymous text item naming so the
614    // round-trip identity (`report.<name>` keys) stays unique
615    // across the whole document, not just within one group.
616    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    // Stamp the declaration ordinal across the whole block. The
661    // persisted form (`order <n>`) carries it through the db so
662    // `nmbrs report` renders items in declaration order even when
663    // the metadata rows iterate alphabetically by key.
664    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/// Result of parsing a `report:` block. Warnings are non-fatal
676/// in normal mode; callers running under SRD-15 strict mode
677/// should promote them to errors.
678#[derive(Debug, Clone, Default)]
679pub struct ParsedReport {
680    pub report: Report,
681    pub warnings: Vec<String>,
682}
683
684/// Parse a single persisted-form report item — header line
685/// (`<kind> <name>`) followed by indented directive lines, as
686/// produced by [`ReportItem::to_yaml_directive_string`]. Used by
687/// the session-db fallback path in `nmbrs report` so the
688/// directive-form knowledge lives in one place.
689pub 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    // Active output file scope (set by a `file <filename>` line).
712    // Items declared after a `file` directive inherit this until
713    // the next `file` directive in the same group.
714    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        // Strip `#` line comments (SRD-46) before parsing —
731        // honours quoted strings so `label "see #1 above"`
732        // survives.
733        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        // `defaults <directives>` — group-level defaults. Only
741        // valid before any item starts (since otherwise the
742        // intent is ambiguous: do they apply to subsequent items
743        // only? to all items? we pick "before-first-item" so
744        // there's exactly one place defaults can land).
745        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        // `<kind> <args>` — start of new item.
757        if let Some((kind, rest)) = strip_kind_keyword(line) {
758            if let Some(prev) = current.take() {
759                emit(prev, &current_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                    // Allow trailing `:` on the block header
772                    // (`plot recall_vs_qps:`) for visual parity with
773                    // the body's `name: value` directives. The
774                    // colon is a delimiter — strip it before the
775                    // reserved-keyword check so `plot text:` still
776                    // sees the name as `text`.
777                    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                    // Three surface forms for a `text` block:
799                    //   text <body>                              — anonymous, body on this line
800                    //   text <name>[:]                           — named, body on continuation lines
801                    //   text <name> as "<label>"[:]              — named + alias for the markdown heading
802                    //
803                    // The trailing `:` (per the new uniform
804                    // `name: value` style) is decorative and
805                    // gets stripped before the name is captured.
806                    // A bare `text` with no first token degrades
807                    // to the anonymous auto-name form, preserving
808                    // the legacy single-line shape.
809                    let rest = rest.trim();
810                    // Strip the trailing block-header colon from
811                    // the WHOLE line before tokenising, so it
812                    // doesn't get sucked into the `as "..."`
813                    // alias as `"...":`.
814                    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                    // Decide between named and anonymous. A `text`
820                    // block is named only when the author opted in
821                    // with a positive signal:
822                    //   - a trailing `:` on the header line, or
823                    //   - an `as "<label>"` clause.
824                    // A bare `text <word>` line stays anonymous —
825                    // it's the legacy "single-line text body"
826                    // shape (preserves `text First` / `text Second`
827                    // auto-numbering tests + workloads in the wild).
828                    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                        // Anonymous — auto-name globally so the
837                        // persistence round-trip keeps unique
838                        // `report.<name>` keys.
839                        *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                        // Anonymous: keep the original-line body
848                        // as a directive (legacy shape).
849                        p.directives.push(rest.to_string());
850                    }
851                    current = Some(p);
852                }
853                Kind::File => {
854                    // `file <filename> [as <label>]` — switches
855                    // the active output file. The directive is
856                    // also persisted as a Kind::File item so the
857                    // listing surface can show it; no body
858                    // attaches.
859                    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                    // Honor optional `as '<label>'` on the same
872                    // line (passed through to finalize via the
873                    // directive list — the `label` extractor
874                    // already understands quoted strings).
875                    if !trailing.trim().is_empty() {
876                        // Translate `as 'X'` → `label X` so the
877                        // existing label extractor handles it.
878                        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                    // Auto-injected at end-of-run; explicit
890                    // `details` declarations are accepted so the
891                    // author can pin position. Body is whatever
892                    // the assembler decides — usually empty
893                    // when declared explicitly (the runtime
894                    // fills it in).
895                    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        // Continuation directive line for the current item.
907        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, &current_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    /// Pull `label` / `as` / style directives out of the directive
944    /// body. Anything else is left in `body` for the kind-specific
945    /// renderer to consume.
946    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        // Text items: body is verbatim markdown. Pull out a
961        // leading `label "..."` line if present (so the heading
962        // can carry a title) plus the identity directives the
963        // persisted round-trip emits — `target <file>` (only when
964        // the remainder is a single token, so prose that happens
965        // to start with the word stays prose) and `order <n>`
966        // (only when the remainder is a number). Every other
967        // line stays as prose. Style / series / `as` directives
968        // don't apply to text — preserved in body if the user
969        // wrote them (most likely they meant prose).
970        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            // `target <filename>` — per-item override that pins
1031            // the output markdown file independently of any
1032            // surrounding `file <filename>` scope. Used by the
1033            // persisted form (runner emits it for every item so
1034            // the round-trip through the db preserves the
1035            // target the workload's `file` scope assigned).
1036            if let Some(rest) = strip_directive_keyword(line, "target") {
1037                item.target_file = Some(rest.trim().to_string());
1038                continue;
1039            }
1040            // `order <n>` — declaration ordinal, emitted by the
1041            // persisted form so db-sourced renders keep the
1042            // workload's declaration order.
1043            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            // `with-tables: [label1, label2, …]` — plot-only
1050            // multi-table fan-out. Emits one companion
1051            // table per distinct value tuple of the listed
1052            // labels. Comes before `with-table` matching so
1053            // the plural form isn't shadowed by the
1054            // prefix-trimmed singular.
1055            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            // `with-table: true|false` — plot-only flag
1090            // that asks the renderer to emit a companion
1091            // table immediately after the plot, sharing
1092            // the plot's underlying query data. SRD-46
1093            // §"Table-from-plot". Accepted-but-warned for
1094            // non-plot items.
1095            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                    // Per-item directive that doesn't apply to the
1138                    // item's kind: warn (SRD-46 strict-mode hook).
1139                    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
1198/// `true` when `s` is a bare identifier-shaped token suitable to
1199/// use as the name of a `text <name>` block — `[A-Za-z_][A-Za-z0-9_-]*`.
1200/// Falls back to anonymous-text auto-naming when the first token
1201/// has whitespace, punctuation, or starts with a digit; that's
1202/// the legacy single-line `text <body>` shape.
1203fn 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
1223/// Apply style directives from a single line (any combination
1224/// of `key=value` pairs separated by whitespace or commas) to
1225/// the given Style. Returns `Ok(Some(true))` if every token on
1226/// the line was a recognized style directive, `Ok(Some(false))`
1227/// if some tokens belonged to the kind-specific spec body, or
1228/// `Ok(None)` if the line doesn't look like a style directive
1229/// line at all (no `=` and no leading style keyword).
1230fn 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            // Bare keyword without value — not yet meaningful.
1244            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        // Routing, not cosmetics — but it cascades through the
1284        // same `defaults:` mapping, so it is applied here.
1285        "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    // Split on whitespace and commas at depth 0. Quoted values
1293    // stay together. No JSON braces here — those are only inside
1294    // `series` sub-blocks and parsed separately.
1295    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    // Shape: `<key>=<value> {<json>}`, `<key>=<value> <directives>`,
1353    // or the colon-separator form `<key>=<value>:<directives>` (same
1354    // shape the plot DSL's --style flag accepts; lets workload
1355    // authors write `style phase=pvs_query:line=dotted` without
1356    // having to remember which directive uses which separator).
1357    let s = s.trim();
1358    // Try `:` first when it appears before any whitespace — that's
1359    // the colon-form. Otherwise the head ends at the first
1360    // whitespace boundary as before.
1361    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)), // colon wins
1365        (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        // JSON sub-block — strict.
1382        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        // Brace-free directive form.
1390        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
1436/// Strip a `#` line comment from one source line. A `#` starts
1437/// a comment only when it is at line start or preceded by
1438/// whitespace — so hex colors (`#117733`) and JSON sub-blocks
1439/// (`{"color": "#fff"}`) are unaffected. Quoted strings are
1440/// honoured so `label "see #1 above"` keeps its `#`.
1441fn strip_line_comment(line: &str) -> &str {
1442    let mut quote: Option<char> = None;
1443    let mut prev_ws = true; // start-of-line counts as whitespace boundary
1444    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        // Item-level inherits via merge_from at apply time.
1542        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        // The full text-identity round trip: named text with a
1623        // heading label, a file target, and a declaration
1624        // ordinal must come back from the persisted form with
1625        // NOTHING leaked into the prose body. (Before this, the
1626        // header lost its named signal — the name and `target`
1627        // line rendered as prose and the section fell back to
1628        // summary.md.)
1629        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        // Prose survives verbatim — including a line that merely
1646        // STARTS with the word `target` (multi-token ⇒ prose).
1647        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        // Comma, whitespace, and mixed separators all work; the
1655        // three spellings of the session dir are one destination.
1656        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        // Duplicates collapse rather than double-delivering.
1669        assert_eq!(
1670            Destination::parse_list("stdout,stdout").unwrap(),
1671            vec![Stdout]
1672        );
1673        // `none` is a suppression, not a peer — it wins outright.
1674        assert_eq!(
1675            Destination::parse_list("stdout, none, sessiondir").unwrap(),
1676            vec![None]
1677        );
1678        // An unknown destination names the vocabulary.
1679        let err = Destination::parse_list("syslog").unwrap_err();
1680        assert!(err.contains("syslog"), "{err}");
1681        assert!(err.contains("sessiondir"), "names the vocabulary: {err}");
1682        // An empty list is an error, not a silent no-op.
1683        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        // The item declaration REPLACES the report default rather
1707        // than unioning with it — `to stdout` means stdout, not
1708        // "stdout on top of sessiondir".
1709        assert_eq!(
1710            parsed.report.effective_style(group, alpha).destinations,
1711            Some(vec![Destination::Stdout])
1712        );
1713        // An item that declared nothing inherits the cascade.
1714        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        // Nothing anywhere in the cascade declared `to`, so the
1723        // parsed style carries `None` — the render entry point
1724        // decides, and the two entry points differ (files-only for
1725        // the automatic run-end render, files+stdout for an
1726        // explicit `nmbrs report`).
1727        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        // `to` must survive the db round-trip, or an item replayed
1754        // by `nmbrs report` would land where the replaying command
1755        // defaults instead of where it was declared.
1756        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        // Verbatim shape produced by `to_yaml_directive_string`
1813        // and written to `session_metadata.report.<name>`.
1814        // Lines start at column 0 (no indentation in the
1815        // persisted form). The plot must survive a round-trip
1816        // with `with_table=true` and no `with-table` line in
1817        // the body.
1818        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        // Regression: the YAML colon form `with-table: true` used
1839        // to fall through `strip_directive_keyword` (which only
1840        // accepted space/tab/`=` separators), leaving the line in
1841        // `item.body`. The plot renderer then saw it as an
1842        // unknown directive and failed every plot it touched.
1843        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        // Subsequent items inherit the file as target.
1970        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        // Items 0=file alpha, 1=plot p1 (alpha), 2=file beta, 3=plot p2 (beta)
1994        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        // Counter is global, not per-group, so persistence keys
2059        // (`report.<name>`) stay unique.
2060        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    // ------------------------------------------------------------------
2081    // Round-trip emitter — Phase A SRD-64 contract test
2082    // ------------------------------------------------------------------
2083    //
2084    // For every report-grammar shape we accept, the emitter
2085    // must produce a string that re-parses to an equal AST.
2086    // That's the contract that lets `--add` write the same
2087    // grammar back to YAML faithfully.
2088
2089    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        // Compare individual fields for clearer diffs on failure.
2117        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        // Body comparison is whitespace-tolerant: the emitter
2135        // re-indents to canonical 2-space, the parser strips
2136        // leading whitespace anyway.
2137        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        // The emitter must escape the inner quotes so the
2261        // parser sees a single label string. (The parser today
2262        // strips outer quotes only; if it doesn't honour the
2263        // escape, the test catches that as a real bug to fix.)
2264        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        // `as` comes before `label`; identity before style;
2274        // style (scalar fields) before body; body before
2275        // per-series style overrides. This is the canonical
2276        // order from vocab::ALL_DIRECTIVES so the round-trip
2277        // stays stable.
2278        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}