Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ All notable changes to rtk (Rust Token Killer) will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Bug Fixes

* **diff:** correct truncation overflow count in condense_unified_diff ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([5399f83](https://github.com/rtk-ai/rtk/commit/5399f83))
* **git:** replace vague truncation markers with exact counts in log and grep output ([#833](https://github.com/rtk-ai/rtk/pull/833)) ([185fb97](https://github.com/rtk-ai/rtk/commit/185fb97))


## [0.33.0-rc.54](https://github.com/rtk-ai/rtk/compare/v0.32.0-rc.54...v0.33.0-rc.54) (2026-03-24)


Expand Down
18 changes: 14 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,19 +296,21 @@ After install, **restart Claude Code**.

## Supported AI Tools

RTK supports 9 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings.
RTK supports 10 AI coding tools. Each integration transparently rewrites shell commands to `rtk` equivalents for 60-90% token savings.

| Tool | Install | Method |
|------|---------|--------|
| **Claude Code** | `rtk init -g` | PreToolUse hook (bash) |
| **GitHub Copilot** | `rtk init -g` | PreToolUse hook (`rtk hook copilot`) |
| **GitHub Copilot (VS Code)** | `rtk init -g --copilot` | PreToolUse hook (`rtk hook copilot`) — transparent rewrite |
| **GitHub Copilot CLI** | `rtk init -g --copilot` | PreToolUse deny-with-suggestion (CLI limitation) |
| **Cursor** | `rtk init -g --agent cursor` | preToolUse hook (hooks.json) |
| **Gemini CLI** | `rtk init -g --gemini` | BeforeTool hook (`rtk hook gemini`) |
| **Codex** | `rtk init -g --codex` | AGENTS.md + RTK.md instructions |
| **Windsurf** | `rtk init --agent windsurf` | .windsurfrules (project-scoped) |
| **Cline / Roo Code** | `rtk init --agent cline` | .clinerules (project-scoped) |
| **OpenCode** | `rtk init -g --opencode` | Plugin TS (tool.execute.before) |
| **OpenClaw** | `openclaw plugins install ./openclaw` | Plugin TS (before_tool_call) |
| **Mistral Vibe** | Planned (#800) | Blocked on upstream BeforeToolCallback |

### Claude Code (default)

Expand All @@ -322,10 +324,14 @@ rtk init -g --uninstall # Remove
### GitHub Copilot (VS Code + CLI)

```bash
rtk init -g # Same hook as Claude Code
rtk init -g --copilot # Install hook + instructions
```

The hook auto-detects Copilot format (VS Code `runTerminalCommand` or CLI `toolName: bash`) and rewrites commands. Works with both Copilot Chat in VS Code and `copilot` CLI.
Creates `.github/hooks/rtk-rewrite.json` (PreToolUse hook) and `.github/copilot-instructions.md` (prompt-level awareness).

The hook (`rtk hook copilot`) auto-detects the format:
- **VS Code Copilot Chat**: transparent rewrite via `updatedInput` (same as Claude Code)
- **Copilot CLI**: deny-with-suggestion (CLI does not support `updatedInput` yet — see [copilot-cli#2013](https://github.com/github/copilot-cli/issues/2013))

### Cursor

Expand Down Expand Up @@ -384,6 +390,10 @@ openclaw plugins install ./openclaw

Plugin in `openclaw/` directory. Uses `before_tool_call` hook, delegates to `rtk rewrite`.

### Mistral Vibe (planned)

Blocked on upstream BeforeToolCallback support ([mistral-vibe#531](https://github.com/mistralai/mistral-vibe/issues/531), [PR #533](https://github.com/mistralai/mistral-vibe/pull/533)). Tracked in [#800](https://github.com/rtk-ai/rtk/issues/800).

### Commands Rewritten

| Raw Command | Rewritten To |
Expand Down
152 changes: 109 additions & 43 deletions src/diff_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
use crate::tracking;
use crate::utils::truncate;
use anyhow::Result;
use std::fs;
use std::path::Path;
Expand Down Expand Up @@ -39,21 +38,17 @@ pub fn run(file1: &Path, file2: &Path, verbose: u8) -> Result<()> {
diff.added, diff.removed, diff.modified
));

for change in diff.changes.iter().take(50) {
// Never truncate diff content — users make decisions based on this data.
// Only the summary header provides compression; all changes are shown in full.
for change in &diff.changes {
match change {
DiffChange::Added(ln, c) => rtk.push_str(&format!("+{:4} {}\n", ln, truncate(c, 80))),
DiffChange::Removed(ln, c) => rtk.push_str(&format!("-{:4} {}\n", ln, truncate(c, 80))),
DiffChange::Modified(ln, old, new) => rtk.push_str(&format!(
"~{:4} {} → {}\n",
ln,
truncate(old, 70),
truncate(new, 70)
)),
DiffChange::Added(ln, c) => rtk.push_str(&format!("+{:4} {}\n", ln, c)),
DiffChange::Removed(ln, c) => rtk.push_str(&format!("-{:4} {}\n", ln, c)),
DiffChange::Modified(ln, old, new) => {
rtk.push_str(&format!("~{:4} {} → {}\n", ln, old, new))
}
}
}
if diff.changes.len() > 50 {
rtk.push_str(&format!("... +{} more changes", diff.changes.len() - 50));
}

print!("{}", rtk);
timer.track(
Expand Down Expand Up @@ -163,17 +158,19 @@ fn condense_unified_diff(diff: &str) -> String {
let mut removed = 0;
let mut changes = Vec::new();

// Never truncate diff content — users make decisions based on this data.
// Only strip diff metadata (headers, @@ hunks); all +/- lines shown in full.
for line in diff.lines() {
if line.starts_with("diff --git") || line.starts_with("--- ") || line.starts_with("+++ ") {
// File header
if line.starts_with("+++ ") {
if !current_file.is_empty() && (added > 0 || removed > 0) {
result.push(format!("[file] {} (+{} -{})", current_file, added, removed));
for c in changes.iter().take(10) {
for c in &changes {
result.push(format!(" {}", c));
}
if changes.len() > 10 {
result.push(format!(" ... +{} more", changes.len() - 10));
let total = added + removed;
if total > 10 {
result.push(format!(" ... +{} more", total - 10));
}
}
current_file = line
Expand All @@ -186,25 +183,22 @@ fn condense_unified_diff(diff: &str) -> String {
}
} else if line.starts_with('+') && !line.starts_with("+++") {
added += 1;
if changes.len() < 15 {
changes.push(truncate(line, 70));
}
changes.push(line.to_string());
} else if line.starts_with('-') && !line.starts_with("---") {
removed += 1;
if changes.len() < 15 {
changes.push(truncate(line, 70));
}
changes.push(line.to_string());
}
}

// Last file
if !current_file.is_empty() && (added > 0 || removed > 0) {
result.push(format!("[file] {} (+{} -{})", current_file, added, removed));
for c in changes.iter().take(10) {
for c in &changes {
result.push(format!(" {}", c));
}
if changes.len() > 10 {
result.push(format!(" ... +{} more", changes.len() - 10));
let total = added + removed;
if total > 10 {
result.push(format!(" ... +{} more", total - 10));
}
}

Expand Down Expand Up @@ -246,23 +240,6 @@ mod tests {
assert!(similarity("let x = 1;", "let x = 2;") > 0.5);
}

// --- truncate ---

#[test]
fn test_truncate_short_string() {
assert_eq!(truncate("hello", 10), "hello");
}

#[test]
fn test_truncate_exact_length() {
assert_eq!(truncate("hello", 5), "hello");
}

#[test]
fn test_truncate_long_string() {
assert_eq!(truncate("hello world!", 8), "hello...");
}

// --- compute_diff ---

#[test]
Expand Down Expand Up @@ -364,4 +341,93 @@ diff --git a/b.rs b/b.rs
let result = condense_unified_diff("");
assert!(result.is_empty());
}

// --- truncation accuracy ---

fn make_large_unified_diff(added: usize, removed: usize) -> String {
let mut lines = vec![
"diff --git a/config.yaml b/config.yaml".to_string(),
"--- a/config.yaml".to_string(),
"+++ b/config.yaml".to_string(),
"@@ -1,200 +1,200 @@".to_string(),
];
for i in 0..removed {
lines.push(format!("-old_value_{}", i));
}
for i in 0..added {
lines.push(format!("+new_value_{}", i));
}
lines.join("\n")
}

#[test]
fn test_condense_unified_diff_overflow_count_accuracy() {
// 100 added + 100 removed = 200 total changes, only 10 shown
// True overflow = 200 - 10 = 190
// Bug: changes vec capped at 15, so old code showed "+5 more" (15-10) instead of "+190 more"
let diff = make_large_unified_diff(100, 100);
let result = condense_unified_diff(&diff);
assert!(
result.contains("+190 more"),
"Expected '+190 more' but got:\n{}",
result
);
assert!(
!result.contains("+5 more"),
"Bug still present: showing '+5 more' instead of true overflow"
);
}

#[test]
fn test_condense_unified_diff_no_false_overflow() {
// 8 changes total — all fit within the 10-line display cap, no overflow message
let diff = make_large_unified_diff(4, 4);
let result = condense_unified_diff(&diff);
assert!(
!result.contains("more"),
"No overflow message expected for 8 changes, got:\n{}",
result
);
}

#[test]
fn test_no_truncation_large_diff() {
// Verify compute_diff returns all changes without truncation
let mut a = Vec::new();
let mut b = Vec::new();
for i in 0..500 {
a.push(format!("line_{}", i));
if i % 3 == 0 {
b.push(format!("CHANGED_{}", i));
} else {
b.push(format!("line_{}", i));
}
}
let a_refs: Vec<&str> = a.iter().map(|s| s.as_str()).collect();
let b_refs: Vec<&str> = b.iter().map(|s| s.as_str()).collect();
let result = compute_diff(&a_refs, &b_refs);

assert!(
result.changes.len() > 100,
"Expected 100+ changes, got {}",
result.changes.len()
);
assert!(!result.changes.is_empty());
}

#[test]
fn test_long_lines_not_truncated() {
let long_line = "x".repeat(500);
let a = vec![long_line.as_str()];
let b = vec!["short"];
let result = compute_diff(&a, &b);
match &result.changes[0] {
DiffChange::Removed(_, content) | DiffChange::Added(_, content) => {
assert_eq!(content.len(), 500, "Line was truncated!");
}
DiffChange::Modified(_, old, _) => {
assert_eq!(old.len(), 500, "Line was truncated!");
}
}
}
}
21 changes: 21 additions & 0 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1577,6 +1577,27 @@ mod tests {
);
}

#[test]
fn test_classify_swift_test() {
assert!(matches!(
classify_command("swift test"),
Classification::Supported {
rtk_equivalent: "rtk swift",
category: "Build",
estimated_savings_pct: 90.0,
status: RtkStatus::Existing,
}
));
}

#[test]
fn test_rewrite_swift_test() {
assert_eq!(
rewrite_command("swift test --parallel", &[]),
Some("rtk swift test --parallel".into())
);
}

// --- #336: docker compose supported subcommands rewritten, unsupported skipped ---

#[test]
Expand Down
4 changes: 2 additions & 2 deletions src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ pub const PATTERNS: &[&str] = &[
r"^shellcheck\b",
r"^shopify\s+theme\s+(push|pull)",
r"^sops\b",
r"^swift\s+build\b",
r"^swift\s+(build|test)\b",
r"^systemctl\s+status\b",
r"^terraform\s+plan",
r"^tofu\s+(fmt|init|plan|validate)(\s|$)",
Expand Down Expand Up @@ -600,7 +600,7 @@ pub const RULES: &[RtkRule] = &[
rewrite_prefixes: &["swift"],
category: "Build",
savings_pct: 65.0,
subcmd_savings: &[],
subcmd_savings: &[("test", 90.0)],
subcmd_status: &[],
},
RtkRule {
Expand Down
45 changes: 45 additions & 0 deletions src/filter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,4 +491,49 @@ fn main() {
assert!(!result.contains("// This is a comment"));
assert!(result.contains("fn main()"));
}

// --- truncation accuracy ---

#[test]
fn test_smart_truncate_overflow_count_exact() {
// 200 plain-text lines with max_lines=20.
// smart_truncate keeps the first max_lines/2=10 lines, then skips the rest.
// The overflow message "// ... N more lines (total: T)" must satisfy:
// kept_count + N == T
let total_lines = 200usize;
let max_lines = 20usize;
let content: String = (0..total_lines)
.map(|i| format!("plain text line number {}", i))
.collect::<Vec<_>>()
.join("\n");

let output = smart_truncate(&content, max_lines, &Language::Rust);

// Extract the overflow message
let overflow_line = output
.lines()
.find(|l| l.contains("more lines"))
.unwrap_or_else(|| panic!("No overflow message found in:\n{}", output));

// Parse "// ... N more lines (total: T)"
let reported_more: usize = overflow_line
.split_whitespace()
.find(|w| w.parse::<usize>().is_ok())
.and_then(|w| w.parse().ok())
.unwrap_or_else(|| panic!("Could not parse overflow count from: {}", overflow_line));

let kept_count = output
.lines()
.filter(|l| !l.contains("more lines") && !l.contains("omitted"))
.count();

assert_eq!(
kept_count + reported_more,
total_lines,
"kept ({}) + reported_more ({}) must equal total ({})",
kept_count,
reported_more,
total_lines
);
}
}
Loading
Loading