1use 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
31fn atomic_write(path: &Path, content: impl AsRef<[u8]>) -> std::io::Result<()> {
38 use std::io::Write;
39
40 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 temp_file.persist(path).map_err(std::io::Error::other)?;
48
49 Ok(())
50}
51
52#[derive(Debug, Clone)]
56pub struct InitOptions {
57 pub frameworks: Vec<String>,
60 pub local: bool,
62 pub settings_local: bool,
64 pub otel: bool,
66 pub otel_port: u16,
68 pub remove_otel: bool,
70 pub db_only: bool,
72 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 pub fn new() -> Self {
94 Self::default()
95 }
96
97 pub fn for_framework(framework: impl Into<String>) -> Self {
99 Self {
100 frameworks: vec![framework.into()],
101 ..Default::default()
102 }
103 }
104
105 pub fn for_frameworks(frameworks: Vec<String>) -> Self {
107 Self {
108 frameworks,
109 ..Default::default()
110 }
111 }
112
113 pub fn get_frameworks(&self) -> Vec<String> {
115 self.frameworks.clone()
116 }
117
118 pub fn local(mut self, local: bool) -> Self {
120 self.local = local;
121 self
122 }
123
124 pub fn settings_local(mut self, settings_local: bool) -> Self {
126 self.settings_local = settings_local;
127 self
128 }
129
130 pub fn otel(mut self, otel: bool) -> Self {
132 self.otel = otel;
133 self
134 }
135
136 pub fn otel_port(mut self, port: u16) -> Self {
138 self.otel_port = port;
139 self
140 }
141
142 pub fn remove_otel(mut self, remove: bool) -> Self {
144 self.remove_otel = remove;
145 self
146 }
147
148 pub fn db_only(mut self, db_only: bool) -> Self {
150 self.db_only = db_only;
151 self
152 }
153
154 pub fn hooks_only(mut self, hooks_only: bool) -> Self {
156 self.hooks_only = hooks_only;
157 self
158 }
159}
160
161#[derive(Debug, Clone)]
165pub struct InitResult {
166 pub db_path: Option<PathBuf>,
168 pub settings_path: PathBuf,
170 pub hooks_config: serde_json::Value,
172 pub commands_run: Vec<String>,
174}
175
176pub 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
186pub 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
203pub 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
219pub 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 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
260pub 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
279pub(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
310pub(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
349pub(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 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
368fn 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 Ok(InstallHooksResult::default())
422}
423
424fn 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 Ok(InstallHooksResult::default())
461}
462
463pub(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
475pub(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 if let Some(modified) = adapter.remove_hooks(existing) {
508 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 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 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 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 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 std::fs::write(&settings_path, r#"{"existing": "config"}"#).unwrap();
704
705 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 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 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 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 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 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 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 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 assert!(settings_path.exists());
804 }
805}