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