1use 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 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 pub fn extension(self) -> &'static str {
40 match self {
41 Kind::Contacts => "vcf",
42 Kind::Calendars => "ics",
43 }
44 }
45
46 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#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct Collection {
58 pub kind: Kind,
59 pub account: String,
60 pub id: String,
62 pub dir: PathBuf,
63 pub name: String,
64 pub color: Option<String>,
66 pub read_only: bool,
68 pub components: Vec<String>,
70}
71
72impl Collection {
73 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 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 if self.takes_both() { format!("{} · {place} · {}", self.name, tr.text("collection-events-and-tasks", None)) } else { format!("{} · {place}", self.name) }
96 }
97
98 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 pub fn takes_both(&self) -> bool {
106 self.kind == Kind::Calendars && self.holds("VEVENT") && self.holds("VTODO")
107 }
108}
109
110impl Collection {
111 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 pub fn state_path(&self) -> PathBuf {
125 state_path(&self.account, self.kind, &self.id)
126 }
127}
128
129pub 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
134pub fn collections(kind: Kind) -> Vec<Collection> {
136 every_collection(kind).into_iter().filter(|c| !State::load(&c.state_path()).deleted).collect()
137}
138
139const OFF: &str = ".sioul-off";
141
142pub 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
156pub 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#[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
196 also: Vec<PathBuf>,
197 #[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 static CRASH_AFTER: std::cell::Cell<u8> = const { std::cell::Cell::new(0) };
229}
230
231fn 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
241pub fn move_item(from: &Path, to: &Path) -> Result<(), String> {
247 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
255pub 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 crate::filelock::with_lock(&moves_path(), || replace_locked(from, also, made, to, text))
271}
272
273fn 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 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
308pub fn finish_moves() {
314 crate::filelock::with_lock(&moves_path(), finish_moves_now);
315}
316
317fn 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 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 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#[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
388pub 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
396fn 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
414pub fn deletion_done(old: &Path) {
416 moved_change(|m| m.moved.retain(|i| i.old != old));
417}
418
419pub fn moved_here(new: &Path) -> bool {
422 moved_change(|m| m.moved.iter().any(|i| i.new == new))
423}
424
425pub 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 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 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
466fn 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
491fn 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 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
518pub 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
537pub 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
545pub 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
565fn one_folder(name: &str) -> bool {
568 !matches!(name, "" | "." | "..") && !name.contains(['/', '\\', '\0']) && !(cfg!(windows) && name.contains(':'))
569}
570
571pub 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 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
589pub const LOCAL: &str = "local";
591
592pub fn create(kind: Kind, account: &str, name: &str, color: Option<&str>, components: &[&str]) -> Result<Collection, String> {
597 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 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
614pub 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
624pub struct State {
625 #[serde(default)]
627 pub url: String,
628 #[serde(default)]
630 pub sync_token: Option<String>,
631 #[serde(default)]
633 pub ctag: Option<String>,
634 #[serde(default)]
635 pub read_only: bool,
636 #[serde(default)]
638 pub components: Vec<String>,
639 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
641 pub pending: bool,
642 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
644 pub renamed: bool,
645 #[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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
655pub struct ItemState {
656 pub href: String,
658 pub file: String,
660 pub etag: String,
661 pub hash: String,
663 #[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 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 crate::filelock::replace(path, text.as_bytes()).map_err(fail)
686 }
687}
688
689pub fn content_hash(bytes: &[u8]) -> String {
691 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
700pub 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
711pub fn new_name() -> String {
713 use std::hash::{Hash, Hasher};
714 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 #[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 1 | 2 => assert!(here && !there, "step {step}"),
749 _ => 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 fn noted(path: &Path) -> bool {
761 crate::filelock::with_lock(&moves_path(), || moves_load().moves.iter().any(|m| m.from == path))
762 }
763
764 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 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 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 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}