1use std::collections::BTreeMap;
28use std::process::Command;
29
30use camino::Utf8Path;
31use serde::{Deserialize, Serialize};
32use sha2::{Digest, Sha256};
33use tera::Context as TeraContext;
34use tracing::info;
35
36use crate::config::{Config, HookConfig, HookPhase, WhenRun};
37use crate::template::{self, Engine};
38use crate::vars::YuiVars;
39use crate::{Error, Result};
40
41const STATE_REL_PATH: &str = ".yui/state.json";
42const STATE_VERSION: u32 = 1;
43
44#[derive(Debug, Default, Serialize, Deserialize)]
45pub struct State {
46 #[serde(default)]
47 pub version: u32,
48 #[serde(default)]
49 pub hooks: BTreeMap<String, HookState>,
50}
51
52#[derive(Debug, Default, Clone, Serialize, Deserialize)]
53pub struct HookState {
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub last_run_at: Option<String>,
56 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub last_content_hash: Option<String>,
58}
59
60impl State {
61 pub fn load(source: &Utf8Path) -> Result<Self> {
62 let path = source.join(STATE_REL_PATH);
63 match std::fs::read_to_string(&path) {
64 Ok(s) => {
65 serde_json::from_str(&s).map_err(|e| Error::Config(format!("parse {path}: {e}")))
66 }
67 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
68 Err(e) => Err(Error::Io(e)),
69 }
70 }
71
72 pub fn save(&self, source: &Utf8Path) -> Result<()> {
76 let path = source.join(STATE_REL_PATH);
77 if let Some(parent) = path.parent() {
78 std::fs::create_dir_all(parent)?;
79 }
80 let tmp = path.with_extension("json.tmp");
81 let mut body = serde_json::to_string_pretty(self)
82 .map_err(|e| Error::Config(format!("serialize state: {e}")))?;
83 body.push('\n');
84 std::fs::write(&tmp, body)?;
85 std::fs::rename(&tmp, &path)?;
86 Ok(())
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum HookOutcome {
93 Ran,
95 SkippedOnce,
97 SkippedUnchanged,
99 SkippedWhenFalse,
101 DryRun,
103}
104
105pub fn sha256_hex(bytes: &[u8]) -> String {
106 let digest = Sha256::digest(bytes);
107 format!("sha256:{digest:x}")
108}
109
110fn now_iso8601() -> String {
111 jiff::Zoned::now().to_string()
112}
113
114pub fn build_hook_context(
117 yui: &YuiVars,
118 vars: &toml::Table,
119 script_path: &Utf8Path,
120) -> TeraContext {
121 let mut ctx = template::template_context(yui, vars);
122 ctx.insert("script_path", &script_path.as_str());
123 ctx.insert(
124 "script_dir",
125 &script_path.parent().map(|p| p.as_str()).unwrap_or(""),
126 );
127 ctx.insert("script_name", &script_path.file_name().unwrap_or(""));
128 ctx.insert("script_stem", &script_path.file_stem().unwrap_or(""));
129 ctx.insert("script_ext", &script_path.extension().unwrap_or(""));
130 ctx
131}
132
133#[allow(clippy::too_many_arguments)]
142pub fn run_hook(
143 hook: &HookConfig,
144 source: &Utf8Path,
145 yui: &YuiVars,
146 vars: &toml::Table,
147 engine: &mut Engine,
148 base_ctx: &TeraContext,
149 state: &mut State,
150 dry_run: bool,
151 force: bool,
152) -> Result<HookOutcome> {
153 if let Some(when) = &hook.when {
154 if !template::eval_truthy(when, engine, base_ctx)? {
155 return Ok(HookOutcome::SkippedWhenFalse);
156 }
157 }
158
159 let script_path = source.join(&hook.script);
160
161 let current_hash = if hook.when_run == WhenRun::Onchange {
166 match std::fs::read(&script_path) {
167 Ok(bytes) => Some(sha256_hex(&bytes)),
168 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
169 Err(e) => return Err(e.into()),
170 }
171 } else {
172 None
173 };
174
175 if !force {
176 let prior = state.hooks.get(&hook.name);
177 match hook.when_run {
178 WhenRun::Once => {
179 if prior.and_then(|s| s.last_run_at.as_ref()).is_some() {
180 return Ok(HookOutcome::SkippedOnce);
181 }
182 }
183 WhenRun::Onchange => {
184 if let (Some(prior_state), Some(now_hash)) = (prior, current_hash.as_deref()) {
185 if prior_state.last_content_hash.as_deref() == Some(now_hash) {
186 return Ok(HookOutcome::SkippedUnchanged);
187 }
188 }
189 }
190 WhenRun::Every => {}
191 }
192 }
193
194 if !script_path.is_file() {
199 return Err(Error::Other(anyhow::anyhow!(
200 "hook[{}]: script not found at {script_path}",
201 hook.name
202 )));
203 }
204
205 if dry_run {
206 return Ok(HookOutcome::DryRun);
207 }
208
209 let hook_ctx = build_hook_context(yui, vars, &script_path);
210 let command = engine.render(&hook.command, &hook_ctx)?;
211 let args: Vec<String> = hook
212 .args
213 .iter()
214 .map(|a| engine.render(a, &hook_ctx))
215 .collect::<Result<_>>()?;
216
217 info!(
218 "hook[{}] running: {} {}",
219 hook.name,
220 command,
221 args.join(" ")
222 );
223 let status = Command::new(&command)
224 .args(&args)
225 .current_dir(source.as_std_path())
226 .status()
227 .map_err(|e| Error::Other(anyhow::anyhow!("hook[{}]: spawn {command}: {e}", hook.name)))?;
228
229 if !status.success() {
230 return Err(Error::Other(anyhow::anyhow!(
231 "hook[{}] exited with status {status}",
232 hook.name
233 )));
234 }
235
236 state.version = STATE_VERSION;
237 state.hooks.insert(
238 hook.name.clone(),
239 HookState {
240 last_run_at: Some(now_iso8601()),
241 last_content_hash: current_hash,
242 },
243 );
244
245 Ok(HookOutcome::Ran)
246}
247
248pub fn run_phase(
252 config: &Config,
253 source: &Utf8Path,
254 yui: &YuiVars,
255 engine: &mut Engine,
256 base_ctx: &TeraContext,
257 phase: HookPhase,
258 dry_run: bool,
259) -> Result<()> {
260 let mut state = State::load(source)?;
261 for hook in &config.hook {
262 if hook.phase != phase {
263 continue;
264 }
265 let outcome = run_hook(
266 hook,
267 source,
268 yui,
269 &config.vars,
270 engine,
271 base_ctx,
272 &mut state,
273 dry_run,
274 false,
275 )?;
276 let phase_name = match phase {
277 HookPhase::Pre => "pre",
278 HookPhase::Post => "post",
279 };
280 info!("hook[{}] {phase_name}: {:?}", hook.name, outcome);
281 if outcome == HookOutcome::Ran {
284 state.save(source)?;
285 }
286 }
287 Ok(())
288}
289
290#[cfg(test)]
291mod tests {
292 use super::*;
293 use camino::Utf8PathBuf;
294 use tempfile::TempDir;
295
296 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
297 Utf8PathBuf::from_path_buf(p).unwrap()
298 }
299
300 fn yui_vars(source: &Utf8Path) -> YuiVars {
301 YuiVars {
302 os: std::env::consts::OS.to_string(),
303 arch: std::env::consts::ARCH.to_string(),
304 host: "test".into(),
305 user: "u".into(),
306 source: source.to_string(),
307 }
308 }
309
310 #[test]
311 fn state_roundtrip() {
312 let tmp = TempDir::new().unwrap();
313 let source = utf8(tmp.path().to_path_buf());
314 let state = State {
315 version: STATE_VERSION,
316 hooks: BTreeMap::from([(
317 "h1".to_string(),
318 HookState {
319 last_run_at: Some("2026-04-29T00:00:00Z".into()),
320 last_content_hash: Some("sha256:abc".into()),
321 },
322 )]),
323 };
324 state.save(&source).unwrap();
325 let reloaded = State::load(&source).unwrap();
326 assert_eq!(reloaded.version, STATE_VERSION);
327 assert_eq!(
328 reloaded
329 .hooks
330 .get("h1")
331 .unwrap()
332 .last_content_hash
333 .as_deref(),
334 Some("sha256:abc")
335 );
336 }
337
338 #[test]
339 fn state_load_returns_default_when_absent() {
340 let tmp = TempDir::new().unwrap();
341 let source = utf8(tmp.path().to_path_buf());
342 let s = State::load(&source).unwrap();
343 assert_eq!(s.version, 0);
344 assert!(s.hooks.is_empty());
345 }
346
347 #[test]
348 fn sha256_hex_format_includes_prefix() {
349 let h = sha256_hex(b"hello");
350 assert!(h.starts_with("sha256:"));
351 assert_eq!(h.len(), 7 + 64); }
353
354 fn make_engine_and_ctx(source: &Utf8Path, vars: &toml::Table) -> (Engine, TeraContext) {
355 let engine = Engine::new();
356 let ctx = template::template_context(&yui_vars(source), vars);
357 (engine, ctx)
358 }
359
360 fn write_script(source: &Utf8Path, rel: &str, body: &str) -> Utf8PathBuf {
361 let path = source.join(rel);
362 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
363 std::fs::write(&path, body).unwrap();
364 #[cfg(unix)]
365 {
366 use std::os::unix::fs::PermissionsExt;
367 let mut perms = std::fs::metadata(&path).unwrap().permissions();
368 perms.set_mode(0o755);
369 std::fs::set_permissions(&path, perms).unwrap();
370 }
371 path
372 }
373
374 #[allow(clippy::too_many_arguments)]
379 fn run_hook_test(
380 hook: &HookConfig,
381 source: &Utf8Path,
382 yui: &YuiVars,
383 vars: &toml::Table,
384 engine: &mut Engine,
385 ctx: &TeraContext,
386 dry_run: bool,
387 force: bool,
388 ) -> Result<HookOutcome> {
389 let mut state = State::load(source)?;
390 let outcome = run_hook(
391 hook, source, yui, vars, engine, ctx, &mut state, dry_run, force,
392 )?;
393 if outcome == HookOutcome::Ran {
394 state.save(source)?;
395 }
396 Ok(outcome)
397 }
398
399 fn bash_hook(when_run: WhenRun, when: Option<&str>) -> HookConfig {
402 HookConfig {
403 name: "h".into(),
404 script: ".yui/bin/h.sh".into(),
405 command: "bash".into(),
406 args: vec!["{{ script_path }}".into()],
407 when_run,
408 phase: HookPhase::Post,
409 when: when.map(str::to_string),
410 }
411 }
412
413 #[test]
414 fn dry_run_returns_dry_run_outcome() {
415 let tmp = TempDir::new().unwrap();
416 let source = utf8(tmp.path().to_path_buf());
417 write_script(&source, ".yui/bin/h.sh", "#!/bin/sh\nexit 0\n");
419 let hook = bash_hook(WhenRun::Every, None);
420 let vars = toml::Table::new();
421 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
422 let outcome = run_hook_test(
423 &hook,
424 &source,
425 &yui_vars(&source),
426 &vars,
427 &mut engine,
428 &ctx,
429 true,
430 false,
431 )
432 .unwrap();
433 assert_eq!(outcome, HookOutcome::DryRun);
434 assert!(!source.join(STATE_REL_PATH).exists());
435 }
436
437 #[test]
438 fn dry_run_errors_when_script_missing() {
439 let tmp = TempDir::new().unwrap();
443 let source = utf8(tmp.path().to_path_buf());
444 let hook = bash_hook(WhenRun::Every, None);
445 let vars = toml::Table::new();
446 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
447 let err = run_hook_test(
448 &hook,
449 &source,
450 &yui_vars(&source),
451 &vars,
452 &mut engine,
453 &ctx,
454 true,
455 false,
456 )
457 .unwrap_err();
458 assert!(format!("{err}").contains("script not found"));
459 }
460
461 #[test]
462 fn when_false_skips_without_running() {
463 let tmp = TempDir::new().unwrap();
464 let source = utf8(tmp.path().to_path_buf());
465 write_script(&source, ".yui/bin/h.sh", "#!/bin/sh\nexit 1\n"); let hook = bash_hook(WhenRun::Every, Some("yui.os == 'no-such-os'"));
467 let vars = toml::Table::new();
468 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
469 let outcome = run_hook_test(
470 &hook,
471 &source,
472 &yui_vars(&source),
473 &vars,
474 &mut engine,
475 &ctx,
476 false,
477 false,
478 )
479 .unwrap();
480 assert_eq!(outcome, HookOutcome::SkippedWhenFalse);
481 assert!(!source.join(STATE_REL_PATH).exists());
482 }
483
484 #[test]
485 fn force_still_respects_when_filter() {
486 let tmp = TempDir::new().unwrap();
490 let source = utf8(tmp.path().to_path_buf());
491 write_script(&source, ".yui/bin/h.sh", "#!/bin/sh\nexit 1\n");
492 let hook = bash_hook(WhenRun::Every, Some("yui.os == 'no-such-os'"));
493 let vars = toml::Table::new();
494 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
495 let outcome = run_hook_test(
496 &hook,
497 &source,
498 &yui_vars(&source),
499 &vars,
500 &mut engine,
501 &ctx,
502 false,
503 true,
504 )
505 .unwrap();
506 assert_eq!(outcome, HookOutcome::SkippedWhenFalse);
507 }
508
509 #[cfg(unix)]
516 #[test]
517 fn once_runs_first_then_skips() {
518 let tmp = TempDir::new().unwrap();
519 let source = utf8(tmp.path().to_path_buf());
520 let marker = source.join(".ran");
521 write_script(
522 &source,
523 ".yui/bin/h.sh",
524 &format!("#!/bin/sh\necho ok > {:?}\n", marker.as_str()),
525 );
526 let hook = bash_hook(WhenRun::Once, None);
527 let vars = toml::Table::new();
528 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
529
530 let first = run_hook_test(
531 &hook,
532 &source,
533 &yui_vars(&source),
534 &vars,
535 &mut engine,
536 &ctx,
537 false,
538 false,
539 )
540 .unwrap();
541 assert_eq!(first, HookOutcome::Ran);
542 assert!(marker.exists());
543 std::fs::remove_file(&marker).unwrap();
544
545 let second = run_hook_test(
546 &hook,
547 &source,
548 &yui_vars(&source),
549 &vars,
550 &mut engine,
551 &ctx,
552 false,
553 false,
554 )
555 .unwrap();
556 assert_eq!(second, HookOutcome::SkippedOnce);
557 assert!(!marker.exists());
558 }
559
560 #[cfg(unix)]
561 #[test]
562 fn onchange_runs_when_hash_differs() {
563 let tmp = TempDir::new().unwrap();
564 let source = utf8(tmp.path().to_path_buf());
565 let marker = source.join(".ran");
566 let script = source.join(".yui/bin/h.sh");
567 std::fs::create_dir_all(script.parent().unwrap()).unwrap();
568 let body_v1 = format!("#!/bin/sh\necho v1 > {:?}\n", marker.as_str());
569 std::fs::write(&script, &body_v1).unwrap();
570 let hook = bash_hook(WhenRun::Onchange, None);
571 let vars = toml::Table::new();
572 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
573
574 let first = run_hook_test(
575 &hook,
576 &source,
577 &yui_vars(&source),
578 &vars,
579 &mut engine,
580 &ctx,
581 false,
582 false,
583 )
584 .unwrap();
585 assert_eq!(first, HookOutcome::Ran);
586 std::fs::remove_file(&marker).unwrap();
587
588 let second = run_hook_test(
589 &hook,
590 &source,
591 &yui_vars(&source),
592 &vars,
593 &mut engine,
594 &ctx,
595 false,
596 false,
597 )
598 .unwrap();
599 assert_eq!(second, HookOutcome::SkippedUnchanged);
600 assert!(!marker.exists());
601
602 let body_v2 = format!("#!/bin/sh\necho v2 > {:?}\n", marker.as_str());
603 std::fs::write(&script, &body_v2).unwrap();
604 let third = run_hook_test(
605 &hook,
606 &source,
607 &yui_vars(&source),
608 &vars,
609 &mut engine,
610 &ctx,
611 false,
612 false,
613 )
614 .unwrap();
615 assert_eq!(third, HookOutcome::Ran);
616 assert!(marker.exists());
617 }
618
619 #[cfg(unix)]
620 #[test]
621 fn force_bypasses_state_check() {
622 let tmp = TempDir::new().unwrap();
623 let source = utf8(tmp.path().to_path_buf());
624 let marker = source.join(".ran");
625 let script = source.join(".yui/bin/h.sh");
626 std::fs::create_dir_all(script.parent().unwrap()).unwrap();
627 std::fs::write(
628 &script,
629 format!("#!/bin/sh\necho hi >> {:?}\n", marker.as_str()),
630 )
631 .unwrap();
632 let hook = bash_hook(WhenRun::Once, None);
633 let vars = toml::Table::new();
634 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
635
636 let _ = run_hook_test(
637 &hook,
638 &source,
639 &yui_vars(&source),
640 &vars,
641 &mut engine,
642 &ctx,
643 false,
644 false,
645 )
646 .unwrap();
647 let forced = run_hook_test(
648 &hook,
649 &source,
650 &yui_vars(&source),
651 &vars,
652 &mut engine,
653 &ctx,
654 false,
655 true,
656 )
657 .unwrap();
658 assert_eq!(forced, HookOutcome::Ran);
659 let body = std::fs::read_to_string(&marker).unwrap();
660 assert_eq!(body.lines().count(), 2);
661 }
662
663 #[cfg(unix)]
664 #[test]
665 fn run_phase_saves_after_each_success() {
666 let tmp = TempDir::new().unwrap();
669 let source = utf8(tmp.path().to_path_buf());
670 write_script(&source, ".yui/bin/a.sh", "#!/bin/sh\nexit 0\n");
671 write_script(&source, ".yui/bin/b.sh", "#!/bin/sh\nexit 0\n");
672 let cfg = Config {
673 hook: vec![
674 HookConfig {
675 name: "a".into(),
676 script: ".yui/bin/a.sh".into(),
677 command: "bash".into(),
678 args: vec!["{{ script_path }}".into()],
679 when_run: WhenRun::Every,
680 phase: HookPhase::Post,
681 when: None,
682 },
683 HookConfig {
684 name: "b".into(),
685 script: ".yui/bin/b.sh".into(),
686 command: "bash".into(),
687 args: vec!["{{ script_path }}".into()],
688 when_run: WhenRun::Every,
689 phase: HookPhase::Post,
690 when: None,
691 },
692 ],
693 ..Default::default()
694 };
695 let yui = yui_vars(&source);
696 let mut engine = Engine::new();
697 let ctx = template::template_context(&yui, &cfg.vars);
698 run_phase(
699 &cfg,
700 &source,
701 &yui,
702 &mut engine,
703 &ctx,
704 HookPhase::Post,
705 false,
706 )
707 .unwrap();
708 let state = State::load(&source).unwrap();
709 assert!(state.hooks.contains_key("a"));
710 assert!(state.hooks.contains_key("b"));
711 }
712}