Skip to main content

scitadel_export/
diff.rs

1//! Entry-level structural diff between two bibliography snapshots
2//! (#135 sub-feature C).
3//!
4//! `bib verify` already prints a unified line-based diff when drift is
5//! detected, but that output is mechanically useful and editorially
6//! noisy: a one-character change in a citekey ripples through dozens of
7//! lines of BibTeX, and the reader has to reconstruct the *meaning* by
8//! eye. `bib diff` gives the reader the meaning directly: which entries
9//! were added, which were removed, which changed (and which fields
10//! changed).
11//!
12//! ## Identity rule
13//!
14//! Two entries refer to the same paper iff one of these matches —
15//! tried strictly in order, first-match-wins:
16//!
17//! 1. citekey (exact, case-sensitive — citekeys are deterministic)
18//! 2. DOI (lowercased, prefix-stripped — see [`crate::import::parse::normalize_doi`])
19//! 3. arxiv_id (trimmed)
20//! 4. (title, year) — last-resort fallback when all three persistent
21//!    identifiers are missing, e.g. an old NeurIPS PDF with no DOI yet
22//!
23//! Strict per-rung means we don't try multiple at once: if citekey
24//! matches we don't even *look* at DOI. This matters for ambiguous
25//! cases (e.g. the citekey was renamed but DOI still matches — we
26//! treat that as added/removed, not a rename, because the user's
27//! manuscripts still cite by the citekey).
28//!
29//! ## Determinism
30//!
31//! All output lists are sorted by citekey lexicographically before
32//! return so callers (CLI, MCP, tests) see stable output across runs
33//! regardless of input order.
34//!
35//! ## Format-neutral
36//!
37//! The diff operates on [`Entry`] — a deliberately narrow shape that
38//! both BibTeX and CSL-JSON parsers can populate. That way a BibTeX
39//! file and a CSL-JSON file with the same papers produce zero diff
40//! (verified by [`tests::mixed_format_same_papers_produce_zero_diff`]).
41
42use std::collections::HashSet;
43
44use serde::{Deserialize, Serialize};
45
46use crate::import::parse::{BibEntry, normalize_doi};
47
48/// Format-neutral bibliography entry. Populated by the BibTeX parser
49/// (via [`Entry::from_bib_entry`]) or the CSL-JSON parser (via
50/// [`Entry::from_csl_value`]) so the diff layer doesn't care which
51/// flavor it's looking at.
52#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Entry {
54    pub citekey: String,
55    pub title: Option<String>,
56    pub authors: Vec<String>,
57    pub year: Option<i32>,
58    pub doi: Option<String>,
59    pub arxiv_id: Option<String>,
60    pub journal: Option<String>,
61    pub url: Option<String>,
62    pub r#abstract: Option<String>,
63}
64
65impl Entry {
66    /// Lift a parsed BibTeX entry into the format-neutral shape. DOI
67    /// is already normalized by [`normalize_doi`] inside `parse_bibtex`.
68    #[must_use]
69    pub fn from_bib_entry(b: &BibEntry) -> Self {
70        Self {
71            citekey: b.citekey.clone(),
72            title: b.title.clone(),
73            authors: b.authors.clone(),
74            year: b.year,
75            doi: b.doi.clone(),
76            arxiv_id: b.arxiv_id.clone(),
77            journal: b
78                .extra
79                .get("journal")
80                .or_else(|| b.extra.get("journaltitle"))
81                .cloned(),
82            url: b.extra.get("url").cloned(),
83            r#abstract: b.extra.get("abstract").cloned(),
84        }
85    }
86
87    /// Lift a single CSL-JSON object into the format-neutral shape. The
88    /// canonical 1.0.2 schema fixes field names: `id`, `title`,
89    /// `author[].family|given`, `issued.date-parts[0][0]`, `DOI`, `URL`,
90    /// `container-title`, `abstract`. Anything else is dropped.
91    pub fn from_csl_value(v: &serde_json::Value) -> Option<Self> {
92        let obj = v.as_object()?;
93        let citekey = obj.get("id").and_then(|x| match x {
94            serde_json::Value::String(s) => Some(s.clone()),
95            serde_json::Value::Number(n) => Some(n.to_string()),
96            _ => None,
97        })?;
98        let title = obj
99            .get("title")
100            .and_then(|t| t.as_str())
101            .map(str::to_string);
102        let authors = obj
103            .get("author")
104            .and_then(|a| a.as_array())
105            .map(|arr| arr.iter().filter_map(csl_author_to_string).collect())
106            .unwrap_or_default();
107        let year = obj
108            .get("issued")
109            .and_then(|d| d.get("date-parts"))
110            .and_then(|dp| dp.as_array())
111            .and_then(|outer| outer.first())
112            .and_then(|inner| inner.as_array())
113            .and_then(|inner| inner.first())
114            .and_then(|n| n.as_i64())
115            .map(|n| n as i32);
116        let doi = obj
117            .get("DOI")
118            .and_then(|d| d.as_str())
119            .map(normalize_doi)
120            .filter(|s| !s.is_empty());
121        let url = obj.get("URL").and_then(|u| u.as_str()).map(str::to_string);
122        let journal = obj
123            .get("container-title")
124            .and_then(|c| c.as_str())
125            .map(str::to_string);
126        let r#abstract = obj
127            .get("abstract")
128            .and_then(|a| a.as_str())
129            .map(str::to_string);
130        // CSL doesn't carry a dedicated arxiv_id; tools that round-trip
131        // arxiv ids through CSL stash them in `note` or rebuild the URL.
132        // We don't try to recover that here — the diff falls through to
133        // (title, year) when DOI/arxiv are both absent, which is the
134        // intended pre-arxiv fallback path.
135        let arxiv_id = None;
136        Some(Self {
137            citekey,
138            title,
139            authors,
140            year,
141            doi,
142            arxiv_id,
143            journal,
144            url,
145            r#abstract,
146        })
147    }
148}
149
150fn csl_author_to_string(v: &serde_json::Value) -> Option<String> {
151    let obj = v.as_object()?;
152    let family = obj
153        .get("family")
154        .and_then(|x| x.as_str())
155        .or_else(|| obj.get("literal").and_then(|x| x.as_str()))?
156        .trim();
157    if let Some(given) = obj.get("given").and_then(|x| x.as_str())
158        && !given.trim().is_empty()
159    {
160        Some(format!("{family}, {}", given.trim()))
161    } else {
162        Some(family.to_string())
163    }
164}
165
166/// One field that differs between two matched entries.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct FieldChange {
169    pub field: String,
170    pub before: Option<String>,
171    pub after: Option<String>,
172}
173
174/// One entry whose identity matched on both sides but whose fields
175/// differ. `field_changes` is sorted alphabetically by field name for
176/// deterministic output.
177#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct ChangedEntry {
179    pub citekey: String,
180    /// Citekey on the "before" side, in case identity matched on a
181    /// different rung (DOI / arxiv / title+year) and the citekey was
182    /// renamed. `None` when the citekey is unchanged. Always rendered
183    /// from the after-side `citekey`.
184    pub before_citekey: Option<String>,
185    pub field_changes: Vec<FieldChange>,
186}
187
188/// Structural diff between two entry lists. Lists are sorted by
189/// citekey ascending so output is byte-stable across runs.
190#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
191pub struct BibDiff {
192    pub added: Vec<Entry>,
193    pub removed: Vec<Entry>,
194    pub changed: Vec<ChangedEntry>,
195}
196
197impl BibDiff {
198    /// `true` iff there is no structural change. CLI maps this to
199    /// exit code 0 (mirrors `git diff` semantics).
200    #[must_use]
201    pub fn is_empty(&self) -> bool {
202        self.added.is_empty() && self.removed.is_empty() && self.changed.is_empty()
203    }
204}
205
206/// Compute the structural diff between two entry lists. Pure: no I/O,
207/// no global state. Deterministic: same inputs ⇒ same output, lists
208/// sorted by citekey.
209#[must_use]
210pub fn diff_entries(before: &[Entry], after: &[Entry]) -> BibDiff {
211    // Greedy first-match-wins matching. For each `after` entry try the
212    // identity rungs in order against any `before` entry not yet
213    // matched. This is O(n*m) on the rungs, which is fine for the
214    // shortlist sizes we expect (tens to low thousands).
215    let mut matched_before: HashSet<usize> = HashSet::new();
216    let mut matched_after: HashSet<usize> = HashSet::new();
217    let mut pairs: Vec<(usize, usize)> = Vec::new(); // (before_idx, after_idx)
218
219    for (ai, a) in after.iter().enumerate() {
220        if let Some(bi) = find_match(a, before, &matched_before) {
221            matched_before.insert(bi);
222            matched_after.insert(ai);
223            pairs.push((bi, ai));
224        }
225    }
226
227    let mut added: Vec<Entry> = after
228        .iter()
229        .enumerate()
230        .filter(|(i, _)| !matched_after.contains(i))
231        .map(|(_, e)| e.clone())
232        .collect();
233    let mut removed: Vec<Entry> = before
234        .iter()
235        .enumerate()
236        .filter(|(i, _)| !matched_before.contains(i))
237        .map(|(_, e)| e.clone())
238        .collect();
239    let mut changed: Vec<ChangedEntry> = pairs
240        .into_iter()
241        .filter_map(|(bi, ai)| {
242            let b = &before[bi];
243            let a = &after[ai];
244            let fc = field_changes(b, a);
245            if fc.is_empty() && a.citekey == b.citekey {
246                None
247            } else {
248                let before_citekey = (a.citekey != b.citekey).then(|| b.citekey.clone());
249                Some(ChangedEntry {
250                    citekey: a.citekey.clone(),
251                    before_citekey,
252                    field_changes: fc,
253                })
254            }
255        })
256        .collect();
257
258    added.sort_by(|a, b| a.citekey.cmp(&b.citekey));
259    removed.sort_by(|a, b| a.citekey.cmp(&b.citekey));
260    changed.sort_by(|a, b| a.citekey.cmp(&b.citekey));
261    BibDiff {
262        added,
263        removed,
264        changed,
265    }
266}
267
268/// Try the identity rungs in strict order. Returns the index of the
269/// matching `before` entry on first hit, or `None`.
270fn find_match(a: &Entry, before: &[Entry], used: &HashSet<usize>) -> Option<usize> {
271    // Rung 1: citekey.
272    if !a.citekey.is_empty()
273        && let Some(i) = before
274            .iter()
275            .enumerate()
276            .position(|(i, b)| !used.contains(&i) && b.citekey == a.citekey)
277    {
278        return Some(i);
279    }
280    // Rung 2: DOI.
281    if let Some(adoi) = a.doi.as_ref().filter(|s| !s.is_empty())
282        && let Some(i) = before
283            .iter()
284            .enumerate()
285            .position(|(i, b)| !used.contains(&i) && b.doi.as_deref() == Some(adoi.as_str()))
286    {
287        return Some(i);
288    }
289    // Rung 3: arxiv.
290    if let Some(ax) = a.arxiv_id.as_ref().filter(|s| !s.is_empty())
291        && let Some(i) = before
292            .iter()
293            .enumerate()
294            .position(|(i, b)| !used.contains(&i) && b.arxiv_id.as_deref() == Some(ax.as_str()))
295    {
296        return Some(i);
297    }
298    // Rung 4: title + year.
299    if let (Some(t), Some(y)) = (a.title.as_ref(), a.year)
300        && !t.is_empty()
301        && let Some(i) = before.iter().enumerate().position(|(i, b)| {
302            !used.contains(&i) && b.title.as_deref() == Some(t.as_str()) && b.year == Some(y)
303        })
304    {
305        return Some(i);
306    }
307    None
308}
309
310/// Per-field comparison. Returns a sorted-by-field list of changes.
311/// Compares the format-neutral fields; authors are compared as a
312/// single concatenation (`"; "`-joined) for diff output legibility.
313fn field_changes(b: &Entry, a: &Entry) -> Vec<FieldChange> {
314    let mut out: Vec<FieldChange> = Vec::new();
315    if b.title != a.title {
316        out.push(FieldChange {
317            field: "title".into(),
318            before: b.title.clone(),
319            after: a.title.clone(),
320        });
321    }
322    if b.authors != a.authors {
323        out.push(FieldChange {
324            field: "author".into(),
325            before: opt_str_from_authors(&b.authors),
326            after: opt_str_from_authors(&a.authors),
327        });
328    }
329    if b.year != a.year {
330        out.push(FieldChange {
331            field: "year".into(),
332            before: b.year.map(|y| y.to_string()),
333            after: a.year.map(|y| y.to_string()),
334        });
335    }
336    if b.doi != a.doi {
337        out.push(FieldChange {
338            field: "DOI".into(),
339            before: b.doi.clone(),
340            after: a.doi.clone(),
341        });
342    }
343    if b.arxiv_id != a.arxiv_id {
344        out.push(FieldChange {
345            field: "arxiv_id".into(),
346            before: b.arxiv_id.clone(),
347            after: a.arxiv_id.clone(),
348        });
349    }
350    if b.journal != a.journal {
351        out.push(FieldChange {
352            field: "journal".into(),
353            before: b.journal.clone(),
354            after: a.journal.clone(),
355        });
356    }
357    if b.url != a.url {
358        out.push(FieldChange {
359            field: "URL".into(),
360            before: b.url.clone(),
361            after: a.url.clone(),
362        });
363    }
364    if b.r#abstract != a.r#abstract {
365        out.push(FieldChange {
366            field: "abstract".into(),
367            before: b.r#abstract.clone(),
368            after: a.r#abstract.clone(),
369        });
370    }
371    out.sort_by(|x, y| x.field.cmp(&y.field));
372    out
373}
374
375fn opt_str_from_authors(authors: &[String]) -> Option<String> {
376    if authors.is_empty() {
377        None
378    } else {
379        Some(authors.join("; "))
380    }
381}
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    fn entry(citekey: &str) -> Entry {
388        Entry {
389            citekey: citekey.into(),
390            ..Default::default()
391        }
392    }
393
394    fn full(citekey: &str, title: &str, year: i32) -> Entry {
395        Entry {
396            citekey: citekey.into(),
397            title: Some(title.into()),
398            year: Some(year),
399            authors: vec!["Smith, J.".into()],
400            ..Default::default()
401        }
402    }
403
404    // ---------- Identity rule: 4 separate tests, one per rung ----------
405
406    #[test]
407    fn identity_rung1_citekey_match_wins() {
408        let a = full("smith2024", "T", 2024);
409        let b = full("smith2024", "T", 2024);
410        let d = diff_entries(&[a], &[b]);
411        assert!(d.is_empty(), "same citekey + identical fields ⇒ no diff");
412    }
413
414    #[test]
415    fn identity_rung1_citekey_takes_priority_over_doi() {
416        // before has citekey=X but DOI=10.1/abc
417        let mut before = full("X", "Title", 2024);
418        before.doi = Some("10.1/abc".into());
419        // after has different citekey but same DOI — by the rule, the
420        // citekey doesn't match, so we fall through to DOI rung 2.
421        // BUT: we want to verify rung 1 strictness. So set up TWO
422        // before entries: one with the citekey, one with the DOI. The
423        // citekey match must win.
424        let mut before_decoy = full("Y", "DecoyTitle", 2020);
425        before_decoy.doi = Some("10.1/abc".into());
426        let mut after = full("X", "Title", 2024);
427        after.doi = Some("10.1/abc".into());
428
429        let d = diff_entries(&[before_decoy.clone(), before], &[after]);
430        // The citekey-matched entry should be matched (no diff for it),
431        // and the decoy should appear in `removed` (it's still in
432        // before but not after).
433        assert_eq!(d.removed.len(), 1, "decoy must be removed: {d:?}");
434        assert_eq!(d.removed[0].citekey, "Y");
435        assert!(d.changed.is_empty());
436        assert!(d.added.is_empty());
437    }
438
439    #[test]
440    fn identity_rung2_doi_when_citekeys_differ() {
441        let mut before = full("oldkey", "Title", 2024);
442        before.doi = Some("10.1/abc".into());
443        let mut after = full("newkey", "Title", 2024);
444        after.doi = Some("10.1/abc".into());
445        let d = diff_entries(&[before], &[after]);
446        // DOI matched ⇒ same paper, but citekey changed.
447        assert_eq!(d.changed.len(), 1, "DOI rung must match: {d:?}");
448        assert_eq!(d.changed[0].citekey, "newkey");
449        assert_eq!(d.changed[0].before_citekey.as_deref(), Some("oldkey"));
450        assert!(d.added.is_empty());
451        assert!(d.removed.is_empty());
452    }
453
454    #[test]
455    fn identity_rung3_arxiv_when_doi_absent() {
456        let mut before = full("oldkey", "Title", 2024);
457        before.arxiv_id = Some("2301.00001".into());
458        let mut after = full("newkey", "Title", 2024);
459        after.arxiv_id = Some("2301.00001".into());
460        let d = diff_entries(&[before], &[after]);
461        assert_eq!(d.changed.len(), 1, "arxiv rung must match: {d:?}");
462        assert_eq!(d.changed[0].citekey, "newkey");
463    }
464
465    #[test]
466    fn identity_rung4_title_year_when_all_else_absent() {
467        let before = full("oldkey", "A Common Title", 2024);
468        let after = full("newkey", "A Common Title", 2024);
469        let d = diff_entries(&[before], &[after]);
470        assert_eq!(d.changed.len(), 1, "title+year rung must match: {d:?}");
471        assert_eq!(d.changed[0].citekey, "newkey");
472    }
473
474    #[test]
475    fn identity_no_match_returns_added_and_removed() {
476        let before = full("a", "Alpha", 2024);
477        let after = full("b", "Beta", 2025);
478        let d = diff_entries(&[before], &[after]);
479        assert_eq!(d.added.len(), 1);
480        assert_eq!(d.added[0].citekey, "b");
481        assert_eq!(d.removed.len(), 1);
482        assert_eq!(d.removed[0].citekey, "a");
483        assert!(d.changed.is_empty());
484    }
485
486    // ---------- Field-change detection: one field at a time ----------
487
488    fn changed_only(b: Entry, a: Entry) -> Vec<FieldChange> {
489        let d = diff_entries(&[b], &[a]);
490        assert_eq!(d.changed.len(), 1);
491        d.changed.into_iter().next().unwrap().field_changes
492    }
493
494    #[test]
495    fn field_change_title() {
496        let mut b = full("k", "Old", 2024);
497        let mut a = full("k", "New", 2024);
498        b.authors = vec!["X".into()];
499        a.authors = vec!["X".into()];
500        let fc = changed_only(b, a);
501        assert_eq!(fc.len(), 1, "only title changed: {fc:?}");
502        assert_eq!(fc[0].field, "title");
503        assert_eq!(fc[0].before.as_deref(), Some("Old"));
504        assert_eq!(fc[0].after.as_deref(), Some("New"));
505    }
506
507    #[test]
508    fn field_change_year_only() {
509        let mut b = full("k", "T", 2023);
510        let mut a = full("k", "T", 2024);
511        b.authors = vec!["X".into()];
512        a.authors = vec!["X".into()];
513        let fc = changed_only(b, a);
514        assert_eq!(fc.len(), 1, "only year changed: {fc:?}");
515        assert_eq!(fc[0].field, "year");
516    }
517
518    #[test]
519    fn field_change_author_only() {
520        let mut b = full("k", "T", 2024);
521        let mut a = full("k", "T", 2024);
522        b.authors = vec!["Smith, J.".into()];
523        a.authors = vec!["Smith, J.".into(), "Doe, J.".into()];
524        let fc = changed_only(b, a);
525        assert_eq!(fc.len(), 1, "only author changed: {fc:?}");
526        assert_eq!(fc[0].field, "author");
527    }
528
529    #[test]
530    fn field_change_doi_only() {
531        let mut b = full("k", "T", 2024);
532        let mut a = full("k", "T", 2024);
533        b.doi = Some("10.1/old".into());
534        a.doi = Some("10.1/new".into());
535        let fc = changed_only(b, a);
536        assert_eq!(fc.len(), 1, "only DOI changed: {fc:?}");
537        assert_eq!(fc[0].field, "DOI");
538    }
539
540    #[test]
541    fn field_change_journal_only() {
542        let mut b = full("k", "T", 2024);
543        let mut a = full("k", "T", 2024);
544        b.journal = Some("Nature".into());
545        a.journal = Some("Science".into());
546        let fc = changed_only(b, a);
547        assert_eq!(fc.len(), 1, "only journal changed: {fc:?}");
548        assert_eq!(fc[0].field, "journal");
549    }
550
551    #[test]
552    fn field_change_abstract_only() {
553        let mut b = full("k", "T", 2024);
554        let mut a = full("k", "T", 2024);
555        b.r#abstract = Some("old abs".into());
556        a.r#abstract = Some("new abs".into());
557        let fc = changed_only(b, a);
558        assert_eq!(fc.len(), 1, "only abstract changed: {fc:?}");
559        assert_eq!(fc[0].field, "abstract");
560    }
561
562    // ---------- Determinism ----------
563
564    #[test]
565    fn sort_determinism_added_removed_changed() {
566        // Different insertion orders, same content ⇒ same output.
567        let b1 = full("alpha", "A", 2024);
568        let b2 = full("zeta", "Z", 2024);
569        let b3 = full("mu", "M", 2024);
570        let a1 = full("beta", "B", 2024);
571        let a2 = full("zeta", "Z2", 2024); // changed title
572        let a3 = full("nu", "N", 2024);
573
574        let r1 = diff_entries(
575            &[b1.clone(), b2.clone(), b3.clone()],
576            &[a1.clone(), a2.clone(), a3.clone()],
577        );
578        let r2 = diff_entries(&[b3, b1, b2], &[a3, a2, a1]);
579
580        let added_keys = |d: &BibDiff| {
581            d.added
582                .iter()
583                .map(|e| e.citekey.clone())
584                .collect::<Vec<_>>()
585        };
586        let removed_keys = |d: &BibDiff| {
587            d.removed
588                .iter()
589                .map(|e| e.citekey.clone())
590                .collect::<Vec<_>>()
591        };
592        let changed_keys = |d: &BibDiff| {
593            d.changed
594                .iter()
595                .map(|c| c.citekey.clone())
596                .collect::<Vec<_>>()
597        };
598        assert_eq!(added_keys(&r1), added_keys(&r2));
599        assert_eq!(removed_keys(&r1), removed_keys(&r2));
600        assert_eq!(changed_keys(&r1), changed_keys(&r2));
601        // And actually sorted:
602        assert_eq!(added_keys(&r1), vec!["beta", "nu"]);
603        assert_eq!(removed_keys(&r1), vec!["alpha", "mu"]);
604        assert_eq!(changed_keys(&r1), vec!["zeta"]);
605    }
606
607    // ---------- JSON round-trip ----------
608
609    #[test]
610    fn json_round_trip_matches_struct() {
611        let b = full("a", "Old", 2023);
612        let a = full("a", "New", 2024);
613        let extra = full("b", "Beta", 2024);
614        let d = diff_entries(&[b], &[a, extra]);
615        let json = serde_json::to_string(&d).unwrap();
616        let back: BibDiff = serde_json::from_str(&json).unwrap();
617        assert_eq!(d, back);
618    }
619
620    // ---------- Mixed-format diff (BibTeX vs CSL-JSON) ----------
621
622    #[test]
623    fn mixed_format_same_papers_produce_zero_diff() {
624        // Build a BibTeX doc and a CSL-JSON doc that describe the same
625        // two papers; lift to Entry via both paths; assert empty diff.
626        let bib_src = r"
627@article{smith2024quantum,
628    title = {Quantum Advantage},
629    author = {Smith, John and Doe, Jane},
630    year = {2024},
631    doi = {10.1038/nature.2024}
632}
633@article{lee2023neural,
634    title = {Neural Y},
635    author = {Lee, Kai},
636    year = {2023}
637}
638        ";
639        let bib_entries = crate::import::parse::parse_bibtex(bib_src).unwrap();
640        let bib_neutral: Vec<Entry> = bib_entries.iter().map(Entry::from_bib_entry).collect();
641
642        let csl_src = serde_json::json!([
643            {
644                "id": "smith2024quantum",
645                "type": "article-journal",
646                "title": "Quantum Advantage",
647                "author": [
648                    {"family": "Smith", "given": "John"},
649                    {"family": "Doe", "given": "Jane"}
650                ],
651                "issued": {"date-parts": [[2024]]},
652                "DOI": "10.1038/nature.2024"
653            },
654            {
655                "id": "lee2023neural",
656                "type": "article-journal",
657                "title": "Neural Y",
658                "author": [{"family": "Lee", "given": "Kai"}],
659                "issued": {"date-parts": [[2023]]}
660            }
661        ]);
662        let csl_arr = csl_src.as_array().unwrap();
663        let csl_neutral: Vec<Entry> = csl_arr.iter().filter_map(Entry::from_csl_value).collect();
664        assert_eq!(csl_neutral.len(), 2);
665
666        let d = diff_entries(&bib_neutral, &csl_neutral);
667        assert!(
668            d.is_empty(),
669            "BibTeX-vs-CSL of the same papers must produce no diff: {d:?}"
670        );
671    }
672
673    // ---------- Identity strictness ----------
674
675    #[test]
676    fn identity_strict_per_rung_does_not_combine() {
677        // before: citekey=X, no DOI
678        // after:  citekey=Y, DOI=10/abc, no overlap
679        // ⇒ no match (citekey miss; DOI not present in `before`)
680        let before = entry("X");
681        let mut after = entry("Y");
682        after.doi = Some("10/abc".into());
683        let d = diff_entries(&[before], &[after]);
684        assert_eq!(d.added.len(), 1);
685        assert_eq!(d.removed.len(), 1);
686    }
687
688    #[test]
689    fn empty_inputs_are_a_no_op() {
690        let d = diff_entries(&[], &[]);
691        assert!(d.is_empty());
692    }
693}