Skip to main content

oby_core/
capturer.rs

1use crate::{DisplayEntry, DisplayEntryUpdate, HookContext, HookEvent};
2use serde_json::Value;
3
4#[derive(Debug)]
5pub enum RewriteDecision {
6    Passthrough,
7    /// New `tool_input` to be marshaled by oby-hook into `hookSpecificOutput.updatedInput`.
8    /// Keeping this as a raw Value lets capturers stay independent of CC's exact hook-output schema.
9    Rewrite(Value),
10}
11
12/// The contribution API. Every observed CC tool gets one Capturer impl in this crate's source tree.
13pub trait Capturer: Send + Sync + 'static {
14    /// Stable identifier; matches `[capture.<name>]` in config and the filter UI label.
15    fn name(&self) -> &'static str;
16
17    /// CC tool name to match (e.g. `"Bash"`, `"Read"`).
18    fn tool_name(&self) -> &'static str;
19
20    /// Which hook events this capturer wants. Default: both Pre and Post.
21    fn subscribes_to(&self) -> &'static [HookEvent] {
22        &[HookEvent::Pre, HookEvent::Post]
23    }
24
25    /// Optional rewrite. Default: passthrough. Only the Bash capturer overrides this in v0.1.
26    fn pre_rewrite(&self, _ctx: &HookContext, _input: &Value) -> RewriteDecision {
27        RewriteDecision::Passthrough
28    }
29
30    /// Render a PreToolUse event. Return `None` to suppress (e.g. for noisy calls).
31    fn render_pre(&self, ctx: &HookContext, input: &Value) -> Option<DisplayEntry>;
32
33    /// Render a PostToolUse event. Default: no update (Pre-only capturers).
34    /// The wrapper correlates Pre↔Post by `tool_use_id` and applies this update.
35    fn render_post(
36        &self,
37        _ctx: &HookContext,
38        _input: &Value,
39        _response: &Value,
40    ) -> Option<DisplayEntryUpdate> {
41        None
42    }
43}
44
45#[cfg(test)]
46mod tests {
47    use super::*;
48    use crate::{EntryBody, EntryStatus};
49    use std::time::SystemTime;
50
51    struct NoOpCapturer;
52    impl Capturer for NoOpCapturer {
53        fn name(&self) -> &'static str {
54            "noop"
55        }
56        fn tool_name(&self) -> &'static str {
57            "NoSuchTool"
58        }
59        fn render_pre(&self, ctx: &HookContext, _: &Value) -> Option<DisplayEntry> {
60            Some(DisplayEntry {
61                agent_key: ctx.agent_key().to_string(),
62                tool_use_id: ctx.tool_use_id.clone(),
63                tool: "noop".to_string(),
64                timestamp: SystemTime::now(),
65                headline: "noop".into(),
66                body: EntryBody::None,
67                status: EntryStatus::Pending,
68            })
69        }
70    }
71
72    #[test]
73    fn trait_is_object_safe() {
74        let _: Box<dyn Capturer> = Box::new(NoOpCapturer);
75    }
76
77    #[test]
78    fn default_pre_rewrite_is_passthrough() {
79        let c = NoOpCapturer;
80        let ctx: HookContext = serde_json::from_str(
81            r#"{"session_id":"s","transcript_path":"/t","cwd":"/c","hook_event_name":"PreToolUse","tool_name":"Bash","tool_use_id":"t1"}"#,
82        ).unwrap();
83        match c.pre_rewrite(&ctx, &Value::Null) {
84            RewriteDecision::Passthrough => {}
85            _ => panic!("expected Passthrough"),
86        }
87    }
88}