libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! Find collections of a specific type.

use crate::{
    Depth, PropertyName,
    dav::{FoundCollection, Propfind, WebDavError},
    names,
    requests::{DavRequest, ParseResponseError, PreparedRequest},
    xmlutils::{get_normalised_href, validate_xml_response},
};

/// Request to find collections under a given path.
///
/// Sends a PROPFIND request with depth 1 to discover collections (calendars,
/// address books, or any WebDAV collection) and their properties.
///
/// By default, finds all collections. Use [`with_collection_type`](Self::with_collection_type)
/// to filter by a specific type.
///
/// # Example - Finding All Collections
///
/// ```
/// # use libdav::dav::FindCollections;
/// # use libdav::dav::WebDavClient;
/// # use tower_service::Service;
/// # use http::Uri;
/// # async fn example<C>(client: &WebDavClient<C>) -> Result<(), Box<dyn std::error::Error>>
/// # where
/// #     C: Service<http::Request<String>, Response = http::Response<hyper::body::Incoming>> + Send + Sync,
/// #     C::Error: std::error::Error + Send + Sync,
/// # {
/// let home_set = Uri::from_static("/dav/user/");
/// let response = client.request(
///     FindCollections::new(&home_set)
/// ).await?;
///
/// for collection in response.collections {
///     println!("Found collection: {}", collection.href);
///     println!("  Supports sync: {}", collection.supports_sync);
/// }
/// # Ok(())
/// # }
/// ```
pub struct FindCollections<'a> {
    propfind: Propfind<'a>,
    collection_type: Option<&'a PropertyName<'a, 'a>>,
}

impl<'a> FindCollections<'a> {
    /// Create a new request to find all collections.
    ///
    /// `collection_home_set` is the URI of a path where to search for collections.
    ///
    /// Use [`with_collection_type`](Self::with_collection_type) to filter by collection type.
    #[must_use]
    pub fn new(collection_home_set: &'a hyper::Uri) -> Self {
        Self {
            propfind: Propfind::new(collection_home_set.path())
                .with_properties(&[
                    &names::RESOURCETYPE,
                    &names::GETETAG,
                    &names::SUPPORTED_REPORT_SET,
                ])
                .with_depth(Depth::One),
            collection_type: None,
        }
    }

    /// Filter collections by type (e.g., calendars or address books).
    ///
    /// `collection_type` is a property name that identifies a collection type
    /// (e.g., [`names::CALENDAR`] or [`names::ADDRESSBOOK`]).
    #[must_use]
    pub fn with_collection_type(mut self, collection_type: &'a PropertyName<'a, 'a>) -> Self {
        self.collection_type = Some(collection_type);
        self
    }
}

