Skip to main content

sioul_core/
duplicates.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Duplicates in the address books, looked for and cleaned only when you ask:
5//! a number or an address written twice on one card, and two cards that may
6//! be one person. Nothing changes without your click, and every change keeps
7//! the cards as they were, to put them back.
8//!
9//! - **On one card**: numbers are compared by their international form
10//!   (`phones::key`: "04 65 71 12 34" is "+33 4 65 71 12 34"), e-mail
11//!   addresses case aside. Of the same value written twice, the line kept is
12//!   the one written internationally, else the one that says most of what it
13//!   is for; it takes what the others said ("mobile", "work", to use first),
14//!   and its value is never rewritten.
15//! - **Two cards**: the same number, the same e-mail address, or the same name
16//!   (case, accents, punctuation and the order of words aside: "DUPONT, Jean"
17//!   is "Jean Dupont"). What many cards share (a switchboard, an office's
18//!   address, a common name) says nothing of one person. "Not the same" is
19//!   remembered, never asked again.
20//! - **Merging** keeps the card whose name you keep as it is and adds what
21//!   only the other one has: numbers, e-mail and postal addresses, web sites,
22//!   categories, chat addresses; notes joined; a photo, an organisation, a
23//!   birthday when it has none. The other card is deleted, and the sync
24//!   deletes it on the server.
25//! - **Undo**: before any change the cards as they were are kept in
26//!   `$XDG_STATE_HOME/sioul/contacts-undo`, thirty days; undoing writes them
27//!   back, and the sync sends them again.
28
29use crate::contacts::{self, Contact};
30use crate::lines;
31use crate::phones::{self, Region};
32use serde::{Deserialize, Serialize};
33use std::collections::{BTreeMap, BTreeSet};
34use std::path::{Path, PathBuf};
35
36/// A value a cleaning takes off, and the one kept for it, as the card writes them.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
38pub struct Removed {
39    /// "phone" or "email".
40    pub kind: String,
41    pub value: String,
42    pub kept: String,
43}
44
45/// An e-mail address as compared: trimmed, without "mailto:", case aside.
46pub fn email_key(value: &str) -> String {
47    let value = value.trim();
48    let value = value.get(..7).filter(|s| s.eq_ignore_ascii_case("mailto:")).map_or(value, |_| &value[7..]);
49    value.trim().to_lowercase()
50}
51
52/// What two names are compared by: case, accents, punctuation and the order
53/// of words aside ("DUPONT, Jean" is "Jean Dupont", "Zoë" is "Zoe").
54pub fn name_key(name: &str) -> String {
55    let mut folded = String::new();
56    for c in name.chars() {
57        match c {
58            'ß' => folded.push_str("ss"),
59            'æ' | 'Æ' => folded.push_str("ae"),
60            'œ' | 'Œ' => folded.push_str("oe"),
61            _ => folded.push(latin(crate::text::fold_char(c))),
62        }
63    }
64    let mut words: Vec<&str> = folded.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect();
65    words.sort_unstable();
66    words.join(" ")
67}
68
69/// Latin letters `text::fold_char` leaves with their marks (it knows French's).
70fn latin(c: char) -> char {
71    match c {
72        'ñ' | 'ń' | 'ň' => 'n',
73        'ã' | 'å' | 'ā' | 'ą' | 'ă' => 'a',
74        'õ' | 'ø' | 'ō' | 'ő' => 'o',
75        'ł' | 'ľ' | 'ĺ' => 'l',
76        'ś' | 'š' | 'ş' | 'ș' => 's',
77        'ć' | 'č' => 'c',
78        'ź' | 'ż' | 'ž' => 'z',
79        'ř' => 'r',
80        'ě' | 'ę' | 'ē' => 'e',
81        'ů' | 'ű' | 'ū' => 'u',
82        'ý' => 'y',
83        'ğ' => 'g',
84        'ı' | 'ī' => 'i',
85        'đ' | 'ď' => 'd',
86        'ť' | 'ț' => 't',
87        _ => c,
88    }
89}
90
91/// A line's group as written ("item1" of "item1.TEL;…"); none without one.
92fn group_of(line: &str) -> Option<&str> {
93    let head = line.split([';', ':']).next().unwrap_or("");
94    head.rsplit_once('.').map(|(group, _)| group)
95}
96
97fn same_group(line: &str, group: &str) -> bool {
98    group_of(line).is_some_and(|g| g.eq_ignore_ascii_case(group))
99}
100
101/// Whether a line only says something of another line of its group: Apple's
102/// labels and address formats (`item1.X-ABLabel`), not its related names and dates.
103fn is_companion(line: &str) -> bool {
104    let name = lines::name(line);
105    name.starts_with("X-AB") && name != "X-ABRELATEDNAMES" && name != "X-ABDATE"
106}
107
108/// Whether a kind says what a value is for: "voice", "internet" and "pref" do not.
109fn informative(kind: &str) -> bool {
110    !matches!(kind, "internet" | "voice" | "pref" | "x400") && !kind.starts_with("x-")
111}
112
113/// How much a value's line says of what it is for: its kinds ("work",
114/// "cell"), a label of its own (Apple's), whether it comes first.
115fn says(card: &[String], i: usize) -> usize {
116    let line = contacts::split_line(&card[i]);
117    let kinds = contacts::types_of(&line).iter().filter(|k| informative(k)).count();
118    let labelled = group_of(&card[i]).is_some_and(|g| card.iter().any(|l| same_group(l, g) && is_companion(l)));
119    kinds + usize::from(labelled) + usize::from(contacts::is_preferred(&line))
120}
121
122/// The line with more kinds in its TYPE and, when asked, marked as the one
123/// to use first; its value as it was. Kinds are written in the case its TYPE
124/// uses, else upper case in vCard 3.0 and lower case in 4.0.
125fn with_kinds(line: &str, kinds: &[String], first: bool, version4: bool) -> String {
126    if kinds.is_empty() && !first {
127        return line.to_string();
128    }
129    let value = lines::value(line);
130    let Some(colon) = line.len().checked_sub(value.len() + 1).filter(|&at| line.as_bytes().get(at) == Some(&b':')) else { return line.to_string() };
131    let head = &line[..colon];
132    // The head's parts, split at semicolons outside quotes.
133    let mut parts: Vec<String> = Vec::new();
134    let (mut quoted, mut start) = (false, 0);
135    for (i, c) in head.char_indices() {
136        match c {
137            '"' => quoted = !quoted,
138            ';' if !quoted => {
139                parts.push(head[start..i].to_string());
140                start = i + 1;
141            }
142            _ => {}
143        }
144    }
145    parts.push(head[start..].to_string());
146    let mut kinds: Vec<String> = kinds.to_vec();
147    if first && !version4 {
148        kinds.push("pref".into());
149    }
150    let typed = parts.iter().position(|p| p.split_once('=').is_some_and(|(n, _)| n.trim().eq_ignore_ascii_case("TYPE")));
151    if !kinds.is_empty() {
152        match typed {
153            Some(at) => {
154                let (name, written) = parts[at].split_once('=').map(|(n, v)| (n.to_string(), v.to_string())).unwrap_or_default();
155                let lower = written.chars().filter(|c| c.is_alphabetic()).all(char::is_lowercase);
156                let more = kinds.iter().map(|k| if lower { k.to_lowercase() } else { k.to_uppercase() }).collect::<Vec<_>>().join(",");
157                let joined = match written.strip_suffix('"') {
158                    Some(open) => format!("{open},{more}\""),
159                    None if written.is_empty() => more,
160                    None => format!("{written},{more}"),
161                };
162                parts[at] = format!("{name}={joined}");
163            }
164            None => {
165                let more = kinds.iter().map(|k| if version4 { k.to_lowercase() } else { k.to_uppercase() }).collect::<Vec<_>>().join(",");
166                parts.insert(1, format!("TYPE={more}"));
167            }
168        }
169    }
170    if first && version4 {
171        parts.push("PREF=1".into());
172    }
173    format!("{}:{value}", parts.join(";"))
174}
175
176/// A card's values written twice taken off: the same number (`phones::key`
177/// with `region`), the same e-mail address. Returns its new text, REV
178/// renewed, and what goes; none when nothing is there twice.
179pub fn clean(text: &str, region: Option<&Region>) -> Option<(String, Vec<Removed>)> {
180    let card = lines::unfold(text);
181    let version4 = card.iter().any(|l| l.eq_ignore_ascii_case("VERSION:4.0"));
182    let mut out: Vec<Option<String>> = card.iter().cloned().map(Some).collect();
183    let mut removed: Vec<Removed> = Vec::new();
184    let mut gone: Vec<usize> = Vec::new();
185    for (property, kind) in [("TEL", "phone"), ("EMAIL", "email")] {
186        // Each value's lines, in the card's order.
187        let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
188        for (i, line) in card.iter().enumerate().filter(|(_, l)| lines::name(l) == property) {
189            let value = contacts::shown(line);
190            if value.is_empty() {
191                continue;
192            }
193            let key = if property == "TEL" { phones::key(&value, region) } else { email_key(&value) };
194            match groups.iter_mut().find(|(k, _)| *k == key) {
195                Some((_, members)) => members.push(i),
196                None => groups.push((key, vec![i])),
197            }
198        }
199        for (_, members) in groups.into_iter().filter(|(_, m)| m.len() > 1) {
200            // Written internationally first, then what says most, then the first.
201            let keeper = members
202                .iter()
203                .copied()
204                .max_by_key(|&i| (property == "TEL" && phones::is_international(&contacts::shown(&card[i])), says(&card, i), std::cmp::Reverse(i)))
205                .unwrap_or(members[0]);
206            let kept_line = contacts::split_line(&card[keeper]);
207            let kept_kinds = contacts::types_of(&kept_line);
208            let mut more: Vec<String> = Vec::new();
209            for &i in members.iter().filter(|&&i| i != keeper) {
210                for kind in contacts::types_of(&contacts::split_line(&card[i])) {
211                    if informative(&kind) && !kept_kinds.contains(&kind) && !more.contains(&kind) {
212                        more.push(kind);
213                    }
214                }
215            }
216            let first = !contacts::is_preferred(&kept_line) && members.iter().any(|&i| contacts::is_preferred(&contacts::split_line(&card[i])));
217            let mut line = with_kinds(&card[keeper], &more, first, version4);
218            // Kept without a group, it takes a label of its own from a line that goes (Apple's).
219            if group_of(&card[keeper]).is_none()
220                && let Some(group) = members.iter().filter(|&&i| i != keeper).find_map(|&i| group_of(&card[i]).filter(|g| card.iter().any(|l| same_group(l, g) && is_companion(l))))
221            {
222                line = format!("{group}.{line}");
223            }
224            let kept_value = contacts::shown(&card[keeper]);
225            for &i in members.iter().filter(|&&i| i != keeper) {
226                out[i] = None;
227                gone.push(i);
228                removed.push(Removed { kind: kind.to_string(), value: contacts::shown(&card[i]), kept: kept_value.clone() });
229            }
230            out[keeper] = Some(line);
231        }
232    }
233    if removed.is_empty() {
234        return None;
235    }
236    // The labels of a line gone go with it, unless a kept line still has their group.
237    for &i in &gone {
238        let Some(group) = group_of(&card[i]) else { continue };
239        let still_used = out.iter().flatten().any(|l| same_group(l, group) && !is_companion(l));
240        if !still_used {
241            for (j, line) in card.iter().enumerate() {
242                if same_group(line, group) {
243                    out[j] = None;
244                }
245            }
246        }
247    }
248    Some((finish(out.into_iter().flatten().collect(), Vec::new()), removed))
249}
250
251/// The card's lines with `added` before END and REV renewed, folded.
252fn finish(lines_kept: Vec<String>, added: Vec<String>) -> String {
253    let mut kept: Vec<String> = lines_kept.into_iter().filter(|l| lines::name(l) != "REV").collect();
254    let end = kept.iter().rposition(|l| lines::name(l) == "END").unwrap_or(kept.len());
255    let mut more = added;
256    more.push(format!("REV:{}", contacts::now_stamp()));
257    kept.splice(end..end, more);
258    lines::fold(&kept)
259}
260
261/// A card that holds a number or an address twice, and what cleaning it takes off.
262#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
263pub struct Cleaning {
264    /// Its file.
265    pub key: String,
266    pub name: String,
267    pub removed: Vec<Removed>,
268}
269
270/// The cards holding a number or an address twice, as `contacts` orders
271/// them; cards that can only be read are left out. Only those whose values
272/// repeat are read again from their files.
273pub fn cleanings(contacts: &[Contact], region: Option<&Region>) -> Vec<Cleaning> {
274    contacts
275        .iter()
276        .filter(|c| !c.read_only)
277        .filter(|c| {
278            let repeats = |keys: Vec<String>| keys.iter().collect::<BTreeSet<_>>().len() < keys.len();
279            repeats(c.phones.iter().map(|p| phones::key(&p.value, region)).collect()) || repeats(c.emails.iter().map(|e| email_key(&e.value)).collect())
280        })
281        .filter_map(|c| {
282            let text = std::fs::read_to_string(&c.key).ok()?;
283            let (_, removed) = clean(&text, region)?;
284            Some(Cleaning { key: c.key.clone(), name: c.name.clone(), removed })
285        })
286        .collect()
287}
288
289/// Two cards that may be one person, and what they share.
290#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
291pub struct Pair {
292    /// Their files, the first as the list orders them.
293    pub first: String,
294    pub second: String,
295    pub same_name: bool,
296    /// The numbers they share, as the first card writes them.
297    pub phones: Vec<String>,
298    /// The e-mail addresses they share, as the first card writes them.
299    pub emails: Vec<String>,
300}
301
302impl Pair {
303    /// How much says they are one person: the name, a number, an address each count.
304    fn strength(&self) -> (usize, usize) {
305        let kinds = usize::from(self.same_name) + usize::from(!self.phones.is_empty()) + usize::from(!self.emails.is_empty());
306        (kinds, self.phones.len() + self.emails.len())
307    }
308}
309
310/// How many cards may share a number, an address or a name and still say
311/// one person: more is a switchboard, an office, a common name.
312const AT_MOST: usize = 4;
313
314/// What a card is known by for "not the same": its UID, else its file.
315fn identity(contact: &Contact) -> &str {
316    if contact.uid.trim().is_empty() { &contact.key } else { contact.uid.trim() }
317}
318
319/// The pairs of cards that may be one person: the same name, number or
320/// e-mail address; cards that can only be read, and pairs said not the same,
321/// left out. Those sharing most come first.
322pub fn pairs(contacts: &[Contact], region: Option<&Region>, apart: &NotTheSame) -> Vec<Pair> {
323    let open: Vec<&Contact> = contacts.iter().filter(|c| !c.read_only).collect();
324    // What says who a card is: (0 name, 1 number, 2 address; its key) → the cards, and how each writes it.
325    let mut said: BTreeMap<(u8, String), Vec<(usize, String)>> = BTreeMap::new();
326    for (i, contact) in open.iter().enumerate() {
327        let mut own: BTreeSet<(u8, String)> = BTreeSet::new();
328        let mut say = |kind: u8, key: String, written: &str| {
329            if own.insert((kind, key.clone())) {
330                said.entry((kind, key)).or_default().push((i, written.to_string()));
331            }
332        };
333        let name = name_key(&contact.name);
334        // A card named by its address is found by its address.
335        if !name.is_empty() && !contact.name.contains('@') {
336            say(0, name, &contact.name);
337        }
338        for phone in &contact.phones {
339            let key = phones::key(&phone.value, region);
340            if phones::is_whole(&key) {
341                say(1, key, &phone.value);
342            }
343        }
344        for email in &contact.emails {
345            let key = email_key(&email.value);
346            if key.contains('@') {
347                say(2, key, &email.value);
348            }
349        }
350    }
351    let mut found: BTreeMap<(usize, usize), Pair> = BTreeMap::new();
352    for ((kind, _), cards) in said.iter().filter(|(_, cards)| (2..=AT_MOST).contains(&cards.len())) {
353        for (x, (a, written)) in cards.iter().enumerate() {
354            for (b, _) in &cards[x + 1..] {
355                let pair = found.entry((*a, *b)).or_insert_with(|| Pair { first: open[*a].key.clone(), second: open[*b].key.clone(), same_name: false, phones: Vec::new(), emails: Vec::new() });
356                match *kind {
357                    0 => pair.same_name = true,
358                    1 => pair.phones.push(written.clone()),
359                    _ => pair.emails.push(written.clone()),
360                }
361            }
362        }
363    }
364    let mut pairs: Vec<((usize, usize), Pair)> = found
365        .into_iter()
366        .filter(|((a, b), _)| {
367            let (first, second) = (identity(open[*a]), identity(open[*b]));
368            // Two copies of one card (one UID in two address books): by their files.
369            let (first, second) = if first == second { (open[*a].key.as_str(), open[*b].key.as_str()) } else { (first, second) };
370            !apart.contains(first, second)
371        })
372        .collect();
373    pairs.sort_by(|(at_a, a), (at_b, b)| b.strength().cmp(&a.strength()).then(at_a.cmp(at_b)));
374    pairs.into_iter().map(|(_, pair)| pair).collect()
375}
376
377/// Pairs of cards said not to be one person: never offered again on this computer.
378#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
379pub struct NotTheSame {
380    /// By UID (by file for a card without one), each pair in order.
381    #[serde(default)]
382    pub pairs: BTreeSet<[String; 2]>,
383}
384
385impl NotTheSame {
386    /// `$XDG_STATE_HOME/sioul/contacts-not-the-same.toml`.
387    pub fn default_path() -> PathBuf {
388        crate::config::state_dir().join("contacts-not-the-same.toml")
389    }
390
391    pub fn load(path: &Path) -> NotTheSame {
392        std::fs::read_to_string(path).ok().and_then(|t| toml::from_str(&t).ok()).unwrap_or_default()
393    }
394
395    /// Written next to its place, then moved: never half a file.
396    pub fn save(&self, path: &Path) -> Result<(), String> {
397        let fail = |e: std::io::Error| format!("{}: {e}", path.display());
398        if let Some(parent) = path.parent() {
399            std::fs::create_dir_all(parent).map_err(fail)?;
400        }
401        let text = toml::to_string(self).map_err(|e| e.to_string())?;
402        let temporary = path.with_extension("toml.new");
403        std::fs::write(&temporary, text).map_err(fail)?;
404        std::fs::rename(&temporary, path).map_err(fail)
405    }
406
407    fn ordered(a: &str, b: &str) -> [String; 2] {
408        if a <= b { [a.to_string(), b.to_string()] } else { [b.to_string(), a.to_string()] }
409    }
410
411    pub fn contains(&self, a: &str, b: &str) -> bool {
412        self.pairs.contains(&NotTheSame::ordered(a, b))
413    }
414
415    pub fn insert(&mut self, a: &str, b: &str) {
416        self.pairs.insert(NotTheSame::ordered(a, b));
417    }
418
419    /// The pair of these two cards remembered: by UID, by file for a card without one.
420    pub fn remember(&mut self, first: &Contact, second: &Contact) {
421        let (a, b) = (identity(first), identity(second));
422        if a == b { self.insert(&first.key, &second.key) } else { self.insert(a, b) }
423    }
424}
425
426/// Properties of one value: the card that leads keeps its own; the other one
427/// fills it when the leading card has none.
428const SINGLE: &[&str] = &["ORG", "TITLE", "ROLE", "BDAY", "ANNIVERSARY", "PHOTO", "LOGO", "GENDER", "TZ", "GEO", "X-ANNIVERSARY", "X-SPOUSE", "X-MANAGER", "X-ASSISTANT"];
429
430/// Properties of several values the other card brings, unless the leading one has the same.
431const SEVERAL: &[&str] = &[
432    "ADR", "URL", "IMPP", "RELATED", "NICKNAME", "LANG", "KEY", "CALURI", "CALADRURI", "FBURL", "X-SOCIALPROFILE", "X-ABRELATEDNAMES", "X-ABDATE", "X-AIM", "X-ICQ", "X-JABBER", "X-MSN", "X-YAHOO", "X-SKYPE", "X-SKYPE-USERNAME",
433    "X-GOOGLE-TALK", "X-TWITTER",
434];
435
436/// A value as compared between two cards: a web site without its scheme, "www." or last
437/// slash; an address by its words; anything case and spaces aside.
438fn comparable(property: &str, value: &str) -> String {
439    let folded: String = crate::text::fold(value).into_iter().collect();
440    let words = folded.split_whitespace().collect::<Vec<_>>().join(" ");
441    if property == "URL" {
442        let bare = words.trim_start_matches("https://").trim_start_matches("http://").trim_start_matches("www.");
443        return bare.trim_end_matches('/').to_string();
444    }
445    words
446}
447
448/// A line of a vCard 4.0 card as vCard 3.0 writes it (`contacts::as_vcard3`); none when 3.0 has no such property.
449fn as_vcard3_line(line: &str) -> Option<String> {
450    let card = lines::fold(&["BEGIN:VCARD".to_string(), "VERSION:4.0".to_string(), line.to_string(), "END:VCARD".to_string()]);
451    lines::unfold(&contacts::as_vcard3(&card)).into_iter().find(|l| !matches!(lines::name(l).as_str(), "BEGIN" | "VERSION" | "END"))
452}
453
454/// Two cards made one. `lead` stays as it is: its name, and its organisation,
455/// birthday and photo when it has them. `other` adds what only it has:
456/// numbers and e-mail addresses (the same ones written twice then cleaned,
457/// `clean`), postal addresses, web sites and chat addresses unless the same,
458/// its categories, its notes after a blank line (unless one holds the
459/// other), and what `lead` lacks of one value. Apple's groups get names of
460/// their own; a vCard 4.0 line going into a 3.0 card is written as 3.0.
461/// REV renewed.
462pub fn merge(lead: &str, other: &str, region: Option<&Region>) -> String {
463    let mine = lines::unfold(lead);
464    let theirs = lines::unfold(other);
465    let version4 = |card: &[String]| card.iter().any(|l| l.eq_ignore_ascii_case("VERSION:4.0"));
466    let into_three = !version4(&mine) && version4(&theirs);
467    let has = |property: &str| mine.iter().any(|l| lines::name(l) == property);
468    let has_value = |property: &str, value: &str| mine.iter().filter(|l| lines::name(l) == property).any(|l| comparable(property, &contacts::shown(l)) == comparable(property, value));
469    // Which of their lines come.
470    let mut carried: Vec<usize> = Vec::new();
471    for (i, line) in theirs.iter().enumerate() {
472        if is_companion(line) && group_of(line).is_some() {
473            continue;
474        }
475        let property = lines::name(line);
476        let comes = match property.as_str() {
477            // Added, then the same ones written twice cleaned.
478            "TEL" | "EMAIL" => !contacts::shown(line).is_empty(),
479            p if SINGLE.contains(&p) => !has(p),
480            p if SEVERAL.contains(&p) => !contacts::shown(line).is_empty() && !has_value(p, &contacts::shown(line)),
481            _ => false,
482        };
483        if comes {
484            carried.push(i);
485        }
486    }
487    // The labels of the grouped lines that come, with them.
488    let groups: Vec<String> = carried.iter().filter_map(|&i| group_of(&theirs[i]).map(|g| g.to_ascii_lowercase())).collect();
489    for (i, line) in theirs.iter().enumerate() {
490        if is_companion(line) && group_of(line).is_some_and(|g| groups.contains(&g.to_ascii_lowercase())) {
491            carried.push(i);
492        }
493    }
494    carried.sort_unstable();
495    // Their groups renamed to names the leading card does not use.
496    let mut taken: BTreeSet<String> = mine.iter().filter_map(|l| group_of(l)).map(|g| g.to_ascii_lowercase()).collect();
497    let mut renamed: BTreeMap<String, String> = BTreeMap::new();
498    let mut added: Vec<String> = Vec::new();
499    for &i in &carried {
500        let mut line = theirs[i].clone();
501        if let Some(group) = group_of(&line).map(str::to_string) {
502            let new = match renamed.get(&group.to_ascii_lowercase()) {
503                Some(new) => new.clone(),
504                None => {
505                    let new = (1..).map(|n| format!("item{n}")).find(|g| !taken.contains(g)).unwrap_or_default();
506                    taken.insert(new.clone());
507                    renamed.insert(group.to_ascii_lowercase(), new.clone());
508                    new
509                }
510            };
511            line = format!("{new}{}", &line[group.len()..]);
512        }
513        if into_three {
514            match as_vcard3_line(&line) {
515                Some(three) => line = three,
516                None => continue,
517            }
518        }
519        added.push(line);
520    }
521    let mut out: Vec<Option<String>> = mine.iter().cloned().map(Some).collect();
522    // Their categories with the leading card's, as one line.
523    let ours = contacts::categories_of(&mine);
524    let mut united = ours.clone();
525    for name in contacts::categories_of(&theirs) {
526        if !united.iter().any(|n| contacts::category_key(n) == contacts::category_key(&name)) {
527            united.push(name);
528        }
529    }
530    let mut first_lines: Vec<String> = Vec::new();
531    if united != ours {
532        replace(&mine, &mut out, &mut first_lines, "CATEGORIES", contacts::categories_line(&united));
533    }
534    // Notes joined, unless one holds the other.
535    let note_of = |card: &[String]| card.iter().filter(|l| lines::name(l) == "NOTE").map(|l| contacts::shown(l)).filter(|n| !n.is_empty()).collect::<Vec<_>>().join("\n");
536    let (our_note, their_note) = (note_of(&mine), note_of(&theirs));
537    let folded = |text: &str| comparable("NOTE", text);
538    let joined = if their_note.is_empty() || folded(&our_note).contains(&folded(&their_note)) {
539        our_note.clone()
540    } else if our_note.is_empty() || folded(&their_note).contains(&folded(&our_note)) {
541        their_note.clone()
542    } else {
543        format!("{our_note}\n\n{their_note}")
544    };
545    if joined != our_note {
546        replace(&mine, &mut out, &mut first_lines, "NOTE", format!("NOTE:{}", lines::escape(&joined)));
547    }
548    first_lines.extend(added);
549    let text = finish(out.into_iter().flatten().collect(), first_lines);
550    clean(&text, region).map_or(text, |(cleaned, _)| cleaned)
551}
552
553/// A property's lines replaced by one, where the first was; added when there was none.
554fn replace(card: &[String], out: &mut [Option<String>], added: &mut Vec<String>, property: &str, line: String) {
555    let present: Vec<usize> = card.iter().enumerate().filter(|(_, l)| lines::name(l) == property).map(|(i, _)| i).collect();
556    for &i in &present {
557        out[i] = None;
558    }
559    match present.first() {
560        Some(&i) => out[i] = Some(line),
561        None => added.push(line),
562    }
563}
564
565/// One file changed: the card before and after it; none before, it was
566/// made; none after, it was deleted.
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct Change {
569    pub path: PathBuf,
570    pub before: Option<String>,
571    pub after: Option<String>,
572}
573
574/// What was done to the cards, kept to undo it.
575#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
576pub struct Done {
577    /// Its file's name in the folder of what was done.
578    #[serde(skip)]
579    pub id: String,
580    /// When, in seconds since 1970.
581    pub at: i64,
582    /// "clean" or "merge".
583    pub kind: String,
584    /// The names of the cards, to say what it was.
585    pub names: Vec<String>,
586    pub changes: Vec<Change>,
587}
588
589/// How long the cards as they were are kept, to undo: thirty days.
590pub const KEPT_DAYS: i64 = 30;
591
592/// `$XDG_STATE_HOME/sioul/contacts-undo`: what was done to the cards, with them as they were.
593pub fn undo_dir() -> PathBuf {
594    crate::config::state_dir().join("contacts-undo")
595}
596
597/// An id of what was done: letters, digits and dashes, nothing that leaves its folder.
598fn is_id(id: &str) -> bool {
599    !id.is_empty() && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
600}
601
602fn put(path: &Path, text: Option<&str>) -> Result<(), String> {
603    match text {
604        Some(text) => crate::vdir::write_item(path, text),
605        None => match std::fs::remove_file(path) {
606            Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(format!("{}: {e}", path.display())),
607            _ => Ok(()),
608        },
609    }
610}
611
612/// Makes `changes`, the cards as they were saved first in `dir`; returns
613/// what was done. When one change fails, those made before it are undone.
614pub fn apply(dir: &Path, kind: &str, names: Vec<String>, changes: Vec<Change>, now: i64) -> Result<Done, String> {
615    let fail = |e: std::io::Error| format!("{}: {e}", dir.display());
616    std::fs::create_dir_all(dir).map_err(fail)?;
617    let id = std::iter::once(format!("{now}-{kind}")).chain((2..1000).map(|n| format!("{now}-{kind}-{n}"))).find(|id| !dir.join(format!("{id}.json")).exists()).ok_or_else(|| format!("{}: full", dir.display()))?;
618    let done = Done { id: id.clone(), at: now, kind: kind.to_string(), names, changes };
619    let record = dir.join(format!("{id}.json"));
620    let temporary = dir.join(format!("{id}.json.new"));
621    std::fs::write(&temporary, serde_json::to_string(&done).map_err(|e| e.to_string())?).map_err(fail)?;
622    std::fs::rename(&temporary, &record).map_err(fail)?;
623    for (n, change) in done.changes.iter().enumerate() {
624        if let Err(e) = put(&change.path, change.after.as_deref()) {
625            for earlier in done.changes[..n].iter().rev() {
626                let _ = put(&earlier.path, earlier.before.as_deref());
627            }
628            let _ = std::fs::remove_file(&record);
629            return Err(e);
630        }
631    }
632    Ok(done)
633}
634
635/// What was done lately, the newest first. What is older than thirty days
636/// goes, with the versions set aside by its undoing.
637pub fn done(dir: &Path, now: i64) -> Vec<Done> {
638    let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
639    let mut found: Vec<Done> = Vec::new();
640    let mut old: Vec<String> = Vec::new();
641    for path in entries.filter_map(Result::ok).map(|e| e.path()) {
642        let Some(id) = path.file_name().and_then(|n| n.to_str()).and_then(|n| n.strip_suffix(".json")).map(str::to_string) else { continue };
643        let Some(mut record) = std::fs::read_to_string(&path).ok().and_then(|t| serde_json::from_str::<Done>(&t).ok()) else { continue };
644        if now - record.at > KEPT_DAYS * 86_400 {
645            let _ = std::fs::remove_file(&path);
646            old.push(id);
647            continue;
648        }
649        record.id = id;
650        found.push(record);
651    }
652    // The versions an undoing set aside, from records gone ("<id>.<n>.vcf").
653    if let Ok(entries) = std::fs::read_dir(dir) {
654        for path in entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "vcf")) {
655            let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
656            let age = std::fs::metadata(&path).ok().and_then(|m| m.modified().ok()).and_then(|m| m.elapsed().ok()).map_or(0, |d| d.as_secs());
657            if old.iter().any(|id| name.starts_with(&format!("{id}."))) || age > (KEPT_DAYS as u64) * 86_400 {
658                let _ = std::fs::remove_file(&path);
659            }
660        }
661    }
662    found.sort_by(|a, b| b.at.cmp(&a.at).then(b.id.cmp(&a.id)));
663    found
664}
665
666/// Puts the cards back as they were before `id`, then forgets it. A card
667/// changed since (a sync, an edit) is set aside first, as `<id>.<n>.vcf`
668/// beside the record. Files outside `root` (the address books) are never
669/// written; a card whose address book is gone stays gone. Returns the files
670/// written back or removed, and how many versions were set aside.
671pub fn undo(dir: &Path, id: &str, root: &Path) -> Result<(Vec<PathBuf>, usize), String> {
672    if !is_id(id) {
673        return Err(format!("{id}: not something done here"));
674    }
675    let record = dir.join(format!("{id}.json"));
676    let done: Done = std::fs::read_to_string(&record).ok().and_then(|t| serde_json::from_str(&t).ok()).ok_or_else(|| format!("{id}: nothing to undo"))?;
677    let mut touched = Vec::new();
678    let mut aside = 0;
679    for (n, change) in done.changes.iter().enumerate().rev() {
680        let inside = change.path.starts_with(root) && !change.path.components().any(|c| c == std::path::Component::ParentDir);
681        if !inside || change.path.parent().is_some_and(|p| !p.is_dir()) {
682            continue;
683        }
684        let now = std::fs::read_to_string(&change.path).ok();
685        if now.is_some() && now != change.after && now != change.before {
686            std::fs::write(dir.join(format!("{id}.{n}.vcf")), now.unwrap_or_default()).map_err(|e| format!("{}: {e}", dir.display()))?;
687            aside += 1;
688        }
689        put(&change.path, change.before.as_deref())?;
690        touched.push(change.path.clone());
691    }
692    let _ = std::fs::remove_file(&record);
693    Ok((touched, aside))
694}
695
696/// Cleans these cards, one write each, the cards as they were kept first in
697/// `dir`; none when no card holds anything twice any more.
698pub fn clean_files(paths: &[PathBuf], region: Option<&Region>, dir: &Path, now: i64) -> Result<Option<Done>, String> {
699    let mut changes = Vec::new();
700    let mut names = Vec::new();
701    for path in paths {
702        let Ok(text) = std::fs::read_to_string(path) else { continue };
703        if let Some((cleaned, _)) = clean(&text, region) {
704            names.push(contacts::display_name(&lines::unfold(&text)));
705            changes.push(Change { path: path.clone(), before: Some(text), after: Some(cleaned) });
706        }
707    }
708    if changes.is_empty() {
709        return Ok(None);
710    }
711    apply(dir, "clean", names, changes, now).map(Some)
712}
713
714/// Merges two cards (`merge`): the leading one's file holds them both, the
715/// other's is deleted; both kept as they were first in `dir`.
716pub fn merge_files(lead: &Path, other: &Path, region: Option<&Region>, dir: &Path, now: i64) -> Result<Done, String> {
717    if lead == other {
718        return Err(format!("{}: the same card", lead.display()));
719    }
720    let read = |path: &Path| std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()));
721    let (lead_text, other_text) = (read(lead)?, read(other)?);
722    let merged = merge(&lead_text, &other_text, region);
723    let names = vec![contacts::display_name(&lines::unfold(&lead_text)), contacts::display_name(&lines::unfold(&other_text))];
724    let changes = vec![Change { path: lead.to_path_buf(), before: Some(lead_text), after: Some(merged) }, Change { path: other.to_path_buf(), before: Some(other_text), after: None }];
725    apply(dir, "merge", names, changes, now)
726}
727
728#[cfg(test)]
729mod tests {
730    use super::*;
731    use crate::vdir::{Collection, Kind};
732
733    fn france() -> Option<&'static Region> {
734        phones::region_named("FR")
735    }
736
737    fn book(read_only: bool) -> Collection {
738        Collection { kind: Kind::Contacts, account: "a".into(), id: "contacts".into(), dir: PathBuf::from("/tmp"), name: "Contacts".into(), color: None, read_only, components: vec![] }
739    }
740
741    /// A contact from a card's text (no photo: reading one writes into the cache).
742    fn contact_of(text: &str, file: &str) -> Contact {
743        contacts::contact(&lines::unfold(text), Path::new(file), &book(false))
744    }
745
746    fn card(lines_in: &[&str]) -> String {
747        let mut all = vec!["BEGIN:VCARD", "VERSION:3.0"];
748        all.extend_from_slice(lines_in);
749        all.push("END:VCARD");
750        lines::fold(&all.iter().map(|l| l.to_string()).collect::<Vec<_>>())
751    }
752
753    fn without_rev(text: &str) -> Vec<String> {
754        lines::unfold(text).into_iter().filter(|l| lines::name(l) != "REV").collect()
755    }
756
757    #[test]
758    fn one_number_written_twice_on_a_card() {
759        let text = card(&["UID:a", "FN:Lou Exemple", "TEL;TYPE=CELL:04 65 71 12 34", "TEL:+33 4 65 71 12 34", "TEL;TYPE=HOME:05 36 49 00 00", "TEL;TYPE=WORK,PREF:0465711234", "X-OTHER:kept"]);
760        let (cleaned, removed) = clean(&text, france()).unwrap();
761        // The international writing stays, with what the others said: mobile, work, first.
762        assert_eq!(without_rev(&cleaned), ["BEGIN:VCARD", "VERSION:3.0", "UID:a", "FN:Lou Exemple", "TEL;TYPE=CELL,WORK,PREF:+33 4 65 71 12 34", "TEL;TYPE=HOME:05 36 49 00 00", "X-OTHER:kept", "END:VCARD"]);
763        assert!(cleaned.contains("\r\nREV:"), "{cleaned}");
764        assert_eq!(
765            removed,
766            [
767                Removed { kind: "phone".into(), value: "04 65 71 12 34".into(), kept: "+33 4 65 71 12 34".into() },
768                Removed { kind: "phone".into(), value: "0465711234".into(), kept: "+33 4 65 71 12 34".into() }
769            ]
770        );
771        // Cleaned once, nothing is left twice.
772        assert_eq!(clean(&cleaned, france()), None);
773        // Without the region, the national and the international writings are not the same number.
774        let two = card(&["FN:A", "TEL:04 65 71 12 34", "TEL:+33 4 65 71 12 34"]);
775        assert_eq!(clean(&two, None), None);
776        assert_eq!(clean(&two, france()).map(|(_, r)| r.len()), Some(1));
777    }
778
779    #[test]
780    fn addresses_twice_case_aside_and_apple_labels() {
781        // vCard 4.0: kinds in lower case, PREF=1; a label of Apple's goes to the line kept.
782        let text = "BEGIN:VCARD\r\nVERSION:4.0\r\nFN:Lou\r\nEMAIL;TYPE=home:lou@example.org\r\nitem1.EMAIL:Lou@Example.org\r\nitem1.X-ABLabel:club\r\nEMAIL;PREF=1:LOU@example.ORG\r\nEMAIL:other@example.org\r\nEND:VCARD\r\n";
783        let (cleaned, removed) = clean(text, None).unwrap();
784        assert_eq!(removed.len(), 2);
785        let lines = without_rev(&cleaned);
786        assert!(lines.contains(&"item1.EMAIL:Lou@Example.org".to_string()) || lines.iter().any(|l| l.starts_with("item1.EMAIL;TYPE=home")), "{lines:?}");
787        assert!(lines.contains(&"item1.X-ABLabel:club".to_string()), "the label stays with a line: {lines:?}");
788        assert_eq!(lines.iter().filter(|l| lines::name(l) == "EMAIL").count(), 2, "{lines:?}");
789        // A grouped duplicate that goes takes its label with it when the kept line has a group of its own.
790        let grouped = "BEGIN:VCARD\r\nVERSION:3.0\r\nFN:Lou\r\nitem1.TEL:+33 4 65 71 12 34\r\nitem1.X-ABLabel:atelier\r\nitem2.TEL:04 65 71 12 34\r\nitem2.X-ABLabel:vieux\r\nEND:VCARD\r\n";
791        let (cleaned, _) = clean(grouped, france()).unwrap();
792        assert_eq!(without_rev(&cleaned), ["BEGIN:VCARD", "VERSION:3.0", "FN:Lou", "item1.TEL:+33 4 65 71 12 34", "item1.X-ABLabel:atelier", "END:VCARD"]);
793        // Nothing twice: nothing to do.
794        assert_eq!(clean(&card(&["FN:A", "EMAIL:a@example.org", "TEL:+33 4 65 71 12 34"]), france()), None);
795    }
796
797    #[test]
798    fn kinds_added_in_the_case_written() {
799        assert_eq!(with_kinds("TEL;TYPE=cell:+33 6", &["work".into()], false, false), "TEL;TYPE=cell,work:+33 6");
800        assert_eq!(with_kinds("TEL;TYPE=\"cell,voice\":+33 6", &["work".into()], true, true), "TEL;TYPE=\"cell,voice,work\";PREF=1:+33 6");
801        assert_eq!(with_kinds("item1.TEL:+33 6", &["cell".into()], true, false), "item1.TEL;TYPE=CELL,PREF:+33 6");
802        assert_eq!(with_kinds("TEL:+33 6", &[], false, false), "TEL:+33 6");
803    }
804
805    #[test]
806    fn names_compared_as_people_write_them() {
807        assert_eq!(name_key("Jean Dupont"), name_key("DUPONT, Jean"));
808        assert_eq!(name_key("  Jean   Dupont "), name_key("Dupont Jean"));
809        assert_eq!(name_key("Zoë Ångström"), name_key("zoe angstrom"));
810        assert_eq!(name_key("Jean-Pierre Lætitia"), name_key("jean pierre laetitia"));
811        assert_ne!(name_key("Jean Dupont"), name_key("Jeanne Dupont"));
812        assert_eq!(email_key(" mailto:Jane@Example.ORG "), "jane@example.org");
813    }
814
815    #[test]
816    fn pairs_by_number_address_and_name() {
817        let people = [
818            contact_of(&card(&["UID:a", "FN:Jean Dupont", "TEL:04 65 71 12 34", "EMAIL:jean@example.org"]), "/tmp/a.vcf"),
819            contact_of(&card(&["UID:b", "FN:DUPONT Jean", "TEL:+33 4 65 71 12 34"]), "/tmp/b.vcf"),
820            contact_of(&card(&["UID:c", "FN:J. D.", "EMAIL:JEAN@example.org"]), "/tmp/c.vcf"),
821            contact_of(&card(&["UID:d", "FN:Marie Exemple", "TEL:3631"]), "/tmp/d.vcf"),
822            contact_of(&card(&["UID:e", "FN:Paul Exemple", "TEL:36 31"]), "/tmp/e.vcf"),
823        ];
824        let found = pairs(&people, france(), &NotTheSame::default());
825        // The name and the number first, then the address; a short code shared says nothing.
826        assert_eq!(found.len(), 2, "{found:?}");
827        assert_eq!((found[0].first.as_str(), found[0].second.as_str(), found[0].same_name), ("/tmp/a.vcf", "/tmp/b.vcf", true));
828        assert_eq!(found[0].phones, ["04 65 71 12 34"]);
829        assert_eq!((found[1].first.as_str(), found[1].second.as_str(), found[1].emails.clone()), ("/tmp/a.vcf", "/tmp/c.vcf", vec!["jean@example.org".to_string()]));
830        // "Not the same" is never asked again, whichever way round.
831        let mut apart = NotTheSame::default();
832        apart.remember(&people[1], &people[0]);
833        let found = pairs(&people, france(), &apart);
834        assert_eq!(found.len(), 1);
835        assert_eq!(found[0].second, "/tmp/c.vcf");
836        // A card that can only be read is left out.
837        let mut read_only = people.to_vec();
838        read_only[2].read_only = true;
839        assert!(pairs(&read_only, france(), &apart).is_empty());
840        // A switchboard five cards share says nothing.
841        let office: Vec<Contact> = (0..5).map(|n| contact_of(&card(&[&format!("UID:o{n}"), &format!("FN:Person {n}"), "TEL:+33 1 99 00 67 89"]), &format!("/tmp/o{n}.vcf"))).collect();
842        assert!(pairs(&office, france(), &NotTheSame::default()).is_empty());
843    }
844
845    #[test]
846    fn not_the_same_remembered() {
847        let dir = std::env::temp_dir().join(format!("sioul-not-same-{}", std::process::id()));
848        let _ = std::fs::remove_dir_all(&dir);
849        let path = dir.join("contacts-not-the-same.toml");
850        let mut apart = NotTheSame::load(&path);
851        assert!(apart.pairs.is_empty());
852        apart.insert("uid-b", "uid-a");
853        apart.save(&path).unwrap();
854        let again = NotTheSame::load(&path);
855        assert!(again.contains("uid-a", "uid-b") && again.contains("uid-b", "uid-a") && !again.contains("uid-a", "uid-c"));
856        assert_eq!(again, apart);
857        // Two copies of one card (one UID): by their files.
858        let (a, b) = (contact_of(&card(&["UID:same", "FN:A"]), "/tmp/x.vcf"), contact_of(&card(&["UID:same", "FN:A"]), "/tmp/y.vcf"));
859        let mut copies = NotTheSame::default();
860        copies.remember(&a, &b);
861        assert!(copies.contains("/tmp/y.vcf", "/tmp/x.vcf"));
862        let _ = std::fs::remove_dir_all(&dir);
863    }
864
865    #[test]
866    fn merging_keeps_everything_once() {
867        let lead = card(&[
868            "UID:lead",
869            "FN:Jean Dupont",
870            "N:Dupont;Jean;;;",
871            "TEL;TYPE=CELL:04 65 71 12 34",
872            "EMAIL:jean@example.org",
873            "item1.URL:https://example.org/",
874            "item1.X-ABLabel:site",
875            "CATEGORIES:Amis",
876            "NOTE:Met at the market",
877            "X-LEAD:kept",
878        ]);
879        let other = card(&[
880            "UID:other",
881            "FN:J. Dupont",
882            "ORG:Exemple SARL",
883            "BDAY:1984-05-12",
884            "TEL;TYPE=WORK:+33 4 65 71 12 34",
885            "TEL:05 36 49 00 00",
886            "EMAIL:JEAN@example.org",
887            "EMAIL:jd@example.net",
888            "item1.ADR:;;1 rue de l'Exemple;Lyon;;69000;France",
889            "item1.X-ABLabel:atelier",
890            "URL:http://www.example.org",
891            "CATEGORIES:amis,Club\\, échecs",
892            "NOTE:Plays chess",
893            "PHOTO;ENCODING=b;TYPE=JPEG:AAAA",
894            "X-OTHER-APP:not carried",
895        ]);
896        let merged = merge(&lead, &other, france());
897        let lines = without_rev(&merged);
898        let has = |l: &str| lines.iter().any(|x| x == l);
899        // Its own name and lines stay.
900        assert!(has("FN:Jean Dupont") && has("N:Dupont;Jean;;;") && has("UID:lead") && has("X-LEAD:kept") && has("item1.URL:https://example.org/") && has("item1.X-ABLabel:site"), "{lines:#?}");
901        // One number, written internationally, with both kinds; the other number added.
902        assert!(has("TEL;TYPE=WORK,CELL:+33 4 65 71 12 34") && has("TEL:05 36 49 00 00"), "{lines:#?}");
903        assert_eq!(lines.iter().filter(|l| lines::name(l) == "TEL").count(), 2, "{lines:#?}");
904        // One address of each, the same one once.
905        assert!(has("EMAIL:jean@example.org") && has("EMAIL:jd@example.net"), "{lines:#?}");
906        assert_eq!(lines.iter().filter(|l| lines::name(l) == "EMAIL").count(), 2, "{lines:#?}");
907        // The postal address and its label, under a group of their own; the same web site once.
908        assert!(has("item2.ADR:;;1 rue de l'Exemple;Lyon;;69000;France") && has("item2.X-ABLabel:atelier"), "{lines:#?}");
909        assert_eq!(lines.iter().filter(|l| lines::name(l) == "URL").count(), 1, "{lines:#?}");
910        // What it had none of; categories united as one line; notes joined.
911        assert!(has("ORG:Exemple SARL") && has("BDAY:1984-05-12") && has("PHOTO;ENCODING=b;TYPE=JPEG:AAAA"), "{lines:#?}");
912        assert!(has("CATEGORIES:Amis,Club\\, échecs") && has("NOTE:Met at the market\\n\\nPlays chess"), "{lines:#?}");
913        // What Sioul does not know of the other card does not come; nor its name or UID.
914        assert!(!has("X-OTHER-APP:not carried") && !has("FN:J. Dupont") && !has("UID:other"), "{lines:#?}");
915        assert!(contacts::parse(&merged).is_some());
916        // Merged again with the same card, nothing more comes.
917        assert_eq!(without_rev(&merge(&merged, &other, france())), lines);
918    }
919
920    #[test]
921    fn a_vcard4_card_merged_into_a_vcard3_one() {
922        let lead = card(&["UID:lead", "FN:Lou"]);
923        let other = "BEGIN:VCARD\r\nVERSION:4.0\r\nUID:o\r\nFN:Lou\r\nEMAIL;PREF=1:lou@example.org\r\nGENDER:F\r\nPHOTO:data:image/png;base64,iVBORw0K\r\nEND:VCARD\r\n";
924        let lines = without_rev(&merge(&lead, other, None));
925        assert!(lines.contains(&"EMAIL;TYPE=pref:lou@example.org".to_string()) && lines.contains(&"PHOTO;ENCODING=b;TYPE=PNG:iVBORw0K".to_string()), "{lines:#?}");
926        assert!(!lines.iter().any(|l| l.starts_with("GENDER")), "{lines:#?}");
927    }
928
929    #[test]
930    fn undone_as_it_was() {
931        let base = std::env::temp_dir().join(format!("sioul-undo-{}", std::process::id()));
932        let _ = std::fs::remove_dir_all(&base);
933        let (books, dir) = (base.join("contacts/local/book"), base.join("undo"));
934        std::fs::create_dir_all(&books).unwrap();
935        let (a, b, c) = (books.join("a.vcf"), books.join("b.vcf"), books.join("c.vcf"));
936        let text_a = card(&["UID:a", "FN:Jean Dupont", "TEL:04 65 71 12 34", "TEL:+33 4 65 71 12 34"]);
937        let text_b = card(&["UID:b", "FN:J. Dupont", "EMAIL:jean@example.org"]);
938        let text_c = card(&["UID:c", "FN:Marie", "EMAIL:m@example.org", "EMAIL:M@example.org"]);
939        for (path, text) in [(&a, &text_a), (&b, &text_b), (&c, &text_c)] {
940            std::fs::write(path, text).unwrap();
941        }
942        // Cleaning: one write per card, kept as it was first.
943        let now = 1_791_000_000;
944        let cleaned = clean_files(&[a.clone(), c.clone()], france(), &dir, now).unwrap().unwrap();
945        assert_eq!((cleaned.kind.as_str(), cleaned.names.clone(), cleaned.changes.len()), ("clean", vec!["Jean Dupont".to_string(), "Marie".to_string()], 2));
946        assert_eq!(cleaned.changes[0].before.as_deref(), Some(text_a.as_str()));
947        assert!(!std::fs::read_to_string(&a).unwrap().contains("04 65 71 12 34"));
948        // Merging: the other card's file goes.
949        let merged = merge_files(&a, &b, france(), &dir, now + 60).unwrap();
950        assert!(!b.exists() && std::fs::read_to_string(&a).unwrap().contains("jean@example.org"));
951        assert_eq!(merged.changes[1], Change { path: b.clone(), before: Some(text_b.clone()), after: None });
952        let listed = done(&dir, now + 120);
953        assert_eq!(listed.iter().map(|d| d.kind.as_str()).collect::<Vec<_>>(), ["merge", "clean"]);
954        // The merge undone: both cards back as they were; a change made since set aside.
955        std::fs::write(&a, card(&["UID:a", "FN:Jean Dupont", "NOTE:changed since"])).unwrap();
956        let (touched, aside) = undo(&dir, &listed[0].id, &base.join("contacts")).unwrap();
957        assert_eq!((touched.len(), aside), (2, 1));
958        assert_eq!(std::fs::read_to_string(&b).unwrap(), text_b);
959        assert!(std::fs::read_to_string(&a).unwrap().contains("TEL:+33 4 65 71 12 34") && !std::fs::read_to_string(&a).unwrap().contains("jean@"));
960        assert!(std::fs::read_to_string(dir.join(format!("{}.1.vcf", listed[0].id))).is_ok_and(|t| t.contains("changed since")) || std::fs::read_to_string(dir.join(format!("{}.0.vcf", listed[0].id))).is_ok_and(|t| t.contains("changed since")));
961        // Then the cleaning: the cards as they were at first.
962        undo(&dir, &listed[1].id, &base.join("contacts")).unwrap();
963        assert_eq!((std::fs::read_to_string(&a).unwrap(), std::fs::read_to_string(&c).unwrap()), (text_a.clone(), text_c.clone()));
964        assert!(done(&dir, now + 120).is_empty());
965        // Nothing outside the address books, nothing that is not an id.
966        assert!(undo(&dir, "../x", &base.join("contacts")).is_err());
967        // Kept thirty days, then gone.
968        clean_files(&[c.clone()], france(), &dir, now).unwrap().unwrap();
969        assert_eq!(done(&dir, now + 29 * 86_400).len(), 1);
970        assert!(done(&dir, now + 31 * 86_400).is_empty());
971        let _ = std::fs::remove_dir_all(&base);
972    }
973}