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(¤t, &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 }