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