use std::path::PathBuf;
use std::time::{Duration, Instant};
use crate::error::{NikaError, Result};
pub const NIKA_DIR: &str = ".nika";
pub const SESSIONS_DIR: &str = "sessions";
pub const TRACES_DIR: &str = "traces";
#[derive(Debug, Default, Clone)]
pub struct DirectoryReport {
pub nika_dir: Option<PathBuf>,
pub created: Vec<String>,
pub existed: Vec<String>,
pub errors: Vec<String>,
}
impl DirectoryReport {
pub fn is_ok(&self) -> bool {
self.errors.is_empty()
}
}
pub fn ensure_directories() -> Result<DirectoryReport> {
let cwd = std::env::current_dir().map_err(|e| NikaError::StartupError {
phase: "directory_check".into(),
reason: format!("Cannot access current directory: {}", e),
})?;
ensure_directories_in(&cwd)
}
pub fn ensure_directories_in(base_dir: &std::path::Path) -> Result<DirectoryReport> {
let nika_dir = base_dir.join(NIKA_DIR);
let sessions_dir = nika_dir.join(SESSIONS_DIR);
let traces_dir = nika_dir.join(TRACES_DIR);
let mut report = DirectoryReport {
nika_dir: Some(nika_dir.clone()),
..Default::default()
};
for (path, name) in [
(&nika_dir, NIKA_DIR),
(&sessions_dir, SESSIONS_DIR),
(&traces_dir, TRACES_DIR),
] {
if path.exists() {
report.existed.push(name.to_string());
} else {
match std::fs::create_dir_all(path) {
Ok(_) => report.created.push(name.to_string()),
Err(e) => report.errors.push(format!("{}: {}", name, e)),
}
}
}
Ok(report)
}
#[derive(Debug, Default, Clone)]
pub struct SchemaReport {
pub schema_loaded: bool,
pub schema_path: Option<PathBuf>,
pub error: Option<String>,
}
impl SchemaReport {
pub fn is_ok(&self) -> bool {
self.schema_loaded && self.error.is_none()
}
}
pub fn verify_schema() -> Result<SchemaReport> {
use crate::ast::schema_validator::WorkflowSchemaValidator;
let mut report = SchemaReport::default();
match WorkflowSchemaValidator::new() {
Ok(_validator) => {
report.schema_loaded = true;
report.schema_path = Some(PathBuf::from("schemas/nika-workflow.schema.json"));
}
Err(e) => {
report.error = Some(format!("Schema load failed: {}", e));
}
}
Ok(report)
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum ConfigSource {
#[default]
Default,
File,
}
#[derive(Debug, Clone)]
pub struct ConfigReport {
pub loaded: bool,
pub source: ConfigSource,
pub warning: Option<String>,
pub config_path: Option<PathBuf>,
}
impl Default for ConfigReport {
fn default() -> Self {
Self {
loaded: false,
source: ConfigSource::Default,
warning: None,
config_path: None,
}
}
}
impl ConfigReport {
pub fn is_ok(&self) -> bool {
self.loaded
}
}
pub fn load_config_graceful() -> ConfigReport {
let cwd = std::env::current_dir().ok();
load_config_graceful_in(cwd.as_deref())
}
pub fn load_config_graceful_in(base_dir: Option<&std::path::Path>) -> ConfigReport {
let config_path = base_dir.map(|dir| dir.join(NIKA_DIR).join("config.toml"));
let mut report = ConfigReport::default();
if let Some(ref path) = config_path {
if path.exists() {
match std::fs::read_to_string(path) {
Ok(content) => {
if content.trim().is_empty() || content.contains('[') {
report.loaded = true;
report.source = ConfigSource::File;
report.config_path = Some(path.clone());
} else {
report.loaded = true;
report.source = ConfigSource::Default;
report.warning = Some("Config file exists but appears invalid".into());
}
}
Err(e) => {
report.loaded = true;
report.source = ConfigSource::Default;
report.warning = Some(format!("Cannot read config file: {}", e));
}
}
} else {
report.loaded = true;
report.source = ConfigSource::Default;
}
} else {
report.loaded = true;
report.source = ConfigSource::Default;
}
report
}
#[derive(Debug, Default, Clone)]
pub struct ProjectReport {
pub project_dir: Option<PathBuf>,
pub readable: bool,
pub file_count: usize,
pub workflow_count: usize,
pub error: Option<String>,
}
impl ProjectReport {
pub fn is_ok(&self) -> bool {
self.readable && self.error.is_none()
}
}
pub fn verify_project_access() -> Result<ProjectReport> {
let cwd = std::env::current_dir().map_err(|e| NikaError::StartupError {
phase: "project_access".into(),
reason: format!("Cannot access current directory: {}", e),
})?;
verify_project_access_in(&cwd)
}
pub fn verify_project_access_in(project_dir: &std::path::Path) -> Result<ProjectReport> {
let mut report = ProjectReport {
project_dir: Some(project_dir.to_path_buf()),
..Default::default()
};
match std::fs::read_dir(project_dir) {
Ok(entries) => {
report.readable = true;
report.file_count = entries.count();
}
Err(e) => {
report.error = Some(format!("Cannot read project directory: {}", e));
return Ok(report);
}
}
if let Ok(entries) = std::fs::read_dir(project_dir) {
report.workflow_count = entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path()
.extension()
.is_some_and(|ext| ext == "yaml" || ext == "yml")
})
.filter(|e| {
e.path()
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(".nika.yaml") || n.ends_with(".nika.yml"))
})
.count();
}
Ok(report)
}
#[derive(Debug, Clone)]
pub struct StartupReport {
pub directories: DirectoryReport,
pub schema: SchemaReport,
pub config: ConfigReport,
pub project: ProjectReport,
pub started_at: Instant,
pub duration: Duration,
}
impl Default for StartupReport {
fn default() -> Self {
Self {
directories: DirectoryReport::default(),
schema: SchemaReport::default(),
config: ConfigReport::default(),
project: ProjectReport::default(),
started_at: Instant::now(),
duration: Duration::ZERO,
}
}
}
impl StartupReport {
pub fn is_ok(&self) -> bool {
self.directories.is_ok() && self.schema.is_ok() && self.project.is_ok()
}
pub fn warnings(&self) -> Vec<String> {
let mut warnings = Vec::new();
if let Some(w) = &self.config.warning {
warnings.push(format!("Config: {}", w));
}
for err in &self.directories.errors {
warnings.push(format!("Directory: {}", err));
}
if let Some(err) = &self.schema.error {
warnings.push(format!("Schema: {}", err));
}
if let Some(err) = &self.project.error {
warnings.push(format!("Project: {}", err));
}
warnings
}
pub fn summary(&self) -> String {
let status = if self.is_ok() { "OK" } else { "FAILED" };
format!(
"Startup {} in {:?} (dirs: {}, schema: {}, config: {}, project: {})",
status,
self.duration,
if self.directories.is_ok() {
"✓"
} else {
"✗"
},
if self.schema.is_ok() { "✓" } else { "✗" },
if self.config.is_ok() { "✓" } else { "✗" },
if self.project.is_ok() { "✓" } else { "✗" },
)
}
}
pub fn verify_startup() -> Result<StartupReport> {
let started_at = Instant::now();
let directories = ensure_directories()?;
let schema = verify_schema()?;
let config = load_config_graceful();
let project = verify_project_access()?;
Ok(StartupReport {
directories,
schema,
config,
project,
started_at,
duration: started_at.elapsed(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_ensure_directories_creates_all() {
let temp = TempDir::new().unwrap();
let report = ensure_directories_in(temp.path()).unwrap();
assert!(report.is_ok());
assert!(temp.path().join(".nika").exists());
assert!(temp.path().join(".nika/sessions").exists());
assert!(temp.path().join(".nika/traces").exists());
assert_eq!(report.created.len(), 3);
assert!(report.existed.is_empty());
}
#[test]
fn test_ensure_directories_idempotent() {
let temp = TempDir::new().unwrap();
let report1 = ensure_directories_in(temp.path()).unwrap();
let report2 = ensure_directories_in(temp.path()).unwrap();
assert!(report1.is_ok());
assert!(report2.is_ok());
assert_eq!(report2.existed.len(), 3);
assert!(report2.created.is_empty());
}
#[test]
fn test_ensure_directories_partial_exist() {
let temp = TempDir::new().unwrap();
std::fs::create_dir(temp.path().join(".nika")).unwrap();
let report = ensure_directories_in(temp.path()).unwrap();
assert!(report.is_ok());
assert!(report.existed.contains(&NIKA_DIR.to_string()));
assert!(report.created.contains(&SESSIONS_DIR.to_string()));
assert!(report.created.contains(&TRACES_DIR.to_string()));
}
#[test]
fn test_verify_schema_report_structure() {
let report = SchemaReport::default();
assert!(!report.is_ok()); assert!(!report.schema_loaded);
assert!(report.error.is_none());
}
#[test]
fn test_load_config_graceful_missing_file() {
let temp = TempDir::new().unwrap();
let report = load_config_graceful_in(Some(temp.path()));
assert!(report.is_ok());
assert_eq!(report.source, ConfigSource::Default);
assert!(report.warning.is_none()); }
#[test]
fn test_load_config_graceful_with_file() {
let temp = TempDir::new().unwrap();
std::fs::create_dir(temp.path().join(".nika")).unwrap();
std::fs::write(
temp.path().join(".nika/config.toml"),
"[editor]\ntheme = \"dark\"\n",
)
.unwrap();
let report = load_config_graceful_in(Some(temp.path()));
assert!(report.is_ok());
assert_eq!(report.source, ConfigSource::File);
assert!(report.config_path.is_some());
}
#[test]
fn test_load_config_graceful_empty_file() {
let temp = TempDir::new().unwrap();
std::fs::create_dir(temp.path().join(".nika")).unwrap();
std::fs::write(temp.path().join(".nika/config.toml"), "").unwrap();
let report = load_config_graceful_in(Some(temp.path()));
assert!(report.is_ok());
assert_eq!(report.source, ConfigSource::File);
}
#[test]
fn test_load_config_graceful_none_base_dir() {
let report = load_config_graceful_in(None);
assert!(report.is_ok());
assert_eq!(report.source, ConfigSource::Default);
}
#[test]
fn test_verify_project_access_readable() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("test.txt"), "hello").unwrap();
let report = verify_project_access_in(temp.path()).unwrap();
assert!(report.is_ok());
assert!(report.readable);
assert!(report.file_count > 0);
}
#[test]
fn test_verify_project_access_workflow_count() {
let temp = TempDir::new().unwrap();
std::fs::write(temp.path().join("hello.nika.yaml"), "schema: test").unwrap();
std::fs::write(temp.path().join("world.nika.yaml"), "schema: test").unwrap();
std::fs::write(temp.path().join("other.yaml"), "not a workflow").unwrap();
let report = verify_project_access_in(temp.path()).unwrap();
assert!(report.is_ok());
assert_eq!(report.workflow_count, 2);
}
#[test]
fn test_verify_project_access_empty_dir() {
let temp = TempDir::new().unwrap();
let report = verify_project_access_in(temp.path()).unwrap();
assert!(report.is_ok());
assert!(report.readable);
assert_eq!(report.workflow_count, 0);
}
#[test]
fn test_startup_report_is_ok_all_pass() {
let report = StartupReport {
directories: DirectoryReport {
nika_dir: Some(PathBuf::from(".nika")),
created: vec!["sessions".into()],
existed: vec![],
errors: vec![],
},
schema: SchemaReport {
schema_loaded: true,
schema_path: Some(PathBuf::from("schema.json")),
error: None,
},
config: ConfigReport {
loaded: true,
source: ConfigSource::Default,
warning: None,
config_path: None,
},
project: ProjectReport {
project_dir: Some(PathBuf::from(".")),
readable: true,
file_count: 10,
workflow_count: 2,
error: None,
},
started_at: Instant::now(),
duration: Duration::from_millis(50),
};
assert!(report.is_ok());
assert!(report.warnings().is_empty());
}
#[test]
fn test_startup_report_collects_warnings() {
let report = StartupReport {
directories: DirectoryReport {
errors: vec!["permission denied".into()],
..Default::default()
},
config: ConfigReport {
loaded: true,
warning: Some("using defaults".into()),
..Default::default()
},
schema: SchemaReport {
error: Some("schema not found".into()),
..Default::default()
},
project: ProjectReport {
readable: true,
..Default::default()
},
started_at: Instant::now(),
duration: Duration::ZERO,
};
let warnings = report.warnings();
assert_eq!(warnings.len(), 3);
assert!(warnings.iter().any(|w| w.contains("permission denied")));
assert!(warnings.iter().any(|w| w.contains("using defaults")));
assert!(warnings.iter().any(|w| w.contains("schema not found")));
}
#[test]
fn test_startup_report_summary() {
let report = StartupReport {
directories: DirectoryReport {
nika_dir: Some(PathBuf::from(".nika")),
..Default::default()
},
schema: SchemaReport {
schema_loaded: true,
..Default::default()
},
config: ConfigReport {
loaded: true,
..Default::default()
},
project: ProjectReport {
readable: true,
..Default::default()
},
started_at: Instant::now(),
duration: Duration::from_millis(25),
};
let summary = report.summary();
assert!(summary.contains("OK"));
assert!(summary.contains("✓"));
}
}