1use std::collections::BTreeMap;
26
27use schemars::JsonSchema;
28use serde::{Deserialize, Serialize};
29
30#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
65#[non_exhaustive]
66pub struct Metadata {
67 pub title: Option<String>,
69 pub author: Option<String>,
71 pub subject: Option<String>,
73 pub description: Option<String>,
80 pub last_saved_by: Option<String>,
87 pub keywords: Vec<String>,
92 pub created: Option<String>,
94 pub modified: Option<String>,
96 pub extras: BTreeMap<String, String>,
100}
101
102impl Metadata {
103 #[must_use]
109 pub fn new() -> Self {
110 Self::default()
111 }
112
113 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 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}