hwpforge_core/
metadata.rs

1//! Document metadata.
2//!
3//! [`Metadata`] holds the document's title, author, subject, keywords,
4//! and timestamps. All fields are optional; an empty `Metadata` is valid.
5//!
6//! Timestamps are stored as `Option<String>` in ISO 8601 format
7//! (e.g. `"2026-02-07T10:30:00Z"`). The `chrono` crate is intentionally
8//! avoided to keep Core's dependency footprint minimal -- parse dates
9//! at the Smithy layer when needed.
10//!
11//! # Examples
12//!
13//! ```
14//! use hwpforge_core::Metadata;
15//!
16//! let meta = Metadata {
17//!     title: Some("Quarterly Report".to_string()),
18//!     author: Some("Kim".to_string()),
19//!     ..Metadata::default()
20//! };
21//! assert_eq!(meta.title.as_deref(), Some("Quarterly Report"));
22//! assert!(meta.subject.is_none());
23//! ```
24
25use schemars::JsonSchema;
26use serde::{Deserialize, Serialize};
27
28/// Document metadata: title, author, subject, keywords, timestamps.
29///
30/// All fields are optional. `Default` returns a fully empty metadata
31/// (all `None` / empty `Vec`).
32///
33/// # Design Decision
34///
35/// Timestamps use `Option<String>` (ISO 8601) instead of `chrono::DateTime`.
36/// Rationale: `chrono` adds ~250KB compile weight for two fields that Core
37/// never does arithmetic on. Smithy crates parse and validate dates when
38/// reading from format-specific sources.
39///
40/// # Examples
41///
42/// ```
43/// use hwpforge_core::Metadata;
44///
45/// let meta = Metadata::default();
46/// assert!(meta.title.is_none());
47/// assert!(meta.keywords.is_empty());
48/// ```
49#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
50pub struct Metadata {
51    /// Document title.
52    pub title: Option<String>,
53    /// Document author.
54    pub author: Option<String>,
55    /// Document subject or description.
56    pub subject: Option<String>,
57    /// Searchable keywords.
58    pub keywords: Vec<String>,
59    /// Creation timestamp in ISO 8601 format (e.g. `"2026-02-07T10:30:00Z"`).
60    pub created: Option<String>,
61    /// Last modification timestamp in ISO 8601 format.
62    pub modified: Option<String>,
63}
64
65impl std::fmt::Display for Metadata {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        match &self.title {
68            Some(t) => write!(f, "Metadata(\"{}\")", t),
69            None => write!(f, "Metadata(untitled)"),
70        }
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn default_is_all_none_or_empty() {
80        let m = Metadata::default();
81        assert!(m.title.is_none());
82        assert!(m.author.is_none());
83        assert!(m.subject.is_none());
84        assert!(m.keywords.is_empty());
85        assert!(m.created.is_none());
86        assert!(m.modified.is_none());
87    }
88
89    #[test]
90    fn struct_literal_construction() {
91        let m = Metadata {
92            title: Some("Test".to_string()),
93            author: Some("Author".to_string()),
94            subject: Some("Subject".to_string()),
95            keywords: vec!["rust".to_string(), "hwp".to_string()],
96            created: Some("2026-02-07T00:00:00Z".to_string()),
97            modified: Some("2026-02-07T12:00:00Z".to_string()),
98        };
99        assert_eq!(m.title.as_deref(), Some("Test"));
100        assert_eq!(m.keywords.len(), 2);
101    }
102
103    #[test]
104    fn partial_construction_with_defaults() {
105        let m = Metadata { title: Some("Report".to_string()), ..Metadata::default() };
106        assert_eq!(m.title.as_deref(), Some("Report"));
107        assert!(m.author.is_none());
108    }
109
110    #[test]
111    fn display_with_title() {
112        let m = Metadata { title: Some("My Doc".to_string()), ..Metadata::default() };
113        assert_eq!(m.to_string(), "Metadata(\"My Doc\")");
114    }
115
116    #[test]
117    fn display_without_title() {
118        let m = Metadata::default();
119        assert_eq!(m.to_string(), "Metadata(untitled)");
120    }
121
122    #[test]
123    fn equality() {
124        let a = Metadata { title: Some("A".to_string()), ..Metadata::default() };
125        let b = Metadata { title: Some("A".to_string()), ..Metadata::default() };
126        let c = Metadata { title: Some("B".to_string()), ..Metadata::default() };
127        assert_eq!(a, b);
128        assert_ne!(a, c);
129    }
130
131    #[test]
132    fn clone_independence() {
133        let m = Metadata { title: Some("Original".to_string()), ..Metadata::default() };
134        let mut cloned = m.clone();
135        cloned.title = Some("Modified".to_string());
136        assert_eq!(m.title.as_deref(), Some("Original"));
137    }
138
139    #[test]
140    fn korean_text() {
141        let m = Metadata {
142            title: Some("분기 보고서".to_string()),
143            author: Some("김철수".to_string()),
144            keywords: vec!["한글".to_string(), "보고서".to_string()],
145            ..Metadata::default()
146        };
147        assert_eq!(m.title.as_deref(), Some("분기 보고서"));
148    }
149
150    #[test]
151    fn serde_roundtrip() {
152        let m = Metadata {
153            title: Some("Test".to_string()),
154            author: Some("Author".to_string()),
155            subject: None,
156            keywords: vec!["a".to_string(), "b".to_string()],
157            created: Some("2026-02-07T00:00:00Z".to_string()),
158            modified: None,
159        };
160        let json = serde_json::to_string(&m).unwrap();
161        let back: Metadata = serde_json::from_str(&json).unwrap();
162        assert_eq!(m, back);
163    }
164
165    #[test]
166    fn serde_default_roundtrip() {
167        let m = Metadata::default();
168        let json = serde_json::to_string(&m).unwrap();
169        let back: Metadata = serde_json::from_str(&json).unwrap();
170        assert_eq!(m, back);
171    }
172
173    #[test]
174    fn empty_keywords_serializes_as_empty_array() {
175        let m = Metadata::default();
176        let json = serde_json::to_string(&m).unwrap();
177        assert!(json.contains("\"keywords\":[]"), "json: {json}");
178    }
179}