use crate::error::{OptimError, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CitationManager {
pub citations: HashMap<String, Citation>,
pub styles: HashMap<String, CitationStyle>,
pub default_style: String,
pub groups: HashMap<String, CitationGroup>,
pub settings: CitationSettings,
pub modified_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Citation {
pub key: String,
pub publication_type: PublicationType,
pub title: String,
pub authors: Vec<Author>,
pub year: Option<u32>,
pub venue: Option<String>,
pub volume: Option<String>,
pub issue: Option<String>,
pub pages: Option<String>,
pub doi: Option<String>,
pub url: Option<String>,
pub abstracttext: Option<String>,
pub keywords: Vec<String>,
pub notes: Option<String>,
pub custom_fields: HashMap<String, String>,
pub attachments: Vec<String>,
pub groups: Vec<String>,
pub import_source: Option<String>,
pub created_at: DateTime<Utc>,
pub modified_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum PublicationType {
Article,
InProceedings,
Book,
InCollection,
PhDThesis,
MastersThesis,
TechReport,
Manual,
Misc,
Unpublished,
Preprint,
Patent,
Software,
Dataset,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Author {
pub first_name: String,
pub last_name: String,
pub middle_name: Option<String>,
pub suffix: Option<String>,
pub orcid: Option<String>,
pub affiliation: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CitationStyle {
pub name: String,
pub description: String,
pub intext_format: InTextFormat,
pub bibliography_format: BibliographyFormat,
pub formatting_rules: FormattingRules,
pub sorting_rules: SortingRules,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum InTextFormat {
AuthorYear,
Numbered,
Superscript,
AuthorNumber,
Footnote,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BibliographyFormat {
pub entry_separator: String,
pub field_separators: HashMap<String, String>,
pub name_format: NameFormat,
pub title_format: TitleFormat,
pub date_format: DateFormat,
pub punctuation: PunctuationRules,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum NameFormat {
LastFirstMiddle,
FirstMiddleLast,
LastFirstInitial,
FirstInitialLast,
LastFirstInitialNoSpace,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum TitleFormat {
TitleCase,
SentenceCase,
Uppercase,
Lowercase,
AsEntered,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum DateFormat {
Year,
MonthYear,
MonthAbbrevYear,
FullDate,
ISODate,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PunctuationRules {
pub periods_after_abbreviations: bool,
pub commas_between_fields: bool,
pub parentheses_around_year: bool,
pub quote_titles: bool,
pub italicize_journals: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormattingRules {
pub max_authors: Option<usize>,
pub et_altext: String,
pub et_al_threshold: usize,
pub title_case: bool,
pub abbreviate_journals: bool,
pub include_doi: bool,
pub include_url: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SortingRules {
pub primary_sort: SortField,
pub secondary_sort: Option<SortField>,
pub sort_direction: SortDirection,
pub group_by_type: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum SortField {
Author,
Year,
Title,
Venue,
Key,
DateAdded,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum SortDirection {
Ascending,
Descending,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CitationGroup {
pub name: String,
pub description: String,
pub color: Option<String>,
pub citation_keys: Vec<String>,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CitationSettings {
pub auto_generate_keys: bool,
pub key_pattern: String,
pub auto_import_doi: bool,
pub auto_import_url: bool,
pub duplicate_detection: bool,
pub backup_enabled: bool,
pub export_formats: Vec<ExportFormat>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ExportFormat {
BibTeX,
RIS,
EndNote,
JSON,
CSV,
Word,
}
#[derive(Debug)]
pub struct BibTeXProcessor {
settings: BibTeXSettings,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BibTeXSettings {
pub preserve_case: bool,
pub utf8_conversion: bool,
pub cleanup_formatting: bool,
pub validate_entries: bool,
}
#[derive(Debug)]
pub struct CitationDiscovery {
search_engines: Vec<SearchEngine>,
api_keys: HashMap<String, String>,
}
impl CitationDiscovery {
pub fn new() -> Self {
Self {
search_engines: Vec::new(),
api_keys: HashMap::new(),
}
}
pub fn with_search_engine(mut self, engine: SearchEngine) -> Self {
self.search_engines.push(engine);
self
}
pub fn add_search_engine(&mut self, engine: SearchEngine) {
self.search_engines.push(engine);
}
pub fn set_api_key(&mut self, engine_name: &str, key: &str) {
self.api_keys
.insert(engine_name.to_string(), key.to_string());
}
pub fn search_engines(&self) -> &[SearchEngine] {
&self.search_engines
}
pub fn has_credentials(&self, engine_name: &str) -> bool {
self.api_keys.contains_key(engine_name)
}
pub fn engines_for(&self, query_type: &QueryType) -> Vec<&SearchEngine> {
let mut engines: Vec<&SearchEngine> = self
.search_engines
.iter()
.filter(|engine| engine.query_types.contains(query_type))
.collect();
engines.sort_by(|a, b| {
a.rate_limit
.partial_cmp(&b.rate_limit)
.unwrap_or(std::cmp::Ordering::Equal)
});
engines
}
}
impl Default for CitationDiscovery {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchEngine {
pub name: String,
pub endpoint: String,
pub rate_limit: f64,
pub query_types: Vec<QueryType>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum QueryType {
DOI,
Title,
Author,
ArXiv,
PubMed,
ISBN,
FreeText,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CitationNetwork {
pub citations: Vec<String>,
pub relationships: Vec<CitationRelationship>,
pub metrics: NetworkMetrics,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CitationRelationship {
pub citing: String,
pub cited: String,
pub relationship_type: RelationshipType,
pub strength: f64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum RelationshipType {
DirectCitation,
CoCitation,
BibliographicCoupling,
SameAuthor,
SameVenue,
SimilarTopic,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NetworkMetrics {
pub total_nodes: usize,
pub total_edges: usize,
pub density: f64,
pub clustering_coefficient: f64,
pub most_cited: Vec<(String, usize)>,
pub most_influential_authors: Vec<(String, f64)>,
}
impl Default for CitationManager {
fn default() -> Self {
Self::new()
}
}
impl CitationManager {
pub fn new() -> Self {
let mut styles = HashMap::new();
styles.insert("APA".to_string(), Self::create_apa_style());
styles.insert("IEEE".to_string(), Self::create_ieee_style());
styles.insert("ACM".to_string(), Self::create_acm_style());
Self {
citations: HashMap::new(),
styles,
default_style: "APA".to_string(),
groups: HashMap::new(),
settings: CitationSettings::default(),
modified_at: Utc::now(),
}
}
pub fn add_citation(&mut self, citation: Citation) -> Result<()> {
if self.citations.contains_key(&citation.key) {
return Err(OptimError::InvalidConfig(format!(
"Citation with key '{}' already exists",
citation.key
)));
}
self.citations.insert(citation.key.clone(), citation);
self.modified_at = Utc::now();
Ok(())
}
pub fn get_citation(&self, key: &str) -> Option<&Citation> {
self.citations.get(key)
}
pub fn update_citation(&mut self, key: &str, citation: Citation) -> Result<()> {
if !self.citations.contains_key(key) {
return Err(OptimError::InvalidConfig(format!(
"Citation with key '{}' not found",
key
)));
}
self.citations.insert(key.to_string(), citation);
self.modified_at = Utc::now();
Ok(())
}
pub fn remove_citation(&mut self, key: &str) -> Result<()> {
if self.citations.remove(key).is_none() {
return Err(OptimError::InvalidConfig(format!(
"Citation with key '{}' not found",
key
)));
}
self.modified_at = Utc::now();
Ok(())
}
pub fn search_citations(&self, query: &str) -> Vec<&Citation> {
let query_lower = query.to_lowercase();
self.citations
.values()
.filter(|citation| {
citation.title.to_lowercase().contains(&query_lower)
|| citation.authors.iter().any(|author| {
author.last_name.to_lowercase().contains(&query_lower)
|| author.first_name.to_lowercase().contains(&query_lower)
})
|| citation
.keywords
.iter()
.any(|keyword| keyword.to_lowercase().contains(&query_lower))
|| citation
.venue
.as_ref()
.is_some_and(|venue| venue.to_lowercase().contains(&query_lower))
})
.collect()
}
pub fn format_citation(&self, key: &str, style: Option<&str>) -> Result<String> {
let citation = self
.get_citation(key)
.ok_or_else(|| OptimError::InvalidConfig(format!("Citation '{}' not found", key)))?;
let style_name = style.unwrap_or(&self.default_style);
let citation_style = self.styles.get(style_name).ok_or_else(|| {
OptimError::InvalidConfig(format!("Style '{}' not found", style_name))
})?;
self.format_citation_with_style(citation, citation_style, 1)
}
pub fn generate_bibliography(
&self,
citation_keys: &[String],
style: Option<&str>,
) -> Result<String> {
let style_name = style.unwrap_or(&self.default_style);
let citation_style = self.styles.get(style_name).ok_or_else(|| {
OptimError::InvalidConfig(format!("Style '{}' not found", style_name))
})?;
let mut citations: Vec<&Citation> = citation_keys
.iter()
.filter_map(|key| self.citations.get(key))
.collect();
self.sort_citations(&mut citations, &citation_style.sorting_rules);
let mut bibliography = String::new();
for (index, citation) in citations.into_iter().enumerate() {
let formatted = self.format_citation_with_style(citation, citation_style, index + 1)?;
bibliography.push_str(&formatted);
bibliography.push('\n');
}
Ok(bibliography)
}
pub fn export_bibtex(&self, citation_keys: Option<&[String]>) -> String {
let citations: Vec<&Citation> = if let Some(_keys) = citation_keys {
_keys
.iter()
.filter_map(|key| self.citations.get(key))
.collect()
} else {
self.citations.values().collect()
};
let mut bibtex = String::new();
for citation in citations {
bibtex.push_str(&self.citation_to_bibtex(citation));
bibtex.push('\n');
}
bibtex
}
pub fn import_bibtex(&mut self, bibtex_content: &str) -> Result<usize> {
let processor = BibTeXProcessor::new(BibTeXSettings::default());
let citations = processor.parse_bibtex(bibtex_content)?;
let mut imported_count = 0;
for citation in citations {
if !self.citations.contains_key(&citation.key) {
self.citations.insert(citation.key.clone(), citation);
imported_count += 1;
}
}
self.modified_at = Utc::now();
Ok(imported_count)
}
pub fn create_group(&mut self, name: &str, description: &str) -> String {
let group_id = uuid::Uuid::new_v4().to_string();
let group = CitationGroup {
name: name.to_string(),
description: description.to_string(),
color: None,
citation_keys: Vec::new(),
created_at: Utc::now(),
};
self.groups.insert(group_id.clone(), group);
group_id
}
pub fn add_to_group(&mut self, group_id: &str, citation_key: &str) -> Result<()> {
let group = self
.groups
.get_mut(group_id)
.ok_or_else(|| OptimError::InvalidConfig(format!("Group '{}' not found", group_id)))?;
if !group.citation_keys.contains(&citation_key.to_string()) {
group.citation_keys.push(citation_key.to_string());
}
Ok(())
}
fn format_citation_with_style(
&self,
citation: &Citation,
style: &CitationStyle,
position: usize,
) -> Result<String> {
match style.intext_format {
InTextFormat::AuthorYear => self.format_author_year(citation, style),
InTextFormat::Numbered => self.format_numbered(citation, style, position),
InTextFormat::Superscript => self.format_superscript(citation, style, position),
InTextFormat::AuthorNumber => self.format_author_number(citation, style, position),
InTextFormat::Footnote => self.format_footnote(citation, style),
}
}
fn format_author_year(&self, citation: &Citation, style: &CitationStyle) -> Result<String> {
let authors = self.format_authors(&citation.authors, &style.formatting_rules);
let year = citation
.year
.map(|y| y.to_string())
.unwrap_or_else(|| "n.d.".to_string());
Ok(format!("({}, {})", authors, year))
}
fn format_numbered(
&self,
_citation: &Citation,
_style: &CitationStyle,
position: usize,
) -> Result<String> {
Ok(format!("[{position}]"))
}
fn format_superscript(
&self,
_citation: &Citation,
_style: &CitationStyle,
position: usize,
) -> Result<String> {
Ok(to_superscript(position))
}
fn format_author_number(
&self,
citation: &Citation,
style: &CitationStyle,
position: usize,
) -> Result<String> {
let authors = self.format_authors(&citation.authors, &style.formatting_rules);
Ok(format!("{authors} [{position}]"))
}
fn format_footnote(&self, citation: &Citation, style: &CitationStyle) -> Result<String> {
self.format_full_citation(citation, style)
}
fn format_full_citation(&self, citation: &Citation, style: &CitationStyle) -> Result<String> {
let mut formatted = String::new();
let authors = self.format_authors(&citation.authors, &style.formatting_rules);
formatted.push_str(&authors);
let title = self.format_title(&citation.title, &style.bibliography_format.title_format);
formatted.push_str(&format!(". {}.", title));
if let Some(venue) = &citation.venue {
let venue_formatted = if style.bibliography_format.punctuation.italicize_journals {
format!(" *{}*", venue)
} else {
format!(" {venue}")
};
formatted.push_str(&venue_formatted);
}
if let Some(year) = citation.year {
if style
.bibliography_format
.punctuation
.parentheses_around_year
{
formatted.push_str(&format!(" ({})", year));
} else {
formatted.push_str(&format!(" {year}"));
}
}
if style.formatting_rules.include_doi {
if let Some(doi) = &citation.doi {
formatted.push_str(&format!(". DOI: {doi}"));
}
}
Ok(formatted)
}
fn format_authors(&self, authors: &[Author], rules: &FormattingRules) -> String {
if authors.is_empty() {
return "Anonymous".to_string();
}
let max_authors = rules.max_authors.unwrap_or(authors.len());
let display_authors = if authors.len() > max_authors && max_authors > 0 {
&authors[..max_authors]
} else {
authors
};
let mut formatted_authors = Vec::new();
for author in display_authors {
let formatted = format!("{}, {}", author.last_name, author.first_name);
formatted_authors.push(formatted);
}
let mut result = formatted_authors.join(", ");
if authors.len() > max_authors {
result.push_str(&format!(", {}", rules.et_altext));
}
result
}
fn format_title(&self, title: &str, format: &TitleFormat) -> String {
match format {
TitleFormat::TitleCase => self.to_title_case(title),
TitleFormat::SentenceCase => self.to_sentence_case(title),
TitleFormat::Uppercase => title.to_uppercase(),
TitleFormat::Lowercase => title.to_lowercase(),
TitleFormat::AsEntered => title.to_string(),
}
}
fn to_title_case(&self, s: &str) -> String {
s.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(first) => {
first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
}
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn to_sentence_case(&self, s: &str) -> String {
if s.is_empty() {
return String::new();
}
let mut chars = s.chars();
let Some(leading) = chars.next() else {
return String::new();
};
let first = leading.to_uppercase().collect::<String>();
first + &chars.as_str().to_lowercase()
}
fn sort_citations(&self, citations: &mut Vec<&Citation>, rules: &SortingRules) {
citations.sort_by(|a, b| {
let primary_cmp = self.compare_by_field(a, b, &rules.primary_sort);
if primary_cmp == std::cmp::Ordering::Equal {
if let Some(secondary) = &rules.secondary_sort {
self.compare_by_field(a, b, secondary)
} else {
std::cmp::Ordering::Equal
}
} else {
primary_cmp
}
});
if rules.sort_direction == SortDirection::Descending {
citations.reverse();
}
}
fn compare_by_field(
&self,
a: &Citation,
b: &Citation,
field: &SortField,
) -> std::cmp::Ordering {
match field {
SortField::Author => {
let a_author = a
.authors
.first()
.map(|au| au.last_name.as_str())
.unwrap_or("");
let b_author = b
.authors
.first()
.map(|au| au.last_name.as_str())
.unwrap_or("");
a_author.cmp(b_author)
}
SortField::Year => a.year.cmp(&b.year),
SortField::Title => a.title.cmp(&b.title),
SortField::Venue => a.venue.cmp(&b.venue),
SortField::Key => a.key.cmp(&b.key),
SortField::DateAdded => a.created_at.cmp(&b.created_at),
}
}
fn citation_to_bibtex(&self, citation: &Citation) -> String {
let mut bibtex = format!(
"@{}{{{},\n",
self.publication_type_to_bibtex(&citation.publication_type),
citation.key
);
bibtex.push_str(&format!(" title = {{{}}},\n", citation.title));
if !citation.authors.is_empty() {
let authors = citation
.authors
.iter()
.map(|a| format!("{} {}", a.first_name, a.last_name))
.collect::<Vec<_>>()
.join(" and ");
bibtex.push_str(&format!(" author = {{{}}},\n", authors));
}
if let Some(year) = citation.year {
bibtex.push_str(&format!(" year = {{{}}},\n", year));
}
if let Some(venue) = &citation.venue {
let field_name = match citation.publication_type {
PublicationType::Article => "journal",
PublicationType::InProceedings => "booktitle",
PublicationType::Book => "publisher",
PublicationType::InCollection => "booktitle",
PublicationType::PhDThesis => "school",
PublicationType::MastersThesis => "school",
PublicationType::TechReport => "institution",
PublicationType::Manual => "organization",
PublicationType::Misc => "howpublished",
PublicationType::Unpublished => "note",
PublicationType::Preprint => "archivePrefix",
PublicationType::Patent => "assignee",
PublicationType::Software => "url",
PublicationType::Dataset => "url",
};
bibtex.push_str(&format!(" {} = {{{}}},\n", field_name, venue));
}
if let Some(volume) = &citation.volume {
bibtex.push_str(&format!(" volume = {{{}}},\n", volume));
}
if let Some(pages) = &citation.pages {
bibtex.push_str(&format!(" pages = {{{}}},\n", pages));
}
if let Some(doi) = &citation.doi {
bibtex.push_str(&format!(" doi = {{{}}},\n", doi));
}
bibtex.push_str("}\n");
bibtex
}
fn publication_type_to_bibtex(&self, pub_type: &PublicationType) -> &'static str {
match pub_type {
PublicationType::Article => "article",
PublicationType::InProceedings => "inproceedings",
PublicationType::Book => "book",
PublicationType::InCollection => "incollection",
PublicationType::PhDThesis => "phdthesis",
PublicationType::MastersThesis => "mastersthesis",
PublicationType::TechReport => "techreport",
PublicationType::Manual => "manual",
PublicationType::Misc => "misc",
PublicationType::Unpublished => "unpublished",
PublicationType::Preprint => "misc",
PublicationType::Patent => "misc",
PublicationType::Software => "misc",
PublicationType::Dataset => "misc",
}
}
fn create_apa_style() -> CitationStyle {
CitationStyle {
name: "APA".to_string(),
description: "American Psychological Association style".to_string(),
intext_format: InTextFormat::AuthorYear,
bibliography_format: BibliographyFormat {
entry_separator: "\n".to_string(),
field_separators: {
let mut separators = HashMap::new();
separators.insert("author_title".to_string(), ". ".to_string());
separators.insert("title_venue".to_string(), ". ".to_string());
separators
},
name_format: NameFormat::LastFirstInitial,
title_format: TitleFormat::SentenceCase,
date_format: DateFormat::Year,
punctuation: PunctuationRules {
periods_after_abbreviations: true,
commas_between_fields: true,
parentheses_around_year: true,
quote_titles: false,
italicize_journals: true,
},
},
formatting_rules: FormattingRules {
max_authors: Some(7),
et_altext: "et al.".to_string(),
et_al_threshold: 8,
title_case: false,
abbreviate_journals: false,
include_doi: true,
include_url: false,
},
sorting_rules: SortingRules {
primary_sort: SortField::Author,
secondary_sort: Some(SortField::Year),
sort_direction: SortDirection::Ascending,
group_by_type: false,
},
}
}
fn create_ieee_style() -> CitationStyle {
CitationStyle {
name: "IEEE".to_string(),
description: "Institute of Electrical and Electronics Engineers style".to_string(),
intext_format: InTextFormat::Numbered,
bibliography_format: BibliographyFormat {
entry_separator: "\n".to_string(),
field_separators: HashMap::new(),
name_format: NameFormat::FirstInitialLast,
title_format: TitleFormat::AsEntered,
date_format: DateFormat::Year,
punctuation: PunctuationRules {
periods_after_abbreviations: true,
commas_between_fields: true,
parentheses_around_year: false,
quote_titles: true,
italicize_journals: true,
},
},
formatting_rules: FormattingRules {
max_authors: None,
et_altext: "et al.".to_string(),
et_al_threshold: 7,
title_case: false,
abbreviate_journals: true,
include_doi: true,
include_url: false,
},
sorting_rules: SortingRules {
primary_sort: SortField::Year,
secondary_sort: Some(SortField::Author),
sort_direction: SortDirection::Ascending,
group_by_type: false,
},
}
}
fn create_acm_style() -> CitationStyle {
CitationStyle {
name: "ACM".to_string(),
description: "Association for Computing Machinery style".to_string(),
intext_format: InTextFormat::Numbered,
bibliography_format: BibliographyFormat {
entry_separator: "\n".to_string(),
field_separators: HashMap::new(),
name_format: NameFormat::FirstMiddleLast,
title_format: TitleFormat::TitleCase,
date_format: DateFormat::Year,
punctuation: PunctuationRules {
periods_after_abbreviations: true,
commas_between_fields: true,
parentheses_around_year: false,
quote_titles: false,
italicize_journals: true,
},
},
formatting_rules: FormattingRules {
max_authors: None,
et_altext: "et al.".to_string(),
et_al_threshold: 3,
title_case: true,
abbreviate_journals: false,
include_doi: true,
include_url: true,
},
sorting_rules: SortingRules {
primary_sort: SortField::Author,
secondary_sort: Some(SortField::Year),
sort_direction: SortDirection::Ascending,
group_by_type: false,
},
}
}
}
fn to_superscript(position: usize) -> String {
const DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
position
.to_string()
.chars()
.map(|c| c.to_digit(10).map(|d| DIGITS[d as usize]).unwrap_or(c))
.collect()
}
pub(crate) fn parse_bibtex_entries(
content: &str,
) -> Vec<(String, String, HashMap<String, String>)> {
let chars: Vec<char> = content.chars().collect();
let n = chars.len();
let mut i = 0;
let mut entries = Vec::new();
while i < n {
while i < n && chars[i] != '@' {
i += 1;
}
if i >= n {
break;
}
i += 1;
let type_start = i;
while i < n && chars[i] != '{' && chars[i] != '(' {
i += 1;
}
if i >= n {
break;
}
let entry_type: String = chars[type_start..i]
.iter()
.collect::<String>()
.trim()
.to_lowercase();
let open_char = chars[i];
let close_char = if open_char == '{' { '}' } else { ')' };
i += 1;
let body_start = i;
let mut depth = 1usize;
while i < n && depth > 0 {
if chars[i] == open_char {
depth += 1;
} else if chars[i] == close_char {
depth -= 1;
if depth == 0 {
break;
}
}
i += 1;
}
let body: String = chars[body_start..i].iter().collect();
if i < n {
i += 1; }
if entry_type.is_empty() {
continue;
}
if let Some(comma_pos) = body.find(',') {
let key = body[..comma_pos].trim().to_string();
let fields = parse_bibtex_fields(&body[comma_pos + 1..]);
if !key.is_empty() {
entries.push((entry_type, key, fields));
}
}
}
entries
}
fn parse_bibtex_fields(body: &str) -> HashMap<String, String> {
let chars: Vec<char> = body.chars().collect();
let n = chars.len();
let mut i = 0;
let mut fields = HashMap::new();
while i < n {
while i < n && (chars[i].is_whitespace() || chars[i] == ',') {
i += 1;
}
if i >= n {
break;
}
let name_start = i;
while i < n && chars[i] != '=' {
i += 1;
}
if i >= n {
break;
}
let field_name = chars[name_start..i]
.iter()
.collect::<String>()
.trim()
.to_lowercase();
i += 1; while i < n && chars[i].is_whitespace() {
i += 1;
}
if i >= n {
break;
}
let raw_value: String = if chars[i] == '{' {
i += 1;
let val_start = i;
let mut depth = 1usize;
while i < n && depth > 0 {
match chars[i] {
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
break;
}
}
_ => {}
}
i += 1;
}
let value = chars[val_start..i].iter().collect();
if i < n {
i += 1; }
value
} else if chars[i] == '"' {
i += 1;
let val_start = i;
while i < n && chars[i] != '"' {
i += 1;
}
let value = chars[val_start..i].iter().collect();
if i < n {
i += 1; }
value
} else {
let val_start = i;
while i < n && chars[i] != ',' {
i += 1;
}
chars[val_start..i].iter().collect::<String>()
};
if !field_name.is_empty() {
let normalized = raw_value.split_whitespace().collect::<Vec<_>>().join(" ");
fields.insert(field_name, normalized);
}
while i < n && chars[i] != ',' {
i += 1;
}
}
fields
}
impl BibTeXProcessor {
pub fn new(settings: BibTeXSettings) -> Self {
Self { settings }
}
pub fn settings(&self) -> &BibTeXSettings {
&self.settings
}
fn apply_settings(&self, value: String) -> String {
let mut value = if self.settings.preserve_case {
value
} else {
value.replace(['{', '}'], "")
};
if self.settings.utf8_conversion {
for (escape, replacement) in [
("\\\"a", "ä"),
("\\\"o", "ö"),
("\\\"u", "ü"),
("\\'e", "é"),
("\\'a", "á"),
("\\`e", "è"),
("\\^o", "ô"),
("\\~n", "ñ"),
("\\c c", "ç"),
("\\ss", "ß"),
("---", "\u{2014}"),
("--", "\u{2013}"),
] {
value = value.replace(escape, replacement);
}
}
value
}
pub fn parse_bibtex(&self, content: &str) -> Result<Vec<Citation>> {
let mut citations = Vec::new();
for (entry_type, key, fields) in parse_bibtex_entries(content) {
if matches!(entry_type.as_str(), "comment" | "string" | "preamble") {
continue;
}
let pub_type = self.bibtex_type_to_publication_type(&entry_type);
if let Ok(citation) = self.fields_to_citation(key, pub_type, fields) {
citations.push(citation);
}
}
Ok(citations)
}
fn bibtex_type_to_publication_type(&self, bibtex_type: &str) -> PublicationType {
match bibtex_type {
"article" => PublicationType::Article,
"inproceedings" | "conference" => PublicationType::InProceedings,
"book" => PublicationType::Book,
"incollection" | "inbook" => PublicationType::InCollection,
"phdthesis" => PublicationType::PhDThesis,
"mastersthesis" => PublicationType::MastersThesis,
"techreport" => PublicationType::TechReport,
"manual" => PublicationType::Manual,
"unpublished" => PublicationType::Unpublished,
_ => PublicationType::Misc,
}
}
fn fields_to_citation(
&self,
key: String,
pub_type: PublicationType,
fields: HashMap<String, String>,
) -> Result<Citation> {
let title = self.apply_settings(fields.get("title").cloned().unwrap_or_default());
let authors = if let Some(author_str) = fields.get("author") {
self.parse_authors(author_str)
} else {
Vec::new()
};
let year = fields.get("year").and_then(|y| y.parse().ok());
let venue = match pub_type {
PublicationType::Article => fields.get("journal").cloned(),
PublicationType::InProceedings => fields.get("booktitle").cloned(),
PublicationType::Book => fields.get("publisher").cloned(),
PublicationType::InCollection => fields.get("booktitle").cloned(),
PublicationType::PhDThesis => fields.get("school").cloned(),
PublicationType::MastersThesis => fields.get("school").cloned(),
PublicationType::TechReport => fields.get("institution").cloned(),
PublicationType::Manual => fields.get("organization").cloned(),
PublicationType::Misc => fields.get("howpublished").cloned(),
PublicationType::Unpublished => fields.get("note").cloned(),
PublicationType::Preprint => fields.get("archivePrefix").cloned(),
PublicationType::Patent => fields.get("assignee").cloned(),
PublicationType::Software => fields.get("url").cloned(),
PublicationType::Dataset => fields.get("url").cloned(),
};
let now = Utc::now();
Ok(Citation {
key,
publication_type: pub_type,
title,
authors,
year,
venue,
volume: fields.get("volume").cloned(),
issue: fields.get("number").cloned(),
pages: fields.get("pages").cloned(),
doi: fields.get("doi").cloned(),
url: fields.get("url").cloned(),
abstracttext: fields.get("abstract").cloned(),
keywords: Vec::new(),
notes: fields.get("note").cloned(),
custom_fields: HashMap::new(),
attachments: Vec::new(),
groups: Vec::new(),
import_source: Some("BibTeX".to_string()),
created_at: now,
modified_at: now,
})
}
fn parse_authors(&self, author_str: &str) -> Vec<Author> {
author_str
.split(" and ")
.map(|author_part| {
let author_part = author_part.trim();
if let Some(comma_pos) = author_part.find(',') {
let last_name = author_part[..comma_pos].trim().to_string();
let first_name = author_part[comma_pos + 1..].trim().to_string();
Author {
first_name,
last_name,
middle_name: None,
suffix: None,
orcid: None,
affiliation: None,
}
} else {
let parts: Vec<&str> = author_part.split_whitespace().collect();
if parts.len() >= 2 {
let first_name = parts[0].to_string();
let last_name = parts[parts.len() - 1].to_string();
let middle_name = if parts.len() > 2 {
Some(parts[1..parts.len() - 1].join(" "))
} else {
None
};
Author {
first_name,
last_name,
middle_name,
suffix: None,
orcid: None,
affiliation: None,
}
} else {
Author {
first_name: String::new(),
last_name: author_part.to_string(),
middle_name: None,
suffix: None,
orcid: None,
affiliation: None,
}
}
}
})
.collect()
}
}
impl Default for CitationSettings {
fn default() -> Self {
Self {
auto_generate_keys: true,
key_pattern: "{author}{year}".to_string(),
auto_import_doi: true,
auto_import_url: false,
duplicate_detection: true,
backup_enabled: true,
export_formats: vec![ExportFormat::BibTeX, ExportFormat::RIS],
}
}
}
impl Default for BibTeXSettings {
fn default() -> Self {
Self {
preserve_case: true,
utf8_conversion: true,
cleanup_formatting: true,
validate_entries: true,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_citation_manager_creation() {
let manager = CitationManager::new();
assert!(manager.styles.contains_key("APA"));
assert!(manager.styles.contains_key("IEEE"));
assert!(manager.styles.contains_key("ACM"));
assert_eq!(manager.default_style, "APA");
}
#[test]
fn test_add_citation() {
let mut manager = CitationManager::new();
let citation = Citation {
key: "test2023".to_string(),
publication_type: PublicationType::Article,
title: "Test Article".to_string(),
authors: vec![Author {
first_name: "John".to_string(),
last_name: "Doe".to_string(),
middle_name: None,
suffix: None,
orcid: None,
affiliation: None,
}],
year: Some(2023),
venue: Some("Test Journal".to_string()),
volume: None,
issue: None,
pages: None,
doi: None,
url: None,
abstracttext: None,
keywords: Vec::new(),
notes: None,
custom_fields: HashMap::new(),
attachments: Vec::new(),
groups: Vec::new(),
import_source: None,
created_at: Utc::now(),
modified_at: Utc::now(),
};
assert!(manager.add_citation(citation).is_ok());
assert!(manager.citations.contains_key("test2023"));
}
#[test]
fn test_search_citations() {
let mut manager = CitationManager::new();
let citation = Citation {
key: "test2023".to_string(),
publication_type: PublicationType::Article,
title: "Machine Learning Optimization".to_string(),
authors: vec![Author {
first_name: "Jane".to_string(),
last_name: "Smith".to_string(),
middle_name: None,
suffix: None,
orcid: None,
affiliation: None,
}],
year: Some(2023),
venue: None,
volume: None,
issue: None,
pages: None,
doi: None,
url: None,
abstracttext: None,
keywords: vec!["optimization".to_string(), "machine learning".to_string()],
notes: None,
custom_fields: HashMap::new(),
attachments: Vec::new(),
groups: Vec::new(),
import_source: None,
created_at: Utc::now(),
modified_at: Utc::now(),
};
manager.add_citation(citation).expect("unwrap failed");
let results = manager.search_citations("optimization");
assert_eq!(results.len(), 1);
let results = manager.search_citations("Smith");
assert_eq!(results.len(), 1);
}
#[test]
fn test_parse_bibtex_handles_multiline_and_nested_braces() {
let processor = BibTeXProcessor::new(BibTeXSettings::default());
let bibtex = r#"
@article{smith2023multiline,
title = {The {Quick} Brown Fox},
author = {Smith, John and Doe, Jane},
year = {2023},
journal = {Journal of Testing},
abstract = {This abstract deliberately spans
multiple physical lines to verify
that continuation lines are not dropped.},
}
"#;
let citations = processor
.parse_bibtex(bibtex)
.expect("parse should succeed");
assert_eq!(citations.len(), 1);
let citation = &citations[0];
assert_eq!(citation.key, "smith2023multiline");
assert_eq!(citation.title, "The {Quick} Brown Fox");
assert_eq!(citation.year, Some(2023));
assert_eq!(citation.authors.len(), 2);
let abstract_text = citation
.abstracttext
.as_ref()
.expect("abstract should be captured");
assert!(
abstract_text.contains("multiple physical lines"),
"continuation lines were dropped: {abstract_text:?}"
);
assert!(
!abstract_text.contains('\n'),
"internal newlines should be normalized to spaces: {abstract_text:?}"
);
}
#[test]
fn test_parse_bibtex_handles_multiple_entries() {
let processor = BibTeXProcessor::new(BibTeXSettings::default());
let bibtex = "@article{first2020,\n title = {First},\n year = {2020},\n}\n\
@inproceedings{second2021,\n title = {Second},\n year = {2021},\n}\n";
let citations = processor
.parse_bibtex(bibtex)
.expect("parse should succeed");
assert_eq!(citations.len(), 2);
assert_eq!(citations[0].key, "first2020");
assert_eq!(citations[1].key, "second2021");
assert_eq!(
citations[1].publication_type,
PublicationType::InProceedings
);
}
#[test]
fn test_citation_discovery_is_constructible_and_routes_queries() {
let doi_engine = SearchEngine {
name: "crossref".to_string(),
endpoint: "https://api.crossref.org".to_string(),
rate_limit: 5.0,
query_types: vec![QueryType::DOI, QueryType::Title],
};
let arxiv_engine = SearchEngine {
name: "arxiv".to_string(),
endpoint: "https://export.arxiv.org/api".to_string(),
rate_limit: 1.0,
query_types: vec![QueryType::ArXiv, QueryType::Title],
};
let mut discovery = CitationDiscovery::new()
.with_search_engine(doi_engine)
.with_search_engine(arxiv_engine);
discovery.set_api_key("crossref", "secret-token");
assert_eq!(discovery.search_engines().len(), 2);
assert!(discovery.has_credentials("crossref"));
assert!(!discovery.has_credentials("arxiv"));
let doi_engines = discovery.engines_for(&QueryType::DOI);
assert_eq!(doi_engines.len(), 1);
assert_eq!(doi_engines[0].name, "crossref");
let title_engines = discovery.engines_for(&QueryType::Title);
assert_eq!(title_engines.len(), 2);
assert_eq!(title_engines[0].name, "arxiv");
assert!(discovery.engines_for(&QueryType::ISBN).is_empty());
}
fn make_citation(key: &str, last_name: &str, year: u32) -> Citation {
let now = Utc::now();
Citation {
key: key.to_string(),
publication_type: PublicationType::Article,
title: format!("Paper by {last_name}"),
authors: vec![Author {
first_name: "A".to_string(),
last_name: last_name.to_string(),
middle_name: None,
suffix: None,
orcid: None,
affiliation: None,
}],
year: Some(year),
venue: Some("Journal".to_string()),
volume: None,
issue: None,
pages: None,
doi: None,
url: None,
abstracttext: None,
keywords: Vec::new(),
notes: None,
custom_fields: HashMap::new(),
attachments: Vec::new(),
groups: Vec::new(),
import_source: None,
created_at: now,
modified_at: now,
}
}
#[test]
fn test_generate_bibliography_assigns_distinct_numbers() {
let mut manager = CitationManager::new();
manager
.add_citation(make_citation("adams2020", "Adams", 2020))
.expect("add should succeed");
manager
.add_citation(make_citation("zimmerman2021", "Zimmerman", 2021))
.expect("add should succeed");
let bibliography = manager
.generate_bibliography(
&["adams2020".to_string(), "zimmerman2021".to_string()],
Some("IEEE"),
)
.expect("bibliography generation should succeed");
let lines: Vec<&str> = bibliography.lines().filter(|l| !l.is_empty()).collect();
assert_eq!(lines.len(), 2);
assert!(
lines[0].starts_with("[1]"),
"first entry should be numbered [1]: {:?}",
lines[0]
);
assert!(
lines[1].starts_with("[2]"),
"second entry should be numbered [2], not a duplicate [1]: {:?}",
lines[1]
);
}
#[test]
fn test_to_superscript_renders_multi_digit_positions() {
assert_eq!(to_superscript(1), "¹");
assert_eq!(to_superscript(12), "¹²");
assert_eq!(to_superscript(103), "¹⁰³");
}
}