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