Skip to main content

linkmarks_cli/cmd/
sync.rs

1//! `linkmarks sync` — multi-device sync via yrs-encoded payload.
2//!
3//! **Status**: preview / dry-run only. The local state encoding and the
4//! wire format are validated; the actual `POST /v1/sync` round-trip is
5//! pending in the relay binary.
6//!
7//! What this subcommand does today:
8//!
9//! 1. Open the local SQLite store.
10//! 2. List every bookmark (`store::Store::list` with high limit).
11//! 3. Build a per-collection `yrs::Doc`. Bookmarks are grouped by
12//!    `Bookmark::collection` so each collection is a self-contained
13//!    sub-doc — clients merge independently and the relay can serve a
14//!    single collection without unpacking the whole library.
15//! 4. Encode each sub-doc via `yrs::encode_state_as_update_v1`.
16//! 5. Print a summary table: collection name, bookmark count,
17//!    encoded bytes, hash (FNV-1a-64). No HTTP, no write to store,
18//!    no write to disk outside the optional `--out-dir`.
19//!
20//! `--out-dir <PATH>`: write each sub-doc's encoded bytes to
21//! `<PATH>/<collection_slug>.ydoc.bin` so the operator can inspect them
22//! (e.g. `xxd`, `wc -c`, `lz4 <file>` to see the post-LZ4 wire size).
23//!
24//! Exit codes:
25//! - 0: dry-run completed, dry-run report printed.
26//! - 1: store not initialized (run `linkmarks init` first).
27//! - 2: bad args.
28//!
29//! This subcommand is the pre-merge validation hook: running
30//! `linkmarks sync --dry-run` on a freshly-imported library must produce
31//! the same encoded size (within LZ4 compression tolerance) as a real
32//! `linkmarks sync --remote <relay>` once the relay binary ships.
33
34use crate::Paths;
35use anyhow::{bail, Context, Result};
36use clap::Args;
37use linkmarks_core::store;
38use std::path::PathBuf;
39
40#[derive(Args, Debug)]
41pub struct SyncArgs {
42    /// Remote relay URL. **Not yet implemented** — passing this without
43    /// `--dry-run` is rejected with a clear error.
44    #[arg(long)]
45    pub remote: Option<String>,
46
47    /// Compute and print the wire payload, but do not contact the relay
48    /// and do not modify the local store.
49    #[arg(long)]
50    pub dry_run: bool,
51
52    /// Optional directory to write per-collection encoded bytes
53    /// (`<slug>.ydoc.bin`). Defaults to none (in-memory only).
54    #[arg(long)]
55    pub out_dir: Option<PathBuf>,
56
57    /// Page size when listing from the store. Defaults to 100_000
58    /// (effectively "all bookmarks in one page").
59    #[arg(long, default_value = "100000")]
60    pub limit: usize,
61}
62
63pub fn run(args: SyncArgs, _format: crate::Format, paths: Paths) -> Result<i32> {
64    if args.remote.is_some() && !args.dry_run {
65        bail!(
66            "live sync is relay-binary work; today only --dry-run is implemented."
67        );
68    }
69    if !args.dry_run {
70        bail!("pass --dry-run (the only mode implemented in this preview)");
71    }
72    if !paths.store.exists() {
73        bail!(
74            "store not found at {}; run `linkmarks init` first",
75            paths.store.display()
76        );
77    }
78
79    let s = store::open(&paths.store).context("open store")?;
80    let total = s.count_all().context("count_all")?;
81    if total == 0 {
82        println!("(store is empty — nothing to encode)");
83        return Ok(crate::exit_codes::OK);
84    }
85
86    let bookmarks = s.list(args.limit.max(1), 0).context("list bookmarks")?;
87    let by_collection = group_by_collection(&bookmarks);
88
89    if let Some(out_dir) = &args.out_dir {
90        std::fs::create_dir_all(out_dir)
91            .with_context(|| format!("create out-dir {}", out_dir.display()))?;
92    }
93
94    println!(
95        "linkmarks sync --dry-run (preview)\n  \
96         store: {}\n  \
97         bookmarks: {}\n  \
98         collections: {}\n",
99        paths.store.display(),
100        bookmarks.len(),
101        by_collection.len()
102    );
103    println!(
104        "{:<24} {:>10} {:>12} {:>16}  hash",
105        "collection", "bookmarks", "bytes", "fingerprint"
106    );
107    println!("{}", "-".repeat(80));
108
109    let mut grand_total_bytes: usize = 0;
110    let mut grand_total_bookmarks: usize = 0;
111    for (collection, items) in &by_collection {
112        let encoded = encode_collection(collection, items);
113        let hash = fnv1a_64(&encoded);
114        let slug = collection_slug(collection);
115        println!(
116            "{:<24} {:>10} {:>12} {:>16x}  {}",
117            truncate(collection, 24),
118            items.len(),
119            encoded.len(),
120            hash,
121            slug
122        );
123        grand_total_bytes = grand_total_bytes.saturating_add(encoded.len());
124        grand_total_bookmarks = grand_total_bookmarks.saturating_add(items.len());
125
126        if let Some(out_dir) = &args.out_dir {
127            let path = out_dir.join(format!("{slug}.ydoc.bin"));
128            std::fs::write(&path, &encoded)
129                .with_context(|| format!("write {}", path.display()))?;
130        }
131    }
132
133    println!("{}", "-".repeat(80));
134    println!(
135        "{:<24} {:>10} {:>12}",
136        "TOTAL", grand_total_bookmarks, grand_total_bytes
137    );
138    println!(
139        "\ndry-run complete. {} sub-docs, {} bytes uncompressed yrs payload.",
140        by_collection.len(),
141        grand_total_bytes
142    );
143    if let Some(out_dir) = args.out_dir {
144        println!(
145            "wrote per-collection encoded files to {}",
146            out_dir.display()
147        );
148    }
149    println!(
150        "Note: the actual relay wire size will be ~6-14× smaller after LZ4 compression.\n\
151         Run `lz4 <file>` on a `.ydoc.bin` to verify."
152    );
153
154    Ok(crate::exit_codes::OK)
155}
156
157fn group_by_collection(
158    bookmarks: &[linkmarks_core::Bookmark],
159) -> std::collections::BTreeMap<String, Vec<linkmarks_core::Bookmark>> {
160    let mut map: std::collections::BTreeMap<String, Vec<linkmarks_core::Bookmark>> =
161        Default::default();
162    for bm in bookmarks {
163        let key = bm
164            .collection
165           .clone()
166            .unwrap_or_else(|| "(uncategorized)".to_string());
167        map.entry(key).or_default().push(bm.clone());
168    }
169    map
170}
171
172fn encode_collection(collection: &str, bookmarks: &[linkmarks_core::Bookmark]) -> Vec<u8> {
173    use yrs::types::map::MapPrelim;
174    use yrs::{Doc, Map, ReadTxn, StateVector, Transact, WriteTxn};
175
176    let doc = Doc::new();
177    {
178        let mut t = doc.transact_mut();
179        let meta = t.get_or_insert_map("meta");
180        meta.insert(&mut t, "collection_name", collection);
181        meta.insert(&mut t, "spike_marker", "sync-preview");
182        let bookmarks_map = t.get_or_insert_map("bookmarks");
183        let tags_map = t.get_or_insert_map("tags_by_bookmark");
184        for bm in bookmarks {
185            let bm_entry = bookmarks_map.insert(
186                &mut t,
187                bm.id.0.as_str(),
188                MapPrelim::default(),
189            );
190            bm_entry.insert(&mut t, "original_url", bm.original_url.clone());
191            bm_entry.insert(&mut t, "canonical_url", bm.canonical_url.clone());
192            bm_entry.insert(&mut t, "title", bm.title.clone());
193            bm_entry.insert(&mut t, "archived", bm.archived);
194            if let Some(ct) = &bm.content_type {
195                bm_entry.insert(&mut t, "content_type", ct.clone());
196            }
197            bm_entry.insert(
198                &mut t,
199                "source_kind",
200                bm.source.kind.as_cli_str().to_string(),
201            );
202            let tags_entry = tags_map.insert(&mut t, bm.id.0.as_str(), MapPrelim::default());
203            for tag in &bm.tags {
204                tags_entry.insert(&mut t, tag.clone(), 1i64);
205            }
206        }
207        t.commit();
208    }
209    let txn = doc.transact();
210    txn.encode_state_as_update_v1(&StateVector::default())
211}
212
213fn collection_slug(name: &str) -> String {
214    name.chars()
215        .map(|c| if c.is_alphanumeric() { c.to_ascii_lowercase() } else { '_' })
216        .collect::<String>()
217        .trim_matches('_')
218        .to_string()
219}
220
221fn truncate(s: &str, max: usize) -> String {
222    if s.chars().count() <= max {
223        s.to_string()
224    } else {
225        let mut out: String = s.chars().take(max.saturating_sub(1)).collect();
226        out.push('…');
227        out
228    }
229}
230
231fn fnv1a_64(b: &[u8]) -> u64 {
232    let mut h: u64 = 0xcbf29ce484222325;
233    for &byte in b {
234        h ^= byte as u64;
235        h = h.wrapping_mul(0x100000001b3);
236    }
237    h
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn collection_slug_lowercases_and_replaces_separators() {
246        assert_eq!(collection_slug("Work / Research"), "work___research");
247        assert_eq!(collection_slug("(uncategorized)"), "uncategorized");
248        assert_eq!(collection_slug("a:b"), "a_b");
249    }
250
251    #[test]
252    fn truncate_handles_short_and_long() {
253        // Short input passes through unchanged.
254        assert_eq!(truncate("short", 10), "short");
255        // Long input is truncated to exactly `max` chars including the
256        // single-codepoint ellipsis (U+2026). This is what we want for
257        // fixed-width column display where the column is `max` wide.
258        assert_eq!(truncate("a long collection name here", 10), "a long co…");
259        assert_eq!(truncate("a long collection name here", 24), "a long collection name …");
260    }
261
262    #[test]
263    fn fnv_hash_is_deterministic() {
264        let a = fnv1a_64(b"hello");
265        let b = fnv1a_64(b"hello");
266        assert_eq!(a, b);
267        assert_ne!(a, fnv1a_64(b"hellp"));
268    }
269}