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