Skip to main content

cdx_core/metadata/
dublin_core.rs

1//! Dublin Core metadata.
2
3use serde::{Deserialize, Serialize};
4
5/// Dublin Core metadata file structure.
6///
7/// This represents the `metadata/dublin-core.json` file in a Codex document.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
9pub struct DublinCore {
10    /// Dublin Core version (e.g., "1.1").
11    pub version: String,
12
13    /// Dublin Core terms.
14    pub terms: DublinCoreTerms,
15}
16
17impl DublinCore {
18    /// Create a new Dublin Core metadata structure with required fields.
19    #[must_use]
20    pub fn new(title: impl Into<String>, creator: impl Into<String>) -> Self {
21        Self {
22            version: "1.1".to_string(),
23            terms: DublinCoreTerms {
24                title: title.into(),
25                creator: StringOrArray::Single(creator.into()),
26                subject: None,
27                description: None,
28                publisher: None,
29                contributor: None,
30                date: None,
31                dc_type: None,
32                format: None,
33                identifier: None,
34                source: None,
35                language: None,
36                relation: None,
37                coverage: None,
38                rights: None,
39            },
40        }
41    }
42
43    /// Get the document title.
44    #[must_use]
45    pub fn title(&self) -> &str {
46        &self.terms.title
47    }
48
49    /// Get the creator(s) as a slice.
50    #[must_use]
51    pub fn creators(&self) -> Vec<&str> {
52        self.terms.creator.as_slice()
53    }
54
55    /// Get the description if present.
56    #[must_use]
57    pub fn description(&self) -> Option<&str> {
58        self.terms.description.as_deref()
59    }
60
61    /// Get the language if present.
62    #[must_use]
63    pub fn language(&self) -> Option<&str> {
64        self.terms.language.as_deref()
65    }
66
67    /// Get the subject(s) as a slice.
68    #[must_use]
69    pub fn subjects(&self) -> Vec<&str> {
70        self.terms
71            .subject
72            .as_ref()
73            .map_or_else(Vec::new, StringOrArray::as_slice)
74    }
75
76    /// Get the publisher if present.
77    #[must_use]
78    pub fn publisher(&self) -> Option<&str> {
79        self.terms.publisher.as_deref()
80    }
81
82    /// Get the contributor(s) as a slice.
83    #[must_use]
84    pub fn contributors(&self) -> Vec<&str> {
85        self.terms
86            .contributor
87            .as_ref()
88            .map_or_else(Vec::new, StringOrArray::as_slice)
89    }
90
91    /// Get the date if present.
92    #[must_use]
93    pub fn date(&self) -> Option<&str> {
94        self.terms.date.as_deref()
95    }
96
97    /// Get the type if present.
98    #[must_use]
99    pub fn dc_type(&self) -> Option<&str> {
100        self.terms.dc_type.as_deref()
101    }
102
103    /// Get the format if present.
104    #[must_use]
105    pub fn format(&self) -> Option<&str> {
106        self.terms.format.as_deref()
107    }
108
109    /// Get the identifier if present.
110    #[must_use]
111    pub fn identifier(&self) -> Option<&str> {
112        self.terms.identifier.as_deref()
113    }
114
115    /// Get the source if present.
116    #[must_use]
117    pub fn source(&self) -> Option<&str> {
118        self.terms.source.as_deref()
119    }
120
121    /// Get the relation if present.
122    #[must_use]
123    pub fn relation(&self) -> Option<&str> {
124        self.terms.relation.as_deref()
125    }
126
127    /// Get the coverage if present.
128    #[must_use]
129    pub fn coverage(&self) -> Option<&str> {
130        self.terms.coverage.as_deref()
131    }
132
133    /// Get the rights if present.
134    #[must_use]
135    pub fn rights(&self) -> Option<&str> {
136        self.terms.rights.as_deref()
137    }
138
139    /// Set the title.
140    pub fn set_title(&mut self, title: impl Into<String>) {
141        self.terms.title = title.into();
142    }
143
144    /// Set the creator(s).
145    pub fn set_creators(&mut self, creators: Vec<String>) {
146        self.terms.creator = match creators.len() {
147            1 => StringOrArray::Single(creators.into_iter().next().unwrap_or_default()),
148            _ => StringOrArray::Multiple(creators),
149        };
150    }
151
152    /// Set the description.
153    pub fn set_description(&mut self, description: Option<String>) {
154        self.terms.description = description;
155    }
156
157    /// Set the subject(s).
158    pub fn set_subjects(&mut self, subjects: Vec<String>) {
159        self.terms.subject = match subjects.len() {
160            0 => None,
161            1 => Some(StringOrArray::Single(
162                subjects.into_iter().next().unwrap_or_default(),
163            )),
164            _ => Some(StringOrArray::Multiple(subjects)),
165        };
166    }
167
168    /// Set the publisher.
169    pub fn set_publisher(&mut self, publisher: Option<String>) {
170        self.terms.publisher = publisher;
171    }
172
173    /// Set the language.
174    pub fn set_language(&mut self, language: Option<String>) {
175        self.terms.language = language;
176    }
177
178    /// Set the rights.
179    pub fn set_rights(&mut self, rights: Option<String>) {
180        self.terms.rights = rights;
181    }
182}
183
184/// Dublin Core terms.
185///
186/// These are the standard 15 Dublin Core elements.
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188pub struct DublinCoreTerms {
189    /// Document title (required).
190    pub title: String,
191
192    /// Author(s) (required).
193    pub creator: StringOrArray,
194
195    /// Topics or keywords.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub subject: Option<StringOrArray>,
198
199    /// Summary or abstract.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub description: Option<String>,
202
203    /// Publishing entity.
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub publisher: Option<String>,
206
207    /// Other contributors.
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub contributor: Option<StringOrArray>,
210
211    /// Publication date (ISO 8601).
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub date: Option<String>,
214
215    /// Nature or genre of content.
216    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
217    pub dc_type: Option<String>,
218
219    /// MIME type.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub format: Option<String>,
222
223    /// Unique identifier.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub identifier: Option<String>,
226
227    /// Source reference.
228    #[serde(default, skip_serializing_if = "Option::is_none")]
229    pub source: Option<String>,
230
231    /// Language code (BCP 47).
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    pub language: Option<String>,
234
235    /// Related resource.
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub relation: Option<String>,
238
239    /// Scope (temporal/spatial).
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub coverage: Option<String>,
242
243    /// Rights statement.
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub rights: Option<String>,
246}
247
248/// A string or array of strings.
249///
250/// Dublin Core terms can be either a single string or an array of strings.
251#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
252#[serde(untagged)]
253pub enum StringOrArray {
254    /// Single string value.
255    Single(String),
256    /// Multiple string values.
257    Multiple(Vec<String>),
258}
259
260impl StringOrArray {
261    /// Get values as a slice of string references.
262    #[must_use]
263    pub fn as_slice(&self) -> Vec<&str> {
264        match self {
265            Self::Single(s) => vec![s.as_str()],
266            Self::Multiple(v) => v.iter().map(String::as_str).collect(),
267        }
268    }
269
270    /// Get the first value.
271    #[must_use]
272    pub fn first(&self) -> &str {
273        match self {
274            Self::Single(s) => s,
275            Self::Multiple(v) => v.first().map_or("", String::as_str),
276        }
277    }
278
279    /// Check if empty.
280    #[must_use]
281    pub fn is_empty(&self) -> bool {
282        match self {
283            Self::Single(s) => s.is_empty(),
284            Self::Multiple(v) => v.is_empty(),
285        }
286    }
287}
288
289impl From<String> for StringOrArray {
290    fn from(s: String) -> Self {
291        Self::Single(s)
292    }
293}
294
295impl From<&str> for StringOrArray {
296    fn from(s: &str) -> Self {
297        Self::Single(s.to_string())
298    }
299}
300
301impl From<Vec<String>> for StringOrArray {
302    fn from(v: Vec<String>) -> Self {
303        Self::Multiple(v)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_dublin_core_new() {
313        let dc = DublinCore::new("Test Document", "Author Name");
314        assert_eq!(dc.title(), "Test Document");
315        assert_eq!(dc.creators(), vec!["Author Name"]);
316        assert_eq!(dc.version, "1.1");
317    }
318
319    #[test]
320    fn test_string_or_array() {
321        let single = StringOrArray::Single("one".to_string());
322        assert_eq!(single.as_slice(), vec!["one"]);
323        assert_eq!(single.first(), "one");
324
325        let multiple = StringOrArray::Multiple(vec!["one".to_string(), "two".to_string()]);
326        assert_eq!(multiple.as_slice(), vec!["one", "two"]);
327        assert_eq!(multiple.first(), "one");
328    }
329
330    #[test]
331    fn test_serialization() {
332        let dc = DublinCore::new("Test", "Author");
333        let json = serde_json::to_string_pretty(&dc).unwrap();
334        assert!(json.contains("\"title\": \"Test\""));
335        assert!(json.contains("\"creator\": \"Author\""));
336    }
337
338    #[test]
339    fn test_deserialization_single_creator() {
340        let json = r#"{
341            "version": "1.1",
342            "terms": {
343                "title": "My Document",
344                "creator": "John Doe"
345            }
346        }"#;
347        let dc: DublinCore = serde_json::from_str(json).unwrap();
348        assert_eq!(dc.title(), "My Document");
349        assert_eq!(dc.creators(), vec!["John Doe"]);
350    }
351
352    #[test]
353    fn test_deserialization_multiple_creators() {
354        let json = r#"{
355            "version": "1.1",
356            "terms": {
357                "title": "Collaboration",
358                "creator": ["Alice", "Bob", "Charlie"],
359                "subject": ["Science", "Research"]
360            }
361        }"#;
362        let dc: DublinCore = serde_json::from_str(json).unwrap();
363        assert_eq!(dc.creators(), vec!["Alice", "Bob", "Charlie"]);
364        assert_eq!(
365            dc.terms.subject.as_ref().unwrap().as_slice(),
366            vec!["Science", "Research"]
367        );
368    }
369
370    #[test]
371    fn test_full_dublin_core() {
372        let json = r#"{
373            "version": "1.1",
374            "terms": {
375                "title": "Annual Report 2025",
376                "creator": ["Jane Doe", "John Smith"],
377                "subject": ["Finance", "Annual Report"],
378                "description": "Comprehensive annual financial report",
379                "publisher": "Acme Corporation",
380                "contributor": "Finance Team",
381                "date": "2025-01-15",
382                "type": "Text",
383                "format": "application/vnd.codex+json",
384                "identifier": "sha256:3a7bd3e2",
385                "language": "en",
386                "coverage": "2024 fiscal year",
387                "rights": "Copyright 2025 Acme Corporation"
388            }
389        }"#;
390        let dc: DublinCore = serde_json::from_str(json).unwrap();
391        assert_eq!(dc.title(), "Annual Report 2025");
392        assert_eq!(
393            dc.description(),
394            Some("Comprehensive annual financial report")
395        );
396        assert_eq!(dc.language(), Some("en"));
397    }
398}