1use super::ParsedHookInput;
7use std::path::PathBuf;
8
9pub struct ParsedHookInputBuilder<'a> {
40 json: &'a serde_json::Value,
41 result: ParsedHookInput,
42}
43
44impl<'a> ParsedHookInputBuilder<'a> {
45 pub fn new(json: &'a serde_json::Value) -> Self {
47 Self {
48 json,
49 result: ParsedHookInput::default(),
50 }
51 }
52
53 fn get_str(&self, path: &str) -> Option<String> {
57 get_json_path(self.json, path).and_then(|v| v.as_str().map(String::from))
58 }
59
60 fn get_i64(&self, path: &str) -> Option<i64> {
62 get_json_path(self.json, path).and_then(|v| v.as_i64())
63 }
64
65 fn get_f64(&self, path: &str) -> Option<f64> {
67 get_json_path(self.json, path).and_then(|v| v.as_f64())
68 }
69
70 pub fn session_id(mut self, path: &str) -> Self {
72 self.result.session_id = self.get_str(path);
73 self
74 }
75
76 pub fn session_id_or(mut self, paths: &[&str]) -> Self {
78 for path in paths {
79 if let Some(val) = self.get_str(path) {
80 self.result.session_id = Some(val);
81 return self;
82 }
83 }
84 self
85 }
86
87 pub fn tool_use_id(mut self, path: &str) -> Self {
89 self.result.tool_use_id = self.get_str(path);
90 self
91 }
92
93 pub fn tool_use_id_or(mut self, paths: &[&str]) -> Self {
95 for path in paths {
96 if let Some(val) = self.get_str(path) {
97 self.result.tool_use_id = Some(val);
98 return self;
99 }
100 }
101 self
102 }
103
104 pub fn tool_name(mut self, path: &str) -> Self {
106 self.result.tool_name = self.get_str(path);
107 self
108 }
109
110 pub fn tool_name_or(mut self, paths: &[&str]) -> Self {
112 for path in paths {
113 if let Some(val) = self.get_str(path) {
114 self.result.tool_name = Some(val);
115 return self;
116 }
117 }
118 self
119 }
120
121 pub fn subagent_type(mut self, path: &str) -> Self {
123 self.result.subagent_type = self.get_str(path);
124 self
125 }
126
127 pub fn spawned_agent_id(mut self, path: &str) -> Self {
129 self.result.spawned_agent_id = self.get_str(path);
130 self
131 }
132
133 pub fn permission_mode(mut self, path: &str) -> Self {
135 self.result.permission_mode = self.get_str(path);
136 self
137 }
138
139 pub fn transcript_path(mut self, path: &str) -> Self {
141 self.result.transcript_path = self.get_str(path);
142 self
143 }
144
145 pub fn cwd(mut self, path: &str) -> Self {
147 self.result.cwd = self.get_str(path);
148 self
149 }
150
151 pub fn cwd_or(mut self, paths: &[&str]) -> Self {
153 for path in paths {
154 if let Some(val) = self.get_str(path) {
155 self.result.cwd = Some(val);
156 return self;
157 }
158 }
159 self
160 }
161
162 pub fn session_source(mut self, path: &str) -> Self {
164 self.result.session_source = self.get_str(path);
165 self
166 }
167
168 pub fn agent_id(mut self, path: &str) -> Self {
170 self.result.agent_id = self.get_str(path);
171 self
172 }
173
174 pub fn agent_transcript_path(mut self, path: &str) -> Self {
176 self.result.agent_transcript_path = self.get_str(path);
177 self
178 }
179
180 pub fn compact_trigger(mut self, path: &str) -> Self {
182 self.result.compact_trigger = self.get_str(path);
183 self
184 }
185
186 pub fn model(mut self, path: &str) -> Self {
188 self.result.model = self.get_str(path);
189 self
190 }
191
192 pub fn model_or(mut self, paths: &[&str]) -> Self {
194 for path in paths {
195 if let Some(val) = self.get_str(path) {
196 self.result.model = Some(val);
197 return self;
198 }
199 }
200 self
201 }
202
203 pub fn duration_ms(mut self, path: &str) -> Self {
205 self.result.duration_ms = self.get_i64(path);
206 self
207 }
208
209 pub fn duration_ms_or(mut self, paths: &[&str]) -> Self {
211 for path in paths {
212 if let Some(val) = self.get_i64(path) {
213 self.result.duration_ms = Some(val);
214 return self;
215 }
216 }
217 self
218 }
219
220 pub fn tokens_input(mut self, path: &str) -> Self {
222 self.result.tokens_input = self.get_i64(path);
223 self
224 }
225
226 pub fn tokens_output(mut self, path: &str) -> Self {
228 self.result.tokens_output = self.get_i64(path);
229 self
230 }
231
232 pub fn tokens_cache_read(mut self, path: &str) -> Self {
234 self.result.tokens_cache_read = self.get_i64(path);
235 self
236 }
237
238 pub fn tokens_cache_write(mut self, path: &str) -> Self {
240 self.result.tokens_cache_write = self.get_i64(path);
241 self
242 }
243
244 pub fn cost_usd(mut self, path: &str) -> Self {
246 self.result.cost_usd = self.get_f64(path);
247 self
248 }
249
250 pub fn prompt(mut self, path: &str) -> Self {
252 self.result.prompt = self.get_str(path);
253 self
254 }
255
256 pub fn build(self) -> ParsedHookInput {
258 self.result
259 }
260}
261
262fn get_json_path<'a>(json: &'a serde_json::Value, path: &str) -> Option<&'a serde_json::Value> {
271 let mut current = json;
272 for part in path.split('.') {
273 current = current.get(part)?;
274 }
275 Some(current)
276}
277
278pub fn get_first_array_element(json: &serde_json::Value, path: &str) -> Option<String> {
282 get_json_path(json, path)
283 .and_then(|v| v.as_array())
284 .and_then(|arr| arr.first())
285 .and_then(|v| v.as_str())
286 .map(String::from)
287}
288
289pub fn is_framework_installed(config_path: Option<PathBuf>, cli_name: &str) -> bool {
303 if let Some(path) = config_path
305 && path.exists()
306 {
307 return true;
308 }
309 which::which(cli_name).is_ok()
311}
312
313pub fn is_mi6_command(cmd: &str) -> bool {
318 let cmd = cmd.trim();
319 cmd.starts_with("mi6 ingest") || cmd.contains("/mi6 ingest")
321}
322
323fn is_mi6_hook_nested(entry: &serde_json::Value) -> bool {
330 entry
331 .get("hooks")
332 .and_then(|h| h.as_array())
333 .is_some_and(|hooks| {
334 hooks.iter().any(|hook| {
335 hook.get("command")
336 .and_then(|c| c.as_str())
337 .is_some_and(is_mi6_command)
338 })
339 })
340}
341
342pub fn merge_json_hooks(
371 generated: serde_json::Value,
372 existing: Option<serde_json::Value>,
373) -> serde_json::Value {
374 let mut settings = existing.unwrap_or_else(|| serde_json::json!({}));
375
376 let Some(new_hooks) = generated.get("hooks").and_then(|h| h.as_object()) else {
377 return settings;
378 };
379
380 if settings.get("hooks").is_none() {
382 settings["hooks"] = serde_json::json!({});
383 }
384
385 let Some(existing_hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
386 return settings;
387 };
388
389 for (event_type, new_hook_array) in new_hooks {
390 let Some(new_hooks_arr) = new_hook_array.as_array() else {
391 continue;
392 };
393
394 if let Some(existing_event_hooks) = existing_hooks.get_mut(event_type) {
395 if let Some(existing_arr) = existing_event_hooks.as_array_mut() {
396 existing_arr.retain(|entry| !is_mi6_hook_nested(entry));
398
399 for hook in new_hooks_arr {
401 existing_arr.push(hook.clone());
402 }
403 }
404 } else {
405 existing_hooks.insert(event_type.clone(), new_hook_array.clone());
407 }
408 }
409
410 settings
411}
412
413pub fn remove_json_hooks(
428 existing: serde_json::Value,
429 otel_keys: &[&str],
430) -> Option<serde_json::Value> {
431 let mut settings = existing;
432 let mut modified = false;
433
434 if let Some(hooks) = settings.get_mut("hooks")
436 && let Some(hooks_obj) = hooks.as_object_mut()
437 {
438 let keys: Vec<String> = hooks_obj.keys().cloned().collect();
440 for key in keys {
441 if let Some(event_hooks) = hooks_obj.get_mut(&key)
442 && let Some(arr) = event_hooks.as_array_mut()
443 {
444 let original_len = arr.len();
446 arr.retain(|entry| !is_mi6_hook_nested(entry));
447
448 if arr.len() != original_len {
449 modified = true;
450 }
451
452 if arr.is_empty() {
454 hooks_obj.remove(&key);
455 }
456 }
457 }
458
459 if hooks_obj.is_empty()
461 && let Some(obj) = settings.as_object_mut()
462 {
463 obj.remove("hooks");
464 }
465 }
466
467 if let Some(env) = settings.get_mut("env")
469 && let Some(env_obj) = env.as_object_mut()
470 {
471 for key in otel_keys {
472 if env_obj.remove(*key).is_some() {
473 modified = true;
474 }
475 }
476
477 if env_obj.is_empty()
479 && let Some(obj) = settings.as_object_mut()
480 {
481 obj.remove("env");
482 }
483 }
484
485 if modified { Some(settings) } else { None }
486}
487
488#[cfg(test)]
489mod tests {
490 use super::*;
491 use serde_json::json;
492
493 #[test]
498 fn test_builder_simple_fields() {
499 let json = json!({
500 "session_id": "test-session",
501 "tool_use_id": "tool-123",
502 "tool_name": "Bash",
503 "cwd": "/projects/test"
504 });
505
506 let parsed = ParsedHookInputBuilder::new(&json)
507 .session_id("session_id")
508 .tool_use_id("tool_use_id")
509 .tool_name("tool_name")
510 .cwd("cwd")
511 .build();
512
513 assert_eq!(parsed.session_id, Some("test-session".to_string()));
514 assert_eq!(parsed.tool_use_id, Some("tool-123".to_string()));
515 assert_eq!(parsed.tool_name, Some("Bash".to_string()));
516 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
517 }
518
519 #[test]
520 fn test_builder_nested_paths() {
521 let json = json!({
522 "tool_input": {
523 "subagent_type": "Explore"
524 },
525 "tool_response": {
526 "agentId": "agent-456"
527 },
528 "llm_request": {
529 "model": "claude-3-opus"
530 }
531 });
532
533 let parsed = ParsedHookInputBuilder::new(&json)
534 .subagent_type("tool_input.subagent_type")
535 .spawned_agent_id("tool_response.agentId")
536 .model("llm_request.model")
537 .build();
538
539 assert_eq!(parsed.subagent_type, Some("Explore".to_string()));
540 assert_eq!(parsed.spawned_agent_id, Some("agent-456".to_string()));
541 assert_eq!(parsed.model, Some("claude-3-opus".to_string()));
542 }
543
544 #[test]
545 fn test_builder_numeric_fields() {
546 let json = json!({
547 "duration_ms": 1234,
548 "tokens_input": 100,
549 "tokens_output": 50,
550 "cost_usd": 0.005
551 });
552
553 let parsed = ParsedHookInputBuilder::new(&json)
554 .duration_ms("duration_ms")
555 .tokens_input("tokens_input")
556 .tokens_output("tokens_output")
557 .cost_usd("cost_usd")
558 .build();
559
560 assert_eq!(parsed.duration_ms, Some(1234));
561 assert_eq!(parsed.tokens_input, Some(100));
562 assert_eq!(parsed.tokens_output, Some(50));
563 assert_eq!(parsed.cost_usd, Some(0.005));
564 }
565
566 #[test]
567 fn test_builder_missing_fields() {
568 let json = json!({
569 "session_id": "test"
570 });
571
572 let parsed = ParsedHookInputBuilder::new(&json)
573 .session_id("session_id")
574 .tool_name("nonexistent")
575 .cwd("also_missing")
576 .build();
577
578 assert_eq!(parsed.session_id, Some("test".to_string()));
579 assert_eq!(parsed.tool_name, None);
580 assert_eq!(parsed.cwd, None);
581 }
582
583 #[test]
584 fn test_builder_fallback_paths() {
585 let json_with_cwd = json!({
587 "cwd": "/primary",
588 "workspace_roots": ["/fallback"]
589 });
590
591 let parsed = ParsedHookInputBuilder::new(&json_with_cwd)
592 .cwd_or(&["cwd", "workspace_roots.0"])
593 .build();
594
595 assert_eq!(parsed.cwd, Some("/primary".to_string()));
596
597 let json_no_cwd = json!({
599 "other_field": "value"
600 });
601
602 let parsed = ParsedHookInputBuilder::new(&json_no_cwd)
603 .cwd_or(&["cwd", "workspace_dir"])
604 .build();
605
606 assert_eq!(parsed.cwd, None);
607 }
608
609 #[test]
610 fn test_builder_duration_fallback() {
611 let json_ms = json!({
613 "duration_ms": 5000
614 });
615
616 let parsed = ParsedHookInputBuilder::new(&json_ms)
617 .duration_ms_or(&["duration_ms", "duration"])
618 .build();
619
620 assert_eq!(parsed.duration_ms, Some(5000));
621
622 let json_fallback = json!({
624 "duration": 3000
625 });
626
627 let parsed = ParsedHookInputBuilder::new(&json_fallback)
628 .duration_ms_or(&["duration_ms", "duration"])
629 .build();
630
631 assert_eq!(parsed.duration_ms, Some(3000));
632 }
633
634 #[test]
635 fn test_builder_deeply_nested_path() {
636 let json = json!({
637 "a": {
638 "b": {
639 "c": {
640 "value": "deep"
641 }
642 }
643 }
644 });
645
646 let parsed = ParsedHookInputBuilder::new(&json)
647 .session_id("a.b.c.value")
648 .build();
649
650 assert_eq!(parsed.session_id, Some("deep".to_string()));
651 }
652
653 #[test]
654 fn test_get_first_array_element() {
655 let json = json!({
656 "workspace_roots": ["/first", "/second", "/third"]
657 });
658
659 let result = get_first_array_element(&json, "workspace_roots");
660 assert_eq!(result, Some("/first".to_string()));
661
662 let json_empty = json!({
664 "workspace_roots": []
665 });
666
667 let result = get_first_array_element(&json_empty, "workspace_roots");
668 assert_eq!(result, None);
669
670 let result = get_first_array_element(&json, "missing");
672 assert_eq!(result, None);
673 }
674
675 #[test]
680 fn test_is_framework_installed_cli_in_path() {
681 assert!(is_framework_installed(None, "ls"));
683 }
684
685 #[test]
686 fn test_is_framework_installed_config_dir_exists() {
687 let temp_dir = std::env::temp_dir().join("mi6_test_config_dir");
689 std::fs::create_dir_all(&temp_dir).unwrap();
690
691 assert!(is_framework_installed(
693 Some(temp_dir.clone()),
694 "nonexistent_cli_xyz_123"
695 ));
696
697 std::fs::remove_dir_all(&temp_dir).ok();
699 }
700
701 #[test]
702 fn test_is_framework_installed_config_dir_not_exists() {
703 let nonexistent_dir = std::env::temp_dir().join("mi6_nonexistent_config_dir_xyz");
705 assert!(!is_framework_installed(
706 Some(nonexistent_dir),
707 "nonexistent_cli_xyz_123"
708 ));
709 }
710
711 #[test]
712 fn test_is_framework_installed_nothing() {
713 assert!(!is_framework_installed(None, "nonexistent_cli_xyz_123"));
715 }
716
717 #[test]
718 fn test_is_mi6_command() {
719 assert!(is_mi6_command("mi6 ingest event SessionStart"));
721 assert!(is_mi6_command(
722 "mi6 ingest event BeforeTool --framework gemini"
723 ));
724 assert!(is_mi6_command("/usr/local/bin/mi6 ingest event PreToolUse"));
725 assert!(is_mi6_command(" mi6 ingest event Test ")); assert!(!is_mi6_command("my-logger --output /var/log/mi6.log"));
729 assert!(!is_mi6_command("echo mi6 is cool"));
730 assert!(!is_mi6_command("mi6-wrapper some-command")); assert!(!is_mi6_command("other-tool"));
732 }
733
734 #[test]
735 fn test_merge_json_hooks_new() {
736 let generated = json!({
737 "hooks": {
738 "SessionStart": [{"matcher": "", "hooks": []}]
739 }
740 });
741
742 let merged = merge_json_hooks(generated, None);
743
744 assert!(merged.get("hooks").is_some());
745 assert!(merged["hooks"].get("SessionStart").is_some());
746 }
747
748 #[test]
749 fn test_merge_json_hooks_existing() {
750 let generated = json!({
751 "hooks": {
752 "PreToolUse": [{"matcher": "", "hooks": [{"command": "mi6 ingest event PreToolUse"}]}]
753 }
754 });
755 let existing = json!({
756 "theme": "dark",
757 "hooks": {
758 "SessionStart": [{"matcher": "", "hooks": [{"command": "other-tool"}]}]
759 }
760 });
761
762 let merged = merge_json_hooks(generated, Some(existing));
763
764 assert_eq!(merged["theme"], "dark");
766 assert!(merged["hooks"].get("SessionStart").is_some());
768 assert!(merged["hooks"].get("PreToolUse").is_some());
769 }
770
771 #[test]
772 fn test_merge_json_hooks_preserves_user_hooks_for_same_event() {
773 let generated = json!({
777 "hooks": {
778 "BeforeTool": [{"matcher": "*", "hooks": [{"command": "mi6 ingest event BeforeTool"}]}]
779 }
780 });
781 let existing = json!({
782 "hooks": {
783 "BeforeTool": [{"matcher": "", "hooks": [{"command": "my-custom-logger --event tool"}]}]
784 }
785 });
786
787 let merged = merge_json_hooks(generated, Some(existing));
788
789 let before_tool_hooks = merged["hooks"]["BeforeTool"].as_array().unwrap();
791 assert_eq!(
792 before_tool_hooks.len(),
793 2,
794 "Should have 2 hooks for BeforeTool"
795 );
796
797 assert!(
799 before_tool_hooks[0]["hooks"][0]["command"]
800 .as_str()
801 .unwrap()
802 .contains("my-custom-logger"),
803 "User's custom hook should be preserved"
804 );
805
806 assert!(
808 before_tool_hooks[1]["hooks"][0]["command"]
809 .as_str()
810 .unwrap()
811 .contains("mi6 ingest"),
812 "mi6 hook should be appended"
813 );
814 }
815
816 #[test]
817 fn test_merge_json_hooks_updates_existing_mi6_hook() {
818 let generated = json!({
821 "hooks": {
822 "BeforeTool": [{"matcher": "*", "hooks": [{"command": "mi6 ingest event BeforeTool --new-flag"}]}]
823 }
824 });
825 let existing = json!({
826 "hooks": {
827 "BeforeTool": [
828 {"matcher": "", "hooks": [{"command": "my-custom-logger"}]},
829 {"matcher": "", "hooks": [{"command": "mi6 ingest event BeforeTool --old-flag"}]}
830 ]
831 }
832 });
833
834 let merged = merge_json_hooks(generated, Some(existing));
835
836 let before_tool_hooks = merged["hooks"]["BeforeTool"].as_array().unwrap();
837 assert_eq!(before_tool_hooks.len(), 2, "Should still have 2 hooks");
838
839 assert!(
841 before_tool_hooks[0]["hooks"][0]["command"]
842 .as_str()
843 .unwrap()
844 .contains("my-custom-logger"),
845 "User's custom hook should be preserved"
846 );
847
848 let mi6_command = before_tool_hooks[1]["hooks"][0]["command"]
850 .as_str()
851 .unwrap();
852 assert!(
853 mi6_command.contains("--new-flag"),
854 "New mi6 hook should be present"
855 );
856 assert!(
857 !mi6_command.contains("--old-flag"),
858 "Old mi6 hook should be replaced"
859 );
860 }
861
862 #[test]
863 fn test_merge_json_hooks_preserves_multiple_user_hooks() {
864 let generated = json!({
866 "hooks": {
867 "SessionStart": [{"matcher": "*", "hooks": [{"command": "mi6 ingest event SessionStart"}]}]
868 }
869 });
870 let existing = json!({
871 "hooks": {
872 "SessionStart": [
873 {"matcher": "", "hooks": [{"command": "custom-logger-1"}]},
874 {"matcher": "", "hooks": [{"command": "custom-logger-2"}]},
875 {"matcher": "", "hooks": [{"command": "custom-logger-3"}]}
876 ]
877 }
878 });
879
880 let merged = merge_json_hooks(generated, Some(existing));
881
882 let session_hooks = merged["hooks"]["SessionStart"].as_array().unwrap();
883 assert_eq!(
884 session_hooks.len(),
885 4,
886 "Should have all 3 user hooks + 1 mi6 hook"
887 );
888
889 let commands: Vec<&str> = session_hooks
891 .iter()
892 .filter_map(|h| h["hooks"][0]["command"].as_str())
893 .collect();
894 assert!(commands.iter().any(|c| c.contains("custom-logger-1")));
895 assert!(commands.iter().any(|c| c.contains("custom-logger-2")));
896 assert!(commands.iter().any(|c| c.contains("custom-logger-3")));
897 assert!(commands.iter().any(|c| c.contains("mi6 ingest")));
898 }
899
900 #[test]
901 fn test_remove_json_hooks_with_mi6() {
902 let existing = json!({
903 "theme": "dark",
904 "hooks": {
905 "SessionStart": [{
906 "matcher": "",
907 "hooks": [{"type": "command", "command": "mi6 ingest event SessionStart"}]
908 }]
909 },
910 "env": {
911 "OTEL_LOGS_EXPORTER": "otlp",
912 "MY_VAR": "value"
913 }
914 });
915
916 let otel_keys = &["OTEL_LOGS_EXPORTER"];
917 let result = remove_json_hooks(existing, otel_keys);
918
919 assert!(result.is_some());
920 let settings = result.unwrap();
921 assert_eq!(settings["theme"], "dark");
923 assert!(settings.get("hooks").is_none());
925 assert!(settings["env"].get("OTEL_LOGS_EXPORTER").is_none());
927 assert_eq!(settings["env"]["MY_VAR"], "value");
928 }
929
930 #[test]
931 fn test_remove_json_hooks_preserves_non_mi6() {
932 let existing = json!({
933 "hooks": {
934 "SessionStart": [
935 {
936 "matcher": "",
937 "hooks": [{"type": "command", "command": "mi6 ingest event SessionStart"}]
938 },
939 {
940 "matcher": "",
941 "hooks": [{"type": "command", "command": "other-tool log"}]
942 }
943 ]
944 }
945 });
946
947 let result = remove_json_hooks(existing, &[]);
948
949 assert!(result.is_some());
950 let settings = result.unwrap();
951 assert!(settings.get("hooks").is_some());
953 let session_hooks = settings["hooks"]["SessionStart"].as_array().unwrap();
954 assert_eq!(session_hooks.len(), 1);
955 assert!(
956 session_hooks[0]["hooks"][0]["command"]
957 .as_str()
958 .unwrap()
959 .contains("other-tool")
960 );
961 }
962
963 #[test]
964 fn test_remove_json_hooks_no_mi6() {
965 let existing = json!({
966 "hooks": {
967 "SessionStart": [{
968 "matcher": "",
969 "hooks": [{"type": "command", "command": "other-tool log"}]
970 }]
971 }
972 });
973
974 let result = remove_json_hooks(existing, &[]);
975
976 assert!(result.is_none());
978 }
979}