Skip to main content

optirs_core/research/
citations.rs

1// Citation management and bibliographic tools
2//
3// This module provides comprehensive citation management, BibTeX parsing,
4// and automated reference generation for academic publications.
5
6use crate::error::{OptimError, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11/// Citation manager for handling bibliographic references
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CitationManager {
14    /// Citation database
15    pub citations: HashMap<String, Citation>,
16    /// Citation styles
17    pub styles: HashMap<String, CitationStyle>,
18    /// Default citation style
19    pub default_style: String,
20    /// Citation groups/categories
21    pub groups: HashMap<String, CitationGroup>,
22    /// Import/export settings
23    pub settings: CitationSettings,
24    /// Last modified timestamp
25    pub modified_at: DateTime<Utc>,
26}
27
28/// Individual citation record
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Citation {
31    /// Citation key/identifier
32    pub key: String,
33    /// Publication type
34    pub publication_type: PublicationType,
35    /// Title
36    pub title: String,
37    /// Authors
38    pub authors: Vec<Author>,
39    /// Publication year
40    pub year: Option<u32>,
41    /// Journal/Conference/Publisher
42    pub venue: Option<String>,
43    /// Volume number
44    pub volume: Option<String>,
45    /// Issue/Number
46    pub issue: Option<String>,
47    /// Page numbers
48    pub pages: Option<String>,
49    /// DOI
50    pub doi: Option<String>,
51    /// URL
52    pub url: Option<String>,
53    /// Abstract
54    pub abstracttext: Option<String>,
55    /// Keywords
56    pub keywords: Vec<String>,
57    /// Notes
58    pub notes: Option<String>,
59    /// Custom fields
60    pub custom_fields: HashMap<String, String>,
61    /// File attachments
62    pub attachments: Vec<String>,
63    /// Citation groups
64    pub groups: Vec<String>,
65    /// Import source
66    pub import_source: Option<String>,
67    /// Creation timestamp
68    pub created_at: DateTime<Utc>,
69    /// Last modified timestamp
70    pub modified_at: DateTime<Utc>,
71}
72
73/// Publication types for citations
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub enum PublicationType {
76    /// Journal article
77    Article,
78    /// Conference paper
79    InProceedings,
80    /// Book
81    Book,
82    /// Book chapter
83    InCollection,
84    /// PhD thesis
85    PhDThesis,
86    /// Master's thesis
87    MastersThesis,
88    /// Technical report
89    TechReport,
90    /// Manual
91    Manual,
92    /// Miscellaneous
93    Misc,
94    /// Unpublished work
95    Unpublished,
96    /// Preprint
97    Preprint,
98    /// Patent
99    Patent,
100    /// Software
101    Software,
102    /// Dataset
103    Dataset,
104}
105
106/// Author information for citations
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Author {
109    /// First name
110    pub first_name: String,
111    /// Last name
112    pub last_name: String,
113    /// Middle name/initial
114    pub middle_name: Option<String>,
115    /// Name suffix (Jr., Sr., etc.)
116    pub suffix: Option<String>,
117    /// ORCID identifier
118    pub orcid: Option<String>,
119    /// Author affiliation
120    pub affiliation: Option<String>,
121}
122
123/// Citation style definition
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CitationStyle {
126    /// Style name
127    pub name: String,
128    /// Style description
129    pub description: String,
130    /// In-text citation format
131    pub intext_format: InTextFormat,
132    /// Bibliography format
133    pub bibliography_format: BibliographyFormat,
134    /// Formatting rules
135    pub formatting_rules: FormattingRules,
136    /// Sorting rules
137    pub sorting_rules: SortingRules,
138}
139
140/// In-text citation formats
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub enum InTextFormat {
143    /// Author-year format: (Smith, 2023)
144    AuthorYear,
145    /// Numbered format: \[1\]
146    Numbered,
147    /// Superscript format: ¹
148    Superscript,
149    /// Author-number format: Smith \[1\]
150    AuthorNumber,
151    /// Footnote format
152    Footnote,
153}
154
155/// Bibliography formatting
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct BibliographyFormat {
158    /// Entry separator
159    pub entry_separator: String,
160    /// Field separators
161    pub field_separators: HashMap<String, String>,
162    /// Name formatting
163    pub name_format: NameFormat,
164    /// Title formatting
165    pub title_format: TitleFormat,
166    /// Date formatting
167    pub date_format: DateFormat,
168    /// Punctuation rules
169    pub punctuation: PunctuationRules,
170}
171
172/// Name formatting options
173#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174pub enum NameFormat {
175    /// Last, First Middle
176    LastFirstMiddle,
177    /// First Middle Last
178    FirstMiddleLast,
179    /// Last, F. M.
180    LastFirstInitial,
181    /// F. M. Last
182    FirstInitialLast,
183    /// Last, F.M.
184    LastFirstInitialNoSpace,
185}
186
187/// Title formatting options
188#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189pub enum TitleFormat {
190    /// Title Case
191    TitleCase,
192    /// Sentence case
193    SentenceCase,
194    /// UPPERCASE
195    Uppercase,
196    /// lowercase
197    Lowercase,
198    /// As entered
199    AsEntered,
200}
201
202/// Date formatting options
203#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
204pub enum DateFormat {
205    /// 2023
206    Year,
207    /// December 2023
208    MonthYear,
209    /// Dec. 2023
210    MonthAbbrevYear,
211    /// December 15, 2023
212    FullDate,
213    /// 2023-12-15
214    ISODate,
215}
216
217/// Punctuation rules
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct PunctuationRules {
220    /// Use periods after abbreviations
221    pub periods_after_abbreviations: bool,
222    /// Use commas between fields
223    pub commas_between_fields: bool,
224    /// Use parentheses around year
225    pub parentheses_around_year: bool,
226    /// Quote titles
227    pub quote_titles: bool,
228    /// Italicize journal names
229    pub italicize_journals: bool,
230}
231
232/// Formatting rules for citations
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct FormattingRules {
235    /// Maximum authors to show
236    pub max_authors: Option<usize>,
237    /// Text to use for "et al."
238    pub et_altext: String,
239    /// Minimum authors before using et al.
240    pub et_al_threshold: usize,
241    /// Use title case for titles
242    pub title_case: bool,
243    /// Abbreviate journal names
244    pub abbreviate_journals: bool,
245    /// Include DOI
246    pub include_doi: bool,
247    /// Include URL
248    pub include_url: bool,
249}
250
251/// Sorting rules for bibliography
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct SortingRules {
254    /// Primary sort field
255    pub primary_sort: SortField,
256    /// Secondary sort field
257    pub secondary_sort: Option<SortField>,
258    /// Sort direction
259    pub sort_direction: SortDirection,
260    /// Group by type
261    pub group_by_type: bool,
262}
263
264/// Sort fields
265#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
266pub enum SortField {
267    /// Author last name
268    Author,
269    /// Publication year
270    Year,
271    /// Title
272    Title,
273    /// Journal/venue
274    Venue,
275    /// Citation key
276    Key,
277    /// Date added
278    DateAdded,
279}
280
281/// Sort direction
282#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub enum SortDirection {
284    /// Ascending order
285    Ascending,
286    /// Descending order
287    Descending,
288}
289
290/// Citation group for organizing references
291#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct CitationGroup {
293    /// Group name
294    pub name: String,
295    /// Group description
296    pub description: String,
297    /// Group color (for UI)
298    pub color: Option<String>,
299    /// Citation keys in this group
300    pub citation_keys: Vec<String>,
301    /// Creation timestamp
302    pub created_at: DateTime<Utc>,
303}
304
305/// Citation manager settings
306#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct CitationSettings {
308    /// Auto-generate keys
309    pub auto_generate_keys: bool,
310    /// Key generation pattern
311    pub key_pattern: String,
312    /// Auto-import from DOI
313    pub auto_import_doi: bool,
314    /// Auto-import from URL
315    pub auto_import_url: bool,
316    /// Duplicate detection
317    pub duplicate_detection: bool,
318    /// Backup settings
319    pub backup_enabled: bool,
320    /// Export formats
321    pub export_formats: Vec<ExportFormat>,
322}
323
324/// Export formats
325#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
326pub enum ExportFormat {
327    /// BibTeX format
328    BibTeX,
329    /// RIS format
330    RIS,
331    /// EndNote XML
332    EndNote,
333    /// JSON format
334    JSON,
335    /// CSV format
336    CSV,
337    /// Word bibliography
338    Word,
339}
340
341/// BibTeX parser and exporter
342#[derive(Debug)]
343pub struct BibTeXProcessor {
344    /// Parser settings
345    settings: BibTeXSettings,
346}
347
348/// BibTeX processing settings
349#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct BibTeXSettings {
351    /// Preserve case in titles
352    pub preserve_case: bool,
353    /// Convert to UTF-8
354    pub utf8_conversion: bool,
355    /// Clean up formatting
356    pub cleanup_formatting: bool,
357    /// Validate entries
358    pub validate_entries: bool,
359}
360
361/// Citation search and discovery
362#[derive(Debug)]
363pub struct CitationDiscovery {
364    /// Search engines configuration
365    search_engines: Vec<SearchEngine>,
366    /// API keys for services
367    api_keys: HashMap<String, String>,
368}
369
370impl CitationDiscovery {
371    /// Create a discovery service with no search engines registered yet.
372    pub fn new() -> Self {
373        Self {
374            search_engines: Vec::new(),
375            api_keys: HashMap::new(),
376        }
377    }
378
379    /// Register a search engine (builder style).
380    pub fn with_search_engine(mut self, engine: SearchEngine) -> Self {
381        self.search_engines.push(engine);
382        self
383    }
384
385    /// Register a search engine on an existing instance.
386    pub fn add_search_engine(&mut self, engine: SearchEngine) {
387        self.search_engines.push(engine);
388    }
389
390    /// Store the API key/token used to authenticate against `engine_name`.
391    pub fn set_api_key(&mut self, engine_name: &str, key: &str) {
392        self.api_keys
393            .insert(engine_name.to_string(), key.to_string());
394    }
395
396    /// All currently registered search engines.
397    pub fn search_engines(&self) -> &[SearchEngine] {
398        &self.search_engines
399    }
400
401    /// Whether an API key/token has been configured for `engine_name`.
402    pub fn has_credentials(&self, engine_name: &str) -> bool {
403        self.api_keys.contains_key(engine_name)
404    }
405
406    /// The registered engines that advertise support for `query_type`,
407    /// ordered by ascending rate limit (most conservative first) so callers
408    /// naturally prefer the engine least likely to be throttled.
409    ///
410    /// This performs no network I/O: `CitationDiscovery` only tracks engine
411    /// configuration and routes queries to the right engine locally. Issuing
412    /// the actual HTTP request against the selected engine's `endpoint` is
413    /// left to the caller.
414    pub fn engines_for(&self, query_type: &QueryType) -> Vec<&SearchEngine> {
415        let mut engines: Vec<&SearchEngine> = self
416            .search_engines
417            .iter()
418            .filter(|engine| engine.query_types.contains(query_type))
419            .collect();
420        engines.sort_by(|a, b| {
421            a.rate_limit
422                .partial_cmp(&b.rate_limit)
423                .unwrap_or(std::cmp::Ordering::Equal)
424        });
425        engines
426    }
427}
428
429impl Default for CitationDiscovery {
430    fn default() -> Self {
431        Self::new()
432    }
433}
434
435/// Search engine configuration
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct SearchEngine {
438    /// Engine name
439    pub name: String,
440    /// API endpoint
441    pub endpoint: String,
442    /// Rate limit (requests per second)
443    pub rate_limit: f64,
444    /// Supported query types
445    pub query_types: Vec<QueryType>,
446}
447
448/// Query types for citation search
449#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
450pub enum QueryType {
451    /// DOI lookup
452    DOI,
453    /// Title search
454    Title,
455    /// Author search
456    Author,
457    /// ArXiv ID
458    ArXiv,
459    /// PubMed ID
460    PubMed,
461    /// ISBN
462    ISBN,
463    /// Free text search
464    FreeText,
465}
466
467/// Citation network analysis
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct CitationNetwork {
470    /// Citations in the network
471    pub citations: Vec<String>,
472    /// Citation relationships
473    pub relationships: Vec<CitationRelationship>,
474    /// Network metrics
475    pub metrics: NetworkMetrics,
476}
477
478/// Citation relationship
479#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct CitationRelationship {
481    /// Citing paper
482    pub citing: String,
483    /// Cited paper
484    pub cited: String,
485    /// Relationship type
486    pub relationship_type: RelationshipType,
487    /// Relationship strength
488    pub strength: f64,
489}
490
491/// Relationship types between citations
492#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
493pub enum RelationshipType {
494    /// Direct citation
495    DirectCitation,
496    /// Co-citation (cited together)
497    CoCitation,
498    /// Bibliographic coupling (share references)
499    BibliographicCoupling,
500    /// Same author
501    SameAuthor,
502    /// Same venue
503    SameVenue,
504    /// Similar topic
505    SimilarTopic,
506}
507
508/// Network analysis metrics
509#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct NetworkMetrics {
511    /// Total nodes (papers)
512    pub total_nodes: usize,
513    /// Total edges (relationships)
514    pub total_edges: usize,
515    /// Network density
516    pub density: f64,
517    /// Average clustering coefficient
518    pub clustering_coefficient: f64,
519    /// Most cited papers
520    pub most_cited: Vec<(String, usize)>,
521    /// Most influential authors
522    pub most_influential_authors: Vec<(String, f64)>,
523}
524
525impl Default for CitationManager {
526    fn default() -> Self {
527        Self::new()
528    }
529}
530
531impl CitationManager {
532    /// Create a new citation manager
533    pub fn new() -> Self {
534        let mut styles = HashMap::new();
535        styles.insert("APA".to_string(), Self::create_apa_style());
536        styles.insert("IEEE".to_string(), Self::create_ieee_style());
537        styles.insert("ACM".to_string(), Self::create_acm_style());
538
539        Self {
540            citations: HashMap::new(),
541            styles,
542            default_style: "APA".to_string(),
543            groups: HashMap::new(),
544            settings: CitationSettings::default(),
545            modified_at: Utc::now(),
546        }
547    }
548
549    /// Add a citation to the database
550    pub fn add_citation(&mut self, citation: Citation) -> Result<()> {
551        if self.citations.contains_key(&citation.key) {
552            return Err(OptimError::InvalidConfig(format!(
553                "Citation with key '{}' already exists",
554                citation.key
555            )));
556        }
557
558        self.citations.insert(citation.key.clone(), citation);
559        self.modified_at = Utc::now();
560        Ok(())
561    }
562
563    /// Get a citation by key
564    pub fn get_citation(&self, key: &str) -> Option<&Citation> {
565        self.citations.get(key)
566    }
567
568    /// Update an existing citation
569    pub fn update_citation(&mut self, key: &str, citation: Citation) -> Result<()> {
570        if !self.citations.contains_key(key) {
571            return Err(OptimError::InvalidConfig(format!(
572                "Citation with key '{}' not found",
573                key
574            )));
575        }
576
577        self.citations.insert(key.to_string(), citation);
578        self.modified_at = Utc::now();
579        Ok(())
580    }
581
582    /// Remove a citation
583    pub fn remove_citation(&mut self, key: &str) -> Result<()> {
584        if self.citations.remove(key).is_none() {
585            return Err(OptimError::InvalidConfig(format!(
586                "Citation with key '{}' not found",
587                key
588            )));
589        }
590
591        self.modified_at = Utc::now();
592        Ok(())
593    }
594
595    /// Search citations by various criteria
596    pub fn search_citations(&self, query: &str) -> Vec<&Citation> {
597        let query_lower = query.to_lowercase();
598
599        self.citations
600            .values()
601            .filter(|citation| {
602                citation.title.to_lowercase().contains(&query_lower)
603                    || citation.authors.iter().any(|author| {
604                        author.last_name.to_lowercase().contains(&query_lower)
605                            || author.first_name.to_lowercase().contains(&query_lower)
606                    })
607                    || citation
608                        .keywords
609                        .iter()
610                        .any(|keyword| keyword.to_lowercase().contains(&query_lower))
611                    || citation
612                        .venue
613                        .as_ref()
614                        .is_some_and(|venue| venue.to_lowercase().contains(&query_lower))
615            })
616            .collect()
617    }
618
619    /// Generate formatted citation in specified style.
620    ///
621    /// For numbered/superscript/author-number in-text styles, the citation's
622    /// ordinal number is only well-defined relative to a bibliography (the
623    /// order it appears in). Called in isolation like this, it is formatted
624    /// as position 1; to get correct, distinct numbers for a set of
625    /// citations use [`Self::generate_bibliography`], which assigns each
626    /// citation its real position in the (sorted) list.
627    pub fn format_citation(&self, key: &str, style: Option<&str>) -> Result<String> {
628        let citation = self
629            .get_citation(key)
630            .ok_or_else(|| OptimError::InvalidConfig(format!("Citation '{}' not found", key)))?;
631
632        let style_name = style.unwrap_or(&self.default_style);
633        let citation_style = self.styles.get(style_name).ok_or_else(|| {
634            OptimError::InvalidConfig(format!("Style '{}' not found", style_name))
635        })?;
636
637        self.format_citation_with_style(citation, citation_style, 1)
638    }
639
640    /// Generate bibliography for multiple citations
641    pub fn generate_bibliography(
642        &self,
643        citation_keys: &[String],
644        style: Option<&str>,
645    ) -> Result<String> {
646        let style_name = style.unwrap_or(&self.default_style);
647        let citation_style = self.styles.get(style_name).ok_or_else(|| {
648            OptimError::InvalidConfig(format!("Style '{}' not found", style_name))
649        })?;
650
651        let mut citations: Vec<&Citation> = citation_keys
652            .iter()
653            .filter_map(|key| self.citations.get(key))
654            .collect();
655
656        // Sort citations according to style rules
657        self.sort_citations(&mut citations, &citation_style.sorting_rules);
658
659        let mut bibliography = String::new();
660        for (index, citation) in citations.into_iter().enumerate() {
661            let formatted = self.format_citation_with_style(citation, citation_style, index + 1)?;
662            bibliography.push_str(&formatted);
663            bibliography.push('\n');
664        }
665
666        Ok(bibliography)
667    }
668
669    /// Export citations to BibTeX format
670    pub fn export_bibtex(&self, citation_keys: Option<&[String]>) -> String {
671        let citations: Vec<&Citation> = if let Some(_keys) = citation_keys {
672            _keys
673                .iter()
674                .filter_map(|key| self.citations.get(key))
675                .collect()
676        } else {
677            self.citations.values().collect()
678        };
679
680        let mut bibtex = String::new();
681        for citation in citations {
682            bibtex.push_str(&self.citation_to_bibtex(citation));
683            bibtex.push('\n');
684        }
685
686        bibtex
687    }
688
689    /// Import citations from BibTeX
690    pub fn import_bibtex(&mut self, bibtex_content: &str) -> Result<usize> {
691        let processor = BibTeXProcessor::new(BibTeXSettings::default());
692        let citations = processor.parse_bibtex(bibtex_content)?;
693
694        let mut imported_count = 0;
695        for citation in citations {
696            if !self.citations.contains_key(&citation.key) {
697                self.citations.insert(citation.key.clone(), citation);
698                imported_count += 1;
699            }
700        }
701
702        self.modified_at = Utc::now();
703        Ok(imported_count)
704    }
705
706    /// Create a citation group
707    pub fn create_group(&mut self, name: &str, description: &str) -> String {
708        let group_id = uuid::Uuid::new_v4().to_string();
709        let group = CitationGroup {
710            name: name.to_string(),
711            description: description.to_string(),
712            color: None,
713            citation_keys: Vec::new(),
714            created_at: Utc::now(),
715        };
716
717        self.groups.insert(group_id.clone(), group);
718        group_id
719    }
720
721    /// Add citation to group
722    pub fn add_to_group(&mut self, group_id: &str, citation_key: &str) -> Result<()> {
723        let group = self
724            .groups
725            .get_mut(group_id)
726            .ok_or_else(|| OptimError::InvalidConfig(format!("Group '{}' not found", group_id)))?;
727
728        if !group.citation_keys.contains(&citation_key.to_string()) {
729            group.citation_keys.push(citation_key.to_string());
730        }
731
732        Ok(())
733    }
734
735    fn format_citation_with_style(
736        &self,
737        citation: &Citation,
738        style: &CitationStyle,
739        position: usize,
740    ) -> Result<String> {
741        match style.intext_format {
742            InTextFormat::AuthorYear => self.format_author_year(citation, style),
743            InTextFormat::Numbered => self.format_numbered(citation, style, position),
744            InTextFormat::Superscript => self.format_superscript(citation, style, position),
745            InTextFormat::AuthorNumber => self.format_author_number(citation, style, position),
746            InTextFormat::Footnote => self.format_footnote(citation, style),
747        }
748    }
749
750    fn format_author_year(&self, citation: &Citation, style: &CitationStyle) -> Result<String> {
751        let authors = self.format_authors(&citation.authors, &style.formatting_rules);
752        let year = citation
753            .year
754            .map(|y| y.to_string())
755            .unwrap_or_else(|| "n.d.".to_string());
756
757        Ok(format!("({}, {})", authors, year))
758    }
759
760    fn format_numbered(
761        &self,
762        _citation: &Citation,
763        _style: &CitationStyle,
764        position: usize,
765    ) -> Result<String> {
766        Ok(format!("[{position}]"))
767    }
768
769    fn format_superscript(
770        &self,
771        _citation: &Citation,
772        _style: &CitationStyle,
773        position: usize,
774    ) -> Result<String> {
775        Ok(to_superscript(position))
776    }
777
778    fn format_author_number(
779        &self,
780        citation: &Citation,
781        style: &CitationStyle,
782        position: usize,
783    ) -> Result<String> {
784        let authors = self.format_authors(&citation.authors, &style.formatting_rules);
785        Ok(format!("{authors} [{position}]"))
786    }
787
788    fn format_footnote(&self, citation: &Citation, style: &CitationStyle) -> Result<String> {
789        self.format_full_citation(citation, style)
790    }
791
792    fn format_full_citation(&self, citation: &Citation, style: &CitationStyle) -> Result<String> {
793        let mut formatted = String::new();
794
795        // Authors
796        let authors = self.format_authors(&citation.authors, &style.formatting_rules);
797        formatted.push_str(&authors);
798
799        // Title
800        let title = self.format_title(&citation.title, &style.bibliography_format.title_format);
801        formatted.push_str(&format!(". {}.", title));
802
803        // Venue
804        if let Some(venue) = &citation.venue {
805            let venue_formatted = if style.bibliography_format.punctuation.italicize_journals {
806                format!(" *{}*", venue)
807            } else {
808                format!(" {venue}")
809            };
810            formatted.push_str(&venue_formatted);
811        }
812
813        // Year
814        if let Some(year) = citation.year {
815            if style
816                .bibliography_format
817                .punctuation
818                .parentheses_around_year
819            {
820                formatted.push_str(&format!(" ({})", year));
821            } else {
822                formatted.push_str(&format!(" {year}"));
823            }
824        }
825
826        // DOI
827        if style.formatting_rules.include_doi {
828            if let Some(doi) = &citation.doi {
829                formatted.push_str(&format!(". DOI: {doi}"));
830            }
831        }
832
833        Ok(formatted)
834    }
835
836    fn format_authors(&self, authors: &[Author], rules: &FormattingRules) -> String {
837        if authors.is_empty() {
838            return "Anonymous".to_string();
839        }
840
841        let max_authors = rules.max_authors.unwrap_or(authors.len());
842        let display_authors = if authors.len() > max_authors && max_authors > 0 {
843            &authors[..max_authors]
844        } else {
845            authors
846        };
847
848        let mut formatted_authors = Vec::new();
849        for author in display_authors {
850            let formatted = format!("{}, {}", author.last_name, author.first_name);
851            formatted_authors.push(formatted);
852        }
853
854        let mut result = formatted_authors.join(", ");
855
856        if authors.len() > max_authors {
857            result.push_str(&format!(", {}", rules.et_altext));
858        }
859
860        result
861    }
862
863    fn format_title(&self, title: &str, format: &TitleFormat) -> String {
864        match format {
865            TitleFormat::TitleCase => self.to_title_case(title),
866            TitleFormat::SentenceCase => self.to_sentence_case(title),
867            TitleFormat::Uppercase => title.to_uppercase(),
868            TitleFormat::Lowercase => title.to_lowercase(),
869            TitleFormat::AsEntered => title.to_string(),
870        }
871    }
872
873    fn to_title_case(&self, s: &str) -> String {
874        s.split_whitespace()
875            .map(|word| {
876                let mut chars = word.chars();
877                match chars.next() {
878                    None => String::new(),
879                    Some(first) => {
880                        first.to_uppercase().collect::<String>() + &chars.as_str().to_lowercase()
881                    }
882                }
883            })
884            .collect::<Vec<_>>()
885            .join(" ")
886    }
887
888    fn to_sentence_case(&self, s: &str) -> String {
889        if s.is_empty() {
890            return String::new();
891        }
892
893        let mut chars = s.chars();
894        // The emptiness guard above makes `next()` a `Some`, but reading it out
895        // fallibly means a future change to that guard cannot turn this into a
896        // panic.
897        let Some(leading) = chars.next() else {
898            return String::new();
899        };
900        let first = leading.to_uppercase().collect::<String>();
901        first + &chars.as_str().to_lowercase()
902    }
903
904    fn sort_citations(&self, citations: &mut Vec<&Citation>, rules: &SortingRules) {
905        citations.sort_by(|a, b| {
906            let primary_cmp = self.compare_by_field(a, b, &rules.primary_sort);
907            if primary_cmp == std::cmp::Ordering::Equal {
908                if let Some(secondary) = &rules.secondary_sort {
909                    self.compare_by_field(a, b, secondary)
910                } else {
911                    std::cmp::Ordering::Equal
912                }
913            } else {
914                primary_cmp
915            }
916        });
917
918        if rules.sort_direction == SortDirection::Descending {
919            citations.reverse();
920        }
921    }
922
923    fn compare_by_field(
924        &self,
925        a: &Citation,
926        b: &Citation,
927        field: &SortField,
928    ) -> std::cmp::Ordering {
929        match field {
930            SortField::Author => {
931                let a_author = a
932                    .authors
933                    .first()
934                    .map(|au| au.last_name.as_str())
935                    .unwrap_or("");
936                let b_author = b
937                    .authors
938                    .first()
939                    .map(|au| au.last_name.as_str())
940                    .unwrap_or("");
941                a_author.cmp(b_author)
942            }
943            SortField::Year => a.year.cmp(&b.year),
944            SortField::Title => a.title.cmp(&b.title),
945            SortField::Venue => a.venue.cmp(&b.venue),
946            SortField::Key => a.key.cmp(&b.key),
947            SortField::DateAdded => a.created_at.cmp(&b.created_at),
948        }
949    }
950
951    fn citation_to_bibtex(&self, citation: &Citation) -> String {
952        let mut bibtex = format!(
953            "@{}{{{},\n",
954            self.publication_type_to_bibtex(&citation.publication_type),
955            citation.key
956        );
957
958        bibtex.push_str(&format!("  title = {{{}}},\n", citation.title));
959
960        if !citation.authors.is_empty() {
961            let authors = citation
962                .authors
963                .iter()
964                .map(|a| format!("{} {}", a.first_name, a.last_name))
965                .collect::<Vec<_>>()
966                .join(" and ");
967            bibtex.push_str(&format!("  author = {{{}}},\n", authors));
968        }
969
970        if let Some(year) = citation.year {
971            bibtex.push_str(&format!("  year = {{{}}},\n", year));
972        }
973
974        if let Some(venue) = &citation.venue {
975            let field_name = match citation.publication_type {
976                PublicationType::Article => "journal",
977                PublicationType::InProceedings => "booktitle",
978                PublicationType::Book => "publisher",
979                PublicationType::InCollection => "booktitle",
980                PublicationType::PhDThesis => "school",
981                PublicationType::MastersThesis => "school",
982                PublicationType::TechReport => "institution",
983                PublicationType::Manual => "organization",
984                PublicationType::Misc => "howpublished",
985                PublicationType::Unpublished => "note",
986                PublicationType::Preprint => "archivePrefix",
987                PublicationType::Patent => "assignee",
988                PublicationType::Software => "url",
989                PublicationType::Dataset => "url",
990            };
991            bibtex.push_str(&format!("  {} = {{{}}},\n", field_name, venue));
992        }
993
994        if let Some(volume) = &citation.volume {
995            bibtex.push_str(&format!("  volume = {{{}}},\n", volume));
996        }
997
998        if let Some(pages) = &citation.pages {
999            bibtex.push_str(&format!("  pages = {{{}}},\n", pages));
1000        }
1001
1002        if let Some(doi) = &citation.doi {
1003            bibtex.push_str(&format!("  doi = {{{}}},\n", doi));
1004        }
1005
1006        bibtex.push_str("}\n");
1007        bibtex
1008    }
1009
1010    fn publication_type_to_bibtex(&self, pub_type: &PublicationType) -> &'static str {
1011        match pub_type {
1012            PublicationType::Article => "article",
1013            PublicationType::InProceedings => "inproceedings",
1014            PublicationType::Book => "book",
1015            PublicationType::InCollection => "incollection",
1016            PublicationType::PhDThesis => "phdthesis",
1017            PublicationType::MastersThesis => "mastersthesis",
1018            PublicationType::TechReport => "techreport",
1019            PublicationType::Manual => "manual",
1020            PublicationType::Misc => "misc",
1021            PublicationType::Unpublished => "unpublished",
1022            PublicationType::Preprint => "misc",
1023            PublicationType::Patent => "misc",
1024            PublicationType::Software => "misc",
1025            PublicationType::Dataset => "misc",
1026        }
1027    }
1028
1029    fn create_apa_style() -> CitationStyle {
1030        CitationStyle {
1031            name: "APA".to_string(),
1032            description: "American Psychological Association style".to_string(),
1033            intext_format: InTextFormat::AuthorYear,
1034            bibliography_format: BibliographyFormat {
1035                entry_separator: "\n".to_string(),
1036                field_separators: {
1037                    let mut separators = HashMap::new();
1038                    separators.insert("author_title".to_string(), ". ".to_string());
1039                    separators.insert("title_venue".to_string(), ". ".to_string());
1040                    separators
1041                },
1042                name_format: NameFormat::LastFirstInitial,
1043                title_format: TitleFormat::SentenceCase,
1044                date_format: DateFormat::Year,
1045                punctuation: PunctuationRules {
1046                    periods_after_abbreviations: true,
1047                    commas_between_fields: true,
1048                    parentheses_around_year: true,
1049                    quote_titles: false,
1050                    italicize_journals: true,
1051                },
1052            },
1053            formatting_rules: FormattingRules {
1054                max_authors: Some(7),
1055                et_altext: "et al.".to_string(),
1056                et_al_threshold: 8,
1057                title_case: false,
1058                abbreviate_journals: false,
1059                include_doi: true,
1060                include_url: false,
1061            },
1062            sorting_rules: SortingRules {
1063                primary_sort: SortField::Author,
1064                secondary_sort: Some(SortField::Year),
1065                sort_direction: SortDirection::Ascending,
1066                group_by_type: false,
1067            },
1068        }
1069    }
1070
1071    fn create_ieee_style() -> CitationStyle {
1072        CitationStyle {
1073            name: "IEEE".to_string(),
1074            description: "Institute of Electrical and Electronics Engineers style".to_string(),
1075            intext_format: InTextFormat::Numbered,
1076            bibliography_format: BibliographyFormat {
1077                entry_separator: "\n".to_string(),
1078                field_separators: HashMap::new(),
1079                name_format: NameFormat::FirstInitialLast,
1080                title_format: TitleFormat::AsEntered,
1081                date_format: DateFormat::Year,
1082                punctuation: PunctuationRules {
1083                    periods_after_abbreviations: true,
1084                    commas_between_fields: true,
1085                    parentheses_around_year: false,
1086                    quote_titles: true,
1087                    italicize_journals: true,
1088                },
1089            },
1090            formatting_rules: FormattingRules {
1091                max_authors: None,
1092                et_altext: "et al.".to_string(),
1093                et_al_threshold: 7,
1094                title_case: false,
1095                abbreviate_journals: true,
1096                include_doi: true,
1097                include_url: false,
1098            },
1099            sorting_rules: SortingRules {
1100                primary_sort: SortField::Year,
1101                secondary_sort: Some(SortField::Author),
1102                sort_direction: SortDirection::Ascending,
1103                group_by_type: false,
1104            },
1105        }
1106    }
1107
1108    fn create_acm_style() -> CitationStyle {
1109        CitationStyle {
1110            name: "ACM".to_string(),
1111            description: "Association for Computing Machinery style".to_string(),
1112            intext_format: InTextFormat::Numbered,
1113            bibliography_format: BibliographyFormat {
1114                entry_separator: "\n".to_string(),
1115                field_separators: HashMap::new(),
1116                name_format: NameFormat::FirstMiddleLast,
1117                title_format: TitleFormat::TitleCase,
1118                date_format: DateFormat::Year,
1119                punctuation: PunctuationRules {
1120                    periods_after_abbreviations: true,
1121                    commas_between_fields: true,
1122                    parentheses_around_year: false,
1123                    quote_titles: false,
1124                    italicize_journals: true,
1125                },
1126            },
1127            formatting_rules: FormattingRules {
1128                max_authors: None,
1129                et_altext: "et al.".to_string(),
1130                et_al_threshold: 3,
1131                title_case: true,
1132                abbreviate_journals: false,
1133                include_doi: true,
1134                include_url: true,
1135            },
1136            sorting_rules: SortingRules {
1137                primary_sort: SortField::Author,
1138                secondary_sort: Some(SortField::Year),
1139                sort_direction: SortDirection::Ascending,
1140                group_by_type: false,
1141            },
1142        }
1143    }
1144}
1145
1146/// Render a 1-based citation position as Unicode superscript digits, e.g.
1147/// `12` -> `"¹²"`. Used by the [`InTextFormat::Superscript`] citation style.
1148fn to_superscript(position: usize) -> String {
1149    const DIGITS: [char; 10] = ['⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹'];
1150    position
1151        .to_string()
1152        .chars()
1153        .map(|c| c.to_digit(10).map(|d| DIGITS[d as usize]).unwrap_or(c))
1154        .collect()
1155}
1156
1157/// Split raw BibTeX source into `(entry_type, key, fields)` tuples.
1158///
1159/// This is a brace-depth-aware tokenizer rather than a line-oriented scanner:
1160/// it walks the input character by character so that field values spanning
1161/// multiple physical lines and values containing nested braces (both
1162/// extremely common in real-world `.bib` files) are captured correctly. It
1163/// is shared by [`BibTeXProcessor::parse_bibtex`] and
1164/// [`crate::research::publications::Bibliography::parse_bibtex`] so the
1165/// parsing logic has a single source of truth.
1166pub(crate) fn parse_bibtex_entries(
1167    content: &str,
1168) -> Vec<(String, String, HashMap<String, String>)> {
1169    let chars: Vec<char> = content.chars().collect();
1170    let n = chars.len();
1171    let mut i = 0;
1172    let mut entries = Vec::new();
1173
1174    while i < n {
1175        while i < n && chars[i] != '@' {
1176            i += 1;
1177        }
1178        if i >= n {
1179            break;
1180        }
1181        i += 1; // skip '@'
1182
1183        let type_start = i;
1184        while i < n && chars[i] != '{' && chars[i] != '(' {
1185            i += 1;
1186        }
1187        if i >= n {
1188            break;
1189        }
1190        let entry_type: String = chars[type_start..i]
1191            .iter()
1192            .collect::<String>()
1193            .trim()
1194            .to_lowercase();
1195        let open_char = chars[i];
1196        let close_char = if open_char == '{' { '}' } else { ')' };
1197        i += 1; // skip opening delimiter
1198
1199        let body_start = i;
1200        let mut depth = 1usize;
1201        while i < n && depth > 0 {
1202            if chars[i] == open_char {
1203                depth += 1;
1204            } else if chars[i] == close_char {
1205                depth -= 1;
1206                if depth == 0 {
1207                    break;
1208                }
1209            }
1210            i += 1;
1211        }
1212        let body: String = chars[body_start..i].iter().collect();
1213        if i < n {
1214            i += 1; // skip closing delimiter
1215        }
1216
1217        if entry_type.is_empty() {
1218            continue;
1219        }
1220
1221        if let Some(comma_pos) = body.find(',') {
1222            let key = body[..comma_pos].trim().to_string();
1223            let fields = parse_bibtex_fields(&body[comma_pos + 1..]);
1224            if !key.is_empty() {
1225                entries.push((entry_type, key, fields));
1226            }
1227        }
1228    }
1229
1230    entries
1231}
1232
1233/// Parse the `field = value, field = value, ...` body of a single BibTeX
1234/// entry into a name -> value map, honoring brace-delimited values (with
1235/// nesting), quote-delimited values, and bare (unquoted) values such as
1236/// `year = 2024`. Internal line breaks inside a value are collapsed to a
1237/// single space, matching how BibTeX treats whitespace as insignificant.
1238fn parse_bibtex_fields(body: &str) -> HashMap<String, String> {
1239    let chars: Vec<char> = body.chars().collect();
1240    let n = chars.len();
1241    let mut i = 0;
1242    let mut fields = HashMap::new();
1243
1244    while i < n {
1245        while i < n && (chars[i].is_whitespace() || chars[i] == ',') {
1246            i += 1;
1247        }
1248        if i >= n {
1249            break;
1250        }
1251
1252        let name_start = i;
1253        while i < n && chars[i] != '=' {
1254            i += 1;
1255        }
1256        if i >= n {
1257            break;
1258        }
1259        let field_name = chars[name_start..i]
1260            .iter()
1261            .collect::<String>()
1262            .trim()
1263            .to_lowercase();
1264        i += 1; // skip '='
1265        while i < n && chars[i].is_whitespace() {
1266            i += 1;
1267        }
1268        if i >= n {
1269            break;
1270        }
1271
1272        let raw_value: String = if chars[i] == '{' {
1273            i += 1;
1274            let val_start = i;
1275            let mut depth = 1usize;
1276            while i < n && depth > 0 {
1277                match chars[i] {
1278                    '{' => depth += 1,
1279                    '}' => {
1280                        depth -= 1;
1281                        if depth == 0 {
1282                            break;
1283                        }
1284                    }
1285                    _ => {}
1286                }
1287                i += 1;
1288            }
1289            let value = chars[val_start..i].iter().collect();
1290            if i < n {
1291                i += 1; // skip closing '}'
1292            }
1293            value
1294        } else if chars[i] == '"' {
1295            i += 1;
1296            let val_start = i;
1297            while i < n && chars[i] != '"' {
1298                i += 1;
1299            }
1300            let value = chars[val_start..i].iter().collect();
1301            if i < n {
1302                i += 1; // skip closing '"'
1303            }
1304            value
1305        } else {
1306            let val_start = i;
1307            while i < n && chars[i] != ',' {
1308                i += 1;
1309            }
1310            chars[val_start..i].iter().collect::<String>()
1311        };
1312
1313        if !field_name.is_empty() {
1314            let normalized = raw_value.split_whitespace().collect::<Vec<_>>().join(" ");
1315            fields.insert(field_name, normalized);
1316        }
1317
1318        while i < n && chars[i] != ',' {
1319            i += 1;
1320        }
1321    }
1322
1323    fields
1324}
1325
1326impl BibTeXProcessor {
1327    /// Create a new BibTeX processor
1328    pub fn new(settings: BibTeXSettings) -> Self {
1329        Self { settings }
1330    }
1331
1332    /// The settings this processor applies.
1333    pub fn settings(&self) -> &BibTeXSettings {
1334        &self.settings
1335    }
1336
1337    /// Normalise a BibTeX field value according to the configured settings.
1338    ///
1339    /// * `preserve_case == false` strips BibTeX's protective braces (`{DNA}`
1340    ///   becomes `DNA`), which is what makes a style's own capitalisation rules
1341    ///   apply. With it set, the braces are kept verbatim.
1342    /// * `utf8_conversion` decodes the common LaTeX accent escapes into the
1343    ///   characters they denote, so a parsed citation is usable outside LaTeX.
1344    fn apply_settings(&self, value: String) -> String {
1345        let mut value = if self.settings.preserve_case {
1346            value
1347        } else {
1348            value.replace(['{', '}'], "")
1349        };
1350        if self.settings.utf8_conversion {
1351            for (escape, replacement) in [
1352                ("\\\"a", "ä"),
1353                ("\\\"o", "ö"),
1354                ("\\\"u", "ü"),
1355                ("\\'e", "é"),
1356                ("\\'a", "á"),
1357                ("\\`e", "è"),
1358                ("\\^o", "ô"),
1359                ("\\~n", "ñ"),
1360                ("\\c c", "ç"),
1361                ("\\ss", "ß"),
1362                ("---", "\u{2014}"),
1363                ("--", "\u{2013}"),
1364            ] {
1365                value = value.replace(escape, replacement);
1366            }
1367        }
1368        value
1369    }
1370
1371    /// Parse BibTeX content into citations.
1372    ///
1373    /// Unlike a line-oriented scanner, this walks the input character by
1374    /// character and tracks brace depth, so field values that span multiple
1375    /// physical lines (very common for `abstract`/`note` fields) or that
1376    /// contain nested braces (e.g. `title = {The {Quick} Brown Fox}`) are
1377    /// captured in full instead of being truncated at the first newline.
1378    pub fn parse_bibtex(&self, content: &str) -> Result<Vec<Citation>> {
1379        let mut citations = Vec::new();
1380
1381        for (entry_type, key, fields) in parse_bibtex_entries(content) {
1382            if matches!(entry_type.as_str(), "comment" | "string" | "preamble") {
1383                continue;
1384            }
1385            let pub_type = self.bibtex_type_to_publication_type(&entry_type);
1386            if let Ok(citation) = self.fields_to_citation(key, pub_type, fields) {
1387                citations.push(citation);
1388            }
1389        }
1390
1391        Ok(citations)
1392    }
1393
1394    fn bibtex_type_to_publication_type(&self, bibtex_type: &str) -> PublicationType {
1395        match bibtex_type {
1396            "article" => PublicationType::Article,
1397            "inproceedings" | "conference" => PublicationType::InProceedings,
1398            "book" => PublicationType::Book,
1399            "incollection" | "inbook" => PublicationType::InCollection,
1400            "phdthesis" => PublicationType::PhDThesis,
1401            "mastersthesis" => PublicationType::MastersThesis,
1402            "techreport" => PublicationType::TechReport,
1403            "manual" => PublicationType::Manual,
1404            "unpublished" => PublicationType::Unpublished,
1405            _ => PublicationType::Misc,
1406        }
1407    }
1408
1409    fn fields_to_citation(
1410        &self,
1411        key: String,
1412        pub_type: PublicationType,
1413        fields: HashMap<String, String>,
1414    ) -> Result<Citation> {
1415        // `settings` actually shapes the parse now. Until 0.3.2 it was stored
1416        // by `new` and never read, so `preserve_case` and `utf8_conversion`
1417        // were inert: a title's protective braces survived into the rendered
1418        // citation and LaTeX escapes were never decoded.
1419        let title = self.apply_settings(fields.get("title").cloned().unwrap_or_default());
1420
1421        // Parse authors
1422        let authors = if let Some(author_str) = fields.get("author") {
1423            self.parse_authors(author_str)
1424        } else {
1425            Vec::new()
1426        };
1427
1428        // Parse year
1429        let year = fields.get("year").and_then(|y| y.parse().ok());
1430
1431        // Determine venue field based on publication type
1432        let venue = match pub_type {
1433            PublicationType::Article => fields.get("journal").cloned(),
1434            PublicationType::InProceedings => fields.get("booktitle").cloned(),
1435            PublicationType::Book => fields.get("publisher").cloned(),
1436            PublicationType::InCollection => fields.get("booktitle").cloned(),
1437            PublicationType::PhDThesis => fields.get("school").cloned(),
1438            PublicationType::MastersThesis => fields.get("school").cloned(),
1439            PublicationType::TechReport => fields.get("institution").cloned(),
1440            PublicationType::Manual => fields.get("organization").cloned(),
1441            PublicationType::Misc => fields.get("howpublished").cloned(),
1442            PublicationType::Unpublished => fields.get("note").cloned(),
1443            PublicationType::Preprint => fields.get("archivePrefix").cloned(),
1444            PublicationType::Patent => fields.get("assignee").cloned(),
1445            PublicationType::Software => fields.get("url").cloned(),
1446            PublicationType::Dataset => fields.get("url").cloned(),
1447        };
1448
1449        let now = Utc::now();
1450
1451        Ok(Citation {
1452            key,
1453            publication_type: pub_type,
1454            title,
1455            authors,
1456            year,
1457            venue,
1458            volume: fields.get("volume").cloned(),
1459            issue: fields.get("number").cloned(),
1460            pages: fields.get("pages").cloned(),
1461            doi: fields.get("doi").cloned(),
1462            url: fields.get("url").cloned(),
1463            abstracttext: fields.get("abstract").cloned(),
1464            keywords: Vec::new(),
1465            notes: fields.get("note").cloned(),
1466            custom_fields: HashMap::new(),
1467            attachments: Vec::new(),
1468            groups: Vec::new(),
1469            import_source: Some("BibTeX".to_string()),
1470            created_at: now,
1471            modified_at: now,
1472        })
1473    }
1474
1475    fn parse_authors(&self, author_str: &str) -> Vec<Author> {
1476        author_str
1477            .split(" and ")
1478            .map(|author_part| {
1479                let author_part = author_part.trim();
1480                if let Some(comma_pos) = author_part.find(',') {
1481                    // "Last, First" format
1482                    let last_name = author_part[..comma_pos].trim().to_string();
1483                    let first_name = author_part[comma_pos + 1..].trim().to_string();
1484                    Author {
1485                        first_name,
1486                        last_name,
1487                        middle_name: None,
1488                        suffix: None,
1489                        orcid: None,
1490                        affiliation: None,
1491                    }
1492                } else {
1493                    // "First Last" format
1494                    let parts: Vec<&str> = author_part.split_whitespace().collect();
1495                    if parts.len() >= 2 {
1496                        let first_name = parts[0].to_string();
1497                        let last_name = parts[parts.len() - 1].to_string();
1498                        let middle_name = if parts.len() > 2 {
1499                            Some(parts[1..parts.len() - 1].join(" "))
1500                        } else {
1501                            None
1502                        };
1503                        Author {
1504                            first_name,
1505                            last_name,
1506                            middle_name,
1507                            suffix: None,
1508                            orcid: None,
1509                            affiliation: None,
1510                        }
1511                    } else {
1512                        // Single name
1513                        Author {
1514                            first_name: String::new(),
1515                            last_name: author_part.to_string(),
1516                            middle_name: None,
1517                            suffix: None,
1518                            orcid: None,
1519                            affiliation: None,
1520                        }
1521                    }
1522                }
1523            })
1524            .collect()
1525    }
1526}
1527
1528impl Default for CitationSettings {
1529    fn default() -> Self {
1530        Self {
1531            auto_generate_keys: true,
1532            key_pattern: "{author}{year}".to_string(),
1533            auto_import_doi: true,
1534            auto_import_url: false,
1535            duplicate_detection: true,
1536            backup_enabled: true,
1537            export_formats: vec![ExportFormat::BibTeX, ExportFormat::RIS],
1538        }
1539    }
1540}
1541
1542impl Default for BibTeXSettings {
1543    fn default() -> Self {
1544        Self {
1545            preserve_case: true,
1546            utf8_conversion: true,
1547            cleanup_formatting: true,
1548            validate_entries: true,
1549        }
1550    }
1551}
1552
1553#[cfg(test)]
1554mod tests {
1555    use super::*;
1556
1557    #[test]
1558    fn test_citation_manager_creation() {
1559        let manager = CitationManager::new();
1560
1561        assert!(manager.styles.contains_key("APA"));
1562        assert!(manager.styles.contains_key("IEEE"));
1563        assert!(manager.styles.contains_key("ACM"));
1564        assert_eq!(manager.default_style, "APA");
1565    }
1566
1567    #[test]
1568    fn test_add_citation() {
1569        let mut manager = CitationManager::new();
1570
1571        let citation = Citation {
1572            key: "test2023".to_string(),
1573            publication_type: PublicationType::Article,
1574            title: "Test Article".to_string(),
1575            authors: vec![Author {
1576                first_name: "John".to_string(),
1577                last_name: "Doe".to_string(),
1578                middle_name: None,
1579                suffix: None,
1580                orcid: None,
1581                affiliation: None,
1582            }],
1583            year: Some(2023),
1584            venue: Some("Test Journal".to_string()),
1585            volume: None,
1586            issue: None,
1587            pages: None,
1588            doi: None,
1589            url: None,
1590            abstracttext: None,
1591            keywords: Vec::new(),
1592            notes: None,
1593            custom_fields: HashMap::new(),
1594            attachments: Vec::new(),
1595            groups: Vec::new(),
1596            import_source: None,
1597            created_at: Utc::now(),
1598            modified_at: Utc::now(),
1599        };
1600
1601        assert!(manager.add_citation(citation).is_ok());
1602        assert!(manager.citations.contains_key("test2023"));
1603    }
1604
1605    #[test]
1606    fn test_search_citations() {
1607        let mut manager = CitationManager::new();
1608
1609        let citation = Citation {
1610            key: "test2023".to_string(),
1611            publication_type: PublicationType::Article,
1612            title: "Machine Learning Optimization".to_string(),
1613            authors: vec![Author {
1614                first_name: "Jane".to_string(),
1615                last_name: "Smith".to_string(),
1616                middle_name: None,
1617                suffix: None,
1618                orcid: None,
1619                affiliation: None,
1620            }],
1621            year: Some(2023),
1622            venue: None,
1623            volume: None,
1624            issue: None,
1625            pages: None,
1626            doi: None,
1627            url: None,
1628            abstracttext: None,
1629            keywords: vec!["optimization".to_string(), "machine learning".to_string()],
1630            notes: None,
1631            custom_fields: HashMap::new(),
1632            attachments: Vec::new(),
1633            groups: Vec::new(),
1634            import_source: None,
1635            created_at: Utc::now(),
1636            modified_at: Utc::now(),
1637        };
1638
1639        manager.add_citation(citation).expect("unwrap failed");
1640
1641        let results = manager.search_citations("optimization");
1642        assert_eq!(results.len(), 1);
1643
1644        let results = manager.search_citations("Smith");
1645        assert_eq!(results.len(), 1);
1646    }
1647
1648    // Regression test for F77: the previous line-oriented BibTeX parser
1649    // truncated any field value that spanned multiple physical lines (very
1650    // common for `abstract`/`note` fields) at the first newline, and mangled
1651    // values containing nested braces.
1652    #[test]
1653    fn test_parse_bibtex_handles_multiline_and_nested_braces() {
1654        let processor = BibTeXProcessor::new(BibTeXSettings::default());
1655        let bibtex = r#"
1656@article{smith2023multiline,
1657  title = {The {Quick} Brown Fox},
1658  author = {Smith, John and Doe, Jane},
1659  year = {2023},
1660  journal = {Journal of Testing},
1661  abstract = {This abstract deliberately spans
1662              multiple physical lines to verify
1663              that continuation lines are not dropped.},
1664}
1665"#;
1666
1667        let citations = processor
1668            .parse_bibtex(bibtex)
1669            .expect("parse should succeed");
1670        assert_eq!(citations.len(), 1);
1671        let citation = &citations[0];
1672        assert_eq!(citation.key, "smith2023multiline");
1673        assert_eq!(citation.title, "The {Quick} Brown Fox");
1674        assert_eq!(citation.year, Some(2023));
1675        assert_eq!(citation.authors.len(), 2);
1676
1677        let abstract_text = citation
1678            .abstracttext
1679            .as_ref()
1680            .expect("abstract should be captured");
1681        assert!(
1682            abstract_text.contains("multiple physical lines"),
1683            "continuation lines were dropped: {abstract_text:?}"
1684        );
1685        assert!(
1686            !abstract_text.contains('\n'),
1687            "internal newlines should be normalized to spaces: {abstract_text:?}"
1688        );
1689    }
1690
1691    #[test]
1692    fn test_parse_bibtex_handles_multiple_entries() {
1693        let processor = BibTeXProcessor::new(BibTeXSettings::default());
1694        let bibtex = "@article{first2020,\n  title = {First},\n  year = {2020},\n}\n\
1695@inproceedings{second2021,\n  title = {Second},\n  year = {2021},\n}\n";
1696
1697        let citations = processor
1698            .parse_bibtex(bibtex)
1699            .expect("parse should succeed");
1700        assert_eq!(citations.len(), 2);
1701        assert_eq!(citations[0].key, "first2020");
1702        assert_eq!(citations[1].key, "second2021");
1703        assert_eq!(
1704            citations[1].publication_type,
1705            PublicationType::InProceedings
1706        );
1707    }
1708
1709    // Regression test for F24: `CitationDiscovery` was exported with fully
1710    // private fields and zero methods (not even a constructor).
1711    #[test]
1712    fn test_citation_discovery_is_constructible_and_routes_queries() {
1713        let doi_engine = SearchEngine {
1714            name: "crossref".to_string(),
1715            endpoint: "https://api.crossref.org".to_string(),
1716            rate_limit: 5.0,
1717            query_types: vec![QueryType::DOI, QueryType::Title],
1718        };
1719        let arxiv_engine = SearchEngine {
1720            name: "arxiv".to_string(),
1721            endpoint: "https://export.arxiv.org/api".to_string(),
1722            rate_limit: 1.0,
1723            query_types: vec![QueryType::ArXiv, QueryType::Title],
1724        };
1725
1726        let mut discovery = CitationDiscovery::new()
1727            .with_search_engine(doi_engine)
1728            .with_search_engine(arxiv_engine);
1729        discovery.set_api_key("crossref", "secret-token");
1730
1731        assert_eq!(discovery.search_engines().len(), 2);
1732        assert!(discovery.has_credentials("crossref"));
1733        assert!(!discovery.has_credentials("arxiv"));
1734
1735        let doi_engines = discovery.engines_for(&QueryType::DOI);
1736        assert_eq!(doi_engines.len(), 1);
1737        assert_eq!(doi_engines[0].name, "crossref");
1738
1739        // Both engines support Title search; the lower rate-limit engine
1740        // (arxiv, 1 req/s) should be preferred over crossref (5 req/s).
1741        let title_engines = discovery.engines_for(&QueryType::Title);
1742        assert_eq!(title_engines.len(), 2);
1743        assert_eq!(title_engines[0].name, "arxiv");
1744
1745        assert!(discovery.engines_for(&QueryType::ISBN).is_empty());
1746    }
1747
1748    fn make_citation(key: &str, last_name: &str, year: u32) -> Citation {
1749        let now = Utc::now();
1750        Citation {
1751            key: key.to_string(),
1752            publication_type: PublicationType::Article,
1753            title: format!("Paper by {last_name}"),
1754            authors: vec![Author {
1755                first_name: "A".to_string(),
1756                last_name: last_name.to_string(),
1757                middle_name: None,
1758                suffix: None,
1759                orcid: None,
1760                affiliation: None,
1761            }],
1762            year: Some(year),
1763            venue: Some("Journal".to_string()),
1764            volume: None,
1765            issue: None,
1766            pages: None,
1767            doi: None,
1768            url: None,
1769            abstracttext: None,
1770            keywords: Vec::new(),
1771            notes: None,
1772            custom_fields: HashMap::new(),
1773            attachments: Vec::new(),
1774            groups: Vec::new(),
1775            import_source: None,
1776            created_at: now,
1777            modified_at: now,
1778        }
1779    }
1780
1781    // Regression test for F25: every entry in a numbered-style bibliography
1782    // was rendered as "[1]" regardless of its real position in the list.
1783    #[test]
1784    fn test_generate_bibliography_assigns_distinct_numbers() {
1785        let mut manager = CitationManager::new();
1786        manager
1787            .add_citation(make_citation("adams2020", "Adams", 2020))
1788            .expect("add should succeed");
1789        manager
1790            .add_citation(make_citation("zimmerman2021", "Zimmerman", 2021))
1791            .expect("add should succeed");
1792
1793        let bibliography = manager
1794            .generate_bibliography(
1795                &["adams2020".to_string(), "zimmerman2021".to_string()],
1796                Some("IEEE"),
1797            )
1798            .expect("bibliography generation should succeed");
1799
1800        let lines: Vec<&str> = bibliography.lines().filter(|l| !l.is_empty()).collect();
1801        assert_eq!(lines.len(), 2);
1802        // Author-ascending sort puts Adams (position 1) before Zimmerman (position 2).
1803        assert!(
1804            lines[0].starts_with("[1]"),
1805            "first entry should be numbered [1]: {:?}",
1806            lines[0]
1807        );
1808        assert!(
1809            lines[1].starts_with("[2]"),
1810            "second entry should be numbered [2], not a duplicate [1]: {:?}",
1811            lines[1]
1812        );
1813    }
1814
1815    #[test]
1816    fn test_to_superscript_renders_multi_digit_positions() {
1817        assert_eq!(to_superscript(1), "¹");
1818        assert_eq!(to_superscript(12), "¹²");
1819        assert_eq!(to_superscript(103), "¹⁰³");
1820    }
1821}