#![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,
ShuttingDown,
NothingToDo,
CloseIncomplete,
PolicyViolation,
WorkflowCapacityExceeded,
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::ShuttingDown => "SHUTTING_DOWN",
Self::NothingToDo => "NOTHING_TO_DO",
Self::CloseIncomplete => "CLOSE_INCOMPLETE",
Self::PolicyViolation => "POLICY_VIOLATION",
Self::WorkflowCapacityExceeded => "WORKFLOW_CAPACITY_EXCEEDED",
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
| Self::WorkflowCapacityExceeded
| Self::ShuttingDown
)
}
#[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
| Self::CloseIncomplete => 3,
Self::ShuttingDown => 130,
Self::ValidationFailed
| Self::InvalidStatus
| Self::InvalidType
| Self::InvalidPriority
| Self::RequiredField
| Self::PolicyViolation
| Self::WorkflowCapacityExceeded => 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>,
}
#[derive(Clone, Copy)]
struct ArtifactCommitEvidence {
state: &'static str,
namespace_changed: bool,
committed: Option<bool>,
durable: Option<bool>,
witnessed: Option<bool>,
requires_reconciliation: bool,
}
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,
}),
}
}
fn innermost_beads_error(err: &BeadsError) -> &BeadsError {
match err {
BeadsError::WithContext { source, .. } => source
.downcast_ref::<BeadsError>()
.map_or(err, Self::innermost_beads_error),
_ => err,
}
}
fn artifact_commit_evidence(
source: &(dyn std::error::Error + Send + Sync + 'static),
) -> ArtifactCommitEvidence {
let Some(source) = source.downcast_ref::<BeadsError>() else {
return ArtifactCommitEvidence {
state: "not_committed",
namespace_changed: false,
committed: Some(false),
durable: None,
witnessed: None,
requires_reconciliation: false,
};
};
match Self::innermost_beads_error(source) {
BeadsError::JsonlPublishedButNotDurable { .. } => ArtifactCommitEvidence {
state: "committed_not_durable",
namespace_changed: true,
committed: Some(true),
durable: Some(false),
witnessed: Some(true),
requires_reconciliation: true,
},
BeadsError::JsonlPublishedButUnwitnessed { .. } => ArtifactCommitEvidence {
state: "published_unwitnessed",
namespace_changed: true,
committed: None,
durable: None,
witnessed: Some(false),
requires_reconciliation: true,
},
BeadsError::JsonlPublicationConflict { .. } => ArtifactCommitEvidence {
state: "publication_conflict",
namespace_changed: true,
committed: None,
durable: None,
witnessed: None,
requires_reconciliation: true,
},
BeadsError::CommittedStateUnwitnessed { .. }
| BeadsError::CommittedArtifactFailure { .. } => ArtifactCommitEvidence {
state: "committed_unwitnessed",
namespace_changed: false,
committed: Some(true),
durable: None,
witnessed: Some(false),
requires_reconciliation: true,
},
_ => ArtifactCommitEvidence {
state: "not_committed",
namespace_changed: false,
committed: Some(false),
durable: None,
witnessed: None,
requires_reconciliation: false,
},
}
}
#[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::CommittedStateUnwitnessed { operation, source } => {
let source_context = source.downcast_ref::<BeadsError>().and_then(|error| {
let (_, context) = Self::extract_code_and_context(error);
context
});
(
ErrorCode::SyncConflict,
Some(json!({
"operation": operation,
"primary_commit_state": "committed_unwitnessed",
"primary_committed": true,
"primary_witnessed": false,
"retryable": false,
"requires_reconciliation": true,
"source_context": source_context,
})),
)
}
BeadsError::JsonlPublicationConflict {
output_path,
recovery_path,
message,
} => {
let evidence = Self::artifact_commit_evidence(err);
(
ErrorCode::SyncConflict,
Some(json!({
"operation": "jsonl_publication",
"output_path": output_path,
"recovery_path": recovery_path,
"message": message,
"namespace_changed": evidence.namespace_changed,
"artifact_commit_state": evidence.state,
"artifact_committed": evidence.committed,
"artifact_durable": evidence.durable,
"artifact_witnessed": evidence.witnessed,
"retryable": false,
"requires_reconciliation": evidence.requires_reconciliation,
})),
)
}
BeadsError::JsonlPublishedButNotDurable {
output_path,
recovery_path,
content_sha256,
..
} => {
let evidence = Self::artifact_commit_evidence(err);
(
ErrorCode::SyncConflict,
Some(json!({
"operation": "jsonl_publication",
"output_path": output_path,
"recovery_path": recovery_path,
"content_sha256": content_sha256,
"namespace_changed": evidence.namespace_changed,
"artifact_commit_state": evidence.state,
"artifact_committed": evidence.committed,
"artifact_durable": evidence.durable,
"artifact_witnessed": evidence.witnessed,
"retryable": false,
"requires_reconciliation": evidence.requires_reconciliation,
})),
)
}
BeadsError::JsonlPublishedButUnwitnessed {
output_path,
recovery_path,
..
} => {
let evidence = Self::artifact_commit_evidence(err);
(
ErrorCode::SyncConflict,
Some(json!({
"operation": "jsonl_publication",
"output_path": output_path,
"recovery_path": recovery_path,
"namespace_changed": evidence.namespace_changed,
"artifact_commit_state": evidence.state,
"artifact_committed": evidence.committed,
"artifact_durable": evidence.durable,
"artifact_witnessed": evidence.witnessed,
"retryable": false,
"requires_reconciliation": evidence.requires_reconciliation,
})),
)
}
BeadsError::CommittedArtifactFailure {
operation,
primary_path,
artifact_path,
source,
} => {
let evidence = Self::artifact_commit_evidence(source.as_ref());
let source_context = source.downcast_ref::<BeadsError>().and_then(|error| {
let (_, context) = Self::extract_code_and_context(error);
context
});
(
ErrorCode::SyncConflict,
Some(json!({
"operation": operation,
"primary_path": primary_path,
"artifact_path": artifact_path,
"primary_committed": true,
"namespace_changed": evidence.namespace_changed,
"artifact_commit_state": evidence.state,
"artifact_committed": evidence.committed,
"artifact_durable": evidence.durable,
"artifact_witnessed": evidence.witnessed,
"requires_reconciliation": evidence.requires_reconciliation,
"source_context": source_context,
"retryable": false,
"repair_artifact_only": true,
})),
)
}
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::ShuttingDown => (
ErrorCode::ShuttingDown,
Some(json!({"shutdown_requested": true})),
),
BeadsError::NothingToDo { reason } => {
(ErrorCode::NothingToDo, Some(json!({"reason": reason})))
}
BeadsError::CloseIncomplete {
closed,
skipped,
summary,
} => (
ErrorCode::CloseIncomplete,
Some(json!({
"closed": closed,
"skipped": skipped,
"reason": summary,
})),
),
BeadsError::PolicyViolation {
issue_id,
summary,
violations,
} => (
ErrorCode::PolicyViolation,
Some(json!({
"issue_id": issue_id,
"summary": summary,
"violations": violations,
})),
),
BeadsError::WorkflowCapacityExceeded { violation } => (
ErrorCode::WorkflowCapacityExceeded,
Some(serde_json::to_value(violation).unwrap_or_else(|_| {
json!({
"issue_id": violation.issue_id,
"capacity_name": violation.capacity_name,
})
})),
),
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 { reason } => Some(skip_reason_hint(reason)),
BeadsError::CloseIncomplete { summary, .. } => Some(skip_reason_hint(summary)),
BeadsError::ShuttingDown => {
Some("Retry after starting a fresh br process.".to_string())
}
BeadsError::JsonlParse { line, .. } => Some(format!(
"Check line {line} of the JSONL file for syntax errors."
)),
_ => None,
}
}
}
fn skip_reason_hint(reasons: &str) -> String {
if reasons.contains("blocked by") {
"Skipped issue(s) have open blocking dependencies. Close the blockers first, or re-run with --force to close anyway."
.to_string()
} else if reasons.contains("open children") || reasons.contains("child issue") {
"Skipped issue(s) have open children. Close the children first, or re-run with --force to close anyway."
.to_string()
} else {
"Skipped issue(s) were already closed or not found.".to_string()
}
}
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");
assert_eq!(ErrorCode::ShuttingDown.as_str(), "SHUTTING_DOWN");
}
#[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());
assert!(ErrorCode::ShuttingDown.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::ShuttingDown.exit_code(), 130);
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 workflow_capacity_error_preserves_machine_readable_evidence() {
let err = BeadsError::WorkflowCapacityExceeded {
violation: Box::new(crate::close_policy::WorkflowCapacityViolation {
issue_id: "bd-next".to_string(),
from_status: Some("open".to_string()),
to_status: "in_progress".to_string(),
capacity_kind: "status".to_string(),
capacity_name: "in_progress".to_string(),
scope: "repository".to_string(),
scope_key: None,
counting_mode: "all".to_string(),
aggregate_parents_excluded: None,
exempt: None,
current: 2,
prospective: 3,
soft_limit: Some(1),
hard_limit: 2,
policy_path: "workflow.capacity.statuses.in_progress".to_string(),
}),
};
let structured = StructuredError::from_error(&err);
let context = structured.context.expect("capacity evidence");
assert_eq!(structured.code, ErrorCode::WorkflowCapacityExceeded);
assert!(structured.retryable);
assert_eq!(structured.code.exit_code(), 4);
assert_eq!(context["issue_id"], "bd-next");
assert_eq!(context["current"], 2);
assert_eq!(context["prospective"], 3);
assert_eq!(context["hard_limit"], 2);
assert_eq!(
context["policy_path"],
"workflow.capacity.statuses.in_progress"
);
assert!(context.get("aggregate_parents_excluded").is_none());
assert!(context.get("exempt").is_none());
}
#[test]
fn workflow_capacity_error_reports_hierarchy_counting_evidence() {
let err = BeadsError::WorkflowCapacityExceeded {
violation: Box::new(crate::close_policy::WorkflowCapacityViolation {
issue_id: "bd-next".to_string(),
from_status: Some("open".to_string()),
to_status: "in_progress".to_string(),
capacity_kind: "group".to_string(),
capacity_name: "active_work".to_string(),
scope: "repository".to_string(),
scope_key: None,
counting_mode: "leaf_work".to_string(),
aggregate_parents_excluded: Some(2),
exempt: Some(1),
current: 2,
prospective: 3,
soft_limit: None,
hard_limit: 2,
policy_path: "workflow.capacity.groups.active_work".to_string(),
}),
};
let structured = StructuredError::from_error(&err);
let context = structured.context.expect("capacity evidence");
assert_eq!(context["counting_mode"], "leaf_work");
assert_eq!(context["aggregate_parents_excluded"], 2);
assert_eq!(context["exempt"], 1);
assert!(
err.to_string()
.contains("counting: leaf_work, aggregate-excluded: 2, exempt: 1"),
"hierarchy and exemption evidence missing from message: {err}"
);
}
#[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 committed_artifact_error_preserves_nested_publication_evidence() {
let err = BeadsError::CommittedArtifactFailure {
operation: "flush".to_string(),
primary_path: ".beads/issues.jsonl".into(),
artifact_path: ".beads/manifest.json".into(),
source: Box::new(BeadsError::WithContext {
context: "publishing manifest generation".to_string(),
source: Box::new(BeadsError::JsonlPublishedButNotDurable {
output_path: ".beads/manifest.json".into(),
recovery_path: Some(".beads/manifest.json.recovery".into()),
content_sha256: "a".repeat(64),
source: io::Error::other("directory fsync failed"),
}),
}),
};
let structured = StructuredError::from_error(&err);
let context = structured.context.expect("artifact commit evidence");
assert_eq!(structured.code, ErrorCode::SyncConflict);
assert!(!structured.retryable);
assert_eq!(context["primary_committed"], true);
assert_eq!(context["namespace_changed"], true);
assert_eq!(context["artifact_commit_state"], "committed_not_durable");
assert_eq!(context["artifact_committed"], true);
assert_eq!(context["artifact_durable"], false);
assert_eq!(context["artifact_witnessed"], true);
assert_eq!(context["requires_reconciliation"], true);
assert_eq!(
context["source_context"]["wrapper_context"],
"publishing manifest generation"
);
assert_eq!(context["source_context"]["operation"], "jsonl_publication");
}
#[test]
fn committed_artifact_error_marks_prepublication_failure_as_not_committed() {
let err = BeadsError::CommittedArtifactFailure {
operation: "flush".to_string(),
primary_path: ".beads/issues.jsonl".into(),
artifact_path: ".beads/manifest.json".into(),
source: Box::new(io::Error::other("could not create staging file")),
};
let structured = StructuredError::from_error(&err);
let context = structured.context.expect("artifact commit evidence");
assert_eq!(context["primary_committed"], true);
assert_eq!(context["namespace_changed"], false);
assert_eq!(context["artifact_commit_state"], "not_committed");
assert_eq!(context["artifact_committed"], false);
assert!(context["artifact_durable"].is_null());
assert!(context["artifact_witnessed"].is_null());
assert_eq!(context["requires_reconciliation"], false);
assert!(context["source_context"].is_null());
assert_eq!(context["repair_artifact_only"], true);
}
#[test]
#[allow(clippy::too_many_lines)]
fn publication_errors_use_the_same_tri_state_direct_and_nested() {
struct Case {
name: &'static str,
error: fn() -> BeadsError,
commit_state: &'static str,
committed: Option<bool>,
durable: Option<bool>,
witnessed: Option<bool>,
}
fn not_durable_error() -> BeadsError {
BeadsError::JsonlPublishedButNotDurable {
output_path: ".beads/manifest.json".into(),
recovery_path: Some(".beads/manifest.json.recovery".into()),
content_sha256: "a".repeat(64),
source: io::Error::other("directory fsync failed"),
}
}
fn unwitnessed_error() -> BeadsError {
BeadsError::JsonlPublishedButUnwitnessed {
output_path: ".beads/manifest.json".into(),
recovery_path: Some(".beads/manifest.json.recovery".into()),
source: Box::new(io::Error::other("authority changed")),
}
}
fn conflict_error() -> BeadsError {
BeadsError::JsonlPublicationConflict {
output_path: ".beads/manifest.json".into(),
recovery_path: ".beads/manifest.json.recovery".into(),
message: "displaced generation did not match".to_string(),
}
}
fn assert_optional_bool(value: &Value, expected: Option<bool>, field: &str, case: &str) {
match expected {
Some(expected) => assert_eq!(
value.as_bool(),
Some(expected),
"{case} should expose {field}={expected}"
),
None => assert!(
value.is_null(),
"{case} should expose {field}=null, got {value}"
),
}
}
let cases = [
Case {
name: "not_durable",
error: not_durable_error,
commit_state: "committed_not_durable",
committed: Some(true),
durable: Some(false),
witnessed: Some(true),
},
Case {
name: "unwitnessed",
error: unwitnessed_error,
commit_state: "published_unwitnessed",
committed: None,
durable: None,
witnessed: Some(false),
},
Case {
name: "conflict",
error: conflict_error,
commit_state: "publication_conflict",
committed: None,
durable: None,
witnessed: None,
},
];
for case in cases {
let direct = StructuredError::from_error(&(case.error)());
let direct_context = direct.context.expect("direct publication evidence");
let nested = BeadsError::CommittedArtifactFailure {
operation: "flush".to_string(),
primary_path: ".beads/issues.jsonl".into(),
artifact_path: ".beads/manifest.json".into(),
source: Box::new((case.error)()),
};
let nested_context = StructuredError::from_error(&nested)
.context
.expect("nested publication evidence");
assert_eq!(direct.code, ErrorCode::SyncConflict);
assert!(!direct.retryable);
assert_eq!(direct_context["namespace_changed"], true);
assert_eq!(direct_context["artifact_commit_state"], case.commit_state);
assert_optional_bool(
&direct_context["artifact_committed"],
case.committed,
"artifact_committed",
case.name,
);
assert_optional_bool(
&direct_context["artifact_durable"],
case.durable,
"artifact_durable",
case.name,
);
assert_optional_bool(
&direct_context["artifact_witnessed"],
case.witnessed,
"artifact_witnessed",
case.name,
);
assert_eq!(direct_context["requires_reconciliation"], true);
assert!(
direct_context.get("primary_committed").is_none(),
"a direct artifact error must not claim a distinct primary commit"
);
for ambiguous_field in ["committed", "durable", "witnessed"] {
assert!(
direct_context.get(ambiguous_field).is_none(),
"direct publication evidence must use artifact-scoped field names"
);
}
assert_eq!(nested_context["primary_committed"], true);
for field in [
"namespace_changed",
"artifact_commit_state",
"artifact_committed",
"artifact_durable",
"artifact_witnessed",
"requires_reconciliation",
] {
assert_eq!(
nested_context[field], direct_context[field],
"{} differs between direct and nested {field}",
case.name
);
assert_eq!(
nested_context["source_context"][field], direct_context[field],
"{} source context differs from direct {field}",
case.name
);
}
if case.committed.is_none() {
assert_ne!(
direct_context["artifact_committed"].as_bool(),
Some(true),
"recovery must not interpret unknown direct commitment as confirmed"
);
assert_ne!(
nested_context["artifact_committed"].as_bool(),
Some(true),
"recovery must not interpret unknown nested commitment as confirmed"
);
}
}
}
#[test]
fn committed_state_error_preserves_nested_reconciliation_evidence() {
let err = BeadsError::CommittedStateUnwitnessed {
operation: "terminal sync merge adoption".to_string(),
source: Box::new(BeadsError::JsonlPublishedButUnwitnessed {
output_path: ".beads/issues.jsonl".into(),
recovery_path: Some(".beads/issues.jsonl.recovery".into()),
source: Box::new(io::Error::other("authority changed")),
}),
};
let structured = StructuredError::from_error(&err);
let context = structured.context.expect("committed state evidence");
assert_eq!(context["primary_commit_state"], "committed_unwitnessed");
assert_eq!(context["primary_committed"], true);
assert_eq!(context["primary_witnessed"], false);
assert_eq!(context["requires_reconciliation"], true);
assert_eq!(context["source_context"]["operation"], "jsonl_publication");
assert_eq!(
context["source_context"]["recovery_path"],
".beads/issues.jsonl.recovery"
);
}
#[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")); }
}