mi6_core/framework/
install.rs

1//! Hook installation logic.
2//!
3//! This module provides programmatic access to hook installation,
4//! allowing Rust code to install mi6 hooks without going through the CLI.
5//!
6//! # Example
7//!
8//! ```ignore
9//! use mi6_core::{Config, framework::{InitOptions, initialize}};
10//!
11//! let config = Config::load()?;
12//!
13//! // Initialize for a single framework
14//! let options = InitOptions::for_framework("claude")
15//!     .otel(true)
16//!     .otel_port(4318);
17//!
18//! let result = initialize(&config, options)?;
19//! println!("Hooks installed to: {}", result.settings_path.display());
20//! ```
21
22use std::path::{Path, PathBuf};
23
24use crate::config::{Config, DEFAULT_OTEL_PORT, OTEL_ENV_KEYS, generate_otel_env};
25use crate::model::error::InitError;
26
27use super::{ConfigFormat, FrameworkAdapter, get_adapter};
28
29/// Write content to a file atomically using a temp file and rename.
30///
31/// This prevents race conditions where concurrent processes reading/writing
32/// the same file could see partial writes or clobber each other's changes.
33/// The rename operation is atomic on POSIX systems when source and destination
34/// are on the same filesystem.
35fn atomic_write(path: &Path, content: impl AsRef<[u8]>) -> std::io::Result<()> {
36    use std::io::Write;
37
38    // Create temp file in the same directory to ensure same filesystem for atomic rename
39    let parent = path.parent().unwrap_or(Path::new("."));
40    let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
41    temp_file.write_all(content.as_ref())?;
42    temp_file.flush()?;
43
44    // persist_noclobber would fail if target exists, so we use persist which overwrites
45    temp_file.persist(path).map_err(std::io::Error::other)?;
46
47    Ok(())
48}
49
50/// Options for hook initialization and activation.
51///
52/// This struct contains all the options needed to initialize mi6 programmatically.
53#[derive(Debug, Clone)]
54pub struct InitOptions {
55    /// Frameworks to install hooks for (e.g., `["claude", "gemini", "codex"]`).
56    /// If empty, auto-detection will be used.
57    pub frameworks: Vec<String>,
58    /// Install hooks to project config instead of global.
59    pub local: bool,
60    /// Install hooks to project local config (not committed to git).
61    pub settings_local: bool,
62    /// Configure OpenTelemetry env vars for token tracking.
63    pub otel: bool,
64    /// Port for OTel server.
65    pub otel_port: u16,
66    /// Remove OTel configuration (disables token tracking).
67    pub remove_otel: bool,
68    /// Only initialize database, skip hook installation (high-level API only).
69    pub db_only: bool,
70    /// Only install hooks, skip database initialization (high-level API only).
71    pub hooks_only: bool,
72}
73
74impl Default for InitOptions {
75    fn default() -> Self {
76        Self {
77            frameworks: vec![],
78            local: false,
79            settings_local: false,
80            otel: false,
81            otel_port: DEFAULT_OTEL_PORT,
82            remove_otel: false,
83            db_only: false,
84            hooks_only: false,
85        }
86    }
87}
88
89impl InitOptions {
90    /// Create new InitOptions with default values.
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    /// Create InitOptions for a single framework.
96    pub fn for_framework(framework: impl Into<String>) -> Self {
97        Self {
98            frameworks: vec![framework.into()],
99            ..Default::default()
100        }
101    }
102
103    /// Create InitOptions for multiple frameworks.
104    pub fn for_frameworks(frameworks: Vec<String>) -> Self {
105        Self {
106            frameworks,
107            ..Default::default()
108        }
109    }
110
111    /// Get the list of frameworks to initialize.
112    pub fn get_frameworks(&self) -> Vec<String> {
113        self.frameworks.clone()
114    }
115
116    /// Set install location to project config instead of global.
117    pub fn local(mut self, local: bool) -> Self {
118        self.local = local;
119        self
120    }
121
122    /// Set install location to project local config (not committed to git).
123    pub fn settings_local(mut self, settings_local: bool) -> Self {
124        self.settings_local = settings_local;
125        self
126    }
127
128    /// Enable OpenTelemetry configuration for token tracking.
129    pub fn otel(mut self, otel: bool) -> Self {
130        self.otel = otel;
131        self
132    }
133
134    /// Set the port for OTel server.
135    pub fn otel_port(mut self, port: u16) -> Self {
136        self.otel_port = port;
137        self
138    }
139
140    /// Remove OTel configuration (disables token tracking).
141    pub fn remove_otel(mut self, remove: bool) -> Self {
142        self.remove_otel = remove;
143        self
144    }
145
146    /// Only initialize database, skip hook installation.
147    pub fn db_only(mut self, db_only: bool) -> Self {
148        self.db_only = db_only;
149        self
150    }
151
152    /// Only install hooks, skip database initialization.
153    pub fn hooks_only(mut self, hooks_only: bool) -> Self {
154        self.hooks_only = hooks_only;
155        self
156    }
157}
158
159/// Result of initialization.
160///
161/// Contains information about what was initialized and where.
162#[derive(Debug, Clone)]
163pub struct InitResult {
164    /// Path to the database (if database initialization was requested).
165    pub db_path: Option<PathBuf>,
166    /// Path where settings/hooks were installed.
167    pub settings_path: PathBuf,
168    /// The hooks configuration that was generated.
169    pub hooks_config: serde_json::Value,
170}
171
172/// Convert a serde_json::Value to a TOML string.
173///
174/// Useful for frameworks that use TOML configuration files.
175pub fn json_to_toml_string(json: &serde_json::Value) -> Result<String, InitError> {
176    let toml_value: toml::Value = serde_json::from_value(json.clone())
177        .map_err(|e| InitError::Config(format!("failed to convert JSON to TOML: {e}")))?;
178    toml::to_string_pretty(&toml_value)
179        .map_err(|e| InitError::Config(format!("failed to serialize TOML: {e}")))
180}
181
182/// Initialize mi6 for a single framework.
183///
184/// This is the main entry point for programmatic initialization of a single
185/// framework. It:
186/// 1. Resolves the framework adapter
187/// 2. Generates the hooks configuration
188/// 3. Installs the configuration to the appropriate settings file
189pub fn initialize(config: &Config, options: InitOptions) -> Result<InitResult, InitError> {
190    let frameworks = options.get_frameworks();
191    let framework = frameworks
192        .first()
193        .cloned()
194        .unwrap_or_else(|| "claude".to_string());
195
196    initialize_framework(config, &framework, &options)
197}
198
199/// Initialize mi6 for multiple frameworks.
200///
201/// This function initializes mi6 for all specified frameworks, returning
202/// a result for each one.
203pub fn initialize_all(config: &Config, options: InitOptions) -> Result<Vec<InitResult>, InitError> {
204    let frameworks = options.get_frameworks();
205    let mut results = Vec::with_capacity(frameworks.len());
206
207    for framework in &frameworks {
208        let result = initialize_framework(config, framework, &options)?;
209        results.push(result);
210    }
211
212    Ok(results)
213}
214
215/// Initialize mi6 for a specific framework.
216pub fn initialize_framework(
217    config: &Config,
218    framework: &str,
219    options: &InitOptions,
220) -> Result<InitResult, InitError> {
221    let adapter =
222        get_adapter(framework).ok_or_else(|| InitError::UnknownFramework(framework.to_string()))?;
223
224    let settings_path = adapter.settings_path(options.local, options.settings_local)?;
225    let enabled_events = config.hooks.enabled_hooks();
226    let hooks_json =
227        adapter.generate_hooks_config(&enabled_events, "mi6", options.otel, options.otel_port);
228
229    let otel_env = if options.otel {
230        Some(generate_otel_env(options.otel_port))
231    } else {
232        None
233    };
234
235    adapter.install_hooks(
236        &settings_path,
237        &hooks_json,
238        otel_env.clone(),
239        options.remove_otel,
240    )?;
241
242    // Build final hooks_config for return value (include env if otel enabled)
243    let mut hooks_config = hooks_json;
244    if let Some(env) = otel_env {
245        hooks_config["env"] = env;
246    }
247
248    Ok(InitResult {
249        db_path: Config::db_path().ok(),
250        settings_path,
251        hooks_config,
252    })
253}
254
255/// Generate hooks configuration without writing to disk.
256///
257/// This is useful for previewing what would be installed (e.g., `mi6 enable --print`).
258pub fn generate_config(
259    adapter: &dyn FrameworkAdapter,
260    config: &Config,
261    options: &InitOptions,
262) -> serde_json::Value {
263    let enabled_events = config.hooks.enabled_hooks();
264    let mut hooks_json =
265        adapter.generate_hooks_config(&enabled_events, "mi6", options.otel, options.otel_port);
266
267    if options.otel {
268        hooks_json["env"] = generate_otel_env(options.otel_port);
269    }
270
271    hooks_json
272}
273
274// ============================================================================
275// Default trait method implementations
276// ============================================================================
277
278/// Resolve the settings path for a framework.
279pub(crate) fn default_settings_path<A: FrameworkAdapter + ?Sized>(
280    adapter: &A,
281    local: bool,
282    settings_local: bool,
283) -> Result<PathBuf, InitError> {
284    if settings_local {
285        let project_path = adapter.project_config_path();
286        let file_name = project_path
287            .file_stem()
288            .and_then(|s| s.to_str())
289            .unwrap_or("settings");
290        let extension = project_path
291            .extension()
292            .and_then(|s| s.to_str())
293            .unwrap_or("json");
294        let local_name = format!("{file_name}.local.{extension}");
295        Ok(project_path.with_file_name(local_name))
296    } else if local {
297        Ok(adapter.project_config_path())
298    } else {
299        adapter
300            .user_config_path()
301            .ok_or_else(|| InitError::SettingsPath("failed to determine home directory".into()))
302    }
303}
304
305/// Check if a framework has mi6 hooks active in its config file.
306pub(crate) fn default_has_mi6_hooks<A: FrameworkAdapter + ?Sized>(
307    adapter: &A,
308    local: bool,
309    settings_local: bool,
310) -> bool {
311    let Ok(settings_path) = adapter.settings_path(local, settings_local) else {
312        return false;
313    };
314
315    if !settings_path.exists() {
316        return false;
317    }
318
319    match adapter.config_format() {
320        ConfigFormat::Json => {
321            let Ok(contents) = std::fs::read_to_string(&settings_path) else {
322                return false;
323            };
324            let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
325                return false;
326            };
327            adapter.remove_hooks(json).is_some()
328        }
329        ConfigFormat::Toml => {
330            let Ok(contents) = std::fs::read_to_string(&settings_path) else {
331                return false;
332            };
333            let Ok(toml_val) = toml::from_str::<toml::Value>(&contents) else {
334                return false;
335            };
336            let Ok(json) = serde_json::to_value(toml_val) else {
337                return false;
338            };
339            adapter.remove_hooks(json).is_some()
340        }
341    }
342}
343
344/// Install hooks configuration to a settings file.
345pub(crate) fn default_install_hooks<A: FrameworkAdapter + ?Sized>(
346    adapter: &A,
347    path: &Path,
348    hooks: &serde_json::Value,
349    otel_env: Option<serde_json::Value>,
350    remove_otel: bool,
351) -> Result<(), InitError> {
352    // Create parent directory if needed
353    if let Some(parent) = path.parent() {
354        std::fs::create_dir_all(parent)?;
355    }
356
357    match adapter.config_format() {
358        ConfigFormat::Json => install_json_settings(adapter, path, hooks, otel_env, remove_otel),
359        ConfigFormat::Toml => install_toml_settings(adapter, path, hooks, remove_otel),
360    }
361}
362
363/// Install settings to a JSON configuration file.
364fn install_json_settings<A: FrameworkAdapter + ?Sized>(
365    adapter: &A,
366    settings_path: &Path,
367    new_hooks: &serde_json::Value,
368    otel_env: Option<serde_json::Value>,
369    remove_otel: bool,
370) -> Result<(), InitError> {
371    let existing: Option<serde_json::Value> = if settings_path.exists() {
372        let contents = std::fs::read_to_string(settings_path)?;
373        match serde_json::from_str(&contents) {
374            Ok(v) => Some(v),
375            Err(e) => {
376                return Err(InitError::InvalidSettings {
377                    path: settings_path.to_path_buf(),
378                    format: "JSON",
379                    error: e.to_string(),
380                });
381            }
382        }
383    } else {
384        None
385    };
386
387    let mut settings = adapter.merge_config(new_hooks.clone(), existing);
388
389    if remove_otel {
390        if let Some(existing_env) = settings.get_mut("env")
391            && let Some(env_obj) = existing_env.as_object_mut()
392        {
393            for key in OTEL_ENV_KEYS {
394                env_obj.remove(*key);
395            }
396        }
397    } else if let Some(new_env) = otel_env {
398        if let Some(existing_env) = settings.get_mut("env") {
399            if let (Some(existing_obj), Some(new_obj)) =
400                (existing_env.as_object_mut(), new_env.as_object())
401            {
402                for (key, value) in new_obj {
403                    existing_obj.insert(key.clone(), value.clone());
404                }
405            }
406        } else {
407            settings["env"] = new_env;
408        }
409    }
410
411    let output = serde_json::to_string_pretty(&settings)
412        .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}")))?;
413    atomic_write(settings_path, output)?;
414
415    Ok(())
416}
417
418/// Install settings to a TOML configuration file.
419fn install_toml_settings<A: FrameworkAdapter + ?Sized>(
420    adapter: &A,
421    settings_path: &Path,
422    new_config: &serde_json::Value,
423    remove_otel: bool,
424) -> Result<(), InitError> {
425    let existing: Option<serde_json::Value> =
426        if settings_path.exists() {
427            let contents = std::fs::read_to_string(settings_path)?;
428            match toml::from_str::<toml::Value>(&contents) {
429                Ok(toml_val) => Some(serde_json::to_value(toml_val).map_err(|e| {
430                    InitError::Config(format!("failed to convert TOML to JSON: {e}"))
431                })?),
432                Err(e) => {
433                    return Err(InitError::InvalidSettings {
434                        path: settings_path.to_path_buf(),
435                        format: "TOML",
436                        error: e.to_string(),
437                    });
438                }
439            }
440        } else {
441            None
442        };
443
444    let mut settings = adapter.merge_config(new_config.clone(), existing);
445
446    if remove_otel && let Some(obj) = settings.as_object_mut() {
447        obj.remove("otel");
448    }
449
450    let toml_str = json_to_toml_string(&settings)?;
451    atomic_write(settings_path, toml_str)?;
452
453    Ok(())
454}
455
456/// Serialize configuration to string in the appropriate format.
457pub(crate) fn default_serialize_config<A: FrameworkAdapter + ?Sized>(
458    adapter: &A,
459    config: &serde_json::Value,
460) -> Result<String, InitError> {
461    match adapter.config_format() {
462        ConfigFormat::Json => serde_json::to_string_pretty(config)
463            .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}"))),
464        ConfigFormat::Toml => json_to_toml_string(config),
465    }
466}
467
468/// Uninstall mi6 hooks from a framework's configuration.
469///
470/// This is the default implementation that removes mi6 hooks from a settings file.
471/// Frameworks with plugin-based installation (like Claude) should override this.
472pub(crate) fn default_uninstall_hooks<A: FrameworkAdapter + ?Sized>(
473    adapter: &A,
474    local: bool,
475    settings_local: bool,
476) -> Result<bool, InitError> {
477    let settings_path = adapter.settings_path(local, settings_local)?;
478
479    if !settings_path.exists() {
480        return Ok(false);
481    }
482
483    let contents = std::fs::read_to_string(&settings_path)?;
484
485    let existing: serde_json::Value = match adapter.config_format() {
486        ConfigFormat::Json => serde_json::from_str(&contents)
487            .map_err(|e| InitError::Config(format!("failed to parse JSON: {e}")))?,
488        ConfigFormat::Toml => {
489            let toml_val: toml::Value = toml::from_str(&contents)
490                .map_err(|e| InitError::Config(format!("failed to parse TOML: {e}")))?;
491            serde_json::to_value(toml_val)
492                .map_err(|e| InitError::Config(format!("failed to convert TOML to JSON: {e}")))?
493        }
494    };
495
496    // Try to remove mi6 hooks
497    if let Some(modified) = adapter.remove_hooks(existing) {
498        // Write back the modified config
499        let output = match adapter.config_format() {
500            ConfigFormat::Json => serde_json::to_string_pretty(&modified)
501                .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}")))?,
502            ConfigFormat::Toml => json_to_toml_string(&modified)?,
503        };
504        atomic_write(&settings_path, output)?;
505        Ok(true)
506    } else {
507        Ok(false)
508    }
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::ClaudeAdapter;
515
516    #[test]
517    fn test_init_options_default() {
518        let opts = InitOptions::default();
519        assert!(opts.frameworks.is_empty());
520        assert!(!opts.local);
521        assert!(!opts.settings_local);
522        assert!(!opts.otel);
523        assert_eq!(opts.otel_port, 4318);
524        assert!(!opts.remove_otel);
525    }
526
527    #[test]
528    fn test_init_options_for_framework() {
529        let opts = InitOptions::for_framework("claude");
530        assert_eq!(opts.get_frameworks(), vec!["claude"]);
531        assert!(!opts.local);
532        assert!(!opts.otel);
533    }
534
535    #[test]
536    fn test_init_options_for_frameworks() {
537        let opts = InitOptions::for_frameworks(vec!["claude".into(), "gemini".into()]);
538        assert_eq!(opts.get_frameworks(), vec!["claude", "gemini"]);
539    }
540
541    #[test]
542    fn test_init_options_builder() {
543        let opts = InitOptions::for_framework("claude")
544            .local(true)
545            .otel(true)
546            .otel_port(9999);
547        assert_eq!(opts.get_frameworks(), vec!["claude"]);
548        assert!(opts.local);
549        assert!(opts.otel);
550        assert_eq!(opts.otel_port, 9999);
551    }
552
553    #[test]
554    fn test_init_error_unknown_framework() {
555        let config = Config::default();
556        let options = InitOptions::for_framework("unknown");
557
558        let result = initialize(&config, options);
559        assert!(matches!(result, Err(InitError::UnknownFramework(_))));
560    }
561
562    #[test]
563    fn test_settings_path_global() -> Result<(), String> {
564        let adapter = ClaudeAdapter;
565        let path = adapter
566            .settings_path(false, false)
567            .map_err(|e| e.to_string())?;
568        // Claude returns the plugin directory path
569        assert!(
570            path.to_string_lossy().contains(".mi6/claude-plugin"),
571            "expected .mi6/claude-plugin path, got: {}",
572            path.display()
573        );
574        Ok(())
575    }
576
577    #[test]
578    fn test_settings_path_local() -> Result<(), String> {
579        let adapter = ClaudeAdapter;
580        let path = adapter
581            .settings_path(true, false)
582            .map_err(|e| e.to_string())?;
583        // Claude marketplace installation doesn't distinguish local vs global
584        assert!(
585            path.to_string_lossy().contains(".mi6/claude-plugin"),
586            "expected .mi6/claude-plugin path, got: {}",
587            path.display()
588        );
589        Ok(())
590    }
591
592    #[test]
593    fn test_settings_path_settings_local() -> Result<(), String> {
594        // For Claude plugins, settings_local is ignored (plugins don't have a .local variant)
595        // Test with Codex which still uses config files
596        let adapter = crate::CodexAdapter;
597        let path = adapter
598            .settings_path(false, true)
599            .map_err(|e| e.to_string())?;
600        assert!(path.to_string_lossy().contains(".local."));
601        Ok(())
602    }
603
604    #[test]
605    fn test_json_to_toml_string() -> Result<(), String> {
606        let json = serde_json::json!({
607            "key": "value",
608            "number": 42
609        });
610        let toml_str = json_to_toml_string(&json).map_err(|e| e.to_string())?;
611        assert!(toml_str.contains("key = \"value\""));
612        assert!(toml_str.contains("number = 42"));
613        Ok(())
614    }
615
616    #[test]
617    fn test_generate_config() {
618        let config = Config::default();
619        let adapter = ClaudeAdapter;
620        let options = InitOptions::for_framework("claude")
621            .otel(true)
622            .otel_port(4318);
623
624        let config_json = generate_config(&adapter, &config, &options);
625        assert!(config_json["hooks"].is_object());
626        assert!(config_json["env"].is_object());
627    }
628
629    #[test]
630    fn test_serialize_config_json() -> Result<(), String> {
631        let adapter = ClaudeAdapter;
632        let config = serde_json::json!({"key": "value"});
633
634        let output = adapter
635            .serialize_config(&config)
636            .map_err(|e| e.to_string())?;
637        assert!(output.contains("\"key\""));
638        assert!(output.contains("\"value\""));
639        Ok(())
640    }
641
642    #[test]
643    fn test_serialize_config_toml() -> Result<(), String> {
644        let adapter = crate::CodexAdapter;
645        let config = serde_json::json!({"key": "value"});
646
647        let output = adapter
648            .serialize_config(&config)
649            .map_err(|e| e.to_string())?;
650        assert!(output.contains("key = \"value\""));
651        Ok(())
652    }
653
654    #[test]
655    fn test_install_json_settings_invalid_json_error() {
656        let temp_dir = tempfile::tempdir().unwrap();
657        let settings_path = temp_dir.path().join("settings.json");
658
659        // Write invalid JSON (missing comma)
660        std::fs::write(&settings_path, r#"{"key": "value" "another": "bad"}"#).unwrap();
661
662        let adapter = ClaudeAdapter;
663        let hooks = serde_json::json!({"hooks": {}});
664
665        let result = install_json_settings(&adapter, &settings_path, &hooks, None, false);
666
667        match result {
668            Err(InitError::InvalidSettings {
669                path,
670                format,
671                error,
672            }) => {
673                assert_eq!(path, settings_path);
674                assert_eq!(format, "JSON");
675                assert!(!error.is_empty());
676            }
677            other => panic!("expected InvalidSettings error, got: {other:?}"),
678        }
679    }
680
681    #[test]
682    fn test_install_json_settings_valid_json_succeeds() {
683        let temp_dir = tempfile::tempdir().unwrap();
684        let settings_path = temp_dir.path().join("settings.json");
685
686        // Write valid JSON
687        std::fs::write(&settings_path, r#"{"existing": "config"}"#).unwrap();
688
689        // Use GeminiAdapter which properly merges existing config
690        let adapter = crate::GeminiAdapter;
691        let hooks = serde_json::json!({"hooks": {"PreToolUse": "mi6 ingest event"}});
692
693        let result = install_json_settings(&adapter, &settings_path, &hooks, None, false);
694        assert!(result.is_ok(), "expected success, got: {result:?}");
695
696        // Verify existing config is preserved
697        let contents = std::fs::read_to_string(&settings_path).unwrap();
698        let parsed: serde_json::Value = serde_json::from_str(&contents).unwrap();
699        assert_eq!(parsed["existing"], "config");
700        assert!(parsed["hooks"].is_object());
701    }
702
703    #[test]
704    fn test_install_toml_settings_invalid_toml_error() {
705        let temp_dir = tempfile::tempdir().unwrap();
706        let settings_path = temp_dir.path().join("config.toml");
707
708        // Write invalid TOML (unquoted string value)
709        std::fs::write(&settings_path, "key = bad value without quotes").unwrap();
710
711        let adapter = crate::CodexAdapter;
712        let config = serde_json::json!({"hooks": {}});
713
714        let result = install_toml_settings(&adapter, &settings_path, &config, false);
715
716        match result {
717            Err(InitError::InvalidSettings {
718                path,
719                format,
720                error,
721            }) => {
722                assert_eq!(path, settings_path);
723                assert_eq!(format, "TOML");
724                assert!(!error.is_empty());
725            }
726            other => panic!("expected InvalidSettings error, got: {other:?}"),
727        }
728    }
729
730    #[test]
731    fn test_install_toml_settings_valid_toml_succeeds() {
732        let temp_dir = tempfile::tempdir().unwrap();
733        let settings_path = temp_dir.path().join("config.toml");
734
735        // Write valid TOML
736        std::fs::write(&settings_path, r#"existing = "config""#).unwrap();
737
738        let adapter = crate::CodexAdapter;
739        let config = serde_json::json!({"notify": "command"});
740
741        let result = install_toml_settings(&adapter, &settings_path, &config, false);
742        assert!(result.is_ok(), "expected success, got: {result:?}");
743
744        // Verify file was updated
745        let contents = std::fs::read_to_string(&settings_path).unwrap();
746        assert!(contents.contains("existing"));
747        assert!(contents.contains("notify"));
748    }
749
750    #[test]
751    fn test_install_json_settings_nonexistent_file_succeeds() {
752        let temp_dir = tempfile::tempdir().unwrap();
753        let settings_path = temp_dir.path().join("settings.json");
754
755        // Don't create the file - it shouldn't exist
756
757        let adapter = ClaudeAdapter;
758        let hooks = serde_json::json!({"hooks": {"PreToolUse": "mi6 ingest event"}});
759
760        let result = install_json_settings(&adapter, &settings_path, &hooks, None, false);
761        assert!(
762            result.is_ok(),
763            "expected success for nonexistent file, got: {result:?}"
764        );
765
766        // Verify file was created
767        assert!(settings_path.exists());
768    }
769
770    #[test]
771    fn test_install_toml_settings_nonexistent_file_succeeds() {
772        let temp_dir = tempfile::tempdir().unwrap();
773        let settings_path = temp_dir.path().join("config.toml");
774
775        // Don't create the file - it shouldn't exist
776
777        let adapter = crate::CodexAdapter;
778        let config = serde_json::json!({"notify": "command"});
779
780        let result = install_toml_settings(&adapter, &settings_path, &config, false);
781        assert!(
782            result.is_ok(),
783            "expected success for nonexistent file, got: {result:?}"
784        );
785
786        // Verify file was created
787        assert!(settings_path.exists());
788    }
789}