Skip to main content

csan_core/catalog/models/
opus.rs

1//! Defines the [`Opus`] model.
2use crate::catalog::language::Language;
3use crate::catalog::normalization::{NormalizationError, to_ascii_alphabetic};
4use std::borrow::Cow;
5
6/// Represents a work (book, article, etc.) identified by its title.
7#[derive(Debug, Clone)]
8pub struct Opus {
9    /// The raw (unnormalized) title of the work.
10    title: String,
11    /// The language whose initial article the title is filed without, if any.
12    article_language: Option<Language>,
13}
14
15impl Opus {
16    /// Creates a new `Opus` from a title, filed as written (no article
17    /// language).
18    #[must_use]
19    pub fn new(title: impl Into<String>) -> Opus {
20        Opus {
21            title: title.into(),
22            article_language: None,
23        }
24    }
25
26    /// Sets the language whose initial article the title is filed without
27    /// (see [`Language::strip_initial_article`]); `None` files the title as
28    /// written. The last call wins.
29    ///
30    /// # Examples
31    /// ```
32    /// use csan_core::{Language, Opus};
33    ///
34    /// let opus = Opus::new("A Christmas Carol").with_article_language(Some(Language::English));
35    /// assert_eq!(opus.article_language(), Some(Language::English));
36    /// assert_eq!(opus.filing_title(), "Christmas Carol");
37    /// assert_eq!(opus.title(), "A Christmas Carol");
38    ///
39    /// let opus = opus.with_article_language(None);
40    /// assert_eq!(opus.filing_title(), "A Christmas Carol");
41    /// ```
42    #[must_use]
43    pub fn with_article_language(self, language: Option<Language>) -> Self {
44        Opus {
45            article_language: language,
46            ..self
47        }
48    }
49
50    /// The language whose initial article the title is filed without, if
51    /// any.
52    #[must_use]
53    pub fn article_language(&self) -> Option<Language> {
54        self.article_language
55    }
56
57    /// The title as filed: without its initial article when an article
58    /// language is set and [`Language::strip_initial_article`] finds one, and
59    /// otherwise the title as written. It is always a subslice of
60    /// [`title`](Self::title).
61    ///
62    /// Cutter-Sanborn files on letters, so when the filing title has none
63    /// (_The 100_ under English, filed as `100`) it uses the title as written
64    /// instead; `LoC` files `100` by its numeral rule.
65    ///
66    /// # Examples
67    /// ```
68    /// use csan_core::{Language, Opus};
69    ///
70    /// let english = Some(Language::English);
71    /// assert_eq!(Opus::new("The Bear").with_article_language(english).filing_title(), "Bear");
72    /// assert_eq!(Opus::new("Theory").with_article_language(english).filing_title(), "Theory");
73    /// assert_eq!(Opus::new("The").with_article_language(english).filing_title(), "The");
74    /// assert_eq!(Opus::new("The Bear").filing_title(), "The Bear");
75    /// ```
76    #[must_use]
77    pub fn filing_title(&self) -> &str {
78        self.stripped_title().unwrap_or(&self.title)
79    }
80
81    /// The title without its initial article, or `None` when no article
82    /// language is set or nothing was stripped.
83    fn stripped_title(&self) -> Option<&str> {
84        let language = self.article_language?;
85        language.strip_initial_article(&self.title)
86    }
87
88    /// Runs `compute` on the [filing title](Self::filing_title), under the
89    /// rule that stripping never turns a title that works into an error: when
90    /// an article was stripped and `compute` fails with
91    /// [`NormalizationError::EmptyString`], it runs again on the title as
92    /// written. Any other result is returned as is. Every generator reads a
93    /// title through it.
94    ///
95    /// Only the Cutter-Sanborn computations can take the fallback: they file
96    /// on letters, so _The 100_ under English, filed on `100`, gives them
97    /// nothing and they use _The 100_. `LoC` files `100` by its numeral rule,
98    /// and a filing title starts with a letter or an ASCII digit, so `LoC`
99    /// never fails with `EmptyString` there.
100    ///
101    /// `compute` may run twice, so it should be free of side effects; the
102    /// `Fn` bound documents that, but doesn't enforce it.
103    pub(crate) fn compute_on_filing_title<'a, T>(
104        &'a self,
105        compute: impl Fn(&'a str) -> Result<T, NormalizationError>,
106    ) -> Result<T, NormalizationError> {
107        let Some(filing) = self.stripped_title() else {
108            return compute(&self.title);
109        };
110        let result = compute(filing);
111        if matches!(result, Err(NormalizationError::EmptyString)) {
112            compute(&self.title)
113        } else {
114            result
115        }
116    }
117
118    /// Normalizes the [filing title](Self::filing_title) and truncates it to
119    /// at most `suffix_len` characters, producing the lowercase-able work mark
120    /// material used by
121    /// [Cutter-Sanborn generation](crate::catalog::cutter::CutterSanbornMethod).
122    /// When the filing title has no letters (_The 100_ under English), the
123    /// title as written is used instead.
124    ///
125    /// The result is guaranteed to be ASCII-alphabetic. It may be shorter
126    /// than `suffix_len` (including empty when `suffix_len == 0`).
127    ///
128    /// # Errors
129    /// Returns a [`NormalizationError`] if the title normalizes to an empty
130    /// string or contains untransliterable non-ASCII characters.
131    pub fn as_suffix(&self, suffix_len: usize) -> Result<Cow<'_, str>, NormalizationError> {
132        let cow =
133            self.compute_on_filing_title(|title| to_ascii_alphabetic(Cow::Borrowed(title)))?;
134
135        // The normalized title is pure ASCII, so every byte index is a char
136        // boundary and byte length equals char count.
137        let limit = cow.len().min(suffix_len);
138
139        match cow {
140            Cow::Borrowed(s) => Ok(Cow::Borrowed(s.get(..limit).unwrap_or_default())),
141            Cow::Owned(mut s) => {
142                s.truncate(limit);
143                Ok(Cow::Owned(s))
144            }
145        }
146    }
147
148    /// Returns the raw (unnormalized) title of the work.
149    #[must_use]
150    pub fn title(&self) -> &str {
151        &self.title
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158    use std::cell::RefCell;
159
160    #[test]
161    fn suffix_truncates_and_normalizes() {
162        let opus = Opus::new("  Öliver Twist!  ");
163        assert_eq!(opus.as_suffix(1).unwrap(), "O");
164        assert_eq!(opus.as_suffix(3).unwrap(), "Oli");
165        assert_eq!(opus.as_suffix(100).unwrap(), "OliverTwist");
166    }
167
168    #[test]
169    fn suffix_zero_len_is_empty() {
170        let opus = Opus::new("Dune");
171        assert_eq!(opus.as_suffix(0).unwrap(), "");
172    }
173
174    #[test]
175    fn borrowed_input_stays_borrowed() {
176        let opus = Opus::new("Dune");
177        let suffix = opus.as_suffix(2).unwrap();
178        assert_eq!(suffix, "Du");
179        assert!(matches!(suffix, Cow::Borrowed(_)));
180    }
181
182    #[test]
183    fn filing_title_follows_the_article_language() {
184        let opus = Opus::new("The Bear");
185        assert_eq!(opus.article_language(), None);
186        assert_eq!(opus.filing_title(), "The Bear");
187
188        let opus = opus.with_article_language(Some(Language::English));
189        assert_eq!(opus.article_language(), Some(Language::English));
190        assert_eq!(opus.filing_title(), "Bear");
191        assert_eq!(opus.title(), "The Bear");
192
193        // Another language's article isn't one; Latin has none at all.
194        let french = opus.clone().with_article_language(Some(Language::French));
195        assert_eq!(french.filing_title(), "The Bear");
196        let latin = opus.clone().with_article_language(Some(Language::Latin));
197        assert_eq!(latin.filing_title(), "The Bear");
198
199        // The last call wins, and None turns stripping off.
200        let off = opus.with_article_language(None);
201        assert_eq!(off.article_language(), None);
202        assert_eq!(off.filing_title(), "The Bear");
203    }
204
205    #[test]
206    fn filing_title_is_a_subslice_of_the_title() {
207        for (title, want) in [("The Bear", "Bear"), ("  The \"Bear\"  ", "Bear\"  ")] {
208            let opus = Opus::new(title).with_article_language(Some(Language::English));
209            assert_eq!(opus.filing_title(), want);
210            let range = opus.title().as_bytes().as_ptr_range();
211            let filing = opus.filing_title().as_bytes().as_ptr_range();
212            assert!(
213                range.start <= filing.start && filing.end <= range.end,
214                "{title:?}"
215            );
216        }
217    }
218
219    /// Asserts that `compute_on_filing_title`, with a normalizing `compute`,
220    /// returns `want` (an error as its message) after calling `compute` with
221    /// `calls`.
222    #[track_caller]
223    fn assert_filed(
224        title: &str,
225        language: Option<Language>,
226        want: Result<&str, &str>,
227        calls: &[&str],
228    ) {
229        let opus = Opus::new(title).with_article_language(language);
230        let seen = RefCell::new(Vec::new());
231        let result = opus
232            .compute_on_filing_title(|filing| {
233                seen.borrow_mut().push(filing.to_owned());
234                to_ascii_alphabetic(Cow::Borrowed(filing)).map(Cow::into_owned)
235            })
236            .map_err(|error| error.to_string());
237        assert_eq!(result.as_deref().map_err(String::as_str), want, "{title}");
238        assert_eq!(seen.into_inner(), calls, "{title}");
239    }
240
241    #[test]
242    fn compute_on_filing_title_falls_back_only_when_stripping_empties_it() {
243        let english = Some(Language::English);
244        let empty = NormalizationError::EmptyString.to_string();
245        let non_ascii = NormalizationError::NonAsciiAlpha("Война".to_owned()).to_string();
246        assert_filed("The Bear", english, Ok("Bear"), &["Bear"]);
247        // `100` has no letters, so the title as written is used.
248        assert_filed("The 100", english, Ok("The"), &["100", "The 100"]);
249        // Any other error propagates, without a second try.
250        assert_filed("The Война", english, Err(&non_ascii), &["Война"]);
251        // Nothing stripped: one try, on the title as written.
252        assert_filed("The 100", None, Ok("The"), &["The 100"]);
253        assert_filed("100", english, Err(&empty), &["100"]);
254    }
255
256    #[test]
257    fn suffix_disregards_the_initial_article() {
258        let english = Some(Language::English);
259        let opus = Opus::new("A Christmas Carol").with_article_language(english);
260        assert_eq!(opus.as_suffix(3).unwrap(), "Chr");
261        // A clean filing title is still borrowed, not copied.
262        let bear = Opus::new("The Bear").with_article_language(english);
263        assert!(matches!(bear.as_suffix(2).unwrap(), Cow::Borrowed("Be")));
264        assert_eq!(Opus::new("A Christmas Carol").as_suffix(3).unwrap(), "ACh");
265        // The fallback: `The 100` gives today's mark.
266        let numeral = Opus::new("The 100").with_article_language(english);
267        assert_eq!(numeral.as_suffix(3).unwrap(), "The");
268    }
269
270    #[test]
271    fn empty_title_errors() {
272        let opus = Opus::new("  !!! ");
273        assert!(matches!(
274            opus.as_suffix(3),
275            Err(NormalizationError::EmptyString)
276        ));
277    }
278}