use crate::commands::read;
use crate::core::session::Session;
use crate::core::tracker::{self, FallbackReason, ReadOutcome};
use anyhow::{Context, Result};
use serde::Deserialize;
use serde_json::json;
#[derive(Debug, Deserialize)]
struct PreToolUse {
#[serde(default)]
session_id: Option<String>,
#[serde(default)]
tool_name: Option<String>,
#[serde(default)]
tool_input: Option<serde_json::Value>,
}
pub fn handle(stdin_payload: &str) -> Result<String> {
if std::env::var_os("DRIP_DISABLE").is_some() {
return Ok(allow());
}
let payload: PreToolUse =
serde_json::from_str(stdin_payload).context("PreToolUse JSON payload is malformed")?;
if payload.tool_name.as_deref() != Some("Read") {
return Ok(allow());
}
let Some(input) = payload.tool_input else {
return Ok(allow());
};
let Some(file_path) = input.get("file_path").and_then(|v| v.as_str()) else {
return Ok(allow());
};
let session = match payload.session_id.filter(|s| !s.is_empty()) {
Some(id) => Session::open_with_id(id)?,
None => Session::open()?,
};
let offset = input
.get("offset")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
let limit = input
.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as usize);
if offset.is_some() || limit.is_some() {
match tracker::process_partial_read(&session, file_path, offset, limit) {
Ok(Some(outcome)) => {
let rendered = read::render_and_record(&session, file_path, outcome);
return Ok(deny(rendered));
}
Ok(None) => return Ok(allow()),
Err(e) => {
eprintln!("drip: process_partial_read failed for {file_path}: {e:#}");
return Ok(allow());
}
}
}
let budget = claude_read_token_budget();
let bytes_on_disk = std::fs::metadata(file_path)
.ok()
.map(|m| m.len())
.unwrap_or(0);
let estimated_drip_tokens = (bytes_on_disk / 4) as i64;
let early_compress_min_bytes = compress_first_read_min_bytes();
let want_compress_early =
early_compress_min_bytes > 0 && bytes_on_disk >= early_compress_min_bytes;
let route_through_drip_rendered = estimated_drip_tokens > budget || want_compress_early;
let outcome = if route_through_drip_rendered {
tracker::process_read(&session, file_path)
} else {
tracker::process_read_native_passthrough(&session, file_path)
};
let outcome = match outcome {
Ok(o) => o,
Err(e) => {
eprintln!("drip: process_read failed for {file_path}: {e:#}");
return Ok(allow());
}
};
if route_through_drip_rendered {
if let ReadOutcome::FullFirst {
compressed: Some(_),
..
} = &outcome
{
let rendered = read::render_and_record(&session, file_path, outcome);
return Ok(deny(rendered));
}
}
match &outcome {
ReadOutcome::FullFallback {
reason:
tracker::FallbackReason::Ignored
| tracker::FallbackReason::Binary
| tracker::FallbackReason::NonUtf8
| tracker::FallbackReason::HugeFile
| tracker::FallbackReason::Symlink,
..
} => {
let rendered = read::render_and_record(&session, file_path, outcome);
Ok(deny(rendered))
}
ReadOutcome::Passthrough
| ReadOutcome::FullFirst { .. }
| ReadOutcome::FullFallback {
reason:
FallbackReason::LargeFile
| FallbackReason::Truncated
| FallbackReason::DiffBiggerThanFile
| FallbackReason::DripOverheadBiggerThanFile
| FallbackReason::DiffTooComplex { .. }
| FallbackReason::ExternalChange,
..
} => {
let summary = read::summarize(&outcome);
let _ = session.record_event(
file_path,
summary.kind,
summary.fallback_reason.as_deref(),
summary.tokens_full,
summary.tokens_sent,
"[allow → native Read]",
);
if let ReadOutcome::FullFallback {
reason: FallbackReason::ExternalChange,
tokens,
..
} = &outcome
{
return Ok(allow_with_context(&format!(
"[DRIP: native refresh | {tokens} tokens | \
{file_path} changed out-of-band since DRIP's last \
baseline — full content re-shipped natively to keep \
Claude's read-tracker in sync. Future re-reads of \
this file will return the normal unchanged/delta \
view.]"
)));
}
Ok(allow())
}
ReadOutcome::Unchanged { .. }
| ReadOutcome::Delta { .. }
| ReadOutcome::Deleted
| ReadOutcome::EditCertificate { .. } => {
let rendered = read::render_and_record(&session, file_path, outcome);
Ok(deny(rendered))
}
ReadOutcome::WindowUnchanged { .. } | ReadOutcome::WindowDelta { .. } => Ok(allow()),
}
}
fn allow() -> String {
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow"
}
})
.to_string()
}
fn allow_with_context(context: &str) -> String {
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "allow",
"additionalContext": context,
}
})
.to_string()
}
fn deny(reason: String) -> String {
json!({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": reason
}
})
.to_string()
}
const DEFAULT_CLAUDE_READ_TOKEN_BUDGET: i64 = 10_000;
fn claude_read_token_budget() -> i64 {
std::env::var("DRIP_CLAUDE_READ_TOKEN_BUDGET")
.ok()
.and_then(|s| s.parse::<i64>().ok())
.filter(|v| *v > 0)
.unwrap_or(DEFAULT_CLAUDE_READ_TOKEN_BUDGET)
}
fn compress_first_read_min_bytes() -> u64 {
std::env::var("DRIP_COMPRESS_FIRST_READ_MIN_BYTES")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.filter(|v| *v > 0)
.unwrap_or(0)
}