Skip to main content

linkmarks_cli/cmd/
dedupe.rs

1//! `linkmarks dedupe` — local deterministic dedupe by canonical URL.
2//!
3//! Reads the live store by default and runs the core dedupe algorithm
4//! over the rows. `--source=chrome` parses a Chromium JSON file
5//! instead. `--apply` re-writes the store with the canonical set:
6//! archived tombstones are left intact and the winning record is
7//! upserted.
8
9use crate::Paths;
10use anyhow::{bail, Result};
11use clap::Args;
12use linkmarks_core::dedupe as core_dedupe;
13use linkmarks_core::store;
14use linkmarks_core::traits::BookmarkSource;
15use std::path::PathBuf;
16
17#[derive(Args, Debug)]
18pub struct DedupeArgs {
19    /// Source to dedupe. `store` (default) reads the SQLite store;
20    /// `chrome` parses a Chromium JSON file.
21    #[arg(long, default_value = "store")]
22    pub source: String,
23
24    /// Path to a Chromium JSON source. Required when `--source=chrome`.
25    #[arg(long)]
26    pub path: Option<PathBuf>,
27
28    /// Apply the merge (default is dry-run).
29    ///
30    /// Without `--apply`, the command only reports. The gate exists
31    /// because destructive merges are irreversible without a backup.
32    #[arg(long)]
33    pub apply: bool,
34
35    /// Refresh the canonical_url on every row by re-running the loader
36    /// config. Off by default — useful after editing config.toml to
37    /// tighten rules for a specific host.
38    #[arg(long)]
39    pub refresh_canonical: bool,
40}
41
42pub fn run(args: DedupeArgs, format: crate::Format, paths: Paths) -> Result<i32> {
43    let bookmarks = match args.source.as_str() {
44        "store" => {
45            if !paths.store.exists() {
46                bail!(
47                    "store not found at {}; run `linkmarks init` first",
48                    paths.store.display()
49                );
50            }
51            let s = store::open(&paths.store)?;
52            let mut all = Vec::new();
53            // Page through the store with a conservative limit until empty.
54            let mut offset = 0usize;
55            loop {
56                let page = s.list(500, offset)?;
57                if page.is_empty() {
58                    break;
59                }
60                let page_len = page.len();
61                offset += page_len;
62                all.extend(page);
63                if page_len < 500 {
64                    break;
65                }
66            }
67            all
68        }
69        "chrome" => {
70            let path = args
71                .path
72                .clone()
73                .ok_or_else(|| anyhow::anyhow!("--path is required for --source=chrome"))?;
74            let src = linkmarks_bridge_chromium::ChromiumSource::open(&path)?;
75            src.list()?
76        }
77        other => bail!("unsupported --source '{other}' (try `store` or `chrome`)"),
78    };
79
80    let (canonical, report) = core_dedupe(&bookmarks);
81
82    match format {
83        crate::Format::Table => {
84            println!(
85                "canonical_count={} merged={} conflicts={}",
86                report.canonical_count,
87                report.merged_count,
88                report.conflicts.len()
89            );
90            for c in &report.conflicts {
91                println!(
92                    "- {}\n    chosen={}\n    conflicting={:?}\n    differing_fields={:?}",
93                    c.canonical_url, c.chosen_id, c.conflicting_ids, c.differing_fields
94                );
95            }
96        }
97        crate::Format::Json => {
98            let mut out = serde_json::to_string_pretty(&report)?;
99            out.push('\n');
100            print!("{out}");
101        }
102        crate::Format::Yaml => {
103            let value = serde_yaml::to_string(&report)?;
104            print!("{value}");
105        }
106    }
107
108    if !args.apply {
109        eprintln!("(dry-run; pass --apply to write)");
110        if report.conflicts.is_empty() {
111            return Ok(crate::exit_codes::OK);
112        }
113        return Ok(crate::exit_codes::DEDUPE_CONFLICTS);
114    }
115
116    if args.source == "store" {
117        let mut s = store::open(&paths.store)?;
118        let mut rewritten = 0usize;
119        for bm in &canonical {
120            if let Err(e) = s.upsert(bm) {
121                tracing::warn!(error = %e, url = %bm.original_url, "dedupe upsert failed");
122            } else {
123                rewritten += 1;
124            }
125        }
126        eprintln!(
127            "(apply mode: {} canonical records, {} rows upserted back into the store)",
128            canonical.len(),
129            rewritten
130        );
131    } else {
132        eprintln!(
133            "(apply mode: {} canonical records; chrome source has no on-disk write)",
134            canonical.len()
135        );
136    }
137    let _ = args.refresh_canonical; // reserved for a future canonical-refresh pass
138
139    if report.conflicts.is_empty() {
140        Ok(crate::exit_codes::OK)
141    } else {
142        Ok(crate::exit_codes::DEDUPE_CONFLICTS)
143    }
144}