Skip to main content

hayson_binding_shared/
options.rs

1use std::collections::HashMap;
2
3use std::time::Duration;
4
5use hayson_engine::catalog::CatalogCompaction;
6use hayson_engine::crypto::{CipherOptions, MissingKeyPolicy};
7use hayson_engine::database::{
8    FollowOption, LayoutKind, LayoutMigrationOptions, LayoutOptions, LockMode, OpenOptions, VerifyOptions,
9};
10use hayson_engine::lock::{GraceServed, LockOptions};
11use serde_json::Value;
12
13/// Rejection of an `open()` options document, before the engine is ever touched.
14#[derive(Debug, PartialEq, Eq)]
15pub enum OptionsError {
16    /// A malformed option: wrong JSON type, unknown enum member, out-of-range number.
17    Shape(String),
18    /// Unusable key material — reported to JS as a cipher error, not a schema one.
19    Key(String),
20}
21
22fn shape<T>(message: impl Into<String>) -> Result<T, OptionsError> {
23    Err(OptionsError::Shape(message.into()))
24}
25
26fn as_object<'a>(value: &'a Value, what: &str) -> Result<&'a serde_json::Map<String, Value>, OptionsError> {
27    match value.as_object() {
28        Some(object) => Ok(object),
29        None => shape(format!("\"{what}\" must be a JSON object")),
30    }
31}
32
33fn optional_bool(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<bool>, OptionsError> {
34    match object.get(key) {
35        None | Some(Value::Null) => Ok(None),
36        Some(Value::Bool(value)) => Ok(Some(*value)),
37        Some(_) => shape(format!("\"{key}\" must be a boolean")),
38    }
39}
40
41fn optional_u64(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<u64>, OptionsError> {
42    match object.get(key) {
43        None | Some(Value::Null) => Ok(None),
44        Some(Value::Number(number)) => match number.as_u64() {
45            Some(value) => Ok(Some(value)),
46            None => shape(format!("\"{key}\" must be a non-negative whole number")),
47        },
48        Some(_) => shape(format!("\"{key}\" must be a number")),
49    }
50}
51
52fn optional_i64(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<i64>, OptionsError> {
53    match object.get(key) {
54        None | Some(Value::Null) => Ok(None),
55        Some(Value::Number(number)) => match number.as_i64() {
56            Some(value) => Ok(Some(value)),
57            None => shape(format!("\"{key}\" must be a whole number")),
58        },
59        Some(_) => shape(format!("\"{key}\" must be a number")),
60    }
61}
62
63fn optional_string(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, OptionsError> {
64    match object.get(key) {
65        None | Some(Value::Null) => Ok(None),
66        Some(Value::String(value)) => Ok(Some(value.clone())),
67        Some(_) => shape(format!("\"{key}\" must be a string")),
68    }
69}
70
71/// `lock` in the shapes the TS `OpenOptions` takes (`boolean | LockOptions`): an object of lease
72/// tuning, or `false` to open without taking the writer lock at all — mutual exclusion between
73/// writers is then not enforced, which is the whole point and the whole danger of the option.
74/// `true` is TS's "lock the usual way" and resolves to the engine's defaults, not to skipping.
75fn parse_lock(value: &Value) -> Result<LockMode, OptionsError> {
76    match value {
77        Value::Bool(false) => Ok(LockMode::Skip),
78        Value::Bool(true) => Ok(LockMode::Enabled(LockOptions::default())),
79        Value::Object(object) => Ok(LockMode::Enabled(LockOptions {
80            stale_after_ms: optional_u64(object, "staleAfterMs")?,
81            heartbeat_ms: optional_u64(object, "heartbeatMs")?,
82            timeout_ms: optional_u64(object, "timeoutMs")?,
83            retry_interval_ms: optional_u64(object, "retryIntervalMs")?,
84            grace_served: parse_grace_served(object)?,
85        })),
86        _ => shape("\"lock\" must be a boolean or an object of lock options"),
87    }
88}
89
90/// `graceServed` — the lease a host has already challenged and waited the hand-over grace out on,
91/// so that the open it precedes reclaims that lease without the engine sleeping inside a call the
92/// host is waiting on. Not part of the public TS `LockOptions`: the binding layer synthesises it
93/// between two attempts of its own, off the `holder` its previous refusal named.
94///
95/// A token naming a lease that has since moved is not an error — it simply buys nothing, and the
96/// ordinary hand-over runs instead. Only its shape is judged here.
97fn parse_grace_served(object: &serde_json::Map<String, Value>) -> Result<Option<GraceServed>, OptionsError> {
98    let served = match object.get("graceServed") {
99        None | Some(Value::Null) => return Ok(None),
100        Some(value) => as_object(value, "graceServed")?,
101    };
102    let Some(owner) = optional_string(served, "owner")? else {
103        return shape("\"graceServed\" must name the \"owner\" whose grace it waited out");
104    };
105    let Some(heartbeat_at) = optional_i64(served, "heartbeatAt")? else {
106        return shape("\"graceServed\" must name the \"heartbeatAt\" whose grace it waited out");
107    };
108    Ok(GraceServed::parse(&owner, heartbeat_at))
109}
110
111/// `follow` in the three shapes the TS `OpenOptions` takes: `false` to never catch up unprompted,
112/// `true` for the engine's default interval, or `{ intervalMs }` for a custom one. Absent is not
113/// handled here — it means "resolve by `readonly`" and never reaches this function.
114fn parse_follow(value: &Value) -> Result<FollowOption, OptionsError> {
115    match value {
116        Value::Bool(false) => Ok(FollowOption::Off),
117        Value::Bool(true) => Ok(FollowOption::Every(Duration::ZERO)),
118        Value::Object(object) => match optional_u64(object, "intervalMs")? {
119            Some(interval_ms) => Ok(FollowOption::Every(Duration::from_millis(interval_ms))),
120            None => Ok(FollowOption::Every(Duration::ZERO)),
121        },
122        _ => shape("\"follow\" must be a boolean or an object with \"intervalMs\""),
123    }
124}
125
126/// `compaction` in the two shapes the TS `OpenOptions` takes: `false` to never rewrite a log
127/// unprompted, or `{ deadBytesRatio, minDeadBytes }` to set both thresholds. Absent is not handled
128/// here — it means "leave it to the catalog" and never reaches this function.
129fn parse_compaction(value: &Value) -> Result<CatalogCompaction, OptionsError> {
130    match value {
131        Value::Bool(false) => Ok(CatalogCompaction::Disabled),
132        Value::Object(object) => {
133            let ratio = match object.get("deadBytesRatio") {
134                None | Some(Value::Null) => None,
135                Some(Value::Number(number)) => number.as_f64(),
136                Some(_) => return shape("\"deadBytesRatio\" must be a number"),
137            };
138            let Some(dead_bytes_ratio) = ratio.filter(|ratio| *ratio > 0.0 && *ratio <= 1.0) else {
139                return shape("\"deadBytesRatio\" must be a number greater than 0 and at most 1");
140            };
141            let min_dead_bytes = optional_u64(object, "minDeadBytes")?.unwrap_or(0);
142            Ok(CatalogCompaction::Enabled {
143                dead_bytes_ratio,
144                min_dead_bytes,
145            })
146        }
147        _ => shape("\"compaction\" must be false or an object of compaction thresholds"),
148    }
149}
150
151fn parse_layout_kind(raw: &str) -> Result<LayoutKind, OptionsError> {
152    match raw {
153        "flat" => Ok(LayoutKind::Flat),
154        "nested" => Ok(LayoutKind::Nested),
155        other => shape(format!("\"layout\" must be \"flat\" or \"nested\" — got \"{other}\"")),
156    }
157}
158
159/// The `layout`/`metadataFile`/`manifest` triple, passed on unresolved: whether the two file names
160/// agree, and whether the requested arrangement matches the one on disk, is the engine's call, so
161/// both refusals read the same however a caller reaches them.
162fn parse_layout(object: &serde_json::Map<String, Value>) -> Result<LayoutOptions, OptionsError> {
163    Ok(LayoutOptions {
164        kind: match optional_string(object, "layout")? {
165            Some(raw) => Some(parse_layout_kind(&raw)?),
166            None => None,
167        },
168        metadata_file: optional_string(object, "metadataFile")?,
169        manifest: optional_string(object, "manifest")?,
170    })
171}
172
173fn parse_missing_key_policy(raw: &str) -> Result<MissingKeyPolicy, OptionsError> {
174    match raw {
175        "throw" => Ok(MissingKeyPolicy::Throw),
176        "omit" => Ok(MissingKeyPolicy::Omit),
177        "raw" => Ok(MissingKeyPolicy::Raw),
178        other => shape(format!(
179            "\"onMissingKey\" must be one of \"throw\", \"omit\", \"raw\" — got \"{other}\""
180        )),
181    }
182}
183
184/// Decodes one key of the `cypher.keys` map. `encoding` is the document-wide `keyEncoding`.
185fn decode_key(id: &str, raw: &str, encoding: KeyEncoding) -> Result<Vec<u8>, OptionsError> {
186    let decoded = match encoding {
187        KeyEncoding::Base64 => decode_base64(raw),
188        KeyEncoding::Hex => decode_hex(raw),
189    };
190    decoded.ok_or_else(|| OptionsError::Key(format!("key \"{id}\" is not valid {}", encoding.as_str())))
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
194pub enum KeyEncoding {
195    Base64,
196    Hex,
197}
198
199impl KeyEncoding {
200    fn as_str(self) -> &'static str {
201        match self {
202            KeyEncoding::Base64 => "base64",
203            KeyEncoding::Hex => "hex",
204        }
205    }
206}
207
208fn base64_symbol(byte: u8) -> Option<u8> {
209    match byte {
210        b'A'..=b'Z' => Some(byte - b'A'),
211        b'a'..=b'z' => Some(byte - b'a' + 26),
212        b'0'..=b'9' => Some(byte - b'0' + 52),
213        // NOTE: accepts the URL-safe alphabet too — Node emits it for `base64url` encodings.
214        b'+' | b'-' => Some(62),
215        b'/' | b'_' => Some(63),
216        _ => None,
217    }
218}
219
220/// Standard and URL-safe base64, padding optional. `None` on any symbol outside the alphabet or a
221/// length that cannot be a whole number of bytes.
222pub fn decode_base64(raw: &str) -> Option<Vec<u8>> {
223    let symbols: &[u8] = raw.trim_end_matches('=').as_bytes();
224    if symbols.len() % 4 == 1 {
225        return None;
226    }
227    let mut out = Vec::with_capacity(symbols.len() * 3 / 4);
228    let mut accumulator: u32 = 0;
229    let mut bits = 0u32;
230    for byte in symbols {
231        accumulator = (accumulator << 6) | u32::from(base64_symbol(*byte)?);
232        bits += 6;
233        if bits >= 8 {
234            bits -= 8;
235            out.push((accumulator >> bits) as u8);
236        }
237    }
238    // Leftover bits of a truncated group must be zero, or the input was not base64 of these bytes.
239    if accumulator & ((1 << bits) - 1) != 0 {
240        return None;
241    }
242    Some(out)
243}
244
245/// Lowercase or uppercase hex, no separators. `None` on an odd length or a non-hex digit.
246pub fn decode_hex(raw: &str) -> Option<Vec<u8>> {
247    let digits = raw.as_bytes();
248    if !digits.len().is_multiple_of(2) {
249        return None;
250    }
251    let mut out = Vec::with_capacity(digits.len() / 2);
252    for pair in digits.chunks_exact(2) {
253        let high = (pair[0] as char).to_digit(16)?;
254        let low = (pair[1] as char).to_digit(16)?;
255        out.push((high * 16 + low) as u8);
256    }
257    Some(out)
258}
259
260/// The `cypher` sub-document of the `open()` options document, on its own — `{ keys, keyId,
261/// keyEncoding, onMissingKey }`. Public because a host that reads key material from a file or an
262/// environment variable (`crate::keys`) has such a document and no options document around it.
263pub fn parse_cypher(value: &Value) -> Result<CipherOptions, OptionsError> {
264    let object = as_object(value, "cypher")?;
265    let encoding = match optional_string(object, "keyEncoding")?.as_deref() {
266        None | Some("base64") => KeyEncoding::Base64,
267        Some("hex") => KeyEncoding::Hex,
268        Some(other) => {
269            return shape(format!(
270                "\"keyEncoding\" must be \"base64\" or \"hex\" — got \"{other}\""
271            ))
272        }
273    };
274
275    let raw_keys = match object.get("keys") {
276        Some(keys) => as_object(keys, "cypher.keys")?,
277        None => return shape("\"cypher\" needs a \"keys\" object"),
278    };
279
280    let mut keys = HashMap::with_capacity(raw_keys.len());
281    for (id, material) in raw_keys {
282        let Some(material) = material.as_str() else {
283            return shape(format!("key \"{id}\" must be a string"));
284        };
285        keys.insert(id.clone(), decode_key(id, material, encoding)?);
286    }
287
288    let on_missing_key = match optional_string(object, "onMissingKey")? {
289        Some(raw) => Some(parse_missing_key_policy(&raw)?),
290        None => None,
291    };
292
293    Ok(CipherOptions {
294        keys,
295        key_id: optional_string(object, "keyId")?,
296        on_missing_key,
297    })
298}
299
300/// Builds `OpenOptions` from the JSON document `open()` takes. Every member is optional; an absent
301/// one keeps the engine's own default, so `{}` and `null` both mean "open it the usual way".
302pub fn parse_open_options(value: &Value) -> Result<OpenOptions, OptionsError> {
303    let mut options = OpenOptions::default();
304    if value.is_null() {
305        return Ok(options);
306    }
307    let object = as_object(value, "options")?;
308
309    if let Some(readonly) = optional_bool(object, "readonly")? {
310        options.readonly = readonly;
311    }
312    if let Some(snapshot_every) = optional_u64(object, "snapshotEvery")? {
313        options.snapshot_every = snapshot_every;
314    }
315    if let Some(compaction) = object.get("compaction").filter(|value| !value.is_null()) {
316        options.compaction = Some(parse_compaction(compaction)?);
317    }
318    if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
319        options.lock = parse_lock(lock)?;
320    }
321    if let Some(cypher) = object.get("cypher").filter(|value| !value.is_null()) {
322        options.cypher = Some(parse_cypher(cypher)?);
323    }
324    if let Some(follow) = object.get("follow").filter(|value| !value.is_null()) {
325        options.follow = parse_follow(follow)?;
326    }
327    options.layout = parse_layout(object)?;
328
329    Ok(options)
330}
331
332/// Builds [`LayoutMigrationOptions`] from the JSON document `migrateLayout()` takes — the TS
333/// `LayoutMigrationOptions`, member for member, plus the `lock` of `open()` so a host that has to
334/// wait differently for the writer lock can say so. Every member is optional; `{}` and `null` both
335/// mean "move it into the nested arrangement, keeping the document's name".
336///
337/// Neither pair of document names is resolved here, for the same reason `parse_layout` resolves
338/// nothing: whether the two names of a pair agree is the engine's refusal to make, so it reads the
339/// same however a caller reaches it.
340pub fn parse_migrate_layout_options(value: &Value) -> Result<LayoutMigrationOptions, OptionsError> {
341    let mut options = LayoutMigrationOptions::default();
342    if value.is_null() {
343        return Ok(options);
344    }
345    let object = as_object(value, "options")?;
346
347    options.to = match optional_string(object, "to")? {
348        Some(raw) => Some(parse_migration_target(&raw)?),
349        None => None,
350    };
351    options.from_metadata_file = optional_string(object, "fromMetadataFile")?;
352    options.from_manifest = optional_string(object, "fromManifest")?;
353    options.metadata_file = optional_string(object, "metadataFile")?;
354    options.manifest_file = optional_string(object, "manifestFile")?;
355    options.dry_run = optional_bool(object, "dryRun")?.unwrap_or(false);
356    if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
357        options.lock = parse_lock(lock)?;
358    }
359
360    Ok(options)
361}
362
363/// Builds [`VerifyOptions`] from the JSON document `verify()` takes: `{ relations?: boolean }`.
364/// `null` and `{}` mean the defaults.
365pub fn parse_verify_options(value: &Value) -> Result<VerifyOptions, OptionsError> {
366    let mut options = VerifyOptions::default();
367    if value.is_null() {
368        return Ok(options);
369    }
370    let object = as_object(value, "options")?;
371    if let Some(relations) = optional_bool(object, "relations")? {
372        options.relations = relations;
373    }
374    Ok(options)
375}
376
377fn parse_migration_target(raw: &str) -> Result<LayoutKind, OptionsError> {
378    match raw {
379        "flat" => Ok(LayoutKind::Flat),
380        "nested" => Ok(LayoutKind::Nested),
381        other => shape(format!("\"to\" must be \"flat\" or \"nested\" — got \"{other}\"")),
382    }
383}
384
385#[cfg(test)]
386mod tests {
387    use serde_json::json;
388
389    use super::*;
390
391    #[test]
392    fn an_absent_document_keeps_every_engine_default() {
393        let options = parse_open_options(&Value::Null).unwrap();
394
395        assert!(!options.readonly);
396        assert_eq!(options.snapshot_every, OpenOptions::default().snapshot_every);
397        assert!(options.cypher.is_none());
398    }
399
400    #[test]
401    fn readonly_snapshot_every_and_lock_reach_the_engine_options() {
402        let options = parse_open_options(&json!({
403            "readonly": true,
404            "snapshotEvery": 5,
405            "lock": { "timeoutMs": 0, "retryIntervalMs": 10 },
406        }))
407        .unwrap();
408
409        assert!(options.readonly);
410        assert_eq!(options.snapshot_every, 5);
411        let lock = options
412            .lock
413            .options()
414            .expect("an object shape asks for a lock, not for skipping");
415        assert_eq!(lock.timeout_ms, Some(0));
416        assert_eq!(lock.retry_interval_ms, Some(10));
417        assert_eq!(lock.heartbeat_ms, None);
418    }
419
420    #[test]
421    fn compaction_reaches_the_engine_in_both_the_thresholds_and_the_false_shape() {
422        let named = parse_open_options(&json!({
423            "compaction": { "deadBytesRatio": 0.5, "minDeadBytes": 200 },
424        }))
425        .unwrap();
426        assert_eq!(
427            named.compaction,
428            Some(CatalogCompaction::Enabled {
429                dead_bytes_ratio: 0.5,
430                min_dead_bytes: 200
431            })
432        );
433
434        let off = parse_open_options(&json!({ "compaction": false })).unwrap();
435        assert_eq!(off.compaction, Some(CatalogCompaction::Disabled));
436
437        // NOTE: absent and null both mean "leave it to the catalog", which is not the same as false.
438        for document in [json!({}), json!({ "compaction": null })] {
439            assert_eq!(parse_open_options(&document).unwrap().compaction, None, "{document}");
440        }
441    }
442
443    #[test]
444    fn a_compaction_ratio_outside_zero_to_one_is_refused_before_the_engine_is_touched() {
445        for ratio in [json!(0), json!(1.5), json!("half")] {
446            let document = json!({ "compaction": { "deadBytesRatio": ratio, "minDeadBytes": 1 } });
447            assert!(parse_open_options(&document).is_err(), "{document}");
448        }
449        assert!(parse_open_options(&json!({ "compaction": true })).is_err());
450    }
451
452    #[test]
453    fn lock_false_asks_the_engine_to_skip_the_writer_lock_and_true_asks_for_the_defaults() {
454        assert!(matches!(
455            parse_open_options(&json!({ "lock": false })).unwrap().lock,
456            LockMode::Skip
457        ));
458
459        // NOTE: absent and null must stay the locked default — skipping is opt-in only.
460        for document in [
461            json!({}),
462            json!({ "lock": null }),
463            json!({ "lock": true }),
464            json!({ "lock": {} }),
465        ] {
466            let lock = parse_open_options(&document).unwrap().lock;
467            assert!(matches!(lock, LockMode::Enabled(_)), "{document} must keep the lock");
468            assert_eq!(lock.options().unwrap().timeout_ms, None);
469        }
470    }
471
472    #[test]
473    fn a_lock_that_is_neither_a_flag_nor_an_options_object_is_refused_as_a_shape_error() {
474        assert_eq!(
475            parse_open_options(&json!({ "lock": "off" })).unwrap_err(),
476            OptionsError::Shape("\"lock\" must be a boolean or an object of lock options".to_string())
477        );
478        assert_eq!(
479            parse_open_options(&json!({ "lock": { "timeoutMs": -1 } })).unwrap_err(),
480            OptionsError::Shape("\"timeoutMs\" must be a non-negative whole number".to_string())
481        );
482    }
483
484    #[test]
485    fn a_base64_keyring_decodes_to_the_exact_bytes_and_keeps_its_write_key() {
486        let options = parse_open_options(&json!({
487            "cypher": {
488                "keys": { "k1": "AAECAw==" },
489                "keyId": "k1",
490                "onMissingKey": "omit",
491            },
492        }))
493        .unwrap();
494
495        let cypher = options.cypher.unwrap();
496        assert_eq!(cypher.keys["k1"], vec![0, 1, 2, 3]);
497        assert_eq!(cypher.key_id.as_deref(), Some("k1"));
498        assert_eq!(cypher.on_missing_key, Some(MissingKeyPolicy::Omit));
499    }
500
501    #[test]
502    fn a_hex_keyring_decodes_only_when_key_encoding_says_so() {
503        let hex = json!({ "cypher": { "keys": { "k1": "00010203" }, "keyEncoding": "hex" } });
504        let options = parse_open_options(&hex).unwrap();
505        assert_eq!(options.cypher.unwrap().keys["k1"], vec![0, 1, 2, 3]);
506
507        // NOTE: "00010203" is also valid base64 — the same text decodes to different bytes.
508        let as_base64 = json!({ "cypher": { "keys": { "k1": "00010203" } } });
509        assert_ne!(
510            parse_open_options(&as_base64).unwrap().cypher.unwrap().keys["k1"],
511            vec![0, 1, 2, 3]
512        );
513    }
514
515    #[test]
516    fn unusable_key_material_is_reported_as_a_key_error_naming_the_key() {
517        let error = parse_open_options(&json!({ "cypher": { "keys": { "k1": "!!!!" } } })).unwrap_err();
518
519        assert_eq!(error, OptionsError::Key("key \"k1\" is not valid base64".to_string()));
520    }
521
522    #[test]
523    fn a_wrong_typed_option_is_refused_as_a_shape_error_naming_the_member() {
524        assert_eq!(
525            parse_open_options(&json!({ "readonly": "yes" })).unwrap_err(),
526            OptionsError::Shape("\"readonly\" must be a boolean".to_string())
527        );
528        assert_eq!(
529            parse_open_options(&json!({ "snapshotEvery": -1 })).unwrap_err(),
530            OptionsError::Shape("\"snapshotEvery\" must be a non-negative whole number".to_string())
531        );
532        assert!(matches!(
533            parse_open_options(&json!({ "cypher": { "keys": {}, "onMissingKey": "shrug" } })).unwrap_err(),
534            OptionsError::Shape(_)
535        ));
536    }
537
538    #[test]
539    fn follow_reaches_the_engine_in_all_three_shapes_the_option_takes() {
540        assert_eq!(parse_open_options(&json!({})).unwrap().follow, FollowOption::Default);
541        assert_eq!(
542            parse_open_options(&json!({ "follow": null })).unwrap().follow,
543            FollowOption::Default
544        );
545        assert_eq!(
546            parse_open_options(&json!({ "follow": false })).unwrap().follow,
547            FollowOption::Off
548        );
549        assert_eq!(
550            parse_open_options(&json!({ "follow": true })).unwrap().follow,
551            FollowOption::Every(Duration::ZERO)
552        );
553        assert_eq!(
554            parse_open_options(&json!({ "follow": { "intervalMs": 250 } }))
555                .unwrap()
556                .follow,
557            FollowOption::Every(Duration::from_millis(250))
558        );
559        assert_eq!(
560            parse_open_options(&json!({ "follow": {} })).unwrap().follow,
561            FollowOption::Every(Duration::ZERO)
562        );
563    }
564
565    #[test]
566    fn a_follow_that_is_neither_a_flag_nor_an_interval_is_refused_as_a_shape_error() {
567        assert!(matches!(
568            parse_open_options(&json!({ "follow": "always" })).unwrap_err(),
569            OptionsError::Shape(_)
570        ));
571        assert!(matches!(
572            parse_open_options(&json!({ "follow": { "intervalMs": -1 } })).unwrap_err(),
573            OptionsError::Shape(_)
574        ));
575    }
576
577    #[test]
578    fn the_layout_triple_reaches_the_engine_unresolved() {
579        assert_eq!(parse_open_options(&json!({})).unwrap().layout, LayoutOptions::default());
580
581        let named = parse_open_options(&json!({
582            "layout": "nested",
583            "metadataFile": "cerebro.json",
584        }))
585        .unwrap();
586        assert_eq!(named.layout.kind, Some(LayoutKind::Nested));
587        assert_eq!(named.layout.metadata_file.as_deref(), Some("cerebro.json"));
588        assert_eq!(named.layout.manifest, None);
589
590        // NOTE: the two names are passed on as given — the engine, not this, decides they conflict.
591        let both = parse_open_options(&json!({ "manifest": "a.json", "metadataFile": "b.json" })).unwrap();
592        assert_eq!(both.layout.manifest.as_deref(), Some("a.json"));
593        assert_eq!(both.layout.metadata_file.as_deref(), Some("b.json"));
594    }
595
596    #[test]
597    fn an_unknown_layout_kind_and_a_non_string_file_name_are_refused_as_shape_errors() {
598        assert_eq!(
599            parse_open_options(&json!({ "layout": "sideways" })).unwrap_err(),
600            OptionsError::Shape("\"layout\" must be \"flat\" or \"nested\" — got \"sideways\"".to_string())
601        );
602        assert_eq!(
603            parse_open_options(&json!({ "metadataFile": 7 })).unwrap_err(),
604            OptionsError::Shape("\"metadataFile\" must be a string".to_string())
605        );
606    }
607
608    #[test]
609    fn a_migration_with_no_document_moves_into_the_nested_arrangement_under_the_engine_lock() {
610        let options = parse_migrate_layout_options(&Value::Null).unwrap();
611
612        assert_eq!(options.to, None, "absent is the engine's own default, not a second one");
613        assert!(!options.dry_run);
614        assert!(options.metadata_file.is_none() && options.from_metadata_file.is_none());
615        assert!(matches!(options.lock, LockMode::Enabled(_)));
616    }
617
618    #[test]
619    fn both_document_name_pairs_reach_the_engine_unresolved_so_it_makes_the_refusal() {
620        let options = parse_migrate_layout_options(&json!({
621            "to": "flat",
622            "fromMetadataFile": "cerebro.json",
623            "fromManifest": "legacy.json",
624            "metadataFile": "hayson.json",
625            "manifestFile": "old.json",
626            "dryRun": true,
627        }))
628        .unwrap();
629
630        assert_eq!(options.to, Some(LayoutKind::Flat));
631        assert_eq!(options.from_metadata_file.as_deref(), Some("cerebro.json"));
632        assert_eq!(options.from_manifest.as_deref(), Some("legacy.json"));
633        assert_eq!(options.metadata_file.as_deref(), Some("hayson.json"));
634        assert_eq!(options.manifest_file.as_deref(), Some("old.json"));
635        assert!(options.dry_run);
636    }
637
638    #[test]
639    fn a_migration_takes_the_same_lock_document_an_open_takes() {
640        let options = parse_migrate_layout_options(&json!({ "lock": { "timeoutMs": 0 } })).unwrap();
641
642        assert_eq!(
643            options.lock.options().and_then(|lock| lock.timeout_ms),
644            Some(0),
645            "a host that owns the wait has to be able to ask for a fail-fast attempt"
646        );
647        let skipped = parse_migrate_layout_options(&json!({ "lock": false })).unwrap();
648        assert!(matches!(skipped.lock, LockMode::Skip));
649    }
650
651    #[test]
652    fn an_unknown_migration_target_is_refused_as_a_shape_error() {
653        assert_eq!(
654            parse_migrate_layout_options(&json!({ "to": "sideways" })).unwrap_err(),
655            OptionsError::Shape("\"to\" must be \"flat\" or \"nested\" — got \"sideways\"".to_string())
656        );
657        assert_eq!(
658            parse_migrate_layout_options(&json!({ "dryRun": "yes" })).unwrap_err(),
659            OptionsError::Shape("\"dryRun\" must be a boolean".to_string())
660        );
661    }
662
663    #[test]
664    fn base64_accepts_the_url_safe_alphabet_and_optional_padding() {
665        assert_eq!(decode_base64("+/8="), decode_base64("-_8"));
666        assert_eq!(decode_base64(""), Some(Vec::new()));
667        assert_eq!(decode_base64("QQ"), Some(vec![b'A']));
668        assert_eq!(decode_base64("QQ=="), Some(vec![b'A']));
669    }
670
671    #[test]
672    fn base64_refuses_a_length_and_a_symbol_that_cannot_be_base64() {
673        assert_eq!(decode_base64("A"), None);
674        assert_eq!(decode_base64("QQ*="), None);
675    }
676
677    #[test]
678    fn hex_round_trips_both_cases_and_refuses_odd_or_non_hex_input() {
679        assert_eq!(decode_hex("DEADbeef"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
680        assert_eq!(decode_hex("abc"), None);
681        assert_eq!(decode_hex("zz"), None);
682    }
683}