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
29#[derive(Debug, Clone)]
33pub struct InitOptions {
34 pub frameworks: Vec<String>,
37 pub local: bool,
39 pub settings_local: bool,
41 pub otel: bool,
43 pub otel_port: u16,
45 pub remove_otel: bool,
47 pub db_only: bool,
49 pub hooks_only: bool,
51}
52
53impl Default for InitOptions {
54 fn default() -> Self {
55 Self {
56 frameworks: vec![],
57 local: false,
58 settings_local: false,
59 otel: false,
60 otel_port: DEFAULT_OTEL_PORT,
61 remove_otel: false,
62 db_only: false,
63 hooks_only: false,
64 }
65 }
66}
67
68impl InitOptions {
69 pub fn new() -> Self {
71 Self::default()
72 }
73
74 pub fn for_framework(framework: impl Into<String>) -> Self {
76 Self {
77 frameworks: vec![framework.into()],
78 ..Default::default()
79 }
80 }
81
82 pub fn for_frameworks(frameworks: Vec<String>) -> Self {
84 Self {
85 frameworks,
86 ..Default::default()
87 }
88 }
89
90 pub fn get_frameworks(&self) -> Vec<String> {
92 self.frameworks.clone()
93 }
94
95 pub fn local(mut self, local: bool) -> Self {
97 self.local = local;
98 self
99 }
100
101 pub fn settings_local(mut self, settings_local: bool) -> Self {
103 self.settings_local = settings_local;
104 self
105 }
106
107 pub fn otel(mut self, otel: bool) -> Self {
109 self.otel = otel;
110 self
111 }
112
113 pub fn otel_port(mut self, port: u16) -> Self {
115 self.otel_port = port;
116 self
117 }
118
119 pub fn remove_otel(mut self, remove: bool) -> Self {
121 self.remove_otel = remove;
122 self
123 }
124
125 pub fn db_only(mut self, db_only: bool) -> Self {
127 self.db_only = db_only;
128 self
129 }
130
131 pub fn hooks_only(mut self, hooks_only: bool) -> Self {
133 self.hooks_only = hooks_only;
134 self
135 }
136}
137
138#[derive(Debug, Clone)]
142pub struct InitResult {
143 pub db_path: Option<PathBuf>,
145 pub settings_path: PathBuf,
147 pub hooks_config: serde_json::Value,
149}
150
151pub fn json_to_toml_string(json: &serde_json::Value) -> Result<String, InitError> {
155 let toml_value: toml::Value = serde_json::from_value(json.clone())
156 .map_err(|e| InitError::Config(format!("failed to convert JSON to TOML: {e}")))?;
157 toml::to_string_pretty(&toml_value)
158 .map_err(|e| InitError::Config(format!("failed to serialize TOML: {e}")))
159}
160
161pub fn initialize(config: &Config, options: InitOptions) -> Result<InitResult, InitError> {
169 let frameworks = options.get_frameworks();
170 let framework = frameworks
171 .first()
172 .cloned()
173 .unwrap_or_else(|| "claude".to_string());
174
175 initialize_framework(config, &framework, &options)
176}
177
178pub fn initialize_all(config: &Config, options: InitOptions) -> Result<Vec<InitResult>, InitError> {
183 let frameworks = options.get_frameworks();
184 let mut results = Vec::with_capacity(frameworks.len());
185
186 for framework in &frameworks {
187 let result = initialize_framework(config, framework, &options)?;
188 results.push(result);
189 }
190
191 Ok(results)
192}
193
194fn initialize_framework(
196 config: &Config,
197 framework: &str,
198 options: &InitOptions,
199) -> Result<InitResult, InitError> {
200 let adapter =
201 get_adapter(framework).ok_or_else(|| InitError::UnknownFramework(framework.to_string()))?;
202
203 let settings_path = adapter.settings_path(options.local, options.settings_local)?;
204 let enabled_events = config.hooks.enabled_hooks();
205 let hooks_json =
206 adapter.generate_hooks_config(&enabled_events, "mi6", options.otel, options.otel_port);
207
208 let otel_env = if options.otel {
209 Some(generate_otel_env(options.otel_port))
210 } else {
211 None
212 };
213
214 adapter.install_hooks(
215 &settings_path,
216 &hooks_json,
217 otel_env.clone(),
218 options.remove_otel,
219 )?;
220
221 let mut hooks_config = hooks_json;
223 if let Some(env) = otel_env {
224 hooks_config["env"] = env;
225 }
226
227 Ok(InitResult {
228 db_path: config.db_path().ok(),
229 settings_path,
230 hooks_config,
231 })
232}
233
234pub fn generate_config(
238 adapter: &dyn FrameworkAdapter,
239 config: &Config,
240 options: &InitOptions,
241) -> serde_json::Value {
242 let enabled_events = config.hooks.enabled_hooks();
243 let mut hooks_json =
244 adapter.generate_hooks_config(&enabled_events, "mi6", options.otel, options.otel_port);
245
246 if options.otel {
247 hooks_json["env"] = generate_otel_env(options.otel_port);
248 }
249
250 hooks_json
251}
252
253pub(crate) fn default_settings_path<A: FrameworkAdapter + ?Sized>(
259 adapter: &A,
260 local: bool,
261 settings_local: bool,
262) -> Result<PathBuf, InitError> {
263 if settings_local {
264 let project_path = adapter.project_config_path();
265 let file_name = project_path
266 .file_stem()
267 .and_then(|s| s.to_str())
268 .unwrap_or("settings");
269 let extension = project_path
270 .extension()
271 .and_then(|s| s.to_str())
272 .unwrap_or("json");
273 let local_name = format!("{file_name}.local.{extension}");
274 Ok(project_path.with_file_name(local_name))
275 } else if local {
276 Ok(adapter.project_config_path())
277 } else {
278 adapter
279 .user_config_path()
280 .ok_or_else(|| InitError::SettingsPath("failed to determine home directory".into()))
281 }
282}
283
284pub(crate) fn default_has_mi6_hooks<A: FrameworkAdapter + ?Sized>(
286 adapter: &A,
287 local: bool,
288 settings_local: bool,
289) -> bool {
290 let Ok(settings_path) = adapter.settings_path(local, settings_local) else {
291 return false;
292 };
293
294 if !settings_path.exists() {
295 return false;
296 }
297
298 match adapter.config_format() {
299 ConfigFormat::Json => {
300 let Ok(contents) = std::fs::read_to_string(&settings_path) else {
301 return false;
302 };
303 let Ok(json) = serde_json::from_str::<serde_json::Value>(&contents) else {
304 return false;
305 };
306 adapter.remove_hooks(json).is_some()
307 }
308 ConfigFormat::Toml => {
309 let Ok(contents) = std::fs::read_to_string(&settings_path) else {
310 return false;
311 };
312 let Ok(toml_val) = toml::from_str::<toml::Value>(&contents) else {
313 return false;
314 };
315 let Ok(json) = serde_json::to_value(toml_val) else {
316 return false;
317 };
318 adapter.remove_hooks(json).is_some()
319 }
320 }
321}
322
323pub(crate) fn default_install_hooks<A: FrameworkAdapter + ?Sized>(
325 adapter: &A,
326 path: &Path,
327 hooks: &serde_json::Value,
328 otel_env: Option<serde_json::Value>,
329 remove_otel: bool,
330) -> Result<(), InitError> {
331 if let Some(parent) = path.parent() {
333 std::fs::create_dir_all(parent)?;
334 }
335
336 match adapter.config_format() {
337 ConfigFormat::Json => install_json_settings(adapter, path, hooks, otel_env, remove_otel),
338 ConfigFormat::Toml => install_toml_settings(adapter, path, hooks, remove_otel),
339 }
340}
341
342fn install_json_settings<A: FrameworkAdapter + ?Sized>(
344 adapter: &A,
345 settings_path: &Path,
346 new_hooks: &serde_json::Value,
347 otel_env: Option<serde_json::Value>,
348 remove_otel: bool,
349) -> Result<(), InitError> {
350 let existing: Option<serde_json::Value> = if settings_path.exists() {
351 let contents = std::fs::read_to_string(settings_path)?;
352 serde_json::from_str(&contents).ok()
353 } else {
354 None
355 };
356
357 let mut settings = adapter.merge_config(new_hooks.clone(), existing);
358
359 if remove_otel {
360 if let Some(existing_env) = settings.get_mut("env")
361 && let Some(env_obj) = existing_env.as_object_mut()
362 {
363 for key in OTEL_ENV_KEYS {
364 env_obj.remove(*key);
365 }
366 }
367 } else if let Some(new_env) = otel_env {
368 if let Some(existing_env) = settings.get_mut("env") {
369 if let (Some(existing_obj), Some(new_obj)) =
370 (existing_env.as_object_mut(), new_env.as_object())
371 {
372 for (key, value) in new_obj {
373 existing_obj.insert(key.clone(), value.clone());
374 }
375 }
376 } else {
377 settings["env"] = new_env;
378 }
379 }
380
381 let output = serde_json::to_string_pretty(&settings)
382 .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}")))?;
383 std::fs::write(settings_path, output)?;
384
385 Ok(())
386}
387
388fn install_toml_settings<A: FrameworkAdapter + ?Sized>(
390 adapter: &A,
391 settings_path: &Path,
392 new_config: &serde_json::Value,
393 remove_otel: bool,
394) -> Result<(), InitError> {
395 let existing: Option<serde_json::Value> = if settings_path.exists() {
396 let contents = std::fs::read_to_string(settings_path)?;
397 toml::from_str::<toml::Value>(&contents)
398 .ok()
399 .and_then(|v| serde_json::to_value(v).ok())
400 } else {
401 None
402 };
403
404 let mut settings = adapter.merge_config(new_config.clone(), existing);
405
406 if remove_otel && let Some(obj) = settings.as_object_mut() {
407 obj.remove("otel");
408 }
409
410 let toml_str = json_to_toml_string(&settings)?;
411 std::fs::write(settings_path, toml_str)?;
412
413 Ok(())
414}
415
416pub(crate) fn default_serialize_config<A: FrameworkAdapter + ?Sized>(
418 adapter: &A,
419 config: &serde_json::Value,
420) -> Result<String, InitError> {
421 match adapter.config_format() {
422 ConfigFormat::Json => serde_json::to_string_pretty(config)
423 .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}"))),
424 ConfigFormat::Toml => json_to_toml_string(config),
425 }
426}
427
428pub(crate) fn default_uninstall_hooks<A: FrameworkAdapter + ?Sized>(
433 adapter: &A,
434 local: bool,
435 settings_local: bool,
436) -> Result<bool, InitError> {
437 let settings_path = adapter.settings_path(local, settings_local)?;
438
439 if !settings_path.exists() {
440 return Ok(false);
441 }
442
443 let contents = std::fs::read_to_string(&settings_path)?;
444
445 let existing: serde_json::Value = match adapter.config_format() {
446 ConfigFormat::Json => serde_json::from_str(&contents)
447 .map_err(|e| InitError::Config(format!("failed to parse JSON: {e}")))?,
448 ConfigFormat::Toml => {
449 let toml_val: toml::Value = toml::from_str(&contents)
450 .map_err(|e| InitError::Config(format!("failed to parse TOML: {e}")))?;
451 serde_json::to_value(toml_val)
452 .map_err(|e| InitError::Config(format!("failed to convert TOML to JSON: {e}")))?
453 }
454 };
455
456 if let Some(modified) = adapter.remove_hooks(existing) {
458 let output = match adapter.config_format() {
460 ConfigFormat::Json => serde_json::to_string_pretty(&modified)
461 .map_err(|e| InitError::Config(format!("failed to serialize JSON: {e}")))?,
462 ConfigFormat::Toml => json_to_toml_string(&modified)?,
463 };
464 std::fs::write(&settings_path, output)?;
465 Ok(true)
466 } else {
467 Ok(false)
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474 use crate::ClaudeAdapter;
475
476 #[test]
477 fn test_init_options_default() {
478 let opts = InitOptions::default();
479 assert!(opts.frameworks.is_empty());
480 assert!(!opts.local);
481 assert!(!opts.settings_local);
482 assert!(!opts.otel);
483 assert_eq!(opts.otel_port, 4318);
484 assert!(!opts.remove_otel);
485 }
486
487 #[test]
488 fn test_init_options_for_framework() {
489 let opts = InitOptions::for_framework("claude");
490 assert_eq!(opts.get_frameworks(), vec!["claude"]);
491 assert!(!opts.local);
492 assert!(!opts.otel);
493 }
494
495 #[test]
496 fn test_init_options_for_frameworks() {
497 let opts = InitOptions::for_frameworks(vec!["claude".into(), "gemini".into()]);
498 assert_eq!(opts.get_frameworks(), vec!["claude", "gemini"]);
499 }
500
501 #[test]
502 fn test_init_options_builder() {
503 let opts = InitOptions::for_framework("claude")
504 .local(true)
505 .otel(true)
506 .otel_port(9999);
507 assert_eq!(opts.get_frameworks(), vec!["claude"]);
508 assert!(opts.local);
509 assert!(opts.otel);
510 assert_eq!(opts.otel_port, 9999);
511 }
512
513 #[test]
514 fn test_init_error_unknown_framework() {
515 let config = Config::default();
516 let options = InitOptions::for_framework("unknown");
517
518 let result = initialize(&config, options);
519 assert!(matches!(result, Err(InitError::UnknownFramework(_))));
520 }
521
522 #[test]
523 fn test_settings_path_global() -> Result<(), String> {
524 let adapter = ClaudeAdapter;
525 let path = adapter
526 .settings_path(false, false)
527 .map_err(|e| e.to_string())?;
528 assert!(
530 path.to_string_lossy().contains("marketplace://"),
531 "expected marketplace URI, got: {}",
532 path.display()
533 );
534 Ok(())
535 }
536
537 #[test]
538 fn test_settings_path_local() -> Result<(), String> {
539 let adapter = ClaudeAdapter;
540 let path = adapter
541 .settings_path(true, false)
542 .map_err(|e| e.to_string())?;
543 assert!(
545 path.to_string_lossy().contains("marketplace://"),
546 "expected marketplace URI, got: {}",
547 path.display()
548 );
549 Ok(())
550 }
551
552 #[test]
553 fn test_settings_path_settings_local() -> Result<(), String> {
554 let adapter = crate::CodexAdapter;
557 let path = adapter
558 .settings_path(false, true)
559 .map_err(|e| e.to_string())?;
560 assert!(path.to_string_lossy().contains(".local."));
561 Ok(())
562 }
563
564 #[test]
565 fn test_json_to_toml_string() -> Result<(), String> {
566 let json = serde_json::json!({
567 "key": "value",
568 "number": 42
569 });
570 let toml_str = json_to_toml_string(&json).map_err(|e| e.to_string())?;
571 assert!(toml_str.contains("key = \"value\""));
572 assert!(toml_str.contains("number = 42"));
573 Ok(())
574 }
575
576 #[test]
577 fn test_generate_config() {
578 let config = Config::default();
579 let adapter = ClaudeAdapter;
580 let options = InitOptions::for_framework("claude")
581 .otel(true)
582 .otel_port(4318);
583
584 let config_json = generate_config(&adapter, &config, &options);
585 assert!(config_json["hooks"].is_object());
586 assert!(config_json["env"].is_object());
587 }
588
589 #[test]
590 fn test_serialize_config_json() -> Result<(), String> {
591 let adapter = ClaudeAdapter;
592 let config = serde_json::json!({"key": "value"});
593
594 let output = adapter
595 .serialize_config(&config)
596 .map_err(|e| e.to_string())?;
597 assert!(output.contains("\"key\""));
598 assert!(output.contains("\"value\""));
599 Ok(())
600 }
601
602 #[test]
603 fn test_serialize_config_toml() -> Result<(), String> {
604 let adapter = crate::CodexAdapter;
605 let config = serde_json::json!({"key": "value"});
606
607 let output = adapter
608 .serialize_config(&config)
609 .map_err(|e| e.to_string())?;
610 assert!(output.contains("key = \"value\""));
611 Ok(())
612 }
613}