Skip to main content

tuff_hooks_spec/
lib.rs

1//! Canonical hook metadata for Tuff-standard hooks.
2//!
3//! This crate models the stable vocabulary Tuff uses when a hook is authored in
4//! Tuff's manifest format and then rendered by an adapter into a native harness
5//! format. It does not model native harness hook fragments; those remain
6//! adapter-owned passthrough data.
7
8use serde::{Deserialize, Serialize};
9
10/// Current version of the Tuff hook specification.
11pub const SPEC_VERSION: &str = "0.1.0";
12
13/// Canonical Tuff hook events.
14#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum HookEvent {
17    /// A session or subagent starts.
18    SessionStart,
19    /// A main session ends.
20    SessionEnd,
21    /// A harness is about to execute a tool.
22    PreToolUse,
23    /// A harness has finished executing a tool.
24    PostToolUse,
25    /// A harness is about to finish the current turn or task.
26    BeforeFinish,
27    /// A harness saved a file.
28    AfterSave,
29    /// A harness stop/continuation point.
30    Stop,
31}
32
33impl HookEvent {
34    /// Returns the canonical snake_case event name.
35    pub const fn as_str(self) -> &'static str {
36        match self {
37            Self::SessionStart => "session_start",
38            Self::SessionEnd => "session_end",
39            Self::PreToolUse => "pre_tool_use",
40            Self::PostToolUse => "post_tool_use",
41            Self::BeforeFinish => "before_finish",
42            Self::AfterSave => "after_save",
43            Self::Stop => "stop",
44        }
45    }
46}
47
48impl std::fmt::Display for HookEvent {
49    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50        f.write_str(self.as_str())
51    }
52}
53
54/// Payload type names used by [`PayloadField`].
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum PayloadValueType {
58    /// String value.
59    String,
60    /// Boolean value.
61    Boolean,
62    /// Number value.
63    Number,
64    /// Structured JSON object.
65    Object,
66    /// JSON array.
67    Array,
68}
69
70/// A field expected in a hook payload.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct PayloadField {
73    /// Field name as it appears in the canonical payload descriptor.
74    pub name: &'static str,
75    /// Field value type.
76    pub value_type: PayloadValueType,
77    /// Whether the field is required for the canonical event.
78    pub required: bool,
79    /// Human-facing field description.
80    pub description: &'static str,
81}
82
83/// A structured descriptor for a canonical event payload.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct PayloadSchema {
86    /// Common and event-specific fields.
87    pub fields: &'static [PayloadField],
88}
89
90impl PayloadSchema {
91    /// Returns true when the descriptor contains no declared fields.
92    pub fn is_empty(&self) -> bool {
93        self.fields.is_empty()
94    }
95}
96
97/// What a hook can block when a handler returns a blocking result.
98#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum BlockingScope {
101    /// The event is not blocking.
102    NotBlocking,
103    /// The hook can block the action that has not yet run.
104    BlocksAction,
105    /// The hook can block continuation after an action or turn.
106    BlocksContinuation,
107    /// Harness-specific blocking semantics documented by the adapter.
108    Custom(&'static str),
109}
110
111/// Canonical event metadata.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct HookEventSpec {
114    /// Canonical typed event.
115    pub event: HookEvent,
116    /// Event name used in Tuff-standard manifests.
117    pub canonical_name: &'static str,
118    /// Blocking behavior exposed by the canonical event.
119    pub blocking: BlockingScope,
120    /// First Tuff hook spec version that includes this event.
121    pub since_spec_version: &'static str,
122    /// Structured payload descriptor.
123    pub payload_schema: PayloadSchema,
124}
125
126/// Adapter support level for a canonical event.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum CoverageLevel {
130    /// The adapter supports the event for the declared scope.
131    Full,
132    /// The adapter supports the event only for the declared scope.
133    Partial,
134    /// The adapter does not support the event.
135    Unsupported,
136}
137
138impl CoverageLevel {
139    /// Returns whether this coverage level can render/run a hook.
140    pub const fn is_supported(self) -> bool {
141        !matches!(self, Self::Unsupported)
142    }
143}
144
145/// Compatibility for one canonical event on one adapter.
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct CompatibilityEntry {
148    /// Canonical Tuff hook event.
149    pub event: HookEvent,
150    /// Native harness event name emitted by the adapter, when supported.
151    pub native_event: Option<&'static str>,
152    /// Legacy or native names accepted as aliases in Tuff manifests.
153    pub aliases: &'static [&'static str],
154    /// Support level for this event.
155    pub coverage: CoverageLevel,
156    /// Scope labels that explain partial coverage.
157    pub scope: &'static [&'static str],
158    /// Adapter or harness caveat, if any.
159    pub caveat: Option<&'static str>,
160    /// Source note or URL for the compatibility row.
161    pub source: Option<&'static str>,
162    /// First harness version known to have this behavior.
163    pub since_harness_version: Option<&'static str>,
164    /// Last harness version known to have this behavior.
165    pub until_harness_version: Option<&'static str>,
166}
167
168impl CompatibilityEntry {
169    /// Returns true when this row matches a manifest event name.
170    pub fn matches_name(&self, raw_event: &str) -> bool {
171        self.event.as_str() == raw_event || self.aliases.contains(&raw_event)
172    }
173
174    /// Returns the native event to emit when this row is supported.
175    pub fn native_event_name(&self) -> Option<&'static str> {
176        self.coverage
177            .is_supported()
178            .then_some(self.native_event)
179            .flatten()
180    }
181}
182
183/// Static adapter compatibility data.
184#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185pub struct CompatibilityMatrix {
186    /// Tuff hook spec version this matrix targets.
187    pub spec_version: &'static str,
188    /// Adapter id, such as `open-agents` or `claude`.
189    pub adapter: &'static str,
190    /// Matrix rows.
191    pub events: &'static [CompatibilityEntry],
192}
193
194impl CompatibilityMatrix {
195    /// Finds the compatibility row for a manifest event name or alias.
196    pub fn find_event(&self, raw_event: &str) -> Option<&CompatibilityEntry> {
197        self.events
198            .iter()
199            .find(|entry| entry.event.as_str() == raw_event)
200            .or_else(|| {
201                self.events
202                    .iter()
203                    .find(|entry| entry.aliases.contains(&raw_event))
204            })
205    }
206
207    /// Returns supported native event names for user-facing error messages.
208    pub fn supported_native_events(&self) -> Vec<&'static str> {
209        self.events
210            .iter()
211            .filter_map(CompatibilityEntry::native_event_name)
212            .collect()
213    }
214}
215
216/// Common payload fields available to most Tuff-standard hooks.
217pub const COMMON_PAYLOAD_FIELDS: &[PayloadField] = &[
218    PayloadField {
219        name: "session_id",
220        value_type: PayloadValueType::String,
221        required: false,
222        description: "Harness session identifier, when available.",
223    },
224    PayloadField {
225        name: "cwd",
226        value_type: PayloadValueType::String,
227        required: false,
228        description: "Working directory for the hook invocation.",
229    },
230];
231
232/// Current canonical event metadata.
233pub const EVENT_SPECS: &[HookEventSpec] = &[
234    HookEventSpec {
235        event: HookEvent::SessionStart,
236        canonical_name: "session_start",
237        blocking: BlockingScope::NotBlocking,
238        since_spec_version: SPEC_VERSION,
239        payload_schema: PayloadSchema {
240            fields: COMMON_PAYLOAD_FIELDS,
241        },
242    },
243    HookEventSpec {
244        event: HookEvent::SessionEnd,
245        canonical_name: "session_end",
246        blocking: BlockingScope::NotBlocking,
247        since_spec_version: SPEC_VERSION,
248        payload_schema: PayloadSchema {
249            fields: COMMON_PAYLOAD_FIELDS,
250        },
251    },
252    HookEventSpec {
253        event: HookEvent::PreToolUse,
254        canonical_name: "pre_tool_use",
255        blocking: BlockingScope::BlocksAction,
256        since_spec_version: SPEC_VERSION,
257        payload_schema: PayloadSchema {
258            fields: COMMON_PAYLOAD_FIELDS,
259        },
260    },
261    HookEventSpec {
262        event: HookEvent::PostToolUse,
263        canonical_name: "post_tool_use",
264        blocking: BlockingScope::BlocksContinuation,
265        since_spec_version: SPEC_VERSION,
266        payload_schema: PayloadSchema {
267            fields: COMMON_PAYLOAD_FIELDS,
268        },
269    },
270    HookEventSpec {
271        event: HookEvent::BeforeFinish,
272        canonical_name: "before_finish",
273        blocking: BlockingScope::BlocksContinuation,
274        since_spec_version: SPEC_VERSION,
275        payload_schema: PayloadSchema {
276            fields: COMMON_PAYLOAD_FIELDS,
277        },
278    },
279    HookEventSpec {
280        event: HookEvent::AfterSave,
281        canonical_name: "after_save",
282        blocking: BlockingScope::NotBlocking,
283        since_spec_version: SPEC_VERSION,
284        payload_schema: PayloadSchema {
285            fields: COMMON_PAYLOAD_FIELDS,
286        },
287    },
288    HookEventSpec {
289        event: HookEvent::Stop,
290        canonical_name: "stop",
291        blocking: BlockingScope::BlocksContinuation,
292        since_spec_version: SPEC_VERSION,
293        payload_schema: PayloadSchema {
294            fields: COMMON_PAYLOAD_FIELDS,
295        },
296    },
297];
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    const ENTRY: CompatibilityEntry = CompatibilityEntry {
304        event: HookEvent::PreToolUse,
305        native_event: Some("PreToolUse"),
306        aliases: &["pre_tool_execution"],
307        coverage: CoverageLevel::Full,
308        scope: &["Bash"],
309        caveat: None,
310        source: None,
311        since_harness_version: None,
312        until_harness_version: None,
313    };
314
315    #[test]
316    fn hook_event_display_uses_canonical_name() {
317        assert_eq!(HookEvent::PreToolUse.to_string(), "pre_tool_use");
318    }
319
320    #[test]
321    fn compatibility_entry_matches_canonical_name_and_alias() {
322        assert!(ENTRY.matches_name("pre_tool_use"));
323        assert!(ENTRY.matches_name("pre_tool_execution"));
324        assert!(!ENTRY.matches_name("before_finish"));
325    }
326
327    #[test]
328    fn unsupported_entries_have_no_native_event_name() {
329        let entry = CompatibilityEntry {
330            coverage: CoverageLevel::Unsupported,
331            ..ENTRY
332        };
333
334        assert_eq!(entry.native_event_name(), None);
335    }
336
337    #[test]
338    fn canonical_name_takes_precedence_over_an_earlier_alias() {
339        const EVENTS: &[CompatibilityEntry] = &[
340            CompatibilityEntry {
341                event: HookEvent::BeforeFinish,
342                native_event: Some("stop"),
343                aliases: &["stop"],
344                coverage: CoverageLevel::Partial,
345                scope: &[],
346                caveat: None,
347                source: None,
348                since_harness_version: None,
349                until_harness_version: None,
350            },
351            CompatibilityEntry {
352                event: HookEvent::Stop,
353                native_event: Some("stop"),
354                aliases: &[],
355                coverage: CoverageLevel::Full,
356                scope: &[],
357                caveat: None,
358                source: None,
359                since_harness_version: None,
360                until_harness_version: None,
361            },
362        ];
363        let matrix = CompatibilityMatrix {
364            spec_version: SPEC_VERSION,
365            adapter: "test",
366            events: EVENTS,
367        };
368
369        let matched = matrix.find_event("stop").expect("stop event");
370        assert_eq!(matched.event, HookEvent::Stop);
371        assert_eq!(matched.coverage, CoverageLevel::Full);
372    }
373
374    #[test]
375    fn aliases_remain_available_when_no_canonical_name_matches() {
376        const EVENTS: &[CompatibilityEntry] = &[CompatibilityEntry {
377            event: HookEvent::PreToolUse,
378            native_event: Some("PreToolUse"),
379            aliases: &["BeforeTool"],
380            coverage: CoverageLevel::Full,
381            scope: &[],
382            caveat: None,
383            source: None,
384            since_harness_version: None,
385            until_harness_version: None,
386        }];
387        let matrix = CompatibilityMatrix {
388            spec_version: SPEC_VERSION,
389            adapter: "test",
390            events: EVENTS,
391        };
392
393        assert_eq!(
394            matrix.find_event("BeforeTool").map(|entry| entry.event),
395            Some(HookEvent::PreToolUse)
396        );
397    }
398}