Files
rtk-ai__rtk/src/ls.rs
T
Florian BRUNIAUX 60d2d252ef fix: P0 crashes + cargo check + dedup utilities + discover status
Phase 1: Fix P0 crashes (3 bugs)
- Add external_subcommand fallback for cargo/docker/kubectl
- Fixes crashes on unsupported subcommands (e.g., cargo fmt, docker build)
- All passthroughs now route gracefully instead of Clap parse errors

Phase 2: cargo check handler + refactor (-40 lines)
- Add dedicated cargo check handler (most frequently used missing command)
- Extract run_cargo_filtered() to eliminate 90 lines of duplication
- Fix filter_cargo_build() to handle "Checking" prefix (not just "Compiling")
- Normalize exit code propagation in run_test()

Phase 3: Utility deduplication (-60 lines)
- Fix utils::truncate() Unicode handling (char-aware, not byte-based)
- Remove 5 duplicated utility functions across modules:
  - 3x truncate() → use crate::utils::truncate
  - 1x strip_ansi() → use crate::utils::strip_ansi
- Replace ~60 lines of inline package manager detection with utils::package_manager_exec()
- Clean: lint_cmd, prettier_cmd, playwright_cmd now use shared utils

Phase 4: Discover reporting improvements
- Add RtkStatus enum (Existing/Passthrough/NotSupported)
- Show status column in discover report to distinguish handler types
- Prevents future misleading "build 9 git subcommands" false analyses

Cleanup:
- Remove 2 unused imports (parser::ParseError, std::collections::HashMap)
- Reduce clippy warnings from 23 to 19

Testing:
- 220 tests pass
- 88 smoke tests pass
- All manual verification complete

Net: +10 lines, -3 P0 bugs, -5 duplications, +1 feature, +1 enum

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-06 10:37:50 +01:00

