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 use std::fmt::Write as _;
107 let digest = Sha256::digest(bytes);
108 let mut out = String::with_capacity(7 + digest.len() * 2);
112 out.push_str("sha256:");
113 for b in digest.iter() {
114 write!(out, "{b:02x}").expect("writing to String never fails");
115 }
116 out
117}
118
119fn now_iso8601() -> String {
120 jiff::Zoned::now().to_string()
121}
122
123pub fn build_hook_context(
126 yui: &YuiVars,
127 vars: &toml::Table,
128 script_path: &Utf8Path,
129) -> TeraContext {
130 let mut ctx = template::template_context(yui, vars);
131 ctx.insert("script_path", &script_path.as_str());
132 ctx.insert(
133 "script_dir",
134 &script_path.parent().map(|p| p.as_str()).unwrap_or(""),
135 );
136 ctx.insert("script_name", &script_path.file_name().unwrap_or(""));
137 ctx.insert("script_stem", &script_path.file_stem().unwrap_or(""));
138 ctx.insert("script_ext", &script_path.extension().unwrap_or(""));
139 ctx
140}
141
142#[allow(clippy::too_many_arguments)]
151pub fn run_hook(
152 hook: &HookConfig,
153 source: &Utf8Path,
154 yui: &YuiVars,
155 vars: &toml::Table,
156 engine: &mut Engine,
157 base_ctx: &TeraContext,
158 state: &mut State,
159 dry_run: bool,
160 force: bool,
161) -> Result<HookOutcome> {
162 if let Some(when) = &hook.when {
163 if !template::eval_truthy(when, engine, base_ctx)? {
164 return Ok(HookOutcome::SkippedWhenFalse);
165 }
166 }
167
168 let script_path = source.join(&hook.script);
169
170 let current_hash = if hook.when_run == WhenRun::Onchange {
175 match std::fs::read(&script_path) {
176 Ok(bytes) => Some(sha256_hex(&bytes)),
177 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
178 Err(e) => return Err(e.into()),
179 }
180 } else {
181 None
182 };
183
184 if !force {
185 let prior = state.hooks.get(&hook.name);
186 match hook.when_run {
187 WhenRun::Once => {
188 if prior.and_then(|s| s.last_run_at.as_ref()).is_some() {
189 return Ok(HookOutcome::SkippedOnce);
190 }
191 }
192 WhenRun::Onchange => {
193 if let (Some(prior_state), Some(now_hash)) = (prior, current_hash.as_deref()) {
194 if prior_state.last_content_hash.as_deref() == Some(now_hash) {
195 return Ok(HookOutcome::SkippedUnchanged);
196 }
197 }
198 }
199 WhenRun::Every => {}
200 }
201 }
202
203 if !script_path.is_file() {
208 return Err(Error::Other(anyhow::anyhow!(
209 "hook[{}]: script not found at {script_path}",
210 hook.name
211 )));
212 }
213
214 if dry_run {
215 return Ok(HookOutcome::DryRun);
216 }
217
218 let hook_ctx = build_hook_context(yui, vars, &script_path);
219 let command = engine.render(&hook.command, &hook_ctx)?;
220 let args: Vec<String> = hook
221 .args
222 .iter()
223 .map(|a| engine.render(a, &hook_ctx))
224 .collect::<Result<_>>()?;
225
226 info!(
227 "hook[{}] running: {} {}",
228 hook.name,
229 command,
230 args.join(" ")
231 );
232 let status = Command::new(&command)
233 .args(&args)
234 .current_dir(source.as_std_path())
235 .status()
236 .map_err(|e| Error::Other(anyhow::anyhow!("hook[{}]: spawn {command}: {e}", hook.name)))?;
237
238 if !status.success() {
239 return Err(Error::Other(anyhow::anyhow!(
240 "hook[{}] exited with status {status}",
241 hook.name
242 )));
243 }
244
245 state.version = STATE_VERSION;
246 state.hooks.insert(
247 hook.name.clone(),
248 HookState {
249 last_run_at: Some(now_iso8601()),
250 last_content_hash: current_hash,
251 },
252 );
253
254 Ok(HookOutcome::Ran)
255}
256
257pub fn run_phase(
261 config: &Config,
262 source: &Utf8Path,
263 yui: &YuiVars,
264 engine: &mut Engine,
265 base_ctx: &TeraContext,
266 phase: HookPhase,
267 dry_run: bool,
268) -> Result<()> {
269 let mut state = State::load(source)?;
270 for hook in &config.hook {
271 if hook.phase != phase {
272 continue;
273 }
274 let outcome = run_hook(
275 hook,
276 source,
277 yui,
278 &config.vars,
279 engine,
280 base_ctx,
281 &mut state,
282 dry_run,
283 false,
284 )?;
285 let phase_name = match phase {
286 HookPhase::Pre => "pre",
287 HookPhase::Post => "post",
288 };
289 info!("hook[{}] {phase_name}: {:?}", hook.name, outcome);
290 if outcome == HookOutcome::Ran {
293 state.save(source)?;
294 }
295 }
296 Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use camino::Utf8PathBuf;
303 use tempfile::TempDir;
304
305 fn utf8(p: std::path::PathBuf) -> Utf8PathBuf {
306 Utf8PathBuf::from_path_buf(p).unwrap()
307 }
308
309 fn yui_vars(source: &Utf8Path) -> YuiVars {
310 YuiVars {
311 os: std::env::consts::OS.to_string(),
312 arch: std::env::consts::ARCH.to_string(),
313 host: "test".into(),
314 user: "u".into(),
315 source: source.to_string(),
316 }
317 }
318
319 #[test]
320 fn state_roundtrip() {
321 let tmp = TempDir::new().unwrap();
322 let source = utf8(tmp.path().to_path_buf());
323 let state = State {
324 version: STATE_VERSION,
325 hooks: BTreeMap::from([(
326 "h1".to_string(),
327 HookState {
328 last_run_at: Some("2026-04-29T00:00:00Z".into()),
329 last_content_hash: Some("sha256:abc".into()),
330 },
331 )]),
332 };
333 state.save(&source).unwrap();
334 let reloaded = State::load(&source).unwrap();
335 assert_eq!(reloaded.version, STATE_VERSION);
336 assert_eq!(
337 reloaded
338 .hooks
339 .get("h1")
340 .unwrap()
341 .last_content_hash
342 .as_deref(),
343 Some("sha256:abc")
344 );
345 }
346
347 #[test]
348 fn state_load_returns_default_when_absent() {
349 let tmp = TempDir::new().unwrap();
350 let source = utf8(tmp.path().to_path_buf());
351 let s = State::load(&source).unwrap();
352 assert_eq!(s.version, 0);
353 assert!(s.hooks.is_empty());
354 }
355
356 #[test]
357 fn sha256_hex_format_includes_prefix() {
358 let h = sha256_hex(b"hello");
359 assert!(h.starts_with("sha256:"));
360 assert_eq!(h.len(), 7 + 64); }
362
363 fn make_engine_and_ctx(source: &Utf8Path, vars: &toml::Table) -> (Engine, TeraContext) {
364 let engine = Engine::new();
365 let ctx = template::template_context(&yui_vars(source), vars);
366 (engine, ctx)
367 }
368
369 fn write_script(source: &Utf8Path, rel: &str, body: &str) -> Utf8PathBuf {
370 let path = source.join(rel);
371 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
372 std::fs::write(&path, body).unwrap();
373 #[cfg(unix)]
374 {
375 use std::os::unix::fs::PermissionsExt;
376 let mut perms = std::fs::metadata(&path).unwrap().permissions();
377 perms.set_mode(0o755);
378 std::fs::set_permissions(&path, perms).unwrap();
379 }
380 path
381 }
382
383 #[allow(clippy::too_many_arguments)]
388 fn run_hook_test(
389 hook: &HookConfig,
390 source: &Utf8Path,
391 yui: &YuiVars,
392 vars: &toml::Table,
393 engine: &mut Engine,
394 ctx: &TeraContext,
395 dry_run: bool,
396 force: bool,
397 ) -> Result<HookOutcome> {
398 let mut state = State::load(source)?;
399 let outcome = run_hook(
400 hook, source, yui, vars, engine, ctx, &mut state, dry_run, force,
401 )?;
402 if outcome == HookOutcome::Ran {
403 state.save(source)?;
404 }
405 Ok(outcome)
406 }
407
408 fn bash_hook(when_run: WhenRun, when: Option<&str>) -> HookConfig {
411 HookConfig {
412 name: "h".into(),
413 script: ".yui/bin/h.sh".into(),
414 command: "bash".into(),
415 args: vec!["{{ script_path }}".into()],
416 when_run,
417 phase: HookPhase::Post,
418 when: when.map(str::to_string),
419 }
420 }
421
422 #[test]
423 fn dry_run_returns_dry_run_outcome() {
424 let tmp = TempDir::new().unwrap();
425 let source = utf8(tmp.path().to_path_buf());
426 write_script(&source, ".yui/bin/h.sh", "#!/bin/sh\nexit 0\n");
428 let hook = bash_hook(WhenRun::Every, None);
429 let vars = toml::Table::new();
430 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
431 let outcome = run_hook_test(
432 &hook,
433 &source,
434 &yui_vars(&source),
435 &vars,
436 &mut engine,
437 &ctx,
438 true,
439 false,
440 )
441 .unwrap();
442 assert_eq!(outcome, HookOutcome::DryRun);
443 assert!(!source.join(STATE_REL_PATH).exists());
444 }
445
446 #[test]
447 fn dry_run_errors_when_script_missing() {
448 let tmp = TempDir::new().unwrap();
452 let source = utf8(tmp.path().to_path_buf());
453 let hook = bash_hook(WhenRun::Every, None);
454 let vars = toml::Table::new();
455 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
456 let err = run_hook_test(
457 &hook,
458 &source,
459 &yui_vars(&source),
460 &vars,
461 &mut engine,
462 &ctx,
463 true,
464 false,
465 )
466 .unwrap_err();
467 assert!(format!("{err}").contains("script not found"));
468 }
469
470 #[test]
471 fn when_false_skips_without_running() {
472 let tmp = TempDir::new().unwrap();
473 let source = utf8(tmp.path().to_path_buf());
474 write_script(&source, ".yui/bin/h.sh", "#!/bin/sh\nexit 1\n"); let hook = bash_hook(WhenRun::Every, Some("yui.os == 'no-such-os'"));
476 let vars = toml::Table::new();
477 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
478 let outcome = run_hook_test(
479 &hook,
480 &source,
481 &yui_vars(&source),
482 &vars,
483 &mut engine,
484 &ctx,
485 false,
486 false,
487 )
488 .unwrap();
489 assert_eq!(outcome, HookOutcome::SkippedWhenFalse);
490 assert!(!source.join(STATE_REL_PATH).exists());
491 }
492
493 #[test]
494 fn force_still_respects_when_filter() {
495 let tmp = TempDir::new().unwrap();
499 let source = utf8(tmp.path().to_path_buf());
500 write_script(&source, ".yui/bin/h.sh", "#!/bin/sh\nexit 1\n");
501 let hook = bash_hook(WhenRun::Every, Some("yui.os == 'no-such-os'"));
502 let vars = toml::Table::new();
503 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
504 let outcome = run_hook_test(
505 &hook,
506 &source,
507 &yui_vars(&source),
508 &vars,
509 &mut engine,
510 &ctx,
511 false,
512 true,
513 )
514 .unwrap();
515 assert_eq!(outcome, HookOutcome::SkippedWhenFalse);
516 }
517
518 #[cfg(unix)]
525 #[test]
526 fn once_runs_first_then_skips() {
527 let tmp = TempDir::new().unwrap();
528 let source = utf8(tmp.path().to_path_buf());
529 let marker = source.join(".ran");
530 write_script(
531 &source,
532 ".yui/bin/h.sh",
533 &format!("#!/bin/sh\necho ok > {:?}\n", marker.as_str()),
534 );
535 let hook = bash_hook(WhenRun::Once, None);
536 let vars = toml::Table::new();
537 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
538
539 let first = run_hook_test(
540 &hook,
541 &source,
542 &yui_vars(&source),
543 &vars,
544 &mut engine,
545 &ctx,
546 false,
547 false,
548 )
549 .unwrap();
550 assert_eq!(first, HookOutcome::Ran);
551 assert!(marker.exists());
552 std::fs::remove_file(&marker).unwrap();
553
554 let second = run_hook_test(
555 &hook,
556 &source,
557 &yui_vars(&source),
558 &vars,
559 &mut engine,
560 &ctx,
561 false,
562 false,
563 )
564 .unwrap();
565 assert_eq!(second, HookOutcome::SkippedOnce);
566 assert!(!marker.exists());
567 }
568
569 #[cfg(unix)]
570 #[test]
571 fn onchange_runs_when_hash_differs() {
572 let tmp = TempDir::new().unwrap();
573 let source = utf8(tmp.path().to_path_buf());
574 let marker = source.join(".ran");
575 let script = source.join(".yui/bin/h.sh");
576 std::fs::create_dir_all(script.parent().unwrap()).unwrap();
577 let body_v1 = format!("#!/bin/sh\necho v1 > {:?}\n", marker.as_str());
578 std::fs::write(&script, &body_v1).unwrap();
579 let hook = bash_hook(WhenRun::Onchange, None);
580 let vars = toml::Table::new();
581 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
582
583 let first = run_hook_test(
584 &hook,
585 &source,
586 &yui_vars(&source),
587 &vars,
588 &mut engine,
589 &ctx,
590 false,
591 false,
592 )
593 .unwrap();
594 assert_eq!(first, HookOutcome::Ran);
595 std::fs::remove_file(&marker).unwrap();
596
597 let second = run_hook_test(
598 &hook,
599 &source,
600 &yui_vars(&source),
601 &vars,
602 &mut engine,
603 &ctx,
604 false,
605 false,
606 )
607 .unwrap();
608 assert_eq!(second, HookOutcome::SkippedUnchanged);
609 assert!(!marker.exists());
610
611 let body_v2 = format!("#!/bin/sh\necho v2 > {:?}\n", marker.as_str());
612 std::fs::write(&script, &body_v2).unwrap();
613 let third = run_hook_test(
614 &hook,
615 &source,
616 &yui_vars(&source),
617 &vars,
618 &mut engine,
619 &ctx,
620 false,
621 false,
622 )
623 .unwrap();
624 assert_eq!(third, HookOutcome::Ran);
625 assert!(marker.exists());
626 }
627
628 #[cfg(unix)]
629 #[test]
630 fn force_bypasses_state_check() {
631 let tmp = TempDir::new().unwrap();
632 let source = utf8(tmp.path().to_path_buf());
633 let marker = source.join(".ran");
634 let script = source.join(".yui/bin/h.sh");
635 std::fs::create_dir_all(script.parent().unwrap()).unwrap();
636 std::fs::write(
637 &script,
638 format!("#!/bin/sh\necho hi >> {:?}\n", marker.as_str()),
639 )
640 .unwrap();
641 let hook = bash_hook(WhenRun::Once, None);
642 let vars = toml::Table::new();
643 let (mut engine, ctx) = make_engine_and_ctx(&source, &vars);
644
645 let _ = run_hook_test(
646 &hook,
647 &source,
648 &yui_vars(&source),
649 &vars,
650 &mut engine,
651 &ctx,
652 false,
653 false,
654 )
655 .unwrap();
656 let forced = run_hook_test(
657 &hook,
658 &source,
659 &yui_vars(&source),
660 &vars,
661 &mut engine,
662 &ctx,
663 false,
664 true,
665 )
666 .unwrap();
667 assert_eq!(forced, HookOutcome::Ran);
668 let body = std::fs::read_to_string(&marker).unwrap();
669 assert_eq!(body.lines().count(), 2);
670 }
671
672 #[cfg(unix)]
673 #[test]
674 fn run_phase_saves_after_each_success() {
675 let tmp = TempDir::new().unwrap();
678 let source = utf8(tmp.path().to_path_buf());
679 write_script(&source, ".yui/bin/a.sh", "#!/bin/sh\nexit 0\n");
680 write_script(&source, ".yui/bin/b.sh", "#!/bin/sh\nexit 0\n");
681 let cfg = Config {
682 hook: vec![
683 HookConfig {
684 name: "a".into(),
685 script: ".yui/bin/a.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 HookConfig {
693 name: "b".into(),
694 script: ".yui/bin/b.sh".into(),
695 command: "bash".into(),
696 args: vec!["{{ script_path }}".into()],
697 when_run: WhenRun::Every,
698 phase: HookPhase::Post,
699 when: None,
700 },
701 ],
702 ..Default::default()
703 };
704 let yui = yui_vars(&source);
705 let mut engine = Engine::new();
706 let ctx = template::template_context(&yui, &cfg.vars);
707 run_phase(
708 &cfg,
709 &source,
710 &yui,
711 &mut engine,
712 &ctx,
713 HookPhase::Post,
714 false,
715 )
716 .unwrap();
717 let state = State::load(&source).unwrap();
718 assert!(state.hooks.contains_key("a"));
719 assert!(state.hooks.contains_key("b"));
720 }
721}