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