Skip to main content

citum_engine/values/
text_case.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Title text-case transforms.
7//!
8//! Implements structured-title-aware casing for bibliography output.
9//! All transforms operate on Djot-markup-bearing strings and respect
10//! `.nocase` span protection via the rich-text renderer.
11
12use citum_schema::NoteStartTextCase;
13use citum_schema::options::titles::TextCase;
14
15/// Apply a text-case transform to a single plain-text segment.
16///
17/// This function handles the core casing logic for a single string.
18/// For structured titles with subtitles, use [`apply_to_structured_parts`].
19///
20/// `.nocase`-protected spans are handled at the Djot rendering layer,
21/// not here — this function operates on already-resolved text segments.
22#[must_use]
23pub fn apply_text_case(text: &str, case: TextCase) -> String {
24    match case {
25        TextCase::AsIs => text.to_string(),
26        TextCase::Lowercase => text.to_lowercase(),
27        TextCase::Uppercase => text.to_uppercase(),
28        TextCase::CapitalizeFirst => capitalize_first_word(text),
29        TextCase::Sentence | TextCase::SentenceApa | TextCase::SentenceNlm => {
30            to_sentence_case(text)
31        }
32        TextCase::Title => to_title_case(text),
33    }
34}
35
36/// Apply text-case to a structured title (main + subtitles).
37///
38/// The key difference between sentence-case variants:
39/// - `SentenceApa`: capitalize first word of main title AND each subtitle
40/// - `SentenceNlm`: capitalize first word of main title only
41/// - Other variants: applied uniformly to each part
42#[must_use]
43pub fn apply_to_structured_parts(
44    main: &str,
45    subtitles: &[&str],
46    case: TextCase,
47) -> (String, Vec<String>) {
48    match case {
49        TextCase::SentenceApa => {
50            let main_cased = to_sentence_case(main);
51            let subs_cased = subtitles.iter().map(|s| to_sentence_case(s)).collect();
52            (main_cased, subs_cased)
53        }
54        TextCase::SentenceNlm => {
55            let main_cased = to_sentence_case(main);
56            // NLM: subtitles keep only explicit/protected capitals (lowercase the rest)
57            let subs_cased = subtitles.iter().map(|s| s.to_lowercase()).collect();
58            (main_cased, subs_cased)
59        }
60        _ => {
61            let main_cased = apply_text_case(main, case);
62            let subs_cased = subtitles.iter().map(|s| apply_text_case(s, case)).collect();
63            (main_cased, subs_cased)
64        }
65    }
66}
67
68/// Returns true if the given language tag indicates English.
69#[must_use]
70pub fn is_english_language(lang: Option<&str>) -> bool {
71    match lang {
72        Some(tag) => {
73            let primary = tag.split('-').next().unwrap_or(tag);
74            primary.eq_ignore_ascii_case("en")
75        }
76        // Default: assume English for backward compatibility
77        None => true,
78    }
79}
80
81/// Resolve the effective text-case, applying language fallback.
82///
83/// For non-English languages without defined transforms, returns `AsIs`.
84#[must_use]
85pub fn resolve_text_case(case: TextCase, language: Option<&str>) -> TextCase {
86    if is_english_language(language) {
87        case
88    } else {
89        // Non-English: only explicit as-is, lowercase, uppercase pass through.
90        // All English-specific transforms fall back to as-is.
91        match case {
92            TextCase::AsIs | TextCase::Lowercase | TextCase::Uppercase => case,
93            _ => TextCase::AsIs,
94        }
95    }
96}
97
98/// Apply a note-start text-case transform using the same language fallback rules
99/// as other locale-backed casing behavior.
100#[must_use]
101pub(crate) fn apply_note_start_text_case(
102    value: &str,
103    text_case: NoteStartTextCase,
104    language: Option<&str>,
105) -> String {
106    let case = match text_case {
107        NoteStartTextCase::CapitalizeFirst => TextCase::CapitalizeFirst,
108        NoteStartTextCase::Lowercase => TextCase::Lowercase,
109    };
110    apply_text_case(value, resolve_text_case(case, language))
111}
112
113/// Returns true if any character in `word` other than the first is uppercase.
114///
115/// Per CSL 1.0 / citeproc-js, a word carrying internal capitalization (an
116/// all-caps acronym like "DNA", or a mixed-case brand/name like "McDonald" or
117/// "iPhone") is presumed deliberately cased and is left untouched by the
118/// sentence- and title-case transforms; only words whose casing is limited to
119/// (at most) a leading capital are transformed. Uses `char::is_uppercase` so
120/// non-ASCII scripts with case are handled correctly.
121fn has_internal_uppercase(word: &str) -> bool {
122    let mut chars = word.chars();
123    chars.next();
124    chars.any(char::is_uppercase)
125}
126
127/// Rebuild `text` with each whitespace-delimited word replaced by the
128/// corresponding entry in `parts`, preserving the original whitespace runs
129/// (leading/trailing/internal spacing) exactly as written.
130fn rebuild_with_original_whitespace(text: &str, parts: &[String]) -> String {
131    let mut result = String::with_capacity(text.len());
132    let mut word_iter = parts.iter();
133    let mut in_word = false;
134    let mut current_word = word_iter.next();
135
136    for ch in text.chars() {
137        if ch.is_whitespace() {
138            if in_word {
139                in_word = false;
140                current_word = word_iter.next();
141            }
142            result.push(ch);
143        } else if !in_word && let Some(word) = current_word {
144            result.push_str(word);
145            in_word = true;
146        }
147    }
148
149    result
150}
151
152/// Convert text to sentence case: lowercase every word and capitalize the
153/// first, except words carrying internal capitalization (acronyms, mixed-case
154/// names), which are preserved exactly as written — including the first word,
155/// which is left unmodified rather than force-capitalized.
156fn to_sentence_case(text: &str) -> String {
157    if text.is_empty() {
158        return String::new();
159    }
160
161    let words: Vec<&str> = text.split_whitespace().collect();
162    if words.is_empty() {
163        return text.to_string();
164    }
165
166    let mut parts: Vec<String> = Vec::with_capacity(words.len());
167    for (i, word) in words.iter().enumerate() {
168        if has_internal_uppercase(word) {
169            parts.push((*word).to_string());
170        } else if i == 0 {
171            parts.push(capitalize_first_word(&word.to_lowercase()));
172        } else {
173            parts.push(word.to_lowercase());
174        }
175    }
176
177    rebuild_with_original_whitespace(text, &parts)
178}
179
180/// Capitalize the first alphabetic character of the string,
181/// preserving leading whitespace and punctuation.
182///
183/// A leading digit blocks capitalization entirely rather than being skipped
184/// over: a string like `"35 mm film"` has no leading word to capitalize (the
185/// first token is a numeral, not a word), so it is left as-is instead of
186/// capitalizing the first letter found later in the string (which would
187/// wrongly produce `"35 Mm film"`).
188pub(crate) fn capitalize_first_word(text: &str) -> String {
189    let mut result = String::with_capacity(text.len());
190    let mut found_first = false;
191    let mut blocked_by_digit = false;
192    for ch in text.chars() {
193        if !found_first && !blocked_by_digit && ch.is_alphabetic() {
194            for upper in ch.to_uppercase() {
195                result.push(upper);
196            }
197            found_first = true;
198        } else {
199            if !found_first && ch.is_ascii_digit() {
200                blocked_by_digit = true;
201            }
202            result.push(ch);
203        }
204    }
205    result
206}
207
208/// Capitalize the first alphabetic character of the string, skipping over
209/// HTML tags, LaTeX command prefixes, and Typst command prefixes.
210///
211/// Use this variant when the input may already contain rendered markup from a
212/// pre-formatted component. For plain-text input, behaviour is identical to
213/// [`capitalize_first_word`].
214pub(crate) fn capitalize_first_word_markup_aware(text: &str) -> String {
215    let bytes = text.as_bytes();
216    let len = bytes.len();
217    let mut i = 0;
218
219    while i < len {
220        let Some(&b) = bytes.get(i) else { break };
221
222        // Skip HTML tag: <...>
223        // `i` always points to an ASCII byte here, so the slice is on a char boundary.
224        if b == b'<'
225            && let Some(end) = text.get(i..).and_then(|s| s.find('>'))
226        {
227            i += end + 1;
228            continue;
229        }
230
231        // Skip LaTeX command prefix: \letters{ or \letters[...]{
232        if b == b'\\' {
233            let cmd_start = i + 1;
234            let cmd_len = bytes
235                .get(cmd_start..)
236                .unwrap_or_default()
237                .iter()
238                .take_while(|&&c| c.is_ascii_alphabetic())
239                .count();
240            if cmd_len > 0 {
241                let after_cmd = cmd_start + cmd_len;
242                // Skip optional [...]
243                let after_opt = if bytes.get(after_cmd) == Some(&b'[') {
244                    text.get(after_cmd..)
245                        .and_then(|s| s.find(']'))
246                        .map(|e| after_cmd + e + 1)
247                        .unwrap_or(after_cmd)
248                } else {
249                    after_cmd
250                };
251                if bytes.get(after_opt) == Some(&b'{') {
252                    i = after_opt + 1;
253                    continue;
254                }
255            }
256        }
257
258        // Skip Typst command prefix: #letters[
259        if b == b'#' {
260            let cmd_start = i + 1;
261            let cmd_len = bytes
262                .get(cmd_start..)
263                .unwrap_or_default()
264                .iter()
265                .take_while(|&&c| c.is_ascii_alphabetic())
266                .count();
267            if cmd_len > 0 {
268                let after_cmd = cmd_start + cmd_len;
269                if bytes.get(after_cmd) == Some(&b'[') {
270                    i = after_cmd + 1;
271                    continue;
272                }
273            }
274        }
275
276        // Decode the next Unicode character. `i` is always on a char boundary:
277        // the markup-skip branches only advance past ASCII bytes.
278        let ch = text.get(i..).and_then(|s| s.chars().next()).unwrap_or('\0');
279        if ch.is_alphabetic() {
280            let ch_len = ch.len_utf8();
281            let mut result = String::with_capacity(text.len());
282            result.push_str(text.get(..i).unwrap_or_default());
283            for upper in ch.to_uppercase() {
284                result.push(upper);
285            }
286            result.push_str(text.get(i + ch_len..).unwrap_or_default());
287            return result;
288        }
289
290        i += ch.len_utf8().max(1);
291    }
292
293    text.to_string()
294}
295
296/// Apply a text-case transform to a pre-formatted string that may contain
297/// rendered markup.
298///
299/// Delegates to [`capitalize_first_word_markup_aware`] for `CapitalizeFirst`;
300/// all other cases fall back to [`apply_text_case`].
301pub(crate) fn apply_text_case_markup_aware(text: &str, case: TextCase) -> String {
302    match case {
303        TextCase::CapitalizeFirst => capitalize_first_word_markup_aware(text),
304        _ => apply_text_case(text, case),
305    }
306}
307
308// English title-case stop words (articles, short conjunctions, short prepositions).
309const TITLE_CASE_STOP_WORDS: &[&str] = &[
310    "a", "an", "and", "as", "at", "but", "by", "for", "from", "in", "nor", "of", "on", "or", "so",
311    "the", "to", "up", "yet", "v", "vs",
312];
313
314/// Hyphen-like characters that join compound words for title-case purposes.
315///
316/// Includes the ASCII hyphen-minus and the en dash: bibliographic titles
317/// commonly use an en dash as a hyphen substitute in compounds like
318/// "Aging–Disability Nexus" (as-typed source data), and CMOS title-cases
319/// each component the same way it would for an ASCII-hyphenated compound.
320const HYPHEN_LIKE_CHARS: [char; 2] = ['-', '\u{2013}'];
321
322fn contains_hyphen_like(text: &str) -> bool {
323    text.contains(HYPHEN_LIKE_CHARS)
324}
325
326/// Capitalize each component of a hyphen-joined compound word for title case.
327///
328/// When `force_all` is true (first/last word, post-punctuation), every component
329/// is capitalized. Otherwise interior stop-word components stay lowercase.
330/// Splits on both the ASCII hyphen and the en dash (see [`HYPHEN_LIKE_CHARS`]),
331/// preserving whichever separator character was actually used.
332fn capitalize_hyphenated(word: &str, force_all: bool) -> String {
333    let mut result = String::with_capacity(word.len());
334    let mut last_end = 0;
335    for (idx, sep) in word.match_indices(HYPHEN_LIKE_CHARS) {
336        let part = word.get(last_end..idx).unwrap_or_default();
337        result.push_str(&capitalize_hyphen_part(part, force_all));
338        result.push_str(sep);
339        last_end = idx + sep.len();
340    }
341    let tail = word.get(last_end..).unwrap_or_default();
342    result.push_str(&capitalize_hyphen_part(tail, force_all));
343    result
344}
345
346fn capitalize_hyphen_part(part: &str, force_all: bool) -> String {
347    if force_all {
348        capitalize_first_word(part)
349    } else {
350        let alpha_core = part.trim_matches(|c: char| !c.is_alphanumeric());
351        if TITLE_CASE_STOP_WORDS.contains(&alpha_core) {
352            part.to_string()
353        } else {
354            capitalize_first_word(part)
355        }
356    }
357}
358
359fn trim_trailing_closing_punctuation(word: &str) -> &str {
360    word.trim_end_matches(['"', '\'', ')', ']', '}', '»', '”', '’'])
361}
362
363/// Convert text to English headline-style title case.
364///
365/// Capitalizes the first and last word unconditionally.
366/// Interior stop words (articles, short prepositions, conjunctions) stay lowercase.
367/// The first word after `:`, `?`, or `!` is always capitalized.
368/// Hyphenated compounds capitalize each non-stop-word component.
369fn to_title_case(text: &str) -> String {
370    if text.is_empty() {
371        return String::new();
372    }
373
374    let words: Vec<&str> = text.split_whitespace().collect();
375    if words.is_empty() {
376        return text.to_string();
377    }
378
379    let last_idx = words.len() - 1;
380    let mut parts: Vec<String> = Vec::with_capacity(words.len());
381    let mut capitalize_next = false;
382
383    for (i, word) in words.iter().enumerate() {
384        if has_internal_uppercase(word) {
385            // Acronym or mixed-case name (e.g. "DNA", "McDonald", "iPhone"):
386            // preserve exactly as written, regardless of position.
387            parts.push((*word).to_string());
388        } else {
389            let lower = word.to_lowercase();
390            if i == 0 || i == last_idx || capitalize_next {
391                if contains_hyphen_like(&lower) {
392                    parts.push(capitalize_hyphenated(&lower, true));
393                } else {
394                    parts.push(capitalize_first_word(&lower));
395                }
396            } else {
397                // Strip leading/trailing punctuation when checking stop words so that
398                // words like "(and" or "and)" are still treated as the stop word "and".
399                let alpha_core = lower.trim_matches(|c: char| !c.is_alphanumeric());
400                if TITLE_CASE_STOP_WORDS.contains(&alpha_core) {
401                    parts.push(lower);
402                } else if contains_hyphen_like(&lower) {
403                    parts.push(capitalize_hyphenated(&lower, false));
404                } else {
405                    parts.push(capitalize_first_word(&lower));
406                }
407            }
408        }
409        // Capitalize the next word after sentence-ending punctuation or a colon,
410        // even when that punctuation is followed by a closing quote or bracket.
411        let punctuation_core = trim_trailing_closing_punctuation(word);
412        capitalize_next = punctuation_core.ends_with(':')
413            || punctuation_core.ends_with('?')
414            || punctuation_core.ends_with('!');
415    }
416
417    rebuild_with_original_whitespace(text, &parts)
418}
419
420#[cfg(test)]
421#[allow(
422    clippy::unwrap_used,
423    clippy::expect_used,
424    clippy::panic,
425    clippy::indexing_slicing,
426    clippy::todo,
427    clippy::unimplemented,
428    clippy::unreachable,
429    clippy::get_unwrap,
430    reason = "Panicking is acceptable and often desired in tests."
431)]
432mod tests {
433    use super::*;
434
435    // --- capitalize_first_word ---
436
437    #[test]
438    fn test_capitalize_first_word_basic() {
439        assert_eq!(capitalize_first_word("hello world"), "Hello world");
440    }
441
442    #[test]
443    fn test_capitalize_first_word_leading_space() {
444        assert_eq!(capitalize_first_word("  hello"), "  Hello");
445    }
446
447    #[test]
448    fn test_capitalize_first_word_empty() {
449        assert_eq!(capitalize_first_word(""), "");
450    }
451
452    #[test]
453    fn test_capitalize_first_word_already_upper() {
454        assert_eq!(capitalize_first_word("Hello"), "Hello");
455    }
456
457    #[test]
458    fn given_leading_numeral_when_capitalize_first_word_then_left_as_is() {
459        // "35 mm film" has no leading word to capitalize; the first letter
460        // ("m" in "mm") must not be hunted down and capitalized instead.
461        assert_eq!(capitalize_first_word("35 mm film"), "35 mm film");
462    }
463
464    #[test]
465    fn given_leading_letter_when_capitalize_first_word_then_capitalized() {
466        assert_eq!(capitalize_first_word("in Korean"), "In Korean");
467    }
468
469    // --- capitalize_first_word_markup_aware ---
470
471    #[test]
472    fn test_capitalize_markup_aware_plain_text() {
473        assert_eq!(
474            capitalize_first_word_markup_aware("the collected essays"),
475            "The collected essays"
476        );
477    }
478
479    #[test]
480    fn test_capitalize_markup_aware_html_tag() {
481        assert_eq!(
482            capitalize_first_word_markup_aware("<em>the collected essays</em>"),
483            "<em>The collected essays</em>"
484        );
485    }
486
487    #[test]
488    fn test_capitalize_markup_aware_html_nested_tags() {
489        assert_eq!(
490            capitalize_first_word_markup_aware(r#"<span class="x"><em>the title</em></span>"#),
491            r#"<span class="x"><em>The title</em></span>"#
492        );
493    }
494
495    #[test]
496    fn test_capitalize_markup_aware_latex_command() {
497        assert_eq!(
498            capitalize_first_word_markup_aware(r"\emph{the collected essays}"),
499            r"\emph{The collected essays}"
500        );
501    }
502
503    #[test]
504    fn test_capitalize_markup_aware_latex_number_not_corrupted() {
505        // Regression: \emph{521} must not become \Emph{521}
506        assert_eq!(
507            capitalize_first_word_markup_aware(r"\emph{521}"),
508            r"\emph{521}"
509        );
510    }
511
512    #[test]
513    fn test_capitalize_markup_aware_typst_command() {
514        assert_eq!(
515            capitalize_first_word_markup_aware("#emph[the collected essays]"),
516            "#emph[The collected essays]"
517        );
518    }
519
520    #[test]
521    fn test_capitalize_markup_aware_plain_underscore_delimiters() {
522        // PlainText emph uses _..._; _ is non-alphabetic so this was already safe
523        assert_eq!(
524            capitalize_first_word_markup_aware("_the collected essays_"),
525            "_The collected essays_"
526        );
527    }
528
529    #[test]
530    fn test_capitalize_markup_aware_empty_string() {
531        assert_eq!(capitalize_first_word_markup_aware(""), "");
532    }
533
534    #[test]
535    fn test_capitalize_markup_aware_all_markup_no_text() {
536        assert_eq!(capitalize_first_word_markup_aware("<em></em>"), "<em></em>");
537    }
538
539    // --- to_sentence_case ---
540
541    #[test]
542    fn test_sentence_case_basic() {
543        assert_eq!(
544            to_sentence_case("The Quick Brown Fox"),
545            "The quick brown fox"
546        );
547    }
548
549    #[test]
550    fn test_sentence_case_all_caps() {
551        // All-caps words are presumed deliberately cased (acronyms) and are
552        // preserved verbatim, including as the first word.
553        assert_eq!(to_sentence_case("DNA REPLICATION"), "DNA REPLICATION");
554    }
555
556    #[test]
557    fn test_sentence_case_empty() {
558        assert_eq!(to_sentence_case(""), "");
559    }
560
561    #[test]
562    fn given_mixed_case_word_when_sentence_case_then_preserved() {
563        assert_eq!(
564            to_sentence_case("An Introduction to DNA"),
565            "An introduction to DNA"
566        );
567    }
568
569    #[test]
570    fn given_lowercase_iphone_when_sentence_case_then_lowercased() {
571        assert_eq!(to_sentence_case("the iphone problem"), "The iphone problem");
572    }
573
574    #[test]
575    fn given_mixed_case_iphone_when_sentence_case_then_preserved() {
576        assert_eq!(to_sentence_case("the iPhone problem"), "The iPhone problem");
577    }
578
579    #[test]
580    fn given_mixed_case_surname_when_sentence_case_then_preserved() {
581        assert_eq!(
582            to_sentence_case("a study of McDonald"),
583            "A study of McDonald"
584        );
585    }
586
587    // --- to_title_case ---
588
589    #[test]
590    fn test_title_case_basic() {
591        assert_eq!(to_title_case("the quick brown fox"), "The Quick Brown Fox");
592    }
593
594    #[test]
595    fn test_title_case_stop_words() {
596        assert_eq!(
597            to_title_case("a tale of two cities"),
598            "A Tale of Two Cities"
599        );
600    }
601
602    #[test]
603    fn test_title_case_last_word_capitalized() {
604        assert_eq!(
605            to_title_case("the world we live in"),
606            "The World We Live In"
607        );
608    }
609
610    #[test]
611    fn test_title_case_after_colon() {
612        assert_eq!(
613            to_title_case("the title: a subtitle"),
614            "The Title: A Subtitle"
615        );
616    }
617
618    #[test]
619    fn test_title_case_after_colon_stop_word() {
620        // First word after colon is a stop word but must still be capitalized
621        assert_eq!(
622            to_title_case("history of the world: a new perspective"),
623            "History of the World: A New Perspective"
624        );
625    }
626
627    #[test]
628    fn test_title_case_after_question_mark() {
629        assert_eq!(
630            to_title_case("who's black and why? a hidden chapter"),
631            "Who's Black and Why? A Hidden Chapter"
632        );
633    }
634
635    #[test]
636    fn test_title_case_after_question_mark_with_closing_quote() {
637        assert_eq!(
638            to_title_case("who's black and why?\" a hidden chapter"),
639            "Who's Black and Why?\" A Hidden Chapter"
640        );
641    }
642
643    #[test]
644    fn test_title_case_from_is_stop_word() {
645        assert_eq!(
646            to_title_case("a hidden chapter from the eighteenth-century invention of race"),
647            "A Hidden Chapter from the Eighteenth-Century Invention of Race"
648        );
649    }
650
651    #[test]
652    fn test_title_case_hyphenated_compound() {
653        assert_eq!(
654            to_title_case("eighteenth-century studies"),
655            "Eighteenth-Century Studies"
656        );
657    }
658
659    #[test]
660    fn test_title_case_hyphenated_stop_word_part() {
661        // "well-to-do": "to" is a stop word → stays lowercase in interior position
662        assert_eq!(to_title_case("a well-to-do family"), "A Well-to-Do Family");
663    }
664
665    #[test]
666    fn given_en_dash_compound_when_title_case_then_both_sides_capitalized() {
667        // Source data sometimes uses an en dash ("–") as a hyphen substitute
668        // in a compound like "Aging–Disability"; CMOS title-cases each side
669        // the same way it would for an ASCII-hyphenated compound.
670        assert_eq!(
671            to_title_case("the aging\u{2013}disability nexus"),
672            "The Aging\u{2013}Disability Nexus"
673        );
674    }
675
676    // --- apply_to_structured_parts ---
677
678    #[test]
679    fn test_sentence_apa_structured() {
680        let (main, subs) = apply_to_structured_parts(
681            "Understanding Citation Systems",
682            &["History and Practice", "A Comparative View"],
683            TextCase::SentenceApa,
684        );
685        assert_eq!(main, "Understanding citation systems");
686        assert_eq!(subs, vec!["History and practice", "A comparative view"]);
687    }
688
689    #[test]
690    fn test_sentence_nlm_structured() {
691        let (main, subs) = apply_to_structured_parts(
692            "Understanding Citation Systems",
693            &["History and Practice"],
694            TextCase::SentenceNlm,
695        );
696        assert_eq!(main, "Understanding citation systems");
697        // NLM: subtitles lowercased (no first-word capitalization)
698        assert_eq!(subs, vec!["history and practice"]);
699    }
700
701    #[test]
702    fn test_title_case_structured() {
703        // "DNA" is already mixed-case in the source data and must be
704        // preserved verbatim by the title-case transform.
705        let (main, subs) =
706            apply_to_structured_parts("the DNA of empire", &["a new perspective"], TextCase::Title);
707        assert_eq!(main, "The DNA of Empire");
708        assert_eq!(subs, vec!["A New Perspective"]);
709    }
710
711    #[test]
712    fn given_mixed_case_surname_when_title_case_then_preserved() {
713        assert_eq!(to_title_case("a study of McDonald"), "A Study of McDonald");
714    }
715
716    // --- resolve_text_case ---
717
718    #[test]
719    fn test_english_language_detection() {
720        assert!(is_english_language(Some("en")));
721        assert!(is_english_language(Some("en-US")));
722        assert!(is_english_language(Some("en-GB")));
723        assert!(is_english_language(None));
724        assert!(!is_english_language(Some("de")));
725        assert!(!is_english_language(Some("fr-FR")));
726    }
727
728    #[test]
729    fn test_resolve_non_english_falls_back() {
730        assert_eq!(
731            resolve_text_case(TextCase::SentenceApa, Some("de")),
732            TextCase::AsIs
733        );
734        assert_eq!(
735            resolve_text_case(TextCase::Title, Some("fr")),
736            TextCase::AsIs
737        );
738        // Explicit lowercase/uppercase pass through for any language
739        assert_eq!(
740            resolve_text_case(TextCase::Lowercase, Some("de")),
741            TextCase::Lowercase
742        );
743    }
744
745    #[test]
746    fn test_resolve_english_passes_through() {
747        assert_eq!(
748            resolve_text_case(TextCase::SentenceApa, Some("en")),
749            TextCase::SentenceApa
750        );
751        assert_eq!(
752            resolve_text_case(TextCase::Title, Some("en-US")),
753            TextCase::Title
754        );
755    }
756
757    #[test]
758    fn test_note_start_capitalize_first_uses_english_language_rules() {
759        assert_eq!(
760            apply_note_start_text_case(
761                "edited by",
762                NoteStartTextCase::CapitalizeFirst,
763                Some("en-US"),
764            ),
765            "Edited by"
766        );
767    }
768
769    #[test]
770    fn test_note_start_capitalize_first_falls_back_to_as_is_for_non_english() {
771        assert_eq!(
772            apply_note_start_text_case(
773                "hg. von",
774                NoteStartTextCase::CapitalizeFirst,
775                Some("de-DE"),
776            ),
777            "hg. von"
778        );
779    }
780
781    #[test]
782    fn test_note_start_capitalize_first_is_no_op_for_uncased_scripts() {
783        assert_eq!(
784            apply_note_start_text_case("ابن سينا", NoteStartTextCase::CapitalizeFirst, Some("ar"),),
785            "ابن سينا"
786        );
787    }
788}