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
8 changes: 2 additions & 6 deletions build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,7 @@ fn main() {
// Validate: parse the combined TOML to catch errors at build time
let parsed: toml::Value = combined.parse().unwrap_or_else(|e| {
panic!(
"TOML validation failed for combined filters:\n{}\n\nCheck src/filters/*.toml files",
e
"TOML validation failed for combined filters:\n{e}\n\nCheck src/filters/*.toml files"
)
});

Expand All @@ -54,10 +53,7 @@ fn main() {
let mut seen: HashSet<String> = HashSet::new();
for key in filters.keys() {
if !seen.insert(key.clone()) {
panic!(
"Duplicate filter name '{}' found across src/filters/*.toml files",
key
);
panic!("Duplicate filter name '{key}' found across src/filters/*.toml files");
}
}
}
Expand Down
21 changes: 17 additions & 4 deletions src/cmds/cloud/curl_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,13 @@ fn is_binary(bytes: &[u8]) -> bool {
}

fn filter_curl_output(raw: &str, is_tty: bool) -> FilterResult<'_> {
filter_curl_output_with_hint(raw, is_tty, || force_tee_hint(raw, "curl"))
}

fn filter_curl_output_with_hint<F>(raw: &str, is_tty: bool, tee_hint: F) -> FilterResult<'_>
where
F: FnOnce() -> Option<String>,
{
let trimmed = raw.trim();

// Heuristic: looks like a top-level JSON document. Numbers / booleans / null
Expand All @@ -128,7 +135,7 @@ fn filter_curl_output(raw: &str, is_tty: bool) -> FilterResult<'_> {

// We're about to truncate for a human reader. Write a tee file so they (or
// the LLM in their stead) can recover the full body from the printed hint.
let Some(hint) = force_tee_hint(raw, "curl") else {
let Some(hint) = tee_hint() else {
// Tee disabled (RTK_TEE=0 or below MIN_TEE_SIZE): we have nowhere to
// point a recovery hint to, so pass through rather than emit an
// unrecoverable truncation marker.
Expand Down Expand Up @@ -180,7 +187,9 @@ mod tests {
#[test]
fn test_filter_curl_long_output_truncated() {
let long: String = "x".repeat(1000);
let result = filter_curl_output(&long, true);
let result = filter_curl_output_with_hint(&long, true, || {
Some("[full output: /tmp/rtk-curl.log]".to_string())
});
assert!(result.content.starts_with('x'));
assert!(result.content.contains("bytes total"));
assert!(result.content.contains("1000"));
Expand All @@ -191,15 +200,19 @@ mod tests {
#[test]
fn test_filter_curl_multibyte_boundary() {
let content = "a".repeat(499) + "é";
let result = filter_curl_output(&content, true);
let result = filter_curl_output_with_hint(&content, true, || {
Some("[full output: /tmp/rtk-curl.log]".to_string())
});
assert!(result.content.contains("bytes total"));
assert!(result.content.len() < 600);
}

#[test]
fn test_filter_curl_exact_500_bytes() {
let content = "a".repeat(500);
let result = filter_curl_output(&content, true);
let result = filter_curl_output_with_hint(&content, true, || {
Some("[full output: /tmp/rtk-curl.log]".to_string())
});
assert!(result.content.contains("bytes total"));
}

Expand Down
6 changes: 3 additions & 3 deletions src/cmds/system/json_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Inspects JSON structure without showing values, saving tokens on large payloads.

use crate::core::tracking;
use crate::core::{tracking, utils::utf8_prefix_by_byte_limit};
use anyhow::{bail, Context, Result};
use serde_json::Value;
use std::fs;
Expand Down Expand Up @@ -106,8 +106,8 @@ fn compact_json(value: &Value, depth: usize, max_depth: usize) -> String {
Value::Number(n) => format!("{}{}", indent, n),
Value::String(s) => {
if s.len() > 80 {
let end = s.floor_char_boundary(77);
format!("{}\"{}...\"", indent, &s[..end])
let truncated = utf8_prefix_by_byte_limit(s, 77);
format!("{}\"{}...\"", indent, truncated)
} else {
format!("{}\"{}\"", indent, s)
}
Expand Down
12 changes: 6 additions & 6 deletions src/cmds/system/pipe_cmd.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
use anyhow::Result;
use std::io::Read;

use crate::core::stream::RAW_CAP;
use crate::core::truncate::{CAP_LIST, CAP_WARNINGS};
use crate::core::{
stream::RAW_CAP,
truncate::{CAP_LIST, CAP_WARNINGS},
utils::utf8_prefix_by_byte_limit,
};

const MAX_PIPE_MATCHES: usize = CAP_WARNINGS;
const MAX_PIPE_FILES: usize = CAP_WARNINGS;
Expand Down Expand Up @@ -140,10 +143,7 @@ fn find_wrapper(input: &str) -> String {
}

pub fn auto_detect_filter(input: &str) -> fn(&str) -> String {
let end = input.len().min(1024);
// Avoid panic: byte 1024 may fall inside a multi-byte UTF-8 char
let end = input.floor_char_boundary(end);
let first_1k = &input[..end];
let first_1k = utf8_prefix_by_byte_limit(input, 1024);

if first_1k.contains("test result:") && first_1k.contains("passed;") {
return crate::cmds::rust::cargo_cmd::filter_cargo_test;
Expand Down
96 changes: 68 additions & 28 deletions src/core/tracking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1422,6 +1422,43 @@ pub fn args_display(args: &[OsString]) -> String {
#[cfg(test)]
mod tests {
use super::*;
use std::ffi::OsString;
use std::sync::Mutex;

static ENV_LOCK: Mutex<()> = Mutex::new(());

struct EnvVarRestore {
key: &'static str,
old_value: Option<OsString>,
}

impl EnvVarRestore {
fn new(key: &'static str) -> Self {
Self {
key,
old_value: std::env::var_os(key),
}
}
}

impl Drop for EnvVarRestore {
fn drop(&mut self) {
if let Some(value) = &self.old_value {
std::env::set_var(self.key, value);
} else {
std::env::remove_var(self.key);
}
}
}

fn with_temp_db<T>(f: impl FnOnce() -> T) -> T {
let _guard = ENV_LOCK.lock().unwrap();
let _restore = EnvVarRestore::new("RTK_DB_PATH");
let temp_dir = tempfile::tempdir().expect("create temp db dir");
let db_path = temp_dir.path().join("history.db");
std::env::set_var("RTK_DB_PATH", &db_path);
f()
}

// 1. estimate_tokens — verify ~4 chars/token ratio
#[test]
Expand All @@ -1447,7 +1484,7 @@ mod tests {
// 3. Tracker::record + get_recent — round-trip DB
#[test]
fn test_tracker_record_and_recent() {
let tracker = Tracker::new().expect("Failed to create tracker");
let tracker = Tracker::new_in_memory().expect("Failed to create tracker");

// Use unique test identifier to avoid conflicts with other tests
let test_cmd = format!("rtk git status test_{}", std::process::id());
Expand All @@ -1471,7 +1508,7 @@ mod tests {
// 4. track_passthrough doesn't dilute stats (input=0, output=0)
#[test]
fn test_track_passthrough_no_dilution() {
let tracker = Tracker::new().expect("Failed to create tracker");
let tracker = Tracker::new_in_memory().expect("Failed to create tracker");

// Use unique test identifiers
let pid = std::process::id();
Expand Down Expand Up @@ -1515,33 +1552,37 @@ mod tests {
// 5. TimedExecution::track records with exec_time > 0
#[test]
fn test_timed_execution_records_time() {
let timer = TimedExecution::start();
std::thread::sleep(std::time::Duration::from_millis(10));
timer.track("test cmd", "rtk test", "raw input data", "filtered");

// Verify via DB that record exists
let tracker = Tracker::new().expect("Failed to create tracker");
let recent = tracker.get_recent(5).expect("Failed to get recent");
assert!(recent.iter().any(|r| r.rtk_cmd == "rtk test"));
with_temp_db(|| {
let timer = TimedExecution::start();
std::thread::sleep(std::time::Duration::from_millis(10));
timer.track("test cmd", "rtk test", "raw input data", "filtered");

// Verify via DB that record exists
let tracker = Tracker::new().expect("Failed to create tracker");
let recent = tracker.get_recent(5).expect("Failed to get recent");
assert!(recent.iter().any(|r| r.rtk_cmd == "rtk test"));
});
}

// 6. TimedExecution::track_passthrough records with 0 tokens
#[test]
fn test_timed_execution_passthrough() {
let timer = TimedExecution::start();
timer.track_passthrough("git tag", "rtk git tag (passthrough)");

let tracker = Tracker::new().expect("Failed to create tracker");
let recent = tracker.get_recent(5).expect("Failed to get recent");

let pt = recent
.iter()
.find(|r| r.rtk_cmd.contains("passthrough"))
.expect("Passthrough record not found");

// savings_pct should be 0 for passthrough
assert_eq!(pt.savings_pct, 0.0);
assert_eq!(pt.saved_tokens, 0);
with_temp_db(|| {
let timer = TimedExecution::start();
timer.track_passthrough("git tag", "rtk git tag (passthrough)");

let tracker = Tracker::new().expect("Failed to create tracker");
let recent = tracker.get_recent(5).expect("Failed to get recent");

let pt = recent
.iter()
.find(|r| r.rtk_cmd.contains("passthrough"))
.expect("Passthrough record not found");

// savings_pct should be 0 for passthrough
assert_eq!(pt.savings_pct, 0.0);
assert_eq!(pt.saved_tokens, 0);
});
}

// 7. get_db_path respects environment variable RTK_DB_PATH
Expand All @@ -1550,9 +1591,8 @@ mod tests {
#[test]
fn test_db_path_env_and_default() {
use std::env;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
let _guard = ENV_LOCK.lock().unwrap();
let _restore = EnvVarRestore::new("RTK_DB_PATH");

let custom_path = env::temp_dir().join("rtk_test_custom.db");
env::set_var("RTK_DB_PATH", &custom_path);
Expand Down Expand Up @@ -1609,7 +1649,7 @@ mod tests {
// 12. record_parse_failure + get_parse_failure_summary roundtrip
#[test]
fn test_parse_failure_roundtrip() {
let tracker = Tracker::new().expect("Failed to create tracker");
let tracker = Tracker::new_in_memory().expect("Failed to create tracker");
let test_cmd = format!("git -C /path status test_{}", std::process::id());

tracker
Expand All @@ -1627,7 +1667,7 @@ mod tests {
// 13. recovery_rate calculation
#[test]
fn test_parse_failure_recovery_rate() {
let tracker = Tracker::new().expect("Failed to create tracker");
let tracker = Tracker::new_in_memory().expect("Failed to create tracker");
let pid = std::process::id();

// 2 successes, 1 failure
Expand Down
22 changes: 22 additions & 0 deletions src/core/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,16 @@ pub fn truncate(s: &str, max_len: usize) -> String {
}
}

/// Return a prefix whose byte length is at most `max_bytes`, without splitting
/// a multi-byte UTF-8 character.
pub fn utf8_prefix_by_byte_limit(s: &str, max_bytes: usize) -> &str {
let mut end = s.len().min(max_bytes);
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}

/// Strip ANSI escape codes (colors, styles) from a string.
///
/// # Arguments
Expand Down Expand Up @@ -554,6 +564,18 @@ mod tests {
assert!(result.ends_with("..."));
}

#[test]
fn test_utf8_prefix_by_byte_limit_stops_before_multibyte_boundary() {
let input = "abcé";
assert_eq!(utf8_prefix_by_byte_limit(input, 4), "abc");
}

#[test]
fn test_utf8_prefix_by_byte_limit_accepts_exact_boundary() {
let input = "abcé";
assert_eq!(utf8_prefix_by_byte_limit(input, 5), "abcé");
}

// ===== resolve_binary tests (issue #212) =====

#[test]
Expand Down
2 changes: 2 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#![allow(clippy::uninlined_format_args)]

mod analytics;
mod cmds;
mod core;
Expand Down
Loading