Files
gitlore/src/cli/commands/doctor.rs
teernisse 30ed02c694 feat(token): add stored token support with resolve_token and token_source
Introduce a centralized token resolution system that supports both
environment variables and config-file-stored tokens with clear priority
(env var wins). This enables cron-based sync which runs in minimal
shell environments without env vars.

Core changes:
- GitLabConfig gains optional `token` field and `resolve_token()` method
  that checks env var first, then config file, returning trimmed values
- `token_source()` returns human-readable provenance ("environment variable"
  or "config file") for diagnostics
- `ensure_config_permissions()` enforces 0600 on config files containing
  tokens (Unix only, no-op on other platforms)

New CLI commands:
- `lore token set [--token VALUE]` — validates against GitLab API, stores
  in config, enforces file permissions. Supports flag, stdin pipe, or
  interactive entry.
- `lore token show [--unmask]` — displays masked token with source label

Consumers updated to use resolve_token():
- auth_test: removes manual env var lookup
- doctor: shows token source in health check output
- ingest: uses centralized resolution

Includes 10 unit tests for resolve/source logic and 2 for mask_token.
2026-02-18 16:27:48 -05:00

602 lines
19 KiB
Rust

use crate::cli::render::{Icons, Theme};
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 config.gitlab.resolve_token() {
Ok(t) => t,
Err(_) => {
return GitLabCheck {
result: CheckResult {
status: CheckStatus::Error,
message: Some(format!(
"Token not set. Run 'lore token set' or export {}.",
config.gitlab.token_env_var
)),
},
url: Some(config.gitlab.base_url.clone()),
username: None,
};
}
};
let source = config.gitlab.token_source().unwrap_or("unknown");
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 @{}, token from {source})",
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!();
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);
// Count statuses
let checks = [
&result.checks.config.result,
&result.checks.database.result,
&result.checks.gitlab.result,
&result.checks.projects.result,
&result.checks.ollama.result,
&result.checks.logging.result,
];
let passed = checks
.iter()
.filter(|c| c.status == CheckStatus::Ok)
.count();
let warnings = checks
.iter()
.filter(|c| c.status == CheckStatus::Warning)
.count();
let failed = checks
.iter()
.filter(|c| c.status == CheckStatus::Error)
.count();
println!();
let mut summary_parts = Vec::new();
if result.success {
summary_parts.push(Theme::success().render("Ready"));
} else {
summary_parts.push(Theme::error().render("Not ready"));
}
summary_parts.push(format!("{passed} passed"));
if warnings > 0 {
summary_parts.push(Theme::warning().render(&format!("{warnings} warning")));
}
if failed > 0 {
summary_parts.push(Theme::error().render(&format!("{failed} failed")));
}
println!(" {}", summary_parts.join(" \u{b7} "));
println!();
}
fn print_check(name: &str, result: &CheckResult) {
let icon = match result.status {
CheckStatus::Ok => Theme::success().render(Icons::success()),
CheckStatus::Warning => Theme::warning().render(Icons::warning()),
CheckStatus::Error => Theme::error().render(Icons::error()),
};
let message = result.message.as_deref().unwrap_or("");
let message_styled = match result.status {
CheckStatus::Ok => message.to_string(),
CheckStatus::Warning => Theme::warning().render(message),
CheckStatus::Error => Theme::error().render(message),
};
println!(" {icon} {:<10} {message_styled}", name);
}