use clap::{ArgAction, Parser, Subcommand, ValueEnum};
use std::io::{self, Read};
#[cfg(any(
feature = "arbitrary-walkdir",
feature = "arbitrary-cargo",
feature = "arbitrary-git2",
feature = "arbitrary-syn"
))]
use std::path::PathBuf;
#[derive(Clone, Debug, ValueEnum)]
pub enum SortMethod {
Label,
Depth,
}
#[derive(Clone, Debug, ValueEnum)]
pub enum OutputFormat {
Text,
#[cfg(feature = "serde-json")]
Json,
#[cfg(feature = "serde-yaml")]
Yaml,
#[cfg(feature = "serde-toml")]
Toml,
#[cfg(feature = "serde-ron")]
Ron,
#[cfg(feature = "export")]
Html,
#[cfg(feature = "export")]
Svg,
#[cfg(feature = "export")]
Dot,
}
#[derive(Parser)]
#[command(name = "treelog")]
#[command(about = "A customizable tree rendering library for Rust")]
#[command(version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(long, global = true, value_enum, default_value = "unicode")]
pub style: treelog::TreeStyle,
#[arg(long, global = true)]
pub custom_style: Option<String>,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub color: bool,
#[arg(long, global = true, action = ArgAction::SetTrue)]
pub no_color: bool,
#[arg(short, long, global = true)]
pub output: Option<String>,
#[arg(long, global = true, value_enum, default_value = "text")]
pub format: OutputFormat,
}
#[derive(Subcommand)]
pub enum Commands {
From {
#[command(subcommand)]
source: FromSource,
},
Render {
#[arg(default_value = "-")]
input: String,
},
Stats {
#[arg(default_value = "-")]
input: String,
},
Search {
pattern: String,
#[arg(default_value = "-")]
input: String,
},
#[cfg(feature = "transform")]
Transform {
#[command(subcommand)]
operation: TransformOp,
#[arg(default_value = "-")]
input: String,
},
Sort {
#[arg(value_enum, default_value = "label")]
method: SortMethod,
#[arg(short, long)]
reverse: bool,
#[arg(default_value = "-")]
input: String,
},
#[cfg(feature = "compare")]
Compare {
first: String,
second: String,
},
#[cfg(feature = "merge")]
Merge {
first: String,
second: String,
#[arg(long, value_enum, default_value = "append")]
strategy: treelog::merge::MergeStrategy,
},
#[cfg(feature = "export")]
Export {
#[command(subcommand)]
format: ExportFormat,
#[arg(default_value = "-")]
input: String,
},
}
#[derive(Subcommand)]
pub enum FromSource {
#[cfg(feature = "arbitrary-walkdir")]
Dir {
path: PathBuf,
#[arg(long)]
max_depth: Option<usize>,
},
#[cfg(feature = "arbitrary-cargo")]
Cargo {
#[arg(default_value = "Cargo.toml")]
manifest: PathBuf,
#[arg(long)]
package: Option<String>,
},
#[cfg(feature = "arbitrary-git2")]
Git {
#[arg(default_value = ".")]
path: PathBuf,
#[arg(long)]
branches: bool,
#[arg(long)]
commit: bool,
},
#[cfg(feature = "arbitrary-xml")]
Xml {
file: String,
},
#[cfg(feature = "arbitrary-syn")]
Rust {
file: PathBuf,
},
#[cfg(feature = "arbitrary-tree-sitter")]
TreeSitter {
file: String,
#[arg(long)]
language: Option<String>,
},
#[cfg(feature = "serde-json")]
Json {
file: String,
},
#[cfg(feature = "serde-yaml")]
Yaml {
file: String,
},
#[cfg(feature = "serde-toml")]
Toml {
file: String,
},
#[cfg(feature = "serde-ron")]
Ron {
file: String,
},
}
#[derive(Subcommand)]
#[cfg(feature = "transform")]
pub enum TransformOp {
MapNodes {
expr: String,
#[arg(default_value = "-")]
input: String,
},
MapLeaves {
expr: String,
#[arg(default_value = "-")]
input: String,
},
Filter {
pattern: String,
#[arg(default_value = "-")]
input: String,
},
Prune {
pattern: String,
#[arg(default_value = "-")]
input: String,
},
}
#[derive(Subcommand)]
#[cfg(feature = "export")]
pub enum ExportFormat {
Html,
Svg,
Dot,
}
fn main() {
let cli = Cli::parse();
let result = match &cli.command {
Commands::From { source } => handle_from(source, &cli),
Commands::Render { input } => handle_render(input, &cli),
Commands::Stats { input } => handle_stats(input),
Commands::Search { pattern, input } => handle_search(pattern, input),
#[cfg(feature = "transform")]
Commands::Transform { operation, input } => handle_transform(operation, input, &cli),
Commands::Sort {
method,
reverse,
input,
} => handle_sort(method, *reverse, input, &cli),
#[cfg(feature = "compare")]
Commands::Compare { first, second } => handle_compare(first, second),
#[cfg(feature = "merge")]
Commands::Merge {
strategy,
first,
second,
} => handle_merge(strategy, first, second, &cli),
#[cfg(feature = "export")]
Commands::Export { format, input } => handle_export(format, input),
};
if let Err(e) = result {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
#[allow(unreachable_code, unused_variables)]
fn handle_from(source: &FromSource, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
#[cfg(not(any(
feature = "arbitrary-walkdir",
feature = "arbitrary-cargo",
feature = "arbitrary-git2",
feature = "arbitrary-xml",
feature = "arbitrary-syn",
feature = "arbitrary-tree-sitter",
feature = "serde-json",
feature = "serde-yaml",
feature = "serde-toml",
feature = "serde-ron"
)))]
{
return Err("No input source features enabled. Enable at least one feature (arbitrary-walkdir, arbitrary-cargo, arbitrary-git2, arbitrary-xml, arbitrary-syn, arbitrary-tree-sitter, serde-json, serde-yaml, serde-toml, or serde-ron).".into());
}
#[allow(unreachable_code)]
let tree = match source {
#[cfg(feature = "arbitrary-walkdir")]
FromSource::Dir { path, max_depth } => {
if let Some(depth) = max_depth {
treelog::Tree::from_dir_max_depth(path, *depth)?
} else {
treelog::Tree::from_dir(path)?
}
}
#[cfg(feature = "arbitrary-cargo")]
FromSource::Cargo { manifest, package } => {
if let Some(pkg) = package {
treelog::Tree::from_cargo_package_deps(pkg, manifest)?
} else {
treelog::Tree::from_cargo_metadata(manifest)?
}
}
#[cfg(feature = "arbitrary-git2")]
FromSource::Git {
path,
branches,
commit,
} => {
use git2::Repository;
let repo = Repository::open(path)?;
if *branches {
treelog::Tree::from_git_branches(&repo)?
} else if *commit {
let head = repo.head()?.peel_to_commit()?;
treelog::Tree::from_git_commit_tree(&repo, &head)?
} else {
treelog::Tree::from_git_repo(path)?
}
}
#[cfg(feature = "arbitrary-xml")]
FromSource::Xml { file } => {
if file == "-" {
let mut content = String::new();
io::stdin().read_to_string(&mut content)?;
treelog::Tree::from_arbitrary_xml(&content)?
} else {
treelog::Tree::from_arbitrary_xml_file(file)?
}
}
#[cfg(feature = "arbitrary-syn")]
FromSource::Rust { file } => treelog::Tree::from_syn_file(file)?,
#[cfg(feature = "arbitrary-tree-sitter")]
FromSource::TreeSitter {
file: _file,
language: _language,
} => {
return Err("tree-sitter parsing requires language specification. This feature needs implementation.".into());
}
#[cfg(feature = "serde-json")]
FromSource::Json { file } => {
let content = read_file_or_stdin(file)?;
treelog::Tree::from_json(&content)?
}
#[cfg(feature = "serde-yaml")]
FromSource::Yaml { file } => {
let content = read_file_or_stdin(file)?;
treelog::Tree::from_yaml(&content)?
}
#[cfg(feature = "serde-toml")]
FromSource::Toml { file } => {
let content = read_file_or_stdin(file)?;
treelog::Tree::from_toml(&content)?
}
#[cfg(feature = "serde-ron")]
FromSource::Ron { file } => {
let content = read_file_or_stdin(file)?;
treelog::Tree::from_ron(&content)?
}
#[cfg(not(any(
feature = "arbitrary-walkdir",
feature = "arbitrary-cargo",
feature = "arbitrary-git2",
feature = "arbitrary-xml",
feature = "arbitrary-syn",
feature = "arbitrary-tree-sitter",
feature = "serde-json",
feature = "serde-yaml",
feature = "serde-toml",
feature = "serde-ron"
)))]
_ => {
return Err("No input source features enabled. Enable at least one feature (walkdir, cargo-metadata, git2, arbitrary-xml, syn, tree-sitter, serde-json, serde-yaml, serde-toml, or serde-ron).".into());
}
};
output_tree(&tree, cli)
}
fn handle_render(input: &str, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
let tree = read_tree(input)?;
output_tree(&tree, cli)
}
fn handle_stats(input: &str) -> Result<(), Box<dyn std::error::Error>> {
let tree = read_tree(input)?;
let stats = tree.stats();
println!("Tree Statistics:");
println!(" Depth: {}", stats.depth);
println!(" Width: {}", stats.width);
println!(" Node count: {}", stats.node_count);
println!(" Leaf count: {}", stats.leaf_count);
println!(" Total lines: {}", stats.total_lines);
Ok(())
}
fn handle_search(pattern: &str, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let tree = read_tree(input)?;
let matches = tree.find_all_nodes(pattern);
if matches.is_empty() {
println!("No nodes found matching '{}'", pattern);
} else {
println!("Found {} node(s) matching '{}':", matches.len(), pattern);
for (i, node) in matches.iter().enumerate() {
println!(" {}. {}", i + 1, node.label().unwrap_or("(no label)"));
}
}
Ok(())
}
#[allow(unused_variables)]
#[cfg(feature = "transform")]
fn handle_transform(
operation: &TransformOp,
input: &str,
cli: &Cli,
) -> Result<(), Box<dyn std::error::Error>> {
let tree = read_tree(input)?;
let transformed = match operation {
TransformOp::MapNodes { expr, .. } => tree.map_nodes(|label| expr.replace("{}", label)),
TransformOp::MapLeaves { expr, .. } => tree.map_leaves(|line| expr.replace("{}", line)),
TransformOp::Filter { pattern, .. } => {
if let Some(filtered) = tree.filter(|t| match t {
treelog::Tree::Node(label, _) => label.contains(pattern),
treelog::Tree::Leaf(lines) => lines.iter().any(|l| l.contains(pattern)),
}) {
filtered
} else {
return Err("Filter resulted in empty tree".into());
}
}
TransformOp::Prune { pattern, .. } => {
if let Some(pruned) = tree.prune(|t| match t {
treelog::Tree::Node(label, _) => label.contains(pattern),
treelog::Tree::Leaf(lines) => lines.iter().any(|l| l.contains(pattern)),
}) {
pruned
} else {
return Err("Prune resulted in empty tree".into());
}
}
};
output_tree(&transformed, cli)
}
fn handle_sort(
method: &SortMethod,
reverse: bool,
input: &str,
cli: &Cli,
) -> Result<(), Box<dyn std::error::Error>> {
let mut tree = read_tree(input)?;
match method {
SortMethod::Label => {
tree.sort_by_label();
if reverse {
tree.sort_children(&mut |a, b| b.label().cmp(&a.label()));
}
}
SortMethod::Depth => {
tree.sort_by_depth(reverse);
}
}
output_tree(&tree, cli)
}
#[allow(unused_variables)]
#[cfg(feature = "compare")]
fn handle_compare(first: &str, second: &str) -> Result<(), Box<dyn std::error::Error>> {
let tree1 = read_tree(first)?;
let tree2 = read_tree(second)?;
if tree1.eq_structure(&tree2) {
println!("Trees have the same structure");
} else {
println!("Trees have different structures");
}
let diffs = tree1.diff(&tree2);
if diffs.is_empty() {
println!("No differences found");
} else {
println!("Found {} difference(s):", diffs.len());
for diff in diffs {
match diff {
treelog::compare::TreeDiff::OnlyInFirst { path, content } => {
println!(" Only in first (path: {:?}): {}", path, content);
}
treelog::compare::TreeDiff::OnlyInSecond { path, content } => {
println!(" Only in second (path: {:?}): {}", path, content);
}
treelog::compare::TreeDiff::DifferentContent {
path,
first,
second,
} => {
println!(" Different at {:?}: '{}' vs '{}'", path, first, second);
}
}
}
}
Ok(())
}
#[allow(unused_variables)]
#[cfg(feature = "merge")]
fn handle_merge(
strategy: &treelog::merge::MergeStrategy,
first: &str,
second: &str,
cli: &Cli,
) -> Result<(), Box<dyn std::error::Error>> {
let tree1 = read_tree(first)?;
let tree2 = read_tree(second)?;
let merged = tree1.merge(tree2, strategy.clone());
output_tree(&merged, cli)
}
#[allow(unused_variables)]
#[cfg(feature = "export")]
fn handle_export(format: &ExportFormat, input: &str) -> Result<(), Box<dyn std::error::Error>> {
let tree = read_tree(input)?;
let output = match format {
ExportFormat::Html => tree.to_html(),
ExportFormat::Svg => tree.to_svg(),
ExportFormat::Dot => tree.to_dot(),
};
println!("{}", output);
Ok(())
}
#[allow(unused_variables)]
fn read_tree(input: &str) -> Result<treelog::Tree, Box<dyn std::error::Error>> {
let content = read_file_or_stdin(input)?;
#[cfg(feature = "serde-json")]
if let Ok(tree) = treelog::Tree::from_json(&content) {
return Ok(tree);
}
#[cfg(feature = "serde-yaml")]
if let Ok(tree) = treelog::Tree::from_yaml(&content) {
return Ok(tree);
}
#[cfg(feature = "serde-toml")]
if let Ok(tree) = treelog::Tree::from_toml(&content) {
return Ok(tree);
}
#[cfg(feature = "serde-ron")]
if let Ok(tree) = treelog::Tree::from_ron(&content) {
return Ok(tree);
}
#[cfg(not(any(
feature = "serde-json",
feature = "serde-yaml",
feature = "serde-toml",
feature = "serde-ron"
)))]
let _ = content;
Err("Could not parse tree. Ensure the input is valid JSON, YAML, TOML, or RON, or enable the appropriate feature.".into())
}
fn read_file_or_stdin(path: &str) -> Result<String, Box<dyn std::error::Error>> {
if path == "-" {
let mut content = String::new();
io::stdin().read_to_string(&mut content)?;
Ok(content)
} else {
std::fs::read_to_string(path).map_err(|e| e.into())
}
}
fn output_tree(tree: &treelog::Tree, cli: &Cli) -> Result<(), Box<dyn std::error::Error>> {
let config = build_render_config(cli)?;
let output = match &cli.format {
OutputFormat::Text => tree.render_to_string_with_config(&config),
#[cfg(feature = "export")]
OutputFormat::Html => tree.to_html(),
#[cfg(feature = "export")]
OutputFormat::Svg => tree.to_svg(),
#[cfg(feature = "export")]
OutputFormat::Dot => tree.to_dot(),
#[cfg(feature = "serde-json")]
OutputFormat::Json => tree
.to_json_pretty()
.map_err(|e| format!("Failed to serialize to JSON: {}", e))?,
#[cfg(feature = "serde-yaml")]
OutputFormat::Yaml => tree
.to_yaml()
.map_err(|e| format!("Failed to serialize to YAML: {}", e))?,
#[cfg(feature = "serde-toml")]
OutputFormat::Toml => tree
.to_toml()
.map_err(|e| format!("Failed to serialize to TOML: {}", e))?,
#[cfg(feature = "serde-ron")]
OutputFormat::Ron => tree
.to_ron_pretty()
.map_err(|e| format!("Failed to serialize to RON: {}", e))?,
#[cfg(not(feature = "export"))]
OutputFormat::Html | OutputFormat::Svg | OutputFormat::Dot => {
return Err(
"Export feature is not enabled. Enable the 'export' feature to use this format."
.into(),
);
}
#[cfg(not(feature = "serde-json"))]
OutputFormat::Json => {
return Err(
"JSON feature is not enabled. Enable the 'json' feature to use JSON format.".into(),
);
}
#[cfg(not(feature = "serde-yaml"))]
OutputFormat::Yaml => {
return Err(
"YAML feature is not enabled. Enable the 'yaml' feature to use YAML format.".into(),
);
}
#[cfg(not(feature = "serde-toml"))]
OutputFormat::Toml => {
return Err(
"TOML feature is not enabled. Enable the 'toml' feature to use TOML format.".into(),
);
}
#[cfg(not(feature = "serde-ron"))]
OutputFormat::Ron => {
return Err(
"RON feature is not enabled. Enable the 'ron' feature to use RON format.".into(),
);
}
};
if let Some(output_path) = &cli.output {
if output_path == "-" {
print!("{}", output);
} else {
std::fs::write(output_path, output)?;
}
} else {
print!("{}", output);
}
Ok(())
}
fn build_render_config(cli: &Cli) -> Result<treelog::RenderConfig, Box<dyn std::error::Error>> {
use treelog::{RenderConfig, StyleConfig};
let mut config = RenderConfig::default();
if let Some(custom) = &cli.custom_style {
let parts: Vec<&str> = custom.split(',').collect();
if parts.len() != 4 {
return Err(
"Custom style must have 4 comma-separated values: branch,last,vertical,empty"
.into(),
);
}
config = config.with_style(StyleConfig::custom(
parts[0].trim(),
parts[1].trim(),
parts[2].trim(),
parts[3].trim(),
));
} else {
config = config.with_style(cli.style.clone());
}
#[cfg(feature = "color")]
{
if cli.color && !cli.no_color {
config = config.with_colors(true);
} else if cli.no_color {
config = config.with_colors(false);
}
}
Ok(config)
}