Error suggestions now include concrete CLI examples so users (and robot-mode consumers) can act immediately without consulting docs. For instance, ConfigNotFound now shows the expected path and the exact command to run, TokenNotSet shows the export syntax, and Ambiguous shows the -p flag with example project paths. Also fixes the error code for Ambiguous errors: it now maps to GitLabNotFound instead of InternalError, since the entity exists but the user needs to disambiguate -- not an internal failure. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
239 lines
8.3 KiB
Rust
239 lines
8.3 KiB
Rust
//! Custom error types for gitlore.
|
|
//!
|
|
//! Uses thiserror for ergonomic error definitions with structured error codes.
|
|
|
|
use serde::Serialize;
|
|
use thiserror::Error;
|
|
|
|
/// Error codes for programmatic error handling.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ErrorCode {
|
|
ConfigNotFound,
|
|
ConfigInvalid,
|
|
GitLabAuthFailed,
|
|
GitLabNotFound,
|
|
GitLabRateLimited,
|
|
GitLabNetworkError,
|
|
DatabaseLocked,
|
|
DatabaseError,
|
|
MigrationFailed,
|
|
TokenNotSet,
|
|
TransformError,
|
|
IoError,
|
|
InternalError,
|
|
}
|
|
|
|
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",
|
|
};
|
|
write!(f, "{code}")
|
|
}
|
|
}
|
|
|
|
impl ErrorCode {
|
|
/// Get the exit code for this error (for robot mode).
|
|
pub fn exit_code(&self) -> i32 {
|
|
match self {
|
|
Self::InternalError => 1,
|
|
Self::ConfigNotFound => 2,
|
|
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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Main error type for gitlore.
|
|
#[derive(Error, Debug)]
|
|
pub enum GiError {
|
|
#[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,
|
|
#[source]
|
|
source: Option<reqwest::Error>,
|
|
},
|
|
|
|
#[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<rusqlite::Error>,
|
|
},
|
|
|
|
#[error("GitLab token not set. Export {env_var} environment variable.")]
|
|
TokenNotSet { env_var: String },
|
|
|
|
#[error("Database error: {0}")]
|
|
Database(#[from] rusqlite::Error),
|
|
|
|
#[error("HTTP error: {0}")]
|
|
Http(#[from] reqwest::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),
|
|
}
|
|
|
|
impl GiError {
|
|
/// Get the error code for programmatic handling.
|
|
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(_) => ErrorCode::DatabaseError,
|
|
Self::Http(_) => ErrorCode::GitLabNetworkError,
|
|
Self::Json(_) => ErrorCode::InternalError,
|
|
Self::Io(_) => ErrorCode::IoError,
|
|
Self::Transform(_) => ErrorCode::TransformError,
|
|
Self::NotFound(_) => ErrorCode::GitLabNotFound,
|
|
Self::Ambiguous(_) => ErrorCode::GitLabNotFound,
|
|
Self::Other(_) => ErrorCode::InternalError,
|
|
}
|
|
}
|
|
|
|
/// Get a suggestion for how to fix this error, including inline examples.
|
|
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 or reset with 'lore reset'.\n\n Example:\n lore migrate\n lore reset --yes",
|
|
),
|
|
Self::TokenNotSet { .. } => Some(
|
|
"Export the token to your shell:\n\n export GITLAB_TOKEN=glpat-xxxxxxxxxxxx\n\n Your token needs the read_api scope.",
|
|
),
|
|
Self::Database(_) => Some(
|
|
"Check database file permissions or reset with 'lore reset'.\n\n Example:\n lore doctor\n lore reset --yes",
|
|
),
|
|
Self::Http(_) => Some("Check network connection"),
|
|
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",
|
|
),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Get the exit code for this error.
|
|
pub fn exit_code(&self) -> i32 {
|
|
self.code().exit_code()
|
|
}
|
|
|
|
/// Convert to robot-mode JSON error output.
|
|
pub fn to_robot_error(&self) -> RobotError {
|
|
RobotError {
|
|
code: self.code().to_string(),
|
|
message: self.to_string(),
|
|
suggestion: self.suggestion().map(String::from),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Structured error for robot mode JSON output.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct RobotError {
|
|
pub code: String,
|
|
pub message: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub suggestion: Option<String>,
|
|
}
|
|
|
|
/// Wrapper for robot mode error output.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct RobotErrorOutput {
|
|
pub error: RobotError,
|
|
}
|
|
|
|
impl From<&GiError> for RobotErrorOutput {
|
|
fn from(e: &GiError) -> Self {
|
|
Self {
|
|
error: e.to_robot_error(),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub type Result<T> = std::result::Result<T, GiError>;
|