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:
Taylor Eernisse
2026-01-26 11:28:52 -05:00
parent cd60350c6d
commit 8fb890c528
12 changed files with 3034 additions and 0 deletions

504
src/cli/commands/doctor.rs Normal file
View 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);
}