Skip to main content

mati_core/hooks/decide/
file_changed.rs

1//! Pure parsing for Claude Code's `FileChanged` hook event.
2
3use serde::{Deserialize, Serialize};
4
5/// The payload captured from Claude Code 2.1.223's `FileChanged` event.
6///
7/// Unlike the MCP input DTOs, this deliberately does NOT `deny_unknown_fields`:
8/// those parse untrusted client input, this parses a platform payload that gains
9/// fields between Claude Code releases. Rejecting one would stop the freshness
10/// updates with no signal.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct FileChangedPayload {
13    pub session_id: String,
14    pub transcript_path: String,
15    pub cwd: String,
16    /// Absent when the watcher fires outside a prompt turn.
17    #[serde(default)]
18    pub prompt_id: Option<String>,
19    pub hook_event_name: String,
20    /// Absolute, and echoed back in the exact spelling mati handed to
21    /// `watchPaths` — Claude Code does not canonicalize it. Resolve symlinks
22    /// on both sides before comparing it to a repo root.
23    pub file_path: String,
24    /// chokidar event: `add`, `change`, or `unlink`.
25    pub event: String,
26}
27
28/// Parse one raw `FileChanged` hook payload.
29///
30/// Invalid or unrelated payloads return `None` so the hook can fail open
31/// without reparsing a path it did not understand.
32pub fn parse_file_changed(input: &serde_json::Value) -> Option<FileChangedPayload> {
33    let payload: FileChangedPayload = serde_json::from_value(input.clone()).ok()?;
34    (payload.hook_event_name == "FileChanged").then_some(payload)
35}
36
37impl FileChangedPayload {
38    /// Whether this event should drive a reparse.
39    ///
40    /// `add` and `change` only. `unlink` is deliberately ignored: it would set
41    /// the `FileDeleted` staleness signal and pin the record at the `Tombstone`
42    /// tier, and `apply_reparse_staleness` only saturates upward — so a save
43    /// that lands as delete-then-create would suppress that file's context
44    /// injection for good. Real deletions are still caught by `mati init` and
45    /// `mati repair`, and the read gate stats the file live before honouring
46    /// the signal (`hooks::decide::evaluate`).
47    pub fn drives_reparse(&self) -> bool {
48        matches!(self.event.as_str(), "add" | "change")
49    }
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55    use serde_json::json;
56
57    fn payload() -> serde_json::Value {
58        json!({
59            "session_id": "session-123",
60            "transcript_path": "/tmp/transcript.jsonl",
61            "cwd": "/private/tmp/repo",
62            "prompt_id": "prompt-456",
63            "hook_event_name": "FileChanged",
64            "file_path": "/tmp/repo/src/main.rs",
65            "event": "change"
66        })
67    }
68
69    #[test]
70    fn parses_captured_platform_shape() {
71        let parsed = parse_file_changed(&payload()).expect("payload should parse");
72        assert_eq!(parsed.file_path, "/tmp/repo/src/main.rs");
73        assert_eq!(parsed.event, "change");
74        assert_eq!(parsed.prompt_id.as_deref(), Some("prompt-456"));
75    }
76
77    /// The watcher fires outside a prompt turn too, and the field is optional
78    /// in the platform schema.
79    #[test]
80    fn parses_without_prompt_id() {
81        let mut value = payload();
82        value.as_object_mut().unwrap().remove("prompt_id");
83        assert!(parse_file_changed(&value).is_some());
84    }
85
86    #[test]
87    fn rejects_other_hook_events() {
88        let mut value = payload();
89        value["hook_event_name"] = json!("SessionStart");
90        assert!(parse_file_changed(&value).is_none());
91    }
92
93    #[test]
94    fn rejects_missing_or_wrongly_typed_fields() {
95        let mut missing = payload();
96        missing.as_object_mut().unwrap().remove("file_path");
97        assert!(parse_file_changed(&missing).is_none());
98
99        let mut wrong_type = payload();
100        wrong_type["event"] = json!(3);
101        assert!(parse_file_changed(&wrong_type).is_none());
102    }
103
104    /// A Claude Code release that adds a field must not stop the freshness path.
105    #[test]
106    fn accepts_unknown_fields() {
107        let mut value = payload();
108        value["future_platform_field"] = json!(true);
109        assert!(parse_file_changed(&value).is_some());
110    }
111
112    #[test]
113    fn only_add_and_change_drive_a_reparse() {
114        for (event, expected) in [
115            ("change", true),
116            ("add", true),
117            ("unlink", false),
118            ("addDir", false),
119        ] {
120            let mut value = payload();
121            value["event"] = json!(event);
122            let parsed = parse_file_changed(&value).expect("payload should parse");
123            assert_eq!(parsed.drives_reparse(), expected, "event {event}");
124        }
125    }
126}