1use std::process::Stdio;
2use std::time::Duration;
3
4use serde::{Deserialize, Serialize};
5
6use super::registry::Plugin;
7
8#[derive(Debug, Clone, Serialize)]
9#[serde(tag = "hook", rename_all = "snake_case")]
10pub enum HookPoint {
11 OnSessionStart,
12 OnSessionEnd,
13 PreRead {
14 path: String,
15 },
16 PostCompress {
17 path: String,
18 original_tokens: usize,
19 compressed_tokens: usize,
20 },
21 OnKnowledgeUpdate {
22 fact_id: String,
23 },
24}
25
26impl HookPoint {
27 pub fn hook_name(&self) -> &'static str {
28 match self {
29 Self::OnSessionStart => "on_session_start",
30 Self::OnSessionEnd => "on_session_end",
31 Self::PreRead { .. } => "pre_read",
32 Self::PostCompress { .. } => "post_compress",
33 Self::OnKnowledgeUpdate { .. } => "on_knowledge_update",
34 }
35 }
36
37 pub fn all_hook_names() -> &'static [&'static str] {
38 &[
39 "on_session_start",
40 "on_session_end",
41 "pre_read",
42 "post_compress",
43 "on_knowledge_update",
44 ]
45 }
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct HookResult {
50 pub plugin_name: String,
51 pub success: bool,
52 #[serde(skip_serializing_if = "Option::is_none")]
53 pub output: Option<String>,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub error: Option<String>,
56 pub duration_ms: u64,
57}
58
59pub fn execute_hook_sync(plugin: &Plugin, hook: &HookPoint) -> HookResult {
60 let hook_name = hook.hook_name();
61 let plugin_name = plugin.manifest.plugin.name.clone();
62
63 let Some(entry) = plugin.manifest.hooks.get(hook_name) else {
64 return HookResult {
65 plugin_name,
66 success: true,
67 output: None,
68 error: None,
69 duration_ms: 0,
70 };
71 };
72
73 let timeout = Duration::from_millis(entry.timeout_ms);
74 let start = std::time::Instant::now();
75
76 let hook_json = match serde_json::to_string(hook) {
77 Ok(j) => j,
78 Err(e) => {
79 return HookResult {
80 plugin_name,
81 success: false,
82 output: None,
83 error: Some(format!("failed to serialize hook data: {e}")),
84 duration_ms: start.elapsed().as_millis() as u64,
85 };
86 }
87 };
88
89 let result = run_subprocess(
90 &entry.command,
91 &plugin.path,
92 &[("LEAN_CTX_HOOK", hook_name)],
93 &hook_json,
94 timeout,
95 &plugin.manifest.trust.policy(),
96 );
97 let duration_ms = start.elapsed().as_millis() as u64;
98
99 match result {
100 Ok(output) => {
101 let stdout = String::from_utf8_lossy(&output.stdout).to_string();
102 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
103 let success = output.status.success();
104 HookResult {
105 plugin_name,
106 success,
107 output: if stdout.is_empty() {
108 None
109 } else {
110 Some(stdout)
111 },
112 error: if stderr.is_empty() && success {
113 None
114 } else if !stderr.is_empty() {
115 Some(stderr)
116 } else {
117 Some(format!("exit code: {}", output.status))
118 },
119 duration_ms,
120 }
121 }
122 Err(e) => HookResult {
123 plugin_name,
124 success: false,
125 output: None,
126 error: Some(e),
127 duration_ms,
128 },
129 }
130}
131
132pub(crate) fn run_subprocess(
139 command: &str,
140 plugin_dir: &std::path::Path,
141 extra_env: &[(&str, &str)],
142 stdin_data: &str,
143 timeout: Duration,
144 policy: &super::sandbox::SandboxPolicy,
145) -> Result<std::process::Output, String> {
146 let parts: Vec<&str> = command.split_whitespace().collect();
147 let Some((program, args)) = parts.split_first() else {
148 return Err("empty command".to_string());
149 };
150
151 let mut cmd = std::process::Command::new(program);
152 cmd.args(args)
153 .stdin(Stdio::piped())
154 .stdout(Stdio::piped())
155 .stderr(Stdio::piped());
156 policy.apply(&mut cmd, plugin_dir);
159 cmd.env("LEAN_CTX_PLUGIN_DIR", plugin_dir);
160 for (key, value) in extra_env {
161 cmd.env(key, value);
162 }
163
164 let mut child = cmd.spawn().map_err(|e| format!("failed to spawn: {e}"))?;
165
166 if let Some(ref mut stdin) = child.stdin.take() {
167 use std::io::Write;
168 let _ = stdin.write_all(stdin_data.as_bytes());
169 }
170
171 wait_with_timeout(&mut child, timeout)
172}
173
174fn wait_with_timeout(
175 child: &mut std::process::Child,
176 timeout: Duration,
177) -> Result<std::process::Output, String> {
178 let deadline = std::time::Instant::now() + timeout;
179 loop {
180 match child.try_wait() {
181 Ok(Some(status)) => {
182 let stdout = child
183 .stdout
184 .take()
185 .map(|mut s| {
186 use std::io::Read;
187 let mut buf = Vec::new();
188 let _ = s.read_to_end(&mut buf);
189 buf
190 })
191 .unwrap_or_default();
192 let stderr = child
193 .stderr
194 .take()
195 .map(|mut s| {
196 use std::io::Read;
197 let mut buf = Vec::new();
198 let _ = s.read_to_end(&mut buf);
199 buf
200 })
201 .unwrap_or_default();
202 return Ok(std::process::Output {
203 status,
204 stdout,
205 stderr,
206 });
207 }
208 Ok(None) => {
209 if std::time::Instant::now() >= deadline {
210 let _ = child.kill();
211 return Err(format!("timeout after {}ms", timeout.as_millis()));
212 }
213 std::thread::sleep(Duration::from_millis(10));
214 }
215 Err(e) => return Err(format!("wait error: {e}")),
216 }
217 }
218}
219
220pub fn execute_hooks_for_point(plugins: &[&Plugin], hook: &HookPoint) -> Vec<HookResult> {
221 let hook_name = hook.hook_name();
222 plugins
223 .iter()
224 .filter(|p| p.enabled && p.manifest.hooks.contains_key(hook_name))
225 .map(|p| execute_hook_sync(p, hook))
226 .collect()
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn hook_point_names() {
235 assert_eq!(HookPoint::OnSessionStart.hook_name(), "on_session_start");
236 assert_eq!(HookPoint::OnSessionEnd.hook_name(), "on_session_end");
237 assert_eq!(
238 HookPoint::PreRead { path: "x".into() }.hook_name(),
239 "pre_read"
240 );
241 assert_eq!(
242 HookPoint::PostCompress {
243 path: "x".into(),
244 original_tokens: 100,
245 compressed_tokens: 50,
246 }
247 .hook_name(),
248 "post_compress"
249 );
250 assert_eq!(
251 HookPoint::OnKnowledgeUpdate {
252 fact_id: "f1".into()
253 }
254 .hook_name(),
255 "on_knowledge_update"
256 );
257 }
258
259 #[test]
260 fn all_hook_names_complete() {
261 let names = HookPoint::all_hook_names();
262 assert_eq!(names.len(), 5);
263 assert!(names.contains(&"on_session_start"));
264 assert!(names.contains(&"pre_read"));
265 assert!(names.contains(&"post_compress"));
266 }
267
268 #[test]
269 fn hook_point_serializes_to_json() {
270 let hook = HookPoint::PostCompress {
271 path: "/tmp/file.rs".into(),
272 original_tokens: 1000,
273 compressed_tokens: 200,
274 };
275 let json = serde_json::to_string(&hook).unwrap();
276 assert!(json.contains("post_compress"));
277 assert!(json.contains("1000"));
278 assert!(json.contains("200"));
279 }
280
281 #[test]
282 fn execute_missing_hook_is_noop() {
283 let manifest = crate::core::plugins::manifest::PluginManifest::from_str(
284 r#"
285[plugin]
286name = "no-hooks"
287version = "1.0.0"
288"#,
289 &std::path::PathBuf::from("test.toml"),
290 )
291 .unwrap();
292
293 let plugin = Plugin {
294 manifest,
295 enabled: true,
296 path: std::path::PathBuf::from("/tmp/no-hooks"),
297 };
298
299 let result = execute_hook_sync(&plugin, &HookPoint::OnSessionStart);
300 assert!(result.success);
301 assert_eq!(result.duration_ms, 0);
302 }
303
304 #[test]
305 fn execute_nonexistent_binary_fails() {
306 let manifest = crate::core::plugins::manifest::PluginManifest::from_str(
307 r#"
308[plugin]
309name = "bad-binary"
310version = "1.0.0"
311
312[hooks.on_session_start]
313command = "__nonexistent_lean_ctx_test_binary__ start"
314timeout_ms = 1000
315"#,
316 &std::path::PathBuf::from("test.toml"),
317 )
318 .unwrap();
319
320 let plugin = Plugin {
321 manifest,
322 enabled: true,
323 path: std::path::PathBuf::from("/tmp/bad-binary"),
324 };
325
326 let result = execute_hook_sync(&plugin, &HookPoint::OnSessionStart);
327 assert!(!result.success);
328 assert!(result.error.unwrap().contains("failed to spawn"));
329 }
330
331 #[cfg(unix)]
332 #[test]
333 fn run_subprocess_echoes_stdin() {
334 let out = run_subprocess(
335 "cat",
336 std::path::Path::new("/tmp"),
337 &[("LEAN_CTX_TOOL", "demo")],
338 "hello-stdin",
339 Duration::from_secs(2),
340 &super::super::sandbox::SandboxPolicy::strict(),
341 )
342 .unwrap();
343 assert!(out.status.success());
344 assert_eq!(String::from_utf8_lossy(&out.stdout), "hello-stdin");
345 }
346
347 #[test]
348 fn run_subprocess_empty_command_errors() {
349 let err = run_subprocess(
350 " ",
351 std::path::Path::new("/tmp"),
352 &[],
353 "",
354 Duration::from_millis(500),
355 &super::super::sandbox::SandboxPolicy::strict(),
356 )
357 .unwrap_err();
358 assert!(err.contains("empty command"));
359 }
360
361 #[cfg(unix)]
362 #[test]
363 fn execute_echo_plugin_succeeds() {
364 let manifest = crate::core::plugins::manifest::PluginManifest::from_str(
365 r#"
366[plugin]
367name = "echo-plugin"
368version = "1.0.0"
369
370[hooks.on_session_start]
371command = "echo hello"
372timeout_ms = 2000
373"#,
374 &std::path::PathBuf::from("test.toml"),
375 )
376 .unwrap();
377
378 let plugin = Plugin {
379 manifest,
380 enabled: true,
381 path: std::path::PathBuf::from("/tmp/echo-plugin"),
382 };
383
384 let result = execute_hook_sync(&plugin, &HookPoint::OnSessionStart);
385 assert!(result.success);
386 assert!(result.output.unwrap().contains("hello"));
387 }
388}