400 lines
12 KiB
Rust
400 lines
12 KiB
Rust
//! 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<Value>,
|
|
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<Value> },
|
|
ReplaceItems { items: Vec<Value> },
|
|
RemoveItems { indices: Vec<usize> },
|
|
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<HookResponse> {
|
|
match serde_json::from_str::<HookResponse>(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::<HookResponse>(json);
|
|
assert!(result.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn response_invalid_json() {
|
|
let result = serde_json::from_str::<HookResponse>("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::<HookResponse>(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"]));
|
|
}
|
|
}
|