diff --git a/.claude/hooks/rtk-rewrite.sh b/.claude/hooks/rtk-rewrite.sh index e1f8d1e5..6e7524d8 100755 --- a/.claude/hooks/rtk-rewrite.sh +++ b/.claude/hooks/rtk-rewrite.sh @@ -1,9 +1,16 @@ #!/bin/bash +# rtk-hook-version: 3 # RTK auto-rewrite hook for Claude Code PreToolUse:Bash # Transparently rewrites raw commands to their RTK equivalents. # Uses `rtk rewrite` as single source of truth — no duplicate mapping logic here. # # To add support for new commands, update src/discover/registry.rs (PATTERNS + RULES). +# +# Exit code protocol for `rtk rewrite`: +# 0 + stdout Rewrite found, no deny/ask rule matched → auto-allow +# 1 No RTK equivalent → pass through unchanged +# 2 Deny rule matched → pass through (Claude Code native deny handles it) +# 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user # --- Audit logging (opt-in via RTK_HOOK_AUDIT=1) --- _rtk_audit_log() { @@ -37,19 +44,37 @@ case "$CMD" in *'<<'*) _rtk_audit_log "skip:heredoc" "$CMD"; exit 0 ;; esac -# Rewrite via rtk — single source of truth for all command mappings. -# Exit 1 = no RTK equivalent, pass through unchanged. -# Exit 0 = rewritten command (or already RTK, identical output). -REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || { - _rtk_audit_log "skip:no_match" "$CMD" - exit 0 -} +# Rewrite via rtk — single source of truth for all command mappings and permission checks. +# Use "|| EXIT_CODE=$?" to capture non-zero exit codes without triggering set -e. +EXIT_CODE=0 +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || EXIT_CODE=$? -# If output is identical, command was already using RTK — nothing to do. -if [ "$CMD" = "$REWRITTEN" ]; then - _rtk_audit_log "skip:already_rtk" "$CMD" - exit 0 -fi +case $EXIT_CODE in + 0) + # Rewrite found, no permission rules matched — safe to auto-allow. + if [ "$CMD" = "$REWRITTEN" ]; then + _rtk_audit_log "skip:already_rtk" "$CMD" + exit 0 + fi + ;; + 1) + # No RTK equivalent — pass through unchanged. + _rtk_audit_log "skip:no_match" "$CMD" + exit 0 + ;; + 2) + # Deny rule matched — let Claude Code's native deny rule handle it. + _rtk_audit_log "skip:deny_rule" "$CMD" + exit 0 + ;; + 3) + # Ask rule matched — rewrite the command but do NOT auto-allow so that + # Claude Code prompts the user for confirmation. + ;; + *) + exit 0 + ;; +esac _rtk_audit_log "rewrite" "$CMD" "$REWRITTEN" @@ -57,14 +82,26 @@ _rtk_audit_log "rewrite" "$CMD" "$REWRITTEN" ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input') UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd') -# Output the rewrite instruction in Claude Code hook format. -jq -n \ - --argjson updated "$UPDATED_INPUT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": $updated - } - }' +if [ "$EXIT_CODE" -eq 3 ]; then + # Ask: rewrite the command, omit permissionDecision so Claude Code prompts. + jq -n \ + --argjson updated "$UPDATED_INPUT" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": $updated + } + }' +else + # Allow: output the rewrite instruction in Claude Code hook format. + jq -n \ + --argjson updated "$UPDATED_INPUT" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": $updated + } + }' +fi diff --git a/.claude/skills/rtk-triage/SKILL.md b/.claude/skills/rtk-triage/SKILL.md new file mode 100644 index 00000000..34b9d0fc --- /dev/null +++ b/.claude/skills/rtk-triage/SKILL.md @@ -0,0 +1,237 @@ +--- +description: > + Triage complet RTK : exécute issue-triage + pr-triage en parallèle, + puis croise les données pour détecter doubles couvertures, trous sécurité, + P0 sans PR, et conflits internes. Sauvegarde dans claudedocs/RTK-YYYY-MM-DD.md. + Args: "en"/"fr" pour la langue (défaut: fr), "save" pour forcer la sauvegarde. +allowed-tools: + - Bash + - Write + - Read + - AskUserQuestion +--- + +# /rtk-triage + +Orchestrateur de triage RTK. Fusionne issue-triage + pr-triage et produit une analyse croisée. + +--- + +## Quand utiliser + +- Hebdomadaire ou avant chaque sprint +- Quand le backlog PR/issues grossit rapidement +- Pour identifier les doublons avant de reviewer + +--- + +## Workflow en 4 phases + +### Phase 0 — Préconditions + +```bash +git rev-parse --is-inside-work-tree +gh auth status +``` + +Vérifier que la date actuelle est connue (utiliser `date +%Y-%m-%d`). + +--- + +### Phase 1 — Data gathering (parallèle) + +Lancer les deux collectes simultanément : + +**Issues** : +```bash +gh repo view --json nameWithOwner -q .nameWithOwner + +gh issue list --state open --limit 150 \ + --json number,title,author,createdAt,updatedAt,labels,assignees,body + +gh issue list --state closed --limit 20 \ + --json number,title,labels,closedAt + +gh api "repos/{owner}/{repo}/collaborators" --jq '.[].login' +``` + +**PRs** : +```bash +gh pr list --state open --limit 60 \ + --json number,title,author,createdAt,updatedAt,additions,deletions,changedFiles,isDraft,mergeable,reviewDecision,statusCheckRollup,body + +# Pour chaque PR, récupérer les fichiers modifiés (nécessaire pour overlap detection) +# Prioriser les PRs candidates (même domaine, même auteur) +gh pr view {num} --json files --jq '[.files[].path] | join(",")' +``` + +--- + +### Phase 2 — Triage individuel + +Exécuter les analyses de `/issue-triage` et `/pr-triage` séparément (même logique que les skills individuels) pour produire : + +**Issues** : +- Catégorisation (Bug/Feature/Enhancement/Question/Duplicate) +- Risque (Rouge/Jaune/Vert) +- Staleness (>30j) +- Map `issue_number → [PR numbers]` via scan `fixes #N`, `closes #N`, `resolves #N` + +**PRs** : +- Taille (XS/S/M/L/XL) +- CI status (clean/dirty) +- Nos PRs vs externes +- Overlaps (>50% fichiers communs entre 2 PRs) +- Clusters (auteur avec 3+ PRs) + +Afficher les tableaux standards de chaque skill (voir SKILL.md de issue-triage et pr-triage pour le format exact). + +--- + +### Phase 3 — Analyse croisée (cœur de ce skill) + +C'est ici que ce skill apporte de la valeur au-delà des deux skills individuels. + +#### 3.1 Double couverture — 2 PRs pour 1 issue + +Pour chaque issue liée à ≥2 PRs (via scan des bodies + overlap fichiers) : + +| Issue | PR1 (infos) | PR2 (infos) | Verdict recommandé | +|-------|-------------|-------------|-------------------| +| #N (titre) | PR#X — auteur, taille, CI | PR#Y — auteur, taille, CI | Garder la plus ciblée. Fermer/coordonner l'autre | + +Règle de verdict : +- Préférer la plus petite (XS < S < M) si même scope +- Préférer CI clean sur CI dirty +- Préférer "nos PRs" si l'une est interne +- Si overlap de fichiers >80% → conflit quasi-certain, signaler + +#### 3.2 Trous de couverture sécurité + +Pour chaque issue rouge (#640-type security review) : +- Lister les sous-findings mentionnés dans le body +- Croiser avec les PRs existantes (mots-clés dans titre/body) +- Identifier les findings sans PR + +Format : +``` +## Issue #N — security review (finding par finding) +| Finding | PR associée | Status | +|---------|-------------|--------| +| Description finding 1 | PR#X | En review | +| **Description finding critique** | **AUCUNE** | ⚠️ Trou | +``` + +#### 3.3 P0/P1 bugs sans PR + +Issues labelisées P0 ou P1 (ou mots-clés : "crash", "truncat", "cap", "hardcoded") sans aucune PR liée. + +Format : +``` +## Bugs critiques sans PR +| Issue | Titre | Pattern commun | Effort estimé | +|-------|-------|----------------|---------------| +``` + +Chercher un pattern commun (ex: "cap hardcodé", "exit code perdu") — si 3+ bugs partagent un pattern, suggérer un sprint groupé. + +#### 3.4 Nos PRs dirty — causes probables + +Pour chaque PR interne avec CI dirty ou CONFLICTING : +- Vérifier si un autre PR touche les mêmes fichiers +- Vérifier si un merge récent sur develop peut expliquer le conflit +- Recommander : rebase, fermeture, ou attente + +Format : +``` +## Nos PRs dirty +| PR | Issue(s) | Cause probable | Action | +|----|----------|----------------|--------| +``` + +#### 3.5 PRs sans issue trackée + +PRs internes sans `fixes #N` dans le body — signaler pour traçabilité. + +--- + +### Phase 4 — Output final + +#### Afficher l'analyse croisée complète (sections 3.1 → 3.5) + +Puis afficher le résumé chiffré : + +``` +## Résumé chiffré — YYYY-MM-DD + +| Catégorie | Count | +|-----------|-------| +| PRs prêtes à merger (nos) | N | +| Quick wins externes | N | +| Double couverture (conflicts) | N paires | +| P0/P1 bugs sans PR | N | +| Security findings sans PR | N | +| Nos PRs dirty à rebaser | N | +| PRs à fermer (recommandé) | N | +``` + +#### Sauvegarder dans claudedocs + +```bash +date +%Y-%m-%d # Pour construire le nom de fichier +``` + +Sauvegarder dans `claudedocs/RTK-YYYY-MM-DD.md` avec : +- Les tableaux de triage issues + PRs (Phase 2) +- L'analyse croisée complète (Phase 3) +- Le résumé chiffré + +Confirmer : `Sauvegardé dans claudedocs/RTK-YYYY-MM-DD.md` + +--- + +## Format du fichier sauvegardé + +```markdown +# RTK Triage — YYYY-MM-DD + +Croisement issues × PRs. {N} PRs ouvertes, {N} issues ouvertes. + +--- + +## 1. Double couverture +... + +## 2. Trous sécurité +... + +## 3. P0/P1 sans PR +... + +## 4. Nos PRs dirty +... + +## 5. Nos PRs prêtes à merger +... + +## 6. Quick wins externes +... + +## 7. Actions prioritaires +(liste ordonnée par impact/urgence) + +--- + +## Résumé chiffré +... +``` + +--- + +## Règles + +- Langue : argument `en`/`fr`. Défaut : `fr`. Les commentaires GitHub restent toujours en anglais. +- Ne jamais poster de commentaires GitHub sans validation utilisateur (AskUserQuestion). +- Si >150 issues ou >60 PRs : prévenir l'utilisateur, proposer de filtrer par label ou date. +- L'analyse croisée (Phase 3) est toujours exécutée — c'est la valeur ajoutée de ce skill. +- Le fichier claudedocs est sauvegardé automatiquement sauf si l'utilisateur dit "no save". diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..df3e32a3 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## Summary + + +- + +## Test plan + + +- [ ] `cargo fmt --all && cargo clippy --all-targets && cargo test` +- [ ] Manual testing: `rtk ` output inspected + +> **Important:** All PRs must target the `develop` branch (not `master`). +> See [CONTRIBUTING.md](../blob/master/CONTRIBUTING.md) for details. diff --git a/.github/workflows/pr-target-check.yml b/.github/workflows/pr-target-check.yml new file mode 100644 index 00000000..60211f1c --- /dev/null +++ b/.github/workflows/pr-target-check.yml @@ -0,0 +1,43 @@ +name: PR Target Branch Check + +on: + pull_request_target: + types: [opened, edited] + +jobs: + check-target: + runs-on: ubuntu-latest + # Skip develop→master PRs (maintainer releases) + if: >- + github.event.pull_request.base.ref == 'master' && + github.event.pull_request.head.ref != 'develop' + steps: + - name: Add wrong-base label + uses: actions/github-script@v7 + with: + script: | + const pr = context.payload.pull_request; + + // Add label + await github.rest.issues.addLabels({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + labels: ['wrong-base'] + }); + + // Post comment + const body = `👋 Thanks for the PR! It looks like this targets \`master\`, but all PRs should target the **\`develop\`** branch. + + Please update the base branch: + 1. Click **Edit** at the top right of this PR + 2. Change the base branch from \`master\` to \`develop\` + + See [CONTRIBUTING.md](https://github.com/${context.repo.owner}/${context.repo.repo}/blob/master/CONTRIBUTING.md) for details.`; + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: body + }); diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3041a7ac..ba32d583 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -286,6 +286,7 @@ SYSTEM init.rs init N/A ✓ gain.rs gain N/A ✓ config.rs (internal) N/A ✓ rewrite_cmd.rs rewrite N/A ✓ + permissions.rs CC permission checks N/A ✓ SHARED utils.rs Helpers N/A ✓ filter.rs Language filters N/A ✓ @@ -293,7 +294,7 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 60 modules** (38 command modules + 22 infrastructure modules) +**Total: 67 modules** (44 command modules + 23 infrastructure modules) ### Module Count Breakdown diff --git a/hooks/rtk-rewrite.sh b/hooks/rtk-rewrite.sh index c9c00f47..f7a42b5d 100644 --- a/hooks/rtk-rewrite.sh +++ b/hooks/rtk-rewrite.sh @@ -1,11 +1,17 @@ #!/usr/bin/env bash -# rtk-hook-version: 2 +# rtk-hook-version: 3 # RTK Claude Code hook — rewrites commands to use rtk for token savings. # Requires: rtk >= 0.23.0, jq # # This is a thin delegating hook: all rewrite logic lives in `rtk rewrite`, # which is the single source of truth (src/discover/registry.rs). # To add or change rewrite rules, edit the Rust registry — not this file. +# +# Exit code protocol for `rtk rewrite`: +# 0 + stdout Rewrite found, no deny/ask rule matched → auto-allow +# 1 No RTK equivalent → pass through unchanged +# 2 Deny rule matched → pass through (Claude Code native deny handles it) +# 3 + stdout Ask rule matched → rewrite but let Claude Code prompt the user if ! command -v jq &>/dev/null; then echo "[rtk] WARNING: jq is not installed. Hook cannot rewrite commands. Install jq: https://jqlang.github.io/jq/download/" >&2 @@ -37,25 +43,56 @@ if [ -z "$CMD" ]; then exit 0 fi -# Delegate all rewrite logic to the Rust binary. -# rtk rewrite exits 1 when there's no rewrite — hook passes through silently. -REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) || exit 0 +# Delegate all rewrite + permission logic to the Rust binary. +REWRITTEN=$(rtk rewrite "$CMD" 2>/dev/null) +EXIT_CODE=$? -# No change — nothing to do. -if [ "$CMD" = "$REWRITTEN" ]; then - exit 0 -fi +case $EXIT_CODE in + 0) + # Rewrite found, no permission rules matched — safe to auto-allow. + # If the output is identical, the command was already using RTK. + [ "$CMD" = "$REWRITTEN" ] && exit 0 + ;; + 1) + # No RTK equivalent — pass through unchanged. + exit 0 + ;; + 2) + # Deny rule matched — let Claude Code's native deny rule handle it. + exit 0 + ;; + 3) + # Ask rule matched — rewrite the command but do NOT auto-allow so that + # Claude Code prompts the user for confirmation. + ;; + *) + exit 0 + ;; +esac ORIGINAL_INPUT=$(echo "$INPUT" | jq -c '.tool_input') UPDATED_INPUT=$(echo "$ORIGINAL_INPUT" | jq --arg cmd "$REWRITTEN" '.command = $cmd') -jq -n \ - --argjson updated "$UPDATED_INPUT" \ - '{ - "hookSpecificOutput": { - "hookEventName": "PreToolUse", - "permissionDecision": "allow", - "permissionDecisionReason": "RTK auto-rewrite", - "updatedInput": $updated - } - }' +if [ "$EXIT_CODE" -eq 3 ]; then + # Ask: rewrite the command, omit permissionDecision so Claude Code prompts. + jq -n \ + --argjson updated "$UPDATED_INPUT" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "updatedInput": $updated + } + }' +else + # Allow: rewrite the command and auto-allow. + jq -n \ + --argjson updated "$UPDATED_INPUT" \ + '{ + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": "allow", + "permissionDecisionReason": "RTK auto-rewrite", + "updatedInput": $updated + } + }' +fi diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs index 159636e7..3e963698 100644 --- a/src/cargo_cmd.rs +++ b/src/cargo_cmd.rs @@ -40,6 +40,11 @@ fn restore_double_dash_with_raw(args: &[String], raw_args: &[String]) -> Vec pos, @@ -1054,6 +1059,42 @@ mod tests { assert_eq!(result, vec!["--", "-D", "warnings"]); } + #[test] + fn test_restore_double_dash_clippy_with_package_flags() { + // rtk cargo clippy -p my-service -p my-crate -- -D warnings + // Clap with trailing_var_arg preserves "--" when args precede it + // → clap gives ["-p", "my-service", "-p", "my-crate", "--", "-D", "warnings"] + let args: Vec = vec![ + "-p".into(), + "my-service".into(), + "-p".into(), + "my-crate".into(), + "--".into(), + "-D".into(), + "warnings".into(), + ]; + let raw = vec![ + "rtk".into(), + "cargo".into(), + "clippy".into(), + "-p".into(), + "my-service".into(), + "-p".into(), + "my-crate".into(), + "--".into(), + "-D".into(), + "warnings".into(), + ]; + let result = restore_double_dash_with_raw(&args, &raw); + // Should NOT double the "--" + assert_eq!( + result, + vec!["-p", "my-service", "-p", "my-crate", "--", "-D", "warnings"] + ); + // Verify only one "--" exists + assert_eq!(result.iter().filter(|a| *a == "--").count(), 1); + } + #[test] fn test_filter_cargo_build_success() { let output = r#" Compiling libc v0.2.153 diff --git a/src/cc_economics.rs b/src/cc_economics.rs index b38bba2f..cf135ac3 100644 --- a/src/cc_economics.rs +++ b/src/cc_economics.rs @@ -14,6 +14,7 @@ use crate::utils::{format_cpt, format_tokens, format_usd}; // ── Constants ── +#[allow(dead_code)] const BILLION: f64 = 1e9; // API pricing ratios (verified Feb 2026, consistent across Claude models <=200K context) diff --git a/src/ccusage.rs b/src/ccusage.rs index 99e88c7f..d9ca8668 100644 --- a/src/ccusage.rs +++ b/src/ccusage.rs @@ -112,6 +112,7 @@ fn build_command() -> Option { } /// Check if ccusage CLI is available (binary or via npx) +#[allow(dead_code)] pub fn is_available() -> bool { build_command().is_some() } diff --git a/src/config.rs b/src/config.rs index 94917a5e..7ffea86d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -16,6 +16,8 @@ pub struct Config { pub telemetry: TelemetryConfig, #[serde(default)] pub hooks: HooksConfig, + #[serde(default)] + pub limits: LimitsConfig, } #[derive(Debug, Serialize, Deserialize, Default)] @@ -94,6 +96,37 @@ impl Default for TelemetryConfig { } } +#[derive(Debug, Serialize, Deserialize)] +pub struct LimitsConfig { + /// Max total grep results to show (default: 200) + pub grep_max_results: usize, + /// Max matches per file in grep output (default: 25) + pub grep_max_per_file: usize, + /// Max staged/modified files shown in git status (default: 15) + pub status_max_files: usize, + /// Max untracked files shown in git status (default: 10) + pub status_max_untracked: usize, + /// Max chars for parser passthrough fallback (default: 2000) + pub passthrough_max_chars: usize, +} + +impl Default for LimitsConfig { + fn default() -> Self { + Self { + grep_max_results: 200, + grep_max_per_file: 25, + status_max_files: 15, + status_max_untracked: 10, + passthrough_max_chars: 2000, + } + } +} + +/// Get limits config. Falls back to defaults if config can't be loaded. +pub fn limits() -> LimitsConfig { + Config::load().map(|c| c.limits).unwrap_or_default() +} + /// Check if telemetry is enabled in config. Returns None if config can't be loaded. pub fn telemetry_enabled() -> Option { Config::load().ok().map(|c| c.telemetry.enabled) diff --git a/src/container.rs b/src/container.rs index 8b582ca1..ee8d4268 100644 --- a/src/container.rs +++ b/src/container.rs @@ -67,7 +67,12 @@ fn docker_ps(_verbose: u8) -> Result<()> { if parts.len() >= 4 { let id = &parts[0][..12.min(parts[0].len())]; let name = parts[1]; - let short_image = parts.get(3).unwrap_or(&"").split('/').last().unwrap_or(""); + let short_image = parts + .get(3) + .unwrap_or(&"") + .split('/') + .next_back() + .unwrap_or(""); let ports = compact_ports(parts.get(4).unwrap_or(&"")); if ports == "-" { rtk.push_str(&format!(" {} {} ({})\n", id, name, short_image)); @@ -183,6 +188,19 @@ fn docker_logs(args: &[String], _verbose: u8) -> Result<()> { let stderr = String::from_utf8_lossy(&output.stderr); let raw = format!("{}\n{}", stdout, stderr); + if !output.status.success() { + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + timer.track( + &format!("docker logs {}", container), + "rtk docker logs", + &raw, + &raw, + ); + std::process::exit(output.status.code().unwrap_or(1)); + } + let analyzed = crate::log_cmd::run_stdin_str(&raw); let rtk = format!("🐳 Logs for {}:\n{}", container, analyzed); println!("{}", rtk); @@ -208,6 +226,15 @@ fn kubectl_pods(args: &[String], _verbose: u8) -> Result<()> { let raw = String::from_utf8_lossy(&output.stdout).to_string(); let mut rtk = String::new(); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + timer.track("kubectl get pods", "rtk kubectl pods", &raw, &raw); + std::process::exit(output.status.code().unwrap_or(1)); + } + let json: serde_json::Value = match serde_json::from_str(&raw) { Ok(v) => v, Err(_) => { @@ -306,6 +333,15 @@ fn kubectl_services(args: &[String], _verbose: u8) -> Result<()> { let raw = String::from_utf8_lossy(&output.stdout).to_string(); let mut rtk = String::new(); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + timer.track("kubectl get svc", "rtk kubectl svc", &raw, &raw); + std::process::exit(output.status.code().unwrap_or(1)); + } + let json: serde_json::Value = match serde_json::from_str(&raw) { Ok(v) => v, Err(_) => { @@ -381,6 +417,21 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result<()> { let output = cmd.output().context("Failed to run kubectl logs")?; let raw = String::from_utf8_lossy(&output.stdout).to_string(); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + timer.track( + &format!("kubectl logs {}", pod), + "rtk kubectl logs", + &raw, + &raw, + ); + std::process::exit(output.status.code().unwrap_or(1)); + } + let analyzed = crate::log_cmd::run_stdin_str(&raw); let rtk = format!("☸️ Logs for {}:\n{}", pod, analyzed); println!("{}", rtk); @@ -516,7 +567,7 @@ fn compact_ports(ports: &str) -> String { // Extract just the port numbers let port_nums: Vec<&str> = ports .split(',') - .filter_map(|p| p.split("->").next().and_then(|s| s.split(':').last())) + .filter_map(|p| p.split("->").next().and_then(|s| s.split(':').next_back())) .collect(); if port_nums.len() <= 3 { diff --git a/src/discover/provider.rs b/src/discover/provider.rs index e9218b2d..ae0852d2 100644 --- a/src/discover/provider.rs +++ b/src/discover/provider.rs @@ -18,6 +18,7 @@ pub struct ExtractedCommand { /// Whether the tool_result indicated an error pub is_error: bool, /// Chronological sequence index within the session + #[allow(dead_code)] pub sequence_index: usize, } @@ -347,7 +348,7 @@ mod tests { let cmds = provider.extract_commands(jsonl.path()).unwrap(); assert_eq!(cmds.len(), 1); assert_eq!(cmds[0].command, "git commit --ammend"); - assert_eq!(cmds[0].is_error, true); + assert!(cmds[0].is_error); assert!(cmds[0].output_content.is_some()); assert_eq!( cmds[0].output_content.as_ref().unwrap(), @@ -365,8 +366,8 @@ mod tests { let provider = ClaudeProvider; let cmds = provider.extract_commands(jsonl.path()).unwrap(); assert_eq!(cmds.len(), 2); - assert_eq!(cmds[0].is_error, false); - assert_eq!(cmds[1].is_error, true); + assert!(!cmds[0].is_error); + assert!(cmds[1].is_error); } #[test] diff --git a/src/discover/registry.rs b/src/discover/registry.rs index ffe7748c..d04a112a 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -48,6 +48,10 @@ lazy_static! { .collect(); static ref ENV_PREFIX: Regex = Regex::new(r"^(?:sudo\s+|env\s+|[A-Z_][A-Z0-9_]*=[^\s]*\s+)+").unwrap(); + // Git global options that appear before the subcommand: -C , -c , + // --git-dir , --work-tree , and flag-only options (#163) + static ref GIT_GLOBAL_OPT: Regex = + Regex::new(r"^(?:(?:-C\s+\S+|-c\s+\S+|--git-dir(?:=\S+|\s+\S+)|--work-tree(?:=\S+|\s+\S+)|--no-pager|--no-optional-locks|--bare|--literal-pathspecs)\s+)+").unwrap(); } /// Classify a single (already-split) command. @@ -76,6 +80,12 @@ pub fn classify_command(cmd: &str) -> Classification { return Classification::Ignored; } + // Normalize absolute binary paths: /usr/bin/grep → grep (#485) + let cmd_normalized = strip_absolute_path(cmd_clean); + // Strip git global options: git -C /tmp status → git status (#163) + let cmd_normalized = strip_git_global_opts(&cmd_normalized); + let cmd_clean = cmd_normalized.as_str(); + // Exclude cat/head/tail with redirect operators — these are writes, not reads (#315) if cmd_clean.starts_with("cat ") || cmd_clean.starts_with("head ") @@ -262,6 +272,42 @@ pub fn split_command_chain(cmd: &str) -> Vec<&str> { results } +/// Strip git global options before the subcommand (#163). +/// `git -C /tmp status` → `git status`, preserving the rest. +/// Returns the original string unchanged if not a git command. +fn strip_git_global_opts(cmd: &str) -> String { + // Only applies to commands starting with "git " + if !cmd.starts_with("git ") { + return cmd.to_string(); + } + let after_git = &cmd[4..]; // skip "git " + let stripped = GIT_GLOBAL_OPT.replace(after_git, ""); + format!("git {}", stripped.trim()) +} + +/// Normalize absolute binary paths: `/usr/bin/grep -rn foo` → `grep -rn foo` (#485) +/// Only strips if the first word contains a `/` (Unix path). +fn strip_absolute_path(cmd: &str) -> String { + let first_space = cmd.find(' '); + let first_word = match first_space { + Some(pos) => &cmd[..pos], + None => cmd, + }; + if first_word.contains('/') { + // Extract basename + let basename = first_word.rsplit('/').next().unwrap_or(first_word); + if basename.is_empty() { + return cmd.to_string(); + } + match first_space { + Some(pos) => format!("{}{}", basename, &cmd[pos..]), + None => basename.to_string(), + } + } else { + cmd.to_string() + } +} + /// Check if a command has RTK_DISABLED= prefix in its env prefix portion. pub fn has_rtk_disabled_prefix(cmd: &str) -> bool { let trimmed = cmd.trim(); @@ -355,8 +401,18 @@ fn rewrite_compound(cmd: &str, excluded: &[String]) -> Option { } else { // `|` pipe — rewrite first segment only, pass through the rest unchanged let seg = cmd[seg_start..i].trim(); - let rewritten = - rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string()); + // Skip rewriting `find`/`fd` in pipes — rtk find outputs a grouped + // format that is incompatible with pipe consumers like xargs, grep, + // wc, sort, etc. which expect one path per line (#439). + let is_pipe_incompatible = seg.starts_with("find ") + || seg == "find" + || seg.starts_with("fd ") + || seg == "fd"; + let rewritten = if is_pipe_incompatible { + seg.to_string() + } else { + rewrite_segment(seg, excluded).unwrap_or_else(|| seg.to_string()) + }; if rewritten != seg { any_changed = true; } @@ -679,12 +735,10 @@ mod tests { "tail -f app.log > /dev/null", ]; for cmd in &write_commands { - match classify_command(cmd) { - Classification::Supported { .. } => { - panic!("{} should NOT be classified as Supported", cmd) - } - _ => {} // Unsupported or Ignored is fine + if let Classification::Supported { .. } = classify_command(cmd) { + panic!("{} should NOT be classified as Supported", cmd) } + // Unsupported or Ignored is fine } } @@ -1113,6 +1167,30 @@ mod tests { ); } + #[test] + fn test_rewrite_find_pipe_skipped() { + // find in a pipe should NOT be rewritten — rtk find output format + // is incompatible with pipe consumers like xargs (#439) + assert_eq!( + rewrite_command("find . -name '*.rs' | xargs grep 'fn run'", &[]), + None + ); + } + + #[test] + fn test_rewrite_find_pipe_xargs_wc() { + assert_eq!(rewrite_command("find src -type f | wc -l", &[]), None); + } + + #[test] + fn test_rewrite_find_no_pipe_still_rewritten() { + // find WITHOUT a pipe should still be rewritten + assert_eq!( + rewrite_command("find . -name '*.rs'", &[]), + Some("rtk find . -name '*.rs'".into()) + ); + } + #[test] fn test_rewrite_heredoc_returns_none() { assert_eq!(rewrite_command("cat <<'EOF'\nfoo\nEOF", &[]), None); @@ -2061,4 +2139,132 @@ mod tests { ); assert_eq!(strip_disabled_prefix("git status"), "git status"); } + + // --- #485: absolute path normalization --- + + #[test] + fn test_classify_absolute_path_grep() { + assert_eq!( + classify_command("/usr/bin/grep -rni pattern"), + Classification::Supported { + rtk_equivalent: "rtk grep", + category: "Files", + estimated_savings_pct: 75.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_absolute_path_ls() { + assert_eq!( + classify_command("/bin/ls -la"), + Classification::Supported { + rtk_equivalent: "rtk ls", + category: "Files", + estimated_savings_pct: 65.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_absolute_path_git() { + assert_eq!( + classify_command("/usr/local/bin/git status"), + Classification::Supported { + rtk_equivalent: "rtk git", + category: "Git", + estimated_savings_pct: 70.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_absolute_path_no_args() { + // /usr/bin/find alone → still classified + assert_eq!( + classify_command("/usr/bin/find ."), + Classification::Supported { + rtk_equivalent: "rtk find", + category: "Files", + estimated_savings_pct: 70.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_strip_absolute_path_helper() { + assert_eq!(strip_absolute_path("/usr/bin/grep -rn foo"), "grep -rn foo"); + assert_eq!(strip_absolute_path("/bin/ls -la"), "ls -la"); + assert_eq!(strip_absolute_path("grep -rn foo"), "grep -rn foo"); + assert_eq!(strip_absolute_path("/usr/local/bin/git"), "git"); + } + + // --- #163: git global options --- + + #[test] + fn test_classify_git_with_dash_c_path() { + assert_eq!( + classify_command("git -C /tmp status"), + Classification::Supported { + rtk_equivalent: "rtk git", + category: "Git", + estimated_savings_pct: 70.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_git_no_pager_log() { + assert_eq!( + classify_command("git --no-pager log -5"), + Classification::Supported { + rtk_equivalent: "rtk git", + category: "Git", + estimated_savings_pct: 70.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_classify_git_git_dir() { + assert_eq!( + classify_command("git --git-dir /tmp/.git status"), + Classification::Supported { + rtk_equivalent: "rtk git", + category: "Git", + estimated_savings_pct: 70.0, + status: RtkStatus::Existing, + } + ); + } + + #[test] + fn test_rewrite_git_dash_c() { + assert_eq!( + rewrite_command("git -C /tmp status", &[]), + Some("rtk git -C /tmp status".to_string()) + ); + } + + #[test] + fn test_rewrite_git_no_pager() { + assert_eq!( + rewrite_command("git --no-pager log -5", &[]), + Some("rtk git --no-pager log -5".to_string()) + ); + } + + #[test] + fn test_strip_git_global_opts_helper() { + assert_eq!(strip_git_global_opts("git -C /tmp status"), "git status"); + assert_eq!(strip_git_global_opts("git --no-pager log"), "git log"); + assert_eq!(strip_git_global_opts("git status"), "git status"); + assert_eq!(strip_git_global_opts("cargo test"), "cargo test"); + } } diff --git a/src/filter.rs b/src/filter.rs index c4c255ad..d6d9d19b 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -50,6 +50,7 @@ pub enum Language { Java, Ruby, Shell, + /// Data formats (JSON, YAML, TOML, XML, CSV) — no comment stripping Data, Unknown, } @@ -67,9 +68,10 @@ impl Language { "java" => Language::Java, "rb" => Language::Ruby, "sh" | "bash" | "zsh" => Language::Shell, - "json" | "jsonc" | "json5" | "yaml" | "yml" | "toml" | "xml" | "html" | "htm" - | "css" | "scss" | "svg" | "md" | "markdown" | "txt" | "csv" | "tsv" | "env" - | "ini" | "cfg" | "conf" | "lock" => Language::Data, + "json" | "jsonc" | "json5" | "yaml" | "yml" | "toml" | "xml" | "csv" | "tsv" + | "graphql" | "gql" | "sql" | "md" | "markdown" | "txt" | "env" | "lock" => { + Language::Data + } _ => Language::Unknown, } } @@ -249,6 +251,11 @@ lazy_static! { impl FilterStrategy for AggressiveFilter { fn filter(&self, content: &str, lang: &Language) -> String { + // Data formats (JSON, YAML, etc.) must never be code-filtered + if *lang == Language::Data { + return MinimalFilter.filter(content, lang); + } + let minimal = MinimalFilter.filter(content, lang); let mut result = String::with_capacity(minimal.len() / 2); let mut brace_depth = 0; @@ -407,14 +414,15 @@ mod tests { assert_eq!(Language::from_extension("yml"), Language::Data); assert_eq!(Language::from_extension("toml"), Language::Data); assert_eq!(Language::from_extension("xml"), Language::Data); - assert_eq!(Language::from_extension("md"), Language::Data); assert_eq!(Language::from_extension("csv"), Language::Data); + assert_eq!(Language::from_extension("md"), Language::Data); assert_eq!(Language::from_extension("lock"), Language::Data); } #[test] - fn test_data_files_no_comment_stripping() { - // Regression test for #464: package.json with `/*` in strings + fn test_json_no_comment_stripping() { + // Reproduces #464: package.json with "packages/*" was corrupted + // because /* was treated as block comment start let json = r#"{ "workspaces": { "packages": [ @@ -432,17 +440,41 @@ mod tests { }"#; let filter = MinimalFilter; let result = filter.filter(json, &Language::Data); + // All fields must be preserved — no comment stripping on JSON + assert!( + result.contains("packages/*"), + "packages/* should not be treated as block comment start" + ); assert!( result.contains("scripts"), - "scripts section must be preserved" + "scripts section must not be stripped" ); assert!( - result.contains("packages/*"), - "glob pattern must be preserved" + result.contains("lint-staged"), + "lint-staged section must not be stripped" ); assert!( result.contains("**/package.json"), - "glob pattern must be preserved" + "**/package.json should not be treated as block comment end" + ); + } + + #[test] + fn test_json_aggressive_filter_preserves_structure() { + let json = r#"{ + "name": "my-app", + "dependencies": { + "react": "^18.0.0" + }, + "scripts": { + "dev": "next dev /* not a comment */" + } +}"#; + let filter = AggressiveFilter; + let result = filter.filter(json, &Language::Data); + assert!( + result.contains("/* not a comment */"), + "Aggressive filter must not strip comment-like patterns in JSON" ); } diff --git a/src/filters/gradle.toml b/src/filters/gradle.toml new file mode 100644 index 00000000..e6ad28a3 --- /dev/null +++ b/src/filters/gradle.toml @@ -0,0 +1,35 @@ +[filters.gradle] +description = "Compact Gradle build output — strip progress, keep tasks and errors" +match_command = "^(gradle|gradlew|\\./)gradlew?\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^> Configuring project", + "^> Resolving dependencies", + "^> Transform ", + "^Download(ing)?\\s+http", + "^\\s*<-+>\\s*$", + "^> Task :.*UP-TO-DATE$", + "^> Task :.*NO-SOURCE$", + "^> Task :.*FROM-CACHE$", + "^Starting a Gradle Daemon", + "^Daemon will be stopped", +] +truncate_lines_at = 150 +max_lines = 50 +on_empty = "gradle: ok" + +[[tests.gradle]] +name = "strips UP-TO-DATE tasks, keeps build result" +input = "> Configuring project :app\n> Task :app:compileJava UP-TO-DATE\n> Task :app:compileKotlin UP-TO-DATE\n> Task :app:test\n\n3 tests completed, 1 failed\n\nBUILD FAILED in 12s" +expected = "> Task :app:test\n3 tests completed, 1 failed\nBUILD FAILED in 12s" + +[[tests.gradle]] +name = "clean build preserved" +input = "BUILD SUCCESSFUL in 8s\n7 actionable tasks: 7 executed" +expected = "BUILD SUCCESSFUL in 8s\n7 actionable tasks: 7 executed" + +[[tests.gradle]] +name = "empty after stripping" +input = "> Configuring project :app\n" +expected = "gradle: ok" diff --git a/src/filters/jira.toml b/src/filters/jira.toml new file mode 100644 index 00000000..9de5ad3b --- /dev/null +++ b/src/filters/jira.toml @@ -0,0 +1,20 @@ +[filters.jira] +description = "Compact Jira CLI output — strip verbose metadata, keep essentials" +match_command = "^jira\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s*--", +] +truncate_lines_at = 120 +max_lines = 40 + +[[tests.jira]] +name = "strips blank lines from issue list" +input = "TYPE\tKEY\tSUMMARY\tSTATUS\n\nStory\tPROJ-123\tAdd login feature\tIn Progress\n\nBug\tPROJ-456\tFix crash on startup\tOpen" +expected = "TYPE\tKEY\tSUMMARY\tSTATUS\nStory\tPROJ-123\tAdd login feature\tIn Progress\nBug\tPROJ-456\tFix crash on startup\tOpen" + +[[tests.jira]] +name = "single issue view" +input = "KEY: PROJ-123\nSummary: Add login feature\nStatus: In Progress\nAssignee: john@example.com" +expected = "KEY: PROJ-123\nSummary: Add login feature\nStatus: In Progress\nAssignee: john@example.com" diff --git a/src/filters/just.toml b/src/filters/just.toml new file mode 100644 index 00000000..31e58a54 --- /dev/null +++ b/src/filters/just.toml @@ -0,0 +1,26 @@ +[filters.just] +description = "Compact just task runner output — strip recipe headers, keep command output" +match_command = "^just\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s*Available recipes:", + "^\\s*just --list", +] +truncate_lines_at = 150 +max_lines = 50 + +[[tests.just]] +name = "preserves command output" +input = "cargo test\n\ntest result: ok. 42 passed; 0 failed\n" +expected = "cargo test\ntest result: ok. 42 passed; 0 failed" + +[[tests.just]] +name = "preserves error output" +input = "error: Compilation failed\nsrc/main.rs:10: expected `;`" +expected = "error: Compilation failed\nsrc/main.rs:10: expected `;`" + +[[tests.just]] +name = "empty input" +input = "" +expected = "" diff --git a/src/filters/mise.toml b/src/filters/mise.toml new file mode 100644 index 00000000..7223d12b --- /dev/null +++ b/src/filters/mise.toml @@ -0,0 +1,30 @@ +[filters.mise] +description = "Compact mise task runner output — strip status lines, keep task results" +match_command = "^mise\\s+(run|exec|install|upgrade)\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^mise\\s+(trust|install|upgrade).*✓", + "^mise\\s+Installing\\s", + "^mise\\s+Downloading\\s", + "^mise\\s+Extracting\\s", + "^mise\\s+\\w+@[\\d.]+ installed", +] +truncate_lines_at = 150 +max_lines = 50 +on_empty = "mise: ok" + +[[tests.mise]] +name = "strips install noise, keeps task output" +input = "mise Installing node@20.0.0\nmise Downloading node@20.0.0\nmise Extracting node@20.0.0\nmise node@20.0.0 installed\n\nlint check passed\n2 warnings found" +expected = "lint check passed\n2 warnings found" + +[[tests.mise]] +name = "preserves error output" +input = "mise run lint\nError: biome check failed\nsrc/index.ts:5 — unused variable" +expected = "mise run lint\nError: biome check failed\nsrc/index.ts:5 — unused variable" + +[[tests.mise]] +name = "empty after stripping" +input = "mise trust ~/dev/.mise.toml ✓\nmise install node@20 ✓\n" +expected = "mise: ok" diff --git a/src/filters/nx.toml b/src/filters/nx.toml new file mode 100644 index 00000000..d42dfb76 --- /dev/null +++ b/src/filters/nx.toml @@ -0,0 +1,25 @@ +[filters.nx] +description = "Compact Nx monorepo output — strip task graph noise, keep results" +match_command = "^(pnpm\\s+)?nx\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s*>\\s*NX\\s+Running target", + "^\\s*>\\s*NX\\s+Nx read the output", + "^\\s*>\\s*NX\\s+View logs", + "^———————", + "^—————————", + "^\\s+Nx \\(powered by", +] +truncate_lines_at = 150 +max_lines = 60 + +[[tests.nx]] +name = "strips Nx noise, keeps build output" +input = "\n > NX Running target build for project myapp\n\n———————————————————————————————————————\nCompiled successfully.\nOutput: dist/apps/myapp\n\n > NX View logs at /tmp/.nx/runs/abc123\n\n Nx (powered by computation caching)\n" +expected = "Compiled successfully.\nOutput: dist/apps/myapp" + +[[tests.nx]] +name = "preserves error output" +input = "ERROR: Cannot find module '@myapp/shared'\n\n > NX Running target build for project myapp\n\nFailed at step: build" +expected = "ERROR: Cannot find module '@myapp/shared'\nFailed at step: build" diff --git a/src/filters/ollama.toml b/src/filters/ollama.toml new file mode 100644 index 00000000..e325ec94 --- /dev/null +++ b/src/filters/ollama.toml @@ -0,0 +1,23 @@ +[filters.ollama] +description = "Strip ANSI spinners and cursor control from ollama output, keep final text" +match_command = "^ollama\\s+run\\b" +strip_ansi = true +strip_lines_matching = [ + "^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏\\s]*$", + "^\\s*$", +] + +[[tests.ollama]] +name = "strips spinner lines, keeps response" +input = "⠋ \n⠙ \n⠹ \nHello! How can I help you today?" +expected = "Hello! How can I help you today?" + +[[tests.ollama]] +name = "preserves multi-line response" +input = "⠋ \n⠙ \nLine one of the response.\nLine two of the response." +expected = "Line one of the response.\nLine two of the response." + +[[tests.ollama]] +name = "empty input" +input = "" +expected = "" diff --git a/src/filters/spring-boot.toml b/src/filters/spring-boot.toml new file mode 100644 index 00000000..5ec03e58 --- /dev/null +++ b/src/filters/spring-boot.toml @@ -0,0 +1,28 @@ +[filters.spring-boot] +description = "Compact Spring Boot output — strip banner and verbose startup logs, keep key events" +match_command = "^(mvn\\s+spring-boot:run|java\\s+-jar.*\\.jar|gradle\\s+.*bootRun)" +strip_ansi = true +keep_lines_matching = [ + "Started\\s.*\\sin\\s", + "Tomcat started on port", + "ERROR", + "WARN", + "Exception", + "Caused by:", + "Application run failed", + "BUILD\\s", + "Tests run:", + "FAILURE", + "listening on port", +] +max_lines = 30 + +[[tests.spring-boot]] +name = "keeps startup summary and errors" +input = " . ____ _ \n /\\\\ / ___'_ __ _ _(_)_ __ \n( ( )\\___ | '_ | '_| | '_ \\ \n \\/ ___)| |_)| | | | | || )\n ' |____| .__|_| |_|_| |_\\__|\n :: Spring Boot :: (v3.2.0)\n2024-01-01 INFO Initializing Spring\n2024-01-01 INFO Bean 'dataSource' created\n2024-01-01 INFO Tomcat started on port 8080\n2024-01-01 INFO Started MyApp in 3.2 seconds" +expected = "2024-01-01 INFO Tomcat started on port 8080\n2024-01-01 INFO Started MyApp in 3.2 seconds" + +[[tests.spring-boot]] +name = "preserves errors" +input = " :: Spring Boot :: (v3.2.0)\n2024-01-01 INFO Initializing Spring\n2024-01-01 ERROR Application run failed\nCaused by: java.lang.NullPointerException" +expected = "2024-01-01 ERROR Application run failed\nCaused by: java.lang.NullPointerException" diff --git a/src/filters/task.toml b/src/filters/task.toml new file mode 100644 index 00000000..31868fc0 --- /dev/null +++ b/src/filters/task.toml @@ -0,0 +1,27 @@ +[filters.task] +description = "Compact go-task output — strip task headers, keep command results" +match_command = "^task\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^task: \\[.*\\] ", + "^task: Task .* is up to date", +] +truncate_lines_at = 150 +max_lines = 50 +on_empty = "task: ok" + +[[tests.task]] +name = "strips task headers, keeps output" +input = "task: [build] go build ./...\n\ntask: [test] go test ./...\nok myapp 0.5s\n\ntask: Task \"lint\" is up to date" +expected = "ok myapp 0.5s" + +[[tests.task]] +name = "preserves error output" +input = "task: [build] go build ./...\n./main.go:10: undefined: foo\ntask: Failed to run task \"build\": exit status 1" +expected = "./main.go:10: undefined: foo\ntask: Failed to run task \"build\": exit status 1" + +[[tests.task]] +name = "all up to date" +input = "task: Task \"build\" is up to date\ntask: Task \"lint\" is up to date\n" +expected = "task: ok" diff --git a/src/filters/turbo.toml b/src/filters/turbo.toml new file mode 100644 index 00000000..c5a09acf --- /dev/null +++ b/src/filters/turbo.toml @@ -0,0 +1,30 @@ +[filters.turbo] +description = "Compact Turborepo output — strip cache status noise, keep task results" +match_command = "^turbo\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s*cache (hit|miss|bypass)", + "^\\s*\\d+ packages in scope", + "^\\s*Tasks:\\s+\\d+", + "^\\s*Duration:\\s+", + "^\\s*Remote caching (enabled|disabled)", +] +truncate_lines_at = 150 +max_lines = 50 +on_empty = "turbo: ok" + +[[tests.turbo]] +name = "strips cache noise, keeps task output" +input = " cache hit, replaying logs abc123\n cache miss, executing abc456\n\n3 packages in scope\n\n> myapp:build\n\nCompiled successfully.\n\nTasks: 2 successful, 2 total (1 cached)\nDuration: 3.2s" +expected = "> myapp:build\nCompiled successfully." + +[[tests.turbo]] +name = "preserves error output" +input = "> myapp:lint\n\nError: src/index.ts(5,1): error TS2304\n\nTasks: 0 successful, 1 total\nDuration: 1.1s" +expected = "> myapp:lint\nError: src/index.ts(5,1): error TS2304" + +[[tests.turbo]] +name = "empty after stripping" +input = " cache hit, replaying logs abc\n\n" +expected = "turbo: ok" diff --git a/src/filters/yadm.toml b/src/filters/yadm.toml new file mode 100644 index 00000000..f2cd3d1a --- /dev/null +++ b/src/filters/yadm.toml @@ -0,0 +1,21 @@ +[filters.yadm] +description = "Compact yadm (git wrapper) output — same filtering as git" +match_command = "^yadm\\b" +strip_ansi = true +strip_lines_matching = [ + "^\\s*$", + "^\\s*\\(use \"git ", + "^\\s*\\(use \"yadm ", +] +truncate_lines_at = 120 +max_lines = 40 + +[[tests.yadm]] +name = "strips hint lines" +input = "On branch main\nYour branch is up to date with 'origin/main'.\n\n (use \"yadm add\" to update what will be committed)\n\nChanges not staged for commit:\n modified: .bashrc" +expected = "On branch main\nYour branch is up to date with 'origin/main'.\nChanges not staged for commit:\n modified: .bashrc" + +[[tests.yadm]] +name = "short output preserved" +input = "Already up to date." +expected = "Already up to date." diff --git a/src/format_cmd.rs b/src/format_cmd.rs index c2de5d71..fe6ce13f 100644 --- a/src/format_cmd.rs +++ b/src/format_cmd.rs @@ -168,7 +168,7 @@ fn filter_black_output(output: &str) -> String { // Split by comma to handle both parts for part in trimmed.split(',') { let part_lower = part.to_lowercase(); - let words: Vec<&str> = part.trim().split_whitespace().collect(); + let words: Vec<&str> = part.split_whitespace().collect(); if part_lower.contains("would be reformatted") { // Parse "X file(s) would be reformatted" diff --git a/src/gain.rs b/src/gain.rs index 2dce35f1..dfba7e08 100644 --- a/src/gain.rs +++ b/src/gain.rs @@ -8,6 +8,7 @@ use serde::Serialize; use std::io::IsTerminal; use std::path::PathBuf; +#[allow(clippy::too_many_arguments)] pub fn run( project: bool, // added: per-project scope flag graph: bool, diff --git a/src/gh_cmd.rs b/src/gh_cmd.rs index 9e1fe7ec..cf17fabd 100644 --- a/src/gh_cmd.rs +++ b/src/gh_cmd.rs @@ -193,8 +193,8 @@ fn run_pr(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> { "create" => pr_create(&args[1..], verbose), "merge" => pr_merge(&args[1..], verbose), "diff" => pr_diff(&args[1..], verbose), - "comment" => pr_action("commented", &args, verbose), - "edit" => pr_action("edited", &args, verbose), + "comment" => pr_action("commented", args, verbose), + "edit" => pr_action("edited", args, verbose), _ => run_passthrough("gh", "pr", args), } } diff --git a/src/git.rs b/src/git.rs index ade27f8b..0f4d137a 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,3 +1,4 @@ +use crate::config; use crate::tracking; use crate::utils::resolved_command; use anyhow::{Context, Result}; @@ -76,6 +77,9 @@ fn run_diff( let mut cmd = git_cmd(global_args); cmd.arg("diff"); for arg in args { + if arg == "--no-compact" { + continue; // RTK flag, not a git flag + } cmd.arg(arg); } @@ -111,6 +115,21 @@ fn run_diff( let output = cmd.output().context("Failed to run git diff")?; let stat_stdout = String::from_utf8_lossy(&output.stdout); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + let raw = stat_stdout.to_string(); + timer.track( + &format!("git diff {}", args.join(" ")), + &format!("rtk git diff {}", args.join(" ")), + &raw, + &raw, + ); + std::process::exit(output.status.code().unwrap_or(1)); + } + if verbose > 0 { eprintln!("Git diff summary:"); } @@ -279,6 +298,7 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { let mut in_hunk = false; let mut hunk_lines = 0; let max_hunk_lines = 30; + let mut was_truncated = false; for line in diff.lines() { if line.starts_with("diff --git") { @@ -287,7 +307,7 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { result.push(format!(" +{} -{}", added, removed)); } current_file = line.split(" b/").nth(1).unwrap_or("unknown").to_string(); - result.push(format!("\n📄 {}", current_file)); + result.push(format!("\n{}", current_file)); added = 0; removed = 0; in_hunk = false; @@ -321,11 +341,13 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { if hunk_lines == max_hunk_lines { result.push(" ... (truncated)".to_string()); hunk_lines += 1; + was_truncated = true; } } if result.len() >= max_lines { result.push("\n... (more changes truncated)".to_string()); + was_truncated = true; break; } } @@ -334,6 +356,10 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { result.push(format!(" +{} -{}", added, removed)); } + if was_truncated { + result.push("[full diff: rtk git diff --no-compact]".to_string()); + } + result.join("\n") } @@ -355,7 +381,7 @@ fn run_log( // Check if user provided limit flag (-N, -n N, --max-count=N, --max-count N) let has_limit_flag = args.iter().any(|arg| { - (arg.starts_with('-') && arg.chars().nth(1).map_or(false, |c| c.is_ascii_digit())) + (arg.starts_with('-') && arg.chars().nth(1).is_some_and(|c| c.is_ascii_digit())) || arg == "-n" || arg.starts_with("--max-count") }); @@ -433,7 +459,7 @@ fn parse_user_limit(args: &[String]) -> Option { // -20 (combined digit form) if arg.starts_with('-') && arg.len() > 1 - && arg.chars().nth(1).map_or(false, |c| c.is_ascii_digit()) + && arg.chars().nth(1).is_some_and(|c| c.is_ascii_digit()) { if let Ok(n) = arg[1..].parse::() { return Some(n); @@ -547,7 +573,7 @@ fn format_status_output(porcelain: &str) -> String { if let Some(branch_line) = lines.first() { if branch_line.starts_with("##") { let branch = branch_line.trim_start_matches("## "); - output.push_str(&format!("📌 {}\n", branch)); + output.push_str(&format!("branch: {}\n", branch)); } } @@ -592,38 +618,56 @@ fn format_status_output(porcelain: &str) -> String { } // Build summary + let limits = config::limits(); + let max_files = limits.status_max_files; + let max_untracked = limits.status_max_untracked; + if staged > 0 { - output.push_str(&format!("✅ Staged: {} files\n", staged)); - for f in staged_files.iter().take(5) { + output.push_str(&format!("staged: {} files\n", staged)); + for f in staged_files.iter().take(max_files) { output.push_str(&format!(" {}\n", f)); } - if staged_files.len() > 5 { - output.push_str(&format!(" ... +{} more\n", staged_files.len() - 5)); + if staged_files.len() > max_files { + output.push_str(&format!( + " ... +{} more\n", + staged_files.len() - max_files + )); } } if modified > 0 { - output.push_str(&format!("📝 Modified: {} files\n", modified)); - for f in modified_files.iter().take(5) { + output.push_str(&format!("modified: {} files\n", modified)); + for f in modified_files.iter().take(max_files) { output.push_str(&format!(" {}\n", f)); } - if modified_files.len() > 5 { - output.push_str(&format!(" ... +{} more\n", modified_files.len() - 5)); + if modified_files.len() > max_files { + output.push_str(&format!( + " ... +{} more\n", + modified_files.len() - max_files + )); } } if untracked > 0 { - output.push_str(&format!("❓ Untracked: {} files\n", untracked)); - for f in untracked_files.iter().take(3) { + output.push_str(&format!("untracked: {} files\n", untracked)); + for f in untracked_files.iter().take(max_untracked) { output.push_str(&format!(" {}\n", f)); } - if untracked_files.len() > 3 { - output.push_str(&format!(" ... +{} more\n", untracked_files.len() - 3)); + if untracked_files.len() > max_untracked { + output.push_str(&format!( + " ... +{} more\n", + untracked_files.len() - max_untracked + )); } } if conflicts > 0 { - output.push_str(&format!("⚠️ Conflicts: {} files\n", conflicts)); + output.push_str(&format!("conflicts: {} files\n", conflicts)); + } + + // When working tree is clean (only branch line, no changes) + if staged == 0 && modified == 0 && untracked == 0 && conflicts == 0 { + output.push_str("clean — nothing to commit\n"); } output.trim_end().to_string() @@ -680,6 +724,20 @@ fn run_status(args: &[String], verbose: u8, global_args: &[String]) -> Result<() let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); + if !output.status.success() { + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + let raw = stdout.to_string(); + timer.track( + &format!("git status {}", args.join(" ")), + &format!("rtk git status {}", args.join(" ")), + &raw, + &raw, + ); + std::process::exit(output.status.code().unwrap_or(1)); + } + if verbose > 0 || !stderr.is_empty() { eprint!("{}", stderr); } @@ -833,7 +891,7 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result<() // Extract commit hash from output like "[main abc1234] message" let compact = if let Some(line) = stdout.lines().next() { if let Some(hash_start) = line.find(' ') { - let hash = line[1..hash_start].split(' ').last().unwrap_or(""); + let hash = line[1..hash_start].split(' ').next_back().unwrap_or(""); if !hash.is_empty() && hash.len() >= 7 { format!("ok ✓ {}", &hash[..7.min(hash.len())]) } else { @@ -859,13 +917,14 @@ fn run_commit(args: &[String], verbose: u8, global_args: &[String]) -> Result<() "ok (nothing to commit)", ); } else { - eprintln!("FAILED: git commit"); if !stderr.trim().is_empty() { - eprintln!("{}", stderr); + eprint!("{}", stderr); } if !stdout.trim().is_empty() { - eprintln!("{}", stdout); + eprint!("{}", stdout); } + timer.track(&original_cmd, "rtk git commit", &raw_output, &raw_output); + std::process::exit(output.status.code().unwrap_or(1)); } } @@ -1027,10 +1086,23 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<() eprintln!("git branch"); } - // Detect write operations: delete, rename, copy - let has_action_flag = args - .iter() - .any(|a| a == "-d" || a == "-D" || a == "-m" || a == "-M" || a == "-c" || a == "-C"); + // Detect write operations: delete, rename, copy, upstream tracking + let has_action_flag = args.iter().any(|a| { + a == "-d" + || a == "-D" + || a == "-m" + || a == "-M" + || a == "-c" + || a == "-C" + || a == "--set-upstream-to" + || a.starts_with("--set-upstream-to=") + || a == "-u" + || a == "--unset-upstream" + || a == "--edit-description" + }); + + // Detect flags that produce specific output (not a branch list) + let has_show_flag = args.iter().any(|a| a == "--show-current"); // Detect list-mode flags let has_list_flag = args.iter().any(|a| { @@ -1043,11 +1115,49 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<() || a == "--no-merged" || a == "--contains" || a == "--no-contains" + || a == "--format" + || a.starts_with("--format=") + || a == "--sort" + || a.starts_with("--sort=") + || a == "--points-at" + || a.starts_with("--points-at=") }); // Detect positional arguments (not flags) — indicates branch creation let has_positional_arg = args.iter().any(|a| !a.starts_with('-')); + // --show-current: passthrough with raw stdout (not "ok ✓") + if has_show_flag { + let mut cmd = git_cmd(global_args); + cmd.arg("branch"); + for arg in args { + cmd.arg(arg); + } + let output = cmd.output().context("Failed to run git branch")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{}{}", stdout, stderr); + + let trimmed = stdout.trim(); + timer.track( + &format!("git branch {}", args.join(" ")), + &format!("rtk git branch {}", args.join(" ")), + &combined, + trimmed, + ); + + if output.status.success() { + println!("{}", trimmed); + } else { + eprintln!("FAILED: git branch {}", args.join(" ")); + if !stderr.trim().is_empty() { + eprintln!("{}", stderr); + } + std::process::exit(output.status.code().unwrap_or(1)); + } + return Ok(()); + } + // Write operation: action flags, or positional args without list flags (= branch creation) if has_action_flag || (has_positional_arg && !has_list_flag) { let mut cmd = git_cmd(global_args); @@ -1103,6 +1213,20 @@ fn run_branch(args: &[String], verbose: u8, global_args: &[String]) -> Result<() let stdout = String::from_utf8_lossy(&output.stdout); let raw = stdout.to_string(); + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + timer.track( + &format!("git branch {}", args.join(" ")), + &format!("rtk git branch {}", args.join(" ")), + &raw, + &raw, + ); + std::process::exit(output.status.code().unwrap_or(1)); + } + let filtered = filter_branch_output(&stdout); println!("{}", filtered); @@ -1303,7 +1427,42 @@ fn run_stash( std::process::exit(output.status.code().unwrap_or(1)); } } - _ => { + Some(sub) => { + // Unrecognized subcommand: passthrough to git stash [args] + let mut cmd = git_cmd(global_args); + cmd.args(["stash", sub]); + for arg in args { + cmd.arg(arg); + } + let output = cmd.output().context("Failed to run git stash")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{}{}", stdout, stderr); + + let msg = if output.status.success() { + let msg = format!("ok stash {}", sub); + println!("{}", msg); + msg + } else { + eprintln!("FAILED: git stash {}", sub); + if !stderr.trim().is_empty() { + eprintln!("{}", stderr); + } + combined.clone() + }; + + timer.track( + &format!("git stash {}", sub), + &format!("rtk git stash {}", sub), + &combined, + &msg, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + } + None => { // Default: git stash (push) let mut cmd = git_cmd(global_args); cmd.arg("stash"); @@ -1649,8 +1808,8 @@ mod tests { fn test_format_status_output_modified_files() { let porcelain = "## main...origin/main\n M src/main.rs\n M src/lib.rs\n"; let result = format_status_output(porcelain); - assert!(result.contains("📌 main...origin/main")); - assert!(result.contains("📝 Modified: 2 files")); + assert!(result.contains("branch: main...origin/main")); + assert!(result.contains("modified: 2 files")); assert!(result.contains("src/main.rs")); assert!(result.contains("src/lib.rs")); assert!(!result.contains("Staged")); @@ -1661,8 +1820,8 @@ mod tests { fn test_format_status_output_untracked_files() { let porcelain = "## feature/new\n?? temp.txt\n?? debug.log\n?? test.sh\n"; let result = format_status_output(porcelain); - assert!(result.contains("📌 feature/new")); - assert!(result.contains("❓ Untracked: 3 files")); + assert!(result.contains("branch: feature/new")); + assert!(result.contains("untracked: 3 files")); assert!(result.contains("temp.txt")); assert!(result.contains("debug.log")); assert!(result.contains("test.sh")); @@ -1678,35 +1837,60 @@ A added.rs ?? untracked.txt "#; let result = format_status_output(porcelain); - assert!(result.contains("📌 main")); - assert!(result.contains("✅ Staged: 2 files")); + assert!(result.contains("branch: main")); + assert!(result.contains("staged: 2 files")); assert!(result.contains("staged.rs")); assert!(result.contains("added.rs")); - assert!(result.contains("📝 Modified: 1 files")); + assert!(result.contains("modified: 1 files")); assert!(result.contains("modified.rs")); - assert!(result.contains("❓ Untracked: 1 files")); + assert!(result.contains("untracked: 1 files")); assert!(result.contains("untracked.txt")); } #[test] fn test_format_status_output_truncation() { - // Test that >5 staged files show "... +N more" - let porcelain = r#"## main -M file1.rs -M file2.rs -M file3.rs -M file4.rs -M file5.rs -M file6.rs -M file7.rs -"#; - let result = format_status_output(porcelain); - assert!(result.contains("✅ Staged: 7 files")); + // Test that >15 staged files show "... +N more" + let mut porcelain = String::from("## main\n"); + for i in 1..=20 { + porcelain.push_str(&format!("M file{}.rs\n", i)); + } + let result = format_status_output(&porcelain); + assert!(result.contains("staged: 20 files")); + assert!(result.contains("file1.rs")); + assert!(result.contains("file15.rs")); + assert!(result.contains("... +5 more")); + assert!(!result.contains("file16.rs")); + assert!(!result.contains("file20.rs")); + } + + #[test] + fn test_format_status_modified_truncation() { + // Test that >15 modified files show "... +N more" + let mut porcelain = String::from("## main\n"); + for i in 1..=20 { + porcelain.push_str(&format!(" M file{}.rs\n", i)); + } + let result = format_status_output(&porcelain); + assert!(result.contains("modified: 20 files")); + assert!(result.contains("file1.rs")); + assert!(result.contains("file15.rs")); + assert!(result.contains("... +5 more")); + assert!(!result.contains("file16.rs")); + } + + #[test] + fn test_format_status_untracked_truncation() { + // Test that >10 untracked files show "... +N more" + let mut porcelain = String::from("## main\n"); + for i in 1..=15 { + porcelain.push_str(&format!("?? file{}.rs\n", i)); + } + let result = format_status_output(&porcelain); + assert!(result.contains("untracked: 15 files")); assert!(result.contains("file1.rs")); - assert!(result.contains("file5.rs")); - assert!(result.contains("... +2 more")); - assert!(!result.contains("file6.rs")); - assert!(!result.contains("file7.rs")); + assert!(result.contains("file10.rs")); + assert!(result.contains("... +5 more")); + assert!(!result.contains("file11.rs")); } #[test] @@ -1916,7 +2100,7 @@ no changes added to commit (use "git add" and/or "git commit -a") let porcelain = "## main\n M สวัสดี.txt\n?? ทดสอบ.rs\n"; let result = format_status_output(porcelain); // Should not panic - assert!(result.contains("📌 main")); + assert!(result.contains("branch: main")); assert!(result.contains("สวัสดี.txt")); assert!(result.contains("ทดสอบ.rs")); } @@ -1925,7 +2109,7 @@ no changes added to commit (use "git add" and/or "git commit -a") fn test_format_status_output_emoji_filename() { let porcelain = "## main\nA 🎉-party.txt\n M 日本語ファイル.rs\n"; let result = format_status_output(porcelain); - assert!(result.contains("📌 main")); + assert!(result.contains("branch: main")); } /// Regression test: --oneline and other user format flags must preserve all commits. diff --git a/src/golangci_cmd.rs b/src/golangci_cmd.rs index 72b9ee6f..ab0f74f3 100644 --- a/src/golangci_cmd.rs +++ b/src/golangci_cmd.rs @@ -1,3 +1,4 @@ +use crate::config; use crate::tracking; use crate::utils::{resolved_command, truncate}; use anyhow::{Context, Result}; @@ -9,8 +10,10 @@ struct Position { #[serde(rename = "Filename")] filename: String, #[serde(rename = "Line")] + #[allow(dead_code)] line: usize, #[serde(rename = "Column")] + #[allow(dead_code)] column: usize, } @@ -19,6 +22,7 @@ struct Issue { #[serde(rename = "FromLinter")] from_linter: String, #[serde(rename = "Text")] + #[allow(dead_code)] text: String, #[serde(rename = "Pos")] pos: Position, @@ -106,7 +110,7 @@ fn filter_golangci_json(output: &str) -> String { return format!( "golangci-lint (JSON parse failed: {})\n{}", e, - truncate(output, 500) + truncate(output, config::limits().passthrough_max_chars) ); } }; diff --git a/src/grep_cmd.rs b/src/grep_cmd.rs index 0ba58770..50ee4ad6 100644 --- a/src/grep_cmd.rs +++ b/src/grep_cmd.rs @@ -1,9 +1,11 @@ +use crate::config; use crate::tracking; use crate::utils::resolved_command; use anyhow::{Context, Result}; use regex::Regex; use std::collections::HashMap; +#[allow(clippy::too_many_arguments)] pub fn run( pattern: &str, path: &str, @@ -77,6 +79,13 @@ pub fn run( let mut by_file: HashMap> = HashMap::new(); let mut total = 0; + // Compile context regex once (instead of per-line in clean_line) + let context_re = if context_only { + Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))).ok() + } else { + None + }; + for line in stdout.lines() { let parts: Vec<&str> = line.splitn(3, ':').collect(); @@ -91,7 +100,7 @@ pub fn run( }; total += 1; - let cleaned = clean_line(content, max_line_len, context_only, pattern); + let cleaned = clean_line(content, max_line_len, context_re.as_ref(), pattern); by_file.entry(file).or_default().push((line_num, cleaned)); } @@ -110,7 +119,8 @@ pub fn run( let file_display = compact_path(file); rtk_output.push_str(&format!("📄 {} ({}):\n", file_display, matches.len())); - for (line_num, content) in matches.iter().take(10) { + let per_file = config::limits().grep_max_per_file; + for (line_num, content) in matches.iter().take(per_file) { rtk_output.push_str(&format!(" {:>4}: {}\n", line_num, content)); shown += 1; if shown >= max_results { @@ -118,8 +128,8 @@ pub fn run( } } - if matches.len() > 10 { - rtk_output.push_str(&format!(" +{}\n", matches.len() - 10)); + if matches.len() > per_file { + rtk_output.push_str(&format!(" +{}\n", matches.len() - per_file)); } rtk_output.push('\n'); } @@ -143,16 +153,14 @@ pub fn run( Ok(()) } -fn clean_line(line: &str, max_len: usize, context_only: bool, pattern: &str) -> String { +fn clean_line(line: &str, max_len: usize, context_re: Option<&Regex>, pattern: &str) -> String { let trimmed = line.trim(); - if context_only { - if let Ok(re) = Regex::new(&format!("(?i).{{0,20}}{}.*", regex::escape(pattern))) { - if let Some(m) = re.find(trimmed) { - let matched = m.as_str(); - if matched.len() <= max_len { - return matched.to_string(); - } + if let Some(re) = context_re { + if let Some(m) = re.find(trimmed) { + let matched = m.as_str(); + if matched.len() <= max_len { + return matched.to_string(); } } } @@ -216,7 +224,7 @@ mod tests { #[test] fn test_clean_line() { let line = " const result = someFunction();"; - let cleaned = clean_line(line, 50, false, "result"); + let cleaned = clean_line(line, 50, None, "result"); assert!(!cleaned.starts_with(' ')); assert!(cleaned.len() <= 50); } @@ -240,7 +248,7 @@ mod tests { fn test_clean_line_multibyte() { // Thai text that exceeds max_len in bytes let line = " สวัสดีครับ นี่คือข้อความที่ยาวมากสำหรับทดสอบ "; - let cleaned = clean_line(line, 20, false, "ครับ"); + let cleaned = clean_line(line, 20, None, "ครับ"); // Should not panic assert!(!cleaned.is_empty()); } @@ -248,7 +256,7 @@ mod tests { #[test] fn test_clean_line_emoji() { let line = "🎉🎊🎈🎁🎂🎄 some text 🎃🎆🎇✨"; - let cleaned = clean_line(line, 15, false, "text"); + let cleaned = clean_line(line, 15, None, "text"); assert!(!cleaned.is_empty()); } diff --git a/src/hook_check.rs b/src/hook_check.rs index 2716ec15..50a6537a 100644 --- a/src/hook_check.rs +++ b/src/hook_check.rs @@ -1,6 +1,6 @@ use std::path::PathBuf; -const CURRENT_HOOK_VERSION: u8 = 2; +const CURRENT_HOOK_VERSION: u8 = 3; const WARN_INTERVAL_SECS: u64 = 24 * 3600; /// Hook status for diagnostics and `rtk gain`. diff --git a/src/init.rs b/src/init.rs index 72907d62..e50b79f5 100644 --- a/src/init.rs +++ b/src/init.rs @@ -507,7 +507,7 @@ pub fn uninstall(global: bool, verbose: u8) -> Result<()> { fs::write(&claude_md_path, cleaned).with_context(|| { format!("Failed to write CLAUDE.md: {}", claude_md_path.display()) })?; - removed.push(format!("CLAUDE.md: removed @RTK.md reference")); + removed.push("CLAUDE.md: removed @RTK.md reference".to_string()); } } @@ -566,7 +566,7 @@ fn patch_settings_json( }; // Check idempotency - if hook_already_present(&root, &hook_command) { + if hook_already_present(&root, hook_command) { if verbose > 0 { eprintln!("settings.json: hook already present"); } @@ -591,7 +591,7 @@ fn patch_settings_json( } // Deep-merge hook - insert_hook_entry(&mut root, &hook_command); + insert_hook_entry(&mut root, hook_command); // Backup original if settings_path.exists() { @@ -637,7 +637,6 @@ fn clean_double_blanks(content: &str) -> String { if line.trim().is_empty() { // Count consecutive blank lines let mut blank_count = 0; - let start = i; while i < lines.len() && lines[i].trim().is_empty() { blank_count += 1; i += 1; @@ -645,9 +644,7 @@ fn clean_double_blanks(content: &str) -> String { // Keep at most 2 blank lines let keep = blank_count.min(2); - for _ in 0..keep { - result.push(""); - } + result.extend(std::iter::repeat_n("", keep)); } else { result.push(line); i += 1; @@ -1405,7 +1402,6 @@ fn run_opencode_only_mode(verbose: u8) -> Result<()> { #[cfg(test)] mod tests { use super::*; - use std::path::Path; use tempfile::TempDir; #[test] @@ -1774,8 +1770,8 @@ More notes let serialized = serde_json::to_string(&parsed).unwrap(); // Keys should appear in same order - let original_keys: Vec<&str> = original.split("\"").filter(|s| s.contains(":")).collect(); - let serialized_keys: Vec<&str> = + let _original_keys: Vec<&str> = original.split("\"").filter(|s| s.contains(":")).collect(); + let _serialized_keys: Vec<&str> = serialized.split("\"").filter(|s| s.contains(":")).collect(); // Just check that keys exist (preserve_order doesn't guarantee exact order in nested objects) diff --git a/src/learn/detector.rs b/src/learn/detector.rs index 87f0e162..21407668 100644 --- a/src/learn/detector.rs +++ b/src/learn/detector.rs @@ -5,6 +5,7 @@ use regex::Regex; pub enum ErrorType { UnknownFlag, CommandNotFound, + #[allow(dead_code)] WrongSyntax, WrongPath, MissingArg, @@ -229,9 +230,7 @@ pub fn find_corrections(commands: &[CommandExecution]) -> Vec { } // Look ahead for correction within CORRECTION_WINDOW - for j in (i + 1)..std::cmp::min(i + 1 + CORRECTION_WINDOW, commands.len()) { - let candidate = &commands[j]; - + for candidate in commands.iter().skip(i + 1).take(CORRECTION_WINDOW) { let similarity = command_similarity(&cmd.command, &candidate.command); // Must meet minimum similarity diff --git a/src/lint_cmd.rs b/src/lint_cmd.rs index 267a21e9..5e96acd1 100644 --- a/src/lint_cmd.rs +++ b/src/lint_cmd.rs @@ -1,3 +1,4 @@ +use crate::config; use crate::mypy_cmd; use crate::ruff_cmd; use crate::tracking; @@ -35,11 +36,13 @@ struct PylintDiagnostic { module: String, #[allow(dead_code)] obj: String, + #[allow(dead_code)] line: usize, #[allow(dead_code)] column: usize, path: String, symbol: String, // rule code like "unused-variable" + #[allow(dead_code)] message: String, #[serde(rename = "message-id")] message_id: String, // e.g., "W0612" @@ -234,7 +237,7 @@ fn filter_eslint_json(output: &str) -> String { return format!( "ESLint output (JSON parse failed: {})\n{}", e, - truncate(output, 500) + truncate(output, config::limits().passthrough_max_chars) ); } }; @@ -326,7 +329,7 @@ fn filter_pylint_json(output: &str) -> String { return format!( "Pylint output (JSON parse failed: {})\n{}", e, - truncate(output, 500) + truncate(output, config::limits().passthrough_max_chars) ); } }; diff --git a/src/log_cmd.rs b/src/log_cmd.rs index 01670af7..e8026d54 100644 --- a/src/log_cmd.rs +++ b/src/log_cmd.rs @@ -105,7 +105,7 @@ fn analyze_logs(content: &str) -> String { let total_warnings: usize = warn_counts.values().sum(); let total_info: usize = info_counts.values().sum(); - result.push(format!("📊 Log Summary")); + result.push("📊 Log Summary".to_string()); result.push(format!( " ❌ {} errors ({} unique)", total_errors, diff --git a/src/main.rs b/src/main.rs index b7b45633..fba79a4c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -38,6 +38,7 @@ mod mypy_cmd; mod next_cmd; mod npm_cmd; mod parser; +mod permissions; mod pip_cmd; mod playwright_cmd; mod pnpm_cmd; @@ -306,7 +307,7 @@ enum Commands { #[arg(short = 'l', long, default_value = "80")] max_len: usize, /// Max results to show - #[arg(short, long, default_value = "50")] + #[arg(short, long, default_value = "200")] max: usize, /// Show only match context (not full line) #[arg(short, long)] @@ -1179,11 +1180,11 @@ enum GtCommands { fn shell_split(input: &str) -> Vec { let mut tokens = Vec::new(); let mut current = String::new(); - let mut chars = input.chars().peekable(); + let chars = input.chars(); let mut in_single = false; let mut in_double = false; - while let Some(c) = chars.next() { + for c in chars { match c { '\'' if !in_double => in_single = !in_single, '"' if !in_single => in_double = !in_double, diff --git a/src/parser/README.md b/src/parser/README.md index 6f0d2420..96aea256 100644 --- a/src/parser/README.md +++ b/src/parser/README.md @@ -70,7 +70,7 @@ impl OutputParser for VitestParser { ) } else { // Tier 3: Passthrough - ParseResult::Passthrough(truncate_output(input, 500)) + ParseResult::Passthrough(truncate_output(input, 2000)) } } } diff --git a/src/parser/error.rs b/src/parser/error.rs index eee4f343..e3e48f07 100644 --- a/src/parser/error.rs +++ b/src/parser/error.rs @@ -2,6 +2,7 @@ use thiserror::Error; #[derive(Error, Debug)] +#[allow(dead_code)] pub enum ParseError { #[error("JSON parse failed at line {line}, column {col}: {msg}")] JsonError { diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 5561ec68..0af1de19 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -29,6 +29,7 @@ pub enum ParseResult { impl ParseResult { /// Unwrap the parsed data, panicking on Passthrough + #[allow(dead_code)] pub fn unwrap(self) -> T { match self { ParseResult::Full(data) => data, @@ -38,6 +39,7 @@ impl ParseResult { } /// Get the tier level (1 = Full, 2 = Degraded, 3 = Passthrough) + #[allow(dead_code)] pub fn tier(&self) -> u8 { match self { ParseResult::Full(_) => 1, @@ -47,11 +49,13 @@ impl ParseResult { } /// Check if parsing succeeded (Full or Degraded) + #[allow(dead_code)] pub fn is_ok(&self) -> bool { !matches!(self, ParseResult::Passthrough(_)) } /// Map the parsed data while preserving tier + #[allow(dead_code)] pub fn map(self, f: F) -> ParseResult where F: FnOnce(T) -> U, @@ -64,6 +68,7 @@ impl ParseResult { } /// Get warnings if Degraded tier + #[allow(dead_code)] pub fn warnings(&self) -> Vec { match self { ParseResult::Degraded(_, warnings) => warnings.clone(), @@ -85,16 +90,23 @@ pub trait OutputParser: Sized { fn parse(input: &str) -> ParseResult; /// Parse with explicit tier preference (for testing/debugging) + #[allow(dead_code)] fn parse_with_tier(input: &str, max_tier: u8) -> ParseResult { let result = Self::parse(input); if result.tier() > max_tier { // Force degradation to passthrough if exceeds max tier - return ParseResult::Passthrough(truncate_output(input, 500)); + return ParseResult::Passthrough(truncate_passthrough(input)); } result } } +/// Truncate output using configured passthrough limit +pub fn truncate_passthrough(output: &str) -> String { + let max_chars = crate::config::limits().passthrough_max_chars; + truncate_output(output, max_chars) +} + /// Truncate output to max length with ellipsis pub fn truncate_output(output: &str, max_chars: usize) -> String { let chars: Vec = output.chars().collect(); diff --git a/src/parser/types.rs b/src/parser/types.rs index 2339e2d4..4fa6b804 100644 --- a/src/parser/types.rs +++ b/src/parser/types.rs @@ -23,6 +23,7 @@ pub struct TestFailure { /// Linting result (eslint, biome, tsc, etc.) #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct LintResult { pub total_files: usize, pub files_with_issues: usize, @@ -33,6 +34,7 @@ pub struct LintResult { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct LintIssue { pub file_path: String, pub line: usize, @@ -43,6 +45,7 @@ pub struct LintIssue { } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[allow(dead_code)] pub enum LintSeverity { Error, Warning, @@ -68,6 +71,7 @@ pub struct Dependency { /// Build output (next, webpack, vite, cargo, etc.) #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct BuildOutput { pub success: bool, pub duration_ms: Option, @@ -78,6 +82,7 @@ pub struct BuildOutput { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct BundleInfo { pub name: String, pub size_bytes: u64, @@ -85,6 +90,7 @@ pub struct BundleInfo { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct RouteInfo { pub path: String, pub size_kb: f64, @@ -93,6 +99,7 @@ pub struct RouteInfo { /// Git operation result #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct GitResult { pub operation: String, pub files_changed: usize, @@ -102,6 +109,7 @@ pub struct GitResult { } #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct GitCommit { pub hash: String, pub author: String, @@ -111,6 +119,7 @@ pub struct GitCommit { /// Generic command output (for tools without specific types) #[derive(Debug, Clone, Serialize, Deserialize)] +#[allow(dead_code)] pub struct GenericOutput { pub exit_code: i32, pub stdout: String, diff --git a/src/permissions.rs b/src/permissions.rs new file mode 100644 index 00000000..52fad6a4 --- /dev/null +++ b/src/permissions.rs @@ -0,0 +1,461 @@ +use serde_json::Value; +use std::path::PathBuf; + +/// Verdict from checking a command against Claude Code's permission rules. +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum PermissionVerdict { + /// No deny/ask rules matched — safe to auto-allow. + Allow, + /// A deny rule matched — pass through to Claude Code's native deny handling. + Deny, + /// An ask rule matched — rewrite the command but let Claude Code prompt the user. + Ask, +} + +/// Check `cmd` against Claude Code's deny/ask permission rules. +/// +/// Returns `Allow` when no rules match (preserves existing behavior), +/// `Deny` when a deny rule matches, or `Ask` when an ask rule matches. +/// Deny takes priority over Ask if both match the same command. +pub fn check_command(cmd: &str) -> PermissionVerdict { + let (deny_rules, ask_rules) = load_deny_ask_rules(); + check_command_with_rules(cmd, &deny_rules, &ask_rules) +} + +/// Internal implementation allowing tests to inject rules without file I/O. +pub(crate) fn check_command_with_rules( + cmd: &str, + deny_rules: &[String], + ask_rules: &[String], +) -> PermissionVerdict { + let segments = split_compound_command(cmd); + let mut any_ask = false; + + for segment in &segments { + let segment = segment.trim(); + if segment.is_empty() { + continue; + } + + for pattern in deny_rules { + if command_matches_pattern(segment, pattern) { + return PermissionVerdict::Deny; + } + } + + if !any_ask { + for pattern in ask_rules { + if command_matches_pattern(segment, pattern) { + any_ask = true; + break; + } + } + } + } + + if any_ask { + PermissionVerdict::Ask + } else { + PermissionVerdict::Allow + } +} + +/// Load deny and ask Bash rules from all Claude Code settings files. +/// +/// Files read (in order, later files do not override earlier ones — all are merged): +/// 1. `$PROJECT_ROOT/.claude/settings.json` +/// 2. `$PROJECT_ROOT/.claude/settings.local.json` +/// 3. `~/.claude/settings.json` +/// 4. `~/.claude/settings.local.json` +/// +/// Missing files and malformed JSON are silently skipped. +fn load_deny_ask_rules() -> (Vec, Vec) { + let mut deny_rules = Vec::new(); + let mut ask_rules = Vec::new(); + + for path in get_settings_paths() { + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let Ok(json) = serde_json::from_str::(&content) else { + continue; + }; + let Some(permissions) = json.get("permissions") else { + continue; + }; + + append_bash_rules(permissions.get("deny"), &mut deny_rules); + append_bash_rules(permissions.get("ask"), &mut ask_rules); + } + + (deny_rules, ask_rules) +} + +/// Extract Bash-scoped patterns from a JSON array and append them to `target`. +/// +/// Only rules with a `Bash(...)` prefix are kept. Non-Bash rules (e.g. `Read(...)`) are ignored. +fn append_bash_rules(rules_value: Option<&Value>, target: &mut Vec) { + let Some(arr) = rules_value.and_then(|v| v.as_array()) else { + return; + }; + for rule in arr { + if let Some(s) = rule.as_str() { + if s.starts_with("Bash(") { + target.push(extract_bash_pattern(s).to_string()); + } + } + } +} + +/// Return the ordered list of Claude Code settings file paths to check. +fn get_settings_paths() -> Vec { + let mut paths = Vec::new(); + + if let Some(root) = find_project_root() { + paths.push(root.join(".claude").join("settings.json")); + paths.push(root.join(".claude").join("settings.local.json")); + } + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".claude").join("settings.json")); + paths.push(home.join(".claude").join("settings.local.json")); + } + + paths +} + +/// Locate the project root by walking up from CWD looking for `.claude/`. +/// +/// Falls back to `git rev-parse --show-toplevel` if not found via directory walk. +fn find_project_root() -> Option { + // Fast path: walk up CWD looking for .claude/ — no subprocess needed. + let mut dir = std::env::current_dir().ok()?; + loop { + if dir.join(".claude").exists() { + return Some(dir); + } + if !dir.pop() { + break; + } + } + + // Fallback: git (spawns a subprocess, slower but handles monorepo layouts). + let output = std::process::Command::new("git") + .args(["rev-parse", "--show-toplevel"]) + .output() + .ok()?; + + if output.status.success() { + let path = String::from_utf8(output.stdout).ok()?; + return Some(PathBuf::from(path.trim())); + } + + None +} + +/// Extract the pattern string from inside `Bash(pattern)`. +/// +/// Returns the original string unchanged if it does not match the expected format. +pub(crate) fn extract_bash_pattern(rule: &str) -> &str { + if let Some(inner) = rule.strip_prefix("Bash(") { + if let Some(pattern) = inner.strip_suffix(')') { + return pattern; + } + } + rule +} + +/// Check if `cmd` matches a Claude Code permission pattern. +/// +/// Pattern forms: +/// - `*` → matches everything +/// - `prefix:*` or `prefix *` (trailing `*`, no other wildcards) → prefix match with word boundary +/// - `* suffix`, `pre * suf` → glob matching where `*` matches any sequence of characters +/// - `pattern` → exact match or prefix match (cmd must equal pattern or start with `{pattern} `) +pub(crate) fn command_matches_pattern(cmd: &str, pattern: &str) -> bool { + // 1. Global wildcard + if pattern == "*" { + return true; + } + + // 2. Trailing-only wildcard: fast path with word-boundary preservation + // Handles: "git push*", "git push *", "sudo:*" + if let Some(p) = pattern.strip_suffix('*') { + let prefix = p.trim_end_matches(':').trim_end(); + // Bug 2 fix: after stripping, if prefix is empty or just wildcards, match everything + if prefix.is_empty() || prefix == "*" { + return true; + } + // No other wildcards in prefix -> use word-boundary fast path + if !prefix.contains('*') { + return cmd == prefix || cmd.starts_with(&format!("{} ", prefix)); + } + // Prefix still contains '*' -> fall through to glob matching + } + + // 3. Complex wildcards (leading, middle, multiple): glob matching + if pattern.contains('*') { + return glob_matches(cmd, pattern); + } + + // 4. No wildcard: exact match or prefix with word boundary + cmd == pattern || cmd.starts_with(&format!("{} ", pattern)) +} + +/// Glob-style matching where `*` matches any character sequence (including empty). +/// +/// Colon syntax normalized: `sudo:*` treated as `sudo *` for word separation. +fn glob_matches(cmd: &str, pattern: &str) -> bool { + // Normalize colon-wildcard syntax: "sudo:*" -> "sudo *", "*:rm" -> "* rm" + let normalized = pattern.replace(":*", " *").replace("*:", "* "); + let parts: Vec<&str> = normalized.split('*').collect(); + + // All-stars pattern (e.g. "***") matches everything + if parts.iter().all(|p| p.is_empty()) { + return true; + } + + let mut search_from = 0; + + for (i, part) in parts.iter().enumerate() { + if part.is_empty() { + continue; + } + + if i == 0 { + // First segment: must be prefix (pattern doesn't start with *) + if !cmd.starts_with(part) { + return false; + } + search_from = part.len(); + } else if i == parts.len() - 1 { + // Last segment: must be suffix (pattern doesn't end with *) + if !cmd[search_from..].ends_with(*part) { + return false; + } + } else { + // Middle segment: find next occurrence + match cmd[search_from..].find(*part) { + Some(pos) => search_from += pos + part.len(), + None => return false, + } + } + } + + true +} + +/// Split a compound shell command into individual segments. +/// +/// Splits on `&&`, `||`, `|`, and `;`. Not a full shell parser — handles common cases. +fn split_compound_command(cmd: &str) -> Vec<&str> { + cmd.split("&&") + .flat_map(|s| s.split("||")) + .flat_map(|s| s.split(['|', ';'])) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_bash_pattern() { + assert_eq!( + extract_bash_pattern("Bash(git push --force)"), + "git push --force" + ); + assert_eq!(extract_bash_pattern("Bash(*)"), "*"); + assert_eq!(extract_bash_pattern("Bash(sudo:*)"), "sudo:*"); + assert_eq!(extract_bash_pattern("Read(**/.env*)"), "Read(**/.env*)"); // unchanged + } + + #[test] + fn test_exact_match() { + assert!(command_matches_pattern( + "git push --force", + "git push --force" + )); + } + + #[test] + fn test_wildcard_colon() { + assert!(command_matches_pattern("sudo rm -rf /", "sudo:*")); + } + + #[test] + fn test_no_match() { + assert!(!command_matches_pattern("git status", "git push --force")); + } + + #[test] + fn test_deny_precedence_over_ask() { + let deny = vec!["git push --force".to_string()]; + let ask = vec!["git push --force".to_string()]; + assert_eq!( + check_command_with_rules("git push --force", &deny, &ask), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_non_bash_rules_ignored() { + // Non-Bash rules (e.g. Read, Write) must not match Bash commands. + // In load_deny_ask_rules, only Bash( rules are kept — we verify that + // extract_bash_pattern returns the original string for non-Bash rules. + assert_eq!(extract_bash_pattern("Read(**/.env*)"), "Read(**/.env*)"); + + // With empty rule sets (what you get after filtering out non-Bash rules), + // verdict is always Allow. + assert_eq!( + check_command_with_rules("cat .env", &[], &[]), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_empty_permissions() { + assert_eq!( + check_command_with_rules("git push --force", &[], &[]), + PermissionVerdict::Allow + ); + } + + #[test] + fn test_prefix_match() { + assert!(command_matches_pattern( + "git push --force origin main", + "git push --force" + )); + } + + #[test] + fn test_wildcard_all() { + assert!(command_matches_pattern("anything at all", "*")); + assert!(command_matches_pattern("", "*")); + } + + #[test] + fn test_no_partial_word_match() { + // "git push --forceful" must NOT match pattern "git push --force". + assert!(!command_matches_pattern( + "git push --forceful", + "git push --force" + )); + } + + #[test] + fn test_compound_command_deny() { + let deny = vec!["git push --force".to_string()]; + assert_eq!( + check_command_with_rules("git status && git push --force", &deny, &[]), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_compound_command_ask() { + let ask = vec!["git push".to_string()]; + assert_eq!( + check_command_with_rules("git status && git push origin main", &[], &ask), + PermissionVerdict::Ask + ); + } + + #[test] + fn test_compound_command_deny_overrides_ask() { + let deny = vec!["git push --force".to_string()]; + let ask = vec!["git status".to_string()]; + // deny in compound cmd takes priority even if ask also matches a segment + assert_eq!( + check_command_with_rules("git status && git push --force", &deny, &ask), + PermissionVerdict::Deny + ); + } + + #[test] + fn test_ask_verdict() { + let ask = vec!["git push".to_string()]; + assert_eq!( + check_command_with_rules("git push origin main", &[], &ask), + PermissionVerdict::Ask + ); + } + + #[test] + fn test_sudo_wildcard_no_false_positive() { + // "sudoedit" must NOT match "sudo:*" (word boundary respected). + assert!(!command_matches_pattern("sudoedit /etc/hosts", "sudo:*")); + } + + // Bug 2: *:* catch-all must match everything + #[test] + fn test_star_colon_star_matches_everything() { + assert!(command_matches_pattern("rm -rf /", "*:*")); + assert!(command_matches_pattern("git push --force", "*:*")); + assert!(command_matches_pattern("anything", "*:*")); + } + + // Bug 3: leading wildcard — positive + #[test] + fn test_leading_wildcard() { + assert!(command_matches_pattern("git push --force", "* --force")); + assert!(command_matches_pattern("npm run --force", "* --force")); + } + + // Bug 3: leading wildcard — negative (suffix anchoring) + #[test] + fn test_leading_wildcard_no_partial() { + assert!(!command_matches_pattern("git push --forceful", "* --force")); + assert!(!command_matches_pattern("git push", "* --force")); + } + + // Bug 3: middle wildcard — positive + #[test] + fn test_middle_wildcard() { + assert!(command_matches_pattern("git push main", "git * main")); + assert!(command_matches_pattern("git rebase main", "git * main")); + } + + // Bug 3: middle wildcard — negative + #[test] + fn test_middle_wildcard_no_match() { + assert!(!command_matches_pattern("git push develop", "git * main")); + } + + // Bug 3: multiple wildcards + #[test] + fn test_multiple_wildcards() { + assert!(command_matches_pattern( + "git push --force origin main", + "git * --force *" + )); + assert!(!command_matches_pattern( + "git pull origin main", + "git * --force *" + )); + } + + // Integration: deny with leading wildcard + #[test] + fn test_deny_with_leading_wildcard() { + let deny = vec!["* --force".to_string()]; + assert_eq!( + check_command_with_rules("git push --force", &deny, &[]), + PermissionVerdict::Deny + ); + assert_eq!( + check_command_with_rules("git push", &deny, &[]), + PermissionVerdict::Allow + ); + } + + // Integration: deny *:* blocks everything + #[test] + fn test_deny_star_colon_star() { + let deny = vec!["*:*".to_string()]; + assert_eq!( + check_command_with_rules("rm -rf /", &deny, &[]), + PermissionVerdict::Deny + ); + } +} diff --git a/src/pip_cmd.rs b/src/pip_cmd.rs index 359aef31..0442a9cd 100644 --- a/src/pip_cmd.rs +++ b/src/pip_cmd.rs @@ -33,10 +33,8 @@ pub fn run(args: &[String], verbose: u8) -> Result<()> { run_passthrough(base_cmd, args, verbose)? } _ => { - anyhow::bail!( - "rtk pip: unsupported subcommand '{}'\nSupported: list, outdated, install, uninstall, show", - subcommand - ); + // Unknown subcommand: passthrough to pip/uv + run_passthrough(base_cmd, args, verbose)? } }; @@ -216,11 +214,7 @@ fn filter_pip_outdated(output: &str) -> String { result.push_str("═══════════════════════════════════════\n"); for (i, pkg) in packages.iter().take(20).enumerate() { - let latest = pkg - .latest_version - .as_ref() - .map(|v| v.as_str()) - .unwrap_or("unknown"); + let latest = pkg.latest_version.as_deref().unwrap_or("unknown"); result.push_str(&format!( "{}. {} ({} → {})\n", i + 1, diff --git a/src/playwright_cmd.rs b/src/playwright_cmd.rs index 0031ecc3..c553bcc2 100644 --- a/src/playwright_cmd.rs +++ b/src/playwright_cmd.rs @@ -5,8 +5,8 @@ use regex::Regex; use serde::Deserialize; use crate::parser::{ - emit_degradation_warning, emit_passthrough_warning, truncate_output, FormatMode, OutputParser, - ParseResult, TestFailure, TestResult, TokenFormatter, + emit_degradation_warning, emit_passthrough_warning, truncate_passthrough, FormatMode, + OutputParser, ParseResult, TestFailure, TestResult, TokenFormatter, }; /// Matches real Playwright JSON reporter output (suites → specs → tests → results) @@ -110,7 +110,7 @@ impl OutputParser for PlaywrightParser { } None => { // Tier 3: Passthrough - ParseResult::Passthrough(truncate_output(input, 500)) + ParseResult::Passthrough(truncate_passthrough(input)) } } } diff --git a/src/pnpm_cmd.rs b/src/pnpm_cmd.rs index 50371763..3b05bba9 100644 --- a/src/pnpm_cmd.rs +++ b/src/pnpm_cmd.rs @@ -6,7 +6,7 @@ use std::collections::HashMap; use std::ffi::OsString; use crate::parser::{ - emit_degradation_warning, emit_passthrough_warning, truncate_output, Dependency, + emit_degradation_warning, emit_passthrough_warning, truncate_passthrough, Dependency, DependencyState, FormatMode, OutputParser, ParseResult, TokenFormatter, }; @@ -75,7 +75,7 @@ impl OutputParser for PnpmListParser { } None => { // Tier 3: Passthrough - ParseResult::Passthrough(truncate_output(input, 500)) + ParseResult::Passthrough(truncate_passthrough(input)) } } } @@ -202,7 +202,7 @@ impl OutputParser for PnpmOutdatedParser { } None => { // Tier 3: Passthrough - ParseResult::Passthrough(truncate_output(input, 500)) + ParseResult::Passthrough(truncate_passthrough(input)) } } } @@ -307,7 +307,8 @@ fn run_list(depth: usize, args: &[String], verbose: u8) -> Result<()> { if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("pnpm list failed: {}", stderr); + eprint!("{}", stderr); + std::process::exit(output.status.code().unwrap_or(1)); } let stdout = String::from_utf8_lossy(&output.stdout); @@ -431,7 +432,8 @@ fn run_install(packages: &[String], args: &[String], verbose: u8) -> Result<()> let stderr = String::from_utf8_lossy(&output.stderr); if !output.status.success() { - anyhow::bail!("pnpm install failed: {}", stderr); + eprint!("{}", stderr); + std::process::exit(output.status.code().unwrap_or(1)); } let combined = format!("{}{}", stdout, stderr); diff --git a/src/rewrite_cmd.rs b/src/rewrite_cmd.rs index 754f51a9..c64997b4 100644 --- a/src/rewrite_cmd.rs +++ b/src/rewrite_cmd.rs @@ -1,26 +1,47 @@ use crate::discover::registry; +use crate::permissions::{check_command, PermissionVerdict}; +use std::io::Write; /// Run the `rtk rewrite` command. /// -/// Prints the RTK-rewritten command to stdout and exits 0. -/// Exits 1 (without output) if the command has no RTK equivalent. +/// Prints the RTK-rewritten command to stdout and exits with a code that tells +/// the caller how to handle permissions: /// -/// Used by shell hooks to rewrite commands transparently: -/// ```bash -/// REWRITTEN=$(rtk rewrite "$CMD") || exit 0 -/// [ "$CMD" = "$REWRITTEN" ] && exit 0 # already RTK, skip -/// ``` +/// | Exit | Stdout | Meaning | +/// |------|----------|--------------------------------------------------------------| +/// | 0 | rewritten| Rewrite allowed — hook may auto-allow the rewritten command. | +/// | 1 | (none) | No RTK equivalent — hook passes through unchanged. | +/// | 2 | (none) | Deny rule matched — hook defers to Claude Code native deny. | +/// | 3 | rewritten| Ask rule matched — hook rewrites but lets Claude Code prompt.| pub fn run(cmd: &str) -> anyhow::Result<()> { let excluded = crate::config::Config::load() .map(|c| c.hooks.exclude_commands) .unwrap_or_default(); + // SECURITY: check deny/ask BEFORE rewrite so non-RTK commands are also covered. + let verdict = check_command(cmd); + + if verdict == PermissionVerdict::Deny { + std::process::exit(2); + } + match registry::rewrite_command(cmd, &excluded) { - Some(rewritten) => { - print!("{}", rewritten); - Ok(()) - } + Some(rewritten) => match verdict { + PermissionVerdict::Allow => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + Ok(()) + } + PermissionVerdict::Ask => { + print!("{}", rewritten); + let _ = std::io::stdout().flush(); + std::process::exit(3); + } + PermissionVerdict::Deny => unreachable!(), + }, None => { + // No RTK equivalent. Exit 1 = passthrough. + // Claude Code independently evaluates its own ask rules on the original cmd. std::process::exit(1); } } diff --git a/src/ruff_cmd.rs b/src/ruff_cmd.rs index 00df94d3..289ffe74 100644 --- a/src/ruff_cmd.rs +++ b/src/ruff_cmd.rs @@ -1,3 +1,4 @@ +use crate::config; use crate::tracking; use crate::utils::{resolved_command, truncate}; use anyhow::{Context, Result}; @@ -6,7 +7,9 @@ use std::collections::HashMap; #[derive(Debug, Deserialize)] struct RuffLocation { + #[allow(dead_code)] row: usize, + #[allow(dead_code)] column: usize, } @@ -19,7 +22,9 @@ struct RuffFix { #[derive(Debug, Deserialize)] struct RuffDiagnostic { code: String, + #[allow(dead_code)] message: String, + #[allow(dead_code)] location: RuffLocation, #[allow(dead_code)] end_location: Option, @@ -121,7 +126,7 @@ pub fn filter_ruff_check_json(output: &str) -> String { return format!( "Ruff check (JSON parse failed: {})\n{}", e, - truncate(output, 500) + truncate(output, config::limits().passthrough_max_chars) ); } }; @@ -237,7 +242,7 @@ pub fn filter_ruff_format(output: &str) -> String { for part in parts { let part_lower = part.to_lowercase(); if part_lower.contains("left unchanged") { - let words: Vec<&str> = part.trim().split_whitespace().collect(); + let words: Vec<&str> = part.split_whitespace().collect(); // Look for number before "file" or "files" for (i, word) in words.iter().enumerate() { if (word == &"file" || word == &"files") && i > 0 { diff --git a/src/summary.rs b/src/summary.rs index bea9fe28..bc7aa769 100644 --- a/src/summary.rs +++ b/src/summary.rs @@ -96,10 +96,11 @@ fn detect_output_type(output: &str, command: &str) -> OutputType { OutputType::JsonOutput } else if output.lines().all(|l| { l.len() < 200 - && !l - .contains('\t') - .then_some(true) - .unwrap_or(l.split_whitespace().count() < 10) + && if l.contains('\t') { + false + } else { + l.split_whitespace().count() < 10 + } }) { OutputType::ListOutput } else { diff --git a/src/tee.rs b/src/tee.rs index 90fef523..1dbbe4e8 100644 --- a/src/tee.rs +++ b/src/tee.rs @@ -182,20 +182,15 @@ pub fn tee_and_hint(raw: &str, command_slug: &str, exit_code: i32) -> Option Self { - Self::Failures - } -} - /// Configuration for the tee feature. #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct TeeConfig { diff --git a/src/toml_filter.rs b/src/toml_filter.rs index f72f3c87..69db33bf 100644 --- a/src/toml_filter.rs +++ b/src/toml_filter.rs @@ -574,7 +574,7 @@ pub fn run_filter_tests(filter_name_opt: Option<&str>) -> VerifyResults { .into_iter() .filter(|name| { // When a specific filter is requested, only report that one as missing tests - filter_name_opt.map_or(true, |f| name == f) + filter_name_opt.is_none_or(|f| name == f) }) .filter(|name| !tested_filter_names.contains(name)) .collect(); @@ -1610,8 +1610,8 @@ match_command = "^make\\b" let filters = make_filters(BUILTIN_TOML); assert_eq!( filters.len(), - 47, - "Expected exactly 47 built-in filters, got {}. \ + 57, + "Expected exactly 57 built-in filters, got {}. \ Update this count when adding/removing filters in src/filters/.", filters.len() ); @@ -1668,11 +1668,11 @@ expected = "output line 1\noutput line 2" let combined = format!("{}\n\n{}", BUILTIN_TOML, new_filter); let filters = make_filters(&combined); - // All 47 existing filters still present + 1 new = 48 + // All 57 existing filters still present + 1 new = 58 assert_eq!( filters.len(), - 48, - "Expected 48 filters after concat (47 built-in + 1 new)" + 58, + "Expected 58 filters after concat (57 built-in + 1 new)" ); // New filter is discoverable diff --git a/src/tracking.rs b/src/tracking.rs index 66363a6d..dd73788a 100644 --- a/src/tracking.rs +++ b/src/tracking.rs @@ -221,6 +221,9 @@ pub struct MonthStats { pub avg_time_ms: u64, } +/// Type alias for command statistics tuple: (command, count, saved_tokens, avg_savings_pct, avg_time_ms) +type CommandStats = (String, usize, usize, f64, u64); + impl Tracker { /// Create a new tracker instance. /// @@ -251,6 +254,12 @@ impl Tracker { } let conn = Connection::open(&db_path)?; + // WAL mode + busy_timeout for concurrent access (multiple Claude Code instances). + // Non-fatal: NFS/read-only filesystems may not support WAL. + let _ = conn.execute_batch( + "PRAGMA journal_mode=WAL; + PRAGMA busy_timeout=5000;", + ); conn.execute( "CREATE TABLE IF NOT EXISTS commands ( id INTEGER PRIMARY KEY, @@ -488,6 +497,7 @@ impl Tracker { /// summary.total_saved, summary.avg_savings_pct); /// # Ok::<(), anyhow::Error>(()) /// ``` + #[allow(dead_code)] pub fn get_summary(&self) -> Result { self.get_summary_filtered(None) // delegate to filtered variant } @@ -560,7 +570,7 @@ impl Tracker { fn get_by_command( &self, project_path: Option<&str>, // added - ) -> Result> { + ) -> Result> { let (project_exact, project_glob) = project_filter_params(project_path); // added let mut stmt = self.conn.prepare( "SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms) @@ -851,6 +861,7 @@ impl Tracker { /// } /// # Ok::<(), anyhow::Error>(()) /// ``` + #[allow(dead_code)] pub fn get_recent(&self, limit: usize) -> Result> { self.get_recent_filtered(limit, None) // delegate to filtered variant } @@ -971,6 +982,7 @@ fn get_db_path() -> Result { pub struct ParseFailureRecord { pub timestamp: String, pub raw_command: String, + #[allow(dead_code)] pub error_message: String, pub fallback_succeeded: bool, } @@ -1175,6 +1187,7 @@ pub fn args_display(args: &[OsString]) -> String { /// timer.track("ls -la", "rtk ls", "input", "output"); /// ``` #[deprecated(note = "Use TimedExecution instead")] +#[allow(dead_code)] pub fn track(original_cmd: &str, rtk_cmd: &str, input: &str, output: &str) { let input_tokens = estimate_tokens(input); let output_tokens = estimate_tokens(output); diff --git a/src/tree.rs b/src/tree.rs index 39c5ece9..4727a740 100644 --- a/src/tree.rs +++ b/src/tree.rs @@ -125,7 +125,7 @@ fn filter_tree_output(raw: &str) -> String { } // Remove trailing empty lines - while filtered_lines.last().map_or(false, |l| l.trim().is_empty()) { + while filtered_lines.last().is_some_and(|l| l.trim().is_empty()) { filtered_lines.pop(); } diff --git a/src/trust.rs b/src/trust.rs index ffe7c83f..e4e2af19 100644 --- a/src/trust.rs +++ b/src/trust.rs @@ -140,6 +140,7 @@ pub fn check_trust(filter_path: &Path) -> Result { } /// Store current SHA-256 hash as trusted (computes hash from file). +#[allow(dead_code)] pub fn trust_filter(filter_path: &Path) -> Result<()> { let hash = integrity::compute_hash(filter_path) .with_context(|| format!("Failed to hash: {}", filter_path.display()))?; diff --git a/src/vitest_cmd.rs b/src/vitest_cmd.rs index e9c24be3..fcb043f7 100644 --- a/src/vitest_cmd.rs +++ b/src/vitest_cmd.rs @@ -3,7 +3,7 @@ use regex::Regex; use serde::Deserialize; use crate::parser::{ - emit_degradation_warning, emit_passthrough_warning, extract_json_object, truncate_output, + emit_degradation_warning, emit_passthrough_warning, extract_json_object, truncate_passthrough, FormatMode, OutputParser, ParseResult, TestFailure, TestResult, TokenFormatter, }; use crate::tracking; @@ -88,7 +88,7 @@ impl OutputParser for VitestParser { } None => { // Tier 3: Passthrough - ParseResult::Passthrough(truncate_output(input, 500)) + ParseResult::Passthrough(truncate_passthrough(input)) } } } diff --git a/src/wc_cmd.rs b/src/wc_cmd.rs index 6ac16192..7cd01998 100644 --- a/src/wc_cmd.rs +++ b/src/wc_cmd.rs @@ -167,7 +167,7 @@ fn format_single_line(line: &str, mode: &WcMode) -> 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()); + let last_is_path = parts.last().is_some_and(|p| p.parse::().is_err()); if last_is_path { parts[..parts.len() - 1].join(" ") } else { @@ -202,7 +202,7 @@ fn format_multi_line(lines: &[&str], mode: &WcMode) -> String { continue; } - let is_total = parts.last().map_or(false, |p| *p == "total"); + let is_total = parts.last().is_some_and(|p| *p == "total"); match mode { WcMode::Lines | WcMode::Words | WcMode::Bytes | WcMode::Chars => { @@ -236,7 +236,7 @@ fn format_multi_line(lines: &[&str], mode: &WcMode) -> String { 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()); + let last_is_path = parts.last().is_some_and(|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(); diff --git a/src/wget_cmd.rs b/src/wget_cmd.rs index 548f94a8..630c807a 100644 --- a/src/wget_cmd.rs +++ b/src/wget_cmd.rs @@ -41,10 +41,16 @@ pub fn run(url: &str, args: &[String], verbose: u8) -> Result<()> { println!("{}", msg); timer.track(&format!("wget {}", url), "rtk wget", &raw_output, &msg); } else { - let error = parse_error(&stderr, &stdout); - let msg = format!("⬇️ {} FAILED: {}", compact_url(url), error); - println!("{}", msg); - timer.track(&format!("wget {}", url), "rtk wget", &raw_output, &msg); + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + timer.track( + &format!("wget {}", url), + "rtk wget", + &raw_output, + &raw_output, + ); + std::process::exit(output.status.code().unwrap_or(1)); } Ok(()) @@ -103,10 +109,16 @@ pub fn run_stdout(url: &str, args: &[String], verbose: u8) -> Result<()> { ); } else { let stderr = String::from_utf8_lossy(&output.stderr); - let error = parse_error(&stderr, ""); - let msg = format!("⬇️ {} FAILED: {}", compact_url(url), error); - println!("{}", msg); - timer.track(&format!("wget -O - {}", url), "rtk wget -o", &stderr, &msg); + if !stderr.trim().is_empty() { + eprint!("{}", stderr); + } + timer.track( + &format!("wget -O - {}", url), + "rtk wget -o", + &stderr, + &stderr, + ); + std::process::exit(output.status.code().unwrap_or(1)); } Ok(()) @@ -206,6 +218,7 @@ fn compact_url(url: &str) -> String { } } +#[allow(dead_code)] fn parse_error(stderr: &str, stdout: &str) -> String { // Common wget error patterns let combined = format!("{}\n{}", stderr, stdout);