HTTP client initialization (embedding/ollama.rs, gitlab/client.rs): - Replace expect/panic with unwrap_or_else fallback to default Client - Log warning when configured client fails to build - Prevents crash on TLS/system configuration issues Doctor command (cli/commands/doctor.rs): - Handle reqwest Client::builder() failure in Ollama health check - Return Warning status with descriptive message instead of panicking - Ensures doctor command remains operational even with HTTP issues These changes improve resilience when running in unusual environments (containers with limited TLS, restrictive network policies, etc.) without affecting normal operation. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
577 lines
18 KiB
Rust
577 lines
18 KiB
Rust
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<String>,
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DatabaseCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub path: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub schema_version: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct GitLabCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub username: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct ProjectsCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub configured: Option<usize>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub resolved: Option<i64>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct OllamaCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub url: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub model: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct LoggingCheck {
|
|
#[serde(flatten)]
|
|
pub result: CheckResult,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub log_dir: Option<String>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub file_count: Option<usize>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub total_bytes: Option<u64>,
|
|
}
|
|
|
|
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<Config>) {
|
|
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<Vec<ModelInfo>>,
|
|
}
|
|
#[derive(serde::Deserialize)]
|
|
struct ModelInfo {
|
|
name: String,
|
|
}
|
|
|
|
match response.json::<TagsResponse>().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);
|
|
}
|