1use serde::Serialize;
20use std::fmt;
21use std::path::PathBuf;
22use std::time::Duration;
23
24#[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 #[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 #[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 #[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#[derive(Debug, thiserror::Error)]
244pub enum BonesError {
245 #[error(transparent)]
247 Event(#[from] EventError),
248
249 #[error(transparent)]
251 Projection(#[from] ProjectionError),
252
253 #[error(transparent)]
255 Config(#[from] ConfigError),
256
257 #[error(transparent)]
259 Io(#[from] IoError),
260
261 #[error(transparent)]
263 Model(#[from] ModelError),
264
265 #[error(transparent)]
267 Lock(#[from] LockError),
268}
269
270impl BonesError {
271 #[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 #[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 #[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#[derive(Debug, Clone, Serialize)]
310pub struct JsonError {
311 pub error_code: String,
313 pub message: String,
315 pub suggestion: String,
317}
318
319#[derive(Debug, thiserror::Error)]
325pub enum EventError {
326 #[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 line_num: usize,
333 reason: String,
335 },
336
337 #[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 event_type: String,
344 },
345
346 #[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 raw: String,
353 },
354
355 #[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: PathBuf,
362 },
363
364 #[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 shard: PathBuf,
371 expected_hash: String,
373 actual_hash: String,
375 },
376
377 #[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 size: usize,
384 max: usize,
386 },
387
388 #[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 #[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 reason: String,
401 },
402
403 #[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 reason: String,
410 },
411}
412
413impl EventError {
414 #[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 #[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#[derive(Debug, thiserror::Error)]
474pub enum ProjectionError {
475 #[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 path: PathBuf,
482 },
483
484 #[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: u32,
491 found: u32,
493 },
494
495 #[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 sql: String,
502 reason: String,
504 },
505
506 #[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 reason: String,
513 },
514
515 #[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 reason: String,
522 },
523
524 #[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 #[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 #[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#[derive(Debug, thiserror::Error)]
575pub enum ConfigError {
576 #[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 path: PathBuf,
583 },
584
585 #[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 key: String,
592 value: String,
594 reason: String,
596 },
597
598 #[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: PathBuf,
605 reason: String,
607 },
608}
609
610impl ConfigError {
611 #[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 #[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#[derive(Debug, thiserror::Error)]
645pub enum IoError {
646 #[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 path: PathBuf,
653 },
654
655 #[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 path: PathBuf,
662 },
663
664 #[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 path: PathBuf,
671 },
672
673 #[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 path: PathBuf,
680 reason: String,
682 },
683}
684
685impl IoError {
686 #[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 #[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#[derive(Debug, thiserror::Error)]
725pub enum ModelError {
726 #[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 item_id: String,
733 from: String,
735 to: String,
737 },
738
739 #[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 item_id: String,
746 },
747
748 #[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 cycle: Vec<String>,
753 },
754
755 #[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 raw: String,
762 },
763
764 #[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 prefix: String,
769 count: usize,
771 matches: Vec<String>,
773 },
774
775 #[error(
777 "Error: Invalid {field} value '{value}'\nCause: '{value}' is not a recognized {field}\nFix: Valid {field} values: {valid_values}"
778 )]
779 InvalidEnumValue {
780 field: String,
782 value: String,
784 valid_values: String,
786 },
787
788 #[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 item_id: String,
795 },
796
797 #[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 cycle: Vec<String>,
802 },
803}
804
805impl ModelError {
806 #[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 #[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#[derive(Debug, thiserror::Error)]
860pub enum LockError {
861 #[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 path: PathBuf,
868 waited: Duration,
870 },
871
872 #[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 path: PathBuf,
877 holder: Option<String>,
879 },
880}
881
882impl LockError {
883 #[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 #[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
909impl 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#[cfg(test)]
950mod tests {
951 use super::*;
952 use std::collections::HashSet;
953
954 #[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 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 #[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 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 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 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 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 assert!(value.get("error_code").is_some());
1528 assert!(value.get("message").is_some());
1529 assert!(value.get("suggestion").is_some());
1530
1531 assert!(value["error_code"].is_string());
1533 assert!(value["message"].is_string());
1534 assert!(value["suggestion"].is_string());
1535 }
1536}