vstorage 0.8.1

Common API for various icalendar/vcard storages.
Documentation
// Copyright 2023-2025 Hugo Osvaldo Barrera
//
// SPDX-License-Identifier: EUPL-1.2

//! A [`CalDavStorage`] is a single caldav repository, as specified in rfc4791.

use std::path::Path;

use async_trait::async_trait;
use http::{StatusCode, Uri};
use libdav::{
    CalDavClient, HttpClient,
    caldav::{CreateCalendar, FindCalendarHomeSet, FindCalendars, GetCalendarResources},
    dav::{
        CheckSupport, Delete, GetProperties, GetProperty, ListResources, PutResource, SetProperty,
        mime_types,
    },
};

use crate::{
    CollectionId, Error, ErrorKind, Etag, Href, ItemKind, Result,
    base::{
        Collection, CreateItemOptions, FetchedItem, FetchedProperty, Item, ItemVersion, Storage,
    },
    dav::{
        self, collection_href_for_item, collection_id_for_href, name_for_creation,
        parse_list_items, path_for_collection_in_home_set,
    },
    disco::{DiscoveredCollection, Discovery},
    property::Property,
};

pub use crate::dav::CollectionIdSegment;

/// Builder for [`CalDavStorage`].
pub struct CalDavStorageBuilder<C: HttpClient> {
    client: CalDavClient<C>,
    collection_id_segment: CollectionIdSegment,
}

impl<C: HttpClient> CalDavStorageBuilder<C> {
    /// Set the collection ID segment strategy.
    ///
    /// Specifies which segment of a collection's URL path should be used as the collection id.
    #[must_use]
    pub fn with_collection_id_segment(mut self, segment: CollectionIdSegment) -> Self {
        self.collection_id_segment = segment;
        self
    }

    /// Build the storage instance.
    ///
    /// Performs discovery operations to find the principal and calendar home set.
    ///
    /// # Errors
    ///
    /// If there are errors discovering the CalDAV server.
    pub async fn build(self) -> Result<CalDavStorage<C>> {
        let principal = self
            .client
            .find_current_user_principal()
            .await
            .map_err(|e| ErrorKind::Io.error(e))?
            .ok_or_else(|| ErrorKind::Unavailable.error("no user principal found"))?;
        let calendar_home_set = self
            .client
            .request(FindCalendarHomeSet::new(principal.path()))
            .await
            .map_err(|e| ErrorKind::Io.error(e))?
            .home_sets;

        Ok(CalDavStorage {
            client: self.client,
            calendar_home_set,
            calendar_id_segment: self.collection_id_segment,
        })
    }
}

impl<C: HttpClient> CalDavStorage<C> {
    /// Create a new builder for this storage type.
    #[must_use]
    pub fn builder(client: CalDavClient<C>) -> CalDavStorageBuilder<C> {
        CalDavStorageBuilder {
            client,
            collection_id_segment: CollectionIdSegment::default(),
        }
    }
}

/// A storage backed by a CalDAV server.
///
/// A single storage represents a single server with a specific set of credentials.
pub struct CalDavStorage<C: HttpClient> {
    client: CalDavClient<C>,
    calendar_home_set: Vec<Uri>,
    calendar_id_segment: CollectionIdSegment,
}

#[async_trait]
impl<C: HttpClient> Storage for CalDavStorage<C> {
    fn item_kind(&self) -> ItemKind {
        ItemKind::Calendar
    }

    async fn check(&self) -> Result<()> {
        self.client
            .request(CheckSupport::caldav(&self.client.base_url))
            .await
            .map_err(|e| ErrorKind::Uncategorised.error(e))
    }

    /// Finds existing collections for this storage.
    ///
    /// Will only return collections stored under the principal's home set. In most common
    /// scenarios, this implies that only collections owned by the current user are found and not
    /// other collections.
    ///
    /// Collections outside the principal's home set can be referenced by using an absolute path.
    async fn discover_collections(&self) -> Result<Discovery> {
        let mut collections = Vec::new();
        for home in &self.calendar_home_set {
            let mut response = self
                .client
                .request(FindCalendars::new(home.path()))
                .await
                .map_err(|e| ErrorKind::Io.error(e))?;
            collections.append(&mut response.calendars);
        }

        collections
            .into_iter()
            .map(|collection| {
                collection_id_for_href(&collection.href, self.calendar_id_segment)
                    .map_err(|e| ErrorKind::InvalidData.error(e))
                    .map(|id| DiscoveredCollection::new(collection.href, id))
            })
            .collect::<Result<Vec<_>>>()
            .map(Discovery::try_from)?
            .map_err(|e| ErrorKind::InvalidData.error(e))
    }

