Skip to main content

sioul_core/
vdir.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Address books and calendars on disk, as vdir: one folder per collection,
5//! one file per contact (`.vcf`) or per event (`.ics`), the collection's name
6//! and colour in the files `displayname` and `color`. pimsync, vdirsyncer,
7//! khal and khard read the same folders
8//! (<https://vdirsyncer.pimutils.org/en/stable/vdir.html>).
9//!
10//! What sync needs to remember, where each file sits on the server and its
11//! ETag, lives apart in the state folder: the data folders hold only your
12//! contacts and events.
13
14use crate::config::{data_dir, state_dir};
15use serde::{Deserialize, Serialize};
16use std::path::{Path, PathBuf};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "lowercase")]
20pub enum Kind {
21    Contacts,
22    Calendars,
23}
24
25impl Kind {
26    /// `~/.local/share/sioul/contacts`, `…/calendars`.
27    pub fn root(self) -> PathBuf {
28        data_dir().join(self.folder())
29    }
30
31    fn folder(self) -> &'static str {
32        match self {
33            Kind::Contacts => "contacts",
34            Kind::Calendars => "calendars",
35        }
36    }
37
38    /// The extension of its items.
39    pub fn extension(self) -> &'static str {
40        match self {
41            Kind::Contacts => "vcf",
42            Kind::Calendars => "ics",
43        }
44    }
45
46    /// The media type a server expects for its items.
47    pub fn media_type(self) -> &'static str {
48        match self {
49            Kind::Contacts => "text/vcard; charset=utf-8",
50            Kind::Calendars => "text/calendar; charset=utf-8",
51        }
52    }
53}
54
55/// One address book or calendar.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Collection {
58    pub kind: Kind,
59    pub account: String,
60    /// Its folder's name, made from the server's address of it.
61    pub id: String,
62    pub dir: PathBuf,
63    pub name: String,
64    /// "#4c6b5c", when the server gives one.
65    pub color: Option<String>,
66    /// The server lets you read it only.
67    pub read_only: bool,
68    /// For calendars: what it may hold ("VEVENT", "VTODO"); empty when the server does not say.
69    pub components: Vec<String>,
70}
71
72impl Collection {
73    /// Where it lives, in a word: "Google", its server's domain ("example.org");
74    /// "" for one kept on this computer only.
75    pub fn place(&self, config: &crate::config::Config) -> String {
76        if self.account == LOCAL {
77            return String::new();
78        }
79        let account = config.account(&self.account);
80        if matches!(crate::capabilities::provider_of_collection(account, self), crate::capabilities::Provider::Google | crate::capabilities::Provider::GoogleTasks) {
81            return "Google".into();
82        }
83        let address = account.and_then(|a| a.url.clone().or_else(|| a.host.clone())).unwrap_or_default();
84        let host = crate::sites::host_of(&if address.contains("://") { address } else { format!("https://{address}") });
85        let domain = crate::sites::domain_of(&host);
86        if domain.is_empty() { self.account.clone() } else { domain }
87    }
88
89    /// Its name and where it lives: "Agenda · example.org", "Maison · on this computer only";
90    /// a calendar taking events and tasks, "Personnel · example.org · events and tasks".
91    pub fn label(&self, config: &crate::config::Config, tr: &crate::i18n::Translator) -> String {
92        let place = self.place(config);
93        let place = if place.is_empty() { tr.text("collection-here", None) } else { place };
94        // One that takes events and tasks says so, wherever it is offered (21 October 2026).
95        if self.takes_both() { format!("{} · {place} · {}", self.name, tr.text("collection-events-and-tasks", None)) } else { format!("{} · {place}", self.name) }
96    }
97
98    /// Whether it takes items of this kind ("VEVENT", "VTODO"): when the server says nothing, it does.
99    pub fn holds(&self, component: &str) -> bool {
100        self.components.is_empty() || self.components.iter().any(|c| c.eq_ignore_ascii_case(component))
101    }
102
103    /// A calendar that takes both events and tasks: it shows in the agenda
104    /// and among the task lists, and says so (`label`).
105    pub fn takes_both(&self) -> bool {
106        self.kind == Kind::Calendars && self.holds("VEVENT") && self.holds("VTODO")
107    }
108}
109
110impl Collection {
111    /// Its contacts or events, by file.
112    pub fn items(&self) -> Vec<PathBuf> {
113        let Ok(entries) = std::fs::read_dir(&self.dir) else { return Vec::new() };
114        let mut items: Vec<PathBuf> = entries
115            .filter_map(Result::ok)
116            .map(|e| e.path())
117            .filter(|p| p.extension().is_some_and(|x| x == self.kind.extension()))
118            .collect();
119        items.sort();
120        items
121    }
122
123    /// Where sync keeps what it knows of it.
124    pub fn state_path(&self) -> PathBuf {
125        state_path(&self.account, self.kind, &self.id)
126    }
127}
128
129/// Where sync keeps what it knows of a collection.
130pub fn state_path(account: &str, kind: Kind, id: &str) -> PathBuf {
131    state_dir().join("dav").join(account).join(kind.folder()).join(format!("{id}.toml"))
132}
133
134/// Every collection of a kind, from every account, by account then name.
135pub fn collections(kind: Kind) -> Vec<Collection> {
136    every_collection(kind).into_iter().filter(|c| !State::load(&c.state_path()).deleted).collect()
137}
138
139/// The mark of an account switched off, in its folder of each kind.
140const OFF: &str = ".sioul-off";
141
142/// An account's calendars and address books out of the pages (`off`), or back.
143pub fn set_account_off(account: &str, off: bool) {
144    for kind in [Kind::Contacts, Kind::Calendars] {
145        let dir = kind.root().join(account);
146        if off {
147            if dir.is_dir() {
148                let _ = std::fs::write(dir.join(OFF), "Switched off in Sioul's Accounts: these collections wait here.\n");
149            }
150        } else {
151            let _ = std::fs::remove_file(dir.join(OFF));
152        }
153    }
154}
155
156/// The marks of switched-off accounts made to follow the configuration, on
157/// this device: the mark is this device's own, while the configuration
158/// travels; an account switched on again elsewhere (or here before, in a
159/// window that died) kept its mark here, and its calendars and address
160/// books, synced all the same, never showed (the phone of 21 October 2026).
161/// `on`: the accounts switched on; `off`: those switched off. Whether any mark changed.
162pub fn follow_switches(on: &[String], off: &[String]) -> bool {
163    let mut changed = false;
164    for kind in [Kind::Contacts, Kind::Calendars] {
165        for (accounts, wanted) in [(on, false), (off, true)] {
166            for account in accounts.iter().filter(|a| one_folder(a)) {
167                let dir = kind.root().join(account);
168                let marked = dir.join(OFF).exists();
169                if marked != wanted && (wanted || marked) && dir.is_dir() {
170                    set_account_off(account, wanted);
171                    changed = true;
172                }
173            }
174        }
175    }
176    changed
177}
178
179/// Items being moved from one collection to another (an event to another
180/// calendar: `move_item`), each `from` → `to` written before the move and
181/// taken out after it, so that a move cut short (a crash, Android stopping
182/// Sioul) is finished at the next look (`finish_moves`): never twice, never lost.
183#[derive(Debug, Clone, Default, Serialize, Deserialize)]
184struct Moves {
185    #[serde(default, rename = "move")]
186    moves: Vec<Move>,
187}
188
189#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
190struct Move {
191    from: PathBuf,
192    to: PathBuf,
193    /// Taken out with `from` once `to` is there (an item made another: its
194    /// task's time blocks, `replace_items`).
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    also: Vec<PathBuf>,
197    /// Written before `to`, taken back when `to` never came (a task's time
198    /// block, made with the task an appointment becomes).
199    #[serde(default, skip_serializing_if = "Vec::is_empty")]
200    made: Vec<PathBuf>,
201}
202
203fn moves_path() -> PathBuf {
204    crate::config::state_dir().join("dav").join("moves.toml")
205}
206
207fn moves_load() -> Moves {
208    std::fs::read_to_string(moves_path()).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
209}
210
211fn moves_save(moves: &Moves) -> Result<(), String> {
212    let path = moves_path();
213    if moves.moves.is_empty() {
214        let _ = std::fs::remove_file(&path);
215        return Ok(());
216    }
217    if let Some(parent) = path.parent() {
218        std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
219    }
220    crate::filelock::replace(&path, toml::to_string(moves).map_err(|e| e.to_string())?.as_bytes()).map_err(|e| e.to_string())
221}
222
223#[cfg(test)]
224thread_local! {
225    /// Tests only: the step after which a move stops, as a crash would (1: the
226    /// move noted, 2: what goes with it made, 3: written there, 4: taken out
227    /// here). Never in a build.
228    static CRASH_AFTER: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
229}
230
231/// Whether a move stops here, as a crash would (tests only).
232fn crashed(step: u8) -> bool {
233    #[cfg(test)]
234    if CRASH_AFTER.with(std::cell::Cell::get) == step {
235        return true;
236    }
237    let _ = step;
238    false
239}
240
241/// An item moved to another collection's folder as `to` (21 October 2026):
242/// noted first, then written there, then taken out here, then the note
243/// gone; a step cut short is finished by `finish_moves`. Its sync sends the
244/// new item and the deletion, whatever their accounts (this device's own
245/// calendar to a server's: the event reaches your other devices).
246pub fn move_item(from: &Path, to: &Path) -> Result<(), String> {
247    // Read under the journal's lock, never before it: a sync writing it in
248    // between is moved as it wrote it (the review of 22 October 2026, F8).
249    crate::filelock::with_lock(&moves_path(), || {
250        let text = std::fs::read_to_string(from).map_err(|e| e.to_string())?;
251        replace_locked(from, &[], &[], to, &text)
252    })
253}
254
255/// Items made another (a task made an appointment, an appointment a task,
256/// 21 October 2026): `gone` (the first, then what goes with it: a task's
257/// time blocks) replaced by `to`, written as `text`, with `made` written
258/// just before it (a time block for the task an event becomes). The same
259/// steps as a move: noted, `made` then `to` written, `gone` taken out, the
260/// note gone. Cut short before `to` is whole, `made` is taken back and
261/// `gone` kept; after, `gone` is taken out (`finish_moves`): never both,
262/// never neither.
263pub fn replace_items(gone: &[PathBuf], made: &[(PathBuf, String)], to: &Path, text: &str) -> Result<(), String> {
264    let Some((from, also)) = gone.split_first() else { return Err("nothing to replace".into()) };
265    // One move at a time, in this program and between programs (the window,
266    // `sioul`, a sync): another finishing moves meanwhile never takes back
267    // what one under way has made, nor clears its note (22 October 2026; the
268    // Windows run of `an_item_made_another_cut_at_each_step_ends_once`,
269    // whose journal another test finished in between).
270    crate::filelock::with_lock(&moves_path(), || replace_locked(from, also, made, to, text))
271}
272
273/// `replace_items`, the journal's lock held.
274fn replace_locked(from: &Path, also: &[PathBuf], made: &[(PathBuf, String)], to: &Path, text: &str) -> Result<(), String> {
275    finish_moves_now();
276    let mut moves = moves_load();
277    moves.moves.push(Move { from: from.to_path_buf(), to: to.to_path_buf(), also: also.to_vec(), made: made.iter().map(|(p, _)| p.clone()).collect() });
278    moves_save(&moves)?;
279    if crashed(1) {
280        return Ok(());
281    }
282    for (path, text) in made {
283        write_item(path, text)?;
284    }
285    if crashed(2) {
286        return Ok(());
287    }
288    write_item(to, text)?;
289    if crashed(3) {
290        return Ok(());
291    }
292    // The old one taken out, unless the new one took its place: the same
293    // collection, the same name (a plan's task become its event, the review
294    // of 22 October 2026, F1: both were lost).
295    if from != to {
296        std::fs::remove_file(from).map_err(|e| e.to_string())?;
297    }
298    for path in also.iter().filter(|p| p.as_path() != to) {
299        let _ = std::fs::remove_file(path);
300    }
301    if crashed(4) {
302        return Ok(());
303    }
304    finish_moves_now();
305    Ok(())
306}
307
308/// Every move cut short, finished: the item there and still here, taken out
309/// here (written whole before the note said so), with what goes with it; not
310/// there, the move forgotten, the item kept here and what was made for it
311/// taken back; gone from here, the move done. Called before each move and
312/// each sync of contacts and calendars.
313pub fn finish_moves() {
314    crate::filelock::with_lock(&moves_path(), finish_moves_now);
315}
316
317/// `finish_moves`, the journal's lock held.
318fn finish_moves_now() {
319    let moves = moves_load();
320    if moves.moves.is_empty() {
321        return;
322    }
323    for m in &moves.moves {
324        if m.to.exists() {
325            // Never the item made, when it took the old one's place (one collection, one name: F1).
326            for path in std::iter::once(&m.from).chain(&m.also).filter(|p| **p != m.to) {
327                if path.exists() {
328                    let _ = std::fs::remove_file(path);
329                }
330                // Its deletion on its server waits for the new item to be there (F6).
331                note_moved(path, &m.to);
332            }
333        } else {
334            for path in &m.made {
335                let _ = std::fs::remove_file(path);
336            }
337        }
338    }
339    let _ = moves_save(&Moves::default());
340}
341
342/// Items moved or made another here, whose old copy's deletion on its
343/// server waits until the new one is there (the review of 22 October 2026,
344/// F6: a refused PUT after the DELETE lost the event on every other device).
345#[derive(Debug, Clone, Default, Serialize, Deserialize)]
346struct Moved {
347    #[serde(default, rename = "moved")]
348    moved: Vec<MovedItem>,
349}
350
351#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
352struct MovedItem {
353    old: PathBuf,
354    new: PathBuf,
355}
356
357fn moved_path() -> PathBuf {
358    crate::config::state_dir().join("dav").join("moved.toml")
359}
360
361fn moved_change<T>(f: impl FnOnce(&mut Moved) -> T) -> T {
362    let path = moved_path();
363    crate::filelock::with_lock(&path, || {
364        let mut moved: Moved = std::fs::read_to_string(&path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default();
365        let before = moved.moved.clone();
366        let out = f(&mut moved);
367        if moved.moved != before {
368            if moved.moved.is_empty() {
369                let _ = std::fs::remove_file(&path);
370            } else if let Ok(text) = toml::to_string(&moved) {
371                if let Some(parent) = path.parent() {
372                    let _ = std::fs::create_dir_all(parent);
373                }
374                let _ = crate::filelock::replace(&path, text.as_bytes());
375            }
376        }
377        out
378    })
379}
380
381fn note_moved(old: &Path, new: &Path) {
382    moved_change(|m| {
383        m.moved.retain(|i| i.old != old);
384        m.moved.push(MovedItem { old: old.to_path_buf(), new: new.to_path_buf() });
385    });
386}
387
388/// The new item an old one's deletion on its server waits for, while the
389/// new one is not there yet (`dav`: its PUT first, then the DELETE): None
390/// once it is, or when nothing waits.
391pub fn deletion_waits_for(old: &Path) -> Option<PathBuf> {
392    let new = moved_change(|m| m.moved.iter().find(|i| i.old == old).map(|i| i.new.clone()))?;
393    (!new_is_there(old, &new)).then_some(new)
394}
395
396/// Whether the new item of a move is on its server, or needs not be: gone
397/// since, kept on this device alone, or in the old one's own collection.
398fn new_is_there(old: &Path, new: &Path) -> bool {
399    if !new.exists() || new.parent() == old.parent() {
400        return true;
401    }
402    let found = [Kind::Calendars, Kind::Contacts].into_iter().find_map(|kind| {
403        let rest = new.strip_prefix(kind.root()).ok()?;
404        let mut parts = rest.components().map(|c| c.as_os_str().to_string_lossy().to_string());
405        Some((kind, parts.next()?, parts.next()?, parts.next()?))
406    });
407    let Some((kind, account, id, file)) = found else { return true };
408    if account == LOCAL {
409        return true;
410    }
411    State::load(&state_path(&account, kind, &id)).items.iter().any(|i| i.file == file && (!i.etag.is_empty() || !i.hash.is_empty()))
412}
413
414/// The old copy's deletion done on its server: nothing waits for it any more.
415pub fn deletion_done(old: &Path) {
416    moved_change(|m| m.moved.retain(|i| i.old != old));
417}
418
419/// Whether `new` is an item moved or made here whose old copy may still be
420/// on its server: never replaced by what the server holds under its UID.
421pub fn moved_here(new: &Path) -> bool {
422    moved_change(|m| m.moved.iter().any(|i| i.new == new))
423}
424
425/// Every collection kept here, those deleted and not yet gone from the server included.
426pub fn every_collection(kind: Kind) -> Vec<Collection> {
427    let mut found = Vec::new();
428    let Ok(accounts) = std::fs::read_dir(kind.root()) else { return found };
429    // An account switched off keeps its folders, out of the pages (`set_account_off`).
430    for account in accounts.filter_map(Result::ok).filter(|e| e.path().is_dir() && !e.path().join(OFF).exists()) {
431        let account_id = account.file_name().to_string_lossy().to_string();
432        let Ok(dirs) = std::fs::read_dir(account.path()) else { continue };
433        for dir in dirs.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.is_dir()) {
434            let id = dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
435            let read = |name: &str| std::fs::read_to_string(dir.join(name)).ok().map(|t| t.trim().to_string()).filter(|t| !t.is_empty());
436            let mut state = State::load(&state_path(&account_id, kind, &id));
437            if kind == Kind::Calendars && account_id == LOCAL && state.components.is_empty() {
438                settle_kinds(&dir, &mut state, &state_path(&account_id, kind, &id));
439            }
440            // What it holds counts too, at each read: a task that came into a
441            // calendar said to take events alone (another device's, an older
442            // Sioul's, an import) is never hidden (the review of 22 October 2026, F3).
443            if kind == Kind::Calendars && !state.components.is_empty() {
444                for held in kinds_held(&dir) {
445                    if !state.components.iter().any(|c| c.eq_ignore_ascii_case(held)) {
446                        state.components.push(held.to_string());
447                    }
448                }
449            }
450            found.push(Collection {
451                kind,
452                account: account_id.clone(),
453                name: read("displayname").unwrap_or_else(|| id.clone()),
454                color: read("color"),
455                read_only: state.read_only,
456                components: state.components.clone(),
457                id,
458                dir,
459            });
460        }
461    }
462    found.sort_by(|a, b| (&a.account, a.name.to_lowercase()).cmp(&(&b.account, b.name.to_lowercase())));
463    found
464}
465
466/// The kinds a calendar's folder holds ("VEVENT", "VTODO"), read again only
467/// when its list of files changed (its time and count), else as last read.
468fn kinds_held(dir: &Path) -> Vec<&'static str> {
469    static SEEN: std::sync::Mutex<Option<std::collections::HashMap<PathBuf, (Option<std::time::SystemTime>, usize, Vec<&'static str>)>>> = std::sync::Mutex::new(None);
470    let items: Vec<PathBuf> = std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "ics")).collect();
471    let time = std::fs::metadata(dir).and_then(|m| m.modified()).ok();
472    let mut seen = SEEN.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
473    let known = seen.get_or_insert_with(Default::default);
474    if let Some((t, n, held)) = known.get(dir)
475        && *t == time
476        && *n == items.len()
477    {
478        return held.clone();
479    }
480    let (mut events, mut tasks) = (false, false);
481    for item in &items {
482        let text = std::fs::read_to_string(item).unwrap_or_default().to_ascii_uppercase();
483        events |= text.contains("BEGIN:VEVENT");
484        tasks |= text.contains("BEGIN:VTODO");
485    }
486    let held: Vec<&'static str> = [(events, "VEVENT"), (tasks, "VTODO")].into_iter().filter(|(h, _)| *h).map(|(_, k)| k).collect();
487    known.insert(dir.to_path_buf(), (time, items.len(), held.clone()));
488    held
489}
490
491/// A calendar of this device's own whose kinds were never written (made
492/// before 21 October 2026 without them, when "unsaid" meant both): what it
493/// holds says them, written once with it (its state, `dav/local/`): events
494/// alone, tasks alone, or both when it holds both or nothing. A server's says
495/// its own at each sync (`supported-calendar-component-set`; unsaid, both, as
496/// RFC 4791 §5.2.3 has it).
497fn settle_kinds(dir: &Path, state: &mut State, path: &Path) {
498    let (mut events, mut tasks) = (false, false);
499    for item in std::fs::read_dir(dir).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "ics")) {
500        let text = std::fs::read_to_string(&item).unwrap_or_default().to_ascii_uppercase();
501        events |= text.contains("BEGIN:VEVENT");
502        tasks |= text.contains("BEGIN:VTODO");
503    }
504    // Said meanwhile (a collection being made): what was said stays.
505    let now = State::load(path);
506    if !now.components.is_empty() {
507        *state = now;
508        return;
509    }
510    state.components = match (events, tasks) {
511        (true, false) => vec!["VEVENT".into()],
512        (false, true) => vec!["VTODO".into()],
513        _ => vec!["VEVENT".into(), "VTODO".into()],
514    };
515    let _ = state.save(path);
516}
517
518/// A collection renamed here; the next sync tells the server.
519pub fn rename_collection(collection: &Collection, name: &str) -> Result<(), String> {
520    let name = name.trim();
521    if name.is_empty() {
522        return Err(format!("{}: a name is needed", collection.name));
523    }
524    if collection.read_only {
525        return Err(format!("{}: read only", collection.name));
526    }
527    crate::filelock::replace(&collection.dir.join("displayname"), name.as_bytes()).map_err(|e| e.to_string())?;
528    let path = collection.state_path();
529    let mut state = State::load(&path);
530    if collection.account != LOCAL && !state.pending {
531        state.renamed = true;
532        state.save(&path)?;
533    }
534    Ok(())
535}
536
537/// The items a collection holds here: events, tasks or contacts.
538pub fn holds_items(collection: &Collection) -> bool {
539    std::fs::read_dir(&collection.dir).into_iter().flatten().filter_map(Result::ok).any(|e| {
540        let name = e.file_name().to_string_lossy().to_lowercase();
541        name.ends_with(".ics") || name.ends_with(".vcf")
542    })
543}
544
545/// An empty collection deleted: at once when it was never sent, else hidden
546/// until the next sync deletes it on the server. One holding anything stays.
547pub fn delete_collection(collection: &Collection) -> Result<(), String> {
548    if holds_items(collection) {
549        return Err(format!("{}: not empty", collection.name));
550    }
551    if collection.read_only {
552        return Err(format!("{}: read only", collection.name));
553    }
554    let path = collection.state_path();
555    let mut state = State::load(&path);
556    if collection.account == LOCAL || state.pending {
557        let _ = std::fs::remove_dir_all(&collection.dir);
558        let _ = std::fs::remove_file(&path);
559        return Ok(());
560    }
561    state.deleted = true;
562    state.save(&path)
563}
564
565/// Whether a name (an account's, a collection's) is one folder of its own:
566/// not ".", "..", nor a path ("../x", "C:"), which would put files elsewhere.
567fn one_folder(name: &str) -> bool {
568    !matches!(name, "" | "." | "..") && !name.contains(['/', '\\', '\0']) && !(cfg!(windows) && name.contains(':'))
569}
570
571/// A collection's folder, made if needed, with its name and colour written for other tools.
572pub fn prepare(kind: Kind, account: &str, id: &str, name: &str, color: Option<&str>) -> std::io::Result<PathBuf> {
573    if !one_folder(account) || !one_folder(id) {
574        return Err(std::io::Error::other(format!("{account}/{id}: not a folder's name")));
575    }
576    let dir = kind.root().join(account).join(id);
577    std::fs::create_dir_all(&dir)?;
578    // Written beside, then renamed: the sharing (local lists) never reads them empty.
579    crate::filelock::replace(&dir.join("displayname"), name.as_bytes())?;
580    match color {
581        Some(color) => crate::filelock::replace(&dir.join("color"), color.as_bytes())?,
582        None => {
583            let _ = std::fs::remove_file(dir.join("color"));
584        }
585    }
586    Ok(dir)
587}
588
589/// The account of collections kept on this computer only.
590pub const LOCAL: &str = "local";
591
592/// A new collection made here: its folder, its name and colour, and a state
593/// that asks the next sync to create it on the server (MKCALENDAR or an
594/// extended MKCOL). `components` says what a calendar holds ("VTODO" for a
595/// task list). Returns it; an id already taken gets a number.
596pub fn create(kind: Kind, account: &str, name: &str, color: Option<&str>, components: &[&str]) -> Result<Collection, String> {
597    // The account comes from the window or an agent (the MCP): never a path.
598    if !one_folder(account) {
599        return Err(format!("{account}: not an account"));
600    }
601    let base = crate::config::slug(name);
602    let base = if base.is_empty() { "list".to_string() } else { base };
603    let taken = |id: &str| kind.root().join(account).join(id).exists() || state_path(account, kind, id).exists();
604    let id = std::iter::once(base.clone()).chain((2..100).map(|n| format!("{base}-{n}"))).find(|id| !taken(id)).ok_or_else(|| format!("{name}: no free name"))?;
605    // "local": kept here only, no server to create it on. Its state first,
606    // what it takes with it: a folder never shows without them, which a
607    // look meanwhile would settle as both (`settle_kinds`; 22 October 2026).
608    let state = State { pending: account != LOCAL, components: components.iter().map(|c| c.to_string()).collect(), ..State::default() };
609    state.save(&state_path(account, kind, &id))?;
610    let dir = prepare(kind, account, &id, name, color).map_err(|e| e.to_string())?;
611    Ok(Collection { kind, account: account.to_string(), id, dir, name: name.to_string(), color: color.map(str::to_string), read_only: false, components: state.components })
612}
613
614/// "personal", "contacts", "Calendrier%20perso" → a folder name that holds on
615/// every system: letters, digits, `-` and `_`, the rest as `_`.
616pub fn folder_id(href: &str) -> String {
617    let last = href.trim_end_matches('/').rsplit('/').next().unwrap_or("collection");
618    let id: String = last.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }).collect();
619    if id.is_empty() { "collection".into() } else { id }
620}
621
622/// What sync knows of a collection: where it is, how far it got, and each item.
623#[derive(Debug, Clone, Default, Serialize, Deserialize)]
624pub struct State {
625    /// The collection's address on the server.
626    #[serde(default)]
627    pub url: String,
628    /// RFC 6578: what the server gave at the last sync, to ask what changed since.
629    #[serde(default)]
630    pub sync_token: Option<String>,
631    /// The collection's own tag (getctag): unchanged, nothing changed.
632    #[serde(default)]
633    pub ctag: Option<String>,
634    #[serde(default)]
635    pub read_only: bool,
636    /// For calendars: what the server lets it hold ("VEVENT", "VTODO"); empty when it does not say.
637    #[serde(default)]
638    pub components: Vec<String>,
639    /// Made here: the next sync creates it on the server, then sends its items.
640    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
641    pub pending: bool,
642    /// Renamed here: the next sync gives the server its new name.
643    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
644    pub renamed: bool,
645    /// Deleted here, empty: hidden, and the next sync deletes it on the
646    /// server, unless something was put in it there meanwhile.
647    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
648    pub deleted: bool,
649    #[serde(default, rename = "item")]
650    pub items: Vec<ItemState>,
651}
652
653/// One item, as last agreed with the server.
654#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
655pub struct ItemState {
656    /// Its address on the server, as the server writes it.
657    pub href: String,
658    /// Its file name in the collection's folder.
659    pub file: String,
660    pub etag: String,
661    /// A hash of the file as last synced: a different one means it was changed here.
662    pub hash: String,
663    /// The file's size and modification time (nanoseconds since 1970) when
664    /// last found as synced: both the same, it is unchanged, and not read.
665    /// A time of 0 is unknown: the file is read and its hash compared.
666    #[serde(default)]
667    pub size: u64,
668    #[serde(default)]
669    pub mtime: i64,
670}
671
672impl State {
673    pub fn load(path: &Path) -> State {
674        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
675    }
676
677    /// Written next to its place, then moved: an interruption never leaves half a state.
678    pub fn save(&self, path: &Path) -> Result<(), String> {
679        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
680        if let Some(parent) = path.parent() {
681            std::fs::create_dir_all(parent).map_err(fail)?;
682        }
683        let text = toml::to_string(self).map_err(|e| e.to_string())?;
684        // A name of its own beside it: two writers at once never share one (22 October 2026).
685        crate::filelock::replace(path, text.as_bytes()).map_err(fail)
686    }
687}
688
689/// A short hash of a file's content, to tell whether it changed since sync.
690pub fn content_hash(bytes: &[u8]) -> String {
691    // FNV-1a, 64 bits: stable across versions of Rust, unlike `DefaultHasher`.
692    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
693    for byte in bytes {
694        hash ^= u64::from(*byte);
695        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
696    }
697    format!("{hash:016x}")
698}
699
700/// Writes an item next to its place, then moves it in.
701pub fn write_item(path: &Path, text: &str) -> Result<(), String> {
702    let fail = |e: std::io::Error| format!("{}: {e}", path.display());
703    if let Some(parent) = path.parent() {
704        std::fs::create_dir_all(parent).map_err(fail)?;
705    }
706    let temporary = path.with_extension("tmp");
707    std::fs::write(&temporary, text).map_err(fail)?;
708    std::fs::rename(&temporary, path).map_err(fail)
709}
710
711/// A new item's name: unique, and safe as a file name and in a URL.
712pub fn new_name() -> String {
713    use std::hash::{Hash, Hasher};
714    // Two names asked in the same instant (an import; a clock that ticks by the
715    // microsecond, as on macOS) still differ: a file would be written over.
716    static MADE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
717    let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
718    let mut hasher = std::collections::hash_map::DefaultHasher::new();
719    now.hash(&mut hasher);
720    std::process::id().hash(&mut hasher);
721    MADE.fetch_add(1, std::sync::atomic::Ordering::Relaxed).hash(&mut hasher);
722    format!("sioul-{:x}-{:016x}", now.as_secs(), hasher.finish())
723}
724
725#[cfg(test)]
726mod tests {
727    use super::*;
728
729    /// An event moved to another calendar (21 October 2026), cut short after
730    /// each step as a crash would cut it: the next look finishes it, never
731    /// twice, never lost.
732    #[test]
733    fn a_move_cut_at_each_step_ends_once() {
734        let text = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:moved-1\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
735        for step in 0..=4u8 {
736            let account = format!("moves-{step}");
737            let from = prepare(Kind::Calendars, LOCAL, &format!("from-{step}"), "From", None).unwrap();
738            let to = prepare(Kind::Calendars, &account, "to", "To", None).unwrap();
739            let (source, target) = (from.join("e.ics"), to.join("e.ics"));
740            std::fs::write(&source, text).unwrap();
741            CRASH_AFTER.with(|c| c.set(step));
742            move_item(&source, &target).unwrap();
743            CRASH_AFTER.with(|c| c.set(0));
744            finish_moves();
745            let (here, there) = (source.exists(), target.exists());
746            match step {
747                // Noted only: kept here, nothing there.
748                1 | 2 => assert!(here && !there, "step {step}"),
749                // Written there, then the crash: taken out here at the next look.
750                _ => assert!(!here && there && std::fs::read_to_string(&target).unwrap() == text, "step {step}"),
751            }
752            assert!(here != there, "step {step}: never twice, never lost");
753            assert!(!noted(&source), "step {step}: nothing left to finish");
754            let _ = std::fs::remove_dir_all(&from);
755            let _ = std::fs::remove_dir_all(Kind::Calendars.root().join(&account));
756        }
757    }
758
759    /// Whether the journal still notes a move from `path` (another test's own may be under way).
760    fn noted(path: &Path) -> bool {
761        crate::filelock::with_lock(&moves_path(), || moves_load().moves.iter().any(|m| m.from == path))
762    }
763
764    /// A task made an appointment and back (21 October 2026), cut short
765    /// after each step: the task and its block, or the event and what was
766    /// made with it, one side whole, never both, never neither.
767    #[test]
768    fn an_item_made_another_cut_at_each_step_ends_once() {
769        for step in 0..=4u8 {
770            let list = prepare(Kind::Calendars, LOCAL, &format!("kinds-list-{step}"), "List", None).unwrap();
771            let calendar = prepare(Kind::Calendars, LOCAL, &format!("kinds-calendar-{step}"), "Calendar", None).unwrap();
772            let (task, block, made, event) = (list.join("t.ics"), calendar.join("b.ics"), calendar.join("m.ics"), calendar.join("e.ics"));
773            std::fs::write(&task, "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t\r\nEND:VTODO\r\nEND:VCALENDAR\r\n").unwrap();
774            std::fs::write(&block, "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:b\r\nX-SIOUL-TASK:t\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n").unwrap();
775            let text = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:e\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
776            CRASH_AFTER.with(|c| c.set(step));
777            replace_items(&[task.clone(), block.clone()], &[(made.clone(), "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n".into())], &event, text).unwrap();
778            CRASH_AFTER.with(|c| c.set(0));
779            finish_moves();
780            let before = task.exists() && block.exists() && !made.exists() && !event.exists();
781            let after = !task.exists() && !block.exists() && made.exists() && std::fs::read_to_string(&event).ok().as_deref() == Some(text);
782            match step {
783                1 | 2 => assert!(before, "step {step}: the task kept, what was made taken back"),
784                _ => assert!(after, "step {step}: the event and its block, the task gone"),
785            }
786            assert!(!noted(&task), "step {step}: nothing left to finish");
787            let _ = std::fs::remove_dir_all(&list);
788            let _ = std::fs::remove_dir_all(&calendar);
789        }
790        assert!(replace_items(&[], &[], Path::new("nowhere.ics"), "").is_err());
791    }
792
793    /// What a calendar takes, events, tasks or both (21 October 2026): a
794    /// server's says it (`supported-calendar-component-set`; unsaid, both);
795    /// one of this device's own made before without it is settled once from
796    /// what it holds, and kept with it. One that takes both says so.
797    #[test]
798    fn a_calendar_says_what_it_takes() {
799        let tr = crate::i18n::Translator::new("en");
800        let config = crate::config::Config::default();
801        // Each folder made whole beside, then put in place: a look from another
802        // test meanwhile never sees it before its item (CI's Windows job of 22
803        // October 2026 settled one while it was still empty).
804        let made = |id: &str, item: Option<&str>| {
805            let staged = Kind::Calendars.root().parent().unwrap().join(format!("staged-{id}"));
806            let _ = std::fs::remove_dir_all(&staged);
807            std::fs::create_dir_all(&staged).unwrap();
808            std::fs::write(staged.join("displayname"), id).unwrap();
809            if let Some(item) = item {
810                std::fs::write(staged.join("x.ics"), format!("BEGIN:VCALENDAR\r\nBEGIN:{item}\r\nUID:x\r\nEND:{item}\r\nEND:VCALENDAR\r\n")).unwrap();
811            }
812            let dir = Kind::Calendars.root().join(LOCAL).join(id);
813            let _ = std::fs::remove_dir_all(&dir);
814            let _ = std::fs::remove_file(state_path(LOCAL, Kind::Calendars, id));
815            std::fs::create_dir_all(dir.parent().unwrap()).unwrap();
816            std::fs::rename(&staged, &dir).unwrap();
817            dir
818        };
819        let dirs = [made("kinds-old-events", Some("VEVENT")), made("kinds-old-tasks", Some("VTODO")), made("kinds-old-empty", None)];
820        let find = |account: &str, id: &str| collections(Kind::Calendars).into_iter().find(|c| c.account == account && c.id == id).unwrap();
821        assert_eq!(find(LOCAL, "kinds-old-events").components, ["VEVENT"]);
822        assert_eq!(find(LOCAL, "kinds-old-tasks").components, ["VTODO"]);
823        let empty = find(LOCAL, "kinds-old-empty");
824        assert_eq!(empty.components, ["VEVENT", "VTODO"]);
825        assert_eq!(State::load(&state_path(LOCAL, Kind::Calendars, "kinds-old-tasks")).components, ["VTODO"], "kept with it");
826        assert!(empty.takes_both() && empty.label(&config, &tr).ends_with("events and tasks"));
827        assert!(!find(LOCAL, "kinds-old-tasks").label(&config, &tr).contains("events and tasks"));
828        // A server's says its own: unsaid, both, and nothing is written for it here.
829        prepare(Kind::Calendars, "kinds-server", "personnel", "Personnel", None).unwrap();
830        let unsaid = find("kinds-server", "personnel");
831        assert!(unsaid.components.is_empty() && unsaid.holds("VEVENT") && unsaid.holds("VTODO") && unsaid.takes_both());
832        let path = state_path("kinds-server", Kind::Calendars, "personnel");
833        State { components: vec!["VEVENT".into()], ..State::default() }.save(&path).unwrap();
834        let events = find("kinds-server", "personnel");
835        assert!(events.holds("VEVENT") && !events.holds("VTODO") && !events.takes_both());
836        for dir in dirs {
837            let _ = std::fs::remove_dir_all(dir);
838        }
839        let _ = std::fs::remove_dir_all(Kind::Calendars.root().join("kinds-server"));
840    }
841
842    /// A task become an event in a calendar taking both, under the same
843    /// name (a plan's key, 22 October 2026, F1): the event stays, whether the
844    /// move ends or is cut short at any step.
845    #[test]
846    fn an_item_made_another_in_its_own_place_stays() {
847        for step in 0..=4u8 {
848            let dir = prepare(Kind::Calendars, LOCAL, &format!("same-place-{step}"), "Both", None).unwrap();
849            let path = dir.join("plan-x.ics");
850            std::fs::write(&path, "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:plan-x\r\nEND:VTODO\r\nEND:VCALENDAR\r\n").unwrap();
851            let event = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:plan-x\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
852            CRASH_AFTER.with(|c| c.set(step));
853            replace_items(std::slice::from_ref(&path), &[], &path, event).unwrap();
854            CRASH_AFTER.with(|c| c.set(0));
855            finish_moves();
856            let now = std::fs::read_to_string(&path).unwrap_or_default();
857            match step {
858                1 | 2 => assert!(now.contains("VTODO"), "step {step}: cut before the event: the task kept"),
859                _ => assert_eq!(now, event, "step {step}: the event kept"),
860            }
861            let _ = std::fs::remove_dir_all(&dir);
862        }
863    }
864
865    /// Moves made side by side while another finishes moves in a loop (a
866    /// sync, the window): each ends once, its block kept (22 October 2026: on
867    /// Windows, another test's finishing took a block back between its
868    /// writing and its event's).
869    #[test]
870    fn moves_side_by_side_each_end_once() {
871        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
872        let finishing = {
873            let stop = std::sync::Arc::clone(&stop);
874            std::thread::spawn(move || {
875                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
876                    finish_moves();
877                }
878            })
879        };
880        let workers: Vec<_> = (0..4)
881            .map(|n| {
882                std::thread::spawn(move || {
883                    let dir = prepare(Kind::Calendars, LOCAL, &format!("side-by-side-{n}"), "Side", None).unwrap();
884                    for i in 0..25 {
885                        let (task, block, event) = (dir.join(format!("t{i}.ics")), dir.join(format!("b{i}.ics")), dir.join(format!("e{i}.ics")));
886                        std::fs::write(&task, "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t\r\nEND:VTODO\r\nEND:VCALENDAR\r\n").unwrap();
887                        replace_items(std::slice::from_ref(&task), &[(block.clone(), "block".into())], &event, "event").unwrap();
888                        assert!(!task.exists() && block.exists() && event.exists(), "worker {n}, move {i}");
889                    }
890                    let _ = std::fs::remove_dir_all(&dir);
891                })
892            })
893            .collect();
894        for worker in workers {
895            worker.join().unwrap();
896        }
897        stop.store(true, std::sync::atomic::Ordering::Relaxed);
898        finishing.join().unwrap();
899    }
900
901    /// A task that came into a calendar said to take events alone (another
902    /// device's files arriving, an older Sioul, an import) is never hidden:
903    /// what a folder holds counts at each read (the review of 22 October
904    /// 2026, F3). Saved kinds are not changed by it.
905    #[test]
906    fn what_a_calendar_holds_always_shows() {
907        for (account, id) in [(LOCAL, "held-events-local"), ("held-server", "held-events")] {
908            let dir = prepare(Kind::Calendars, account, id, "Events", None).unwrap();
909            State { components: vec!["VEVENT".into()], ..State::default() }.save(&state_path(account, Kind::Calendars, id)).unwrap();
910            std::fs::write(dir.join("e.ics"), "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:e\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n").unwrap();
911            let find = || collections(Kind::Calendars).into_iter().find(|c| c.account == account && c.id == id).unwrap();
912            assert!(!find().holds("VTODO"));
913            std::fs::write(dir.join("t.ics"), "BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:t\r\nEND:VTODO\r\nEND:VCALENDAR\r\n").unwrap();
914            assert!(find().holds("VTODO") && find().holds("VEVENT"), "{account}: the task that came shows");
915            assert!(crate::tasks::lists().iter().any(|c| c.account == account && c.id == id), "{account}: among the task lists");
916            assert_eq!(State::load(&state_path(account, Kind::Calendars, id)).components, ["VEVENT"], "what the server said stays said");
917            let _ = std::fs::remove_dir_all(&dir);
918            let _ = std::fs::remove_file(state_path(account, Kind::Calendars, id));
919        }
920    }
921
922    /// A calendar made while another thread looks at the calendars (a sync,
923    /// the window) keeps what it takes: its folder showed before its state,
924    /// and the look settled it as both (22 October 2026, found by the
925    /// choosers' test run beside others).
926    #[test]
927    fn a_calendar_made_while_others_look_keeps_what_it_takes() {
928        let stop = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
929        let looking = {
930            let stop = std::sync::Arc::clone(&stop);
931            std::thread::spawn(move || {
932                while !stop.load(std::sync::atomic::Ordering::Relaxed) {
933                    let _ = collections(Kind::Calendars);
934                }
935            })
936        };
937        let made: Vec<Collection> = (0..40).map(|n| create(Kind::Calendars, LOCAL, &format!("Raced events {n}"), None, &["VEVENT"]).unwrap()).collect();
938        stop.store(true, std::sync::atomic::Ordering::Relaxed);
939        looking.join().unwrap();
940        for c in &made {
941            assert_eq!(State::load(&c.state_path()).components, ["VEVENT"], "{}", c.id);
942            let _ = std::fs::remove_dir_all(&c.dir);
943            let _ = std::fs::remove_file(c.state_path());
944        }
945    }
946
947    /// The phone of 21 October 2026: an account switched off once, then on
948    /// again on another device, kept its mark here, and its calendars,
949    /// synced all the same, never showed. The marks follow the configuration.
950    #[test]
951    fn an_account_on_again_elsewhere_shows_its_calendars_here() {
952        let account = "marked-elsewhere";
953        prepare(Kind::Calendars, account, "work", "Work", None).unwrap();
954        prepare(Kind::Calendars, account, "family", "Family", None).unwrap();
955        let shown = || collections(Kind::Calendars).into_iter().filter(|c| c.account == account).count();
956        assert_eq!(shown(), 2);
957        set_account_off(account, true);
958        assert_eq!(shown(), 0, "off here");
959        // Switched on again on another device: the configuration says on, the mark stays.
960        assert!(follow_switches(&[account.to_string()], &[]), "the mark follows");
961        assert_eq!(shown(), 2, "every calendar shown again");
962        assert!(!follow_switches(&[account.to_string()], &[]), "nothing more to change");
963        // Switched off elsewhere: its folders leave the pages here too.
964        assert!(follow_switches(&[], &[account.to_string()]));
965        assert_eq!(shown(), 0);
966        set_account_off(account, false);
967    }
968
969    #[test]
970    fn folder_names_hold_everywhere() {
971        assert_eq!(folder_id("/remote.php/dav/calendars/jane/personal/"), "personal");
972        assert_eq!(folder_id("/dav/Calendrier%20perso/"), "Calendrier_20perso");
973        assert_eq!(folder_id("/"), "collection");
974        assert_eq!(content_hash(b"BEGIN:VCARD"), content_hash(b"BEGIN:VCARD"));
975        assert_ne!(content_hash(b"a"), content_hash(b"b"));
976        // An account or a collection is one folder, never a path out of the data folders.
977        assert!(one_folder("local") && one_folder("murmur-2") && !one_folder("..") && !one_folder("../x") && !one_folder("a\\b") && !one_folder(""));
978        assert!(create(Kind::Calendars, "../elsewhere", "List", None, &["VTODO"]).is_err());
979        let names: std::collections::BTreeSet<String> = (0..1000).map(|_| new_name()).collect();
980        assert_eq!(names.len(), 1000, "never the same name twice");
981    }
982}