cdx-core 0.7.1

Core library for reading, writing, and validating Codex Document Format (.cdx) files
Documentation
//! Dublin Core metadata.

use serde::{Deserialize, Serialize};

/// Dublin Core metadata file structure.
///
/// This represents the `metadata/dublin-core.json` file in a Codex document.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DublinCore {
    /// Dublin Core version (e.g., "1.1").
    pub version: String,

    /// Dublin Core terms.
    pub terms: DublinCoreTerms,
}

impl DublinCore {
    /// Create a new Dublin Core metadata structure with required fields.
    #[must_use]
    pub fn new(title: impl Into<String>, creator: impl Into<String>) -> Self {
        Self {
            version: "1.1".to_string(),
            terms: DublinCoreTerms {
                title: title.into(),
                creator: StringOrArray::Single(creator.into()),
                subject: None,
                description: None,
                publisher: None,
                contributor: None,
                date: None,
                dc_type: None,
                format: None,
                identifier: None,
                source: None,
                language: None,
                relation: None,
                coverage: None,
                rights: None,
            },
        }
    }

    /// Get the document title.
    #[must_use]
    pub fn title(&self) -> &str {
        &self.terms.title
    }

    /// Get the creator(s) as a slice.
    #[must_use]
    pub fn creators(&self) -> Vec<&str> {
        self.terms.creator.as_slice()
    }

    /// Get the description if present.
    #[must_use]
    pub fn description(&self) -> Option<&str> {
        self.terms.description.as_deref()
    }

    /// Get the language if present.
    #[must_use]
    pub fn language(&self) -> Option<&str> {
        self.terms.language.as_deref()
    }

    /// Get the subject(s) as a slice.
    #[must_use]
    pub fn subjects(&self) -> Vec<&str> {
        self.terms
            .subject
            .as_ref()
            .map_or_else(Vec::new, StringOrArray::as_slice)
    }

    /// Get the publisher if present.
    #[must_use]
    pub fn publisher(&self) -> Option<&str> {
        self.terms.publisher.as_deref()
    }

    /// Get the contributor(s) as a slice.
    #[must_use]
    pub fn contributors(&self) -> Vec<&str> {
        self.terms
            .contributor
            .as_ref()
            .map_or_else(Vec::new, StringOrArray::as_slice)
    }

    /// Get the date if present.
    #[must_use]
    pub fn date(&self) -> Option<&str> {
        self.terms.date.as_deref()
    }

    /// Get the type if present.
    #[must_use]
    pub fn dc_type(&self) -> Option<&str> {
        self.terms.dc_type.as_deref()
    }

    /// Get the format if present.
    #[must_use]
    pub fn format(&self) -> Option<&str> {
        self.terms.format.as_deref()
    }

    /// Get the identifier if present.
    #[must_use]
    pub fn identifier(&self) -> Option<&str> {
        self.terms.identifier.as_deref()
    }

    /// Get the source if present.
    #[must_use]
    pub fn source(&self) -> Option<&str> {
        self.terms.source.as_deref()
    }

    /// Get the relation if present.
    #[must_use]
    pub fn relation(&self) -> Option<&str> {
        self.terms.relation.as_deref()
    }

    /// Get the coverage if present.
    #[must_use]
    pub fn coverage(&self) -> Option<&str> {
        self.terms.coverage.as_deref()
    }

    /// Get the rights if present.
    #[must_use]
    pub fn rights(&self) -> Option<&str> {
        self.terms.rights.as_deref()
    }

    /// Set the title.
    pub fn set_title(&mut self, title: impl Into<String>) {
        self.terms.title = title.into();
    }

    /// Set the creator(s).
    pub fn set_creators(&mut self, creators: Vec<String>) {
        self.terms.creator = match creators.len() {
            1 => StringOrArray::Single(creators.into_iter().next().unwrap_or_default()),
            _ => StringOrArray::Multiple(creators),
        };
    }

    /// Set the description.
    pub fn set_description(&mut self, description: Option<String>) {
        self.terms.description = description;
    }

    /// Set the subject(s).
    pub fn set_subjects(&mut self, subjects: Vec<String>) {
        self.terms.subject = match subjects.len() {
            0 => None,
            1 => Some(StringOrArray::Single(
                subjects.into_iter().next().unwrap_or_default(),
            )),
            _ => Some(StringOrArray::Multiple(subjects)),
        };
    }

    /// Set the publisher.
    pub fn set_publisher(&mut self, publisher: Option<String>) {
        self.terms.publisher = publisher;
    }

    /// Set the language.
    pub fn set_language(&mut self, language: Option<String>) {
        self.terms.language = language;
    }

    /// Set the rights.
    pub fn set_rights(&mut self, rights: Option<String>) {
        self.terms.rights = rights;
    }
}

