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::{ConfigFormat, FrameworkAdapter, get_adapter};
28
29fn atomic_write(path: &Path, content: impl AsRef<[u8]>) -> std::io::Result<()> {
36 use std::io::Write;
37
38 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 temp_file.persist(path).map_err(std::io::Error::other)?;
46
47 Ok(())
48}
49
50#[derive(Debug, Clone)]
54pub struct InitOptions {
55 pub frameworks: Vec<String>,
58 pub local: bool,
60 pub settings_local: bool,
62 pub otel: bool,
64 pub otel_port: u16,
66 pub remove_otel: bool,
68 pub db_only: bool,
70 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 pub fn new() -> Self {
92 Self::default()
93 }
94
95 pub fn for_framework(framework: impl Into<String>) -> Self {
97 Self {
98 frameworks: vec![framework.into()],
99 ..Default::default()
100 }
101 }
102
103 pub fn for_frameworks(frameworks: Vec<String>) -> Self {
105 Self {
106 frameworks,
107 ..Default::default()
108 }
109 }
110
111 pub fn get_frameworks(&self) -> Vec<String> {
113 self.frameworks.clone()
114 }
115
116 pub fn local(mut self, local: bool) -> Self {
118 self.local = local;
119 self
120 }
121
122 pub fn settings_local(mut self, settings_local: bool) -> Self {
124 self.settings_local = settings_local;
125 self
126 }
127
128 pub fn otel(mut self, otel: bool) -> Self {
130 self.otel = otel;
131 self
132 }
133
134 pub fn otel_port(mut self, port: u16) -> Self {
136 self.otel_port = port;
137 self
138 }
139
140 pub fn remove_otel(mut self, remove: bool) -> Self {
142 self.remove_otel = remove;
143 self
144 }
145
146 pub fn db_only(mut self, db_only: bool) -> Self {
148 self.db_only = db_only;
149 self
150 }
151
152 pub fn hooks_only(mut self, hooks_only: bool) -> Self {
154 self.hooks_only = hooks_only;
155 self
156 }
157}
158
159#[derive(Debug, Clone)]
163pub struct InitResult {
164 pub db_path: Option<PathBuf>,
166 pub settings_path: PathBuf,
168 pub hooks_config: serde_json::Value,
170}
171
172pub 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
182pub 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
199pub 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
215pub 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 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
255pub 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
274pub(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
305pub(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
344pub(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 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
363fn 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
418fn 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
456pub(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
468pub(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 if let Some(modified) = adapter.remove_hooks(existing) {
498 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 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 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 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 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 std::fs::write(&settings_path, r#"{"existing": "config"}"#).unwrap();
688
689 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 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 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 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 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 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 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 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 assert!(settings_path.exists());
788 }
789}