things-to-remember checklist
git clone https://git.lucas.co/cce-list.git
src/bin/sync.rs (69K)
1 //! `cce-list-sync` — two-way sync between remote task lists and cce-list's
2 //! markdown checklists.
3 //!
4 //! Accounts are cce-mail's (accounts.json, owned by cce-system-interface).
5 //! Two backends, one of which is chosen per run (`--backend google|icloud`;
6 //! default Google when an OAuth account exists, else iCloud):
7 //!
8 //! - **Google Tasks**, over its REST API with the OAuth tokens the settings
9 //! app's Google sign-in stores (it requests the `tasks` scope). The
10 //! access token is refreshed in memory each run, never written back. This
11 //! is the backend that reaches the phone. Lists sync both ways: a file
12 //! created in cce-list becomes a Google list, a list made on the phone
13 //! becomes a file, renames and deletions follow in either direction.
14 //! - **iCloud Reminders**, over CalDAV VTODO with the "cce-mail" keyring
15 //! password. Each VTODO calendar is a list, read-only as a list (items
16 //! inside it sync both ways; creating, renaming or deleting calendars is
17 //! not attempted). Kept working but unlikely to be useful: an account
18 //! whose Reminders were "upgraded" (CloudKit) exposes only Apple's legacy
19 //! stub list over CalDAV, invisible to the Reminders app.
20 //!
21 //! The DAV discovery code is deliberately duplicated from cce-calendar-sync
22 //! rather than extracted: every crate builds standalone (multi-repo), and
23 //! two copies of ~100 lines beats a new published crate until a third
24 //! consumer exists.
25 //!
26 //! The merge is three-way against `sync-state.json`, the last-synced server
27 //! snapshot: for lists (by id: title), and for items (by uid: text, done).
28 //! A difference between the files and the state is a local edit to push;
29 //! between the server and the state, a remote edit to pull; both changed →
30 //! local wins (the next tick reconciles). Deletions propagate both ways,
31 //! guarded: a missing `lists/` directory re-imports instead of deleting, a
32 //! run that would delete every remote list refuses, and one that would
33 //! delete most of a list's tracked items (>5 and >50%) refuses without
34 //! `--force-deletes` — a mangled tree must not empty the phone. State
35 //! entries record their account, so a run only reasons about its own
36 //! backend's lists and items; rows another backend owns pass through.
37 //!
38 //! An item whose uid belongs to a different list than the file it sits in
39 //! has been moved by hand; it is recreated in the new list and deleted from
40 //! the old one (the Tasks API has no cross-list move). CalDAV pushes PATCH
41 //! the fetched iCalendar rather than rebuilding it, so due dates, notes, and
42 //! alarms Apple attached survive a checkbox toggle; Google pushes are
43 //! field-level PATCHes for the same reason. Recurring reminders (RRULE) are
44 //! skipped entirely. Server-side completed items that were never tracked
45 //! are not imported, and neither are blank-title tasks.
46 //!
47 //! Usage: `cce-list-sync [--dry-run] [--force-deletes] [--backend google|icloud]`.
48 //! Driven by cce-list-sync.timer; harmless to run by hand.
49
50 use std::collections::{BTreeMap, BTreeSet};
51
52 use cce_list::{
53 delete_list, legacy_path, list_path, lists_dir, load_current, load_lists, load_sync_state,
54 rename_list, safe_title, save_current, save_list, save_sync_state, Item, ListFile, SyncState,
55 SyncedItem, SyncedList,
56 };
57 use chrono::Utc;
58
59 const CALDAV_ROOT: &str = "https://caldav.icloud.com/";
60 const CALDAV_NS: &str = "urn:ietf:params:xml:ns:caldav";
61 const KEYRING_SERVICE: &str = "cce-mail";
62
63 fn main() {
64 env_logger::init();
65 let args: Vec<String> = std::env::args().collect();
66 let dry_run = args.iter().any(|a| a == "--dry-run");
67 let force_deletes = args.iter().any(|a| a == "--force-deletes");
68 let wanted = args
69 .iter()
70 .position(|a| a == "--backend")
71 .and_then(|i| args.get(i + 1))
72 .map(|s| s.to_ascii_lowercase());
73
74 let backend = match choose_backend(wanted.as_deref()) {
75 Ok(Some(b)) => b,
76 Ok(None) => {
77 log::info!("no usable account in accounts.json; nothing to sync");
78 return;
79 }
80 Err(e) => {
81 log::error!("cannot read accounts: {e}");
82 std::process::exit(1);
83 }
84 };
85
86 match run_sync(&backend, dry_run, force_deletes) {
87 Ok(()) => {}
88 Err(e) => {
89 log::error!("{}: sync failed: {e}", backend.email());
90 std::process::exit(1);
91 }
92 }
93 }
94
95 enum Backend {
96 ICloud(Account),
97 Google(GoogleAccount),
98 }
99
100 impl Backend {
101 fn email(&self) -> &str {
102 match self {
103 Backend::ICloud(a) => &a.email,
104 Backend::Google(a) => &a.email,
105 }
106 }
107 }
108
109 fn choose_backend(wanted: Option<&str>) -> Result<Option<Backend>, String> {
110 match wanted {
111 Some("icloud") => Ok(icloud_accounts()?.into_iter().next().map(Backend::ICloud)),
112 Some("google") => Ok(google_accounts()?.into_iter().next().map(Backend::Google)),
113 Some(other) => Err(format!("unknown --backend {other:?} (google|icloud)")),
114 None => {
115 if let Some(g) = google_accounts()?.into_iter().next() {
116 return Ok(Some(Backend::Google(g)));
117 }
118 Ok(icloud_accounts()?.into_iter().next().map(Backend::ICloud))
119 }
120 }
121 }
122
123 /// Per-run credentials the pushes need beyond the account itself.
124 enum Session {
125 ICloud,
126 Google(String),
127 }
128
129 // ── Remote model (both backends produce it) ───────────────────────────────
130
131 #[derive(Debug, Clone)]
132 struct RemoteList {
133 title: String,
134 etag: String,
135 /// Where a new item in this list is created.
136 create_target: reqwest::Url,
137 }
138
139 #[derive(Debug)]
140 struct RemoteTodo {
141 url: reqwest::Url,
142 etag: String,
143 summary: String,
144 done: bool,
145 /// Unfolded logical lines of the full VCALENDAR, for patch-and-PUT
146 /// (CalDAV only; empty for Google).
147 lines: Vec<String>,
148 /// The list (id) the item lives in.
149 list: String,
150 }
151
152 struct RemoteSnapshot {
153 lists: BTreeMap<String, RemoteList>,
154 todos: BTreeMap<String, RemoteTodo>,
155 }
156
157 // ── The pass ──────────────────────────────────────────────────────────────
158
159 fn run_sync(backend: &Backend, dry_run: bool, force_deletes: bool) -> Result<(), String> {
160 let client = reqwest::blocking::Client::builder()
161 .timeout(std::time::Duration::from_secs(60))
162 .build()
163 .map_err(|e| e.to_string())?;
164 let email = backend.email().to_string();
165
166 // Whether there is any local knowledge at all, judged BEFORE load_lists
167 // runs the legacy migration (which creates lists/ from list.md).
168 let had_local = lists_dir().exists() || legacy_path().exists();
169 let mut local = load_lists().map_err(|e| format!("reading lists: {e}"))?;
170 let mut state = load_sync_state().map_err(|e| format!("sync-state.json: {e}"))?;
171 if !had_local && (!state.items.is_empty() || !state.lists.is_empty()) {
172 // The tree is gone (fresh clone, deleted directory). Re-import
173 // rather than reading absence as "delete everything on the server".
174 log::warn!("lists/ missing; discarding sync state and re-importing");
175 state = SyncState::default();
176 }
177
178 let (remote, session) = match backend {
179 Backend::ICloud(acc) => (fetch_icloud(&client, acc)?, Session::ICloud),
180 Backend::Google(acc) => {
181 let token = google_access_token(&client, acc)?;
182 (google_fetch(&client, &token, &acc.email)?, Session::Google(token))
183 }
184 };
185 let ops = Ops { client: &client, backend, session: &session };
186
187 // ── Lists ───────────────────────────────────────────────────────────
188 let mine_lists: BTreeMap<String, SyncedList> = state
189 .lists
190 .iter()
191 .filter(|(_, v)| v.account == email)
192 .map(|(k, v)| (k.clone(), v.clone()))
193 .collect();
194 let lplan = plan_lists(&local, &mine_lists, &remote.lists);
195 if !force_deletes && !lplan.push_deletes.is_empty() && lplan.push_deletes.len() >= remote.lists.len()
196 {
197 return Err(format!(
198 "refusing to delete every remote list ({}) — if that was really meant, run \
199 cce-list-sync --force-deletes",
200 lplan.push_deletes.len()
201 ));
202 }
203 log::info!(
204 "{email}: lists — pull {} new / {} renamed / {} deleted; push {} new / {} renamed / {} deleted",
205 lplan.pull_new.len(),
206 lplan.pull_renames.len(),
207 lplan.pull_deletes.len(),
208 lplan.push_creates.len(),
209 lplan.push_renames.len(),
210 lplan.push_deletes.len(),
211 );
212 if dry_run {
213 print_list_plan(&lplan, &remote.lists);
214 } else {
215 apply_list_plan(&ops, &lplan, &mut local, &mut state, &remote, &email)?;
216 }
217
218 // The lists this run can sync items for: local files with an id the
219 // server knows (after the list phase, that is every list unless a push
220 // failed and will retry next tick).
221 let mut remote_lists = remote.lists.clone();
222 for (id, l) in &state.lists {
223 // Lists created this run are not in the fetched snapshot yet.
224 if l.account == email && !remote_lists.contains_key(id) {
225 if let Some(target) = ops.create_target_for(id) {
226 remote_lists.insert(
227 id.clone(),
228 RemoteList { title: l.title.clone(), etag: l.etag.clone(), create_target: target },
229 );
230 }
231 }
232 }
233
234 // ── Items, per list ─────────────────────────────────────────────────
235 let foreign_uids: BTreeSet<String> = state
236 .items
237 .iter()
238 .filter(|(_, v)| v.account != email)
239 .map(|(k, _)| k.clone())
240 .collect();
241 // Where each uid lives, server-side or as last synced: a row found in a
242 // different file has been moved by hand. Precomputed so the loop below
243 // can take `state` mutably.
244 let owners: BTreeMap<String, String> = state
245 .items
246 .iter()
247 .filter(|(_, v)| v.account == email)
248 .map(|(k, v)| (k.clone(), v.list_id()))
249 .chain(remote.todos.iter().map(|(k, t)| (k.clone(), t.list.clone())))
250 .collect();
251 let owner_of = |uid: &str| -> Option<String> { owners.get(uid).cloned() };
252
253 let mut current_title = load_current();
254 for file in &local {
255 let Some(list_id) = file.id.clone() else {
256 continue; // creation failed this run; retried next tick
257 };
258 if !remote_lists.contains_key(&list_id) {
259 continue;
260 }
261 let rows: Vec<Item> = file
262 .items
263 .iter()
264 .filter(|i| i.uid.as_deref().is_none_or(|u| !foreign_uids.contains(u)))
265 .map(|i| {
266 let moved = i
267 .uid
268 .as_deref()
269 .and_then(owner_of)
270 .is_some_and(|owner| owner != list_id);
271 if moved {
272 // Recreate here; the old list's plan pushes the delete.
273 Item { uid: None, ..i.clone() }
274 } else {
275 i.clone()
276 }
277 })
278 .collect();
279 // Rows that were moved keep their old uid on disk until apply_local
280 // swaps it, so remember which text came from which uid.
281 let moved_from: Vec<(String, String)> = file
282 .items
283 .iter()
284 .filter_map(|i| {
285 let uid = i.uid.as_deref()?;
286 (owner_of(uid)? != list_id).then(|| (i.text.clone(), uid.to_string()))
287 })
288 .collect();
289
290 let mine = SyncState {
291 items: state
292 .items
293 .iter()
294 .filter(|(_, v)| v.account == email && v.list_id() == list_id)
295 .map(|(k, v)| (k.clone(), v.clone()))
296 .collect(),
297 lists: BTreeMap::new(),
298 };
299 let todos: BTreeMap<String, &RemoteTodo> = remote
300 .todos
301 .iter()
302 .filter(|(_, t)| t.list == list_id)
303 .map(|(k, v)| (k.clone(), v))
304 .collect();
305 let plan = plan_items(&rows, &mine, &todos);
306
307 if !force_deletes && plan.push_deletes.len() > 5 && plan.push_deletes.len() * 2 > mine.items.len()
308 {
309 return Err(format!(
310 "{}: refusing to delete {} of {} tracked items on the server — if the list \
311 was really emptied on purpose, run cce-list-sync --force-deletes",
312 file.title,
313 plan.push_deletes.len(),
314 mine.items.len()
315 ));
316 }
317 log::info!(
318 "{email}: {} — pull {} new / {} changed / {} deleted; push {} changed / {} new / {} deleted",
319 file.title,
320 plan.pull_new.len(),
321 plan.pull_updates.len(),
322 plan.pull_deletes.len(),
323 plan.push_updates.len(),
324 plan.push_creates.len(),
325 plan.push_deletes.len(),
326 );
327 if dry_run {
328 print_item_plan(&plan, &todos);
329 continue;
330 }
331
332 let target = &remote_lists[&list_id].create_target;
333 let created = apply_item_plan(&ops, &plan, &rows, &todos, target, &list_id, &mut state, &email)?;
334
335 // Local side last, as deltas on a FRESH read: the user may have
336 // edited the list while the network calls ran, and rows this plan
337 // does not touch must survive verbatim.
338 let mut fresh = cce_list::load_list(&file.title).unwrap_or_else(|_| ListFile {
339 title: file.title.clone(),
340 id: Some(list_id.clone()),
341 items: Vec::new(),
342 });
343 fresh.id = Some(list_id.clone());
344 apply_local(&mut fresh.items, &plan, &todos, &created, &moved_from);
345 save_list(&fresh).map_err(|e| e.to_string())?;
346 }
347
348 if !dry_run {
349 // The app's pointer may name a list this run renamed or removed.
350 if let Some(cur) = current_title.take() {
351 if !list_path(&cur).exists() {
352 if let Some(first) = local.first() {
353 save_current(&first.title).map_err(|e| e.to_string())?;
354 }
355 }
356 }
357 save_sync_state(&state).map_err(|e| e.to_string())?;
358 }
359 Ok(())
360 }
361
362 /// The backend operations, bundled so the two phases share one signature.
363 struct Ops<'a> {
364 client: &'a reqwest::blocking::Client,
365 backend: &'a Backend,
366 session: &'a Session,
367 }
368
369 impl Ops<'_> {
370 fn token(&self) -> &str {
371 match self.session {
372 Session::Google(t) => t,
373 Session::ICloud => "",
374 }
375 }
376
377 fn create_target_for(&self, list_id: &str) -> Option<reqwest::Url> {
378 match self.backend {
379 Backend::Google(_) => reqwest::Url::parse(&format!("{TASKS_API}/lists/{list_id}/tasks")).ok(),
380 Backend::ICloud(_) => reqwest::Url::parse(list_id).ok(),
381 }
382 }
383
384 fn list_create(&self, title: &str) -> Result<(String, RemoteList), String> {
385 match self.backend {
386 Backend::Google(_) => google_list_create(self.client, self.token(), title),
387 Backend::ICloud(_) => Err("the iCloud backend cannot create lists".into()),
388 }
389 }
390
391 fn list_rename(&self, id: &str, title: &str) -> Result<String, String> {
392 match self.backend {
393 Backend::Google(_) => google_list_rename(self.client, self.token(), id, title),
394 Backend::ICloud(_) => Err("the iCloud backend cannot rename lists".into()),
395 }
396 }
397
398 fn list_delete(&self, id: &str) -> Result<(), String> {
399 match self.backend {
400 Backend::Google(_) => google_list_delete(self.client, self.token(), id),
401 Backend::ICloud(_) => Err("the iCloud backend cannot delete lists".into()),
402 }
403 }
404
405 fn item_update(&self, todo: &RemoteTodo, text: &str, done: bool) -> Result<String, String> {
406 match self.backend {
407 Backend::ICloud(acc) => {
408 let body = patch_vtodo(&todo.lines, text, done);
409 put_ics(self.client, acc, &todo.url, &body, Some(&todo.etag))
410 }
411 Backend::Google(_) => google_update(self.client, self.token(), &todo.url, text, done),
412 }
413 }
414
415 fn item_create(
416 &self,
417 target: &reqwest::Url,
418 text: &str,
419 done: bool,
420 ) -> Result<(String, reqwest::Url, String), String> {
421 match self.backend {
422 Backend::ICloud(acc) => {
423 let uid = new_uid();
424 let url = target.join(&format!("{uid}.ics")).map_err(|e| e.to_string())?;
425 put_ics(self.client, acc, &url, &new_vtodo(&uid, text, done), None)
426 .map(|etag| (uid, url, etag))
427 }
428 Backend::Google(_) => google_create(self.client, self.token(), target, text, done),
429 }
430 }
431
432 fn item_delete(&self, url: &reqwest::Url, etag: &str) -> Result<(), String> {
433 match self.backend {
434 Backend::ICloud(acc) => delete_ics(self.client, acc, url, etag),
435 Backend::Google(_) => google_delete(self.client, self.token(), url),
436 }
437 }
438 }
439
440 // ── List planning (pure; tested) ──────────────────────────────────────────
441
442 #[derive(Default, Debug, PartialEq)]
443 struct ListPlan {
444 /// Remote ids with no local file and no state: new on the server.
445 pull_new: Vec<String>,
446 /// (id, local title, remote title): renamed on the server.
447 pull_renames: Vec<(String, String, String)>,
448 /// (id, local title): the server list is gone.
449 pull_deletes: Vec<(String, String)>,
450 /// (title, stale id if the file carried one the server does not know).
451 push_creates: Vec<(String, Option<String>)>,
452 /// (id, new title): renamed locally.
453 push_renames: Vec<(String, String)>,
454 /// Ids whose local file is gone.
455 push_deletes: Vec<String>,
456 /// Title/etag unchanged in substance; just record the server's etag.
457 refresh: Vec<String>,
458 }
459
460 fn plan_lists(
461 local: &[ListFile],
462 state: &BTreeMap<String, SyncedList>,
463 remote: &BTreeMap<String, RemoteList>,
464 ) -> ListPlan {
465 let mut plan = ListPlan::default();
466 let mut claimed: BTreeSet<&str> = BTreeSet::new();
467 for file in local {
468 match &file.id {
469 None => plan.push_creates.push((file.title.clone(), None)),
470 Some(id) => match (remote.get(id), state.get(id)) {
471 (Some(r), base) => {
472 claimed.insert(id.as_str());
473 let remote_title = safe_title(&r.title);
474 match base {
475 // Never synced as a list before (e.g. the migrated
476 // legacy file): the server's name wins.
477 None if remote_title != file.title => {
478 plan.pull_renames.push((id.clone(), file.title.clone(), remote_title));
479 }
480 None => plan.refresh.push(id.clone()),
481 Some(b) => {
482 let base_title = safe_title(&b.title);
483 let local_changed = file.title != base_title;
484 let remote_changed = remote_title != base_title;
485 if local_changed && file.title != remote_title {
486 plan.push_renames.push((id.clone(), file.title.clone()));
487 } else if remote_changed && file.title != remote_title {
488 plan.pull_renames.push((id.clone(), file.title.clone(), remote_title));
489 } else if r.etag != b.etag || local_changed {
490 plan.refresh.push(id.clone());
491 }
492 }
493 }
494 }
495 (None, Some(_)) => plan.pull_deletes.push((id.clone(), file.title.clone())),
496 // A header the server never heard of and the state does not
497 // track: recreate rather than orphan the file.
498 (None, None) => plan.push_creates.push((file.title.clone(), Some(id.clone()))),
499 },
500 }
501 }
502 for id in remote.keys() {
503 if claimed.contains(id.as_str()) {
504 continue;
505 }
506 if state.contains_key(id) {
507 plan.push_deletes.push(id.clone());
508 } else {
509 plan.pull_new.push(id.clone());
510 }
511 }
512 plan
513 }
514
515 fn print_list_plan(plan: &ListPlan, remote: &BTreeMap<String, RemoteList>) {
516 for id in &plan.pull_new {
517 println!("list pull new: {} ({id})", remote[id].title);
518 }
519 for (id, from, to) in &plan.pull_renames {
520 println!("list pull rename: {from} -> {to} ({id})");
521 }
522 for (id, title) in &plan.pull_deletes {
523 println!("list pull delete: {title} ({id})");
524 }
525 for (title, _) in &plan.push_creates {
526 println!("list push new: {title}");
527 }
528 for (id, title) in &plan.push_renames {
529 println!("list push rename: -> {title} ({id})");
530 }
531 for id in &plan.push_deletes {
532 println!("list push delete: {} ({id})", remote.get(id).map(|l| l.title.as_str()).unwrap_or("?"));
533 }
534 }
535
536 /// A free file stem for a server title: `Groceries`, then `Groceries (2)`.
537 fn unique_local_title(title: &str, taken: &[ListFile]) -> String {
538 let base = safe_title(title);
539 let exists = |t: &str| taken.iter().any(|l| l.title == t) || list_path(t).exists();
540 if !exists(&base) {
541 return base;
542 }
543 (2..)
544 .map(|n| format!("{base} ({n})"))
545 .find(|t| !exists(t))
546 .expect("unbounded")
547 }
548
549 fn apply_list_plan(
550 ops: &Ops,
551 plan: &ListPlan,
552 local: &mut Vec<ListFile>,
553 state: &mut SyncState,
554 remote: &RemoteSnapshot,
555 email: &str,
556 ) -> Result<(), String> {
557 // Server side first; each success is recorded before the next call, so
558 // a failure mid-way retries just the remainder next tick.
559 for (title, stale_id) in &plan.push_creates {
560 match ops.list_create(title) {
561 Ok((id, rl)) => {
562 if let Some(file) = local.iter_mut().find(|f| f.title == *title) {
563 file.id = Some(id.clone());
564 save_list(file).map_err(|e| e.to_string())?;
565 }
566 if let Some(old) = stale_id {
567 state.lists.remove(old);
568 }
569 state.lists.insert(
570 id,
571 SyncedList { title: rl.title, etag: rl.etag, account: email.to_string() },
572 );
573 }
574 Err(e) => log::warn!("creating list {title:?} failed (will retry next tick): {e}"),
575 }
576 }
577 for (id, title) in &plan.push_renames {
578 match ops.list_rename(id, title) {
579 Ok(etag) => {
580 state.lists.insert(
581 id.clone(),
582 SyncedList { title: title.clone(), etag, account: email.to_string() },
583 );
584 }
585 Err(e) => log::warn!("renaming list {id} failed (will retry next tick): {e}"),
586 }
587 }
588 for id in &plan.push_deletes {
589 match ops.list_delete(id) {
590 Ok(()) => {
591 state.lists.remove(id);
592 state.items.retain(|_, v| v.list_id() != *id);
593 }
594 Err(e) => log::warn!("deleting list {id} failed (will retry next tick): {e}"),
595 }
596 }
597
598 // Then the local tree.
599 let current = load_current();
600 for (id, from, to) in &plan.pull_renames {
601 let to = unique_local_title(to, local);
602 rename_list(from, &to).map_err(|e| format!("renaming {from} -> {to}: {e}"))?;
603 if let Some(file) = local.iter_mut().find(|f| f.title == *from) {
604 file.title = to.clone();
605 }
606 if current.as_deref() == Some(from.as_str()) {
607 save_current(&to).map_err(|e| e.to_string())?;
608 }
609 let r = &remote.lists[id];
610 state.lists.insert(
611 id.clone(),
612 SyncedList { title: r.title.clone(), etag: r.etag.clone(), account: email.to_string() },
613 );
614 }
615 for (id, title) in &plan.pull_deletes {
616 delete_list(title).map_err(|e| format!("deleting {title}: {e}"))?;
617 local.retain(|f| f.title != *title);
618 state.lists.remove(id);
619 state.items.retain(|_, v| v.list_id() != *id);
620 }
621 for id in &plan.pull_new {
622 let r = &remote.lists[id];
623 let title = unique_local_title(&r.title, local);
624 let file = ListFile { title, id: Some(id.clone()), items: Vec::new() };
625 save_list(&file).map_err(|e| e.to_string())?;
626 local.push(file);
627 state.lists.insert(
628 id.clone(),
629 SyncedList { title: r.title.clone(), etag: r.etag.clone(), account: email.to_string() },
630 );
631 }
632 for id in &plan.refresh {
633 let r = &remote.lists[id];
634 let title = local
635 .iter()
636 .find(|f| f.id.as_deref() == Some(id))
637 .map(|f| f.title.clone())
638 .unwrap_or_else(|| r.title.clone());
639 state.lists.insert(
640 id.clone(),
641 SyncedList { title, etag: r.etag.clone(), account: email.to_string() },
642 );
643 }
644 local.sort_by_key(|l| l.title.to_lowercase());
645 Ok(())
646 }
647
648 // ── Item planning (pure; tested) ──────────────────────────────────────────
649
650 #[derive(Default, Debug)]
651 struct Plan {
652 pull_new: Vec<String>,
653 pull_updates: Vec<String>,
654 pull_deletes: Vec<String>,
655 push_updates: Vec<String>,
656 /// (text, done, the row's stale uid if it carried one) to create.
657 push_creates: Vec<(String, bool, Option<String>)>,
658 push_deletes: Vec<String>,
659 /// Server etag moved but content is identical — track it, change nothing.
660 refresh_etags: Vec<String>,
661 }
662
663 fn plan_items(local: &[Item], state: &SyncState, remote: &BTreeMap<String, &RemoteTodo>) -> Plan {
664 let mut plan = Plan::default();
665 let local_by_uid: BTreeMap<&str, &Item> = local
666 .iter()
667 .filter_map(|i| i.uid.as_deref().map(|u| (u, i)))
668 .collect();
669
670 for (uid, todo) in remote {
671 let in_state = state.items.get(uid);
672 let in_local = local_by_uid.get(uid.as_str());
673 match (in_state, in_local) {
674 (Some(base), Some(item)) => {
675 let local_changed = item.text != base.text || item.done != base.done;
676 let remote_changed = todo.summary != base.text || todo.done != base.done;
677 if local_changed {
678 // Local wins on both-changed; the push makes the server
679 // match, and the next tick sees all three agree.
680 plan.push_updates.push(uid.clone());
681 } else if remote_changed {
682 plan.pull_updates.push(uid.clone());
683 } else if todo.etag != base.etag {
684 plan.refresh_etags.push(uid.clone());
685 }
686 }
687 (Some(_), None) => plan.push_deletes.push(uid.clone()),
688 (None, Some(item)) => {
689 // Untracked but present on both ends (state lost, or a
690 // hand-copied line): adopt it, local text/done winning.
691 if item.text != todo.summary || item.done != todo.done {
692 plan.push_updates.push(uid.clone());
693 } else {
694 plan.refresh_etags.push(uid.clone());
695 }
696 }
697 (None, None) => {
698 if !todo.done {
699 plan.pull_new.push(uid.clone());
700 }
701 }
702 }
703 }
704 for uid in state.items.keys() {
705 if !remote.contains_key(uid) {
706 plan.pull_deletes.push(uid.clone());
707 }
708 }
709 for item in local {
710 match &item.uid {
711 None => plan.push_creates.push((item.text.clone(), item.done, None)),
712 // A uid the server never heard of and the state does not track:
713 // recreate it rather than orphan the row.
714 Some(uid) if !remote.contains_key(uid) && !state.items.contains_key(uid) => {
715 plan.push_creates.push((item.text.clone(), item.done, Some(uid.clone())));
716 }
717 Some(_) => {}
718 }
719 }
720 plan
721 }
722
723 fn print_item_plan(plan: &Plan, remote: &BTreeMap<String, &RemoteTodo>) {
724 for uid in &plan.pull_new {
725 println!(" pull new: {} ({uid})", remote[uid].summary);
726 }
727 for uid in &plan.pull_updates {
728 println!(" pull change: {} ({uid})", remote[uid].summary);
729 }
730 for uid in &plan.pull_deletes {
731 println!(" pull delete: {uid}");
732 }
733 for uid in &plan.push_updates {
734 println!(" push change: {uid}");
735 }
736 for (text, done, _) in &plan.push_creates {
737 println!(" push new: {}{text}", if *done { "[x] " } else { "" });
738 }
739 for uid in &plan.push_deletes {
740 println!(" push delete: {uid}");
741 }
742 }
743
744 /// Server-side half of an item plan. Returns the creates that succeeded as
745 /// (text, stale uid the row carried, new uid) for `apply_local` to annotate.
746 #[allow(clippy::too_many_arguments)]
747 fn apply_item_plan(
748 ops: &Ops,
749 plan: &Plan,
750 rows: &[Item],
751 todos: &BTreeMap<String, &RemoteTodo>,
752 target: &reqwest::Url,
753 list_id: &str,
754 state: &mut SyncState,
755 email: &str,
756 ) -> Result<Vec<(String, Option<String>, String)>, String> {
757 let synced = |url: &reqwest::Url, etag: String, text: &str, done: bool| SyncedItem {
758 url: url.to_string(),
759 etag,
760 account: email.to_string(),
761 text: text.to_string(),
762 done,
763 list: list_id.to_string(),
764 };
765 for uid in &plan.push_updates {
766 let todo = todos[uid];
767 let item = rows.iter().find(|i| i.uid.as_deref() == Some(uid)).expect("planned");
768 match ops.item_update(todo, &item.text, item.done) {
769 Ok(etag) => {
770 state.items.insert(uid.clone(), synced(&todo.url, etag, &item.text, item.done));
771 }
772 Err(e) => log::warn!("push update {uid} failed (will retry next tick): {e}"),
773 }
774 }
775 let mut created = Vec::new();
776 for (text, done, stale) in &plan.push_creates {
777 match ops.item_create(target, text, *done) {
778 Ok((uid, url, etag)) => {
779 state.items.insert(uid.clone(), synced(&url, etag, text, *done));
780 created.push((text.clone(), stale.clone(), uid));
781 }
782 Err(e) => log::warn!("push create {text:?} failed (will retry next tick): {e}"),
783 }
784 }
785 for uid in &plan.push_deletes {
786 let entry = &state.items[uid];
787 let url = reqwest::Url::parse(&entry.url).map_err(|e| e.to_string())?;
788 match ops.item_delete(&url, &entry.etag) {
789 Ok(()) => {
790 state.items.remove(uid);
791 }
792 Err(e) => log::warn!("push delete {uid} failed (will retry next tick): {e}"),
793 }
794 }
795 // Pulls refresh the state from the server snapshot.
796 for uid in plan.pull_new.iter().chain(&plan.pull_updates) {
797 let todo = todos[uid];
798 state.items.insert(uid.clone(), synced(&todo.url, todo.etag.clone(), &todo.summary, todo.done));
799 }
800 for uid in &plan.pull_deletes {
801 state.items.remove(uid);
802 }
803 for uid in &plan.refresh_etags {
804 if let (Some(entry), Some(todo)) = (state.items.get_mut(uid), todos.get(uid)) {
805 entry.etag = todo.etag.clone();
806 entry.list = list_id.to_string();
807 }
808 }
809 Ok(created)
810 }
811
812 /// Apply the plan's local half as deltas onto a fresh read of the list.
813 /// `moved_from` pairs a text with the stale uid its row carried when it was
814 /// moved in from another list, so the annotation swap finds the row.
815 fn apply_local(
816 items: &mut Vec<Item>,
817 plan: &Plan,
818 remote: &BTreeMap<String, &RemoteTodo>,
819 created: &[(String, Option<String>, String)],
820 moved_from: &[(String, String)],
821 ) {
822 items.retain(|i| {
823 i.uid.as_deref().is_none_or(|u| !plan.pull_deletes.iter().any(|d| d == u))
824 });
825 for uid in &plan.pull_updates {
826 let todo = remote[uid];
827 if let Some(item) = items.iter_mut().find(|i| i.uid.as_deref() == Some(uid)) {
828 item.text = todo.summary.clone();
829 item.done = todo.done;
830 }
831 }
832 for (text, stale, uid) in created {
833 let stale = stale.clone().or_else(|| {
834 moved_from.iter().find(|(t, _)| t == text).map(|(_, u)| u.clone())
835 });
836 let row = match &stale {
837 Some(old) => items.iter_mut().find(|i| i.uid.as_deref() == Some(old)),
838 None => items.iter_mut().find(|i| i.uid.is_none() && i.text == *text),
839 };
840 if let Some(item) = row {
841 item.uid = Some(uid.clone());
842 }
843 }
844 for uid in &plan.pull_new {
845 let todo = remote[uid];
846 items.push(Item { text: todo.summary.clone(), done: todo.done, uid: Some(uid.clone()) });
847 }
848 }
849
850 // ── Accounts ──────────────────────────────────────────────────────────────
851
852 struct Account {
853 email: String,
854 password: String,
855 }
856
857 #[derive(serde::Deserialize)]
858 struct AccountOnDisk {
859 email: String,
860 #[serde(default)]
861 imap: String,
862 #[serde(default)]
863 password: String,
864 }
865
866 fn icloud_accounts() -> Result<Vec<Account>, String> {
867 let path = cce_ui::config::cce_config_dir().join("accounts.json");
868 let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
869 let on_disk: Vec<AccountOnDisk> =
870 serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
871 let mut out = Vec::new();
872 for acc in on_disk {
873 let host = acc.imap.split(':').next().unwrap_or("");
874 let icloud = host.ends_with(".mail.me.com")
875 || ["@icloud.com", "@me.com", "@mac.com"].iter().any(|d| acc.email.ends_with(d));
876 if !icloud {
877 continue;
878 }
879 let password = if !acc.password.is_empty() {
880 acc.password.clone()
881 } else {
882 match keyring::Entry::new(KEYRING_SERVICE, &acc.email).and_then(|e| e.get_password()) {
883 Ok(p) => p,
884 Err(e) => {
885 log::warn!("{}: no password available ({e}); skipping", acc.email);
886 continue;
887 }
888 }
889 };
890 out.push(Account { email: acc.email, password });
891 }
892 Ok(out)
893 }
894
895 // ── Google Tasks ──────────────────────────────────────────────────────────
896
897 const TASKS_API: &str = "https://tasks.googleapis.com/tasks/v1";
898
899 struct GoogleAccount {
900 email: String,
901 refresh_token: String,
902 client_id: String,
903 client_secret: String,
904 }
905
906 /// The OAuth fields the settings app's Google sign-in writes.
907 #[derive(serde::Deserialize)]
908 struct OAuthOnDisk {
909 email: String,
910 #[serde(default)]
911 is_oauth: bool,
912 #[serde(default)]
913 refresh_token: Option<String>,
914 #[serde(default)]
915 client_id: Option<String>,
916 #[serde(default)]
917 client_secret: Option<String>,
918 }
919
920 #[derive(serde::Deserialize, Default)]
921 struct GoogleClientConfig {
922 #[serde(default)]
923 client_id: String,
924 #[serde(default)]
925 client_secret: String,
926 }
927
928 fn google_accounts() -> Result<Vec<GoogleAccount>, String> {
929 let dir = cce_ui::config::cce_config_dir();
930 let path = dir.join("accounts.json");
931 let text = std::fs::read_to_string(&path).map_err(|e| format!("{}: {e}", path.display()))?;
932 let on_disk: Vec<OAuthOnDisk> =
933 serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))?;
934 // An account without its own pinned client credentials falls back to
935 // the global template the settings app maintains.
936 let template: GoogleClientConfig = std::fs::read_to_string(dir.join("google_client.json"))
937 .ok()
938 .and_then(|t| serde_json::from_str(&t).ok())
939 .unwrap_or_default();
940 let mut out = Vec::new();
941 for acc in on_disk {
942 if !acc.is_oauth {
943 continue;
944 }
945 let Some(refresh_token) = acc.refresh_token.filter(|t| !t.is_empty()) else {
946 log::warn!("{}: OAuth account without a refresh token; sign in again", acc.email);
947 continue;
948 };
949 out.push(GoogleAccount {
950 email: acc.email,
951 refresh_token,
952 client_id: acc.client_id.filter(|s| !s.is_empty()).unwrap_or(template.client_id.clone()),
953 client_secret: acc
954 .client_secret
955 .filter(|s| !s.is_empty())
956 .unwrap_or(template.client_secret.clone()),
957 });
958 }
959 Ok(out)
960 }
961
962 /// A fresh access token from the refresh grant. Tokens last an hour and a
963 /// tick is one request burst, so refreshing every run is simpler than
964 /// tracking expiry — and keeps this helper from writing accounts.json.
965 fn google_access_token(
966 client: &reqwest::blocking::Client,
967 acc: &GoogleAccount,
968 ) -> Result<String, String> {
969 let resp = client
970 .post("https://oauth2.googleapis.com/token")
971 .form(&[
972 ("client_id", acc.client_id.as_str()),
973 ("client_secret", acc.client_secret.as_str()),
974 ("refresh_token", acc.refresh_token.as_str()),
975 ("grant_type", "refresh_token"),
976 ])
977 .send()
978 .map_err(|e| format!("token refresh: {e}"))?;
979 let status = resp.status();
980 let body: serde_json::Value = resp.json().map_err(|e| format!("token refresh: {e}"))?;
981 if !status.is_success() {
982 // invalid_grant here means the refresh token was revoked or the
983 // consent predates the tasks scope — a re-login fixes both.
984 return Err(format!("token refresh: HTTP {status} {body}"));
985 }
986 body.get("access_token")
987 .and_then(|v| v.as_str())
988 .map(String::from)
989 .ok_or_else(|| "token refresh: no access_token in response".to_string())
990 }
991
992 fn google_call(
993 client: &reqwest::blocking::Client,
994 token: &str,
995 method: reqwest::Method,
996 url: &str,
997 query: &[(&str, &str)],
998 body: Option<&serde_json::Value>,
999 ) -> Result<serde_json::Value, String> {
1000 let mut req = client.request(method.clone(), url).bearer_auth(token).query(query);
1001 if let Some(b) = body {
1002 req = req.json(b);
1003 }
1004 let resp = req.send().map_err(|e| format!("{method} {url}: {e}"))?;
1005 let status = resp.status();
1006 if status == reqwest::StatusCode::NO_CONTENT {
1007 return Ok(serde_json::Value::Null);
1008 }
1009 let text = resp.text().map_err(|e| format!("{method} {url}: {e}"))?;
1010 if !status.is_success() {
1011 return Err(format!("{method} {url}: HTTP {status} {text}"));
1012 }
1013 if text.trim().is_empty() {
1014 return Ok(serde_json::Value::Null);
1015 }
1016 serde_json::from_str(&text).map_err(|e| format!("{method} {url}: bad JSON: {e}"))
1017 }
1018
1019 fn google_list_url(id: &str) -> String {
1020 format!("{TASKS_API}/users/@me/lists/{id}")
1021 }
1022
1023 /// Every list and every task in it.
1024 fn google_fetch(
1025 client: &reqwest::blocking::Client,
1026 token: &str,
1027 email: &str,
1028 ) -> Result<RemoteSnapshot, String> {
1029 let get = |url: &str, q: &[(&str, &str)]| {
1030 google_call(client, token, reqwest::Method::GET, url, q, None)
1031 };
1032 let page = get(&format!("{TASKS_API}/users/@me/lists"), &[("maxResults", "100")])?;
1033 let mut lists = BTreeMap::new();
1034 for l in page["items"].as_array().into_iter().flatten() {
1035 let Some(id) = l["id"].as_str() else { continue };
1036 lists.insert(
1037 id.to_string(),
1038 RemoteList {
1039 title: l["title"].as_str().unwrap_or("Untitled").to_string(),
1040 etag: l["etag"].as_str().unwrap_or("").to_string(),
1041 create_target: reqwest::Url::parse(&format!("{TASKS_API}/lists/{id}/tasks"))
1042 .map_err(|e| e.to_string())?,
1043 },
1044 );
1045 }
1046 log::info!("{email}: {} task list(s)", lists.len());
1047
1048 let mut todos = BTreeMap::new();
1049 for (list_id, list) in &lists {
1050 let base = list.create_target.as_str().to_string();
1051 let mut page_token = String::new();
1052 loop {
1053 let mut q = vec![
1054 ("showCompleted", "true"),
1055 ("showHidden", "true"),
1056 ("maxResults", "100"),
1057 ("fields", "nextPageToken,items(id,title,status,etag,deleted)"),
1058 ];
1059 if !page_token.is_empty() {
1060 q.push(("pageToken", page_token.as_str()));
1061 }
1062 let page = get(&base, &q).map_err(|e| format!("list {}: {e}", list.title))?;
1063 for t in page["items"].as_array().into_iter().flatten() {
1064 if t["deleted"].as_bool().unwrap_or(false) {
1065 continue;
1066 }
1067 let Some(id) = t["id"].as_str() else { continue };
1068 let summary = t["title"].as_str().unwrap_or("").trim().to_string();
1069 if summary.is_empty() {
1070 // Google's apps mint blank placeholder tasks freely; a
1071 // row with no text is nothing to remember.
1072 continue;
1073 }
1074 let url = reqwest::Url::parse(&format!("{base}/{id}")).map_err(|e| e.to_string())?;
1075 todos.insert(id.to_string(), RemoteTodo {
1076 url,
1077 etag: t["etag"].as_str().unwrap_or("").to_string(),
1078 summary,
1079 done: t["status"].as_str() == Some("completed"),
1080 lines: Vec::new(),
1081 list: list_id.clone(),
1082 });
1083 }
1084 match page["nextPageToken"].as_str() {
1085 Some(next) if !next.is_empty() => page_token = next.to_string(),
1086 _ => break,
1087 }
1088 }
1089 }
1090 Ok(RemoteSnapshot { lists, todos })
1091 }
1092
1093 fn google_list_create(
1094 client: &reqwest::blocking::Client,
1095 token: &str,
1096 title: &str,
1097 ) -> Result<(String, RemoteList), String> {
1098 let body = serde_json::json!({ "title": title });
1099 let resp = google_call(
1100 client, token, reqwest::Method::POST,
1101 &format!("{TASKS_API}/users/@me/lists"), &[], Some(&body),
1102 )?;
1103 let id = resp["id"].as_str().ok_or("created list has no id")?.to_string();
1104 let list = RemoteList {
1105 title: resp["title"].as_str().unwrap_or(title).to_string(),
1106 etag: resp["etag"].as_str().unwrap_or("").to_string(),
1107 create_target: reqwest::Url::parse(&format!("{TASKS_API}/lists/{id}/tasks"))
1108 .map_err(|e| e.to_string())?,
1109 };
1110 Ok((id, list))
1111 }
1112
1113 fn google_list_rename(
1114 client: &reqwest::blocking::Client,
1115 token: &str,
1116 id: &str,
1117 title: &str,
1118 ) -> Result<String, String> {
1119 let body = serde_json::json!({ "title": title });
1120 let resp = google_call(client, token, reqwest::Method::PATCH, &google_list_url(id), &[], Some(&body))?;
1121 Ok(resp["etag"].as_str().unwrap_or("").to_string())
1122 }
1123
1124 fn google_list_delete(client: &reqwest::blocking::Client, token: &str, id: &str) -> Result<(), String> {
1125 match google_call(client, token, reqwest::Method::DELETE, &google_list_url(id), &[], None) {
1126 Ok(_) => Ok(()),
1127 Err(e) if e.contains("HTTP 404") => Ok(()),
1128 Err(e) => Err(e),
1129 }
1130 }
1131
1132 /// Field-level PATCH: title and status only, so due dates and notes set in
1133 /// Google's own apps ride through. Un-completing must also clear the
1134 /// completion timestamp or the API rejects the status.
1135 fn google_update(
1136 client: &reqwest::blocking::Client,
1137 token: &str,
1138 url: &reqwest::Url,
1139 text: &str,
1140 done: bool,
1141 ) -> Result<String, String> {
1142 let body = if done {
1143 serde_json::json!({ "title": text, "status": "completed" })
1144 } else {
1145 serde_json::json!({ "title": text, "status": "needsAction", "completed": null })
1146 };
1147 let resp = google_call(client, token, reqwest::Method::PATCH, url.as_str(), &[], Some(&body))?;
1148 Ok(resp["etag"].as_str().unwrap_or("").to_string())
1149 }
1150
1151 fn google_create(
1152 client: &reqwest::blocking::Client,
1153 token: &str,
1154 target: &reqwest::Url,
1155 text: &str,
1156 done: bool,
1157 ) -> Result<(String, reqwest::Url, String), String> {
1158 let status = if done { "completed" } else { "needsAction" };
1159 let body = serde_json::json!({ "title": text, "status": status });
1160 let resp = google_call(client, token, reqwest::Method::POST, target.as_str(), &[], Some(&body))?;
1161 let id = resp["id"].as_str().ok_or("created task has no id")?.to_string();
1162 let url = reqwest::Url::parse(&format!("{}/{id}", target.as_str().trim_end_matches('/')))
1163 .map_err(|e| e.to_string())?;
1164 Ok((id, url, resp["etag"].as_str().unwrap_or("").to_string()))
1165 }
1166
1167 fn google_delete(
1168 client: &reqwest::blocking::Client,
1169 token: &str,
1170 url: &reqwest::Url,
1171 ) -> Result<(), String> {
1172 match google_call(client, token, reqwest::Method::DELETE, url.as_str(), &[], None) {
1173 Ok(_) => Ok(()),
1174 // Already gone counts as done.
1175 Err(e) if e.contains("HTTP 404") => Ok(()),
1176 Err(e) => Err(e),
1177 }
1178 }
1179
1180 // ── CalDAV (iCloud) ───────────────────────────────────────────────────────
1181
1182 /// Every VTODO calendar as a list (its URL is the list id) and its items.
1183 fn fetch_icloud(client: &reqwest::blocking::Client, acc: &Account) -> Result<RemoteSnapshot, String> {
1184 let root = reqwest::Url::parse(CALDAV_ROOT).expect("static url");
1185 let principal = discover_href(
1186 client, acc, &root, "0",
1187 r#"<?xml version="1.0" encoding="utf-8"?>
1188 <propfind xmlns="DAV:"><prop><current-user-principal/></prop></propfind>"#,
1189 "current-user-principal",
1190 )?;
1191 let home = discover_href(
1192 client, acc, &principal, "0",
1193 r#"<?xml version="1.0" encoding="utf-8"?>
1194 <propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"><prop><C:calendar-home-set/></prop></propfind>"#,
1195 "calendar-home-set",
1196 )?;
1197 let calendars = todo_calendars(client, acc, &home)?;
1198 if calendars.is_empty() {
1199 return Err("no VTODO calendars (Reminders lists) found".into());
1200 }
1201 log::info!("{}: {} Reminders list(s)", acc.email, calendars.len());
1202
1203 let mut lists = BTreeMap::new();
1204 let mut todos = BTreeMap::new();
1205 let mut skipped_recurring = 0usize;
1206 for (url, name) in &calendars {
1207 lists.insert(
1208 url.to_string(),
1209 RemoteList { title: name.clone(), etag: String::new(), create_target: url.clone() },
1210 );
1211 fetch_todos(client, acc, url, &mut todos, &mut skipped_recurring)
1212 .map_err(|e| format!("list {name}: {e}"))?;
1213 }
1214 if skipped_recurring > 0 {
1215 log::info!("{skipped_recurring} recurring reminder(s) left alone (RRULE)");
1216 }
1217 Ok(RemoteSnapshot { lists, todos })
1218 }
1219
1220 fn dav_request(
1221 client: &reqwest::blocking::Client,
1222 acc: &Account,
1223 method: &str,
1224 url: &reqwest::Url,
1225 depth: &str,
1226 body: &str,
1227 ) -> Result<String, String> {
1228 let resp = client
1229 .request(reqwest::Method::from_bytes(method.as_bytes()).expect("static method"), url.clone())
1230 .basic_auth(&acc.email, Some(&acc.password))
1231 .header("Depth", depth)
1232 .header("Content-Type", "application/xml; charset=utf-8")
1233 .body(body.to_string())
1234 .send()
1235 .map_err(|e| format!("{method} {url}: {e}"))?;
1236 let status = resp.status();
1237 let text = resp.text().map_err(|e| e.to_string())?;
1238 if !status.is_success() {
1239 return Err(format!("{method} {url}: HTTP {status}"));
1240 }
1241 Ok(text)
1242 }
1243
1244 fn discover_href(
1245 client: &reqwest::blocking::Client,
1246 acc: &Account,
1247 url: &reqwest::Url,
1248 depth: &str,
1249 body: &str,
1250 prop: &str,
1251 ) -> Result<reqwest::Url, String> {
1252 let xml = dav_request(client, acc, "PROPFIND", url, depth, body)?;
1253 let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
1254 let href = doc
1255 .descendants()
1256 .find(|n| n.tag_name().name() == prop)
1257 .and_then(|n| n.descendants().find(|c| c.tag_name().name() == "href"))
1258 .and_then(|n| n.text())
1259 .ok_or_else(|| format!("no {prop} in PROPFIND response"))?;
1260 url.join(href.trim()).map_err(|e| format!("bad {prop} href {href:?}: {e}"))
1261 }
1262
1263 fn todo_calendars(
1264 client: &reqwest::blocking::Client,
1265 acc: &Account,
1266 home: &reqwest::Url,
1267 ) -> Result<Vec<(reqwest::Url, String)>, String> {
1268 let body = r#"<?xml version="1.0" encoding="utf-8"?>
1269 <propfind xmlns="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
1270 <prop><resourcetype/><displayname/><C:supported-calendar-component-set/></prop>
1271 </propfind>"#;
1272 let xml = dav_request(client, acc, "PROPFIND", home, "1", body)?;
1273 let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
1274 let mut out = Vec::new();
1275 for resp in doc.descendants().filter(|n| n.tag_name().name() == "response") {
1276 let Some(href) = resp
1277 .children()
1278 .find(|c| c.tag_name().name() == "href")
1279 .and_then(|n| n.text())
1280 else {
1281 continue;
1282 };
1283 let is_calendar = resp.descendants().any(|n| {
1284 n.tag_name().name() == "calendar" && n.tag_name().namespace() == Some(CALDAV_NS)
1285 });
1286 if !is_calendar {
1287 continue;
1288 }
1289 // Unlike the events side, VTODO support must be stated: a calendar
1290 // that lists no component set is an events calendar here.
1291 let supports_vtodo = resp
1292 .descendants()
1293 .filter(|n| n.tag_name().name() == "comp")
1294 .filter_map(|n| n.attribute("name"))
1295 .any(|c| c == "VTODO");
1296 if !supports_vtodo {
1297 continue;
1298 }
1299 let name = resp
1300 .descendants()
1301 .find(|n| n.tag_name().name() == "displayname")
1302 .and_then(|n| n.text())
1303 .unwrap_or(href)
1304 .to_string();
1305 let url = home.join(href.trim()).map_err(|e| format!("bad href {href:?}: {e}"))?;
1306 if url.path().trim_end_matches('/') == home.path().trim_end_matches('/') {
1307 continue;
1308 }
1309 out.push((url, name));
1310 }
1311 Ok(out)
1312 }
1313
1314 fn fetch_todos(
1315 client: &reqwest::blocking::Client,
1316 acc: &Account,
1317 cal: &reqwest::Url,
1318 todos: &mut BTreeMap<String, RemoteTodo>,
1319 skipped_recurring: &mut usize,
1320 ) -> Result<(), String> {
1321 let body = r#"<?xml version="1.0" encoding="utf-8"?>
1322 <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
1323 <D:prop><D:getetag/><C:calendar-data/></D:prop>
1324 <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VTODO"/></C:comp-filter></C:filter>
1325 </C:calendar-query>"#;
1326 let xml = dav_request(client, acc, "REPORT", cal, "1", body)?;
1327 let doc = roxmltree::Document::parse(&xml).map_err(|e| format!("bad multistatus: {e}"))?;
1328 for resp in doc.descendants().filter(|n| n.tag_name().name() == "response") {
1329 let href = resp
1330 .children()
1331 .find(|c| c.tag_name().name() == "href")
1332 .and_then(|n| n.text())
1333 .unwrap_or_default();
1334 let etag = resp
1335 .descendants()
1336 .find(|n| n.tag_name().name() == "getetag")
1337 .and_then(|n| n.text())
1338 .unwrap_or_default()
1339 .to_string();
1340 let Some(ics) = resp
1341 .descendants()
1342 .find(|n| n.tag_name().name() == "calendar-data")
1343 .and_then(|n| n.text())
1344 else {
1345 continue;
1346 };
1347 let url = cal.join(href.trim()).map_err(|e| format!("bad href {href:?}: {e}"))?;
1348 match parse_vtodo(ics) {
1349 Some(parsed) if parsed.recurring => *skipped_recurring += 1,
1350 Some(parsed) => {
1351 todos.insert(parsed.uid.clone(), RemoteTodo {
1352 url,
1353 etag,
1354 summary: parsed.summary,
1355 done: parsed.done,
1356 lines: parsed.lines,
1357 list: cal.to_string(),
1358 });
1359 }
1360 None => log::warn!("unparsable VTODO at {url}, skipping"),
1361 }
1362 }
1363 Ok(())
1364 }
1365
1366 fn put_ics(
1367 client: &reqwest::blocking::Client,
1368 acc: &Account,
1369 url: &reqwest::Url,
1370 body: &str,
1371 etag: Option<&str>,
1372 ) -> Result<String, String> {
1373 let mut req = client
1374 .put(url.clone())
1375 .basic_auth(&acc.email, Some(&acc.password))
1376 .header("Content-Type", "text/calendar; charset=utf-8")
1377 .body(body.to_string());
1378 req = match etag {
1379 // An empty stored etag (a PUT whose response carried none) falls
1380 // back to an unconditional overwrite of our own resource.
1381 Some(e) if !e.is_empty() => req.header("If-Match", e),
1382 Some(_) => req,
1383 None => req.header("If-None-Match", "*"),
1384 };
1385 let resp = req.send().map_err(|e| format!("PUT {url}: {e}"))?;
1386 let status = resp.status();
1387 if !status.is_success() {
1388 return Err(format!("PUT {url}: HTTP {status}"));
1389 }
1390 let etag = resp
1391 .headers()
1392 .get("etag")
1393 .and_then(|v| v.to_str().ok())
1394 .unwrap_or_default()
1395 .to_string();
1396 if !etag.is_empty() {
1397 return Ok(etag);
1398 }
1399 // No ETag on the PUT response: ask for it, so the next If-Match works.
1400 Ok(fetch_etag(client, acc, url).unwrap_or_default())
1401 }
1402
1403 fn fetch_etag(
1404 client: &reqwest::blocking::Client,
1405 acc: &Account,
1406 url: &reqwest::Url,
1407 ) -> Option<String> {
1408 let body = r#"<?xml version="1.0" encoding="utf-8"?>
1409 <propfind xmlns="DAV:"><prop><getetag/></prop></propfind>"#;
1410 let xml = dav_request(client, acc, "PROPFIND", url, "0", body).ok()?;
1411 let doc = roxmltree::Document::parse(&xml).ok()?;
1412 doc.descendants()
1413 .find(|n| n.tag_name().name() == "getetag")
1414 .and_then(|n| n.text())
1415 .map(|s| s.to_string())
1416 }
1417
1418 fn delete_ics(
1419 client: &reqwest::blocking::Client,
1420 acc: &Account,
1421 url: &reqwest::Url,
1422 etag: &str,
1423 ) -> Result<(), String> {
1424 let mut req = client.delete(url.clone()).basic_auth(&acc.email, Some(&acc.password));
1425 if !etag.is_empty() {
1426 req = req.header("If-Match", etag);
1427 }
1428 let resp = req.send().map_err(|e| format!("DELETE {url}: {e}"))?;
1429 let status = resp.status();
1430 // Already gone counts as done.
1431 if status.is_success() || status == reqwest::StatusCode::NOT_FOUND {
1432 Ok(())
1433 } else {
1434 Err(format!("DELETE {url}: HTTP {status}"))
1435 }
1436 }
1437
1438 // ── iCalendar: parse, patch, mint ─────────────────────────────────────────
1439
1440 struct ParsedTodo {
1441 uid: String,
1442 summary: String,
1443 done: bool,
1444 recurring: bool,
1445 lines: Vec<String>,
1446 }
1447
1448 fn unfold(ics: &str) -> Vec<String> {
1449 let mut lines: Vec<String> = Vec::new();
1450 for raw in ics.split('\n') {
1451 let raw = raw.strip_suffix('\r').unwrap_or(raw);
1452 if let Some(rest) = raw.strip_prefix(' ').or_else(|| raw.strip_prefix('\t')) {
1453 if let Some(last) = lines.last_mut() {
1454 last.push_str(rest);
1455 continue;
1456 }
1457 }
1458 lines.push(raw.to_string());
1459 }
1460 lines.retain(|l| !l.is_empty());
1461 lines
1462 }
1463
1464 fn split_content_line(line: &str) -> Option<(&str, &str)> {
1465 let mut in_quotes = false;
1466 for (i, c) in line.char_indices() {
1467 match c {
1468 '"' => in_quotes = !in_quotes,
1469 ':' if !in_quotes => return Some((&line[..i], &line[i + 1..])),
1470 _ => {}
1471 }
1472 }
1473 None
1474 }
1475
1476 fn unescape_text(v: &str) -> String {
1477 let mut out = String::with_capacity(v.len());
1478 let mut chars = v.chars();
1479 while let Some(c) = chars.next() {
1480 if c != '\\' {
1481 out.push(c);
1482 continue;
1483 }
1484 match chars.next() {
1485 Some('n') | Some('N') => out.push(' '),
1486 Some(other) => out.push(other),
1487 None => {}
1488 }
1489 }
1490 out
1491 }
1492
1493 fn escape_text(v: &str) -> String {
1494 let mut out = String::with_capacity(v.len());
1495 for c in v.chars() {
1496 match c {
1497 '\\' => out.push_str("\\\\"),
1498 ',' => out.push_str("\\,"),
1499 ';' => out.push_str("\\;"),
1500 '\n' => out.push_str("\\n"),
1501 _ => out.push(c),
1502 }
1503 }
1504 out
1505 }
1506
1507 fn parse_vtodo(ics: &str) -> Option<ParsedTodo> {
1508 let lines = unfold(ics);
1509 let mut in_todo = false;
1510 let mut uid = String::new();
1511 let mut summary = String::new();
1512 let mut done = false;
1513 let mut recurring = false;
1514 for line in &lines {
1515 let Some((head, value)) = split_content_line(line) else { continue };
1516 let name = head.split(';').next().unwrap_or("").to_ascii_uppercase();
1517 match name.as_str() {
1518 "BEGIN" if value.eq_ignore_ascii_case("VTODO") => in_todo = true,
1519 "END" if value.eq_ignore_ascii_case("VTODO") => in_todo = false,
1520 _ if !in_todo => {}
1521 _ => match name.as_str() {
1522 "UID" => uid = value.trim().to_string(),
1523 "SUMMARY" => summary = unescape_text(value.trim()),
1524 "STATUS" => done |= value.trim().eq_ignore_ascii_case("COMPLETED"),
1525 "COMPLETED" => done = true,
1526 "PERCENT-COMPLETE" => done |= value.trim() == "100",
1527 "RRULE" | "RDATE" => recurring = true,
1528 _ => {}
1529 },
1530 }
1531 }
1532 (!uid.is_empty()).then_some(ParsedTodo { uid, summary, done, recurring, lines })
1533 }
1534
1535 /// Rewrite only SUMMARY and the completion trio inside the VTODO block,
1536 /// leaving every other property (DUE, DESCRIPTION, VALARM, X-APPLE-*)
1537 /// exactly as the server sent it.
1538 fn patch_vtodo(lines: &[String], summary: &str, done: bool) -> String {
1539 let now = Utc::now().format("%Y%m%dT%H%M%SZ");
1540 let mut out: Vec<String> = Vec::with_capacity(lines.len() + 4);
1541 let mut in_todo = false;
1542 for line in lines {
1543 let name = split_content_line(line)
1544 .map(|(h, _)| h.split(';').next().unwrap_or("").to_ascii_uppercase())
1545 .unwrap_or_default();
1546 let value = split_content_line(line).map(|(_, v)| v).unwrap_or_default();
1547 if name == "BEGIN" && value.eq_ignore_ascii_case("VTODO") {
1548 in_todo = true;
1549 out.push(line.clone());
1550 continue;
1551 }
1552 if name == "END" && value.eq_ignore_ascii_case("VTODO") {
1553 out.push(format!("SUMMARY:{}", escape_text(summary)));
1554 if done {
1555 out.push("STATUS:COMPLETED".to_string());
1556 out.push("PERCENT-COMPLETE:100".to_string());
1557 out.push(format!("COMPLETED:{now}"));
1558 } else {
1559 out.push("STATUS:NEEDS-ACTION".to_string());
1560 out.push("PERCENT-COMPLETE:0".to_string());
1561 }
1562 in_todo = false;
1563 out.push(line.clone());
1564 continue;
1565 }
1566 if in_todo
1567 && matches!(name.as_str(), "SUMMARY" | "STATUS" | "PERCENT-COMPLETE" | "COMPLETED")
1568 {
1569 continue;
1570 }
1571 out.push(line.clone());
1572 }
1573 let mut s = out.join("\r\n");
1574 s.push_str("\r\n");
1575 s
1576 }
1577
1578 fn new_vtodo(uid: &str, summary: &str, done: bool) -> String {
1579 let now = Utc::now().format("%Y%m%dT%H%M%SZ");
1580 let status = if done {
1581 format!("STATUS:COMPLETED\r\nPERCENT-COMPLETE:100\r\nCOMPLETED:{now}\r\n")
1582 } else {
1583 "STATUS:NEEDS-ACTION\r\n".to_string()
1584 };
1585 format!(
1586 "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//cce//cce-list-sync//EN\r\n\
1587 BEGIN:VTODO\r\nUID:{uid}\r\nDTSTAMP:{now}\r\nCREATED:{now}\r\n\
1588 SUMMARY:{}\r\n{status}END:VTODO\r\nEND:VCALENDAR\r\n",
1589 escape_text(summary)
1590 )
1591 }
1592
1593 /// Random-enough UID from the kernel, no uuid dependency.
1594 fn new_uid() -> String {
1595 let mut bytes = [0u8; 16];
1596 if std::fs::File::open("/dev/urandom")
1597 .and_then(|mut f| std::io::Read::read_exact(&mut f, &mut bytes))
1598 .is_err()
1599 {
1600 // Fall back to a timestamp; uniqueness against one user's own list.
1601 return format!("CCE-{}", Utc::now().format("%Y%m%dT%H%M%S%fZ"));
1602 }
1603 let hex: String = bytes.iter().map(|b| format!("{b:02X}")).collect();
1604 format!("CCE-{}-{}-{}", &hex[..8], &hex[8..16], &hex[16..])
1605 }
1606
1607 #[cfg(test)]
1608 mod tests {
1609 use super::*;
1610
1611 fn item(text: &str, done: bool, uid: Option<&str>) -> Item {
1612 Item { text: text.into(), done, uid: uid.map(String::from) }
1613 }
1614
1615 fn todo(summary: &str, done: bool, etag: &str) -> RemoteTodo {
1616 RemoteTodo {
1617 url: reqwest::Url::parse("https://example.com/cal/x.ics").unwrap(),
1618 etag: etag.into(),
1619 summary: summary.into(),
1620 done,
1621 lines: Vec::new(),
1622 list: "L".into(),
1623 }
1624 }
1625
1626 fn synced(text: &str, done: bool, etag: &str) -> SyncedItem {
1627 SyncedItem {
1628 url: "https://example.com/cal/x.ics".into(),
1629 etag: etag.into(),
1630 account: "[email protected]".into(),
1631 text: text.into(),
1632 done,
1633 list: "L".into(),
1634 }
1635 }
1636
1637 fn rlist(title: &str, etag: &str) -> RemoteList {
1638 RemoteList {
1639 title: title.into(),
1640 etag: etag.into(),
1641 create_target: reqwest::Url::parse("https://example.com/l/tasks").unwrap(),
1642 }
1643 }
1644
1645 fn slist(title: &str, etag: &str) -> SyncedList {
1646 SyncedList { title: title.into(), etag: etag.into(), account: "a".into() }
1647 }
1648
1649 fn lfile(title: &str, id: Option<&str>) -> ListFile {
1650 ListFile { title: title.into(), id: id.map(String::from), items: Vec::new() }
1651 }
1652
1653 fn refs<'a>(m: &'a BTreeMap<String, RemoteTodo>) -> BTreeMap<String, &'a RemoteTodo> {
1654 m.iter().map(|(k, v)| (k.clone(), v)).collect()
1655 }
1656
1657 #[test]
1658 fn merge_decision_table() {
1659 let local = vec![
1660 item("unchanged", false, Some("u1")),
1661 item("toggled here", true, Some("u2")), // local change → push
1662 item("old name", false, Some("u3")), // remote change → pull
1663 item("fresh local", false, None), // no uid → create
1664 ];
1665 // u4 in state but not local → deleted here → push delete.
1666 // u5 on server, unknown → pull new. u6 server-completed, unknown → ignore.
1667 let mut state = SyncState::default();
1668 state.items.insert("u1".into(), synced("unchanged", false, "e1"));
1669 state.items.insert("u2".into(), synced("toggled here", false, "e2"));
1670 state.items.insert("u3".into(), synced("old name", false, "e3"));
1671 state.items.insert("u4".into(), synced("deleted here", false, "e4"));
1672 let mut remote = BTreeMap::new();
1673 remote.insert("u1".into(), todo("unchanged", false, "e1"));
1674 remote.insert("u2".into(), todo("toggled here", false, "e2"));
1675 remote.insert("u3".into(), todo("renamed on phone", false, "e3b"));
1676 remote.insert("u4".into(), todo("deleted here", false, "e4"));
1677 remote.insert("u5".into(), todo("from the phone", false, "e5"));
1678 remote.insert("u6".into(), todo("ancient done thing", true, "e6"));
1679
1680 let p = plan_items(&local, &state, &refs(&remote));
1681 assert_eq!(p.push_updates, vec!["u2"]);
1682 assert_eq!(p.pull_updates, vec!["u3"]);
1683 assert_eq!(p.push_deletes, vec!["u4"]);
1684 assert_eq!(p.pull_new, vec!["u5"]);
1685 assert_eq!(p.push_creates, vec![("fresh local".to_string(), false, None)]);
1686 assert!(p.pull_deletes.is_empty());
1687 }
1688
1689 #[test]
1690 fn both_changed_local_wins() {
1691 let local = vec![item("mine", false, Some("u1"))];
1692 let mut state = SyncState::default();
1693 state.items.insert("u1".into(), synced("base", false, "e1"));
1694 let mut remote = BTreeMap::new();
1695 remote.insert("u1".into(), todo("theirs", false, "e2"));
1696 let p = plan_items(&local, &state, &refs(&remote));
1697 assert_eq!(p.push_updates, vec!["u1"]);
1698 assert!(p.pull_updates.is_empty());
1699 }
1700
1701 #[test]
1702 fn server_deletion_pulls_row_out() {
1703 let local = vec![item("gone on phone", false, Some("u1"))];
1704 let mut state = SyncState::default();
1705 state.items.insert("u1".into(), synced("gone on phone", false, "e1"));
1706 let remote = BTreeMap::new();
1707 let p = plan_items(&local, &state, &refs(&remote));
1708 assert_eq!(p.pull_deletes, vec!["u1"]);
1709 assert!(p.push_creates.is_empty());
1710
1711 let mut items = local;
1712 apply_local(&mut items, &p, &refs(&remote), &[], &[]);
1713 assert!(items.is_empty());
1714 }
1715
1716 #[test]
1717 fn recreated_row_is_reannotated_by_its_stale_uid() {
1718 // A row carrying a uid nobody knows is recreated; the new uid must
1719 // replace the stale one on THAT row, not on a same-text sibling.
1720 let plan = Plan {
1721 push_creates: vec![("dup".into(), false, Some("stale".into()))],
1722 ..Default::default()
1723 };
1724 let mut items = vec![item("dup", false, None), item("dup", false, Some("stale"))];
1725 apply_local(&mut items, &plan, &BTreeMap::new(), &[("dup".into(), Some("stale".into()), "new".into())], &[]);
1726 assert_eq!(items[0].uid, None);
1727 assert_eq!(items[1].uid.as_deref(), Some("new"));
1728 }
1729
1730 #[test]
1731 fn moved_row_swaps_uid_via_moved_from() {
1732 let plan = Plan { push_creates: vec![("milk".into(), false, None)], ..Default::default() };
1733 let mut items = vec![item("milk", false, Some("from-other-list"))];
1734 apply_local(
1735 &mut items,
1736 &plan,
1737 &BTreeMap::new(),
1738 &[("milk".into(), None, "new".into())],
1739 &[("milk".into(), "from-other-list".into())],
1740 );
1741 assert_eq!(items[0].uid.as_deref(), Some("new"));
1742 }
1743
1744 #[test]
1745 fn patch_preserves_foreign_properties() {
1746 let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:u\r\nDUE;VALUE=DATE:20261001\r\nSUMMARY:old\r\nSTATUS:NEEDS-ACTION\r\nX-APPLE-SORT-ORDER:7\r\nEND:VTODO\r\nEND:VCALENDAR\r\n";
1747 let patched = patch_vtodo(&unfold(ics), "new, name", true);
1748 assert!(patched.contains("DUE;VALUE=DATE:20261001"));
1749 assert!(patched.contains("X-APPLE-SORT-ORDER:7"));
1750 assert!(patched.contains("SUMMARY:new\\, name"));
1751 assert!(patched.contains("STATUS:COMPLETED"));
1752 assert!(patched.contains("PERCENT-COMPLETE:100"));
1753 assert!(!patched.contains("SUMMARY:old"));
1754 assert!(!patched.contains("NEEDS-ACTION"));
1755 }
1756
1757 #[test]
1758 fn fresh_edits_survive_apply_local() {
1759 // A row typed while the sync was talking to the network is untouched.
1760 let plan = Plan { pull_new: vec!["u9".into()], ..Default::default() };
1761 let mut remote = BTreeMap::new();
1762 remote.insert("u9".into(), todo("from phone", false, "e9"));
1763 let mut items = vec![item("typed mid-sync", false, None)];
1764 apply_local(&mut items, &plan, &refs(&remote), &[], &[]);
1765 assert_eq!(items.len(), 2);
1766 assert_eq!(items[0].text, "typed mid-sync");
1767 assert_eq!(items[1].uid.as_deref(), Some("u9"));
1768 }
1769
1770 #[test]
1771 fn list_decision_table() {
1772 let local = vec![
1773 lfile("Tasks", Some("L1")), // unchanged
1774 lfile("Errands", Some("L2")), // renamed here (base "Chores") → push
1775 lfile("Work", Some("L3")), // renamed on phone → pull
1776 lfile("Gone remote", Some("L4")), // server deleted it → pull delete
1777 lfile("Brand new", None), // → push create
1778 lfile("Orphan", Some("LX")), // header nobody knows → recreate
1779 lfile("Tasks (legacy)", Some("L7")), // never synced as list; server name wins
1780 ];
1781 let mut state = BTreeMap::new();
1782 state.insert("L1".into(), slist("Tasks", "e1"));
1783 state.insert("L2".into(), slist("Chores", "e2"));
1784 state.insert("L3".into(), slist("Work", "e3"));
1785 state.insert("L4".into(), slist("Gone remote", "e4"));
1786 state.insert("L5".into(), slist("Deleted here", "e5")); // no file → push delete
1787 let mut remote = BTreeMap::new();
1788 remote.insert("L1".into(), rlist("Tasks", "e1"));
1789 remote.insert("L2".into(), rlist("Chores", "e2"));
1790 remote.insert("L3".into(), rlist("Office", "e3b"));
1791 remote.insert("L5".into(), rlist("Deleted here", "e5"));
1792 remote.insert("L6".into(), rlist("From the phone", "e6")); // → pull new
1793 remote.insert("L7".into(), rlist("LSGalante12's list", "e7"));
1794
1795 let p = plan_lists(&local, &state, &remote);
1796 assert_eq!(p.push_renames, vec![("L2".to_string(), "Errands".to_string())]);
1797 assert_eq!(p.pull_renames, vec![
1798 ("L3".to_string(), "Work".to_string(), "Office".to_string()),
1799 ("L7".to_string(), "Tasks (legacy)".to_string(), "LSGalante12's list".to_string()),
1800 ]);
1801 assert_eq!(p.pull_deletes, vec![("L4".to_string(), "Gone remote".to_string())]);
1802 assert_eq!(p.push_creates, vec![
1803 ("Brand new".to_string(), None),
1804 ("Orphan".to_string(), Some("LX".to_string())),
1805 ]);
1806 assert_eq!(p.push_deletes, vec!["L5"]);
1807 assert_eq!(p.pull_new, vec!["L6"]);
1808 // L1 matches the base exactly; nothing to record.
1809 assert!(p.refresh.is_empty());
1810 }
1811
1812 #[test]
1813 fn list_rename_both_sides_local_wins() {
1814 let local = vec![lfile("Mine", Some("L1"))];
1815 let mut state = BTreeMap::new();
1816 state.insert("L1".into(), slist("Base", "e1"));
1817 let mut remote = BTreeMap::new();
1818 remote.insert("L1".into(), rlist("Theirs", "e2"));
1819 let p = plan_lists(&local, &state, &remote);
1820 assert_eq!(p.push_renames, vec![("L1".to_string(), "Mine".to_string())]);
1821 assert!(p.pull_renames.is_empty());
1822 }
1823 }