Skip to main content

yui/
hook.rs

1//! Hook system — run user-supplied scripts around `yui apply`.
2//!
3//! Scripts live at `$DOTFILES/<config.script>` (idiomatic place is
4//! `.yui/bin/<name>.sh`). They're plain executables — no yui imports,
5//! no special protocol. yui just decides *when* to invoke them based on
6//! the `[[hook]]` config and the persisted state file.
7//!
8//! ## State
9//!
10//! Per-hook outcomes are stored in `$DOTFILES/.yui/state.json`:
11//!
12//! ```json
13//! {
14//!   "version": 1,
15//!   "hooks": {
16//!     "install-tools": {
17//!       "last_run_at": "2026-04-29T08:30:00+09:00[Asia/Tokyo]",
18//!       "last_content_hash": "sha256:abc123..."
19//!     }
20//!   }
21//! }
22//! ```
23//!
24//! `last_run_at` is filled on every successful run; `last_content_hash`
25//! is only filled for `when_run = "onchange"` hooks.
26
27use 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    /// Atomically persist the state to disk: write to a sibling `.tmp`
73    /// file then rename, so an interrupted save can't leave a half-
74    /// written state.json behind. (gemini medium PR #20)
75    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/// What happened when we considered running a hook.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum HookOutcome {
93    /// The hook ran and exited successfully.
94    Ran,
95    /// `when_run = "once"` and the hook has run before.
96    SkippedOnce,
97    /// `when_run = "onchange"` and the script's hash matches state.
98    SkippedUnchanged,
99    /// `when` evaluated false on this host.
100    SkippedWhenFalse,
101    /// `dry_run = true` — the hook would have run.
102    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
114/// Build a Tera context for a hook: standard `template_context` + the
115/// `script_*` vars that `command` / `args` can interpolate.
116pub 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/// Decide whether to run `hook` and run it if so. Updates `state` in
134/// memory on a successful run — caller is responsible for persisting
135/// (typically via `state.save(source)` after each `Ran` outcome, so a
136/// later hook failure doesn't lose the record of the earlier success).
137///
138/// `force = true` bypasses the `when_run` state check (still respects
139/// `when` — an explicit `yui hooks run <name>` shouldn't suddenly run a
140/// hook that's `when = "yui.os == 'macos'"` on Linux).
141#[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    // Hash the script body only when the `when_run` policy actually
162    // uses the value (`Onchange`). For `Once` / `Every` we don't need
163    // the hash either for the decision OR for state, so skip the
164    // read+SHA. (gemini medium PR #20)
165    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    // Validate that the script exists *before* the dry-run early
195    // return — `apply --dry-run` should still surface a missing script
196    // as an error rather than silently reporting "would run". (gemini
197    // medium PR #20)
198    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
248/// Run every hook whose phase matches. Stops at the first failure (the
249/// user can investigate, fix, and re-run; we don't want to silently keep
250/// going after a failed `pre` hook).
251pub 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            /* force */ 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        // Save after each successful run so a later failure doesn't
282        // discard the earlier successes.
283        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); // "sha256:" + 64 hex chars
352    }
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    /// Helper that wraps run_hook + a freshly-loaded state. Used by all
375    /// the test cases below — the production callers (`run_phase`,
376    /// `cmd::hooks_run`) reuse one State across multiple hooks, but
377    /// per-test isolation is fine here.
378    #[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    /// Most tests need only a `bash` hook; this builds one with the
400    /// usual defaults. Caller picks the `when_run`.
401    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        // Script must exist — dry_run now validates existence.
418        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            /* dry_run */ true,
430            /* force */ 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        // Even in dry-run we should validate that the script exists —
440        // otherwise `apply --dry-run` would happily report "would run"
441        // for a misconfigured hook. (gemini PR #20 medium #4.)
442        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            /* dry_run */ true,
455            /* force */ 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"); // would fail if run
466        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            /* dry_run */ false,
477            /* force */ 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        // --force bypasses the time/hash state check, but the OS gate
487        // (`when = "yui.os == 'no-such-os'"`) is a real config filter
488        // that should still keep the hook from running.
489        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            /* force */ true,
504        )
505        .unwrap();
506        assert_eq!(outcome, HookOutcome::SkippedWhenFalse);
507    }
508
509    // The remaining tests actually spawn `bash` so they're Unix-only —
510    // Windows GitHub runners don't have bash on $PATH (and we don't
511    // want to tie the test suite to Git Bash's path on Windows). The
512    // production code is portable; the tests just pick a portable
513    // command set when CI matters.
514
515    #[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            /* force */ 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        // Two `every` hooks; both run, both records persist in
667        // state.json — verifying we save inside the loop, not at end.
668        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}