#![allow(clippy::option_if_let_else, clippy::manual_map, clippy::manual_find)]
use crate::error::BeadsError;
use crate::format::sanitize_terminal_text;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::collections::HashSet;
use std::sync::LazyLock;
const PRIORITY_DETAIL_HINT: &str =
"Priority must be 0-4 (or P0-P4): 0=critical, 1=high, 2=medium, 3=low, 4=backlog";
const PRIORITY_SHORT_HINT: &str = "Priority must be 0-4 (0=critical, 4=backlog).";
const VALID_STATUS_HINT: &str =
"Valid statuses: open, in_progress, blocked, deferred, draft, closed, tombstone, pinned";
const VALID_TYPE_HINT: &str = "Valid types: task, bug, feature, epic, chore, docs, question";
#[must_use]
fn flag_value_hint(flag: &str, detected: &str) -> String {
format!("Did you mean --{flag} {detected}?")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ErrorCode {
DatabaseNotFound,
DatabaseLocked,
SchemaMismatch,
DatabaseError,
NotInitialized,
AlreadyInitialized,
IssueNotFound,
AmbiguousId,
IdCollision,
InvalidId,
ValidationFailed,
InvalidStatus,
InvalidType,
InvalidPriority,
RequiredField,
CycleDetected,
DependencyNotFound,
HasDependents,
SelfDependency,
DuplicateDependency,
JsonlParseError,
PrefixMismatch,
ImportCollision,
SyncConflict,
ConflictMarkers,
PathTraversal,
ConfigError,
ConfigNotFound,
ConfigParseError,
IoError,
JsonError,
YamlError,
NothingToDo,
PolicyViolation,
InternalError,
}
impl ErrorCode {
#[must_use]
pub const fn as_str(&self) -> &'static str {
match self {
Self::DatabaseNotFound => "DATABASE_NOT_FOUND",
Self::DatabaseLocked => "DATABASE_LOCKED",
Self::SchemaMismatch => "SCHEMA_MISMATCH",
Self::DatabaseError => "DATABASE_ERROR",
Self::NotInitialized => "NOT_INITIALIZED",
Self::AlreadyInitialized => "ALREADY_INITIALIZED",
Self::IssueNotFound => "ISSUE_NOT_FOUND",
Self::AmbiguousId => "AMBIGUOUS_ID",
Self::IdCollision => "ID_COLLISION",
Self::InvalidId => "INVALID_ID",
Self::ValidationFailed => "VALIDATION_FAILED",
Self::InvalidStatus => "INVALID_STATUS",
Self::InvalidType => "INVALID_TYPE",
Self::InvalidPriority => "INVALID_PRIORITY",
Self::RequiredField => "REQUIRED_FIELD",
Self::CycleDetected => "CYCLE_DETECTED",
Self::DependencyNotFound => "DEPENDENCY_NOT_FOUND",
Self::HasDependents => "HAS_DEPENDENTS",
Self::SelfDependency => "SELF_DEPENDENCY",
Self::DuplicateDependency => "DUPLICATE_DEPENDENCY",
Self::JsonlParseError => "JSONL_PARSE_ERROR",
Self::PrefixMismatch => "PREFIX_MISMATCH",
Self::ImportCollision => "IMPORT_COLLISION",
Self::SyncConflict => "SYNC_CONFLICT",
Self::ConflictMarkers => "CONFLICT_MARKERS",
Self::PathTraversal => "PATH_TRAVERSAL",
Self::ConfigError => "CONFIG_ERROR",
Self::ConfigNotFound => "CONFIG_NOT_FOUND",
Self::ConfigParseError => "CONFIG_PARSE_ERROR",
Self::IoError => "IO_ERROR",
Self::JsonError => "JSON_ERROR",
Self::YamlError => "YAML_ERROR",
Self::NothingToDo => "NOTHING_TO_DO",
Self::PolicyViolation => "POLICY_VIOLATION",
Self::InternalError => "INTERNAL_ERROR",
}
}
#[must_use]
pub const fn is_retryable(&self) -> bool {
matches!(
self,
Self::DatabaseLocked
| Self::ValidationFailed
| Self::InvalidStatus
| Self::InvalidType
| Self::InvalidPriority
| Self::RequiredField
| Self::AmbiguousId
)
}
#[must_use]
pub const fn exit_code(&self) -> i32 {
match self {
Self::DatabaseNotFound
| Self::DatabaseLocked
| Self::SchemaMismatch
| Self::DatabaseError
| Self::NotInitialized
| Self::AlreadyInitialized => 2,
Self::IssueNotFound
| Self::AmbiguousId
| Self::IdCollision
| Self::InvalidId
| Self::NothingToDo => 3,
Self::ValidationFailed
| Self::InvalidStatus
| Self::InvalidType
| Self::InvalidPriority
| Self::RequiredField
| Self::PolicyViolation => 4,
Self::CycleDetected
| Self::DependencyNotFound
| Self::HasDependents
| Self::SelfDependency
| Self::DuplicateDependency => 5,
Self::JsonlParseError
| Self::PrefixMismatch
| Self::ImportCollision
| Self::SyncConflict
| Self::ConflictMarkers
| Self::PathTraversal => 6,
Self::ConfigError | Self::ConfigNotFound | Self::ConfigParseError => 7,
Self::IoError | Self::JsonError | Self::YamlError => 8,
Self::InternalError => 1,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StructuredError {
pub code: ErrorCode,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
pub retryable: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub context: Option<Value>,
}
impl StructuredError {
#[must_use]
pub fn from_error(err: &BeadsError) -> Self {
let (code, context) = Self::extract_code_and_context(err);
let hint = Self::generate_hint(Self::hint_source(err), context.as_ref());
Self {
code,
message: err.to_string(),
hint,
retryable: code.is_retryable(),
context,
}
}
fn hint_source(err: &BeadsError) -> &BeadsError {
match err {
BeadsError::WithContext { source, .. } => source
.downcast_ref::<BeadsError>()
.map_or(err, Self::hint_source),
_ => err,
}
}
fn add_wrapper_context(wrapper_context: &str, inner_context: Option<Value>) -> Value {
match inner_context {
Some(Value::Object(mut object)) => {
object.insert(
"wrapper_context".to_string(),
Value::String(wrapper_context.to_string()),
);
Value::Object(object)
}
Some(other) => json!({
"wrapper_context": wrapper_context,
"source_context": other,
}),
None => json!({
"wrapper_context": wrapper_context,
}),
}
}
#[must_use]
pub fn issue_not_found(searched_id: &str, existing_ids: &[String]) -> Self {
let similar = find_similar_ids(searched_id, existing_ids, 3);
let hint = if similar.is_empty() {
Some("Run 'br list' to see available issues.".to_string())
} else if similar.len() == 1 {
Some(format!("Did you mean '{}'?", similar[0]))
} else {
Some(format!("Did you mean one of: {}?", similar.join(", ")))
};
let context = json!({
"searched_id": searched_id,
"similar_ids": similar,
});
Self {
code: ErrorCode::IssueNotFound,
message: format!("Issue not found: {searched_id}"),
hint,
retryable: false,
context: Some(context),
}
}
#[must_use]
pub fn ambiguous_id(partial: &str, matches: &[String]) -> Self {
let hint = Some(format!(
"Provide more characters to disambiguate. Matches: {}",
matches.join(", ")
));
let context = json!({
"partial_id": partial,
"matches": matches,
"match_count": matches.len(),
});
Self {
code: ErrorCode::AmbiguousId,
message: format!(
"Ambiguous ID '{}': matches {} issues",
partial,
matches.len()
),
hint,
retryable: true,
context: Some(context),
}
}
#[must_use]
pub fn cycle_detected(cycle_path: &str) -> Self {
let parts: Vec<&str> = cycle_path.split(" -> ").collect();
let context = json!({
"cycle_path": cycle_path,
"cycle_nodes": parts,
});
Self {
code: ErrorCode::CycleDetected,
message: format!("Cycle detected in dependencies: {cycle_path}"),
hint: Some("Remove one dependency to break the cycle.".to_string()),
retryable: false,
context: Some(context),
}
}
#[must_use]
pub fn not_initialized() -> Self {
Self {
code: ErrorCode::NotInitialized,
message: "Beads not initialized: run 'br init' first".to_string(),
hint: Some("Run: br init".to_string()),
retryable: false,
context: None,
}
}
#[must_use]
pub fn invalid_priority(provided: &str) -> Self {
let hint = Some(detect_priority_intent(provided).map_or_else(
|| PRIORITY_DETAIL_HINT.to_string(),
|detected| format!("Did you mean --priority {detected}? {PRIORITY_DETAIL_HINT}"),
));
let context = json!({
"provided": provided,
"valid_values": ["0", "1", "2", "3", "4", "P0", "P1", "P2", "P3", "P4"],
"priority_mapping": {
"0": "critical",
"1": "high",
"2": "medium",
"3": "low",
"4": "backlog"
}
});
Self {
code: ErrorCode::InvalidPriority,
message: format!("Invalid priority: {provided}"),
hint,
retryable: true,
context: Some(context),
}
}
#[must_use]
pub fn invalid_status(provided: &str) -> Self {
let hint = Some(detect_status_intent(provided).map_or_else(
|| VALID_STATUS_HINT.to_string(),
|detected| flag_value_hint("status", detected),
));
let context = json!({
"provided": provided,
"valid_values": VALID_STATUSES.iter().collect::<Vec<_>>(),
});
Self {
code: ErrorCode::InvalidStatus,
message: format!("Invalid status: {provided}"),
hint,
retryable: true,
context: Some(context),
}
}
#[must_use]
pub fn invalid_type(provided: &str) -> Self {
let hint = Some(detect_type_intent(provided).map_or_else(
|| VALID_TYPE_HINT.to_string(),
|detected| flag_value_hint("type", detected),
));
let context = json!({
"provided": provided,
"valid_values": VALID_TYPES.iter().collect::<Vec<_>>(),
});
Self {
code: ErrorCode::InvalidType,
message: format!("Invalid issue type: {provided}"),
hint,
retryable: true,
context: Some(context),
}
}
#[must_use]
pub fn to_json(&self) -> Value {
json!({
"error": {
"code": self.code.as_str(),
"message": self.message,
"hint": self.hint,
"retryable": self.retryable,
"context": self.context,
}
})
}
#[must_use]
pub fn to_human(&self, color: bool) -> String {
let mut output = String::new();
if color {
output.push_str("\x1b[31mError:\x1b[0m ");
} else {
output.push_str("Error: ");
}
output.push_str(&sanitize_terminal_text(&self.message));
if let Some(hint) = &self.hint {
output.push('\n');
if color {
output.push_str("\x1b[33mHint:\x1b[0m ");
} else {
output.push_str("Hint: ");
}
output.push_str(&sanitize_terminal_text(hint));
}
output
}
#[allow(clippy::too_many_lines)]
fn extract_code_and_context(err: &BeadsError) -> (ErrorCode, Option<Value>) {
match err {
BeadsError::DatabaseNotFound { path } => (
ErrorCode::DatabaseNotFound,
Some(json!({"path": path.display().to_string()})),
),
BeadsError::DatabaseLocked { path } => (
ErrorCode::DatabaseLocked,
Some(json!({"path": path.display().to_string()})),
),
BeadsError::SchemaMismatch { expected, found } => (
ErrorCode::SchemaMismatch,
Some(json!({"expected": expected, "found": found})),
),
BeadsError::Database(_) => (ErrorCode::DatabaseError, None),
BeadsError::NotInitialized => (ErrorCode::NotInitialized, None),
BeadsError::AlreadyInitialized { path } => (
ErrorCode::AlreadyInitialized,
Some(json!({"path": path.display().to_string()})),
),
BeadsError::IssueNotFound { id } => {
(ErrorCode::IssueNotFound, Some(json!({"searched_id": id})))
}
BeadsError::AmbiguousId { partial, matches } => (
ErrorCode::AmbiguousId,
Some(json!({"partial_id": partial, "matches": matches})),
),
BeadsError::IdCollision { id } => (ErrorCode::IdCollision, Some(json!({"id": id}))),
BeadsError::InvalidId { id } => (ErrorCode::InvalidId, Some(json!({"id": id}))),
BeadsError::Validation { field, reason } => (
ErrorCode::ValidationFailed,
Some(json!({"field": field, "reason": reason})),
),
BeadsError::ValidationErrors { errors } => (
ErrorCode::ValidationFailed,
Some(json!({
"errors": errors.iter()
.map(|e| json!({"field": e.field, "message": e.message}))
.collect::<Vec<_>>()
})),
),
BeadsError::InvalidStatus { status } => {
let hint = detect_status_intent(status)
.map(|detected| flag_value_hint("status", detected));
(
ErrorCode::InvalidStatus,
Some(serde_json::json!({
"status": status,
"hint": hint
})),
)
}
BeadsError::InvalidType { issue_type } => {
let hint = detect_type_intent(issue_type)
.map(|detected| flag_value_hint("type", detected));
(
ErrorCode::InvalidType,
Some(serde_json::json!({
"issue_type": issue_type,
"hint": hint
})),
)
}
BeadsError::InvalidPriority { priority } => {
let hint = Some(detect_priority_intent(priority).map_or_else(
|| PRIORITY_SHORT_HINT.to_string(),
|detected| flag_value_hint("priority", detected),
));
(
ErrorCode::InvalidPriority,
Some(serde_json::json!({
"priority": priority,
"hint": hint
})),
)
}
BeadsError::JsonlParse { line, reason } => (
ErrorCode::JsonlParseError,
Some(json!({"line": line, "reason": reason})),
),
BeadsError::PrefixMismatch { expected, found } => (
ErrorCode::PrefixMismatch,
Some(json!({"expected": expected, "found": found})),
),
BeadsError::ImportCollision { count } => (
ErrorCode::ImportCollision,
Some(json!({"collision_count": count})),
),
BeadsError::SyncConflict { message } => {
(ErrorCode::SyncConflict, Some(json!({"message": message})))
}
BeadsError::DependencyCycle { path } => {
(ErrorCode::CycleDetected, Some(json!({"cycle_path": path})))
}
BeadsError::HasDependents { id, count } => (
ErrorCode::HasDependents,
Some(json!({"id": id, "dependent_count": count})),
),
BeadsError::SelfDependency { id } => {
(ErrorCode::SelfDependency, Some(json!({"id": id})))
}
BeadsError::DependencyNotFound { id } => {
(ErrorCode::DependencyNotFound, Some(json!({"id": id})))
}
BeadsError::DuplicateDependency { from, to } => (
ErrorCode::DuplicateDependency,
Some(json!({"from": from, "to": to})),
),
BeadsError::NothingToDo { reason } => {
(ErrorCode::NothingToDo, Some(json!({"reason": reason})))
}
BeadsError::PolicyViolation {
issue_id,
summary,
violations,
} => (
ErrorCode::PolicyViolation,
Some(json!({
"issue_id": issue_id,
"summary": summary,
"violations": violations,
})),
),
BeadsError::Config(_) => (ErrorCode::ConfigError, None),
BeadsError::ExternalCommand { command, reason } => (
ErrorCode::IoError,
Some(json!({"command": command, "reason": reason})),
),
BeadsError::Upgrade { reason } => (
ErrorCode::IoError,
Some(json!({"operation": "upgrade", "reason": reason})),
),
BeadsError::Internal { message } => {
(ErrorCode::InternalError, Some(json!({"message": message})))
}
BeadsError::Io(_) => (ErrorCode::IoError, None),
BeadsError::Json(_) => (ErrorCode::JsonError, None),
BeadsError::Yaml(_) => (ErrorCode::YamlError, None),
BeadsError::WithContext { context, source } => {
if let Some(source_err) = source.downcast_ref::<BeadsError>() {
let (code, inner_context) = Self::extract_code_and_context(source_err);
(
code,
Some(Self::add_wrapper_context(context, inner_context)),
)
} else if source.downcast_ref::<std::io::Error>().is_some() {
(
ErrorCode::IoError,
Some(Self::add_wrapper_context(context, None)),
)
} else if source.downcast_ref::<serde_json::Error>().is_some() {
(
ErrorCode::JsonError,
Some(Self::add_wrapper_context(context, None)),
)
} else if source.downcast_ref::<serde_yml::Error>().is_some() {
(
ErrorCode::YamlError,
Some(Self::add_wrapper_context(context, None)),
)
} else {
(
ErrorCode::InternalError,
Some(Self::add_wrapper_context(context, None)),
)
}
}
}
}
fn generate_hint(err: &BeadsError, context: Option<&Value>) -> Option<String> {
if let Some(suggestion) = err.suggestion() {
return Some(suggestion.to_string());
}
match err {
BeadsError::IssueNotFound { .. } => {
Some("Run 'br list' to see available issues.".to_string())
}
BeadsError::InvalidPriority { priority } => {
Some(detect_priority_intent(priority).map_or_else(
|| PRIORITY_SHORT_HINT.to_string(),
|detected| flag_value_hint("priority", detected),
))
}
BeadsError::InvalidStatus { status } => {
detect_status_intent(status).map(|detected| flag_value_hint("status", detected))
}
BeadsError::InvalidType { issue_type } => {
detect_type_intent(issue_type).map(|detected| flag_value_hint("type", detected))
}
BeadsError::HasDependents { id, .. } => {
if let Some(ctx) = context
&& let Some(count) = ctx.get("dependent_count")
{
return Some(format!(
"Use --force to delete anyway, or close {count} dependents first."
));
}
Some(format!("Use --force to delete '{id}' anyway."))
}
BeadsError::NothingToDo { .. } => {
Some("All specified issues were already closed or not found.".to_string())
}
BeadsError::JsonlParse { line, .. } => Some(format!(
"Check line {line} of the JSONL file for syntax errors."
)),
_ => None,
}
}
}
static VALID_STATUSES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"open",
"in_progress",
"blocked",
"deferred",
"draft",
"closed",
"tombstone",
"pinned",
]
.into_iter()
.collect()
});
static VALID_TYPES: LazyLock<HashSet<&'static str>> = LazyLock::new(|| {
[
"task", "bug", "feature", "epic", "chore", "docs", "question",
]
.into_iter()
.collect()
});
static STATUS_SYNONYMS: LazyLock<std::collections::HashMap<&'static str, &'static str>> =
LazyLock::new(|| {
[
("done", "closed"),
("complete", "closed"),
("completed", "closed"),
("finished", "closed"),
("resolved", "closed"),
("wontfix", "closed"),
("wip", "in_progress"),
("working", "in_progress"),
("active", "in_progress"),
("started", "in_progress"),
("new", "open"),
("todo", "open"),
("pending", "open"),
("waiting", "blocked"),
("hold", "deferred"),
("later", "deferred"),
("postponed", "deferred"),
]
.into_iter()
.collect()
});
static TYPE_SYNONYMS: LazyLock<std::collections::HashMap<&'static str, &'static str>> =
LazyLock::new(|| {
[
("story", "feature"),
("enhancement", "feature"),
("improvement", "feature"),
("issue", "bug"),
("defect", "bug"),
("problem", "bug"),
("ticket", "task"),
("item", "task"),
("work", "task"),
("documentation", "docs"),
("doc", "docs"),
("readme", "docs"),
("cleanup", "chore"),
("refactor", "chore"),
("maintenance", "chore"),
("parent", "epic"),
("initiative", "epic"),
("ask", "question"),
("help", "question"),
]
.into_iter()
.collect()
});
static PRIORITY_SYNONYMS: LazyLock<std::collections::HashMap<&'static str, &'static str>> =
LazyLock::new(|| {
[
("critical", "0"),
("crit", "0"),
("urgent", "0"),
("highest", "0"),
("high", "1"),
("important", "1"),
("medium", "2"),
("normal", "2"),
("default", "2"),
("low", "3"),
("minor", "3"),
("backlog", "4"),
("lowest", "4"),
("trivial", "4"),
]
.into_iter()
.collect()
});
fn detect_status_intent(input: &str) -> Option<&'static str> {
let lower = input.to_lowercase();
if VALID_STATUSES.contains(lower.as_str()) {
return VALID_STATUSES.get(lower.as_str()).copied();
}
if let Some(&canonical) = STATUS_SYNONYMS.get(lower.as_str()) {
return Some(canonical);
}
for &status in VALID_STATUSES.iter() {
if status.starts_with(&lower) {
return Some(status);
}
}
None
}
fn detect_type_intent(input: &str) -> Option<&'static str> {
let lower = input.to_lowercase();
if VALID_TYPES.contains(lower.as_str()) {
return VALID_TYPES.get(lower.as_str()).copied();
}
if let Some(&canonical) = TYPE_SYNONYMS.get(lower.as_str()) {
return Some(canonical);
}
for &t in VALID_TYPES.iter() {
if t.starts_with(&lower) {
return Some(t);
}
}
None
}
fn detect_priority_intent(input: &str) -> Option<&'static str> {
let lower = input.to_lowercase();
if ["0", "1", "2", "3", "4"].contains(&lower.as_str()) {
return match lower.as_str() {
"0" => Some("0"),
"1" => Some("1"),
"2" => Some("2"),
"3" => Some("3"),
"4" => Some("4"),
_ => None,
};
}
if lower.starts_with('p') && lower.len() == 2 {
let digit = lower.chars().nth(1)?;
if digit.is_ascii_digit() && digit <= '4' {
return match digit {
'0' => Some("0"),
'1' => Some("1"),
'2' => Some("2"),
'3' => Some("3"),
'4' => Some("4"),
_ => None,
};
}
}
PRIORITY_SYNONYMS.get(lower.as_str()).copied()
}
fn levenshtein_distance(a: &str, b: &str) -> usize {
let a_len = a.chars().count();
let b_len = b.chars().count();
if a_len == 0 {
return b_len;
}
if b_len == 0 {
return a_len;
}
let mut matrix = vec![vec![0; b_len + 1]; a_len + 1];
for (i, row) in matrix.iter_mut().enumerate().take(a_len + 1) {
row[0] = i;
}
for (j, item) in matrix[0].iter_mut().enumerate().take(b_len + 1) {
*item = j;
}
let a_chars: Vec<char> = a.chars().collect();
let b_chars: Vec<char> = b.chars().collect();
for (i, a_char) in a_chars.iter().enumerate() {
for (j, b_char) in b_chars.iter().enumerate() {
let cost = usize::from(a_char != b_char);
matrix[i + 1][j + 1] = std::cmp::min(
std::cmp::min(matrix[i][j + 1] + 1, matrix[i + 1][j] + 1),
matrix[i][j] + cost,
);
}
}
matrix[a_len][b_len]
}
pub fn find_similar_ids(
searched: &str,
existing: &[String],
max_suggestions: usize,
) -> Vec<String> {
let mut candidates: Vec<(usize, &str)> = existing
.iter()
.map(|id| (levenshtein_distance(searched, id), id.as_str()))
.filter(|(dist, _)| *dist <= 3) .collect();
candidates.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(b.1)));
candidates
.into_iter()
.take(max_suggestions)
.map(|(_, id)| id.to_string())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io;
#[test]
fn test_error_code_as_str() {
assert_eq!(ErrorCode::IssueNotFound.as_str(), "ISSUE_NOT_FOUND");
assert_eq!(ErrorCode::CycleDetected.as_str(), "CYCLE_DETECTED");
assert_eq!(ErrorCode::NotInitialized.as_str(), "NOT_INITIALIZED");
}
#[test]
fn test_error_code_is_retryable() {
assert!(!ErrorCode::IssueNotFound.is_retryable());
assert!(!ErrorCode::CycleDetected.is_retryable());
assert!(ErrorCode::DatabaseLocked.is_retryable());
assert!(ErrorCode::ValidationFailed.is_retryable());
assert!(ErrorCode::InvalidPriority.is_retryable());
}
#[test]
fn test_error_code_exit_codes() {
assert_eq!(ErrorCode::NotInitialized.exit_code(), 2);
assert_eq!(ErrorCode::IssueNotFound.exit_code(), 3);
assert_eq!(ErrorCode::ValidationFailed.exit_code(), 4);
assert_eq!(ErrorCode::CycleDetected.exit_code(), 5);
assert_eq!(ErrorCode::JsonlParseError.exit_code(), 6);
assert_eq!(ErrorCode::ConfigError.exit_code(), 7);
assert_eq!(ErrorCode::IoError.exit_code(), 8);
assert_eq!(ErrorCode::InternalError.exit_code(), 1);
}
#[test]
fn test_structured_error_to_json() {
let err = StructuredError {
code: ErrorCode::IssueNotFound,
message: "Issue not found: bd-abc".to_string(),
hint: Some("Did you mean 'bd-abd'?".to_string()),
retryable: false,
context: Some(json!({"searched_id": "bd-abc"})),
};
let json = err.to_json();
assert_eq!(json["error"]["code"], "ISSUE_NOT_FOUND");
assert_eq!(json["error"]["hint"], "Did you mean 'bd-abd'?");
assert!(!json["error"]["retryable"].as_bool().unwrap());
}
#[test]
fn test_levenshtein_distance() {
assert_eq!(levenshtein_distance("", ""), 0);
assert_eq!(levenshtein_distance("abc", "abc"), 0);
assert_eq!(levenshtein_distance("abc", "abd"), 1);
assert_eq!(levenshtein_distance("abc", "abcd"), 1);
assert_eq!(levenshtein_distance("kitten", "sitting"), 3);
}
#[test]
fn test_find_similar_ids() {
let existing = vec![
"bd-abc123".to_string(),
"bd-xyz789".to_string(),
"bd-abc124".to_string(),
"bd-def456".to_string(),
];
let suggestions = find_similar_ids("bd-abc12", &existing, 3);
assert!(!suggestions.is_empty());
assert!(suggestions.contains(&"bd-abc123".to_string()));
}
#[test]
fn test_detect_status_intent() {
assert_eq!(detect_status_intent("done"), Some("closed"));
assert_eq!(detect_status_intent("wip"), Some("in_progress"));
assert_eq!(detect_status_intent("OPEN"), Some("open"));
assert_eq!(detect_status_intent("draft"), Some("draft"));
assert_eq!(detect_status_intent("op"), Some("open")); assert_eq!(detect_status_intent("xyz"), None);
}
#[test]
fn test_detect_type_intent() {
assert_eq!(detect_type_intent("story"), Some("feature"));
assert_eq!(detect_type_intent("defect"), Some("bug"));
assert_eq!(detect_type_intent("TASK"), Some("task"));
assert_eq!(detect_type_intent("docs"), Some("docs"));
assert_eq!(detect_type_intent("xyz"), None);
}
#[test]
fn test_detect_priority_intent() {
assert_eq!(detect_priority_intent("high"), Some("1"));
assert_eq!(detect_priority_intent("critical"), Some("0"));
assert_eq!(detect_priority_intent("P2"), Some("2"));
assert_eq!(detect_priority_intent("p3"), Some("3"));
assert_eq!(detect_priority_intent("2"), Some("2"));
assert_eq!(detect_priority_intent("xyz"), None);
}
#[test]
fn test_detect_priority_intent_all_digits() {
for (digit, expected) in [("0", "0"), ("1", "1"), ("2", "2"), ("3", "3"), ("4", "4")] {
assert_eq!(detect_priority_intent(digit), Some(expected));
}
}
#[test]
fn test_detect_priority_intent_all_p_prefixed() {
for (input, expected) in [
("p0", "0"),
("P0", "0"),
("p1", "1"),
("P1", "1"),
("p2", "2"),
("P2", "2"),
("p3", "3"),
("P3", "3"),
("p4", "4"),
("P4", "4"),
] {
assert_eq!(
detect_priority_intent(input),
Some(expected),
"input: {input}"
);
}
}
#[test]
fn test_detect_priority_intent_rejects_malformed() {
assert_eq!(detect_priority_intent("p5"), None);
assert_eq!(detect_priority_intent("P5"), None);
assert_eq!(detect_priority_intent("px"), None);
assert_eq!(detect_priority_intent("p10"), None);
assert_eq!(detect_priority_intent("5"), None);
assert_eq!(detect_priority_intent("9"), None);
assert_eq!(detect_priority_intent(""), None);
assert_eq!(detect_priority_intent("p"), None);
assert_eq!(detect_priority_intent("P"), None);
}
#[test]
fn test_structured_error_not_initialized() {
let err = StructuredError::not_initialized();
assert_eq!(err.code, ErrorCode::NotInitialized);
assert!(err.hint.as_ref().unwrap().contains("br init"));
}
#[test]
fn test_structured_error_invalid_priority() {
let err = StructuredError::invalid_priority("high");
assert_eq!(err.code, ErrorCode::InvalidPriority);
assert!(err.hint.as_ref().unwrap().contains("--priority 1"));
assert!(err.retryable);
}
#[test]
fn test_structured_error_invalid_status() {
let err = StructuredError::invalid_status("done");
assert_eq!(err.code, ErrorCode::InvalidStatus);
assert!(err.hint.as_ref().unwrap().contains("closed"));
}
#[test]
fn test_structured_error_ambiguous_id() {
let matches = vec!["bd-abc".to_string(), "bd-abd".to_string()];
let err = StructuredError::ambiguous_id("bd-ab", &matches);
assert_eq!(err.code, ErrorCode::AmbiguousId);
assert!(err.retryable);
assert!(err.context.as_ref().unwrap()["matches"].is_array());
}
#[test]
fn test_structured_error_preserves_wrapped_beads_error_code() {
let err = BeadsError::WithContext {
context: "failed to preserve blocked cache after partial close mutation".to_string(),
source: Box::new(BeadsError::validation("ids", "boom")),
};
let structured = StructuredError::from_error(&err);
let context = structured.context.expect("context");
assert_eq!(structured.code, ErrorCode::ValidationFailed);
assert!(structured.retryable);
assert_eq!(context["field"], "ids");
assert_eq!(context["reason"], "boom");
assert_eq!(
context["wrapper_context"],
"failed to preserve blocked cache after partial close mutation"
);
}
#[test]
fn test_structured_error_preserves_wrapped_io_error_code() {
let err = BeadsError::WithContext {
context: "failed to rename recovered database".to_string(),
source: Box::new(io::Error::other("disk full")),
};
let structured = StructuredError::from_error(&err);
let context = structured.context.expect("context");
assert_eq!(structured.code, ErrorCode::IoError);
assert_eq!(
context["wrapper_context"],
"failed to rename recovered database"
);
}
#[test]
fn test_to_human_output() {
let err = StructuredError {
code: ErrorCode::IssueNotFound,
message: "Issue not found: bd-abc".to_string(),
hint: Some("Did you mean 'bd-abd'?".to_string()),
retryable: false,
context: None,
};
let plain = err.to_human(false);
assert!(plain.contains("Error: Issue not found: bd-abc"));
assert!(plain.contains("Hint: Did you mean 'bd-abd'?"));
let colored = err.to_human(true);
assert!(colored.contains("\x1b[31m")); assert!(colored.contains("\x1b[33m")); }
}