Skip to main content

hayson_binding_shared/
errors.rs

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