use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use super::project_runtime_dir;
#[derive(Debug, Clone)]
pub struct PostToolUseArgs {
pub config_path: PathBuf,
pub cwd: Option<PathBuf>,
pub tool_name: Option<String>,
pub payload: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DistillSignalEnvelope {
pub recorded_at: u64,
pub tool_name: Option<String>,
pub cwd: String,
pub payload: Option<String>,
}
const MAX_PAYLOAD_BYTES: usize = 4096;
pub(crate) const QUEUE_FILE_NAME: &str = "distill-pending.queue";
pub fn run(args: PostToolUseArgs) -> anyhow::Result<()> {
let cwd = match args.cwd {
Some(p) => p,
None => std::env::current_dir().context("resolving cwd for post-tool-use hook")?,
};
let runtime_dir = project_runtime_dir(&cwd)?;
let queue_path = runtime_dir.join(QUEUE_FILE_NAME);
let envelope = DistillSignalEnvelope {
recorded_at: SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0),
tool_name: args.tool_name,
cwd: cwd.display().to_string(),
payload: args.payload.map(truncate_payload),
};
let line = serde_json::to_string(&envelope).context("serializing distill signal envelope")?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&queue_path)
.with_context(|| format!("opening distill queue {}", queue_path.display()))?;
writeln!(file, "{}", line).with_context(|| format!("appending to {}", queue_path.display()))?;
if !args.config_path.exists() {
anyhow::bail!("config path does not exist: {}", args.config_path.display());
}
Ok(())
}
fn truncate_payload(p: String) -> String {
if p.len() <= MAX_PAYLOAD_BYTES {
return p;
}
let mut end = MAX_PAYLOAD_BYTES;
while end > 0 && !p.is_char_boundary(end) {
end -= 1;
}
let mut out = String::with_capacity(end + 1);
out.push_str(&p[..end]);
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn cfg(temp: &tempfile::TempDir) -> PathBuf {
let cfg = temp.path().join("spool.toml");
std::fs::write(&cfg, "x=1").unwrap();
cfg
}
#[test]
fn run_appends_envelope_to_queue() {
let temp = tempdir().unwrap();
let config_path = cfg(&temp);
run(PostToolUseArgs {
config_path,
cwd: Some(temp.path().to_path_buf()),
tool_name: Some("Bash".into()),
payload: Some("ls -la".into()),
})
.unwrap();
let queue = temp.path().join(".spool").join("distill-pending.queue");
let raw = std::fs::read_to_string(&queue).unwrap();
assert_eq!(raw.lines().count(), 1, "exactly one line per call");
let env: DistillSignalEnvelope = serde_json::from_str(raw.lines().next().unwrap()).unwrap();
assert_eq!(env.tool_name.as_deref(), Some("Bash"));
assert_eq!(env.payload.as_deref(), Some("ls -la"));
}
#[test]
fn run_appends_multiple_lines_idempotently() {
let temp = tempdir().unwrap();
let config_path = cfg(&temp);
for i in 0..3 {
run(PostToolUseArgs {
config_path: config_path.clone(),
cwd: Some(temp.path().to_path_buf()),
tool_name: Some(format!("tool-{}", i)),
payload: None,
})
.unwrap();
}
let queue = temp.path().join(".spool").join("distill-pending.queue");
let raw = std::fs::read_to_string(&queue).unwrap();
assert_eq!(raw.lines().count(), 3);
}
#[test]
fn truncate_payload_caps_long_input() {
let big = "x".repeat(MAX_PAYLOAD_BYTES * 2);
let short = truncate_payload(big);
assert!(short.len() <= MAX_PAYLOAD_BYTES + 4);
assert!(short.ends_with('…'));
}
#[test]
fn truncate_payload_passes_short_input_through() {
let s = "hello".to_string();
assert_eq!(truncate_payload(s.clone()), s);
}
#[test]
fn run_errors_on_missing_config() {
let temp = tempdir().unwrap();
let err = run(PostToolUseArgs {
config_path: temp.path().join("nope.toml"),
cwd: Some(temp.path().to_path_buf()),
tool_name: None,
payload: None,
})
.unwrap_err();
assert!(err.to_string().contains("config path does not exist"));
}
}