use console::style; use serde::Serialize; use crate::core::config::Config; use crate::core::db::{create_connection, get_schema_version, verify_pragmas}; use crate::core::error::LoreError; use crate::core::paths::{get_config_path, get_db_path, get_log_dir}; use crate::gitlab::GitLabClient; #[derive(Debug, Clone, Serialize)] pub struct CheckResult { pub status: CheckStatus, #[serde(skip_serializing_if = "Option::is_none")] pub message: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "lowercase")] pub enum CheckStatus { Ok, Warning, Error, } #[derive(Debug, Serialize)] pub struct DoctorResult { pub success: bool, pub checks: DoctorChecks, } #[derive(Debug, Serialize)] pub struct DoctorChecks { pub config: ConfigCheck, pub database: DatabaseCheck, pub gitlab: GitLabCheck, pub projects: ProjectsCheck, pub ollama: OllamaCheck, pub logging: LoggingCheck, } #[derive(Debug, Serialize)] pub struct ConfigCheck { #[serde(flatten)] pub result: CheckResult, #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, } #[derive(Debug, Serialize)] pub struct DatabaseCheck { #[serde(flatten)] pub result: CheckResult, #[serde(skip_serializing_if = "Option::is_none")] pub path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub schema_version: Option, } #[derive(Debug, Serialize)] pub struct GitLabCheck { #[serde(flatten)] pub result: CheckResult, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, } #[derive(Debug, Serialize)] pub struct ProjectsCheck { #[serde(flatten)] pub result: CheckResult, #[serde(skip_serializing_if = "Option::is_none")] pub configured: Option, #[serde(skip_serializing_if = "Option::is_none")] pub resolved: Option, } #[derive(Debug, Serialize)] pub struct OllamaCheck { #[serde(flatten)] pub result: CheckResult, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, } #[derive(Debug, Serialize)] pub struct LoggingCheck { #[serde(flatten)] pub result: CheckResult, #[serde(skip_serializing_if = "Option::is_none")] pub log_dir: Option, #[serde(skip_serializing_if = "Option::is_none")] pub file_count: Option, #[serde(skip_serializing_if = "Option::is_none")] pub total_bytes: Option, } pub async fn run_doctor(config_path: Option<&str>) -> DoctorResult { let config_path_buf = get_config_path(config_path); let config_path_str = config_path_buf.display().to_string(); let (config_check, config) = check_config(&config_path_str); let database_check = check_database(config.as_ref()); let gitlab_check = check_gitlab(config.as_ref()).await; let projects_check = check_projects(config.as_ref()); let ollama_check = check_ollama(config.as_ref()).await; let logging_check = check_logging(config.as_ref()); let success = config_check.result.status == CheckStatus::Ok && database_check.result.status == CheckStatus::Ok && gitlab_check.result.status == CheckStatus::Ok && projects_check.result.status == CheckStatus::Ok; DoctorResult { success, checks: DoctorChecks { config: config_check, database: database_check, gitlab: gitlab_check, projects: projects_check, ollama: ollama_check, logging: logging_check, }, } } fn check_config(config_path: &str) -> (ConfigCheck, Option) { match Config::load(Some(config_path)) { Ok(config) => ( ConfigCheck { result: CheckResult { status: CheckStatus::Ok, message: Some(format!("Loaded from {config_path}")), }, path: Some(config_path.to_string()), }, Some(config), ), Err(LoreError::ConfigNotFound { path }) => ( ConfigCheck { result: CheckResult { status: CheckStatus::Error, message: Some(format!("Config not found at {path}")), }, path: Some(path), }, None, ), Err(e) => ( ConfigCheck { result: CheckResult { status: CheckStatus::Error, message: Some(e.to_string()), }, path: Some(config_path.to_string()), }, None, ), } } fn check_database(config: Option<&Config>) -> DatabaseCheck { let Some(config) = config else { return DatabaseCheck { result: CheckResult { status: CheckStatus::Error, message: Some("Config not loaded".to_string()), }, path: None, schema_version: None, }; }; let db_path = get_db_path(config.storage.db_path.as_deref()); if !db_path.exists() { return DatabaseCheck { result: CheckResult { status: CheckStatus::Error, message: Some("Database file not found. Run \"lore init\" first.".to_string()), }, path: Some(db_path.display().to_string()), schema_version: None, }; } match create_connection(&db_path) { Ok(conn) => { let schema_version = get_schema_version(&conn); let (pragmas_ok, issues) = verify_pragmas(&conn); if !pragmas_ok { return DatabaseCheck { result: CheckResult { status: CheckStatus::Warning, message: Some(format!("Pragma issues: {}", issues.join(", "))), }, path: Some(db_path.display().to_string()), schema_version: Some(schema_version), }; } DatabaseCheck { result: CheckResult { status: CheckStatus::Ok, message: Some(format!("{} (schema v{schema_version})", db_path.display())), }, path: Some(db_path.display().to_string()), schema_version: Some(schema_version), } } Err(e) => DatabaseCheck { result: CheckResult { status: CheckStatus::Error, message: Some(e.to_string()), }, path: Some(db_path.display().to_string()), schema_version: None, }, } } async fn check_gitlab(config: Option<&Config>) -> GitLabCheck { let Some(config) = config else { return GitLabCheck { result: CheckResult { status: CheckStatus::Error, message: Some("Config not loaded".to_string()), }, url: None, username: None, }; }; let token = match std::env::var(&config.gitlab.token_env_var) { Ok(t) if !t.trim().is_empty() => t.trim().to_string(), _ => { return GitLabCheck { result: CheckResult { status: CheckStatus::Error, message: Some(format!( "{} not set in environment", config.gitlab.token_env_var )), }, url: Some(config.gitlab.base_url.clone()), username: None, }; } }; let client = GitLabClient::new(&config.gitlab.base_url, &token, None); match client.get_current_user().await { Ok(user) => GitLabCheck { result: CheckResult { status: CheckStatus::Ok, message: Some(format!( "{} (authenticated as @{})", config.gitlab.base_url, user.username )), }, url: Some(config.gitlab.base_url.clone()), username: Some(user.username), }, Err(LoreError::GitLabAuthFailed) => GitLabCheck { result: CheckResult { status: CheckStatus::Error, message: Some("Authentication failed. Check your token.".to_string()), }, url: Some(config.gitlab.base_url.clone()), username: None, }, Err(e) => GitLabCheck { result: CheckResult { status: CheckStatus::Error, message: Some(e.to_string()), }, url: Some(config.gitlab.base_url.clone()), username: None, }, } } fn check_projects(config: Option<&Config>) -> ProjectsCheck { let Some(config) = config else { return ProjectsCheck { result: CheckResult { status: CheckStatus::Error, message: Some("Config not loaded".to_string()), }, configured: None, resolved: None, }; }; let configured = config.projects.len(); let db_path = get_db_path(config.storage.db_path.as_deref()); if !db_path.exists() { return ProjectsCheck { result: CheckResult { status: CheckStatus::Error, message: Some("Database not found. Run \"lore init\" first.".to_string()), }, configured: Some(configured), resolved: Some(0), }; } match create_connection(&db_path) { Ok(conn) => { let resolved: i64 = conn .query_row("SELECT COUNT(*) FROM projects", [], |row| row.get(0)) .unwrap_or(0); if resolved == 0 { return ProjectsCheck { result: CheckResult { status: CheckStatus::Error, message: Some(format!( "{configured} configured, 0 resolved. Run \"lore init\" to resolve projects." )), }, configured: Some(configured), resolved: Some(resolved), }; } if resolved != configured as i64 { return ProjectsCheck { result: CheckResult { status: CheckStatus::Warning, message: Some(format!( "{configured} configured, {resolved} resolved (mismatch)" )), }, configured: Some(configured), resolved: Some(resolved), }; } ProjectsCheck { result: CheckResult { status: CheckStatus::Ok, message: Some(format!("{configured} configured, {resolved} resolved")), }, configured: Some(configured), resolved: Some(resolved), } } Err(e) => ProjectsCheck { result: CheckResult { status: CheckStatus::Error, message: Some(e.to_string()), }, configured: Some(configured), resolved: None, }, } } async fn check_ollama(config: Option<&Config>) -> OllamaCheck { let Some(config) = config else { return OllamaCheck { result: CheckResult { status: CheckStatus::Warning, message: Some("Config not loaded".to_string()), }, url: None, model: None, }; }; let base_url = &config.embedding.base_url; let model = &config.embedding.model; let client = match reqwest::Client::builder() .timeout(std::time::Duration::from_secs(2)) .build() { Ok(client) => client, Err(e) => { return OllamaCheck { result: CheckResult { status: CheckStatus::Warning, message: Some(format!("Failed to build HTTP client: {e}")), }, url: Some(base_url.clone()), model: Some(model.clone()), }; } }; match client.get(format!("{base_url}/api/tags")).send().await { Ok(response) if response.status().is_success() => { #[derive(serde::Deserialize)] struct TagsResponse { models: Option>, } #[derive(serde::Deserialize)] struct ModelInfo { name: String, } match response.json::().await { Ok(data) => { let models = data.models.unwrap_or_default(); let model_names: Vec<&str> = models .iter() .map(|m| m.name.split(':').next().unwrap_or(&m.name)) .collect(); let model_base = model.split(':').next().unwrap_or(model); if !model_names.contains(&model_base) { return OllamaCheck { result: CheckResult { status: CheckStatus::Warning, message: Some(format!( "Model \"{model}\" not found. Available: {}", if model_names.is_empty() { "none".to_string() } else { model_names.join(", ") } )), }, url: Some(base_url.clone()), model: Some(model.clone()), }; } OllamaCheck { result: CheckResult { status: CheckStatus::Ok, message: Some(format!("{base_url} (model: {model})")), }, url: Some(base_url.clone()), model: Some(model.clone()), } } Err(_) => OllamaCheck { result: CheckResult { status: CheckStatus::Warning, message: Some("Could not parse Ollama response".to_string()), }, url: Some(base_url.clone()), model: Some(model.clone()), }, } } Ok(response) => OllamaCheck { result: CheckResult { status: CheckStatus::Warning, message: Some(format!("Ollama responded with {}", response.status())), }, url: Some(base_url.clone()), model: Some(model.clone()), }, Err(_) => OllamaCheck { result: CheckResult { status: CheckStatus::Warning, message: Some("Not running (semantic search unavailable)".to_string()), }, url: Some(base_url.clone()), model: Some(model.clone()), }, } } fn check_logging(config: Option<&Config>) -> LoggingCheck { let log_dir = get_log_dir(config.and_then(|c| c.logging.log_dir.as_deref())); let log_dir_str = log_dir.display().to_string(); if !log_dir.exists() { return LoggingCheck { result: CheckResult { status: CheckStatus::Ok, message: Some(format!("{log_dir_str} (no log files yet)")), }, log_dir: Some(log_dir_str), file_count: Some(0), total_bytes: Some(0), }; } let mut file_count = 0usize; let mut total_bytes = 0u64; if let Ok(entries) = std::fs::read_dir(&log_dir) { for entry in entries.flatten() { let name = entry.file_name(); let name_str = name.to_string_lossy(); if name_str.starts_with("lore.") { file_count += 1; if let Ok(meta) = entry.metadata() { total_bytes += meta.len(); } } } } let size_display = if total_bytes < 1024 { format!("{total_bytes} B") } else if total_bytes < 1024 * 1024 { format!("{:.1} KB", total_bytes as f64 / 1024.0) } else { format!("{:.1} MB", total_bytes as f64 / (1024.0 * 1024.0)) }; LoggingCheck { result: CheckResult { status: CheckStatus::Ok, message: Some(format!( "{log_dir_str} ({file_count} files, {size_display})" )), }, log_dir: Some(log_dir_str), file_count: Some(file_count), total_bytes: Some(total_bytes), } } pub fn print_doctor_results(result: &DoctorResult) { println!("\nlore doctor\n"); print_check("Config", &result.checks.config.result); print_check("Database", &result.checks.database.result); print_check("GitLab", &result.checks.gitlab.result); print_check("Projects", &result.checks.projects.result); print_check("Ollama", &result.checks.ollama.result); print_check("Logging", &result.checks.logging.result); println!(); if result.success { let ollama_ok = result.checks.ollama.result.status == CheckStatus::Ok; if ollama_ok { println!("{}", style("Status: Ready").green()); } else { println!( "{} {}", style("Status: Ready").green(), style("(lexical search available, semantic search requires Ollama)").yellow() ); } } else { println!("{}", style("Status: Not ready").red()); } println!(); } fn print_check(name: &str, result: &CheckResult) { let symbol = match result.status { CheckStatus::Ok => style("✓").green(), CheckStatus::Warning => style("⚠").yellow(), CheckStatus::Error => style("✗").red(), }; let message = result.message.as_deref().unwrap_or(""); let message_styled = match result.status { CheckStatus::Ok => message.to_string(), CheckStatus::Warning => style(message).yellow().to_string(), CheckStatus::Error => style(message).red().to_string(), }; println!(" {symbol} {:<10} {message_styled}", name); }