/// Dublin Core terms.
///
/// These are the standard 15 Dublin Core elements.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DublinCoreTerms {
    /// Document title (required).
    pub title: String,

    /// Author(s) (required).
    pub creator: StringOrArray,

    /// Topics or keywords.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub subject: Option<StringOrArray>,

    /// Summary or abstract.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub description: Option<String>,

    /// Publishing entity.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub publisher: Option<String>,

    /// Other contributors.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub contributor: Option<StringOrArray>,

    /// Publication date (ISO 8601).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub date: Option<String>,

    /// Nature or genre of content.
    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
    pub dc_type: Option<String>,

    /// MIME type.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub format: Option<String>,

    /// Unique identifier.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub identifier: Option<String>,

    /// Source reference.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,

    /// Language code (BCP 47).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub language: Option<String>,

    /// Related resource.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub relation: Option<String>,

    /// Scope (temporal/spatial).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub coverage: Option<String>,

    /// Rights statement.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rights: Option<String>,
}

/// A string or array of strings.
///
/// Dublin Core terms can be either a single string or an array of strings.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum StringOrArray {
    /// Single string value.
    Single(String),
    /// Multiple string values.
    Multiple(Vec<String>),
}

impl StringOrArray {
    /// Get values as a slice of string references.
    #[must_use]
    pub fn as_slice(&self) -> Vec<&str> {
        match self {
            Self::Single(s) => vec![s.as_str()],
            Self::Multiple(v) => v.iter().map(String::as_str).collect(),
        }
    }

    /// Get the first value.
    #[must_use]
    pub fn first(&self) -> &str {
        match self {
            Self::Single(s) => s,
            Self::Multiple(v) => v.first().map_or("", String::as_str),
        }
    }

    /// Check if empty.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        match self {
            Self::Single(s) => s.is_empty(),
            Self::Multiple(v) => v.is_empty(),
        }
    }
}

impl From<String> for StringOrArray {
    fn from(s: String) -> Self {
        Self::Single(s)
    }
}

impl From<&str> for StringOrArray {
    fn from(s: &str) -> Self {
        Self::Single(s.to_string())
    }
}

impl From<Vec<String>> for StringOrArray {
    fn from(v: Vec<String>) -> Self {
        Self::Multiple(v)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_dublin_core_new() {
        let dc = DublinCore::new("Test Document", "Author Name");
        assert_eq!(dc.title(), "Test Document");
        assert_eq!(dc.creators(), vec!["Author Name"]);
        assert_eq!(dc.version, "1.1");
    }

    #[test]
    fn test_string_or_array() {
        let single = StringOrArray::Single("one".to_string());
        assert_eq!(single.as_slice(), vec!["one"]);
        assert_eq!(single.first(), "one");

        let multiple = StringOrArray::Multiple(vec!["one".to_string(), "two".to_string()]);
        assert_eq!(multiple.as_slice(), vec!["one", "two"]);
        assert_eq!(multiple.first(), "one");
    }

    #[test]
    fn test_serialization() {
        let dc = DublinCore::new("Test", "Author");
        let json = serde_json::to_string_pretty(&dc).unwrap();
        assert!(json.contains("\"title\": \"Test\""));
        assert!(json.contains("\"creator\": \"Author\""));
    }

    #[test]
    fn test_deserialization_single_creator() {
        let json = r#"{
            "version": "1.1",
            "terms": {
                "title": "My Document",
                "creator": "John Doe"
            }
        }"#;
        let dc: DublinCore = serde_json::from_str(json).unwrap();
        assert_eq!(dc.title(), "My Document");
        assert_eq!(dc.creators(), vec!["John Doe"]);
    }

    #[test]
    fn test_deserialization_multiple_creators() {
        let json = r#"{
            "version": "1.1",
            "terms": {
                "title": "Collaboration",
                "creator": ["Alice", "Bob", "Charlie"],
                "subject": ["Science", "Research"]
            }
        }"#;
        let dc: DublinCore = serde_json::from_str(json).unwrap();
        assert_eq!(dc.creators(), vec!["Alice", "Bob", "Charlie"]);
        assert_eq!(
            dc.terms.subject.as_ref().unwrap().as_slice(),
            vec!["Science", "Research"]
        );
    }

    #[test]
    fn test_full_dublin_core() {
        let json = r#"{
            "version": "1.1",
            "terms": {
                "title": "Annual Report 2025",
                "creator": ["Jane Doe", "John Smith"],
                "subject": ["Finance", "Annual Report"],
                "description": "Comprehensive annual financial report",
                "publisher": "Acme Corporation",
                "contributor": "Finance Team",
                "date": "2025-01-15",
                "type": "Text",
                "format": "application/vnd.codex+json",
                "identifier": "sha256:3a7bd3e2",
                "language": "en",
                "coverage": "2024 fiscal year",
                "rights": "Copyright 2025 Acme Corporation"
            }
        }"#;
        let dc: DublinCore = serde_json::from_str(json).unwrap();
        assert_eq!(dc.title(), "Annual Report 2025");
        assert_eq!(
            dc.description(),
            Some("Comprehensive annual financial report")
        );
        assert_eq!(dc.language(), Some("en"));
    }
}