//! Duplicate detection: an imported item duplicates an existing one (or an earlier one in the //! same import) when (title, username, primary URL host) match, case-insensitively. use std::collections::HashSet; use kv_model::Item; pub const IMPORTED_SUFFIX: &str = " (imported)"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DuplicatePolicy { /// Leave the existing item alone and drop the imported one. Skip, /// Import it anyway, with `" (imported)"` appended to the title. AddSuffix, } pub type Key = (String, String, String); /// Host of the first URL, lowercased, without a leading `www.`. Empty when there is no usable URL. pub fn primary_host(item: &Item) -> String { let Some(raw) = item.urls.first().map(|u| u.trim()) else { return String::new() }; let parsed = url::Url::parse(raw).ok().filter(|u| u.host_str().is_some()).or_else(|| { // Bare "example.com/login" from CSVs. url::Url::parse(&format!("https://{raw}")).ok() }); let host = parsed.and_then(|u| u.host_str().map(str::to_lowercase)).unwrap_or_else(|| raw.to_lowercase()); host.strip_prefix("www.").map(str::to_string).unwrap_or(host) } pub fn key(item: &Item) -> Key { (item.title.trim().to_lowercase(), item.username.as_deref().unwrap_or("").trim().to_lowercase(), primary_host(item)) } pub struct Plan { /// Items to store, in input order. pub to_add: Vec, /// Indices into the input of the duplicates (skipped or renamed). pub duplicates: Vec, pub skipped: u32, pub renamed: u32, } /// Decides what to store. `existing` are the keys of the items already in the vault. pub fn plan(items: Vec, existing: impl IntoIterator, policy: DuplicatePolicy) -> Plan { let mut seen: HashSet = existing.into_iter().collect(); let mut out = Plan { to_add: Vec::with_capacity(items.len()), duplicates: vec![], skipped: 0, renamed: 0 }; for (idx, mut item) in items.into_iter().enumerate() { let k = key(&item); if !seen.contains(&k) { seen.insert(k); out.to_add.push(item); continue; } out.duplicates.push(idx); match policy { DuplicatePolicy::Skip => out.skipped += 1, DuplicatePolicy::AddSuffix => { item.title.push_str(IMPORTED_SUFFIX); // Importing the same file twice still yields only one "(imported)" copy per key. while !seen.insert(key(&item)) { item.title.push_str(IMPORTED_SUFFIX); } out.renamed += 1; out.to_add.push(item); } } } out } #[cfg(test)] mod tests { use super::*; fn login(title: &str, user: &str, url: &str) -> Item { let mut i = Item::new_login(title, 0); i.username = Some(user.into()); if !url.is_empty() { i.urls = vec![url.into()]; } i } #[test] fn host_normalization() { assert_eq!(primary_host(&login("a", "b", "https://WWW.Example.com/login?x=1")), "example.com"); assert_eq!(primary_host(&login("a", "b", "example.com/login")), "example.com"); assert_eq!(primary_host(&login("a", "b", "")), ""); } #[test] fn skip_and_suffix() { let existing = [key(&login("GitHub", "me", "https://github.com"))]; let items = vec![ login("github", "ME", "https://github.com/login"), login("GitHub", "other", "https://github.com"), login("Mail", "me", "https://mail.example"), login("Mail", "me", "https://mail.example/inbox"), ]; let p = plan(items.clone(), existing.clone(), DuplicatePolicy::Skip); assert_eq!((p.to_add.len(), p.skipped, p.renamed), (2, 2, 0)); assert_eq!(p.duplicates, vec![0, 3]); let p = plan(items, existing, DuplicatePolicy::AddSuffix); assert_eq!((p.to_add.len(), p.skipped, p.renamed), (4, 0, 2)); assert_eq!(p.to_add[0].title, "github (imported)"); assert_eq!(p.to_add[3].title, "Mail (imported)"); } }