Skip to main content

faucet_core/
tree.rs

1//! Recursive report-tree / matrix flatten transform (`tree_flatten`, #530).
2//!
3//! Financial-report APIs (profit-and-loss, balance-sheet and similar report
4//! endpoints) return a self-referential nested-`Rows` matrix — a tree of
5//! section → subsection → line, where the tabular output is one row per **leaf**
6//! carrying the section labels it sits under plus the period columns. Flattening
7//! it is the one reshape that kept those taps on the embedded-DuckDB SQL path;
8//! `tree_flatten` moves them back to an inbuilt transform.
9//!
10//! Pure + recursive: a depth-first walk carrying an ancestor-label stack; at each
11//! leaf it emits `{ <ancestor columns…>, <header→value columns…>, [path] }`. It
12//! routes through [`TransformStage::Custom`](crate::stage::TransformStage) (1→0..N),
13//! so no new stage-enum variant is needed (the exhaustive-enum rule).
14
15// The whole module is gated by `#[cfg(feature = "transform-tree-flatten")]` at
16// its `pub mod tree;` declaration in `lib.rs`.
17use crate::FaucetError;
18use crate::stage::TransformStage;
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21use std::sync::Arc;
22
23/// Default [`TreeFlattenSpec::max_depth`] — a stack-overflow backstop for a
24/// malformed or cyclic tree, far above any real report nesting.
25pub const DEFAULT_MAX_DEPTH: usize = 64;
26
27fn default_max_depth() -> usize {
28    DEFAULT_MAX_DEPTH
29}
30fn default_leaf() -> String {
31    "has_no_children".to_owned()
32}
33fn default_value_field() -> String {
34    "value".to_owned()
35}
36fn default_path_sep() -> String {
37    " > ".to_owned()
38}
39
40/// How the value columns are read from a leaf node.
41#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct ColumnsSpec {
44    /// Path (within a node) to the leaf's cell array — e.g. `ColData`.
45    pub from: String,
46    /// Path (within the whole record) to the header definitions, paired
47    /// positionally with the cells to name the value columns. Absent → cells are
48    /// named `col_0`, `col_1`, ….
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub header: Option<String>,
51    /// Field within each header element holding its label (e.g. `ColTitle`).
52    /// Absent → the header element is used as a scalar string.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub header_label: Option<String>,
55    /// Field within each cell holding the value (e.g. `value`). Absent-in-cell →
56    /// the whole cell is used.
57    #[serde(default = "default_value_field")]
58    pub value: String,
59}
60
61impl Default for ColumnsSpec {
62    fn default() -> Self {
63        Self {
64            from: String::new(),
65            header: None,
66            header_label: None,
67            value: default_value_field(),
68        }
69    }
70}
71
72impl ColumnsSpec {
73    fn is_unset(&self) -> bool {
74        self.from.trim().is_empty()
75    }
76}
77
78/// Which ancestor labels to carry down onto each emitted row.
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
80#[serde(deny_unknown_fields)]
81pub struct AncestorsSpec {
82    /// Path (within a node) to that node's group label — e.g.
83    /// `Header.ColData[0].value`.
84    pub field: String,
85    /// Column names for depth 1, 2, …; extra depth is appended as
86    /// `ancestor_<n>`, missing levels are null.
87    #[serde(default, rename = "as")]
88    pub as_names: Vec<String>,
89}
90
91/// Spec for the `tree_flatten` transform — recursive tree/matrix → leaf rows
92/// (1→0..N). Compile with [`TreeFlattenSpec::compile`]; attach via
93/// [`TreeFlattenSpec::into_stage`].
94#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
95#[serde(deny_unknown_fields)]
96pub struct TreeFlattenSpec {
97    /// Path to the top-level node array within the record — e.g. `Rows.Row`.
98    /// Absent → the record itself is the single root node.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub root: Option<String>,
101    /// Path (within a node) to its child-node array — the recursion key, e.g.
102    /// `Rows.Row`.
103    pub children: String,
104    /// Leaf detection: `has_no_children` (default) or `has_field:<name>` (a node
105    /// carrying `<name>` is a leaf even if it also has children).
106    #[serde(default = "default_leaf")]
107    pub leaf: String,
108    /// How the value columns are read from a leaf. Set exactly one of
109    /// `columns` or `groups`.
110    #[serde(default, skip_serializing_if = "ColumnsSpec::is_unset")]
111    pub columns: ColumnsSpec,
112    /// Several positional cell arrays per leaf, each named by its own header
113    /// list (#746) — the `zip_columns` `groups` shape. Each group needs a
114    /// `header`; the groups' columns are merged into one row, and a width
115    /// mismatch, a missing cell array or a column two groups both name fails
116    /// the page.
117    #[serde(default, skip_serializing_if = "Vec::is_empty")]
118    pub groups: Vec<ColumnsSpec>,
119    /// Ancestor/group labels carried onto every row.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub ancestors: Option<AncestorsSpec>,
122    /// Emit the joined ancestor path under this column (e.g. `Income > Sales`).
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub path_as: Option<String>,
125    /// Separator for `path_as`.
126    #[serde(default = "default_path_sep")]
127    pub path_sep: String,
128    /// Skip leaves whose value cells are all empty (null or `""`).
129    #[serde(default)]
130    pub drop_empty: bool,
131    /// Also emit a row for a group node that carries its own cells (subtotals).
132    #[serde(default)]
133    pub emit_group_rows: bool,
134    /// Stack-overflow backstop; a branch deeper than this is truncated (logged).
135    #[serde(default = "default_max_depth")]
136    pub max_depth: usize,
137}
138
139impl TreeFlattenSpec {
140    /// Validate the spec, returning a reusable [`CompiledTreeFlatten`].
141    pub fn compile(&self) -> Result<CompiledTreeFlatten, FaucetError> {
142        CompiledTreeFlatten::compile(self)
143    }
144
145    /// Compile and wrap as a [`TransformStage::Custom`] (1→0..N). With
146    /// `groups` the stage is a [`TransformStage::PageFn`], so a record that
147    /// cannot be zipped fails the page instead of passing through.
148    pub fn into_stage(&self) -> Result<TransformStage, FaucetError> {
149        let compiled = self.compile()?;
150        if compiled.spec.groups.is_empty() {
151            return Ok(TransformStage::Custom(Arc::new(move |rec| {
152                compiled.apply(rec)
153            })));
154        }
155        Ok(TransformStage::PageFn(Arc::new(move |page: Vec<Value>| {
156            let mut out = Vec::with_capacity(page.len());
157            for rec in page {
158                out.extend(compiled.try_apply(rec)?);
159            }
160            Ok(out)
161        })))
162    }
163}
164
165#[derive(Debug, Clone, PartialEq)]
166enum LeafMode {
167    NoChildren,
168    HasField(String),
169}
170
171/// Validated [`TreeFlattenSpec`] — apply per record with [`CompiledTreeFlatten::apply`].
172#[derive(Debug, Clone)]
173pub struct CompiledTreeFlatten {
174    spec: TreeFlattenSpec,
175    leaf_mode: LeafMode,
176}
177
178impl CompiledTreeFlatten {
179    fn compile(spec: &TreeFlattenSpec) -> Result<Self, FaucetError> {
180        if spec.children.trim().is_empty() {
181            return Err(FaucetError::Transform(
182                "tree_flatten: `children` must be non-empty".to_owned(),
183            ));
184        }
185        if spec.groups.is_empty() && spec.columns.is_unset() {
186            return Err(FaucetError::Transform(
187                "tree_flatten: `columns.from` must be non-empty".to_owned(),
188            ));
189        }
190        if !spec.groups.is_empty() && !spec.columns.is_unset() {
191            return Err(FaucetError::Transform(
192                "tree_flatten: set exactly one of `columns` or `groups`".to_owned(),
193            ));
194        }
195        for (i, g) in spec.groups.iter().enumerate() {
196            if g.is_unset() || g.header.as_deref().is_none_or(|h| h.trim().is_empty()) {
197                return Err(FaucetError::Transform(format!(
198                    "tree_flatten: group {i} needs a non-empty `from` and `header`"
199                )));
200            }
201        }
202        if spec.max_depth == 0 {
203            return Err(FaucetError::Transform(
204                "tree_flatten: `max_depth` must be greater than zero".to_owned(),
205            ));
206        }
207        let leaf_mode = if spec.leaf == "has_no_children" {
208            LeafMode::NoChildren
209        } else if let Some(field) = spec.leaf.strip_prefix("has_field:") {
210            if field.trim().is_empty() {
211                return Err(FaucetError::Transform(
212                    "tree_flatten: `leaf: has_field:<name>` requires a field name".to_owned(),
213                ));
214            }
215            LeafMode::HasField(field.to_owned())
216        } else {
217            return Err(FaucetError::Transform(format!(
218                "tree_flatten: `leaf` must be `has_no_children` or `has_field:<name>`, got '{}'",
219                spec.leaf
220            )));
221        };
222        Ok(Self {
223            spec: spec.clone(),
224            leaf_mode,
225        })
226    }
227
228    /// Flatten one record (a report) into 0..N leaf rows. Non-object records and
229    /// records with no resolvable root pass through unchanged (never silently
230    /// dropped). With `groups`, a record that cannot be zipped (see
231    /// [`try_apply`](Self::try_apply)) is also passed through, with an error
232    /// logged; the pipeline stage uses `try_apply` and fails instead.
233    pub fn apply(&self, rec: Value) -> Vec<Value> {
234        match self.flatten(&rec) {
235            Ok(Some(rows)) => rows,
236            Ok(None) => vec![rec],
237            Err(e) => {
238                tracing::error!(error = %e, "tree_flatten: record passed through unflattened");
239                vec![rec]
240            }
241        }
242    }
243
244    /// [`apply`](Self::apply), failing on a record whose `groups` cannot be
245    /// zipped: a leaf missing a group's cell array, a group whose width differs
246    /// from its header count, or a column two groups both name.
247    pub fn try_apply(&self, rec: Value) -> Result<Vec<Value>, FaucetError> {
248        Ok(self.flatten(&rec)?.unwrap_or_else(|| vec![rec]))
249    }
250
251    /// `Ok(None)` = pass the record through unchanged.
252    fn flatten(&self, rec: &Value) -> Result<Option<Vec<Value>>, FaucetError> {
253        if !rec.is_object() {
254            return Ok(None);
255        }
256        let sources = self.column_sources(rec)?;
257
258        // Resolve the root node list. A root that resolves to an (even empty)
259        // array/node is used as-is — an empty report yields zero rows. Only a
260        // *missing* root path passes the record through (never silently dropped).
261        let roots: Vec<&Value> = match &self.spec.root {
262            Some(path) => match path_get(rec, path) {
263                Some(Value::Array(a)) => a.iter().collect(),
264                Some(v) => vec![v],
265                None => return Ok(None),
266            },
267            None => vec![rec],
268        };
269
270        let mut out: Vec<Value> = Vec::new();
271        let mut ancestors: Vec<Value> = Vec::new();
272        let mut depth_exceeded = false;
273        for node in roots {
274            self.walk(
275                node,
276                &mut ancestors,
277                0,
278                &sources,
279                &mut out,
280                &mut depth_exceeded,
281            )?;
282        }
283        Ok(Some(out))
284    }
285
286    /// Each column source with its header labels. `columns` is lenient
287    /// (unnamed cells become `col_<i>`); every group is strict.
288    fn column_sources<'s>(
289        &'s self,
290        rec: &Value,
291    ) -> Result<Vec<(&'s ColumnsSpec, Vec<String>)>, FaucetError> {
292        if self.spec.groups.is_empty() {
293            let labels = self
294                .spec
295                .columns
296                .header
297                .as_deref()
298                .and_then(|h| path_get(rec, h))
299                .and_then(Value::as_array)
300                .map(|arr| {
301                    arr.iter()
302                        .map(|el| header_label(&self.spec.columns, el))
303                        .collect()
304                })
305                .unwrap_or_default();
306            return Ok(vec![(&self.spec.columns, labels)]);
307        }
308        let mut owner: Map<String, Value> = Map::new();
309        let mut out = Vec::with_capacity(self.spec.groups.len());
310        for g in &self.spec.groups {
311            let header = g.header.as_deref().unwrap_or_default();
312            let labels: Vec<String> = path_get(rec, header)
313                .and_then(Value::as_array)
314                .ok_or_else(|| {
315                    FaucetError::Transform(format!(
316                        "tree_flatten: group '{}': header `{header}` is not an array",
317                        g.from
318                    ))
319                })?
320                .iter()
321                .map(|el| header_label(g, el))
322                .collect();
323            for n in &labels {
324                if let Some(Value::String(prev)) =
325                    owner.insert(n.clone(), Value::String(g.from.clone()))
326                {
327                    let which = if prev == g.from {
328                        format!("group '{prev}' names it twice")
329                    } else {
330                        format!("groups '{prev}' and '{}' both name it", g.from)
331                    };
332                    return Err(FaucetError::Transform(format!(
333                        "tree_flatten: duplicate column '{n}': {which}"
334                    )));
335                }
336            }
337            out.push((g, labels));
338        }
339        Ok(out)
340    }
341
342    fn is_leaf(&self, node: &Value, has_children: bool) -> bool {
343        match &self.leaf_mode {
344            LeafMode::NoChildren => !has_children,
345            LeafMode::HasField(f) => node.get(f).is_some(),
346        }
347    }
348
349    #[allow(clippy::too_many_arguments)]
350    fn walk(
351        &self,
352        node: &Value,
353        ancestors: &mut Vec<Value>,
354        depth: usize,
355        sources: &[(&ColumnsSpec, Vec<String>)],
356        out: &mut Vec<Value>,
357        depth_exceeded: &mut bool,
358    ) -> Result<(), FaucetError> {
359        if depth >= self.spec.max_depth {
360            if !*depth_exceeded {
361                *depth_exceeded = true;
362                tracing::error!(
363                    max_depth = self.spec.max_depth,
364                    "tree_flatten: max_depth exceeded — branch truncated (malformed or cyclic tree?)"
365                );
366            }
367            return Ok(());
368        }
369        let children = path_get(node, &self.spec.children).and_then(Value::as_array);
370        let has_children = children.is_some_and(|c| !c.is_empty());
371        let leaf = self.is_leaf(node, has_children);
372        let group_row =
373            self.spec.emit_group_rows && sources.iter().any(|(c, _)| node_has_cells(node, &c.from));
374
375        if (leaf || group_row)
376            && let Some(row) = self.emit_row(node, ancestors, sources, out.len())?
377        {
378            out.push(row);
379        }
380
381        if let Some(children) = children.filter(|c| !c.is_empty()) {
382            // Push this node's label, recurse, pop.
383            let label = self
384                .spec
385                .ancestors
386                .as_ref()
387                .and_then(|a| path_get(node, &a.field).cloned())
388                .unwrap_or(Value::Null);
389            ancestors.push(label);
390            for child in children {
391                self.walk(child, ancestors, depth + 1, sources, out, depth_exceeded)?;
392            }
393            ancestors.pop();
394        }
395        Ok(())
396    }
397
398    fn emit_row(
399        &self,
400        node: &Value,
401        ancestors: &[Value],
402        sources: &[(&ColumnsSpec, Vec<String>)],
403        row_index: usize,
404    ) -> Result<Option<Value>, FaucetError> {
405        let mut row = Map::new();
406
407        // Ancestor columns.
408        if let Some(anc) = &self.spec.ancestors {
409            for (i, label) in ancestors.iter().enumerate() {
410                let name = anc
411                    .as_names
412                    .get(i)
413                    .cloned()
414                    .unwrap_or_else(|| format!("ancestor_{}", i + 1));
415                row.insert(name, label.clone());
416            }
417        }
418        // Joined ancestor path.
419        if let Some(path_col) = &self.spec.path_as {
420            let joined = ancestors
421                .iter()
422                .map(scalar_string)
423                .collect::<Vec<_>>()
424                .join(&self.spec.path_sep);
425            row.insert(path_col.clone(), Value::String(joined));
426        }
427
428        // Value columns from the node's cell array(s).
429        let strict = !self.spec.groups.is_empty();
430        let mut all_empty = true;
431        for (spec, labels) in sources {
432            let cells = path_get(node, &spec.from).and_then(Value::as_array);
433            if strict {
434                let Some(cells) = cells else {
435                    return Err(FaucetError::Transform(format!(
436                        "tree_flatten: row {row_index} has no `{}` array (group '{}')",
437                        spec.from, spec.from
438                    )));
439                };
440                if cells.len() != labels.len() {
441                    return Err(FaucetError::Transform(format!(
442                        "tree_flatten: row {row_index}, group '{}': {} cell(s) but {} header(s)",
443                        spec.from,
444                        cells.len(),
445                        labels.len()
446                    )));
447                }
448            }
449            for (i, cell) in cells.into_iter().flatten().enumerate() {
450                let value = path_get(cell, &spec.value)
451                    .cloned()
452                    .unwrap_or_else(|| cell.clone());
453                if !is_empty_value(&value) {
454                    all_empty = false;
455                }
456                let name = labels
457                    .get(i)
458                    .cloned()
459                    .filter(|s| !s.is_empty())
460                    .unwrap_or_else(|| format!("col_{i}"));
461                row.insert(name, value);
462            }
463        }
464
465        if self.spec.drop_empty && all_empty {
466            return Ok(None);
467        }
468        Ok(Some(Value::Object(row)))
469    }
470}
471
472fn header_label(spec: &ColumnsSpec, el: &Value) -> String {
473    if let Some(field) = &spec.header_label
474        && let Some(v) = path_get(el, field)
475    {
476        return scalar_string(v);
477    }
478    scalar_string(el)
479}
480
481fn node_has_cells(node: &Value, from: &str) -> bool {
482    path_get(node, from)
483        .and_then(Value::as_array)
484        .is_some_and(|a| !a.is_empty())
485}
486
487fn is_empty_value(v: &Value) -> bool {
488    match v {
489        Value::Null => true,
490        Value::String(s) => s.is_empty(),
491        _ => false,
492    }
493}
494
495/// Render a JSON scalar as a plain string (objects/arrays → compact JSON).
496fn scalar_string(v: &Value) -> String {
497    match v {
498        Value::String(s) => s.clone(),
499        Value::Null => String::new(),
500        Value::Bool(b) => b.to_string(),
501        Value::Number(n) => n.to_string(),
502        other => other.to_string(),
503    }
504}
505
506/// Resolve a dot/bracket path against a value. Supports a leading `$`/`$.`, `.key`
507/// segments, and `[n]` array indices (e.g. `Header.ColData[0].value`,
508/// `$.Rows.Row`). Returns `None` on any miss. Purpose-built here because
509/// `CompiledPath` (stage.rs) does not support array indexing.
510fn path_get<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
511    let mut cur = root;
512    let mut rest = path.trim();
513    rest = rest.strip_prefix('$').unwrap_or(rest);
514    rest = rest.strip_prefix('.').unwrap_or(rest);
515    while !rest.is_empty() {
516        if let Some(after) = rest.strip_prefix('[') {
517            // [n] index
518            let close = after.find(']')?;
519            let idx: usize = after[..close].trim().parse().ok()?;
520            cur = cur.as_array()?.get(idx)?;
521            rest = &after[close + 1..];
522            rest = rest.strip_prefix('.').unwrap_or(rest);
523        } else {
524            // .key up to the next '.' or '['
525            let end = rest.find(['.', '[']).unwrap_or(rest.len());
526            let key = &rest[..end];
527            if key.is_empty() {
528                return None;
529            }
530            cur = cur.get(key)?;
531            rest = &rest[end..];
532            rest = rest.strip_prefix('.').unwrap_or(rest);
533        }
534    }
535    Some(cur)
536}
537
538#[cfg(test)]
539mod tests {
540    use super::*;
541    use serde_json::json;
542
543    fn spec() -> TreeFlattenSpec {
544        TreeFlattenSpec {
545            root: Some("Rows.Row".to_owned()),
546            children: "Rows.Row".to_owned(),
547            leaf: "has_no_children".to_owned(),
548            columns: ColumnsSpec {
549                from: "ColData".to_owned(),
550                header: Some("Columns.Column".to_owned()),
551                header_label: Some("ColTitle".to_owned()),
552                value: "value".to_owned(),
553            },
554            groups: vec![],
555            ancestors: Some(AncestorsSpec {
556                field: "Header.ColData[0].value".to_owned(),
557                as_names: vec!["section".to_owned(), "subsection".to_owned()],
558            }),
559            path_as: Some("group_path".to_owned()),
560            path_sep: " > ".to_owned(),
561            drop_empty: false,
562            emit_group_rows: false,
563            max_depth: DEFAULT_MAX_DEPTH,
564        }
565    }
566
567    /// A nested-`Rows` P&L: Income → {Sales, Services}, one leaf each, two
568    /// period columns.
569    fn nested_rows_report() -> Value {
570        json!({
571            "Columns": { "Column": [ {"ColTitle": ""}, {"ColTitle": "Jan 2024"}, {"ColTitle": "Feb 2024"} ] },
572            "Rows": { "Row": [
573                {
574                    "Header": { "ColData": [ {"value": "Income"} ] },
575                    "Rows": { "Row": [
576                        { "ColData": [ {"value": "Sales"}, {"value": "100"}, {"value": "120"} ] },
577                        { "ColData": [ {"value": "Services"}, {"value": "50"}, {"value": "60"} ] }
578                    ] }
579                }
580            ] }
581        })
582    }
583
584    #[test]
585    fn flattens_nested_rows_report_to_leaf_rows() {
586        let out = spec().compile().unwrap().apply(nested_rows_report());
587        assert_eq!(out.len(), 2);
588        assert_eq!(out[0]["section"], json!("Income"));
589        assert_eq!(out[0]["group_path"], json!("Income"));
590        // Header pairing: the first column's header is empty, so it falls back to
591        // `col_0` (an empty column name is unusable downstream); the two periods
592        // take their header titles.
593        assert_eq!(out[0]["col_0"], json!("Sales"));
594        assert_eq!(out[0]["Jan 2024"], json!("100"));
595        assert_eq!(out[0]["Feb 2024"], json!("120"));
596        assert_eq!(out[1]["col_0"], json!("Services"));
597        assert_eq!(out[1]["Feb 2024"], json!("60"));
598    }
599
600    #[test]
601    fn uneven_depth_names_extra_levels_and_leaves_missing_null() {
602        // Income has a nested subsection; a sibling leaf sits at depth 1.
603        let report = json!({
604            "Rows": { "Row": [
605                {
606                    "Header": { "ColData": [ {"value": "Income"} ] },
607                    "Rows": { "Row": [
608                        {
609                            "Header": { "ColData": [ {"value": "Domestic"} ] },
610                            "Rows": { "Row": [
611                                { "ColData": [ {"value": "Sales"}, {"value": "100"} ] }
612                            ] }
613                        }
614                    ] }
615                },
616                { "ColData": [ {"value": "Other"}, {"value": "5"} ] }
617            ] }
618        });
619        let mut s = spec();
620        s.columns.header = None;
621        let out = s.compile().unwrap().apply(report);
622        assert_eq!(out.len(), 2);
623        // Deep leaf: section=Income, subsection=Domestic.
624        assert_eq!(out[0]["section"], json!("Income"));
625        assert_eq!(out[0]["subsection"], json!("Domestic"));
626        assert_eq!(out[0]["col_0"], json!("Sales"));
627        // Shallow leaf: no ancestors at all.
628        assert!(out[1].get("section").is_none());
629        assert_eq!(out[1]["col_0"], json!("Other"));
630    }
631
632    #[test]
633    fn header_cell_length_mismatch_zips_to_shorter() {
634        let mut s = spec();
635        s.ancestors = None;
636        s.root = None;
637        s.children = "children".to_owned();
638        let report = json!({
639            "Columns": { "Column": [ {"ColTitle": "A"}, {"ColTitle": "B"} ] },
640            "ColData": [ {"value": "x"}, {"value": "y"}, {"value": "z"} ]
641        });
642        let out = s.compile().unwrap().apply(report);
643        assert_eq!(out.len(), 1);
644        assert_eq!(out[0]["A"], json!("x"));
645        assert_eq!(out[0]["B"], json!("y"));
646        // Third cell has no header → col_2.
647        assert_eq!(out[0]["col_2"], json!("z"));
648    }
649
650    #[test]
651    fn leaf_has_field_mode() {
652        let mut s = spec();
653        s.leaf = "has_field:ColData".to_owned();
654        s.emit_group_rows = false;
655        // A node that has BOTH children and ColData is a leaf under has_field.
656        let report = json!({
657            "Rows": { "Row": [
658                {
659                    "Header": { "ColData": [ {"value": "Total"} ] },
660                    "ColData": [ {"value": "Total"}, {"value": "9"} ],
661                    "Rows": { "Row": [ { "ColData": [ {"value": "x"}, {"value": "1"} ] } ] }
662                }
663            ] }
664        });
665        s.columns.header = None;
666        let out = s.compile().unwrap().apply(report);
667        // Parent (has ColData) emits, plus its child leaf → 2 rows.
668        assert_eq!(out.len(), 2);
669        assert_eq!(out[0]["col_0"], json!("Total"));
670    }
671
672    #[test]
673    fn emit_group_rows_includes_subtotals() {
674        let mut s = spec();
675        s.emit_group_rows = true;
676        s.columns.header = None;
677        let report = json!({
678            "Rows": { "Row": [
679                {
680                    "Header": { "ColData": [ {"value": "Income"} ] },
681                    "ColData": [ {"value": "Income total"}, {"value": "150"} ],
682                    "Rows": { "Row": [
683                        { "ColData": [ {"value": "Sales"}, {"value": "100"} ] }
684                    ] }
685                }
686            ] }
687        });
688        let out = s.compile().unwrap().apply(report);
689        // The group row (subtotal) + the leaf.
690        assert_eq!(out.len(), 2);
691        assert_eq!(out[0]["col_0"], json!("Income total"));
692        assert_eq!(out[1]["col_0"], json!("Sales"));
693    }
694
695    #[test]
696    fn drop_empty_skips_all_empty_leaves() {
697        let mut s = spec();
698        s.drop_empty = true;
699        s.columns.header = None;
700        s.ancestors = None;
701        s.root = None;
702        s.children = "children".to_owned();
703        let report = json!({ "ColData": [ {"value": ""}, {"value": null} ] });
704        let out = s.compile().unwrap().apply(report);
705        assert!(out.is_empty(), "an all-empty leaf is dropped");
706    }
707
708    #[test]
709    fn max_depth_guard_truncates_without_panicking() {
710        // Build a chain deeper than max_depth.
711        let mut node = json!({ "ColData": [ {"value": "leaf"} ] });
712        for _ in 0..10 {
713            node = json!({ "Header": {"ColData":[{"value":"g"}]}, "children": [node] });
714        }
715        let mut s = spec();
716        s.root = None;
717        s.children = "children".to_owned();
718        s.columns.header = None;
719        s.ancestors = None;
720        s.max_depth = 3;
721        let out = s.compile().unwrap().apply(node);
722        // Truncated: the deep leaf is never reached, no panic.
723        assert!(out.is_empty());
724    }
725
726    #[test]
727    fn empty_report_yields_nothing() {
728        let mut s = spec();
729        let out = s
730            .clone()
731            .compile()
732            .unwrap()
733            .apply(json!({ "Rows": { "Row": [] } }));
734        assert!(out.is_empty());
735        // Non-object passes through.
736        s.root = None;
737        let passed = s.compile().unwrap().apply(json!("scalar"));
738        assert_eq!(passed, vec![json!("scalar")]);
739    }
740
741    #[test]
742    fn compile_rejects_bad_config() {
743        let mut s = spec();
744        s.children = " ".to_owned();
745        assert!(s.compile().is_err());
746        let mut s = spec();
747        s.columns.from = "".to_owned();
748        assert!(s.compile().is_err());
749        let mut s = spec();
750        s.leaf = "bogus".to_owned();
751        assert!(s.compile().is_err());
752        let mut s = spec();
753        s.leaf = "has_field:".to_owned();
754        assert!(s.compile().is_err());
755        let mut s = spec();
756        s.max_depth = 0;
757        assert!(s.compile().is_err());
758    }
759
760    #[test]
761    fn path_get_supports_dots_and_indices() {
762        let v = json!({ "Header": { "ColData": [ {"value": "hi"} ] } });
763        assert_eq!(path_get(&v, "Header.ColData[0].value"), Some(&json!("hi")));
764        assert_eq!(
765            path_get(&v, "$.Header.ColData[0].value"),
766            Some(&json!("hi"))
767        );
768        assert_eq!(path_get(&v, "Header.missing"), None);
769        assert_eq!(path_get(&v, "Header.ColData[9].value"), None);
770    }
771
772    #[test]
773    fn into_stage_produces_a_custom_stage() {
774        let stage = spec().into_stage().unwrap();
775        assert!(matches!(stage, TransformStage::Custom(_)));
776    }
777
778    /// #746: a leaf carrying two positional cell arrays, each named by its own
779    /// header list.
780    fn grouped() -> TreeFlattenSpec {
781        serde_json::from_value(json!({
782            "root": "rows",
783            "children": "rows",
784            "groups": [
785                {"from": "dims", "header": "dimHeaders", "header_label": "name"},
786                {"from": "mets", "header": "metHeaders", "header_label": "name"}
787            ],
788            "ancestors": {"field": "label", "as": ["section"]}
789        }))
790        .unwrap()
791    }
792
793    fn grouped_report() -> Value {
794        json!({
795            "dimHeaders": [{"name": "date"}, {"name": "country"}],
796            "metHeaders": [{"name": "sessions"}],
797            "rows": [
798                {"label": "Web", "rows": [
799                    {"dims": [{"value": "20260901"}, {"value": "DE"}], "mets": [{"value": "12"}]},
800                    {"dims": [{"value": "20260902"}, {}], "mets": [{"value": "7"}]}
801                ]}
802            ]
803        })
804    }
805
806    #[test]
807    fn groups_merge_into_one_row_per_leaf() {
808        let c = grouped().compile().unwrap();
809        let out = c.try_apply(grouped_report()).unwrap();
810        assert_eq!(
811            out,
812            vec![
813                json!({"section": "Web", "date": "20260901", "country": "DE", "sessions": "12"}),
814                json!({"section": "Web", "date": "20260902", "country": {}, "sessions": "7"}),
815            ]
816        );
817        assert_eq!(c.apply(grouped_report()), out);
818    }
819
820    #[test]
821    fn groups_fail_loudly_and_apply_passes_through() {
822        let c = grouped().compile().unwrap();
823        let err = |r: Value| c.try_apply(r).unwrap_err().to_string();
824
825        let mut r = grouped_report();
826        r["rows"][0]["rows"][1]["mets"] = json!([]);
827        let e = err(r.clone());
828        assert!(
829            e.contains("row 1, group 'mets': 0 cell(s) but 1 header(s)"),
830            "{e}"
831        );
832        assert_eq!(c.apply(r.clone()), vec![r]);
833
834        let mut r = grouped_report();
835        r["rows"][0]["rows"][0]
836            .as_object_mut()
837            .unwrap()
838            .remove("dims");
839        assert!(err(r).contains("row 0 has no `dims` array"));
840
841        let mut r = grouped_report();
842        r["metHeaders"][0]["name"] = json!("date");
843        let e = err(r);
844        assert!(
845            e.contains("duplicate column 'date'") && e.contains("'dims' and 'mets'"),
846            "{e}"
847        );
848
849        let mut r = grouped_report();
850        r["dimHeaders"][1]["name"] = json!("date");
851        assert!(err(r).contains("group 'dims' names it twice"));
852
853        let mut r = grouped_report();
854        r["metHeaders"] = json!("sessions");
855        assert!(err(r).contains("group 'mets': header `metHeaders` is not an array"));
856
857        // Non-objects and a missing root still pass through.
858        assert_eq!(c.try_apply(json!(7)).unwrap(), vec![json!(7)]);
859        let bare = json!({"dimHeaders": [], "metHeaders": []});
860        assert_eq!(c.try_apply(bare.clone()).unwrap(), vec![bare]);
861    }
862
863    #[test]
864    fn groups_compile_validation_and_stage() {
865        let bad = |v: Value| {
866            serde_json::from_value::<TreeFlattenSpec>(v)
867                .unwrap()
868                .compile()
869                .unwrap_err()
870                .to_string()
871        };
872        let g = json!({"from": "a", "header": "h"});
873        assert!(bad(json!({"children": "rows"})).contains("`columns.from`"));
874        assert!(
875            bad(json!({"children": "rows", "columns": {"from": "c"}, "groups": [g]}))
876                .contains("exactly one")
877        );
878        assert!(bad(json!({"children": "rows", "groups": [{"from": "a"}]})).contains("group 0"));
879        assert!(
880            bad(json!({"children": "rows", "groups": [{"from": "a", "header": " "}]}))
881                .contains("group 0")
882        );
883        let s = grouped();
884        let back: TreeFlattenSpec =
885            serde_json::from_value(serde_json::to_value(&s).unwrap()).unwrap();
886        assert_eq!(back, s);
887        assert!(serde_json::to_value(&s).unwrap().get("columns").is_none());
888
889        match s.into_stage().unwrap() {
890            TransformStage::PageFn(f) => {
891                assert_eq!(f(vec![grouped_report()]).unwrap().len(), 2);
892                let mut r = grouped_report();
893                r["rows"][0]["rows"][0]["mets"] = json!([]);
894                assert!(f(vec![r]).is_err());
895            }
896            other => panic!("expected PageFn, got {other:?}"),
897        }
898    }
899
900    #[test]
901    fn group_rows_count_any_groups_cells() {
902        let mut s = grouped();
903        s.emit_group_rows = true;
904        let c = s.compile().unwrap();
905        let mut r = grouped_report();
906        r["rows"][0]["dims"] = json!([{"value": "total"}, {"value": "*"}]);
907        r["rows"][0]["mets"] = json!([{"value": "19"}]);
908        let out = c.try_apply(r).unwrap();
909        assert_eq!(out.len(), 3);
910        assert_eq!(out[0]["sessions"], json!("19"));
911    }
912}