From 004787ba7210dda2588e4927a711dddb36427073 Mon Sep 17 00:00:00 2001 From: Bion Howard Date: Tue, 9 Jun 2026 19:02:58 -0400 Subject: [PATCH] feat: --json flag for machine-readable output Adds TreePlus::to_json() (nested objects with category, name, aggregated counts, components on file nodes, subtrees on containers) and a -j/--json CLI flag that prints the JSON document instead of the rendered tree and footer. Requested in issue #7; makes tprs scriptable in pipelines (jq, LLM tooling) without parsing the unicode render. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 1 + crates/tree_plus_cli/Cargo.toml | 1 + crates/tree_plus_cli/src/main.rs | 11 +++++++++ crates/tree_plus_cli/tests/cli.rs | 39 ++++++++++++++++++++++++++++++ crates/tree_plus_core/src/model.rs | 39 ++++++++++++++++++++++++++++++ docs/rust-port-differences.md | 7 ++++++ 6 files changed, 98 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 700cc68..b4426e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -918,6 +918,7 @@ name = "tree_plus_cli" version = "2.0.0-alpha.1" dependencies = [ "clap", + "serde_json", "terminal_size", "tree_plus_core", ] diff --git a/crates/tree_plus_cli/Cargo.toml b/crates/tree_plus_cli/Cargo.toml index 44b5c50..1a592ec 100644 --- a/crates/tree_plus_cli/Cargo.toml +++ b/crates/tree_plus_cli/Cargo.toml @@ -19,3 +19,4 @@ path = "src/main.rs" tree_plus_core = { path = "../tree_plus_core" } clap.workspace = true terminal_size = "0.4" +serde_json = { workspace = true } diff --git a/crates/tree_plus_cli/src/main.rs b/crates/tree_plus_cli/src/main.rs index f0f5af5..a8677da 100644 --- a/crates/tree_plus_cli/src/main.rs +++ b/crates/tree_plus_cli/src/main.rs @@ -50,6 +50,11 @@ struct Cli { #[arg(short = 'c', long = "concise")] concise: bool, + /// Emit the tree as JSON instead of rendering it (machine-readable; + /// no footer). File nodes carry components; counts are aggregated. + #[arg(short = 'j', long = "json")] + json: bool, + /// A shorthand for tiktoken with the 'gpt-4o' tokenizer (unsupported in /// the Rust port: errors explicitly). #[arg(short = 't', long = "tiktoken")] @@ -112,6 +117,12 @@ fn main() { let root = from_seeds(&cli.paths, &config); + if cli.json { + let json = serde_json::to_string_pretty(&root.to_json()).expect("serialize tree"); + println!("{json}"); + return; + } + let width = terminal_width(); print!("{}", render_to_string(&root, width)); diff --git a/crates/tree_plus_cli/tests/cli.rs b/crates/tree_plus_cli/tests/cli.rs index 50ecc6e..f8da4a3 100644 --- a/crates/tree_plus_cli/tests/cli.rs +++ b/crates/tree_plus_cli/tests/cli.rs @@ -90,3 +90,42 @@ fn renders_this_repository() { assert!(stdout.contains("📁 tree_plus (")); assert!(stdout.contains("README.md")); } + +#[test] +fn json_flag_emits_machine_readable_tree() { + let out = tree_plus(&["--json", "tests/path_to_test"]); + assert!(out.status.success()); + let stdout = String::from_utf8_lossy(&out.stdout); + let value: serde_json::Value = serde_json::from_str(&stdout).expect("valid JSON"); + assert_eq!(value["category"], "folder"); + assert_eq!(value["name"], "path_to_test"); + assert_eq!(value["n_files"], 6); + // no footer noise after the JSON document + assert!(!stdout.contains("second(s).")); + // a file node carries its components + let files = value["subtrees"].as_array().expect("subtrees"); + let py = files + .iter() + .find(|f| f["name"] == "file.py") + .expect("file.py node"); + assert_eq!(py["category"], "file"); + assert_eq!( + py["components"].as_array().expect("components")[0], + "def hello_world()" + ); + assert!(py.get("subtrees").is_none()); +} + +#[test] +fn json_flag_respects_concise() { + let out = tree_plus(&["--json", "--concise", "tests/path_to_test"]); + assert!(out.status.success()); + let value: serde_json::Value = + serde_json::from_str(&String::from_utf8_lossy(&out.stdout)).expect("valid JSON"); + let files = value["subtrees"].as_array().expect("subtrees"); + let py = files + .iter() + .find(|f| f["name"] == "file.py") + .expect("file.py"); + assert_eq!(py["components"].as_array().expect("components").len(), 0); +} diff --git a/crates/tree_plus_core/src/model.rs b/crates/tree_plus_core/src/model.rs index 2e4c10c..4e79188 100644 --- a/crates/tree_plus_core/src/model.rs +++ b/crates/tree_plus_core/src/model.rs @@ -14,6 +14,18 @@ pub enum Category { Component, } +impl Category { + pub fn as_str(self) -> &'static str { + match self { + Category::Root => "root", + Category::Glob => "glob", + Category::Folder => "folder", + Category::File => "file", + Category::Component => "component", + } + } +} + /// A node in the tree_plus tree. /// /// `components` holds the extracted display labels for FILE nodes @@ -72,6 +84,33 @@ impl TreePlus { self.token_count + self.subtrees.iter().map(TreePlus::n_tokens).sum::() } + /// Structured representation for `--json` output. File nodes carry + /// their own counts and components; folder/root counts are the + /// aggregates so consumers don't have to re-derive them. + pub fn to_json(&self) -> serde_json::Value { + let mut obj = serde_json::Map::new(); + obj.insert("category".into(), self.category.as_str().into()); + obj.insert("name".into(), self.name.clone().into()); + obj.insert("n_folders".into(), self.n_folders().into()); + obj.insert("n_files".into(), self.n_files().into()); + obj.insert("n_lines".into(), self.n_lines().into()); + obj.insert("n_tokens".into(), self.n_tokens().into()); + if self.is_file() { + obj.insert("components".into(), self.components.clone().into()); + } + if !self.is_file() { + obj.insert( + "subtrees".into(), + self.subtrees + .iter() + .map(TreePlus::to_json) + .collect::>() + .into(), + ); + } + serde_json::Value::Object(obj) + } + /// Legacy `stats()` string, e.g. `1 folder(s), 6 file(s), 1,234 line(s), 5,678 token(s)`. pub fn stats(&self) -> String { format!( diff --git a/docs/rust-port-differences.md b/docs/rust-port-differences.md index eb7e7f9..c094445 100644 --- a/docs/rust-port-differences.md +++ b/docs/rust-port-differences.md @@ -76,3 +76,10 @@ cargo install --path crates/tree_plus_cli # both binaries in trees with correct counts and TODO/BUG/NOTE markers, but no language components. The legacy goldens for those languages are kept as the contract for future ports (`tests/golden/legacy/components/`). + +13. **`--json` flag (Rust-port addition).** `tprs --json ` emits the + tree as a JSON document instead of rendering it (no footer): nested + objects with `category`, `name`, aggregated `n_folders`/`n_files`/ + `n_lines`/`n_tokens`, `components` on file nodes, and `subtrees` on + container nodes. The legacy CLI had no structured output (it was a + TODO in issue #7).