    async fn create_collection(&self, href: &str) -> Result<Collection> {
        self.client
            // TODO: support different item types.
            .request(CreateCalendar::new(href))
            .await
            .map_err(|e| ErrorKind::Uncategorised.error(e))?;
        Ok(Collection::new(href.to_string()))
    }

    /// Deletes a CalDAV collection.
    ///
    /// Performs multiple network calls to ensure that the collection is empty. If the
    /// server supports `Etag` (it MUST as per the spec), this method guarantees that the
    /// collection is empty when deleting it.
    ///
    /// If the server does not support Etags on collections, possible race conditions
    /// could occur and if calendar components are added to the collection at the same
    /// time, they may be deleted.
    async fn delete_collection(&self, href: &str) -> Result<()> {
        let mut results = self
            .client
            .request(GetCalendarResources::new(href).with_hrefs([href]))
            .await
            .map_err(|e| ErrorKind::Uncategorised.error(e))?
            .resources;

        if results.len() > 1 {
            return Err(ErrorKind::InvalidData.into());
        }

        let item = results
            .pop()
            .ok_or_else(|| Error::from(ErrorKind::InvalidData))?;

        if item.href != href {
            return Err(ErrorKind::InvalidData
                .error(format!("Requested href: {}, got: {}", href, item.href)));
        }

        let etag = item
            .content
            .map_err(|e| ErrorKind::Uncategorised.error(format!("Got status code: {e}")))?
            .etag;
        // TODO: specific error kind type for MissingEtag?

        // TODO: if no etag -> use force deletion (and warn)

        // TODO: verify that the collection is actually a calendar collection?
        // This could be done by using discover above.
        let items = self.list_items(href).await?;
        if !items.is_empty() {
            return Err(ErrorKind::CollectionNotEmpty.into());
        }

        self.client
            .request(Delete::new(href).with_etag(&etag))
            .await
            .map_err(|e| ErrorKind::Uncategorised.error(e))?;
        Ok(())
    }

    async fn list_items(&self, collection_href: &str) -> Result<Vec<ItemVersion>> {
        let response = self
            .client
            .request(ListResources::new(collection_href))
            .await?
            .resources;
        parse_list_items(response)
    }

    async fn get_item(&self, href: &str) -> Result<(Item, Etag)> {
        let collection_href = collection_href_for_item(href)?;
        let mut results = self
            .client
            .request(GetCalendarResources::new(collection_href).with_hrefs([href]))
            .await
            .map_err(|e| ErrorKind::Uncategorised.error(e))?
            .resources;

        if results.len() != 1 {
            return Err(ErrorKind::InvalidData.into());
        }

        let item = results.pop().expect("results has exactly one item");
        if item.href != href {
            return Err(ErrorKind::Uncategorised
                .error(format!("Requested href: {}, got: {}", href, item.href)));
        }

        let content = item
            .content
            .map_err(|e| ErrorKind::Uncategorised.error(format!("Got status code: {e}")))?;

        Ok((Item::from(content.data), content.etag.into()))
    }

    async fn get_many_items(&self, hrefs: Vec<Href>) -> Result<Vec<FetchedItem>> {
        // TODO: use generics for CalDavClient+CardDavClient and make this method generic too.
        let Some(first_href) = hrefs.first() else {
            return Ok(Vec::new());
        };
        let collection_href = collection_href_for_item(first_href)?.to_owned();
        self.client
            .request(GetCalendarResources::new(&collection_href).with_hrefs(hrefs))
            .await
            .map_err(|e| ErrorKind::Uncategorised.error(e))?
            .resources
            .into_iter()
            .filter_map(|resource| match resource.content {
                Ok(content) => Some(Ok(FetchedItem {
                    href: resource.href,
                    item: Item::from(content.data),
                    etag: content.etag.into(),
                })),
                Err(StatusCode::NOT_FOUND) => None,
                Err(e) => Some(Err(
                    ErrorKind::Io.error(format!("Got status code {} for {}", e, resource.href))
                )),
            })
            .collect()
    }

