use serde::Serialize; use thiserror::Error; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum NetworkErrorKind { Timeout, ConnectionRefused, DnsResolution, Tls, Other, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ErrorCode { ConfigNotFound, ConfigInvalid, GitLabAuthFailed, GitLabNotFound, GitLabRateLimited, GitLabNetworkError, DatabaseLocked, DatabaseError, MigrationFailed, TokenNotSet, TransformError, IoError, InternalError, OllamaUnavailable, OllamaModelNotFound, EmbeddingFailed, NotFound, Ambiguous, SurgicalPreflightFailed, } impl std::fmt::Display for ErrorCode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let code = match self { Self::ConfigNotFound => "CONFIG_NOT_FOUND", Self::ConfigInvalid => "CONFIG_INVALID", Self::GitLabAuthFailed => "GITLAB_AUTH_FAILED", Self::GitLabNotFound => "GITLAB_NOT_FOUND", Self::GitLabRateLimited => "GITLAB_RATE_LIMITED", Self::GitLabNetworkError => "GITLAB_NETWORK_ERROR", Self::DatabaseLocked => "DB_LOCKED", Self::DatabaseError => "DB_ERROR", Self::MigrationFailed => "MIGRATION_FAILED", Self::TokenNotSet => "TOKEN_NOT_SET", Self::TransformError => "TRANSFORM_ERROR", Self::IoError => "IO_ERROR", Self::InternalError => "INTERNAL_ERROR", Self::OllamaUnavailable => "OLLAMA_UNAVAILABLE", Self::OllamaModelNotFound => "OLLAMA_MODEL_NOT_FOUND", Self::EmbeddingFailed => "EMBEDDING_FAILED", Self::NotFound => "NOT_FOUND", Self::Ambiguous => "AMBIGUOUS", Self::SurgicalPreflightFailed => "SURGICAL_PREFLIGHT_FAILED", }; write!(f, "{code}") } } impl ErrorCode { pub fn exit_code(&self) -> i32 { match self { Self::InternalError => 1, Self::ConfigNotFound => 20, Self::ConfigInvalid => 3, Self::TokenNotSet => 4, Self::GitLabAuthFailed => 5, Self::GitLabNotFound => 6, Self::GitLabRateLimited => 7, Self::GitLabNetworkError => 8, Self::DatabaseLocked => 9, Self::DatabaseError => 10, Self::MigrationFailed => 11, Self::IoError => 12, Self::TransformError => 13, Self::OllamaUnavailable => 14, Self::OllamaModelNotFound => 15, Self::EmbeddingFailed => 16, Self::NotFound => 17, Self::Ambiguous => 18, // Shares exit code 6 with GitLabNotFound — same semantic category (resource not found). // Robot consumers distinguish via ErrorCode string, not exit code. Self::SurgicalPreflightFailed => 6, } } } #[derive(Error, Debug)] pub enum LoreError { #[error("Config file not found at {path}. Run \"lore init\" first.")] ConfigNotFound { path: String }, #[error("Invalid config: {details}")] ConfigInvalid { details: String }, #[error("GitLab authentication failed. Check your token has read_api scope.")] GitLabAuthFailed, #[error("GitLab resource not found: {resource}")] GitLabNotFound { resource: String }, #[error("Rate limited. Retry after {retry_after}s")] GitLabRateLimited { retry_after: u64 }, #[error("Cannot connect to GitLab at {base_url}")] GitLabNetworkError { base_url: String, kind: NetworkErrorKind, detail: Option, }, #[error( "Another sync is running (owner: {owner}, started: {started_at}). Use --force to override if stale." )] DatabaseLocked { owner: String, started_at: String }, #[error("Migration {version} failed: {message}")] MigrationFailed { version: i32, message: String, #[source] source: Option, }, #[error("GitLab token not set. Run 'lore token set' or export {env_var}.")] TokenNotSet { env_var: String }, #[error("Database error: {0}")] Database(#[from] rusqlite::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), #[error("IO error: {0}")] Io(#[from] std::io::Error), #[error("Transform error: {0}")] Transform(#[from] crate::gitlab::transformers::issue::TransformError), #[error("Not found: {0}")] NotFound(String), #[error("Ambiguous: {0}")] Ambiguous(String), #[error("{0}")] Other(String), #[error("Cannot connect to Ollama at {base_url}. Is it running?")] OllamaUnavailable { base_url: String, detail: Option, }, #[error("Ollama model '{model}' not found. Run: ollama pull {model}")] OllamaModelNotFound { model: String }, #[error("Embedding failed for document {document_id}: {reason}")] EmbeddingFailed { document_id: i64, reason: String }, #[error("No embeddings found. Run: lore embed")] EmbeddingsNotBuilt, #[error("Surgical preflight failed for {entity_type} !{iid} in {project}: {reason}")] SurgicalPreflightFailed { entity_type: String, iid: u64, project: String, reason: String, }, } impl LoreError { pub fn code(&self) -> ErrorCode { match self { Self::ConfigNotFound { .. } => ErrorCode::ConfigNotFound, Self::ConfigInvalid { .. } => ErrorCode::ConfigInvalid, Self::GitLabAuthFailed => ErrorCode::GitLabAuthFailed, Self::GitLabNotFound { .. } => ErrorCode::GitLabNotFound, Self::GitLabRateLimited { .. } => ErrorCode::GitLabRateLimited, Self::GitLabNetworkError { .. } => ErrorCode::GitLabNetworkError, Self::DatabaseLocked { .. } => ErrorCode::DatabaseLocked, Self::MigrationFailed { .. } => ErrorCode::MigrationFailed, Self::TokenNotSet { .. } => ErrorCode::TokenNotSet, Self::Database(e) => { if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) { ErrorCode::DatabaseLocked } else { ErrorCode::DatabaseError } } Self::Json(_) => ErrorCode::InternalError, Self::Io(_) => ErrorCode::IoError, Self::Transform(_) => ErrorCode::TransformError, Self::NotFound(_) => ErrorCode::NotFound, Self::Ambiguous(_) => ErrorCode::Ambiguous, Self::Other(_) => ErrorCode::InternalError, Self::OllamaUnavailable { .. } => ErrorCode::OllamaUnavailable, Self::OllamaModelNotFound { .. } => ErrorCode::OllamaModelNotFound, Self::EmbeddingFailed { .. } => ErrorCode::EmbeddingFailed, Self::EmbeddingsNotBuilt => ErrorCode::EmbeddingFailed, Self::SurgicalPreflightFailed { .. } => ErrorCode::SurgicalPreflightFailed, } } pub fn suggestion(&self) -> Option<&'static str> { match self { Self::ConfigNotFound { .. } => Some( "Run 'lore init' to set up your GitLab connection.\n\n Expected: ~/.config/lore/config.json", ), Self::ConfigInvalid { .. } => Some( "Check config file syntax or run 'lore init' to recreate.\n\n Example:\n lore init\n lore init --force", ), Self::GitLabAuthFailed => Some( "Verify token has read_api scope and is not expired.\n\n Example:\n export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n lore auth", ), Self::GitLabNotFound { .. } => Some( "Check the resource path exists and you have access.\n\n Example:\n lore issues -p group/project\n lore mrs -p group/project", ), Self::GitLabRateLimited { .. } => Some("Wait and retry, or reduce request frequency"), Self::GitLabNetworkError { .. } => Some( "Check network connection and GitLab URL.\n\n Example:\n lore doctor\n lore auth", ), Self::DatabaseLocked { .. } => Some( "Wait for other sync to complete or use --force.\n\n Example:\n lore ingest --force\n lore ingest issues --force", ), Self::MigrationFailed { .. } => Some( "Check database file permissions and try again.\n\n Example:\n lore migrate\n lore doctor", ), Self::TokenNotSet { .. } => Some( "Set your token:\n\n lore token set\n\n Or export to your shell:\n\n export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n\n Your token needs the read_api scope.", ), Self::Database(e) => { if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) { Some( "Another process has the database locked. Wait a moment and retry.\n\n Common causes:\n - A cron sync is running (lore cron status)\n - Another lore command is active", ) } else { Some("Check database file permissions.\n\n Example:\n lore doctor") } } Self::NotFound(_) => { Some("Verify the entity exists.\n\n Example:\n lore issues\n lore mrs") } Self::Ambiguous(_) => Some( "Use -p to choose a specific project.\n\n Example:\n lore issues 42 -p group/project-a\n lore mrs 99 -p group/project-b", ), Self::OllamaUnavailable { .. } => Some("Start Ollama: ollama serve"), Self::OllamaModelNotFound { .. } => { Some("Pull the model: ollama pull nomic-embed-text") } Self::EmbeddingFailed { .. } => { Some("Check Ollama logs or retry with 'lore embed --retry-failed'") } Self::EmbeddingsNotBuilt => Some("Generate embeddings first: lore embed"), Self::SurgicalPreflightFailed { .. } => Some( "Verify the IID exists in the project and you have access.\n\n Example:\n lore issues -p \n lore mrs -p ", ), Self::Json(_) | Self::Io(_) | Self::Transform(_) | Self::Other(_) => None, } } pub fn is_permanent_api_error(&self) -> bool { matches!(self, Self::GitLabNotFound { .. } | Self::GitLabAuthFailed) } pub fn exit_code(&self) -> i32 { self.code().exit_code() } pub fn actions(&self) -> Vec<&'static str> { match self { Self::ConfigNotFound { .. } => vec!["lore init"], Self::ConfigInvalid { .. } => vec!["lore init --force"], Self::GitLabAuthFailed => { vec!["export GITLAB_TOKEN=glpat-xxx", "lore auth"] } Self::TokenNotSet { .. } => vec!["lore token set", "export GITLAB_TOKEN=glpat-xxx"], Self::OllamaUnavailable { .. } => vec!["ollama serve"], Self::OllamaModelNotFound { .. } => vec!["ollama pull nomic-embed-text"], Self::DatabaseLocked { .. } => vec!["lore ingest --force"], Self::Database(e) if e.sqlite_error_code() == Some(rusqlite::ErrorCode::DatabaseBusy) => { vec!["lore cron status"] } Self::EmbeddingsNotBuilt => vec!["lore embed"], Self::EmbeddingFailed { .. } => vec!["lore embed --retry-failed"], Self::MigrationFailed { .. } => vec!["lore migrate"], Self::GitLabNetworkError { .. } => vec!["lore doctor"], Self::SurgicalPreflightFailed { .. } => { vec!["lore issues -p ", "lore mrs -p "] } _ => vec![], } } pub fn to_robot_error(&self) -> RobotError { let actions = self.actions().into_iter().map(String::from).collect(); RobotError { code: self.code().to_string(), message: self.to_string(), suggestion: self.suggestion().map(String::from), actions, } } } #[derive(Debug, Serialize)] pub struct RobotError { pub code: String, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub suggestion: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub actions: Vec, } #[derive(Debug, Serialize)] pub struct RobotErrorOutput { pub error: RobotError, } impl From<&LoreError> for RobotErrorOutput { fn from(e: &LoreError) -> Self { Self { error: e.to_robot_error(), } } } pub type Result = std::result::Result; #[cfg(test)] mod tests { use super::*; #[test] fn surgical_preflight_failed_display() { let err = LoreError::SurgicalPreflightFailed { entity_type: "issue".to_string(), iid: 42, project: "group/repo".to_string(), reason: "not found on GitLab".to_string(), }; let msg = err.to_string(); assert!(msg.contains("issue"), "missing entity_type: {msg}"); assert!(msg.contains("42"), "missing iid: {msg}"); assert!(msg.contains("group/repo"), "missing project: {msg}"); assert!(msg.contains("not found on GitLab"), "missing reason: {msg}"); } #[test] fn surgical_preflight_failed_error_code() { let code = ErrorCode::SurgicalPreflightFailed; assert_eq!(code.exit_code(), 6); } #[test] fn surgical_preflight_failed_code_mapping() { let err = LoreError::SurgicalPreflightFailed { entity_type: "merge_request".to_string(), iid: 99, project: "ns/proj".to_string(), reason: "404".to_string(), }; assert_eq!(err.code(), ErrorCode::SurgicalPreflightFailed); } }