git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

src/raindrop/mod.rs (30K)

  1 //! Bookmark sync with Raindrop.io — phase 1: the merge, with no network.
  2 //!
  3 //! The design is in `RAINDROP-SYNC.md`. In short: the local `bookmarks.tsv`
  4 //! stays the live store the chrome reads, and a sync pass reconciles it with
  5 //! one Raindrop collection by a **three-way merge** against `base` — the set
  6 //! as it stood after the last successful sync, kept in `raindrop-sync.tsv`.
  7 //! Only a base can tell "deleted over there" from "new over here".
  8 //!
  9 //! Everything in this file is a pure function of its inputs, because that is
 10 //! where a sync goes wrong: the API client (phase 2) only fetches and sends,
 11 //! and the browser (phase 3) only applies. Rules the merge keeps:
 12 //!
 13 //! * **Identity is the Raindrop `_id`, not the URL.** A base entry pairs a
 14 //!   local URL with a remote id; Raindrop may tidy a link, and keying on it
 15 //!   would read every tidied link as one deletion plus one creation, forever.
 16 //!   URLs pair only things the base has never seen (the first run, or the
 17 //!   same page saved on both sides in between).
 18 //! * **The browser owns the link and the title, nothing else.** A plan never
 19 //!   carries tags, notes or collections, so it cannot clobber them.
 20 //! * **A first run never deletes**: with an empty base nothing was ever
 21 //!   synced, so nothing can have been deleted since.
 22 //! * **Mass deletions are refused** (`guard`): an emptied file, the wrong
 23 //!   collection or an API answer that came back empty all look like "delete
 24 //!   everything", and that must take a person to confirm.
 25 //! * **Raindrop's duplicates are left alone.** Locally a URL is a key; a second
 26 //!   Raindrop entry for a link already here is neither imported nor removed.
 27 
 28 pub mod api;
 29 pub mod sync;
 30 
 31 use std::collections::{HashMap, HashSet};
 32 use std::path::{Path, PathBuf};
 33 
 34 pub(crate) fn unix_now() -> u64 {
 35     std::time::SystemTime::now()
 36         .duration_since(std::time::UNIX_EPOCH)
 37         .map(|d| d.as_secs())
 38         .unwrap_or(0)
 39 }
 40 
 41 /// A Raindrop bookmark's id (`_id` in the API).
 42 pub type RaindropId = u64;
 43 
 44 /// A local bookmark: a row of `bookmarks.tsv`.
 45 #[derive(Clone, Debug, PartialEq)]
 46 pub struct Local {
 47     pub url: String,
 48     pub title: String,
 49     /// When it was bookmarked, in seconds — the local order.
 50     pub ts: u64,
 51 }
 52 
 53 /// A Raindrop bookmark, as much of it as the merge reads.
 54 #[derive(Clone, Debug, PartialEq)]
 55 pub struct Remote {
 56     pub id: RaindropId,
 57     pub link: String,
 58     pub title: String,
 59     /// `created`, in seconds — becomes `ts` when imported.
 60     pub created: u64,
 61 }
 62 
 63 /// One pair as it stood after the last successful sync.
 64 #[derive(Clone, Debug, PartialEq)]
 65 pub struct Synced {
 66     pub id: RaindropId,
 67     /// The local URL this id is paired with.
 68     pub url: String,
 69     /// The title both sides agreed on, which is what says *which side* changed
 70     /// it since.
 71     pub title: String,
 72 }
 73 
 74 /// What one sync pass will do. Applied in field order on each side.
 75 #[derive(Clone, Debug, Default, PartialEq)]
 76 pub struct Plan {
 77     /// New here: create in Raindrop (link and title only).
 78     pub create_remote: Vec<Local>,
 79     pub rename_remote: Vec<(RaindropId, String)>,
 80     /// Deleted here: move to Raindrop's trash, where it stays recoverable.
 81     pub trash_remote: Vec<RaindropId>,
 82     /// The link was edited in Raindrop: `(old local URL, new URL)`.
 83     pub relink_local: Vec<(String, String)>,
 84     /// `(local URL, new title)` — by the URL *after* any relink.
 85     pub rename_local: Vec<(String, String)>,
 86     pub delete_local: Vec<String>,
 87     /// New in Raindrop: add here.
 88     pub add_local: Vec<Local>,
 89     /// The base after this plan, except the pairs `create_remote` makes: their
 90     /// ids exist only once Raindrop answers. See [`Plan::base_after`].
 91     pub base: Vec<Synced>,
 92 }
 93 
 94 impl Plan {
 95     /// Whether this pass changes nothing on either side.
 96     pub fn is_noop(&self) -> bool {
 97         self.create_remote.is_empty()
 98             && self.rename_remote.is_empty()
 99             && self.trash_remote.is_empty()
100             && self.relink_local.is_empty()
101             && self.rename_local.is_empty()
102             && self.delete_local.is_empty()
103             && self.add_local.is_empty()
104     }
105 
106     /// The base to save once the remote side has been applied (`prior` is the
107     /// base the plan was made from). It must describe what *happened*, not
108     /// what was planned, or the next pass misreads a failure:
109     ///
110     /// * a create that failed stays out of the base, so it is tried again as
111     ///   "new here" — never read as deleted in Raindrop;
112     /// * a trash that failed keeps its old pair, so it is tried again — not
113     ///   re-imported as "new in Raindrop";
114     /// * a rename that failed keeps its old title, so Raindrop's unchanged
115     ///   title is not read as Raindrop renaming it back.
116     pub fn base_after(&self, prior: &[Synced], applied: &api::Applied) -> Vec<Synced> {
117         let mut base = self.base.clone();
118         for id in &applied.failed_renames {
119             if let (Some(b), Some(old)) =
120                 (base.iter_mut().find(|b| b.id == *id), prior.iter().find(|p| p.id == *id))
121             {
122                 b.title = old.title.clone();
123             }
124         }
125         for id in &applied.failed_trash {
126             if let Some(old) = prior.iter().find(|p| p.id == *id) {
127                 base.push(old.clone());
128             }
129         }
130         for (url, id) in &applied.created {
131             if let Some(l) = self.create_remote.iter().find(|l| l.url == *url) {
132                 base.push(Synced { id: *id, url: l.url.clone(), title: l.title.clone() });
133             }
134         }
135         base
136     }
137 }
138 
139 /// A pass refused by [`guard`], with the plan it would have run, so a person
140 /// can look at it and force it.
141 #[derive(Clone, Debug, PartialEq)]
142 pub struct Refusal {
143     pub reason: String,
144     pub plan: Plan,
145 }
146 
147 /// More deletions than this on one side in one pass needs a person.
148 pub const MAX_DELETES: usize = 10;
149 /// A side this large being emptied outright needs a person.
150 pub const WIPE_MIN: usize = 3;
151 
152 /// The key two URLs are paired on when the base does not know them: scheme
153 /// and host case-folded (the URL parser does that) and one trailing slash
154 /// dropped. Only http(s) syncs — Raindrop stores web links, and a `file:`
155 /// bookmark means nothing on another machine. Not an identity: once paired,
156 /// the id is.
157 pub fn pair_key(url: &str) -> Option<String> {
158     let u = url::Url::parse(url.trim()).ok()?;
159     if !matches!(u.scheme(), "http" | "https") {
160         return None;
161     }
162     let mut s = u.to_string();
163     if s.ends_with('/') {
164         s.pop();
165     }
166     Some(s)
167 }
168 
169 /// Work out one pass. `Err` when the pass trips the deletion guard.
170 pub fn plan(local: &[Local], remote: &[Remote], base: &[Synced]) -> Result<Plan, Refusal> {
171     let mut plan = Plan::default();
172     let local_by_url: HashMap<&str, &Local> = local.iter().map(|l| (l.url.as_str(), l)).collect();
173     let remote_by_id: HashMap<RaindropId, &Remote> = remote.iter().map(|r| (r.id, r)).collect();
174     // Every local URL's pairing key, so nothing imported duplicates one.
175     let local_keys: HashSet<String> = local.iter().filter_map(|l| pair_key(&l.url)).collect();
176     let mut used_local: HashSet<&str> = HashSet::new();
177     let mut used_remote: HashSet<RaindropId> = HashSet::new();
178 
179     // 1. What the base knows: three-way.
180     for b in base {
181         let l = local_by_url.get(b.url.as_str()).copied();
182         let r = remote_by_id.get(&b.id).copied();
183         if let Some(l) = l {
184             used_local.insert(l.url.as_str());
185         }
186         if let Some(r) = r {
187             used_remote.insert(r.id);
188         }
189         match (l, r) {
190             (Some(l), Some(r)) => {
191                 // Raindrop's link edited since: follow it, unless the new
192                 // link is already a bookmark here — then the pair stays put.
193                 let mut url = l.url.clone();
194                 if pair_key(&r.link) != pair_key(&l.url) {
195                     match pair_key(&r.link) {
196                         Some(k) if !local_keys.contains(&k) => {
197                             plan.relink_local.push((l.url.clone(), r.link.clone()));
198                             url = r.link.clone();
199                         }
200                         _ => {}
201                     }
202                 }
203                 // Title: whichever side moved off the base wins; both moved,
204                 // Raindrop wins (the browser has no rename for bookmarks, so
205                 // a local change only comes from a hand-edited file).
206                 let title = if r.title != b.title {
207                     if l.title != r.title {
208                         plan.rename_local.push((url.clone(), r.title.clone()));
209                     }
210                     r.title.clone()
211                 } else {
212                     if l.title != b.title {
213                         plan.rename_remote.push((r.id, l.title.clone()));
214                     }
215                     l.title.clone()
216                 };
217                 plan.base.push(Synced { id: r.id, url, title });
218             }
219             // Gone from the collection — deleted there, or moved out of it.
220             (Some(l), None) => plan.delete_local.push(l.url.clone()),
221             (None, Some(r)) => plan.trash_remote.push(r.id),
222             // Gone on both sides: nothing to do, and it leaves the base.
223             (None, None) => {}
224         }
225     }
226 
227     // 2. What it does not: pair by URL, else it is new on its own side.
228     let mut unpaired_remote: Vec<(String, &Remote)> = Vec::new();
229     let mut seen: HashSet<String> = HashSet::new();
230     for r in remote.iter().filter(|r| !used_remote.contains(&r.id)) {
231         if let Some(k) = pair_key(&r.link) {
232             // The first of Raindrop's duplicates speaks for the link.
233             if seen.insert(k.clone()) {
234                 unpaired_remote.push((k, r));
235             }
236         }
237     }
238     let mut taken: HashSet<RaindropId> = HashSet::new();
239     for l in local.iter().filter(|l| !used_local.contains(l.url.as_str())) {
240         let Some(k) = pair_key(&l.url) else { continue };
241         match unpaired_remote.iter().find(|(rk, r)| *rk == k && !taken.contains(&r.id)) {
242             Some((_, r)) => {
243                 // Paired for the first time: Raindrop's title wins, since
244                 // that is where titles get edited.
245                 taken.insert(r.id);
246                 if l.title != r.title {
247                     plan.rename_local.push((l.url.clone(), r.title.clone()));
248                 }
249                 plan.base.push(Synced { id: r.id, url: l.url.clone(), title: r.title.clone() });
250             }
251             None => plan.create_remote.push(l.clone()),
252         }
253     }
254     // A link being deleted here this pass is free again: Raindrop's entry
255     // for it is a re-save (deleted and saved anew there), not a duplicate.
256     let leaving: HashSet<String> = plan.delete_local.iter().filter_map(|u| pair_key(u)).collect();
257     for (k, r) in &unpaired_remote {
258         if taken.contains(&r.id) || (local_keys.contains(k) && !leaving.contains(k)) {
259             continue;
260         }
261         plan.add_local.push(Local { url: r.link.clone(), title: r.title.clone(), ts: r.created });
262         plan.base.push(Synced { id: r.id, url: r.link.clone(), title: r.title.clone() });
263     }
264 
265     match guard(&plan, local.len(), remote.len(), base.len()) {
266         Some(reason) => Err(Refusal { reason, plan }),
267         None => Ok(plan),
268     }
269 }
270 
271 /// Why a plan must not run unattended, if it must not.
272 pub fn guard(plan: &Plan, local_len: usize, remote_len: usize, base_len: usize) -> Option<String> {
273     if remote_len == 0 && base_len > 0 {
274         return Some(format!(
275             "Raindrop returned no bookmarks, but {base_len} were synced before — \
276              the wrong collection, or a token for another account?"
277         ));
278     }
279     for (side, deletes, size) in [
280         ("here", plan.delete_local.len(), local_len),
281         ("in Raindrop", plan.trash_remote.len(), remote_len),
282     ] {
283         if deletes > MAX_DELETES {
284             return Some(format!("this would delete {deletes} bookmarks {side} at once"));
285         }
286         if size >= WIPE_MIN && deletes == size {
287             return Some(format!("this would delete every bookmark {side} ({size})"));
288         }
289     }
290     None
291 }
292 
293 /// What [`apply_local`] left undone.
294 #[derive(Debug, Default, PartialEq)]
295 pub struct Skipped {
296     pub count: usize,
297     /// The *new* URLs of link edits not applied. Their pairs must leave the
298     /// base: it would pair the id with a URL that is not here, and the next
299     /// pass would read that as "deleted here" and trash the Raindrop copy.
300     /// Dropped from the base, the two simply re-pair (or both survive).
301     pub relinks: Vec<String>,
302 }
303 
304 /// Apply a plan's local half to the bookmarks as they are **now**.
305 ///
306 /// The plan was made from a `snapshot`, and the person may have bookmarked or
307 /// removed something while Raindrop was answering. An operation on a URL whose
308 /// entry is not what the snapshot had is skipped — the next pass sees the new
309 /// state and plans again — so a local edit is never overwritten by a stale
310 /// plan.
311 pub fn apply_local(current: &mut Vec<Local>, snapshot: &[Local], plan: &Plan) -> Skipped {
312     let unchanged = |current: &Vec<Local>, url: &str| {
313         current.iter().find(|l| l.url == url) == snapshot.iter().find(|l| l.url == url)
314     };
315     let mut skipped = 0;
316     let mut relinks = Vec::new();
317     for (old, new) in &plan.relink_local {
318         if unchanged(current, old) && !current.iter().any(|l| l.url == *new) {
319             if let Some(l) = current.iter_mut().find(|l| l.url == *old) {
320                 l.url = new.clone();
321             }
322         } else {
323             skipped += 1;
324             relinks.push(new.clone());
325         }
326     }
327     for (url, title) in &plan.rename_local {
328         // Renames address the post-relink URL; a relinked entry was checked
329         // against the snapshot by its old one above.
330         let relinked =
331             plan.relink_local.iter().any(|(_, n)| n == url) && !relinks.contains(url);
332         if relinked || unchanged(current, url) {
333             if let Some(l) = current.iter_mut().find(|l| l.url == *url) {
334                 l.title = title.clone();
335                 continue;
336             }
337         }
338         skipped += 1;
339     }
340     for url in &plan.delete_local {
341         if unchanged(current, url) {
342             current.retain(|l| l.url != *url);
343         } else {
344             skipped += 1;
345         }
346     }
347     for add in &plan.add_local {
348         if current.iter().any(|l| l.url == add.url) {
349             skipped += 1;
350         } else {
351             current.push(add.clone());
352         }
353     }
354     // Local order is bookmarking time; an import lands where it was made.
355     current.sort_by_key(|l| l.ts);
356     Skipped { count: skipped, relinks }
357 }
358 
359 /// The base to save after a pass: `base_after`, minus the pairs of link edits
360 /// that were skipped here (see [`Skipped::relinks`]).
361 pub fn settle_base(plan: &Plan, prior: &[Synced], applied: &api::Applied, skipped: &Skipped) -> Vec<Synced> {
362     let mut base = plan.base_after(prior, applied);
363     base.retain(|b| !skipped.relinks.contains(&b.url));
364     base
365 }
366 
367 /// `cce-browser --raindrop-plan`: fetch Unsorted, plan a pass against the
368 /// local bookmarks and the base, and describe it — changing nothing on either
369 /// side. The way to look at a real account before anything is allowed to
370 /// write to it.
371 pub fn dry_run() -> Result<String, String> {
372     let local: Vec<Local> = crate::pages::Bookmarks::load()
373         .rows()
374         .into_iter()
375         .map(|(ts, url, title)| Local { url, title, ts })
376         .collect();
377     let base = load_base(&state_path());
378     let token = crate::accounts::raindrop_token()?;
379     let remote = api::Client::new(token).fetch(api::UNSORTED).map_err(|e| e.to_string())?;
380     let mut out = String::from("dry run: nothing has been changed\n\n");
381     match plan(&local, &remote, &base) {
382         Ok(p) => out.push_str(&api::describe(&p, &local, &remote, &base)),
383         Err(refused) => {
384             out.push_str(&format!("REFUSED: {}\n\n", refused.reason));
385             out.push_str(&api::describe(&refused.plan, &local, &remote, &base));
386         }
387     }
388     Ok(out)
389 }
390 
391 /// `~/.local/state/cce/browser/raindrop-sync.tsv`.
392 pub fn state_path() -> PathBuf {
393     crate::pages::state_dir().join("raindrop-sync.tsv")
394 }
395 
396 /// Read the base: `id \t url \t title` per line. A malformed line is dropped,
397 /// and so is a repeat of an id or a URL — a pair is one-to-one, and a second
398 /// claim on either half can only be damage.
399 pub fn load_base(path: &Path) -> Vec<Synced> {
400     let Ok(text) = std::fs::read_to_string(path) else { return Vec::new() };
401     let mut ids = HashSet::new();
402     let mut urls = HashSet::new();
403     let mut out = Vec::new();
404     for line in text.lines() {
405         let mut parts = line.splitn(3, '\t');
406         let (Some(id), Some(url), Some(title)) = (parts.next(), parts.next(), parts.next()) else {
407             continue;
408         };
409         let Ok(id) = id.parse::<RaindropId>() else { continue };
410         if url.is_empty() || !ids.insert(id) || !urls.insert(url.to_string()) {
411             continue;
412         }
413         out.push(Synced { id, url: url.to_string(), title: title.to_string() });
414     }
415     out
416 }
417 
418 /// Write the base, atomically: a pass that dies mid-write must leave the old
419 /// base, not half a new one — half a base reads as half the bookmarks deleted.
420 pub fn save_base(path: &Path, base: &[Synced]) -> std::io::Result<()> {
421     if let Some(dir) = path.parent() {
422         std::fs::create_dir_all(dir)?;
423     }
424     let field = |s: &str| s.replace(['\t', '\n', '\r'], " ");
425     let mut out = String::new();
426     for b in base {
427         out.push_str(&format!("{}\t{}\t{}\n", b.id, field(&b.url), field(&b.title)));
428     }
429     let tmp = path.with_extension("tsv.tmp");
430     std::fs::write(&tmp, out)?;
431     std::fs::rename(&tmp, path)
432 }
433 
434 #[cfg(test)]
435 mod tests {
436     use super::*;
437 
438     fn l(url: &str, title: &str, ts: u64) -> Local {
439         Local { url: url.into(), title: title.into(), ts }
440     }
441     fn r(id: RaindropId, link: &str, title: &str) -> Remote {
442         Remote { id, link: link.into(), title: title.into(), created: 1000 + id }
443     }
444     fn s(id: RaindropId, url: &str, title: &str) -> Synced {
445         Synced { id, url: url.into(), title: title.into() }
446     }
447 
448     /// Run a whole pass the way phases 2 and 3 will: plan, apply to Raindrop
449     /// (handing out ids), apply locally, save the base. Returns the new state.
450     fn run(
451         local: &[Local],
452         remote: &[Remote],
453         base: &[Synced],
454     ) -> (Vec<Local>, Vec<Remote>, Vec<Synced>) {
455         let p = plan(local, remote, base).expect("not refused");
456         let mut remote = remote.to_vec();
457         remote.retain(|x| !p.trash_remote.contains(&x.id));
458         for (id, t) in &p.rename_remote {
459             remote.iter_mut().find(|x| x.id == *id).unwrap().title = t.clone();
460         }
461         let mut created = Vec::new();
462         for c in &p.create_remote {
463             let id = 500 + remote.len() as RaindropId + created.len() as RaindropId;
464             remote.push(Remote { id, link: c.url.clone(), title: c.title.clone(), created: c.ts });
465             created.push((c.url.clone(), id));
466         }
467         let mut local_now = local.to_vec();
468         assert_eq!(apply_local(&mut local_now, local, &p).count, 0);
469         let applied = api::Applied { created, ..Default::default() };
470         (local_now, remote, p.base_after(base, &applied))
471     }
472 
473     #[test]
474     fn a_first_run_unions_and_pairs_by_link() {
475         let local = [l("https://Example.com/", "mine", 1), l("https://only-here.test/a", "A", 2)];
476         let remote = [r(1, "https://example.com", "theirs"), r(2, "https://only-there.test/", "B")];
477         let p = plan(&local, &remote, &[]).unwrap();
478         assert!(p.delete_local.is_empty() && p.trash_remote.is_empty(), "a first run never deletes");
479         assert_eq!(p.rename_local, vec![("https://Example.com/".into(), "theirs".into())]);
480         assert_eq!(p.create_remote, vec![l("https://only-here.test/a", "A", 2)]);
481         assert_eq!(p.add_local, vec![l("https://only-there.test/", "B", 1002)]);
482         assert!(p.base.contains(&s(1, "https://Example.com/", "theirs")));
483     }
484 
485     #[test]
486     fn deletions_cross_over_through_the_base() {
487         let base = [s(1, "https://a.test/", "A"), s(2, "https://b.test/", "B")];
488         // a.test deleted here, b.test deleted in Raindrop.
489         let local = [l("https://b.test/", "B", 1)];
490         let remote = [r(1, "https://a.test/", "A")];
491         let p = plan(&local, &remote, &base).unwrap();
492         assert_eq!(p.trash_remote, vec![1]);
493         assert_eq!(p.delete_local, vec!["https://b.test/".to_string()]);
494         assert!(p.base.is_empty());
495         assert!(p.add_local.is_empty() && p.create_remote.is_empty(), "no resurrection");
496     }
497 
498     #[test]
499     fn titles_follow_whichever_side_moved() {
500         let base = [s(1, "https://a.test/", "A"), s(2, "https://b.test/", "B"), s(3, "https://c.test/", "C")];
501         let local = [l("https://a.test/", "A", 1), l("https://b.test/", "B local", 2), l("https://c.test/", "C local", 3)];
502         let remote = [r(1, "https://a.test/", "A remote"), r(2, "https://b.test/", "B"), r(3, "https://c.test/", "C remote")];
503         let p = plan(&local, &remote, &base).unwrap();
504         assert_eq!(
505             p.rename_local,
506             vec![("https://a.test/".into(), "A remote".into()), ("https://c.test/".into(), "C remote".into())],
507             "Raindrop's edit lands here, and wins when both moved"
508         );
509         assert_eq!(p.rename_remote, vec![(2, "B local".into())]);
510     }
511 
512     #[test]
513     fn a_link_edited_in_raindrop_is_followed() {
514         let base = [s(1, "http://old.test/page", "P")];
515         let local = [l("http://old.test/page", "P", 1)];
516         let remote = [r(1, "https://new.test/page", "P")];
517         let p = plan(&local, &remote, &base).unwrap();
518         assert_eq!(p.relink_local, vec![("http://old.test/page".into(), "https://new.test/page".into())]);
519         assert!(p.delete_local.is_empty() && p.add_local.is_empty());
520         assert_eq!(p.base, vec![s(1, "https://new.test/page", "P")]);
521     }
522 
523     #[test]
524     fn a_tidied_link_is_not_a_change() {
525         let base = [s(1, "https://Example.com/x/", "X")];
526         let local = [l("https://Example.com/x/", "X", 1)];
527         let remote = [r(1, "https://example.com/x", "X")];
528         assert!(plan(&local, &remote, &base).unwrap().is_noop());
529     }
530 
531     #[test]
532     fn raindrop_duplicates_are_left_alone() {
533         let base = [s(1, "https://a.test/", "A")];
534         let local = [l("https://a.test/", "A", 1)];
535         let remote = [r(1, "https://a.test/", "A"), r(2, "https://a.test", "A again"), r(3, "https://b.test/", "B"), r(4, "https://b.test/", "B again")];
536         let p = plan(&local, &remote, &base).unwrap();
537         assert_eq!(p.add_local, vec![l("https://b.test/", "B", 1003)], "one import per link");
538         assert!(p.trash_remote.is_empty(), "and nothing of theirs is removed");
539     }
540 
541     #[test]
542     fn a_link_re_saved_in_raindrop_settles_in_one_pass() {
543         // Deleted and saved again in Raindrop: same link, a new id.
544         let base = [s(1, "https://a.test/", "A")];
545         let local = [l("https://a.test/", "A", 1)];
546         let remote = [r(7, "https://a.test/", "A anew")];
547         let (local, remote, base) = run(&local, &remote, &base);
548         assert_eq!(local, vec![l("https://a.test/", "A anew", 1007)]);
549         assert!(plan(&local, &remote, &base).unwrap().is_noop());
550     }
551 
552     #[test]
553     fn only_web_links_sync() {
554         let local = [l("file:///home/me/notes.html", "notes", 1)];
555         let p = plan(&local, &[r(1, "https://a.test/", "A")], &[]).unwrap();
556         assert!(p.create_remote.is_empty(), "a file: bookmark stays here");
557     }
558 
559     #[test]
560     fn mass_deletion_is_refused() {
561         let n = MAX_DELETES as RaindropId + 1;
562         let base: Vec<_> = (1..=n).map(|i| s(i, &format!("https://{i}.test/"), "t")).collect();
563         let mut remote: Vec<_> = (1..=n).map(|i| r(i, &format!("https://{i}.test/"), "t")).collect();
564         remote.push(r(99, "https://keep.test/", "k"));
565         let err = plan(&[], &remote, &base).unwrap_err();
566         assert!(err.reason.contains("11 bookmarks in Raindrop"), "{}", err.reason);
567         assert_eq!(err.plan.trash_remote.len(), 11, "the refused plan is kept for a person to force");
568     }
569 
570     #[test]
571     fn emptying_a_side_is_refused() {
572         let base: Vec<_> = (1..=3).map(|i| s(i, &format!("https://{i}.test/"), "t")).collect();
573         let local: Vec<_> = (1..=3).map(|i| l(&format!("https://{i}.test/"), "t", i)).collect();
574         let remote: Vec<_> = (1..=3).map(|i| r(i, &format!("https://{i}.test/"), "t")).collect();
575         assert!(plan(&[], &remote, &base).unwrap_err().reason.contains("every bookmark in Raindrop"));
576         assert!(plan(&local, &[], &base).unwrap_err().reason.contains("returned no bookmarks"));
577     }
578 
579     #[test]
580     fn ordinary_deletion_is_allowed() {
581         let base = [s(1, "https://a.test/", "A"), s(2, "https://b.test/", "B")];
582         let local = [l("https://a.test/", "A", 1)];
583         let remote = [r(1, "https://a.test/", "A"), r(2, "https://b.test/", "B")];
584         assert_eq!(plan(&local, &remote, &base).unwrap().trash_remote, vec![2]);
585         // Deleting the only bookmark is a person's choice, not a wipe.
586         let p = plan(&[], &[r(1, "https://a.test/", "A")], &[s(1, "https://a.test/", "A")]).unwrap();
587         assert_eq!(p.trash_remote, vec![1]);
588     }
589 
590     #[test]
591     fn a_pass_settles_and_the_next_one_is_a_noop() {
592         let local = vec![l("https://a.test/", "A", 1), l("https://here.test/", "H", 2)];
593         let remote = vec![r(1, "https://a.test", "A (theirs)"), r(2, "https://there.test/", "T")];
594         let (local, remote, base) = run(&local, &remote, &[]);
595         assert_eq!(local.len(), 3);
596         assert_eq!(remote.len(), 3);
597         assert!(plan(&local, &remote, &base).unwrap().is_noop(), "a synced state plans nothing");
598 
599         // A deletion on each side, then a rename in Raindrop, each settles.
600         let local: Vec<_> = local.into_iter().filter(|x| x.url != "https://here.test/").collect();
601         let (local, mut remote, base) = run(&local, &remote, &base);
602         assert_eq!(remote.len(), 2);
603         remote.iter_mut().find(|x| x.link.contains("there")).unwrap().title = "T2".into();
604         let (local, remote, base) = run(&local, &remote, &base);
605         assert!(local.iter().any(|x| x.title == "T2"));
606         assert!(plan(&local, &remote, &base).unwrap().is_noop());
607     }
608 
609     #[test]
610     fn a_failed_create_is_retried_not_deleted() {
611         let local = [l("https://new.test/", "N", 1)];
612         let p = plan(&local, &[], &[]).unwrap();
613         let base = p.base_after(&[], &api::Applied::default()); // Raindrop refused the create
614         let again = plan(&local, &[], &base).unwrap();
615         assert_eq!(again.create_remote.len(), 1);
616         assert!(again.delete_local.is_empty());
617     }
618 
619     #[test]
620     fn failed_trash_and_rename_are_retried_not_reversed() {
621         let prior = [s(1, "https://a.test/", "A"), s(2, "https://b.test/", "B")];
622         // a.test deleted here; b.test renamed here.
623         let local = [l("https://b.test/", "B new", 2)];
624         let remote = [r(1, "https://a.test/", "A"), r(2, "https://b.test/", "B")];
625         let p = plan(&local, &remote, &prior).unwrap();
626         assert_eq!((p.trash_remote.clone(), p.rename_remote.clone()), (vec![1], vec![(2, "B new".to_string())]));
627         // Both calls fail.
628         let applied = api::Applied { failed_trash: vec![1], failed_renames: vec![2], ..Default::default() };
629         let base = p.base_after(&prior, &applied);
630         let again = plan(&local, &remote, &base).unwrap();
631         assert_eq!(again.trash_remote, vec![1], "the trash is retried");
632         assert!(again.add_local.is_empty(), "not re-imported");
633         assert_eq!(again.rename_remote, vec![(2, "B new".to_string())], "the rename is retried");
634         assert!(again.rename_local.is_empty(), "not reversed");
635     }
636 
637     #[test]
638     fn edits_made_during_a_pass_survive_it() {
639         let snapshot = vec![l("https://a.test/", "A", 1), l("https://b.test/", "B", 2)];
640         let p = Plan {
641             delete_local: vec!["https://a.test/".into()],
642             rename_local: vec![("https://b.test/".into(), "B2".into())],
643             add_local: vec![l("https://c.test/", "C", 3)],
644             ..Plan::default()
645         };
646         // Meanwhile: a.test re-bookmarked with a new title, c.test bookmarked.
647         let mut current = vec![l("https://a.test/", "A again", 5), l("https://b.test/", "B", 2), l("https://c.test/", "mine", 6)];
648         assert_eq!(apply_local(&mut current, &snapshot, &p).count, 2);
649         assert!(current.iter().any(|x| x.url == "https://a.test/" && x.title == "A again"));
650         assert!(current.iter().any(|x| x.url == "https://b.test/" && x.title == "B2"));
651         assert!(current.iter().any(|x| x.url == "https://c.test/" && x.title == "mine"));
652     }
653 
654     #[test]
655     fn a_skipped_link_edit_cannot_become_a_deletion() {
656         let prior = [s(1, "http://old.test/", "P")];
657         let snapshot = vec![l("http://old.test/", "P", 1)];
658         let remote = [r(1, "https://new.test/", "P")];
659         let p = plan(&snapshot, &remote, &prior).unwrap();
660         // Mid-pass the person re-bookmarked the old link with a new title.
661         let mut current = vec![l("http://old.test/", "P again", 9)];
662         let skipped = apply_local(&mut current, &snapshot, &p);
663         assert_eq!(skipped.relinks, vec!["https://new.test/".to_string()]);
664         let base = settle_base(&p, &prior, &api::Applied::default(), &skipped);
665         let next = plan(&current, &remote, &base).unwrap();
666         assert!(next.trash_remote.is_empty(), "Raindrop's copy is not trashed");
667         assert!(next.delete_local.is_empty(), "and neither is the local one");
668     }
669 
670     #[test]
671     fn the_base_round_trips_and_drops_damage() {
672         let dir = std::env::temp_dir().join(format!("cce-raindrop-{}", std::process::id()));
673         let path = dir.join("raindrop-sync.tsv");
674         save_base(&path, &[s(1, "https://a.test/", "tab\there"), s(2, "https://b.test/", "B")]).unwrap();
675         let mut text = std::fs::read_to_string(&path).unwrap();
676         text.push_str("3\thttps://a.test/\tsame url\n1\thttps://c.test/\tsame id\nnot a line\n");
677         std::fs::write(&path, text).unwrap();
678         assert_eq!(load_base(&path), vec![s(1, "https://a.test/", "tab here"), s(2, "https://b.test/", "B")]);
679         assert!(!path.with_extension("tsv.tmp").exists());
680         let _ = std::fs::remove_dir_all(dir);
681     }
682 }