1use crate::model::Bookmark;
15use serde::{Deserialize, Serialize};
16use std::collections::BTreeMap;
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
21pub struct ConflictRecord {
22 pub canonical_url: String,
24 pub chosen_id: String,
26 pub conflicting_ids: Vec<String>,
28 pub differing_fields: Vec<String>,
31}
32
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
35pub struct DedupeReport {
36 pub canonical_count: usize,
38 pub merged_count: usize,
40 pub conflicts: Vec<ConflictRecord>,
42}
43
44pub fn dedupe(bookmarks: &[Bookmark]) -> (Vec<Bookmark>, DedupeReport) {
53 let mut groups: BTreeMap<String, Vec<&Bookmark>> = BTreeMap::new();
55 for b in bookmarks {
56 groups.entry(b.canonical_url.clone()).or_default().push(b);
57 }
58
59 let mut canonical: Vec<Bookmark> = Vec::with_capacity(groups.len());
60 let mut conflicts: Vec<ConflictRecord> = Vec::new();
61 let mut merged = 0usize;
62
63 for (canonical_url, group) in &groups {
64 let chosen = group
66 .iter()
67 .min_by(|a, b| {
68 a.created_at
69 .cmp(&b.created_at)
70 .then_with(|| a.id.0.cmp(&b.id.0))
71 })
72 .expect("non-empty group");
73
74 let mut differing: Vec<String> = Vec::new();
76 let mut conflicting_ids: Vec<String> = Vec::new();
77 for b in group {
78 if b.id == chosen.id {
79 continue;
80 }
81 let mut differs = false;
82 if b.title != chosen.title {
83 if !differing.iter().any(|f| f == "title") {
84 differing.push("title".to_string());
85 }
86 differs = true;
87 }
88 if b.tags != chosen.tags {
89 if !differing.iter().any(|f| f == "tags") {
90 differing.push("tags".to_string());
91 }
92 differs = true;
93 }
94 if b.collection != chosen.collection {
95 if !differing.iter().any(|f| f == "collection") {
96 differing.push("collection".to_string());
97 }
98 differs = true;
99 }
100 if differs {
101 conflicting_ids.push(b.id.0.clone());
102 }
103 }
104 if !conflicting_ids.is_empty() {
105 conflicts.push(ConflictRecord {
106 canonical_url: canonical_url.clone(),
107 chosen_id: chosen.id.0.clone(),
108 conflicting_ids,
109 differing_fields: differing,
110 });
111 }
112
113 merged += group.len() - 1;
114 canonical.push((*chosen).clone());
115 }
116
117 let report = DedupeReport {
118 canonical_count: canonical.len(),
119 merged_count: merged,
120 conflicts,
121 };
122
123 (canonical, report)
124}
125
126#[cfg(test)]
127mod tests {
128 use super::*;
129 use crate::model::{BookmarkId, SourceKind, SourceRef};
130 use chrono::{TimeZone, Utc};
131
132 fn mk(id: &str, canonical: &str, title: &str, created_secs: i64) -> Bookmark {
133 Bookmark {
134 id: BookmarkId(id.into()),
135 original_url: format!("https://example.com/{id}"),
136 canonical_url: canonical.into(),
137 title: title.into(),
138 description: None,
139 tags: vec![],
140 collection: None,
141 created_at: Utc.timestamp_opt(created_secs, 0).unwrap(),
142 updated_at: Utc.timestamp_opt(created_secs, 0).unwrap(),
143 source: SourceRef {
144 kind: SourceKind::Manual,
145 external_id: None,
146 imported_at: Utc.timestamp_opt(0, 0).unwrap(),
147 raw: None,
148 },
149 content_type: None,
150 archived: false,
151 }
152 }
153
154 #[test]
155 fn dedupes_by_canonical_url() {
156 let bs = vec![
157 mk("a", "https://example.com/x", "Title A", 100),
158 mk("b", "https://example.com/x", "Title A", 200),
159 mk("c", "https://example.com/y", "Title C", 150),
160 ];
161 let (canon, report) = dedupe(&bs);
162 assert_eq!(canon.len(), 2);
163 assert_eq!(report.merged_count, 1);
164 assert_eq!(report.canonical_count, 2);
165 assert!(report.conflicts.is_empty());
166 }
167
168 #[test]
169 fn picks_oldest_as_canonical() {
170 let bs = vec![
171 mk("a", "https://example.com/x", "Newer", 200),
172 mk("b", "https://example.com/x", "Older", 100),
173 ];
174 let (canon, _) = dedupe(&bs);
175 assert_eq!(canon.len(), 1);
176 assert_eq!(canon[0].id.0, "b");
177 }
178
179 #[test]
180 fn tie_break_by_id() {
181 let bs = vec![
182 mk("zzz", "https://example.com/x", "Same time", 100),
183 mk("aaa", "https://example.com/x", "Same time", 100),
184 ];
185 let (canon, _) = dedupe(&bs);
186 assert_eq!(canon[0].id.0, "aaa");
187 }
188
189 #[test]
190 fn reports_conflict_on_title_mismatch() {
191 let bs = vec![
192 mk("a", "https://example.com/x", "Title A", 100),
193 mk("b", "https://example.com/x", "Title B", 200),
194 ];
195 let (_, report) = dedupe(&bs);
196 assert_eq!(report.conflicts.len(), 1);
197 assert!(report.conflicts[0]
198 .differing_fields
199 .iter()
200 .any(|f| f == "title"));
201 }
202
203 #[test]
204 fn report_is_deterministic() {
205 let bs = vec![
206 mk("a", "https://example.com/x", "A", 100),
207 mk("b", "https://example.com/x", "B", 200),
208 mk("c", "https://example.com/y", "C", 150),
209 ];
210 let (_, r1) = dedupe(&bs);
211 let (_, r2) = dedupe(&bs);
212 let s1 = serde_json::to_string(&r1).unwrap();
213 let s2 = serde_json::to_string(&r2).unwrap();
214 assert_eq!(s1, s2);
215 }
216}