use std::collections::BTreeMap;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[non_exhaustive]
pub struct Metadata {
pub title: Option<String>,
pub author: Option<String>,
pub subject: Option<String>,
pub description: Option<String>,
pub last_saved_by: Option<String>,
pub keywords: Vec<String>,
pub created: Option<String>,
pub modified: Option<String>,
pub extras: BTreeMap<String, String>,
}
impl Metadata {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_title(mut self, value: impl Into<String>) -> Self {
self.title = Some(value.into());
self
}
#[must_use]
pub fn with_author(mut self, value: impl Into<String>) -> Self {
self.author = Some(value.into());
self
}
#[must_use]
pub fn with_subject(mut self, value: impl Into<String>) -> Self {
self.subject = Some(value.into());
self
}
#[must_use]
pub fn with_description(mut self, value: impl Into<String>) -> Self {
self.description = Some(value.into());
self
}
#[must_use]
pub fn with_last_saved_by(mut self, value: impl Into<String>) -> Self {
self.last_saved_by = Some(value.into());
self
}
#[must_use]
pub fn with_keywords<I, S>(mut self, values: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.keywords = values.into_iter().map(Into::into).collect();
self
}
#[must_use]
pub fn with_created(mut self, value: impl Into<String>) -> Self {
self.created = Some(value.into());
self
}
#[must_use]
pub fn with_modified(mut self, value: impl Into<String>) -> Self {
self.modified = Some(value.into());
self
}
pub fn with_extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extras.insert(key.into(), value.into());
self
}
}
impl std::fmt::Display for Metadata {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.title {
Some(t) => write!(f, "Metadata(\"{}\")", t),
None => write!(f, "Metadata(untitled)"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_is_all_none_or_empty() {
let m = Metadata::default();
assert!(m.title.is_none());
assert!(m.author.is_none());
assert!(m.subject.is_none());
assert!(m.description.is_none());
assert!(m.last_saved_by.is_none());
assert!(m.keywords.is_empty());
assert!(m.created.is_none());
assert!(m.modified.is_none());
assert!(m.extras.is_empty());
}
#[test]
fn struct_literal_construction() {
let mut extras = BTreeMap::new();
extras.insert("category".to_string(), "Test".to_string());
let m = Metadata {
title: Some("Test".to_string()),
author: Some("Author".to_string()),
subject: Some("Subject".to_string()),
description: Some("Long form description".to_string()),
last_saved_by: Some("Editor".to_string()),
keywords: vec!["rust".to_string(), "hwp".to_string()],
created: Some("2026-02-07T00:00:00Z".to_string()),
modified: Some("2026-02-07T12:00:00Z".to_string()),
extras,
};
assert_eq!(m.title.as_deref(), Some("Test"));
assert_eq!(m.keywords.len(), 2);
assert_eq!(m.description.as_deref(), Some("Long form description"));
assert_eq!(m.last_saved_by.as_deref(), Some("Editor"));
assert_eq!(m.extras.get("category").map(String::as_str), Some("Test"));
}
#[test]
fn extras_btree_ordering_is_deterministic() {
let mut m = Metadata::default();
m.extras.insert("zeta".to_string(), "z".to_string());
m.extras.insert("alpha".to_string(), "a".to_string());
m.extras.insert("mu".to_string(), "m".to_string());
let keys: Vec<&str> = m.extras.keys().map(String::as_str).collect();
assert_eq!(keys, vec!["alpha", "mu", "zeta"]);
}
#[test]
fn partial_construction_with_defaults() {
let m = Metadata { title: Some("Report".to_string()), ..Metadata::default() };
assert_eq!(m.title.as_deref(), Some("Report"));
assert!(m.author.is_none());
}
#[test]
fn display_with_title() {
let m = Metadata { title: Some("My Doc".to_string()), ..Metadata::default() };
assert_eq!(m.to_string(), "Metadata(\"My Doc\")");
}
#[test]
fn display_without_title() {
let m = Metadata::default();
assert_eq!(m.to_string(), "Metadata(untitled)");
}
#[test]
fn equality() {
let a = Metadata { title: Some("A".to_string()), ..Metadata::default() };
let b = Metadata { title: Some("A".to_string()), ..Metadata::default() };
let c = Metadata { title: Some("B".to_string()), ..Metadata::default() };
assert_eq!(a, b);
assert_ne!(a, c);
}
#[test]
fn clone_independence() {
let m = Metadata { title: Some("Original".to_string()), ..Metadata::default() };
let mut cloned = m.clone();
cloned.title = Some("Modified".to_string());
assert_eq!(m.title.as_deref(), Some("Original"));
}
#[test]
fn korean_text() {
let m = Metadata {
title: Some("분기 보고서".to_string()),
author: Some("김철수".to_string()),
keywords: vec!["한글".to_string(), "보고서".to_string()],
..Metadata::default()
};
assert_eq!(m.title.as_deref(), Some("분기 보고서"));
}
#[test]
fn serde_roundtrip() {
let mut extras = BTreeMap::new();
extras.insert("category".to_string(), "draft".to_string());
let m = Metadata {
title: Some("Test".to_string()),
author: Some("Author".to_string()),
subject: None,
description: Some("body".to_string()),
last_saved_by: Some("Editor".to_string()),
keywords: vec!["a".to_string(), "b".to_string()],
created: Some("2026-02-07T00:00:00Z".to_string()),
modified: None,
extras,
};
let json = serde_json::to_string(&m).unwrap();
let back: Metadata = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
#[test]
fn serde_default_roundtrip() {
let m = Metadata::default();
let json = serde_json::to_string(&m).unwrap();
let back: Metadata = serde_json::from_str(&json).unwrap();
assert_eq!(m, back);
}
#[test]
fn empty_keywords_serializes_as_empty_array() {
let m = Metadata::default();
let json = serde_json::to_string(&m).unwrap();
assert!(json.contains("\"keywords\":[]"), "json: {json}");
}
}