mi6_core/framework/
common.rs

1//! Common helper functions for framework adapters.
2//!
3//! This module provides shared logic used by multiple framework adapters,
4//! reducing code duplication across Claude, Gemini, and Codex adapters.
5
6use super::ParsedHookInput;
7use std::path::PathBuf;
8
9// ============================================================================
10// ParsedHookInput Builder
11// ============================================================================
12
13/// Builder for extracting fields from JSON into [`ParsedHookInput`].
14///
15/// This builder provides a fluent API for extracting fields from JSON hook payloads,
16/// supporting both simple top-level fields and nested paths using dot notation.
17///
18/// # Example
19///
20/// ```
21/// use mi6_core::framework::common::ParsedHookInputBuilder;
22/// use serde_json::json;
23///
24/// let json = json!({
25///     "session_id": "abc123",
26///     "tool_input": {
27///         "subagent_type": "Explore"
28///     }
29/// });
30///
31/// let parsed = ParsedHookInputBuilder::new(&json)
32///     .session_id("session_id")
33///     .subagent_type("tool_input.subagent_type")
34///     .build();
35///
36/// assert_eq!(parsed.session_id, Some("abc123".to_string()));
37/// assert_eq!(parsed.subagent_type, Some("Explore".to_string()));
38/// ```
39pub struct ParsedHookInputBuilder<'a> {
40    json: &'a serde_json::Value,
41    result: ParsedHookInput,
42}
43
44impl<'a> ParsedHookInputBuilder<'a> {
45    /// Create a new builder for extracting fields from the given JSON value.
46    pub fn new(json: &'a serde_json::Value) -> Self {
47        Self {
48            json,
49            result: ParsedHookInput::default(),
50        }
51    }
52
53    /// Extract a string value from a JSON path.
54    ///
55    /// Supports dot notation for nested paths: "tool_input.subagent_type"
56    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    /// Extract an i64 value from a JSON path.
61    fn get_i64(&self, path: &str) -> Option<i64> {
62        get_json_path(self.json, path).and_then(|v| v.as_i64())
63    }
64
65    /// Extract an f64 value from a JSON path.
66    fn get_f64(&self, path: &str) -> Option<f64> {
67        get_json_path(self.json, path).and_then(|v| v.as_f64())
68    }
69
70    /// Set session_id from the given JSON path.
71    pub fn session_id(mut self, path: &str) -> Self {
72        self.result.session_id = self.get_str(path);
73        self
74    }
75
76    /// Set session_id with fallback paths. Uses the first path that yields a value.
77    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    /// Set tool_use_id from the given JSON path.
88    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    /// Set tool_use_id with fallback paths. Uses the first path that yields a value.
94    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    /// Set tool_name from the given JSON path.
105    pub fn tool_name(mut self, path: &str) -> Self {
106        self.result.tool_name = self.get_str(path);
107        self
108    }
109
110    /// Set tool_name with fallback paths. Uses the first path that yields a value.
111    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    /// Set subagent_type from the given JSON path.
122    pub fn subagent_type(mut self, path: &str) -> Self {
123        self.result.subagent_type = self.get_str(path);
124        self
125    }
126
127    /// Set spawned_agent_id from the given JSON path.
128    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    /// Set permission_mode from the given JSON path.
134    pub fn permission_mode(mut self, path: &str) -> Self {
135        self.result.permission_mode = self.get_str(path);
136        self
137    }
138
139    /// Set transcript_path from the given JSON path.
140    pub fn transcript_path(mut self, path: &str) -> Self {
141        self.result.transcript_path = self.get_str(path);
142        self
143    }
144
145    /// Set cwd from the given JSON path.
146    pub fn cwd(mut self, path: &str) -> Self {
147        self.result.cwd = self.get_str(path);
148        self
149    }
150
151    /// Set cwd with fallback paths. Uses the first path that yields a value.
152    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    /// Set session_source from the given JSON path.
163    pub fn session_source(mut self, path: &str) -> Self {
164        self.result.session_source = self.get_str(path);
165        self
166    }
167
168    /// Set agent_id from the given JSON path.
169    pub fn agent_id(mut self, path: &str) -> Self {
170        self.result.agent_id = self.get_str(path);
171        self
172    }
173
174    /// Set agent_transcript_path from the given JSON path.
175    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    /// Set compact_trigger from the given JSON path.
181    pub fn compact_trigger(mut self, path: &str) -> Self {
182        self.result.compact_trigger = self.get_str(path);
183        self
184    }
185
186    /// Set model from the given JSON path.
187    pub fn model(mut self, path: &str) -> Self {
188        self.result.model = self.get_str(path);
189        self
190    }
191
192    /// Set model with fallback paths. Uses the first path that yields a value.
193    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    /// Set duration_ms from the given JSON path.
204    pub fn duration_ms(mut self, path: &str) -> Self {
205        self.result.duration_ms = self.get_i64(path);
206        self
207    }
208
209    /// Set duration_ms with fallback paths. Uses the first path that yields a value.
210    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    /// Set tokens_input from the given JSON path.
221    pub fn tokens_input(mut self, path: &str) -> Self {
222        self.result.tokens_input = self.get_i64(path);
223        self
224    }
225
226    /// Set tokens_output from the given JSON path.
227    pub fn tokens_output(mut self, path: &str) -> Self {
228        self.result.tokens_output = self.get_i64(path);
229        self
230    }
231
232    /// Set tokens_cache_read from the given JSON path.
233    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    /// Set tokens_cache_write from the given JSON path.
239    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    /// Set cost_usd from the given JSON path.
245    pub fn cost_usd(mut self, path: &str) -> Self {
246        self.result.cost_usd = self.get_f64(path);
247        self
248    }
249
250    /// Set prompt from the given JSON path.
251    pub fn prompt(mut self, path: &str) -> Self {
252        self.result.prompt = self.get_str(path);
253        self
254    }
255
256    /// Consume the builder and return the constructed [`ParsedHookInput`].
257    pub fn build(self) -> ParsedHookInput {
258        self.result
259    }
260}
261
262/// Navigate a JSON value using dot-notation path.
263///
264/// # Example
265///
266/// ```ignore
267/// let json = json!({"a": {"b": {"c": "value"}}});
268/// assert_eq!(get_json_path(&json, "a.b.c"), Some(&json!("value")));
269/// ```
270fn 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
278/// Extract the first array element from a JSON path as a string.
279///
280/// Useful for fields like Cursor's `workspace_roots` where we want the first entry.
281pub 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
289// ============================================================================
290// Existing helper functions
291// ============================================================================
292
293/// Check if a framework is installed on the system.
294///
295/// A framework is considered installed if either:
296/// 1. Its config directory exists (e.g., `~/.claude`, `~/.gemini`)
297/// 2. Its CLI tool is available in PATH
298///
299/// # Arguments
300/// * `config_path` - Optional path to the framework's config file
301/// * `cli_name` - Name of the CLI executable (e.g., "claude", "gemini")
302pub fn is_framework_installed(config_path: Option<PathBuf>, cli_name: &str) -> bool {
303    // Check if config directory exists
304    if let Some(path) = config_path
305        && let Some(parent) = path.parent()
306        && parent.exists()
307    {
308        return true;
309    }
310    // Check if CLI is in PATH
311    which::which(cli_name).is_ok()
312}
313
314/// Check if a command string is an mi6 hook command.
315///
316/// Uses specific prefix matching to avoid false positives with user commands
317/// that might coincidentally contain "mi6" in their path or arguments.
318pub fn is_mi6_command(cmd: &str) -> bool {
319    let cmd = cmd.trim();
320    // Match commands that start with "mi6 ingest" or have a path ending in "/mi6 ingest"
321    cmd.starts_with("mi6 ingest") || cmd.contains("/mi6 ingest")
322}
323
324/// Check if a hook entry contains an mi6 command (nested hooks structure).
325///
326/// This handles the hook format used by Claude and Gemini:
327/// ```json
328/// { "matcher": "", "hooks": [{ "command": "mi6 ingest event ..." }] }
329/// ```
330fn 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
343/// Merge generated hooks into existing JSON settings, preserving user hooks.
344///
345/// This function handles the common pattern of merging hook configurations:
346/// - For each event type, appends mi6 hooks to existing user hooks
347/// - If an mi6 hook already exists for an event, it is replaced (not duplicated)
348/// - User hooks (non-mi6) are never removed or modified
349/// - Preserves all other settings
350///
351/// # Hook Structure
352///
353/// This function handles the nested hooks format used by Claude and Gemini:
354/// ```json
355/// {
356///   "hooks": {
357///     "BeforeTool": [
358///       { "matcher": "", "hooks": [{ "command": "user-tool" }] },
359///       { "matcher": "", "hooks": [{ "command": "mi6 ingest event BeforeTool" }] }
360///     ]
361///   }
362/// }
363/// ```
364///
365/// # Arguments
366/// * `generated` - The generated hooks configuration (must have "hooks" key)
367/// * `existing` - Optional existing settings content
368///
369/// # Returns
370/// The merged settings JSON with user hooks preserved
371pub 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    // Ensure hooks object exists
382    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                // Remove any existing mi6 hooks to avoid duplicates
398                existing_arr.retain(|entry| !is_mi6_hook_nested(entry));
399
400                // Append the new mi6 hooks
401                for hook in new_hooks_arr {
402                    existing_arr.push(hook.clone());
403                }
404            }
405        } else {
406            // Event type doesn't exist, add the entire array
407            existing_hooks.insert(event_type.clone(), new_hook_array.clone());
408        }
409    }
410
411    settings
412}
413
414/// Remove mi6 hooks from JSON settings.
415///
416/// This function handles the common pattern of removing mi6-related hooks:
417/// - Iterates over all event types in the hooks object
418/// - Filters out entries that contain "mi6" in their command
419/// - Removes empty arrays and objects
420/// - Optionally removes OTel environment variables
421///
422/// # Arguments
423/// * `existing` - The existing settings content
424/// * `otel_keys` - List of OTel-related environment variable names to remove
425///
426/// # Returns
427/// Some(modified settings) if any mi6 hooks were removed, None otherwise
428pub 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    // Remove only mi6 hooks from settings, preserving user's custom hooks
436    if let Some(hooks) = settings.get_mut("hooks")
437        && let Some(hooks_obj) = hooks.as_object_mut()
438    {
439        // Process each event type
440        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                // Filter out entries that contain mi6 hooks
446                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                // Remove the event key entirely if array is now empty
454                if arr.is_empty() {
455                    hooks_obj.remove(&key);
456                }
457            }
458        }
459
460        // If hooks object is now empty, remove it entirely
461        if hooks_obj.is_empty()
462            && let Some(obj) = settings.as_object_mut()
463        {
464            obj.remove("hooks");
465        }
466    }
467
468    // Remove OTel env vars
469    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 object is now empty, remove it entirely
479        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    // ========================================================================
495    // ParsedHookInputBuilder tests
496    // ========================================================================
497
498    #[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        // cwd present - should use it
587        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        // cwd missing - should use fallback
599        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        // duration_ms present
613        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        // Only duration present (fallback)
624        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        // Empty array
664        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        // Missing field
672        let result = get_first_array_element(&json, "missing");
673        assert_eq!(result, None);
674    }
675
676    // ========================================================================
677    // Existing tests
678    // ========================================================================
679
680    #[test]
681    fn test_is_framework_installed_cli_in_path() {
682        // "ls" should be in PATH on any Unix system
683        assert!(is_framework_installed(None, "ls"));
684    }
685
686    #[test]
687    fn test_is_framework_installed_config_dir_exists() {
688        // Create a temp directory to simulate a config directory
689        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        // Config dir exists, CLI doesn't matter
694        assert!(is_framework_installed(
695            Some(config_path.clone()),
696            "nonexistent_cli_xyz_123"
697        ));
698
699        // Cleanup
700        std::fs::remove_dir_all(&temp_dir).ok();
701    }
702
703    #[test]
704    fn test_is_framework_installed_nothing() {
705        // Non-existent CLI should return false
706        assert!(!is_framework_installed(None, "nonexistent_cli_xyz_123"));
707    }
708
709    #[test]
710    fn test_is_mi6_command() {
711        // Should match mi6 ingest commands
712        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  ")); // with whitespace
718
719        // Should NOT match other commands that happen to contain "mi6"
720        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")); // different executable
723        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        // Should preserve existing settings
757        assert_eq!(merged["theme"], "dark");
758        // Should merge hooks - both should exist
759        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        // This is the key test case from issue #440:
766        // User has custom hooks for an event, and mi6 enable should append
767        // its hook instead of replacing the user's hook.
768        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        // Should have both hooks for BeforeTool
782        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        // First hook should be the user's custom hook (preserved)
790        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        // Second hook should be mi6's hook (appended)
799        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        // When mi6 is re-enabled, it should replace the old mi6 hook
811        // instead of creating duplicates
812        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        // First hook should be the user's custom hook
832        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        // Second hook should be the NEW mi6 hook (not the old one)
841        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        // User might have multiple custom hooks for the same event
857        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        // Verify all custom loggers are preserved
882        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        // Theme should be preserved
914        assert_eq!(settings["theme"], "dark");
915        // Hooks should be removed (was only mi6 hooks)
916        assert!(settings.get("hooks").is_none());
917        // Only OTEL key removed, MY_VAR preserved
918        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        // Hooks object should still exist with the non-mi6 hook
944        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        // Should return None since no mi6 hooks were found
969        assert!(result.is_none());
970    }
971}