//! Hook types for lifecycle events. The core library defines //! the event and response types plus the handler trait. //! Concrete implementations (shell exec hooks, persistent //! handler processes) live in frontend crates. use serde::{Deserialize, Serialize}; use serde_json::Value; use crate::error::PiklError; use crate::event::Modifiers; /// A lifecycle event emitted by the menu engine. Handler /// hooks receive these as JSON lines on stdin. The `event` /// field is the tag for serde's tagged representation. #[derive(Debug, Clone, Serialize)] #[serde(tag = "event", rename_all = "snake_case")] pub enum HookEvent { Open, Close, Hover { item: Value, index: usize, #[serde(skip_serializing_if = "Modifiers::is_empty")] modifiers: Modifiers, }, Select { item: Value, index: usize, #[serde(skip_serializing_if = "Modifiers::is_empty")] modifiers: Modifiers, }, Cancel { #[serde(skip_serializing_if = "Modifiers::is_empty")] modifiers: Modifiers, }, Filter { text: String, #[serde(skip_serializing_if = "Modifiers::is_empty")] modifiers: Modifiers, }, Quicklist { items: Vec, count: usize, #[serde(skip_serializing_if = "Modifiers::is_empty")] modifiers: Modifiers, }, } /// Discriminant for [`HookEvent`], used as a key for /// debounce config and handler routing. #[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)] pub enum HookEventKind { Open, Close, Hover, Select, Cancel, Filter, Quicklist, } impl HookEvent { /// Get the discriminant kind for this event. pub fn kind(&self) -> HookEventKind { match self { HookEvent::Open => HookEventKind::Open, HookEvent::Close => HookEventKind::Close, HookEvent::Hover { .. } => HookEventKind::Hover, HookEvent::Select { .. } => HookEventKind::Select, HookEvent::Cancel { .. } => HookEventKind::Cancel, HookEvent::Filter { .. } => HookEventKind::Filter, HookEvent::Quicklist { .. } => HookEventKind::Quicklist, } } } /// A command from a handler hook back to the menu engine. /// Handler hooks emit these as JSON lines on stdout. The /// `action` field is the tag. #[derive(Debug, Clone, Deserialize, PartialEq)] #[serde(tag = "action", rename_all = "snake_case")] pub enum HookResponse { AddItems { items: Vec }, ReplaceItems { items: Vec }, RemoveItems { indices: Vec }, SetFilter { text: String }, Close, } /// Handler trait for lifecycle hooks. Implementations /// receive events and may produce side effects (spawning /// processes, sending to channels). Responses flow through /// the action channel, not the return value. /// /// This is deliberately synchronous for dyn-compatibility. /// Implementations that need async work (spawning processes, /// writing to channels) should use `tokio::spawn` internally. pub trait HookHandler: Send + Sync { fn handle(&self, event: HookEvent) -> Result<(), PiklError>; } /// Parse a single line of JSON as a [`HookResponse`]. /// Returns None on parse failure, logging a warning via /// tracing. pub fn parse_hook_response(line: &str) -> Option { match serde_json::from_str::(line) { Ok(resp) => { tracing::debug!(response = ?resp, "parsed hook response"); Some(resp) } Err(e) => { tracing::warn!(line, error = %e, "failed to parse hook response"); None } } } #[cfg(test)] mod tests { use super::*; use serde_json::json; // -- HookEvent serialization -- #[test] fn event_open_serializes() { let json = serde_json::to_value(&HookEvent::Open).unwrap_or_default(); assert_eq!(json["event"], "open"); } #[test] fn event_close_serializes() { let json = serde_json::to_value(&HookEvent::Close).unwrap_or_default(); assert_eq!(json["event"], "close"); } #[test] fn event_hover_serializes() { let event = HookEvent::Hover { item: json!({"label": "test"}), index: 5, modifiers: Modifiers::default(), }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["event"], "hover"); assert_eq!(json["item"]["label"], "test"); assert_eq!(json["index"], 5); // Empty modifiers should be omitted assert!(json.get("modifiers").is_none()); } #[test] fn event_select_serializes() { let event = HookEvent::Select { item: json!("hello"), index: 0, modifiers: Modifiers::default(), }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["event"], "select"); assert_eq!(json["item"], "hello"); assert_eq!(json["index"], 0); } #[test] fn event_cancel_serializes() { let json = serde_json::to_value(&HookEvent::Cancel { modifiers: Modifiers::default(), }) .unwrap_or_default(); assert_eq!(json["event"], "cancel"); assert!(json.get("modifiers").is_none()); } #[test] fn event_filter_serializes() { let event = HookEvent::Filter { text: "foo".to_string(), modifiers: Modifiers::default(), }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["event"], "filter"); assert_eq!(json["text"], "foo"); } // -- HookEvent kind -- #[test] fn event_kind_matches() { assert_eq!(HookEvent::Open.kind(), HookEventKind::Open); assert_eq!(HookEvent::Close.kind(), HookEventKind::Close); assert_eq!( HookEvent::Hover { item: json!(null), index: 0, modifiers: Modifiers::default(), } .kind(), HookEventKind::Hover ); assert_eq!( HookEvent::Select { item: json!(null), index: 0, modifiers: Modifiers::default(), } .kind(), HookEventKind::Select ); assert_eq!( HookEvent::Cancel { modifiers: Modifiers::default() } .kind(), HookEventKind::Cancel ); assert_eq!( HookEvent::Filter { text: String::new(), modifiers: Modifiers::default(), } .kind(), HookEventKind::Filter ); } // -- HookResponse deserialization -- #[test] fn response_add_items() { let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#; let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}")); assert_eq!( resp, HookResponse::AddItems { items: vec![json!({"label": "new"})] } ); } #[test] fn response_replace_items() { let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#; let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}")); assert_eq!( resp, HookResponse::ReplaceItems { items: vec![json!("a"), json!("b")] } ); } #[test] fn response_remove_items() { let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#; let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}")); assert_eq!( resp, HookResponse::RemoveItems { indices: vec![0, 3, 5] } ); } #[test] fn response_set_filter() { let json = r#"{"action": "set_filter", "text": "hello"}"#; let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}")); assert_eq!( resp, HookResponse::SetFilter { text: "hello".to_string() } ); } #[test] fn response_close() { let json = r#"{"action": "close"}"#; let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}")); assert_eq!(resp, HookResponse::Close); } #[test] fn response_unknown_action() { let json = r#"{"action": "explode"}"#; let result = serde_json::from_str::(json); assert!(result.is_err()); } #[test] fn response_invalid_json() { let result = serde_json::from_str::("not json at all"); assert!(result.is_err()); } #[test] fn response_missing_required_field() { // add_items without items field let json = r#"{"action": "add_items"}"#; let result = serde_json::from_str::(json); assert!(result.is_err()); } // -- parse_hook_response -- #[test] fn parse_valid_response() { let resp = parse_hook_response(r#"{"action": "close"}"#); assert_eq!(resp, Some(HookResponse::Close)); } #[test] fn parse_invalid_returns_none() { let resp = parse_hook_response("garbage"); assert!(resp.is_none()); } #[test] fn parse_unknown_action_returns_none() { let resp = parse_hook_response(r#"{"action": "nope"}"#); assert!(resp.is_none()); } #[test] fn event_quicklist_serializes() { let event = HookEvent::Quicklist { items: vec![json!("alpha"), json!("beta")], count: 2, modifiers: Modifiers::default(), }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["event"], "quicklist"); assert_eq!(json["count"], 2); assert_eq!(json["items"].as_array().map(|a| a.len()), Some(2)); } #[test] fn event_kind_quicklist() { let event = HookEvent::Quicklist { items: vec![], count: 0, modifiers: Modifiers::default(), }; assert_eq!(event.kind(), HookEventKind::Quicklist); } // -- Roundtrip: HookEvent serialize -> check shape -- #[test] fn hover_event_roundtrip_shape() { let event = HookEvent::Hover { item: json!({"label": "Firefox", "url": "https://firefox.com"}), index: 2, modifiers: Modifiers::default(), }; let serialized = serde_json::to_string(&event).unwrap_or_default(); let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default(); assert_eq!(parsed["event"], "hover"); assert_eq!(parsed["item"]["label"], "Firefox"); assert_eq!(parsed["index"], 2); } // -- Modifier serialization in hook events -- #[test] fn event_select_with_modifiers() { let event = HookEvent::Select { item: json!("test"), index: 0, modifiers: Modifiers { shift: true, ctrl: false, alt: false, }, }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["modifiers"], json!(["shift"])); } #[test] fn event_cancel_with_modifiers() { let event = HookEvent::Cancel { modifiers: Modifiers { shift: true, ctrl: true, alt: false, }, }; let json = serde_json::to_value(&event).unwrap_or_default(); assert_eq!(json["event"], "cancel"); assert_eq!(json["modifiers"], json!(["shift", "ctrl"])); } }