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 && let Some(parent) = path.parent()
306 && parent.exists()
307 {
308 return true;
309 }
310 which::which(cli_name).is_ok()
312}
313
314pub fn is_mi6_command(cmd: &str) -> bool {
319 let cmd = cmd.trim();
320 cmd.starts_with("mi6 ingest") || cmd.contains("/mi6 ingest")
322}
323
324fn is_mi6_hook_nested(entry: &serde_json::Value) -> bool {
331 entry
332 .get("hooks")
333 .and_then(|h| h.as_array())
334 .is_some_and(|hooks| {
335 hooks.iter().any(|hook| {
336 hook.get("command")
337 .and_then(|c| c.as_str())
338 .is_some_and(is_mi6_command)
339 })
340 })
341}
342
343pub fn merge_json_hooks(
372 generated: serde_json::Value,
373 existing: Option<serde_json::Value>,
374) -> serde_json::Value {
375 let mut settings = existing.unwrap_or_else(|| serde_json::json!({}));
376
377 let Some(new_hooks) = generated.get("hooks").and_then(|h| h.as_object()) else {
378 return settings;
379 };
380
381 if settings.get("hooks").is_none() {
383 settings["hooks"] = serde_json::json!({});
384 }
385
386 let Some(existing_hooks) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) else {
387 return settings;
388 };
389
390 for (event_type, new_hook_array) in new_hooks {
391 let Some(new_hooks_arr) = new_hook_array.as_array() else {
392 continue;
393 };
394
395 if let Some(existing_event_hooks) = existing_hooks.get_mut(event_type) {
396 if let Some(existing_arr) = existing_event_hooks.as_array_mut() {
397 existing_arr.retain(|entry| !is_mi6_hook_nested(entry));
399
400 for hook in new_hooks_arr {
402 existing_arr.push(hook.clone());
403 }
404 }
405 } else {
406 existing_hooks.insert(event_type.clone(), new_hook_array.clone());
408 }
409 }
410
411 settings
412}
413
414pub fn remove_json_hooks(
429 existing: serde_json::Value,
430 otel_keys: &[&str],
431) -> Option<serde_json::Value> {
432 let mut settings = existing;
433 let mut modified = false;
434
435 if let Some(hooks) = settings.get_mut("hooks")
437 && let Some(hooks_obj) = hooks.as_object_mut()
438 {
439 let keys: Vec<String> = hooks_obj.keys().cloned().collect();
441 for key in keys {
442 if let Some(event_hooks) = hooks_obj.get_mut(&key)
443 && let Some(arr) = event_hooks.as_array_mut()
444 {
445 let original_len = arr.len();
447 arr.retain(|entry| !is_mi6_hook_nested(entry));
448
449 if arr.len() != original_len {
450 modified = true;
451 }
452
453 if arr.is_empty() {
455 hooks_obj.remove(&key);
456 }
457 }
458 }
459
460 if hooks_obj.is_empty()
462 && let Some(obj) = settings.as_object_mut()
463 {
464 obj.remove("hooks");
465 }
466 }
467
468 if let Some(env) = settings.get_mut("env")
470 && let Some(env_obj) = env.as_object_mut()
471 {
472 for key in otel_keys {
473 if env_obj.remove(*key).is_some() {
474 modified = true;
475 }
476 }
477
478 if env_obj.is_empty()
480 && let Some(obj) = settings.as_object_mut()
481 {
482 obj.remove("env");
483 }
484 }
485
486 if modified { Some(settings) } else { None }
487}
488
489#[cfg(test)]
490mod tests {
491 use super::*;
492 use serde_json::json;
493
494 #[test]
499 fn test_builder_simple_fields() {
500 let json = json!({
501 "session_id": "test-session",
502 "tool_use_id": "tool-123",
503 "tool_name": "Bash",
504 "cwd": "/projects/test"
505 });
506
507 let parsed = ParsedHookInputBuilder::new(&json)
508 .session_id("session_id")
509 .tool_use_id("tool_use_id")
510 .tool_name("tool_name")
511 .cwd("cwd")
512 .build();
513
514 assert_eq!(parsed.session_id, Some("test-session".to_string()));
515 assert_eq!(parsed.tool_use_id, Some("tool-123".to_string()));
516 assert_eq!(parsed.tool_name, Some("Bash".to_string()));
517 assert_eq!(parsed.cwd, Some("/projects/test".to_string()));
518 }
519
520 #[test]
521 fn test_builder_nested_paths() {
522 let json = json!({
523 "tool_input": {
524 "subagent_type": "Explore"
525 },
526 "tool_response": {
527 "agentId": "agent-456"
528 },
529 "llm_request": {
530 "model": "claude-3-opus"
531 }
532 });
533
534 let parsed = ParsedHookInputBuilder::new(&json)
535 .subagent_type("tool_input.subagent_type")
536 .spawned_agent_id("tool_response.agentId")
537 .model("llm_request.model")
538 .build();
539
540 assert_eq!(parsed.subagent_type, Some("Explore".to_string()));
541 assert_eq!(parsed.spawned_agent_id, Some("agent-456".to_string()));
542 assert_eq!(parsed.model, Some("claude-3-opus".to_string()));
543 }
544
545 #[test]
546 fn test_builder_numeric_fields() {
547 let json = json!({
548 "duration_ms": 1234,
549 "tokens_input": 100,
550 "tokens_output": 50,
551 "cost_usd": 0.005
552 });
553
554 let parsed = ParsedHookInputBuilder::new(&json)
555 .duration_ms("duration_ms")
556 .tokens_input("tokens_input")
557 .tokens_output("tokens_output")
558 .cost_usd("cost_usd")
559 .build();
560
561 assert_eq!(parsed.duration_ms, Some(1234));
562 assert_eq!(parsed.tokens_input, Some(100));
563 assert_eq!(parsed.tokens_output, Some(50));
564 assert_eq!(parsed.cost_usd, Some(0.005));
565 }
566
567 #[test]
568 fn test_builder_missing_fields() {
569 let json = json!({
570 "session_id": "test"
571 });
572
573 let parsed = ParsedHookInputBuilder::new(&json)
574 .session_id("session_id")
575 .tool_name("nonexistent")
576 .cwd("also_missing")
577 .build();
578
579 assert_eq!(parsed.session_id, Some("test".to_string()));
580 assert_eq!(parsed.tool_name, None);
581 assert_eq!(parsed.cwd, None);
582 }
583
584 #[test]
585 fn test_builder_fallback_paths() {
586 let json_with_cwd = json!({
588 "cwd": "/primary",
589 "workspace_roots": ["/fallback"]
590 });
591
592 let parsed = ParsedHookInputBuilder::new(&json_with_cwd)
593 .cwd_or(&["cwd", "workspace_roots.0"])
594 .build();
595
596 assert_eq!(parsed.cwd, Some("/primary".to_string()));
597
598 let json_no_cwd = json!({
600 "other_field": "value"
601 });
602
603 let parsed = ParsedHookInputBuilder::new(&json_no_cwd)
604 .cwd_or(&["cwd", "workspace_dir"])
605 .build();
606
607 assert_eq!(parsed.cwd, None);
608 }
609
610 #[test]
611 fn test_builder_duration_fallback() {
612 let json_ms = json!({
614 "duration_ms": 5000
615 });
616
617 let parsed = ParsedHookInputBuilder::new(&json_ms)
618 .duration_ms_or(&["duration_ms", "duration"])
619 .build();
620
621 assert_eq!(parsed.duration_ms, Some(5000));
622
623 let json_fallback = json!({
625 "duration": 3000
626 });
627
628 let parsed = ParsedHookInputBuilder::new(&json_fallback)
629 .duration_ms_or(&["duration_ms", "duration"])
630 .build();
631
632 assert_eq!(parsed.duration_ms, Some(3000));
633 }
634
635 #[test]
636 fn test_builder_deeply_nested_path() {
637 let json = json!({
638 "a": {
639 "b": {
640 "c": {
641 "value": "deep"
642 }
643 }
644 }
645 });
646
647 let parsed = ParsedHookInputBuilder::new(&json)
648 .session_id("a.b.c.value")
649 .build();
650
651 assert_eq!(parsed.session_id, Some("deep".to_string()));
652 }
653
654 #[test]
655 fn test_get_first_array_element() {
656 let json = json!({
657 "workspace_roots": ["/first", "/second", "/third"]
658 });
659
660 let result = get_first_array_element(&json, "workspace_roots");
661 assert_eq!(result, Some("/first".to_string()));
662
663 let json_empty = json!({
665 "workspace_roots": []
666 });
667
668 let result = get_first_array_element(&json_empty, "workspace_roots");
669 assert_eq!(result, None);
670
671 let result = get_first_array_element(&json, "missing");
673 assert_eq!(result, None);
674 }
675
676 #[test]
681 fn test_is_framework_installed_cli_in_path() {
682 assert!(is_framework_installed(None, "ls"));
684 }
685
686 #[test]
687 fn test_is_framework_installed_config_dir_exists() {
688 let temp_dir = std::env::temp_dir().join("mi6_test_config_dir");
690 std::fs::create_dir_all(&temp_dir).unwrap();
691 let config_path = temp_dir.join("settings.json");
692
693 assert!(is_framework_installed(
695 Some(config_path.clone()),
696 "nonexistent_cli_xyz_123"
697 ));
698
699 std::fs::remove_dir_all(&temp_dir).ok();
701 }
702
703 #[test]
704 fn test_is_framework_installed_nothing() {
705 assert!(!is_framework_installed(None, "nonexistent_cli_xyz_123"));
707 }
708
709 #[test]
710 fn test_is_mi6_command() {
711 assert!(is_mi6_command("mi6 ingest event SessionStart"));
713 assert!(is_mi6_command(
714 "mi6 ingest event BeforeTool --framework gemini"
715 ));
716 assert!(is_mi6_command("/usr/local/bin/mi6 ingest event PreToolUse"));
717 assert!(is_mi6_command(" mi6 ingest event Test ")); assert!(!is_mi6_command("my-logger --output /var/log/mi6.log"));
721 assert!(!is_mi6_command("echo mi6 is cool"));
722 assert!(!is_mi6_command("mi6-wrapper some-command")); assert!(!is_mi6_command("other-tool"));
724 }
725
726 #[test]
727 fn test_merge_json_hooks_new() {
728 let generated = json!({
729 "hooks": {
730 "SessionStart": [{"matcher": "", "hooks": []}]
731 }
732 });
733
734 let merged = merge_json_hooks(generated, None);
735
736 assert!(merged.get("hooks").is_some());
737 assert!(merged["hooks"].get("SessionStart").is_some());
738 }
739
740 #[test]
741 fn test_merge_json_hooks_existing() {
742 let generated = json!({
743 "hooks": {
744 "PreToolUse": [{"matcher": "", "hooks": [{"command": "mi6 ingest event PreToolUse"}]}]
745 }
746 });
747 let existing = json!({
748 "theme": "dark",
749 "hooks": {
750 "SessionStart": [{"matcher": "", "hooks": [{"command": "other-tool"}]}]
751 }
752 });
753
754 let merged = merge_json_hooks(generated, Some(existing));
755
756 assert_eq!(merged["theme"], "dark");
758 assert!(merged["hooks"].get("SessionStart").is_some());
760 assert!(merged["hooks"].get("PreToolUse").is_some());
761 }
762
763 #[test]
764 fn test_merge_json_hooks_preserves_user_hooks_for_same_event() {
765 let generated = json!({
769 "hooks": {
770 "BeforeTool": [{"matcher": "*", "hooks": [{"command": "mi6 ingest event BeforeTool"}]}]
771 }
772 });
773 let existing = json!({
774 "hooks": {
775 "BeforeTool": [{"matcher": "", "hooks": [{"command": "my-custom-logger --event tool"}]}]
776 }
777 });
778
779 let merged = merge_json_hooks(generated, Some(existing));
780
781 let before_tool_hooks = merged["hooks"]["BeforeTool"].as_array().unwrap();
783 assert_eq!(
784 before_tool_hooks.len(),
785 2,
786 "Should have 2 hooks for BeforeTool"
787 );
788
789 assert!(
791 before_tool_hooks[0]["hooks"][0]["command"]
792 .as_str()
793 .unwrap()
794 .contains("my-custom-logger"),
795 "User's custom hook should be preserved"
796 );
797
798 assert!(
800 before_tool_hooks[1]["hooks"][0]["command"]
801 .as_str()
802 .unwrap()
803 .contains("mi6 ingest"),
804 "mi6 hook should be appended"
805 );
806 }
807
808 #[test]
809 fn test_merge_json_hooks_updates_existing_mi6_hook() {
810 let generated = json!({
813 "hooks": {
814 "BeforeTool": [{"matcher": "*", "hooks": [{"command": "mi6 ingest event BeforeTool --new-flag"}]}]
815 }
816 });
817 let existing = json!({
818 "hooks": {
819 "BeforeTool": [
820 {"matcher": "", "hooks": [{"command": "my-custom-logger"}]},
821 {"matcher": "", "hooks": [{"command": "mi6 ingest event BeforeTool --old-flag"}]}
822 ]
823 }
824 });
825
826 let merged = merge_json_hooks(generated, Some(existing));
827
828 let before_tool_hooks = merged["hooks"]["BeforeTool"].as_array().unwrap();
829 assert_eq!(before_tool_hooks.len(), 2, "Should still have 2 hooks");
830
831 assert!(
833 before_tool_hooks[0]["hooks"][0]["command"]
834 .as_str()
835 .unwrap()
836 .contains("my-custom-logger"),
837 "User's custom hook should be preserved"
838 );
839
840 let mi6_command = before_tool_hooks[1]["hooks"][0]["command"]
842 .as_str()
843 .unwrap();
844 assert!(
845 mi6_command.contains("--new-flag"),
846 "New mi6 hook should be present"
847 );
848 assert!(
849 !mi6_command.contains("--old-flag"),
850 "Old mi6 hook should be replaced"
851 );
852 }
853
854 #[test]
855 fn test_merge_json_hooks_preserves_multiple_user_hooks() {
856 let generated = json!({
858 "hooks": {
859 "SessionStart": [{"matcher": "*", "hooks": [{"command": "mi6 ingest event SessionStart"}]}]
860 }
861 });
862 let existing = json!({
863 "hooks": {
864 "SessionStart": [
865 {"matcher": "", "hooks": [{"command": "custom-logger-1"}]},
866 {"matcher": "", "hooks": [{"command": "custom-logger-2"}]},
867 {"matcher": "", "hooks": [{"command": "custom-logger-3"}]}
868 ]
869 }
870 });
871
872 let merged = merge_json_hooks(generated, Some(existing));
873
874 let session_hooks = merged["hooks"]["SessionStart"].as_array().unwrap();
875 assert_eq!(
876 session_hooks.len(),
877 4,
878 "Should have all 3 user hooks + 1 mi6 hook"
879 );
880
881 let commands: Vec<&str> = session_hooks
883 .iter()
884 .filter_map(|h| h["hooks"][0]["command"].as_str())
885 .collect();
886 assert!(commands.iter().any(|c| c.contains("custom-logger-1")));
887 assert!(commands.iter().any(|c| c.contains("custom-logger-2")));
888 assert!(commands.iter().any(|c| c.contains("custom-logger-3")));
889 assert!(commands.iter().any(|c| c.contains("mi6 ingest")));
890 }
891
892 #[test]
893 fn test_remove_json_hooks_with_mi6() {
894 let existing = json!({
895 "theme": "dark",
896 "hooks": {
897 "SessionStart": [{
898 "matcher": "",
899 "hooks": [{"type": "command", "command": "mi6 ingest event SessionStart"}]
900 }]
901 },
902 "env": {
903 "OTEL_LOGS_EXPORTER": "otlp",
904 "MY_VAR": "value"
905 }
906 });
907
908 let otel_keys = &["OTEL_LOGS_EXPORTER"];
909 let result = remove_json_hooks(existing, otel_keys);
910
911 assert!(result.is_some());
912 let settings = result.unwrap();
913 assert_eq!(settings["theme"], "dark");
915 assert!(settings.get("hooks").is_none());
917 assert!(settings["env"].get("OTEL_LOGS_EXPORTER").is_none());
919 assert_eq!(settings["env"]["MY_VAR"], "value");
920 }
921
922 #[test]
923 fn test_remove_json_hooks_preserves_non_mi6() {
924 let existing = json!({
925 "hooks": {
926 "SessionStart": [
927 {
928 "matcher": "",
929 "hooks": [{"type": "command", "command": "mi6 ingest event SessionStart"}]
930 },
931 {
932 "matcher": "",
933 "hooks": [{"type": "command", "command": "other-tool log"}]
934 }
935 ]
936 }
937 });
938
939 let result = remove_json_hooks(existing, &[]);
940
941 assert!(result.is_some());
942 let settings = result.unwrap();
943 assert!(settings.get("hooks").is_some());
945 let session_hooks = settings["hooks"]["SessionStart"].as_array().unwrap();
946 assert_eq!(session_hooks.len(), 1);
947 assert!(
948 session_hooks[0]["hooks"][0]["command"]
949 .as_str()
950 .unwrap()
951 .contains("other-tool")
952 );
953 }
954
955 #[test]
956 fn test_remove_json_hooks_no_mi6() {
957 let existing = json!({
958 "hooks": {
959 "SessionStart": [{
960 "matcher": "",
961 "hooks": [{"type": "command", "command": "other-tool log"}]
962 }]
963 }
964 });
965
966 let result = remove_json_hooks(existing, &[]);
967
968 assert!(result.is_none());
970 }
971}