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    use std::fmt::Write as _;
107    let digest = Sha256::digest(bytes);
108    // sha2 0.11 returns a `hybrid_array::Array` that no longer
109    // implements `LowerHex`, so the `{:x}` shorthand the older
110    // crate version supported is gone — format byte-by-byte.
111    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
123/// Build a Tera context for a hook: standard `template_context` + the
124/// `script_*` vars that `command` / `args` can interpolate.
125pub 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/// Decide whether to run `hook` and run it if so. Updates `state` in
143/// memory on a successful run — caller is responsible for persisting
144/// (typically via `state.save(source)` after each `Ran` outcome, so a
145/// later hook failure doesn't lose the record of the earlier success).
146///
147/// `force = true` bypasses the `when_run` state check (still respects
148/// `when` — an explicit `yui hooks run <name>` shouldn't suddenly run a
149/// hook that's `when = "yui.os == 'macos'"` on Linux).
150#[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    // Hash the script body only when the `when_run` policy actually
171    // uses the value (`Onchange`). For `Once` / `Every` we don't need
172    // the hash either for the decision OR for state, so skip the
173    // read+SHA. (gemini medium PR #20)
174    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    // Validate that the script exists *before* the dry-run early
204    // return — `apply --dry-run` should still surface a missing script
205    // as an error rather than silently reporting "would run". (gemini
206    // medium PR #20)
207    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
257/// Run every hook whose phase matches. Stops at the first failure (the
258/// user can investigate, fix, and re-run; we don't want to silently keep
259/// going after a failed `pre` hook).
260pub 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            /* force */ 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        // Save after each successful run so a later failure doesn't
291        // discard the earlier successes.
292        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); // "sha256:" + 64 hex chars
361    }
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    /// Helper that wraps run_hook + a freshly-loaded state. Used by all
384    /// the test cases below — the production callers (`run_phase`,
385    /// `cmd::hooks_run`) reuse one State across multiple hooks, but
386    /// per-test isolation is fine here.
387    #[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    /// Most tests need only a `bash` hook; this builds one with the
409    /// usual defaults. Caller picks the `when_run`.
410    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        // Script must exist — dry_run now validates existence.
427        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            /* dry_run */ true,
439            /* force */ 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        // Even in dry-run we should validate that the script exists —
449        // otherwise `apply --dry-run` would happily report "would run"
450        // for a misconfigured hook. (gemini PR #20 medium #4.)
451        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            /* dry_run */ true,
464            /* force */ 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"); // would fail if run
475        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            /* dry_run */ false,
486            /* force */ 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        // --force bypasses the time/hash state check, but the OS gate
496        // (`when = "yui.os == 'no-such-os'"`) is a real config filter
497        // that should still keep the hook from running.
498        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            /* force */ true,
513        )
514        .unwrap();
515        assert_eq!(outcome, HookOutcome::SkippedWhenFalse);
516    }
517
518    // The remaining tests actually spawn `bash` so they're Unix-only —
519    // Windows GitHub runners don't have bash on $PATH (and we don't
520    // want to tie the test suite to Git Bash's path on Windows). The
521    // production code is portable; the tests just pick a portable
522    // command set when CI matters.
523
524    #[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            /* force */ 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        // Two `every` hooks; both run, both records persist in
676        // state.json — verifying we save inside the loop, not at end.
677        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}