1use crate::error::{OptimError, Result};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct CitationManager {
14 pub citations: HashMap<String, Citation>,
16 pub styles: HashMap<String, CitationStyle>,
18 pub default_style: String,
20 pub groups: HashMap<String, CitationGroup>,
22 pub settings: CitationSettings,
24 pub modified_at: DateTime<Utc>,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct Citation {
31 pub key: String,
33 pub publication_type: PublicationType,
35 pub title: String,
37 pub authors: Vec<Author>,
39 pub year: Option<u32>,
41 pub venue: Option<String>,
43 pub volume: Option<String>,
45 pub issue: Option<String>,
47 pub pages: Option<String>,
49 pub doi: Option<String>,
51 pub url: Option<String>,
53 pub abstracttext: Option<String>,
55 pub keywords: Vec<String>,
57 pub notes: Option<String>,
59 pub custom_fields: HashMap<String, String>,
61 pub attachments: Vec<String>,
63 pub groups: Vec<String>,
65 pub import_source: Option<String>,
67 pub created_at: DateTime<Utc>,
69 pub modified_at: DateTime<Utc>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75pub enum PublicationType {
76 Article,
78 InProceedings,
80 Book,
82 InCollection,
84 PhDThesis,
86 MastersThesis,
88 TechReport,
90 Manual,
92 Misc,
94 Unpublished,
96 Preprint,
98 Patent,
100 Software,
102 Dataset,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct Author {
109 pub first_name: String,
111 pub last_name: String,
113 pub middle_name: Option<String>,
115 pub suffix: Option<String>,
117 pub orcid: Option<String>,
119 pub affiliation: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CitationStyle {
126 pub name: String,
128 pub description: String,
130 pub intext_format: InTextFormat,
132 pub bibliography_format: BibliographyFormat,
134 pub formatting_rules: FormattingRules,
136 pub sorting_rules: SortingRules,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub enum InTextFormat {
143 AuthorYear,
145 Numbered,
147 Superscript,
149 AuthorNumber,
151 Footnote,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct BibliographyFormat {
158 pub entry_separator: String,
160 pub field_separators: HashMap<String, String>,
162 pub name_format: NameFormat,
164 pub title_format: TitleFormat,
166 pub date_format: DateFormat,
168 pub punctuation: PunctuationRules,
170}
171
172#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
174pub enum NameFormat {
175 LastFirstMiddle,
177 FirstMiddleLast,
179 LastFirstInitial,
181 FirstInitialLast,
183 LastFirstInitialNoSpace,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
189pub enum TitleFormat {
190 TitleCase,
192 SentenceCase,
194 Uppercase,
196 Lowercase,
198 AsEntered,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
204pub enum DateFormat {
205 Year,
207 MonthYear,
209 MonthAbbrevYear,
211 FullDate,
213 ISODate,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct PunctuationRules {
220 pub periods_after_abbreviations: bool,
222 pub commas_between_fields: bool,
224 pub parentheses_around_year: bool,
226 pub quote_titles: bool,
228 pub italicize_journals: bool,
230}
231
232#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct FormattingRules {
235 pub max_authors: Option<usize>,
237 pub et_altext: String,
239 pub et_al_threshold: usize,
241 pub title_case: bool,
243 pub abbreviate_journals: bool,
245 pub include_doi: bool,
247 pub include_url: bool,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct SortingRules {
254 pub primary_sort: SortField,
256 pub secondary_sort: Option<SortField>,
258 pub sort_direction: SortDirection,
260 pub group_by_type: bool,
262}
263
264#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
266pub enum SortField {
267 Author,
269 Year,
271 Title,
273 Venue,
275 Key,
277 DateAdded,
279}
280
281#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
283pub enum SortDirection {
284 Ascending,
286 Descending,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
292pub struct CitationGroup {
293 pub name: String,
295 pub description: String,
297 pub color: Option<String>,
299 pub citation_keys: Vec<String>,
301 pub created_at: DateTime<Utc>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
307pub struct CitationSettings {
308 pub auto_generate_keys: bool,
310 pub key_pattern: String,
312 pub auto_import_doi: bool,
314 pub auto_import_url: bool,
316 pub duplicate_detection: bool,
318 pub backup_enabled: bool,
320 pub export_formats: Vec<ExportFormat>,
322}
323
324#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
326pub enum ExportFormat {
327 BibTeX,
329 RIS,
331 EndNote,
333 JSON,
335 CSV,
337 Word,
339}
340
341#[derive(Debug)]
343pub struct BibTeXProcessor {
344 settings: BibTeXSettings,
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize)]
350pub struct BibTeXSettings {
351 pub preserve_case: bool,
353 pub utf8_conversion: bool,
355 pub cleanup_formatting: bool,
357 pub validate_entries: bool,
359}
360
361#[derive(Debug)]
363pub struct CitationDiscovery {
364 search_engines: Vec<SearchEngine>,
366 api_keys: HashMap<String, String>,
368}
369
370impl CitationDiscovery {
371 pub fn new() -> Self {
373 Self {
374 search_engines: Vec::new(),
375 api_keys: HashMap::new(),
376 }
377 }
378
379 pub fn with_search_engine(mut self, engine: SearchEngine) -> Self {
381 self.search_engines.push(engine);
382 self
383 }
384
385 pub fn add_search_engine(&mut self, engine: SearchEngine) {
387 self.search_engines.push(engine);
388 }
389
390 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 pub fn search_engines(&self) -> &[SearchEngine] {
398 &self.search_engines
399 }
400
401 pub fn has_credentials(&self, engine_name: &str) -> bool {
403 self.api_keys.contains_key(engine_name)
404 }
405
406 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#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct SearchEngine {
438 pub name: String,
440 pub endpoint: String,
442 pub rate_limit: f64,
444 pub query_types: Vec<QueryType>,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
450pub enum QueryType {
451 DOI,
453 Title,
455 Author,
457 ArXiv,
459 PubMed,
461 ISBN,
463 FreeText,
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct CitationNetwork {
470 pub citations: Vec<String>,
472 pub relationships: Vec<CitationRelationship>,
474 pub metrics: NetworkMetrics,
476}
477
478#[derive(Debug, Clone, Serialize, Deserialize)]
480pub struct CitationRelationship {
481 pub citing: String,
483 pub cited: String,
485 pub relationship_type: RelationshipType,
487 pub strength: f64,
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
493pub enum RelationshipType {
494 DirectCitation,
496 CoCitation,
498 BibliographicCoupling,
500 SameAuthor,
502 SameVenue,
504 SimilarTopic,
506}
507
508#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct NetworkMetrics {
511 pub total_nodes: usize,
513 pub total_edges: usize,
515 pub density: f64,
517 pub clustering_coefficient: f64,
519 pub most_cited: Vec<(String, usize)>,
521 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 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 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 pub fn get_citation(&self, key: &str) -> Option<&Citation> {
565 self.citations.get(key)
566 }
567
568 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 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 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 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 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 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 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 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 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 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 let authors = self.format_authors(&citation.authors, &style.formatting_rules);
797 formatted.push_str(&authors);
798
799 let title = self.format_title(&citation.title, &style.bibliography_format.title_format);
801 formatted.push_str(&format!(". {}.", title));
802
803 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 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 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 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
1146fn 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
1157pub(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; 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; 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; }
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
1233fn 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; 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; }
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; }
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 pub fn new(settings: BibTeXSettings) -> Self {
1329 Self { settings }
1330 }
1331
1332 pub fn settings(&self) -> &BibTeXSettings {
1334 &self.settings
1335 }
1336
1337 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 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 let title = self.apply_settings(fields.get("title").cloned().unwrap_or_default());
1420
1421 let authors = if let Some(author_str) = fields.get("author") {
1423 self.parse_authors(author_str)
1424 } else {
1425 Vec::new()
1426 };
1427
1428 let year = fields.get("year").and_then(|y| y.parse().ok());
1430
1431 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 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 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 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 #[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 #[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 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 #[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 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}