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