libdav 0.10.3

CalDAV and CardDAV client implementations.
Documentation
//! Delete a resource or collection.

use http::Method;

use crate::{
    dav::WebDavError,
    requests::{DavRequest, ParseResponseError, PreparedRequest},
};

/// Marker type indicating no deletion mode has been set yet.
#[derive(Debug, Clone, Copy)]
pub struct NotConfigured;

/// Marker type indicating an Etag has been set.
#[derive(Debug, Clone)]
pub struct WithEtag(String);

/// Marker type indicating force deletion (no Etag check).
#[derive(Debug, Clone, Copy)]
pub struct Force;

/// Request to delete a resource or collection.
///
/// Requires specifying either an etag (for conditional deletion) or
/// using `.force()` for unconditional deletion.
///
/// See also: <https://www.rfc-editor.org/rfc/rfc4918#section-9.6>
#[derive(Debug, Clone)]
pub struct Delete<'a, Mode> {
    href: &'a str,
    mode: Mode,
}

impl<'a> Delete<'a, NotConfigured> {
    /// Create a new delete request for the given href.
    ///
    /// The href should be a path relative to the server's base URL.
    /// Either `.with_etag()` or `.force()` must be called before the request can be executed.
    #[must_use]
    pub fn new(href: &'a str) -> Self {
        Self {
            href,
            mode: NotConfigured,
        }
    }

    /// Set the Etag for conditional deletion.
    ///
    /// The resource will only be deleted if its current Etag matches the provided value.
    #[must_use]
    pub fn with_etag(self, etag: impl Into<String>) -> Delete<'a, WithEtag> {
        Delete {
            href: self.href,
            mode: WithEtag(etag.into()),
        }
    }

    /// Force deletion without etag check.
    ///
    /// **Use with care**: This does not guarantee that the resource has not been modified
    /// since it was last read.
    #[must_use]
    pub fn force(self) -> Delete<'a, Force> {
        Delete {
            href: self.href,
            mode: Force,
        }
    }
}

/// Response from a [`Delete`] request.
///
/// Deletion returns no data on success.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeleteResponse;

impl DavRequest for Delete<'_, WithEtag> {
    type Response = DeleteResponse;
    type ParseError = ParseResponseError;
    type Error<E> = WebDavError<E>;

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        Ok(PreparedRequest {
            method: Method::DELETE,
            path: self.href.to_string(),
            body: String::new(),
            headers: vec![("If-Match".to_string(), self.mode.0.clone())],
        })
    }

    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));
        }
        Ok(DeleteResponse)
    }
}

impl DavRequest for Delete<'_, Force> {
    type Response = DeleteResponse;
    type ParseError = ParseResponseError;
    type Error<E> = WebDavError<E>;

    fn prepare_request(&self) -> Result<PreparedRequest, http::Error> {
        Ok(PreparedRequest {
            method: Method::DELETE,
            path: self.href.to_string(),
            body: String::new(),
            headers: vec![],
        })
    }

    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));
        }
        Ok(DeleteResponse)
    }
}

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

    #[test]
    fn test_prepare_request_with_etag() {
        let req = Delete::new("/calendars/personal/event.ics").with_etag("\"abc123\"");
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::DELETE);
        assert_eq!(prepared.path, "/calendars/personal/event.ics");
        assert!(prepared.body.is_empty());
        assert!(
            prepared
                .headers
                .contains(&("If-Match".to_string(), "\"abc123\"".to_string()))
        );
    }

    #[test]
    fn test_prepare_request_force() {
        let req = Delete::new("/calendars/old/").force();
        let prepared = req.prepare_request().unwrap();

        assert_eq!(prepared.method, Method::DELETE);
        assert_eq!(prepared.path, "/calendars/old/");
        assert!(prepared.body.is_empty());
        assert!(prepared.headers.is_empty());
    }

    #[test]
    fn test_parse_response_success() {
        let req = Delete::new("/calendars/personal/event.ics").with_etag("\"abc123\"");
        let response = http::Response::builder()
            .status(StatusCode::NO_CONTENT)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"").unwrap();

        assert_eq!(result, DeleteResponse);
    }

    #[test]
    fn test_parse_response_success_ok() {
        let req = Delete::new("/calendars/old/").force();
        let response = http::Response::builder()
            .status(StatusCode::OK)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"").unwrap();

        assert_eq!(result, DeleteResponse);
    }

    #[test]
    fn test_parse_response_not_found() {
        let req = Delete::new("/calendars/nonexistent/").force();
        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))
        ));
    }

    #[test]
    fn test_parse_response_precondition_failed() {
        let req = Delete::new("/calendars/personal/event.ics").with_etag("\"old-etag\"");
        let response = http::Response::builder()
            .status(StatusCode::PRECONDITION_FAILED)
            .body(())
            .unwrap();
        let (parts, ()) = response.into_parts();
        let result = req.parse_response(&parts, b"");

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

    #[test]
    fn test_with_etag_accepts_string() {
        let etag = String::from("\"abc123\"");
        let req = Delete::new("/path").with_etag(etag);
        let prepared = req.prepare_request().unwrap();
        assert!(
            prepared
                .headers
                .contains(&("If-Match".to_string(), "\"abc123\"".to_string()))
        );
    }

    #[test]
    fn test_with_etag_accepts_str() {
        let req = Delete::new("/path").with_etag("\"abc123\"");
        let prepared = req.prepare_request().unwrap();
        assert!(
            prepared
                .headers
                .contains(&("If-Match".to_string(), "\"abc123\"".to_string()))
        );
    }
}