use anyhow::Result;
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::chunker::count_tokens;
use crate::store::{index_staleness, log_hook_event, HookEvent};
const MIN_LINES_FOR_OUTLINE: usize = 200;
fn min_lines_for_outline() -> usize {
crate::chunker::hook_config()
.read_min_lines
.filter(|n| *n > 0)
.unwrap_or(MIN_LINES_FOR_OUTLINE)
}
#[derive(Deserialize, Debug, Default)]
pub struct HookInput {
#[serde(default)]
pub tool_name: String,
#[serde(default)]
pub tool_input: serde_json::Value,
#[serde(default)]
pub session_id: String,
#[serde(skip)]
raw_tool_name: String,
}
#[derive(Deserialize, Debug)]
struct CopilotHookInput {
#[serde(rename = "toolName")]
tool_name: String,
#[serde(rename = "toolArgs", default)]
tool_args: serde_json::Value,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct AntigravityHookInput {
tool_call: AntigravityToolCall,
}
#[derive(Debug, Deserialize)]
struct AntigravityToolCall {
name: String,
#[serde(default)]
args: serde_json::Value,
}
impl HookInput {
fn from_env() -> Option<Self> {
let tool_name = std::env::var("TOOL_NAME").ok()?;
let tool_input_raw = std::env::var("TOOL_INPUT").unwrap_or_default();
let tool_input = serde_json::from_str(&tool_input_raw).unwrap_or(serde_json::Value::Null);
Some(HookInput {
raw_tool_name: tool_name.clone(),
tool_name,
tool_input,
session_id: String::new(),
})
}
fn from_stdin(raw: &str) -> Option<Self> {
let clean = raw.trim_start_matches('\u{feff}').trim();
if clean.is_empty() {
return None;
}
if let Ok(input) = serde_json::from_str::<AntigravityHookInput>(clean) {
let tool_name = canonical_tool_name(&input.tool_call.name);
let tool_input = normalize_tool_input(&tool_name, input.tool_call.args);
return Some(HookInput {
raw_tool_name: input.tool_call.name,
tool_name,
tool_input,
session_id: String::new(),
});
}
if let Ok(input) = serde_json::from_str::<HookInput>(clean) {
if !input.tool_name.is_empty() {
let tool_name = canonical_tool_name(&input.tool_name);
let tool_input = normalize_tool_input(&tool_name, input.tool_input);
return Some(HookInput {
raw_tool_name: input.tool_name,
tool_name,
tool_input,
session_id: input.session_id,
});
}
}
serde_json::from_str::<CopilotHookInput>(clean)
.ok()
.map(|input| normalize_copilot_input(&input.tool_name, &input.tool_args))
}
}
fn canonical_tool_name(name: &str) -> String {
match name.to_ascii_lowercase().as_str() {
"read" | "view" | "read_file" | "view_file" => "Read".to_string(),
"grep" | "grep_search" => "Grep".to_string(),
_ => name.to_string(),
}
}
fn normalize_copilot_input(tool_name: &str, tool_args: &serde_json::Value) -> HookInput {
let args = if let Some(raw) = tool_args.as_str() {
serde_json::from_str(raw).unwrap_or(serde_json::Value::Null)
} else {
tool_args.clone()
};
let tool_name = canonical_tool_name(tool_name);
let tool_input = normalize_tool_input(&tool_name, args);
HookInput {
raw_tool_name: tool_name.clone(),
tool_name,
tool_input,
session_id: String::new(),
}
}
fn normalize_tool_input(tool_name: &str, args: serde_json::Value) -> serde_json::Value {
match tool_name {
"Read" => normalize_read_args(args),
"Grep" => normalize_grep_args(args),
_ => args,
}
}
fn normalize_read_args(mut args: serde_json::Value) -> serde_json::Value {
if args.get("file_path").and_then(|v| v.as_str()).is_some() {
return args;
}
let path = args
.get("path")
.or_else(|| args.get("file"))
.and_then(|v| v.as_str())
.map(str::to_string);
if let Some(path) = path {
if let Some(obj) = args.as_object_mut() {
obj.insert("file_path".to_string(), serde_json::Value::String(path));
}
}
args
}
fn normalize_grep_args(mut args: serde_json::Value) -> serde_json::Value {
if args.get("pattern").and_then(|v| v.as_str()).is_some() {
return args;
}
let pattern = args
.get("query")
.or_else(|| args.get("regex"))
.or_else(|| args.get("search"))
.and_then(|v| v.as_str())
.map(str::to_string);
if let Some(pattern) = pattern {
if let Some(obj) = args.as_object_mut() {
obj.insert("pattern".to_string(), serde_json::Value::String(pattern));
}
}
args
}
fn find_repo_root() -> PathBuf {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
crate::store::find_project_root(&cwd)
}
fn now_ts() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64()
}
fn get_file_outline(file_path: &Path) -> Option<String> {
let content = std::fs::read_to_string(file_path).ok()?;
let path_str = file_path.to_string_lossy().replace('\\', "/");
Some(crate::chunker::generate_outline(&content, &path_str))
}
fn handle_read(
tool_input: &serde_json::Value,
repo_root: &Path,
session: &str,
) -> (bool, String, String) {
let file_path = match tool_input["file_path"].as_str() {
Some(p) => p,
None => return (false, String::new(), "missing file_path".to_string()),
};
let has_offset = tool_input["offset"].is_number();
let has_limit = tool_input["limit"].is_number();
if has_offset || has_limit {
return (
false,
String::new(),
"targeted read (offset/limit specified)".to_string(),
);
}
let full_path = {
let p = Path::new(file_path);
if p.exists() {
p.to_path_buf()
} else {
repo_root.join(file_path)
}
};
if !full_path.exists() {
return (
false,
String::new(),
format!("file not found: {}", file_path),
);
}
const MAX_READ_BYTES: u64 = 8 * 1024 * 1024;
match std::fs::metadata(&full_path) {
Ok(m) if !m.is_file() => {
return (false, String::new(), "not a regular file".to_string());
}
Ok(m) if m.len() > MAX_READ_BYTES => {
return (
false,
String::new(),
"file too large to outline".to_string(),
);
}
Ok(_) => {}
Err(_) => return (false, String::new(), format!("read error: {}", file_path)),
}
let content = match std::fs::read_to_string(&full_path) {
Ok(c) => c,
Err(_) => return (false, String::new(), format!("read error: {}", file_path)),
};
let recall_path = full_path.to_string_lossy().replace('\\', "/");
let content_tokens = count_tokens(&content);
let now = now_ts();
if let Some(hit) =
crate::recall::find_recent_read(&recall_path, &content, content_tokens, now, session)
{
return (
true,
crate::recall::read_marker(&hit, now),
"unchanged since a recent full read".to_string(),
);
}
let remember_full_read =
|| crate::recall::remember_read(&recall_path, &content, content_tokens, now, session);
let line_count = content.lines().count();
let min_lines = min_lines_for_outline();
if line_count < min_lines {
remember_full_read();
return (
false,
String::new(),
format!("small file ({} < {} lines)", line_count, min_lines),
);
}
let outline = match get_file_outline(&full_path) {
Some(o) => o,
None => {
remember_full_read();
return (
false,
String::new(),
"failed to generate outline".to_string(),
);
}
};
let rel = full_path
.strip_prefix(repo_root)
.unwrap_or(&full_path)
.to_string_lossy()
.replace('\\', "/");
let ext = full_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_ascii_lowercase();
let is_code = matches!(
ext.as_str(),
"rs" | "py"
| "ts"
| "tsx"
| "js"
| "jsx"
| "mjs"
| "cjs"
| "go"
| "c"
| "cpp"
| "h"
| "hpp"
| "cc"
| "cxx"
| "bas"
| "cls"
| "ctl"
| "frm"
| "sql"
| "fnc"
| "trg"
| "pkg"
| "prc"
| "tab"
| "vw"
);
if !is_code {
return (
false,
String::new(),
format!("unsupported language: .{}", ext),
);
}
let file_tokens = count_tokens(&content) as i64;
let outline_tokens = count_tokens(&outline) as i64;
if file_tokens <= 0 || (file_tokens - outline_tokens) * 100 < file_tokens * 30 {
return (
false,
String::new(),
format!(
"outline saves <30% ({} vs {} tokens) — passing through",
outline_tokens, file_tokens
),
);
}
let msg = format!(
"{}\n\n[tokenix] File has {} lines. Showing symbol outline above.\n\
To read a specific symbol: tokenix read {} --symbol <name>\n\
To read specific lines: use Read with offset/limit parameters.",
outline, line_count, rel
);
(true, msg, "generated symbol outline".to_string())
}
fn measured_original_tokens(
tool_name: &str,
tool_input: &serde_json::Value,
repo_root: &Path,
) -> Option<i64> {
if tool_name == "Read" {
if let Some(fp) = tool_input["file_path"].as_str() {
let p = Path::new(fp);
let full = if p.exists() {
p.to_path_buf()
} else {
repo_root.join(fp)
};
if let Ok(content) = std::fs::read_to_string(&full) {
return Some(count_tokens(&content) as i64);
}
}
}
None
}
fn original_tokens_for_log(
tool_name: &str,
tool_input: &serde_json::Value,
repo_root: &Path,
actual_tokens: i64,
) -> i64 {
measured_original_tokens(tool_name, tool_input, repo_root).unwrap_or(actual_tokens)
}
fn bash_rewrite_output(
input: &HookInput,
rewritten: &str,
reason: &str,
antigravity: bool,
) -> serde_json::Value {
if antigravity {
let mut args = input.tool_input.clone();
if let Some(obj) = args.as_object_mut() {
for key in ["command", "CommandLine", "commandLine", "command_line"] {
obj.insert(
key.to_string(),
serde_json::Value::String(rewritten.to_string()),
);
}
}
return serde_json::json!({
"decision": "allow",
"reason": reason,
"overwrite": {
"name": input.raw_tool_name,
"args": args
}
});
}
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": reason,
"updatedInput": {
"command": rewritten,
"CommandLine": rewritten,
"commandLine": rewritten,
"command_line": rewritten,
}
}
})
}
fn input_rewrite_output(
input: &HookInput,
updated: serde_json::Value,
reason: &str,
antigravity: bool,
) -> serde_json::Value {
if antigravity {
return serde_json::json!({
"decision": "allow",
"reason": reason,
"overwrite": {
"name": input.raw_tool_name,
"args": updated
}
});
}
serde_json::json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"permissionDecisionReason": reason,
"updatedInput": updated
}
})
}
const DEFAULT_GREP_HEAD_LIMIT: i64 = 100;
fn grep_head_limit() -> i64 {
std::env::var("TOKENIX_GREP_HEAD_LIMIT")
.ok()
.and_then(|v| v.trim().parse::<i64>().ok())
.unwrap_or(DEFAULT_GREP_HEAD_LIMIT)
}
fn grep_cap_input(tool_input: &serde_json::Value) -> Option<(serde_json::Value, String)> {
let limit = grep_head_limit();
if limit <= 0 {
return None;
}
if tool_input["head_limit"].is_number() {
return None;
}
if tool_input["output_mode"].as_str() != Some("content") {
return None;
}
let mut updated = tool_input.clone();
updated.as_object_mut()?.insert(
"head_limit".to_string(),
serde_json::Value::Number(limit.into()),
);
Some((
updated,
format!("tokenix: capped uncapped content grep at head_limit={limit}"),
))
}
fn try_grep_cap(input: &HookInput, repo_root: &Path, antigravity: bool, raw_stdin: &str) {
if input.tool_name != "Grep" {
return;
}
let Some((updated, reason)) = grep_cap_input(&input.tool_input) else {
return;
};
let out = input_rewrite_output(input, updated, &reason, antigravity);
let _ = log_hook_event(
repo_root,
&HookEvent {
ts: now_ts(),
tool: input.tool_name.clone(),
action: "intercepted".to_string(),
phase: "pre".to_string(),
reason,
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: raw_stdin.chars().take(200).collect(),
command: String::new(),
},
);
println!("{}", serde_json::to_string(&out).unwrap_or_default());
exit_success();
}
fn pass_through(antigravity: bool) -> ! {
if antigravity {
println!(r#"{{"decision":"allow","reason":"tokenix pass-through"}}"#);
}
std::process::exit(0);
}
fn exit_success() -> ! {
std::process::exit(0);
}
fn invokes_tokenix(command: &str) -> bool {
const SUBCOMMANDS: &[&str] = &["run", "mcp-proxy", "hook", "hook-post", "mcp"];
let tokens: Vec<&str> = command.split_whitespace().collect();
tokens.windows(2).any(|w| {
let prog = w[0].trim_matches(|c| c == '"' || c == '\'');
let stem = prog
.rsplit(['/', '\\'])
.next()
.unwrap_or(prog)
.trim_end_matches(".exe");
stem.eq_ignore_ascii_case("tokenix") && SUBCOMMANDS.contains(&w[1])
})
}
fn shell_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', r"'\''"))
}
fn ps_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "''"))
}
fn is_git_invocation(command: &str) -> bool {
crate::filters::split_on_operators(command)
.iter()
.any(|segment| segment_is_git(segment))
}
fn segment_is_git(segment: &str) -> bool {
first_token_is_git(segment)
|| first_token_is_git(&crate::filters::get_effective_command(segment))
}
fn first_token_is_git(s: &str) -> bool {
crate::filters::tokenize_command(s)
.first()
.map(|t| {
let t = t.as_str();
let stem = t.rsplit(['/', '\\']).next().unwrap_or(t);
stem.trim_end_matches(".exe").eq_ignore_ascii_case("git")
})
.unwrap_or(false)
}
fn is_bash_tool(name: &str) -> bool {
let lower = name.to_ascii_lowercase();
matches!(
lower.as_str(),
"bash"
| "powershell"
| "cmd"
| "shell"
| "run_shell_command"
| "default_api:run_shell_command"
| "run_in_terminal"
| "default_api:run_in_terminal"
| "run_command"
| "default_api:run_command"
| "get_terminal_output"
| "default_api:get_terminal_output"
)
}
fn should_route_powershell(tool_name: &str, antigravity: bool, is_windows: bool) -> bool {
if tool_name == "PowerShell" || tool_name.eq_ignore_ascii_case("powershell") {
return true;
}
if !is_windows || !is_bash_tool(tool_name) {
return false;
}
antigravity
|| matches!(
tool_name,
"run_command"
| "default_api:run_command"
| "run_in_terminal"
| "default_api:run_in_terminal"
)
}
pub fn run_hook(antigravity: bool) -> Result<()> {
let raw_stdin = std::io::read_to_string(std::io::stdin()).unwrap_or_default();
let input = HookInput::from_env()
.or_else(|| HookInput::from_stdin(&raw_stdin))
.unwrap_or_default();
let repo_root = find_repo_root();
let is_powershell = should_route_powershell(&input.tool_name, antigravity, cfg!(windows));
let is_bash = is_bash_tool(&input.tool_name) && !is_powershell;
let is_supported =
input.tool_name == "Read" || input.tool_name == "Grep" || is_bash || is_powershell;
if input.tool_name.is_empty() {
pass_through(antigravity);
}
if !is_supported {
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: input.tool_name,
action: "pass".to_string(),
phase: "pre".to_string(),
reason: "unsupported tool".to_string(),
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: raw_stdin.chars().take(200).collect(),
command: String::new(),
},
);
pass_through(antigravity);
}
if is_bash {
let command = input.tool_input["command"]
.as_str()
.or_else(|| input.tool_input["CommandLine"].as_str())
.or_else(|| input.tool_input["commandLine"].as_str())
.or_else(|| input.tool_input["command_line"].as_str())
.unwrap_or("")
.trim();
if command.is_empty() {
pass_through(antigravity);
}
if invokes_tokenix(command) {
pass_through(antigravity);
}
if command.contains("TOKENIX_DISABLED=1") {
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: "Bash".to_string(),
action: "bypassed".to_string(),
phase: "pre".to_string(),
reason: "TOKENIX_DISABLED=1".to_string(),
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: command.chars().take(200).collect(),
command: command.to_string(),
},
);
pass_through(antigravity);
}
if crate::recordings::is_in_scope(&repo_root, command) {
let exe_path = std::env::current_exe()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| "tokenix".to_string());
let rewritten = format!("{} run {}", shell_quote(&exe_path), shell_quote(command));
let out = bash_rewrite_output(
&input,
&rewritten,
"recording: capturing output for filter generation",
antigravity,
);
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: "Bash".to_string(),
action: "intercepted".to_string(),
phase: "pre".to_string(),
reason: "recording capture".to_string(),
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: command.chars().take(200).collect(),
command: command.to_string(),
},
);
println!("{}", serde_json::to_string(&out).unwrap_or_default());
exit_success();
}
let is_short_git_status = (command.starts_with("git status")
|| command.starts_with("git status"))
&& (command.contains("-s") || command.contains("--short"));
if is_short_git_status {
pass_through(antigravity);
}
let status_re = regex::Regex::new(r"^git\s+status(\s+.*)?$").unwrap();
if status_re.is_match(command) && !command.contains("-") {
let trimmed = command
.split_once("status")
.map(|(_, rest)| rest.trim())
.unwrap_or("");
let rewritten = if trimmed.is_empty() {
"git status --short".to_string()
} else {
format!("git status --short {}", trimmed)
};
let out = bash_rewrite_output(
&input,
&rewritten,
"rewrite git status to git status --short for token efficiency",
antigravity,
);
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: "Bash".to_string(),
action: "intercepted".to_string(),
phase: "pre".to_string(),
reason: "rewrote git status to git status --short".to_string(),
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: command.chars().take(200).collect(),
command: command.to_string(),
},
);
println!("{}", serde_json::to_string(&out).unwrap_or_default());
exit_success();
}
let unwrapped =
crate::filters::unwrap_shell_runner(command).unwrap_or_else(|| command.to_string());
if is_git_invocation(&unwrapped) {
pass_through(antigravity);
}
let filters = crate::filters::load_filter_groups_for_command(&unwrapped);
if crate::filters::find_filter_ranked(&unwrapped, &filters).is_some() {
let exe_path = std::env::current_exe()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| "tokenix".to_string());
let rewritten = format!("{} run {}", shell_quote(&exe_path), shell_quote(command));
let out = bash_rewrite_output(
&input,
&rewritten,
"wrapped in tokenix compression run",
antigravity,
);
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: "Bash".to_string(),
action: "intercepted".to_string(),
phase: "pre".to_string(),
reason: "rewrote command to tokenix run".to_string(),
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: command.chars().take(200).collect(),
command: command.to_string(),
},
);
println!("{}", serde_json::to_string(&out).unwrap_or_default());
exit_success();
}
pass_through(antigravity);
}
if is_powershell {
let command = input.tool_input["command"]
.as_str()
.or_else(|| input.tool_input["CommandLine"].as_str())
.or_else(|| input.tool_input["commandLine"].as_str())
.or_else(|| input.tool_input["command_line"].as_str())
.unwrap_or("")
.trim();
if command.is_empty() {
pass_through(antigravity);
}
if invokes_tokenix(command) {
pass_through(antigravity);
}
if command.contains("TOKENIX_DISABLED") {
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: "PowerShell".to_string(),
action: "bypassed".to_string(),
phase: "pre".to_string(),
reason: "TOKENIX_DISABLED".to_string(),
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: command.chars().take(200).collect(),
command: command.to_string(),
},
);
pass_through(antigravity);
}
if is_git_invocation(command) {
pass_through(antigravity);
}
let filters = crate::filters::load_filter_groups_for_command(command);
if crate::filters::find_filter_ranked(command, &filters).is_some() {
let exe_path = std::env::current_exe()
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| "tokenix".to_string());
let rewritten = format!(
"& {} run --shell pwsh {}",
ps_quote(&exe_path),
ps_quote(command)
);
let out = bash_rewrite_output(
&input,
&rewritten,
"wrapped in tokenix compression run (pwsh)",
antigravity,
);
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: "PowerShell".to_string(),
action: "intercepted".to_string(),
phase: "pre".to_string(),
reason: "rewrote command to tokenix run (pwsh)".to_string(),
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: command.chars().take(200).collect(),
command: command.to_string(),
},
);
println!("{}", serde_json::to_string(&out).unwrap_or_default());
exit_success();
}
pass_through(antigravity);
}
let staleness = index_staleness(&repo_root);
if staleness.stale {
try_grep_cap(&input, &repo_root, antigravity, &raw_stdin);
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: input.tool_name,
action: "pass".to_string(),
phase: "pre".to_string(),
reason: staleness.reason,
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: String::new(),
command: String::new(),
},
);
pass_through(antigravity);
}
let (intercepted, output, reason) = match input.tool_name.as_str() {
"Read" => handle_read(&input.tool_input, &repo_root, &input.session_id),
"Grep" => (false, String::new(), "grep runs natively".to_string()),
_ => (false, String::new(), "unsupported tool".to_string()),
};
if !intercepted {
try_grep_cap(&input, &repo_root, antigravity, &raw_stdin);
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: input.tool_name,
action: "pass".to_string(),
phase: "pre".to_string(),
reason,
saved_tokens: 0,
actual_tokens: 0,
original_estimate: 0,
input_preview: String::new(),
command: String::new(),
},
);
pass_through(antigravity);
}
let actual_tokens = count_tokens(&output) as i64;
let original_tokens = original_tokens_for_log(
&input.tool_name,
&input.tool_input,
&repo_root,
actual_tokens,
);
let saved = (original_tokens - actual_tokens).max(0);
let _ = log_hook_event(
&repo_root,
&HookEvent {
ts: now_ts(),
tool: input.tool_name.clone(),
action: "intercepted".to_string(),
phase: "pre".to_string(),
reason,
saved_tokens: saved,
actual_tokens,
original_estimate: original_tokens,
input_preview: raw_stdin.chars().take(200).collect(),
command: String::new(),
},
);
if antigravity {
println!(
"{}",
serde_json::json!({
"decision": "deny",
"reason": output
})
);
std::process::exit(0);
}
eprintln!("{}", output);
std::process::exit(2);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shell_quote_keeps_command_literal_through_the_outer_shell() {
let q = shell_quote("echo '$HOME and $(id -un)'");
assert!(q.starts_with('\'') && q.ends_with('\''));
assert!(
q.contains("$(id -un)"),
"substitution must stay literal: {q}"
);
assert_eq!(shell_quote("it's"), r"'it'\''s'");
}
#[test]
fn is_git_invocation_recognizes_bare_and_compound_git_commands() {
assert!(is_git_invocation("git log -1"));
assert!(is_git_invocation("git status -sb"));
assert!(is_git_invocation(
"pwd && git status && git log --oneline -5"
));
assert!(is_git_invocation("cd /repo && git diff"));
assert!(is_git_invocation("Set-Location D:/repo; git log -1"));
assert!(is_git_invocation(
"'C:/Program Files/Git/bin/git.exe' log -1"
));
assert!(!is_git_invocation("cargo test"));
assert!(!is_git_invocation("npm test && echo done"));
assert!(!is_git_invocation("grep -r tokenix ."));
}
#[test]
fn recursion_guard_matches_invocations_not_mentions() {
assert!(invokes_tokenix("tokenix run 'git status'"));
assert!(invokes_tokenix("'C:/tools/tokenix.exe' run 'git status'"));
assert!(invokes_tokenix(
"& 'C:/tools/tokenix.exe' run --shell pwsh 'ls'"
));
assert!(invokes_tokenix("cd repo && tokenix mcp-proxy -- npx srv"));
assert!(!invokes_tokenix("grep -r tokenix ."));
assert!(!invokes_tokenix("cat tokenix.toml"));
assert!(!invokes_tokenix("git log --grep=tokenix"));
}
#[test]
fn git_status_rewrite_keeps_path_arg_with_extra_spaces() {
let trimmed = "git status src"
.split_once("status")
.map(|(_, rest)| rest.trim())
.unwrap_or("");
assert_eq!(trimmed, "src");
}
#[test]
fn powershell_routing_targets_command_tools_not_read_grep() {
assert!(should_route_powershell("PowerShell", false, false));
assert!(should_route_powershell("powershell", false, true));
assert!(should_route_powershell("run_command", true, true));
assert!(should_route_powershell("run_in_terminal", true, true));
assert!(!should_route_powershell("Read", true, true));
assert!(!should_route_powershell("Grep", true, true));
assert!(!should_route_powershell("Bash", false, true));
assert!(!should_route_powershell("run_command", true, false));
}
#[test]
fn ps_quote_wraps_and_escapes_single_quotes() {
assert_eq!(ps_quote("Get-ChildItem"), "'Get-ChildItem'");
assert_eq!(
ps_quote("Select-String -Pattern 'foo'"),
"'Select-String -Pattern ''foo'''"
);
assert_eq!(ps_quote("$env:PATH"), "'$env:PATH'");
}
#[test]
fn ps_rewrite_is_valid_powershell_call() {
let exe = "C:/tokenix/bin/tokenix.exe";
let cmd = "Get-ChildItem | Select-String 'todo'";
let rewritten = format!("& {} run --shell pwsh {}", ps_quote(exe), ps_quote(cmd));
assert_eq!(
rewritten,
"& 'C:/tokenix/bin/tokenix.exe' run --shell pwsh 'Get-ChildItem | Select-String ''todo'''"
);
}
#[test]
fn parses_claude_input() {
let raw = r#"{"tool_name":"Read","tool_input":{"file_path":"src/main.rs"}}"#;
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Read");
assert_eq!(input.tool_input["file_path"], "src/main.rs");
}
#[test]
fn parses_copilot_view_input() {
let raw = r#"{"toolName":"view","toolArgs":"{\"path\":\"src/main.rs\"}"}"#;
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Read");
assert_eq!(input.tool_input["file_path"], "src/main.rs");
}
#[test]
fn empty_stdin_returns_none() {
assert!(HookInput::from_stdin("").is_none());
assert!(HookInput::from_stdin(" ").is_none());
}
#[test]
fn bom_prefix_stripped() {
let raw =
"\u{feff}{\"tool_name\":\"Grep\",\"tool_input\":{\"pattern\":\"how does auth work\"}}";
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Grep");
}
#[test]
fn unknown_tool_parses_as_is() {
let raw = r#"{"tool_name":"Edit","tool_input":{"file_path":"x.rs"}}"#;
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Edit");
}
#[test]
fn copilot_grep_normalized() {
let raw = r#"{"toolName":"grep","toolArgs":{"pattern":"how does auth work"}}"#;
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Grep");
assert_eq!(input.tool_input["pattern"], "how does auth work");
}
#[test]
fn grep_search_query_normalized() {
let raw = r#"{"toolName":"grep_search","toolArgs":{"query":"how does auth work"}}"#;
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Grep");
assert_eq!(input.tool_input["pattern"], "how does auth work");
}
#[test]
fn snake_case_grep_search_regex_normalized() {
let raw = r#"{"tool_name":"grep_search","tool_input":{"regex":"fn.*main"}}"#;
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Grep");
assert_eq!(input.tool_input["pattern"], "fn.*main");
}
#[test]
fn run_in_terminal_is_bash_tool() {
assert!(is_bash_tool("run_in_terminal"));
assert!(is_bash_tool("default_api:run_in_terminal"));
}
#[test]
fn copilot_read_with_path_key() {
let raw = r#"{"toolName":"view","toolArgs":{"path":"src/lib.rs"}}"#;
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Read");
assert_eq!(input.tool_input["file_path"], "src/lib.rs");
}
#[test]
fn snake_case_view_normalized_to_read() {
let raw = r#"{"tool_name":"view","tool_input":{"path":"src/main.rs"}}"#;
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Read");
assert_eq!(input.tool_input["file_path"], "src/main.rs");
}
#[test]
fn antigravity_tool_call_normalizes_read_file() {
let raw = r#"{"toolCall":{"name":"read_file","args":{"path":"src/main.rs"}}}"#;
let input = HookInput::from_stdin(raw).unwrap();
assert_eq!(input.tool_name, "Read");
assert_eq!(input.tool_input["file_path"], "src/main.rs");
}
#[test]
fn read_intercepts_only_when_outline_saves_tokens() {
use std::io::Write;
let dir = std::env::temp_dir().join(format!("tokenix_read_hook_{}", std::process::id()));
let _ = std::fs::create_dir_all(&dir);
let dense = dir.join("dense.rs");
let mut f = std::fs::File::create(&dense).unwrap();
for i in 0..220 {
writeln!(f, "pub fn s{i}(x: i64, y: i64) -> i64 {{ x + y + {i} }}").unwrap();
}
drop(f);
let input = serde_json::json!({ "file_path": dense.to_string_lossy() });
let (intercepted, _, reason) = handle_read(&input, &dir, "");
assert!(
!intercepted,
"dense small-symbol file should pass through, got: {reason}"
);
let sparse = dir.join("sparse.rs");
let mut f = std::fs::File::create(&sparse).unwrap();
for i in 0..6 {
writeln!(f, "pub fn big{i}(x: i64) -> i64 {{").unwrap();
for j in 0..50 {
writeln!(
f,
" let v{j} = x + {j} * {i}; // body line padding the function"
)
.unwrap();
}
writeln!(f, " x\n}}").unwrap();
}
drop(f);
let input = serde_json::json!({ "file_path": sparse.to_string_lossy() });
let (intercepted, _, reason) = handle_read(&input, &dir, "");
assert!(
intercepted,
"large-body file should be intercepted, got: {reason}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn grep_intercepts_do_not_claim_measured_savings() {
let input = serde_json::json!({ "pattern": "how does auth work" });
assert_eq!(
original_tokens_for_log("Grep", &input, Path::new("."), 1234),
1234
);
}
#[test]
fn unmeasured_intercepts_are_neutral_not_estimated() {
let input = serde_json::json!({ "pattern": "anything" });
assert_eq!(
measured_original_tokens("Grep", &input, Path::new(".")),
None
);
assert_eq!(
original_tokens_for_log("Grep", &input, Path::new("."), 77),
77
);
}
#[test]
fn grep_cap_injects_head_limit_on_uncapped_content_grep() {
let args = serde_json::json!({"pattern": "foo", "output_mode": "content", "-C": 3});
let (updated, reason) = grep_cap_input(&args).expect("should cap");
assert_eq!(updated["head_limit"], DEFAULT_GREP_HEAD_LIMIT);
assert_eq!(updated["pattern"], "foo");
assert_eq!(updated["-C"], 3);
assert!(reason.contains("head_limit"));
}
#[test]
fn grep_cap_respects_agent_supplied_limit() {
let args = serde_json::json!({"pattern": "foo", "output_mode": "content", "head_limit": 5});
assert!(grep_cap_input(&args).is_none());
}
#[test]
fn grep_cap_skips_cheap_output_modes() {
for mode in ["files_with_matches", "count"] {
let args = serde_json::json!({"pattern": "foo", "output_mode": mode});
assert!(grep_cap_input(&args).is_none(), "mode {mode} should pass");
}
assert!(grep_cap_input(&serde_json::json!({"pattern": "foo"})).is_none());
}
#[test]
fn grep_cap_emits_valid_pretooluse_rewrite() {
let input = HookInput {
tool_name: "Grep".to_string(),
tool_input: serde_json::json!({"pattern": "foo", "output_mode": "content"}),
raw_tool_name: "Grep".to_string(),
session_id: String::new(),
};
let (updated, reason) = grep_cap_input(&input.tool_input).unwrap();
let out = input_rewrite_output(&input, updated, &reason, false);
let hso = &out["hookSpecificOutput"];
assert_eq!(hso["hookEventName"], "PreToolUse");
assert_eq!(hso["permissionDecision"], "allow");
assert_eq!(hso["updatedInput"]["head_limit"], DEFAULT_GREP_HEAD_LIMIT);
assert_eq!(hso["updatedInput"]["pattern"], "foo");
}
#[test]
fn grep_cap_antigravity_uses_overwrite_shape() {
let input = HookInput {
tool_name: "Grep".to_string(),
tool_input: serde_json::json!({"pattern": "foo", "output_mode": "content"}),
raw_tool_name: "grep_search".to_string(),
session_id: String::new(),
};
let (updated, reason) = grep_cap_input(&input.tool_input).unwrap();
let out = input_rewrite_output(&input, updated, &reason, true);
assert_eq!(out["decision"], "allow");
assert_eq!(out["overwrite"]["name"], "grep_search");
assert_eq!(
out["overwrite"]["args"]["head_limit"],
DEFAULT_GREP_HEAD_LIMIT
);
}
#[test]
fn bash_rewrite_output_has_required_hook_event_name() {
let input = HookInput {
tool_name: "Bash".to_string(),
tool_input: serde_json::json!({"command": "git status"}),
raw_tool_name: "Bash".to_string(),
session_id: String::new(),
};
let out = bash_rewrite_output(&input, "git status --short", "test reason", false);
let hso = &out["hookSpecificOutput"];
assert_eq!(hso["hookEventName"], "PreToolUse");
assert_eq!(hso["permissionDecision"], "allow");
assert_eq!(hso["permissionDecisionReason"], "test reason");
assert_eq!(hso["updatedInput"]["command"], "git status --short");
assert_eq!(hso["updatedInput"]["CommandLine"], "git status --short");
assert_eq!(hso["updatedInput"]["commandLine"], "git status --short");
assert_eq!(hso["updatedInput"]["command_line"], "git status --short");
}
#[test]
fn antigravity_bash_rewrite_uses_native_allow_and_overwrite() {
let input = HookInput::from_stdin(
r#"{"toolCall":{"name":"run_command","args":{"CommandLine":"git status","Cwd":"."}}}"#,
)
.unwrap();
let out = bash_rewrite_output(&input, "git status --short", "test reason", true);
assert_eq!(out["decision"], "allow");
assert_eq!(out["reason"], "test reason");
assert_eq!(out["overwrite"]["name"], "run_command");
assert_eq!(
out["overwrite"]["args"]["CommandLine"],
"git status --short"
);
assert_eq!(out["overwrite"]["args"]["Cwd"], ".");
}
}