Skip to main content

internetarchive_rs/
metadata.rs

1//! Flexible metadata wrappers and JSON Patch helpers.
2
3use 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/// Common Internet Archive media types.
12#[derive(Clone, Debug, PartialEq, Eq, Hash)]
13pub enum MediaType {
14    /// Texts and documents.
15    Texts,
16    /// Movies and videos.
17    Movies,
18    /// Audio recordings.
19    Audio,
20    /// Images.
21    Image,
22    /// Software and executables.
23    Software,
24    /// Datasets and miscellaneous files.
25    Data,
26    /// Collections of items.
27    Collection,
28    /// A custom mediatype string.
29    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/// Flexible metadata value wrapper.
77#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
78#[serde(untagged)]
79pub enum MetadataValue {
80    /// Single string value.
81    Text(String),
82    /// Multiple string values.
83    TextList(Vec<String>),
84    /// Raw JSON value for less common metadata shapes.
85    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/// Flexible item metadata map.
113#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
114#[serde(transparent)]
115pub struct ItemMetadata(BTreeMap<String, Value>);
116
117impl ItemMetadata {
118    /// Starts building metadata with convenient typed helpers.
119    #[must_use]
120    pub fn builder() -> ItemMetadataBuilder {
121        ItemMetadataBuilder::default()
122    }
123
124    /// Returns the raw JSON value for a metadata key.
125    #[must_use]
126    pub fn get(&self, key: &str) -> Option<&Value> {
127        self.0.get(key)
128    }
129
130    /// Returns a common metadata field as text.
131    #[must_use]
132    pub fn get_text(&self, key: &str) -> Option<&str> {
133        self.get(key).and_then(Value::as_str)
134    }
135
136    /// Returns a metadata field as one or many text values.
137    #[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    /// Returns the configured title, when present.
143    #[must_use]
144    pub fn title(&self) -> Option<&str> {
145        self.get_text("title")
146    }
147
148    /// Returns the configured mediatype, when present.
149    #[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    /// Returns the configured collections, when present.
157    #[must_use]
158    pub fn collections(&self) -> Option<Vec<String>> {
159        self.get_texts("collection")
160    }
161
162    /// Returns the raw map view.
163    #[must_use]
164    pub fn as_map(&self) -> &BTreeMap<String, Value> {
165        &self.0
166    }
167
168    /// Converts metadata into the raw JSON map.
169    #[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/// Helper produced when turning metadata into S3 creation headers.
297#[derive(Clone, Debug, PartialEq)]
298pub(crate) struct HeaderEncoding {
299    pub(crate) headers: Vec<(String, String)>,
300    pub(crate) remainder: ItemMetadata,
301}
302
303/// Builder for [`ItemMetadata`].
304#[derive(Clone, Debug, PartialEq, Default)]
305pub struct ItemMetadataBuilder {
306    inner: BTreeMap<String, Value>,
307}
308
309impl ItemMetadataBuilder {
310    /// Sets the item mediatype.
311    #[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    /// Sets the item title.
329    #[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    /// Sets the item description.
337    #[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    /// Sets the item archival date.
345    #[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    /// Appends a collection membership.
353    #[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    /// Appends a creator value.
360    #[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    /// Appends a publisher value.
367    #[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    /// Appends a subject value.
374    #[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    /// Appends a language value.
381    #[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    /// Sets the metadata license URL.
388    #[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    /// Sets the item rights statement.
396    #[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    /// Sets any extra metadata key to a string value.
404    #[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    /// Sets any extra metadata key to a multi-value string list.
411    #[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    /// Sets any extra metadata key to an arbitrary JSON-compatible value.
430    #[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    /// Builds the metadata value.
437    #[must_use]
438    pub fn build(self) -> ItemMetadata {
439        ItemMetadata(self.inner)
440    }
441}
442
443/// Metadata write target for the MDAPI write endpoint.
444#[derive(Clone, Debug, PartialEq, Eq)]
445pub enum MetadataTarget {
446    /// Update the item-level `metadata` object.
447    Metadata,
448    /// Update metadata for a specific file entry.
449    File(String),
450    /// Update a named user JSON document; persisted by IA as
451    /// `{identifier}_{name}.json`.
452    UserJson(String),
453    /// Update the unnamed root user JSON document; persisted by IA as
454    /// `{identifier}.json`. The wire-level target value sent to MDAPI is the
455    /// item identifier itself (an empty string would be rejected with
456    /// `Target name '' not supported`).
457    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/// One patch operation accepted by MDAPI.
473#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
474#[serde(tag = "op", rename_all = "lowercase")]
475pub enum PatchOperation {
476    /// Adds a new value at a path.
477    Add {
478        /// JSON Pointer path to modify.
479        path: String,
480        /// JSON value to insert.
481        value: Value,
482    },
483    /// Removes a value at a path.
484    Remove {
485        /// JSON Pointer path to modify.
486        path: String,
487    },
488    /// Replaces a value at a path.
489    Replace {
490        /// JSON Pointer path to modify.
491        path: String,
492        /// Replacement JSON value.
493        value: Value,
494    },
495    /// Asserts the current value at a path.
496    Test {
497        /// JSON Pointer path to compare.
498        path: String,
499        /// Expected JSON value.
500        value: Value,
501    },
502    /// Internet Archive extension: removes the first matching value from the
503    /// indexed array at `path`.
504    ///
505    /// IA's MDAPI requires the target value to be an indexed (list-shaped)
506    /// array. If `path` points at an associative array (PHP map / JSON
507    /// object), or at a field that was created earlier in the same patch
508    /// (newly-added fields land as associative arrays inside MDAPI's
509    /// processor), IA rejects the request with
510    /// `Can't remove first value of associative array`. Apply the `add` that
511    /// creates the list in a prior patch submission, then run `RemoveFirst`
512    /// in a follow-up patch so MDAPI sees a settled indexed array.
513    #[serde(rename = "remove-first")]
514    RemoveFirst {
515        /// JSON Pointer path to the target array, ending in `/-`.
516        path: String,
517        /// JSON value to match and remove.
518        value: Value,
519    },
520    /// Internet Archive extension: removes all matching values from the
521    /// indexed array at `path`.
522    ///
523    /// Same constraint as [`Self::RemoveFirst`]: IA's MDAPI requires the
524    /// target to be an indexed array, not an associative one. Fields created
525    /// in the same patch are treated as associative and rejected with
526    /// `Can't remove first value of associative array`. Split into two
527    /// patches if you need to create-then-remove.
528    #[serde(rename = "remove-all")]
529    RemoveAll {
530        /// JSON Pointer path to the target array or object, ending in `/-`.
531        path: String,
532        /// JSON value to match and remove.
533        value: Value,
534    },
535}
536
537impl PatchOperation {
538    /// Creates a `test` operation from a JSON-compatible value.
539    #[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    /// Creates a `replace` operation from a JSON-compatible value.
548    #[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    /// Creates an `add` operation from a JSON-compatible value.
557    #[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/// Multi-target metadata write entry.
567#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
568pub struct MetadataChange {
569    /// The target being updated.
570    pub target: String,
571    /// The patch document to apply.
572    pub patch: Vec<PatchOperation>,
573}
574
575impl MetadataChange {
576    /// Creates a new metadata change.
577    #[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(&current, &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(&current, &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(&current, &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(&current, &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}