Skip to main content

sheetkit_xml/
doc_props.rs

1//! Document properties XML schema structures.
2//!
3//! Covers:
4//! - Core properties (`docProps/core.xml`) - Dublin Core metadata
5//! - Extended properties (`docProps/app.xml`) - application metadata
6//! - Custom properties (`docProps/custom.xml`) - user-defined key/value pairs
7
8use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
9use quick_xml::Reader;
10use quick_xml::Writer;
11use serde::{Deserialize, Serialize};
12
13use crate::namespaces;
14
15/// Core document properties (docProps/core.xml).
16///
17/// Uses Dublin Core namespaces (`dc:`, `dcterms:`, `cp:`).
18/// Because quick-xml serde does not handle namespace prefixes well,
19/// serialization and deserialization are done manually.
20#[derive(Debug, Clone, Default, PartialEq)]
21pub struct CoreProperties {
22    pub title: Option<String>,
23    pub subject: Option<String>,
24    pub creator: Option<String>,
25    pub keywords: Option<String>,
26    pub description: Option<String>,
27    pub last_modified_by: Option<String>,
28    pub revision: Option<String>,
29    pub created: Option<String>,
30    pub modified: Option<String>,
31    pub category: Option<String>,
32    pub content_status: Option<String>,
33}
34
35/// Serialize `CoreProperties` to its XML string representation.
36pub fn serialize_core_properties(props: &CoreProperties) -> String {
37    let mut writer = Writer::new(Vec::new());
38
39    // XML declaration
40    writer
41        .write_event(Event::Decl(BytesDecl::new(
42            "1.0",
43            Some("UTF-8"),
44            Some("yes"),
45        )))
46        .unwrap();
47
48    // Root element with namespaces
49    let mut root = BytesStart::new("cp:coreProperties");
50    root.push_attribute(("xmlns:cp", namespaces::CORE_PROPERTIES));
51    root.push_attribute(("xmlns:dc", namespaces::DC));
52    root.push_attribute(("xmlns:dcterms", namespaces::DC_TERMS));
53    root.push_attribute(("xmlns:dcmitype", DC_MITYPE));
54    root.push_attribute(("xmlns:xsi", namespaces::XSI));
55    writer.write_event(Event::Start(root)).unwrap();
56
57    // Helper: write a simple text element
58    fn write_element(writer: &mut Writer<Vec<u8>>, tag: &str, value: &str) {
59        writer
60            .write_event(Event::Start(BytesStart::new(tag)))
61            .unwrap();
62        writer
63            .write_event(Event::Text(BytesText::new(value)))
64            .unwrap();
65        writer.write_event(Event::End(BytesEnd::new(tag))).unwrap();
66    }
67
68    // Helper: write dcterms element with xsi:type attribute
69    fn write_dcterms_element(writer: &mut Writer<Vec<u8>>, tag: &str, value: &str) {
70        let mut start = BytesStart::new(tag);
71        start.push_attribute(("xsi:type", "dcterms:W3CDTF"));
72        writer.write_event(Event::Start(start)).unwrap();
73        writer
74            .write_event(Event::Text(BytesText::new(value)))
75            .unwrap();
76        writer.write_event(Event::End(BytesEnd::new(tag))).unwrap();
77    }
78
79    if let Some(ref v) = props.title {
80        write_element(&mut writer, "dc:title", v);
81    }
82    if let Some(ref v) = props.subject {
83        write_element(&mut writer, "dc:subject", v);
84    }
85    if let Some(ref v) = props.creator {
86        write_element(&mut writer, "dc:creator", v);
87    }
88    if let Some(ref v) = props.keywords {
89        write_element(&mut writer, "cp:keywords", v);
90    }
91    if let Some(ref v) = props.description {
92        write_element(&mut writer, "dc:description", v);
93    }
94    if let Some(ref v) = props.last_modified_by {
95        write_element(&mut writer, "cp:lastModifiedBy", v);
96    }
97    if let Some(ref v) = props.revision {
98        write_element(&mut writer, "cp:revision", v);
99    }
100    if let Some(ref v) = props.created {
101        write_dcterms_element(&mut writer, "dcterms:created", v);
102    }
103    if let Some(ref v) = props.modified {
104        write_dcterms_element(&mut writer, "dcterms:modified", v);
105    }
106    if let Some(ref v) = props.category {
107        write_element(&mut writer, "cp:category", v);
108    }
109    if let Some(ref v) = props.content_status {
110        write_element(&mut writer, "cp:contentStatus", v);
111    }
112
113    writer
114        .write_event(Event::End(BytesEnd::new("cp:coreProperties")))
115        .unwrap();
116
117    String::from_utf8(writer.into_inner()).unwrap()
118}
119
120/// Deserialize `CoreProperties` from an XML string.
121pub fn deserialize_core_properties(xml: &str) -> Result<CoreProperties, String> {
122    let mut reader = Reader::from_str(xml);
123    reader.config_mut().trim_text(true);
124
125    let mut props = CoreProperties::default();
126    let mut current_tag: Option<String> = None;
127
128    loop {
129        match reader.read_event() {
130            Ok(Event::Start(ref e)) => {
131                let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
132                current_tag = Some(name);
133            }
134            Ok(Event::Text(ref e)) => {
135                if let Some(ref tag) = current_tag {
136                    let text = e.unescape().unwrap_or_default().to_string();
137                    match tag.as_str() {
138                        "dc:title" | "title" => props.title = Some(text),
139                        "dc:subject" | "subject" => props.subject = Some(text),
140                        "dc:creator" | "creator" => props.creator = Some(text),
141                        "cp:keywords" | "keywords" => props.keywords = Some(text),
142                        "dc:description" | "description" => props.description = Some(text),
143                        "cp:lastModifiedBy" | "lastModifiedBy" => {
144                            props.last_modified_by = Some(text);
145                        }
146                        "cp:revision" | "revision" => props.revision = Some(text),
147                        "dcterms:created" | "created" => props.created = Some(text),
148                        "dcterms:modified" | "modified" => props.modified = Some(text),
149                        "cp:category" | "category" => props.category = Some(text),
150                        "cp:contentStatus" | "contentStatus" => {
151                            props.content_status = Some(text);
152                        }
153                        _ => {}
154                    }
155                }
156            }
157            Ok(Event::End(_)) => {
158                current_tag = None;
159            }
160            Ok(Event::Eof) => break,
161            Err(e) => return Err(format!("XML parse error: {e}")),
162            _ => {}
163        }
164    }
165
166    Ok(props)
167}
168
169// DCMI Type namespace (not in namespaces.rs because it's only used here)
170const DC_MITYPE: &str = "http://purl.org/dc/dcmitype/";
171
172/// Extended (application) properties (`docProps/app.xml`).
173#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
174#[serde(rename = "Properties")]
175pub struct ExtendedProperties {
176    #[serde(rename = "@xmlns")]
177    pub xmlns: String,
178    #[serde(rename = "@xmlns:vt", skip_serializing_if = "Option::is_none")]
179    pub xmlns_vt: Option<String>,
180
181    #[serde(rename = "Application", skip_serializing_if = "Option::is_none")]
182    pub application: Option<String>,
183    #[serde(rename = "DocSecurity", skip_serializing_if = "Option::is_none")]
184    pub doc_security: Option<u32>,
185    #[serde(rename = "ScaleCrop", skip_serializing_if = "Option::is_none")]
186    pub scale_crop: Option<bool>,
187    #[serde(rename = "Company", skip_serializing_if = "Option::is_none")]
188    pub company: Option<String>,
189    #[serde(rename = "LinksUpToDate", skip_serializing_if = "Option::is_none")]
190    pub links_up_to_date: Option<bool>,
191    #[serde(rename = "SharedDoc", skip_serializing_if = "Option::is_none")]
192    pub shared_doc: Option<bool>,
193    #[serde(rename = "HyperlinksChanged", skip_serializing_if = "Option::is_none")]
194    pub hyperlinks_changed: Option<bool>,
195    #[serde(rename = "AppVersion", skip_serializing_if = "Option::is_none")]
196    pub app_version: Option<String>,
197    #[serde(rename = "Template", skip_serializing_if = "Option::is_none")]
198    pub template: Option<String>,
199    #[serde(rename = "Manager", skip_serializing_if = "Option::is_none")]
200    pub manager: Option<String>,
201}
202
203impl ExtendedProperties {
204    /// Create a new `ExtendedProperties` with the standard namespace set.
205    pub fn with_defaults() -> Self {
206        Self {
207            xmlns: namespaces::EXTENDED_PROPERTIES.to_string(),
208            xmlns_vt: Some(namespaces::VT.to_string()),
209            ..Default::default()
210        }
211    }
212}
213
214/// Custom properties collection (`docProps/custom.xml`).
215///
216/// Because the child value elements use a `vt:` namespace prefix that
217/// quick-xml serde cannot handle, serialization/deserialization is manual.
218#[derive(Debug, Clone, Default, PartialEq)]
219pub struct CustomProperties {
220    pub properties: Vec<CustomProperty>,
221}
222
223/// A single custom property entry.
224#[derive(Debug, Clone, PartialEq)]
225pub struct CustomProperty {
226    pub fmtid: String,
227    pub pid: u32,
228    pub name: String,
229    pub value: CustomPropertyValue,
230}
231
232/// The typed value of a custom property.
233#[derive(Debug, Clone, PartialEq)]
234pub enum CustomPropertyValue {
235    String(String),
236    Int(i32),
237    Float(f64),
238    Bool(bool),
239    DateTime(String),
240}
241
242/// Standard fmtid used for custom properties.
243pub const CUSTOM_PROPERTY_FMTID: &str = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
244
245/// Serialize `CustomProperties` to its XML string representation.
246pub fn serialize_custom_properties(props: &CustomProperties) -> String {
247    let mut writer = Writer::new(Vec::new());
248
249    // XML declaration
250    writer
251        .write_event(Event::Decl(BytesDecl::new(
252            "1.0",
253            Some("UTF-8"),
254            Some("yes"),
255        )))
256        .unwrap();
257
258    let mut root = BytesStart::new("Properties");
259    root.push_attribute(("xmlns", namespaces::CUSTOM_PROPERTIES));
260    root.push_attribute(("xmlns:vt", namespaces::VT));
261    writer.write_event(Event::Start(root)).unwrap();
262
263    for prop in &props.properties {
264        let mut elem = BytesStart::new("property");
265        elem.push_attribute(("fmtid", prop.fmtid.as_str()));
266        elem.push_attribute(("pid", prop.pid.to_string().as_str()));
267        elem.push_attribute(("name", prop.name.as_str()));
268        writer.write_event(Event::Start(elem)).unwrap();
269
270        match &prop.value {
271            CustomPropertyValue::String(s) => {
272                writer
273                    .write_event(Event::Start(BytesStart::new("vt:lpwstr")))
274                    .unwrap();
275                writer.write_event(Event::Text(BytesText::new(s))).unwrap();
276                writer
277                    .write_event(Event::End(BytesEnd::new("vt:lpwstr")))
278                    .unwrap();
279            }
280            CustomPropertyValue::Int(n) => {
281                writer
282                    .write_event(Event::Start(BytesStart::new("vt:i4")))
283                    .unwrap();
284                writer
285                    .write_event(Event::Text(BytesText::new(&n.to_string())))
286                    .unwrap();
287                writer
288                    .write_event(Event::End(BytesEnd::new("vt:i4")))
289                    .unwrap();
290            }
291            CustomPropertyValue::Float(f) => {
292                writer
293                    .write_event(Event::Start(BytesStart::new("vt:r8")))
294                    .unwrap();
295                writer
296                    .write_event(Event::Text(BytesText::new(&f.to_string())))
297                    .unwrap();
298                writer
299                    .write_event(Event::End(BytesEnd::new("vt:r8")))
300                    .unwrap();
301            }
302            CustomPropertyValue::Bool(b) => {
303                writer
304                    .write_event(Event::Start(BytesStart::new("vt:bool")))
305                    .unwrap();
306                writer
307                    .write_event(Event::Text(BytesText::new(if *b {
308                        "true"
309                    } else {
310                        "false"
311                    })))
312                    .unwrap();
313                writer
314                    .write_event(Event::End(BytesEnd::new("vt:bool")))
315                    .unwrap();
316            }
317            CustomPropertyValue::DateTime(dt) => {
318                writer
319                    .write_event(Event::Start(BytesStart::new("vt:filetime")))
320                    .unwrap();
321                writer.write_event(Event::Text(BytesText::new(dt))).unwrap();
322                writer
323                    .write_event(Event::End(BytesEnd::new("vt:filetime")))
324                    .unwrap();
325            }
326        }
327
328        writer
329            .write_event(Event::End(BytesEnd::new("property")))
330            .unwrap();
331    }
332
333    writer
334        .write_event(Event::End(BytesEnd::new("Properties")))
335        .unwrap();
336
337    String::from_utf8(writer.into_inner()).unwrap()
338}
339
340/// Deserialize `CustomProperties` from an XML string.
341pub fn deserialize_custom_properties(xml: &str) -> Result<CustomProperties, String> {
342    let mut reader = Reader::from_str(xml);
343    reader.config_mut().trim_text(true);
344
345    let mut props = CustomProperties::default();
346
347    // State for the current property being parsed
348    let mut current_fmtid: Option<String> = None;
349    let mut current_pid: Option<u32> = None;
350    let mut current_name: Option<String> = None;
351    let mut current_value_tag: Option<String> = None;
352
353    loop {
354        match reader.read_event() {
355            Ok(Event::Start(ref e)) => {
356                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
357                if tag == "property" {
358                    // Extract attributes
359                    for attr in e.attributes().flatten() {
360                        let key = String::from_utf8_lossy(attr.key.as_ref()).to_string();
361                        let val = String::from_utf8_lossy(&attr.value).to_string();
362                        match key.as_str() {
363                            "fmtid" => current_fmtid = Some(val),
364                            "pid" => current_pid = val.parse().ok(),
365                            "name" => current_name = Some(val),
366                            _ => {}
367                        }
368                    }
369                } else if tag.starts_with("vt:")
370                    || matches!(tag.as_str(), "lpwstr" | "i4" | "r8" | "bool" | "filetime")
371                {
372                    current_value_tag = Some(tag);
373                }
374            }
375            Ok(Event::Text(ref e)) => {
376                if let Some(ref vtag) = current_value_tag {
377                    let text = e.unescape().unwrap_or_default().to_string();
378                    let value = match vtag.as_str() {
379                        "vt:lpwstr" | "lpwstr" => Some(CustomPropertyValue::String(text)),
380                        "vt:i4" | "i4" => text.parse::<i32>().ok().map(CustomPropertyValue::Int),
381                        "vt:r8" | "r8" => text.parse::<f64>().ok().map(CustomPropertyValue::Float),
382                        "vt:bool" | "bool" => {
383                            Some(CustomPropertyValue::Bool(text == "true" || text == "1"))
384                        }
385                        "vt:filetime" | "filetime" => Some(CustomPropertyValue::DateTime(text)),
386                        _ => None,
387                    };
388                    if let (Some(fmtid), Some(pid), Some(name), Some(val)) = (
389                        current_fmtid.take(),
390                        current_pid.take(),
391                        current_name.take(),
392                        value,
393                    ) {
394                        props.properties.push(CustomProperty {
395                            fmtid,
396                            pid,
397                            name,
398                            value: val,
399                        });
400                    }
401                }
402            }
403            Ok(Event::End(ref e)) => {
404                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
405                if tag.starts_with("vt:")
406                    || matches!(tag.as_str(), "lpwstr" | "i4" | "r8" | "bool" | "filetime")
407                {
408                    current_value_tag = None;
409                }
410            }
411            Ok(Event::Eof) => break,
412            Err(e) => return Err(format!("XML parse error: {e}")),
413            _ => {}
414        }
415    }
416
417    Ok(props)
418}
419
420#[cfg(test)]
421mod tests {
422    use super::*;
423
424    #[test]
425    fn test_core_properties_roundtrip() {
426        let props = CoreProperties {
427            title: Some("Test Title".to_string()),
428            subject: Some("Test Subject".to_string()),
429            creator: Some("Test Author".to_string()),
430            keywords: Some("key1, key2".to_string()),
431            description: Some("A description".to_string()),
432            last_modified_by: Some("Editor".to_string()),
433            revision: Some("3".to_string()),
434            created: Some("2024-01-01T00:00:00Z".to_string()),
435            modified: Some("2024-06-15T12:30:00Z".to_string()),
436            category: Some("Reports".to_string()),
437            content_status: Some("Draft".to_string()),
438        };
439
440        let xml = serialize_core_properties(&props);
441        let parsed = deserialize_core_properties(&xml).unwrap();
442        assert_eq!(props, parsed);
443    }
444
445    #[test]
446    fn test_core_properties_empty_fields() {
447        let props = CoreProperties::default();
448        let xml = serialize_core_properties(&props);
449        let parsed = deserialize_core_properties(&xml).unwrap();
450        assert_eq!(props, parsed);
451    }
452
453    #[test]
454    fn test_core_properties_partial_fields() {
455        let props = CoreProperties {
456            title: Some("Only Title".to_string()),
457            creator: Some("Only Author".to_string()),
458            ..Default::default()
459        };
460
461        let xml = serialize_core_properties(&props);
462        let parsed = deserialize_core_properties(&xml).unwrap();
463        assert_eq!(props, parsed);
464    }
465
466    #[test]
467    fn test_core_properties_serialized_format() {
468        let props = CoreProperties {
469            title: Some("My Title".to_string()),
470            creator: Some("Author Name".to_string()),
471            created: Some("2024-01-01T00:00:00Z".to_string()),
472            ..Default::default()
473        };
474
475        let xml = serialize_core_properties(&props);
476        assert!(xml.contains("<cp:coreProperties"));
477        assert!(xml.contains("xmlns:cp="));
478        assert!(xml.contains("xmlns:dc="));
479        assert!(xml.contains("xmlns:dcterms="));
480        assert!(xml.contains("<dc:title>My Title</dc:title>"));
481        assert!(xml.contains("<dc:creator>Author Name</dc:creator>"));
482        assert!(xml.contains("xsi:type=\"dcterms:W3CDTF\""));
483        assert!(xml.contains("<dcterms:created"));
484        assert!(xml.contains("2024-01-01T00:00:00Z</dcterms:created>"));
485    }
486
487    #[test]
488    fn test_parse_real_excel_core_xml() {
489        let xml = r#"<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
490<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
491  <dc:title>Budget Report</dc:title>
492  <dc:subject>Finance</dc:subject>
493  <dc:creator>John Doe</dc:creator>
494  <cp:keywords>budget, 2024</cp:keywords>
495  <dc:description>Annual budget report</dc:description>
496  <cp:lastModifiedBy>Jane Smith</cp:lastModifiedBy>
497  <cp:revision>5</cp:revision>
498  <dcterms:created xsi:type="dcterms:W3CDTF">2024-01-15T08:00:00Z</dcterms:created>
499  <dcterms:modified xsi:type="dcterms:W3CDTF">2024-06-20T16:45:00Z</dcterms:modified>
500  <cp:category>Financial</cp:category>
501  <cp:contentStatus>Final</cp:contentStatus>
502</cp:coreProperties>"#;
503
504        let props = deserialize_core_properties(xml).unwrap();
505        assert_eq!(props.title.as_deref(), Some("Budget Report"));
506        assert_eq!(props.subject.as_deref(), Some("Finance"));
507        assert_eq!(props.creator.as_deref(), Some("John Doe"));
508        assert_eq!(props.keywords.as_deref(), Some("budget, 2024"));
509        assert_eq!(props.description.as_deref(), Some("Annual budget report"));
510        assert_eq!(props.last_modified_by.as_deref(), Some("Jane Smith"));
511        assert_eq!(props.revision.as_deref(), Some("5"));
512        assert_eq!(props.created.as_deref(), Some("2024-01-15T08:00:00Z"));
513        assert_eq!(props.modified.as_deref(), Some("2024-06-20T16:45:00Z"));
514        assert_eq!(props.category.as_deref(), Some("Financial"));
515        assert_eq!(props.content_status.as_deref(), Some("Final"));
516    }
517
518    #[test]
519    fn test_extended_properties_serde_roundtrip() {
520        let props = ExtendedProperties {
521            xmlns: namespaces::EXTENDED_PROPERTIES.to_string(),
522            xmlns_vt: Some(namespaces::VT.to_string()),
523            application: Some("SheetKit".to_string()),
524            doc_security: Some(0),
525            scale_crop: Some(false),
526            company: Some("Acme Corp".to_string()),
527            links_up_to_date: Some(false),
528            shared_doc: Some(false),
529            hyperlinks_changed: Some(false),
530            app_version: Some("1.0.0".to_string()),
531            template: None,
532            manager: Some("Boss".to_string()),
533        };
534
535        let xml = quick_xml::se::to_string(&props).unwrap();
536        let parsed: ExtendedProperties = quick_xml::de::from_str(&xml).unwrap();
537        assert_eq!(props, parsed);
538    }
539
540    #[test]
541    fn test_extended_properties_with_defaults() {
542        let props = ExtendedProperties::with_defaults();
543        assert_eq!(props.xmlns, namespaces::EXTENDED_PROPERTIES);
544        assert_eq!(props.xmlns_vt.as_deref(), Some(namespaces::VT));
545        assert!(props.application.is_none());
546    }
547
548    #[test]
549    fn test_extended_properties_skip_none_fields() {
550        let props = ExtendedProperties {
551            xmlns: namespaces::EXTENDED_PROPERTIES.to_string(),
552            xmlns_vt: None,
553            application: Some("Test".to_string()),
554            doc_security: None,
555            scale_crop: None,
556            company: None,
557            links_up_to_date: None,
558            shared_doc: None,
559            hyperlinks_changed: None,
560            app_version: None,
561            template: None,
562            manager: None,
563        };
564
565        let xml = quick_xml::se::to_string(&props).unwrap();
566        assert!(xml.contains("<Application>Test</Application>"));
567        assert!(!xml.contains("DocSecurity"));
568        assert!(!xml.contains("Company"));
569    }
570
571    #[test]
572    fn test_custom_properties_roundtrip() {
573        let props = CustomProperties {
574            properties: vec![
575                CustomProperty {
576                    fmtid: CUSTOM_PROPERTY_FMTID.to_string(),
577                    pid: 2,
578                    name: "Project".to_string(),
579                    value: CustomPropertyValue::String("SheetKit".to_string()),
580                },
581                CustomProperty {
582                    fmtid: CUSTOM_PROPERTY_FMTID.to_string(),
583                    pid: 3,
584                    name: "Version".to_string(),
585                    value: CustomPropertyValue::Int(42),
586                },
587            ],
588        };
589
590        let xml = serialize_custom_properties(&props);
591        let parsed = deserialize_custom_properties(&xml).unwrap();
592        assert_eq!(props, parsed);
593    }
594
595    #[test]
596    fn test_custom_properties_all_value_types() {
597        let props = CustomProperties {
598            properties: vec![
599                CustomProperty {
600                    fmtid: CUSTOM_PROPERTY_FMTID.to_string(),
601                    pid: 2,
602                    name: "StringProp".to_string(),
603                    value: CustomPropertyValue::String("hello".to_string()),
604                },
605                CustomProperty {
606                    fmtid: CUSTOM_PROPERTY_FMTID.to_string(),
607                    pid: 3,
608                    name: "IntProp".to_string(),
609                    value: CustomPropertyValue::Int(-7),
610                },
611                CustomProperty {
612                    fmtid: CUSTOM_PROPERTY_FMTID.to_string(),
613                    pid: 4,
614                    name: "FloatProp".to_string(),
615                    value: CustomPropertyValue::Float(3.15),
616                },
617                CustomProperty {
618                    fmtid: CUSTOM_PROPERTY_FMTID.to_string(),
619                    pid: 5,
620                    name: "BoolProp".to_string(),
621                    value: CustomPropertyValue::Bool(true),
622                },
623                CustomProperty {
624                    fmtid: CUSTOM_PROPERTY_FMTID.to_string(),
625                    pid: 6,
626                    name: "DateProp".to_string(),
627                    value: CustomPropertyValue::DateTime("2024-01-01T00:00:00Z".to_string()),
628                },
629            ],
630        };
631
632        let xml = serialize_custom_properties(&props);
633        let parsed = deserialize_custom_properties(&xml).unwrap();
634        assert_eq!(props.properties.len(), parsed.properties.len());
635        for (orig, p) in props.properties.iter().zip(parsed.properties.iter()) {
636            assert_eq!(orig.name, p.name);
637            assert_eq!(orig.value, p.value);
638        }
639    }
640
641    #[test]
642    fn test_custom_properties_empty() {
643        let props = CustomProperties::default();
644        let xml = serialize_custom_properties(&props);
645        let parsed = deserialize_custom_properties(&xml).unwrap();
646        assert!(parsed.properties.is_empty());
647    }
648
649    #[test]
650    fn test_custom_properties_serialized_format() {
651        let props = CustomProperties {
652            properties: vec![CustomProperty {
653                fmtid: CUSTOM_PROPERTY_FMTID.to_string(),
654                pid: 2,
655                name: "MyProp".to_string(),
656                value: CustomPropertyValue::String("MyValue".to_string()),
657            }],
658        };
659
660        let xml = serialize_custom_properties(&props);
661        assert!(xml.contains("<Properties"));
662        assert!(xml.contains("xmlns:vt="));
663        assert!(xml.contains("<property"));
664        assert!(xml.contains("fmtid="));
665        assert!(xml.contains("pid=\"2\""));
666        assert!(xml.contains("name=\"MyProp\""));
667        assert!(xml.contains("<vt:lpwstr>MyValue</vt:lpwstr>"));
668    }
669
670    #[test]
671    fn test_custom_properties_bool_false() {
672        let props = CustomProperties {
673            properties: vec![CustomProperty {
674                fmtid: CUSTOM_PROPERTY_FMTID.to_string(),
675                pid: 2,
676                name: "Flag".to_string(),
677                value: CustomPropertyValue::Bool(false),
678            }],
679        };
680
681        let xml = serialize_custom_properties(&props);
682        let parsed = deserialize_custom_properties(&xml).unwrap();
683        assert_eq!(parsed.properties[0].value, CustomPropertyValue::Bool(false));
684    }
685}