mod context;
mod structured;
pub use context::{OptionExt, ResultExt};
pub use structured::{ErrorCode, StructuredError};
use std::path::PathBuf;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum BeadsError {
#[error("Database not found at '{path}'")]
DatabaseNotFound { path: PathBuf },
#[error("Database is locked: {path}")]
DatabaseLocked { path: PathBuf },
#[error("Schema version mismatch: expected {expected}, found {found}")]
SchemaMismatch { expected: i32, found: i32 },
#[error("Database error: {0}")]
Database(#[from] fsqlite_error::FrankenError),
#[error("Issue not found: {id}")]
IssueNotFound { id: String },
#[error("Issue ID collision: {id}")]
IdCollision { id: String },
#[error("Ambiguous ID '{partial}': matches {matches:?}")]
AmbiguousId {
partial: String,
matches: Vec<String>,
},
#[error("Invalid issue ID format: {id}")]
InvalidId { id: String },
#[error("Validation failed: {field}: {reason}")]
Validation { field: String, reason: String },
#[error("Validation errors: {errors:?}")]
ValidationErrors { errors: Vec<ValidationError> },
#[error("Invalid status: {status}")]
InvalidStatus { status: String },
#[error("Invalid issue type: {issue_type}")]
InvalidType { issue_type: String },
#[error("Priority must be 0-4, got: {priority}")]
InvalidPriority { priority: String },
#[error("JSONL parse error at line {line}: {reason}")]
JsonlParse { line: usize, reason: String },
#[error("Prefix mismatch: expected '{expected}', found '{found}'")]
PrefixMismatch { expected: String, found: String },
#[error("Import collision: {count} issues have conflicting content")]
ImportCollision { count: usize },
#[error("Sync conflict: {message}")]
SyncConflict { message: String },
#[error("Cycle detected in dependencies: {path}")]
DependencyCycle { path: String },
#[error("Cannot delete: {id} has {count} dependents")]
HasDependents { id: String, count: usize },
#[error("Issue cannot depend on itself: {id}")]
SelfDependency { id: String },
#[error("Dependency target not found: {id}")]
DependencyNotFound { id: String },
#[error("Dependency already exists: {from} -> {to}")]
DuplicateDependency { from: String, to: String },
#[error("Configuration error: {0}")]
Config(String),
#[error("Beads not initialized: run 'br init' first")]
NotInitialized,
#[error("Already initialized at '{path}'")]
AlreadyInitialized { path: PathBuf },
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("YAML error: {0}")]
Yaml(#[from] serde_yml::Error),
#[error("{context}: {source}")]
WithContext {
context: String,
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
#[error("Nothing to do: {reason}")]
NothingToDo { reason: String },
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl BeadsError {
#[must_use]
pub fn is_transient(&self) -> bool {
match self {
Self::Database(e) => e.is_transient(),
Self::Io(e) => {
matches!(
e.kind(),
std::io::ErrorKind::Interrupted
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::WouldBlock
)
}
_ => false,
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationError {
pub field: String,
pub message: String,
}
impl ValidationError {
#[must_use]
pub fn new(field: impl Into<String>, message: impl Into<String>) -> Self {
Self {
field: field.into(),
message: message.into(),
}
}
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.field, self.message)
}
}
impl std::error::Error for ValidationError {}
impl BeadsError {
#[must_use]
pub const fn is_user_recoverable(&self) -> bool {
matches!(
self,
Self::DatabaseNotFound { .. }
| Self::NotInitialized
| Self::IssueNotFound { .. }
| Self::Validation { .. }
| Self::InvalidStatus { .. }
| Self::InvalidType { .. }
| Self::InvalidPriority { .. }
| Self::PrefixMismatch { .. }
| Self::AmbiguousId { .. }
)
}
#[must_use]
pub const fn suggests_force(&self) -> bool {
matches!(
self,
Self::HasDependents { .. }
| Self::ImportCollision { .. }
| Self::AlreadyInitialized { .. }
)
}
#[must_use]
pub const fn suggestion(&self) -> Option<&'static str> {
match self {
Self::NotInitialized => Some("Run: br init"),
Self::DatabaseNotFound { .. } => Some("Check path or run: br init"),
Self::AmbiguousId { .. } => Some("Provide more characters of the ID"),
Self::HasDependents { .. } => Some("Use --force or --cascade to delete anyway"),
Self::ImportCollision { .. } => Some("Use --force to overwrite or resolve manually"),
Self::DependencyCycle { .. } => Some("Remove one dependency to break the cycle"),
Self::SelfDependency { .. } => Some("An issue cannot depend on itself"),
Self::AlreadyInitialized { .. } => Some("Use --force to reinitialize"),
Self::InvalidPriority { .. } => {
Some("Use a priority between 0 (critical) and 4 (backlog)")
}
Self::InvalidStatus { .. } => Some(
"Valid statuses: open, in_progress, blocked, deferred, draft, closed, tombstone, pinned",
),
Self::InvalidType { .. } => {
Some("Valid types: task, bug, feature, epic, chore, docs, question")
}
_ => None,
}
}
#[must_use]
pub fn exit_code(&self) -> i32 {
StructuredError::from_error(self).code.exit_code()
}
#[must_use]
pub fn validation(field: impl Into<String>, reason: impl Into<String>) -> Self {
Self::Validation {
field: field.into(),
reason: reason.into(),
}
}
#[must_use]
pub fn from_validation_errors(errors: Vec<ValidationError>) -> Self {
if errors.is_empty() {
Self::ValidationErrors { errors }
} else if errors.len() == 1 {
let err = &errors[0];
Self::Validation {
field: err.field.clone(),
reason: err.message.clone(),
}
} else {
Self::ValidationErrors { errors }
}
}
}
pub type Result<T> = std::result::Result<T, BeadsError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display() {
let err = BeadsError::IssueNotFound {
id: "bd-abc123".to_string(),
};
assert_eq!(err.to_string(), "Issue not found: bd-abc123");
}
#[test]
fn test_validation_error() {
let err = BeadsError::validation("title", "cannot be empty");
assert_eq!(err.to_string(), "Validation failed: title: cannot be empty");
}
#[test]
fn test_user_recoverable() {
let recoverable = BeadsError::NotInitialized;
assert!(recoverable.is_user_recoverable());
let not_recoverable =
BeadsError::Database(fsqlite_error::FrankenError::Internal("test".to_string()));
assert!(!not_recoverable.is_user_recoverable());
}
#[test]
fn test_suggestion() {
let err = BeadsError::NotInitialized;
assert_eq!(err.suggestion(), Some("Run: br init"));
let err = BeadsError::AmbiguousId {
partial: "bd-a".to_string(),
matches: vec!["bd-abc".to_string(), "bd-abd".to_string()],
};
assert_eq!(err.suggestion(), Some("Provide more characters of the ID"));
let err = BeadsError::InvalidStatus {
status: "dra".to_string(),
};
assert_eq!(
err.suggestion(),
Some(
"Valid statuses: open, in_progress, blocked, deferred, draft, closed, tombstone, pinned",
)
);
}
#[test]
fn test_validation_error_struct() {
let err = ValidationError::new("priority", "must be 0-4");
assert_eq!(err.to_string(), "priority: must be 0-4");
}
}