Files
rtk-ai__rtk/src/tree.rs
T
Florian BRUNIAUX a240d1a1ee feat(grep): add extra args passthrough (-i, -A/-B/-C, etc.)
Add support for passing additional ripgrep arguments to rtk grep
while preserving RTK's token-optimized output formatting.

Changes:
- src/main.rs: Add extra_args field to Grep command variant
- src/main.rs: Pass extra_args to grep_cmd::run()
- src/grep_cmd.rs: Add extra_args parameter to run() signature
- src/grep_cmd.rs: Inject extra_args into ripgrep command
- Unit test: test_extra_args_accepted verifies parameter exists
- Smoke tests: 2 assertions for -i and -A flags

Usage note: Extra args must come AFTER pattern and path:
  ✓ rtk grep "pattern" src/ -i
  ✗ rtk grep "pattern" -i  (clap interprets -i as path)

This enables full ripgrep functionality (case-insensitive, context
lines, word boundaries, globs) while maintaining RTK's compact output.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-02 23:19:43 +01:00

210 lines
6.1 KiB
Rust

//! tree command - proxy to native tree with token-optimized output
//!
//! This module proxies to the native `tree` command and filters the output
//! to reduce token usage while preserving structure visibility.
//!
//! Token optimization: automatically excludes noise directories via -I pattern
//! unless -a flag is present (respecting user intent).
use crate::tracking;
use anyhow::{Context, Result};
use std::process::Command;
/// Noise directories commonly excluded from LLM context
const NOISE_DIRS: &[&str] = &[
"node_modules",
".git",
"target",
"__pycache__",
".next",
"dist",
"build",
".cache",
".turbo",
".vercel",
".pytest_cache",
".mypy_cache",
".tox",
".venv",
"venv",
"env",
".env",
"coverage",
".nyc_output",
".DS_Store",
"Thumbs.db",
".idea",
".vscode",
".vs",
"*.egg-info",
".eggs",
];
pub fn run(args: &[String], verbose: u8) -> Result<()> {
let timer = tracking::TimedExecution::start();
// Check if tree is installed
let tree_check = Command::new("which").arg("tree").output();
if tree_check.is_err() || !tree_check.unwrap().status.success() {
anyhow::bail!(
"tree command not found. Install it first:\n\
- macOS: brew install tree\n\
- Ubuntu/Debian: sudo apt install tree\n\
- Fedora/RHEL: sudo dnf install tree\n\
- Arch: sudo pacman -S tree"
);
}
let mut cmd = Command::new("tree");
// Determine if user wants all files or default behavior
let show_all = args.iter().any(|a| a == "-a" || a == "--all");
let has_ignore = args.iter().any(|a| a == "-I" || a.starts_with("--ignore="));
// Auto-inject -I pattern unless user wants all or already specified -I
if !show_all && !has_ignore {
let ignore_pattern = NOISE_DIRS.join("|");
cmd.arg("-I").arg(&ignore_pattern);
}
// Pass all user args
for arg in args {
cmd.arg(arg);
}
let output = cmd.output().context("Failed to run tree")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
eprint!("{}", stderr);
std::process::exit(output.status.code().unwrap_or(1));
}
let raw = String::from_utf8_lossy(&output.stdout).to_string();
let filtered = filter_tree_output(&raw);
if verbose > 0 {
eprintln!(
"Lines: {} → {} ({}% reduction)",
raw.lines().count(),
filtered.lines().count(),
if raw.lines().count() > 0 {
100 - (filtered.lines().count() * 100 / raw.lines().count())
} else {
0
}
);
}
print!("{}", filtered);
timer.track("tree", "rtk tree", &raw, &filtered);
Ok(())
}
fn filter_tree_output(raw: &str) -> String {
let lines: Vec<&str> = raw.lines().collect();
if lines.is_empty() {
return "\n".to_string();
}
let mut filtered_lines = Vec::new();
for line in lines {
// Skip the final summary line (e.g., "5 directories, 23 files")
if line.contains("director") && line.contains("file") {
continue;
}
// Skip empty lines at the end
if line.trim().is_empty() && filtered_lines.is_empty() {
continue;
}
filtered_lines.push(line);
}
// Remove trailing empty lines
while filtered_lines.last().map_or(false, |l| l.trim().is_empty()) {
filtered_lines.pop();
}
filtered_lines.join("\n") + "\n"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_filter_removes_summary() {
let input = ".\n├── src\n│ └── main.rs\n└── Cargo.toml\n\n2 directories, 3 files\n";
let output = filter_tree_output(input);
assert!(!output.contains("directories"));
assert!(!output.contains("files"));
assert!(output.contains("main.rs"));
assert!(output.contains("Cargo.toml"));
}
#[test]
fn test_filter_preserves_structure() {
let input = ".\n├── src\n│ ├── main.rs\n│ └── lib.rs\n└── tests\n └── test.rs\n";
let output = filter_tree_output(input);
assert!(output.contains("├──"));
assert!(output.contains("│"));
assert!(output.contains("└──"));
assert!(output.contains("main.rs"));
assert!(output.contains("test.rs"));
}
#[test]
fn test_filter_handles_empty() {
let input = "";
let output = filter_tree_output(input);
assert_eq!(output, "\n");
}
#[test]
fn test_filter_removes_trailing_empty_lines() {
let input = ".\n├── file.txt\n\n\n";
let output = filter_tree_output(input);
assert_eq!(output.matches('\n').count(), 2); // Root + file.txt + final newline
}
#[test]
fn test_filter_summary_variations() {
// Test different summary formats
let inputs = vec![
(".\n└── file.txt\n\n0 directories, 1 file\n", "1 file"),
(".\n└── file.txt\n\n1 directory, 0 files\n", "1 directory"),
(".\n└── file.txt\n\n10 directories, 25 files\n", "25 files"),
];
for (input, summary_fragment) in inputs {
let output = filter_tree_output(input);
assert!(
!output.contains(summary_fragment),
"Should remove summary '{}' from output",
summary_fragment
);
assert!(
output.contains("file.txt"),
"Should preserve file.txt in output"
);
}
}
#[test]
fn test_noise_dirs_constant() {
// Verify NOISE_DIRS contains expected patterns
assert!(NOISE_DIRS.contains(&"node_modules"));
assert!(NOISE_DIRS.contains(&".git"));
assert!(NOISE_DIRS.contains(&"target"));
assert!(NOISE_DIRS.contains(&"__pycache__"));
assert!(NOISE_DIRS.contains(&".next"));
assert!(NOISE_DIRS.contains(&"dist"));
assert!(NOISE_DIRS.contains(&"build"));
}
}