1use crate::FaucetError;
18use crate::stage::TransformStage;
19use serde::{Deserialize, Serialize};
20use serde_json::{Map, Value};
21use std::sync::Arc;
22
23pub 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
42#[serde(deny_unknown_fields)]
43pub struct ColumnsSpec {
44 pub from: String,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub header: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub header_label: Option<String>,
55 #[serde(default = "default_value_field")]
58 pub value: String,
59}
60
61#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
63#[serde(deny_unknown_fields)]
64pub struct AncestorsSpec {
65 pub field: String,
68 #[serde(default, rename = "as")]
71 pub as_names: Vec<String>,
72}
73
74#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
78#[serde(deny_unknown_fields)]
79pub struct TreeFlattenSpec {
80 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub root: Option<String>,
84 pub children: String,
87 #[serde(default = "default_leaf")]
90 pub leaf: String,
91 pub columns: ColumnsSpec,
93 #[serde(default, skip_serializing_if = "Option::is_none")]
95 pub ancestors: Option<AncestorsSpec>,
96 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub path_as: Option<String>,
99 #[serde(default = "default_path_sep")]
101 pub path_sep: String,
102 #[serde(default)]
104 pub drop_empty: bool,
105 #[serde(default)]
107 pub emit_group_rows: bool,
108 #[serde(default = "default_max_depth")]
110 pub max_depth: usize,
111}
112
113impl TreeFlattenSpec {
114 pub fn compile(&self) -> Result<CompiledTreeFlatten, FaucetError> {
116 CompiledTreeFlatten::compile(self)
117 }
118
119 pub fn into_stage(&self) -> Result<TransformStage, FaucetError> {
121 let compiled = self.compile()?;
122 Ok(TransformStage::Custom(Arc::new(move |rec| {
123 compiled.apply(rec)
124 })))
125 }
126}
127
128#[derive(Debug, Clone, PartialEq)]
129enum LeafMode {
130 NoChildren,
131 HasField(String),
132}
133
134#[derive(Debug, Clone)]
136pub struct CompiledTreeFlatten {
137 spec: TreeFlattenSpec,
138 leaf_mode: LeafMode,
139}
140
141impl CompiledTreeFlatten {
142 fn compile(spec: &TreeFlattenSpec) -> Result<Self, FaucetError> {
143 if spec.children.trim().is_empty() {
144 return Err(FaucetError::Transform(
145 "tree_flatten: `children` must be non-empty".to_owned(),
146 ));
147 }
148 if spec.columns.from.trim().is_empty() {
149 return Err(FaucetError::Transform(
150 "tree_flatten: `columns.from` must be non-empty".to_owned(),
151 ));
152 }
153 if spec.max_depth == 0 {
154 return Err(FaucetError::Transform(
155 "tree_flatten: `max_depth` must be greater than zero".to_owned(),
156 ));
157 }
158 let leaf_mode = if spec.leaf == "has_no_children" {
159 LeafMode::NoChildren
160 } else if let Some(field) = spec.leaf.strip_prefix("has_field:") {
161 if field.trim().is_empty() {
162 return Err(FaucetError::Transform(
163 "tree_flatten: `leaf: has_field:<name>` requires a field name".to_owned(),
164 ));
165 }
166 LeafMode::HasField(field.to_owned())
167 } else {
168 return Err(FaucetError::Transform(format!(
169 "tree_flatten: `leaf` must be `has_no_children` or `has_field:<name>`, got '{}'",
170 spec.leaf
171 )));
172 };
173 Ok(Self {
174 spec: spec.clone(),
175 leaf_mode,
176 })
177 }
178
179 pub fn apply(&self, rec: Value) -> Vec<Value> {
183 if !rec.is_object() {
184 return vec![rec];
185 }
186 let header_labels: Vec<String> = self
188 .spec
189 .columns
190 .header
191 .as_deref()
192 .and_then(|h| path_get(&rec, h))
193 .and_then(Value::as_array)
194 .map(|arr| {
195 arr.iter()
196 .map(|el| self.header_label(el))
197 .collect::<Vec<_>>()
198 })
199 .unwrap_or_default();
200
201 let roots: Vec<&Value> = match &self.spec.root {
205 Some(path) => match path_get(&rec, path) {
206 Some(Value::Array(a)) => a.iter().collect(),
207 Some(v) => vec![v],
208 None => return vec![rec],
209 },
210 None => vec![&rec],
211 };
212
213 let mut out: Vec<Value> = Vec::new();
214 let mut ancestors: Vec<Value> = Vec::new();
215 let mut depth_exceeded = false;
216 for node in roots {
217 self.walk(
218 node,
219 &mut ancestors,
220 0,
221 &header_labels,
222 &mut out,
223 &mut depth_exceeded,
224 );
225 }
226 out
227 }
228
229 fn header_label(&self, el: &Value) -> String {
230 if let Some(field) = &self.spec.columns.header_label
231 && let Some(v) = path_get(el, field)
232 {
233 return scalar_string(v);
234 }
235 scalar_string(el)
236 }
237
238 fn is_leaf(&self, node: &Value, has_children: bool) -> bool {
239 match &self.leaf_mode {
240 LeafMode::NoChildren => !has_children,
241 LeafMode::HasField(f) => node.get(f).is_some(),
242 }
243 }
244
245 #[allow(clippy::too_many_arguments)]
246 fn walk(
247 &self,
248 node: &Value,
249 ancestors: &mut Vec<Value>,
250 depth: usize,
251 header_labels: &[String],
252 out: &mut Vec<Value>,
253 depth_exceeded: &mut bool,
254 ) {
255 if depth >= self.spec.max_depth {
256 if !*depth_exceeded {
257 *depth_exceeded = true;
258 tracing::error!(
259 max_depth = self.spec.max_depth,
260 "tree_flatten: max_depth exceeded — branch truncated (malformed or cyclic tree?)"
261 );
262 }
263 return;
264 }
265 let children = path_get(node, &self.spec.children).and_then(Value::as_array);
266 let has_children = children.is_some_and(|c| !c.is_empty());
267 let leaf = self.is_leaf(node, has_children);
268
269 if (leaf || (self.spec.emit_group_rows && node_has_cells(node, &self.spec.columns.from)))
270 && let Some(row) = self.emit_row(node, ancestors, header_labels)
271 {
272 out.push(row);
273 }
274
275 if has_children {
276 let label = self
278 .spec
279 .ancestors
280 .as_ref()
281 .and_then(|a| path_get(node, &a.field).cloned())
282 .unwrap_or(Value::Null);
283 ancestors.push(label);
284 for child in children.unwrap() {
285 self.walk(
286 child,
287 ancestors,
288 depth + 1,
289 header_labels,
290 out,
291 depth_exceeded,
292 );
293 }
294 ancestors.pop();
295 }
296 }
297
298 fn emit_row(
299 &self,
300 node: &Value,
301 ancestors: &[Value],
302 header_labels: &[String],
303 ) -> Option<Value> {
304 let mut row = Map::new();
305
306 if let Some(anc) = &self.spec.ancestors {
308 for (i, label) in ancestors.iter().enumerate() {
309 let name = anc
310 .as_names
311 .get(i)
312 .cloned()
313 .unwrap_or_else(|| format!("ancestor_{}", i + 1));
314 row.insert(name, label.clone());
315 }
316 }
317 if let Some(path_col) = &self.spec.path_as {
319 let joined = ancestors
320 .iter()
321 .map(scalar_string)
322 .collect::<Vec<_>>()
323 .join(&self.spec.path_sep);
324 row.insert(path_col.clone(), Value::String(joined));
325 }
326
327 let cells = path_get(node, &self.spec.columns.from).and_then(Value::as_array);
329 let mut all_empty = true;
330 if let Some(cells) = cells {
331 for (i, cell) in cells.iter().enumerate() {
332 let value = path_get(cell, &self.spec.columns.value)
333 .cloned()
334 .unwrap_or_else(|| cell.clone());
335 if !is_empty_value(&value) {
336 all_empty = false;
337 }
338 let name = header_labels
339 .get(i)
340 .cloned()
341 .filter(|s| !s.is_empty())
342 .unwrap_or_else(|| format!("col_{i}"));
343 row.insert(name, value);
344 }
345 }
346
347 if self.spec.drop_empty && all_empty {
348 return None;
349 }
350 Some(Value::Object(row))
351 }
352}
353
354fn node_has_cells(node: &Value, from: &str) -> bool {
355 path_get(node, from)
356 .and_then(Value::as_array)
357 .is_some_and(|a| !a.is_empty())
358}
359
360fn is_empty_value(v: &Value) -> bool {
361 match v {
362 Value::Null => true,
363 Value::String(s) => s.is_empty(),
364 _ => false,
365 }
366}
367
368fn scalar_string(v: &Value) -> String {
370 match v {
371 Value::String(s) => s.clone(),
372 Value::Null => String::new(),
373 Value::Bool(b) => b.to_string(),
374 Value::Number(n) => n.to_string(),
375 other => other.to_string(),
376 }
377}
378
379fn path_get<'a>(root: &'a Value, path: &str) -> Option<&'a Value> {
384 let mut cur = root;
385 let mut rest = path.trim();
386 rest = rest.strip_prefix('$').unwrap_or(rest);
387 rest = rest.strip_prefix('.').unwrap_or(rest);
388 while !rest.is_empty() {
389 if let Some(after) = rest.strip_prefix('[') {
390 let close = after.find(']')?;
392 let idx: usize = after[..close].trim().parse().ok()?;
393 cur = cur.as_array()?.get(idx)?;
394 rest = &after[close + 1..];
395 rest = rest.strip_prefix('.').unwrap_or(rest);
396 } else {
397 let end = rest.find(['.', '[']).unwrap_or(rest.len());
399 let key = &rest[..end];
400 if key.is_empty() {
401 return None;
402 }
403 cur = cur.get(key)?;
404 rest = &rest[end..];
405 rest = rest.strip_prefix('.').unwrap_or(rest);
406 }
407 }
408 Some(cur)
409}
410
411#[cfg(test)]
412mod tests {
413 use super::*;
414 use serde_json::json;
415
416 fn spec() -> TreeFlattenSpec {
417 TreeFlattenSpec {
418 root: Some("Rows.Row".to_owned()),
419 children: "Rows.Row".to_owned(),
420 leaf: "has_no_children".to_owned(),
421 columns: ColumnsSpec {
422 from: "ColData".to_owned(),
423 header: Some("Columns.Column".to_owned()),
424 header_label: Some("ColTitle".to_owned()),
425 value: "value".to_owned(),
426 },
427 ancestors: Some(AncestorsSpec {
428 field: "Header.ColData[0].value".to_owned(),
429 as_names: vec!["section".to_owned(), "subsection".to_owned()],
430 }),
431 path_as: Some("group_path".to_owned()),
432 path_sep: " > ".to_owned(),
433 drop_empty: false,
434 emit_group_rows: false,
435 max_depth: DEFAULT_MAX_DEPTH,
436 }
437 }
438
439 fn quickbooks_report() -> Value {
442 json!({
443 "Columns": { "Column": [ {"ColTitle": ""}, {"ColTitle": "Jan 2024"}, {"ColTitle": "Feb 2024"} ] },
444 "Rows": { "Row": [
445 {
446 "Header": { "ColData": [ {"value": "Income"} ] },
447 "Rows": { "Row": [
448 { "ColData": [ {"value": "Sales"}, {"value": "100"}, {"value": "120"} ] },
449 { "ColData": [ {"value": "Services"}, {"value": "50"}, {"value": "60"} ] }
450 ] }
451 }
452 ] }
453 })
454 }
455
456 #[test]
457 fn flattens_quickbooks_report_to_leaf_rows() {
458 let out = spec().compile().unwrap().apply(quickbooks_report());
459 assert_eq!(out.len(), 2);
460 assert_eq!(out[0]["section"], json!("Income"));
461 assert_eq!(out[0]["group_path"], json!("Income"));
462 assert_eq!(out[0]["col_0"], json!("Sales"));
466 assert_eq!(out[0]["Jan 2024"], json!("100"));
467 assert_eq!(out[0]["Feb 2024"], json!("120"));
468 assert_eq!(out[1]["col_0"], json!("Services"));
469 assert_eq!(out[1]["Feb 2024"], json!("60"));
470 }
471
472 #[test]
473 fn uneven_depth_names_extra_levels_and_leaves_missing_null() {
474 let report = json!({
476 "Rows": { "Row": [
477 {
478 "Header": { "ColData": [ {"value": "Income"} ] },
479 "Rows": { "Row": [
480 {
481 "Header": { "ColData": [ {"value": "Domestic"} ] },
482 "Rows": { "Row": [
483 { "ColData": [ {"value": "Sales"}, {"value": "100"} ] }
484 ] }
485 }
486 ] }
487 },
488 { "ColData": [ {"value": "Other"}, {"value": "5"} ] }
489 ] }
490 });
491 let mut s = spec();
492 s.columns.header = None;
493 let out = s.compile().unwrap().apply(report);
494 assert_eq!(out.len(), 2);
495 assert_eq!(out[0]["section"], json!("Income"));
497 assert_eq!(out[0]["subsection"], json!("Domestic"));
498 assert_eq!(out[0]["col_0"], json!("Sales"));
499 assert!(out[1].get("section").is_none());
501 assert_eq!(out[1]["col_0"], json!("Other"));
502 }
503
504 #[test]
505 fn header_cell_length_mismatch_zips_to_shorter() {
506 let mut s = spec();
507 s.ancestors = None;
508 s.root = None;
509 s.children = "children".to_owned();
510 let report = json!({
511 "Columns": { "Column": [ {"ColTitle": "A"}, {"ColTitle": "B"} ] },
512 "ColData": [ {"value": "x"}, {"value": "y"}, {"value": "z"} ]
513 });
514 let out = s.compile().unwrap().apply(report);
515 assert_eq!(out.len(), 1);
516 assert_eq!(out[0]["A"], json!("x"));
517 assert_eq!(out[0]["B"], json!("y"));
518 assert_eq!(out[0]["col_2"], json!("z"));
520 }
521
522 #[test]
523 fn leaf_has_field_mode() {
524 let mut s = spec();
525 s.leaf = "has_field:ColData".to_owned();
526 s.emit_group_rows = false;
527 let report = json!({
529 "Rows": { "Row": [
530 {
531 "Header": { "ColData": [ {"value": "Total"} ] },
532 "ColData": [ {"value": "Total"}, {"value": "9"} ],
533 "Rows": { "Row": [ { "ColData": [ {"value": "x"}, {"value": "1"} ] } ] }
534 }
535 ] }
536 });
537 s.columns.header = None;
538 let out = s.compile().unwrap().apply(report);
539 assert_eq!(out.len(), 2);
541 assert_eq!(out[0]["col_0"], json!("Total"));
542 }
543
544 #[test]
545 fn emit_group_rows_includes_subtotals() {
546 let mut s = spec();
547 s.emit_group_rows = true;
548 s.columns.header = None;
549 let report = json!({
550 "Rows": { "Row": [
551 {
552 "Header": { "ColData": [ {"value": "Income"} ] },
553 "ColData": [ {"value": "Income total"}, {"value": "150"} ],
554 "Rows": { "Row": [
555 { "ColData": [ {"value": "Sales"}, {"value": "100"} ] }
556 ] }
557 }
558 ] }
559 });
560 let out = s.compile().unwrap().apply(report);
561 assert_eq!(out.len(), 2);
563 assert_eq!(out[0]["col_0"], json!("Income total"));
564 assert_eq!(out[1]["col_0"], json!("Sales"));
565 }
566
567 #[test]
568 fn drop_empty_skips_all_empty_leaves() {
569 let mut s = spec();
570 s.drop_empty = true;
571 s.columns.header = None;
572 s.ancestors = None;
573 s.root = None;
574 s.children = "children".to_owned();
575 let report = json!({ "ColData": [ {"value": ""}, {"value": null} ] });
576 let out = s.compile().unwrap().apply(report);
577 assert!(out.is_empty(), "an all-empty leaf is dropped");
578 }
579
580 #[test]
581 fn max_depth_guard_truncates_without_panicking() {
582 let mut node = json!({ "ColData": [ {"value": "leaf"} ] });
584 for _ in 0..10 {
585 node = json!({ "Header": {"ColData":[{"value":"g"}]}, "children": [node] });
586 }
587 let mut s = spec();
588 s.root = None;
589 s.children = "children".to_owned();
590 s.columns.header = None;
591 s.ancestors = None;
592 s.max_depth = 3;
593 let out = s.compile().unwrap().apply(node);
594 assert!(out.is_empty());
596 }
597
598 #[test]
599 fn empty_report_yields_nothing() {
600 let mut s = spec();
601 let out = s
602 .clone()
603 .compile()
604 .unwrap()
605 .apply(json!({ "Rows": { "Row": [] } }));
606 assert!(out.is_empty());
607 s.root = None;
609 let passed = s.compile().unwrap().apply(json!("scalar"));
610 assert_eq!(passed, vec![json!("scalar")]);
611 }
612
613 #[test]
614 fn compile_rejects_bad_config() {
615 let mut s = spec();
616 s.children = " ".to_owned();
617 assert!(s.compile().is_err());
618 let mut s = spec();
619 s.columns.from = "".to_owned();
620 assert!(s.compile().is_err());
621 let mut s = spec();
622 s.leaf = "bogus".to_owned();
623 assert!(s.compile().is_err());
624 let mut s = spec();
625 s.leaf = "has_field:".to_owned();
626 assert!(s.compile().is_err());
627 let mut s = spec();
628 s.max_depth = 0;
629 assert!(s.compile().is_err());
630 }
631
632 #[test]
633 fn path_get_supports_dots_and_indices() {
634 let v = json!({ "Header": { "ColData": [ {"value": "hi"} ] } });
635 assert_eq!(path_get(&v, "Header.ColData[0].value"), Some(&json!("hi")));
636 assert_eq!(
637 path_get(&v, "$.Header.ColData[0].value"),
638 Some(&json!("hi"))
639 );
640 assert_eq!(path_get(&v, "Header.missing"), None);
641 assert_eq!(path_get(&v, "Header.ColData[9].value"), None);
642 }
643
644 #[test]
645 fn into_stage_produces_a_custom_stage() {
646 let stage = spec().into_stage().unwrap();
647 assert!(matches!(stage, TransformStage::Custom(_)));
648 }
649}