1use std::collections::BTreeMap;
15
16use mos_bib::{BibEntry, Bibliography, unwrap_value};
17
18use crate::item::{
19 Date, DateVariable, Item, ItemType, Name, NameVariable, NumberVariable, StandardVariable,
20};
21
22#[must_use]
24pub fn item_from_bib_entry(entry: &BibEntry) -> Item {
25 let mut item = Item::new(entry.key.clone(), item_type_for(&entry.entry_type));
26 for (field, value) in &entry.fields {
27 apply_field(&mut item, &entry.entry_type, field, unwrap_value(value));
28 }
29 item
30}
31
32#[must_use]
34pub fn library_from_bibliography(bibliography: &Bibliography) -> BTreeMap<String, Item> {
35 bibliography
36 .entries
37 .iter()
38 .map(|(key, entry)| (key.clone(), item_from_bib_entry(entry)))
39 .collect()
40}
41
42fn item_type_for(entry_type: &str) -> ItemType {
44 match entry_type {
45 "article" => ItemType::ArticleJournal,
46 "book" | "proceedings" => ItemType::Book,
47 "booklet" => ItemType::Pamphlet,
48 "inbook" | "incollection" => ItemType::Chapter,
49 "conference" | "inproceedings" => ItemType::PaperConference,
50 "manual" | "techreport" => ItemType::Report,
51 "mastersthesis" | "phdthesis" | "thesis" => ItemType::Thesis,
52 "unpublished" => ItemType::Manuscript,
53 "online" | "electronic" => ItemType::Webpage,
54 _ => ItemType::Document,
55 }
56}
57
58fn apply_field(item: &mut Item, entry_type: &str, field: &str, value: &str) {
60 let standard = match field {
62 "title" => Some(StandardVariable::Title),
63 "journal" | "booktitle" => Some(StandardVariable::ContainerTitle),
64 "publisher" | "school" | "institution" => Some(StandardVariable::Publisher),
65 "address" if is_conference_entry(entry_type) => Some(StandardVariable::EventPlace),
66 "address" => Some(StandardVariable::PublisherPlace),
67 "series" => Some(StandardVariable::CollectionTitle),
68 "note" => Some(StandardVariable::Note),
69 "abstract" => Some(StandardVariable::Abstract),
70 "keywords" => Some(StandardVariable::Keyword),
71 "doi" => Some(StandardVariable::Doi),
72 "url" => Some(StandardVariable::Url),
73 "isbn" => Some(StandardVariable::Isbn),
74 "issn" => Some(StandardVariable::Issn),
75 "language" => Some(StandardVariable::Language),
76 _ => None,
77 };
78 if let Some(variable) = standard {
79 item.standard.insert(variable, value.to_owned());
80 return;
81 }
82
83 let number = match field {
85 "volume" => Some(NumberVariable::Volume),
86 "number" if is_report_entry(entry_type) => Some(NumberVariable::Number),
87 "number" => Some(NumberVariable::Issue),
88 "pages" => Some(NumberVariable::Page),
89 "edition" => Some(NumberVariable::Edition),
90 "chapter" => Some(NumberVariable::ChapterNumber),
91 _ => None,
92 };
93 if let Some(variable) = number {
94 item.number.insert(variable, value.to_owned());
95 return;
96 }
97
98 match field {
100 "author" => {
101 item.name.insert(NameVariable::Author, parse_names(value));
102 }
103 "editor" => {
104 item.name.insert(NameVariable::Editor, parse_names(value));
105 }
106 "year" => {
107 item.date.insert(DateVariable::Issued, parse_year(value));
108 }
109 _ => {}
110 }
111}
112
113fn is_conference_entry(entry_type: &str) -> bool {
114 matches!(entry_type, "conference" | "inproceedings")
115}
116
117fn is_report_entry(entry_type: &str) -> bool {
118 matches!(entry_type, "manual" | "techreport")
119}
120
121fn parse_names(value: &str) -> Vec<Name> {
123 let mut names = Vec::new();
124 let mut token_start = 0;
125 let mut search_start = 0;
126
127 while let Some(relative_start) = value[search_start..].find("and") {
128 let and_start = search_start + relative_start;
129 let and_end = and_start + "and".len();
130 if is_name_separator(value, and_start, and_end) {
131 push_name(&mut names, &value[token_start..and_start]);
132 token_start = and_end;
133 }
134 search_start = and_end;
135 }
136
137 push_name(&mut names, &value[token_start..]);
138 names
139}
140
141fn is_name_separator(value: &str, start: usize, end: usize) -> bool {
142 let before = value[..start].chars().next_back();
143 let after = value[end..].chars().next();
144 before.is_some_and(char::is_whitespace) && after.is_some_and(char::is_whitespace)
145}
146
147fn push_name(names: &mut Vec<Name>, token: &str) {
148 let trimmed = token.trim();
149 if !trimmed.is_empty() {
150 names.push(parse_one_name(trimmed));
151 }
152}
153
154fn parse_one_name(token: &str) -> Name {
157 match token.split_once(',') {
158 Some((family, given)) => Name::person(family.trim(), given.trim()),
159 None => parse_name_without_comma(token),
160 }
161}
162
163fn parse_name_without_comma(token: &str) -> Name {
164 match token.rsplit_once(char::is_whitespace) {
165 Some((given, family)) => Name::person(family.trim(), given.trim()),
166 None => Name::literal(token),
167 }
168}
169
170fn parse_year(value: &str) -> Date {
173 value
174 .trim()
175 .parse::<i32>()
176 .map_or_else(|_err| Date::literal(value), Date::year)
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 fn entry(entry_type: &str, key: &str, fields: &[(&str, &str)]) -> BibEntry {
184 BibEntry {
185 entry_type: entry_type.to_owned(),
186 key: key.to_owned(),
187 key_span: 0..key.len(),
188 fields: fields
189 .iter()
190 .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
191 .collect(),
192 }
193 }
194
195 #[test]
196 fn maps_article_type_and_core_fields() {
197 let bib_entry = entry(
198 "article",
199 "knuth1984",
200 &[
201 ("title", "Literate Programming"),
202 ("year", "1984"),
203 ("journal", "The Computer Journal"),
204 ],
205 );
206 let item = item_from_bib_entry(&bib_entry);
207 assert_eq!(item.id, "knuth1984");
208 assert_eq!(item.kind, ItemType::ArticleJournal);
209 assert_eq!(
210 item.standard
211 .get(&StandardVariable::Title)
212 .map(String::as_str),
213 Some("Literate Programming")
214 );
215 assert_eq!(
216 item.standard
217 .get(&StandardVariable::ContainerTitle)
218 .map(String::as_str),
219 Some("The Computer Journal")
220 );
221 assert_eq!(
222 item.date.get(&DateVariable::Issued),
223 Some(&Date::year(1984))
224 );
225 }
226
227 #[test]
228 fn unwraps_outer_value_delimiters_before_mapping() {
229 let bib_entry = entry(
230 "article",
231 "knuth1984",
232 &[
233 ("title", "{The {TeX}book}"),
234 ("journal", r#""The Computer Journal""#),
235 ("year", "{1984}"),
236 ("volume", r#""27""#),
237 ("author", "{Knuth, Donald E. and Ada Lovelace}"),
238 ],
239 );
240 let item = item_from_bib_entry(&bib_entry);
241 assert_eq!(
242 item.standard
243 .get(&StandardVariable::Title)
244 .map(String::as_str),
245 Some("The {TeX}book")
246 );
247 assert_eq!(
248 item.standard
249 .get(&StandardVariable::ContainerTitle)
250 .map(String::as_str),
251 Some("The Computer Journal")
252 );
253 assert_eq!(
254 item.date.get(&DateVariable::Issued),
255 Some(&Date::year(1984))
256 );
257 assert_eq!(
258 item.number.get(&NumberVariable::Volume).map(String::as_str),
259 Some("27")
260 );
261 assert_eq!(
262 item.name.get(&NameVariable::Author),
263 Some(&vec![
264 Name::person("Knuth", "Donald E."),
265 Name::person("Lovelace", "Ada")
266 ])
267 );
268 }
269
270 #[test]
271 fn splits_authors_on_and_and_comma() {
272 let bib_entry = entry(
273 "book",
274 "k",
275 &[("author", "Knuth, Donald E. and Ada Lovelace")],
276 );
277 let item = item_from_bib_entry(&bib_entry);
278 let authors = item
279 .name
280 .get(&NameVariable::Author)
281 .expect("authors present");
282 assert_eq!(authors.len(), 2);
283 assert_eq!(authors[0], Name::person("Knuth", "Donald E."));
284 assert_eq!(authors[1], Name::person("Lovelace", "Ada"));
285 }
286
287 #[test]
288 fn splits_names_on_whitespace_delimited_and_tokens() {
289 let bib_entry = entry(
290 "book",
291 "k",
292 &[("author", "Knuth, Donald E. and\n Ada Lovelace")],
293 );
294 let item = item_from_bib_entry(&bib_entry);
295 let authors = item
296 .name
297 .get(&NameVariable::Author)
298 .expect("authors present");
299 assert_eq!(authors.len(), 2);
300 assert_eq!(authors[0], Name::person("Knuth", "Donald E."));
301 assert_eq!(authors[1], Name::person("Lovelace", "Ada"));
302 }
303
304 #[test]
305 fn unknown_type_is_document_and_unknown_fields_drop() {
306 let bib_entry = entry("flibble", "k", &[("title", "T"), ("nonsense", "x")]);
307 let item = item_from_bib_entry(&bib_entry);
308 assert_eq!(item.kind, ItemType::Document);
309 assert!(item.standard.contains_key(&StandardVariable::Title));
310 assert_eq!(item.standard.len(), 1, "unknown field should be dropped");
311 }
312
313 #[test]
314 fn non_numeric_year_becomes_a_literal_date() {
315 let bib_entry = entry("misc", "k", &[("year", "in press")]);
316 let item = item_from_bib_entry(&bib_entry);
317 assert_eq!(
318 item.date.get(&DateVariable::Issued),
319 Some(&Date::literal("in press"))
320 );
321 }
322
323 #[test]
324 fn maps_bibtex_entry_type_groups() {
325 let cases = [
326 ("article", ItemType::ArticleJournal),
327 ("book", ItemType::Book),
328 ("proceedings", ItemType::Book),
329 ("booklet", ItemType::Pamphlet),
330 ("inbook", ItemType::Chapter),
331 ("incollection", ItemType::Chapter),
332 ("conference", ItemType::PaperConference),
333 ("inproceedings", ItemType::PaperConference),
334 ("manual", ItemType::Report),
335 ("techreport", ItemType::Report),
336 ("mastersthesis", ItemType::Thesis),
337 ("phdthesis", ItemType::Thesis),
338 ("thesis", ItemType::Thesis),
339 ("unpublished", ItemType::Manuscript),
340 ("online", ItemType::Webpage),
341 ("electronic", ItemType::Webpage),
342 ("misc", ItemType::Document),
343 ];
344
345 for (entry_type, expected) in cases {
346 let item = item_from_bib_entry(&entry(entry_type, "k", &[]));
347 assert_eq!(item.kind, expected, "entry type: {entry_type}");
348 }
349 }
350
351 fn mapped_field_group_item() -> Item {
352 let bib_entry = entry(
353 "book",
354 "k",
355 &[
356 ("title", "Title"),
357 ("booktitle", "Container"),
358 ("publisher", "Publisher"),
359 ("school", "School"),
360 ("institution", "Institution"),
361 ("address", "Place"),
362 ("series", "Series"),
363 ("note", "Note"),
364 ("abstract", "Abstract"),
365 ("keywords", "Keywords"),
366 ("doi", "10.0/demo"),
367 ("url", "https://example.invalid"),
368 ("isbn", "ISBN"),
369 ("issn", "ISSN"),
370 ("language", "en"),
371 ("volume", "2"),
372 ("number", "4"),
373 ("pages", "10-20"),
374 ("edition", "3"),
375 ("chapter", "7"),
376 ],
377 );
378 item_from_bib_entry(&bib_entry)
379 }
380
381 #[test]
382 fn maps_standard_field_groups() {
383 let item = mapped_field_group_item();
384
385 assert_eq!(
386 item.standard
387 .get(&StandardVariable::Title)
388 .map(String::as_str),
389 Some("Title")
390 );
391 assert_eq!(
392 item.standard
393 .get(&StandardVariable::ContainerTitle)
394 .map(String::as_str),
395 Some("Container")
396 );
397 assert_eq!(
398 item.standard
399 .get(&StandardVariable::Publisher)
400 .map(String::as_str),
401 Some("School")
402 );
403 assert_eq!(
404 item.standard
405 .get(&StandardVariable::PublisherPlace)
406 .map(String::as_str),
407 Some("Place")
408 );
409 assert_eq!(
410 item.standard
411 .get(&StandardVariable::CollectionTitle)
412 .map(String::as_str),
413 Some("Series")
414 );
415 assert_eq!(
416 item.standard
417 .get(&StandardVariable::Note)
418 .map(String::as_str),
419 Some("Note")
420 );
421 assert_eq!(
422 item.standard
423 .get(&StandardVariable::Abstract)
424 .map(String::as_str),
425 Some("Abstract")
426 );
427 assert_eq!(
428 item.standard
429 .get(&StandardVariable::Keyword)
430 .map(String::as_str),
431 Some("Keywords")
432 );
433 assert_eq!(
434 item.standard
435 .get(&StandardVariable::Doi)
436 .map(String::as_str),
437 Some("10.0/demo")
438 );
439 assert_eq!(
440 item.standard
441 .get(&StandardVariable::Url)
442 .map(String::as_str),
443 Some("https://example.invalid")
444 );
445 assert_eq!(
446 item.standard
447 .get(&StandardVariable::Isbn)
448 .map(String::as_str),
449 Some("ISBN")
450 );
451 assert_eq!(
452 item.standard
453 .get(&StandardVariable::Issn)
454 .map(String::as_str),
455 Some("ISSN")
456 );
457 assert_eq!(
458 item.standard
459 .get(&StandardVariable::Language)
460 .map(String::as_str),
461 Some("en")
462 );
463 }
464
465 #[test]
466 fn maps_number_field_groups() {
467 let item = mapped_field_group_item();
468
469 assert_eq!(
470 item.number.get(&NumberVariable::Volume).map(String::as_str),
471 Some("2")
472 );
473 assert_eq!(
474 item.number.get(&NumberVariable::Issue).map(String::as_str),
475 Some("4")
476 );
477 assert_eq!(
478 item.number.get(&NumberVariable::Page).map(String::as_str),
479 Some("10-20")
480 );
481 assert_eq!(
482 item.number
483 .get(&NumberVariable::Edition)
484 .map(String::as_str),
485 Some("3")
486 );
487 assert_eq!(
488 item.number
489 .get(&NumberVariable::ChapterNumber)
490 .map(String::as_str),
491 Some("7")
492 );
493 }
494
495 #[test]
496 fn maps_report_number_to_number_not_issue() {
497 let item = item_from_bib_entry(&entry("techreport", "k", &[("number", "TR-7")]));
498 assert_eq!(
499 item.number.get(&NumberVariable::Number).map(String::as_str),
500 Some("TR-7")
501 );
502 assert!(!item.number.contains_key(&NumberVariable::Issue));
503 }
504
505 #[test]
506 fn maps_conference_address_to_event_place() {
507 let item = item_from_bib_entry(&entry("inproceedings", "k", &[("address", "Paris")]));
508 assert_eq!(
509 item.standard
510 .get(&StandardVariable::EventPlace)
511 .map(String::as_str),
512 Some("Paris")
513 );
514 assert!(
515 !item
516 .standard
517 .contains_key(&StandardVariable::PublisherPlace)
518 );
519 }
520
521 #[test]
522 fn maps_editors_and_skips_empty_name_tokens() {
523 let bib_entry = entry(
524 "book",
525 "k",
526 &[
527 ("author", " Ada Lovelace and and Turing, Alan "),
528 ("editor", "Knuth, Donald"),
529 ],
530 );
531 let item = item_from_bib_entry(&bib_entry);
532 assert_eq!(
533 item.name.get(&NameVariable::Author),
534 Some(&vec![
535 Name::person("Lovelace", "Ada"),
536 Name::person("Turing", "Alan")
537 ])
538 );
539 assert_eq!(
540 item.name.get(&NameVariable::Editor),
541 Some(&vec![Name::person("Knuth", "Donald")])
542 );
543 }
544
545 #[test]
546 fn maps_whole_bibliography_by_key() {
547 let bibliography = Bibliography {
548 entries: [
549 ("a".to_owned(), entry("article", "a", &[("title", "First")])),
550 ("b".to_owned(), entry("book", "b", &[("title", "Second")])),
551 ]
552 .into_iter()
553 .collect(),
554 };
555
556 let library = library_from_bibliography(&bibliography);
557 assert_eq!(library.len(), 2);
558 assert_eq!(
559 library.get("a").map(|item| item.kind),
560 Some(ItemType::ArticleJournal)
561 );
562 assert_eq!(library.get("b").map(|item| item.kind), Some(ItemType::Book));
563 }
564
565 #[test]
566 fn keeps_single_token_names_literal() {
567 let item = item_from_bib_entry(&entry("book", "k", &[("author", "Plato")]));
568 assert_eq!(
569 item.name.get(&NameVariable::Author),
570 Some(&vec![Name::literal("Plato")])
571 );
572 }
573}