Skip to main content

citum_engine/values/
mod.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Value extraction for template components.
7//!
8//! This module provides the logic to extract formatted values from references
9//! based on template component specifications.
10
11/// Contributor extraction and name-formatting helpers.
12pub mod contributor;
13/// Date extraction and date-formatting helpers.
14pub mod date;
15/// List-component value extraction helpers.
16pub mod list;
17/// Locator rendering logic.
18pub mod locator;
19/// Locale message component rendering.
20pub mod message;
21/// Numeric variable extraction and page-range helpers.
22pub mod number;
23/// Shared helpers for collapsing consecutive numeric or ordinal numbering.
24pub mod range;
25/// Locale term resolution helpers.
26pub mod term;
27/// Title text-case transform functions.
28pub mod text_case;
29/// Title extraction and title-formatting helpers.
30pub mod title;
31/// Single source of truth for reference-type classification (title
32/// category, `TypeClass` membership, serial-parent-ness, selector aliases,
33/// DOI-URL synthesis).
34pub(crate) mod type_class;
35/// `type-label` component rendering (localized reference-type description).
36pub mod type_label;
37/// Generic variable extraction helpers.
38pub mod variable;
39
40#[cfg(test)]
41#[allow(
42    clippy::unwrap_used,
43    clippy::expect_used,
44    clippy::panic,
45    clippy::indexing_slicing,
46    clippy::todo,
47    clippy::unimplemented,
48    clippy::unreachable,
49    clippy::get_unwrap,
50    reason = "Panicking is acceptable and often desired in tests."
51)]
52mod tests;
53
54use crate::reference::Reference;
55use citum_schema::locale::Locale;
56use citum_schema::options::{Config, bibliography::BibliographyConfig};
57use citum_schema::reference::types::Title;
58use citum_schema::template::{TemplateComponent, TitleType};
59use std::rc::Rc;
60
61pub use contributor::format_contributors_short;
62pub use date::int_to_letter;
63
64/// Resolve preferred transliteration from a map of transliterations.
65///
66/// Applies priority-based matching:
67/// 1. Preferred transliteration list: exact match
68/// 2. Preferred transliteration list: substring match
69/// 3. Preferred script: exact match
70/// 4. Preferred script: substring match
71fn resolve_transliteration<'a>(
72    transliterations: &'a std::collections::HashMap<String, String>,
73    preferred_transliteration: Option<&[String]>,
74    preferred_script: Option<&String>,
75) -> Option<&'a str> {
76    // 1. Priority list: exact match
77    if let Some(tags) = preferred_transliteration {
78        for tag in tags {
79            if let Some(v) = transliterations.get(tag) {
80                return Some(v.as_str());
81            }
82        }
83        // 2. Priority list: substring match
84        for tag in tags {
85            for (k, v) in transliterations {
86                if k.contains(tag.as_str()) {
87                    return Some(v.as_str());
88                }
89            }
90        }
91    }
92    // 3. preferred_script exact match
93    if let Some(script) = preferred_script {
94        if let Some(v) = transliterations.get(script) {
95            return Some(v.as_str());
96        }
97        // 4. preferred_script substring match
98        for (k, v) in transliterations {
99            if k.contains(script.as_str()) {
100                return Some(v.as_str());
101            }
102        }
103    }
104    None
105}
106
107fn resolve_translation<'a>(
108    translations: &'a std::collections::HashMap<citum_schema::reference::LangID, String>,
109    style_locale: &str,
110) -> Option<&'a str> {
111    translations
112        .get(style_locale)
113        .or_else(|| {
114            style_locale
115                .split(['-', '_'])
116                .next()
117                .and_then(|base| translations.get(base))
118        })
119        .map(String::as_str)
120}
121
122/// Resolve a multilingual string based on style configuration.
123///
124/// Applies BCP 47 fallback logic:
125/// 1. Exact tag match (e.g., "ja-Latn-hepburn")
126/// 2. Script prefix match (e.g., "ja-Latn")
127/// 3. Fallback to original field
128///
129/// # Arguments
130/// * `string` - The multilingual string to resolve
131/// * `mode` - The rendering mode from style config
132/// * `preferred_transliteration` - Optional ordered list of BCP 47 transliteration tags
133/// * `preferred_script` - Optional preferred script (e.g., "Latn")
134/// * `style_locale` - The style's locale for translation matching
135#[must_use]
136pub fn resolve_multilingual_string(
137    string: &citum_schema::reference::types::MultilingualString,
138    mode: Option<&citum_schema::options::MultilingualMode>,
139    preferred_transliteration: Option<&[String]>,
140    preferred_script: Option<&String>,
141    style_locale: &str,
142) -> String {
143    use citum_schema::options::MultilingualMode;
144    use citum_schema::reference::types::MultilingualString;
145
146    match string {
147        MultilingualString::Simple(s) => s.clone(),
148        MultilingualString::Complex(complex) => {
149            let mode = mode.unwrap_or(&MultilingualMode::Primary);
150
151            match mode {
152                MultilingualMode::Primary => complex.original.clone(),
153
154                MultilingualMode::Transliterated => {
155                    if let Some(trans) = resolve_transliteration(
156                        &complex.transliterations,
157                        preferred_transliteration,
158                        preferred_script,
159                    ) {
160                        return trans.to_string();
161                    }
162
163                    // Fallback: use any available transliteration, or original
164                    complex
165                        .transliterations
166                        .values()
167                        .next()
168                        .cloned()
169                        .unwrap_or_else(|| complex.original.clone())
170                }
171
172                MultilingualMode::Translated => {
173                    // Try to match style locale
174                    resolve_translation(&complex.translations, style_locale)
175                        .map(ToString::to_string)
176                        .unwrap_or_else(|| complex.original.clone())
177                }
178
179                MultilingualMode::Combined => {
180                    // Format: "transliterated [translated]" or fallback variants
181                    let trans = resolve_transliteration(
182                        &complex.transliterations,
183                        preferred_transliteration,
184                        preferred_script,
185                    );
186
187                    let translation = resolve_translation(&complex.translations, style_locale);
188
189                    match (trans, translation) {
190                        (Some(t), Some(tr)) => format!("{t} [{tr}]"),
191                        (Some(t), None) => t.to_string(),
192                        (None, Some(tr)) => format!("{} [{}]", complex.original, tr),
193                        (None, None) => complex.original.clone(),
194                    }
195                }
196
197                MultilingualMode::Pattern(segments) => resolve_multilingual_pattern(
198                    segments,
199                    &complex.original,
200                    &complex.transliterations,
201                    &complex.translations,
202                    preferred_transliteration,
203                    preferred_script,
204                    style_locale,
205                ),
206            }
207        }
208    }
209}
210
211/// Render a [`MultilingualMode::Pattern`] against a complex multilingual string.
212///
213/// Each segment is resolved to its text; segments that are empty or identical to
214/// the previous non-empty segment are skipped (dedup).  The surviving segments are
215/// joined by a single space.
216fn resolve_multilingual_pattern(
217    segments: &[citum_schema::options::MultilingualSegment],
218    original: &str,
219    transliterations: &std::collections::HashMap<String, String>,
220    translations: &std::collections::HashMap<citum_schema::reference::types::LangID, String>,
221    preferred_transliteration: Option<&[String]>,
222    preferred_script: Option<&String>,
223    style_locale: &str,
224) -> String {
225    use citum_schema::options::{MultilingualView, SegmentWrap};
226    let mut parts: Vec<String> = Vec::with_capacity(segments.len());
227    let mut last_text: Option<String> = None;
228
229    for seg in segments {
230        let text: Option<String> = match &seg.view {
231            MultilingualView::OriginalScript => Some(original.to_string()),
232            MultilingualView::Transliterated => resolve_transliteration(
233                transliterations,
234                preferred_transliteration,
235                preferred_script,
236            )
237            .map(ToString::to_string),
238            MultilingualView::Translated => {
239                resolve_translation(translations, style_locale).map(ToString::to_string)
240            }
241        };
242
243        let Some(text) = text else { continue };
244        if text.is_empty() {
245            continue;
246        }
247        // Skip duplicate: if this text is identical to the previous segment (e.g. when
248        // transliteration falls back to the same value as original).
249        if last_text.as_deref() == Some(text.as_str()) {
250            continue;
251        }
252
253        let wrapped = match &seg.wrap {
254            SegmentWrap::None => text.clone(),
255            other => other.apply(&text),
256        };
257        last_text = Some(text);
258        parts.push(wrapped);
259    }
260
261    parts.join(" ")
262}
263
264/// Resolve the effective language for one logical field scope on a reference.
265///
266/// This prefers an explicit `field_languages` entry, then a multilingual title
267/// language tag for the provided title value, and finally the reference-level
268/// language.
269#[must_use]
270pub fn effective_field_language(
271    reference: &Reference,
272    scope: &str,
273    title: Option<&Title>,
274) -> Option<String> {
275    reference
276        .field_languages()
277        .get(scope)
278        .map(ToString::to_string)
279        .or_else(|| match title {
280            Some(Title::Multilingual(multilingual)) => {
281                multilingual.lang.as_ref().map(ToString::to_string)
282            }
283            _ => None,
284        })
285        .or_else(|| reference.language().map(|lang| lang.to_string()))
286}
287
288/// Resolve the effective language for the primary title of a reference.
289#[must_use]
290pub fn effective_item_language(reference: &Reference) -> Option<String> {
291    effective_field_language(reference, "title", reference.title().as_ref())
292}
293
294/// Resolve the effective language for the specific template component being rendered.
295#[must_use]
296pub fn effective_component_language(
297    reference: &Reference,
298    component: &TemplateComponent,
299) -> Option<String> {
300    match component {
301        TemplateComponent::Title(title_component) => {
302            let title = match title_component.title {
303                TitleType::Primary => reference.title(),
304                TitleType::ContainerTitle => reference.container_title(),
305                TitleType::ParentMonograph => reference.container_title(),
306                TitleType::ParentSerial => reference.container_title(),
307                TitleType::CollectionTitle => reference.collection_title(),
308                _ => reference.title(),
309            };
310
311            let scope = match title_component.title {
312                TitleType::Primary => "title",
313                TitleType::ContainerTitle => "container-title",
314                TitleType::ParentMonograph => "parent-monograph.title",
315                TitleType::ParentSerial => "parent-serial.title",
316                TitleType::CollectionTitle => "collection-title",
317                _ => "title",
318            };
319
320            effective_field_language(reference, scope, title.as_ref())
321        }
322        _ => effective_item_language(reference),
323    }
324}
325
326/// Select a structured name from transliteration maps using priority-list then script-match rules.
327fn select_by_transliteration<'a>(
328    m: &'a citum_schema::reference::contributor::MultilingualName,
329    preferred_transliteration: Option<&[String]>,
330    preferred_script: Option<&String>,
331) -> &'a citum_schema::reference::contributor::StructuredName {
332    // 1. Priority list: exact match
333    if let Some(tags) = preferred_transliteration {
334        for tag in tags {
335            if let Some(name) = m.transliterations.get(tag) {
336                return name;
337            }
338        }
339        // 2. Priority list: substring match
340        for tag in tags {
341            if let Some((_, name)) = m
342                .transliterations
343                .iter()
344                .find(|(k, _)| k.contains(tag.as_str()))
345            {
346                return name;
347            }
348        }
349    }
350    // 3. Preferred script: exact match
351    if let Some(script) = preferred_script {
352        if let Some(name) = m.transliterations.get(script) {
353            return name;
354        }
355        // 4. Preferred script: substring match
356        if let Some((_, name)) = m
357            .transliterations
358            .iter()
359            .find(|(tag, _)| tag.contains(script))
360        {
361            return name;
362        }
363    }
364    // Fallback: any available transliteration before falling back to original
365    m.transliterations.values().next().unwrap_or(&m.original)
366}
367
368/// Render the original-script display form of a structured name.
369///
370/// CJK names display family-first with no separator (`华林甫`); other scripts
371/// display given-first with a space.
372fn original_script_display(name: &citum_schema::reference::contributor::StructuredName) -> String {
373    use unicode_script::{Script, UnicodeScript};
374
375    let family = name.family.to_string();
376    let given = name.given.to_string();
377    let is_cjk = family.chars().chain(given.chars()).any(|ch| {
378        matches!(
379            ch.script(),
380            Script::Han | Script::Hiragana | Script::Katakana | Script::Hangul
381        )
382    });
383    if is_cjk || family.is_empty() || given.is_empty() {
384        format!("{family}{given}")
385    } else {
386        format!("{given} {family}")
387    }
388}
389
390/// Resolve a multilingual contributor name based on style configuration.
391///
392/// Uses holistic name matching - selects the entire name variant (original/transliterated/translated)
393/// as a unit rather than mixing fields from different variants.
394///
395/// # Arguments
396/// * `contributor` - The contributor to resolve
397/// * `mode` - The rendering mode from style config
398/// * `preferred_transliteration` - Optional ordered list of BCP 47 transliteration tags
399/// * `preferred_script` - Optional preferred script (e.g., "Latn")
400/// * `style_locale` - The style's locale for translation matching
401#[must_use]
402pub fn resolve_multilingual_name(
403    contributor: &citum_schema::reference::contributor::Contributor,
404    mode: Option<&citum_schema::options::MultilingualMode>,
405    preferred_transliteration: Option<&[String]>,
406    preferred_script: Option<&String>,
407    style_locale: &str,
408) -> Vec<crate::reference::FlatName> {
409    use citum_schema::options::MultilingualMode;
410    use citum_schema::reference::contributor::Contributor;
411
412    match contributor {
413        // Simple and structured names have no multilingual data
414        Contributor::SimpleName(_) | Contributor::StructuredName(_) => contributor.to_names_vec(),
415
416        // Multilingual names: select variant holistically
417        Contributor::Multilingual(m) => {
418            let mode = mode.unwrap_or(&MultilingualMode::Primary);
419
420            let selected_name = match mode {
421                MultilingualMode::Primary => &m.original,
422                MultilingualMode::Transliterated => {
423                    select_by_transliteration(m, preferred_transliteration, preferred_script)
424                }
425                MultilingualMode::Translated => {
426                    m.translations.get(style_locale).unwrap_or(&m.original)
427                }
428                // Combined mode for names defaults to transliterated (parenthetical combo not common for names)
429                MultilingualMode::Combined => {
430                    select_by_transliteration(m, preferred_transliteration, preferred_script)
431                }
432                // Pattern mode for names: render the romanized view, carrying the
433                // original-script form along when the pattern requests it
434                // (e.g. "Hua Linfu 华林甫").
435                MultilingualMode::Pattern(_) => {
436                    select_by_transliteration(m, preferred_transliteration, preferred_script)
437                }
438            };
439
440            // When a name pattern includes an `original-script` view alongside
441            // the selected transliteration, carry the original-script display
442            // form (with the segment's wrap applied) so formatting can append
443            // it after the romanized name.
444            let original_script = match mode {
445                MultilingualMode::Pattern(segments) if selected_name != &m.original => segments
446                    .iter()
447                    .find(|segment| {
448                        segment.view == citum_schema::options::MultilingualView::OriginalScript
449                    })
450                    .map(|segment| segment.wrap.apply(&original_script_display(&m.original))),
451                _ => None,
452            };
453
454            // Convert selected name to FlatName
455            vec![crate::reference::FlatName {
456                given: Some(selected_name.given.to_string()),
457                family: Some(selected_name.family.to_string()),
458                suffix: selected_name.suffix.clone(),
459                dropping_particle: selected_name.dropping_particle.clone(),
460                non_dropping_particle: selected_name.non_dropping_particle.clone(),
461                literal: None,
462                short_name: None,
463                original_script,
464            }]
465        }
466
467        Contributor::ContributorList(l) => {
468            l.0.iter()
469                .flat_map(|c| {
470                    resolve_multilingual_name(
471                        c,
472                        mode,
473                        preferred_transliteration,
474                        preferred_script,
475                        style_locale,
476                    )
477                })
478                .collect()
479        }
480    }
481}
482
483/// Resolve the URL for a component based on its links configuration and the reference data.
484#[must_use]
485pub fn resolve_url(
486    links: &citum_schema::options::LinksConfig,
487    reference: &Reference,
488) -> Option<String> {
489    use citum_schema::options::LinkTarget;
490
491    let target = links.target.as_ref().unwrap_or(&LinkTarget::UrlOrDoi);
492
493    match target {
494        LinkTarget::Url => reference.url().map(|u| u.to_string()),
495        LinkTarget::Doi => reference.doi().map(|d| format!("https://doi.org/{d}")),
496        LinkTarget::UrlOrDoi => reference
497            .url()
498            .map(|u| u.to_string())
499            .or_else(|| reference.doi().map(|d| format!("https://doi.org/{d}"))),
500        LinkTarget::Pubmed => reference
501            .id()
502            .filter(|id| id.starts_with("pmid:"))
503            .map(|id| {
504                #[allow(clippy::string_slice, reason = "known ASCII prefix")]
505                let result = format!("https://pubmed.ncbi.nlm.nih.gov/{}/", &id[5..]);
506                result
507            }),
508        LinkTarget::Pmcid => reference
509            .id()
510            .filter(|id| id.starts_with("pmc:"))
511            .map(|id| {
512                #[allow(clippy::string_slice, reason = "known ASCII prefix")]
513                let result = format!("https://www.ncbi.nlm.nih.gov/pmc/articles/{}/", &id[4..]);
514                result
515            }),
516    }
517}
518
519/// Resolve the effective URL for a component, checking local links then falling back to global config.
520#[must_use]
521pub fn resolve_effective_url(
522    local_links: Option<&citum_schema::options::LinksConfig>,
523    global_links: Option<&citum_schema::options::LinksConfig>,
524    reference: &Reference,
525    component_anchor: citum_schema::options::LinkAnchor,
526) -> Option<String> {
527    use citum_schema::options::LinkAnchor;
528
529    // 1. Check local links first
530    if let Some(links) = local_links {
531        let anchor = links.anchor.as_ref().unwrap_or(&LinkAnchor::Component);
532        if matches!(anchor, LinkAnchor::Component) || *anchor == component_anchor {
533            return resolve_url(links, reference);
534        }
535    }
536
537    // 2. Fall back to global links if anchor matches this component type
538    if let Some(links) = global_links
539        && let Some(anchor) = &links.anchor
540        && *anchor == component_anchor
541    {
542        return resolve_url(links, reference);
543    }
544
545    None
546}
547
548/// Processed values ready for rendering.
549#[derive(Debug, Clone, Default)]
550pub struct ProcValues<T = String> {
551    /// The primary formatted value.
552    pub value: T,
553    /// Optional prefix to prepend.
554    pub prefix: Option<String>,
555    /// Optional suffix to append.
556    pub suffix: Option<String>,
557    /// Optional URL for hyperlinking.
558    pub url: Option<String>,
559    /// Variable key that was substituted (e.g., "title:Primary" when title replaces author).
560    /// Used to prevent duplicate rendering per CSL variable-once rule.
561    pub substituted_key: Option<String>,
562    /// Whether the value is already pre-formatted.
563    pub pre_formatted: bool,
564}
565
566/// Processing hints computed before rendering a reference or citation item.
567#[derive(Debug, Clone, Default)]
568pub struct ProcHints {
569    /// Whether disambiguation is active (triggers year-suffix).
570    pub disamb_condition: bool,
571    /// Index in the disambiguation group (1-based).
572    pub group_index: usize,
573    /// Total size of the disambiguation group.
574    pub group_length: usize,
575    /// The grouping key used.
576    pub group_key: String,
577    /// Whether to expand given names for disambiguation.
578    pub expand_given_names: bool,
579    /// Whether to expand given names for primary author only.
580    pub expand_given_names_primary_only: bool,
581    /// Minimum number of names to show to resolve ambiguity (overrides et-al-use-first).
582    pub min_names_to_show: Option<usize>,
583    /// Citation number for numeric citation styles (1-based).
584    pub citation_number: Option<usize>,
585    /// Optional sub-label for compound numeric citation addressing (e.g., "a" in "1a").
586    pub citation_sub_label: Option<String>,
587    /// Citation position (first, subsequent, ibid, etc.).
588    pub position: Option<citum_schema::citation::Position>,
589    /// Explicit integral citation name-memory state for this rendered item.
590    pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
591    /// Explicit org-abbreviation state for this rendered item.
592    pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
593    /// First note number in which this reference was cited (note styles only).
594    /// Set for subsequent-position citations; `None` otherwise.
595    pub first_reference_note_number: Option<u32>,
596    /// When true, suppress a `disambiguate_only` title component.
597    /// Set when `first_reference_note_number` is present — the note number
598    /// already identifies the work; the disambiguating short title is redundant.
599    pub suppress_disambiguation_title: bool,
600}
601
602/// Context for rendering (citation vs bibliography).
603#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
604pub enum RenderContext {
605    #[default]
606    /// Render values for citation output.
607    Citation,
608    /// Render values for bibliography output.
609    Bibliography,
610}
611
612/// Options for rendering.
613#[derive(Clone)]
614pub struct RenderOptions<'a> {
615    /// Effective configuration after style and default resolution.
616    pub config: Rc<Config>,
617    /// Effective bibliography-only configuration when rendering bibliography behavior.
618    pub bibliography_config: Option<Rc<BibliographyConfig>>,
619    /// Locale used for term lookup and locale-sensitive formatting.
620    pub locale: &'a Locale,
621    /// Whether the current render target is a citation or bibliography.
622    pub context: RenderContext,
623    /// Citation mode for the current render operation.
624    pub mode: citum_schema::citation::CitationMode,
625    /// Whether to suppress the author name for this citation.
626    /// Set from the citation-level `suppress_author` flag.
627    pub suppress_author: bool,
628    /// Optional raw citation locator for rendering via locator config.
629    pub locator_raw: Option<&'a citum_schema::citation::CitationLocator>,
630    /// Reference type for optional type-class gating in locator patterns.
631    pub ref_type: Option<String>,
632    /// Whether to output semantic markup (HTML spans, Djot attributes).
633    pub show_semantics: bool,
634    /// The current top-level template index, when propagating preview annotations.
635    pub current_template_index: Option<usize>,
636    /// Document-level abbreviation map for post-render substitution.
637    pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
638}
639
640/// Trait for extracting values from template components.
641pub trait ComponentValues {
642    /// Resolve the component into processed render values for one reference.
643    fn values<F: crate::render::format::OutputFormat<Output = String>>(
644        &self,
645        reference: &Reference,
646        hints: &ProcHints,
647        options: &RenderOptions<'_>,
648    ) -> Option<ProcValues<F::Output>>;
649}
650
651impl ComponentValues for TemplateComponent {
652    fn values<F: crate::render::format::OutputFormat<Output = String>>(
653        &self,
654        reference: &Reference,
655        hints: &ProcHints,
656        options: &RenderOptions<'_>,
657    ) -> Option<ProcValues<F::Output>> {
658        match self {
659            TemplateComponent::Contributor(c) => c.values::<F>(reference, hints, options),
660            TemplateComponent::Date(d) => d.values::<F>(reference, hints, options),
661            TemplateComponent::Title(t) => t.values::<F>(reference, hints, options),
662            TemplateComponent::Number(n) => n.values::<F>(reference, hints, options),
663            TemplateComponent::Variable(v) => v.values::<F>(reference, hints, options),
664            TemplateComponent::Message(m) => m.values::<F>(reference, hints, options),
665            TemplateComponent::Group(l) => l.values::<F>(reference, hints, options),
666            TemplateComponent::Term(t) => t.values::<F>(reference, hints, options),
667            TemplateComponent::TypeLabel(t) => t.values::<F>(reference, hints, options),
668            _ => None,
669        }
670    }
671}
672
673/// Check if periods should be stripped based on three-tier precedence.
674///
675/// Resolution order:
676/// 1. Component-level `strip_periods`
677/// 2. Global config `strip_periods`
678/// 3. Defaults to false
679#[must_use]
680pub fn should_strip_periods(
681    rendering: &citum_schema::template::Rendering,
682    options: &RenderOptions<'_>,
683) -> bool {
684    rendering
685        .strip_periods
686        .or(options.config.strip_periods)
687        .unwrap_or(false)
688}
689
690/// Strip trailing periods from a string.
691///
692/// Only removes periods at the end of the string, preserves internal periods
693/// (e.g., "Ph.D." remains unchanged if there's no trailing period).
694#[must_use]
695pub fn strip_trailing_periods(s: &str) -> String {
696    s.trim_end_matches('.').to_string()
697}
698
699/// Apply abbreviation substitution if the map contains an entry for `value`.
700///
701/// Returns the abbreviation if found, otherwise returns the original value unchanged.
702#[must_use]
703pub fn apply_abbreviation(value: String, map: Option<&crate::api::AbbreviationMap>) -> String {
704    if let Some(abbr) = map.and_then(|m| m.0.get(&value)) {
705        return abbr.clone();
706    }
707    value
708}