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