spool/hook_runtime/
post_tool_use.rs1use std::fs::OpenOptions;
24use std::io::Write;
25use std::path::PathBuf;
26use std::time::{SystemTime, UNIX_EPOCH};
27
28use anyhow::Context;
29use serde::{Deserialize, Serialize};
30
31use super::project_runtime_dir;
32
33#[derive(Debug, Clone)]
34pub struct PostToolUseArgs {
35 pub config_path: PathBuf,
36 pub cwd: Option<PathBuf>,
37 pub tool_name: Option<String>,
39 pub payload: Option<String>,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct DistillSignalEnvelope {
46 pub recorded_at: u64,
47 pub tool_name: Option<String>,
48 pub cwd: String,
49 pub payload: Option<String>,
50}
51
52const MAX_PAYLOAD_BYTES: usize = 4096;
53pub(crate) const QUEUE_FILE_NAME: &str = "distill-pending.queue";
54
55pub fn run(args: PostToolUseArgs) -> anyhow::Result<()> {
56 let cwd = match args.cwd {
57 Some(p) => p,
58 None => std::env::current_dir().context("resolving cwd for post-tool-use hook")?,
59 };
60 let runtime_dir = project_runtime_dir(&cwd)?;
61 let queue_path = runtime_dir.join(QUEUE_FILE_NAME);
62
63 let envelope = DistillSignalEnvelope {
64 recorded_at: SystemTime::now()
65 .duration_since(UNIX_EPOCH)
66 .map(|d| d.as_secs())
67 .unwrap_or(0),
68 tool_name: args.tool_name,
69 cwd: cwd.display().to_string(),
70 payload: args.payload.map(truncate_payload),
71 };
72 let line = serde_json::to_string(&envelope).context("serializing distill signal envelope")?;
73
74 let mut file = OpenOptions::new()
75 .create(true)
76 .append(true)
77 .open(&queue_path)
78 .with_context(|| format!("opening distill queue {}", queue_path.display()))?;
79 writeln!(file, "{}", line).with_context(|| format!("appending to {}", queue_path.display()))?;
80
81 if !args.config_path.exists() {
84 anyhow::bail!("config path does not exist: {}", args.config_path.display());
85 }
86 Ok(())
87}
88
89fn truncate_payload(p: String) -> String {
90 if p.len() <= MAX_PAYLOAD_BYTES {
91 return p;
92 }
93 let mut end = MAX_PAYLOAD_BYTES;
94 while end > 0 && !p.is_char_boundary(end) {
96 end -= 1;
97 }
98 let mut out = String::with_capacity(end + 1);
99 out.push_str(&p[..end]);
100 out.push('…');
101 out
102}
103
104#[cfg(test)]
105mod tests {
106 use super::*;
107 use tempfile::tempdir;
108
109 fn cfg(temp: &tempfile::TempDir) -> PathBuf {
110 let cfg = temp.path().join("spool.toml");
111 std::fs::write(&cfg, "x=1").unwrap();
112 cfg
113 }
114
115 #[test]
116 fn run_appends_envelope_to_queue() {
117 let temp = tempdir().unwrap();
118 let config_path = cfg(&temp);
119 run(PostToolUseArgs {
120 config_path,
121 cwd: Some(temp.path().to_path_buf()),
122 tool_name: Some("Bash".into()),
123 payload: Some("ls -la".into()),
124 })
125 .unwrap();
126
127 let queue = temp.path().join(".spool").join("distill-pending.queue");
128 let raw = std::fs::read_to_string(&queue).unwrap();
129 assert_eq!(raw.lines().count(), 1, "exactly one line per call");
130 let env: DistillSignalEnvelope = serde_json::from_str(raw.lines().next().unwrap()).unwrap();
131 assert_eq!(env.tool_name.as_deref(), Some("Bash"));
132 assert_eq!(env.payload.as_deref(), Some("ls -la"));
133 }
134
135 #[test]
136 fn run_appends_multiple_lines_idempotently() {
137 let temp = tempdir().unwrap();
138 let config_path = cfg(&temp);
139 for i in 0..3 {
140 run(PostToolUseArgs {
141 config_path: config_path.clone(),
142 cwd: Some(temp.path().to_path_buf()),
143 tool_name: Some(format!("tool-{}", i)),
144 payload: None,
145 })
146 .unwrap();
147 }
148 let queue = temp.path().join(".spool").join("distill-pending.queue");
149 let raw = std::fs::read_to_string(&queue).unwrap();
150 assert_eq!(raw.lines().count(), 3);
151 }
152
153 #[test]
154 fn truncate_payload_caps_long_input() {
155 let big = "x".repeat(MAX_PAYLOAD_BYTES * 2);
156 let short = truncate_payload(big);
157 assert!(short.len() <= MAX_PAYLOAD_BYTES + 4);
158 assert!(short.ends_with('…'));
159 }
160
161 #[test]
162 fn truncate_payload_passes_short_input_through() {
163 let s = "hello".to_string();
164 assert_eq!(truncate_payload(s.clone()), s);
165 }
166
167 #[test]
168 fn run_errors_on_missing_config() {
169 let temp = tempdir().unwrap();
170 let err = run(PostToolUseArgs {
171 config_path: temp.path().join("nope.toml"),
172 cwd: Some(temp.path().to_path_buf()),
173 tool_name: None,
174 payload: None,
175 })
176 .unwrap_err();
177 assert!(err.to_string().contains("config path does not exist"));
178 }
179}