Skip to main content

citum_engine/processor/rendering/
mod.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6//! Rendering logic for citation and bibliography output.
7//!
8//! This module handles template-based rendering of citations and bibliographies,
9//! including handling of localization, numbering, formatting, and special modes
10//! like integral (narrative) citations for numeric and label styles.
11
12use crate::error::ProcessorError;
13use crate::reference::{Bibliography, Reference};
14use crate::values::{ProcHints, RenderContext, RenderOptions};
15use citum_schema::citation::CitationLocator;
16use citum_schema::locale::Locale;
17use citum_schema::options::{Config, bibliography::BibliographyConfig};
18use citum_schema::template::TemplateComponent;
19use grouped::component_predicates::resolve_type_variant;
20use indexmap::IndexMap;
21use std::borrow::Cow;
22use std::cell::RefCell;
23use std::collections::{HashMap, HashSet};
24
25/// The renderer for citation and bibliography templates.
26///
27/// The `Renderer` is responsible for taking compiled templates and applying them
28/// to bibliographic data, handling localization, numbering, and formatting.
29pub struct Renderer<'a> {
30    /// The style definition containing templates and options.
31    pub style: &'a citum_schema::Style,
32    /// The bibliography containing the reference data.
33    pub bibliography: &'a Bibliography,
34    /// The locale used for terms and formatting.
35    pub locale: &'a Locale,
36    /// The active configuration options.
37    pub config: &'a Config,
38    /// The active bibliography-only configuration.
39    pub bibliography_config: Option<BibliographyConfig>,
40    /// Pre-calculated hints for optimization.
41    pub hints: &'a HashMap<String, ProcHints>,
42    /// Shared state for citation numbers (used in numeric styles).
43    pub citation_numbers: &'a RefCell<HashMap<String, usize>>,
44    /// Optional compound set membership indexed by reference id.
45    pub compound_set_by_ref: &'a HashMap<String, String>,
46    /// Optional 0-based member index within each compound set.
47    pub compound_member_index: &'a HashMap<String, usize>,
48    /// Compound sets keyed by set id.
49    pub compound_sets: &'a IndexMap<String, Vec<String>>,
50    /// Whether to output semantic markup (HTML spans, Djot attributes).
51    pub show_semantics: bool,
52    /// Whether to attach source template indices to rendered semantic wrappers.
53    pub inject_ast_indices: bool,
54    /// Mapping from filtered to original template indices (for grouped citations).
55    pub filtered_to_original_index: RefCell<Option<Vec<usize>>>,
56    /// Document-level abbreviation map for post-render substitution.
57    pub abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
58    /// First note number per reference id (populated by normalize_note_context).
59    pub first_note_by_id: Option<&'a RefCell<HashMap<String, u32>>>,
60}
61
62/// Borrowed compound-set context for rendering.
63pub struct CompoundRenderData<'a> {
64    /// Optional compound set membership indexed by reference id.
65    pub set_by_ref: &'a HashMap<String, String>,
66    /// Optional 0-based member index within each compound set.
67    pub member_index: &'a HashMap<String, usize>,
68    /// Compound sets keyed by set id.
69    pub sets: &'a IndexMap<String, Vec<String>>,
70}
71
72mod collapse;
73mod grouped;
74mod grouped_fallback;
75mod helpers;
76
77#[cfg(test)]
78#[allow(
79    clippy::unwrap_used,
80    clippy::expect_used,
81    clippy::panic,
82    clippy::indexing_slicing,
83    clippy::todo,
84    clippy::unimplemented,
85    clippy::unreachable,
86    clippy::get_unwrap,
87    reason = "Panicking is acceptable and often desired in tests."
88)]
89mod tests;
90
91pub use grouped_fallback::GroupRenderParams;
92pub use grouped_fallback::TemplateRenderParams;
93pub(super) use helpers::{
94    find_grouping_component, has_contributor_component, leading_group_affix,
95    strip_author_component, strip_leading_group_affixes,
96};
97
98/// Internal render request used to keep template-processing call sites compact.
99pub struct TemplateRenderRequest<'a> {
100    /// The template to render.
101    pub template: &'a [TemplateComponent],
102    /// The rendering context (Citation or Bibliography).
103    pub context: RenderContext,
104    /// The citation mode (Integral or `NonIntegral`).
105    pub mode: citum_schema::citation::CitationMode,
106    /// Whether to suppress the author in output.
107    pub suppress_author: bool,
108    /// The raw citation locator if present (for new rendering logic).
109    pub locator_raw: Option<&'a CitationLocator>,
110    /// The citation number for numeric styles.
111    pub citation_number: usize,
112    /// The citation position (e.g., Ibid).
113    pub position: Option<citum_schema::citation::Position>,
114    /// Optional note-start text-case policy for note-style repeated-note output.
115    pub note_start_text_case: Option<citum_schema::NoteStartTextCase>,
116    /// Integral name state for name formatting.
117    pub integral_name_state: Option<citum_schema::citation::IntegralNameState>,
118    /// Org abbreviation state for org-name formatting.
119    pub org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
120    /// First note number for this reference (note styles, subsequent position).
121    pub first_reference_note_number: Option<u32>,
122}
123
124/// Per-item state resolved for rendering one ungrouped citation item.
125struct UngroupedItemRenderState<'a> {
126    reference: &'a Reference,
127    template: Cow<'a, [TemplateComponent]>,
128    delimiter: &'a str,
129}
130
131/// Shared, citation-wide parameters threaded into each ungrouped item render.
132#[derive(Clone, Copy)]
133struct UngroupedItemRenderParams<'a> {
134    mode: &'a citum_schema::citation::CitationMode,
135    suppress_author: bool,
136    position: Option<&'a citum_schema::citation::Position>,
137    note_start_text_case: Option<citum_schema::NoteStartTextCase>,
138}
139
140#[derive(Clone, Default)]
141struct TemplateComponentTracker {
142    rendered_vars: HashSet<String>,
143    substituted_bases: HashSet<String>,
144}
145
146impl TemplateComponentTracker {
147    fn should_skip(&self, var_key: Option<&str>) -> bool {
148        let Some(var_key) = var_key else {
149            return false;
150        };
151        let base = key_base(var_key);
152        self.rendered_vars.contains(var_key) || self.substituted_bases.contains(base.as_ref())
153    }
154
155    fn mark_rendered(&mut self, var_key: Option<String>, substituted_key: Option<&str>) {
156        if let Some(var_key) = var_key {
157            self.rendered_vars.insert(var_key);
158        }
159        if let Some(substituted_key) = substituted_key {
160            self.rendered_vars.insert(substituted_key.to_string());
161            self.substituted_bases
162                .insert(key_base(substituted_key).into_owned());
163        }
164    }
165
166    fn merge_from(&mut self, other: Self) {
167        self.rendered_vars.extend(other.rendered_vars);
168        self.substituted_bases.extend(other.substituted_bases);
169    }
170}
171
172/// Core style resources borrowed by every [`Renderer`] instance.
173///
174/// Bundles the four immutable resolution inputs so that [`Renderer::new`] stays
175/// within clippy's argument-count limit.
176pub struct RendererResources<'a> {
177    /// The style definition containing templates and options.
178    pub style: &'a citum_schema::Style,
179    /// The bibliography containing the reference data.
180    pub bibliography: &'a Bibliography,
181    /// The locale used for terms and formatting.
182    pub locale: &'a Locale,
183    /// The active configuration options.
184    pub config: &'a Config,
185    /// The active bibliography-only configuration.
186    pub bibliography_config: Option<BibliographyConfig>,
187    /// First note number per reference id (note styles; `None` for bibliography rendering).
188    pub first_note_by_id: Option<&'a RefCell<HashMap<String, u32>>>,
189}
190
191impl<'a> Renderer<'a> {
192    /// Creates a new `Renderer` instance.
193    pub fn new(
194        resources: RendererResources<'a>,
195        hints: &'a HashMap<String, ProcHints>,
196        citation_numbers: &'a RefCell<HashMap<String, usize>>,
197        compound: CompoundRenderData<'a>,
198        show_semantics: bool,
199        inject_ast_indices: bool,
200        abbreviation_map: Option<&'a crate::api::AbbreviationMap>,
201    ) -> Self {
202        Self {
203            style: resources.style,
204            bibliography: resources.bibliography,
205            locale: resources.locale,
206            config: resources.config,
207            bibliography_config: resources.bibliography_config,
208            hints,
209            citation_numbers,
210            compound_set_by_ref: compound.set_by_ref,
211            compound_member_index: compound.member_index,
212            compound_sets: compound.sets,
213            show_semantics,
214            inject_ast_indices,
215            filtered_to_original_index: RefCell::new(None),
216            abbreviation_map,
217            first_note_by_id: resources.first_note_by_id,
218        }
219    }
220
221    /// Resolve multilingual contributor names using the style's config.
222    fn resolve_contributor_names(
223        &self,
224        contributor: &citum_schema::reference::contributor::Contributor,
225    ) -> Vec<crate::reference::FlatName> {
226        let ml = self.config.multilingual.as_ref();
227        crate::values::resolve_multilingual_name(
228            contributor,
229            ml.and_then(|m| m.name_mode.as_ref()),
230            ml.and_then(|m| m.preferred_transliteration.as_deref()),
231            ml.and_then(|m| m.preferred_script.as_ref()),
232            &self.locale.locale,
233        )
234    }
235
236    /// Generate an alphabetic or numeric sub-label (e.g., "a", "1") for a
237    /// reference member of a compound set.
238    fn citation_sub_label_for_ref(&self, ref_id: &str) -> Option<String> {
239        let compound = self
240            .bibliography_config
241            .as_ref()
242            .and_then(|b| b.compound_numeric.as_ref())?;
243        let set_id = self.compound_set_by_ref.get(ref_id)?;
244        let members = self.compound_sets.get(set_id)?;
245        if members.len() <= 1 {
246            return None;
247        }
248        if !compound.subentry {
249            return None;
250        }
251        let idx = *self.compound_member_index.get(ref_id)?;
252        match compound.sub_label {
253            citum_schema::options::bibliography::SubLabelStyle::Alphabetic => {
254                crate::values::int_to_letter((idx + 1) as u32)
255            }
256            citum_schema::options::bibliography::SubLabelStyle::Numeric => {
257                Some((idx + 1).to_string())
258            }
259        }
260    }
261
262    /// Determines if the processor should render author-plus-number text for a numeric style
263    /// when in "integral" (narrative) citation mode.
264    ///
265    /// This happens when the style is numeric and the user requests a narrative
266    /// citation (e.g., "Smith [1]"), but hasn't provided an explicit narrative template.
267    fn should_render_author_number_for_numeric_integral(
268        &self,
269        mode: &citum_schema::citation::CitationMode,
270    ) -> bool {
271        matches!(mode, citum_schema::citation::CitationMode::Integral)
272            && self.config.processing.as_ref().is_some_and(|processing| {
273                matches!(processing, citum_schema::options::Processing::Numeric)
274            })
275            && !self.has_explicit_integral_template()
276    }
277
278    /// Whether the style provides an explicit integral (narrative) template.
279    fn has_explicit_integral_template(&self) -> bool {
280        self.style.citation.as_ref().is_some_and(|c| {
281            c.integral.as_ref().is_some_and(|i| {
282                i.template.is_some() || i.template_ref.is_some() || i.locales.is_some()
283            })
284        })
285    }
286
287    /// Determine if compound subentries should be collapsed for this citation.
288    fn should_collapse_compound_subentries(
289        &self,
290        mode: &citum_schema::citation::CitationMode,
291    ) -> bool {
292        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
293            return false;
294        }
295
296        self.bibliography_config
297            .as_ref()
298            .and_then(|b| b.compound_numeric.as_ref())
299            .is_some_and(|c| c.subentry && c.collapse_subentries)
300    }
301
302    /// Determine if citation numbers should be collapsed into ranges.
303    fn should_collapse_citation_numbers(
304        &self,
305        spec: &citum_schema::CitationSpec,
306        mode: &citum_schema::citation::CitationMode,
307    ) -> bool {
308        if !matches!(mode, citum_schema::citation::CitationMode::NonIntegral) {
309            return false;
310        }
311
312        let is_numeric = self
313            .config
314            .processing
315            .as_ref()
316            .is_some_and(|p| matches!(p, citum_schema::options::Processing::Numeric));
317
318        is_numeric
319            && matches!(
320                spec.collapse,
321                Some(citum_schema::CitationCollapse::CitationNumber)
322            )
323    }
324
325    /// Heuristic for ensuring proper spacing after a citation prefix.
326    fn normalize_prefix_spacing(prefix: &str) -> String {
327        if !prefix.is_empty() && !prefix.ends_with(char::is_whitespace) {
328            format!("{prefix} ")
329        } else {
330            prefix.to_string()
331        }
332    }
333
334    /// Ensure suffix has proper spacing (add space if suffix doesn't start with
335    /// punctuation and isn't empty).
336    fn ensure_suffix_spacing(suffix: &str) -> String {
337        if suffix.is_empty() {
338            String::new()
339        } else if suffix.starts_with(char::is_whitespace)
340            || suffix.starts_with(',')
341            || suffix.starts_with(';')
342            || suffix.starts_with('.')
343        {
344            // Already has leading space or punctuation
345            suffix.to_string()
346        } else {
347            // Add space before suffix to separate from content
348            format!(" {suffix}")
349        }
350    }
351
352    /// Apply prefix and suffix spacing heuristics to a rendered string.
353    fn affix_content<F>(
354        &self,
355        fmt: &F,
356        content: String,
357        prefix: Option<&str>,
358        suffix: Option<&str>,
359    ) -> String
360    where
361        F: crate::render::format::OutputFormat<Output = String>,
362    {
363        let prefix = prefix.unwrap_or("");
364        let suffix = suffix.unwrap_or("");
365        if prefix.is_empty() && suffix.is_empty() {
366            content
367        } else {
368            fmt.affix(
369                &Self::normalize_prefix_spacing(prefix),
370                content,
371                &Self::ensure_suffix_spacing(suffix),
372            )
373        }
374    }
375
376    /// Pair rendered content with associated reference IDs to form a semantic chunk.
377    fn build_citation_chunk<F>(
378        &self,
379        fmt: &F,
380        ids: Vec<String>,
381        content: String,
382        prefix: Option<&str>,
383        suffix: Option<&str>,
384    ) -> Option<(Vec<String>, String)>
385    where
386        F: crate::render::format::OutputFormat<Output = String>,
387    {
388        if content.is_empty() {
389            None
390        } else {
391            Some((ids, self.affix_content(fmt, content, prefix, suffix)))
392        }
393    }
394
395    /// Build a citation chunk for a single item from its rendered content.
396    fn build_item_chunk<F>(
397        &self,
398        fmt: &F,
399        item: &crate::reference::CitationItem,
400        content: String,
401    ) -> Option<(Vec<String>, String)>
402    where
403        F: crate::render::format::OutputFormat<Output = String>,
404    {
405        self.build_citation_chunk(
406            fmt,
407            vec![item.id.clone()],
408            content,
409            item.prefix.as_deref(),
410            item.suffix.as_deref(),
411        )
412    }
413
414    /// Create a template render request for a single citation item.
415    fn citation_render_request<'b>(
416        &self,
417        item: &'b crate::reference::CitationItem,
418        template: &'b [TemplateComponent],
419        mode: &citum_schema::citation::CitationMode,
420        suppress_author: bool,
421        position: Option<&citum_schema::citation::Position>,
422        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
423    ) -> TemplateRenderRequest<'b> {
424        TemplateRenderRequest {
425            template,
426            context: RenderContext::Citation,
427            mode: mode.clone(),
428            suppress_author,
429            locator_raw: item.locator.as_ref(),
430            citation_number: self.get_or_assign_citation_number(&item.id),
431            position: position.cloned(),
432            note_start_text_case,
433            integral_name_state: item.integral_name_state,
434            org_abbreviation_state: item.org_abbreviation_state,
435            first_reference_note_number: self
436                .first_note_by_id
437                .as_ref()
438                .and_then(|m| m.borrow().get(&item.id).copied()),
439        }
440    }
441
442    /// Render a single item to a formatted string using a template.
443    fn render_item_from_template_with_format<F>(
444        &self,
445        reference: &Reference,
446        request: TemplateRenderRequest<'_>,
447        delimiter: &str,
448    ) -> Option<String>
449    where
450        F: crate::render::format::OutputFormat<Output = String>,
451    {
452        self.process_template_request_with_format::<F>(reference, request)
453            .map(|proc| {
454                crate::render::citation::citation_to_string_with_format::<F>(
455                    &proc,
456                    None,
457                    None,
458                    None,
459                    Some(delimiter),
460                )
461            })
462    }
463
464    /// Resolve the reference, template, and delimiter needed to render one
465    /// ungrouped citation item, applying type-variant and language fallbacks.
466    fn resolve_ungrouped_item_render_state<'b>(
467        &'b self,
468        item: &'b crate::reference::CitationItem,
469        spec: &'b citum_schema::CitationSpec,
470        intra_delimiter: &'b str,
471    ) -> Result<UngroupedItemRenderState<'b>, ProcessorError> {
472        let reference = self
473            .bibliography
474            .get(&item.id)
475            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
476        let ref_type = reference.ref_type();
477        let item_language = crate::values::effective_item_language(reference);
478        let template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
479            .map(Cow::Borrowed)
480            .or_else(|| {
481                spec.resolve_template_for_language(item_language.as_deref())
482                    .map(Cow::Owned)
483            })
484            .unwrap_or(Cow::Borrowed(&[]));
485
486        Ok(UngroupedItemRenderState {
487            reference,
488            template,
489            delimiter: spec.delimiter.as_deref().unwrap_or(intra_delimiter),
490        })
491    }
492
493    /// Initialize render options for a citation.
494    fn citation_render_options<'b>(
495        &'b self,
496        mode: citum_schema::citation::CitationMode,
497        suppress_author: bool,
498        locator_raw: Option<&'b CitationLocator>,
499        ref_type: Option<String>,
500    ) -> RenderOptions<'b> {
501        RenderOptions {
502            config: self.config,
503            bibliography_config: self.bibliography_config.clone(),
504            locale: self.locale,
505            context: RenderContext::Citation,
506            mode,
507            suppress_author,
508            locator_raw,
509            ref_type,
510            show_semantics: self.show_semantics,
511            current_template_index: None,
512            abbreviation_map: self.abbreviation_map,
513        }
514    }
515
516    /// Render author + citation number for numeric integral citations.
517    ///
518    /// Default implementation for narrative citations in numeric styles (e.g., "Smith [1]").
519    fn render_author_number_for_numeric_integral_with_format<F>(
520        &self,
521        reference: &Reference,
522        item: &crate::reference::CitationItem,
523        citation_number: usize,
524    ) -> String
525    where
526        F: crate::render::format::OutputFormat<Output = String>,
527    {
528        let fmt = F::default();
529        let options = self.citation_render_options(
530            citum_schema::citation::CitationMode::Integral,
531            false,
532            item.locator.as_ref(),
533            Some(reference.ref_type()),
534        );
535
536        // Render author in short form
537        let author_part = if let Some(authors) = reference.author() {
538            let names_vec = self.resolve_contributor_names(&authors);
539            fmt.text(&crate::values::format_contributors_short(
540                &names_vec, &options,
541            ))
542        } else {
543            String::new()
544        };
545
546        // Include compound sub-label (e.g. "a", "b") when applicable.
547        let ref_id = reference.id().unwrap_or_default().to_string();
548        let sub_label = self.citation_sub_label_for_ref(&ref_id).unwrap_or_default();
549
550        // Format: "Author [Na]"
551        if author_part.is_empty() {
552            // Fallback: just citation number if no author
553            format!("[{citation_number}{sub_label}]")
554        } else {
555            format!("{author_part} [{citation_number}{sub_label}]")
556        }
557    }
558
559    /// Render one item as author + citation number for numeric integral cites.
560    fn render_numeric_integral_item_chunk_with_format<F>(
561        &self,
562        fmt: &F,
563        item: &crate::reference::CitationItem,
564    ) -> Result<Option<(Vec<String>, String)>, ProcessorError>
565    where
566        F: crate::render::format::OutputFormat<Output = String>,
567    {
568        let reference = self
569            .bibliography
570            .get(&item.id)
571            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
572        let citation_number = self.get_or_assign_citation_number(&item.id);
573        let item_str = self.render_author_number_for_numeric_integral_with_format::<F>(
574            reference,
575            item,
576            citation_number,
577        );
578        Ok(self.build_item_chunk(fmt, item, item_str))
579    }
580
581    /// Render one ungrouped item from its resolved template state.
582    fn render_template_item_chunk_with_format<F>(
583        &self,
584        fmt: &F,
585        item: &crate::reference::CitationItem,
586        state: UngroupedItemRenderState<'_>,
587        params: UngroupedItemRenderParams<'_>,
588    ) -> Option<(Vec<String>, String)>
589    where
590        F: crate::render::format::OutputFormat<Output = String>,
591    {
592        let request = self.citation_render_request(
593            item,
594            &state.template,
595            params.mode,
596            params.suppress_author,
597            params.position,
598            params.note_start_text_case,
599        );
600        self.render_item_from_template_with_format::<F>(state.reference, request, state.delimiter)
601            .and_then(|item_str| self.build_item_chunk(fmt, item, item_str))
602    }
603
604    /// Render citation items without grouping, using plain text format.
605    ///
606    /// # Errors
607    ///
608    /// Returns an error when a referenced item is missing or item rendering
609    /// fails.
610    pub fn render_ungrouped_citation(
611        &self,
612        items: &[crate::reference::CitationItem],
613        spec: &citum_schema::CitationSpec,
614        mode: &citum_schema::citation::CitationMode,
615        intra_delimiter: &str,
616        suppress_author: bool,
617        position: Option<&citum_schema::citation::Position>,
618    ) -> Result<Vec<String>, ProcessorError> {
619        self.render_ungrouped_citation_with_format::<crate::render::plain::PlainText>(
620            items,
621            spec,
622            mode,
623            intra_delimiter,
624            suppress_author,
625            position,
626            spec.note_start_text_case,
627        )
628    }
629
630    /// Render citation items without grouping, generic over the output format.
631    ///
632    /// This is the core logic for iterating over citation items, looking up references,
633    /// and applying the appropriate template or fallback logic.
634    ///
635    /// # Errors
636    ///
637    /// Returns an error when a referenced item is missing or item rendering
638    /// fails.
639    #[allow(
640        clippy::too_many_arguments,
641        reason = "Ungrouped citation rendering now needs explicit note-start context."
642    )]
643    pub fn render_ungrouped_citation_with_format<F>(
644        &self,
645        items: &[crate::reference::CitationItem],
646        spec: &citum_schema::CitationSpec,
647        mode: &citum_schema::citation::CitationMode,
648        intra_delimiter: &str,
649        suppress_author: bool,
650        position: Option<&citum_schema::citation::Position>,
651        note_start_text_case: Option<citum_schema::NoteStartTextCase>,
652    ) -> Result<Vec<String>, ProcessorError>
653    where
654        F: crate::render::format::OutputFormat<Output = String>,
655    {
656        let fmt = F::default();
657        let mut chunks: Vec<(Vec<String>, String)> = Vec::new();
658
659        // For numeric styles with integral mode, render author + citation number instead.
660        let use_author_number = self.should_render_author_number_for_numeric_integral(mode);
661        let params = UngroupedItemRenderParams {
662            mode,
663            suppress_author,
664            position,
665            note_start_text_case,
666        };
667
668        for item in items {
669            let chunk = if use_author_number {
670                self.render_numeric_integral_item_chunk_with_format::<F>(&fmt, item)?
671            } else {
672                let state =
673                    self.resolve_ungrouped_item_render_state(item, spec, intra_delimiter)?;
674                self.render_template_item_chunk_with_format::<F>(&fmt, item, state, params)
675            };
676
677            if let Some(chunk) = chunk {
678                chunks.push(chunk);
679            }
680        }
681
682        if self.should_collapse_compound_subentries(mode) {
683            chunks = self.collapse_compound_citation_chunks(chunks);
684        }
685        if self.should_collapse_citation_numbers(spec, mode) {
686            chunks = self.collapse_numeric_citation_chunks(chunks);
687        }
688
689        Ok(chunks
690            .into_iter()
691            .map(|(ids, content)| fmt.citation(ids, content))
692            .collect())
693    }
694}
695
696fn key_base(key: &str) -> Cow<'_, str> {
697    let mut parts = key.splitn(3, ':');
698    match (parts.next(), parts.next()) {
699        (Some(kind), Some(var)) => Cow::Owned(format!("{kind}:{var}")),
700        _ => Cow::Borrowed(key),
701    }
702}
703
704/// Get a unique key for a template component's variable.
705///
706/// The key includes rendering context (prefix/suffix) to allow the same variable
707/// to render multiple times if it appears in semantically different contexts.
708/// This enables styles like Chicago that require year after author AND after publisher.
709#[must_use]
710pub fn get_variable_key(component: &TemplateComponent) -> Option<String> {
711    use citum_schema::template::Rendering;
712    use std::fmt::Write;
713
714    fn push_context_suffix(key: &mut String, rendering: &Rendering) {
715        match (&rendering.prefix, &rendering.suffix) {
716            (Some(prefix), Some(suffix)) => {
717                key.push(':');
718                key.push_str(prefix);
719                key.push('_');
720                key.push_str(suffix);
721            }
722            (Some(prefix), None) => {
723                key.push(':');
724                key.push_str(prefix);
725            }
726            (None, Some(suffix)) => {
727                key.push(':');
728                key.push_str(suffix);
729            }
730            (None, None) => {}
731        }
732    }
733
734    fn make_key(kind: &str, value: impl std::fmt::Debug, rendering: &Rendering) -> Option<String> {
735        let mut key = String::new();
736        write!(&mut key, "{kind}:{value:?}").ok()?;
737        push_context_suffix(&mut key, rendering);
738        Some(key)
739    }
740
741    match component {
742        TemplateComponent::Contributor(c) => make_key("contributor", &c.contributor, &c.rendering),
743        TemplateComponent::Date(d) => make_key("date", &d.date, &d.rendering),
744        TemplateComponent::Variable(v) => make_key("variable", &v.variable, &v.rendering),
745        TemplateComponent::Title(t) => {
746            let mut key = format!("title:{:?}", t.title);
747            if let Some(form) = &t.form {
748                write!(&mut key, ":{form:?}").ok()?;
749            }
750            push_context_suffix(&mut key, &t.rendering);
751            Some(key)
752        }
753        TemplateComponent::Number(n) => make_key("number", &n.number, &n.rendering),
754        TemplateComponent::Group(_) => None,
755        _ => None,
756    }
757}