use anyhow::Result;
use serde_json::json;
use std::io::Read;
use tersify::{compress::CompressOptions, detect, tokens};
pub fn run() -> Result<()> {
let mut raw = String::new();
std::io::stdin().read_to_string(&mut raw)?;
let input: serde_json::Value = match serde_json::from_str(&raw) {
Ok(v) => v,
Err(_) => return Ok(()), };
let event = input
.get("hookEventName")
.and_then(|v| v.as_str())
.unwrap_or("");
let tool = input
.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("");
match (event, tool) {
("PostToolUse", "Read") => handle_post_read(&input),
("PostToolUse", "Bash") => handle_post_bash(&input),
("PreToolUse", "Write") | ("PreToolUse", "Edit") => handle_pre_write_edit(&input),
_ => Ok(()),
}
}
fn handle_post_read(input: &serde_json::Value) -> Result<()> {
let content = match extract_response_content(input) {
Some(c) if !c.trim().is_empty() => c,
_ => return Ok(()),
};
let file_path = input
.get("tool_input")
.and_then(|i| i.get("file_path"))
.and_then(|f| f.as_str());
let ct = match file_path {
Some(p) => detect::detect_for_path(std::path::Path::new(p), content),
None => detect::detect(content),
};
compress_and_respond(content, &ct, true)
}
fn handle_post_bash(input: &serde_json::Value) -> Result<()> {
let content = match extract_response_content(input) {
Some(c) if !c.trim().is_empty() => c,
_ => return Ok(()),
};
let ct = detect::detect(content);
compress_and_respond(content, &ct, true)
}
fn handle_pre_write_edit(input: &serde_json::Value) -> Result<()> {
let file_path = match input
.get("tool_input")
.and_then(|i| i.get("file_path"))
.and_then(|f| f.as_str())
{
Some(p) => p,
None => return Ok(()),
};
let path = std::path::Path::new(file_path);
let content = match std::fs::read_to_string(path) {
Ok(c) if !c.trim().is_empty() => c,
_ => return Ok(()),
};
let ct = detect::detect_for_path(path, &content);
let opts = CompressOptions::default();
let compressed = match tersify::compress::compress_with(&content, &ct, &opts) {
Ok(c) => c,
Err(_) => return Ok(()),
};
let before = tokens::count(&content);
let after = tokens::count(&compressed);
if after >= before {
return Ok(());
}
let pct = (1.0 - after as f64 / before as f64) * 100.0;
let response = json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": format!(
"[tersify: current file {before}→{after} tokens ({pct:.0}% smaller)]\n\n{compressed}"
)
}
});
println!("{response}");
Ok(())
}
fn compress_and_respond(content: &str, ct: &detect::ContentType, suppress: bool) -> Result<()> {
let opts = CompressOptions::default();
let compressed = match tersify::compress::compress_with(content, ct, &opts) {
Ok(c) => c,
Err(_) => return Ok(()),
};
let before = tokens::count(content);
let after = tokens::count(&compressed);
if after >= before {
return Ok(());
}
let _ = crate::stats::record_with_lang(before, after, Some(ct.lang_str()));
let pct = (1.0 - after as f64 / before as f64) * 100.0;
let response = json!({
"hookSpecificOutput": {
"hookEventName": "PostToolUse",
"suppressOutput": suppress,
"additionalContext": format!(
"[tersify: {before}→{after} tokens, {pct:.0}% saved]\n\n{compressed}"
)
}
});
println!("{response}");
Ok(())
}
fn extract_response_content(input: &serde_json::Value) -> Option<&str> {
let response = input.get("tool_response")?;
if let Some(arr) = response.get("content").and_then(|c| c.as_array()) {
for item in arr {
if item.get("type").and_then(|t| t.as_str()) == Some("text")
&& let Some(text) = item.get("text").and_then(|t| t.as_str())
{
return Some(text);
}
}
}
response.as_str()
}