csan_core/catalog/models/
opus.rs1use crate::catalog::language::Language;
3use crate::catalog::normalization::{NormalizationError, to_ascii_alphabetic};
4use std::borrow::Cow;
5
6#[derive(Debug, Clone)]
8pub struct Opus {
9 title: String,
11 article_language: Option<Language>,
13}
14
15impl Opus {
16 #[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 #[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 #[must_use]
53 pub fn article_language(&self) -> Option<Language> {
54 self.article_language
55 }
56
57 #[must_use]
77 pub fn filing_title(&self) -> &str {
78 self.stripped_title().unwrap_or(&self.title)
79 }
80
81 fn stripped_title(&self) -> Option<&str> {
84 let language = self.article_language?;
85 language.strip_initial_article(&self.title)
86 }
87
88 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 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 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 #[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 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 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 #[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 assert_filed("The 100", english, Ok("The"), &["100", "The 100"]);
249 assert_filed("The Война", english, Err(&non_ascii), &["Война"]);
251 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 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 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}