diff --git a/.claude/hooks/rtk-suggest.sh b/.claude/hooks/rtk-suggest.sh index 34fb50f3b..e8fa172b8 100755 --- a/.claude/hooks/rtk-suggest.sh +++ b/.claude/hooks/rtk-suggest.sh @@ -1,152 +1,39 @@ #!/usr/bin/env bash # RTK suggest hook for Claude Code PreToolUse:Bash -# Emits system reminders when rtk-compatible commands are detected. +# Delegates to `rtk suggest` to check if a command has an RTK equivalent. # Outputs JSON with systemMessage to inform Claude Code without modifying execution. +# +# This hook is intentionally thin — all rewrite logic lives in +# src/discover/registry.rs (single source of truth). set -euo pipefail INPUT=$(cat) CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty') -if [ -z "$CMD" ]; then - exit 0 -fi +# No command → pass through +[ -z "$CMD" ] && exit 0 -# Extract the first meaningful command (before pipes, &&, etc.) -FIRST_CMD="$CMD" - -# Skip if already using rtk -case "$FIRST_CMD" in +# Already using rtk → skip +case "$CMD" in rtk\ *|*/rtk\ *) exit 0 ;; esac -# Skip commands with heredocs, variable assignments, etc. -case "$FIRST_CMD" in +# Heredocs → skip (not safe to rewrite) +case "$CMD" in *'<<'*) exit 0 ;; esac -SUGGESTION="" - -# --- Git commands --- -if echo "$FIRST_CMD" | grep -qE '^git\s+status(\s|$)'; then - SUGGESTION="rtk git status" -elif echo "$FIRST_CMD" | grep -qE '^git\s+diff(\s|$)'; then - SUGGESTION="rtk git diff" -elif echo "$FIRST_CMD" | grep -qE '^git\s+log(\s|$)'; then - SUGGESTION="rtk git log" -elif echo "$FIRST_CMD" | grep -qE '^git\s+add(\s|$)'; then - SUGGESTION="rtk git add" -elif echo "$FIRST_CMD" | grep -qE '^git\s+commit(\s|$)'; then - SUGGESTION="rtk git commit" -elif echo "$FIRST_CMD" | grep -qE '^git\s+push(\s|$)'; then - SUGGESTION="rtk git push" -elif echo "$FIRST_CMD" | grep -qE '^git\s+pull(\s|$)'; then - SUGGESTION="rtk git pull" -elif echo "$FIRST_CMD" | grep -qE '^git\s+branch(\s|$)'; then - SUGGESTION="rtk git branch" -elif echo "$FIRST_CMD" | grep -qE '^git\s+fetch(\s|$)'; then - SUGGESTION="rtk git fetch" -elif echo "$FIRST_CMD" | grep -qE '^git\s+stash(\s|$)'; then - SUGGESTION="rtk git stash" -elif echo "$FIRST_CMD" | grep -qE '^git\s+show(\s|$)'; then - SUGGESTION="rtk git show" - -# --- GitHub CLI --- -elif echo "$FIRST_CMD" | grep -qE '^gh\s+(pr|issue|run)(\s|$)'; then - SUGGESTION=$(echo "$CMD" | sed 's/^gh /rtk gh /') - -# --- Cargo --- -elif echo "$FIRST_CMD" | grep -qE '^cargo\s+test(\s|$)'; then - SUGGESTION="rtk cargo test" -elif echo "$FIRST_CMD" | grep -qE '^cargo\s+build(\s|$)'; then - SUGGESTION="rtk cargo build" -elif echo "$FIRST_CMD" | grep -qE '^cargo\s+clippy(\s|$)'; then - SUGGESTION="rtk cargo clippy" -elif echo "$FIRST_CMD" | grep -qE '^cargo\s+check(\s|$)'; then - SUGGESTION="rtk cargo check" -elif echo "$FIRST_CMD" | grep -qE '^cargo\s+install(\s|$)'; then - SUGGESTION="rtk cargo install" -elif echo "$FIRST_CMD" | grep -qE '^cargo\s+nextest(\s|$)'; then - SUGGESTION="rtk cargo nextest" -elif echo "$FIRST_CMD" | grep -qE '^cargo\s+fmt(\s|$)'; then - SUGGESTION="rtk cargo fmt" - -# --- File operations --- -elif echo "$FIRST_CMD" | grep -qE '^cat\s+'; then - SUGGESTION=$(echo "$CMD" | sed 's/^cat /rtk read /') -elif echo "$FIRST_CMD" | grep -qE '^(rg|grep)\s+'; then - SUGGESTION=$(echo "$CMD" | sed -E 's/^(rg|grep) /rtk grep /') -elif echo "$FIRST_CMD" | grep -qE '^ls(\s|$)'; then - SUGGESTION=$(echo "$CMD" | sed 's/^ls/rtk ls/') -elif echo "$FIRST_CMD" | grep -qE '^tree(\s|$)'; then - SUGGESTION=$(echo "$CMD" | sed 's/^tree/rtk tree/') -elif echo "$FIRST_CMD" | grep -qE '^find\s+'; then - SUGGESTION=$(echo "$CMD" | sed 's/^find /rtk find /') -elif echo "$FIRST_CMD" | grep -qE '^diff\s+'; then - SUGGESTION=$(echo "$CMD" | sed 's/^diff /rtk diff /') -elif echo "$FIRST_CMD" | grep -qE '^head\s+'; then - # Suggest rtk read with --max-lines transformation - if echo "$FIRST_CMD" | grep -qE '^head\s+-[0-9]+\s+'; then - LINES=$(echo "$FIRST_CMD" | sed -E 's/^head +-([0-9]+) +.+$/\1/') - FILE=$(echo "$FIRST_CMD" | sed -E 's/^head +-[0-9]+ +(.+)$/\1/') - SUGGESTION="rtk read $FILE --max-lines $LINES" - elif echo "$FIRST_CMD" | grep -qE '^head\s+--lines=[0-9]+\s+'; then - LINES=$(echo "$FIRST_CMD" | sed -E 's/^head +--lines=([0-9]+) +.+$/\1/') - FILE=$(echo "$FIRST_CMD" | sed -E 's/^head +--lines=[0-9]+ +(.+)$/\1/') - SUGGESTION="rtk read $FILE --max-lines $LINES" - fi - -# --- JS/TS tooling --- -elif echo "$FIRST_CMD" | grep -qE '^(pnpm\s+)?vitest(\s|$)'; then - SUGGESTION="rtk vitest run" -elif echo "$FIRST_CMD" | grep -qE '^pnpm\s+test(\s|$)'; then - SUGGESTION="rtk vitest run" -elif echo "$FIRST_CMD" | grep -qE '^pnpm\s+tsc(\s|$)'; then - SUGGESTION="rtk tsc" -elif echo "$FIRST_CMD" | grep -qE '^(npx\s+)?tsc(\s|$)'; then - SUGGESTION="rtk tsc" -elif echo "$FIRST_CMD" | grep -qE '^pnpm\s+lint(\s|$)'; then - SUGGESTION="rtk lint" -elif echo "$FIRST_CMD" | grep -qE '^(npx\s+)?eslint(\s|$)'; then - SUGGESTION="rtk lint" -elif echo "$FIRST_CMD" | grep -qE '^(npx\s+)?prettier(\s|$)'; then - SUGGESTION="rtk prettier" -elif echo "$FIRST_CMD" | grep -qE '^(npx\s+)?playwright(\s|$)'; then - SUGGESTION="rtk playwright" -elif echo "$FIRST_CMD" | grep -qE '^pnpm\s+playwright(\s|$)'; then - SUGGESTION="rtk playwright" -elif echo "$FIRST_CMD" | grep -qE '^(npx\s+)?prisma(\s|$)'; then - SUGGESTION="rtk prisma" - -# --- Containers --- -elif echo "$FIRST_CMD" | grep -qE '^docker\s+(ps|images|logs)(\s|$)'; then - SUGGESTION=$(echo "$CMD" | sed 's/^docker /rtk docker /') -elif echo "$FIRST_CMD" | grep -qE '^kubectl\s+(get|logs)(\s|$)'; then - SUGGESTION=$(echo "$CMD" | sed 's/^kubectl /rtk kubectl /') - -# --- Network --- -elif echo "$FIRST_CMD" | grep -qE '^curl\s+'; then - SUGGESTION=$(echo "$CMD" | sed 's/^curl /rtk curl /') -elif echo "$FIRST_CMD" | grep -qE '^wget\s+'; then - SUGGESTION=$(echo "$CMD" | sed 's/^wget /rtk wget /') - -# --- pnpm package management --- -elif echo "$FIRST_CMD" | grep -qE '^pnpm\s+(list|ls|outdated)(\s|$)'; then - SUGGESTION=$(echo "$CMD" | sed 's/^pnpm /rtk pnpm /') -fi - -# If no suggestion, allow command as-is -if [ -z "$SUGGESTION" ]; then - exit 0 -fi +# Ask rtk for a suggestion (exits 1 if no equivalent) +SUGGESTION=$(rtk suggest "$CMD" 2>/dev/null) || exit 0 -# Output suggestion as system message +# Emit suggestion as system message jq -n \ --arg suggestion "$SUGGESTION" \ '{ "hookSpecificOutput": { "hookEventName": "PreToolUse", "permissionDecision": "allow", - "systemMessage": ("⚡ RTK available: `" + $suggestion + "` (60-90% token savings)") + "systemMessage": ("RTK available: `" + $suggestion + "` (60-90% token savings)") } }' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a1b2c261e..a0d9f1f2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,14 @@ jobs: - uses: Swatinem/rust-cache@v2 - run: cargo test --all + supply-chain: + name: supply-chain (cargo-deny) + needs: clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + security: name: Security Scan needs: clippy diff --git a/README.md b/README.md index 24a93f364..241bc4f88 100644 --- a/README.md +++ b/README.md @@ -485,15 +485,24 @@ brew uninstall rtk # If installed via Homebrew ## Privacy & Telemetry -RTK collects **anonymous, aggregate usage metrics** once per day, **enabled by default**. This helps prioritize development. See opt-out options below. - -**What is collected:** -- Device hash (salted SHA-256 — per-user random salt stored locally, not reversible) -- RTK version, OS, architecture -- Command count (last 24h) and top command names (e.g. "git", "cargo" — no arguments, no file paths) -- Token savings percentage - -**What is NOT collected:** source code, file paths, command arguments, secrets, environment variables, or any personally identifiable information. +RTK collects **anonymous, aggregate usage metrics** at most once every 23 hours, **enabled by default**. This helps prioritize which commands to optimize next. See opt-out options below. + +**What is collected** (exact payload — see `src/core/telemetry.rs`): +- `device_hash` — salted SHA-256 of hostname+username (per-user random salt stored locally in `~/.local/share/rtk/.device_salt`, not reversible) +- `version` — RTK version (e.g. "0.34.3") +- `os`, `arch` — operating system and CPU architecture +- `install_method` — how RTK was installed (homebrew, cargo, script, nix, or other) +- `commands_24h` — number of RTK commands run in the last 24 hours +- `top_commands` — top 5 command names by frequency (e.g. "git status", "cargo test" — no file paths, no arguments beyond the subcommand) +- `savings_pct` — overall token savings percentage +- `tokens_saved_24h`, `tokens_saved_total` — token counts saved (last 24h and all-time) + +**What is NOT collected:** source code, file paths, full command arguments, secrets, environment variables, or any personally identifiable information. + +**Technical details:** +- Telemetry URL is compiled into the binary at build time via `RTK_TELEMETRY_URL`. Open-source builds from source have no URL compiled in, so **no data is ever sent** unless you explicitly set this env var during compilation. +- Pings use a 2-second timeout and run in a background thread — they never block CLI execution. +- The salt file is created with `0600` permissions (owner-only read/write). **Opt-out** (any of these): ```bash diff --git a/deny.toml b/deny.toml new file mode 100644 index 000000000..0453d4510 --- /dev/null +++ b/deny.toml @@ -0,0 +1,41 @@ +# cargo-deny configuration for RTK +# https://embarkstudios.github.io/cargo-deny/ + +[advisories] +# Deny crates with known security vulnerabilities +vulnerability = "deny" +# Warn about unmaintained crates +unmaintained = "warn" +# Warn about yanked crates +yanked = "warn" + +[licenses] +# Allow only these open-source licenses +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-3.0", + "Unicode-DFS-2016", + "Zlib", + "CC0-1.0", +] +confidence-threshold = 0.8 + +[bans] +# Warn when multiple versions of the same crate are pulled in +multiple-versions = "warn" +# Deny wildcard dependencies (e.g. version = "*") +wildcards = "deny" + +[sources] +# Deny crates from unknown registries +unknown-registry = "deny" +# Deny crates fetched from unknown git repos +unknown-git = "deny" +# Only allow crates from crates.io +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/src/analytics/cc_economics.rs b/src/analytics/cc_economics.rs index 037593102..d79b62724 100644 --- a/src/analytics/cc_economics.rs +++ b/src/analytics/cc_economics.rs @@ -384,12 +384,14 @@ fn compute_totals(periods: &[PeriodEconomics]) -> Totals { // Compute global dual metrics (legacy) if totals.cc_total_tokens > 0 { - totals.blended_cpt = Some(totals.cc_cost / totals.cc_total_tokens as f64); - totals.savings_blended = Some(totals.rtk_saved_tokens as f64 * totals.blended_cpt.unwrap()); + let blended = totals.cc_cost / totals.cc_total_tokens as f64; + totals.blended_cpt = Some(blended); + totals.savings_blended = Some(totals.rtk_saved_tokens as f64 * blended); } if totals.cc_active_tokens > 0 { - totals.active_cpt = Some(totals.cc_cost / totals.cc_active_tokens as f64); - totals.savings_active = Some(totals.rtk_saved_tokens as f64 * totals.active_cpt.unwrap()); + let active = totals.cc_cost / totals.cc_active_tokens as f64; + totals.active_cpt = Some(active); + totals.savings_active = Some(totals.rtk_saved_tokens as f64 * active); } totals diff --git a/src/cmds/cloud/curl_cmd.rs b/src/cmds/cloud/curl_cmd.rs index d6930ef67..cb36d5a40 100644 --- a/src/cmds/cloud/curl_cmd.rs +++ b/src/cmds/cloud/curl_cmd.rs @@ -4,6 +4,17 @@ use crate::core::tracking; use crate::core::utils::{exit_code_from_output, resolved_command, truncate}; use crate::json_cmd; use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; + +lazy_static! { + static ref HTML_TAG_RE: Regex = Regex::new(r"<[^>]+>").unwrap(); + static ref HTML_ENTITY_RE: Regex = Regex::new(r"&(amp|lt|gt|quot|nbsp|#\d+);").unwrap(); + static ref SCRIPT_RE: Regex = + Regex::new(r"(?si)]*>.*?").unwrap(); + static ref STYLE_RE: Regex = + Regex::new(r"(?si)]*>.*?").unwrap(); +} /// Not using run_filtered: on failure, curl can return HTML error pages (404, 500) /// that the JSON schema filter would mangle. The early exit skips filtering entirely. @@ -66,15 +77,22 @@ fn filter_curl_output(output: &str) -> String { } } - // Not JSON: truncate long output - let lines: Vec<&str> = trimmed.lines().collect(); + // Detect HTML and strip tags for cleaner output + let text = if trimmed.contains(" = text.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.len() > 30 { let mut result: Vec<&str> = lines[..30].to_vec(); result.push(""); let msg = format!( "... ({} more lines, {} bytes total)", lines.len() - 30, - trimmed.len() + text.len() ); return format!("{}\n{}", result.join("\n"), msg); } @@ -87,6 +105,34 @@ fn filter_curl_output(output: &str) -> String { .join("\n") } +/// Strip HTML tags and entities, returning only visible text content. +fn strip_html_tags(html: &str) -> String { + // Remove

