libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! List resources in a collection.

use http::Method;

use crate::{
    Depth,
    dav::{ListedResource, WebDavError, extract_listed_resources},
    names,
    requests::{DavRequest, ParseResponseError, PreparedRequest, xml_content_type_header},
    xmlutils::XmlNode,
};

/// Request to list resources in a collection.
///
/// Sends a PROPFIND request with Depth:1 to retrieve metadata about resources
/// in a collection. Returns metadata only (href, etag, content-type, resource-type),
/// not the actual resource content.
///
/// For fetching actual resource data, use [`crate::caldav::GetCalendarResources`] or
/// [`crate::carddav::GetAddressBookResources`] instead.
///
/// # See also
///
/// - <https://www.rfc-editor.org/rfc/rfc4918#section-9.1>
/// - [`crate::caldav::ListCalendarResources`]
#[derive(Debug, Clone)]
pub struct ListResources<'a> {
    collection_href: &'a str,
}

impl<'a> ListResources<'a> {
    /// Create a new request to list resources in the given collection.
    ///
    /// `collection_href` should be a path relative to the server's base URL, typically
    /// ending with a trailing slash.
    #[must_use]
    pub fn new(collection_href: &'a str) -> Self {
        Self { collection_href }
    }
}

/// Response from a [`ListResources`] request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListResourcesResponse {
    /// Resources in the collection.
    pub resources: Vec<ListedResource>,
}

impl DavRequest for ListResources<'_> {
    type Response = ListResourcesResponse;
    type ParseError = ParseResponseError;
    type Error<E> = WebDavError<E>;

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        let mut prop = XmlNode::new(&names::PROP);
        prop.children = vec![
            XmlNode::new(&names::RESOURCETYPE),
            XmlNode::new(&names::GETCONTENTTYPE),
            XmlNode::new(&names::GETETAG),
        ];

        let mut propfind = XmlNode::new(&names::PROPFIND);
        propfind.children = vec![prop];

        Ok(PreparedRequest {
            method: Method::from_bytes(b"PROPFIND")?,
            path: self.collection_href.to_string(),
            body: propfind.render_node(),
            headers: vec![
                ("Depth".to_string(), Depth::One.to_string()),
                xml_content_type_header(),
            ],
        })
    }

    fn parse_response(
        &self,
        parts: &http::response::Parts,
        body: &[u8],
    ) -> Result<Self::Response, ParseResponseError> {
        if !parts.status.is_success() {
            return Err(ParseResponseError::BadStatusCode(parts.status));
        }

        let resources = extract_listed_resources(body, self.collection_href)?;
        Ok(ListResourcesResponse { resources })
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use http::StatusCode;

    #[test]
    fn test_prepare_request() {
        let req = ListResources::new("/calendars/personal/");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::from_bytes(b"PROPFIND").unwrap());
        assert_eq!(prepared.path, "/calendars/personal/");
        assert!(prepared.body.contains("resourcetype"));
        assert!(prepared.body.contains("getcontenttype"));
        assert!(prepared.body.contains("getetag"));
        assert!(prepared.body.contains(r#"xmlns:D="DAV:""#));
        assert!(
            prepared
                .headers
                .contains(&("Depth".to_string(), "1".to_string()))
        );
    }

    #[test]
    fn test_parse_response_success() {
        let req = ListResources::new("/calendars/personal/");
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/personal/</href>
    <propstat>
      <prop>
        <resourcetype><collection/></resourcetype>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
  <response>
    <href>/calendars/personal/event1.ics</href>
    <propstat>
      <prop>
        <getetag>"abc123"</getetag>
        <getcontenttype>text/calendar</getcontenttype>
        <resourcetype/>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
  <response>
    <href>/calendars/personal/event2.ics</href>
    <propstat>
      <prop>
        <getetag>"def456"</getetag>
        <getcontenttype>text/calendar</getcontenttype>
        <resourcetype/>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        let response = http::Response::builder()
            .status(StatusCode::MULTI_STATUS)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, body).unwrap();

        // Should not include the collection itself
        assert_eq!(result.resources.len(), 2);
        assert_eq!(result.resources[0].href, "/calendars/personal/event1.ics");
        assert_eq!(result.resources[0].etag, Some("\"abc123\"".to_string()));
        assert_eq!(
            result.resources[0].content_type,
            Some("text/calendar".to_string())
        );
        assert_eq!(result.resources[1].href, "/calendars/personal/event2.ics");
        assert_eq!(result.resources[1].etag, Some("\"def456\"".to_string()));
    }

    #[test]
    fn test_parse_response_empty_collection() {
        let req = ListResources::new("/calendars/empty/");
        let body = br#"<?xml version="1.0" encoding="utf-8"?>
<multistatus xmlns="DAV:">
  <response>
    <href>/calendars/empty/</href>
    <propstat>
      <prop>
        <resourcetype><collection/></resourcetype>
      </prop>
      <status>HTTP/1.1 200 OK</status>
    </propstat>
  </response>
</multistatus>"#;

        let response = http::Response::builder()
            .status(StatusCode::MULTI_STATUS)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, body).unwrap();

        assert!(result.resources.is_empty());
    }

    #[test]
    fn test_parse_response_bad_status() {
        let req = ListResources::new("/calendars/personal/");
        let response = http::Response::builder()
            .status(StatusCode::FORBIDDEN)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"");

        assert!(matches!(
            result,
            Err(ParseResponseError::BadStatusCode(StatusCode::FORBIDDEN))
        ));
    }

    #[test]
    fn test_parse_response_not_found() {
        let req = ListResources::new("/calendars/nonexistent/");
        let response = http::Response::builder()
            .status(StatusCode::NOT_FOUND)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"");

        assert!(matches!(
            result,
            Err(ParseResponseError::BadStatusCode(StatusCode::NOT_FOUND))
        ));
    }
}