git.lucas.co / cce-list
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 }