visible

"; + let result = strip_html_tags(html); + assert!(result.contains("visible")); + assert!(!result.contains("var x")); + } + + #[test] + fn test_filter_curl_html_response() { + let html = "

Server Error

Something went wrong

"; + let result = filter_curl_output(html); + assert!(result.contains("Server Error")); + assert!(!result.contains("

")); + } + + #[test] + fn test_strip_html_entities() { + let html = "

A & B < C

"; + let result = strip_html_tags(html); + assert!(result.contains("A & B < C")); + } } diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index fb210fd29..9643a7024 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -255,7 +255,9 @@ fn run_show( let stat_stdout = String::from_utf8_lossy(&stat_output.stdout); let stat_text = stat_stdout.trim(); if !stat_text.is_empty() { - println!("{}", stat_text); + // Filter out generated/lockfile lines from stat, collapsing them into a summary + let stat_filtered = filter_stat_generated(stat_text); + println!("{}", stat_filtered); } // Step 3: compacted diff @@ -293,90 +295,289 @@ fn is_blob_show_arg(arg: &str) -> bool { !arg.starts_with('-') && arg.contains(':') } +/// Filter `--stat` output to collapse generated/lockfile entries into a summary. +/// Stat lines look like: ` path/to/file | 123 ++--` +fn filter_stat_generated(stat: &str) -> String { + let mut kept = Vec::new(); + let mut generated_count = 0usize; + + for line in stat.lines() { + // Stat lines contain ' | ', the summary line does not + if line.contains(" | ") { + // Extract filename: everything before the first ' | ' + let filename = line.split(" | ").next().unwrap_or("").trim(); + if is_generated_file(filename) { + generated_count += 1; + continue; + } + } + kept.push(line); + } + + if generated_count > 0 { + let summary_line = format!( + " ({} generated file{} omitted)", + generated_count, + if generated_count == 1 { "" } else { "s" } + ); + // Insert summary before the last line (which is the total summary) + let mut result: Vec = kept.iter().map(|l| l.to_string()).collect(); + let insert_pos = if result.len() > 1 { result.len() - 1 } else { result.len() }; + result.insert(insert_pos, summary_line); + result.join("\n") + } else { + kept.join("\n") + } +} + +/// Returns true if the filename looks like a generated/lockfile that should be +/// summarised in diffs rather than shown line-by-line. +fn is_generated_file(filename: &str) -> bool { + let lower = filename.to_lowercase(); + let basename = filename.rsplit('/').next().unwrap_or(filename); + let basename_lower = basename.to_lowercase(); + + // Lock files + if basename_lower.ends_with(".lock") + || basename_lower.contains("-lock.") + || basename_lower == "shrinkwrap.json" + { + return true; + } + + // Test snapshots + if lower.ends_with(".snap") || lower.ends_with(".snapshot") { + return true; + } + + // Minified assets + if lower.ends_with(".min.js") + || lower.ends_with(".min.css") + || lower.ends_with(".min.mjs") + { + return true; + } + + // Code-generated files + if lower.contains(".generated.") + || lower.ends_with(".g.dart") + || lower.ends_with(".pb.go") + || lower.ends_with(".pb.h") + || lower.ends_with(".pb.cc") + { + return true; + } + + // Generated directories + if lower.starts_with("dist/") + || lower.starts_with("build/") + || lower.starts_with("vendor/") + || lower.starts_with("node_modules/") + || lower.contains("/dist/") + || lower.contains("/build/output/") + || lower.contains("/vendor/") + || lower.contains("/node_modules/") + { + return true; + } + + false +} + pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { - let mut result = Vec::new(); - let mut current_file = String::new(); - let mut added = 0; - let mut removed = 0; + // --- First pass: parse into per-file entries --- + struct FileDiff { + filename: String, + is_generated: bool, + hunks: Vec, // formatted hunk lines (for non-generated files) + added: usize, + removed: usize, + } + + let mut files: Vec = Vec::new(); + let mut current: Option = None; let mut in_hunk = false; let mut hunk_shown = 0; let mut hunk_skipped = 0usize; + let mut any_hunk_truncated = false; let max_hunk_lines = 100; - let mut was_truncated = false; for line in diff.lines() { if line.starts_with("diff --git") { - // Flush hunk truncation before starting a new file - if hunk_skipped > 0 { - result.push(format!(" ... ({} lines truncated)", hunk_skipped)); - was_truncated = true; - hunk_skipped = 0; + // Flush previous hunk truncation + if let Some(ref mut f) = current { + if hunk_skipped > 0 && !f.is_generated { + f.hunks + .push(format!(" ... ({} lines truncated)", hunk_skipped)); + any_hunk_truncated = true; + } } - if !current_file.is_empty() && (added > 0 || removed > 0) { - result.push(format!(" +{} -{}", added, removed)); + if let Some(f) = current.take() { + files.push(f); } - current_file = line.split(" b/").nth(1).unwrap_or("unknown").to_string(); - result.push(format!("\n{}", current_file)); - added = 0; - removed = 0; + + let filename = line.split(" b/").nth(1).unwrap_or("unknown").to_string(); + let is_generated = is_generated_file(&filename); + current = Some(FileDiff { + filename, + is_generated, + hunks: Vec::new(), + added: 0, + removed: 0, + }); in_hunk = false; hunk_shown = 0; - } else if line.starts_with("@@") { - // Flush hunk truncation before starting a new hunk - if hunk_skipped > 0 { - result.push(format!(" ... ({} lines truncated)", hunk_skipped)); - was_truncated = true; - hunk_skipped = 0; - } - in_hunk = true; - hunk_shown = 0; - // Preserve the full unified diff hunk header, including trailing - // function / symbol context after the second @@ marker. - result.push(format!(" {}", line)); - } else if in_hunk { - if line.starts_with('+') && !line.starts_with("+++") { - added += 1; - if hunk_shown < max_hunk_lines { - result.push(format!(" {}", line)); - hunk_shown += 1; - } else { - hunk_skipped += 1; + hunk_skipped = 0; + } else if let Some(ref mut f) = current { + if line.starts_with("@@") { + if hunk_skipped > 0 && !f.is_generated { + f.hunks + .push(format!(" ... ({} lines truncated)", hunk_skipped)); + any_hunk_truncated = true; + hunk_skipped = 0; } - } else if line.starts_with('-') && !line.starts_with("---") { - removed += 1; - if hunk_shown < max_hunk_lines { - result.push(format!(" {}", line)); - hunk_shown += 1; - } else { - hunk_skipped += 1; + in_hunk = true; + hunk_shown = 0; + if !f.is_generated { + // Preserve the full unified diff hunk header, including trailing + // function / symbol context after the second @@ marker. + f.hunks.push(format!(" {}", line)); } - } else if hunk_shown < max_hunk_lines && !line.starts_with("\\") { - // Context line - if hunk_shown > 0 { - result.push(format!(" {}", line)); + } else if in_hunk { + if line.starts_with('+') && !line.starts_with("+++") { + f.added += 1; + if !f.is_generated && hunk_shown < max_hunk_lines { + f.hunks.push(format!(" {}", line)); + hunk_shown += 1; + } else if !f.is_generated { + hunk_skipped += 1; + } + } else if line.starts_with('-') && !line.starts_with("---") { + f.removed += 1; + if !f.is_generated && hunk_shown < max_hunk_lines { + f.hunks.push(format!(" {}", line)); + hunk_shown += 1; + } else if !f.is_generated { + hunk_skipped += 1; + } + } else if !f.is_generated + && hunk_shown < max_hunk_lines + && !line.starts_with("\\") + && hunk_shown > 0 + { + f.hunks.push(format!(" {}", line)); hunk_shown += 1; } } } + } + // Flush last file + if let Some(ref mut f) = current { + if hunk_skipped > 0 && !f.is_generated { + f.hunks + .push(format!(" ... ({} lines truncated)", hunk_skipped)); + any_hunk_truncated = true; + } + } + if let Some(f) = current.take() { + files.push(f); + } + + // --- Second pass: group files with identical hunks --- + let mut result = Vec::new(); + let mut was_truncated = false; + let mut i = 0; + + while i < files.len() { if result.len() >= max_lines { result.push("\n... (more changes truncated)".to_string()); was_truncated = true; break; } - } - // Flush last hunk - if hunk_skipped > 0 { - result.push(format!(" ... ({} lines truncated)", hunk_skipped)); - was_truncated = true; - } + let f = &files[i]; + + if f.is_generated { + // Generated file — 1-line summary + result.push(format!("\n{}", f.filename)); + result.push(format!( + " (generated file — +{} -{} lines, content omitted)", + f.added, f.removed + )); + i += 1; + continue; + } + + // Look ahead for files with identical hunks + let mut similar = vec![i]; + for (j, other) in files.iter().enumerate().skip(i + 1) { + if !other.is_generated && other.hunks == f.hunks && !f.hunks.is_empty() { + similar.push(j); + } + } + + if similar.len() >= 2 { + // Group similar files + let names: Vec<&str> = similar.iter().map(|&idx| files[idx].filename.as_str()).collect(); + let total_added: usize = similar.iter().map(|&idx| files[idx].added).sum(); + let total_removed: usize = similar.iter().map(|&idx| files[idx].removed).sum(); + + if names.len() <= 3 { + result.push(format!("\n{}", names.join(", "))); + } else { + result.push(format!( + "\n{}, {} ... +{} similar files", + names[0], + names[1], + names.len() - 2 + )); + } + result.push(" (identical changes in each file)".to_string()); + for hunk_line in &f.hunks { + result.push(hunk_line.clone()); + if result.len() >= max_lines { + result.push("\n... (more changes truncated)".to_string()); + was_truncated = true; + break; + } + } + if f.added > 0 || f.removed > 0 { + result.push(format!( + " +{} -{} (total across {} files: +{} -{})", + f.added, + f.removed, + similar.len(), + total_added, + total_removed + )); + } - if !current_file.is_empty() && (added > 0 || removed > 0) { - result.push(format!(" +{} -{}", added, removed)); + // Skip all similar files + let similar_set: std::collections::HashSet = similar.into_iter().collect(); + i += 1; + while i < files.len() && similar_set.contains(&i) { + i += 1; + } + } else { + // Unique file — show normally + result.push(format!("\n{}", f.filename)); + for hunk_line in &f.hunks { + result.push(hunk_line.clone()); + if result.len() >= max_lines { + result.push("\n... (more changes truncated)".to_string()); + was_truncated = true; + break; + } + } + if f.added > 0 || f.removed > 0 { + result.push(format!(" +{} -{}", f.added, f.removed)); + } + i += 1; + } } - if was_truncated { + if was_truncated || any_hunk_truncated { result.push("[full diff: rtk git diff --no-compact]".to_string()); } @@ -520,7 +721,7 @@ fn filter_log_output( user_set_limit: bool, user_format: bool, ) -> String { - let truncate_width = if user_set_limit { 120 } else { 80 }; + let truncate_width = if user_set_limit { 120 } else { 100 }; // When user specified their own format (--oneline, --pretty, --format), // RTK did not inject ---END--- markers. Use simple line-based truncation. @@ -1313,11 +1514,11 @@ fn filter_branch_output(output: &str) -> String { .collect(); if !remote_only.is_empty() { result.push(format!(" remote-only ({}):", remote_only.len())); - for b in remote_only.iter().take(10) { + for b in remote_only.iter().take(5) { result.push(format!(" {}", b)); } - if remote_only.len() > 10 { - result.push(format!(" ... +{} more", remote_only.len() - 10)); + if remote_only.len() > 5 { + result.push(format!(" ... +{} more", remote_only.len() - 5)); } } } @@ -1423,8 +1624,7 @@ fn run_stash( timer.track("git stash show", "rtk git stash show", &raw, &filtered); } - Some("pop") | Some("apply") | Some("drop") | Some("push") => { - let sub = subcommand.unwrap(); + Some(sub @ ("pop" | "apply" | "drop" | "push")) => { let mut cmd = git_cmd(global_args); cmd.args(["stash", sub]); for arg in args { @@ -1534,10 +1734,13 @@ fn run_stash( Ok(0) } +const MAX_STASH_ENTRIES: usize = 20; + fn filter_stash_list(output: &str) -> String { // Format: "stash@{0}: WIP on main: abc1234 commit message" let mut result = Vec::new(); - for line in output.lines() { + let total = output.lines().count(); + for line in output.lines().take(MAX_STASH_ENTRIES) { if let Some(colon_pos) = line.find(": ") { let index = &line[..colon_pos]; let rest = &line[colon_pos + 2..]; @@ -1552,6 +1755,9 @@ fn filter_stash_list(output: &str) -> String { result.push(line.to_string()); } } + if total > MAX_STASH_ENTRIES { + result.push(format!("... +{} more stashes", total - MAX_STASH_ENTRIES)); + } result.join("\n") } @@ -1989,7 +2195,7 @@ A added.rs let result = filter_log_output(&long_line, 10, false, false); assert!(result.chars().count() < long_line.chars().count()); assert!(result.contains("...")); - assert!(result.chars().count() <= 80); + assert!(result.chars().count() <= 100); } #[test] @@ -2020,22 +2226,22 @@ A added.rs #[test] fn test_filter_log_output_user_limit_wider_truncation() { // When user explicitly passes -N, lines up to 120 chars should NOT be truncated - let line_90_chars = format!("abc1234 {} (2 days ago) ", "x".repeat(60)); - assert!(line_90_chars.chars().count() > 80); - assert!(line_90_chars.chars().count() < 120); + let line_110_chars = format!("abc1234 {} (2 days ago) ", "x".repeat(80)); + assert!(line_110_chars.chars().count() > 100); + assert!(line_110_chars.chars().count() < 120); - let result_default = filter_log_output(&line_90_chars, 10, false, false); - let result_user = filter_log_output(&line_90_chars, 10, true, false); + let result_default = filter_log_output(&line_110_chars, 10, false, false); + let result_user = filter_log_output(&line_110_chars, 10, true, false); - // Default truncates at 80 chars + // Default truncates at 100 chars assert!( result_default.contains("..."), - "Default should truncate at 80 chars" + "Default should truncate at 100 chars" ); // User-set limit uses wider threshold (120 chars) assert!( !result_user.contains("..."), - "User limit should not truncate 90-char line" + "User limit should not truncate 110-char line" ); } @@ -2425,4 +2631,51 @@ no changes added to commit (use "git add" and/or "git commit -a") result ); } + + #[test] + fn test_filter_stash_list_caps_at_20() { + let mut input = String::new(); + for i in 0..30 { + input.push_str(&format!( + "stash@{{{}}}: WIP on main: abc{:04x} commit {}\n", + i, i, i + )); + } + let result = filter_stash_list(&input); + assert!( + result.contains("... +10 more stashes"), + "Expected stash cap message, got:\n{}", + result + ); + // Should contain stash@{0} through stash@{19} but not stash@{20} + assert!(result.contains("stash@{0}:")); + assert!(result.contains("stash@{19}:")); + assert!(!result.contains("stash@{20}:")); + } + + #[test] + fn test_filter_stash_list_no_cap_under_limit() { + let input = "stash@{0}: WIP on main: abc1234 fix\nstash@{1}: On dev: def5678 wip\n"; + let result = filter_stash_list(input); + assert!(!result.contains("more stashes")); + } + + #[test] + fn test_filter_branch_remote_cap_at_5() { + let mut input = String::from("* main\n"); + for i in 0..15 { + input.push_str(&format!(" remotes/origin/feature-{}\n", i)); + } + let result = filter_branch_output(&input); + assert!( + result.contains("... +10 more"), + "Expected remote cap at 5 (15 remotes - 5 shown = 10 more), got:\n{}", + result + ); + // Should show first 5 remote-only branches + assert!(result.contains("feature-0")); + assert!(result.contains("feature-4")); + // Should NOT show 6th remote + assert!(!result.contains("feature-5")); + } } diff --git a/src/cmds/python/pip_cmd.rs b/src/cmds/python/pip_cmd.rs index 00090d425..66f59834f 100644 --- a/src/cmds/python/pip_cmd.rs +++ b/src/cmds/python/pip_cmd.rs @@ -174,7 +174,7 @@ fn filter_pip_list(output: &str) -> String { letters.sort(); for letter in letters { - let pkgs = by_letter.get(letter).unwrap(); + let Some(pkgs) = by_letter.get(letter) else { continue }; result.push_str(&format!("\n[{}]\n", letter.to_uppercase())); for pkg in pkgs.iter().take(10) { diff --git a/src/cmds/system/constants.rs b/src/cmds/system/constants.rs index 2454f5299..1389c1d16 100644 --- a/src/cmds/system/constants.rs +++ b/src/cmds/system/constants.rs @@ -25,4 +25,19 @@ pub const NOISE_DIRS: &[&str] = &[ ".vs", "*.egg-info", ".eggs", + // Build tool caches + ".gradle", + ".cargo", + ".npm", + ".yarn", + ".pnpm-store", + // Vendor directories + "vendor", + // IaC / framework caches + ".terraform", + ".angular", + ".svelte-kit", + ".nuxt", + // Test snapshots + "__snapshots__", ]; diff --git a/src/cmds/system/deps.rs b/src/cmds/system/deps.rs index 0342b2bef..36705bf47 100644 --- a/src/cmds/system/deps.rs +++ b/src/cmds/system/deps.rs @@ -75,9 +75,13 @@ pub fn run(path: &Path, verbose: u8) -> Result<()> { fn summarize_cargo_str(path: &Path) -> Result { let content = fs::read_to_string(path)?; - let dep_re = - Regex::new(r#"^([a-zA-Z0-9_-]+)\s*=\s*(?:"([^"]+)"|.*version\s*=\s*"([^"]+)")"#).unwrap(); - let section_re = Regex::new(r"^\[([^\]]+)\]").unwrap(); + lazy_static::lazy_static! { + static ref DEP_RE: Regex = + Regex::new(r#"^([a-zA-Z0-9_-]+)\s*=\s*(?:"([^"]+)"|.*version\s*=\s*"([^"]+)")"#).unwrap(); + static ref SECTION_RE: Regex = Regex::new(r"^\[([^\]]+)\]").unwrap(); + } + let dep_re = &*DEP_RE; + let section_re = &*SECTION_RE; let mut current_section = String::new(); let mut deps = Vec::new(); let mut dev_deps = Vec::new(); @@ -164,7 +168,10 @@ fn summarize_package_json_str(path: &Path) -> Result { fn summarize_requirements_str(path: &Path) -> Result { let content = fs::read_to_string(path)?; - let dep_re = Regex::new(r"^([a-zA-Z0-9_-]+)([=<>!~]+.*)?$").unwrap(); + lazy_static::lazy_static! { + static ref REQ_DEP_RE: Regex = Regex::new(r"^([a-zA-Z0-9_-]+)([=<>!~]+.*)?$").unwrap(); + } + let dep_re = &*REQ_DEP_RE; let mut deps = Vec::new(); let mut out = String::new(); diff --git a/src/cmds/system/find_cmd.rs b/src/cmds/system/find_cmd.rs index 942843fba..c6b52af36 100644 --- a/src/cmds/system/find_cmd.rs +++ b/src/cmds/system/find_cmd.rs @@ -345,6 +345,38 @@ pub fn run( println!("+{} more", total_files - shown); } + // Repeated-filename summary: group files with the same name across ≥3 dirs + let mut by_name: HashMap> = HashMap::new(); + for file in &files { + let p = Path::new(file); + let name = p + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .unwrap_or_default(); + let dir = p + .parent() + .map(|d| d.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + by_name.entry(name).or_default().push(dir); + } + + let mut frequent: Vec<(String, usize)> = by_name + .iter() + .filter(|(_, dirs)| dirs.len() >= 3) + .map(|(name, dirs)| (name.clone(), dirs.len())) + .collect(); + frequent.sort_by(|a, b| b.1.cmp(&a.1)); + + if !frequent.is_empty() { + println!(); + let summary: Vec = frequent + .iter() + .take(5) + .map(|(name, count)| format!("{}({})", name, count)) + .collect(); + println!("repeated: {}", summary.join(" ")); + } + // Extension summary let mut by_ext: HashMap = HashMap::new(); for file in &files { diff --git a/src/cmds/system/grep_cmd.rs b/src/cmds/system/grep_cmd.rs index a4119247f..bd046bcea 100644 --- a/src/cmds/system/grep_cmd.rs +++ b/src/cmds/system/grep_cmd.rs @@ -21,6 +21,13 @@ pub fn run( ) -> Result { let timer = tracking::TimedExecution::start(); + // Check for --no-compact flag to bypass deduplication + let no_compact = extra_args.iter().any(|a| a == "--no-compact"); + let extra_args: Vec<&String> = extra_args + .iter() + .filter(|a| a.as_str() != "--no-compact") + .collect(); + if verbose > 0 { eprintln!("grep: '{}' in {}", pattern, path); } @@ -107,10 +114,86 @@ pub fn run( by_file.entry(file).or_default().push((line_num, cleaned)); } + // --- Cross-file deduplication (skipped with --no-compact) --- + let cross_file_contents: Vec<(String, Vec<(String, usize)>)>; + let deduped: std::collections::HashSet<(String, usize)>; + + if no_compact { + cross_file_contents = Vec::new(); + deduped = std::collections::HashSet::new(); + } else { + // Build reverse map: content → [(file, line_num)] + let mut content_to_files: HashMap> = HashMap::new(); + for (file, matches) in &by_file { + for (line_num, content) in matches { + content_to_files + .entry(content.clone()) + .or_default() + .push((file.clone(), *line_num)); + } + } + + // Identify cross-file matches (same content in ≥3 files) + let mut cfc: Vec<(String, Vec<(String, usize)>)> = content_to_files + .into_iter() + .filter(|(_, locations)| { + let unique_files: std::collections::HashSet<&str> = + locations.iter().map(|(f, _)| f.as_str()).collect(); + unique_files.len() >= 3 + }) + .collect(); + cfc.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + + // Track which (file, line_num) pairs are covered by cross-file groups + let mut d: std::collections::HashSet<(String, usize)> = + std::collections::HashSet::new(); + for (_, locations) in &cfc { + for loc in locations { + d.insert(loc.clone()); + } + } + cross_file_contents = cfc; + deduped = d; + } + let mut rtk_output = String::new(); rtk_output.push_str(&format!("{} matches in {}F:\n\n", total, by_file.len())); let mut shown = 0; + + // Show cross-file groups first + if !cross_file_contents.is_empty() { + for (content, locations) in &cross_file_contents { + if shown >= max_results { + break; + } + let unique_files: std::collections::HashSet<&str> = + locations.iter().map(|(f, _)| f.as_str()).collect(); + let mut file_list: Vec<&str> = unique_files.into_iter().collect(); + file_list.sort(); + + if file_list.len() <= 3 { + let names: Vec = file_list.iter().map(|f| compact_path(f)).collect(); + rtk_output.push_str(&format!( + "[{}F] {}:\n", + file_list.len(), + names.join(", ") + )); + } else { + rtk_output.push_str(&format!( + "[{}F] {}, {} ... +{} more:\n", + file_list.len(), + compact_path(file_list[0]), + compact_path(file_list[1]), + file_list.len() - 2 + )); + } + rtk_output.push_str(&format!(" {}\n\n", content)); + shown += locations.len(); + } + } + + // Show remaining per-file matches (excluding deduped ones) let mut files: Vec<_> = by_file.iter().collect(); files.sort_by_key(|(f, _)| *f); @@ -119,11 +202,21 @@ pub fn run( break; } + // Filter out matches already shown in cross-file groups + let remaining: Vec<&(usize, String)> = matches + .iter() + .filter(|(ln, _)| !deduped.contains(&(file.clone(), *ln))) + .collect(); + + if remaining.is_empty() { + continue; + } + let file_display = compact_path(file); - rtk_output.push_str(&format!("[file] {} ({}):\n", file_display, matches.len())); + rtk_output.push_str(&format!("[file] {} ({}):\n", file_display, remaining.len())); let per_file = config::limits().grep_max_per_file; - for (line_num, content) in matches.iter().take(per_file) { + for (line_num, content) in remaining.iter().take(per_file) { rtk_output.push_str(&format!(" {:>4}: {}\n", line_num, content)); shown += 1; if shown >= max_results { @@ -131,8 +224,8 @@ pub fn run( } } - if matches.len() > per_file { - rtk_output.push_str(&format!(" +{}\n", matches.len() - per_file)); + if remaining.len() > per_file { + rtk_output.push_str(&format!(" +{}\n", remaining.len() - per_file)); } rtk_output.push('\n'); } @@ -279,6 +372,71 @@ mod tests { assert_eq!(filtered[0], "-i"); } + #[test] + fn test_cross_file_dedup_groups_identical_content() { + // Simulate the dedup logic: same content in 3+ files → grouped + let mut by_file: HashMap> = HashMap::new(); + by_file.insert( + "a.rs".to_string(), + vec![(1, "use std::fmt;".to_string()), (5, "unique_a".to_string())], + ); + by_file.insert( + "b.rs".to_string(), + vec![(1, "use std::fmt;".to_string())], + ); + by_file.insert( + "c.rs".to_string(), + vec![(1, "use std::fmt;".to_string())], + ); + by_file.insert( + "d.rs".to_string(), + vec![(1, "use std::fmt;".to_string())], + ); + + // Build content_to_files + let mut content_to_files: HashMap> = HashMap::new(); + for (file, matches) in &by_file { + for (ln, content) in matches { + content_to_files + .entry(content.clone()) + .or_default() + .push((file.clone(), *ln)); + } + } + + let cross: Vec<_> = content_to_files + .iter() + .filter(|(_, locs)| { + let files: std::collections::HashSet<&str> = + locs.iter().map(|(f, _)| f.as_str()).collect(); + files.len() >= 3 + }) + .collect(); + + assert_eq!(cross.len(), 1, "Only 'use std::fmt;' should be grouped"); + assert_eq!(cross[0].0, "use std::fmt;"); + assert_eq!(cross[0].1.len(), 4, "Should appear in 4 files"); + } + + #[test] + fn test_cross_file_dedup_no_group_under_threshold() { + // Content in only 2 files should NOT be grouped + let mut content_to_files: HashMap> = HashMap::new(); + content_to_files.insert( + "import foo".to_string(), + vec![("a.rs".to_string(), 1), ("b.rs".to_string(), 1)], + ); + let cross: Vec<_> = content_to_files + .iter() + .filter(|(_, locs)| { + let files: std::collections::HashSet<&str> = + locs.iter().map(|(f, _)| f.as_str()).collect(); + files.len() >= 3 + }) + .collect(); + assert!(cross.is_empty(), "2 files should not trigger grouping"); + } + // --- truncation accuracy --- #[test] diff --git a/src/cmds/system/local_llm.rs b/src/cmds/system/local_llm.rs index 20ac7c18f..e3aa9fd5b 100644 --- a/src/cmds/system/local_llm.rs +++ b/src/cmds/system/local_llm.rs @@ -139,7 +139,10 @@ fn extract_imports(content: &str, lang: &Language) -> Vec { _ => return Vec::new(), }; - let re = Regex::new(pattern).unwrap(); + let re = match Regex::new(pattern) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; let mut imports = Vec::new(); let mut seen = std::collections::HashSet::new(); @@ -178,7 +181,10 @@ fn extract_functions(content: &str, lang: &Language) -> Vec { _ => return Vec::new(), }; - let re = Regex::new(pattern).unwrap(); + let re = match Regex::new(pattern) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; let mut functions = Vec::new(); for line in content.lines() { @@ -205,7 +211,10 @@ fn extract_structs(content: &str, lang: &Language) -> Vec { _ => return Vec::new(), }; - let re = Regex::new(pattern).unwrap(); + let re = match Regex::new(pattern) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; re.captures_iter(content) .filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string())) .take(10) @@ -219,7 +228,10 @@ fn extract_traits(content: &str, lang: &Language) -> Vec { _ => return Vec::new(), }; - let re = Regex::new(pattern).unwrap(); + let re = match Regex::new(pattern) { + Ok(r) => r, + Err(_) => return Vec::new(), + }; re.captures_iter(content) .filter_map(|caps| caps.get(1).map(|m| m.as_str().to_string())) .take(5) diff --git a/src/cmds/system/ls.rs b/src/cmds/system/ls.rs index a69c271fe..b9192b85d 100644 --- a/src/cmds/system/ls.rs +++ b/src/cmds/system/ls.rs @@ -156,20 +156,39 @@ fn compact_ls(raw: &str, show_all: bool) -> (String, String) { return ("(empty)\n".to_string(), String::new()); } + const MAX_LS_ENTRIES: usize = 50; + let total_entries = dirs.len() + files.len(); + let mut entries = String::new(); + let mut shown = 0usize; // Dirs first, compact for d in &dirs { + if shown >= MAX_LS_ENTRIES { + break; + } entries.push_str(d); entries.push_str("/\n"); + shown += 1; } // Files with size for (name, size) in &files { + if shown >= MAX_LS_ENTRIES { + break; + } entries.push_str(name); entries.push_str(" "); entries.push_str(size); entries.push('\n'); + shown += 1; + } + + if total_entries > MAX_LS_ENTRIES { + entries.push_str(&format!( + "... +{} more entries\n", + total_entries - MAX_LS_ENTRIES + )); } // Summary line (separate so caller can suppress when piped) @@ -325,4 +344,38 @@ mod tests { line_count ); } + + #[test] + fn test_compact_ls_caps_at_50() { + // Generate 60 files to exceed the MAX_LS_ENTRIES cap + let mut input = String::from("total 1000\n"); + for i in 0..60 { + input.push_str(&format!( + "-rw-r--r-- 1 user staff 100 Jan 1 12:00 file_{:03}.txt\n", + i + )); + } + let (entries, _summary) = compact_ls(&input, false); + assert!( + entries.contains("... +10 more entries"), + "should truncate at 50 entries, got:\n{}", + entries + ); + // Should show exactly 50 file lines + 1 truncation line + assert_eq!(entries.lines().count(), 51); + } + + #[test] + fn test_compact_ls_no_cap_under_limit() { + let mut input = String::from("total 100\n"); + for i in 0..10 { + input.push_str(&format!( + "-rw-r--r-- 1 user staff 100 Jan 1 12:00 file_{}.txt\n", + i + )); + } + let (entries, _summary) = compact_ls(&input, false); + assert!(!entries.contains("... +")); + assert_eq!(entries.lines().count(), 10); + } } diff --git a/src/cmds/system/read.rs b/src/cmds/system/read.rs index 2f56687ee..663ad5dfb 100644 --- a/src/cmds/system/read.rs +++ b/src/cmds/system/read.rs @@ -1,5 +1,6 @@ //! Reads source files with optional language-aware filtering to strip boilerplate. +use crate::cmds::system::json_cmd; use crate::core::filter::{self, FilterLevel, Language}; use crate::core::tracking; use anyhow::{Context, Result}; @@ -24,6 +25,26 @@ pub fn run( let content = fs::read_to_string(file) .with_context(|| format!("Failed to read file: {}", file.display()))?; + // Lockfile shortcut: show summary instead of full content + if is_lockfile(file) && level != FilterLevel::None { + let line_count = content.lines().count(); + let size_kb = content.len() as f64 / 1024.0; + let summary = format!( + "(lockfile: {} lines, {:.1} kB — use `cat {}` for full content)", + line_count, + size_kb, + file.display() + ); + println!("{}", summary); + timer.track( + &format!("cat {}", file.display()), + "rtk read", + &content, + &summary, + ); + return Ok(()); + } + // Detect language from extension let lang = file .extension() @@ -35,9 +56,22 @@ pub fn run( eprintln!("Detected language: {:?}", lang); } - // Apply filter - let filter = filter::get_filter(level); - let mut filtered = filter.filter(&content, &lang); + // Apply filter — for JSON files, use the compact JSON filter for better token savings + let is_json = file + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|ext| ext == "json" || ext == "jsonc"); + + let mut filtered = if is_json && matches!(level, FilterLevel::Minimal | FilterLevel::Smart) { + json_cmd::filter_json_compact(&content, 10).unwrap_or_else(|_| { + // If JSON parsing fails, fall back to generic filter + let filter = filter::get_filter(level); + filter.filter(&content, &lang) + }) + } else { + let filter = filter::get_filter(level); + filter.filter(&content, &lang) + }; // Safety: if filter emptied a non-empty file, fall back to raw content if filtered.trim().is_empty() && !content.trim().is_empty() { @@ -176,6 +210,31 @@ fn apply_line_window( content.to_string() } +/// Detect common lockfile names by filename (not just extension). +fn is_lockfile(path: &Path) -> bool { + const LOCKFILE_NAMES: &[&str] = &[ + "Cargo.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "Gemfile.lock", + "Pipfile.lock", + "poetry.lock", + "composer.lock", + "go.sum", + "flake.lock", + "packages.lock.json", + "pdm.lock", + "uv.lock", + "bun.lockb", + ]; + let name = path + .file_name() + .and_then(|f| f.to_str()) + .unwrap_or(""); + LOCKFILE_NAMES.contains(&name) +} + #[cfg(test)] mod tests { use super::*; @@ -219,6 +278,31 @@ fn main() {{ assert_eq!(output, "c\nd"); } + #[test] + fn test_read_json_uses_compact_filter() -> Result<()> { + let mut file = NamedTempFile::with_suffix(".json")?; + // Write a JSON with a large array (>5 items) to trigger compact truncation + writeln!( + file, + r#"{{"items": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10], "name": "test"}}"# + )?; + // Should not panic and should use compact JSON filter + run(file.path(), FilterLevel::Minimal, None, None, false, 0)?; + Ok(()) + } + + #[test] + fn test_read_json_compact_truncates_arrays() { + let json = r#"{"items": [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]}"#; + let result = json_cmd::filter_json_compact(json, 10).unwrap(); + // Array with 10 items should be truncated (>5 items triggers summary) + assert!( + result.contains("more"), + "Expected array truncation, got:\n{}", + result + ); + } + #[test] fn test_apply_line_window_max_lines_still_works() { let input = "a\nb\nc\nd\n"; @@ -296,4 +380,59 @@ fn main() {{ stderr ); } + + #[test] + fn test_is_lockfile_known_names() { + for name in [ + "Cargo.lock", + "package-lock.json", + "yarn.lock", + "pnpm-lock.yaml", + "Gemfile.lock", + "poetry.lock", + "go.sum", + "uv.lock", + ] { + assert!( + is_lockfile(Path::new(name)), + "{} should be detected as lockfile", + name + ); + } + } + + #[test] + fn test_is_lockfile_non_lockfile() { + for name in ["Cargo.toml", "package.json", "main.rs", "go.mod"] { + assert!( + !is_lockfile(Path::new(name)), + "{} should NOT be a lockfile", + name + ); + } + } + + #[test] + fn test_lockfile_shows_summary() -> Result<()> { + // Create a fake lockfile with many lines + let dir = tempfile::tempdir()?; + let lock_path = dir.path().join("Cargo.lock"); + let content = "line\n".repeat(500); + fs::write(&lock_path, &content)?; + + // With Minimal filter, lockfile should produce summary + run(&lock_path, FilterLevel::Minimal, None, None, false, 0)?; + Ok(()) + } + + #[test] + fn test_lockfile_none_filter_shows_full() -> Result<()> { + let dir = tempfile::tempdir()?; + let lock_path = dir.path().join("Cargo.lock"); + fs::write(&lock_path, "line1\nline2\n")?; + + // With None filter, lockfile should show full content + run(&lock_path, FilterLevel::None, None, None, false, 0)?; + Ok(()) + } } diff --git a/src/cmds/system/tree.rs b/src/cmds/system/tree.rs index 576e6c800..a52ac810f 100644 --- a/src/cmds/system/tree.rs +++ b/src/cmds/system/tree.rs @@ -62,6 +62,8 @@ pub fn run(args: &[String], verbose: u8) -> Result { ) } +const MAX_TREE_LINES: usize = 200; + fn filter_tree_output(raw: &str) -> String { let lines: Vec<&str> = raw.lines().collect(); @@ -90,6 +92,17 @@ fn filter_tree_output(raw: &str) -> String { filtered_lines.pop(); } + // Cap output at MAX_TREE_LINES to prevent token explosion in large repos + if filtered_lines.len() > MAX_TREE_LINES { + let total = filtered_lines.len(); + let mut result = filtered_lines[..MAX_TREE_LINES].join("\n"); + result.push_str(&format!( + "\n... +{} more entries (use native `tree` for full output)\n", + total - MAX_TREE_LINES + )); + return result; + } + filtered_lines.join("\n") + "\n" } @@ -155,6 +168,36 @@ mod tests { } } + #[test] + fn test_filter_caps_at_max_lines() { + // Generate 300 lines of tree output + let mut input = String::from(".\n"); + for i in 0..300 { + input.push_str(&format!("├── file{}.rs\n", i)); + } + input.push_str("\n300 directories, 300 files\n"); + + let output = filter_tree_output(&input); + let line_count = output.lines().count(); + // Should be capped: MAX_TREE_LINES + the "... +N more" line + assert!( + line_count <= MAX_TREE_LINES + 2, + "Expected capped output, got {} lines", + line_count + ); + assert!( + output.contains("more entries"), + "Expected truncation message" + ); + } + + #[test] + fn test_filter_no_cap_under_limit() { + 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("more entries")); + } + #[test] fn test_noise_dirs_constant() { // Verify NOISE_DIRS contains expected patterns diff --git a/src/core/filter.rs b/src/core/filter.rs index de74368c7..4e8b67ac4 100644 --- a/src/core/filter.rs +++ b/src/core/filter.rs @@ -8,6 +8,7 @@ use std::str::FromStr; pub enum FilterLevel { None, Minimal, + Smart, Aggressive, } @@ -18,6 +19,7 @@ impl FromStr for FilterLevel { match s.to_lowercase().as_str() { "none" => Ok(FilterLevel::None), "minimal" => Ok(FilterLevel::Minimal), + "smart" => Ok(FilterLevel::Smart), "aggressive" => Ok(FilterLevel::Aggressive), _ => Err(format!("Unknown filter level: {}", s)), } @@ -29,6 +31,7 @@ impl std::fmt::Display for FilterLevel { match self { FilterLevel::None => write!(f, "none"), FilterLevel::Minimal => write!(f, "minimal"), + FilterLevel::Smart => write!(f, "smart"), FilterLevel::Aggressive => write!(f, "aggressive"), } } @@ -230,7 +233,7 @@ impl FilterStrategy for MinimalFilter { } } -pub struct AggressiveFilter; +pub struct SmartFilter; lazy_static! { static ref IMPORT_PATTERN: Regex = @@ -239,8 +242,203 @@ lazy_static! { r"^(pub\s+)?(async\s+)?(fn|def|function|func|class|struct|enum|trait|interface|type)\s+\w+" ) .unwrap(); + static ref TEST_PATTERN: Regex = Regex::new( + r"(#\[test\]|#\[cfg\(test\)\]|fn test_|def test_|it\(|test\(|describe\()" + ) + .unwrap(); + static ref DOC_COMMENT_PATTERN: Regex = + Regex::new(r"^(///|//!|/\*\*|#\s|##\s|\*\s|@\w+)").unwrap(); + static ref MULTI_BLANK_SMART: Regex = Regex::new(r"\n{3,}").unwrap(); +} + +impl FilterStrategy for SmartFilter { + fn filter(&self, content: &str, lang: &Language) -> String { + // Data formats: delegate to MinimalFilter (no smart collapsing) + if *lang == Language::Data { + return MinimalFilter.filter(content, lang); + } + + // Start with minimal filtering (strip comments, normalize blanks) + let minimal = MinimalFilter.filter(content, lang); + let lines: Vec<&str> = minimal.lines().collect(); + let mut result: Vec = Vec::with_capacity(lines.len()); + + let mut i = 0; + while i < lines.len() { + let trimmed = lines[i].trim(); + + // --- Collapse import blocks (>10 consecutive → keep first 5 + last 2) --- + if IMPORT_PATTERN.is_match(trimmed) { + let import_start = i; + while i < lines.len() { + let t = lines[i].trim(); + if t.is_empty() || IMPORT_PATTERN.is_match(t) { + i += 1; + } else { + break; + } + } + let import_end = i; + let import_lines: Vec<&str> = lines[import_start..import_end] + .iter() + .filter(|l| !l.trim().is_empty()) + .copied() + .collect(); + + if import_lines.len() > 10 { + for line in import_lines.iter().take(5) { + result.push(line.to_string()); + } + result.push(format!( + "// ... +{} more imports", + import_lines.len() - 7 + )); + for line in import_lines.iter().skip(import_lines.len() - 2) { + result.push(line.to_string()); + } + } else { + for line in &import_lines { + result.push(line.to_string()); + } + } + continue; + } + + // --- Collapse test blocks --- + if TEST_PATTERN.is_match(trimmed) { + let test_start = i; + let mut test_count = 0; + let mut test_blocks: Vec<(usize, usize)> = Vec::new(); + + // Scan ahead to collect consecutive test functions + while i < lines.len() { + let t = lines[i].trim(); + + // Is this a test marker or test function start? + if TEST_PATTERN.is_match(t) { + test_count += 1; + let block_start = i; + // Find the end of this test block (track braces/indentation) + let mut brace_depth = 0i32; + let mut found_brace = false; + i += 1; + while i < lines.len() { + let bt = lines[i].trim(); + brace_depth += bt.matches('{').count() as i32; + brace_depth -= bt.matches('}').count() as i32; + if bt.contains('{') { + found_brace = true; + } + i += 1; + if found_brace && brace_depth <= 0 { + break; + } + // Python/JS: no braces, use indentation + if !found_brace + && i < lines.len() + && !lines[i].trim().is_empty() + && !lines[i].starts_with(' ') + && !lines[i].starts_with('\t') + { + break; + } + } + test_blocks.push((block_start, i)); + } else if t.is_empty() { + i += 1; + } else { + break; + } + } + + if test_count > 2 { + // Keep first 2 test blocks, summarize the rest + for &(start, end) in test_blocks.iter().take(2) { + for line in &lines[start..end] { + result.push(line.to_string()); + } + } + result.push(format!( + "// ... +{} more tests", + test_count - 2 + )); + } else { + // ≤2 tests: keep everything + for line in &lines[test_start..i] { + result.push(line.to_string()); + } + } + continue; + } + + // --- Collapse doc-comment blocks (>15 consecutive lines) --- + if DOC_COMMENT_PATTERN.is_match(trimmed) { + let doc_start = i; + while i < lines.len() { + let t = lines[i].trim(); + if t.is_empty() + || DOC_COMMENT_PATTERN.is_match(t) + || t == "*/" + || t == "*" + { + i += 1; + } else { + break; + } + } + let doc_lines: Vec<&str> = lines[doc_start..i] + .iter() + .filter(|l| !l.trim().is_empty()) + .copied() + .collect(); + + if doc_lines.len() > 15 { + for line in doc_lines.iter().take(3) { + result.push(line.to_string()); + } + result.push(format!( + "// ... +{} more doc lines", + doc_lines.len() - 3 + )); + } else { + for line in &doc_lines { + result.push(line.to_string()); + } + } + continue; + } + + // --- Truncate long string literals (>200 chars) --- + if trimmed.len() > 200 { + // Check if this looks like a string/array literal line + let has_long_string = trimmed.contains('"') && trimmed.len() > 200; + if has_long_string { + let truncated: String = trimmed.chars().take(120).collect(); + let indent = lines[i].len() - lines[i].trim_start().len(); + let prefix = &lines[i][..indent]; + result.push(format!( + "{}{}... (line truncated, {} chars total)", + prefix, + truncated, + trimmed.len() + )); + i += 1; + continue; + } + } + + result.push(lines[i].to_string()); + i += 1; + } + + // Normalize consecutive blank lines (3+ → 2) + let joined = result.join("\n").trim().to_string(); + MULTI_BLANK_SMART.replace_all(&joined, "\n\n").to_string() + } } +pub struct AggressiveFilter; + impl FilterStrategy for AggressiveFilter { fn filter(&self, content: &str, lang: &Language) -> String { // Data formats (JSON, YAML, etc.) must never be code-filtered @@ -316,6 +514,7 @@ pub fn get_filter(level: FilterLevel) -> Box { match level { FilterLevel::None => Box::new(NoFilter), FilterLevel::Minimal => Box::new(MinimalFilter), + FilterLevel::Smart => Box::new(SmartFilter), FilterLevel::Aggressive => Box::new(AggressiveFilter), } } @@ -382,6 +581,10 @@ mod tests { FilterLevel::from_str("minimal").unwrap(), FilterLevel::Minimal ); + assert_eq!( + FilterLevel::from_str("smart").unwrap(), + FilterLevel::Smart + ); assert_eq!( FilterLevel::from_str("aggressive").unwrap(), FilterLevel::Aggressive @@ -480,6 +683,158 @@ fn main() { assert!(result.contains("fn main()")); } + // --- SmartFilter tests --- + + #[test] + fn test_smart_filter_collapses_imports() { + let mut code = String::new(); + for i in 0..15 { + code.push_str(&format!("use crate::module{};\n", i)); + } + code.push_str("\nfn main() {}\n"); + + let filter = SmartFilter; + let result = filter.filter(&code, &Language::Rust); + // Should have collapsed 15 imports to 5 + summary + 2 + assert!( + result.contains("more imports"), + "Expected import collapse summary, got:\n{}", + result + ); + // First 5 should be present + assert!(result.contains("use crate::module0;")); + assert!(result.contains("use crate::module4;")); + // Last 2 should be present + assert!(result.contains("use crate::module13;")); + assert!(result.contains("use crate::module14;")); + // Middle ones should NOT be present + assert!(!result.contains("use crate::module6;")); + } + + #[test] + fn test_smart_filter_keeps_small_import_blocks() { + let code = "use std::fmt;\nuse std::io;\nuse std::fs;\n\nfn main() {}\n"; + let filter = SmartFilter; + let result = filter.filter(code, &Language::Rust); + // Only 3 imports — should keep all + assert!(!result.contains("more imports")); + assert!(result.contains("use std::fmt;")); + assert!(result.contains("use std::io;")); + assert!(result.contains("use std::fs;")); + } + + #[test] + fn test_smart_filter_collapses_tests() { + let code = r#" +fn main() {} + +#[test] +fn test_one() { + assert!(true); +} + +#[test] +fn test_two() { + assert!(true); +} + +#[test] +fn test_three() { + assert!(true); +} + +#[test] +fn test_four() { + assert!(true); +} +"#; + let filter = SmartFilter; + let result = filter.filter(code, &Language::Rust); + // Should keep first 2 tests, collapse the rest + assert!( + result.contains("more tests"), + "Expected test collapse summary, got:\n{}", + result + ); + assert!(result.contains("fn test_one()")); + assert!(result.contains("fn test_two()")); + // test_three and test_four should be collapsed + assert!(!result.contains("fn test_three()")); + } + + #[test] + fn test_smart_filter_truncates_long_strings() { + let long = format!( + " let s = \"{}\";", + "x".repeat(250) + ); + let code = format!("fn main() {{\n{}\n}}\n", long); + let filter = SmartFilter; + let result = filter.filter(&code, &Language::Rust); + assert!( + result.contains("line truncated"), + "Expected long string truncation, got:\n{}", + result + ); + } + + #[test] + fn test_smart_filter_data_delegates_to_minimal() { + let json = r#"{"key": "value"}"#; + let smart = SmartFilter.filter(json, &Language::Data); + let minimal = MinimalFilter.filter(json, &Language::Data); + assert_eq!(smart, minimal); + } + + #[test] + fn test_smart_filter_collapses_long_doc_comments() { + let mut code = String::from("fn main() {}\n\n"); + // 20 doc-comment lines + for i in 0..20 { + code.push_str(&format!("/// Doc line {}\n", i)); + } + code.push_str("fn documented() {}\n"); + + let filter = SmartFilter; + let result = filter.filter(&code, &Language::Rust); + assert!( + result.contains("more doc lines"), + "Expected doc-comment collapse, got:\n{}", + result + ); + // First 3 should be kept + assert!(result.contains("/// Doc line 0")); + assert!(result.contains("/// Doc line 2")); + // Later ones should be collapsed + assert!(!result.contains("/// Doc line 10")); + } + + #[test] + fn test_smart_filter_keeps_short_doc_comments() { + let code = "/// Short doc\n/// Second line\nfn foo() {}\n"; + let filter = SmartFilter; + let result = filter.filter(code, &Language::Rust); + assert!(!result.contains("more doc lines")); + assert!(result.contains("/// Short doc")); + assert!(result.contains("/// Second line")); + } + + #[test] + fn test_smart_filter_normalizes_blank_lines() { + // After stripping comments, we might end up with 3+ blank lines + let code = "fn a() {}\n\n\n\n\n\nfn b() {}\n"; + let filter = SmartFilter; + let result = filter.filter(code, &Language::Rust); + // Should not have 3+ consecutive newlines + assert!( + !result.contains("\n\n\n"), + "Should collapse 3+ blanks, got:\n{:?}", + result + ); + assert!(result.contains("fn a()")); + assert!(result.contains("fn b()")); + } + // --- truncation accuracy --- #[test] diff --git a/src/core/toml_filter.rs b/src/core/toml_filter.rs index bd294f8af..f10917cf1 100644 --- a/src/core/toml_filter.rs +++ b/src/core/toml_filter.rs @@ -1613,8 +1613,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 58, - "Expected exactly 58 built-in filters, got {}. \ + 61, + "Expected exactly 61 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1671,11 +1671,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 58 existing filters still present + 1 new = 59 + // All 61 existing filters still present + 1 new = 62 assert_eq!( filters.len(), - 59, - "Expected 59 filters after concat (58 built-in + 1 new)" + 62, + "Expected 62 filters after concat (61 built-in + 1 new)" ); // New filter is discoverable diff --git a/src/filters/docker-build.toml b/src/filters/docker-build.toml new file mode 100644 index 000000000..a2b038505 --- /dev/null +++ b/src/filters/docker-build.toml @@ -0,0 +1,56 @@ +[filters.docker-build] +description = "Compact docker build output — strip layer downloads, progress bars, and cache metadata" +match_command = "^docker\\s+(build|buildx\\s+build)\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^#\\d+\\s+\\[internal\\]", + "^#\\d+\\s+sha256:", + "^#\\d+\\s+\\d+\\.\\d+s done$", + "^#\\d+\\s+extracting", + "^#\\d+\\s+downloading", + "^#\\d+\\s+resolve", + "^#\\d+\\s+transferring", + "^\\s*=>\\s*\\[internal\\]", + "^\\s*=>\\s*resolve", + "^\\s*=>\\s*sha256:", + "^\\s*=>\\s*extracting", + "^\\s*=>\\s*CACHED", + "^\\s*=>\\s*transferring", +] +match_output = [ + { pattern = "exporting to image", message = "ok (image built)" }, +] +max_lines = 40 + +[[tests.docker-build]] +name = "strips layer download lines" +input = """ +#1 [internal] load build definition from Dockerfile +#1 sha256:abc123def456 +#1 transferring dockerfile: 1.2kB +#1 0.1s done +#2 [internal] load .dockerignore +#2 sha256:def456abc789 +#2 0.0s done +#3 [1/4] FROM docker.io/library/node:18@sha256:abc +#3 resolve docker.io/library/node:18@sha256:abc +#3 sha256:abc 0.0s done +#4 [2/4] WORKDIR /app +#5 [3/4] COPY package.json . +#6 [4/4] RUN npm install +#6 npm warn deprecated package@1.0.0 +#7 exporting to image +""" +expected = "#3 [1/4] FROM docker.io/library/node:18@sha256:abc\n#4 [2/4] WORKDIR /app\n#5 [3/4] COPY package.json .\n#6 [4/4] RUN npm install\n#6 npm warn deprecated package@1.0.0\n#7 exporting to image" + +[[tests.docker-build]] +name = "short-circuits on successful build" +input = """ +#1 [internal] load build definition +#2 [1/2] FROM node:18 +#3 [2/2] COPY . . +#4 exporting to image +#4 exporting layers done +""" +expected = "ok (image built)" diff --git a/src/filters/flutter-build.toml b/src/filters/flutter-build.toml new file mode 100644 index 000000000..2d325b889 --- /dev/null +++ b/src/filters/flutter-build.toml @@ -0,0 +1,46 @@ +[filters.flutter-build] +description = "Compact flutter build output — strip download progress and verbose compilation lines" +match_command = "^flutter\\s+(build|run)\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^Downloading ", + "^\\s*\\[\\s*\\d+ms\\]", + "^Running Gradle task", + "^✓\\s+Built ", + "^\\s*Compiling, linking", + "^\\s*\\[\\s*\\+\\d+\\s*ms\\]", + "^\\s*Resolving dependencies", + "^\\s*Got dependencies", +] +match_output = [ + { pattern = "Built build/", message = "ok (built)", unless = "Error|FAILURE|error:" }, +] +max_lines = 30 + +[[tests.flutter-build]] +name = "successful build short-circuits" +input = """ +Running Gradle task 'assembleRelease'... +Resolving dependencies... +Got dependencies! +Compiling, linking and signing... +Built build/app/outputs/flutter-apk/app-release.apk (18.2MB) +""" +expected = "ok (built)" + +[[tests.flutter-build]] +name = "strips download and progress lines" +input = """ +Downloading Material fonts... +Downloading Gradle Wrapper... +[ +8 ms] executing: /usr/bin/xcode-select --print-path +[ +37 ms] Exit code 0 from: /usr/bin/xcode-select --print-path +Running Gradle task 'assembleDebug'... +Resolving dependencies... +Got dependencies! +Launching lib/main.dart on iPhone 15 in debug mode... +Xcode build done. 8.3s +Syncing files to device iPhone 15... +""" +expected = "Launching lib/main.dart on iPhone 15 in debug mode...\nXcode build done. 8.3s\nSyncing files to device iPhone 15..." diff --git a/src/filters/pip-install.toml b/src/filters/pip-install.toml new file mode 100644 index 000000000..33665b46c --- /dev/null +++ b/src/filters/pip-install.toml @@ -0,0 +1,50 @@ +[filters.pip-install] +description = "Compact pip install output — strip downloads, progress bars, and metadata" +match_command = "^pip3?\\s+install\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s*Downloading ", + "^\\s*Using cached ", + "^\\s*━+", + "^\\s*Collecting ", + "^\\s*Preparing metadata", + "^\\s*Building wheel", + "^\\s*Created wheel", + "^\\s*Stored in directory", + "^\\[notice\\]", +] +match_output = [ + { pattern = "already satisfied", message = "ok (already installed)", unless = "ERROR|error:" }, +] +max_lines = 30 + +[[tests.pip-install]] +name = "already installed short-circuits" +input = """ +Requirement already satisfied: requests in /usr/lib/python3/dist-packages (2.28.1) +Requirement already satisfied: urllib3 in /usr/lib/python3/dist-packages (1.26.15) +""" +expected = "ok (already installed)" + +[[tests.pip-install]] +name = "install strips download lines" +input = """ +Collecting requests + Downloading requests-2.31.0-py3-none-any.whl (62 kB) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 62.6/62.6 kB 1.2 MB/s eta 0:00:00 +Collecting urllib3<3,>=1.21.1 + Downloading urllib3-2.1.0-py3-none-any.whl (104 kB) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 104.6/104.6 kB 2.1 MB/s eta 0:00:00 +Installing collected packages: urllib3, requests +Successfully installed requests-2.31.0 urllib3-2.1.0 +""" +expected = "Installing collected packages: urllib3, requests\nSuccessfully installed requests-2.31.0 urllib3-2.1.0" + +[[tests.pip-install]] +name = "errors are not short-circuited" +input = """ +Requirement already satisfied: requests in /usr/lib/python3/dist-packages (2.28.1) +ERROR: Could not install packages due to an OSError +""" +expected = "Requirement already satisfied: requests in /usr/lib/python3/dist-packages (2.28.1)\nERROR: Could not install packages due to an OSError" diff --git a/src/hooks/mod.rs b/src/hooks/mod.rs index 14157c43f..ab4ef474e 100644 --- a/src/hooks/mod.rs +++ b/src/hooks/mod.rs @@ -8,5 +8,6 @@ pub mod init; pub mod integrity; pub mod permissions; pub mod rewrite_cmd; +pub mod suggest_cmd; pub mod trust; pub mod verify_cmd; diff --git a/src/hooks/suggest_cmd.rs b/src/hooks/suggest_cmd.rs new file mode 100644 index 000000000..bf9967168 --- /dev/null +++ b/src/hooks/suggest_cmd.rs @@ -0,0 +1,53 @@ +//! Returns the RTK-rewritten form of a command for use in suggestion hooks. +//! +//! Unlike `rtk rewrite` (which also enforces permission rules), `rtk suggest` +//! only checks whether a command has an RTK equivalent and prints it. +//! +//! Exit codes: +//! 0 + stdout — suggestion found (caller should emit systemMessage) +//! 1 — no RTK equivalent (caller should exit silently) + +use crate::discover::registry; +use std::io::Write; + +/// Run the `rtk suggest` command. +pub fn run(cmd: &str) -> anyhow::Result<()> { + let excluded = crate::core::config::Config::load() + .map(|c| c.hooks.exclude_commands) + .unwrap_or_default(); + + match registry::rewrite_command(cmd, &excluded) { + Some(rewritten) if rewritten != cmd => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + Ok(()) + } + _ => { + // No RTK equivalent — exit 1 so the hook knows to skip. + std::process::exit(1); + } + } +} + +#[cfg(test)] +mod tests { + use crate::discover::registry; + + #[test] + fn test_suggest_returns_rewrite_for_known_command() { + let result = registry::rewrite_command("git status", &[]); + assert!(result.is_some()); + assert_ne!(result.unwrap(), "git status"); + } + + #[test] + fn test_suggest_returns_none_for_unknown_command() { + assert!(registry::rewrite_command("htop", &[]).is_none()); + } + + #[test] + fn test_suggest_skips_excluded_command() { + let excluded = vec!["curl".to_string()]; + assert!(registry::rewrite_command("curl https://example.com", &excluded).is_none()); + } +} diff --git a/src/learn/report.rs b/src/learn/report.rs index 6ec0b442c..8b72063b7 100644 --- a/src/learn/report.rs +++ b/src/learn/report.rs @@ -83,7 +83,7 @@ pub fn write_rules_file(rules: &[CorrectionRule], path: &str) -> Result<()> { base_commands.sort(); for base_cmd in base_commands { - let rules_for_cmd = grouped.get(&base_cmd).unwrap(); + let Some(rules_for_cmd) = grouped.get(&base_cmd) else { continue }; // Capitalize first letter for section header let section_header = capitalize_first(&base_cmd); diff --git a/src/main.rs b/src/main.rs index d9648c3fb..1384e8cec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -678,6 +678,19 @@ enum Commands { args: Vec, }, + /// Check if a command has an RTK equivalent and print the suggestion + /// + /// Exits 0 and prints the rewritten command if supported. + /// Exits 1 with no output if the command has no RTK equivalent. + /// + /// Used by the suggest hook to avoid duplicating rewrite logic in bash: + /// SUGGESTION=$(rtk suggest "$CMD") || exit 0 + Suggest { + /// Raw command to check (e.g. "git status", "cargo test") + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Hook processors for LLM CLI tools (Gemini CLI, Copilot, etc.) Hook { #[command(subcommand)] @@ -1948,6 +1961,12 @@ fn run_cli() -> Result { 0 } + Commands::Suggest { args } => { + let cmd = args.join(" "); + hooks::suggest_cmd::run(&cmd)?; + 0 + } + Commands::Proxy { args } => { use std::io::{Read, Write}; use std::process::Stdio;