mi6_core/
enable.rs

1//! High-level enable API for programmatic hook installation.
2//!
3//! This module provides a simple, high-level API for enabling mi6 hooks
4//! that can be used by other Rust programs (like mi6) without depending
5//! on the CLI crate.
6//!
7//! # Example
8//!
9//! ```ignore
10//! use mi6_core::{InitOptions, enable};
11//!
12//! // Enable with auto-detection
13//! let result = enable(InitOptions::default())?;
14//!
15//! for framework in &result.frameworks {
16//!     println!("Enabled {} at {}", framework.name, framework.settings_path.display());
17//! }
18//!
19//! // Enable specific frameworks with OTel
20//! let result = enable(
21//!     InitOptions::for_frameworks(vec!["claude".into(), "gemini".into()])
22//!         .otel(true)
23//!         .otel_port(4318)
24//! )?;
25//! ```
26
27use std::path::PathBuf;
28
29use crate::config::Config;
30use crate::framework::{
31    ConfigFormat, FrameworkAdapter, FrameworkResolutionMode, InitOptions, all_adapters,
32    generate_config, initialize_framework, resolve_frameworks,
33};
34use crate::model::error::{FrameworkResolutionError, InitError};
35
36/// Result of enabling a single framework.
37#[derive(Debug, Clone)]
38pub struct FrameworkEnablement {
39    /// The framework name (e.g., "claude", "gemini", "codex").
40    pub name: String,
41    /// The display name (e.g., "Claude Code", "Gemini CLI").
42    pub display_name: String,
43    /// Path where hooks were installed.
44    pub settings_path: PathBuf,
45    /// The hooks configuration that was installed.
46    pub hooks_config: serde_json::Value,
47}
48
49/// Result of a failed framework enable attempt.
50#[derive(Debug, Clone)]
51pub struct FrameworkFailure {
52    /// The framework name (e.g., "claude", "gemini", "codex").
53    pub name: String,
54    /// Error message describing what went wrong.
55    pub error: String,
56}
57
58/// Result of the enable process.
59#[derive(Debug, Clone)]
60pub struct EnableResult {
61    /// Frameworks that were enabled.
62    pub frameworks: Vec<FrameworkEnablement>,
63    /// Frameworks that failed to enable.
64    pub failures: Vec<FrameworkFailure>,
65    /// Path to the database (if database initialization was requested).
66    pub db_path: Option<PathBuf>,
67    /// Whether the database should be initialized.
68    pub should_init_db: bool,
69}
70
71/// Error during enable.
72#[derive(Debug)]
73pub enum EnableError {
74    /// No frameworks detected or specified.
75    NoFrameworks {
76        /// List of all supported frameworks.
77        supported: Vec<String>,
78    },
79    /// Unknown framework name.
80    UnknownFramework(String),
81    /// Configuration error.
82    Config(String),
83    /// Initialization error from the core library.
84    Init(InitError),
85}
86
87impl std::fmt::Display for EnableError {
88    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
89        match self {
90            Self::NoFrameworks { supported } => {
91                write!(
92                    f,
93                    "no supported AI coding frameworks detected.\n\
94                     Supported frameworks: {}\n\
95                     Install one first, or specify explicitly.",
96                    supported.join(", ")
97                )
98            }
99            Self::UnknownFramework(name) => write!(f, "unknown framework: {name}"),
100            Self::Config(msg) => write!(f, "configuration error: {msg}"),
101            Self::Init(e) => write!(f, "{e}"),
102        }
103    }
104}
105
106impl std::error::Error for EnableError {
107    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
108        match self {
109            Self::Init(e) => Some(e),
110            _ => None,
111        }
112    }
113}
114
115impl From<InitError> for EnableError {
116    fn from(e: InitError) -> Self {
117        Self::Init(e)
118    }
119}
120
121impl From<FrameworkResolutionError> for EnableError {
122    fn from(e: FrameworkResolutionError) -> Self {
123        match e {
124            FrameworkResolutionError::NoFrameworksFound(_) => Self::NoFrameworks {
125                supported: all_adapters()
126                    .iter()
127                    .map(|a| a.name().to_string())
128                    .collect(),
129            },
130            FrameworkResolutionError::UnknownFramework(name) => Self::UnknownFramework(name),
131        }
132    }
133}
134
135/// Result of previewing enable for a single framework.
136#[derive(Debug, Clone)]
137pub struct PreviewResult {
138    /// The framework name (e.g., "claude", "gemini", "codex").
139    pub name: String,
140    /// The hooks configuration that would be installed.
141    pub hooks_config: serde_json::Value,
142    /// The config format for this framework (JSON or TOML).
143    pub config_format: ConfigFormat,
144}
145
146/// Enable mi6 hooks for AI coding frameworks.
147///
148/// This is the main entry point for programmatic enabling. It:
149/// 1. Resolves which frameworks to enable (auto-detect or explicit)
150/// 2. Installs hooks for each framework
151///
152/// Note: Port conflict checking should be done by the caller using
153/// `mi6_otel_server::is_server_running()` and `mi6_otel_server::find_available_port()`
154/// before calling this function if OTel is enabled.
155///
156/// # Example
157/// ```ignore
158/// use mi6_core::{InitOptions, enable};
159///
160/// let result = enable(InitOptions::for_framework("claude").otel(true))?;
161/// ```
162pub fn enable(options: InitOptions) -> Result<EnableResult, EnableError> {
163    let config = Config::load().map_err(|e| EnableError::Config(e.to_string()))?;
164
165    let mut result = EnableResult {
166        frameworks: Vec::new(),
167        failures: Vec::new(),
168        db_path: None,
169        should_init_db: !options.hooks_only,
170    };
171
172    if result.should_init_db {
173        result.db_path = Config::db_path().ok();
174    }
175
176    if options.db_only {
177        return Ok(result);
178    }
179
180    let adapters = resolve_frameworks(
181        &options.frameworks,
182        Some(FrameworkResolutionMode::Installed),
183    )?;
184
185    // Try to enable each framework individually, collecting successes and failures
186    for adapter in adapters {
187        match enable_single_framework(&config, adapter, &options) {
188            Ok(enablement) => {
189                result.frameworks.push(enablement);
190            }
191            Err(e) => {
192                result.failures.push(FrameworkFailure {
193                    name: adapter.name().to_string(),
194                    error: e.to_string(),
195                });
196            }
197        }
198    }
199
200    // If all frameworks failed, return an error
201    if result.frameworks.is_empty() && !result.failures.is_empty() {
202        // Return the first error for backwards compatibility when all fail
203        let first_failure = &result.failures[0];
204        return Err(EnableError::Config(first_failure.error.clone()));
205    }
206
207    Ok(result)
208}
209
210/// Enable mi6 for a single framework adapter.
211fn enable_single_framework(
212    config: &Config,
213    adapter: &dyn FrameworkAdapter,
214    options: &InitOptions,
215) -> Result<FrameworkEnablement, InitError> {
216    let init_result = initialize_framework(config, adapter.name(), options)?;
217
218    Ok(FrameworkEnablement {
219        name: adapter.name().to_string(),
220        display_name: adapter.display_name().to_string(),
221        settings_path: init_result.settings_path,
222        hooks_config: init_result.hooks_config,
223    })
224}
225
226/// Generate hooks configuration without installing.
227///
228/// This is useful for previewing what would be installed (e.g., `--print` mode).
229pub fn preview_enable(options: &InitOptions) -> Result<Vec<PreviewResult>, EnableError> {
230    let config = Config::load().map_err(|e| EnableError::Config(e.to_string()))?;
231    let adapters = resolve_frameworks(
232        &options.frameworks,
233        Some(FrameworkResolutionMode::Installed),
234    )?;
235
236    Ok(adapters
237        .into_iter()
238        .map(|adapter| PreviewResult {
239            name: adapter.name().to_string(),
240            hooks_config: generate_config(adapter, &config, options),
241            config_format: adapter.config_format(),
242        })
243        .collect())
244}
245
246/// Result of disabling a single framework.
247#[derive(Debug, Clone)]
248pub struct FrameworkDisablement {
249    /// The framework name (e.g., "claude", "gemini", "codex").
250    pub name: String,
251    /// The display name (e.g., "Claude Code", "Gemini CLI").
252    pub display_name: String,
253    /// Path where hooks were removed from.
254    pub settings_path: PathBuf,
255    /// Whether hooks were actually removed (false if no mi6 hooks were present).
256    pub hooks_removed: bool,
257}
258
259/// Result of the disable process.
260#[derive(Debug, Clone)]
261pub struct DisableResult {
262    /// Frameworks that were disabled.
263    pub frameworks: Vec<FrameworkDisablement>,
264}
265
266/// Disable mi6 hooks for AI coding frameworks.
267///
268/// This removes mi6 hooks from the specified frameworks' configuration files.
269/// For plugin-based frameworks (like Claude Code), this removes the plugin directory.
270/// If no frameworks are specified, it will auto-detect installed frameworks
271/// that have mi6 hooks enabled.
272///
273/// # Example
274/// ```ignore
275/// use mi6_core::{InitOptions, disable};
276///
277/// // Disable all frameworks with mi6 hooks
278/// let result = disable(&[])?;
279///
280/// // Disable specific framework
281/// let result = disable(&["claude"])?;
282/// ```
283pub fn disable(frameworks: &[&str]) -> Result<DisableResult, EnableError> {
284    disable_with_options(frameworks, false, false)
285}
286
287/// Disable mi6 hooks with specific options.
288///
289/// # Arguments
290/// * `frameworks` - Framework names to disable, or empty to auto-detect
291/// * `local` - If true, disable from project-level config instead of user-level
292/// * `settings_local` - If true, disable from the `.local` variant
293pub fn disable_with_options(
294    frameworks: &[&str],
295    local: bool,
296    settings_local: bool,
297) -> Result<DisableResult, EnableError> {
298    use crate::framework::get_adapter;
299
300    let adapters = if frameworks.is_empty() {
301        // Auto-detect frameworks with mi6 hooks
302        all_adapters()
303            .into_iter()
304            .filter(|a| a.has_mi6_hooks(local, settings_local))
305            .collect::<Vec<_>>()
306    } else {
307        // Use specified frameworks
308        let mut adapters = Vec::new();
309        for name in frameworks {
310            let adapter = get_adapter(name)
311                .ok_or_else(|| EnableError::UnknownFramework((*name).to_string()))?;
312            adapters.push(adapter);
313        }
314        adapters
315    };
316
317    let mut result = DisableResult {
318        frameworks: Vec::new(),
319    };
320
321    for adapter in adapters {
322        let settings_path = match adapter.settings_path(local, settings_local) {
323            Ok(path) => path,
324            Err(_) => continue,
325        };
326
327        // Use the uninstall_hooks method which handles both config-based and plugin-based frameworks
328        let hooks_removed = adapter
329            .uninstall_hooks(local, settings_local)
330            .unwrap_or_default();
331
332        result.frameworks.push(FrameworkDisablement {
333            name: adapter.name().to_string(),
334            display_name: adapter.display_name().to_string(),
335            settings_path,
336            hooks_removed,
337        });
338    }
339
340    Ok(result)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_enable_error_display() {
349        let err = EnableError::NoFrameworks {
350            supported: vec!["claude".into(), "gemini".into()],
351        };
352        assert!(
353            err.to_string()
354                .contains("no supported AI coding frameworks")
355        );
356
357        let err = EnableError::UnknownFramework("foo".into());
358        assert!(err.to_string().contains("unknown framework: foo"));
359    }
360
361    #[test]
362    fn test_enable_error_from_framework_resolution_error() {
363        let err: EnableError = FrameworkResolutionError::UnknownFramework("foo".to_string()).into();
364        assert!(matches!(err, EnableError::UnknownFramework(name) if name == "foo"));
365
366        let err: EnableError =
367            FrameworkResolutionError::NoFrameworksFound("no frameworks".to_string()).into();
368        assert!(matches!(err, EnableError::NoFrameworks { .. }));
369    }
370
371    #[test]
372    fn test_framework_failure_struct() {
373        let failure = FrameworkFailure {
374            name: "codex".to_string(),
375            error: "config.toml has invalid TOML syntax".to_string(),
376        };
377        assert_eq!(failure.name, "codex");
378        assert!(failure.error.contains("invalid TOML"));
379    }
380
381    #[test]
382    fn test_enable_result_has_failures_field() {
383        let result = EnableResult {
384            frameworks: vec![],
385            failures: vec![FrameworkFailure {
386                name: "codex".to_string(),
387                error: "some error".to_string(),
388            }],
389            db_path: None,
390            should_init_db: true,
391        };
392        assert!(result.frameworks.is_empty());
393        assert_eq!(result.failures.len(), 1);
394        assert_eq!(result.failures[0].name, "codex");
395    }
396
397    #[test]
398    fn test_enable_result_with_mixed_success_failure() {
399        let result = EnableResult {
400            frameworks: vec![FrameworkEnablement {
401                name: "claude".to_string(),
402                display_name: "Claude Code".to_string(),
403                settings_path: PathBuf::from("/home/user/.claude/settings.json"),
404                hooks_config: serde_json::json!({}),
405            }],
406            failures: vec![FrameworkFailure {
407                name: "codex".to_string(),
408                error: "config error".to_string(),
409            }],
410            db_path: None,
411            should_init_db: true,
412        };
413        assert_eq!(result.frameworks.len(), 1);
414        assert_eq!(result.frameworks[0].name, "claude");
415        assert_eq!(result.failures.len(), 1);
416        assert_eq!(result.failures[0].name, "codex");
417    }
418}