use crate::error::NikaError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum BootPhase {
ConfigDiscovery,
ConfigValidation,
MemoryLoading,
SecretsLoading,
McpStartup,
ProviderValidation,
Ready,
}
impl BootPhase {
pub fn name(&self) -> &'static str {
match self {
Self::ConfigDiscovery => "Config Discovery",
Self::ConfigValidation => "Config Validation",
Self::MemoryLoading => "Memory Loading",
Self::SecretsLoading => "Secrets Loading",
Self::McpStartup => "MCP Startup",
Self::ProviderValidation => "Provider Validation",
Self::Ready => "Ready",
}
}
pub fn number(&self) -> u8 {
match self {
Self::ConfigDiscovery => 1,
Self::ConfigValidation => 2,
Self::MemoryLoading => 3,
Self::SecretsLoading => 4,
Self::McpStartup => 5,
Self::ProviderValidation => 6,
Self::Ready => 7,
}
}
pub fn icon(&self) -> &'static str {
match self {
Self::ConfigDiscovery => "🔍",
Self::ConfigValidation => "✅",
Self::MemoryLoading => "📚",
Self::SecretsLoading => "🔐",
Self::McpStartup => "🔌",
Self::ProviderValidation => "🔑",
Self::Ready => "🚀",
}
}
}
#[derive(Debug, Clone)]
pub struct PhaseResult {
pub phase: BootPhase,
pub success: bool,
pub duration: Duration,
pub message: Option<String>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct BootContext {
pub nika_dir: Option<PathBuf>,
pub config: Option<BootstrapConfig>,
pub memory: Option<HashMap<String, serde_json::Value>>,
pub secrets_loaded: Option<crate::secrets::SecretsLoadResult>,
pub mcp_servers: Vec<String>,
pub providers: Vec<String>,
pub phases: Vec<PhaseResult>,
pub total_duration: Duration,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct BootstrapConfig {
#[serde(default)]
pub tools: ToolsConfig,
#[serde(default)]
pub provider: ProviderConfig,
#[serde(default)]
pub editor: EditorConfig,
#[serde(default)]
pub session: SessionConfig,
#[serde(default)]
pub trace: TraceConfig,
#[serde(default)]
pub policy: PolicyConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsConfig {
#[serde(default = "default_permission")]
pub permission: String,
pub working_dir: Option<String>,
}
impl Default for ToolsConfig {
fn default() -> Self {
Self {
permission: default_permission(),
working_dir: None,
}
}
}
fn default_permission() -> String {
"plan".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
#[serde(default = "default_provider")]
pub default: String,
pub model: Option<String>,
}
impl Default for ProviderConfig {
fn default() -> Self {
Self {
default: default_provider(),
model: None,
}
}
}
fn default_provider() -> String {
"claude".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditorConfig {
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default = "default_tab_width")]
pub tab_width: u8,
#[serde(default = "default_true")]
pub auto_format: bool,
}
impl Default for EditorConfig {
fn default() -> Self {
Self {
theme: default_theme(),
tab_width: default_tab_width(),
auto_format: true,
}
}
}
fn default_theme() -> String {
"solarized".to_string()
}
fn default_tab_width() -> u8 {
2
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionConfig {
#[serde(default = "default_true")]
pub auto_restore: bool,
#[serde(default = "default_max_sessions")]
pub max_sessions: u32,
#[serde(default = "default_session_ttl")]
pub session_ttl_days: u32,
}
impl Default for SessionConfig {
fn default() -> Self {
Self {
auto_restore: true,
max_sessions: default_max_sessions(),
session_ttl_days: default_session_ttl(),
}
}
}
fn default_max_sessions() -> u32 {
50
}
fn default_session_ttl() -> u32 {
7
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceConfig {
#[serde(default = "default_retention")]
pub retention_days: u32,
#[serde(default = "default_max_traces")]
pub max_traces: u32,
}
impl Default for TraceConfig {
fn default() -> Self {
Self {
retention_days: default_retention(),
max_traces: default_max_traces(),
}
}
}
fn default_retention() -> u32 {
7
}
fn default_max_traces() -> u32 {
100
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PolicyConfig {
#[serde(default = "default_true")]
pub allow_exec: bool,
#[serde(default = "default_true")]
pub allow_network: bool,
#[serde(default)]
pub blocked_commands: Vec<String>,
pub max_token_spend: Option<u64>,
#[serde(default)]
pub allowed_hosts: Vec<String>,
#[serde(default)]
pub blocked_hosts: Vec<String>,
}
impl Default for PolicyConfig {
fn default() -> Self {
Self {
allow_exec: true,
allow_network: true,
blocked_commands: vec![
"rm -rf /".to_string(),
"sudo".to_string(),
"chmod 777".to_string(),
],
max_token_spend: None,
allowed_hosts: vec![],
blocked_hosts: vec![],
}
}
}
pub struct BootSequence {
start_dir: PathBuf,
verbose: bool,
}
impl BootSequence {
pub fn new(start_dir: impl AsRef<Path>) -> Self {
Self {
start_dir: start_dir.as_ref().to_path_buf(),
verbose: false,
}
}
pub fn with_verbose(mut self, verbose: bool) -> Self {
self.verbose = verbose;
self
}
pub async fn run(&self) -> Result<BootContext, NikaError> {
let boot_start = Instant::now();
let mut ctx = BootContext::default();
let phase_result = self.phase_config_discovery(&mut ctx).await;
ctx.phases.push(phase_result.clone());
if !phase_result.success {
ctx.total_duration = boot_start.elapsed();
return Err(NikaError::BootFailed {
phase: phase_result.phase.name().to_string(),
reason: phase_result
.message
.unwrap_or_else(|| "Config discovery failed".into()),
});
}
let phase_result = self.phase_config_validation(&mut ctx).await;
ctx.phases.push(phase_result.clone());
if !phase_result.success {
ctx.total_duration = boot_start.elapsed();
return Err(NikaError::BootFailed {
phase: phase_result.phase.name().to_string(),
reason: phase_result
.message
.unwrap_or_else(|| "Config validation failed".into()),
});
}
let phase_result = self.phase_memory_loading(&mut ctx).await;
ctx.phases.push(phase_result);
let phase_result = self.phase_secrets_loading(&mut ctx).await;
ctx.phases.push(phase_result);
let phase_result = self.phase_mcp_startup(&mut ctx).await;
ctx.phases.push(phase_result);
let phase_result = self.phase_provider_validation(&mut ctx).await;
ctx.phases.push(phase_result);
ctx.phases.push(PhaseResult {
phase: BootPhase::Ready,
success: true,
duration: Duration::ZERO,
message: Some("Boot complete".into()),
warnings: vec![],
});
ctx.total_duration = boot_start.elapsed();
Ok(ctx)
}
async fn phase_config_discovery(&self, ctx: &mut BootContext) -> PhaseResult {
let start = Instant::now();
let mut warnings = vec![];
let mut dir = self.start_dir.as_path();
loop {
let nika_dir = dir.join(".nika");
if nika_dir.exists() && nika_dir.is_dir() {
ctx.nika_dir = Some(nika_dir);
return PhaseResult {
phase: BootPhase::ConfigDiscovery,
success: true,
duration: start.elapsed(),
message: Some(format!("Found .nika/ at {}", dir.display())),
warnings,
};
}
match dir.parent() {
Some(parent) => dir = parent,
None => break,
}
}
warnings.push("No .nika/ directory found, using defaults".into());
ctx.nika_dir = Some(self.start_dir.join(".nika"));
PhaseResult {
phase: BootPhase::ConfigDiscovery,
success: true, duration: start.elapsed(),
message: Some("Using default configuration".into()),
warnings,
}
}
async fn phase_config_validation(&self, ctx: &mut BootContext) -> PhaseResult {
let start = Instant::now();
let mut warnings = vec![];
let nika_dir = match &ctx.nika_dir {
Some(dir) => dir.clone(),
None => {
return PhaseResult {
phase: BootPhase::ConfigValidation,
success: false,
duration: start.elapsed(),
message: Some("No .nika directory".into()),
warnings,
};
}
};
let config_path = nika_dir.join("config.toml");
if !config_path.exists() {
ctx.config = Some(BootstrapConfig::default());
warnings.push("config.toml not found, using defaults".into());
return PhaseResult {
phase: BootPhase::ConfigValidation,
success: true,
duration: start.elapsed(),
message: Some("Using default configuration".into()),
warnings,
};
}
match tokio::fs::read_to_string(&config_path).await {
Ok(content) => match toml::from_str::<BootstrapConfig>(&content) {
Ok(config) => {
ctx.config = Some(config);
PhaseResult {
phase: BootPhase::ConfigValidation,
success: true,
duration: start.elapsed(),
message: Some("Configuration loaded".into()),
warnings,
}
}
Err(e) => {
warnings.push(format!("Config parse error: {}", e));
ctx.config = Some(BootstrapConfig::default());
PhaseResult {
phase: BootPhase::ConfigValidation,
success: true, duration: start.elapsed(),
message: Some("Using defaults due to parse error".into()),
warnings,
}
}
},
Err(e) => {
warnings.push(format!("Config read error: {}", e));
ctx.config = Some(BootstrapConfig::default());
PhaseResult {
phase: BootPhase::ConfigValidation,
success: true,
duration: start.elapsed(),
message: Some("Using defaults due to read error".into()),
warnings,
}
}
}
}
async fn phase_memory_loading(&self, ctx: &mut BootContext) -> PhaseResult {
let start = Instant::now();
let warnings = vec![];
let nika_dir = match &ctx.nika_dir {
Some(dir) => dir.clone(),
None => {
return PhaseResult {
phase: BootPhase::MemoryLoading,
success: true,
duration: start.elapsed(),
message: Some("Skipped (no .nika/)".into()),
warnings,
};
}
};
let memory_path = nika_dir.join("memory.yaml");
if !memory_path.exists() {
return PhaseResult {
phase: BootPhase::MemoryLoading,
success: true,
duration: start.elapsed(),
message: Some("No memory.yaml".into()),
warnings,
};
}
ctx.memory = Some(HashMap::new());
PhaseResult {
phase: BootPhase::MemoryLoading,
success: true,
duration: start.elapsed(),
message: Some("Memory loaded".into()),
warnings,
}
}
async fn phase_secrets_loading(&self, ctx: &mut BootContext) -> PhaseResult {
let start = Instant::now();
let mut warnings = vec![];
let result = crate::secrets::load_from_daemon_or_fallback().await;
if !result.daemon_available {
warnings.push("nika daemon not running, using fallback".into());
}
let message = if result.daemon_available {
format!(
"{} secrets loaded ({} daemon, {} fallback)",
result.total_loaded(),
result.from_daemon.len(),
result.from_fallback.len()
)
} else {
format!("{} secrets loaded (fallback)", result.total_loaded())
};
ctx.secrets_loaded = Some(result);
PhaseResult {
phase: BootPhase::SecretsLoading,
success: true, duration: start.elapsed(),
message: Some(message),
warnings,
}
}
async fn phase_mcp_startup(&self, _ctx: &mut BootContext) -> PhaseResult {
let start = Instant::now();
let warnings = vec![];
PhaseResult {
phase: BootPhase::McpStartup,
success: true,
duration: start.elapsed(),
message: Some("MCP servers ready (on-demand)".into()),
warnings,
}
}
async fn phase_provider_validation(&self, ctx: &mut BootContext) -> PhaseResult {
use crate::core::{ProviderCategory, KNOWN_PROVIDERS};
let start = Instant::now();
let mut warnings = vec![];
let mut providers = vec![];
for p in KNOWN_PROVIDERS
.iter()
.filter(|p| p.category == ProviderCategory::Llm)
{
if std::env::var(p.env_var)
.map(|v| !v.is_empty())
.unwrap_or(false)
{
providers.push(p.id.to_string());
}
}
if providers.is_empty() {
warnings.push("No API keys found".into());
}
ctx.providers = providers.clone();
PhaseResult {
phase: BootPhase::ProviderValidation,
success: true,
duration: start.elapsed(),
message: Some(format!("{} provider(s) available", providers.len())),
warnings,
}
}
}
impl BootContext {
pub fn is_ready(&self) -> bool {
self.phases
.iter()
.any(|p| p.phase == BootPhase::Ready && p.success)
}
pub fn all_warnings(&self) -> Vec<String> {
self.phases
.iter()
.flat_map(|p| p.warnings.clone())
.collect()
}
pub fn summary(&self) -> String {
let mut lines = vec![format!("Boot completed in {:?}", self.total_duration)];
for phase in &self.phases {
let status = if phase.success { "✓" } else { "✗" };
lines.push(format!(
" {} {} {} ({:?})",
status,
phase.phase.icon(),
phase.phase.name(),
phase.duration
));
for warning in &phase.warnings {
lines.push(format!(" ⚠ {}", warning));
}
}
lines.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_boot_phase_properties() {
assert_eq!(BootPhase::ConfigDiscovery.number(), 1);
assert_eq!(BootPhase::SecretsLoading.number(), 4);
assert_eq!(BootPhase::Ready.number(), 7);
assert_eq!(BootPhase::McpStartup.name(), "MCP Startup");
assert_eq!(BootPhase::SecretsLoading.icon(), "🔐");
}
#[test]
fn test_default_config() {
let config = BootstrapConfig::default();
assert_eq!(config.tools.permission, "plan");
assert_eq!(config.provider.default, "claude");
assert_eq!(config.editor.theme, "solarized");
assert!(config.policy.allow_exec);
}
#[test]
fn test_policy_defaults() {
let policy = PolicyConfig::default();
assert!(policy.allow_exec);
assert!(policy.allow_network);
assert!(policy.blocked_commands.contains(&"sudo".to_string()));
}
#[tokio::test]
async fn test_boot_sequence_no_nika_dir() {
let temp = tempdir().unwrap();
let boot = BootSequence::new(temp.path());
let ctx = boot.run().await.unwrap();
assert!(ctx.is_ready());
assert!(ctx.all_warnings().iter().any(|w| w.contains("No .nika/")));
}
#[tokio::test]
async fn test_boot_sequence_with_config() {
let temp = tempdir().unwrap();
let nika_dir = temp.path().join(".nika");
std::fs::create_dir_all(&nika_dir).unwrap();
let config_content = r#"
[tools]
permission = "accept-edits"
[provider]
default = "openai"
"#;
std::fs::write(nika_dir.join("config.toml"), config_content).unwrap();
let boot = BootSequence::new(temp.path());
let ctx = boot.run().await.unwrap();
assert!(ctx.is_ready());
let config = ctx.config.unwrap();
assert_eq!(config.tools.permission, "accept-edits");
assert_eq!(config.provider.default, "openai");
}
#[tokio::test]
async fn test_provider_validation_detects_xai() {
let temp = tempdir().unwrap();
let nika_dir = temp.path().join(".nika");
std::fs::create_dir_all(&nika_dir).unwrap();
std::fs::write(nika_dir.join("config.toml"), "").unwrap();
let key = "XAI_API_KEY";
let original = std::env::var(key).ok();
std::env::set_var(key, "xai-test-key-12345");
let boot = BootSequence::new(temp.path());
let ctx = boot.run().await.unwrap();
assert!(
ctx.providers.contains(&"xai".to_string()),
"Provider validation must detect xAI, got: {:?}",
ctx.providers
);
match original {
Some(v) => std::env::set_var(key, v),
None => unsafe { std::env::remove_var(key) },
}
}
#[tokio::test]
async fn test_provider_validation_ignores_empty_env_var() {
let temp = tempdir().unwrap();
let nika_dir = temp.path().join(".nika");
std::fs::create_dir_all(&nika_dir).unwrap();
std::fs::write(nika_dir.join("config.toml"), "").unwrap();
let key = "MISTRAL_API_KEY";
let original = std::env::var(key).ok();
std::env::set_var(key, "");
let boot = BootSequence::new(temp.path());
let ctx = boot.run().await.unwrap();
assert!(
!ctx.providers.contains(&"mistral".to_string()),
"Provider validation must ignore empty env vars, got: {:?}",
ctx.providers
);
match original {
Some(v) => std::env::set_var(key, v),
None => unsafe { std::env::remove_var(key) },
}
}
#[test]
fn test_boot_context_summary() {
let ctx = BootContext {
phases: vec![
PhaseResult {
phase: BootPhase::ConfigDiscovery,
success: true,
duration: Duration::from_millis(5),
message: Some("Found".into()),
warnings: vec![],
},
PhaseResult {
phase: BootPhase::Ready,
success: true,
duration: Duration::ZERO,
message: Some("Ready".into()),
warnings: vec![],
},
],
total_duration: Duration::from_millis(10),
..Default::default()
};
let summary = ctx.summary();
assert!(summary.contains("Boot completed"));
assert!(summary.contains("Config Discovery"));
}
}