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