use std::path::Path;
use cosmic_pim_caldav::{CalDavStore as _, VdirStore};
use crate::error::{Error, Result};
#[derive(Debug)]
pub struct Saved<T> {
pub value: T,
pub queued: Result<bool>,
}
pub fn save_and_queue<T, E>(
root: &Path,
collection_id: &str,
file_names: &[&str],
write: impl FnOnce() -> std::result::Result<T, E>,
) -> Result<Saved<T>>
where
Error: From<E>,
{
save_and_queue_creating(root, collection_id, file_names, || {
write().map(|value| (value, Vec::new()))
})
}
pub fn save_and_queue_creating<T, E>(
root: &Path,
collection_id: &str,
file_names: &[&str],
write: impl FnOnce() -> std::result::Result<(T, Vec<String>), E>,
) -> Result<Saved<T>>
where
Error: From<E>,
{
let Some(mut store) = syncing_store(root, collection_id)? else {
let (value, _created) = write()?;
return Ok(Saved {
value,
queued: Ok(false),
});
};
let dir = store.collection().path.clone();
let mut written = None;
let queued = store.exclusively(|store| {
store.reload()?;
let before: Vec<std::io::Result<Vec<u8>>> = file_names
.iter()
.map(|name| std::fs::read(dir.join(name)))
.collect();
let created = match write() {
Ok((value, created)) => {
written = Some(Ok(value));
created
}
Err(why) => {
written = Some(Err(why));
return Ok(false);
}
};
let mut queued = false;
for (name, before) in file_names.iter().zip(&before) {
if unchanged(before, &dir.join(name)) {
continue;
}
let base = before
.as_ref()
.ok()
.and_then(|bytes| std::str::from_utf8(bytes).ok());
queued |= queue_what_is_there(store, &dir, name, base)?;
}
for name in created
.iter()
.filter(|name| !file_names.contains(&name.as_str()))
{
queued |= queue_what_is_there(store, &dir, name, None)?;
}
Ok(queued)
});
match written {
Some(Ok(value)) => Ok(Saved {
value,
queued: queued.map_err(Error::CalDav),
}),
Some(Err(why)) => Err(why.into()),
None => Err(Error::CalDav(queued.err().unwrap_or_else(|| {
cosmic_pim_caldav::Error::internal(
"the collection's lock was taken but the write was not run",
)
}))),
}
}
fn unchanged(before: &std::io::Result<Vec<u8>>, path: &Path) -> bool {
use std::io::ErrorKind::NotFound;
match (before, std::fs::read(path)) {
(Ok(before), Ok(after)) => *before == after,
(Err(before), Err(after)) => before.kind() == NotFound && after.kind() == NotFound,
_ => false,
}
}
fn syncing_store(root: &Path, collection_id: &str) -> Result<Option<VdirStore>> {
let Some(meta) = crate::provision::open_collection(root, collection_id) else {
return Ok(None);
};
if cosmic_pim_caldav::is_local_only(&meta.path) {
return Ok(None);
}
let store = VdirStore::open(meta)?;
if store.href().is_none() {
return Ok(None);
}
if store.is_read_only() {
tracing::warn!(
collection_id,
"edit saved to a collection the server made read-only; it will not be uploaded"
);
return Ok(None);
}
Ok(Some(store))
}
fn queue_what_is_there(
store: &mut VdirStore,
dir: &Path,
file_name: &str,
base: Option<&str>,
) -> cosmic_pim_caldav::Result<bool> {
if dir.join(file_name).exists() {
let Some(href) = store.href_for_file(file_name) else {
return Ok(false);
};
store.queue_put_with_base(&href, base)?;
return Ok(true);
}
let Some((href, _)) = store.entry_for_by_file(file_name) else {
return Ok(false);
};
store.queue_delete(&href)?;
Ok(true)
}
pub fn queue_save(root: &Path, collection_id: &str, file_name: &str) -> Result<bool> {
queue_save_with_base(root, collection_id, file_name, None)
}
pub fn queue_save_with_base(
root: &Path,
collection_id: &str,
file_name: &str,
previous: Option<&str>,
) -> Result<bool> {
let Some(meta) = crate::provision::open_collection(root, collection_id) else {
return Ok(false);
};
if cosmic_pim_caldav::is_local_only(&meta.path) {
return Ok(false);
}
let mut store = VdirStore::open(meta)?;
if store.is_read_only() {
tracing::warn!(
collection_id,
"edit saved to a collection the server made read-only; it will not be uploaded"
);
return Ok(false);
}
let Some(href) = store.href_for_file(file_name) else {
return Ok(false);
};
store.queue_put_with_base(&href, previous)?;
Ok(true)
}
pub fn queue_delete(root: &Path, collection_id: &str, file_name: &str) -> Result<bool> {
let Some(meta) = crate::provision::open_collection(root, collection_id) else {
return Ok(false);
};
if cosmic_pim_caldav::is_local_only(&meta.path) {
return Ok(false);
}
let mut store = VdirStore::open(meta)?;
if store.is_read_only() {
return Ok(false);
}
let Some(href) = store.entry_for_by_file(file_name).map(|(href, _)| href) else {
return Ok(false);
};
store.queue_delete(&href)?;
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;
use cosmic_pim_caldav::push::PushQueue;
use cosmic_pim_caldav::{CalDavStore, RemoteEvent};
use cosmic_pim_core::StoreError;
use cosmic_pim_core::model::Rgb;
use cosmic_pim_core::store::vdir;
const ICS: &str = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//EN\r\n\
BEGIN:VEVENT\r\nUID:a@test\r\nDTSTART:20260804T090000Z\r\nSUMMARY:X\r\n\
END:VEVENT\r\nEND:VCALENDAR\r\n";
fn collection(bind: bool) -> (tempfile::TempDir, String) {
let dir = tempfile::tempdir().unwrap();
let meta = vdir::create_collection(dir.path(), "Personal", Rgb(1, 2, 3)).unwrap();
let id = meta.id.clone();
if bind {
let mut store = VdirStore::open(meta).unwrap();
store.set_remote("/dav/cal/", false).unwrap();
}
(dir, id)
}
fn pending(root: &Path, id: &str) -> Vec<String> {
let meta = crate::provision::open_collection(root, id).unwrap();
VdirStore::open(meta)
.unwrap()
.pending()
.unwrap()
.into_iter()
.map(|p| p.op.href().to_owned())
.collect()
}
#[test]
fn a_save_to_a_local_only_calendar_queues_nothing() {
let (dir, id) = collection(false);
assert!(!queue_save(dir.path(), &id, "a.ics").unwrap());
assert_eq!(pending(dir.path(), &id), [] as [String; 0]);
}
#[test]
fn a_save_to_a_synced_calendar_queues_a_put_under_the_collection_href() {
let (dir, id) = collection(true);
assert!(queue_save(dir.path(), &id, "a.ics").unwrap());
assert_eq!(pending(dir.path(), &id), vec!["/dav/cal/a.ics".to_string()]);
}
#[test]
fn a_save_to_a_known_file_reuses_its_existing_href() {
let (dir, id) = collection(true);
{
let meta = crate::provision::open_collection(dir.path(), &id).unwrap();
let mut store = VdirStore::open(meta).unwrap();
store
.upsert(&RemoteEvent {
href: "/dav/cal/8f3c-aa11.ics".into(),
etag: "\"1\"".into(),
ics: ICS.into(),
})
.unwrap();
}
assert!(queue_save(dir.path(), &id, "8f3c-aa11.ics").unwrap());
assert_eq!(
pending(dir.path(), &id),
vec!["/dav/cal/8f3c-aa11.ics".to_string()],
"a synced event was queued under a freshly derived href instead of its own"
);
}
#[test]
fn a_read_only_collection_queues_nothing() {
let dir = tempfile::tempdir().unwrap();
let meta = vdir::create_collection(dir.path(), "Shared", Rgb(1, 2, 3)).unwrap();
let id = meta.id.clone();
VdirStore::open(meta)
.unwrap()
.set_remote("/dav/shared/", true)
.unwrap();
assert!(!queue_save(dir.path(), &id, "a.ics").unwrap());
assert!(
pending(dir.path(), &id).is_empty(),
"queued a push that can only ever 403"
);
}
#[test]
fn deleting_an_event_the_server_never_saw_queues_nothing() {
let (dir, id) = collection(true);
assert!(
!queue_delete(dir.path(), &id, "never-synced.ics").unwrap(),
"queued a DELETE for a resource that does not exist on the server"
);
}
#[test]
fn deleting_a_synced_event_queues_a_delete() {
let (dir, id) = collection(true);
{
let meta = crate::provision::open_collection(dir.path(), &id).unwrap();
let mut store = VdirStore::open(meta).unwrap();
store
.upsert(&RemoteEvent {
href: "/dav/cal/a.ics".into(),
etag: "\"1\"".into(),
ics: ICS.into(),
})
.unwrap();
}
assert!(queue_delete(dir.path(), &id, "a.ics").unwrap());
assert_eq!(pending(dir.path(), &id), vec!["/dav/cal/a.ics".to_string()]);
}
#[test]
fn the_pre_edit_bytes_reach_the_queue_as_the_merge_base() {
let (dir, id) = collection(true);
{
let meta = crate::provision::open_collection(dir.path(), &id).unwrap();
let mut store = VdirStore::open(meta).unwrap();
store
.upsert(&RemoteEvent {
href: "/dav/cal/a.ics".into(),
etag: "\"1\"".into(),
ics: ICS.into(),
})
.unwrap();
}
assert!(queue_save_with_base(dir.path(), &id, "a.ics", Some(ICS)).unwrap());
let meta = crate::provision::open_collection(dir.path(), &id).unwrap();
let mut store = VdirStore::open(meta).unwrap();
assert_eq!(
store.unpushed_base("/dav/cal/a.ics").unwrap().as_deref(),
Some(ICS),
"the base was dropped between the app and the queue"
);
}
const SERVER_V1: &str = "BEGIN:VCALENDAR\r\nX-V:1\r\nEND:VCALENDAR\r\n";
const LOCAL_EDIT: &str = "BEGIN:VCALENDAR\r\nX-V:1-mine\r\nEND:VCALENDAR\r\n";
const SERVER_V2: &str = "BEGIN:VCALENDAR\r\nX-V:2-theirs\r\nEND:VCALENDAR\r\n";
fn synced() -> (tempfile::TempDir, String, std::path::PathBuf) {
let (dir, id) = collection(true);
let meta = crate::provision::open_collection(dir.path(), &id).unwrap();
let file = meta.path.join("a.ics");
VdirStore::open(meta)
.unwrap()
.upsert(&RemoteEvent {
href: "/dav/cal/a.ics".into(),
etag: "\"1\"".into(),
ics: SERVER_V1.into(),
})
.unwrap();
(dir, id, file)
}
fn pull(root: &Path, id: &str) {
let meta = crate::provision::open_collection(root, id).unwrap();
let mut store = VdirStore::open(meta).unwrap();
store
.exclusively(|store| {
if store.unpushed_local("/dav/cal/a.ics")?.is_none() {
store.upsert(&RemoteEvent {
href: "/dav/cal/a.ics".into(),
etag: "\"2\"".into(),
ics: SERVER_V2.into(),
})?;
}
Ok(())
})
.unwrap();
}
#[test]
fn a_pull_cannot_land_between_a_save_and_its_enqueue() {
let (dir, id, file) = synced();
let (written, pull_may_run) = std::sync::mpsc::channel();
std::thread::scope(|scope| {
scope.spawn(|| {
let saved = save_and_queue(dir.path(), &id, &["a.ics"], || {
std::fs::write(&file, LOCAL_EDIT)?;
written.send(()).unwrap();
std::thread::sleep(std::time::Duration::from_millis(150));
Ok::<_, StoreError>(())
})
.unwrap();
assert!(saved.queued.unwrap());
});
pull_may_run.recv().unwrap();
pull(dir.path(), &id);
});
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
LOCAL_EDIT,
"a pull overwrote a save that had not been queued yet"
);
assert_eq!(pending(dir.path(), &id), ["/dav/cal/a.ics"]);
}
#[test]
fn the_bytes_before_the_write_become_the_merge_base() {
let (dir, id, file) = synced();
save_and_queue(dir.path(), &id, &["a.ics"], || {
std::fs::write(&file, LOCAL_EDIT).map_err(StoreError::from)
})
.unwrap();
let meta = crate::provision::open_collection(dir.path(), &id).unwrap();
assert_eq!(
VdirStore::open(meta)
.unwrap()
.unpushed_base("/dav/cal/a.ics")
.unwrap()
.as_deref(),
Some(SERVER_V1)
);
}
#[test]
fn a_write_that_fails_queues_nothing_and_is_the_error() {
let (dir, id, _file) = synced();
let result = save_and_queue(dir.path(), &id, &["a.ics"], || {
Err::<(), _>(StoreError::from(std::io::Error::other("disk full")))
});
assert!(matches!(result, Err(Error::Store(_))), "{result:?}");
assert_eq!(pending(dir.path(), &id), [] as [String; 0]);
}
#[test]
fn a_write_that_removes_the_file_queues_its_deletion() {
let (dir, id, file) = synced();
let saved = save_and_queue(dir.path(), &id, &["a.ics", "never-synced.ics"], || {
std::fs::remove_file(&file).map_err(StoreError::from)
})
.unwrap();
assert!(saved.queued.unwrap());
let meta = crate::provision::open_collection(dir.path(), &id).unwrap();
let queue = VdirStore::open(meta).unwrap().pending().unwrap();
assert_eq!(queue.len(), 1, "{queue:?}");
assert!(
matches!(&queue[0].op, cosmic_pim_caldav::push::PushOp::Delete { href, .. } if href == "/dav/cal/a.ics")
);
}
#[test]
fn a_write_that_changes_nothing_queues_nothing() {
let (dir, id, file) = synced();
let untouched = save_and_queue(dir.path(), &id, &["a.ics", "never-synced.ics"], || {
Ok::<_, StoreError>(())
})
.unwrap();
assert!(!untouched.queued.unwrap(), "an untouched file was queued");
let same = save_and_queue(dir.path(), &id, &["a.ics"], || {
std::fs::write(&file, SERVER_V1).map_err(StoreError::from)
})
.unwrap();
assert!(!same.queued.unwrap(), "rewriting the same bytes was queued");
assert_eq!(pending(dir.path(), &id), [] as [String; 0]);
}
#[test]
fn a_file_the_write_creates_is_queued_with_it() {
let (dir, id, _file) = synced();
let path = crate::provision::open_collection(dir.path(), &id)
.unwrap()
.path;
let saved = save_and_queue_creating(dir.path(), &id, &["a.ics"], || {
std::fs::write(path.join("b-1.ics"), LOCAL_EDIT)?;
Ok::<_, StoreError>(("made", vec!["b-1.ics".to_owned()]))
})
.unwrap();
assert_eq!(saved.value, "made");
assert!(saved.queued.unwrap());
assert_eq!(
pending(dir.path(), &id),
["/dav/cal/b-1.ics"],
"the created file was not queued, or the unchanged one was"
);
let (local, local_id) = collection(false);
let saved = save_and_queue_creating(local.path(), &local_id, &[], || {
Ok::<_, StoreError>(((), vec!["c.ics".to_owned()]))
})
.unwrap();
assert!(!saved.queued.unwrap());
}
#[test]
fn a_collection_with_no_server_is_written_and_not_queued() {
let (dir, id) = collection(false);
let saved = save_and_queue(dir.path(), &id, &["a.ics"], || {
Ok::<_, StoreError>("written")
})
.unwrap();
assert_eq!(saved.value, "written");
assert!(!saved.queued.unwrap());
let saved = save_and_queue(dir.path(), "no-such-collection", &["a.ics"], || {
Ok::<_, StoreError>(())
})
.unwrap();
assert!(!saved.queued.unwrap());
}
#[test]
fn an_unknown_collection_is_not_an_error() {
let dir = tempfile::tempdir().unwrap();
assert!(!queue_save(dir.path(), "no-such-collection", "a.ics").unwrap());
assert!(!queue_delete(dir.path(), "no-such-collection", "a.ics").unwrap());
}
#[test]
fn a_disconnected_collection_queues_nothing_despite_its_binding() {
let (dir, id) = collection(true);
{
let meta = crate::provision::open_collection(dir.path(), &id).unwrap();
cosmic_pim_caldav::mark_local_only(&meta.path).unwrap();
}
assert!(!queue_save(dir.path(), &id, "a.ics").unwrap());
assert!(!queue_delete(dir.path(), &id, "a.ics").unwrap());
assert_eq!(pending(dir.path(), &id), [] as [String; 0]);
{
let meta = crate::provision::open_collection(dir.path(), &id).unwrap();
cosmic_pim_caldav::unmark_local_only(&meta.path).unwrap();
}
assert!(queue_save(dir.path(), &id, "a.ics").unwrap());
}
}