Skip to main content

kaizen/collect/hooks/
cursor.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2//! Parse Cursor hook JSON from stdin.
3//!
4//! Cursor sends a JSON object with `event`, `session_id`, `timestamp_ms`.
5//! Fields may vary by hook type; unknown fields are stored in `payload`.
6
7use super::{EventKind, HookEvent};
8use anyhow::{Context, Result, bail};
9use serde_json::Value;
10
11/// Parse a Cursor hook payload (one JSON object, UTF-8 string).
12///
13/// # Errors
14/// Returns `Err` if input is not valid JSON or missing required fields.
15pub fn parse_cursor_hook(input: &str) -> Result<HookEvent> {
16    let v: Value = serde_json::from_str(input.trim()).context("cursor hook: invalid JSON")?;
17    let obj = v.as_object().context("cursor hook: expected JSON object")?;
18
19    let kind_str = obj
20        .get("event")
21        .and_then(|v| v.as_str())
22        .context("cursor hook: missing 'event' field")?;
23
24    let session_id = obj
25        .get("session_id")
26        .and_then(|v| v.as_str())
27        .unwrap_or("")
28        .to_string();
29
30    let ts_ms = obj
31        .get("timestamp_ms")
32        .and_then(|v| v.as_u64())
33        .unwrap_or(0);
34
35    if session_id.is_empty() {
36        bail!("cursor hook: missing 'session_id' field");
37    }
38
39    Ok(HookEvent {
40        kind: EventKind::parse(kind_str),
41        session_id,
42        ts_ms,
43        payload: Value::Object(obj.clone()),
44    })
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50
51    #[test]
52    fn parse_stop_fixture() {
53        let json = include_str!("../../../tests/fixtures/hooks/cursor_stop.json");
54        let ev = parse_cursor_hook(json).unwrap();
55        assert_eq!(ev.kind, EventKind::Stop);
56        assert!(!ev.session_id.is_empty());
57    }
58
59    #[test]
60    fn missing_event_field_errors() {
61        let err = parse_cursor_hook(r#"{"session_id":"s1","timestamp_ms":0}"#);
62        assert!(err.is_err());
63    }
64}