    async fn get_all_items(&self, collection: &str) -> Result<Vec<FetchedItem>> {
        let list = self.list_items(collection).await?;
        let hrefs = list.into_iter().map(|i| i.href).collect::<Vec<_>>();
        self.get_many_items(hrefs).await
    }

    async fn create_item(
        &self,
        collection_href: &str,
        item: &Item,
        opts: CreateItemOptions,
    ) -> Result<ItemVersion> {
        let mut href = name_for_creation(collection_href, item, opts);
        if !Path::new(&href)
            .extension()
            .is_some_and(|ext| ext.eq_ignore_ascii_case("ics"))
        {
            href.push_str(".ics");
        }

        let response = self
            .client
            .request(PutResource::new(&href).create(item.as_str(), mime_types::CALENDAR))
            .await
            .map_err(|e| match e {
                libdav::dav::WebDavError::PreconditionFailed(p) => ErrorKind::Exists.error(p),
                other => Error::from(other),
            })?;
        let etag = match response.etag {
            Some(e) => e,
            // TODO: we should only perform a HEAD request here; we don't need actual data.
            None => self.get_item(&href).await?.1.to_string(),
        };
        Ok(ItemVersion::new(href, Etag::from(etag)))
    }

    async fn update_item(&self, href: &str, etag: &Etag, item: &Item) -> Result<Etag> {
        // TODO: check that href is a sub-path of collection.href?
        let response = self
            .client
            .request(PutResource::new(href).update(
                item.as_str(),
                mime_types::CALENDAR,
                etag.as_str(),
            ))
            .await?;
        if let Some(etag) = response.etag {
            return Ok(Etag::from(etag));
        }
        let (new_item, etag) = self.get_item(href).await?;
        if new_item.hash() == item.hash() {
            return Ok(etag);
        }
        return Err(ErrorKind::Io.error("Item was overwritten replaced before reading Etag"));
    }

    /// # Errors
    async fn set_property(&self, href: &str, prop: Property, value: &str) -> Result<()> {
        let propname = dav::dav_propname(prop, self.item_kind())?;
        self.client
            .request(SetProperty::new(href, propname, Some(value)))
            .await
            .map(|_| ())
            .map_err(Error::from)
    }

    async fn unset_property(&self, href: &str, prop: Property) -> Result<()> {
        let propname = dav::dav_propname(prop, self.item_kind())?;
        self.client
            .request(SetProperty::new(href, propname, None))
            .await
            .map(|_| ())
            .map_err(Error::from)
    }

    /// Read metadata from a collection.
    ///
    /// Metadata is fetched using the `PROPFIND` method under the hood. Some servers may not
    /// support some properties.
    ///
    /// # Errors
    ///
    /// If the underlying HTTP connection fails or if the server returns invalid data.
    async fn get_property(&self, href: &str, prop: Property) -> Result<Option<String>> {
        let propname = dav::dav_propname(prop, self.item_kind())?;
        self.client
            .request(GetProperty::new(href, propname))
            .await
            .map(|r| r.value)
            .map_err(Error::from)
    }

    async fn delete_item(&self, href: &str, etag: &Etag) -> Result<()> {
        // TODO: check that href is a sub-path of this storage?
        self.client
            .request(Delete::new(href).with_etag(etag.as_str()))
            .await?;

        Ok(())
    }

    /// # Errors
    ///
    /// Returns [`ErrorKind::PreconditionFailed`] if a home set was not found in the caldav
    /// server.
    fn href_for_collection_id(&self, id: &CollectionId) -> Result<Href> {
        if let Some(home_set) = &self.calendar_home_set.first() {
            Ok(path_for_collection_in_home_set(home_set, id.as_ref()))
        } else {
            Err(ErrorKind::PreconditionFailed.error("calendar home set not found in caldav server"))
        }
    }

    async fn list_properties(&self, collection_href: &str) -> Result<Vec<FetchedProperty>> {
        let properties = Property::known_properties(self.item_kind());
        let prop_names = properties
            .iter()
            .map(|p| dav::dav_propname(*p, self.item_kind()))
            .collect::<Result<Vec<_>>>()?;
        let result = self
            .client
            .request(GetProperties::new(collection_href, &prop_names))
            .await?
            .values
            .into_iter()
            .zip(properties)
            .filter_map(|((_, v), p): ((_, _), &Property)| {
                v.map(|value| FetchedProperty {
                    property: *p,
                    value,
                })
            })
            .collect::<Vec<_>>();

        Ok(result)
    }
}