1use std::collections::BTreeMap;
15
16use mos_bib::{BibEntry, Bibliography};
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, 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 splits_authors_on_and_and_comma() {
229 let bib_entry = entry(
230 "book",
231 "k",
232 &[("author", "Knuth, Donald E. and Ada Lovelace")],
233 );
234 let item = item_from_bib_entry(&bib_entry);
235 let authors = item
236 .name
237 .get(&NameVariable::Author)
238 .expect("authors present");
239 assert_eq!(authors.len(), 2);
240 assert_eq!(authors[0], Name::person("Knuth", "Donald E."));
241 assert_eq!(authors[1], Name::person("Lovelace", "Ada"));
242 }
243
244 #[test]
245 fn splits_names_on_whitespace_delimited_and_tokens() {
246 let bib_entry = entry(
247 "book",
248 "k",
249 &[("author", "Knuth, Donald E. and\n Ada Lovelace")],
250 );
251 let item = item_from_bib_entry(&bib_entry);
252 let authors = item
253 .name
254 .get(&NameVariable::Author)
255 .expect("authors present");
256 assert_eq!(authors.len(), 2);
257 assert_eq!(authors[0], Name::person("Knuth", "Donald E."));
258 assert_eq!(authors[1], Name::person("Lovelace", "Ada"));
259 }
260
261 #[test]
262 fn unknown_type_is_document_and_unknown_fields_drop() {
263 let bib_entry = entry("flibble", "k", &[("title", "T"), ("nonsense", "x")]);
264 let item = item_from_bib_entry(&bib_entry);
265 assert_eq!(item.kind, ItemType::Document);
266 assert!(item.standard.contains_key(&StandardVariable::Title));
267 assert_eq!(item.standard.len(), 1, "unknown field should be dropped");
268 }
269
270 #[test]
271 fn non_numeric_year_becomes_a_literal_date() {
272 let bib_entry = entry("misc", "k", &[("year", "in press")]);
273 let item = item_from_bib_entry(&bib_entry);
274 assert_eq!(
275 item.date.get(&DateVariable::Issued),
276 Some(&Date::literal("in press"))
277 );
278 }
279
280 #[test]
281 fn maps_bibtex_entry_type_groups() {
282 let cases = [
283 ("article", ItemType::ArticleJournal),
284 ("book", ItemType::Book),
285 ("proceedings", ItemType::Book),
286 ("booklet", ItemType::Pamphlet),
287 ("inbook", ItemType::Chapter),
288 ("incollection", ItemType::Chapter),
289 ("conference", ItemType::PaperConference),
290 ("inproceedings", ItemType::PaperConference),
291 ("manual", ItemType::Report),
292 ("techreport", ItemType::Report),
293 ("mastersthesis", ItemType::Thesis),
294 ("phdthesis", ItemType::Thesis),
295 ("thesis", ItemType::Thesis),
296 ("unpublished", ItemType::Manuscript),
297 ("online", ItemType::Webpage),
298 ("electronic", ItemType::Webpage),
299 ("misc", ItemType::Document),
300 ];
301
302 for (entry_type, expected) in cases {
303 let item = item_from_bib_entry(&entry(entry_type, "k", &[]));
304 assert_eq!(item.kind, expected, "entry type: {entry_type}");
305 }
306 }
307
308 fn mapped_field_group_item() -> Item {
309 let bib_entry = entry(
310 "book",
311 "k",
312 &[
313 ("title", "Title"),
314 ("booktitle", "Container"),
315 ("publisher", "Publisher"),
316 ("school", "School"),
317 ("institution", "Institution"),
318 ("address", "Place"),
319 ("series", "Series"),
320 ("note", "Note"),
321 ("abstract", "Abstract"),
322 ("keywords", "Keywords"),
323 ("doi", "10.0/demo"),
324 ("url", "https://example.invalid"),
325 ("isbn", "ISBN"),
326 ("issn", "ISSN"),
327 ("language", "en"),
328 ("volume", "2"),
329 ("number", "4"),
330 ("pages", "10-20"),
331 ("edition", "3"),
332 ("chapter", "7"),
333 ],
334 );
335 item_from_bib_entry(&bib_entry)
336 }
337
338 #[test]
339 fn maps_standard_field_groups() {
340 let item = mapped_field_group_item();
341
342 assert_eq!(
343 item.standard
344 .get(&StandardVariable::Title)
345 .map(String::as_str),
346 Some("Title")
347 );
348 assert_eq!(
349 item.standard
350 .get(&StandardVariable::ContainerTitle)
351 .map(String::as_str),
352 Some("Container")
353 );
354 assert_eq!(
355 item.standard
356 .get(&StandardVariable::Publisher)
357 .map(String::as_str),
358 Some("School")
359 );
360 assert_eq!(
361 item.standard
362 .get(&StandardVariable::PublisherPlace)
363 .map(String::as_str),
364 Some("Place")
365 );
366 assert_eq!(
367 item.standard
368 .get(&StandardVariable::CollectionTitle)
369 .map(String::as_str),
370 Some("Series")
371 );
372 assert_eq!(
373 item.standard
374 .get(&StandardVariable::Note)
375 .map(String::as_str),
376 Some("Note")
377 );
378 assert_eq!(
379 item.standard
380 .get(&StandardVariable::Abstract)
381 .map(String::as_str),
382 Some("Abstract")
383 );
384 assert_eq!(
385 item.standard
386 .get(&StandardVariable::Keyword)
387 .map(String::as_str),
388 Some("Keywords")
389 );
390 assert_eq!(
391 item.standard
392 .get(&StandardVariable::Doi)
393 .map(String::as_str),
394 Some("10.0/demo")
395 );
396 assert_eq!(
397 item.standard
398 .get(&StandardVariable::Url)
399 .map(String::as_str),
400 Some("https://example.invalid")
401 );
402 assert_eq!(
403 item.standard
404 .get(&StandardVariable::Isbn)
405 .map(String::as_str),
406 Some("ISBN")
407 );
408 assert_eq!(
409 item.standard
410 .get(&StandardVariable::Issn)
411 .map(String::as_str),
412 Some("ISSN")
413 );
414 assert_eq!(
415 item.standard
416 .get(&StandardVariable::Language)
417 .map(String::as_str),
418 Some("en")
419 );
420 }
421
422 #[test]
423 fn maps_number_field_groups() {
424 let item = mapped_field_group_item();
425
426 assert_eq!(
427 item.number.get(&NumberVariable::Volume).map(String::as_str),
428 Some("2")
429 );
430 assert_eq!(
431 item.number.get(&NumberVariable::Issue).map(String::as_str),
432 Some("4")
433 );
434 assert_eq!(
435 item.number.get(&NumberVariable::Page).map(String::as_str),
436 Some("10-20")
437 );
438 assert_eq!(
439 item.number
440 .get(&NumberVariable::Edition)
441 .map(String::as_str),
442 Some("3")
443 );
444 assert_eq!(
445 item.number
446 .get(&NumberVariable::ChapterNumber)
447 .map(String::as_str),
448 Some("7")
449 );
450 }
451
452 #[test]
453 fn maps_report_number_to_number_not_issue() {
454 let item = item_from_bib_entry(&entry("techreport", "k", &[("number", "TR-7")]));
455 assert_eq!(
456 item.number.get(&NumberVariable::Number).map(String::as_str),
457 Some("TR-7")
458 );
459 assert!(!item.number.contains_key(&NumberVariable::Issue));
460 }
461
462 #[test]
463 fn maps_conference_address_to_event_place() {
464 let item = item_from_bib_entry(&entry("inproceedings", "k", &[("address", "Paris")]));
465 assert_eq!(
466 item.standard
467 .get(&StandardVariable::EventPlace)
468 .map(String::as_str),
469 Some("Paris")
470 );
471 assert!(
472 !item
473 .standard
474 .contains_key(&StandardVariable::PublisherPlace)
475 );
476 }
477
478 #[test]
479 fn maps_editors_and_skips_empty_name_tokens() {
480 let bib_entry = entry(
481 "book",
482 "k",
483 &[
484 ("author", " Ada Lovelace and and Turing, Alan "),
485 ("editor", "Knuth, Donald"),
486 ],
487 );
488 let item = item_from_bib_entry(&bib_entry);
489 assert_eq!(
490 item.name.get(&NameVariable::Author),
491 Some(&vec![
492 Name::person("Lovelace", "Ada"),
493 Name::person("Turing", "Alan")
494 ])
495 );
496 assert_eq!(
497 item.name.get(&NameVariable::Editor),
498 Some(&vec![Name::person("Knuth", "Donald")])
499 );
500 }
501
502 #[test]
503 fn maps_whole_bibliography_by_key() {
504 let bibliography = Bibliography {
505 entries: [
506 ("a".to_owned(), entry("article", "a", &[("title", "First")])),
507 ("b".to_owned(), entry("book", "b", &[("title", "Second")])),
508 ]
509 .into_iter()
510 .collect(),
511 };
512
513 let library = library_from_bibliography(&bibliography);
514 assert_eq!(library.len(), 2);
515 assert_eq!(
516 library.get("a").map(|item| item.kind),
517 Some(ItemType::ArticleJournal)
518 );
519 assert_eq!(library.get("b").map(|item| item.kind), Some(ItemType::Book));
520 }
521
522 #[test]
523 fn keeps_single_token_names_literal() {
524 let item = item_from_bib_entry(&entry("book", "k", &[("author", "Plato")]));
525 assert_eq!(
526 item.name.get(&NameVariable::Author),
527 Some(&vec![Name::literal("Plato")])
528 );
529 }
530}