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