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 hook 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, HookOutput, MergeOutput, NativeHookError, 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 native hook'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 native hook table.
81#[derive(Debug, Clone, PartialEq, Eq)]
82pub struct NativeHook {
83 /// The native hook file in the hook's directory: the declared one, else `<target>.json`.
84 pub file: String,
85 unchecked: bool,
86}
87
88impl NativeHook {
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, NativeHook>,
102 portable: Option<PortableHooks>,
103 skip_targets: BTreeSet<String>,
104}
105
106impl HookDef {
107 /// The native hook table this hook declares for `target_root`.
108 pub fn native_hook(&self, target_root: &str) -> Option<&NativeHook> {
109 self.targets.get(target_root)
110 }
111
112 /// Every declared native hook table, by target root.
113 pub fn native_hooks(&self) -> impl Iterator<Item = (&str, &NativeHook)> {
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 native hook table. A native hook
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}