1use std::io::Read;
18use std::path::{Path, PathBuf};
19use std::process::{Command, Stdio};
20use std::time::{Duration, Instant};
21
22const HOOK_TIMEOUT: Duration = Duration::from_secs(10);
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum HookEvent {
27 Save,
28 Open,
29 Quit,
30}
31
32impl HookEvent {
33 pub fn as_str(self) -> &'static str {
34 match self {
35 HookEvent::Save => "save",
36 HookEvent::Open => "open",
37 HookEvent::Quit => "quit",
38 }
39 }
40
41 pub fn config_key(self) -> &'static str {
42 match self {
43 HookEvent::Save => "on_save",
44 HookEvent::Open => "on_open",
45 HookEvent::Quit => "on_quit",
46 }
47 }
48}
49
50#[derive(Debug, Clone)]
51pub struct HooksConfig {
52 pub enabled: bool,
53 pub on_save: String,
54 pub on_open: String,
55 pub on_quit: String,
56}
57
58impl Default for HooksConfig {
59 fn default() -> Self {
60 Self {
61 enabled: true,
62 on_save: String::new(),
63 on_open: String::new(),
64 on_quit: String::new(),
65 }
66 }
67}
68
69impl HooksConfig {
70 pub fn config_path() -> PathBuf {
71 dirs_fallback().join("hooks.toml")
72 }
73
74 pub fn load() -> Self {
75 let path = Self::config_path();
76 let Ok(text) = std::fs::read_to_string(&path) else {
77 return Self::default();
78 };
79 parse_hooks_toml(&text)
80 }
81
82 pub fn for_event(&self, ev: HookEvent) -> &str {
83 match ev {
84 HookEvent::Save => &self.on_save,
85 HookEvent::Open => &self.on_open,
86 HookEvent::Quit => &self.on_quit,
87 }
88 }
89
90 pub fn has_hook(&self, ev: HookEvent) -> bool {
92 self.enabled && !self.for_event(ev).trim().is_empty()
93 }
94}
95
96fn dirs_fallback() -> PathBuf {
97 if let Some(h) =
98 std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE"))
99 {
100 return PathBuf::from(h).join(".xei");
101 }
102 PathBuf::from(".xei")
103}
104
105fn parse_hooks_toml(text: &str) -> HooksConfig {
106 let mut cfg = HooksConfig::default();
107 for line in text.lines() {
108 let line = line.trim();
109 if line.is_empty() || line.starts_with('#') || line.starts_with('[') {
110 continue;
111 }
112 let Some((k, v)) = line.split_once('=') else {
113 continue;
114 };
115 let k = k.trim();
116 let v = parse_toml_value(v);
117 match k {
118 "enabled" => {
119 cfg.enabled = matches!(v.to_ascii_lowercase().as_str(), "true" | "1" | "yes" | "on");
120 }
121 "on_save" => cfg.on_save = v,
122 "on_open" => cfg.on_open = v,
123 "on_quit" => cfg.on_quit = v,
124 _ => {}
125 }
126 }
127 cfg
128}
129
130fn parse_toml_value(raw: &str) -> String {
133 let v = raw.trim();
134 for quote in ['"', '\''] {
135 if let Some(rest) = v.strip_prefix(quote) {
136 if let Some(end) = rest.find(quote) {
137 return rest[..end].to_string();
138 }
139 return rest.to_string();
140 }
141 }
142 v.split('#').next().unwrap_or("").trim().to_string()
143}
144
145fn sh_quote(s: &str) -> String {
148 format!("'{}'", s.replace('\'', r"'\''"))
149}
150
151fn expand_commands(
153 cfg: &HooksConfig,
154 event: HookEvent,
155 file: Option<&Path>,
156) -> Vec<(String, PathBuf)> {
157 if !cfg.has_hook(event) {
158 return Vec::new();
159 }
160 let path = file.map(|p| p.display().to_string()).unwrap_or_default();
161 let dir = file
162 .and_then(|p| p.parent())
163 .map(|p| p.display().to_string())
164 .unwrap_or_else(|| {
165 std::env::current_dir()
166 .map(|p| p.display().to_string())
167 .unwrap_or_else(|_| ".".into())
168 });
169 let name = file
170 .and_then(|p| p.file_name())
171 .and_then(|n| n.to_str())
172 .unwrap_or("")
173 .to_string();
174 let ext = file
175 .and_then(|p| p.extension())
176 .and_then(|e| e.to_str())
177 .unwrap_or("")
178 .to_string();
179 let cwd = file
180 .and_then(|p| p.parent())
181 .map(Path::to_path_buf)
182 .unwrap_or_else(|| PathBuf::from("."));
183
184 cfg.for_event(event)
185 .split(";;")
186 .filter_map(|raw| {
187 let cmd = raw
188 .trim()
189 .replace("{file}", &sh_quote(&name))
190 .replace("{path}", &sh_quote(&path))
191 .replace("{dir}", &sh_quote(&dir))
192 .replace("{ext}", &sh_quote(&ext))
193 .replace("{event}", event.as_str());
194 if cmd.is_empty() {
195 None
196 } else {
197 Some((cmd, cwd.clone()))
198 }
199 })
200 .collect()
201}
202
203pub fn run_hooks(
206 cfg: &HooksConfig,
207 event: HookEvent,
208 file: Option<&Path>,
209) -> Option<String> {
210 let mut last_msg = None;
211 for (cmd, cwd) in expand_commands(cfg, event, file) {
212 match run_with_timeout(&cmd, &cwd) {
213 HookOutcome::Ok(line) => {
214 if !line.is_empty() {
215 last_msg = Some(line);
216 }
217 }
218 HookOutcome::Failed(err) => {
219 last_msg = Some(format!("hook({}): {err}", event.as_str()));
220 }
221 HookOutcome::TimedOut => {
222 last_msg = Some(format!(
223 "hook({}): timed out ({}s), killed",
224 event.as_str(),
225 HOOK_TIMEOUT.as_secs()
226 ));
227 }
228 }
229 }
230 last_msg
231}
232
233fn shell_command(cmd: &str) -> Command {
236 if cfg!(windows) {
237 let mut c = Command::new("cmd");
238 c.arg("/C").arg(cmd);
239 c
240 } else {
241 let mut c = Command::new("sh");
242 c.arg("-c").arg(cmd);
243 c
244 }
245}
246
247pub fn run_hooks_detached(cfg: &HooksConfig, event: HookEvent, file: Option<&Path>) {
250 for (cmd, cwd) in expand_commands(cfg, event, file) {
251 let _ = shell_command(&cmd)
252 .current_dir(&cwd)
253 .stdin(Stdio::null())
254 .stdout(Stdio::null())
255 .stderr(Stdio::null())
256 .spawn();
257 }
258}
259
260enum HookOutcome {
261 Ok(String),
263 Failed(String),
264 TimedOut,
265}
266
267fn run_with_timeout(cmd: &str, cwd: &Path) -> HookOutcome {
268 let mut child = match shell_command(cmd)
269 .current_dir(cwd)
270 .stdin(Stdio::null())
271 .stdout(Stdio::piped())
272 .stderr(Stdio::piped())
273 .spawn()
274 {
275 Ok(c) => c,
276 Err(e) => return HookOutcome::Failed(format!("spawn: {e}")),
277 };
278 let stdout = child.stdout.take().map(drain_to_string);
280 let stderr = child.stderr.take().map(drain_to_string);
281
282 let deadline = Instant::now() + HOOK_TIMEOUT;
283 let status = loop {
284 match child.try_wait() {
285 Ok(Some(status)) => break Some(status),
286 Ok(None) => {
287 if Instant::now() >= deadline {
288 let _ = child.kill();
289 let _ = child.wait();
290 break None;
291 }
292 std::thread::sleep(Duration::from_millis(50));
293 }
294 Err(_) => break None,
295 }
296 };
297 let out = stdout.map(|h| h.join().unwrap_or_default()).unwrap_or_default();
298 let err = stderr.map(|h| h.join().unwrap_or_default()).unwrap_or_default();
299
300 match status {
301 None => HookOutcome::TimedOut,
302 Some(s) if s.success() => HookOutcome::Ok(
303 out.lines()
304 .map(str::trim)
305 .find(|l| !l.is_empty())
306 .unwrap_or("")
307 .to_string(),
308 ),
309 Some(_) => HookOutcome::Failed(
310 err.lines()
311 .chain(out.lines())
312 .map(str::trim)
313 .find(|l| !l.is_empty())
314 .unwrap_or("hook failed")
315 .to_string(),
316 ),
317 }
318}
319
320fn drain_to_string<R: Read + Send + 'static>(mut r: R) -> std::thread::JoinHandle<String> {
321 std::thread::spawn(move || {
322 let mut s = String::new();
323 let mut buf = [0u8; 4096];
324 loop {
325 match r.read(&mut buf) {
326 Ok(0) | Err(_) => break,
327 Ok(n) => s.push_str(&String::from_utf8_lossy(&buf[..n])),
328 }
329 }
330 s
331 })
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn parse_basic() {
340 let t = r#"
341enabled = true
342on_save = "echo {file}"
343on_open = ""
344"#;
345 let c = parse_hooks_toml(t);
346 assert!(c.enabled);
347 assert_eq!(c.on_save, "echo {file}");
348 assert!(c.on_open.is_empty());
349 }
350
351 #[test]
352 fn parse_inline_comments_and_quotes() {
353 let t = r#"
354enabled = true # yes
355on_save = "echo hi" # runs on save
356on_open = 'echo # not a comment'
357on_quit = echo bare # trailing
358"#;
359 let c = parse_hooks_toml(t);
360 assert!(c.enabled);
361 assert_eq!(c.on_save, "echo hi");
362 assert_eq!(c.on_open, "echo # not a comment");
363 assert_eq!(c.on_quit, "echo bare");
364 }
365
366 #[test]
367 fn placeholders_are_shell_quoted() {
368 let cfg = HooksConfig {
369 enabled: true,
370 on_save: "cat {path}".into(),
371 ..Default::default()
372 };
373 let cmds = expand_commands(&cfg, HookEvent::Save, Some(Path::new("/tmp/a b'c.rs")));
374 assert_eq!(cmds.len(), 1);
375 assert_eq!(cmds[0].0, r#"cat '/tmp/a b'\''c.rs'"#);
376 }
377
378 #[test]
379 fn sh_quote_roundtrip() {
380 assert_eq!(sh_quote("plain"), "'plain'");
381 assert_eq!(sh_quote("a'b"), r"'a'\''b'");
382 }
383
384 #[test]
385 fn hook_runs_and_captures_stdout() {
386 let cfg = HooksConfig {
387 enabled: true,
388 on_save: "echo ok {event}".into(),
389 ..Default::default()
390 };
391 let msg = run_hooks(&cfg, HookEvent::Save, Some(Path::new("/tmp/x.rs")));
392 assert_eq!(msg.as_deref(), Some("ok save"));
393 }
394
395 #[test]
396 fn hook_failure_reports_stderr() {
397 let cfg = HooksConfig {
398 enabled: true,
399 on_save: "echo boom >&2; false".into(),
400 ..Default::default()
401 };
402 let msg = run_hooks(&cfg, HookEvent::Save, None);
403 assert_eq!(msg.as_deref(), Some("hook(save): boom"));
404 }
405}