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#[derive(Debug, PartialEq, Eq)]
16pub enum OptionsError {
17 Shape(String),
19 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
72fn 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
91fn 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
112fn 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
127fn 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
160fn 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
185fn 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 b'+' | b'-' => Some(62),
216 b'/' | b'_' => Some(63),
217 _ => None,
218 }
219}
220
221pub 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 if accumulator & ((1 << bits) - 1) != 0 {
241 return None;
242 }
243 Some(out)
244}
245
246pub 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
261pub 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
301pub 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
336pub 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
367pub 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
392pub 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
406pub 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
449pub 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
457pub 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
481pub 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 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 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 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 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}