use anyhow::Result;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::chunker::count_tokens;
use crate::store::{log_hook_event, HookEvent};
const POST_HOOK_TOOLS: &[&str] = &["Bash", "ListDirectory"];
const BASH_MAX_LINES: usize = 100;
const BASH_HEAD_LINES: usize = 40;
const BASH_TAIL_LINES: usize = 15;
fn log_unfiltered_cmd(cmd: &str) {
if cmd.is_empty() {
return;
}
let log_path = match dirs::home_dir() {
Some(h) => h.join(".tokenix").join("unfiltered_cmds.log"),
None => return,
};
if let Some(parent) = log_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let entry = format!("{}\n", cmd);
use std::io::Write;
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&log_path)
{
let _ = f.write_all(entry.as_bytes());
}
}
pub fn compress_bash_output(cmd: &str, s: &str) -> String {
let user_filters = crate::filters::load_all_filters();
if let Some(f) = crate::filters::find_filter(cmd, &user_filters) {
return crate::filters::apply_filter(s, f);
}
log_unfiltered_cmd(cmd);
let base = compress_output(s);
let lines: Vec<&str> = base.lines().collect();
if is_cargo_output(&lines) {
let cargo_out = compress_cargo(&lines);
if cargo_out.len() < base.len() {
return cargo_out;
}
}
if is_path_listing_command(cmd) {
let listing_out = compress_path_listing(&lines);
if listing_out.len() < base.len() {
return listing_out;
}
}
if is_git_status_command(cmd) {
let status_out = compress_git_status(&lines);
if status_out.len() < base.len() {
return status_out;
}
}
if is_git_log_command(cmd) || is_git_log(&lines) {
let log_out = compress_git_log(&lines);
if log_out.len() < base.len() {
return log_out;
}
}
if is_git_diff_command(cmd) {
let diff_out = compress_git_diff(&lines);
if diff_out.len() < base.len() {
return diff_out;
}
}
if lines.len() <= BASH_MAX_LINES {
return base;
}
truncate_head_tail(&lines, BASH_HEAD_LINES, BASH_TAIL_LINES)
}
fn is_path_listing_command(cmd: &str) -> bool {
let trimmed = cmd.trim();
trimmed == "ls"
|| trimmed == "ls -R"
|| trimmed.starts_with("ls ")
|| trimmed.starts_with("find ")
|| trimmed == "dir"
|| trimmed.starts_with("dir ")
|| trimmed.starts_with("Get-ChildItem")
|| trimmed.starts_with("get-childitem")
|| trimmed == "gci"
|| trimmed.starts_with("gci ")
|| trimmed == "tree"
|| trimmed.starts_with("tree ")
}
fn is_cargo_output(lines: &[&str]) -> bool {
lines.iter().take(50).any(|l| {
let t = l.trim();
t.starts_with("Compiling ")
|| t.starts_with("Finished ")
|| t.starts_with("error[E")
|| t.contains("test result:")
})
}
fn compress_cargo(lines: &[&str]) -> String {
let mut out: Vec<&str> = Vec::new();
let mut in_diagnostic = false;
let mut in_failure_block = false;
let mut warning_count: u32 = 0;
const MAX_WARNINGS: u32 = 5;
for line in lines {
let t = line.trim();
if !in_failure_block && t.starts_with("---- ") && t.ends_with("----") {
in_failure_block = true;
}
if in_failure_block {
out.push(line);
if t.starts_with("test result:") || t.starts_with("running ") {
in_failure_block = false;
}
continue;
}
let is_error = (t.starts_with("error[") || t == "error" || t.starts_with("error: "))
&& !t.starts_with("error_");
let is_warning = t.starts_with("warning[") || t.starts_with("warning: ");
let is_context = t.starts_with("-->")
|| (t.starts_with('|') && t.len() > 1)
|| t.starts_with("= note:")
|| t.starts_with("= help:")
|| t.starts_with("help:");
let is_summary = t.starts_with("Finished ")
|| t.starts_with("error: aborting")
|| t.contains("test result:")
|| t.starts_with("running ")
|| t.starts_with("FAILED")
|| (t.starts_with("test ") && (t.ends_with("ok") || t.ends_with("FAILED")));
let is_panic = t.contains("panicked at");
if is_error || is_panic {
out.push(line);
in_diagnostic = true;
} else if is_warning && warning_count < MAX_WARNINGS {
out.push(line);
in_diagnostic = true;
warning_count += 1;
} else if is_context && in_diagnostic {
out.push(line);
} else if is_summary {
out.push(line);
in_diagnostic = false;
} else {
in_diagnostic = false;
}
}
if warning_count >= MAX_WARNINGS {
out.push(" ... (additional warnings omitted)");
}
out.join("\n")
}
fn is_git_log(lines: &[&str]) -> bool {
lines.iter().take(5).any(|l| l.starts_with("commit "))
}
fn is_git_log_command(cmd: &str) -> bool {
let cmd = cmd.trim();
cmd == "git log" || cmd.starts_with("git log ")
}
fn is_git_status_command(cmd: &str) -> bool {
let cmd = cmd.trim();
cmd == "git status" || cmd.starts_with("git status ")
}
fn is_git_diff_command(cmd: &str) -> bool {
let cmd = cmd.trim();
cmd == "git diff" || cmd.starts_with("git diff ")
}
fn compress_git_log(lines: &[&str]) -> String {
let oneline: Vec<&str> = lines
.iter()
.map(|line| line.trim())
.filter(|line| {
line.len() > 8
&& line.chars().take_while(|c| c.is_ascii_hexdigit()).count() >= 7
&& line.chars().nth(7).is_some_and(|c| c.is_whitespace())
})
.collect();
if oneline.len() >= 3 {
let first = oneline.first().copied().unwrap_or_default();
let last = oneline.last().copied().unwrap_or_default();
return format!(
"git log: {} commits\nfirst: {first}\nlast: {last}",
oneline.len()
);
}
const MAX_COMMITS: usize = 20;
let mut commit_count: usize = 0;
let mut keep_until: usize = 0;
for (i, line) in lines.iter().enumerate() {
if line.starts_with("commit ") {
commit_count += 1;
if commit_count > MAX_COMMITS {
break;
}
}
keep_until = i + 1;
}
if keep_until >= lines.len() {
return lines.join("\n");
}
let omitted = lines.len() - keep_until;
format!(
"{}\n[... {} more lines omitted (>{} commits)]",
lines[..keep_until].join("\n"),
omitted,
MAX_COMMITS
)
}
fn compress_git_status(lines: &[&str]) -> String {
let mut modified = 0usize;
let mut added = 0usize;
let mut deleted = 0usize;
let mut untracked = 0usize;
let mut renamed = 0usize;
let mut examples = Vec::new();
for line in lines {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let code = trimmed.chars().take(2).collect::<String>();
match code.as_str() {
"??" => untracked += 1,
" D" | "D " | "DD" => deleted += 1,
" A" | "A " => added += 1,
" R" | "R " => renamed += 1,
_ if code.contains('M') => modified += 1,
_ => {}
}
if examples.len() < 5 {
examples.push(trimmed.to_string());
}
}
if modified + added + deleted + untracked + renamed == 0 {
return lines.join("\n");
}
let mut out =
format!("git status: M={modified} A={added} D={deleted} R={renamed} ?={untracked}");
if !examples.is_empty() {
out.push_str("\nexamples:\n");
out.push_str(&examples.join("\n"));
}
out
}
fn compress_git_diff(lines: &[&str]) -> String {
let files = lines
.iter()
.filter_map(|line| line.strip_prefix("diff --git "))
.count();
let hunks = lines.iter().filter(|line| line.starts_with("@@")).count();
let additions = lines
.iter()
.filter(|line| line.starts_with('+') && !line.starts_with("+++"))
.count();
let deletions = lines
.iter()
.filter(|line| line.starts_with('-') && !line.starts_with("---"))
.count();
let mut keep = Vec::new();
for line in lines {
if line.starts_with("diff --git ")
|| line.starts_with("@@")
|| line.starts_with("+++")
|| line.starts_with("---")
{
keep.push(*line);
}
if keep.len() >= 40 {
break;
}
}
if files == 0 && hunks == 0 {
return lines.join("\n");
}
format!(
"git diff: files={files} hunks={hunks} +{additions} -{deletions}\n{}",
keep.join("\n")
)
}
fn compress_path_listing(lines: &[&str]) -> String {
let paths: Vec<&str> = lines
.iter()
.map(|line| line.trim())
.filter(|line| {
!line.is_empty()
&& !line.ends_with(':')
&& !line.contains(" -> ")
&& (line.contains('/') || line.contains('\\'))
})
.collect();
if paths.len() < 4 {
return lines.join("\n");
}
let mut counts = std::collections::BTreeMap::<String, usize>::new();
for path in paths {
let normalized = path.replace('\\', "/");
let dir = normalized
.rsplit_once('/')
.map(|(dir, _)| dir)
.unwrap_or(".")
.to_string();
*counts.entry(dir).or_insert(0) += 1;
}
let total_files: usize = counts.values().sum();
let mut out = vec![format!("{} files in {} dirs:", total_files, counts.len())];
for (dir, count) in counts {
out.push(format!("{}/ ({})", dir, count));
}
out.join("\n")
}
fn truncate_head_tail(lines: &[&str], head: usize, tail: usize) -> String {
let total = lines.len();
if total <= head + tail {
return lines.join("\n");
}
let omitted = total - head - tail;
format!(
"{}\n[... {} lines omitted ...]\n{}",
lines[..head].join("\n"),
omitted,
lines[total - tail..].join("\n")
)
}
pub fn compress_output(s: &str) -> String {
let compacted = compact_json(s);
if compacted != s {
return compacted;
}
let s = strip_ansi(s);
let s = remove_emojis(&s);
let s = collapse_blank_lines(&s);
group_repeated_lines(&s)
}
fn compact_json(s: &str) -> String {
let trimmed = s.trim();
if trimmed.len() < 2 {
return s.to_string();
}
if trimmed.starts_with('{') || trimmed.starts_with('[') {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
if let Ok(compact) = serde_json::to_string(&v) {
if compact.len() < trimmed.len() {
return if s.ends_with('\n') {
compact + "\n"
} else {
compact
};
}
}
}
}
let lines: Vec<&str> = trimmed.lines().collect();
if lines.len() > 1
&& lines.iter().all(|l| {
let t = l.trim();
t.is_empty()
|| (t.starts_with('{') && serde_json::from_str::<serde_json::Value>(t).is_ok())
|| (t.starts_with('[') && serde_json::from_str::<serde_json::Value>(t).is_ok())
})
{
let compacted: String = lines
.iter()
.filter_map(|l| {
let t = l.trim();
if t.is_empty() {
return None;
}
Some(
serde_json::from_str::<serde_json::Value>(t)
.and_then(|v| serde_json::to_string(&v))
.unwrap_or_else(|_| t.to_string()),
)
})
.collect::<Vec<_>>()
.join("\n");
let result = if s.ends_with('\n') {
compacted + "\n"
} else {
compacted
};
if result.len() < s.len() {
return result;
}
}
s.to_string()
}
pub(crate) fn strip_ansi(s: &str) -> String {
let bytes = s.as_bytes();
let mut result: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != 0x1b {
result.push(bytes[i]);
i += 1;
continue;
}
i += 1;
if i >= bytes.len() {
break;
}
match bytes[i] {
b'[' => {
i += 1;
while i < bytes.len() {
let b = bytes[i];
i += 1;
if (0x40..=0x7E).contains(&b) {
break;
}
}
}
b']' => {
i += 1;
while i < bytes.len() {
if bytes[i] == 0x07 {
i += 1;
break;
}
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
i += 2;
break;
}
i += 1;
}
}
_ => {
i += 1; }
}
}
String::from_utf8(result).unwrap_or_default()
}
fn remove_emojis(s: &str) -> String {
s.chars().filter(|&c| !is_emoji_char(c)).collect()
}
fn is_emoji_char(c: char) -> bool {
matches!(c,
'\u{1F000}'..='\u{1FFFF}' | '\u{2600}'..='\u{26FF}' | '\u{2700}'..='\u{27BF}' | '\u{FE00}'..='\u{FE0F}' | '\u{200D}' | '\u{20E3}' )
}
fn collapse_blank_lines(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut newline_run = 0usize;
for c in s.chars() {
if c == '\n' {
newline_run += 1;
if newline_run <= 2 {
result.push('\n');
}
} else {
newline_run = 0;
result.push(c);
}
}
result
}
fn group_repeated_lines(s: &str) -> String {
let trailing_newline = s.ends_with('\n');
let source = if trailing_newline {
&s[..s.len() - 1]
} else {
s
};
let lines: Vec<&str> = source.split('\n').collect();
let mut result = String::with_capacity(s.len());
let mut i = 0;
while i < lines.len() {
let line = lines[i];
let mut end = i + 1;
while end < lines.len() && lines[end] == line {
end += 1;
}
let count = end - i;
if count >= 3 {
result.push_str(line);
result.push('\n');
result.push_str(&format!("[repeated {}x]\n", count - 1));
i = end;
continue;
}
if let Some(fuzzy_count) = try_fuzzy_group(&lines, i) {
if fuzzy_count >= 3 {
result.push_str(line);
result.push_str(" ... (and ");
result.push_str(&(fuzzy_count - 1).to_string());
result.push_str(" similar lines)\n");
i += fuzzy_count;
continue;
}
}
result.push_str(line);
result.push('\n');
i += 1;
}
if !trailing_newline && result.ends_with('\n') {
result.pop();
}
result
}
fn try_fuzzy_group(lines: &[&str], start: usize) -> Option<usize> {
let line = lines[start];
if line.len() < 5 {
return None;
}
let prefixes = [
"Removing ",
"Compiling ",
"Installing ",
"Download ",
"Extracting ",
"Checked ",
"test ",
];
for prefix in prefixes {
if line.starts_with(prefix) {
if prefix == "test " && !line.contains(" ... ok") {
continue;
}
let mut count = 1;
for next_line in lines.iter().skip(start + 1) {
if next_line.starts_with(prefix) {
count += 1;
} else {
break;
}
}
if count >= 3 {
return Some(count);
}
}
}
None
}
fn find_repo_root() -> PathBuf {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
crate::store::find_project_root(&cwd)
}
pub fn now_ts() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64()
}
fn extract_response_text(response: &serde_json::Value) -> Option<String> {
if let Some(s) = response.as_str() {
return Some(s.to_string());
}
if let Some(s) = response["output"].as_str() {
return Some(s.to_string());
}
let stdout = response["stdout"].as_str().unwrap_or("");
let stderr = response["stderr"].as_str().unwrap_or("");
if !stdout.is_empty() || !stderr.is_empty() {
let mut combined = stdout.to_string();
if !stderr.is_empty() {
if !combined.is_empty() {
combined.push('\n');
}
combined.push_str(stderr);
}
return Some(combined);
}
if let Some(arr) = response["content"].as_array() {
let text: String = arr
.iter()
.filter_map(|item| {
if item["type"].as_str() == Some("text") {
item["text"].as_str().map(str::to_string)
} else {
None
}
})
.collect::<Vec<_>>()
.join("\n");
if !text.is_empty() {
return Some(text);
}
}
None
}
#[derive(Debug, PartialEq)]
enum PostDialect {
ClaudeNoop,
CopilotJson,
}
struct PostHookInput {
tool_name: String,
command: String,
text: String,
dialect: PostDialect,
}
fn decode_tool_args(v: &serde_json::Value) -> serde_json::Value {
match v.as_str() {
Some(raw) => serde_json::from_str(raw).unwrap_or(serde_json::Value::Null),
None => v.clone(),
}
}
fn normalize_post_tool(name: &str) -> String {
match name.to_ascii_lowercase().as_str() {
"bash"
| "powershell"
| "shell"
| "run_shell_command"
| "default_api:run_shell_command"
| "run_command"
| "default_api:run_command"
| "get_terminal_output"
| "default_api:get_terminal_output" => "Bash".to_string(),
"listdirectory" | "default_api:list_directory" => "ListDirectory".to_string(),
_ => name.to_string(),
}
}
fn extract_copilot_result(tr: &serde_json::Value) -> Option<String> {
if let Some(s) = tr.as_str() {
return Some(s.to_string());
}
tr["textResultForLlm"]
.as_str()
.or_else(|| tr["text_result_for_llm"].as_str())
.map(str::to_string)
}
fn parse_post_input(v: &serde_json::Value) -> Option<PostHookInput> {
if let Some(raw_name) = v["toolName"].as_str() {
let args = decode_tool_args(&v["toolArgs"]);
let command = args["command"]
.as_str()
.or_else(|| args["CommandLine"].as_str())
.or_else(|| args["commandLine"].as_str())
.or_else(|| args["command_line"].as_str())
.unwrap_or("")
.to_string();
return Some(PostHookInput {
tool_name: normalize_post_tool(raw_name),
command,
text: extract_copilot_result(&v["toolResult"])?,
dialect: PostDialect::CopilotJson,
});
}
let raw_name = v["tool_name"].as_str()?;
let command = v["tool_input"]["command"]
.as_str()
.or_else(|| v["tool_input"]["CommandLine"].as_str())
.or_else(|| v["tool_input"]["commandLine"].as_str())
.or_else(|| v["tool_input"]["command_line"].as_str())
.unwrap_or("")
.to_string();
Some(PostHookInput {
tool_name: normalize_post_tool(raw_name),
command,
text: extract_response_text(&v["tool_response"])?,
dialect: PostDialect::ClaudeNoop,
})
}
pub fn run_hook_post() -> Result<()> {
let raw_stdin = std::io::read_to_string(std::io::stdin()).unwrap_or_default();
let clean = raw_stdin.trim_start_matches('\u{feff}').trim();
let v: serde_json::Value = match serde_json::from_str(clean) {
Ok(v) => v,
Err(_) => std::process::exit(0),
};
let input = match parse_post_input(&v) {
Some(i) if !i.text.is_empty() && POST_HOOK_TOOLS.contains(&i.tool_name.as_str()) => i,
_ => std::process::exit(0),
};
let compressed = if input.tool_name == "Bash" {
compress_bash_output(&input.command, &input.text)
} else {
compress_output(&input.text)
};
if compressed == input.text {
std::process::exit(0);
}
if input.dialect == PostDialect::ClaudeNoop {
std::process::exit(0);
}
let repo_root = find_repo_root();
let original_tokens = count_tokens(&input.text) as i64;
let actual_tokens = count_tokens(&compressed) as i64;
let saved = (original_tokens - actual_tokens).max(0);
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: input.tool_name,
action: "intercepted".to_string(),
phase: "post".to_string(),
reason: String::new(),
saved_tokens: saved,
actual_tokens,
original_estimate: original_tokens,
input_preview: clean.chars().take(200).collect(),
command: input.command,
},
);
let out = serde_json::json!({
"modifiedResult": {
"resultType": "success",
"textResultForLlm": compressed,
}
});
println!("{}", serde_json::to_string(&out).unwrap_or_default());
std::process::exit(0);
}
pub fn run_command_and_compress(command_str: &str) -> Result<i32> {
let mut cmd = if cfg!(windows) {
let mut c = std::process::Command::new("cmd");
c.args(["/C", command_str]);
c
} else {
let mut c = std::process::Command::new("sh");
c.args(["-c", command_str]);
c
};
let output = cmd.output()?;
let stdout_raw = String::from_utf8_lossy(&output.stdout);
let stderr_raw = String::from_utf8_lossy(&output.stderr);
let stdout_compressed = compress_bash_output(command_str, &stdout_raw);
let stderr_compressed = compress_bash_output(command_str, &stderr_raw);
print!("{}", stdout_compressed);
eprint!("{}", stderr_compressed);
let repo_root = find_repo_root();
crate::recordings::capture(&repo_root, command_str, &stdout_raw, &stderr_raw);
let original_tokens = (count_tokens(&stdout_raw) + count_tokens(&stderr_raw)) as i64;
let actual_tokens =
(count_tokens(&stdout_compressed) + count_tokens(&stderr_compressed)) as i64;
let saved = (original_tokens - actual_tokens).max(0);
if saved > 0 {
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: "Bash".to_string(),
action: "intercepted".to_string(),
phase: "ToolOutputCompressed".to_string(),
reason: "compressed command output".to_string(),
saved_tokens: saved,
actual_tokens,
original_estimate: original_tokens,
input_preview: command_str.chars().take(200).collect(),
command: command_str.to_string(),
},
);
}
Ok(output.status.code().unwrap_or(0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_ansi_colors() {
assert_eq!(strip_ansi("\x1b[32mOK\x1b[0m"), "OK");
assert_eq!(strip_ansi("\x1b[1;31mError\x1b[0m: bad"), "Error: bad");
}
#[test]
fn cargo_test_failure_detail_is_preserved() {
let raw = "\
Compiling foo v0.1.0
running 2 tests
test tests::ok_one ... ok
test tests::adds ... FAILED
failures:
---- tests::adds stdout ----
thread 'tests::adds' panicked at src/lib.rs:10:9:
custom failure: widget count drifted by 1
Diff < left / right > :
<4
>5
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
failures:
tests::adds
test result: FAILED. 1 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out";
let lines: Vec<&str> = raw.lines().collect();
let out = compress_cargo(&lines);
assert!(out.contains("panicked at"), "panic line must be preserved");
assert!(
out.contains("custom failure: widget count drifted by 1"),
"custom panic message must be preserved"
);
assert!(
out.contains("Diff < left / right > :") && out.contains("<4") && out.contains(">5"),
"pretty-assertion diff must be preserved"
);
assert!(
out.contains("---- tests::adds stdout ----"),
"failing test name marker must be preserved"
);
assert!(
out.contains("test result: FAILED"),
"summary must be preserved"
);
assert!(!out.contains("Compiling foo"), "noise should be dropped");
}
#[test]
fn strips_osc_sequences() {
assert_eq!(strip_ansi("\x1b]0;title\x07text"), "text");
}
#[test]
fn removes_emojis() {
assert_eq!(remove_emojis("🚀 Build done"), " Build done");
assert_eq!(remove_emojis("no emojis here"), "no emojis here");
}
#[test]
fn collapses_blank_lines() {
let input = "a\n\n\n\n\nb";
let output = collapse_blank_lines(input);
assert_eq!(output, "a\n\nb");
}
#[test]
fn groups_repeated_lines() {
let input = "line1\nline1\nline1\nline1\nline2\n";
let output = group_repeated_lines(input);
assert_eq!(output, "line1\n[repeated 3x]\nline2\n");
}
#[test]
fn does_not_group_two_identical_lines() {
let input = "a\na\nb\n";
assert_eq!(group_repeated_lines(input), "a\na\nb\n");
}
#[test]
fn compacts_pretty_json_object() {
let input = "{\n \"status\": \"ok\",\n \"count\": 42\n}\n";
let output = compact_json(input);
assert!(output.len() < input.len(), "should be shorter");
assert!(output.ends_with('\n'));
let v_in: serde_json::Value = serde_json::from_str(input.trim()).unwrap();
let v_out: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
assert_eq!(v_in, v_out);
}
#[test]
fn compacts_pretty_json_array() {
let input = "[\n 1,\n 2,\n 3\n]";
let output = compact_json(input);
assert_eq!(output, "[1,2,3]");
}
#[test]
fn passes_through_already_compact_json() {
let input = "{\"a\":1}\n";
assert_eq!(compact_json(input), input);
}
#[test]
fn compacts_ndjson() {
let input = "{ \"level\": \"info\", \"msg\": \"started\" }\n{ \"level\": \"error\", \"msg\": \"failed\" }\n";
let output = compact_json(input);
assert_eq!(
output,
"{\"level\":\"info\",\"msg\":\"started\"}\n{\"level\":\"error\",\"msg\":\"failed\"}\n"
);
}
#[test]
fn passes_through_plain_text() {
let input = "On branch main\nnothing to commit\n";
assert_eq!(compact_json(input), input);
}
#[test]
fn compress_is_idempotent_on_clean_input() {
let clean = "hello\nworld\n";
assert_eq!(compress_output(clean), clean);
}
#[test]
fn full_compression_pipeline() {
let input = "\x1b[32m🚀 Starting\x1b[0m\n\n\n\nline\nline\nline\nline\ndone\n";
let output = compress_output(input);
assert!(output.contains("Starting"));
assert!(!output.contains("\x1b["));
assert!(!output.contains('🚀'));
assert!(output.contains("[repeated"));
assert!(!output.contains("\n\n\n"));
}
#[test]
fn bash_short_output_passes_through() {
let input = "hello\nworld\n";
assert_eq!(compress_bash_output("", input), input);
}
#[test]
fn bash_generic_truncation_over_100_lines() {
let lines: String = (1..=150).map(|i| format!("line {}\n", i)).collect();
let out = compress_bash_output("", &lines);
assert!(out.contains("lines omitted"), "should truncate: {}", out);
assert!(out.contains("line 1\n"));
assert!(out.contains("line 150"));
}
#[test]
fn bash_path_listing_groups_by_directory() {
let input = [
"src/main.rs",
"src/query.rs",
"src/hook.rs",
"benchmark/samples/database_client.ts",
]
.join("\n");
let out = compress_bash_output("ls -R", &input);
assert!(out.contains("4 files in 2 dirs"), "output: {}", out);
assert!(out.contains("src/ (3)"), "output: {}", out);
assert!(out.contains("benchmark/samples/ (1)"), "output: {}", out);
}
#[test]
fn bash_cargo_extracts_errors() {
let mut input = String::new();
for i in 0..60 {
input.push_str(&format!("Compiling crate{} v0.1.0\n", i));
}
input.push_str("error[E0425]: cannot find value `foo`\n");
input.push_str(" --> src/main.rs:3:5\n");
input.push_str(" |\n");
input.push_str("3 | foo();\n");
input.push_str("error: aborting due to 1 previous error\n");
input.push_str("Finished dev in 1.23s\n");
let out = compress_bash_output("", &input);
assert!(out.contains("error[E0425]"), "should keep error: {}", out);
assert!(out.contains("Finished"), "should keep summary: {}", out);
assert!(
!out.contains("Compiling crate0"),
"should strip Compiling lines"
);
}
#[test]
fn bash_git_log_truncated_after_20_commits() {
let mut input = String::new();
for i in 0..30 {
input.push_str(&format!("commit {:040}\n", i));
input.push_str("Author: Test\nDate: Today\n\n message\n\n");
}
let out = compress_bash_output("", &input);
assert!(out.contains("lines omitted"), "should truncate: {}", out);
}
#[test]
fn parses_claude_post_input() {
let v = serde_json::json!({
"tool_name": "Bash",
"tool_input": {"command": "git status"},
"tool_response": "On branch main\n"
});
let input = parse_post_input(&v).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.command, "git status");
assert_eq!(input.text, "On branch main\n");
assert_eq!(input.dialect, PostDialect::ClaudeNoop);
}
#[test]
fn parses_claude_bash_stdout_stderr_shape() {
let v = serde_json::json!({
"tool_name": "Bash",
"tool_input": {"command": "npm install"},
"tool_response": {
"stdout": "added 120 packages in 3s\n",
"stderr": "npm warn deprecated foo\n",
"interrupted": false,
"isImage": false
}
});
let input = parse_post_input(&v).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.command, "npm install");
assert!(input.text.contains("added 120 packages"));
assert!(input.text.contains("npm warn deprecated foo"));
assert_eq!(input.dialect, PostDialect::ClaudeNoop);
}
#[test]
fn parses_copilot_post_input_camelcase() {
let v = serde_json::json!({
"toolName": "bash",
"toolArgs": {"command": "git diff"},
"toolResult": {"resultType": "success", "textResultForLlm": "diff output"}
});
let input = parse_post_input(&v).unwrap();
assert_eq!(input.tool_name, "Bash"); assert_eq!(input.command, "git diff");
assert_eq!(input.text, "diff output");
assert_eq!(input.dialect, PostDialect::CopilotJson);
}
#[test]
fn parses_copilot_post_input_string_encoded_args() {
let v = serde_json::json!({
"toolName": "powershell",
"toolArgs": "{\"command\":\"git status\"}",
"toolResult": {"textResultForLlm": "status output"}
});
let input = parse_post_input(&v).unwrap();
assert_eq!(input.tool_name, "Bash"); assert_eq!(input.command, "git status");
assert_eq!(input.text, "status output");
}
#[test]
fn extract_copilot_result_handles_both_casings() {
let camel = serde_json::json!({"textResultForLlm": "a"});
let snake = serde_json::json!({"text_result_for_llm": "b"});
let plain = serde_json::Value::String("c".to_string());
assert_eq!(extract_copilot_result(&camel).as_deref(), Some("a"));
assert_eq!(extract_copilot_result(&snake).as_deref(), Some("b"));
assert_eq!(extract_copilot_result(&plain).as_deref(), Some("c"));
}
#[test]
fn normalize_post_tool_maps_shells_to_bash() {
assert_eq!(normalize_post_tool("bash"), "Bash");
assert_eq!(normalize_post_tool("powershell"), "Bash");
assert_eq!(normalize_post_tool("Bash"), "Bash"); assert_eq!(normalize_post_tool("run_command"), "Bash");
assert_eq!(normalize_post_tool("default_api:run_command"), "Bash");
assert_eq!(normalize_post_tool("ListDirectory"), "ListDirectory");
assert_eq!(normalize_post_tool("view"), "view"); }
#[test]
fn parses_claude_post_input_run_command() {
let v = serde_json::json!({
"tool_name": "default_api:run_command",
"tool_input": {"CommandLine": "git diff"},
"tool_response": "diff output"
});
let input = parse_post_input(&v).unwrap();
assert_eq!(input.tool_name, "Bash");
assert_eq!(input.command, "git diff");
assert_eq!(input.text, "diff output");
assert_eq!(input.dialect, PostDialect::ClaudeNoop);
}
}