biblion/tools/
bibliography.rs

1//! Native bibliography formatting — APA and IEEE styles.
2//!
3//! # Why native?
4//!
5//! The full CSL (Citation Style Language) spec supports 9000+ styles, but
6//! we only use 2-3 regularly. Implementing those natively eliminates the
7//! last BBT dependency for common usage. BBT remains as fallback for
8//! exotic styles.
9//!
10//! # Reference implementation
11//!
12//! These formatters were built from the official CSL style definitions:
13//! - APA 7th edition: <https://www.zotero.org/styles/apa>
14//! - IEEE: <https://www.zotero.org/styles/ieee>
15//!
16//! The Python MCP server delegated this to BBT's CSL engine via JSON-RPC
17//! (`item.bibliography` method at `bbt_client.py:99`). That engine uses
18//! `citeproc-js` internally. Our native implementation handles the common
19//! cases; edge cases (non-Latin scripts, legal citations, etc.) fall back
20//! to BBT.
21//!
22//! # Verification
23//!
24//! To verify output matches BBT's CSL engine, compare:
25//! ```bash
26//! # Native (Rust):
27//! echo '...tools/call zotero_get_bibliography...' | biblion-rs
28//!
29//! # BBT reference (Python, requires Zotero running):
30//! curl -X POST http://localhost:23119/better-bibtex/json-rpc \
31//!   -d '{"jsonrpc":"2.0","id":1,"method":"item.bibliography",
32//!        "params":[["citekey"],{"id":"http://www.zotero.org/styles/apa"}]}'
33//! ```
34
35use super::format::extract_year;
36use crate::db::zotero::{Creator, ZoteroItem};
37use std::collections::HashMap;
38
39/// Supported native styles. Anything else falls back to BBT.
40pub fn is_native_style(style: &str) -> bool {
41    let s = style.to_lowercase();
42    s.contains("apa") || s.contains("ieee")
43}
44
45/// Format a bibliography entry in the requested style.
46pub fn format_bibliography(
47    item: &ZoteroItem,
48    metadata: &HashMap<String, String>,
49    style: &str,
50) -> String {
51    let s = style.to_lowercase();
52    if s.contains("ieee") {
53        format_ieee(item, metadata)
54    } else {
55        // Default to APA
56        format_apa(item, metadata)
57    }
58}
59
60/// Format multiple items as a numbered bibliography.
61pub fn format_bibliography_list(
62    items: &[(ZoteroItem, HashMap<String, String>)],
63    style: &str,
64) -> String {
65    items
66        .iter()
67        .enumerate()
68        .map(|(i, (item, metadata))| {
69            let entry = format_bibliography(item, metadata, style);
70            if style.to_lowercase().contains("ieee") {
71                format!("[{}] {}", i + 1, entry)
72            } else {
73                entry
74            }
75        })
76        .collect::<Vec<_>>()
77        .join("\n\n")
78}
79
80// ---------------------------------------------------------------------------
81// APA 7th edition
82// ---------------------------------------------------------------------------
83//
84// Reference: https://apastyle.apa.org/style-grammar-guidelines/references
85//
86// Journal article pattern:
87//   Author, A. A., Author, B. B., & Author, C. C. (Year). Title of article.
88//   *Title of Periodical*, *Volume*(Issue), Pages. https://doi.org/xxxxx
89//
90// Book pattern:
91//   Author, A. A. (Year). *Title of work*. Publisher.
92//
93// Conference paper pattern:
94//   Author, A. A. (Year). Title of paper. In *Proceedings of Conference*
95//   (pp. Pages). Publisher. https://doi.org/xxxxx
96
97fn format_apa(item: &ZoteroItem, metadata: &HashMap<String, String>) -> String {
98    let mut parts: Vec<String> = Vec::new();
99
100    // Authors: "Last, F. I., Last2, F. I., & Last3, F. I."
101    let authors = format_apa_authors(&item.creators);
102    if !authors.is_empty() {
103        parts.push(authors);
104    }
105
106    // Year: "(2024)."
107    if let Some(year) = item.date.as_ref().and_then(|d| extract_year(d)) {
108        parts.push(format!("({year})."));
109    }
110
111    // Title (italicized for books, plain for articles)
112    match item.item_type.as_str() {
113        "book" => parts.push(format!("*{}*.", item.title)),
114        _ => parts.push(format!("{}.", item.title)),
115    }
116
117    // Journal/conference name (italicized)
118    if let Some(journal) = metadata.get("publicationTitle") {
119        let volume = metadata.get("volume");
120        let issue = metadata.get("issue");
121        let pages = metadata.get("pages");
122
123        let mut journal_part = format!("*{journal}*");
124        if let Some(vol) = volume {
125            journal_part.push_str(&format!(", *{vol}*"));
126            if let Some(iss) = issue {
127                journal_part.push_str(&format!("({iss})"));
128            }
129        }
130        if let Some(pp) = pages {
131            journal_part.push_str(&format!(", {pp}"));
132        }
133        journal_part.push('.');
134        parts.push(journal_part);
135    } else if let Some(conf) = metadata.get("conferenceName") {
136        let mut conf_part = format!("In *{conf}*");
137        if let Some(pp) = metadata.get("pages") {
138            conf_part.push_str(&format!(" (pp. {pp})"));
139        }
140        conf_part.push('.');
141        parts.push(conf_part);
142    } else if let Some(publisher) = metadata.get("publisher") {
143        parts.push(format!("{publisher}."));
144    }
145
146    // DOI
147    if let Some(doi) = &item.doi {
148        parts.push(format!("https://doi.org/{doi}"));
149    }
150
151    parts.join(" ")
152}
153
154/// Format authors in APA style: "Last, F. I., Last2, F. I., & Last3, F. I."
155fn format_apa_authors(creators: &[Creator]) -> String {
156    let authors: Vec<&Creator> = creators
157        .iter()
158        .filter(|c| c.creator_type == "author")
159        .collect();
160
161    if authors.is_empty() {
162        return String::new();
163    }
164
165    let formatted: Vec<String> = authors
166        .iter()
167        .map(|c| {
168            match &c.first_name {
169                Some(first) if !first.is_empty() => {
170                    // "DeMillo, R. A." — initials with periods
171                    let initials: String = first
172                        .split_whitespace()
173                        .map(|w| format!("{}.", w.chars().next().unwrap_or(' ')))
174                        .collect::<Vec<_>>()
175                        .join(" ");
176                    format!("{}, {}", c.last_name, initials)
177                }
178                _ => c.last_name.clone(),
179            }
180        })
181        .collect();
182
183    match formatted.len() {
184        1 => formatted[0].clone(),
185        2 => format!("{} & {}", formatted[0], formatted[1]),
186        _ => {
187            // APA: "A, B, C, ... & Z" (up to 20 authors)
188            let last = formatted.last().unwrap();
189            let rest = &formatted[..formatted.len() - 1];
190            format!("{}, & {}", rest.join(", "), last)
191        }
192    }
193}
194
195// ---------------------------------------------------------------------------
196// IEEE
197// ---------------------------------------------------------------------------
198//
199// Reference: https://ieeeauthorcenter.ieee.org/wp-content/uploads/IEEE-Reference-Guide.pdf
200//
201// Journal article pattern:
202//   F. I. Author, F. I. Author, and F. I. Author, "Title of article,"
203//   *Title of Journal*, vol. V, no. N, pp. P1–P2, Month Year.
204//
205// Conference paper pattern:
206//   F. I. Author, "Title of paper," in *Proc. Conference*, Year, pp. P1–P2.
207
208fn format_ieee(item: &ZoteroItem, metadata: &HashMap<String, String>) -> String {
209    let mut parts: Vec<String> = Vec::new();
210
211    // Authors: "F. I. Last, F. I. Last, and F. I. Last"
212    let authors = format_ieee_authors(&item.creators);
213    if !authors.is_empty() {
214        parts.push(format!("{authors},"));
215    }
216
217    // Title in quotes
218    parts.push(format!("\"{}\"", item.title));
219
220    // Journal/conference
221    if let Some(journal) = metadata.get("publicationTitle") {
222        let mut journal_part = format!("*{journal}*");
223        if let Some(vol) = metadata.get("volume") {
224            journal_part.push_str(&format!(", vol. {vol}"));
225        }
226        if let Some(iss) = metadata.get("issue") {
227            journal_part.push_str(&format!(", no. {iss}"));
228        }
229        if let Some(pp) = metadata.get("pages") {
230            journal_part.push_str(&format!(", pp. {pp}"));
231        }
232        if let Some(year) = item.date.as_ref().and_then(|d| extract_year(d)) {
233            journal_part.push_str(&format!(", {year}"));
234        }
235        journal_part.push('.');
236        parts.push(journal_part);
237    } else if let Some(conf) = metadata.get("conferenceName") {
238        let mut conf_part = format!("in *{conf}*");
239        if let Some(year) = item.date.as_ref().and_then(|d| extract_year(d)) {
240            conf_part.push_str(&format!(", {year}"));
241        }
242        if let Some(pp) = metadata.get("pages") {
243            conf_part.push_str(&format!(", pp. {pp}"));
244        }
245        conf_part.push('.');
246        parts.push(conf_part);
247    } else if let Some(year) = item.date.as_ref().and_then(|d| extract_year(d)) {
248        parts.push(format!("{year}."));
249    }
250
251    // DOI
252    if let Some(doi) = &item.doi {
253        parts.push(format!("doi: {doi}."));
254    }
255
256    parts.join(" ")
257}
258
259/// Format authors in IEEE style: "F. I. Last, F. I. Last, and F. I. Last"
260fn format_ieee_authors(creators: &[Creator]) -> String {
261    let authors: Vec<&Creator> = creators
262        .iter()
263        .filter(|c| c.creator_type == "author")
264        .collect();
265
266    if authors.is_empty() {
267        return String::new();
268    }
269
270    let formatted: Vec<String> = authors
271        .iter()
272        .map(|c| match &c.first_name {
273            Some(first) if !first.is_empty() => {
274                let initials: String = first
275                    .split_whitespace()
276                    .map(|w| format!("{}.", w.chars().next().unwrap_or(' ')))
277                    .collect::<Vec<_>>()
278                    .join(" ");
279                format!("{} {}", initials, c.last_name)
280            }
281            _ => c.last_name.clone(),
282        })
283        .collect();
284
285    match formatted.len() {
286        1 => formatted[0].clone(),
287        2 => format!("{} and {}", formatted[0], formatted[1]),
288        _ => {
289            let last = formatted.last().unwrap();
290            let rest = &formatted[..formatted.len() - 1];
291            format!("{}, and {}", rest.join(", "), last)
292        }
293    }
294}
295
296// extract_year is imported from format.rs
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301    use crate::db::zotero::Creator;
302
303    fn demillo_item() -> ZoteroItem {
304        ZoteroItem {
305            item_id: 1,
306            item_key: "ABC12345".into(),
307            item_type: "journalArticle".into(),
308            title: "Hints on Test Data Selection: Help for the Practicing Programmer".into(),
309            date: Some("1978".into()),
310            doi: Some("10.1109/C-M.1978.218136".into()),
311            url: None,
312            abstract_note: None,
313            creators: vec![
314                Creator {
315                    creator_type: "author".into(),
316                    first_name: Some("Richard A.".into()),
317                    last_name: "DeMillo".into(),
318                    order: 0,
319                },
320                Creator {
321                    creator_type: "author".into(),
322                    first_name: Some("Richard J.".into()),
323                    last_name: "Lipton".into(),
324                    order: 1,
325                },
326                Creator {
327                    creator_type: "author".into(),
328                    first_name: Some("Fred G.".into()),
329                    last_name: "Sayward".into(),
330                    order: 2,
331                },
332            ],
333            tags: vec![],
334            date_added: "2024-01-01".into(),
335            date_modified: "2024-06-15".into(),
336        }
337    }
338
339    fn demillo_metadata() -> HashMap<String, String> {
340        let mut m = HashMap::new();
341        m.insert("publicationTitle".into(), "Computer".into());
342        m.insert("volume".into(), "11".into());
343        m.insert("issue".into(), "4".into());
344        m.insert("pages".into(), "34-41".into());
345        m
346    }
347
348    // --- APA ---
349
350    #[test]
351    fn apa_journal_article() {
352        let bib = format_apa(&demillo_item(), &demillo_metadata());
353        // APA: "DeMillo, R. A., Lipton, R. J., & Sayward, F. G. (1978). Hints on..."
354        assert!(
355            bib.contains("DeMillo, R. A., Lipton, R. J., & Sayward, F. G."),
356            "Got: {bib}"
357        );
358        assert!(bib.contains("(1978)."));
359        assert!(bib.contains("Hints on Test Data Selection"));
360        assert!(bib.contains("*Computer*, *11*(4), 34-41."));
361        assert!(bib.contains("https://doi.org/10.1109/C-M.1978.218136"));
362    }
363
364    #[test]
365    fn apa_two_authors() {
366        let mut item = demillo_item();
367        item.creators = vec![
368            Creator {
369                creator_type: "author".into(),
370                first_name: Some("Yue".into()),
371                last_name: "Jia".into(),
372                order: 0,
373            },
374            Creator {
375                creator_type: "author".into(),
376                first_name: Some("Mark".into()),
377                last_name: "Harman".into(),
378                order: 1,
379            },
380        ];
381        let bib = format_apa(&item, &HashMap::new());
382        assert!(bib.contains("Jia, Y. & Harman, M."), "Got: {bib}");
383    }
384
385    #[test]
386    fn apa_single_author() {
387        let mut item = demillo_item();
388        item.creators = vec![Creator {
389            creator_type: "author".into(),
390            first_name: Some("Yue".into()),
391            last_name: "Jia".into(),
392            order: 0,
393        }];
394        let bib = format_apa(&item, &HashMap::new());
395        assert!(bib.starts_with("Jia, Y."), "Got: {bib}");
396    }
397
398    #[test]
399    fn apa_book() {
400        let mut item = demillo_item();
401        item.item_type = "book".into();
402        item.title = "The Art of Software Testing".into();
403        let mut meta = HashMap::new();
404        meta.insert("publisher".into(), "Wiley".into());
405        let bib = format_apa(&item, &meta);
406        assert!(bib.contains("*The Art of Software Testing*."), "Got: {bib}");
407        assert!(bib.contains("Wiley."));
408    }
409
410    // --- IEEE ---
411
412    #[test]
413    fn ieee_journal_article() {
414        let bib = format_ieee(&demillo_item(), &demillo_metadata());
415        // IEEE: "R. A. DeMillo, R. J. Lipton, and F. G. Sayward, "Hints on..."
416        assert!(
417            bib.contains("R. A. DeMillo, R. J. Lipton, and F. G. Sayward"),
418            "Got: {bib}"
419        );
420        assert!(bib.contains("\"Hints on Test Data Selection"));
421        assert!(bib.contains("*Computer*"));
422        assert!(bib.contains("vol. 11"));
423        assert!(bib.contains("no. 4"));
424        assert!(bib.contains("pp. 34-41"));
425        assert!(bib.contains("doi: 10.1109/C-M.1978.218136"));
426    }
427
428    #[test]
429    fn ieee_two_authors() {
430        let mut item = demillo_item();
431        item.creators = vec![
432            Creator {
433                creator_type: "author".into(),
434                first_name: Some("Goran".into()),
435                last_name: "Petrovic".into(),
436                order: 0,
437            },
438            Creator {
439                creator_type: "author".into(),
440                first_name: Some("Marko".into()),
441                last_name: "Ivankovic".into(),
442                order: 1,
443            },
444        ];
445        let bib = format_ieee(&item, &HashMap::new());
446        assert!(bib.contains("G. Petrovic and M. Ivankovic"), "Got: {bib}");
447    }
448
449    // --- Style detection ---
450
451    #[test]
452    fn native_style_detection() {
453        assert!(is_native_style("http://www.zotero.org/styles/apa"));
454        assert!(is_native_style("apa"));
455        assert!(is_native_style("APA"));
456        assert!(is_native_style("http://www.zotero.org/styles/ieee"));
457        assert!(is_native_style("IEEE"));
458        assert!(!is_native_style(
459            "http://www.zotero.org/styles/chicago-author-date"
460        ));
461        assert!(!is_native_style("vancouver"));
462    }
463
464    // --- List formatting ---
465
466    #[test]
467    fn ieee_list_is_numbered() {
468        let items = vec![(demillo_item(), demillo_metadata())];
469        let list = format_bibliography_list(&items, "ieee");
470        assert!(list.starts_with("[1]"), "Got: {list}");
471    }
472
473    #[test]
474    fn apa_list_is_not_numbered() {
475        let items = vec![(demillo_item(), demillo_metadata())];
476        let list = format_bibliography_list(&items, "apa");
477        assert!(!list.starts_with("[1]"));
478        assert!(list.starts_with("DeMillo"), "Got: {list}");
479    }
480}