Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Features

* **json:** TOON encoding preserves all JSON values with 22-59% compression ([#621](https://github.com/rtk-ai/rtk/issues/621), [#827](https://github.com/rtk-ai/rtk/issues/827))

### 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))
Expand All @@ -24,7 +28,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* **hook:** respect Claude Code deny/ask permission rules on rewrite ([a051a6f](https://github.com/rtk-ai/rtk/commit/a051a6f5e56c7ee59375a365580bced634e29c02))
* strip trailing stderr redirects before rewrite matching ([#530](https://github.com/rtk-ai/rtk/issues/530)) ([edd9c02](https://github.com/rtk-ai/rtk/commit/edd9c02e892b297a7e349031b61ef971c982b53d))
* strip trailing stderr redirects before rewrite matching ([#530](https://github.com/rtk-ai/rtk/issues/530)) ([36a6f48](https://github.com/rtk-ai/rtk/commit/36a6f482296d6fc85f8116040a16de2e128733f8))

## [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
12 changes: 12 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ colored = "2"
dirs = "5"
rusqlite = { version = "0.31", features = ["bundled"] }
toml = "0.8"
toon-rust = "0.1.3"
chrono = "0.4"
thiserror = "1.0"
tempfile = "3"
Expand Down
20 changes: 11 additions & 9 deletions src/cmds/cloud/curl_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,12 @@ fn filter_curl_output(output: &str) -> String {
if (trimmed.starts_with('{') || trimmed.starts_with('['))
&& (trimmed.ends_with('}') || trimmed.ends_with(']'))
{
// Try TOON first (lossless, better compression)
if let Some(toon) = crate::core::toon_convert::json_to_toon(trimmed) {
return toon;
}
// Fall back to schema extraction (types only)
if let Ok(schema) = json_cmd::filter_json_string(trimmed, 5) {
// Only use schema if it's actually shorter than the original (#297)
if schema.len() <= trimmed.len() {
return schema;
}
Expand Down Expand Up @@ -90,12 +94,11 @@ mod tests {

#[test]
fn test_filter_curl_json() {
// Large JSON where schema is shorter than original — schema should be returned
// Large JSON — TOON or schema should compress it
let output = r#"{"name": "a very long user name here", "count": 42, "items": [1, 2, 3], "description": "a very long description that takes up many characters in the original JSON payload", "status": "active", "url": "https://example.com/api/v1/users/123"}"#;
let result = filter_curl_output(output);
assert!(result.contains("name"));
assert!(result.contains("string"));
assert!(result.contains("int"));
assert!(result.len() < output.len(), "output should be shorter");
}

#[test]
Expand All @@ -114,13 +117,12 @@ mod tests {
}

#[test]
fn test_filter_curl_json_small_returns_original() {
// Small JSON where schema would be larger than original (issue #297)
fn test_filter_curl_json_small_returns_shorter() {
// Small JSON — may already be compact enough (issue #297)
let output = r#"{"r2Ready":true,"status":"ok"}"#;
let result = filter_curl_output(output);
// Schema would be "{\n r2Ready: bool,\n status: string\n}" which is longer
// Should return the original JSON unchanged
assert_eq!(result.trim(), output.trim());
assert!(result.len() <= output.len(), "output should not be longer");
assert!(result.contains("r2Ready"));
}

#[test]
Expand Down
5 changes: 5 additions & 0 deletions src/cmds/system/json_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,11 @@ pub fn run_stdin(max_depth: usize, schema_only: bool, verbose: u8) -> Result<()>
/// Parse a JSON string and return compact representation with values preserved.
/// Long strings are truncated, arrays are summarized.
pub fn filter_json_compact(json_str: &str, max_depth: usize) -> Result<String> {
// Try TOON first (lossless, better compression than compact_json)
if let Some(toon) = crate::core::toon_convert::json_to_toon(json_str) {
return Ok(toon);
}

let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON")?;
Ok(compact_json(&value, 0, max_depth))
}
Expand Down
1 change: 1 addition & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ pub mod filter;
pub mod tee;
pub mod telemetry;
pub mod toml_filter;
pub mod toon_convert;
pub mod tracking;
pub mod utils;
170 changes: 170 additions & 0 deletions src/core/toon_convert.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//! TOON (Token-Oriented Object Notation) encoding for JSON output.
//!
//! Bypass with `RTK_NO_TOON=1`.
use serde_json::Value;

const TOON_BUDGET_BYTES: usize = 16_384; // 16 KB ≈ 4,000 tokens at RTK's 4-char estimate

/// Try converting a JSON string to TOON. Returns `None` if disabled,
/// parsing fails, encoding fails, or TOON output is not shorter.
/// Over-budget output is truncated at line boundaries.
pub fn json_to_toon(json_str: &str) -> Option<String> {
if std::env::var("RTK_NO_TOON").ok().as_deref() == Some("1") {
return None;
}

let value: Value = serde_json::from_str(json_str).ok()?;
let toon = toon_rust::encode(&value, None).ok()?;

if toon.len() >= json_str.len() {
return None;
}

if toon.len() > TOON_BUDGET_BYTES {
Some(truncate_at_line_boundary(&toon, TOON_BUDGET_BYTES))
} else {
Some(toon)
}
}

fn truncate_at_line_boundary(toon: &str, budget: usize) -> String {
let safe_budget = (0..=budget.min(toon.len()))
.rev()
.find(|&i| toon.is_char_boundary(i))
.unwrap_or(0);

let truncate_at = match toon[..safe_budget].rfind('\n') {
Some(pos) => pos,
None => safe_budget,
};

let kept = &toon[..truncate_at];
let remaining_lines = toon[truncate_at..].lines().count().saturating_sub(1);

format!(
"{}\n... ({} more lines, {} bytes total)",
kept,
remaining_lines,
toon.len()
)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_toon_basic_object() {
let json = r#"{"name": "Alice", "age": 30, "active": true, "role": "admin", "email": "alice@example.com"}"#;
let toon = json_to_toon(json).expect("should encode object");
assert!(toon.len() < json.len());
assert!(toon.contains("Alice"));
}

#[test]
fn test_toon_array_preserves_all_values() {
let items: Vec<Value> = (0..20)
.map(|i| {
serde_json::json!({
"id": i,
"name": format!("item_{}", i),
"status": "active"
})
})
.collect();
let json = serde_json::to_string(&serde_json::json!({"items": items})).expect("serialize");
let toon = json_to_toon(&json).expect("should encode array");
for i in 0..20 {
assert!(
toon.contains(&format!("item_{}", i)),
"should preserve item_{}",
i
);
}
}

#[test]
fn test_toon_byte_savings() {
let rows: Vec<Value> = (0..50)
.map(|i| {
serde_json::json!({
"id": i,
"name": format!("service_{}", i),
"status": if i % 3 == 0 { "running" } else { "stopped" },
"port": 3000 + i,
"region": "us-east-1"
})
})
.collect();
let json =
serde_json::to_string(&serde_json::json!({"services": rows})).expect("serialize");
let toon = json_to_toon(&json).expect("should encode dataset");
let savings_pct = 100.0 - (toon.len() as f64 / json.len() as f64 * 100.0);
assert!(
savings_pct >= 30.0,
"expected >=30% byte savings, got {:.1}%",
savings_pct
);
}

#[test]
fn test_toon_budget_truncation() {
let rows: Vec<Value> = (0..500)
.map(|i| {
serde_json::json!({
"id": i,
"name": format!("long_service_name_for_testing_{}", i),
"description": format!("Description text for service {} to inflate output", i),
"status": "running",
"port": 3000 + i,
"region": "us-east-1"
})
})
.collect();
let json = serde_json::to_string(&serde_json::json!({"data": rows})).expect("serialize");
let toon = json_to_toon(&json).expect("should encode large dataset");
assert!(
toon.len() <= TOON_BUDGET_BYTES + 80,
"should be budget-truncated (got {} bytes)",
toon.len()
);
assert!(toon.contains("more lines"));
}

#[test]
fn test_toon_returns_none_on_invalid() {
assert!(json_to_toon("not json").is_none());
assert!(json_to_toon("").is_none());
assert!(json_to_toon("{broken").is_none());
}

#[test]
fn test_toon_returns_none_when_not_shorter() {
assert!(json_to_toon("1").is_none());
}

#[test]
fn test_truncate_at_line_boundary_basic() {
let input = "line1\nline2\nline3\nline4\nline5\n";
let result = truncate_at_line_boundary(input, 12);
assert!(result.starts_with("line1\nline2"));
assert!(result.contains("more lines"));
}

#[test]
fn test_truncate_no_newline_within_budget() {
let input = "a".repeat(20_000);
let result = truncate_at_line_boundary(&input, 100);
assert!(result.len() <= 200, "got {} bytes", result.len());
assert!(result.contains("more lines"));
}

#[test]
fn test_truncate_utf8_boundary() {
let mut input = "a".repeat(98);
input.push_str("é\n");
input.push_str("after\n");
let result = truncate_at_line_boundary(&input, 99);
assert!(!result.is_empty());
}
}