Skip to main content

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::new()
17//!     .with_title("Quarterly Report")
18//!     .with_author("Kim");
19//! assert_eq!(meta.title.as_deref(), Some("Quarterly Report"));
20//! assert!(meta.subject.is_none());
21//! ```
22
23use std::collections::BTreeMap;
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` / empty map).
32///
33/// # Design Decisions
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/// **`#[non_exhaustive]`** mirrors the established Core pattern
41/// ([`Control`](crate::control::Control), shape enums, etc.). New fields
42/// can be added in future versions without breaking external struct
43/// literals — callers must use `..Default::default()`. See `CLAUDE.md`
44/// "semver-first" working principle.
45///
46/// **`extras`** carries `<opf:meta name="X">` entries (HWPX) or
47/// PropertySet entries (HWP5) that have not yet been promoted to typed
48/// fields. Uses [`BTreeMap`] for deterministic ordering so encoder output
49/// is byte-stable for round-trip tests. Keys are the wire `name=` value
50/// (e.g. `"category"`); values are the raw text content.
51///
52/// # Examples
53///
54/// ```
55/// use hwpforge_core::Metadata;
56///
57/// let meta = Metadata::default();
58/// assert!(meta.title.is_none());
59/// assert!(meta.keywords.is_empty());
60/// assert!(meta.extras.is_empty());
61/// ```
62#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
63#[non_exhaustive]
64pub struct Metadata {
65    /// Document title.
66    pub title: Option<String>,
67    /// Document author (corresponds to HWPX `<opf:meta name="creator">`).
68    pub author: Option<String>,
69    /// Document subject (corresponds to HWPX `<opf:meta name="subject">`).
70    pub subject: Option<String>,
71    /// Free-form description (corresponds to HWPX
72    /// `<opf:meta name="description">`).
73    ///
74    /// Distinct from [`subject`](Self::subject): `subject` is the
75    /// canonical "subject" line; `description` carries longer prose
76    /// summary text that Hancom stores separately.
77    pub description: Option<String>,
78    /// Last person to save the document (corresponds to HWPX
79    /// `<opf:meta name="lastsaveby">`).
80    ///
81    /// Distinct from [`author`](Self::author): `author` is the document
82    /// creator; `last_saved_by` is the most recent editor. Hancom
83    /// surfaces this via the `$lastsaveby` SUMMERY auto-field.
84    pub last_saved_by: Option<String>,
85    /// Searchable keywords.
86    ///
87    /// Encoders that target HWPX join with `";"` into a single
88    /// `<opf:meta name="keyword">` element (matches Hancom convention).
89    pub keywords: Vec<String>,
90    /// Creation timestamp in ISO 8601 format (e.g. `"2026-02-07T10:30:00Z"`).
91    pub created: Option<String>,
92    /// Last modification timestamp in ISO 8601 format.
93    pub modified: Option<String>,
94    /// Carry slot for `<opf:meta>` keys not yet promoted to typed
95    /// fields. Preserves lossless round-trip when Hancom adds new
96    /// metadata names that HwpForge has not modeled yet.
97    pub extras: BTreeMap<String, String>,
98}
99
100impl Metadata {
101    /// Returns a fresh [`Metadata`] with all fields at their default
102    /// (`None` / empty). Equivalent to [`Metadata::default()`]; provided
103    /// as a chainable seed for builder-style construction so external
104    /// crates can populate the `#[non_exhaustive]` struct without
105    /// struct-literal syntax.
106    #[must_use]
107    pub fn new() -> Self {
108        Self::default()
109    }
110
111    /// Sets the document [`title`](Self::title).
112    #[must_use]
113    pub fn with_title(mut self, value: impl Into<String>) -> Self {
114        self.title = Some(value.into());
115        self
116    }
117
118    /// Sets the document [`author`](Self::author).
119    #[must_use]
120    pub fn with_author(mut self, value: impl Into<String>) -> Self {
121        self.author = Some(value.into());
122        self
123    }
124
125    /// Sets the document [`subject`](Self::subject).
126    #[must_use]
127    pub fn with_subject(mut self, value: impl Into<String>) -> Self {
128        self.subject = Some(value.into());
129        self
130    }
131
132    /// Sets the document [`description`](Self::description).
133    #[must_use]
134    pub fn with_description(mut self, value: impl Into<String>) -> Self {
135        self.description = Some(value.into());
136        self
137    }
138
139    /// Sets the document [`last_saved_by`](Self::last_saved_by).
140    #[must_use]
141    pub fn with_last_saved_by(mut self, value: impl Into<String>) -> Self {
142        self.last_saved_by = Some(value.into());
143        self
144    }
145
146    /// Replaces the document [`keywords`](Self::keywords) list.
147    #[must_use]
148    pub fn with_keywords<I, S>(mut self, values: I) -> Self
149    where
150        I: IntoIterator<Item = S>,
151        S: Into<String>,
152    {
153        self.keywords = values.into_iter().map(Into::into).collect();
154        self
155    }
156
157    /// Sets the [`created`](Self::created) timestamp (ISO 8601).
158    #[must_use]
159    pub fn with_created(mut self, value: impl Into<String>) -> Self {
160        self.created = Some(value.into());
161        self
162    }
163
164    /// Sets the [`modified`](Self::modified) timestamp (ISO 8601).
165    #[must_use]
166    pub fn with_modified(mut self, value: impl Into<String>) -> Self {
167        self.modified = Some(value.into());
168        self
169    }
170
171    /// Inserts a single `<opf:meta>` carry entry into
172    /// [`extras`](Self::extras). Chainable.
173    pub fn with_extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
174        self.extras.insert(key.into(), value.into());
175        self
176    }
177}
178
179impl std::fmt::Display for Metadata {
180    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
181        match &self.title {
182            Some(t) => write!(f, "Metadata(\"{}\")", t),
183            None => write!(f, "Metadata(untitled)"),
184        }
185    }
186}
187
188#[cfg(test)]
189mod tests {
190    use super::*;
191
192    #[test]
193    fn default_is_all_none_or_empty() {
194        let m = Metadata::default();
195        assert!(m.title.is_none());
196        assert!(m.author.is_none());
197        assert!(m.subject.is_none());
198        assert!(m.description.is_none());
199        assert!(m.last_saved_by.is_none());
200        assert!(m.keywords.is_empty());
201        assert!(m.created.is_none());
202        assert!(m.modified.is_none());
203        assert!(m.extras.is_empty());
204    }
205
206    #[test]
207    fn struct_literal_construction() {
208        let mut extras = BTreeMap::new();
209        extras.insert("category".to_string(), "Test".to_string());
210        let m = Metadata {
211            title: Some("Test".to_string()),
212            author: Some("Author".to_string()),
213            subject: Some("Subject".to_string()),
214            description: Some("Long form description".to_string()),
215            last_saved_by: Some("Editor".to_string()),
216            keywords: vec!["rust".to_string(), "hwp".to_string()],
217            created: Some("2026-02-07T00:00:00Z".to_string()),
218            modified: Some("2026-02-07T12:00:00Z".to_string()),
219            extras,
220        };
221        assert_eq!(m.title.as_deref(), Some("Test"));
222        assert_eq!(m.keywords.len(), 2);
223        assert_eq!(m.description.as_deref(), Some("Long form description"));
224        assert_eq!(m.last_saved_by.as_deref(), Some("Editor"));
225        assert_eq!(m.extras.get("category").map(String::as_str), Some("Test"));
226    }
227
228    #[test]
229    fn extras_btree_ordering_is_deterministic() {
230        // BTreeMap preserves key ordering — important for byte-stable
231        // encoder output and predictable round-trip diffs.
232        let mut m = Metadata::default();
233        m.extras.insert("zeta".to_string(), "z".to_string());
234        m.extras.insert("alpha".to_string(), "a".to_string());
235        m.extras.insert("mu".to_string(), "m".to_string());
236        let keys: Vec<&str> = m.extras.keys().map(String::as_str).collect();
237        assert_eq!(keys, vec!["alpha", "mu", "zeta"]);
238    }
239
240    #[test]
241    fn partial_construction_with_defaults() {
242        let m = Metadata { title: Some("Report".to_string()), ..Metadata::default() };
243        assert_eq!(m.title.as_deref(), Some("Report"));
244        assert!(m.author.is_none());
245    }
246
247    #[test]
248    fn display_with_title() {
249        let m = Metadata { title: Some("My Doc".to_string()), ..Metadata::default() };
250        assert_eq!(m.to_string(), "Metadata(\"My Doc\")");
251    }
252
253    #[test]
254    fn display_without_title() {
255        let m = Metadata::default();
256        assert_eq!(m.to_string(), "Metadata(untitled)");
257    }
258
259    #[test]
260    fn equality() {
261        let a = Metadata { title: Some("A".to_string()), ..Metadata::default() };
262        let b = Metadata { title: Some("A".to_string()), ..Metadata::default() };
263        let c = Metadata { title: Some("B".to_string()), ..Metadata::default() };
264        assert_eq!(a, b);
265        assert_ne!(a, c);
266    }
267
268    #[test]
269    fn clone_independence() {
270        let m = Metadata { title: Some("Original".to_string()), ..Metadata::default() };
271        let mut cloned = m.clone();
272        cloned.title = Some("Modified".to_string());
273        assert_eq!(m.title.as_deref(), Some("Original"));
274    }
275
276    #[test]
277    fn korean_text() {
278        let m = Metadata {
279            title: Some("분기 보고서".to_string()),
280            author: Some("김철수".to_string()),
281            keywords: vec!["한글".to_string(), "보고서".to_string()],
282            ..Metadata::default()
283        };
284        assert_eq!(m.title.as_deref(), Some("분기 보고서"));
285    }
286
287    #[test]
288    fn serde_roundtrip() {
289        let mut extras = BTreeMap::new();
290        extras.insert("category".to_string(), "draft".to_string());
291        let m = Metadata {
292            title: Some("Test".to_string()),
293            author: Some("Author".to_string()),
294            subject: None,
295            description: Some("body".to_string()),
296            last_saved_by: Some("Editor".to_string()),
297            keywords: vec!["a".to_string(), "b".to_string()],
298            created: Some("2026-02-07T00:00:00Z".to_string()),
299            modified: None,
300            extras,
301        };
302        let json = serde_json::to_string(&m).unwrap();
303        let back: Metadata = serde_json::from_str(&json).unwrap();
304        assert_eq!(m, back);
305    }
306
307    #[test]
308    fn serde_default_roundtrip() {
309        let m = Metadata::default();
310        let json = serde_json::to_string(&m).unwrap();
311        let back: Metadata = serde_json::from_str(&json).unwrap();
312        assert_eq!(m, back);
313    }
314
315    #[test]
316    fn empty_keywords_serializes_as_empty_array() {
317        let m = Metadata::default();
318        let json = serde_json::to_string(&m).unwrap();
319        assert!(json.contains("\"keywords\":[]"), "json: {json}");
320    }
321}