Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/tree_plus_cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
11 changes: 11 additions & 0 deletions crates/tree_plus_cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand Down Expand Up @@ -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));

Expand Down
39 changes: 39 additions & 0 deletions crates/tree_plus_cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
39 changes: 39 additions & 0 deletions crates/tree_plus_core/src/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,6 +84,33 @@ impl TreePlus {
self.token_count + self.subtrees.iter().map(TreePlus::n_tokens).sum::<u64>()
}

/// 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::<Vec<_>>()
.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!(
Expand Down
7 changes: 7 additions & 0 deletions docs/rust-port-differences.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <paths>` 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).
Loading