Skip to main content

mos_csl/
item.rs

1//! The CSL item data model; the typed input data a CSL style formats.
2//!
3//! This mirrors the "CSL-JSON" shape from the CSL 1.0.2 specification
4//! (Appendix III item types, Appendix IV variables) as typed Rust. An [`Item`]
5//! is an `id` plus an [`ItemType`] and four category-keyed maps of variables
6//! (string, number, date, name). Everything is stored in
7//! [`BTreeMap`]s keyed by ordered enums, so
8//! iteration is deterministic.
9
10use std::collections::BTreeMap;
11use std::fmt;
12
13/// Generate a closed CSL vocabulary enum with `as_str` / `from_csl` / Display.
14///
15/// Each variant maps to its exact CSL string form (e.g. `"article-journal"`,
16/// `"DOI"`). `from_csl` is the inverse and returns `None` for unknown strings.
17macro_rules! csl_vocab {
18    (
19        $(#[$meta:meta])*
20        $vis:vis enum $name:ident { $($(#[$variant_meta:meta])* $variant:ident => $text:literal),+ $(,)? }
21    ) => {
22        $(#[$meta])*
23        #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
24        $vis enum $name {
25            $($(#[$variant_meta])* $variant),+
26        }
27
28        impl $name {
29            /// The CSL string form of this value.
30            #[must_use]
31            pub const fn as_str(self) -> &'static str {
32                match self {
33                    $(Self::$variant => $text,)+
34                }
35            }
36
37            /// Parse a CSL string form; returns `None` if unrecognised.
38            #[must_use]
39            pub fn from_csl(text: &str) -> Option<Self> {
40                match text {
41                    $($text => Some(Self::$variant),)+
42                    _ => None,
43                }
44            }
45        }
46
47        impl fmt::Display for $name {
48            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49                f.write_str(self.as_str())
50            }
51        }
52    };
53}
54
55csl_vocab! {
56    /// CSL item types (specification Appendix III).
57    ///
58    /// [`ItemType::Document`] is the catch-all used when a source type has no
59    /// closer match.
60    ///
61    /// # Examples
62    ///
63    /// ```
64    /// use mos_csl::ItemType;
65    ///
66    /// assert_eq!(ItemType::ArticleJournal.as_str(), "article-journal");
67    /// assert_eq!(ItemType::from_csl("legal_case"), Some(ItemType::LegalCase));
68    /// assert_eq!(ItemType::from_csl("not-a-type"), None);
69    /// ```
70    pub enum ItemType {
71        Article => "article",
72        ArticleJournal => "article-journal",
73        ArticleMagazine => "article-magazine",
74        ArticleNewspaper => "article-newspaper",
75        Bill => "bill",
76        Book => "book",
77        Broadcast => "broadcast",
78        Chapter => "chapter",
79        Classic => "classic",
80        Collection => "collection",
81        Dataset => "dataset",
82        Document => "document",
83        Entry => "entry",
84        EntryDictionary => "entry-dictionary",
85        EntryEncyclopedia => "entry-encyclopedia",
86        Event => "event",
87        Figure => "figure",
88        Graphic => "graphic",
89        Hearing => "hearing",
90        Interview => "interview",
91        LegalCase => "legal_case",
92        Legislation => "legislation",
93        Manuscript => "manuscript",
94        Map => "map",
95        MotionPicture => "motion_picture",
96        MusicalScore => "musical_score",
97        Pamphlet => "pamphlet",
98        PaperConference => "paper-conference",
99        Patent => "patent",
100        Performance => "performance",
101        Periodical => "periodical",
102        PersonalCommunication => "personal_communication",
103        Post => "post",
104        PostWeblog => "post-weblog",
105        Regulation => "regulation",
106        Report => "report",
107        Review => "review",
108        ReviewBook => "review-book",
109        Software => "software",
110        Song => "song",
111        Speech => "speech",
112        Standard => "standard",
113        Thesis => "thesis",
114        Treaty => "treaty",
115        Webpage => "webpage",
116    }
117}
118
119csl_vocab! {
120    /// CSL string ("standard") variables (specification Appendix IV).
121    pub enum StandardVariable {
122        Abstract => "abstract",
123        Annote => "annote",
124        Archive => "archive",
125        ArchiveCollection => "archive_collection",
126        ArchiveLocation => "archive_location",
127        ArchivePlace => "archive-place",
128        Authority => "authority",
129        CallNumber => "call-number",
130        CitationKey => "citation-key",
131        CitationLabel => "citation-label",
132        CollectionTitle => "collection-title",
133        ContainerTitle => "container-title",
134        ContainerTitleShort => "container-title-short",
135        Dimensions => "dimensions",
136        Division => "division",
137        Doi => "DOI",
138        /// Deprecated CSL standard variable retained for spec coverage.
139        Event => "event",
140        EventTitle => "event-title",
141        EventPlace => "event-place",
142        Genre => "genre",
143        Isbn => "ISBN",
144        Issn => "ISSN",
145        Jurisdiction => "jurisdiction",
146        Keyword => "keyword",
147        Language => "language",
148        License => "license",
149        Medium => "medium",
150        Note => "note",
151        OriginalPublisher => "original-publisher",
152        OriginalPublisherPlace => "original-publisher-place",
153        OriginalTitle => "original-title",
154        PartTitle => "part-title",
155        Pmcid => "PMCID",
156        Pmid => "PMID",
157        Publisher => "publisher",
158        PublisherPlace => "publisher-place",
159        References => "references",
160        ReviewedGenre => "reviewed-genre",
161        ReviewedTitle => "reviewed-title",
162        Scale => "scale",
163        Source => "source",
164        Status => "status",
165        Title => "title",
166        TitleShort => "title-short",
167        Url => "URL",
168        VolumeTitle => "volume-title",
169        YearSuffix => "year-suffix",
170    }
171}
172
173csl_vocab! {
174    /// CSL number variables (specification Appendix IV).
175    ///
176    /// Stored as strings because CSL numbers may carry affixes (`2E`) and
177    /// ranges (`5-7`); extraction is the processor's concern, not this model's.
178    pub enum NumberVariable {
179        ChapterNumber => "chapter-number",
180        CitationNumber => "citation-number",
181        CollectionNumber => "collection-number",
182        Edition => "edition",
183        FirstReferenceNoteNumber => "first-reference-note-number",
184        Issue => "issue",
185        Locator => "locator",
186        Number => "number",
187        NumberOfPages => "number-of-pages",
188        NumberOfVolumes => "number-of-volumes",
189        Page => "page",
190        PageFirst => "page-first",
191        PartNumber => "part-number",
192        PrintingNumber => "printing-number",
193        Section => "section",
194        SupplementNumber => "supplement-number",
195        Version => "version",
196        Volume => "volume",
197    }
198}
199
200csl_vocab! {
201    /// CSL date variables (specification Appendix IV).
202    pub enum DateVariable {
203        Accessed => "accessed",
204        AvailableDate => "available-date",
205        EventDate => "event-date",
206        Issued => "issued",
207        OriginalDate => "original-date",
208        Submitted => "submitted",
209    }
210}
211
212csl_vocab! {
213    /// CSL name variables (specification Appendix IV).
214    pub enum NameVariable {
215        Author => "author",
216        Chair => "chair",
217        CollectionEditor => "collection-editor",
218        Compiler => "compiler",
219        Composer => "composer",
220        ContainerAuthor => "container-author",
221        Contributor => "contributor",
222        Curator => "curator",
223        Director => "director",
224        Editor => "editor",
225        EditorialDirector => "editorial-director",
226        EditorTranslator => "editor-translator",
227        ExecutiveProducer => "executive-producer",
228        Guest => "guest",
229        Host => "host",
230        Illustrator => "illustrator",
231        Interviewer => "interviewer",
232        Narrator => "narrator",
233        Organizer => "organizer",
234        OriginalAuthor => "original-author",
235        Performer => "performer",
236        Producer => "producer",
237        Recipient => "recipient",
238        ReviewedAuthor => "reviewed-author",
239        ScriptWriter => "script-writer",
240        SeriesCreator => "series-creator",
241        Translator => "translator",
242    }
243}
244
245/// A personal or institutional name (specification "Name" name-parts).
246///
247/// Personal names use the part fields; an institution, or any name kept whole,
248/// goes in [`literal`](Self::literal).
249#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
250pub struct Name {
251    pub family: Option<String>,
252    pub given: Option<String>,
253    pub suffix: Option<String>,
254    pub dropping_particle: Option<String>,
255    pub non_dropping_particle: Option<String>,
256    pub literal: Option<String>,
257}
258
259impl Name {
260    /// A whole-name literal (an institution, or a name not split into parts).
261    #[must_use]
262    pub fn literal(text: impl Into<String>) -> Self {
263        Self {
264            literal: Some(text.into()),
265            ..Self::default()
266        }
267    }
268
269    /// A personal name split into `family` and `given` parts.
270    #[must_use]
271    pub fn person(family: impl Into<String>, given: impl Into<String>) -> Self {
272        Self {
273            family: Some(family.into()),
274            given: Some(given.into()),
275            ..Self::default()
276        }
277    }
278}
279
280/// One date in a CSL [`Date`]; any subset of the parts may be present.
281#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
282pub struct DateParts {
283    pub year: Option<i32>,
284    pub month: Option<u8>,
285    pub day: Option<u8>,
286    pub season: Option<u8>,
287}
288
289/// A CSL date-variable value: a single date, a range (`start`..`end`), or a
290/// free-form [`literal`](Self::literal). `circa` marks an approximate date.
291#[derive(Clone, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
292pub struct Date {
293    pub start: DateParts,
294    pub end: Option<DateParts>,
295    pub circa: bool,
296    pub literal: Option<String>,
297}
298
299impl Date {
300    /// A date carrying only a year.
301    #[must_use]
302    pub fn year(year: i32) -> Self {
303        Self {
304            start: DateParts {
305                year: Some(year),
306                ..DateParts::default()
307            },
308            ..Self::default()
309        }
310    }
311
312    /// A free-form literal date (when a source can't be split into parts).
313    #[must_use]
314    pub fn literal(text: impl Into<String>) -> Self {
315        Self {
316            literal: Some(text.into()),
317            ..Self::default()
318        }
319    }
320}
321
322/// A single bibliographic item; the unit a CSL style formats.
323///
324/// Variables are split by category into deterministic [`BTreeMap`]s. Build one
325/// from a parsed BibTeX record with
326/// [`item_from_bib_entry`](crate::item_from_bib_entry).
327#[derive(Clone, Debug, PartialEq, Eq)]
328pub struct Item {
329    pub id: String,
330    pub item_type: ItemType,
331    pub standard: BTreeMap<StandardVariable, String>,
332    pub number: BTreeMap<NumberVariable, String>,
333    pub date: BTreeMap<DateVariable, Date>,
334    pub name: BTreeMap<NameVariable, Vec<Name>>,
335}
336
337impl Default for Item {
338    fn default() -> Self {
339        // `ItemType` deliberately has no `Default`; `Document` is the CSL
340        // catch-all, so an item's default type is `Document`.
341        Self {
342            id: String::new(),
343            item_type: ItemType::Document,
344            standard: BTreeMap::new(),
345            number: BTreeMap::new(),
346            date: BTreeMap::new(),
347            name: BTreeMap::new(),
348        }
349    }
350}
351
352impl Item {
353    /// An empty item of `item_type` identified by `id`.
354    ///
355    /// # Examples
356    ///
357    /// ```
358    /// use mos_csl::{Item, ItemType};
359    ///
360    /// let item = Item::new("knuth1984", ItemType::ArticleJournal);
361    /// assert_eq!(item.id, "knuth1984");
362    /// assert!(item.standard.is_empty());
363    /// ```
364    #[must_use]
365    pub fn new(id: impl Into<String>, item_type: ItemType) -> Self {
366        Self {
367            id: id.into(),
368            item_type,
369            ..Self::default()
370        }
371    }
372}
373
374#[cfg(test)]
375mod tests {
376    use super::*;
377
378    #[test]
379    fn vocab_helpers_round_trip_known_values_and_reject_unknowns() {
380        assert_eq!(ItemType::ArticleJournal.as_str(), "article-journal");
381        assert_eq!(ItemType::from_csl("book"), Some(ItemType::Book));
382        assert_eq!(ItemType::from_csl("unknown"), None);
383        assert_eq!(ItemType::Webpage.to_string(), "webpage");
384
385        assert_eq!(StandardVariable::Doi.as_str(), "DOI");
386        assert_eq!(StandardVariable::Event.as_str(), "event");
387        assert_eq!(
388            StandardVariable::from_csl("container-title"),
389            Some(StandardVariable::ContainerTitle)
390        );
391        assert_eq!(
392            StandardVariable::from_csl("event"),
393            Some(StandardVariable::Event)
394        );
395        assert_eq!(StandardVariable::from_csl("doi"), None);
396        assert_eq!(StandardVariable::Url.to_string(), "URL");
397
398        assert_eq!(NumberVariable::from_csl("page"), Some(NumberVariable::Page));
399        assert_eq!(NumberVariable::from_csl("pages"), None);
400        assert_eq!(NumberVariable::Volume.to_string(), "volume");
401
402        assert_eq!(DateVariable::from_csl("issued"), Some(DateVariable::Issued));
403        assert_eq!(DateVariable::from_csl("published"), None);
404        assert_eq!(DateVariable::Accessed.to_string(), "accessed");
405
406        assert_eq!(NameVariable::from_csl("author"), Some(NameVariable::Author));
407        assert_eq!(NameVariable::from_csl("authors"), None);
408        assert_eq!(NameVariable::Translator.to_string(), "translator");
409    }
410
411    #[test]
412    fn constructors_create_precise_item_values() {
413        let literal = Name::literal("Mosaic Team");
414        assert_eq!(literal.literal.as_deref(), Some("Mosaic Team"));
415        assert_eq!(literal.family, None);
416
417        let person = Name::person("Lovelace", "Ada");
418        assert_eq!(person.family.as_deref(), Some("Lovelace"));
419        assert_eq!(person.given.as_deref(), Some("Ada"));
420
421        let year = Date::year(1843);
422        assert_eq!(year.start.year, Some(1843));
423        assert_eq!(year.literal, None);
424
425        let literal_date = Date::literal("forthcoming");
426        assert_eq!(literal_date.literal.as_deref(), Some("forthcoming"));
427        assert_eq!(literal_date.start.year, None);
428
429        let default_item = Item::default();
430        assert_eq!(default_item.item_type, ItemType::Document);
431        assert!(default_item.standard.is_empty());
432
433        let article = Item::new("ada1843", ItemType::ArticleJournal);
434        assert_eq!(article.id, "ada1843");
435        assert_eq!(article.item_type, ItemType::ArticleJournal);
436    }
437}