Skip to main content

postproject_core/
metadata_registry.rs

1//! Optional hints for a small set of externally defined metadata properties.
2
3mod catalog;
4
5use crate::{
6    Error, ErrorKind, MetadataProperty, MetadataValue, MetadataValueKind, Result, VocabularyId,
7};
8
9pub use catalog::{
10    DUBLIN_CORE_ELEMENTS_VOCABULARY, EBUCORE_VOCABULARY, IPTC_VMH_JSON_VOCABULARY,
11    METADATA_VOCABULARIES, POSTPROJECT_METADATA_VOCABULARY, XMP_BASIC_VOCABULARY,
12};
13
14/// Whether a property accepts at most one assertion or repeated assertions.
15#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
16#[non_exhaustive]
17pub enum MetadataCardinality {
18    /// Zero or one assertion on a target.
19    Single,
20    /// Zero or more assertions on a target.
21    Repeatable,
22}
23
24/// One externally defined spelling corresponding to a property.
25#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
26pub struct MetadataPropertyAlias {
27    profile: &'static str,
28    property: &'static str,
29}
30
31impl MetadataPropertyAlias {
32    /// Returns the mapping profile or representation name.
33    #[must_use]
34    pub const fn profile(self) -> &'static str {
35        self.profile
36    }
37
38    /// Returns the property spelling used by that profile.
39    #[must_use]
40    pub const fn property(self) -> &'static str {
41        self.property
42    }
43}
44
45/// Advisory type, cardinality, and mapping information for one property.
46#[derive(Clone, Copy, Debug)]
47pub struct MetadataPropertyDefinition {
48    property: &'static str,
49    label: &'static str,
50    description: &'static str,
51    accepted_kinds: &'static [MetadataValueKind],
52    cardinality: MetadataCardinality,
53    aliases: &'static [MetadataPropertyAlias],
54    validator: Option<fn(&MetadataValue) -> bool>,
55}
56
57impl MetadataPropertyDefinition {
58    /// Returns the exact vocabulary-local property identifier.
59    #[must_use]
60    pub const fn property(self) -> &'static str {
61        self.property
62    }
63
64    /// Returns a short human-readable label.
65    #[must_use]
66    pub const fn label(self) -> &'static str {
67        self.label
68    }
69
70    /// Returns a concise description of the property's intended meaning.
71    #[must_use]
72    pub const fn description(self) -> &'static str {
73        self.description
74    }
75
76    /// Returns the accepted value kinds for opt-in validation.
77    #[must_use]
78    pub const fn accepted_kinds(self) -> &'static [MetadataValueKind] {
79        self.accepted_kinds
80    }
81
82    /// Returns the suggested assertion cardinality.
83    #[must_use]
84    pub const fn cardinality(self) -> MetadataCardinality {
85        self.cardinality
86    }
87
88    /// Returns known spellings in external mapping profiles.
89    #[must_use]
90    pub const fn aliases(self) -> &'static [MetadataPropertyAlias] {
91        self.aliases
92    }
93
94    /// Applies the advisory type and cardinality rules to a complete value set.
95    ///
96    /// # Errors
97    ///
98    /// Returns [`ErrorKind::InvalidArgument`] for an unexpected value kind or
99    /// more than one value for a single-valued property.
100    pub fn validate_values(self, values: &[MetadataValue]) -> Result<()> {
101        if self.cardinality == MetadataCardinality::Single && values.len() > 1 {
102            return Err(Error::new(
103                ErrorKind::InvalidArgument,
104                format!(
105                    "metadata property {} accepts at most one value",
106                    self.property
107                ),
108            ));
109        }
110        if let Some(value) = values.iter().find(|value| {
111            !self.accepted_kinds.contains(&value.kind())
112                || self.validator.is_some_and(|validator| !validator(value))
113        }) {
114            return Err(Error::new(
115                ErrorKind::InvalidArgument,
116                format!(
117                    "metadata property {} does not accept value kind {:?}",
118                    self.property,
119                    value.kind()
120                ),
121            ));
122        }
123        Ok(())
124    }
125}
126
127/// Documentation and property hints for one metadata vocabulary.
128#[derive(Clone, Copy, Debug)]
129pub struct MetadataVocabularyDefinition {
130    vocabulary: &'static str,
131    label: &'static str,
132    reference: &'static str,
133    properties: &'static [MetadataPropertyDefinition],
134}
135
136impl MetadataVocabularyDefinition {
137    /// Returns the exact persisted vocabulary identifier.
138    #[must_use]
139    pub const fn vocabulary(self) -> &'static str {
140        self.vocabulary
141    }
142
143    /// Returns a short human-readable label.
144    #[must_use]
145    pub const fn label(self) -> &'static str {
146        self.label
147    }
148
149    /// Returns the authoritative vocabulary or schema reference.
150    #[must_use]
151    pub const fn reference(self) -> &'static str {
152        self.reference
153    }
154
155    /// Returns the deliberately small set of built-in property hints.
156    #[must_use]
157    pub const fn properties(self) -> &'static [MetadataPropertyDefinition] {
158        self.properties
159    }
160}
161
162/// Finds a built-in vocabulary definition by exact identifier spelling.
163#[must_use]
164pub fn metadata_vocabulary_definition(
165    vocabulary: &VocabularyId,
166) -> Option<&'static MetadataVocabularyDefinition> {
167    METADATA_VOCABULARIES
168        .iter()
169        .find(|definition| definition.vocabulary == vocabulary.as_str())
170}
171
172/// Finds a built-in property hint by exact vocabulary and property spelling.
173#[must_use]
174pub fn metadata_property_definition(
175    property: &MetadataProperty,
176) -> Option<&'static MetadataPropertyDefinition> {
177    metadata_vocabulary_definition(property.vocabulary())?
178        .properties
179        .iter()
180        .find(|definition| definition.property == property.property().as_str())
181}
182
183#[cfg(test)]
184mod tests {
185    use crate::{MetadataProperty, MetadataValue, PropertyId, VocabularyId};
186
187    use super::*;
188
189    fn property(vocabulary: &str, property: &str) -> MetadataProperty {
190        MetadataProperty::new(
191            VocabularyId::new(vocabulary).expect("valid vocabulary"),
192            PropertyId::new(property).expect("valid property"),
193        )
194    }
195
196    #[test]
197    fn vocabulary_lookup_is_exact_and_descriptive() {
198        let vocabulary = VocabularyId::new(IPTC_VMH_JSON_VOCABULARY).unwrap();
199        let definition = metadata_vocabulary_definition(&vocabulary).expect("known vocabulary");
200        assert_eq!(definition.vocabulary(), IPTC_VMH_JSON_VOCABULARY);
201        assert_eq!(definition.label(), "IPTC Video Metadata Hub 1.7 JSON");
202        assert_eq!(definition.reference(), IPTC_VMH_JSON_VOCABULARY);
203        assert_eq!(definition.properties().len(), 2);
204
205        let unknown = VocabularyId::new("https://example.com/metadata").unwrap();
206        assert!(metadata_vocabulary_definition(&unknown).is_none());
207    }
208
209    #[test]
210    fn property_hints_expose_types_cardinality_and_mappings() {
211        let title = property(IPTC_VMH_JSON_VOCABULARY, "title");
212        let definition = metadata_property_definition(&title).expect("known property");
213        assert_eq!(definition.label(), "Title");
214        assert!(!definition.description().is_empty());
215        assert_eq!(definition.cardinality(), MetadataCardinality::Single);
216        assert_eq!(
217            definition.accepted_kinds(),
218            &[MetadataValueKind::String, MetadataValueKind::LangString]
219        );
220        assert!(
221            definition
222                .aliases()
223                .iter()
224                .any(|alias| alias.profile() == "XMP" && alias.property() == "dc:title")
225        );
226
227        assert!(
228            metadata_property_definition(&property(IPTC_VMH_JSON_VOCABULARY, "unknown")).is_none()
229        );
230    }
231
232    #[test]
233    fn validation_is_opt_in_and_checks_the_complete_value_set() {
234        let title = metadata_property_definition(&property(IPTC_VMH_JSON_VOCABULARY, "title"))
235            .expect("known title");
236        let plain = MetadataValue::string("Interview").unwrap();
237        let localized = MetadataValue::language_string("Interview", "en-US").unwrap();
238        assert!(title.validate_values(std::slice::from_ref(&plain)).is_ok());
239        assert!(
240            title
241                .validate_values(std::slice::from_ref(&localized))
242                .is_ok()
243        );
244        assert!(title.validate_values(&[plain, localized]).is_err());
245        assert!(title.validate_values(&[MetadataValue::u64(42)]).is_err());
246
247        let keywords =
248            metadata_property_definition(&property(IPTC_VMH_JSON_VOCABULARY, "keywords"))
249                .expect("known keywords");
250        assert!(
251            keywords
252                .validate_values(&[
253                    MetadataValue::string("interview").unwrap(),
254                    MetadataValue::string("studio").unwrap(),
255                ])
256                .is_ok()
257        );
258    }
259
260    #[test]
261    fn built_in_validation_callbacks_can_add_property_rules() {
262        let definition = MetadataPropertyDefinition {
263            property: "example",
264            label: "Example",
265            description: "Test-only callback coverage.",
266            accepted_kinds: &[MetadataValueKind::String],
267            cardinality: MetadataCardinality::Single,
268            aliases: &[],
269            validator: Some(|value| value.as_string() == Some("accepted")),
270        };
271        assert!(
272            definition
273                .validate_values(&[MetadataValue::string("accepted").unwrap()])
274                .is_ok()
275        );
276        assert!(
277            definition
278                .validate_values(&[MetadataValue::string("rejected").unwrap()])
279                .is_err()
280        );
281    }
282}