webdav-serde 0.1.2

webdav-serde
Documentation
use serde::{Deserialize, Serialize};

#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename = "multistatus")]
pub struct Multistatus {
    #[serde(rename = "response", default)]
    pub response: Vec<Response>,
}

impl Multistatus {
    /// Parse WebDAV multistatus XML from a string
    pub fn from_xml(xml: &str) -> Result<Self, quick_xml::DeError> {
        quick_xml::de::from_str(xml)
    }

    /// Serialize to XML string
    pub fn to_xml(&self) -> Result<String, quick_xml::SeError> {
        quick_xml::se::to_string(self)
    }
}

#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename = "response")]
pub struct Response {
    pub href: String,
    pub propstat: PropStat,
}

impl Response {
    /// Check if this response represents a collection (directory)
    pub fn is_collection(&self) -> bool {
        self.propstat
            .prop
            .resourcetype
            .as_ref()
            .and_then(|rt| rt.collection.as_ref())
            .is_some()
    }

    /// Get the file/directory name from href
    pub fn name(&self) -> &str {
        self.href
            .trim()
            .trim_end_matches('/')
            .rsplit('/')
            .next()
            .unwrap_or(&self.href)
    }
}

#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename = "propstat")]
pub struct PropStat {
    pub prop: Prop,
    pub status: String,
}

#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename = "prop")]
pub struct Prop {
    #[serde(rename = "displayname", default, skip_serializing_if = "Option::is_none")]
    pub displayname: Option<String>,
    
    #[serde(rename = "creationdate", default, skip_serializing_if = "Option::is_none")]
    pub creationdate: Option<String>,
    
    #[serde(rename = "getcontentlength", default, skip_serializing_if = "Option::is_none")]
    pub getcontentlength: Option<u64>,
    
    #[serde(rename = "getcontenttype", default, skip_serializing_if = "Option::is_none")]
    pub getcontenttype: Option<String>,
    
    #[serde(rename = "getetag", default, skip_serializing_if = "Option::is_none")]
    pub getetag: Option<String>,
    
    #[serde(rename = "getlastmodified", default, skip_serializing_if = "Option::is_none")]
    pub getlastmodified: Option<String>,
    
    #[serde(rename = "lockdiscovery", default, skip_serializing_if = "Option::is_none")]
    pub lockdiscovery: Option<String>,
    
    #[serde(rename = "resourcetype", default, skip_serializing_if = "Option::is_none")]
    pub resourcetype: Option<ResourceType>,
    
    #[serde(rename = "supportedlock", default, skip_serializing_if = "Option::is_none")]
    pub supportedlock: Option<String>,
}

#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename = "resourcetype")]
pub struct ResourceType {
    #[serde(rename = "collection", default, skip_serializing_if = "Option::is_none")]
    pub collection: Option<Collection>,
}

#[derive(Debug, Deserialize, Serialize, PartialEq)]
#[serde(rename = "collection")]
pub struct Collection {}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use std::path::Path;

    fn test_roundtrip(xml_content: &str, test_name: &str) {
        // First deserialization: XML -> Multistatus
        let multistatus1 = Multistatus::from_xml(xml_content)
            .unwrap_or_else(|e| panic!("{}: Failed to parse XML: {}", test_name, e));

        // Serialization: Multistatus -> XML
        let xml_output = multistatus1
            .to_xml()
            .unwrap_or_else(|e| panic!("{}: Failed to serialize to XML: {}", test_name, e));

        // Second deserialization: XML -> Multistatus
        let multistatus2 = Multistatus::from_xml(&xml_output)
            .unwrap_or_else(|e| panic!("{}: Failed to parse serialized XML: {}", test_name, e));

        // Compare the two Multistatus objects
        assert_eq!(
            multistatus1, multistatus2,
            "{}: Roundtrip conversion failed - objects are not equal",
            test_name
        );
    }

    #[test]
    fn test_roundtrip_a_xml() {
        let xml_path = Path::new("xml/a.xml");
        if xml_path.exists() {
            let xml_content = fs::read_to_string(xml_path)
                .expect("Failed to read xml/a.xml");
            test_roundtrip(&xml_content, "a.xml");
        } else {
            println!("Skipping test_roundtrip_a_xml: file not found");
        }
    }

    #[test]
    fn test_roundtrip_dufs_xml() {
        let xml_path = Path::new("xml/dufs.xml");
        if xml_path.exists() {
            let xml_content = fs::read_to_string(xml_path)
                .expect("Failed to read xml/dufs.xml");
            test_roundtrip(&xml_content, "dufs.xml");
        } else {
            println!("Skipping test_roundtrip_dufs_xml: file not found");
        }
    }

    #[test]
    fn test_roundtrip_list_xml() {
        let xml_path = Path::new("xml/list.xml");
        if xml_path.exists() {
            let xml_content = fs::read_to_string(xml_path)
                .expect("Failed to read xml/list.xml");
            test_roundtrip(&xml_content, "list.xml");
        } else {
            println!("Skipping test_roundtrip_list_xml: file not found");
        }
    }

    #[test]
    fn test_simple_roundtrip() {
        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<multistatus>
  <response>
    <href>/test.txt</href>
    <propstat>
      <prop>
        <displayname>test.txt</displayname>
        <getcontentlength>1024</getcontentlength>
        <getlastmodified>Mon, 13 Nov 2023 14:52:27 GMT</getlastmodified>
        <resourcetype></resourcetype>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        test_roundtrip(xml, "simple");
    }

    #[test]
    fn test_collection_roundtrip() {
        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<multistatus>
  <response>
    <href>/folder/</href>
    <propstat>
      <prop>
        <displayname>folder</displayname>
        <getlastmodified>Mon, 13 Nov 2023 14:52:27 GMT</getlastmodified>
        <resourcetype>
          <collection></collection>
        </resourcetype>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        test_roundtrip(xml, "collection");
    }

    #[test]
    fn test_is_collection() {
        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<multistatus>
  <response>
    <href>/folder/</href>
    <propstat>
      <prop>
        <getlastmodified>Mon, 13 Nov 2023 14:52:27 GMT</getlastmodified>
        <resourcetype>
          <collection></collection>
        </resourcetype>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
  <response>
    <href>/file.txt</href>
    <propstat>
      <prop>
        <getlastmodified>Mon, 13 Nov 2023 14:52:27 GMT</getlastmodified>
        <resourcetype></resourcetype>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        let multistatus = Multistatus::from_xml(xml).unwrap();
        assert_eq!(multistatus.response.len(), 2);
        assert!(multistatus.response[0].is_collection());
        assert!(!multistatus.response[1].is_collection());
    }

    #[test]
    fn test_name_extraction() {
        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
<multistatus>
  <response>
    <href>/path/to/file.txt</href>
    <propstat>
      <prop>
        <getlastmodified>Mon, 13 Nov 2023 14:52:27 GMT</getlastmodified>
        <resourcetype></resourcetype>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
  <response>
    <href>/folder/</href>
    <propstat>
      <prop>
        <getlastmodified>Mon, 13 Nov 2023 14:52:27 GMT</getlastmodified>
        <resourcetype></resourcetype>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        let multistatus = Multistatus::from_xml(xml).unwrap();
        assert_eq!(multistatus.response[0].name(), "file.txt");
        assert_eq!(multistatus.response[1].name(), "folder");
    }
}