Skip to main content

cdx_core/extensions/semantic/
bibliography.rs

1//! Bibliography management for academic documents.
2
3use serde::{Deserialize, Serialize};
4
5/// A bibliography containing all references cited in a document.
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7#[serde(rename_all = "camelCase")]
8pub struct Bibliography {
9    /// Citation style used for formatting.
10    #[serde(default)]
11    pub style: CitationStyle,
12
13    /// Bibliography entries.
14    pub entries: Vec<BibliographyEntry>,
15}
16
17impl Bibliography {
18    /// Create a new empty bibliography.
19    #[must_use]
20    pub fn new(style: CitationStyle) -> Self {
21        Self {
22            style,
23            entries: Vec::new(),
24        }
25    }
26
27    /// Add an entry to the bibliography.
28    pub fn add_entry(&mut self, entry: BibliographyEntry) {
29        self.entries.push(entry);
30    }
31
32    /// Find an entry by its ID.
33    #[must_use]
34    pub fn get(&self, id: &str) -> Option<&BibliographyEntry> {
35        self.entries.iter().find(|e| e.id == id)
36    }
37
38    /// Check if the bibliography contains an entry with the given ID.
39    #[must_use]
40    pub fn contains(&self, id: &str) -> bool {
41        self.entries.iter().any(|e| e.id == id)
42    }
43
44    /// Get the number of entries.
45    #[must_use]
46    pub fn len(&self) -> usize {
47        self.entries.len()
48    }
49
50    /// Check if the bibliography is empty.
51    #[must_use]
52    pub fn is_empty(&self) -> bool {
53        self.entries.is_empty()
54    }
55}
56
57impl Default for Bibliography {
58    fn default() -> Self {
59        Self::new(CitationStyle::default())
60    }
61}
62
63/// Citation style for formatting references.
64#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, strum::Display)]
65#[serde(rename_all = "lowercase")]
66pub enum CitationStyle {
67    /// APA (American Psychological Association) style.
68    #[default]
69    #[strum(serialize = "APA")]
70    Apa,
71    /// MLA (Modern Language Association) style.
72    #[strum(serialize = "MLA")]
73    Mla,
74    /// Chicago Manual of Style.
75    Chicago,
76    /// IEEE style.
77    #[strum(serialize = "IEEE")]
78    Ieee,
79    /// Harvard style.
80    Harvard,
81    /// Vancouver style.
82    Vancouver,
83    /// ACM style.
84    #[strum(serialize = "ACM")]
85    Acm,
86    /// Custom style (implementation-defined).
87    Custom,
88}
89
90/// A bibliography entry representing a single reference.
91#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct BibliographyEntry {
94    /// Unique identifier for the entry (used in citations).
95    pub id: String,
96
97    /// Type of the entry.
98    pub entry_type: EntryType,
99
100    /// Title of the work.
101    pub title: String,
102
103    /// Authors of the work.
104    #[serde(default, skip_serializing_if = "Vec::is_empty")]
105    pub authors: Vec<Author>,
106
107    /// Publication date.
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub issued: Option<PartialDate>,
110
111    /// Container title (e.g., journal name, book title for chapters).
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub container_title: Option<String>,
114
115    /// Volume number.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub volume: Option<String>,
118
119    /// Issue number.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub issue: Option<String>,
122
123    /// Page range.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub page: Option<String>,
126
127    /// Digital Object Identifier.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub doi: Option<String>,
130
131    /// URL to the work.
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub url: Option<String>,
134
135    /// ISBN for books.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub isbn: Option<String>,
138
139    /// ISSN for journals.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub issn: Option<String>,
142
143    /// Publisher name.
144    #[serde(default, skip_serializing_if = "Option::is_none")]
145    pub publisher: Option<String>,
146
147    /// Publication location.
148    #[serde(default, skip_serializing_if = "Option::is_none")]
149    pub publisher_place: Option<String>,
150
151    /// Edition number or description.
152    #[serde(default, skip_serializing_if = "Option::is_none")]
153    pub edition: Option<String>,
154
155    /// Editors (for edited volumes).
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub editors: Vec<Author>,
158
159    /// Abstract text.
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub abstract_text: Option<String>,
162
163    /// Keywords or tags.
164    #[serde(default, skip_serializing_if = "Vec::is_empty")]
165    pub keywords: Vec<String>,
166
167    /// Language of the work.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub language: Option<String>,
170
171    /// Access date for online resources.
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub accessed: Option<PartialDate>,
174
175    /// Additional notes.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub note: Option<String>,
178}
179
180impl BibliographyEntry {
181    /// Create a new bibliography entry.
182    #[must_use]
183    pub fn new(id: impl Into<String>, entry_type: EntryType, title: impl Into<String>) -> Self {
184        Self {
185            id: id.into(),
186            entry_type,
187            title: title.into(),
188            authors: Vec::new(),
189            issued: None,
190            container_title: None,
191            volume: None,
192            issue: None,
193            page: None,
194            doi: None,
195            url: None,
196            isbn: None,
197            issn: None,
198            publisher: None,
199            publisher_place: None,
200            edition: None,
201            editors: Vec::new(),
202            abstract_text: None,
203            keywords: Vec::new(),
204            language: None,
205            accessed: None,
206            note: None,
207        }
208    }
209
210    /// Add an author.
211    #[must_use]
212    pub fn with_author(mut self, author: Author) -> Self {
213        self.authors.push(author);
214        self
215    }
216
217    /// Add multiple authors.
218    #[must_use]
219    pub fn with_authors(mut self, authors: Vec<Author>) -> Self {
220        self.authors = authors;
221        self
222    }
223
224    /// Set the publication date.
225    #[must_use]
226    pub fn with_issued(mut self, date: PartialDate) -> Self {
227        self.issued = Some(date);
228        self
229    }
230
231    /// Set the container title.
232    #[must_use]
233    pub fn with_container(mut self, container: impl Into<String>) -> Self {
234        self.container_title = Some(container.into());
235        self
236    }
237
238    /// Set volume and issue.
239    #[must_use]
240    pub fn with_volume_issue(mut self, volume: impl Into<String>, issue: Option<String>) -> Self {
241        self.volume = Some(volume.into());
242        self.issue = issue;
243        self
244    }
245
246    /// Set page range.
247    #[must_use]
248    pub fn with_pages(mut self, pages: impl Into<String>) -> Self {
249        self.page = Some(pages.into());
250        self
251    }
252
253    /// Set DOI.
254    #[must_use]
255    pub fn with_doi(mut self, doi: impl Into<String>) -> Self {
256        self.doi = Some(doi.into());
257        self
258    }
259
260    /// Set URL.
261    #[must_use]
262    pub fn with_url(mut self, url: impl Into<String>) -> Self {
263        self.url = Some(url.into());
264        self
265    }
266
267    /// Set publisher information.
268    #[must_use]
269    pub fn with_publisher(mut self, publisher: impl Into<String>, place: Option<String>) -> Self {
270        self.publisher = Some(publisher.into());
271        self.publisher_place = place;
272        self
273    }
274}
275
276/// Type of bibliography entry.
277#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, strum::Display)]
278#[serde(rename_all = "camelCase")]
279#[strum(serialize_all = "lowercase")]
280pub enum EntryType {
281    /// Journal article.
282    Article,
283    /// Book.
284    Book,
285    /// Chapter in a book.
286    Chapter,
287    /// Conference paper.
288    Conference,
289    /// Thesis or dissertation.
290    Thesis,
291    /// Technical report.
292    Report,
293    /// Website or webpage.
294    Webpage,
295    /// Patent.
296    Patent,
297    /// Dataset.
298    Dataset,
299    /// Software.
300    Software,
301    /// Legal case.
302    #[strum(serialize = "legal-case")]
303    LegalCase,
304    /// Legislation or statute.
305    Legislation,
306    /// Personal communication.
307    Personal,
308    /// Manuscript.
309    Manuscript,
310    /// Other type.
311    Other,
312}
313
314/// An author or contributor.
315#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
316#[serde(rename_all = "camelCase")]
317pub struct Author {
318    /// Given name (first name).
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub given: Option<String>,
321
322    /// Family name (last name).
323    #[serde(default, skip_serializing_if = "Option::is_none")]
324    pub family: Option<String>,
325
326    /// Full literal name (for non-standard names or organizations).
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub literal: Option<String>,
329
330    /// ORCID identifier.
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub orcid: Option<String>,
333
334    /// Affiliation.
335    #[serde(default, skip_serializing_if = "Option::is_none")]
336    pub affiliation: Option<String>,
337}
338
339impl Author {
340    /// Create an author from given and family names.
341    #[must_use]
342    pub fn new(given: impl Into<String>, family: impl Into<String>) -> Self {
343        Self {
344            given: Some(given.into()),
345            family: Some(family.into()),
346            literal: None,
347            orcid: None,
348            affiliation: None,
349        }
350    }
351
352    /// Create an author from a literal name (e.g., organization).
353    #[must_use]
354    pub fn literal(name: impl Into<String>) -> Self {
355        Self {
356            given: None,
357            family: None,
358            literal: Some(name.into()),
359            orcid: None,
360            affiliation: None,
361        }
362    }
363
364    /// Set ORCID.
365    #[must_use]
366    pub fn with_orcid(mut self, orcid: impl Into<String>) -> Self {
367        self.orcid = Some(orcid.into());
368        self
369    }
370
371    /// Set affiliation.
372    #[must_use]
373    pub fn with_affiliation(mut self, affiliation: impl Into<String>) -> Self {
374        self.affiliation = Some(affiliation.into());
375        self
376    }
377
378    /// Get the display name.
379    #[must_use]
380    pub fn display_name(&self) -> String {
381        if let Some(literal) = &self.literal {
382            return literal.clone();
383        }
384        match (&self.family, &self.given) {
385            (Some(family), Some(given)) => format!("{family}, {given}"),
386            (Some(family), None) => family.clone(),
387            (None, Some(given)) => given.clone(),
388            (None, None) => String::new(),
389        }
390    }
391}
392
393/// A partial date (year, year-month, or full date).
394#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
395#[serde(rename_all = "camelCase")]
396pub struct PartialDate {
397    /// Year.
398    pub year: i32,
399
400    /// Month (1-12).
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub month: Option<u8>,
403
404    /// Day (1-31).
405    #[serde(default, skip_serializing_if = "Option::is_none")]
406    pub day: Option<u8>,
407
408    /// Season (for quarterly publications).
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub season: Option<String>,
411}
412
413impl PartialDate {
414    /// Create a year-only date.
415    #[must_use]
416    pub const fn year(year: i32) -> Self {
417        Self {
418            year,
419            month: None,
420            day: None,
421            season: None,
422        }
423    }
424
425    /// Create a year-month date.
426    ///
427    /// Note: `month` should be in the range 1-12. Use [`Self::try_year_month`]
428    /// for validated construction.
429    #[must_use]
430    pub const fn year_month(year: i32, month: u8) -> Self {
431        Self {
432            year,
433            month: Some(month),
434            day: None,
435            season: None,
436        }
437    }
438
439    /// Create a year-month date with validation.
440    ///
441    /// # Errors
442    ///
443    /// Returns an error if month is not in the range 1-12.
444    pub fn try_year_month(year: i32, month: u8) -> Result<Self, String> {
445        if !(1..=12).contains(&month) {
446            return Err(format!("month must be 1-12, got {month}"));
447        }
448        Ok(Self::year_month(year, month))
449    }
450
451    /// Create a full date.
452    ///
453    /// Note: `month` should be 1-12 and `day` should be 1-31.
454    /// Use [`Self::try_full`] for validated construction.
455    #[must_use]
456    pub const fn full(year: i32, month: u8, day: u8) -> Self {
457        Self {
458            year,
459            month: Some(month),
460            day: Some(day),
461            season: None,
462        }
463    }
464
465    /// Create a full date with validation.
466    ///
467    /// # Errors
468    ///
469    /// Returns an error if month is not 1-12 or day is not 1-31.
470    pub fn try_full(year: i32, month: u8, day: u8) -> Result<Self, String> {
471        if !(1..=12).contains(&month) {
472            return Err(format!("month must be 1-12, got {month}"));
473        }
474        if !(1..=31).contains(&day) {
475            return Err(format!("day must be 1-31, got {day}"));
476        }
477        Ok(Self::full(year, month, day))
478    }
479
480    /// Create a seasonal date.
481    #[must_use]
482    pub fn seasonal(year: i32, season: impl Into<String>) -> Self {
483        Self {
484            year,
485            month: None,
486            day: None,
487            season: Some(season.into()),
488        }
489    }
490}
491
492impl<'de> Deserialize<'de> for PartialDate {
493    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
494    where
495        D: serde::Deserializer<'de>,
496    {
497        #[derive(Deserialize)]
498        #[serde(rename_all = "camelCase")]
499        struct Raw {
500            year: i32,
501            #[serde(default)]
502            month: Option<u8>,
503            #[serde(default)]
504            day: Option<u8>,
505            #[serde(default)]
506            season: Option<String>,
507        }
508        let raw = Raw::deserialize(deserializer)?;
509        if let Some(m) = raw.month {
510            if !(1..=12).contains(&m) {
511                return Err(serde::de::Error::custom(format!(
512                    "month must be 1-12, got {m}"
513                )));
514            }
515        }
516        if let Some(d) = raw.day {
517            if !(1..=31).contains(&d) {
518                return Err(serde::de::Error::custom(format!(
519                    "day must be 1-31, got {d}"
520                )));
521            }
522        }
523        Ok(PartialDate {
524            year: raw.year,
525            month: raw.month,
526            day: raw.day,
527            season: raw.season,
528        })
529    }
530}
531
532impl std::fmt::Display for PartialDate {
533    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
534        if let Some(season) = &self.season {
535            return write!(f, "{} {}", season, self.year);
536        }
537        match (self.month, self.day) {
538            (Some(month), Some(day)) => write!(f, "{}-{:02}-{:02}", self.year, month, day),
539            (Some(month), None) => write!(f, "{}-{:02}", self.year, month),
540            _ => write!(f, "{}", self.year),
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use super::*;
548
549    #[test]
550    fn test_try_year_month_valid() {
551        assert!(PartialDate::try_year_month(2024, 1).is_ok());
552        assert!(PartialDate::try_year_month(2024, 12).is_ok());
553    }
554
555    #[test]
556    fn test_try_year_month_invalid() {
557        assert!(PartialDate::try_year_month(2024, 0).is_err());
558        assert!(PartialDate::try_year_month(2024, 13).is_err());
559    }
560
561    #[test]
562    fn test_try_full_valid() {
563        assert!(PartialDate::try_full(2024, 6, 15).is_ok());
564    }
565
566    #[test]
567    fn test_try_full_invalid() {
568        assert!(PartialDate::try_full(2024, 0, 15).is_err());
569        assert!(PartialDate::try_full(2024, 6, 0).is_err());
570        assert!(PartialDate::try_full(2024, 6, 32).is_err());
571    }
572
573    #[test]
574    fn test_partial_date_deser_rejects_invalid_month() {
575        let json = r#"{"year":2024,"month":13}"#;
576        let result: Result<PartialDate, _> = serde_json::from_str(json);
577        assert!(result.is_err());
578    }
579
580    #[test]
581    fn test_partial_date_deser_rejects_invalid_day() {
582        let json = r#"{"year":2024,"month":6,"day":32}"#;
583        let result: Result<PartialDate, _> = serde_json::from_str(json);
584        assert!(result.is_err());
585    }
586
587    #[test]
588    fn test_partial_date_deser_accepts_valid() {
589        let json = r#"{"year":2024,"month":6,"day":15}"#;
590        let result: PartialDate = serde_json::from_str(json).unwrap();
591        assert_eq!(result.year, 2024);
592        assert_eq!(result.month, Some(6));
593        assert_eq!(result.day, Some(15));
594    }
595}