1use crate::error::NikaError;
13use crate::event::{EventKind, EventLog};
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::time::{Duration, Instant};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub enum BootPhase {
22 ConfigDiscovery,
24 ConfigValidation,
26 MemoryLoading,
28 SecretsLoading,
30 McpStartup,
32 ProviderValidation,
34 Ready,
36}
37
38impl BootPhase {
39 pub fn name(&self) -> &'static str {
41 match self {
42 Self::ConfigDiscovery => "Config Discovery",
43 Self::ConfigValidation => "Config Validation",
44 Self::MemoryLoading => "Memory Loading",
45 Self::SecretsLoading => "Secrets Loading",
46 Self::McpStartup => "MCP Startup",
47 Self::ProviderValidation => "Provider Validation",
48 Self::Ready => "Ready",
49 }
50 }
51
52 pub fn snake_case_name(&self) -> &'static str {
54 match self {
55 Self::ConfigDiscovery => "config_discovery",
56 Self::ConfigValidation => "config_validation",
57 Self::MemoryLoading => "memory_loading",
58 Self::SecretsLoading => "secrets_loading",
59 Self::McpStartup => "mcp_startup",
60 Self::ProviderValidation => "provider_validation",
61 Self::Ready => "ready",
62 }
63 }
64
65 pub fn number(&self) -> u8 {
67 match self {
68 Self::ConfigDiscovery => 1,
69 Self::ConfigValidation => 2,
70 Self::MemoryLoading => 3,
71 Self::SecretsLoading => 4,
72 Self::McpStartup => 5,
73 Self::ProviderValidation => 6,
74 Self::Ready => 7,
75 }
76 }
77
78 pub fn icon(&self) -> &'static str {
80 match self {
81 Self::ConfigDiscovery => "🔍",
82 Self::ConfigValidation => "✅",
83 Self::MemoryLoading => "📚",
84 Self::SecretsLoading => "🔐",
85 Self::McpStartup => "🔌",
86 Self::ProviderValidation => "🔑",
87 Self::Ready => "🚀",
88 }
89 }
90}
91
92#[derive(Debug, Clone)]
94pub struct PhaseResult {
95 pub phase: BootPhase,
96 pub success: bool,
97 pub duration: Duration,
98 pub message: Option<String>,
99 pub warnings: Vec<String>,
100}
101
102#[derive(Debug, Clone, Default)]
104pub struct BootContext {
105 pub nika_dir: Option<PathBuf>,
107 pub config: Option<BootstrapConfig>,
109 pub memory: Option<HashMap<String, serde_json::Value>>,
111 pub secrets_loaded: Option<crate::secrets::SecretsLoadResult>,
113 pub mcp_servers: Vec<String>,
115 pub providers: Vec<String>,
117 pub phases: Vec<PhaseResult>,
119 pub total_duration: Duration,
121}
122
123#[derive(Debug, Clone, Default, Serialize, Deserialize)]
125pub struct BootstrapConfig {
126 #[serde(default)]
127 pub tools: ToolsConfig,
128 #[serde(default)]
129 pub provider: ProviderConfig,
130 #[serde(default)]
131 pub editor: EditorConfig,
132 #[serde(default)]
133 pub session: SessionConfig,
134 #[serde(default)]
135 pub trace: TraceConfig,
136 #[serde(default)]
137 pub policy: PolicyConfig,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct ToolsConfig {
142 #[serde(default = "default_permission")]
143 pub permission: String,
144 pub working_dir: Option<String>,
145}
146
147impl Default for ToolsConfig {
148 fn default() -> Self {
149 Self {
150 permission: default_permission(),
151 working_dir: None,
152 }
153 }
154}
155
156fn default_permission() -> String {
157 "plan".to_string()
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct ProviderConfig {
162 #[serde(default = "default_provider")]
163 pub default: String,
164 pub model: Option<String>,
165}
166
167impl Default for ProviderConfig {
168 fn default() -> Self {
169 Self {
170 default: default_provider(),
171 model: None,
172 }
173 }
174}
175
176fn default_provider() -> String {
177 "claude".to_string()
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct EditorConfig {
182 #[serde(default = "default_theme")]
183 pub theme: String,
184 #[serde(default = "default_tab_width")]
185 pub tab_width: u8,
186 #[serde(default = "default_true")]
187 pub auto_format: bool,
188}
189
190impl Default for EditorConfig {
191 fn default() -> Self {
192 Self {
193 theme: default_theme(),
194 tab_width: default_tab_width(),
195 auto_format: true,
196 }
197 }
198}
199
200fn default_theme() -> String {
201 "solarized".to_string()
202}
203
204fn default_tab_width() -> u8 {
205 2
206}
207
208fn default_true() -> bool {
209 true
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
213pub struct SessionConfig {
214 #[serde(default = "default_true")]
215 pub auto_restore: bool,
216 #[serde(default = "default_max_sessions")]
217 pub max_sessions: u32,
218 #[serde(default = "default_session_ttl")]
219 pub session_ttl_days: u32,
220}
221
222impl Default for SessionConfig {
223 fn default() -> Self {
224 Self {
225 auto_restore: true,
226 max_sessions: default_max_sessions(),
227 session_ttl_days: default_session_ttl(),
228 }
229 }
230}
231
232fn default_max_sessions() -> u32 {
233 50
234}
235
236fn default_session_ttl() -> u32 {
237 7
238}
239
240#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct TraceConfig {
242 #[serde(default = "default_retention")]
243 pub retention_days: u32,
244 #[serde(default = "default_max_traces")]
245 pub max_traces: u32,
246}
247
248impl Default for TraceConfig {
249 fn default() -> Self {
250 Self {
251 retention_days: default_retention(),
252 max_traces: default_max_traces(),
253 }
254 }
255}
256
257fn default_retention() -> u32 {
258 7
259}
260
261fn default_max_traces() -> u32 {
262 100
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct PolicyConfig {
268 #[serde(default = "default_true")]
270 pub allow_exec: bool,
271 #[serde(default = "default_true")]
273 pub allow_network: bool,
274 #[serde(default)]
276 pub blocked_commands: Vec<String>,
277 pub max_token_spend: Option<u64>,
279 #[serde(default)]
281 pub allowed_hosts: Vec<String>,
282 #[serde(default)]
284 pub blocked_hosts: Vec<String>,
285}
286
287impl Default for PolicyConfig {
288 fn default() -> Self {
289 Self {
290 allow_exec: true,
291 allow_network: true,
292 blocked_commands: vec![
293 "rm -rf /".to_string(),
294 "sudo".to_string(),
295 "chmod 777".to_string(),
296 ],
297 max_token_spend: None,
298 allowed_hosts: vec![],
299 blocked_hosts: vec![],
300 }
301 }
302}
303
304pub struct BootSequence {
306 start_dir: PathBuf,
307 verbose: bool,
308}
309
310impl BootSequence {
311 pub fn new(start_dir: impl AsRef<Path>) -> Self {
313 Self {
314 start_dir: start_dir.as_ref().to_path_buf(),
315 verbose: false,
316 }
317 }
318
319 pub fn with_verbose(mut self, verbose: bool) -> Self {
321 self.verbose = verbose;
322 self
323 }
324
325 pub async fn run(&self, event_log: Option<&EventLog>) -> Result<BootContext, NikaError> {
330 let boot_start = Instant::now();
331 let mut ctx = BootContext::default();
332
333 let emit_phase = |log: Option<&EventLog>, result: &PhaseResult| {
335 if let Some(log) = log {
336 log.emit(EventKind::BootPhaseCompleted {
337 phase: result.phase.snake_case_name().to_string(),
338 success: result.success,
339 duration_ms: result.duration.as_millis() as u64,
340 warnings: result.warnings.clone(),
341 });
342 }
343 };
344
345 let phase_result = self.phase_config_discovery(&mut ctx).await;
347 ctx.phases.push(phase_result.clone());
348 emit_phase(event_log, &phase_result);
349 if !phase_result.success {
350 ctx.total_duration = boot_start.elapsed();
351 return Err(NikaError::BootFailed {
352 phase: phase_result.phase.name().to_string(),
353 reason: phase_result
354 .message
355 .unwrap_or_else(|| "Config discovery failed".into()),
356 });
357 }
358
359 let phase_result = self.phase_config_validation(&mut ctx).await;
361 ctx.phases.push(phase_result.clone());
362 emit_phase(event_log, &phase_result);
363 if !phase_result.success {
364 ctx.total_duration = boot_start.elapsed();
365 return Err(NikaError::BootFailed {
366 phase: phase_result.phase.name().to_string(),
367 reason: phase_result
368 .message
369 .unwrap_or_else(|| "Config validation failed".into()),
370 });
371 }
372
373 let phase_result = self.phase_memory_loading(&mut ctx).await;
375 emit_phase(event_log, &phase_result);
376 ctx.phases.push(phase_result);
377
378 let phase_result = self.phase_secrets_loading(&mut ctx).await;
380 emit_phase(event_log, &phase_result);
381 ctx.phases.push(phase_result);
382
383 let phase_result = self.phase_mcp_startup(&mut ctx).await;
385 emit_phase(event_log, &phase_result);
386 ctx.phases.push(phase_result);
387
388 let phase_result = self.phase_provider_validation(&mut ctx).await;
390 emit_phase(event_log, &phase_result);
391 ctx.phases.push(phase_result);
392
393 let ready_result = PhaseResult {
395 phase: BootPhase::Ready,
396 success: true,
397 duration: Duration::ZERO,
398 message: Some("Boot complete".into()),
399 warnings: vec![],
400 };
401 emit_phase(event_log, &ready_result);
402 ctx.phases.push(ready_result);
403
404 ctx.total_duration = boot_start.elapsed();
405 Ok(ctx)
406 }
407
408 async fn phase_config_discovery(&self, ctx: &mut BootContext) -> PhaseResult {
409 let start = Instant::now();
410 let mut warnings = vec![];
411
412 let mut dir = self.start_dir.as_path();
414 loop {
415 let nika_dir = dir.join(".nika");
416 if nika_dir.exists() && nika_dir.is_dir() {
417 ctx.nika_dir = Some(nika_dir);
418 return PhaseResult {
419 phase: BootPhase::ConfigDiscovery,
420 success: true,
421 duration: start.elapsed(),
422 message: Some(format!("Found .nika/ at {}", dir.display())),
423 warnings,
424 };
425 }
426
427 match dir.parent() {
428 Some(parent) => dir = parent,
429 None => break,
430 }
431 }
432
433 warnings.push("No .nika/ directory found, using defaults".into());
435 ctx.nika_dir = Some(self.start_dir.join(".nika"));
436
437 PhaseResult {
438 phase: BootPhase::ConfigDiscovery,
439 success: true, duration: start.elapsed(),
441 message: Some("Using default configuration".into()),
442 warnings,
443 }
444 }
445
446 async fn phase_config_validation(&self, ctx: &mut BootContext) -> PhaseResult {
447 let start = Instant::now();
448 let mut warnings = vec![];
449
450 let nika_dir = match &ctx.nika_dir {
451 Some(dir) => dir.clone(),
452 None => {
453 return PhaseResult {
454 phase: BootPhase::ConfigValidation,
455 success: false,
456 duration: start.elapsed(),
457 message: Some("No .nika directory".into()),
458 warnings,
459 };
460 }
461 };
462
463 let config_path = nika_dir.join("config.toml");
464 if !config_path.exists() {
465 ctx.config = Some(BootstrapConfig::default());
467 warnings.push("config.toml not found, using defaults".into());
468 return PhaseResult {
469 phase: BootPhase::ConfigValidation,
470 success: true,
471 duration: start.elapsed(),
472 message: Some("Using default configuration".into()),
473 warnings,
474 };
475 }
476
477 match tokio::fs::read_to_string(&config_path).await {
479 Ok(content) => match toml::from_str::<BootstrapConfig>(&content) {
480 Ok(config) => {
481 ctx.config = Some(config);
482 PhaseResult {
483 phase: BootPhase::ConfigValidation,
484 success: true,
485 duration: start.elapsed(),
486 message: Some("Configuration loaded".into()),
487 warnings,
488 }
489 }
490 Err(e) => {
491 let msg = format!("Config parse error in {}: {}", config_path.display(), e);
492 tracing::error!("{}", msg);
493 warnings.push(msg);
494 ctx.config = Some(BootstrapConfig::default());
495 PhaseResult {
496 phase: BootPhase::ConfigValidation,
497 success: true, duration: start.elapsed(),
499 message: Some("Using defaults due to parse error".into()),
500 warnings,
501 }
502 }
503 },
504 Err(e) => {
505 warnings.push(format!("Config read error: {}", e));
506 ctx.config = Some(BootstrapConfig::default());
507 PhaseResult {
508 phase: BootPhase::ConfigValidation,
509 success: true,
510 duration: start.elapsed(),
511 message: Some("Using defaults due to read error".into()),
512 warnings,
513 }
514 }
515 }
516 }
517
518 async fn phase_memory_loading(&self, ctx: &mut BootContext) -> PhaseResult {
519 let start = Instant::now();
520 let warnings = vec![];
521
522 let nika_dir = match &ctx.nika_dir {
523 Some(dir) => dir.clone(),
524 None => {
525 return PhaseResult {
526 phase: BootPhase::MemoryLoading,
527 success: true,
528 duration: start.elapsed(),
529 message: Some("Skipped (no .nika/)".into()),
530 warnings,
531 };
532 }
533 };
534
535 let memory_path = nika_dir.join("memory.yaml");
536 if !memory_path.exists() {
537 return PhaseResult {
538 phase: BootPhase::MemoryLoading,
539 success: true,
540 duration: start.elapsed(),
541 message: Some("No memory.yaml".into()),
542 warnings,
543 };
544 }
545
546 ctx.memory = Some(HashMap::new());
548
549 PhaseResult {
550 phase: BootPhase::MemoryLoading,
551 success: true,
552 duration: start.elapsed(),
553 message: Some("Memory loaded".into()),
554 warnings,
555 }
556 }
557
558 async fn phase_secrets_loading(&self, ctx: &mut BootContext) -> PhaseResult {
560 let start = Instant::now();
561 let warnings = vec![];
562
563 let result = crate::secrets::load_from_daemon_or_fallback().await;
565
566 let message = format!(
567 "{} secrets loaded ({})",
568 result.total_loaded(),
569 result.summary()
570 );
571
572 ctx.secrets_loaded = Some(result);
573
574 PhaseResult {
575 phase: BootPhase::SecretsLoading,
576 success: true, duration: start.elapsed(),
578 message: Some(message),
579 warnings,
580 }
581 }
582
583 async fn phase_mcp_startup(&self, _ctx: &mut BootContext) -> PhaseResult {
584 let start = Instant::now();
585 let warnings = vec![];
586
587 PhaseResult {
591 phase: BootPhase::McpStartup,
592 success: true,
593 duration: start.elapsed(),
594 message: Some("MCP servers ready (on-demand)".into()),
595 warnings,
596 }
597 }
598
599 async fn phase_provider_validation(&self, ctx: &mut BootContext) -> PhaseResult {
600 use crate::core::{ProviderCategory, KNOWN_PROVIDERS};
601
602 let start = Instant::now();
603 let mut warnings = vec![];
604 let mut providers = vec![];
605
606 for p in KNOWN_PROVIDERS
608 .iter()
609 .filter(|p| p.category == ProviderCategory::Llm)
610 {
611 if std::env::var(p.env_var)
612 .map(|v| !v.is_empty())
613 .unwrap_or(false)
614 {
615 providers.push(p.id.to_string());
616 }
617 }
618
619 if providers.is_empty() {
620 warnings.push("No API keys found".into());
621 }
622
623 ctx.providers = providers.clone();
624
625 PhaseResult {
626 phase: BootPhase::ProviderValidation,
627 success: true,
628 duration: start.elapsed(),
629 message: Some(format!("{} provider(s) available", providers.len())),
630 warnings,
631 }
632 }
633}
634
635impl BootContext {
636 pub fn is_ready(&self) -> bool {
638 self.phases
639 .iter()
640 .any(|p| p.phase == BootPhase::Ready && p.success)
641 }
642
643 pub fn all_warnings(&self) -> Vec<String> {
645 self.phases
646 .iter()
647 .flat_map(|p| p.warnings.clone())
648 .collect()
649 }
650
651 pub fn summary(&self) -> String {
653 let mut lines = vec![format!("Boot completed in {:?}", self.total_duration)];
654
655 for phase in &self.phases {
656 let status = if phase.success { "✓" } else { "✗" };
657 lines.push(format!(
658 " {} {} {} ({:?})",
659 status,
660 phase.phase.icon(),
661 phase.phase.name(),
662 phase.duration
663 ));
664 for warning in &phase.warnings {
665 lines.push(format!(" ⚠ {}", warning));
666 }
667 }
668
669 lines.join("\n")
670 }
671}
672
673#[cfg(test)]
674mod tests {
675 use super::*;
676 use tempfile::tempdir;
677
678 #[test]
679 fn test_boot_phase_properties() {
680 assert_eq!(BootPhase::ConfigDiscovery.number(), 1);
681 assert_eq!(BootPhase::SecretsLoading.number(), 4);
682 assert_eq!(BootPhase::Ready.number(), 7);
683 assert_eq!(BootPhase::McpStartup.name(), "MCP Startup");
684 assert_eq!(BootPhase::SecretsLoading.icon(), "🔐");
685 }
686
687 #[test]
688 fn test_default_config() {
689 let config = BootstrapConfig::default();
690 assert_eq!(config.tools.permission, "plan");
691 assert_eq!(config.provider.default, "claude");
692 assert_eq!(config.editor.theme, "solarized");
693 assert!(config.policy.allow_exec);
694 }
695
696 #[test]
697 fn test_policy_defaults() {
698 let policy = PolicyConfig::default();
699 assert!(policy.allow_exec);
700 assert!(policy.allow_network);
701 assert!(policy.blocked_commands.contains(&"sudo".to_string()));
702 }
703
704 #[tokio::test]
705 async fn test_boot_sequence_no_nika_dir() {
706 let temp = tempdir().unwrap();
707 let boot = BootSequence::new(temp.path());
708 let ctx = boot.run(None).await.unwrap();
709
710 assert!(ctx.is_ready());
711 assert!(ctx.all_warnings().iter().any(|w| w.contains("No .nika/")));
712 }
713
714 #[tokio::test]
715 async fn test_boot_sequence_with_config() {
716 let temp = tempdir().unwrap();
717 let nika_dir = temp.path().join(".nika");
718 std::fs::create_dir_all(&nika_dir).unwrap();
719
720 let config_content = r#"
721[tools]
722permission = "accept-edits"
723
724[provider]
725default = "openai"
726"#;
727 std::fs::write(nika_dir.join("config.toml"), config_content).unwrap();
728
729 let boot = BootSequence::new(temp.path());
730 let ctx = boot.run(None).await.unwrap();
731
732 assert!(ctx.is_ready());
733 let config = ctx.config.unwrap();
734 assert_eq!(config.tools.permission, "accept-edits");
735 assert_eq!(config.provider.default, "openai");
736 }
737
738 #[tokio::test]
741 async fn test_provider_validation_detects_xai() {
742 let temp = tempdir().unwrap();
743 let nika_dir = temp.path().join(".nika");
744 std::fs::create_dir_all(&nika_dir).unwrap();
745 std::fs::write(nika_dir.join("config.toml"), "").unwrap();
746
747 let key = "XAI_API_KEY";
749 let original = std::env::var(key).ok();
750 std::env::set_var(key, "xai-test-key-12345");
751
752 let boot = BootSequence::new(temp.path());
753 let ctx = boot.run(None).await.unwrap();
754
755 assert!(
756 ctx.providers.contains(&"xai".to_string()),
757 "Provider validation must detect xAI, got: {:?}",
758 ctx.providers
759 );
760
761 match original {
763 Some(v) => std::env::set_var(key, v),
764 None => unsafe { std::env::remove_var(key) },
765 }
766 }
767
768 #[tokio::test]
769 async fn test_provider_validation_ignores_empty_env_var() {
770 let temp = tempdir().unwrap();
771 let nika_dir = temp.path().join(".nika");
772 std::fs::create_dir_all(&nika_dir).unwrap();
773 std::fs::write(nika_dir.join("config.toml"), "").unwrap();
774
775 let key = "MISTRAL_API_KEY";
777 let original = std::env::var(key).ok();
778 std::env::set_var(key, "");
779
780 let boot = BootSequence::new(temp.path());
781 let ctx = boot.run(None).await.unwrap();
782
783 assert!(
784 !ctx.providers.contains(&"mistral".to_string()),
785 "Provider validation must ignore empty env vars, got: {:?}",
786 ctx.providers
787 );
788
789 match original {
791 Some(v) => std::env::set_var(key, v),
792 None => unsafe { std::env::remove_var(key) },
793 }
794 }
795
796 #[test]
797 fn test_boot_context_summary() {
798 let ctx = BootContext {
799 phases: vec![
800 PhaseResult {
801 phase: BootPhase::ConfigDiscovery,
802 success: true,
803 duration: Duration::from_millis(5),
804 message: Some("Found".into()),
805 warnings: vec![],
806 },
807 PhaseResult {
808 phase: BootPhase::Ready,
809 success: true,
810 duration: Duration::ZERO,
811 message: Some("Ready".into()),
812 warnings: vec![],
813 },
814 ],
815 total_duration: Duration::from_millis(10),
816 ..Default::default()
817 };
818
819 let summary = ctx.summary();
820 assert!(summary.contains("Boot completed"));
821 assert!(summary.contains("Config Discovery"));
822 }
823}