Skip to main content

csan_core/catalog/models/
entry.rs

1//! Defines the [`Attribution`] model.
2
3use crate::catalog::language::Language;
4use crate::catalog::models::{author::Author, opus::Opus};
5
6/// The bibliographic information a Cutter number is generated from.
7///
8/// Cutter numbers are normally derived from the author's name, but works
9/// without a (usable) author are cuttered by title instead, and works with
10/// both can carry a title-derived work mark. `Attribution` captures which of
11/// these inputs are available for an entry.
12///
13/// Prefer [`Attribution::from_parts`] to construct one from optional inputs.
14/// The type is `#[non_exhaustive]` so the crate can add input shapes in a
15/// future release without a breaking change; downstream `match`es on it
16/// therefore need a wildcard arm.
17#[non_exhaustive]
18pub enum Attribution {
19    /// Both an author and a work are known.
20    Both {
21        /// The author of the work.
22        author: Author,
23        /// The work itself, used for the work mark.
24        opus: Opus,
25    },
26    /// Only the author is known.
27    AuthorOnly(Author),
28    /// Only the work is known; the Cutter number is derived from its title.
29    OpusOnly(Opus),
30}
31
32impl Attribution {
33    /// Builds an `Attribution` from optional name and title parts, treating
34    /// blank (empty or whitespace-only) strings as absent.
35    ///
36    /// This is the shared front door for user-facing inputs (CLI arguments,
37    /// plugin argument maps, FFI bindings): each part is trimmed, blank
38    /// parts are dropped, and `None` is returned when nothing usable
39    /// remains.
40    ///
41    /// # Examples
42    /// ```
43    /// # use csan_core::catalog::models::entry::Attribution;
44    /// assert!(matches!(
45    ///     Attribution::from_parts(Some("Charles"), Some("Dickens"), None),
46    ///     Some(Attribution::AuthorOnly(_))
47    /// ));
48    /// assert!(matches!(
49    ///     Attribution::from_parts(None, Some("  "), Some("Dune")),
50    ///     Some(Attribution::OpusOnly(_))
51    /// ));
52    /// assert!(Attribution::from_parts(None, Some("  "), None).is_none());
53    /// ```
54    #[must_use]
55    pub fn from_parts(
56        forename: Option<&str>,
57        surname: Option<&str>,
58        title: Option<&str>,
59    ) -> Option<Self> {
60        let given = forename.map(str::trim).filter(|n| !n.is_empty());
61        let family = surname.map(str::trim).filter(|n| !n.is_empty());
62        let author = match (given, family) {
63            (Some(g), Some(f)) => Some(Author::new(g, f)),
64            (None, Some(f)) => Some(Author::surname_only(f)),
65            (Some(g), None) => Some(Author::forename_only(g)),
66            (None, None) => None,
67        };
68        let opus = title
69            .map(str::trim)
70            .filter(|t| !t.is_empty())
71            .map(Opus::new);
72
73        match (author, opus) {
74            (Some(author), Some(opus)) => Some(Attribution::Both { author, opus }),
75            (Some(author), None) => Some(Attribution::AuthorOnly(author)),
76            (None, Some(opus)) => Some(Attribution::OpusOnly(opus)),
77            (None, None) => None,
78        }
79    }
80
81    /// Sets the language whose initial article the entry's title is filed
82    /// without, as [`Opus::with_article_language`] does; `None` files the
83    /// title as written. It applies to the title of `Both` and `OpusOnly`, and
84    /// does nothing for `AuthorOnly`. Author names are never stripped: an
85    /// article in a name is part of the heading (LC G 100 §10).
86    ///
87    /// Frontends call it after [`from_parts`](Self::from_parts).
88    ///
89    /// # Examples
90    /// ```
91    /// use csan_core::{Attribution, Language};
92    ///
93    /// let entry = Attribution::from_parts(None, Some("Dickens"), Some("A Christmas Carol"))
94    ///     .map(|entry| entry.with_article_language(Some(Language::English)));
95    /// assert!(matches!(
96    ///     entry,
97    ///     Some(Attribution::Both { ref opus, .. }) if opus.filing_title() == "Christmas Carol"
98    /// ));
99    /// ```
100    #[must_use]
101    pub fn with_article_language(self, language: Option<Language>) -> Self {
102        match self {
103            Attribution::Both { author, opus } => Attribution::Both {
104                author,
105                opus: opus.with_article_language(language),
106            },
107            Attribution::OpusOnly(opus) => {
108                Attribution::OpusOnly(opus.with_article_language(language))
109            }
110            Attribution::AuthorOnly(author) => Attribution::AuthorOnly(author),
111        }
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn from_parts_selects_the_right_variant() {
121        assert!(matches!(
122            Attribution::from_parts(Some("Charles"), Some("Dickens"), Some("Oliver Twist")),
123            Some(Attribution::Both { .. })
124        ));
125        assert!(matches!(
126            Attribution::from_parts(None, Some("Dickens"), None),
127            Some(Attribution::AuthorOnly(_))
128        ));
129        assert!(matches!(
130            Attribution::from_parts(None, None, Some("Bear")),
131            Some(Attribution::OpusOnly(_))
132        ));
133        assert!(Attribution::from_parts(None, None, None).is_none());
134    }
135
136    #[test]
137    fn blank_parts_are_absent() {
138        // A blank field must not poison a usable one.
139        let Some(Attribution::AuthorOnly(author)) =
140            Attribution::from_parts(Some("Prince"), Some("   "), None)
141        else {
142            panic!("expected an author-only attribution");
143        };
144        assert_eq!(author.forename(), Some("Prince"));
145        assert_eq!(author.surname(), None);
146
147        assert!(Attribution::from_parts(Some(""), Some("  "), Some("\t")).is_none());
148    }
149
150    #[test]
151    fn parts_are_trimmed() {
152        let Some(Attribution::Both { author, opus }) =
153            Attribution::from_parts(Some("  Charles "), Some(" Dickens"), Some(" Oliver "))
154        else {
155            panic!("expected a both attribution");
156        };
157        assert_eq!(author.forename(), Some("Charles"));
158        assert_eq!(author.surname(), Some("Dickens"));
159        assert_eq!(opus.title(), "Oliver");
160    }
161
162    /// The article language of the entry's title, or `None` for `AuthorOnly`.
163    fn article_language(entry: &Attribution) -> Option<Language> {
164        match entry {
165            Attribution::Both { opus, .. } | Attribution::OpusOnly(opus) => opus.article_language(),
166            Attribution::AuthorOnly(_) => None,
167        }
168    }
169
170    #[test]
171    fn article_language_reaches_the_title() {
172        let english = Some(Language::English);
173        for entry in [
174            Attribution::from_parts(None, Some("Dickens"), Some("A Christmas Carol")),
175            Attribution::from_parts(None, None, Some("A Christmas Carol")),
176        ] {
177            let entry = entry.map(|entry| entry.with_article_language(english));
178            let Some(Attribution::Both { opus, .. } | Attribution::OpusOnly(opus)) = entry else {
179                panic!("expected a title");
180            };
181            assert_eq!(opus.article_language(), english);
182            assert_eq!(opus.filing_title(), "Christmas Carol");
183        }
184    }
185
186    #[test]
187    fn last_article_language_wins() {
188        for entry in [
189            Attribution::Both {
190                author: Author::surname_only("Faulkner"),
191                opus: Opus::new("The Bear"),
192            },
193            Attribution::OpusOnly(Opus::new("The Bear")),
194        ] {
195            let entry = entry
196                .with_article_language(Some(Language::French))
197                .with_article_language(Some(Language::English));
198            assert_eq!(article_language(&entry), Some(Language::English));
199            let entry = entry.with_article_language(None);
200            assert_eq!(article_language(&entry), None);
201        }
202    }
203
204    #[test]
205    fn author_names_are_never_stripped() {
206        // G 100 §10: an article in a name is part of the heading.
207        let entry =
208            Attribution::from_parts(Some("Jean de"), Some("La Fontaine"), Some("Les Fables"))
209                .map(|entry| entry.with_article_language(Some(Language::French)));
210        let Some(Attribution::Both { author, opus }) = entry else {
211            panic!("expected a both attribution");
212        };
213        assert_eq!(author.forename(), Some("Jean de"));
214        assert_eq!(author.surname(), Some("La Fontaine"));
215        assert_eq!(opus.filing_title(), "Fables");
216
217        let author_only = Attribution::AuthorOnly(Author::surname_only("La Fontaine"))
218            .with_article_language(Some(Language::French));
219        let Attribution::AuthorOnly(kept) = author_only else {
220            panic!("expected an author-only attribution");
221        };
222        assert_eq!(kept.surname(), Some("La Fontaine"));
223    }
224}