1use std::collections::{BTreeMap, BTreeSet};
4
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7
8use crate::serde_util::normalize_string_list;
9use crate::ItemIdentifier;
10
11#[derive(Clone, Debug, PartialEq, Eq, Hash)]
13pub enum MediaType {
14 Texts,
16 Movies,
18 Audio,
20 Image,
22 Software,
24 Data,
26 Collection,
28 Custom(String),
30}
31
32impl MediaType {
33 #[must_use]
34 fn as_str(&self) -> &str {
35 match self {
36 Self::Texts => "texts",
37 Self::Movies => "movies",
38 Self::Audio => "audio",
39 Self::Image => "image",
40 Self::Software => "software",
41 Self::Data => "data",
42 Self::Collection => "collection",
43 Self::Custom(value) => value.as_str(),
44 }
45 }
46}
47
48impl Serialize for MediaType {
49 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
50 where
51 S: serde::Serializer,
52 {
53 serializer.serialize_str(self.as_str())
54 }
55}
56
57impl<'de> Deserialize<'de> for MediaType {
58 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
59 where
60 D: serde::Deserializer<'de>,
61 {
62 let value = String::deserialize(deserializer)?;
63 Ok(match value.as_str() {
64 "texts" => Self::Texts,
65 "movies" => Self::Movies,
66 "audio" => Self::Audio,
67 "image" => Self::Image,
68 "software" => Self::Software,
69 "data" => Self::Data,
70 "collection" => Self::Collection,
71 other => Self::Custom(other.to_owned()),
72 })
73 }
74}
75
76#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
78#[serde(untagged)]
79pub enum MetadataValue {
80 Text(String),
82 TextList(Vec<String>),
84 Json(Value),
86}
87
88impl From<String> for MetadataValue {
89 fn from(value: String) -> Self {
90 Self::Text(value)
91 }
92}
93
94impl From<&str> for MetadataValue {
95 fn from(value: &str) -> Self {
96 Self::Text(value.to_owned())
97 }
98}
99
100impl From<Vec<String>> for MetadataValue {
101 fn from(value: Vec<String>) -> Self {
102 Self::TextList(value)
103 }
104}
105
106impl From<Vec<&str>> for MetadataValue {
107 fn from(value: Vec<&str>) -> Self {
108 Self::TextList(value.into_iter().map(str::to_owned).collect())
109 }
110}
111
112#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
114#[serde(transparent)]
115pub struct ItemMetadata(BTreeMap<String, Value>);
116
117impl ItemMetadata {
118 #[must_use]
120 pub fn builder() -> ItemMetadataBuilder {
121 ItemMetadataBuilder::default()
122 }
123
124 #[must_use]
126 pub fn get(&self, key: &str) -> Option<&Value> {
127 self.0.get(key)
128 }
129
130 #[must_use]
132 pub fn get_text(&self, key: &str) -> Option<&str> {
133 self.get(key).and_then(Value::as_str)
134 }
135
136 #[must_use]
138 pub fn get_texts(&self, key: &str) -> Option<Vec<String>> {
139 self.get(key).and_then(normalize_string_list)
140 }
141
142 #[must_use]
144 pub fn title(&self) -> Option<&str> {
145 self.get_text("title")
146 }
147
148 #[must_use]
150 pub fn mediatype(&self) -> Option<MediaType> {
151 self.get("mediatype")
152 .cloned()
153 .and_then(|value| serde_json::from_value(value).ok())
154 }
155
156 #[must_use]
158 pub fn collections(&self) -> Option<Vec<String>> {
159 self.get_texts("collection")
160 }
161
162 #[must_use]
164 pub fn as_map(&self) -> &BTreeMap<String, Value> {
165 &self.0
166 }
167
168 #[must_use]
170 pub fn into_map(self) -> BTreeMap<String, Value> {
171 self.0
172 }
173
174 pub(crate) fn as_header_encoding(&self) -> HeaderEncoding {
175 let mut headers = Vec::new();
176 let mut remainder = BTreeMap::new();
177
178 for (key, value) in &self.0 {
179 match value {
180 Value::String(text) => headers.push((header_name(key, None), header_value(text))),
181 Value::Array(values) => {
182 let strings = values.iter().map(Value::as_str).collect::<Option<Vec<_>>>();
183 if let Some(strings) = strings {
184 if strings.len() <= 1 {
185 if let Some(value) = strings.first() {
186 headers.push((header_name(key, None), header_value(value)));
187 }
188 } else {
189 for (index, value) in strings.into_iter().enumerate() {
190 headers
191 .push((header_name(key, Some(index + 1)), header_value(value)));
192 }
193 }
194 } else {
195 remainder.insert(key.clone(), value.clone());
196 }
197 }
198 _ => {
199 remainder.insert(key.clone(), value.clone());
200 }
201 }
202 }
203
204 HeaderEncoding {
205 headers,
206 remainder: Self(remainder),
207 }
208 }
209}
210
211pub(crate) fn metadata_contains_projection(actual: &ItemMetadata, expected: &ItemMetadata) -> bool {
212 expected.as_map().iter().all(|(key, expected_value)| {
213 actual.get(key).is_some_and(|actual_value| {
214 metadata_value_contains_projection(actual_value, expected_value)
215 })
216 })
217}
218
219fn metadata_value_contains_projection(actual: &Value, expected: &Value) -> bool {
220 if value_is_string_array(actual) || value_is_string_array(expected) {
221 if let (Some(actual_list), Some(expected_list)) = (
222 normalize_string_list(actual),
223 normalize_string_list(expected),
224 ) {
225 return expected_list
226 .iter()
227 .all(|entry| actual_list.iter().any(|present| present == entry));
228 }
229 }
230 metadata_values_match(actual, expected)
231}
232
233pub(crate) fn merge_metadata_semantically(
234 current: &ItemMetadata,
235 updates: &ItemMetadata,
236) -> ItemMetadata {
237 let mut merged = current.as_map().clone();
238 for (key, update_value) in updates.as_map() {
239 if merged
240 .get(key)
241 .is_some_and(|current_value| metadata_values_match(current_value, update_value))
242 {
243 continue;
244 }
245
246 if let Some(current_value) = merged.get(key) {
247 if let Some(unioned) = merge_list_values(current_value, update_value) {
248 merged.insert(key.clone(), unioned);
249 continue;
250 }
251 }
252
253 merged.insert(key.clone(), update_value.clone());
254 }
255
256 ItemMetadata::from(merged)
257}
258
259fn merge_list_values(current: &Value, update: &Value) -> Option<Value> {
260 if !value_is_string_array(current) && !value_is_string_array(update) {
261 return None;
262 }
263
264 let current_list = normalize_string_list(current)?;
265 let update_list = normalize_string_list(update)?;
266
267 let mut seen: BTreeSet<String> = current_list.iter().cloned().collect();
268 let mut unioned: Vec<String> = current_list;
269 for entry in update_list {
270 if seen.insert(entry.clone()) {
271 unioned.push(entry);
272 }
273 }
274
275 Some(Value::Array(
276 unioned.into_iter().map(Value::String).collect(),
277 ))
278}
279
280fn value_is_string_array(value: &Value) -> bool {
281 matches!(value, Value::Array(items) if items.iter().all(Value::is_string))
282}
283
284impl From<BTreeMap<String, Value>> for ItemMetadata {
285 fn from(value: BTreeMap<String, Value>) -> Self {
286 Self(value)
287 }
288}
289
290impl From<Map<String, Value>> for ItemMetadata {
291 fn from(value: Map<String, Value>) -> Self {
292 Self(value.into_iter().collect())
293 }
294}
295
296#[derive(Clone, Debug, PartialEq)]
298pub(crate) struct HeaderEncoding {
299 pub(crate) headers: Vec<(String, String)>,
300 pub(crate) remainder: ItemMetadata,
301}
302
303#[derive(Clone, Debug, PartialEq, Default)]
305pub struct ItemMetadataBuilder {
306 inner: BTreeMap<String, Value>,
307}
308
309impl ItemMetadataBuilder {
310 #[must_use]
312 pub fn mediatype(mut self, mediatype: MediaType) -> Self {
313 let mediatype = match mediatype {
314 MediaType::Texts => "texts".to_owned(),
315 MediaType::Movies => "movies".to_owned(),
316 MediaType::Audio => "audio".to_owned(),
317 MediaType::Image => "image".to_owned(),
318 MediaType::Software => "software".to_owned(),
319 MediaType::Data => "data".to_owned(),
320 MediaType::Collection => "collection".to_owned(),
321 MediaType::Custom(value) => value,
322 };
323 self.inner
324 .insert("mediatype".to_owned(), Value::String(mediatype));
325 self
326 }
327
328 #[must_use]
330 pub fn title(mut self, title: impl Into<String>) -> Self {
331 self.inner
332 .insert("title".to_owned(), Value::String(title.into()));
333 self
334 }
335
336 #[must_use]
338 pub fn description_html(mut self, description: impl Into<String>) -> Self {
339 self.inner
340 .insert("description".to_owned(), Value::String(description.into()));
341 self
342 }
343
344 #[must_use]
346 pub fn date(mut self, date: impl Into<String>) -> Self {
347 self.inner
348 .insert("date".to_owned(), Value::String(date.into()));
349 self
350 }
351
352 #[must_use]
354 pub fn collection(mut self, collection: impl Into<String>) -> Self {
355 append_text_value(&mut self.inner, "collection", collection.into());
356 self
357 }
358
359 #[must_use]
361 pub fn creator(mut self, creator: impl Into<String>) -> Self {
362 append_text_value(&mut self.inner, "creator", creator.into());
363 self
364 }
365
366 #[must_use]
368 pub fn publisher(mut self, publisher: impl Into<String>) -> Self {
369 append_text_value(&mut self.inner, "publisher", publisher.into());
370 self
371 }
372
373 #[must_use]
375 pub fn subject(mut self, subject: impl Into<String>) -> Self {
376 append_text_value(&mut self.inner, "subject", subject.into());
377 self
378 }
379
380 #[must_use]
382 pub fn language(mut self, language: impl Into<String>) -> Self {
383 append_text_value(&mut self.inner, "language", language.into());
384 self
385 }
386
387 #[must_use]
389 pub fn license_url(mut self, license_url: impl Into<String>) -> Self {
390 self.inner
391 .insert("licenseurl".to_owned(), Value::String(license_url.into()));
392 self
393 }
394
395 #[must_use]
397 pub fn rights(mut self, rights: impl Into<String>) -> Self {
398 self.inner
399 .insert("rights".to_owned(), Value::String(rights.into()));
400 self
401 }
402
403 #[must_use]
405 pub fn extra_text(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
406 self.inner.insert(key.into(), Value::String(value.into()));
407 self
408 }
409
410 #[must_use]
412 pub fn extra_texts(
413 mut self,
414 key: impl Into<String>,
415 values: impl IntoIterator<Item = impl Into<String>>,
416 ) -> Self {
417 self.inner.insert(
418 key.into(),
419 Value::Array(
420 values
421 .into_iter()
422 .map(|value| Value::String(value.into()))
423 .collect(),
424 ),
425 );
426 self
427 }
428
429 #[must_use]
431 pub fn extra_json(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
432 self.inner.insert(key.into(), value.into());
433 self
434 }
435
436 #[must_use]
438 pub fn build(self) -> ItemMetadata {
439 ItemMetadata(self.inner)
440 }
441}
442
443#[derive(Clone, Debug, PartialEq, Eq)]
445pub enum MetadataTarget {
446 Metadata,
448 File(String),
450 UserJson(String),
453 RootUserJson(ItemIdentifier),
458}
459
460impl MetadataTarget {
461 #[must_use]
462 pub(crate) fn as_str(&self) -> String {
463 match self {
464 Self::Metadata => "metadata".to_owned(),
465 Self::File(name) => format!("files/{name}"),
466 Self::UserJson(name) => name.clone(),
467 Self::RootUserJson(identifier) => identifier.as_str().to_owned(),
468 }
469 }
470}
471
472#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
474#[serde(tag = "op", rename_all = "lowercase")]
475pub enum PatchOperation {
476 Add {
478 path: String,
480 value: Value,
482 },
483 Remove {
485 path: String,
487 },
488 Replace {
490 path: String,
492 value: Value,
494 },
495 Test {
497 path: String,
499 value: Value,
501 },
502 #[serde(rename = "remove-first")]
514 RemoveFirst {
515 path: String,
517 value: Value,
519 },
520 #[serde(rename = "remove-all")]
529 RemoveAll {
530 path: String,
532 value: Value,
534 },
535}
536
537impl PatchOperation {
538 #[must_use]
540 pub fn test(path: impl Into<String>, value: impl Into<Value>) -> Self {
541 Self::Test {
542 path: path.into(),
543 value: value.into(),
544 }
545 }
546
547 #[must_use]
549 pub fn replace(path: impl Into<String>, value: impl Into<Value>) -> Self {
550 Self::Replace {
551 path: path.into(),
552 value: value.into(),
553 }
554 }
555
556 #[must_use]
558 pub fn add(path: impl Into<String>, value: impl Into<Value>) -> Self {
559 Self::Add {
560 path: path.into(),
561 value: value.into(),
562 }
563 }
564}
565
566#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
568pub struct MetadataChange {
569 pub target: String,
571 pub patch: Vec<PatchOperation>,
573}
574
575impl MetadataChange {
576 #[must_use]
578 pub fn new(target: &MetadataTarget, patch: Vec<PatchOperation>) -> Self {
579 Self {
580 target: target.as_str(),
581 patch,
582 }
583 }
584}
585
586fn append_text_value(map: &mut BTreeMap<String, Value>, key: &str, value: String) {
587 match map.get_mut(key) {
588 Some(Value::Array(values)) => values.push(Value::String(value)),
589 Some(existing) => {
590 let previous = existing.take();
591 *existing = Value::Array(vec![previous, Value::String(value)]);
592 }
593 None => {
594 map.insert(key.to_owned(), Value::String(value));
595 }
596 }
597}
598
599fn metadata_values_match(actual: &Value, expected: &Value) -> bool {
600 match (
601 normalize_string_list(actual),
602 normalize_string_list(expected),
603 ) {
604 (Some(actual), Some(expected)) => actual == expected,
605 _ => actual == expected,
606 }
607}
608
609fn header_name(key: &str, position: Option<usize>) -> String {
610 let normalized = key.replace('_', "--");
611 if let Some(position) = position {
612 format!("x-archive-meta{position:02}-{normalized}")
613 } else {
614 format!("x-archive-meta-{normalized}")
615 }
616}
617
618fn header_value(value: &str) -> String {
619 if value.is_ascii() {
620 value.to_owned()
621 } else {
622 let encoded =
623 percent_encoding::utf8_percent_encode(value, percent_encoding::NON_ALPHANUMERIC);
624 format!("uri({encoded})")
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use serde_json::{json, Map, Value};
631
632 use super::{
633 merge_metadata_semantically, metadata_contains_projection, ItemIdentifier, ItemMetadata,
634 MediaType, MetadataChange, MetadataTarget, MetadataValue, PatchOperation,
635 };
636
637 fn metadata_from(value: Value) -> ItemMetadata {
638 serde_json::from_value(value).expect("metadata literal")
639 }
640
641 #[test]
642 fn merge_unions_list_values_preserving_current_order_then_appending() {
643 let current = metadata_from(json!({"collection": ["a", "b"]}));
644 let update = metadata_from(json!({"collection": ["b", "c", "a"]}));
645
646 let merged = merge_metadata_semantically(¤t, &update);
647
648 assert_eq!(merged.get("collection"), Some(&json!(["a", "b", "c"])));
649 }
650
651 #[test]
652 fn merge_promotes_scalar_to_list_when_update_is_multi_value() {
653 let current = metadata_from(json!({"collection": "a"}));
654 let update = metadata_from(json!({"collection": ["a", "b"]}));
655
656 let merged = merge_metadata_semantically(¤t, &update);
657
658 assert_eq!(merged.get("collection"), Some(&json!(["a", "b"])));
659 }
660
661 #[test]
662 fn merge_keeps_replace_semantics_for_differing_scalars() {
663 let current = metadata_from(json!({"title": "Old"}));
664 let update = metadata_from(json!({"title": "New"}));
665
666 let merged = merge_metadata_semantically(¤t, &update);
667
668 assert_eq!(merged.get("title"), Some(&json!("New")));
669 }
670
671 #[test]
672 fn merge_falls_through_to_replace_for_non_string_arrays() {
673 let current = metadata_from(json!({"weights": [1, 2]}));
674 let update = metadata_from(json!({"weights": [3]}));
675
676 let merged = merge_metadata_semantically(¤t, &update);
677
678 assert_eq!(merged.get("weights"), Some(&json!([3])));
679 }
680
681 #[test]
682 fn contains_projection_treats_list_as_subset_check() {
683 let superset = metadata_from(json!({"collection": ["a", "b"]}));
684 let expected_subset = metadata_from(json!({"collection": ["a"]}));
685 assert!(metadata_contains_projection(&superset, &expected_subset));
686
687 let scalar_subset = metadata_from(json!({"collection": "a"}));
688 assert!(metadata_contains_projection(&superset, &scalar_subset));
689
690 let unmet = metadata_from(json!({"collection": ["a", "c"]}));
691 assert!(!metadata_contains_projection(&superset, &unmet));
692
693 let single = metadata_from(json!({"collection": ["a"]}));
694 let wider = metadata_from(json!({"collection": ["a", "b"]}));
695 assert!(!metadata_contains_projection(&single, &wider));
696 }
697
698 #[test]
699 fn contains_projection_scalar_exact_match_unchanged() {
700 let actual = metadata_from(json!({"title": "Demo"}));
701 let same = metadata_from(json!({"title": "Demo"}));
702 let different = metadata_from(json!({"title": "Other"}));
703
704 assert!(metadata_contains_projection(&actual, &same));
705 assert!(!metadata_contains_projection(&actual, &different));
706 }
707
708 #[test]
709 fn builder_handles_common_fields_and_lists() {
710 let metadata = ItemMetadata::builder()
711 .mediatype(MediaType::Texts)
712 .title("Demo")
713 .collection("opensource")
714 .collection("community")
715 .creator("Doe, Jane")
716 .language("eng")
717 .build();
718
719 assert_eq!(metadata.title(), Some("Demo"));
720 assert_eq!(metadata.mediatype(), Some(MediaType::Texts));
721 assert_eq!(
722 metadata.collections().unwrap(),
723 vec!["opensource".to_owned(), "community".to_owned()]
724 );
725 }
726
727 #[test]
728 fn media_types_and_metadata_value_conversions_cover_all_variants() {
729 let variants = [
730 (MediaType::Texts, "texts"),
731 (MediaType::Movies, "movies"),
732 (MediaType::Audio, "audio"),
733 (MediaType::Image, "image"),
734 (MediaType::Software, "software"),
735 (MediaType::Data, "data"),
736 (MediaType::Collection, "collection"),
737 ];
738
739 for (variant, expected) in variants {
740 assert_eq!(
741 serde_json::to_value(&variant).unwrap(),
742 Value::String(expected.to_owned())
743 );
744 assert_eq!(
745 serde_json::from_value::<MediaType>(Value::String(expected.to_owned())).unwrap(),
746 variant
747 );
748 }
749 assert_eq!(
750 serde_json::from_value::<MediaType>(Value::String("custom".to_owned())).unwrap(),
751 MediaType::Custom("custom".to_owned())
752 );
753
754 for variant in [
755 MediaType::Movies,
756 MediaType::Audio,
757 MediaType::Image,
758 MediaType::Software,
759 MediaType::Data,
760 MediaType::Collection,
761 ] {
762 let metadata = ItemMetadata::builder().mediatype(variant.clone()).build();
763 assert_eq!(metadata.mediatype(), Some(variant));
764 }
765
766 assert_eq!(
767 MetadataValue::from(String::from("demo")),
768 MetadataValue::Text(String::from("demo"))
769 );
770 assert_eq!(
771 MetadataValue::from("demo"),
772 MetadataValue::Text(String::from("demo"))
773 );
774 assert_eq!(
775 MetadataValue::from(vec![String::from("a"), String::from("b")]),
776 MetadataValue::TextList(vec![String::from("a"), String::from("b")])
777 );
778 assert_eq!(
779 MetadataValue::from(vec!["a", "b"]),
780 MetadataValue::TextList(vec![String::from("a"), String::from("b")])
781 );
782 }
783
784 #[test]
785 fn builder_and_accessors_cover_all_common_metadata_helpers() {
786 let metadata = ItemMetadata::builder()
787 .mediatype(MediaType::Custom("zines".to_owned()))
788 .title("Demo")
789 .description_html("<p>Description</p>")
790 .date("2026-04-22")
791 .collection("opensource")
792 .creator("Jane Doe")
793 .publisher("Example Press")
794 .subject("rust")
795 .language("eng")
796 .rights("CC BY 4.0")
797 .license_url("https://creativecommons.org/licenses/by/4.0/")
798 .extra_text("identifier", "demo-item")
799 .extra_texts("collection", ["opensource", "community"])
800 .extra_json("custom", json!({"nested": true}))
801 .build();
802
803 assert_eq!(metadata.get("custom").unwrap(), &json!({"nested": true}));
804 assert_eq!(metadata.get_text("title"), Some("Demo"));
805 assert_eq!(metadata.get_text("date"), Some("2026-04-22"));
806 assert_eq!(
807 metadata.get_texts("collection").unwrap(),
808 vec!["opensource".to_owned(), "community".to_owned()]
809 );
810 assert_eq!(
811 metadata.get_texts("publisher").unwrap(),
812 vec!["Example Press".to_owned()]
813 );
814 assert_eq!(metadata.get_text("rights"), Some("CC BY 4.0"));
815 assert_eq!(metadata.title(), Some("Demo"));
816 assert_eq!(
817 metadata.mediatype(),
818 Some(MediaType::Custom("zines".to_owned()))
819 );
820 assert_eq!(
821 metadata.collections().unwrap(),
822 vec!["opensource".to_owned(), "community".to_owned()]
823 );
824 assert!(metadata.as_map().contains_key("licenseurl"));
825
826 let raw = metadata.clone().into_map();
827 assert_eq!(raw["identifier"], Value::String("demo-item".to_owned()));
828 }
829
830 #[test]
831 fn header_encoding_supports_ascii_lists_and_leaves_complex_values_for_patching() {
832 let metadata = ItemMetadata::builder()
833 .mediatype(MediaType::Texts)
834 .title("Demo")
835 .collection("opensource")
836 .collection("community")
837 .extra_json("custom", serde_json::json!({"nested": true}))
838 .build();
839
840 let encoding = metadata.as_header_encoding();
841 assert_eq!(encoding.headers.len(), 4);
842 assert_eq!(
843 encoding.remainder.get("custom").unwrap(),
844 &serde_json::json!({"nested": true})
845 );
846 }
847
848 #[test]
849 fn header_encoding_uri_encodes_unicode_values() {
850 let metadata = ItemMetadata::builder().title("Snowman ☃").build();
851 let encoding = metadata.as_header_encoding();
852 assert!(encoding.headers[0].1.starts_with("uri("));
853 }
854
855 #[test]
856 fn header_encoding_handles_single_value_arrays_and_map_conversions() {
857 let mut map = Map::new();
858 map.insert("single".to_owned(), json!(["only"]));
859 map.insert("mixed".to_owned(), json!([1, 2, 3]));
860
861 let metadata = ItemMetadata::from(map);
862 let encoding = metadata.as_header_encoding();
863
864 assert!(encoding
865 .headers
866 .iter()
867 .any(|(name, value)| name == "x-archive-meta-single" && value == "only"));
868 assert_eq!(encoding.remainder.get("mixed").unwrap(), &json!([1, 2, 3]));
869 }
870
871 #[test]
872 fn metadata_targets_and_patch_helpers_serialize_as_expected() {
873 let change = MetadataChange::new(
874 &MetadataTarget::Metadata,
875 vec![
876 PatchOperation::test("/version", 1),
877 PatchOperation::replace("/title", "Updated"),
878 PatchOperation::add("/subjects/-", "rust"),
879 PatchOperation::Remove {
880 path: "/deprecated".to_owned(),
881 },
882 PatchOperation::RemoveFirst {
883 path: "/subjects/-".to_owned(),
884 value: Value::String("old".to_owned()),
885 },
886 PatchOperation::RemoveAll {
887 path: "/subjects/-".to_owned(),
888 value: Value::String("older".to_owned()),
889 },
890 ],
891 );
892 let json = serde_json::to_value(change).unwrap();
893 assert_eq!(json["target"], "metadata");
894 assert_eq!(json["patch"][0]["op"], "test");
895 assert_eq!(
896 MetadataTarget::File("demo.txt".into()).as_str(),
897 "files/demo.txt"
898 );
899 assert_eq!(
900 MetadataTarget::UserJson("extra.json".into()).as_str(),
901 "extra.json"
902 );
903 assert_eq!(
904 MetadataTarget::RootUserJson(ItemIdentifier::new("demo-item").unwrap()).as_str(),
905 "demo-item"
906 );
907 }
908}