Skip to main content

bones_core/
error.rs

1//! Comprehensive error types for bones-core.
2//!
3//! Every error explains what went wrong, why, and how to fix it. Errors are
4//! organized by category and carry stable machine-readable codes for
5//! programmatic handling via `--json`.
6//!
7//! # Error Code Ranges
8//!
9//! | Range       | Category          |
10//! |-------------|-------------------|
11//! | E1xxx       | Configuration     |
12//! | E2xxx       | Domain model      |
13//! | E3xxx       | Data integrity    |
14//! | E4xxx       | Event operations  |
15//! | E5xxx       | I/O and system    |
16//! | E6xxx       | Search/index      |
17//! | E9xxx       | Internal          |
18
19use serde::Serialize;
20use std::fmt;
21use std::path::PathBuf;
22use std::time::Duration;
23
24// ---------------------------------------------------------------------------
25// Machine-readable error codes (backward-compatible)
26// ---------------------------------------------------------------------------
27
28/// Machine-readable error codes for agent-friendly decision making.
29///
30/// These are kept for backward compatibility with existing code that
31/// references `ErrorCode` directly (e.g., `lock.rs`).
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
33pub enum ErrorCode {
34    NotInitialized,
35    ConfigParseError,
36    ConfigInvalidValue,
37    ModelNotFound,
38    ItemNotFound,
39    InvalidStateTransition,
40    CycleDetected,
41    AmbiguousId,
42    InvalidEnumValue,
43    InvalidItemId,
44    DuplicateItem,
45    ShardManifestMismatch,
46    EventHashCollision,
47    CorruptProjection,
48    EventParseFailed,
49    EventUnknownType,
50    EventInvalidTimestamp,
51    EventOversizedPayload,
52    EventFileWriteFailed,
53    ShardNotFound,
54    LockContention,
55    LockAlreadyHeld,
56    FtsIndexMissing,
57    SemanticModelLoadFailed,
58    PermissionDenied,
59    DiskFull,
60    NotABonesProject,
61    DbMissing,
62    DbSchemaVersion,
63    DbQueryFailed,
64    DbRebuildFailed,
65    InternalUnexpected,
66}
67
68impl ErrorCode {
69    /// Stable code identifier (`E####`) for machine parsing.
70    #[must_use]
71    pub const fn code(self) -> &'static str {
72        match self {
73            Self::NotInitialized => "E1001",
74            Self::ConfigParseError => "E1002",
75            Self::ConfigInvalidValue => "E1003",
76            Self::ModelNotFound => "E1004",
77            Self::ItemNotFound => "E2001",
78            Self::InvalidStateTransition => "E2002",
79            Self::CycleDetected => "E2003",
80            Self::AmbiguousId => "E2004",
81            Self::InvalidEnumValue => "E2005",
82            Self::InvalidItemId => "E2006",
83            Self::DuplicateItem => "E2007",
84            Self::ShardManifestMismatch => "E3001",
85            Self::EventHashCollision => "E3002",
86            Self::CorruptProjection => "E3003",
87            Self::EventParseFailed => "E4001",
88            Self::EventUnknownType => "E4002",
89            Self::EventInvalidTimestamp => "E4003",
90            Self::EventOversizedPayload => "E4004",
91            Self::EventFileWriteFailed => "E5001",
92            Self::LockContention => "E5002",
93            Self::LockAlreadyHeld => "E5003",
94            Self::PermissionDenied => "E5004",
95            Self::DiskFull => "E5005",
96            Self::NotABonesProject => "E5006",
97            Self::ShardNotFound => "E5007",
98            Self::DbMissing => "E5008",
99            Self::DbSchemaVersion => "E5009",
100            Self::DbQueryFailed => "E5010",
101            Self::DbRebuildFailed => "E5011",
102            Self::FtsIndexMissing => "E6001",
103            Self::SemanticModelLoadFailed => "E6002",
104            Self::InternalUnexpected => "E9001",
105        }
106    }
107
108    /// Short human-facing summary for logs and terminal output.
109    #[must_use]
110    pub const fn message(self) -> &'static str {
111        match self {
112            Self::NotInitialized => "Project not initialized",
113            Self::ConfigParseError => "Config file parse error",
114            Self::ConfigInvalidValue => "Invalid config value",
115            Self::ModelNotFound => "Semantic model not found",
116            Self::ItemNotFound => "Item not found",
117            Self::InvalidStateTransition => "Invalid state transition",
118            Self::CycleDetected => "Cycle would be created",
119            Self::AmbiguousId => "Ambiguous item ID",
120            Self::InvalidEnumValue => "Invalid kind/urgency/size value",
121            Self::InvalidItemId => "Invalid item ID format",
122            Self::DuplicateItem => "Duplicate item",
123            Self::ShardManifestMismatch => "Shard manifest mismatch",
124            Self::EventHashCollision => "Event hash collision",
125            Self::CorruptProjection => "Corrupt SQLite projection",
126            Self::EventParseFailed => "Event parse failed",
127            Self::EventUnknownType => "Unknown event type",
128            Self::EventInvalidTimestamp => "Invalid event timestamp",
129            Self::EventOversizedPayload => "Event payload too large",
130            Self::EventFileWriteFailed => "Event file write failed",
131            Self::LockContention => "Lock contention",
132            Self::LockAlreadyHeld => "Lock already held",
133            Self::PermissionDenied => "Permission denied",
134            Self::DiskFull => "Disk full",
135            Self::NotABonesProject => "Not a bones project",
136            Self::ShardNotFound => "Shard file not found",
137            Self::DbMissing => "Projection database missing",
138            Self::DbSchemaVersion => "Schema version mismatch",
139            Self::DbQueryFailed => "Database query failed",
140            Self::DbRebuildFailed => "Database rebuild failed",
141            Self::FtsIndexMissing => "FTS index missing",
142            Self::SemanticModelLoadFailed => "Semantic model load failed",
143            Self::InternalUnexpected => "Internal unexpected error",
144        }
145    }
146
147    /// Optional remediation hint that can be surfaced to operators and agents.
148    #[must_use]
149    pub const fn hint(self) -> Option<&'static str> {
150        match self {
151            Self::NotInitialized => Some("Run `bn init` to initialize this repository."),
152            Self::ConfigParseError => Some("Fix syntax in .bones/config.toml and retry."),
153            Self::ConfigInvalidValue => {
154                Some("Check .bones/config.toml for the invalid key and correct it.")
155            }
156            Self::ModelNotFound => Some("Install or configure the semantic model before search."),
157            Self::ItemNotFound => {
158                Some("Check the item ID and try again. Use `bn list` to find valid IDs.")
159            }
160            Self::InvalidStateTransition => {
161                Some("Follow valid transitions: open -> doing -> done -> archived.")
162            }
163            Self::CycleDetected => {
164                Some("Remove/adjust dependency links to keep the graph acyclic.")
165            }
166            Self::AmbiguousId => Some("Use a longer ID prefix to disambiguate."),
167            Self::InvalidEnumValue => Some("Use one of the documented kind/urgency/size values."),
168            Self::InvalidItemId => {
169                Some("Item IDs must be alphanumeric. Use `bn list` to find valid IDs.")
170            }
171            Self::DuplicateItem => Some("An item with this ID already exists."),
172            Self::ShardManifestMismatch => {
173                Some("Run `bn admin rebuild` to repair the shard manifest.")
174            }
175            Self::EventHashCollision => {
176                Some("Regenerate the event with a different payload/metadata.")
177            }
178            Self::CorruptProjection => {
179                Some("Run `bn admin rebuild` to repair the SQLite projection.")
180            }
181            Self::EventParseFailed => {
182                Some("Check the event file for malformed lines. Run `bn admin verify` for details.")
183            }
184            Self::EventUnknownType => {
185                Some("This event type is not recognized. You may need a newer version of bn.")
186            }
187            Self::EventInvalidTimestamp => {
188                Some("The timestamp is malformed. Check the event file for corruption.")
189            }
190            Self::EventOversizedPayload => {
191                Some("Reduce the event payload size or split into smaller events.")
192            }
193            Self::EventFileWriteFailed => Some("Check disk space and write permissions."),
194            Self::LockContention => Some("Retry after the other `bn` process releases its lock."),
195            Self::LockAlreadyHeld => {
196                Some("Another process holds the lock. Wait or check for stale lock files.")
197            }
198            Self::PermissionDenied => {
199                Some("Check file permissions and ownership on the .bones directory.")
200            }
201            Self::DiskFull => Some("Free disk space and retry."),
202            Self::NotABonesProject => {
203                Some("Run `bn init` in the project root, or cd to a bones project.")
204            }
205            Self::ShardNotFound => Some(
206                "The shard file may have been deleted. Run `bn admin verify` to check integrity.",
207            ),
208            Self::DbMissing => Some("Run `bn admin rebuild` to recreate the projection database."),
209            Self::DbSchemaVersion => {
210                Some("Run `bn admin rebuild` to migrate to the current schema version.")
211            }
212            Self::DbQueryFailed => Some(
213                "Run `bn admin rebuild` to repair the database. If the error persists, report a bug.",
214            ),
215            Self::DbRebuildFailed => Some(
216                "Check disk space and permissions. Try deleting .bones/db.sqlite and rebuilding.",
217            ),
218            Self::FtsIndexMissing => Some("Run `bn admin rebuild` to create the FTS index."),
219            Self::SemanticModelLoadFailed => {
220                Some("Verify model files and runtime dependencies are available.")
221            }
222            Self::InternalUnexpected => Some("Retry once. If persistent, report a bug with logs."),
223        }
224    }
225}
226
227impl fmt::Display for ErrorCode {
228    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
229        write!(f, "{}", self.code())
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Top-level BonesError
235// ---------------------------------------------------------------------------
236
237/// Top-level error type for all bones-core operations.
238///
239/// Each variant delegates to a category-specific error enum that carries
240/// contextual details. Use [`error_code()`](BonesError::error_code) for
241/// machine-readable codes and [`suggestion()`](BonesError::suggestion)
242/// for actionable remediation hints.
243#[derive(Debug, thiserror::Error)]
244pub enum BonesError {
245    /// Event parsing, writing, or validation failures.
246    #[error(transparent)]
247    Event(#[from] EventError),
248
249    /// `SQLite` projection failures (schema, query, rebuild).
250    #[error(transparent)]
251    Projection(#[from] ProjectionError),
252
253    /// Configuration loading or validation failures.
254    #[error(transparent)]
255    Config(#[from] ConfigError),
256
257    /// Filesystem and I/O failures.
258    #[error(transparent)]
259    Io(#[from] IoError),
260
261    /// Domain model violations (invalid state transition, circular containment).
262    #[error(transparent)]
263    Model(#[from] ModelError),
264
265    /// Concurrency failures (lock timeout, locked DB).
266    #[error(transparent)]
267    Lock(#[from] LockError),
268}
269
270impl BonesError {
271    /// Machine-readable error code for `--json` output (e.g., `"E2001"`).
272    #[must_use]
273    pub const fn error_code(&self) -> &'static str {
274        match self {
275            Self::Event(e) => e.error_code(),
276            Self::Projection(e) => e.error_code(),
277            Self::Config(e) => e.error_code(),
278            Self::Io(e) => e.error_code(),
279            Self::Model(e) => e.error_code(),
280            Self::Lock(e) => e.error_code(),
281        }
282    }
283
284    /// Human-readable suggestion for how to fix the error.
285    #[must_use]
286    pub fn suggestion(&self) -> String {
287        match self {
288            Self::Event(e) => e.suggestion(),
289            Self::Projection(e) => e.suggestion(),
290            Self::Config(e) => e.suggestion(),
291            Self::Io(e) => e.suggestion(),
292            Self::Model(e) => e.suggestion(),
293            Self::Lock(e) => e.suggestion(),
294        }
295    }
296
297    /// Structured error payload for JSON serialization.
298    #[must_use]
299    pub fn to_json_error(&self) -> JsonError {
300        JsonError {
301            error_code: self.error_code().to_string(),
302            message: self.to_string(),
303            suggestion: self.suggestion(),
304        }
305    }
306}
307
308/// JSON-serializable error payload for `--json` mode.
309#[derive(Debug, Clone, Serialize)]
310pub struct JsonError {
311    /// Machine-readable error code (e.g., `"E2001"`).
312    pub error_code: String,
313    /// Human-readable error message.
314    pub message: String,
315    /// Actionable suggestion for fixing the error.
316    pub suggestion: String,
317}
318
319// ---------------------------------------------------------------------------
320// EventError
321// ---------------------------------------------------------------------------
322
323/// Errors related to event parsing, writing, and validation.
324#[derive(Debug, thiserror::Error)]
325pub enum EventError {
326    /// A line in the event file could not be parsed.
327    #[error(
328        "Error: Failed to parse event at line {line_num}\nCause: {reason}\nFix: Check the event file for malformed lines. Run `bn admin verify` for details."
329    )]
330    ParseFailed {
331        /// 1-based line number within the shard file.
332        line_num: usize,
333        /// Description of the parse failure.
334        reason: String,
335    },
336
337    /// The event type string is not recognized.
338    #[error(
339        "Error: Unknown event type '{event_type}'\nCause: This event type is not part of the bones schema\nFix: You may need a newer version of bn. Supported types: item.create, item.update, item.state, item.tag, item.untag, item.link, item.unlink, item.move, item.assign, item.unassign, item.comment"
340    )]
341    UnknownType {
342        /// The unrecognized event type string.
343        event_type: String,
344    },
345
346    /// A timestamp in an event line is malformed.
347    #[error(
348        "Error: Invalid timestamp '{raw}'\nCause: Timestamp does not match expected microsecond epoch format\nFix: Check the event file for corruption. Valid timestamps are positive integers (microseconds since Unix epoch)."
349    )]
350    InvalidTimestamp {
351        /// The raw timestamp string that failed to parse.
352        raw: String,
353    },
354
355    /// The referenced shard file does not exist on disk.
356    #[error(
357        "Error: Shard file not found at {path}\nCause: The shard file may have been deleted or moved\nFix: Run `bn admin verify` to check integrity. Run `bn admin rebuild` if the projection is stale."
358    )]
359    ShardNotFound {
360        /// Path where the shard was expected.
361        path: PathBuf,
362    },
363
364    /// A sealed shard's content does not match its manifest.
365    #[error(
366        "Error: Shard manifest mismatch for {shard}\nCause: Expected hash {expected_hash}, got {actual_hash}\nFix: Run `bn admin rebuild` to repair. If the shard was modified externally, the data may be corrupted."
367    )]
368    ManifestMismatch {
369        /// Path to the shard file.
370        shard: PathBuf,
371        /// Hash recorded in the manifest.
372        expected_hash: String,
373        /// Hash computed from the current file.
374        actual_hash: String,
375    },
376
377    /// An event payload exceeds the maximum allowed size.
378    #[error(
379        "Error: Event payload is {size} bytes (max: {max} bytes)\nCause: The event data exceeds the size limit\nFix: Reduce the payload size or split into smaller events."
380    )]
381    OversizedPayload {
382        /// Actual payload size in bytes.
383        size: usize,
384        /// Maximum allowed size in bytes.
385        max: usize,
386    },
387
388    /// An event line contains an invalid hash.
389    #[error(
390        "Error: Event hash collision detected\nCause: Two events produced the same hash, which should be statistically impossible\nFix: Regenerate the event with different metadata. If this recurs, report a bug."
391    )]
392    HashCollision,
393
394    /// Failed to write an event to the shard file.
395    #[error(
396        "Error: Failed to write event to shard\nCause: {reason}\nFix: Check disk space and file permissions on the .bones/events directory."
397    )]
398    WriteFailed {
399        /// Description of the write failure.
400        reason: String,
401    },
402
403    /// JSON serialization of event data failed.
404    #[error(
405        "Error: Failed to serialize event data\nCause: {reason}\nFix: Check that event data contains only valid JSON-serializable values."
406    )]
407    SerializeFailed {
408        /// Description of the serialization failure.
409        reason: String,
410    },
411}
412
413impl EventError {
414    /// Machine-readable error code.
415    #[must_use]
416    pub const fn error_code(&self) -> &'static str {
417        match self {
418            Self::ParseFailed { .. } => ErrorCode::EventParseFailed.code(),
419            Self::UnknownType { .. } => ErrorCode::EventUnknownType.code(),
420            Self::InvalidTimestamp { .. } => ErrorCode::EventInvalidTimestamp.code(),
421            Self::ShardNotFound { .. } => ErrorCode::ShardNotFound.code(),
422            Self::ManifestMismatch { .. } => ErrorCode::ShardManifestMismatch.code(),
423            Self::OversizedPayload { .. } => ErrorCode::EventOversizedPayload.code(),
424            Self::HashCollision => ErrorCode::EventHashCollision.code(),
425            Self::WriteFailed { .. } | Self::SerializeFailed { .. } => {
426                ErrorCode::EventFileWriteFailed.code()
427            }
428        }
429    }
430
431    /// Human-readable suggestion.
432    #[must_use]
433    pub fn suggestion(&self) -> String {
434        match self {
435            Self::ParseFailed { .. } => {
436                "Check the event file for malformed lines. Run `bn admin verify` for details."
437                    .into()
438            }
439            Self::UnknownType { .. } => {
440                "You may need a newer version of bn to handle this event type.".into()
441            }
442            Self::InvalidTimestamp { .. } => {
443                "Check the event file for corruption. Run `bn admin verify`.".into()
444            }
445            Self::ShardNotFound { .. } => {
446                "Run `bn admin verify` to check integrity. Run `bn admin rebuild` if needed.".into()
447            }
448            Self::ManifestMismatch { .. } => {
449                "Run `bn admin rebuild` to repair. The shard may have been modified externally."
450                    .into()
451            }
452            Self::OversizedPayload { .. } => {
453                "Reduce the payload size or split into smaller events.".into()
454            }
455            Self::HashCollision => {
456                "Regenerate the event with different metadata. Report a bug if this recurs.".into()
457            }
458            Self::WriteFailed { .. } => {
459                "Check disk space and file permissions on the .bones/events directory.".into()
460            }
461            Self::SerializeFailed { .. } => {
462                "Check that event data contains only valid JSON-serializable values.".into()
463            }
464        }
465    }
466}
467
468// ---------------------------------------------------------------------------
469// ProjectionError
470// ---------------------------------------------------------------------------
471
472/// Errors related to the `SQLite` projection layer.
473#[derive(Debug, thiserror::Error)]
474pub enum ProjectionError {
475    /// The projection database file does not exist.
476    #[error(
477        "Error: Projection database not found at {path}\nCause: The database file is missing or was deleted\nFix: Run `bn admin rebuild` to recreate the projection database."
478    )]
479    DbMissing {
480        /// Expected path to the database file.
481        path: PathBuf,
482    },
483
484    /// The database schema version does not match the expected version.
485    #[error(
486        "Error: Schema version mismatch (expected v{expected}, found v{found})\nCause: The database was created by a different version of bn\nFix: Run `bn admin rebuild` to migrate to the current schema version."
487    )]
488    SchemaVersion {
489        /// Expected schema version.
490        expected: u32,
491        /// Actual schema version found.
492        found: u32,
493    },
494
495    /// A SQL query failed.
496    #[error(
497        "Error: Database query failed\nCause: {reason}\nFix: Run `bn admin rebuild` to repair the database. If the error persists, report a bug."
498    )]
499    QueryFailed {
500        /// The SQL that failed (may be truncated for large queries).
501        sql: String,
502        /// Description of the failure.
503        reason: String,
504    },
505
506    /// Rebuilding the projection from events failed.
507    #[error(
508        "Error: Projection rebuild failed\nCause: {reason}\nFix: Delete .bones/db.sqlite and retry `bn admin rebuild`. Check disk space and permissions."
509    )]
510    RebuildFailed {
511        /// Description of the failure.
512        reason: String,
513    },
514
515    /// The projection database appears corrupt.
516    #[error(
517        "Error: Corrupt projection database\nCause: {reason}\nFix: Delete .bones/db.sqlite and run `bn admin rebuild` to recreate from events."
518    )]
519    Corrupt {
520        /// Description of the corruption.
521        reason: String,
522    },
523
524    /// The full-text search index is missing.
525    #[error(
526        "Error: FTS index is missing from the projection database\nCause: The database may have been created without FTS support\nFix: Run `bn admin rebuild` to create the FTS index."
527    )]
528    FtsIndexMissing,
529}
530
531impl ProjectionError {
532    /// Machine-readable error code.
533    #[must_use]
534    pub const fn error_code(&self) -> &'static str {
535        match self {
536            Self::DbMissing { .. } => ErrorCode::DbMissing.code(),
537            Self::SchemaVersion { .. } => ErrorCode::DbSchemaVersion.code(),
538            Self::QueryFailed { .. } => ErrorCode::DbQueryFailed.code(),
539            Self::RebuildFailed { .. } => ErrorCode::DbRebuildFailed.code(),
540            Self::Corrupt { .. } => ErrorCode::CorruptProjection.code(),
541            Self::FtsIndexMissing => ErrorCode::FtsIndexMissing.code(),
542        }
543    }
544
545    /// Human-readable suggestion.
546    #[must_use]
547    pub fn suggestion(&self) -> String {
548        match self {
549            Self::DbMissing { .. } => {
550                "Run `bn admin rebuild` to recreate the projection database.".into()
551            }
552            Self::SchemaVersion { .. } => {
553                "Run `bn admin rebuild` to migrate to the current schema version.".into()
554            }
555            Self::QueryFailed { .. } => {
556                "Run `bn admin rebuild` to repair. If the error persists, report a bug.".into()
557            }
558            Self::RebuildFailed { .. } => {
559                "Delete .bones/db.sqlite and retry `bn admin rebuild`. Check disk space.".into()
560            }
561            Self::Corrupt { .. } => {
562                "Delete .bones/db.sqlite and run `bn admin rebuild` to recreate from events.".into()
563            }
564            Self::FtsIndexMissing => "Run `bn admin rebuild` to create the FTS index.".into(),
565        }
566    }
567}
568
569// ---------------------------------------------------------------------------
570// ConfigError
571// ---------------------------------------------------------------------------
572
573/// Errors related to configuration loading and validation.
574#[derive(Debug, thiserror::Error)]
575pub enum ConfigError {
576    /// The config file does not exist.
577    #[error(
578        "Error: Config file not found at {path}\nCause: The config file is missing\nFix: Run `bn init` to create a default configuration, or create .bones/config.toml manually."
579    )]
580    NotFound {
581        /// Expected path to the config file.
582        path: PathBuf,
583    },
584
585    /// A config value is invalid.
586    #[error(
587        "Error: Invalid config value for '{key}': '{value}'\nCause: {reason}\nFix: Edit .bones/config.toml and correct the value for '{key}'."
588    )]
589    InvalidValue {
590        /// The config key with the invalid value.
591        key: String,
592        /// The invalid value.
593        value: String,
594        /// Why the value is invalid.
595        reason: String,
596    },
597
598    /// The config file could not be parsed.
599    #[error(
600        "Error: Failed to parse config file at {path}\nCause: {reason}\nFix: Fix the syntax in .bones/config.toml. Check for missing quotes, brackets, or invalid TOML."
601    )]
602    ParseFailed {
603        /// Path to the config file.
604        path: PathBuf,
605        /// Parse error description.
606        reason: String,
607    },
608}
609
610impl ConfigError {
611    /// Machine-readable error code.
612    #[must_use]
613    pub const fn error_code(&self) -> &'static str {
614        match self {
615            Self::NotFound { .. } => ErrorCode::NotInitialized.code(),
616            Self::InvalidValue { .. } => ErrorCode::ConfigInvalidValue.code(),
617            Self::ParseFailed { .. } => ErrorCode::ConfigParseError.code(),
618        }
619    }
620
621    /// Human-readable suggestion.
622    #[must_use]
623    pub fn suggestion(&self) -> String {
624        match self {
625            Self::NotFound { .. } => {
626                "Run `bn init` to create a default config, or create .bones/config.toml manually."
627                    .into()
628            }
629            Self::InvalidValue { key, .. } => {
630                format!("Edit .bones/config.toml and correct the value for '{key}'.")
631            }
632            Self::ParseFailed { .. } => {
633                "Fix the TOML syntax in .bones/config.toml and retry.".into()
634            }
635        }
636    }
637}
638
639// ---------------------------------------------------------------------------
640// IoError
641// ---------------------------------------------------------------------------
642
643/// Errors related to filesystem and I/O operations.
644#[derive(Debug, thiserror::Error)]
645pub enum IoError {
646    /// Permission denied accessing a path.
647    #[error(
648        "Error: Permission denied at {path}\nCause: The current user lacks read/write access\nFix: Check file permissions and ownership. Run `ls -la {path}` to inspect."
649    )]
650    PermissionDenied {
651        /// The path that could not be accessed.
652        path: PathBuf,
653    },
654
655    /// The disk is full.
656    #[error(
657        "Error: Disk full — cannot write to {path}\nCause: No disk space remaining on the target filesystem\nFix: Free disk space and retry. Check usage with `df -h`."
658    )]
659    DiskFull {
660        /// The path where the write failed.
661        path: PathBuf,
662    },
663
664    /// The directory is not a bones project.
665    #[error(
666        "Error: Not a bones project at {path}\nCause: No .bones directory found in this path or any parent\nFix: Run `bn init` to create a new bones project, or cd to an existing one."
667    )]
668    NotABonesProject {
669        /// The path that was checked.
670        path: PathBuf,
671    },
672
673    /// Generic I/O error with context.
674    #[error(
675        "Error: I/O error at {path}\nCause: {reason}\nFix: Check that the path exists and is accessible. Verify disk space and permissions."
676    )]
677    Generic {
678        /// The path involved in the error.
679        path: PathBuf,
680        /// Description of the I/O error.
681        reason: String,
682    },
683}
684
685impl IoError {
686    /// Machine-readable error code.
687    #[must_use]
688    pub const fn error_code(&self) -> &'static str {
689        match self {
690            Self::PermissionDenied { .. } => ErrorCode::PermissionDenied.code(),
691            Self::DiskFull { .. } => ErrorCode::DiskFull.code(),
692            Self::NotABonesProject { .. } => ErrorCode::NotABonesProject.code(),
693            Self::Generic { .. } => ErrorCode::EventFileWriteFailed.code(),
694        }
695    }
696
697    /// Human-readable suggestion.
698    #[must_use]
699    pub fn suggestion(&self) -> String {
700        match self {
701            Self::PermissionDenied { path } => {
702                format!(
703                    "Check file permissions and ownership. Run `ls -la {}` to inspect.",
704                    path.display()
705                )
706            }
707            Self::DiskFull { .. } => "Free disk space and retry. Check usage with `df -h`.".into(),
708            Self::NotABonesProject { .. } => {
709                "Run `bn init` to create a new bones project, or cd to an existing one.".into()
710            }
711            Self::Generic { .. } => {
712                "Check that the path exists and is accessible. Verify disk space and permissions."
713                    .into()
714            }
715        }
716    }
717}
718
719// ---------------------------------------------------------------------------
720// ModelError
721// ---------------------------------------------------------------------------
722
723/// Errors related to domain model violations.
724#[derive(Debug, thiserror::Error)]
725pub enum ModelError {
726    /// An invalid state transition was attempted.
727    #[error(
728        "Error: Cannot transition item '{item_id}' from '{from}' to '{to}'\nCause: This state transition is not allowed by lifecycle rules\nFix: Valid transitions: open->doing, open->done, doing->done, doing->open, done->archived, done->open, archived->open"
729    )]
730    InvalidTransition {
731        /// The item being transitioned.
732        item_id: String,
733        /// Current state.
734        from: String,
735        /// Attempted target state.
736        to: String,
737    },
738
739    /// The referenced item does not exist.
740    #[error(
741        "Error: Item '{item_id}' not found\nCause: No item with this ID exists in the project\nFix: Check the ID and try again. Use `bn list` to see all items. Use a longer prefix if the ID is ambiguous."
742    )]
743    ItemNotFound {
744        /// The ID that was not found.
745        item_id: String,
746    },
747
748    /// Moving the item would create a circular containment chain.
749    #[error("Error: Moving this item would create a cycle: {}\nCause: Circular containment is not allowed in the hierarchy\nFix: Choose a different parent or restructure the hierarchy. Remove/adjust links to break the cycle.", cycle.join(" -> "))]
750    CircularContainment {
751        /// The IDs forming the cycle.
752        cycle: Vec<String>,
753    },
754
755    /// The item ID format is invalid.
756    #[error(
757        "Error: Invalid item ID '{raw}'\nCause: Item IDs must be valid terseid identifiers\nFix: Use `bn list` to find valid item IDs. IDs are short alphanumeric strings."
758    )]
759    InvalidItemId {
760        /// The raw string that failed validation.
761        raw: String,
762    },
763
764    /// The ID prefix matches multiple items.
765    #[error("Error: Ambiguous item ID '{prefix}' matches {count} items\nCause: The prefix is too short to uniquely identify an item\nFix: Use a longer prefix. Matching items: {}", matches.join(", "))]
766    AmbiguousId {
767        /// The ambiguous prefix.
768        prefix: String,
769        /// Number of matching items.
770        count: usize,
771        /// The matching item IDs (up to a reasonable limit).
772        matches: Vec<String>,
773    },
774
775    /// An enum value (kind, state, urgency, size) is invalid.
776    #[error(
777        "Error: Invalid {field} value '{value}'\nCause: '{value}' is not a recognized {field}\nFix: Valid {field} values: {valid_values}"
778    )]
779    InvalidEnumValue {
780        /// Which field (e.g., "kind", "state", "urgency", "size").
781        field: String,
782        /// The invalid value.
783        value: String,
784        /// Comma-separated list of valid values.
785        valid_values: String,
786    },
787
788    /// A duplicate item was detected.
789    #[error(
790        "Error: Duplicate item '{item_id}'\nCause: An item with this ID already exists\nFix: Use a different ID or update the existing item."
791    )]
792    DuplicateItem {
793        /// The duplicate item ID.
794        item_id: String,
795    },
796
797    /// A dependency cycle was detected.
798    #[error("Error: Adding this dependency would create a cycle: {}\nCause: Circular dependencies are not allowed\nFix: Remove/adjust dependency links to keep the graph acyclic.", cycle.join(" -> "))]
799    CycleDetected {
800        /// The IDs forming the cycle.
801        cycle: Vec<String>,
802    },
803}
804
805impl ModelError {
806    /// Machine-readable error code.
807    #[must_use]
808    pub const fn error_code(&self) -> &'static str {
809        match self {
810            Self::InvalidTransition { .. } => ErrorCode::InvalidStateTransition.code(),
811            Self::ItemNotFound { .. } => ErrorCode::ItemNotFound.code(),
812            Self::CircularContainment { .. } | Self::CycleDetected { .. } => {
813                ErrorCode::CycleDetected.code()
814            }
815            Self::InvalidItemId { .. } => ErrorCode::InvalidItemId.code(),
816            Self::AmbiguousId { .. } => ErrorCode::AmbiguousId.code(),
817            Self::InvalidEnumValue { .. } => ErrorCode::InvalidEnumValue.code(),
818            Self::DuplicateItem { .. } => ErrorCode::DuplicateItem.code(),
819        }
820    }
821
822    /// Human-readable suggestion.
823    #[must_use]
824    pub fn suggestion(&self) -> String {
825        match self {
826            Self::InvalidTransition { .. } => {
827                "Valid transitions: open->doing, open->done, doing->done, doing->open, done->archived, done->open, archived->open".into()
828            }
829            Self::ItemNotFound { .. } => {
830                "Check the ID and try again. Use `bn list` to see all items.".into()
831            }
832            Self::CircularContainment { .. } => {
833                "Choose a different parent or restructure the hierarchy.".into()
834            }
835            Self::InvalidItemId { .. } => {
836                "Use `bn list` to find valid item IDs. IDs are short alphanumeric strings.".into()
837            }
838            Self::AmbiguousId { prefix, .. } => {
839                format!("Use a longer prefix than '{prefix}' to uniquely identify the item.")
840            }
841            Self::InvalidEnumValue { field, valid_values, .. } => {
842                format!("Use one of the valid {field} values: {valid_values}")
843            }
844            Self::DuplicateItem { .. } => {
845                "Use a different ID or update the existing item.".into()
846            }
847            Self::CycleDetected { .. } => {
848                "Remove/adjust dependency links to keep the graph acyclic.".into()
849            }
850        }
851    }
852}
853
854// ---------------------------------------------------------------------------
855// LockError
856// ---------------------------------------------------------------------------
857
858/// Errors related to concurrency and locking.
859#[derive(Debug, thiserror::Error)]
860pub enum LockError {
861    /// A lock acquisition timed out.
862    #[error(
863        "Error: Lock timed out after {waited:?} at {path}\nCause: Another bn process is holding the lock\nFix: Wait for the other process to finish, then retry. Check for stale lock files at {path}."
864    )]
865    Timeout {
866        /// The lock file path.
867        path: PathBuf,
868        /// How long the acquisition was attempted.
869        waited: Duration,
870    },
871
872    /// The lock is already held by another process.
873    #[error("Error: Lock already held at {path}{}\nCause: Another process is using the repository\nFix: Wait for the other process to finish. If no process is running, remove the lock file.", holder.as_ref().map(|h| format!(" by {h}")).unwrap_or_default())]
874    AlreadyLocked {
875        /// The lock file path.
876        path: PathBuf,
877        /// Optional holder identity.
878        holder: Option<String>,
879    },
880}
881
882impl LockError {
883    /// Machine-readable error code.
884    #[must_use]
885    pub const fn error_code(&self) -> &'static str {
886        match self {
887            Self::Timeout { .. } => ErrorCode::LockContention.code(),
888            Self::AlreadyLocked { .. } => ErrorCode::LockAlreadyHeld.code(),
889        }
890    }
891
892    /// Human-readable suggestion.
893    #[must_use]
894    pub fn suggestion(&self) -> String {
895        match self {
896            Self::Timeout { path, .. } => {
897                format!(
898                    "Wait for the other process to finish, then retry. Check for stale lock files at {}.",
899                    path.display()
900                )
901            }
902            Self::AlreadyLocked { .. } => {
903                "Wait for the other process to finish. If no process is running, remove the lock file.".into()
904            }
905        }
906    }
907}
908
909// ---------------------------------------------------------------------------
910// From implementations for common error types
911// ---------------------------------------------------------------------------
912
913impl From<std::io::Error> for BonesError {
914    fn from(err: std::io::Error) -> Self {
915        let kind = err.kind();
916        match kind {
917            std::io::ErrorKind::PermissionDenied => Self::Io(IoError::PermissionDenied {
918                path: PathBuf::from("<unknown>"),
919            }),
920            _ => Self::Io(IoError::Generic {
921                path: PathBuf::from("<unknown>"),
922                reason: err.to_string(),
923            }),
924        }
925    }
926}
927
928impl From<rusqlite::Error> for BonesError {
929    fn from(err: rusqlite::Error) -> Self {
930        Self::Projection(ProjectionError::QueryFailed {
931            sql: String::new(),
932            reason: err.to_string(),
933        })
934    }
935}
936
937impl From<serde_json::Error> for BonesError {
938    fn from(err: serde_json::Error) -> Self {
939        Self::Event(EventError::SerializeFailed {
940            reason: err.to_string(),
941        })
942    }
943}
944
945// ---------------------------------------------------------------------------
946// Tests
947// ---------------------------------------------------------------------------
948
949#[cfg(test)]
950mod tests {
951    use super::*;
952    use std::collections::HashSet;
953
954    // --- ErrorCode backward-compat tests ---
955
956    #[test]
957    fn all_codes_are_unique() {
958        let all = [
959            ErrorCode::NotInitialized,
960            ErrorCode::ConfigParseError,
961            ErrorCode::ConfigInvalidValue,
962            ErrorCode::ModelNotFound,
963            ErrorCode::ItemNotFound,
964            ErrorCode::InvalidStateTransition,
965            ErrorCode::CycleDetected,
966            ErrorCode::AmbiguousId,
967            ErrorCode::InvalidEnumValue,
968            ErrorCode::InvalidItemId,
969            ErrorCode::DuplicateItem,
970            ErrorCode::ShardManifestMismatch,
971            ErrorCode::EventHashCollision,
972            ErrorCode::CorruptProjection,
973            ErrorCode::EventParseFailed,
974            ErrorCode::EventUnknownType,
975            ErrorCode::EventInvalidTimestamp,
976            ErrorCode::EventOversizedPayload,
977            ErrorCode::EventFileWriteFailed,
978            ErrorCode::ShardNotFound,
979            ErrorCode::LockContention,
980            ErrorCode::LockAlreadyHeld,
981            ErrorCode::PermissionDenied,
982            ErrorCode::DiskFull,
983            ErrorCode::NotABonesProject,
984            ErrorCode::DbMissing,
985            ErrorCode::DbSchemaVersion,
986            ErrorCode::DbQueryFailed,
987            ErrorCode::DbRebuildFailed,
988            ErrorCode::FtsIndexMissing,
989            ErrorCode::SemanticModelLoadFailed,
990            ErrorCode::InternalUnexpected,
991        ];
992
993        let mut seen = HashSet::new();
994        for code in all {
995            assert!(seen.insert(code.code()), "duplicate code {}", code.code());
996        }
997
998        // Acceptance criterion: 30+ distinct error conditions
999        assert!(
1000            all.len() >= 30,
1001            "Expected 30+ error codes, got {}",
1002            all.len()
1003        );
1004    }
1005
1006    #[test]
1007    fn code_format_is_machine_friendly() {
1008        let code = ErrorCode::InvalidStateTransition.code();
1009        assert_eq!(code.len(), 5);
1010        assert!(code.starts_with('E'));
1011        assert!(code.chars().skip(1).all(|c| c.is_ascii_digit()));
1012    }
1013
1014    #[test]
1015    fn all_codes_have_messages() {
1016        let all = [
1017            ErrorCode::NotInitialized,
1018            ErrorCode::ConfigParseError,
1019            ErrorCode::ConfigInvalidValue,
1020            ErrorCode::ModelNotFound,
1021            ErrorCode::ItemNotFound,
1022            ErrorCode::InvalidStateTransition,
1023            ErrorCode::CycleDetected,
1024            ErrorCode::AmbiguousId,
1025            ErrorCode::InvalidEnumValue,
1026            ErrorCode::InvalidItemId,
1027            ErrorCode::DuplicateItem,
1028            ErrorCode::ShardManifestMismatch,
1029            ErrorCode::EventHashCollision,
1030            ErrorCode::CorruptProjection,
1031            ErrorCode::EventParseFailed,
1032            ErrorCode::EventUnknownType,
1033            ErrorCode::EventInvalidTimestamp,
1034            ErrorCode::EventOversizedPayload,
1035            ErrorCode::EventFileWriteFailed,
1036            ErrorCode::ShardNotFound,
1037            ErrorCode::LockContention,
1038            ErrorCode::LockAlreadyHeld,
1039            ErrorCode::PermissionDenied,
1040            ErrorCode::DiskFull,
1041            ErrorCode::NotABonesProject,
1042            ErrorCode::DbMissing,
1043            ErrorCode::DbSchemaVersion,
1044            ErrorCode::DbQueryFailed,
1045            ErrorCode::DbRebuildFailed,
1046            ErrorCode::FtsIndexMissing,
1047            ErrorCode::SemanticModelLoadFailed,
1048            ErrorCode::InternalUnexpected,
1049        ];
1050
1051        for code in all {
1052            assert!(!code.message().is_empty(), "{:?} has empty message", code);
1053        }
1054    }
1055
1056    #[test]
1057    fn all_codes_have_hints() {
1058        let all = [
1059            ErrorCode::NotInitialized,
1060            ErrorCode::ConfigParseError,
1061            ErrorCode::ConfigInvalidValue,
1062            ErrorCode::ModelNotFound,
1063            ErrorCode::ItemNotFound,
1064            ErrorCode::InvalidStateTransition,
1065            ErrorCode::CycleDetected,
1066            ErrorCode::AmbiguousId,
1067            ErrorCode::InvalidEnumValue,
1068            ErrorCode::InvalidItemId,
1069            ErrorCode::DuplicateItem,
1070            ErrorCode::ShardManifestMismatch,
1071            ErrorCode::EventHashCollision,
1072            ErrorCode::CorruptProjection,
1073            ErrorCode::EventParseFailed,
1074            ErrorCode::EventUnknownType,
1075            ErrorCode::EventInvalidTimestamp,
1076            ErrorCode::EventOversizedPayload,
1077            ErrorCode::EventFileWriteFailed,
1078            ErrorCode::ShardNotFound,
1079            ErrorCode::LockContention,
1080            ErrorCode::LockAlreadyHeld,
1081            ErrorCode::PermissionDenied,
1082            ErrorCode::DiskFull,
1083            ErrorCode::NotABonesProject,
1084            ErrorCode::DbMissing,
1085            ErrorCode::DbSchemaVersion,
1086            ErrorCode::DbQueryFailed,
1087            ErrorCode::DbRebuildFailed,
1088            ErrorCode::FtsIndexMissing,
1089            ErrorCode::SemanticModelLoadFailed,
1090            ErrorCode::InternalUnexpected,
1091        ];
1092
1093        for code in all {
1094            assert!(code.hint().is_some(), "{:?} has no hint", code);
1095        }
1096    }
1097
1098    // --- BonesError hierarchy tests ---
1099
1100    #[test]
1101    fn bones_error_from_event_error() {
1102        let err = BonesError::Event(EventError::ParseFailed {
1103            line_num: 42,
1104            reason: "unexpected token".into(),
1105        });
1106        assert_eq!(err.error_code(), "E4001");
1107        assert!(err.to_string().contains("line 42"));
1108        assert!(err.to_string().contains("unexpected token"));
1109        assert!(!err.suggestion().is_empty());
1110    }
1111
1112    #[test]
1113    fn bones_error_from_projection_error() {
1114        let err = BonesError::Projection(ProjectionError::SchemaVersion {
1115            expected: 3,
1116            found: 1,
1117        });
1118        assert_eq!(err.error_code(), "E5009");
1119        assert!(err.to_string().contains("v3"));
1120        assert!(err.to_string().contains("v1"));
1121    }
1122
1123    #[test]
1124    fn bones_error_from_config_error() {
1125        let err = BonesError::Config(ConfigError::InvalidValue {
1126            key: "shard_size".into(),
1127            value: "-1".into(),
1128            reason: "must be positive".into(),
1129        });
1130        assert_eq!(err.error_code(), "E1003");
1131        assert!(err.to_string().contains("shard_size"));
1132    }
1133
1134    #[test]
1135    fn bones_error_from_io_error() {
1136        let err = BonesError::Io(IoError::NotABonesProject {
1137            path: PathBuf::from("/tmp/foo"),
1138        });
1139        assert_eq!(err.error_code(), "E5006");
1140        assert!(err.to_string().contains("/tmp/foo"));
1141        assert!(err.suggestion().contains("bn init"));
1142    }
1143
1144    #[test]
1145    fn bones_error_from_model_error() {
1146        let err = BonesError::Model(ModelError::InvalidTransition {
1147            item_id: "abc123".into(),
1148            from: "done".into(),
1149            to: "doing".into(),
1150        });
1151        assert_eq!(err.error_code(), "E2002");
1152        assert!(err.to_string().contains("abc123"));
1153        assert!(err.to_string().contains("done"));
1154        assert!(err.to_string().contains("doing"));
1155    }
1156
1157    #[test]
1158    fn bones_error_from_lock_error() {
1159        let err = BonesError::Lock(LockError::Timeout {
1160            path: PathBuf::from("/repo/.bones/lock"),
1161            waited: Duration::from_secs(5),
1162        });
1163        assert_eq!(err.error_code(), "E5002");
1164        assert!(err.to_string().contains("timed out"));
1165    }
1166
1167    #[test]
1168    fn model_error_item_not_found() {
1169        let err = ModelError::ItemNotFound {
1170            item_id: "xyz789".into(),
1171        };
1172        assert_eq!(err.error_code(), ErrorCode::ItemNotFound.code());
1173        assert!(err.to_string().contains("xyz789"));
1174        assert!(err.suggestion().contains("bn list"));
1175    }
1176
1177    #[test]
1178    fn model_error_ambiguous_id() {
1179        let err = ModelError::AmbiguousId {
1180            prefix: "ab".into(),
1181            count: 3,
1182            matches: vec!["abc".into(), "abd".into(), "abe".into()],
1183        };
1184        assert_eq!(err.error_code(), ErrorCode::AmbiguousId.code());
1185        assert!(err.to_string().contains("3 items"));
1186        assert!(err.to_string().contains("abc"));
1187    }
1188
1189    #[test]
1190    fn model_error_invalid_enum_value() {
1191        let err = ModelError::InvalidEnumValue {
1192            field: "kind".into(),
1193            value: "epic".into(),
1194            valid_values: "task, goal, bug".into(),
1195        };
1196        assert_eq!(err.error_code(), ErrorCode::InvalidEnumValue.code());
1197        assert!(err.to_string().contains("epic"));
1198        assert!(err.to_string().contains("task, goal, bug"));
1199    }
1200
1201    #[test]
1202    fn model_error_cycle_detected() {
1203        let err = ModelError::CycleDetected {
1204            cycle: vec!["a".into(), "b".into(), "c".into(), "a".into()],
1205        };
1206        assert_eq!(err.error_code(), ErrorCode::CycleDetected.code());
1207        assert!(err.to_string().contains("a -> b -> c -> a"));
1208    }
1209
1210    #[test]
1211    fn event_error_unknown_type() {
1212        let err = EventError::UnknownType {
1213            event_type: "item.frobnicate".into(),
1214        };
1215        assert_eq!(err.error_code(), ErrorCode::EventUnknownType.code());
1216        assert!(err.to_string().contains("item.frobnicate"));
1217    }
1218
1219    #[test]
1220    fn event_error_manifest_mismatch() {
1221        let err = EventError::ManifestMismatch {
1222            shard: PathBuf::from("2026-01.events"),
1223            expected_hash: "blake3:aaa".into(),
1224            actual_hash: "blake3:bbb".into(),
1225        };
1226        assert_eq!(err.error_code(), ErrorCode::ShardManifestMismatch.code());
1227        assert!(err.to_string().contains("blake3:aaa"));
1228        assert!(err.to_string().contains("blake3:bbb"));
1229    }
1230
1231    #[test]
1232    fn event_error_oversized_payload() {
1233        let err = EventError::OversizedPayload {
1234            size: 2_000_000,
1235            max: 1_000_000,
1236        };
1237        assert_eq!(err.error_code(), ErrorCode::EventOversizedPayload.code());
1238        assert!(err.to_string().contains("2000000"));
1239        assert!(err.to_string().contains("1000000"));
1240    }
1241
1242    #[test]
1243    fn projection_error_db_missing() {
1244        let err = ProjectionError::DbMissing {
1245            path: PathBuf::from(".bones/db.sqlite"),
1246        };
1247        assert_eq!(err.error_code(), ErrorCode::DbMissing.code());
1248        assert!(err.to_string().contains("db.sqlite"));
1249    }
1250
1251    #[test]
1252    fn projection_error_fts_missing() {
1253        let err = ProjectionError::FtsIndexMissing;
1254        assert_eq!(err.error_code(), ErrorCode::FtsIndexMissing.code());
1255        assert!(err.suggestion().contains("bn admin rebuild"));
1256    }
1257
1258    #[test]
1259    fn config_error_not_found() {
1260        let err = ConfigError::NotFound {
1261            path: PathBuf::from(".bones/config.toml"),
1262        };
1263        assert_eq!(err.error_code(), ErrorCode::NotInitialized.code());
1264        assert!(err.suggestion().contains("bn init"));
1265    }
1266
1267    #[test]
1268    fn config_error_parse_failed() {
1269        let err = ConfigError::ParseFailed {
1270            path: PathBuf::from(".bones/config.toml"),
1271            reason: "expected '=' at line 5".into(),
1272        };
1273        assert_eq!(err.error_code(), ErrorCode::ConfigParseError.code());
1274        assert!(err.to_string().contains("line 5"));
1275    }
1276
1277    #[test]
1278    fn io_error_permission_denied() {
1279        let err = IoError::PermissionDenied {
1280            path: PathBuf::from("/etc/secret"),
1281        };
1282        assert_eq!(err.error_code(), ErrorCode::PermissionDenied.code());
1283        assert!(err.to_string().contains("/etc/secret"));
1284    }
1285
1286    #[test]
1287    fn io_error_disk_full() {
1288        let err = IoError::DiskFull {
1289            path: PathBuf::from("/mnt/data"),
1290        };
1291        assert_eq!(err.error_code(), ErrorCode::DiskFull.code());
1292        assert!(err.suggestion().contains("df -h"));
1293    }
1294
1295    #[test]
1296    fn lock_error_already_locked() {
1297        let err = LockError::AlreadyLocked {
1298            path: PathBuf::from(".bones/lock"),
1299            holder: Some("pid:1234".into()),
1300        };
1301        assert_eq!(err.error_code(), ErrorCode::LockAlreadyHeld.code());
1302        assert!(err.to_string().contains("pid:1234"));
1303    }
1304
1305    #[test]
1306    fn bones_error_to_json_error() {
1307        let err = BonesError::Model(ModelError::ItemNotFound {
1308            item_id: "test123".into(),
1309        });
1310        let json_err = err.to_json_error();
1311        assert_eq!(json_err.error_code, "E2001");
1312        assert!(json_err.message.contains("test123"));
1313        assert!(!json_err.suggestion.is_empty());
1314
1315        // Verify it serializes cleanly
1316        let serialized = serde_json::to_string(&json_err).unwrap();
1317        assert!(serialized.contains("E2001"));
1318        assert!(serialized.contains("test123"));
1319    }
1320
1321    #[test]
1322    fn bones_error_from_std_io_error_permission() {
1323        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "forbidden");
1324        let err: BonesError = io_err.into();
1325        assert_eq!(err.error_code(), ErrorCode::PermissionDenied.code());
1326    }
1327
1328    #[test]
1329    fn bones_error_from_std_io_error_generic() {
1330        let io_err = std::io::Error::new(std::io::ErrorKind::Other, "disk on fire");
1331        let err: BonesError = io_err.into();
1332        assert!(err.to_string().contains("disk on fire"));
1333    }
1334
1335    #[test]
1336    fn bones_error_from_serde_json_error() {
1337        let json_err =
1338            serde_json::from_str::<serde_json::Value>("{{bad}}").expect_err("should fail");
1339        let err: BonesError = json_err.into();
1340        assert!(matches!(
1341            err,
1342            BonesError::Event(EventError::SerializeFailed { .. })
1343        ));
1344    }
1345
1346    #[test]
1347    fn every_error_variant_has_suggestion() {
1348        // Comprehensive check: create one of each variant and verify suggestion is non-empty
1349        let errors: Vec<BonesError> = vec![
1350            EventError::ParseFailed {
1351                line_num: 1,
1352                reason: "x".into(),
1353            }
1354            .into(),
1355            EventError::UnknownType {
1356                event_type: "x".into(),
1357            }
1358            .into(),
1359            EventError::InvalidTimestamp { raw: "x".into() }.into(),
1360            EventError::ShardNotFound {
1361                path: PathBuf::from("x"),
1362            }
1363            .into(),
1364            EventError::ManifestMismatch {
1365                shard: PathBuf::from("x"),
1366                expected_hash: "a".into(),
1367                actual_hash: "b".into(),
1368            }
1369            .into(),
1370            EventError::OversizedPayload { size: 1, max: 0 }.into(),
1371            EventError::HashCollision.into(),
1372            EventError::WriteFailed { reason: "x".into() }.into(),
1373            EventError::SerializeFailed { reason: "x".into() }.into(),
1374            ProjectionError::DbMissing {
1375                path: PathBuf::from("x"),
1376            }
1377            .into(),
1378            ProjectionError::SchemaVersion {
1379                expected: 1,
1380                found: 0,
1381            }
1382            .into(),
1383            ProjectionError::QueryFailed {
1384                sql: "x".into(),
1385                reason: "x".into(),
1386            }
1387            .into(),
1388            ProjectionError::RebuildFailed { reason: "x".into() }.into(),
1389            ProjectionError::Corrupt { reason: "x".into() }.into(),
1390            ProjectionError::FtsIndexMissing.into(),
1391            ConfigError::NotFound {
1392                path: PathBuf::from("x"),
1393            }
1394            .into(),
1395            ConfigError::InvalidValue {
1396                key: "k".into(),
1397                value: "v".into(),
1398                reason: "r".into(),
1399            }
1400            .into(),
1401            ConfigError::ParseFailed {
1402                path: PathBuf::from("x"),
1403                reason: "r".into(),
1404            }
1405            .into(),
1406            IoError::PermissionDenied {
1407                path: PathBuf::from("x"),
1408            }
1409            .into(),
1410            IoError::DiskFull {
1411                path: PathBuf::from("x"),
1412            }
1413            .into(),
1414            IoError::NotABonesProject {
1415                path: PathBuf::from("x"),
1416            }
1417            .into(),
1418            IoError::Generic {
1419                path: PathBuf::from("x"),
1420                reason: "r".into(),
1421            }
1422            .into(),
1423            ModelError::InvalidTransition {
1424                item_id: "x".into(),
1425                from: "a".into(),
1426                to: "b".into(),
1427            }
1428            .into(),
1429            ModelError::ItemNotFound {
1430                item_id: "x".into(),
1431            }
1432            .into(),
1433            ModelError::CircularContainment {
1434                cycle: vec!["a".into(), "b".into()],
1435            }
1436            .into(),
1437            ModelError::InvalidItemId { raw: "x".into() }.into(),
1438            ModelError::AmbiguousId {
1439                prefix: "x".into(),
1440                count: 2,
1441                matches: vec!["xa".into(), "xb".into()],
1442            }
1443            .into(),
1444            ModelError::InvalidEnumValue {
1445                field: "f".into(),
1446                value: "v".into(),
1447                valid_values: "a, b".into(),
1448            }
1449            .into(),
1450            ModelError::DuplicateItem {
1451                item_id: "x".into(),
1452            }
1453            .into(),
1454            ModelError::CycleDetected {
1455                cycle: vec!["a".into(), "b".into()],
1456            }
1457            .into(),
1458            LockError::Timeout {
1459                path: PathBuf::from("x"),
1460                waited: Duration::from_secs(1),
1461            }
1462            .into(),
1463            LockError::AlreadyLocked {
1464                path: PathBuf::from("x"),
1465                holder: None,
1466            }
1467            .into(),
1468        ];
1469
1470        for (i, err) in errors.iter().enumerate() {
1471            assert!(
1472                !err.suggestion().is_empty(),
1473                "Error variant {i} has empty suggestion: {err}"
1474            );
1475            assert!(
1476                !err.error_code().is_empty(),
1477                "Error variant {i} has empty error_code: {err}"
1478            );
1479            assert!(
1480                !err.to_string().is_empty(),
1481                "Error variant {i} has empty display: {err}"
1482            );
1483        }
1484
1485        // Acceptance criterion: 30+ distinct error conditions
1486        assert!(
1487            errors.len() >= 30,
1488            "Expected 30+ error variants, got {}",
1489            errors.len()
1490        );
1491    }
1492
1493    #[test]
1494    fn display_format_has_error_cause_fix() {
1495        // Verify the Error/Cause/Fix pattern for representative variants
1496        let err = EventError::ParseFailed {
1497            line_num: 42,
1498            reason: "bad json".into(),
1499        };
1500        let msg = err.to_string();
1501        assert!(msg.contains("Error:"), "Missing 'Error:' in: {msg}");
1502        assert!(
1503            msg.contains("Cause:") || msg.contains("bad json"),
1504            "Missing cause in: {msg}"
1505        );
1506        assert!(msg.contains("Fix:"), "Missing 'Fix:' in: {msg}");
1507
1508        let err = ModelError::InvalidTransition {
1509            item_id: "abc".into(),
1510            from: "done".into(),
1511            to: "doing".into(),
1512        };
1513        let msg = err.to_string();
1514        assert!(msg.contains("Error:"), "Missing 'Error:' in: {msg}");
1515        assert!(msg.contains("Fix:"), "Missing 'Fix:' in: {msg}");
1516    }
1517
1518    #[test]
1519    fn json_error_serialization_stable() {
1520        let err = BonesError::Model(ModelError::ItemNotFound {
1521            item_id: "abc".into(),
1522        });
1523        let json_err = err.to_json_error();
1524        let value: serde_json::Value = serde_json::to_value(&json_err).unwrap();
1525
1526        // Verify required fields exist
1527        assert!(value.get("error_code").is_some());
1528        assert!(value.get("message").is_some());
1529        assert!(value.get("suggestion").is_some());
1530
1531        // Verify types
1532        assert!(value["error_code"].is_string());
1533        assert!(value["message"].is_string());
1534        assert!(value["suggestion"].is_string());
1535    }
1536}