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 directory
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        && path.exists()
306    {
307        return true;
308    }
309    // Check if CLI is in PATH
310    which::which(cli_name).is_ok()
311}
312
313/// Check if a command string is an mi6 hook command.
314///
315/// Uses specific prefix matching to avoid false positives with user commands
316/// that might coincidentally contain "mi6" in their path or arguments.
317pub fn is_mi6_command(cmd: &str) -> bool {
318    let cmd = cmd.trim();
319    // Match commands that start with "mi6 ingest" or have a path ending in "/mi6 ingest"
320    cmd.starts_with("mi6 ingest") || cmd.contains("/mi6 ingest")
321}
322
323/// Check if a hook entry contains an mi6 command (nested hooks structure).
324///
325/// This handles the hook format used by Claude and Gemini:
326/// ```json
327/// { "matcher": "", "hooks": [{ "command": "mi6 ingest event ..." }] }
328/// ```
329fn 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
342/// Merge generated hooks into existing JSON settings, preserving user hooks.
343///
344/// This function handles the common pattern of merging hook configurations:
345/// - For each event type, appends mi6 hooks to existing user hooks
346/// - If an mi6 hook already exists for an event, it is replaced (not duplicated)
347/// - User hooks (non-mi6) are never removed or modified
348/// - Preserves all other settings
349///
350/// # Hook Structure
351///
352/// This function handles the nested hooks format used by Claude and Gemini:
353/// ```json
354/// {
355///   "hooks": {
356///     "BeforeTool": [
357///       { "matcher": "", "hooks": [{ "command": "user-tool" }] },
358///       { "matcher": "", "hooks": [{ "command": "mi6 ingest event BeforeTool" }] }
359///     ]
360///   }
361/// }
362/// ```
363///
364/// # Arguments
365/// * `generated` - The generated hooks configuration (must have "hooks" key)
366/// * `existing` - Optional existing settings content
367///
368/// # Returns
369/// The merged settings JSON with user hooks preserved
370pub 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    // Ensure hooks object exists
381    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                // Remove any existing mi6 hooks to avoid duplicates
397                existing_arr.retain(|entry| !is_mi6_hook_nested(entry));
398
399                // Append the new mi6 hooks
400                for hook in new_hooks_arr {
401                    existing_arr.push(hook.clone());
402                }
403            }
404        } else {
405            // Event type doesn't exist, add the entire array
406            existing_hooks.insert(event_type.clone(), new_hook_array.clone());
407        }
408    }
409
410    settings
411}
412
413/// Remove mi6 hooks from JSON settings.
414///
415/// This function handles the common pattern of removing mi6-related hooks:
416/// - Iterates over all event types in the hooks object
417/// - Filters out entries that contain "mi6" in their command
418/// - Removes empty arrays and objects
419/// - Optionally removes OTel environment variables
420///
421/// # Arguments
422/// * `existing` - The existing settings content
423/// * `otel_keys` - List of OTel-related environment variable names to remove
424///
425/// # Returns
426/// Some(modified settings) if any mi6 hooks were removed, None otherwise
427pub 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    // Remove only mi6 hooks from settings, preserving user's custom hooks
435    if let Some(hooks) = settings.get_mut("hooks")
436        && let Some(hooks_obj) = hooks.as_object_mut()
437    {
438        // Process each event type
439        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                // Filter out entries that contain mi6 hooks
445                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                // Remove the event key entirely if array is now empty
453                if arr.is_empty() {
454                    hooks_obj.remove(&key);
455                }
456            }
457        }
458
459        // If hooks object is now empty, remove it entirely
460        if hooks_obj.is_empty()
461            && let Some(obj) = settings.as_object_mut()
462        {
463            obj.remove("hooks");
464        }
465    }
466
467    // Remove OTel env vars
468    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 object is now empty, remove it entirely
478        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    // ========================================================================
494    // ParsedHookInputBuilder tests
495    // ========================================================================
496
497    #[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        // cwd present - should use it
586        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        // cwd missing - should use fallback
598        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        // duration_ms present
612        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        // Only duration present (fallback)
623        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        // Empty array
663        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        // Missing field
671        let result = get_first_array_element(&json, "missing");
672        assert_eq!(result, None);
673    }
674
675    // ========================================================================
676    // Existing tests
677    // ========================================================================
678
679    #[test]
680    fn test_is_framework_installed_cli_in_path() {
681        // "ls" should be in PATH on any Unix system
682        assert!(is_framework_installed(None, "ls"));
683    }
684
685    #[test]
686    fn test_is_framework_installed_config_dir_exists() {
687        // Create a temp directory to simulate a config directory (e.g., ~/.claude)
688        let temp_dir = std::env::temp_dir().join("mi6_test_config_dir");
689        std::fs::create_dir_all(&temp_dir).unwrap();
690
691        // Config dir exists, CLI doesn't matter
692        assert!(is_framework_installed(
693            Some(temp_dir.clone()),
694            "nonexistent_cli_xyz_123"
695        ));
696
697        // Cleanup
698        std::fs::remove_dir_all(&temp_dir).ok();
699    }
700
701    #[test]
702    fn test_is_framework_installed_config_dir_not_exists() {
703        // When config directory doesn't exist and CLI not in PATH, should return false
704        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        // Non-existent CLI should return false
714        assert!(!is_framework_installed(None, "nonexistent_cli_xyz_123"));
715    }
716
717    #[test]
718    fn test_is_mi6_command() {
719        // Should match mi6 ingest commands
720        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  ")); // with whitespace
726
727        // Should NOT match other commands that happen to contain "mi6"
728        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")); // different executable
731        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        // Should preserve existing settings
765        assert_eq!(merged["theme"], "dark");
766        // Should merge hooks - both should exist
767        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        // This is the key test case from issue #440:
774        // User has custom hooks for an event, and mi6 enable should append
775        // its hook instead of replacing the user's hook.
776        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        // Should have both hooks for BeforeTool
790        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        // First hook should be the user's custom hook (preserved)
798        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        // Second hook should be mi6's hook (appended)
807        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        // When mi6 is re-enabled, it should replace the old mi6 hook
819        // instead of creating duplicates
820        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        // First hook should be the user's custom hook
840        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        // Second hook should be the NEW mi6 hook (not the old one)
849        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        // User might have multiple custom hooks for the same event
865        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        // Verify all custom loggers are preserved
890        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        // Theme should be preserved
922        assert_eq!(settings["theme"], "dark");
923        // Hooks should be removed (was only mi6 hooks)
924        assert!(settings.get("hooks").is_none());
925        // Only OTEL key removed, MY_VAR preserved
926        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        // Hooks object should still exist with the non-mi6 hook
952        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        // Should return None since no mi6 hooks were found
977        assert!(result.is_none());
978    }
979}