Skip to main content

nika_engine/runtime/
boot.rs

1//! Boot Sequence - 7-phase startup with progress reporting
2//!
3//! Phases:
4//! 1. Config discovery (find .nika/)
5//! 2. Config validation (parse config.toml)
6//! 3. Memory loading (load memory files)
7//! 4. Secrets loading (load from nika daemon or fallback)
8//! 5. MCP server startup (launch configured servers)
9//! 6. Provider validation (check API keys)
10//! 7. Ready state
11
12use 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/// Boot phase enumeration
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub enum BootPhase {
22    /// Phase 1: Finding .nika/ directory
23    ConfigDiscovery,
24    /// Phase 2: Parsing and validating config.toml
25    ConfigValidation,
26    /// Phase 3: Loading memory files
27    MemoryLoading,
28    /// Phase 4: Loading secrets from env vars + keychain
29    SecretsLoading,
30    /// Phase 5: Starting MCP servers
31    McpStartup,
32    /// Phase 6: Validating provider API keys
33    ProviderValidation,
34    /// Phase 7: System ready
35    Ready,
36}
37
38impl BootPhase {
39    /// Get human-readable phase name
40    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    /// Get snake_case identifier for event logging
53    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    /// Get phase number (1-7)
66    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    /// Get phase icon
79    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/// Phase result with timing
93#[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/// Boot context - accumulated state during boot
103#[derive(Debug, Clone, Default)]
104pub struct BootContext {
105    /// Root .nika/ directory
106    pub nika_dir: Option<PathBuf>,
107    /// Parsed configuration
108    pub config: Option<BootstrapConfig>,
109    /// Loaded memory context
110    pub memory: Option<HashMap<String, serde_json::Value>>,
111    /// Secrets loading result
112    pub secrets_loaded: Option<crate::secrets::SecretsLoadResult>,
113    /// Available MCP servers
114    pub mcp_servers: Vec<String>,
115    /// Available providers with API keys
116    pub providers: Vec<String>,
117    /// Phase results
118    pub phases: Vec<PhaseResult>,
119    /// Total boot duration
120    pub total_duration: Duration,
121}
122
123/// Nika configuration from config.toml
124#[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/// Policy configuration for security enforcement
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct PolicyConfig {
268    /// Allow exec: verb
269    #[serde(default = "default_true")]
270    pub allow_exec: bool,
271    /// Allow fetch: verb (network access)
272    #[serde(default = "default_true")]
273    pub allow_network: bool,
274    /// Blocked shell commands (substrings)
275    #[serde(default)]
276    pub blocked_commands: Vec<String>,
277    /// Maximum token spend per workflow run
278    pub max_token_spend: Option<u64>,
279    /// Allowed hosts for fetch:
280    #[serde(default)]
281    pub allowed_hosts: Vec<String>,
282    /// Blocked hosts for fetch:
283    #[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
304/// Boot sequence runner
305pub struct BootSequence {
306    start_dir: PathBuf,
307    verbose: bool,
308}
309
310impl BootSequence {
311    /// Create a new boot sequence starting from a directory
312    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    /// Enable verbose output
320    pub fn with_verbose(mut self, verbose: bool) -> Self {
321        self.verbose = verbose;
322        self
323    }
324
325    /// Run the full boot sequence
326    ///
327    /// Pass `Some(&event_log)` to emit `BootPhaseCompleted` events for each phase.
328    /// Pass `None` if the EventLog is not yet available.
329    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        // Helper closure to emit boot phase events
334        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        // Phase 1: Config Discovery
346        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        // Phase 2: Config Validation
360        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        // Phase 3: Memory Loading (optional, doesn't fail boot)
374        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        // Phase 4: Secrets Loading
379        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        // Phase 5: MCP Startup (optional, doesn't fail boot)
384        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        // Phase 6: Provider Validation (optional, doesn't fail boot)
389        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        // Phase 7: Ready
394        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        // Search for .nika/ directory
413        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        // Not found - use current directory
434        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, // Still succeeds, just with defaults
440            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            // Use defaults
466            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        // Parse config
478        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, // Proceed with defaults
498                        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        // Load memory (simplified - full implementation in memory_loader.rs)
547        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    /// Phase 4: Load secrets from env vars + keychain
559    async fn phase_secrets_loading(&self, ctx: &mut BootContext) -> PhaseResult {
560        let start = Instant::now();
561        let warnings = vec![];
562
563        // Load secrets from env vars (+ keychain if NIKA_KEYCHAIN_BOOT=1)
564        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, // Never fails boot, just warns
577            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        // MCP servers are started on-demand, not at boot
588        // This phase just checks for configured servers
589
590        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        // Check for API keys using KNOWN_PROVIDERS as source of truth
607        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    /// Check if boot was successful
637    pub fn is_ready(&self) -> bool {
638        self.phases
639            .iter()
640            .any(|p| p.phase == BootPhase::Ready && p.success)
641    }
642
643    /// Get all warnings from boot phases
644    pub fn all_warnings(&self) -> Vec<String> {
645        self.phases
646            .iter()
647            .flat_map(|p| p.warnings.clone())
648            .collect()
649    }
650
651    /// Format boot summary for display
652    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    // ─── Bug 14: provider validation must use KNOWN_PROVIDERS, not hardcoded ──
739
740    #[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        // Set xAI env var
748        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        // Restore
762        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        // Set empty env var
776        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        // Restore
790        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}