1use chrono::{DateTime, Utc};
9use serde::{Deserialize, Serialize};
10use serde_json::Value;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct NormalizedHookEvent {
15 pub agent: String,
16 pub event_name: String,
17 pub observed_at: DateTime<Utc>,
18 pub session_id: Option<String>,
19 pub turn_id: Option<String>,
20 pub cwd: Option<String>,
21 pub tool_name: Option<String>,
22 pub tool_input: Option<Value>,
23 pub tool_response_text: Option<String>,
24 pub assistant_message_text: Option<String>,
25 pub user_message_text: Option<String>,
26 pub raw_payload: Value,
27}
28
29const CLAUDE_AGENT_TYPES: &[&str] = &["claude-code", "claude"];
31
32pub fn flatten_text_value(value: &Value) -> Option<String> {
37 match value {
38 Value::Null => None,
39 Value::String(s) if s.is_empty() => None,
40 Value::String(s) => Some(s.clone()),
41 Value::Array(items) => {
42 let flattened: Vec<String> = items.iter().filter_map(flatten_text_value).collect();
43 if flattened.is_empty() {
44 None
45 } else {
46 Some(flattened.join("\n"))
47 }
48 }
49 Value::Object(map) => {
50 if let Some(text) = map.get("text").and_then(|v| v.as_str()) {
51 if text.is_empty() {
52 None
53 } else {
54 Some(text.to_string())
55 }
56 } else {
57 Some(value.to_string())
58 }
59 }
60 _ => Some(value.to_string()),
61 }
62}
63
64pub fn normalize_generic_payload(agent: &str, event: &str, raw: &Value) -> NormalizedHookEvent {
75 let obj = raw.as_object().cloned().unwrap_or_default();
76
77 let tool_name = obj
79 .get("tool_name")
80 .or_else(|| obj.get("toolName"))
81 .or_else(|| obj.get("name"))
82 .or_else(|| obj.get("functionCall").and_then(|fc| fc.get("name")))
83 .or_else(|| obj.get("function").and_then(|f| f.get("name")))
84 .cloned();
85
86 let tool_input = obj
88 .get("tool_input")
89 .or_else(|| obj.get("toolInput"))
90 .or_else(|| obj.get("input"))
91 .or_else(|| obj.get("arguments"))
92 .or_else(|| obj.get("functionCall").and_then(|fc| fc.get("args")))
93 .or_else(|| obj.get("function").and_then(|f| f.get("arguments")))
94 .cloned();
95
96 let tool_response_text = obj
98 .get("tool_response_text")
99 .or_else(|| obj.get("toolResponseText"))
100 .or_else(|| obj.get("output"))
101 .or_else(|| obj.get("result"))
102 .or_else(|| {
103 obj.get("functionResponse")
104 .and_then(|fr| fr.get("response"))
105 })
106 .and_then(flatten_text_value);
107
108 let assistant_message_text = obj
110 .get("assistant_message_text")
111 .or_else(|| obj.get("assistantMessageText"))
112 .or_else(|| obj.get("assistant_message"))
113 .or_else(|| obj.get("response"))
114 .or_else(|| {
115 obj.get("choices")
116 .and_then(|c| c.get(0))
117 .and_then(|c| c.get("message"))
118 .and_then(|m| m.get("content"))
119 })
120 .and_then(flatten_text_value);
121
122 let user_message_text = obj
123 .get("user_message_text")
124 .or_else(|| obj.get("userMessageText"))
125 .or_else(|| obj.get("user_message"))
126 .or_else(|| obj.get("prompt"))
127 .and_then(flatten_text_value);
128
129 let session_id = obj
131 .get("session_id")
132 .or_else(|| obj.get("sessionId"))
133 .or_else(|| obj.get("sessionKey"))
134 .or_else(|| obj.get("thread_id"))
135 .and_then(|v| v.as_str().map(String::from));
136
137 let turn_id = obj
138 .get("turn_id")
139 .or_else(|| obj.get("turnId"))
140 .and_then(|v| v.as_str().map(String::from));
141
142 let cwd = obj
144 .get("cwd")
145 .or_else(|| obj.get("working_directory"))
146 .or_else(|| obj.get("workingDirectory"))
147 .or_else(|| obj.get("workingDir"))
148 .and_then(|v| v.as_str().map(String::from));
149
150 NormalizedHookEvent {
151 agent: agent.to_string(),
152 event_name: event.to_string(),
153 observed_at: chrono::Utc::now(),
154 session_id,
155 turn_id,
156 cwd,
157 tool_name: tool_name.and_then(|v| v.as_str().map(String::from)),
158 tool_input,
159 tool_response_text,
160 assistant_message_text,
161 user_message_text,
162 raw_payload: raw.clone(),
163 }
164}
165
166pub fn normalize_payload(agent: &str, event: &str, raw: &Value) -> NormalizedHookEvent {
171 let agent_lower = agent.to_lowercase();
172 if CLAUDE_AGENT_TYPES.contains(&agent_lower.as_str()) {
173 normalize_claude_payload(agent, event, raw)
174 } else {
175 normalize_generic_payload(agent, event, raw)
176 }
177}
178
179pub fn normalize_claude_payload(agent: &str, event_name: &str, raw: &Value) -> NormalizedHookEvent {
184 let extracted_event_name = get_string(
186 raw,
187 &[
188 "hook_event_name",
189 "hookEventName",
190 "event_name",
191 "eventName",
192 ],
193 )
194 .unwrap_or_else(|| event_name.to_string());
195
196 let tool_name = get_string(raw, &["tool_name", "toolName", "name"]);
198
199 let tool_input = raw
201 .get("tool_input")
202 .or_else(|| raw.get("toolInput"))
203 .or_else(|| raw.get("input"))
204 .cloned();
205
206 let session_id = get_string(
208 raw,
209 &[
210 "session_id",
211 "sessionId",
212 "thread_id",
213 "threadId",
214 "conversation_id",
215 "conversationId",
216 ],
217 );
218
219 let turn_id = get_string(raw, &["turn_id", "turnId", "message_id", "messageId"]);
221
222 let tool_response_text = raw
224 .get("tool_response")
225 .or_else(|| raw.get("toolResponse"))
226 .and_then(|v| {
227 if v.is_string() {
228 v.as_str().map(|s| s.to_string())
229 } else {
230 Some(v.to_string())
231 }
232 });
233
234 let assistant_message_text = raw
236 .get("message")
237 .and_then(|m| m.get("content"))
238 .or_else(|| raw.get("content"))
239 .or_else(|| raw.get("assistant_message"))
240 .or_else(|| raw.get("assistantMessage"))
241 .and_then(|c| flatten_message_content(Some(c)));
242
243 let user_message_text =
245 get_string(raw, &["user_message", "userMessage"]).filter(|s| s.len() > 20);
246
247 let cwd = get_string(
249 raw,
250 &[
251 "cwd",
252 "directory",
253 "workspace",
254 "working_directory",
255 "workingDirectory",
256 ],
257 );
258
259 NormalizedHookEvent {
260 agent: agent.to_string(),
261 event_name: extracted_event_name,
262 observed_at: Utc::now(),
263 session_id,
264 turn_id,
265 cwd,
266 tool_name,
267 tool_input,
268 tool_response_text,
269 assistant_message_text,
270 user_message_text,
271 raw_payload: raw.clone(),
272 }
273}
274
275pub fn get_string(value: &Value, keys: &[&str]) -> Option<String> {
280 for key in keys {
281 if let Some(v) = value.get(key) {
282 if let Some(s) = v.as_str() {
283 if !s.is_empty() {
284 return Some(s.to_string());
285 }
286 }
287 }
288 }
289 None
290}
291
292pub fn flatten_message_content(value: Option<&Value>) -> Option<String> {
299 match value {
300 Some(Value::String(s)) => {
301 let trimmed = s.trim();
302 if !trimmed.is_empty() {
303 Some(trimmed.to_string())
304 } else {
305 None
306 }
307 }
308 Some(Value::Array(arr)) => {
309 let text_blocks: Vec<&str> = arr
310 .iter()
311 .filter_map(|block| {
312 if let Some(obj) = block.as_object() {
313 if obj.get("type").and_then(|t| t.as_str()) == Some("text") {
314 return obj.get("text").and_then(|t| t.as_str());
315 }
316 }
317 None
318 })
319 .map(|s| s.trim())
320 .filter(|s| !s.is_empty())
321 .collect();
322
323 let joined = text_blocks.join("\n\n");
324 if !joined.is_empty() {
325 Some(joined)
326 } else {
327 None
328 }
329 }
330 _ => None,
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use serde_json::json;
338
339 #[test]
340 fn test_normalize_claude_payload_with_bash_tool() {
341 let raw = json!({
342 "hook_event_name": "post-tool-use",
343 "tool_name": "Bash",
344 "tool_input": {"command": "cargo test"},
345 "tool_response": "running 12 tests... test result: ok",
346 "session_id": "sess-123",
347 "cwd": "/project"
348 });
349
350 let normalized = normalize_claude_payload("claude-code", "post-tool-use", &raw);
351
352 assert_eq!(normalized.agent, "claude-code");
353 assert_eq!(normalized.event_name, "post-tool-use");
354 assert_eq!(normalized.tool_name, Some("Bash".to_string()));
355 assert_eq!(normalized.session_id, Some("sess-123".to_string()));
356 assert_eq!(normalized.cwd, Some("/project".to_string()));
357 assert!(normalized.tool_response_text.is_some());
358 }
359
360 #[test]
361 fn test_normalize_claude_payload_camelcase_fallback() {
362 let raw = json!({
363 "hookEventName": "postToolUse",
364 "toolName": "Read",
365 "toolInput": {"file_path": "src/main.rs"},
366 "sessionId": "sess-456"
367 });
368
369 let normalized = normalize_claude_payload("claude-code", "post-tool-use", &raw);
370
371 assert_eq!(normalized.event_name, "postToolUse");
372 assert_eq!(normalized.tool_name, Some("Read".to_string()));
373 assert_eq!(normalized.session_id, Some("sess-456".to_string()));
374 }
375
376 #[test]
377 fn test_flatten_message_content_string() {
378 let content = Value::String(" Hello world ".to_string());
379 let result = flatten_message_content(Some(&content));
380 assert_eq!(result, Some("Hello world".to_string()));
381 }
382
383 #[test]
384 fn test_flatten_message_content_empty_string() {
385 let content = Value::String(" ".to_string());
386 let result = flatten_message_content(Some(&content));
387 assert_eq!(result, None);
388 }
389
390 #[test]
391 fn test_flatten_message_content_array() {
392 let content = json!( [
393 {"type": "text", "text": "First paragraph"},
394 {"type": "image", "source": "..."},
395 {"type": "text", "text": "Second paragraph"}
396 ]);
397
398 let result = flatten_message_content(Some(&content));
399 assert_eq!(
400 result,
401 Some("First paragraph\n\nSecond paragraph".to_string())
402 );
403 }
404
405 #[test]
406 fn test_flatten_message_content_non_text_array() {
407 let content = json!( [
408 {"type": "image", "source": "..."},
409 {"type": "tool_use", "id": "..."}
410 ]);
411
412 let result = flatten_message_content(Some(&content));
413 assert_eq!(result, None);
414 }
415
416 #[test]
417 fn test_get_string_multiple_keys() {
418 let value = json!({
419 "first": "",
420 "second": "found",
421 "third": "unused"
422 });
423
424 let result = get_string(&value, &["first", "second", "third"]);
425 assert_eq!(result, Some("found".to_string()));
426 }
427
428 #[test]
429 fn test_get_string_no_match() {
430 let value = json!({"other": "value"});
431 let result = get_string(&value, &["missing", "also_missing"]);
432 assert_eq!(result, None);
433 }
434
435 #[test]
436 fn test_normalize_minimal_payload() {
437 let raw = json!({});
438
439 let normalized = normalize_claude_payload("claude-code", "test-event", &raw);
440
441 assert_eq!(normalized.agent, "claude-code");
442 assert_eq!(normalized.event_name, "test-event");
443 assert_eq!(normalized.tool_name, None);
444 assert_eq!(normalized.session_id, None);
445 }
446
447 #[test]
448 fn test_normalize_with_message_content() {
449 let raw = json!({
450 "message": {
451 "content": [
452 {"type": "text", "text": "Decision made"}
453 ]
454 }
455 });
456
457 let normalized = normalize_claude_payload("claude-code", "assistant-message", &raw);
458
459 assert_eq!(
460 normalized.assistant_message_text,
461 Some("Decision made".to_string())
462 );
463 }
464
465 #[test]
466 fn test_normalize_with_user_message() {
467 let raw = json!({
468 "user_message": "Please implement the feature with proper error handling"
469 });
470
471 let normalized = normalize_claude_payload("claude-code", "user-prompt-submit", &raw);
472
473 assert_eq!(
474 normalized.user_message_text,
475 Some("Please implement the feature with proper error handling".to_string())
476 );
477 }
478
479 #[test]
480 fn test_normalize_user_message_too_short() {
481 let raw = json!({
482 "userMessage": "short"
483 });
484
485 let normalized = normalize_claude_payload("claude-code", "test", &raw);
486
487 assert_eq!(normalized.user_message_text, None);
488 }
489
490 #[test]
493 fn test_normalize_generic_gemini_payload() {
494 let raw = json!({
495 "toolName": "Bash",
496 "toolInput": {"command": "npm test"},
497 "output": "12 tests passed",
498 "sessionId": "gem-sess-1",
499 "workingDirectory": "/home/user/project"
500 });
501
502 let normalized = normalize_generic_payload("gemini", "post-tool-use", &raw);
503
504 assert_eq!(normalized.agent, "gemini");
505 assert_eq!(normalized.event_name, "post-tool-use");
506 assert_eq!(normalized.tool_name, Some("Bash".to_string()));
507 assert_eq!(normalized.session_id, Some("gem-sess-1".to_string()));
508 assert_eq!(normalized.cwd, Some("/home/user/project".to_string()));
509 assert_eq!(
510 normalized.tool_response_text,
511 Some("12 tests passed".to_string())
512 );
513 }
514
515 #[test]
516 fn test_normalize_generic_qwen_payload() {
517 let raw = json!({
518 "name": "Read",
519 "input": {"file_path": "src/main.rs"},
520 "sessionKey": "qw-sess-1",
521 "cwd": "/project"
522 });
523
524 let normalized = normalize_generic_payload("qwen", "tool-use", &raw);
525
526 assert_eq!(normalized.agent, "qwen");
527 assert_eq!(normalized.tool_name, Some("Read".to_string()));
528 assert_eq!(normalized.session_id, Some("qw-sess-1".to_string()));
529 assert_eq!(normalized.cwd, Some("/project".to_string()));
530 }
531
532 #[test]
533 fn test_normalize_generic_minimal_payload() {
534 let raw = json!({});
535
536 let normalized = normalize_generic_payload("codex", "event", &raw);
537
538 assert_eq!(normalized.agent, "codex");
539 assert_eq!(normalized.event_name, "event");
540 assert_eq!(normalized.tool_name, None);
541 assert_eq!(normalized.session_id, None);
542 assert_eq!(normalized.cwd, None);
543 }
544
545 #[test]
546 fn test_normalize_generic_camelcase_fields() {
547 let raw = json!({
548 "toolName": "Write",
549 "toolInput": {"path": "foo.rs"},
550 "toolResponseText": "Written 42 bytes",
551 "assistantMessageText": "File created successfully",
552 "userMessageText": "Create a new file",
553 "turnId": "turn-1",
554 "workingDirectory": "/workspace"
555 });
556
557 let normalized = normalize_generic_payload("amp", "post-tool-use", &raw);
558
559 assert_eq!(normalized.tool_name, Some("Write".to_string()));
560 assert!(normalized.tool_input.is_some());
561 assert_eq!(
562 normalized.tool_response_text,
563 Some("Written 42 bytes".to_string())
564 );
565 assert_eq!(
566 normalized.assistant_message_text,
567 Some("File created successfully".to_string())
568 );
569 assert_eq!(
570 normalized.user_message_text,
571 Some("Create a new file".to_string())
572 );
573 assert_eq!(normalized.turn_id, Some("turn-1".to_string()));
574 assert_eq!(normalized.cwd, Some("/workspace".to_string()));
575 }
576
577 #[test]
578 fn test_flatten_text_value_null() {
579 assert_eq!(flatten_text_value(&Value::Null), None);
580 }
581
582 #[test]
583 fn test_flatten_text_value_empty_string() {
584 assert_eq!(flatten_text_value(&Value::String(String::new())), None);
585 }
586
587 #[test]
588 fn test_flatten_text_value_string() {
589 assert_eq!(
590 flatten_text_value(&Value::String("hello".to_string())),
591 Some("hello".to_string())
592 );
593 }
594
595 #[test]
596 fn test_flatten_text_value_array() {
597 let arr = json!(["line1", "line2", "line3"]);
598 assert_eq!(
599 flatten_text_value(&arr),
600 Some("line1\nline2\nline3".to_string())
601 );
602 }
603
604 #[test]
605 fn test_flatten_text_value_object_with_text() {
606 let obj = json!({"text": "content"});
607 assert_eq!(flatten_text_value(&obj), Some("content".to_string()));
608 }
609
610 #[test]
613 fn test_normalize_payload_dispatches_claude() {
614 let raw = json!({
615 "tool_name": "Bash",
616 "session_id": "sess-1"
617 });
618
619 let normalized = normalize_payload("claude-code", "event", &raw);
620 assert_eq!(normalized.agent, "claude-code");
621 assert_eq!(normalized.session_id, Some("sess-1".to_string()));
623 }
624
625 #[test]
626 fn test_normalize_payload_dispatches_generic() {
627 let raw = json!({
628 "toolName": "Read",
629 "sessionId": "sess-2"
630 });
631
632 let normalized = normalize_payload("gemini", "event", &raw);
633 assert_eq!(normalized.agent, "gemini");
634 assert_eq!(normalized.session_id, Some("sess-2".to_string()));
636 }
637
638 #[test]
639 fn test_normalize_payload_dispatches_by_alias() {
640 let raw = json!({
641 "tool_name": "Bash",
642 });
643
644 let normalized = normalize_payload("claude", "event", &raw);
645 assert_eq!(normalized.agent, "claude");
646 assert_eq!(normalized.tool_name, Some("Bash".to_string()));
647 }
648
649 #[test]
652 fn test_normalize_codex_payload() {
653 let raw = json!({
654 "toolName": "Bash",
655 "toolInput": {"command": "go test ./..."},
656 "toolResponseText": "PASS",
657 "sessionId": "cx-1",
658 "turnId": "t-1",
659 "workingDirectory": "/project"
660 });
661
662 let normalized = normalize_payload("codex", "post-tool-use", &raw);
663 assert_eq!(normalized.agent, "codex");
664 assert_eq!(normalized.tool_name, Some("Bash".to_string()));
665 assert_eq!(normalized.session_id, Some("cx-1".to_string()));
666 assert_eq!(normalized.turn_id, Some("t-1".to_string()));
667 assert_eq!(normalized.cwd, Some("/project".to_string()));
668 }
669
670 #[test]
671 fn test_normalize_amp_payload() {
672 let raw = json!({
673 "tool_name": "Edit",
674 "tool_input": {"file": "src/lib.rs"},
675 "tool_response_text": "Updated 3 lines",
676 "assistant_message_text": "Fixed the off-by-one error",
677 "session_id": "amp-sess",
678 "cwd": "/workspace"
679 });
680
681 let normalized = normalize_payload("amp", "post-tool-use", &raw);
682 assert_eq!(normalized.agent, "amp");
683 assert_eq!(normalized.tool_name, Some("Edit".to_string()));
684 assert_eq!(normalized.session_id, Some("amp-sess".to_string()));
685 assert_eq!(normalized.cwd, Some("/workspace".to_string()));
686 assert_eq!(
687 normalized.assistant_message_text,
688 Some("Fixed the off-by-one error".to_string())
689 );
690 }
691
692 #[test]
693 fn test_normalize_droid_payload_minimal() {
694 let raw = json!({
695 "name": "Read",
696 "input": {"file_path": "README.md"}
697 });
698
699 let normalized = normalize_payload("droid", "tool-use", &raw);
700 assert_eq!(normalized.agent, "droid");
701 assert_eq!(normalized.tool_name, Some("Read".to_string()));
702 assert_eq!(normalized.session_id, None);
703 assert_eq!(normalized.cwd, None);
704 }
705
706 #[test]
707 fn test_all_agents_produce_stable_schema() {
708 let agents = [
709 "claude-code",
710 "claude",
711 "gemini",
712 "qwen",
713 "codex",
714 "amp",
715 "droid",
716 "opencode",
717 ];
718 for agent in agents {
719 let raw = json!({
720 "tool_name": "Test",
721 "session_id": "s-1",
722 });
723 let normalized = normalize_payload(agent, "test-event", &raw);
724 assert_eq!(normalized.agent, agent, "agent name mismatch for {}", agent);
725 assert_eq!(
726 normalized.event_name, "test-event",
727 "event_name mismatch for {}",
728 agent
729 );
730 assert!(
732 !normalized.raw_payload.is_null(),
733 "raw_payload should not be null for {}",
734 agent
735 );
736 }
737 }
738
739 #[test]
742 fn test_normalize_gemini_function_call() {
743 let raw = json!({
744 "functionCall": {
745 "name": "run_command",
746 "args": {"command": "npm test"}
747 },
748 "sessionId": "gem-fc-1",
749 "workingDir": "/home/user/project"
750 });
751
752 let normalized = normalize_generic_payload("gemini", "function-call", &raw);
753
754 assert_eq!(normalized.agent, "gemini");
755 assert_eq!(normalized.tool_name, Some("run_command".to_string()));
756 assert!(normalized.tool_input.is_some());
757 assert_eq!(normalized.session_id, Some("gem-fc-1".to_string()));
758 assert_eq!(normalized.cwd, Some("/home/user/project".to_string()));
759 }
760
761 #[test]
762 fn test_normalize_gemini_function_response() {
763 let raw = json!({
764 "functionResponse": {
765 "response": "All 24 tests passed successfully"
766 },
767 "sessionId": "gem-fr-1"
768 });
769
770 let normalized = normalize_generic_payload("gemini", "function-response", &raw);
771
772 assert_eq!(
773 normalized.tool_response_text,
774 Some("All 24 tests passed successfully".to_string())
775 );
776 assert_eq!(normalized.session_id, Some("gem-fr-1".to_string()));
777 }
778
779 #[test]
780 fn test_normalize_codex_openai_function_format() {
781 let raw = json!({
782 "function": {
783 "name": "shell",
784 "arguments": "{\"command\": \"go build\"}"
785 },
786 "sessionId": "cx-fn-1",
787 "turnId": "cx-turn-1",
788 "workingDirectory": "/repo"
789 });
790
791 let normalized = normalize_generic_payload("codex", "tool-call", &raw);
792
793 assert_eq!(normalized.tool_name, Some("shell".to_string()));
794 assert!(normalized.tool_input.is_some());
795 assert_eq!(normalized.session_id, Some("cx-fn-1".to_string()));
796 assert_eq!(normalized.turn_id, Some("cx-turn-1".to_string()));
797 assert_eq!(normalized.cwd, Some("/repo".to_string()));
798 }
799
800 #[test]
801 fn test_normalize_codex_choices_message_content() {
802 let raw = json!({
803 "choices": [
804 {
805 "message": {
806 "content": "I've implemented the feature"
807 }
808 }
809 ]
810 });
811
812 let normalized = normalize_generic_payload("codex", "response", &raw);
813
814 assert_eq!(
815 normalized.assistant_message_text,
816 Some("I've implemented the feature".to_string())
817 );
818 }
819
820 #[test]
821 fn test_normalize_amp_function_format() {
822 let raw = json!({
823 "function": {
824 "name": "edit_file",
825 "arguments": "{\"path\": \"src/main.rs\", \"content\": \"...\"}"
826 },
827 "sessionKey": "amp-fn-1"
828 });
829
830 let normalized = normalize_generic_payload("amp", "function-call", &raw);
831
832 assert_eq!(normalized.tool_name, Some("edit_file".to_string()));
833 assert!(normalized.tool_input.is_some());
834 assert_eq!(normalized.session_id, Some("amp-fn-1".to_string()));
835 }
836}