caldir-core 0.15.0

Core library for caldir calendar storage, sync, and provider protocol
Documentation
/// We need this to distinguish between:
///
/// Scenario A.
///   1. Local event was created + pushed
///   2. Local is deleted
///   3. Sync should now delete remote!
///
/// Scenario B:
///   1. Remote event was created.
///   2. Sync should now create local!
///
/// In both cases, we only see that remote has events that local doesn't.
/// The `known_event_ids` file helps us see if it's a brand new event, or a previously known one.
use super::CalendarStateError;
use crate::event::EventInstanceId;
use std::{collections::HashSet, path::Path};

pub(crate) const KNOWN_IDS_FILE_NAME: &str = "known_event_ids";

#[derive(Debug)]
pub(crate) struct KnownEventIds(HashSet<EventInstanceId>);

/// Event instance IDs are stored in plaintext, one per line:
/// e.g.
///   t5slp0vorqgoasogqkvadjt9jj@hooli.com__20240625T170000Z
///   t5slp0vorqgoasogqkvadjt9jj@hooli.com__20240625T180000
///   t81pd0rkq8ujaughbrjhh87svo@hooli.com
impl KnownEventIds {
    pub fn new() -> Self {
        Self(HashSet::new())
    }

    pub fn iter(&self) -> impl Iterator<Item = &EventInstanceId> {
        self.0.iter()
    }

    pub fn load(path: &Path) -> Result<Self, CalendarStateError> {
        if path.is_file() {
            let contents = std::fs::read_to_string(path)?;

            let ids = contents
                .lines()
                .map(|line| line.trim())
                .filter(|line| !line.is_empty())
                .map(EventInstanceId::from)
                .collect::<HashSet<_>>();

            Ok(Self(ids))
        } else {
            Ok(Self::new())
        }
    }

    #[cfg(test)]
    pub fn write(&self, path: &Path) -> Result<(), CalendarStateError> {
        Self::write_from(self.0.iter(), path)
    }

    /// Writes atomically (tempfile + rename)
    pub fn write_from<'a>(
        event_ids: impl IntoIterator<Item = &'a EventInstanceId>,
        path: &Path,
    ) -> Result<(), CalendarStateError> {
        // Sort for deterministic output so the file diffs stay stable across writes.
        let mut lines = event_ids
            .into_iter()
            .map(ToString::to_string)
            .collect::<Vec<_>>();

        lines.sort_unstable();

        let contents = lines.join("\n");

        let parent = path.parent().ok_or_else(|| {
            std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                "known ids path has no parent directory",
            )
        })?;

        std::fs::create_dir_all(parent)?;
        let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
        std::io::Write::write_all(&mut tmp, contents.as_bytes())?;
        tmp.persist(path).map_err(|e| e.error)?;

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::EventTime;
    use crate::event::{EventUid, RecurrenceId};
    use chrono::{NaiveDate, TimeZone, Utc};
    use pretty_assertions::assert_eq;

    fn sample_ids() -> HashSet<EventInstanceId> {
        HashSet::from([
            EventInstanceId::new(
                EventUid::new("t5slp0vorqgoasogqkvadjt9jj@hooli.com".to_string()),
                Some(RecurrenceId::from_event_time(EventTime::DateTimeUtc(
                    Utc.with_ymd_and_hms(2024, 6, 25, 17, 0, 0).unwrap(),
                ))),
            ),
            EventInstanceId::new(
                EventUid::new("t5slp0vorqgoasogqkvadjt9jj@hooli.com".to_string()),
                Some(RecurrenceId::from_event_time(EventTime::DateTimeFloating(
                    NaiveDate::from_ymd_opt(2024, 6, 25)
                        .unwrap()
                        .and_hms_opt(18, 0, 0)
                        .unwrap(),
                ))),
            ),
            EventInstanceId::new(
                EventUid::new("t81pd0rkq8ujaughbrjhh87svo@hooli.com".to_string()),
                None,
            ),
        ])
    }

    fn sample_lines() -> Vec<&'static str> {
        vec![
            "t5slp0vorqgoasogqkvadjt9jj@hooli.com__20240625T170000Z",
            "t5slp0vorqgoasogqkvadjt9jj@hooli.com__20240625T180000",
            "t81pd0rkq8ujaughbrjhh87svo@hooli.com",
        ]
    }

    #[test]
    fn loads_ids_from_plaintext_file() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("known_event_ids");
        std::fs::write(&path, sample_lines().join("\n")).unwrap();

        let loaded = KnownEventIds::load(&path).unwrap();

        assert_eq!(loaded.0, sample_ids());
    }

    #[test]
    fn write_replaces_existing_file_contents() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("known_event_ids");
        std::fs::write(&path, "stale-id@example.com").unwrap();

        KnownEventIds(sample_ids()).write(&path).unwrap();

        let got = std::fs::read_to_string(&path).unwrap();
        assert!(!got.contains("stale-id@example.com"));
        assert!(got.contains("t81pd0rkq8ujaughbrjhh87svo@hooli.com"));
    }

    #[test]
    fn writes_ids_to_plaintext_file() {
        let tmp = tempfile::TempDir::new().unwrap();
        let path = tmp.path().join("known_event_ids");
        let ids = KnownEventIds(sample_ids());

        ids.write(&path).unwrap();

        // Output is sorted, so it must match the (already sorted) sample lines exactly.
        let got = std::fs::read_to_string(&path).unwrap();

        assert_eq!(got, sample_lines().join("\n"));
    }
}