feat(cli): Implement complete command-line interface
Provides a user-friendly CLI for all GitLab Inbox operations. src/cli/mod.rs - Clap command definitions: - Global --config flag for alternate config path - Subcommands: init, auth-test, doctor, version, backup, reset, migrate, sync-status, ingest, list, count, show - Ingest supports --type (issues/merge_requests), --project filter, --force lock override, --full resync - List supports rich filtering: --state, --author, --assignee, --label, --milestone, --since, --due-before, --has-due-date - List supports --sort (updated/created/iid), --order (asc/desc) - List supports --open to launch browser, --json for scripting src/cli/commands/ - Command implementations: init.rs: Interactive configuration wizard - Prompts for GitLab URL, token env var, projects to track - Creates config file and initializes database - Supports --force overwrite and --non-interactive mode auth_test.rs: Verify GitLab authentication - Calls /api/v4/user to validate token - Displays username and GitLab instance URL doctor.rs: Environment health check - Validates config file exists and parses correctly - Checks database connectivity and migration state - Verifies GitLab authentication - Reports token environment variable status - Supports --json output for CI integration ingest.rs: Data synchronization from GitLab - Acquires sync lock with stale detection - Shows progress bars for issues and discussions - Reports sync statistics on completion - Supports --full flag to reset cursors and refetch all data list.rs: Query local database - Formatted table output with comfy-table - Filters build dynamic SQL with parameterized queries - Username filters normalize @ prefix automatically - --open flag uses 'open' crate for cross-platform browser launch - --json outputs array of issue objects show.rs: Detailed entity view - Displays issue metadata in structured format - Shows full description with markdown - Lists labels, assignees, milestone - Shows discussion threads with notes count.rs: Entity statistics - Counts issues, discussions, or notes - Supports --type filter for discussions/notes sync_status.rs: Display sync watermarks - Shows last sync time per project - Displays cursor positions for debugging src/main.rs - Application entry point: - Initializes tracing subscriber with env-filter - Parses CLI arguments via clap - Dispatches to appropriate command handler - Consistent error formatting for all failure modes src/lib.rs - Library entry point: - Exports cli, core, gitlab, ingestion modules - Re-exports Config, GiError, Result for convenience Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
504
src/cli/commands/doctor.rs
Normal file
504
src/cli/commands/doctor.rs
Normal file
@@ -0,0 +1,504 @@
|
||||
//! Doctor command - check environment health.
|
||||
|
||||
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::GiError;
|
||||
use crate::core::paths::{get_config_path, get_db_path};
|
||||
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,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
}
|
||||
|
||||
/// Run the doctor command.
|
||||
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();
|
||||
|
||||
// Check config
|
||||
let (config_check, config) = check_config(&config_path_str);
|
||||
|
||||
// Check database
|
||||
let database_check = check_database(config.as_ref());
|
||||
|
||||
// Check GitLab
|
||||
let gitlab_check = check_gitlab(config.as_ref()).await;
|
||||
|
||||
// Check projects
|
||||
let projects_check = check_projects(config.as_ref());
|
||||
|
||||
// Check Ollama
|
||||
let ollama_check = check_ollama(config.as_ref()).await;
|
||||
|
||||
// Success if all required checks pass (ollama is optional)
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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(GiError::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 \"gi 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(GiError::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 \"gi 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 \"gi 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;
|
||||
|
||||
// Short timeout for Ollama check
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap();
|
||||
|
||||
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();
|
||||
|
||||
if !model_names.iter().any(|m| *m == model) {
|
||||
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()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Format and print doctor results to console.
|
||||
pub fn print_doctor_results(result: &DoctorResult) {
|
||||
println!("\ngi 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);
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user