/// Response from a [`FindCollections`] request.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FindCollectionsResponse {
    /// Collections found.
    pub collections: Vec<FoundCollection>,
}

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

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        self.propfind.prepare_request()
    }

    fn parse_response(
        &self,
        parts: &http::response::Parts,
        body: &[u8],
    ) -> Result<Self::Response, ParseResponseError> {
        let doc = validate_xml_response(parts, body)?;
        let root = doc.root_element();

        let responses = root
            .descendants()
            .filter(|node| node.tag_name() == names::RESPONSE);

        let mut items = Vec::new();
        for response in responses {
            if let Some(collection_type) = self.collection_type {
                // Skip responses that don't match.
                if !response
                    .descendants()
                    .find(|node| node.tag_name() == names::RESOURCETYPE)
                    .is_some_and(|node| {
                        node.descendants()
                            .any(|node| node.tag_name() == *collection_type)
                    })
                {
                    continue;
                }
            } else {
                // Exclude root; only include child collections.
                let resourcetype_node = response
                    .descendants()
                    .find(|node| node.tag_name() == names::RESOURCETYPE);

                if let Some(rt) = resourcetype_node {
                    // Must have <collection/> but also have at least one other child
                    // (calendar, addressbook, etc.)
                    let has_collection = rt
                        .descendants()
                        .any(|node| node.tag_name() == names::COLLECTION);
                    let child_count = rt.children().filter(roxmltree::Node::is_element).count();

                    if !has_collection || child_count <= 1 {
                        continue;
                    }
                } else {
                    continue;
                }
            }

            let href = get_normalised_href(&response)?.to_string();

            let etag = response
                .descendants()
                .find(|node| node.tag_name() == names::GETETAG)
                .and_then(|node| node.text().map(str::to_string));

            let supports_sync = response
                .descendants()
                .find(|node| node.tag_name() == names::SUPPORTED_REPORT_SET)
                .is_some_and(|node| {
                    node.descendants()
                        .any(|node| node.tag_name() == names::SYNC_COLLECTION)
                });

            items.push(FoundCollection {
                href,
                etag,
                supports_sync,
            });
        }

        Ok(FindCollectionsResponse { collections: items })
    }
}

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

    #[test]
    fn test_parse_response_calendars() {
        let home_set = hyper::Uri::from_static("/calendars/user/");
        let req = FindCollections::new(&home_set).with_collection_type(&names::CALENDAR);
        let response_xml = r#"<?xml version="1.0"?>
            <multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
                <response>
                    <href>/calendars/user/</href>
                    <propstat>
                        <prop>
                            <resourcetype>
                                <collection/>
                            </resourcetype>
                        </prop>
                        <status>HTTP/1.1 200 OK</status>
                    </propstat>
                </response>
                <response>
                    <href>/calendars/user/work/</href>
                    <propstat>
                        <prop>
                            <resourcetype>
                                <collection/>
                                <C:calendar/>
                            </resourcetype>
                            <getetag>"abc123"</getetag>
                            <supported-report-set>
                                <supported-report>
                                    <report>
                                        <sync-collection/>
                                    </report>
                                </supported-report>
                            </supported-report-set>
                        </prop>
                        <status>HTTP/1.1 200 OK</status>
                    </propstat>
                </response>
                <response>
                    <href>/calendars/user/personal/</href>
                    <propstat>
                        <prop>
                            <resourcetype>
                                <collection/>
                                <C:calendar/>
                            </resourcetype>
                            <getetag>"def456"</getetag>
                        </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, response_xml.as_bytes()).unwrap();

        assert_eq!(result.collections.len(), 2);
        assert_eq!(result.collections[0].href, "/calendars/user/work/");
        assert_eq!(result.collections[0].etag, Some("\"abc123\"".to_string()));
        assert!(result.collections[0].supports_sync);
        assert_eq!(result.collections[1].href, "/calendars/user/personal/");
        assert_eq!(result.collections[1].etag, Some("\"def456\"".to_string()));
        assert!(!result.collections[1].supports_sync);
    }

    #[test]
    fn test_parse_response_addressbooks() {
        let home_set = hyper::Uri::from_static("/addressbooks/user/");
        let req = FindCollections::new(&home_set).with_collection_type(&names::ADDRESSBOOK);
        let response_xml = r#"<?xml version="1.0"?>
            <multistatus xmlns="DAV:" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
                <response>
                    <href>/addressbooks/user/contacts/</href>
                    <propstat>
                        <prop>
                            <resourcetype>
                                <collection/>
                                <CARD:addressbook/>
                            </resourcetype>
                            <getetag>"xyz789"</getetag>
                        </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, response_xml.as_bytes()).unwrap();

        assert_eq!(result.collections.len(), 1);
        assert_eq!(result.collections[0].href, "/addressbooks/user/contacts/");
        assert_eq!(result.collections[0].etag, Some("\"xyz789\"".to_string()));
        assert!(!result.collections[0].supports_sync);
    }

    #[test]
    fn test_parse_response_empty() {
        let home_set = hyper::Uri::from_static("/calendars/user/");
        let req = FindCollections::new(&home_set).with_collection_type(&names::CALENDAR);
        let response_xml = r#"<?xml version="1.0"?>
            <multistatus xmlns="DAV:">
                <response>
                    <href>/calendars/user/</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, response_xml.as_bytes()).unwrap();

        assert_eq!(result.collections.len(), 0);
    }

    #[test]
    fn test_parse_response_bad_status() {
        let home_set = hyper::Uri::from_static("/calendars/user/");
        let req = FindCollections::new(&home_set).with_collection_type(&names::CALENDAR);
        let response_xml = b"";

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

        assert!(result.is_err());
    }

    #[test]
    fn test_parse_response_all_collections() {
        let home_set = hyper::Uri::from_static("/dav/user/");
        let req = FindCollections::new(&home_set); // No type filter
        let response_xml = r#"<?xml version="1.0"?>
            <multistatus xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav" xmlns:CARD="urn:ietf:params:xml:ns:carddav">
                <response>
                    <href>/dav/user/</href>
                    <propstat>
                        <prop>
                            <resourcetype>
                                <collection/>
                            </resourcetype>
                        </prop>
                        <status>HTTP/1.1 200 OK</status>
                    </propstat>
                </response>
                <response>
                    <href>/dav/user/calendar/</href>
                    <propstat>
                        <prop>
                            <resourcetype>
                                <collection/>
                                <C:calendar/>
                            </resourcetype>
                            <getetag>"cal123"</getetag>
                        </prop>
                        <status>HTTP/1.1 200 OK</status>
                    </propstat>
                </response>
                <response>
                    <href>/dav/user/contacts/</href>
                    <propstat>
                        <prop>
                            <resourcetype>
                                <collection/>
                                <CARD:addressbook/>
                            </resourcetype>
                            <getetag>"addr456"</getetag>
                        </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, response_xml.as_bytes()).unwrap();

        // Should find both calendar and addressbook (but not the plain home set collection)
        assert_eq!(result.collections.len(), 2);
        assert_eq!(result.collections[0].href, "/dav/user/calendar/");
        assert_eq!(result.collections[0].etag, Some("\"cal123\"".to_string()));
        assert_eq!(result.collections[1].href, "/dav/user/contacts/");
        assert_eq!(result.collections[1].etag, Some("\"addr456\"".to_string()));
    }
}