325 lines
9.5 KiB
Rust

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",
"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();
// Separate flags from paths
let show_all = args
.iter()
.any(|a| (a.starts_with('-') && !a.starts_with("--") && a.contains('a')) || a == "--all");
let flags: Vec<&str> = args
.iter()
.filter(|a| a.starts_with('-'))
.map(|s| s.as_str())
.collect();
let paths: Vec<&str> = args
.iter()
.filter(|a| !a.starts_with('-'))
.map(|s| s.as_str())
.collect();
// Build ls -la + any extra flags the user passed (e.g. -R)
// Strip -l, -a, -h (we handle all of these ourselves)
let mut cmd = Command::new("ls");
cmd.arg("-la");
for flag in &flags {
if flag.starts_with("--") {
// Long flags: skip --all (already handled)
if *flag != "--all" {
cmd.arg(flag);
}
} else {
let stripped = flag.trim_start_matches('-');
let extra: String = stripped
.chars()
.filter(|c| *c != 'l' && *c != 'a' && *c != 'h')
.collect();
if !extra.is_empty() {
cmd.arg(format!("-{}", extra));
}
}
}
// Add paths (default to "." if none)
if paths.is_empty() {
cmd.arg(".");
} else {
for p in &paths {
cmd.arg(p);
}
}
let output = cmd.output().context("Failed to run ls")?;
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 = compact_ls(&raw, show_all);
if verbose > 0 {
eprintln!(
"Chars: {} → {} ({}% reduction)",
raw.len(),
filtered.len(),
if !raw.is_empty() {
100 - (filtered.len() * 100 / raw.len())
} else {
0
}
);
}
let target_display = if paths.is_empty() {
".".to_string()
} else {
paths.join(" ")
};
print!("{}", filtered);
timer.track(
&format!("ls -la {}", target_display),
"rtk ls",
&raw,
&filtered,
);
Ok(())
}
/// Format bytes into human-readable size
fn human_size(bytes: u64) -> String {
if bytes >= 1_048_576 {
format!("{:.1}M", bytes as f64 / 1_048_576.0)
} else if bytes >= 1024 {
format!("{:.1}K", bytes as f64 / 1024.0)
} else {
format!("{}B", bytes)
}
}
/// Parse ls -la output into compact format:
/// name/ (dirs)
/// name size (files)
fn compact_ls(raw: &str, show_all: bool) -> String {
use std::collections::HashMap;
let mut dirs: Vec<String> = Vec::new();
let mut files: Vec<(String, String)> = Vec::new(); // (name, size)
let mut by_ext: HashMap<String, usize> = HashMap::new();
for line in raw.lines() {
// Skip total, empty, . and ..
if line.starts_with("total ") || line.is_empty() {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() < 9 {
continue;
}
// Filename is everything from column 9 onward (handles spaces)
let name = parts[8..].join(" ");
// Skip . and ..
if name == "." || name == ".." {
continue;
}
// Filter noise dirs unless -a
if !show_all && NOISE_DIRS.iter().any(|noise| name == *noise) {
continue;
}
let is_dir = parts[0].starts_with('d');
if is_dir {
dirs.push(name);
} else if parts[0].starts_with('-') || parts[0].starts_with('l') {
let size: u64 = parts[4].parse().unwrap_or(0);
let ext = if let Some(pos) = name.rfind('.') {
name[pos..].to_string()
} else {
"no ext".to_string()
};
*by_ext.entry(ext).or_insert(0) += 1;
files.push((name, human_size(size)));
}
}
if dirs.is_empty() && files.is_empty() {
return "(empty)\n".to_string();
}
let mut out = String::new();
// Dirs first, compact
for d in &dirs {
out.push_str(d);
out.push_str("/\n");
}
// Files with size
for (name, size) in &files {
out.push_str(name);
out.push_str(" ");
out.push_str(size);
out.push('\n');
}
// Summary line
out.push('\n');
let mut summary = format!("📊 {} files, {} dirs", files.len(), dirs.len());
if !by_ext.is_empty() {
let mut ext_counts: Vec<_> = by_ext.iter().collect();
ext_counts.sort_by(|a, b| b.1.cmp(a.1));
let ext_parts: Vec<String> = ext_counts
.iter()
.take(5)
.map(|(ext, count)| format!("{} {}", count, ext))
.collect();
summary.push_str(" (");
summary.push_str(&ext_parts.join(", "));
if ext_counts.len() > 5 {
summary.push_str(&format!(", +{} more", ext_counts.len() - 5));
}
summary.push(')');
}
out.push_str(&summary);
out.push('\n');
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compact_basic() {
let input = "total 48\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 .\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 ..\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\
-rw-r--r-- 1 user staff 1234 Jan 1 12:00 Cargo.toml\n\
-rw-r--r-- 1 user staff 5678 Jan 1 12:00 README.md\n";
let output = compact_ls(input, false);
assert!(output.contains("src/"));
assert!(output.contains("Cargo.toml"));
assert!(output.contains("README.md"));
assert!(output.contains("1.2K")); // 1234 bytes
assert!(output.contains("5.5K")); // 5678 bytes
assert!(!output.contains("drwx")); // no permissions
assert!(!output.contains("staff")); // no group
assert!(!output.contains("total")); // no total
assert!(!output.contains("\n.\n")); // no . entry
assert!(!output.contains("\n..\n")); // no .. entry
}
#[test]
fn test_compact_filters_noise() {
let input = "total 8\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 node_modules\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 target\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\
-rw-r--r-- 1 user staff 100 Jan 1 12:00 main.rs\n";
let output = compact_ls(input, false);
assert!(!output.contains("node_modules"));
assert!(!output.contains(".git"));
assert!(!output.contains("target"));
assert!(output.contains("src/"));
assert!(output.contains("main.rs"));
}
#[test]
fn test_compact_show_all() {
let input = "total 8\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 .git\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n";
let output = compact_ls(input, true);
assert!(output.contains(".git/"));
assert!(output.contains("src/"));
}
#[test]
fn test_compact_empty() {
let input = "total 0\n";
let output = compact_ls(input, false);
assert_eq!(output, "(empty)\n");
}
#[test]
fn test_compact_summary() {
let input = "total 48\n\
drwxr-xr-x 2 user staff 64 Jan 1 12:00 src\n\
-rw-r--r-- 1 user staff 1234 Jan 1 12:00 main.rs\n\
-rw-r--r-- 1 user staff 5678 Jan 1 12:00 lib.rs\n\
-rw-r--r-- 1 user staff 100 Jan 1 12:00 Cargo.toml\n";
let output = compact_ls(input, false);
assert!(output.contains("📊 3 files, 1 dirs"));
assert!(output.contains(".rs"));
assert!(output.contains(".toml"));
}
#[test]
fn test_human_size() {
assert_eq!(human_size(0), "0B");
assert_eq!(human_size(500), "500B");
assert_eq!(human_size(1024), "1.0K");
assert_eq!(human_size(1234), "1.2K");
assert_eq!(human_size(1_048_576), "1.0M");
assert_eq!(human_size(2_500_000), "2.4M");
}
#[test]
fn test_compact_handles_filenames_with_spaces() {
let input = "total 8\n\
-rw-r--r-- 1 user staff 1234 Jan 1 12:00 my file.txt\n";
let output = compact_ls(input, false);
assert!(output.contains("my file.txt"));
}
#[test]
fn test_compact_symlinks() {
let input = "total 8\n\
lrwxr-xr-x 1 user staff 10 Jan 1 12:00 link -> target\n";
let output = compact_ls(input, false);
assert!(output.contains("link -> target"));
}
}