From 556640caa9b73819cf316b8d6b80e300f0a53c41 Mon Sep 17 00:00:00 2001 From: heAdz0r Date: Tue, 17 Feb 2026 21:59:52 +0300 Subject: [PATCH] feat: add `rtk wc` command for compact word/line/byte counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new `wc` subcommand that compresses wc output by: - Stripping alignment padding: ` 30 96 978 file.py` → `30L 96W 978B` - Removing common path prefixes in multi-file output - Returning bare numbers for single-flag modes (-l, -w, -c, -m) - Showing Σ totals instead of verbose "total" lines Includes 15 unit tests covering single/multi-file, stdin, all flag modes, and common prefix detection. Co-Authored-By: Claude Opus 4.6 --- src/main.rs | 12 ++ src/wc_cmd.rs | 401 ++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 413 insertions(+) create mode 100644 src/wc_cmd.rs diff --git a/src/main.rs b/src/main.rs index 5bec4da..1a85ad3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -45,6 +45,7 @@ mod tree; mod tsc_cmd; mod utils; mod vitest_cmd; +mod wc_cmd; mod wget_cmd; use anyhow::{Context, Result}; @@ -295,6 +296,13 @@ enum Commands { args: Vec, }, + /// Word/line/byte count with compact output (strips paths and padding) + Wc { + /// Arguments passed to wc (files, flags like -l, -w, -c) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show token savings summary and history Gain { /// Show ASCII graph of daily savings @@ -1085,6 +1093,10 @@ fn main() -> Result<()> { } } + Commands::Wc { args } => { + wc_cmd::run(&args, cli.verbose)?; + } + Commands::Gain { graph, history, diff --git a/src/wc_cmd.rs b/src/wc_cmd.rs new file mode 100644 index 0000000..d827eb8 --- /dev/null +++ b/src/wc_cmd.rs @@ -0,0 +1,401 @@ +/// Compact filter for `wc` — strips redundant paths and alignment padding. +/// +/// Compression examples: +/// - `wc file.py` → `30L 96W 978B` +/// - `wc -l file.py` → `30` +/// - `wc -w file.py` → `96` +/// - `wc -c file.py` → `978` +/// - `wc -l *.py` → table with common path prefix stripped +use crate::tracking; +use anyhow::{Context, Result}; +use std::process::Command; + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("wc"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: wc {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run wc")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + let msg = if stderr.trim().is_empty() { + stdout.trim().to_string() + } else { + stderr.trim().to_string() + }; + eprintln!("FAILED: wc {}", msg); + std::process::exit(output.status.code().unwrap_or(1)); + } + + let raw = stdout.to_string(); + + // Detect which columns the user requested + let mode = detect_mode(args); + let filtered = filter_wc_output(&raw, &mode); + println!("{}", filtered); + + timer.track( + &format!("wc {}", args.join(" ")), + &format!("rtk wc {}", args.join(" ")), + &raw, + &filtered, + ); + + Ok(()) +} + +/// Which columns the user requested +#[derive(Debug, PartialEq)] +enum WcMode { + /// Default: lines, words, bytes (3 columns) + Full, + /// Lines only (-l) + Lines, + /// Words only (-w) + Words, + /// Bytes only (-c) + Bytes, + /// Chars only (-m) + Chars, + /// Multiple flags combined — keep compact format + Mixed, +} + +fn detect_mode(args: &[String]) -> WcMode { + let flags: Vec<&str> = args + .iter() + .filter(|a| a.starts_with('-')) + .map(|s| s.as_str()) + .collect(); + + if flags.is_empty() { + return WcMode::Full; + } + + // Collect all single-char flags (handles combined flags like -lw) + let mut has_l = false; + let mut has_w = false; + let mut has_c = false; + let mut has_m = false; + let mut flag_count = 0; + + for flag in &flags { + for ch in flag.chars().skip(1) { + match ch { + 'l' => { + has_l = true; + flag_count += 1; + } + 'w' => { + has_w = true; + flag_count += 1; + } + 'c' => { + has_c = true; + flag_count += 1; + } + 'm' => { + has_m = true; + flag_count += 1; + } + _ => {} + } + } + } + + if flag_count == 0 { + return WcMode::Full; + } + if flag_count > 1 { + return WcMode::Mixed; + } + + if has_l { + WcMode::Lines + } else if has_w { + WcMode::Words + } else if has_c { + WcMode::Bytes + } else if has_m { + WcMode::Chars + } else { + WcMode::Full + } +} + +fn filter_wc_output(raw: &str, mode: &WcMode) -> String { + let lines: Vec<&str> = raw.trim().lines().collect(); + + if lines.is_empty() { + return String::new(); + } + + // Single file (one output line, no "total") + if lines.len() == 1 { + return format_single_line(lines[0], mode); + } + + // Multiple files — compact table + format_multi_line(&lines, mode) +} + +/// Format a single wc output line (one file or stdin) +fn format_single_line(line: &str, mode: &WcMode) -> String { + let parts: Vec<&str> = line.split_whitespace().collect(); + + match mode { + WcMode::Lines | WcMode::Words | WcMode::Bytes | WcMode::Chars => { + // First number is the only requested column + parts.first().map(|s| s.to_string()).unwrap_or_default() + } + WcMode::Full => { + if parts.len() >= 3 { + format!("{}L {}W {}B", parts[0], parts[1], parts[2]) + } else { + line.trim().to_string() + } + } + WcMode::Mixed => { + // Strip file path, keep numbers only + if parts.len() >= 2 { + let last_is_path = parts.last().map_or(false, |p| p.parse::().is_err()); + if last_is_path { + parts[..parts.len() - 1].join(" ") + } else { + parts.join(" ") + } + } else { + line.trim().to_string() + } + } + } +} + +/// Format multiple files as a compact table +fn format_multi_line(lines: &[&str], mode: &WcMode) -> String { + let mut result = Vec::new(); + + // Find common directory prefix to shorten paths + let paths: Vec<&str> = lines + .iter() + .filter_map(|line| { + let parts: Vec<&str> = line.split_whitespace().collect(); + parts.last().copied() + }) + .filter(|p| *p != "total") + .collect(); + + let common_prefix = find_common_prefix(&paths); + + for line in lines { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.is_empty() { + continue; + } + + let is_total = parts.last().map_or(false, |p| *p == "total"); + + match mode { + WcMode::Lines | WcMode::Words | WcMode::Bytes | WcMode::Chars => { + if is_total { + result.push(format!("Σ {}", parts.first().unwrap_or(&"0"))); + } else { + let name = strip_prefix(parts.last().unwrap_or(&""), &common_prefix); + result.push(format!("{} {}", parts.first().unwrap_or(&"0"), name)); + } + } + WcMode::Full => { + if is_total { + result.push(format!( + "Σ {}L {}W {}B", + parts.first().unwrap_or(&"0"), + parts.get(1).unwrap_or(&"0"), + parts.get(2).unwrap_or(&"0"), + )); + } else if parts.len() >= 4 { + let name = strip_prefix(parts[3], &common_prefix); + result.push(format!( + "{}L {}W {}B {}", + parts[0], parts[1], parts[2], name + )); + } else { + result.push(line.trim().to_string()); + } + } + WcMode::Mixed => { + if is_total { + let nums: Vec<&str> = parts[..parts.len() - 1].to_vec(); + result.push(format!("Σ {}", nums.join(" "))); + } else if parts.len() >= 2 { + let last_is_path = parts.last().map_or(false, |p| p.parse::().is_err()); + if last_is_path { + let name = strip_prefix(parts.last().unwrap_or(&""), &common_prefix); + let nums: Vec<&str> = parts[..parts.len() - 1].to_vec(); + result.push(format!("{} {}", nums.join(" "), name)); + } else { + result.push(parts.join(" ")); + } + } else { + result.push(line.trim().to_string()); + } + } + } + } + + result.join("\n") +} + +/// Find common directory prefix among paths +fn find_common_prefix(paths: &[&str]) -> String { + if paths.len() <= 1 { + return String::new(); + } + + let first = paths[0]; + let prefix = if let Some(pos) = first.rfind('/') { + &first[..=pos] + } else { + return String::new(); + }; + + if paths.iter().all(|p| p.starts_with(prefix)) { + return prefix.to_string(); + } + + // Try shorter prefixes by removing right-most segments + let mut candidate = prefix.to_string(); + while !candidate.is_empty() { + if paths.iter().all(|p| p.starts_with(&candidate)) { + return candidate; + } + if let Some(pos) = candidate[..candidate.len() - 1].rfind('/') { + candidate.truncate(pos + 1); + } else { + return String::new(); + } + } + String::new() +} + +/// Strip common prefix from a path +fn strip_prefix<'a>(path: &'a str, prefix: &str) -> &'a str { + if prefix.is_empty() { + return path; + } + path.strip_prefix(prefix).unwrap_or(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_single_file_full() { + let raw = " 30 96 978 scripts/find_duplicate_attrs.py\n"; + let result = filter_wc_output(raw, &WcMode::Full); + assert_eq!(result, "30L 96W 978B"); + } + + #[test] + fn test_single_file_lines_only() { + let raw = " 30 scripts/find_duplicate_attrs.py\n"; + let result = filter_wc_output(raw, &WcMode::Lines); + assert_eq!(result, "30"); + } + + #[test] + fn test_single_file_words_only() { + let raw = " 96 scripts/find_duplicate_attrs.py\n"; + let result = filter_wc_output(raw, &WcMode::Words); + assert_eq!(result, "96"); + } + + #[test] + fn test_stdin_full() { + let raw = " 30 96 978\n"; + let result = filter_wc_output(raw, &WcMode::Full); + assert_eq!(result, "30L 96W 978B"); + } + + #[test] + fn test_stdin_lines() { + let raw = " 30\n"; + let result = filter_wc_output(raw, &WcMode::Lines); + assert_eq!(result, "30"); + } + + #[test] + fn test_multi_file_lines() { + let raw = " 30 src/main.rs\n 50 src/lib.rs\n 80 total\n"; + let result = filter_wc_output(raw, &WcMode::Lines); + assert_eq!(result, "30 main.rs\n50 lib.rs\nΣ 80"); + } + + #[test] + fn test_multi_file_full() { + let raw = " 30 96 978 src/main.rs\n 50 120 1500 src/lib.rs\n 80 216 2478 total\n"; + let result = filter_wc_output(raw, &WcMode::Full); + assert_eq!( + result, + "30L 96W 978B main.rs\n50L 120W 1500B lib.rs\nΣ 80L 216W 2478B" + ); + } + + #[test] + fn test_detect_mode_full() { + let args: Vec = vec!["file.py".into()]; + assert_eq!(detect_mode(&args), WcMode::Full); + } + + #[test] + fn test_detect_mode_lines() { + let args: Vec = vec!["-l".into(), "file.py".into()]; + assert_eq!(detect_mode(&args), WcMode::Lines); + } + + #[test] + fn test_detect_mode_mixed() { + let args: Vec = vec!["-lw".into(), "file.py".into()]; + assert_eq!(detect_mode(&args), WcMode::Mixed); + } + + #[test] + fn test_detect_mode_separate_flags() { + let args: Vec = vec!["-l".into(), "-w".into(), "file.py".into()]; + assert_eq!(detect_mode(&args), WcMode::Mixed); + } + + #[test] + fn test_common_prefix() { + let paths = vec!["src/main.rs", "src/lib.rs", "src/utils.rs"]; + assert_eq!(find_common_prefix(&paths), "src/"); + } + + #[test] + fn test_no_common_prefix() { + let paths = vec!["main.rs", "lib.rs"]; + assert_eq!(find_common_prefix(&paths), ""); + } + + #[test] + fn test_deep_common_prefix() { + let paths = vec!["src/cmd/wc.rs", "src/cmd/ls.rs"]; + assert_eq!(find_common_prefix(&paths), "src/cmd/"); + } + + #[test] + fn test_empty() { + let raw = ""; + let result = filter_wc_output(raw, &WcMode::Full); + assert_eq!(result, ""); + } +}