taxa-core 0.1.0

taxa engine core: manifest model, formula AST→Polars Expr, bounded query generators over Polars.
//! Variable-depth path axis: a single delimited column (`a/b/c.txt`) splits into
//! a variable number of hierarchy components. Directories aggregate every entity
//! beneath them; files are leaves at their own depth. The fixed-level engine is
//! reused via derived `__lvl*` columns, with two path-only corrections:
//! null-drop at the new level, and per-node `has_more`.

use polars::prelude::*;
use serde_json::{json, Map, Value as Json};
use taxa_core::treemap::treemap;
use taxa_core::{FrameDataset, FrameSource, TreeNode, TreemapArgs};

fn frame() -> FrameSource {
    let df = df![
        "path" => ["a/b/c.txt", "a/b/d.txt", "a/e.txt", "f/g.txt"],
        "bytes" => [10i64, 20, 5, 7],
    ]
    .unwrap();
    FrameSource::new(df)
}

fn ds() -> FrameDataset {
    serde_json::from_value(json!({
        "source": "fs", "id_column": "path", "label_column": "path",
        "axes": [{"id": "tree", "path": {"column": "path", "sep": "/"}}],
        "metrics": [{"id": "bytes", "agg": "sum", "column": "bytes", "unit": "count"}],
        "default_axis": "tree", "default_size_by": "bytes"
    }))
    .unwrap()
}

fn m(node: &TreeNode, key: &str) -> f64 {
    node.measures.get(key).and_then(Json::as_f64).unwrap()
}
fn child<'a>(node: &'a TreeNode, name: &str) -> &'a TreeNode {
    node.children
        .iter()
        .find(|c| c.name == name)
        .unwrap_or_else(|| panic!("no child {name:?}"))
}
fn names(node: &TreeNode) -> Vec<String> {
    let mut v: Vec<String> = node.children.iter().map(|c| c.name.clone()).collect();
    v.sort();
    v
}

fn run(focus: &[&str], depth: i64) -> TreeNode {
    let mut a = TreemapArgs::new("tree");
    a.focus = focus.iter().map(|s| json!(s)).collect();
    a.depth = depth;
    a.top_k = 50;
    a.filters = Map::new();
    treemap(&ds(), &frame(), &a).unwrap()
}

#[test]
fn path_axis_directories_aggregate_and_files_are_leaves() {
    // Two full levels from the root.
    let t = run(&[], 2);

    // Level 1: directories `a` (10+20+5) and `f` (7).
    assert_eq!(names(&t), ["a", "f"]);
    assert_eq!(m(child(&t, "a"), "bytes"), 35.0);
    assert_eq!(m(child(&t, "f"), "bytes"), 7.0);

    // Under `a`: dir `b` (10+20, has_more) and file `e.txt` (5, leaf).
    let a = child(&t, "a");
    assert_eq!(names(a), ["b", "e.txt"]);
    let b = child(a, "b");
    assert_eq!(m(b, "bytes"), 30.0);
    assert!(b.has_more, "directory `b` has deeper components");
    let e = child(a, "e.txt");
    assert_eq!(m(e, "bytes"), 5.0);
    assert!(!e.has_more, "file `e.txt` is a leaf at depth 2");
    assert!(e.children.is_empty(), "file `e.txt` has no children");

    // `f` ends at depth 2 (`f/g.txt`) → its child `g.txt` is a leaf.
    let f = child(&t, "f");
    assert_eq!(names(f), ["g.txt"]);
    let g = child(f, "g.txt");
    assert_eq!(m(g, "bytes"), 7.0);
    assert!(!g.has_more);
}

#[test]
fn path_axis_descends_to_files_at_deeper_levels() {
    // Three levels: reach the files under `a/b`.
    let t = run(&[], 3);
    let b = child(child(&t, "a"), "b");
    assert_eq!(names(b), ["c.txt", "d.txt"]);
    let c = child(b, "c.txt");
    let d = child(b, "d.txt");
    assert_eq!(m(c, "bytes"), 10.0);
    assert_eq!(m(d, "bytes"), 20.0);
    assert!(
        !c.has_more && !d.has_more,
        "files are leaves at their own depth"
    );
    assert!(c.children.is_empty() && d.children.is_empty());
}

#[test]
fn path_axis_focus_zoom() {
    // Zoom into directory `a`: its immediate children.
    let t = run(&["a"], 1);
    assert_eq!(m(&t, "bytes"), 35.0);
    assert_eq!(names(&t), ["b", "e.txt"]);
    assert!(child(&t, "b").has_more);
    assert!(!child(&t, "e.txt").has_more);
}