1use std::collections::HashSet;
43
44use serde::{Deserialize, Serialize};
45
46use crate::import::parse::{BibEntry, normalize_doi};
47
48#[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 #[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 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 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#[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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
178pub struct ChangedEntry {
179 pub citekey: String,
180 pub before_citekey: Option<String>,
185 pub field_changes: Vec<FieldChange>,
186}
187
188#[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 #[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#[must_use]
210pub fn diff_entries(before: &[Entry], after: &[Entry]) -> BibDiff {
211 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(); 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
268fn find_match(a: &Entry, before: &[Entry], used: &HashSet<usize>) -> Option<usize> {
271 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 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 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 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
310fn 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 #[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 let mut before = full("X", "Title", 2024);
418 before.doi = Some("10.1/abc".into());
419 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 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 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 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 #[test]
565 fn sort_determinism_added_removed_changed() {
566 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); 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 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 #[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 #[test]
623 fn mixed_format_same_papers_produce_zero_diff() {
624 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 #[test]
676 fn identity_strict_per_rung_does_not_combine() {
677 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}