Skip to main content

mars_schema/hooks/
mod.rs

1//! The `hook.toml` definition and every per-target hook rule.
2//!
3//! A hook is written once as Claude's hooks JSON in TOML (`[hooks]`, see `portable`), a
4//! native fragment per target (`[targets]`), or both. This module owns what is authored
5//! (`HookDef`) and which targets receive it (`HookDef::select`); `read` turns text into a
6//! `HookDef`, `output` says what it installs on one target, `targets` is the per-target
7//! table and `render` turns a `[hooks]` table into a profile's entries.
8//! Nothing here touches the filesystem: the caller reads the bytes and places the result,
9//! and adds the location (a path, or a package and version) to the path-free diagnostics.
10
11mod output;
12pub mod portable;
13mod read;
14pub mod render;
15pub mod targets;
16#[cfg(test)]
17mod tests;
18mod typescript;
19
20use std::collections::{BTreeMap, BTreeSet};
21
22use crate::ordered::OrderedValue;
23use crate::visibility::Visibility;
24use portable::PortableHooks;
25use render::Skip;
26
27pub use output::{FileOutput, FragmentError, HookOutput, MergeOutput, OutputFile};
28pub use read::{HookRead, REMOVED_SCHEMA_CODE, REMOVED_SCHEMA_MESSAGE, read, uses_removed_schema};
29
30/// The authored placeholder for the directory a hook's files are installed in.
31pub const HOOK_DIR_TOKEN: &str = "${MARS_HOOK_DIR}";
32
33/// Hook names ending in this are reserved for files Mars generates beside authored ones.
34pub(crate) const RESERVED_NAME_SUFFIX: &str = ".hooks";
35
36/// Where a `hook.toml` came from. The project's own is strict. A dependency's is lenient
37/// where one author's slip should not stop a sync: an unknown top-level key is ignored and
38/// a merge fragment's unknown event is skipped, each with a warning.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum Origin {
41    Local,
42    Dependency,
43}
44
45/// The authored placeholder for the target a hook is installed for.
46pub const HOOK_TARGET_TOKEN: &str = "${MARS_HOOK_TARGET}";
47
48/// The values a hook's text is substituted with.
49#[derive(Debug, Clone, Copy, Default)]
50pub struct Tokens<'a> {
51    /// Replaces [`HOOK_DIR_TOKEN`]: where the hook's directory is installed.
52    pub hook_dir: &'a str,
53    /// Replaces [`HOOK_TARGET_TOKEN`]: the target root (`.claude`, `.codex`, …), or `plugin`
54    /// in a plugin build's shared tree.
55    pub hook_target: &'a str,
56    /// The hook's name, which a generated file carries as its identity. Not a token: no
57    /// authored text is substituted with it.
58    pub hook_name: &'a str,
59}
60
61impl Tokens<'_> {
62    /// `text` with every token replaced.
63    pub fn substitute(&self, text: &str) -> String {
64        text.replace(HOOK_DIR_TOKEN, self.hook_dir)
65            .replace(HOOK_TARGET_TOKEN, self.hook_target)
66    }
67
68    fn substitute_value(&self, value: &mut OrderedValue) {
69        match value {
70            OrderedValue::String(text) => *text = self.substitute(text),
71            OrderedValue::Array(values) => values.iter_mut().for_each(|v| self.substitute_value(v)),
72            OrderedValue::Object(values) => {
73                values.values_mut().for_each(|v| self.substitute_value(v))
74            }
75            _ => {}
76        }
77    }
78}
79
80/// One target's fragment table.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct Fragment {
83    /// The fragment file in the hook's directory: the declared one, else `<target>.json`.
84    pub file: String,
85    unchecked: bool,
86}
87
88impl Fragment {
89    /// `unchecked = true`: events the target does not list pass through verbatim.
90    pub fn is_unchecked(&self) -> bool {
91        self.unchecked
92    }
93}
94
95/// A `hook.toml`.
96#[derive(Debug, Clone)]
97pub struct HookDef {
98    pub name: String,
99    pub visibility: Visibility,
100    pub order: i32,
101    targets: BTreeMap<String, Fragment>,
102    portable: Option<PortableHooks>,
103    skip_targets: BTreeSet<String>,
104}
105
106impl HookDef {
107    /// The fragment table this hook declares for `target_root`.
108    pub fn fragment(&self, target_root: &str) -> Option<&Fragment> {
109        self.targets.get(target_root)
110    }
111
112    /// Every declared fragment table, by target root.
113    pub fn fragments(&self) -> impl Iterator<Item = (&str, &Fragment)> {
114        self.targets.iter().map(|(root, f)| (root.as_str(), f))
115    }
116
117    /// The `[hooks]` table, if the hook has one.
118    pub fn portable(&self) -> Option<&PortableHooks> {
119        self.portable.as_ref()
120    }
121
122    /// Whether `[hooks]` generates entries for `target_root`: it is not in `skip-targets`.
123    fn generates_for(&self, target_root: &str) -> bool {
124        self.portable.is_some() && !self.skip_targets.contains(target_root)
125    }
126
127    /// The managed target roots this hook is installed for: those where `[hooks]` renders at
128    /// least one event (unless in `skip-targets`) and those with a fragment table. A fragment
129    /// on a skipped target still installs; `skip-targets` only turns the generated entries
130    /// off.
131    pub fn select<'a>(&'a self, managed_targets: &'a [String]) -> Vec<&'a str> {
132        managed_targets
133            .iter()
134            .map(String::as_str)
135            .filter(|root| {
136                self.targets.contains_key(*root)
137                    || (self.generates_for(root)
138                        && targets::target(root).is_some_and(|target| {
139                            self.portable
140                                .as_ref()
141                                .is_some_and(|p| p.renders_on(&target.profile))
142                        }))
143            })
144            .collect()
145    }
146
147    /// What `[hooks]` leaves out on `target_root` when nothing there overrides it; empty
148    /// for a target in `skip-targets` and for a hook with no `[hooks]`.
149    pub fn skips_for(&self, target_root: &str) -> Vec<Skip> {
150        match (&self.portable, targets::target(target_root)) {
151            (Some(portable), Some(target)) if self.generates_for(target_root) => {
152                portable.render(&target.profile, &Tokens::default()).skips
153            }
154            _ => Vec::new(),
155        }
156    }
157}