linkmarks_cli/cmd/
dedupe.rs1use 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 #[arg(long, default_value = "store")]
22 pub source: String,
23
24 #[arg(long)]
26 pub path: Option<PathBuf>,
27
28 #[arg(long)]
33 pub apply: bool,
34
35 #[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 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; if report.conflicts.is_empty() {
140 Ok(crate::exit_codes::OK)
141 } else {
142 Ok(crate::exit_codes::DEDUPE_CONFLICTS)
143 }
144}