1use std::collections::HashMap;
2
3use std::time::Duration;
4
5use hayson_engine::catalog::CatalogCompaction;
6use hayson_engine::crypto::PasswordHashParams;
7use hayson_engine::crypto::{CipherOptions, MissingKeyPolicy};
8use hayson_engine::database::{
9 BackupOptions, ConvertOptions, FollowOption, LayoutKind, LayoutMigrationOptions, LayoutOptions, LockMode,
10 OpenOptions, PackInclude, PackLimits, PackOptions, RestoreOptions, UnpackOptions, VerifyOptions,
11};
12use hayson_engine::lock::{GraceServed, LockOptions};
13use serde_json::Value;
14
15#[derive(Debug, PartialEq, Eq)]
17pub enum OptionsError {
18 Shape(String),
20 Key(String),
22}
23
24fn shape<T>(message: impl Into<String>) -> Result<T, OptionsError> {
25 Err(OptionsError::Shape(message.into()))
26}
27
28fn as_object<'a>(value: &'a Value, what: &str) -> Result<&'a serde_json::Map<String, Value>, OptionsError> {
29 match value.as_object() {
30 Some(object) => Ok(object),
31 None => shape(format!("\"{what}\" must be a JSON object")),
32 }
33}
34
35fn optional_bool(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<bool>, OptionsError> {
36 match object.get(key) {
37 None | Some(Value::Null) => Ok(None),
38 Some(Value::Bool(value)) => Ok(Some(*value)),
39 Some(_) => shape(format!("\"{key}\" must be a boolean")),
40 }
41}
42
43fn optional_u64(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<u64>, OptionsError> {
44 match object.get(key) {
45 None | Some(Value::Null) => Ok(None),
46 Some(Value::Number(number)) => match number.as_u64() {
47 Some(value) => Ok(Some(value)),
48 None => shape(format!("\"{key}\" must be a non-negative whole number")),
49 },
50 Some(_) => shape(format!("\"{key}\" must be a number")),
51 }
52}
53
54fn optional_i64(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<i64>, OptionsError> {
55 match object.get(key) {
56 None | Some(Value::Null) => Ok(None),
57 Some(Value::Number(number)) => match number.as_i64() {
58 Some(value) => Ok(Some(value)),
59 None => shape(format!("\"{key}\" must be a whole number")),
60 },
61 Some(_) => shape(format!("\"{key}\" must be a number")),
62 }
63}
64
65fn optional_string(object: &serde_json::Map<String, Value>, key: &str) -> Result<Option<String>, OptionsError> {
66 match object.get(key) {
67 None | Some(Value::Null) => Ok(None),
68 Some(Value::String(value)) => Ok(Some(value.clone())),
69 Some(_) => shape(format!("\"{key}\" must be a string")),
70 }
71}
72
73fn parse_lock(value: &Value) -> Result<LockMode, OptionsError> {
78 match value {
79 Value::Bool(false) => Ok(LockMode::Skip),
80 Value::Bool(true) => Ok(LockMode::Enabled(LockOptions::default())),
81 Value::Object(object) => Ok(LockMode::Enabled(LockOptions {
82 stale_after_ms: optional_u64(object, "staleAfterMs")?,
83 heartbeat_ms: optional_u64(object, "heartbeatMs")?,
84 timeout_ms: optional_u64(object, "timeoutMs")?,
85 retry_interval_ms: optional_u64(object, "retryIntervalMs")?,
86 grace_served: parse_grace_served(object)?,
87 })),
88 _ => shape("\"lock\" must be a boolean or an object of lock options"),
89 }
90}
91
92fn parse_grace_served(object: &serde_json::Map<String, Value>) -> Result<Option<GraceServed>, OptionsError> {
100 let served = match object.get("graceServed") {
101 None | Some(Value::Null) => return Ok(None),
102 Some(value) => as_object(value, "graceServed")?,
103 };
104 let Some(owner) = optional_string(served, "owner")? else {
105 return shape("\"graceServed\" must name the \"owner\" whose grace it waited out");
106 };
107 let Some(heartbeat_at) = optional_i64(served, "heartbeatAt")? else {
108 return shape("\"graceServed\" must name the \"heartbeatAt\" whose grace it waited out");
109 };
110 Ok(GraceServed::parse(&owner, heartbeat_at))
111}
112
113fn parse_follow(value: &Value) -> Result<FollowOption, OptionsError> {
117 match value {
118 Value::Bool(false) => Ok(FollowOption::Off),
119 Value::Bool(true) => Ok(FollowOption::Every(Duration::ZERO)),
120 Value::Object(object) => match optional_u64(object, "intervalMs")? {
121 Some(interval_ms) => Ok(FollowOption::Every(Duration::from_millis(interval_ms))),
122 None => Ok(FollowOption::Every(Duration::ZERO)),
123 },
124 _ => shape("\"follow\" must be a boolean or an object with \"intervalMs\""),
125 }
126}
127
128fn parse_compaction(value: &Value) -> Result<CatalogCompaction, OptionsError> {
132 match value {
133 Value::Bool(false) => Ok(CatalogCompaction::Disabled),
134 Value::Object(object) => {
135 let ratio = match object.get("deadBytesRatio") {
136 None | Some(Value::Null) => None,
137 Some(Value::Number(number)) => number.as_f64(),
138 Some(_) => return shape("\"deadBytesRatio\" must be a number"),
139 };
140 let Some(dead_bytes_ratio) = ratio.filter(|ratio| *ratio > 0.0 && *ratio <= 1.0) else {
141 return shape("\"deadBytesRatio\" must be a number greater than 0 and at most 1");
142 };
143 let min_dead_bytes = optional_u64(object, "minDeadBytes")?.unwrap_or(0);
144 Ok(CatalogCompaction::Enabled {
145 dead_bytes_ratio,
146 min_dead_bytes,
147 })
148 }
149 _ => shape("\"compaction\" must be false or an object of compaction thresholds"),
150 }
151}
152
153fn parse_layout_kind(raw: &str) -> Result<LayoutKind, OptionsError> {
154 match raw {
155 "flat" => Ok(LayoutKind::Flat),
156 "nested" => Ok(LayoutKind::Nested),
157 other => shape(format!("\"layout\" must be \"flat\" or \"nested\" — got \"{other}\"")),
158 }
159}
160
161fn parse_layout(object: &serde_json::Map<String, Value>) -> Result<LayoutOptions, OptionsError> {
165 Ok(LayoutOptions {
166 kind: match optional_string(object, "layout")? {
167 Some(raw) => Some(parse_layout_kind(&raw)?),
168 None => None,
169 },
170 metadata_file: optional_string(object, "metadataFile")?,
171 manifest: optional_string(object, "manifest")?,
172 })
173}
174
175fn parse_missing_key_policy(raw: &str) -> Result<MissingKeyPolicy, OptionsError> {
176 match raw {
177 "throw" => Ok(MissingKeyPolicy::Throw),
178 "omit" => Ok(MissingKeyPolicy::Omit),
179 "raw" => Ok(MissingKeyPolicy::Raw),
180 other => shape(format!(
181 "\"onMissingKey\" must be one of \"throw\", \"omit\", \"raw\" — got \"{other}\""
182 )),
183 }
184}
185
186fn decode_key(id: &str, raw: &str, encoding: KeyEncoding) -> Result<Vec<u8>, OptionsError> {
188 let decoded = match encoding {
189 KeyEncoding::Base64 => decode_base64(raw),
190 KeyEncoding::Hex => decode_hex(raw),
191 };
192 decoded.ok_or_else(|| OptionsError::Key(format!("key \"{id}\" is not valid {}", encoding.as_str())))
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub enum KeyEncoding {
197 Base64,
198 Hex,
199}
200
201impl KeyEncoding {
202 fn as_str(self) -> &'static str {
203 match self {
204 KeyEncoding::Base64 => "base64",
205 KeyEncoding::Hex => "hex",
206 }
207 }
208}
209
210fn base64_symbol(byte: u8) -> Option<u8> {
211 match byte {
212 b'A'..=b'Z' => Some(byte - b'A'),
213 b'a'..=b'z' => Some(byte - b'a' + 26),
214 b'0'..=b'9' => Some(byte - b'0' + 52),
215 b'+' | b'-' => Some(62),
217 b'/' | b'_' => Some(63),
218 _ => None,
219 }
220}
221
222pub fn decode_base64(raw: &str) -> Option<Vec<u8>> {
225 let symbols: &[u8] = raw.trim_end_matches('=').as_bytes();
226 if symbols.len() % 4 == 1 {
227 return None;
228 }
229 let mut out = Vec::with_capacity(symbols.len() * 3 / 4);
230 let mut accumulator: u32 = 0;
231 let mut bits = 0u32;
232 for byte in symbols {
233 accumulator = (accumulator << 6) | u32::from(base64_symbol(*byte)?);
234 bits += 6;
235 if bits >= 8 {
236 bits -= 8;
237 out.push((accumulator >> bits) as u8);
238 }
239 }
240 if accumulator & ((1 << bits) - 1) != 0 {
242 return None;
243 }
244 Some(out)
245}
246
247pub fn decode_hex(raw: &str) -> Option<Vec<u8>> {
249 let digits = raw.as_bytes();
250 if !digits.len().is_multiple_of(2) {
251 return None;
252 }
253 let mut out = Vec::with_capacity(digits.len() / 2);
254 for pair in digits.chunks_exact(2) {
255 let high = (pair[0] as char).to_digit(16)?;
256 let low = (pair[1] as char).to_digit(16)?;
257 out.push((high * 16 + low) as u8);
258 }
259 Some(out)
260}
261
262pub fn parse_cypher(value: &Value) -> Result<CipherOptions, OptionsError> {
266 parse_key_document(value, "cypher")
267}
268
269pub fn parse_encryption(value: &Value) -> Result<CipherOptions, OptionsError> {
271 parse_key_document(value, "encryption")
272}
273
274fn parse_key_document(value: &Value, label: &str) -> Result<CipherOptions, OptionsError> {
275 let object = as_object(value, label)?;
276 let encoding = match optional_string(object, "keyEncoding")?.as_deref() {
277 None | Some("base64") => KeyEncoding::Base64,
278 Some("hex") => KeyEncoding::Hex,
279 Some(other) => {
280 return shape(format!(
281 "\"keyEncoding\" must be \"base64\" or \"hex\" — got \"{other}\""
282 ))
283 }
284 };
285
286 let raw_keys = match object.get("keys") {
287 Some(keys) => as_object(keys, &format!("{label}.keys"))?,
288 None => return shape(format!("\"{label}\" needs a \"keys\" object")),
289 };
290
291 let mut keys = HashMap::with_capacity(raw_keys.len());
292 for (id, material) in raw_keys {
293 let Some(material) = material.as_str() else {
294 return shape(format!("key \"{id}\" must be a string"));
295 };
296 keys.insert(id.clone(), decode_key(id, material, encoding)?);
297 }
298
299 let on_missing_key = match optional_string(object, "onMissingKey")? {
300 Some(raw) => Some(parse_missing_key_policy(&raw)?),
301 None => None,
302 };
303
304 Ok(CipherOptions {
305 keys,
306 key_id: optional_string(object, "keyId")?,
307 on_missing_key,
308 })
309}
310
311pub fn parse_open_options(value: &Value) -> Result<OpenOptions, OptionsError> {
314 let mut options = OpenOptions::default();
315 if value.is_null() {
316 return Ok(options);
317 }
318 let object = as_object(value, "options")?;
319
320 if let Some(readonly) = optional_bool(object, "readonly")? {
321 options.readonly = readonly;
322 }
323 if let Some(snapshot_every) = optional_u64(object, "snapshotEvery")? {
324 options.snapshot_every = snapshot_every;
325 }
326 if let Some(compaction) = object.get("compaction").filter(|value| !value.is_null()) {
327 options.compaction = Some(parse_compaction(compaction)?);
328 }
329 if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
330 options.lock = parse_lock(lock)?;
331 }
332 if let Some(cypher) = object.get("cypher").filter(|value| !value.is_null()) {
333 options.cypher = Some(parse_cypher(cypher)?);
334 }
335 if let Some(encryption) = object.get("encryption").filter(|value| !value.is_null()) {
336 options.encryption = Some(parse_encryption(encryption)?);
337 }
338 if let Some(follow) = object.get("follow").filter(|value| !value.is_null()) {
339 options.follow = parse_follow(follow)?;
340 }
341 if let Some(max) = optional_u64(object, "maxImageBytes")? {
342 options.max_image_bytes = Some(max);
343 }
344 if let Some(cost) = object.get("passwordHash").filter(|value| !value.is_null()) {
345 options.password_hash = parse_password_hash(cost)?;
346 }
347 options.layout = parse_layout(object)?;
348
349 Ok(options)
350}
351
352pub fn parse_migrate_layout_options(value: &Value) -> Result<LayoutMigrationOptions, OptionsError> {
361 let mut options = LayoutMigrationOptions::default();
362 if value.is_null() {
363 return Ok(options);
364 }
365 let object = as_object(value, "options")?;
366
367 options.to = match optional_string(object, "to")? {
368 Some(raw) => Some(parse_migration_target(&raw)?),
369 None => None,
370 };
371 options.from_metadata_file = optional_string(object, "fromMetadataFile")?;
372 options.from_manifest = optional_string(object, "fromManifest")?;
373 options.metadata_file = optional_string(object, "metadataFile")?;
374 options.manifest_file = optional_string(object, "manifestFile")?;
375 options.dry_run = optional_bool(object, "dryRun")?.unwrap_or(false);
376 if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
377 options.lock = parse_lock(lock)?;
378 }
379
380 Ok(options)
381}
382
383pub fn parse_verify_options(value: &Value) -> Result<VerifyOptions, OptionsError> {
387 let mut options = VerifyOptions::default();
388 if value.is_null() {
389 return Ok(options);
390 }
391 let object = as_object(value, "options")?;
392 if let Some(relations) = optional_bool(object, "relations")? {
393 options.relations = relations;
394 }
395 if let Some(images) = optional_bool(object, "images")? {
396 options.images = images;
397 }
398 options.max_image_bytes = match object.get("maxImageBytes") {
399 None | Some(Value::Null) => None,
400 Some(value) => match value.as_u64() {
401 Some(bytes) => Some(bytes),
402 None => return shape("\"maxImageBytes\" must be a non-negative integer".to_string()),
403 },
404 };
405 options.encryption = parse_optional_encryption(object)?;
406 Ok(options)
407}
408
409pub fn parse_backup_options(value: &Value) -> Result<BackupOptions, OptionsError> {
411 let mut options = BackupOptions::default();
412 if value.is_null() {
413 return Ok(options);
414 }
415 let object = as_object(value, "options")?;
416 options.indexes = optional_bool(object, "indexes")?.unwrap_or(false);
417 options.exclude_tables = parse_exclude_tables(object)?;
418 options.encryption = parse_optional_encryption(object)?;
419 if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
420 options.lock = parse_lock(lock)?;
421 }
422 Ok(options)
423}
424
425pub fn parse_restore_options(value: &Value) -> Result<RestoreOptions, OptionsError> {
427 let mut options = RestoreOptions::default();
428 if value.is_null() {
429 return Ok(options);
430 }
431 let object = as_object(value, "options")?;
432 options.max_image_bytes = optional_u64(object, "maxImageBytes")?;
433 options.encryption = parse_optional_encryption(object)?;
434 if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
435 options.lock = parse_lock(lock)?;
436 }
437 Ok(options)
438}
439
440fn parse_password_hash(value: &Value) -> Result<PasswordHashParams, OptionsError> {
443 let object = as_object(value, "passwordHash")?;
444 let mut params = PasswordHashParams::default();
445 for (key, target) in [
446 ("memoryKiB", &mut params.memory_kib),
447 ("iterations", &mut params.iterations),
448 ("parallelism", &mut params.parallelism),
449 ] {
450 if let Some(number) = optional_u64(object, key)? {
451 *target = match u32::try_from(number) {
452 Ok(number) => number,
453 Err(_) => return shape(format!("\"passwordHash.{key}\" is too large")),
454 };
455 }
456 }
457 params.validate().map_err(|error| OptionsError::Shape(error.0))?;
458 Ok(params)
459}
460
461fn parse_optional_encryption(object: &serde_json::Map<String, Value>) -> Result<Option<CipherOptions>, OptionsError> {
463 match object.get("encryption").filter(|value| !value.is_null()) {
464 Some(value) => Ok(Some(parse_encryption(value)?)),
465 None => Ok(None),
466 }
467}
468
469fn parse_exclude_tables(object: &serde_json::Map<String, Value>) -> Result<Vec<String>, OptionsError> {
471 let Some(raw) = object.get("excludeTables").filter(|value| !value.is_null()) else {
472 return Ok(Vec::new());
473 };
474 let Some(items) = raw.as_array() else {
475 return shape("\"excludeTables\" must be an array of table names");
476 };
477 items
478 .iter()
479 .map(|item| match item.as_str() {
480 Some(name) => Ok(name.to_string()),
481 None => shape("\"excludeTables\" must be an array of table names"),
482 })
483 .collect()
484}
485
486fn parse_pack_includes(object: &serde_json::Map<String, Value>) -> Result<Vec<PackInclude>, OptionsError> {
488 let Some(raw) = object.get("include").filter(|value| !value.is_null()) else {
489 return Ok(Vec::new());
490 };
491 let Some(items) = raw.as_array() else {
492 return shape("\"include\" must be an array of { path, excludeTables? }");
493 };
494 let mut includes = Vec::new();
495 for item in items {
496 let entry = as_object(item, "include[]")?;
497 let Some(path) = optional_string(entry, "path")? else {
498 return shape("every \"include\" entry needs a \"path\"");
499 };
500 includes.push(PackInclude {
501 path,
502 encryption: parse_optional_encryption(entry)?,
503 exclude_tables: parse_exclude_tables(entry)?,
504 });
505 }
506 Ok(includes)
507}
508
509fn parse_pack_limits(object: &serde_json::Map<String, Value>) -> Result<PackLimits, OptionsError> {
510 let mut limits = PackLimits::default();
511 let Some(raw) = object.get("limits").filter(|value| !value.is_null()) else {
512 return Ok(limits);
513 };
514 let raw = as_object(raw, "limits")?;
515 let number = |key: &str| -> Result<Option<u64>, OptionsError> {
516 match raw.get(key).filter(|value| !value.is_null()) {
517 None => Ok(None),
518 Some(value) => match value.as_u64() {
519 Some(number) => Ok(Some(number)),
520 None => shape(format!("\"limits.{key}\" must be a non-negative integer")),
521 },
522 }
523 };
524 if let Some(value) = number("maxArchiveBytes")? {
525 limits.max_archive_bytes = value;
526 }
527 if let Some(value) = number("maxTotalBytes")? {
528 limits.max_total_bytes = value;
529 }
530 if let Some(value) = number("maxEntryBytes")? {
531 limits.max_entry_bytes = value;
532 }
533 if let Some(value) = number("maxEntries")? {
534 limits.max_entries = value as usize;
535 }
536 Ok(limits)
537}
538
539pub fn parse_pack_limits_options(value: &Value) -> Result<PackLimits, OptionsError> {
541 if value.is_null() {
542 return Ok(PackLimits::default());
543 }
544 parse_pack_limits(as_object(value, "options")?)
545}
546
547pub fn parse_pack_options(value: &Value) -> Result<PackOptions, OptionsError> {
550 let mut options = PackOptions::default();
551 if value.is_null() {
552 return Ok(options);
553 }
554 let object = as_object(value, "options")?;
555 options.indexes = optional_bool(object, "indexes")?.unwrap_or(false);
556 options.exclude_tables = parse_exclude_tables(object)?;
557 options.include = parse_pack_includes(object)?;
558 options.encryption = parse_optional_encryption(object)?;
559 if let Some(extra) = object.get("extra").filter(|value| !value.is_null()) {
560 for (name, content) in as_object(extra, "extra")? {
561 match content.as_str() {
562 Some(text) => options.extra.push((name.clone(), text.as_bytes().to_vec())),
563 None => return shape(format!("\"extra.{name}\" must be a string")),
564 }
565 }
566 }
567 options.limits = parse_pack_limits(object)?;
568 if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
569 options.lock = parse_lock(lock)?;
570 }
571 Ok(options)
572}
573
574pub fn parse_unpack_options(value: &Value) -> Result<UnpackOptions, OptionsError> {
576 let mut options = UnpackOptions::default();
577 if value.is_null() {
578 return Ok(options);
579 }
580 let object = as_object(value, "options")?;
581 options.encryption = parse_optional_encryption(object)?;
582 if let Some(nested) = object.get("includeEncryption").filter(|value| !value.is_null()) {
583 for (folder, keys) in as_object(nested, "includeEncryption")? {
584 options
585 .include_encryption
586 .push((folder.clone(), parse_encryption(keys)?));
587 }
588 }
589 options.max_image_bytes = optional_u64(object, "maxImageBytes")?;
590 options.limits = parse_pack_limits(object)?;
591 if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
592 options.lock = parse_lock(lock)?;
593 }
594 Ok(options)
595}
596
597pub fn parse_convert_options(value: &Value) -> Result<ConvertOptions, OptionsError> {
600 let mut options = ConvertOptions::default();
601 if value.is_null() {
602 return Ok(options);
603 }
604 let object = as_object(value, "options")?;
605 if let Some(from) = object.get("from").filter(|value| !value.is_null()) {
606 options.from = Some(parse_key_document(from, "from")?);
607 }
608 if let Some(to) = object.get("to").filter(|value| !value.is_null()) {
609 options.to = Some(parse_key_document(to, "to")?);
610 }
611 if let Some(lock) = object.get("lock").filter(|value| !value.is_null()) {
612 options.lock = parse_lock(lock)?;
613 }
614 Ok(options)
615}
616
617fn parse_migration_target(raw: &str) -> Result<LayoutKind, OptionsError> {
618 match raw {
619 "flat" => Ok(LayoutKind::Flat),
620 "nested" => Ok(LayoutKind::Nested),
621 other => shape(format!("\"to\" must be \"flat\" or \"nested\" — got \"{other}\"")),
622 }
623}
624
625#[cfg(test)]
626mod tests {
627 use serde_json::json;
628
629 use super::*;
630
631 #[test]
632 fn an_absent_document_keeps_every_engine_default() {
633 let options = parse_open_options(&Value::Null).unwrap();
634
635 assert!(!options.readonly);
636 assert_eq!(options.snapshot_every, OpenOptions::default().snapshot_every);
637 assert!(options.cypher.is_none());
638 }
639
640 #[test]
641 fn readonly_snapshot_every_and_lock_reach_the_engine_options() {
642 let options = parse_open_options(&json!({
643 "readonly": true,
644 "snapshotEvery": 5,
645 "lock": { "timeoutMs": 0, "retryIntervalMs": 10 },
646 }))
647 .unwrap();
648
649 assert!(options.readonly);
650 assert_eq!(options.snapshot_every, 5);
651 let lock = options
652 .lock
653 .options()
654 .expect("an object shape asks for a lock, not for skipping");
655 assert_eq!(lock.timeout_ms, Some(0));
656 assert_eq!(lock.retry_interval_ms, Some(10));
657 assert_eq!(lock.heartbeat_ms, None);
658 }
659
660 #[test]
661 fn compaction_reaches_the_engine_in_both_the_thresholds_and_the_false_shape() {
662 let named = parse_open_options(&json!({
663 "compaction": { "deadBytesRatio": 0.5, "minDeadBytes": 200 },
664 }))
665 .unwrap();
666 assert_eq!(
667 named.compaction,
668 Some(CatalogCompaction::Enabled {
669 dead_bytes_ratio: 0.5,
670 min_dead_bytes: 200
671 })
672 );
673
674 let off = parse_open_options(&json!({ "compaction": false })).unwrap();
675 assert_eq!(off.compaction, Some(CatalogCompaction::Disabled));
676
677 for document in [json!({}), json!({ "compaction": null })] {
679 assert_eq!(parse_open_options(&document).unwrap().compaction, None, "{document}");
680 }
681 }
682
683 #[test]
684 fn a_compaction_ratio_outside_zero_to_one_is_refused_before_the_engine_is_touched() {
685 for ratio in [json!(0), json!(1.5), json!("half")] {
686 let document = json!({ "compaction": { "deadBytesRatio": ratio, "minDeadBytes": 1 } });
687 assert!(parse_open_options(&document).is_err(), "{document}");
688 }
689 assert!(parse_open_options(&json!({ "compaction": true })).is_err());
690 }
691
692 #[test]
693 fn lock_false_asks_the_engine_to_skip_the_writer_lock_and_true_asks_for_the_defaults() {
694 assert!(matches!(
695 parse_open_options(&json!({ "lock": false })).unwrap().lock,
696 LockMode::Skip
697 ));
698
699 for document in [
701 json!({}),
702 json!({ "lock": null }),
703 json!({ "lock": true }),
704 json!({ "lock": {} }),
705 ] {
706 let lock = parse_open_options(&document).unwrap().lock;
707 assert!(matches!(lock, LockMode::Enabled(_)), "{document} must keep the lock");
708 assert_eq!(lock.options().unwrap().timeout_ms, None);
709 }
710 }
711
712 #[test]
713 fn a_lock_that_is_neither_a_flag_nor_an_options_object_is_refused_as_a_shape_error() {
714 assert_eq!(
715 parse_open_options(&json!({ "lock": "off" })).unwrap_err(),
716 OptionsError::Shape("\"lock\" must be a boolean or an object of lock options".to_string())
717 );
718 assert_eq!(
719 parse_open_options(&json!({ "lock": { "timeoutMs": -1 } })).unwrap_err(),
720 OptionsError::Shape("\"timeoutMs\" must be a non-negative whole number".to_string())
721 );
722 }
723
724 #[test]
725 fn a_base64_keyring_decodes_to_the_exact_bytes_and_keeps_its_write_key() {
726 let options = parse_open_options(&json!({
727 "cypher": {
728 "keys": { "k1": "AAECAw==" },
729 "keyId": "k1",
730 "onMissingKey": "omit",
731 },
732 }))
733 .unwrap();
734
735 let cypher = options.cypher.unwrap();
736 assert_eq!(cypher.keys["k1"], vec![0, 1, 2, 3]);
737 assert_eq!(cypher.key_id.as_deref(), Some("k1"));
738 assert_eq!(cypher.on_missing_key, Some(MissingKeyPolicy::Omit));
739 }
740
741 #[test]
742 fn a_hex_keyring_decodes_only_when_key_encoding_says_so() {
743 let hex = json!({ "cypher": { "keys": { "k1": "00010203" }, "keyEncoding": "hex" } });
744 let options = parse_open_options(&hex).unwrap();
745 assert_eq!(options.cypher.unwrap().keys["k1"], vec![0, 1, 2, 3]);
746
747 let as_base64 = json!({ "cypher": { "keys": { "k1": "00010203" } } });
749 assert_ne!(
750 parse_open_options(&as_base64).unwrap().cypher.unwrap().keys["k1"],
751 vec![0, 1, 2, 3]
752 );
753 }
754
755 #[test]
756 fn unusable_key_material_is_reported_as_a_key_error_naming_the_key() {
757 let error = parse_open_options(&json!({ "cypher": { "keys": { "k1": "!!!!" } } })).unwrap_err();
758
759 assert_eq!(error, OptionsError::Key("key \"k1\" is not valid base64".to_string()));
760 }
761
762 #[test]
763 fn a_wrong_typed_option_is_refused_as_a_shape_error_naming_the_member() {
764 assert_eq!(
765 parse_open_options(&json!({ "readonly": "yes" })).unwrap_err(),
766 OptionsError::Shape("\"readonly\" must be a boolean".to_string())
767 );
768 assert_eq!(
769 parse_open_options(&json!({ "snapshotEvery": -1 })).unwrap_err(),
770 OptionsError::Shape("\"snapshotEvery\" must be a non-negative whole number".to_string())
771 );
772 assert!(matches!(
773 parse_open_options(&json!({ "cypher": { "keys": {}, "onMissingKey": "shrug" } })).unwrap_err(),
774 OptionsError::Shape(_)
775 ));
776 }
777
778 #[test]
779 fn follow_reaches_the_engine_in_all_three_shapes_the_option_takes() {
780 assert_eq!(parse_open_options(&json!({})).unwrap().follow, FollowOption::Default);
781 assert_eq!(
782 parse_open_options(&json!({ "follow": null })).unwrap().follow,
783 FollowOption::Default
784 );
785 assert_eq!(
786 parse_open_options(&json!({ "follow": false })).unwrap().follow,
787 FollowOption::Off
788 );
789 assert_eq!(
790 parse_open_options(&json!({ "follow": true })).unwrap().follow,
791 FollowOption::Every(Duration::ZERO)
792 );
793 assert_eq!(
794 parse_open_options(&json!({ "follow": { "intervalMs": 250 } }))
795 .unwrap()
796 .follow,
797 FollowOption::Every(Duration::from_millis(250))
798 );
799 assert_eq!(
800 parse_open_options(&json!({ "follow": {} })).unwrap().follow,
801 FollowOption::Every(Duration::ZERO)
802 );
803 }
804
805 #[test]
806 fn a_follow_that_is_neither_a_flag_nor_an_interval_is_refused_as_a_shape_error() {
807 assert!(matches!(
808 parse_open_options(&json!({ "follow": "always" })).unwrap_err(),
809 OptionsError::Shape(_)
810 ));
811 assert!(matches!(
812 parse_open_options(&json!({ "follow": { "intervalMs": -1 } })).unwrap_err(),
813 OptionsError::Shape(_)
814 ));
815 }
816
817 #[test]
818 fn the_layout_triple_reaches_the_engine_unresolved() {
819 assert_eq!(parse_open_options(&json!({})).unwrap().layout, LayoutOptions::default());
820
821 let named = parse_open_options(&json!({
822 "layout": "nested",
823 "metadataFile": "cerebro.json",
824 }))
825 .unwrap();
826 assert_eq!(named.layout.kind, Some(LayoutKind::Nested));
827 assert_eq!(named.layout.metadata_file.as_deref(), Some("cerebro.json"));
828 assert_eq!(named.layout.manifest, None);
829
830 let both = parse_open_options(&json!({ "manifest": "a.json", "metadataFile": "b.json" })).unwrap();
832 assert_eq!(both.layout.manifest.as_deref(), Some("a.json"));
833 assert_eq!(both.layout.metadata_file.as_deref(), Some("b.json"));
834 }
835
836 #[test]
837 fn an_unknown_layout_kind_and_a_non_string_file_name_are_refused_as_shape_errors() {
838 assert_eq!(
839 parse_open_options(&json!({ "layout": "sideways" })).unwrap_err(),
840 OptionsError::Shape("\"layout\" must be \"flat\" or \"nested\" — got \"sideways\"".to_string())
841 );
842 assert_eq!(
843 parse_open_options(&json!({ "metadataFile": 7 })).unwrap_err(),
844 OptionsError::Shape("\"metadataFile\" must be a string".to_string())
845 );
846 }
847
848 #[test]
849 fn a_migration_with_no_document_moves_into_the_nested_arrangement_under_the_engine_lock() {
850 let options = parse_migrate_layout_options(&Value::Null).unwrap();
851
852 assert_eq!(options.to, None, "absent is the engine's own default, not a second one");
853 assert!(!options.dry_run);
854 assert!(options.metadata_file.is_none() && options.from_metadata_file.is_none());
855 assert!(matches!(options.lock, LockMode::Enabled(_)));
856 }
857
858 #[test]
859 fn both_document_name_pairs_reach_the_engine_unresolved_so_it_makes_the_refusal() {
860 let options = parse_migrate_layout_options(&json!({
861 "to": "flat",
862 "fromMetadataFile": "cerebro.json",
863 "fromManifest": "legacy.json",
864 "metadataFile": "hayson.json",
865 "manifestFile": "old.json",
866 "dryRun": true,
867 }))
868 .unwrap();
869
870 assert_eq!(options.to, Some(LayoutKind::Flat));
871 assert_eq!(options.from_metadata_file.as_deref(), Some("cerebro.json"));
872 assert_eq!(options.from_manifest.as_deref(), Some("legacy.json"));
873 assert_eq!(options.metadata_file.as_deref(), Some("hayson.json"));
874 assert_eq!(options.manifest_file.as_deref(), Some("old.json"));
875 assert!(options.dry_run);
876 }
877
878 #[test]
879 fn a_migration_takes_the_same_lock_document_an_open_takes() {
880 let options = parse_migrate_layout_options(&json!({ "lock": { "timeoutMs": 0 } })).unwrap();
881
882 assert_eq!(
883 options.lock.options().and_then(|lock| lock.timeout_ms),
884 Some(0),
885 "a host that owns the wait has to be able to ask for a fail-fast attempt"
886 );
887 let skipped = parse_migrate_layout_options(&json!({ "lock": false })).unwrap();
888 assert!(matches!(skipped.lock, LockMode::Skip));
889 }
890
891 #[test]
892 fn an_unknown_migration_target_is_refused_as_a_shape_error() {
893 assert_eq!(
894 parse_migrate_layout_options(&json!({ "to": "sideways" })).unwrap_err(),
895 OptionsError::Shape("\"to\" must be \"flat\" or \"nested\" — got \"sideways\"".to_string())
896 );
897 assert_eq!(
898 parse_migrate_layout_options(&json!({ "dryRun": "yes" })).unwrap_err(),
899 OptionsError::Shape("\"dryRun\" must be a boolean".to_string())
900 );
901 }
902
903 #[test]
904 fn base64_accepts_the_url_safe_alphabet_and_optional_padding() {
905 assert_eq!(decode_base64("+/8="), decode_base64("-_8"));
906 assert_eq!(decode_base64(""), Some(Vec::new()));
907 assert_eq!(decode_base64("QQ"), Some(vec![b'A']));
908 assert_eq!(decode_base64("QQ=="), Some(vec![b'A']));
909 }
910
911 #[test]
912 fn base64_refuses_a_length_and_a_symbol_that_cannot_be_base64() {
913 assert_eq!(decode_base64("A"), None);
914 assert_eq!(decode_base64("QQ*="), None);
915 }
916
917 #[test]
918 fn hex_round_trips_both_cases_and_refuses_odd_or_non_hex_input() {
919 assert_eq!(decode_hex("DEADbeef"), Some(vec![0xde, 0xad, 0xbe, 0xef]));
920 assert_eq!(decode_hex("abc"), None);
921 assert_eq!(decode_hex("zz"), None);
922 }
923
924 #[test]
925 fn pack_options_carry_extra_entries_limits_and_the_lock() {
926 let options = parse_pack_options(&json!({
927 "indexes": true,
928 "excludeTables": ["users"],
929 "extra": { "gly.json": "{}" },
930 "limits": { "maxEntries": 5, "maxEntryBytes": 10 },
931 }))
932 .unwrap();
933
934 assert!(options.indexes);
935 assert_eq!(options.exclude_tables, vec!["users".to_string()]);
936 assert_eq!(options.extra, vec![("gly.json".to_string(), b"{}".to_vec())]);
937 assert_eq!(options.limits.max_entries, 5);
938 assert_eq!(options.limits.max_entry_bytes, 10);
939 assert_eq!(
940 options.limits.max_archive_bytes,
941 PackLimits::default().max_archive_bytes
942 );
943 assert!(parse_pack_options(&json!({ "extra": { "a": 1 } })).is_err());
944 assert!(parse_unpack_options(&json!({ "limits": { "maxEntries": -1 } })).is_err());
945 assert_eq!(
946 parse_unpack_options(&Value::Null).unwrap().limits,
947 PackLimits::default()
948 );
949 }
950
951 #[test]
952 fn restore_options_carry_the_image_limit_and_the_keys() {
953 let options = parse_restore_options(&json!({
954 "maxImageBytes": 7,
955 "encryption": { "keys": { "k1": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" } },
956 }))
957 .unwrap();
958
959 assert_eq!(options.max_image_bytes, Some(7));
960 assert!(options.encryption.is_some());
961 assert!(parse_restore_options(&json!({ "maxImageBytes": -1 })).is_err());
962 }
963
964 #[test]
965 fn convert_options_read_both_key_documents_and_the_lock() {
966 let keys = json!({ "keys": { "k1": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" } });
967 let options = parse_convert_options(&json!({ "from": keys, "to": keys, "lock": false })).unwrap();
968
969 assert!(options.from.is_some() && options.to.is_some());
970 assert!(matches!(options.lock, LockMode::Skip));
971 assert!(parse_convert_options(&json!({ "to": { "nokeys": 1 } })).is_err());
972 assert!(parse_convert_options(&Value::Null).unwrap().from.is_none());
973 }
974}