use crate::models::{Entry, EntryType};
fn decode_html_entities(s: &str) -> String {
s.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace(""", "\"")
.replace("'", "'")
.replace("'", "'")
.replace("'", "'")
.replace(" ", " ")
}
fn escape_bibtex(s: &str) -> String {
let decoded = decode_html_entities(s);
let mut result = String::with_capacity(decoded.len() + 16);
let chars: Vec<char> = decoded.chars().collect();
let len = chars.len();
let mut i = 0;
while i < len {
if chars[i] == '\\' && i + 1 < len {
match chars[i + 1] {
'&' | '%' | '#' | '_' | '$' => {
result.push('\\');
result.push(chars[i + 1]);
i += 2;
continue;
}
_ => {}
}
}
match chars[i] {
'&' => result.push_str("\\&"),
'%' => result.push_str("\\%"),
'#' => result.push_str("\\#"),
'_' => result.push_str("\\_"),
'$' => result.push_str("\\$"),
ch => result.push(ch),
}
i += 1;
}
result
}
fn is_bibtex_month_macro(s: &str) -> bool {
matches!(
s.trim().to_lowercase().as_str(),
"jan" | "feb" | "mar" | "apr" | "may" | "jun"
| "jul" | "aug" | "sep" | "oct" | "nov" | "dec"
)
}
fn format_month_field(month: &str) -> String {
let trimmed = month.trim();
if is_bibtex_month_macro(trimmed) {
format!(" month = {},", trimmed.to_lowercase())
} else {
format!(" month = {{{}}},", trimmed)
}
}
pub fn entry_to_bibtex(entry: &Entry) -> String {
let mut lines = Vec::new();
let type_str = match entry.entry_type {
EntryType::Article => "article",
EntryType::Book => "book",
EntryType::InProceedings => "inproceedings",
EntryType::Misc => "misc",
};
lines.push(format!("@{}{{{},", type_str, entry.bibtex_key));
if let Some(title) = &entry.title {
lines.push(format!(" title = {{{}}},", escape_bibtex(title)));
}
if !entry.author.is_empty() {
let author_str = entry.author.join(" and ");
lines.push(format!(" author = {{{}}},", escape_bibtex(&author_str)));
}
if let Some(year) = entry.year {
lines.push(format!(" year = {{{}}},", year));
}
if let Some(month) = &entry.month {
lines.push(format_month_field(month));
}
match entry.entry_type {
EntryType::Article => {
if let Some(j) = &entry.journal {
lines.push(format!(" journal = {{{}}},", escape_bibtex(j)));
}
if let Some(v) = &entry.volume {
lines.push(format!(" volume = {{{}}},", v));
}
if let Some(n) = &entry.number {
lines.push(format!(" number = {{{}}},", n));
}
if let Some(p) = &entry.pages {
lines.push(format!(" pages = {{{}}},", p));
}
}
EntryType::Book => {
if let Some(p) = &entry.publisher {
lines.push(format!(" publisher = {{{}}},", escape_bibtex(p)));
}
if let Some(ed) = &entry.editor {
lines.push(format!(" editor = {{{}}},", escape_bibtex(ed)));
}
if let Some(e) = &entry.edition {
lines.push(format!(" edition = {{{}}},", e));
}
if let Some(isbn) = &entry.isbn {
lines.push(format!(" isbn = {{{}}},", isbn));
}
}
EntryType::InProceedings => {
if let Some(bt) = &entry.booktitle {
lines.push(format!(" booktitle = {{{}}},", escape_bibtex(bt)));
}
if let Some(p) = &entry.pages {
lines.push(format!(" pages = {{{}}},", p));
}
}
EntryType::Misc => {
if let Some(hp) = &entry.howpublished {
lines.push(format!(" howpublished = {{{}}},", escape_bibtex(hp)));
} else if let Some(u) = &entry.url {
lines.push(format!(" url = {{{}}},", u));
}
}
}
if let Some(doi) = &entry.doi {
lines.push(format!(" doi = {{{}}},", doi));
}
if let Some(note) = &entry.note {
lines.push(format!(" note = {{{}}},", escape_bibtex(note)));
}
if let Some(last) = lines.last_mut() {
if last.ends_with(',') {
last.pop();
}
}
lines.push("}".to_string());
lines.join("\n")
}
pub fn entries_to_bibtex(entries: &[&Entry]) -> String {
entries
.iter()
.map(|e| entry_to_bibtex(e))
.collect::<Vec<_>>()
.join("\n\n")
}
pub fn entry_to_filename(entry: &Entry) -> String {
let author_part = match entry.author.len() {
0 => "Unknown".to_string(),
1 => entry.author[0]
.split(',')
.next()
.unwrap_or("Unknown")
.trim()
.to_string(),
_ => {
let last = entry.author[0]
.split(',')
.next()
.unwrap_or("Unknown")
.trim()
.to_string();
format!("{}_et_al", last)
}
};
let year_part = entry
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "0000".to_string());
let title_part = entry.title.as_deref().unwrap_or("Untitled");
let sanitize = |s: &str| -> String {
s.chars()
.map(|c| match c {
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
_ => c,
})
.collect()
};
format!(
"{}_{}_{}",
sanitize(&author_part),
year_part,
sanitize(title_part)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn escape_raw_ampersand() {
assert_eq!(escape_bibtex("A & B"), "A \\& B");
}
#[test]
fn escape_all_special_chars() {
assert_eq!(escape_bibtex("a & b % c # d _ e $ f"), "a \\& b \\% c \\# d \\_ e \\$ f");
}
#[test]
fn no_double_escape() {
assert_eq!(escape_bibtex("already \\& escaped"), "already \\& escaped");
assert_eq!(escape_bibtex("test \\% ok"), "test \\% ok");
assert_eq!(escape_bibtex("val \\# x"), "val \\# x");
assert_eq!(escape_bibtex("a \\_ b"), "a \\_ b");
assert_eq!(escape_bibtex("c \\$ d"), "c \\$ d");
}
#[test]
fn html_entity_decode_and_escape() {
assert_eq!(escape_bibtex("Sex & vision"), "Sex \\& vision");
assert_eq!(escape_bibtex("A < B > C"), "A < B > C");
assert_eq!(escape_bibtex(""hello""), "\"hello\"");
}
#[test]
fn html_entity_nbsp() {
assert_eq!(escape_bibtex("word word"), "word word");
}
#[test]
fn mixed_html_and_raw() {
assert_eq!(
escape_bibtex("Virtual Reality & Intelligent Hardware"),
"Virtual Reality \\& Intelligent Hardware"
);
}
#[test]
fn plain_text_unchanged() {
assert_eq!(escape_bibtex("Normal title here"), "Normal title here");
}
#[test]
fn unicode_preserved() {
assert_eq!(escape_bibtex("Müller & Ström"), "Müller \\& Ström");
}
fn make_misc_entry() -> Entry {
Entry {
id: "test-id".to_string(),
bibtex_key: "vrgorilla2024thailand".to_string(),
entry_type: EntryType::Misc,
title: Some("Bangkok, Thailand Guided Tour in 360 VR".to_string()),
author: vec!["VR Gorilla".to_string()],
year: Some(2024),
journal: None,
volume: None,
number: None,
pages: None,
publisher: None,
editor: None,
edition: None,
isbn: None,
booktitle: None,
doi: None,
url: Some("https://www.youtube.com/watch?v=pCVeh2Rv5Qo".to_string()),
abstract_text: None,
tags: vec![],
howpublished: None,
month: None,
note: None,
collections: vec![],
file_path: None,
created_at: "2024-01-01 00:00:00".to_string(),
updated_at: None,
}
}
#[test]
fn misc_howpublished_replaces_url() {
let mut entry = make_misc_entry();
entry.howpublished = Some(r#"[Online Video]. Available: \url{https://www.youtube.com/watch?v=pCVeh2Rv5Qo}"#.to_string());
let bib = entry_to_bibtex(&entry);
assert!(bib.contains("howpublished"), "should contain howpublished field");
assert!(!bib.contains(" url "), "should not contain url when howpublished is set");
assert!(bib.contains(r"\url{"), "backslash url should be preserved");
}
#[test]
fn misc_url_fallback_without_howpublished() {
let entry = make_misc_entry();
let bib = entry_to_bibtex(&entry);
assert!(bib.contains("url"), "should contain url when no howpublished");
assert!(!bib.contains("howpublished"), "should not contain howpublished");
}
#[test]
fn month_macro_no_braces() {
let mut entry = make_misc_entry();
entry.month = Some("jan".to_string());
let bib = entry_to_bibtex(&entry);
assert!(bib.contains("month = jan"), "macro month should have no braces, got:\n{}", bib);
}
#[test]
fn month_non_macro_has_braces() {
let mut entry = make_misc_entry();
entry.month = Some("January".to_string());
let bib = entry_to_bibtex(&entry);
assert!(bib.contains("month = {January}"), "non-macro month should have braces, got:\n{}", bib);
}
#[test]
fn month_macro_case_insensitive() {
let mut entry = make_misc_entry();
entry.month = Some("Jan".to_string());
let bib = entry_to_bibtex(&entry);
assert!(bib.contains("month = jan"), "should normalize to lowercase, got:\n{}", bib);
}
#[test]
fn misc_full_ieee_format() {
let mut entry = make_misc_entry();
entry.month = Some("jan".to_string());
entry.howpublished = Some(r#"[Online Video]. Available: \url{https://www.youtube.com/watch?v=pCVeh2Rv5Qo}"#.to_string());
entry.note = Some("Accessed: Aug. 15, 2024".to_string());
entry.url = Some("https://www.youtube.com/watch?v=pCVeh2Rv5Qo".to_string());
let bib = entry_to_bibtex(&entry);
assert!(bib.contains("@misc{vrgorilla2024thailand,"));
assert!(bib.contains("month = jan,"));
assert!(bib.contains("howpublished = {"));
assert!(bib.contains(r"\url{"));
assert!(bib.contains("note = {Accessed: Aug. 15, 2024}"));
assert!(!bib.contains(" url "), "url field should not appear when howpublished is set");
}
}