use std::io::Write;
use serde_json::{json, Value};
use tracing::debug;
use crate::mcp::protocol::JsonRpcNotification;
#[cfg(feature = "watch")]
use crate::watch::WatchEvent;
#[cfg(feature = "watch")]
#[must_use]
pub fn event_to_channel_notification(event: &WatchEvent) -> Option<Value> {
match event {
WatchEvent::Speech {
text,
confidence,
timestamp,
} => Some(build_notification(
format!(
"[speech detected] \"{}\" (confidence: {:.0}%)",
text,
confidence * 100.0
),
"speech",
timestamp,
)),
WatchEvent::Gesture {
gesture,
confidence,
hand,
timestamp,
} => Some(build_notification(
format!(
"[gesture detected] {} by {} hand (confidence: {:.0}%)",
gesture,
hand,
confidence * 100.0
),
"gesture",
timestamp,
)),
WatchEvent::Error { source, message } => {
debug!(source = %source, error = %message, "watcher error — not forwarded to channel");
None
}
}
}
fn build_notification(content: String, event_type: &str, timestamp: &str) -> Value {
json!({
"method": "notifications/claude/channel",
"params": {
"content": content,
"meta": {
"source": "axterminator",
"event": event_type,
"severity": "info",
"timestamp": timestamp
}
}
})
}
pub fn emit_channel_notification(out: &mut impl Write, params: Value) -> std::io::Result<()> {
let notif = JsonRpcNotification {
jsonrpc: "2.0",
method: "notifications/claude/channel",
params,
};
let json = serde_json::to_string(¬if).expect("notification serialization cannot fail");
debug!(bytes = json.len(), "sending channel notification");
writeln!(out, "{json}")?;
out.flush()
}
#[cfg(all(test, feature = "watch"))]
mod tests {
use super::*;
use crate::watch::WatchEvent;
#[test]
fn speech_event_produces_notification() {
let event = WatchEvent::Speech {
text: "hello world".into(),
confidence: 0.95,
timestamp: "2026-03-20T14:22:01Z".into(),
};
let notif = event_to_channel_notification(&event);
let notif = notif.expect("expected Some notification");
let content = notif["params"]["content"].as_str().unwrap();
assert!(content.contains("speech detected"), "got: {content}");
assert!(content.contains("hello world"), "got: {content}");
assert!(content.contains("95%"), "got: {content}");
}
#[test]
fn gesture_event_produces_notification() {
let event = WatchEvent::Gesture {
gesture: "thumbs_up".into(),
confidence: 0.87,
hand: "right".into(),
timestamp: "2026-03-20T14:22:01Z".into(),
};
let notif = event_to_channel_notification(&event).unwrap();
let content = notif["params"]["content"].as_str().unwrap();
assert!(content.contains("gesture detected"), "got: {content}");
assert!(content.contains("thumbs_up"), "got: {content}");
assert!(content.contains("right"), "got: {content}");
assert!(content.contains("87%"), "got: {content}");
}
#[test]
fn error_event_returns_none() {
let event = WatchEvent::Error {
source: "audio_watcher".into(),
message: "mic unavailable".into(),
};
let notif = event_to_channel_notification(&event);
assert!(notif.is_none());
}
#[test]
fn notification_meta_has_required_fields() {
let event = WatchEvent::Speech {
text: "test".into(),
confidence: 1.0,
timestamp: "2026-03-20T00:00:00Z".into(),
};
let notif = event_to_channel_notification(&event).unwrap();
let meta = ¬if["params"]["meta"];
assert_eq!(meta["source"], "axterminator");
assert_eq!(meta["severity"], "info");
assert_eq!(meta["timestamp"], "2026-03-20T00:00:00Z");
assert_eq!(meta["event"], "speech");
}
#[test]
fn emit_channel_notification_writes_valid_json_line() {
let params = json!({
"content": "test notification",
"meta": { "source": "test" }
});
let mut buf = Vec::<u8>::new();
emit_channel_notification(&mut buf, params).unwrap();
let output = String::from_utf8(buf).unwrap();
assert!(output.ends_with('\n'));
let v: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
assert_eq!(v["method"], "notifications/claude/channel");
}
#[test]
fn confidence_zero_percent_formats_correctly() {
let event = WatchEvent::Speech {
text: "inaudible".into(),
confidence: 0.0,
timestamp: "2026-03-20T00:00:00Z".into(),
};
let notif = event_to_channel_notification(&event).unwrap();
let content = notif["params"]["content"].as_str().unwrap();
assert!(content.contains("0%"), "got: {content}");
}
#[test]
fn confidence_hundred_percent_formats_correctly() {
let event = WatchEvent::Gesture {
gesture: "stop".into(),
confidence: 1.0,
hand: "left".into(),
timestamp: "2026-03-20T00:00:00Z".into(),
};
let notif = event_to_channel_notification(&event).unwrap();
let content = notif["params"]["content"].as_str().unwrap();
assert!(content.contains("100%"), "got: {content}");
}
}