1use std::io;
2use std::path::Path;
3
4use hayson_engine::atomic::AtomicTemporaryFileError;
5use hayson_engine::catalog::MigrateFormat1Error;
6use hayson_engine::database::DatabaseError;
7use hayson_engine::errors::{
8 HaysonCipherError, HaysonCorruptLogError, HaysonImageClashError, HaysonIndexMismatchError, HaysonLogReplacedError,
9};
10use hayson_engine::lock::{DirLockError, LockedError};
11use hayson_engine::table::TableError;
12use hayson_engine::txn::TxnError;
13use serde_json::{json, Map, Value};
14
15pub const ENVELOPE_VERSION: u64 = 1;
18
19pub mod code {
22 pub const VALIDATION: &str = "Validation";
23 pub const UNIQUE_VIOLATION: &str = "UniqueViolation";
24 pub const PENDING_CONFLICT: &str = "PendingConflict";
25 pub const NOT_FOUND: &str = "NotFound";
26 pub const SCHEMA: &str = "Schema";
27 pub const PARTITION: &str = "Partition";
28 pub const RELATION_VIOLATION: &str = "RelationViolation";
29 pub const RELATION_RESTRICT: &str = "RelationRestrict";
30 pub const CIPHER: &str = "Cipher";
31 pub const LOCKED: &str = "Locked";
32 pub const LOCK_LOST: &str = "LockLost";
33 pub const SUSPICIOUS_LOCK_FILE: &str = "SuspiciousLockFile";
34 pub const READ_ONLY: &str = "ReadOnly";
35 pub const FIELD_IN_USE: &str = "FieldInUse";
36 pub const CATALOG: &str = "Catalog";
37 pub const TXN: &str = "Txn";
38 pub const INDEX_MISMATCH: &str = "IndexMismatch";
39 pub const LOG_REPLACED: &str = "LogReplaced";
40 pub const CORRUPT_LOG: &str = "CorruptLog";
41 pub const IMAGE_CLASH: &str = "ImageClash";
42 pub const TEMPORARY_FILE: &str = "TemporaryFile";
43 pub const IO: &str = "Io";
44 pub const INVALID_JSON: &str = "InvalidJson";
45 pub const CLOSED: &str = "Closed";
46}
47
48fn envelope(code: &str, message: String, fields: Map<String, Value>) -> Value {
49 let mut object = Map::new();
50 object.insert("envelopeVersion".to_string(), json!(ENVELOPE_VERSION));
51 object.insert("code".to_string(), json!(code));
52 object.insert("message".to_string(), json!(message));
53 for (key, value) in fields {
54 object.insert(key, value);
55 }
56 Value::Object(object)
57}
58
59fn no_fields() -> Map<String, Value> {
60 Map::new()
61}
62
63fn fields(pairs: Value) -> Map<String, Value> {
64 match pairs {
65 Value::Object(map) => map,
66 _ => Map::new(),
67 }
68}
69
70fn describe_cipher_error(error: &HaysonCipherError) -> Value {
71 envelope(
72 code::CIPHER,
73 error.to_string(),
74 fields(json!({
75 "table": error.table,
76 "field": error.field,
77 "reason": error.reason,
78 "keyId": error.key_id,
79 })),
80 )
81}
82
83fn describe_locked_error(error: &LockedError) -> Value {
84 let dir = error.subject.display().to_string();
87 let holder = error.holder.as_ref().map(|holder| {
88 json!({
89 "owner": holder.owner,
90 "pid": holder.pid,
91 "host": holder.host,
92 "startedAt": holder.started_at,
93 "heartbeatAt": holder.heartbeat_at,
94 })
95 });
96 envelope(
97 code::LOCKED,
98 error.to_string(),
99 fields(json!({ "dir": dir, "holder": holder, "waitedMs": error.waited_ms })),
100 )
101}
102
103pub fn describe_table_error(table: &str, error: &TableError) -> Value {
106 let message = error.to_string();
107 match error {
108 TableError::Validation(inner) => envelope(
109 code::VALIDATION,
110 message,
111 fields(json!({
112 "table": inner.table,
113 "issues": inner
114 .issues
115 .iter()
116 .map(|issue| json!({ "field": issue.field, "reason": issue.reason }))
117 .collect::<Vec<_>>(),
118 })),
119 ),
120 TableError::Unique(inner) => envelope(
121 code::UNIQUE_VIOLATION,
122 message,
123 fields(json!({ "table": inner.table, "field": inner.field, "holder": inner.holder })),
124 ),
125 TableError::Conflict(inner) => envelope(
126 code::PENDING_CONFLICT,
127 message,
128 fields(json!({ "table": inner.table, "key": inner.key, "fields": inner.fields })),
129 ),
130 TableError::NotFound(inner) => envelope(
131 code::NOT_FOUND,
132 message,
133 fields(json!({ "table": inner.table, "key": inner.key })),
134 ),
135 TableError::Schema(_) => envelope(code::SCHEMA, message, fields(json!({ "table": table }))),
136 TableError::Partition(inner) => envelope(
137 code::PARTITION,
138 message,
139 fields(json!({ "table": inner.table, "field": inner.field, "reason": inner.reason })),
140 ),
141 TableError::Relation(inner) => envelope(
142 code::RELATION_VIOLATION,
143 message,
144 fields(json!({
145 "relation": inner.relation,
146 "table": inner.table,
147 "field": inner.field,
148 "value": inner.value,
149 })),
150 ),
151 TableError::RelationRestrict(inner) => envelope(
152 code::RELATION_RESTRICT,
153 message,
154 fields(json!({
155 "relation": inner.relation,
156 "table": inner.table,
157 "key": inner.key,
158 "referencingTable": inner.referencing_table,
159 "referencingField": inner.referencing_field,
160 "referencing": inner.referencing,
161 })),
162 ),
163 TableError::Cipher(inner) => describe_cipher_error(inner),
164 TableError::Io(inner) => describe_io_error(inner),
165 }
166}
167
168fn index_mismatch_of(error: &io::Error) -> Option<&HaysonIndexMismatchError> {
172 error.get_ref()?.downcast_ref::<HaysonIndexMismatchError>()
173}
174
175fn corrupt_log_of(error: &io::Error) -> Option<&HaysonCorruptLogError> {
176 error.get_ref()?.downcast_ref::<HaysonCorruptLogError>()
177}
178
179fn image_clash_of(error: &io::Error) -> Option<&HaysonImageClashError> {
180 error.get_ref()?.downcast_ref::<HaysonImageClashError>()
181}
182
183fn log_replaced_of(error: &io::Error) -> Option<&HaysonLogReplacedError> {
184 error.get_ref()?.downcast_ref::<HaysonLogReplacedError>()
185}
186
187fn temporary_file_of(error: &io::Error) -> Option<&AtomicTemporaryFileError> {
192 AtomicTemporaryFileError::downcast(error)
193}
194
195pub fn describe_io_error(error: &io::Error) -> Value {
196 if let Some(inner) = temporary_file_of(error) {
197 return envelope(
198 code::TEMPORARY_FILE,
199 error.to_string(),
200 fields(json!({
201 "path": inner.path.display().to_string(),
202 "reason": inner.reason,
203 })),
204 );
205 }
206 if let Some(inner) = corrupt_log_of(error) {
207 return envelope(
208 code::CORRUPT_LOG,
209 error.to_string(),
210 fields(json!({ "path": inner.path, "offset": inner.offset, "reason": inner.reason })),
211 );
212 }
213 if let Some(inner) = image_clash_of(error) {
214 return envelope(
215 code::IMAGE_CLASH,
216 error.to_string(),
217 fields(json!({ "physicalPath": inner.physical_path })),
218 );
219 }
220 if let Some(inner) = log_replaced_of(error) {
221 return envelope(
222 code::LOG_REPLACED,
223 error.to_string(),
224 fields(json!({ "path": inner.path })),
225 );
226 }
227 if let Some(inner) = index_mismatch_of(error) {
228 return envelope(
229 code::INDEX_MISMATCH,
230 error.to_string(),
231 fields(json!({
232 "indexPath": inner.path,
233 "logPath": inner.log_path,
234 "watermark": inner.watermark,
235 "expectedHash": inner.expected_hash,
236 "actualHash": inner.actual_hash,
237 })),
238 );
239 }
240 envelope(
241 code::IO,
242 error.to_string(),
243 fields(json!({ "kind": format!("{:?}", error.kind()) })),
244 )
245}
246
247fn errno_name_of(error: &io::Error) -> Option<&'static str> {
252 match error.kind() {
253 io::ErrorKind::NotFound => Some("ENOENT"),
254 io::ErrorKind::PermissionDenied => Some("EACCES"),
255 _ => None,
256 }
257}
258
259fn describe_log_io_error(path: &Path, error: &io::Error) -> Value {
263 let mut described = fields(json!({
264 "kind": format!("{:?}", error.kind()),
265 "path": path.display().to_string(),
266 }));
267 if let Some(name) = errno_name_of(error) {
268 described.insert("errnoCode".to_string(), json!(name));
269 }
270 envelope(code::IO, format!("{error}, open \"{}\"", path.display()), described)
271}
272
273pub fn describe_txn_error(error: &TxnError) -> Value {
277 match error {
278 TxnError::Table(name, inner) => describe_table_error(name, inner),
279 TxnError::Io(inner) => describe_io_error(inner),
280 TxnError::UnknownTable(name) => envelope(code::NOT_FOUND, error.to_string(), fields(json!({ "table": name }))),
281 TxnError::AlreadyCommitted | TxnError::JournalPending(_) => envelope(code::TXN, error.to_string(), no_fields()),
282 }
283}
284
285pub fn describe_database_error(error: &DatabaseError) -> Value {
286 let message = error.to_string();
287 match error {
288 DatabaseError::Lock(DirLockError::Locked(inner)) => describe_locked_error(inner),
289 DatabaseError::Lock(DirLockError::Lost(path)) => envelope(
290 code::LOCK_LOST,
291 message,
292 fields(json!({ "lockPath": path.display().to_string() })),
293 ),
294 DatabaseError::Lock(DirLockError::SuspiciousLockFile(path)) => envelope(
295 code::SUSPICIOUS_LOCK_FILE,
296 message,
297 fields(json!({ "lockPath": path.display().to_string() })),
298 ),
299 DatabaseError::Lock(DirLockError::Io(inner)) => describe_io_error(inner),
300 DatabaseError::Schema(_) => envelope(code::SCHEMA, message, no_fields()),
301 DatabaseError::ReadOnly { dir, operation } => envelope(
302 code::READ_ONLY,
303 message,
304 fields(json!({ "dir": dir.display().to_string(), "operation": operation })),
305 ),
306 DatabaseError::FieldInUse { table, field, records } => envelope(
307 code::FIELD_IN_USE,
308 message,
309 fields(json!({ "table": table, "field": field, "records": records })),
310 ),
311 DatabaseError::Catalog(_) => envelope(code::CATALOG, message, no_fields()),
312 DatabaseError::Io(inner) => describe_io_error(inner),
313 DatabaseError::Log(path, inner) => describe_log_io_error(path, inner),
314 DatabaseError::Table(name, inner) => describe_table_error(name, inner),
315 DatabaseError::Txn(inner) => describe_txn_error(inner),
316 DatabaseError::Cipher(inner) => describe_cipher_error(inner),
317 }
318}
319
320pub fn describe_migrate_format1_error(error: &MigrateFormat1Error) -> Value {
321 let message = error.to_string();
322 match error {
323 MigrateFormat1Error::Schema(_) | MigrateFormat1Error::Write(_) => envelope(code::SCHEMA, message, no_fields()),
324 MigrateFormat1Error::Io(inner) => describe_io_error(inner),
325 MigrateFormat1Error::NotADirectory(path)
326 | MigrateFormat1Error::UnreadableMarker(path, _)
327 | MigrateFormat1Error::UnknownMarker(path, _)
328 | MigrateFormat1Error::NoSchemaFiles(path) => envelope(
329 code::SCHEMA,
330 message,
331 fields(json!({ "path": path.display().to_string() })),
332 ),
333 }
334}
335
336pub fn describe_invalid_json(what: &str, error: &serde_json::Error) -> Value {
337 envelope(
338 code::INVALID_JSON,
339 format!("{what} is not valid JSON: {error}"),
340 fields(json!({ "argument": what })),
341 )
342}
343
344pub fn describe_unknown_table(table: &str, dir: &str) -> Value {
345 envelope(
346 code::NOT_FOUND,
347 format!("table \"{table}\" does not exist in \"{dir}\""),
348 fields(json!({ "table": table, "dir": dir })),
349 )
350}
351
352pub fn describe_closed(operation: &str) -> Value {
353 envelope(
354 code::CLOSED,
355 format!("{operation} was called after the database was closed"),
356 fields(json!({ "operation": operation })),
357 )
358}
359
360pub fn describe_schema(message: String) -> Value {
362 envelope(code::SCHEMA, message, no_fields())
363}
364
365pub fn describe_cipher_config(message: String) -> Value {
366 envelope(code::CIPHER, message, fields(json!({ "table": "", "field": "" })))
367}
368
369#[cfg(test)]
370mod tests {
371 use hayson_engine::errors::{
372 HaysonNotFoundError, HaysonPartitionError, HaysonSchemaError, HaysonUniqueViolationError,
373 HaysonValidationError, ValidationIssue,
374 };
375 use hayson_engine::lock::LockHolder;
376 use std::path::PathBuf;
377
378 use super::*;
379
380 fn scratch_dir(prefix: &str) -> PathBuf {
381 let dir = std::env::temp_dir().join(format!(
382 "{prefix}{}-{}",
383 std::process::id(),
384 std::time::SystemTime::now()
385 .duration_since(std::time::UNIX_EPOCH)
386 .unwrap()
387 .as_nanos()
388 ));
389 std::fs::create_dir_all(&dir).unwrap();
390 dir
391 }
392
393 fn code_of(value: &Value) -> &str {
394 value.get("code").and_then(Value::as_str).unwrap()
395 }
396
397 #[test]
398 fn a_validation_error_carries_every_issue_the_shim_needs_to_rebuild_it() {
399 let error = TableError::Validation(HaysonValidationError {
400 table: "users".into(),
401 issues: vec![ValidationIssue {
402 field: "name".into(),
403 reason: "is required".into(),
404 }],
405 });
406
407 let described = describe_table_error("users", &error);
408
409 assert_eq!(code_of(&described), code::VALIDATION);
410 assert_eq!(described["table"], json!("users"));
411 assert_eq!(
412 described["issues"],
413 json!([{ "field": "name", "reason": "is required" }])
414 );
415 assert_eq!(described["envelopeVersion"], json!(ENVELOPE_VERSION));
416 assert_eq!(described["message"], json!(error.to_string()));
417 }
418
419 #[test]
420 fn a_unique_violation_names_the_field_and_the_key_already_holding_it() {
421 let error = TableError::Unique(HaysonUniqueViolationError {
422 table: "users".into(),
423 field: "email".into(),
424 holder: "u1".into(),
425 });
426
427 let described = describe_table_error("users", &error);
428
429 assert_eq!(code_of(&described), code::UNIQUE_VIOLATION);
430 assert_eq!(described["field"], json!("email"));
431 assert_eq!(described["holder"], json!("u1"));
432 }
433
434 #[test]
435 fn a_not_found_error_keeps_the_table_and_key_apart_from_the_message() {
436 let error = TableError::NotFound(HaysonNotFoundError {
437 table: "users".into(),
438 key: "u9".into(),
439 });
440
441 let described = describe_table_error("users", &error);
442
443 assert_eq!(code_of(&described), code::NOT_FOUND);
444 assert_eq!(described["key"], json!("u9"));
445 }
446
447 #[test]
448 fn a_partition_error_is_tagged_apart_from_a_plain_schema_rejection() {
449 let partition = TableError::Partition(HaysonPartitionError::new("events", "day", "is empty"));
450 let schema = TableError::Schema(HaysonSchemaError("no".into()));
451
452 assert_eq!(code_of(&describe_table_error("events", &partition)), code::PARTITION);
453 assert_eq!(code_of(&describe_table_error("events", &schema)), code::SCHEMA);
454 }
455
456 #[test]
457 fn a_cipher_error_carries_the_key_id_it_could_not_use() {
458 let error = TableError::Cipher(Box::new(
459 HaysonCipherError::new("secrets", "token", "cannot be opened").with_key_id("k2"),
460 ));
461
462 let described = describe_table_error("secrets", &error);
463
464 assert_eq!(code_of(&described), code::CIPHER);
465 assert_eq!(described["keyId"], json!("k2"));
466 assert_eq!(described["field"], json!("token"));
467 }
468
469 #[test]
470 fn a_locked_database_reports_the_directory_the_holder_and_how_long_we_waited() {
471 let error = DatabaseError::Lock(DirLockError::Locked(LockedError {
472 subject: PathBuf::from("/data/db"),
474 holder: Some(LockHolder {
475 owner: "0f1e2d3c".into(),
476 pid: Some(42),
477 host: Some("box".into()),
478 started_at: 10,
479 heartbeat_at: 20,
480 }),
481 waited_ms: 250,
482 }));
483
484 let described = describe_database_error(&error);
485
486 assert_eq!(code_of(&described), code::LOCKED);
487 assert_eq!(described["dir"], json!("/data/db"));
488 assert_eq!(described["waitedMs"], json!(250));
489 assert_eq!(described["holder"]["owner"], json!("0f1e2d3c"));
490 assert_eq!(described["holder"]["pid"], json!(42));
491 assert_eq!(described["holder"]["startedAt"], json!(10));
492 }
493
494 #[test]
495 fn a_lock_taken_over_by_another_writer_reports_the_lock_path_under_its_own_code() {
496 let error = DatabaseError::Lock(DirLockError::Lost(PathBuf::from("/data/db/.hayson.lock")));
497
498 let described = describe_database_error(&error);
499
500 assert_eq!(code_of(&described), code::LOCK_LOST);
501 assert_eq!(described["lockPath"], json!("/data/db/.hayson.lock"));
502 }
503
504 #[test]
505 fn a_symlink_planted_at_the_lock_path_reports_the_lock_path_under_its_own_code() {
506 let error = DatabaseError::Lock(DirLockError::SuspiciousLockFile(PathBuf::from("/data/db/.hayson.lock")));
507
508 let described = describe_database_error(&error);
509
510 assert_eq!(code_of(&described), code::SUSPICIOUS_LOCK_FILE);
511 assert_eq!(described["lockPath"], json!("/data/db/.hayson.lock"));
512 }
513
514 #[test]
515 fn a_readonly_refusal_is_told_apart_from_a_genuine_schema_error() {
516 let readonly = DatabaseError::ReadOnly {
517 dir: PathBuf::from("/data/db"),
518 operation: "createTable".into(),
519 };
520 let genuine = DatabaseError::Schema(HaysonSchemaError("field \"id\" has no type".into()));
521
522 let described = describe_database_error(&readonly);
523 assert_eq!(code_of(&described), code::READ_ONLY);
524 assert_eq!(described["operation"], json!("createTable"));
525 assert_eq!(described["dir"], json!("/data/db"));
526
527 assert_eq!(code_of(&describe_database_error(&genuine)), code::SCHEMA);
528 }
529
530 #[test]
531 fn a_residual_field_refusal_is_told_apart_from_a_plain_catalog_error() {
532 let in_use = DatabaseError::FieldInUse {
533 table: "users".into(),
534 field: "name".into(),
535 records: 3,
536 };
537 let plain = DatabaseError::Catalog("table \"ghost\" does not exist in \"/data/db\"".into());
538
539 let described = describe_database_error(&in_use);
540 assert_eq!(code_of(&described), code::FIELD_IN_USE);
541 assert_eq!(described["table"], json!("users"));
542 assert_eq!(described["field"], json!("name"));
543 assert_eq!(described["records"], json!(3));
544
545 assert_eq!(code_of(&describe_database_error(&plain)), code::CATALOG);
546 }
547
548 #[test]
549 fn a_table_scoped_database_error_is_described_as_its_inner_table_error() {
550 let error = DatabaseError::Table(
551 "users".into(),
552 TableError::NotFound(HaysonNotFoundError {
553 table: "users".into(),
554 key: "u9".into(),
555 }),
556 );
557
558 assert_eq!(code_of(&describe_database_error(&error)), code::NOT_FOUND);
559 }
560
561 #[test]
562 fn an_unknown_table_inside_a_unit_of_work_is_reported_as_not_found() {
563 let error = DatabaseError::Txn(TxnError::UnknownTable("ghost".into()));
564
565 let described = describe_database_error(&error);
566
567 assert_eq!(code_of(&described), code::NOT_FOUND);
568 assert_eq!(described["table"], json!("ghost"));
569 }
570
571 #[test]
572 fn an_io_failure_keeps_its_kind_so_the_shim_can_tell_missing_from_denied() {
573 let error = DatabaseError::Io(io::Error::new(io::ErrorKind::PermissionDenied, "nope"));
574
575 let described = describe_database_error(&error);
576
577 assert_eq!(code_of(&described), code::IO);
578 assert_eq!(described["kind"], json!("PermissionDenied"));
579 }
580
581 #[test]
582 fn an_io_failure_the_engine_could_name_a_file_for_carries_the_path_and_the_errno_name() {
583 let error = DatabaseError::Log(
584 PathBuf::from("/data/db/notes/note.jsonl"),
585 io::Error::from(io::ErrorKind::NotFound),
586 );
587
588 let described = describe_database_error(&error);
589
590 assert_eq!(code_of(&described), code::IO);
591 assert_eq!(described["path"], json!("/data/db/notes/note.jsonl"));
592 assert_eq!(described["errnoCode"], json!("ENOENT"));
593 }
594
595 #[test]
596 fn an_io_kind_with_no_portable_errno_name_leaves_the_field_out_rather_than_guessing() {
597 let error = DatabaseError::Log(
598 PathBuf::from("/data/db/notes.jsonl"),
599 io::Error::new(io::ErrorKind::InvalidData, "garbled"),
600 );
601
602 let described = describe_database_error(&error);
603
604 assert_eq!(described["path"], json!("/data/db/notes.jsonl"));
605 assert!(described.get("errnoCode").is_none(), "{described}");
606 }
607
608 #[test]
609 fn a_read_that_failed_to_decrypt_arrives_typed_with_the_key_id_the_flattened_text_used_to_lose() {
610 let error = TableError::Cipher(Box::new(
611 HaysonCipherError::new("secrets", "token", "was written with key \"k1\", which is not on hand")
612 .with_key_id("k1"),
613 ));
614
615 let described = describe_table_error("secrets", &error);
616
617 assert_eq!(code_of(&described), code::CIPHER);
618 assert_eq!(described["table"], json!("secrets"));
619 assert_eq!(described["field"], json!("token"));
620 assert_eq!(described["keyId"], json!("k1"));
621 }
622
623 #[test]
624 fn an_io_failure_on_a_read_stays_an_io_failure_now_that_nothing_reads_its_text() {
625 let corrupt = TableError::Io(io::Error::new(
626 io::ErrorKind::InvalidData,
627 "log \"a.jsonl\" is corrupt at offset 12",
628 ));
629
630 assert_eq!(code_of(&describe_table_error("notes", &corrupt)), code::IO);
631 }
632
633 #[test]
634 fn a_search_against_a_table_with_no_fulltext_field_arrives_as_a_typed_schema_refusal() {
635 let error = TableError::Schema(HaysonSchemaError(
636 "table \"users\" has no field marked \"fulltext\": true — search is not available".into(),
637 ));
638
639 let described = describe_table_error("users", &error);
640
641 assert_eq!(code_of(&described), code::SCHEMA);
642 assert_eq!(described["table"], json!("users"));
643 }
644
645 #[test]
646 fn a_write_onto_a_replaced_log_travels_under_its_own_code_with_the_path() {
647 let error = TableError::Io(io::Error::other(HaysonLogReplacedError {
648 path: "/data/db/users.jsonl".into(),
649 }));
650
651 let described = describe_table_error("users", &error);
652
653 assert_eq!(code_of(&described), code::LOG_REPLACED);
654 assert_eq!(described["path"], json!("/data/db/users.jsonl"));
655 assert_eq!(described["message"], json!(error.to_string()));
656 }
657
658 #[test]
659 fn a_corrupt_log_line_travels_under_its_own_code_with_file_and_offset() {
660 let error = DatabaseError::Io(io::Error::new(
661 io::ErrorKind::InvalidData,
662 HaysonCorruptLogError {
663 path: "/data/db/users.jsonl".into(),
664 offset: 1234,
665 reason: "line is not valid JSON".into(),
666 },
667 ));
668
669 let described = describe_database_error(&error);
670
671 assert_eq!(code_of(&described), code::CORRUPT_LOG);
672 assert_eq!(described["path"], json!("/data/db/users.jsonl"));
673 assert_eq!(described["offset"], json!(1234));
674 assert_eq!(described["reason"], json!("line is not valid JSON"));
675 }
676
677 #[test]
678 fn an_image_name_clash_travels_under_its_own_code_with_the_physical_path() {
679 let error = TableError::Io(io::Error::new(
680 io::ErrorKind::InvalidData,
681 HaysonImageClashError {
682 physical_path: "ab12.png".into(),
683 },
684 ));
685
686 let described = describe_table_error("docs", &error);
687
688 assert_eq!(code_of(&described), code::IMAGE_CLASH);
689 assert_eq!(described["physicalPath"], json!("ab12.png"));
690 }
691
692 #[test]
693 fn a_refused_index_snapshot_travels_under_its_own_code_with_both_paths_and_the_watermark() {
694 let error = DatabaseError::Io(io::Error::other(HaysonIndexMismatchError {
695 path: "/data/db/users.idx".into(),
696 log_path: "/data/db/users.jsonl".into(),
697 watermark: 397,
698 expected_hash: "9f2c000000000000".into(),
699 actual_hash: "4b1e000000000000".into(),
700 }));
701
702 let described = describe_database_error(&error);
703
704 assert_eq!(code_of(&described), code::INDEX_MISMATCH);
705 assert_eq!(described["indexPath"], json!("/data/db/users.idx"));
706 assert_eq!(described["logPath"], json!("/data/db/users.jsonl"));
707 assert_eq!(described["watermark"], json!(397));
708 assert_eq!(described["expectedHash"], json!("9f2c000000000000"));
709 assert_eq!(described["actualHash"], json!("4b1e000000000000"));
710 assert_eq!(described["message"], json!(error.to_string()));
711 }
712
713 #[test]
714 fn a_temporary_path_that_could_not_be_claimed_travels_under_its_own_code_with_path_and_reason() {
715 let dir = scratch_dir("hayson-envelope-temporary-");
716 let temporary = dir.join("hayson.json.tmp");
719 std::fs::create_dir(&temporary).unwrap();
720 let refusal = hayson_engine::atomic::open_temporary(&temporary, None).unwrap_err();
721 let message = refusal.to_string();
722 let error = DatabaseError::Io(refusal);
723
724 let described = describe_database_error(&error);
725 std::fs::remove_dir_all(&dir).unwrap();
726
727 assert_eq!(code_of(&described), code::TEMPORARY_FILE);
728 assert_eq!(described["path"], json!(temporary.display().to_string()));
729 assert_eq!(described["reason"], json!("is in the way and could not be cleared, so nothing on disk was changed — remove whatever holds that path and retry"));
730 assert_eq!(described["message"], json!(message));
731 }
732
733 #[test]
734 fn an_io_error_that_boxes_nothing_typed_is_still_reported_as_io() {
735 let error = io::Error::other("snapshot \"a.idx\" claims watermark and schema match the log");
736
737 assert_eq!(code_of(&describe_io_error(&error)), code::IO);
738 }
739}