Skip to main content

citum_engine/processor/rendering/grouped/
core.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
4*/
5
6use super::super::{
7    GroupRenderParams, Renderer, TemplateComponentTracker, TemplateRenderParams,
8    TemplateRenderRequest, find_grouping_component, get_variable_key, has_contributor_component,
9    leading_group_affix, strip_author_component, strip_leading_group_affixes,
10};
11use super::component_predicates::{is_term_only_component, resolve_type_variant};
12use super::group_citation_items_by_author;
13use crate::error::ProcessorError;
14use crate::reference::Reference;
15use crate::render::{ProcTemplate, ProcTemplateComponent};
16use crate::values::{ComponentValues, ProcHints, RenderContext, RenderOptions};
17use citum_schema::template::{TemplateComponent, WrapConfig, WrapPunctuation};
18use std::borrow::Cow;
19
20struct GroupRenderState<'a> {
21    first_item: &'a crate::reference::CitationItem,
22    first_ref: &'a Reference,
23    template: Cow<'a, [TemplateComponent]>,
24}
25
26struct ItemRenderState<'a> {
27    item: &'a crate::reference::CitationItem,
28    reference: &'a Reference,
29    template: Cow<'a, [TemplateComponent]>,
30}
31
32struct GroupItemRenderRequest<'a> {
33    item: &'a crate::reference::CitationItem,
34    template: &'a [TemplateComponent],
35    mode: &'a citum_schema::citation::CitationMode,
36    suppress_author: bool,
37    position: Option<&'a citum_schema::citation::Position>,
38    note_start_text_case: Option<citum_schema::NoteStartTextCase>,
39    delimiter: &'a str,
40}
41
42/// Resolved context for rendering a single template (or nested group)
43/// component. Bundles parameters that would otherwise inflate
44/// [`Renderer::render_template_component_with_format`] and
45/// [`Renderer::render_group_component_with_format`] past the clippy
46/// argument-count limit.
47struct TemplateRenderContext<'a> {
48    reference: &'a Reference,
49    ref_type: &'a str,
50    options: &'a RenderOptions<'a>,
51    hint: &'a ProcHints,
52    template_index: usize,
53}
54
55/// Inputs for [`Renderer::build_template_render_hint`]. Bundles the
56/// per-citation state that would otherwise push the method past the clippy
57/// argument-count limit.
58struct HintInputs<'a> {
59    reference: &'a Reference,
60    context: RenderContext,
61    citation_number: usize,
62    position: Option<citum_schema::citation::Position>,
63    integral_name_state: Option<citum_schema::citation::IntegralNameState>,
64    org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
65    first_reference_note_number: Option<u32>,
66}
67
68impl Renderer<'_> {
69    fn strip_redundant_leading_group_punctuation<'a>(
70        &self,
71        value: &'a str,
72        delimiter: &str,
73    ) -> &'a str {
74        let Some(delimiter_char) = delimiter.chars().find(|ch| !ch.is_whitespace()) else {
75            return value;
76        };
77
78        let trimmed = value.trim_start();
79        if !trimmed.starts_with(delimiter_char) {
80            return value;
81        }
82
83        #[allow(clippy::string_slice, reason = "delimiter found at start")]
84        trimmed[delimiter_char.len_utf8()..].trim_start()
85    }
86
87    fn join_integral_group_item_parts(&self, item_parts: &[String], delimiter: &str) -> String {
88        let repeated_item_delimiter = if delimiter.trim().is_empty() {
89            ", "
90        } else {
91            delimiter
92        };
93
94        let mut joined = String::new();
95        for (index, part) in item_parts.iter().enumerate() {
96            if index > 0 {
97                joined.push_str(repeated_item_delimiter);
98            }
99
100            let normalized = if index == 0 {
101                part.as_str()
102            } else {
103                self.strip_redundant_leading_group_punctuation(part, repeated_item_delimiter)
104            };
105            joined.push_str(normalized);
106        }
107
108        joined
109    }
110
111    /// Render citation items with author grouping, using plain text format.
112    ///
113    /// # Errors
114    ///
115    /// Returns an error when a referenced item is missing or grouped rendering fails.
116    pub fn render_grouped_citation(
117        &self,
118        items: &[crate::reference::CitationItem],
119        spec: &citum_schema::CitationSpec,
120        mode: &citum_schema::citation::CitationMode,
121        intra_delimiter: &str,
122        suppress_author: bool,
123        position: Option<&citum_schema::citation::Position>,
124    ) -> Result<Vec<String>, ProcessorError> {
125        self.render_grouped_citation_with_format::<crate::render::plain::PlainText>(
126            items,
127            &GroupRenderParams {
128                spec,
129                mode,
130                intra_delimiter,
131                suppress_author,
132                position,
133                note_start_text_case: spec.note_start_text_case,
134            },
135        )
136    }
137
138    /// Render a group of items that must not be author-collapsed (legal cases,
139    /// personal communications). Returns the rendered citation strings.
140    fn render_special_type_items<F>(
141        &self,
142        group: &[&crate::reference::CitationItem],
143        params: &GroupRenderParams<'_>,
144    ) -> Result<Vec<String>, ProcessorError>
145    where
146        F: crate::render::format::OutputFormat<Output = String>,
147    {
148        let fmt = F::default();
149        let mut rendered_items = Vec::new();
150        for item in group {
151            let state = self.resolve_item_render_state(item, params.spec)?;
152            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
153                state.reference,
154                GroupItemRenderRequest {
155                    item: state.item,
156                    template: &state.template,
157                    mode: params.mode,
158                    suppress_author: params.suppress_author,
159                    position: params.position,
160                    note_start_text_case: params.note_start_text_case,
161                    delimiter: params.intra_delimiter,
162                },
163            ) && let Some((ids, content)) = self.build_citation_chunk(
164                &fmt,
165                vec![item.id.clone()],
166                item_str,
167                item.prefix.as_deref(),
168                item.suffix.as_deref(),
169            ) {
170                rendered_items.push(fmt.citation(ids, content));
171            }
172        }
173        Ok(rendered_items)
174    }
175
176    /// Render one citation group using the explicit integral template.
177    ///
178    /// Returns `Ok(Some(citation))` if the group rendered (caller should push and `continue`),
179    /// or `Ok(None)` if no items produced output (caller should fall through to other branches).
180    fn render_integral_explicit_group<F>(
181        &self,
182        group: &[&crate::reference::CitationItem],
183        spec: &citum_schema::CitationSpec,
184        mode: &citum_schema::citation::CitationMode,
185        suppress_author: bool,
186        position: Option<&citum_schema::citation::Position>,
187    ) -> Result<Option<String>, ProcessorError>
188    where
189        F: crate::render::format::OutputFormat<Output = String>,
190    {
191        let fmt = F::default();
192        let component_delimiter = spec.delimiter.as_deref().unwrap_or(" ");
193        let item_join_delim = spec.multi_cite_delimiter.as_deref().unwrap_or(", ");
194        let mut group_items_str = Vec::new();
195        let mut all_ids = Vec::new();
196
197        for item in group {
198            let state = self.resolve_item_render_state(item, spec)?;
199            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
200                state.reference,
201                GroupItemRenderRequest {
202                    item: state.item,
203                    template: &state.template,
204                    mode,
205                    suppress_author,
206                    position,
207                    note_start_text_case: spec.note_start_text_case,
208                    delimiter: component_delimiter,
209                },
210            ) && !item_str.is_empty()
211            {
212                group_items_str.push(self.affix_content(
213                    &fmt,
214                    item_str,
215                    item.prefix.as_deref(),
216                    item.suffix.as_deref(),
217                ));
218                all_ids.push(item.id.clone());
219            }
220        }
221
222        if group_items_str.is_empty() {
223            return Ok(None);
224        }
225
226        let combined_str = group_items_str.join(item_join_delim);
227        Ok(Some(fmt.citation(all_ids, combined_str)))
228    }
229
230    /// This preserves per-item output when grouping rules require items to stay
231    /// separate, and otherwise applies the requested renderer format to the
232    /// grouped citation output.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error when a referenced item is missing or grouped rendering
237    /// fails.
238    pub fn render_grouped_citation_with_format<F>(
239        &self,
240        items: &[crate::reference::CitationItem],
241        params: &GroupRenderParams<'_>,
242    ) -> Result<Vec<String>, ProcessorError>
243    where
244        F: crate::render::format::OutputFormat<Output = String>,
245    {
246        let groups = group_citation_items_by_author(self, items);
247        let mut rendered_groups = Vec::new();
248        for (_author_key, group) in groups {
249            rendered_groups
250                .extend(self.render_grouped_citation_group_with_format::<F>(&group, params)?);
251        }
252
253        Ok(rendered_groups)
254    }
255
256    fn render_grouped_citation_group_with_format<F>(
257        &self,
258        group: &[&crate::reference::CitationItem],
259        params: &GroupRenderParams<'_>,
260    ) -> Result<Vec<String>, ProcessorError>
261    where
262        F: crate::render::format::OutputFormat<Output = String>,
263    {
264        let state = self.resolve_group_render_state(group, params.spec)?;
265
266        // Multi-item same-author groups always collapse: one author name, all years
267        // joined, in both integral and non-integral modes. The year-group wrap is
268        // captured from the template and applied once around all years.
269        // Single-item groups use the per-item explicit integral path when available.
270        if group.len() == 1
271            && let Some(citation) = self.try_render_integral_group_with_format::<F>(
272                group,
273                params.spec,
274                params.mode,
275                params.suppress_author,
276                params.position,
277            )?
278        {
279            return Ok(vec![citation]);
280        }
281
282        if self.requires_full_group_item_rendering(params.mode, state.first_ref) {
283            return self.render_special_type_items::<F>(group, params);
284        }
285
286        Ok(self
287            .render_fallback_grouped_citation_with_format::<F>(
288                group,
289                state.first_ref,
290                state.first_item,
291                &state.template,
292                params,
293            )?
294            .into_iter()
295            .collect())
296    }
297
298    fn render_fallback_grouped_citation_with_format<F>(
299        &self,
300        group: &[&crate::reference::CitationItem],
301        first_ref: &Reference,
302        first_item: &crate::reference::CitationItem,
303        template: &[TemplateComponent],
304        params: &GroupRenderParams<'_>,
305    ) -> Result<Option<String>, ProcessorError>
306    where
307        F: crate::render::format::OutputFormat<Output = String>,
308    {
309        let fmt = F::default();
310        let author_part = self.render_author_for_grouping_with_format::<F>(
311            first_ref,
312            first_item,
313            template,
314            params.mode,
315            params.suppress_author,
316            params.position,
317        );
318        let (item_parts, group_delimiter, captured_year_wrap) =
319            self.render_group_item_parts_with_format::<F>(&fmt, group, params)?;
320        // Pre-compute a format-aware wrapped years string for integral collapsed groups.
321        // Using fmt.inner_affix + fmt.wrap_punctuation honours output-format-specific
322        // punctuation (e.g. LaTeX ``…'') and preserves WrapConfig.inner_prefix/suffix.
323        // Non-integral groups leave pre_wrapped_years as None and rely on the
324        // per-item template path in build_grouped_citation_content.
325        let pre_wrapped_years =
326            if matches!(params.mode, citum_schema::citation::CitationMode::Integral)
327                && !item_parts.is_empty()
328            {
329                let delimiter = group_delimiter.as_deref().unwrap_or(params.intra_delimiter);
330                let joined = self.join_integral_group_item_parts(&item_parts, delimiter);
331                let wrap_punct = captured_year_wrap
332                    .as_ref()
333                    .map(|w| &w.punctuation)
334                    .unwrap_or(&WrapPunctuation::Parentheses);
335                let inner_prefix = captured_year_wrap
336                    .as_ref()
337                    .and_then(|w| w.inner_prefix.as_deref())
338                    .unwrap_or("");
339                let inner_suffix = captured_year_wrap
340                    .as_ref()
341                    .and_then(|w| w.inner_suffix.as_deref())
342                    .unwrap_or("");
343                let inner = fmt.inner_affix(inner_prefix, joined, inner_suffix);
344                Some(fmt.wrap_punctuation(wrap_punct, inner))
345            } else {
346                None
347            };
348        let Some(content) = self.build_grouped_citation_content(
349            &author_part,
350            &item_parts,
351            params,
352            group_delimiter.as_deref(),
353            pre_wrapped_years.as_deref(),
354        ) else {
355            return Ok(None);
356        };
357        let group_ids = group.iter().map(|item| item.id.clone()).collect();
358        let prefix = first_item.prefix.as_deref().unwrap_or("");
359        // Suffix is embedded in item_parts by render_group_item_parts_with_format when
360        // item_parts is non-empty. Apply it here only when item_parts was empty (author-only output).
361        let suffix = if item_parts.is_empty() {
362            first_item.suffix.as_deref()
363        } else {
364            None
365        };
366
367        Ok(Some(fmt.citation(
368            group_ids,
369            self.affix_content(&fmt, content, Some(prefix), suffix),
370        )))
371    }
372
373    fn build_grouped_citation_content(
374        &self,
375        author_part: &str,
376        item_parts: &[String],
377        params: &GroupRenderParams<'_>,
378        group_delimiter: Option<&str>,
379        pre_wrapped_years: Option<&str>,
380    ) -> Option<String> {
381        if !author_part.is_empty() && !item_parts.is_empty() {
382            let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
383            return Some(match params.mode {
384                citum_schema::citation::CitationMode::Integral => {
385                    // pre_wrapped_years is Some for collapsed multi-item integral groups
386                    // (format-aware wrap applied upstream). For single-item groups this
387                    // path is not reached (they use the explicit integral path instead).
388                    let wrapped = pre_wrapped_years.map(str::to_string).unwrap_or_else(|| {
389                        self.join_integral_group_item_parts(item_parts, author_item_delimiter)
390                    });
391                    self.format_integral_grouped_items(
392                        author_part,
393                        &wrapped,
394                        params.suppress_author,
395                    )
396                }
397                citum_schema::citation::CitationMode::NonIntegral => {
398                    let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
399                        ", "
400                    } else {
401                        author_item_delimiter
402                    };
403                    let joined_items = item_parts.join(repeated_item_delimiter);
404                    self.format_non_integral_grouped_items(
405                        author_part,
406                        author_item_delimiter,
407                        &joined_items,
408                        params.suppress_author,
409                    )
410                }
411            });
412        }
413
414        if !author_part.is_empty() {
415            return Some(author_part.to_string());
416        }
417
418        if !item_parts.is_empty() {
419            return Some(item_parts.join(params.intra_delimiter));
420        }
421
422        None
423    }
424
425    fn format_integral_grouped_items(
426        &self,
427        author_part: &str,
428        wrapped_content: &str,
429        suppress_author: bool,
430    ) -> String {
431        if suppress_author {
432            wrapped_content.to_string()
433        } else {
434            format!("{author_part} {wrapped_content}")
435        }
436    }
437
438    fn format_non_integral_grouped_items(
439        &self,
440        author_part: &str,
441        author_item_delimiter: &str,
442        joined_items: &str,
443        suppress_author: bool,
444    ) -> String {
445        if suppress_author {
446            return joined_items.to_string();
447        }
448
449        if let Some(adjusted) =
450            self.adjust_grouped_author_quote_punctuation(author_part, author_item_delimiter)
451        {
452            return format!("{adjusted}{joined_items}");
453        }
454
455        format!("{author_part}{author_item_delimiter}{joined_items}")
456    }
457
458    fn adjust_grouped_author_quote_punctuation(
459        &self,
460        author_part: &str,
461        author_item_delimiter: &str,
462    ) -> Option<String> {
463        if !self.config.punctuation_in_quote
464            || !author_item_delimiter.starts_with(',')
465            || !(author_part.ends_with('"') || author_part.ends_with('\u{201D}'))
466        {
467            return None;
468        }
469
470        let is_curly = author_part.ends_with('\u{201D}');
471        let quote_char = if is_curly { '\u{201D}' } else { '"' };
472        #[allow(clippy::string_slice, reason = "quote found at end")]
473        let trimmed = &author_part[..author_part.len() - quote_char.len_utf8()];
474        #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
475        Some(format!(
476            "{trimmed},{quote_char}{}",
477            &author_item_delimiter[1..]
478        ))
479    }
480
481    fn render_group_item_parts_with_format<F>(
482        &self,
483        fmt: &F,
484        group: &[&crate::reference::CitationItem],
485        params: &GroupRenderParams<'_>,
486    ) -> Result<(Vec<String>, Option<String>, Option<WrapConfig>), ProcessorError>
487    where
488        F: crate::render::format::OutputFormat<Output = String>,
489    {
490        let mut item_parts = Vec::new();
491        let mut group_delimiter: Option<String> = None;
492        // For integral multi-item same-author groups, capture the full WrapConfig
493        // (punctuation + inner_prefix/inner_suffix) from the first item's filtered
494        // template and strip the wrap from all items. The caller applies it once,
495        // format-aware, around the joined year string.
496        // Non-integral groups preserve per-item wraps (they may be the primary
497        // wrapping when no cluster-level wrap exists, e.g. author-date disambiguation).
498        let mut captured_year_wrap: Option<WrapConfig> = None;
499        let collapse_group = group.len() > 1
500            && matches!(params.mode, citum_schema::citation::CitationMode::Integral);
501        for (index, item) in group.iter().enumerate() {
502            let state = self.resolve_item_render_state(item, params.spec)?;
503            let (mut filtered_template, leading_affix, strip_item_delimiter) =
504                filter_author_from_template(&state.template);
505            if collapse_group {
506                if index == 0 {
507                    // Capture the full WrapConfig from the first remaining component
508                    // (typically the date or date-group). Preserves inner_prefix and
509                    // inner_suffix alongside punctuation so the caller can apply the
510                    // wrap format-aware via fmt.inner_affix + fmt.wrap_punctuation.
511                    captured_year_wrap = filtered_template
512                        .first_mut()
513                        .and_then(|c| c.rendering_mut().wrap.take());
514                } else {
515                    // Strip the wrap on subsequent items to match the first item.
516                    if let Some(first) = filtered_template.first_mut() {
517                        first.rendering_mut().wrap = None;
518                    }
519                }
520            }
521            if group_delimiter.is_none() {
522                group_delimiter = leading_affix
523                    .as_ref()
524                    .filter(|value| !value.is_empty())
525                    .cloned();
526            }
527            let item_delimiter = if strip_item_delimiter {
528                ""
529            } else {
530                params.intra_delimiter
531            };
532            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
533                state.reference,
534                GroupItemRenderRequest {
535                    item: state.item,
536                    template: &filtered_template,
537                    mode: params.mode,
538                    suppress_author: params.suppress_author,
539                    position: params.position,
540                    note_start_text_case: params.note_start_text_case,
541                    delimiter: item_delimiter,
542                },
543            ) && !item_str.is_empty()
544            {
545                let prefix = (index > 0).then_some(item.prefix.as_deref()).flatten();
546                item_parts.push(self.affix_content(fmt, item_str, prefix, item.suffix.as_deref()));
547            }
548        }
549        Ok((item_parts, group_delimiter, captured_year_wrap))
550    }
551
552    fn resolve_group_render_state<'b>(
553        &'b self,
554        group: &'b [&'b crate::reference::CitationItem],
555        spec: &'b citum_schema::CitationSpec,
556    ) -> Result<GroupRenderState<'b>, ProcessorError> {
557        #[allow(clippy::indexing_slicing, reason = "groups are non-empty")]
558        let first_item = group[0];
559        let first_ref = self
560            .bibliography
561            .get(&first_item.id)
562            .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
563        let first_language = crate::values::effective_item_language(first_ref);
564        let default_template = spec
565            .resolve_template_for_language(first_language.as_deref())
566            .map(Cow::Owned);
567
568        let ref_type = first_ref.ref_type();
569        let first_template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
570            .map(Cow::Borrowed)
571            .or(default_template);
572
573        Ok(GroupRenderState {
574            first_item,
575            first_ref,
576            template: first_template.unwrap_or(Cow::Borrowed(&[])),
577        })
578    }
579
580    fn resolve_item_render_state<'b>(
581        &'b self,
582        item: &'b crate::reference::CitationItem,
583        spec: &'b citum_schema::CitationSpec,
584    ) -> Result<ItemRenderState<'b>, ProcessorError> {
585        let reference = self
586            .bibliography
587            .get(&item.id)
588            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
589        let item_language = crate::values::effective_item_language(reference);
590        let default_template = spec
591            .resolve_template_for_language(item_language.as_deref())
592            .map(Cow::Owned);
593
594        let ref_type = reference.ref_type();
595        let item_template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
596            .map(Cow::Borrowed)
597            .or(default_template);
598
599        Ok(ItemRenderState {
600            item,
601            reference,
602            template: item_template.unwrap_or(Cow::Borrowed(&[])),
603        })
604    }
605
606    fn try_render_integral_group_with_format<F>(
607        &self,
608        group: &[&crate::reference::CitationItem],
609        spec: &citum_schema::CitationSpec,
610        mode: &citum_schema::citation::CitationMode,
611        suppress_author: bool,
612        position: Option<&citum_schema::citation::Position>,
613    ) -> Result<Option<String>, ProcessorError>
614    where
615        F: crate::render::format::OutputFormat<Output = String>,
616    {
617        if !matches!(mode, citum_schema::citation::CitationMode::Integral)
618            || !self.has_explicit_integral_template()
619        {
620            return Ok(None);
621        }
622
623        self.render_integral_explicit_group::<F>(group, spec, mode, suppress_author, position)
624    }
625
626    /// Returns true for non-integral citation types that must render as a single
627    /// unit via [`render_special_type_items`] rather than the split author+items
628    /// path used for standard author-date groups.
629    ///
630    /// Title-first types (`legal-case`, `treaty`, `hearing`) need this because
631    /// their type-variant template leads with a title component, not a
632    /// contributor. The grouped path strips only `Contributor::Author`, so the
633    /// title would render twice (plain in the author slot, emph in the item
634    /// slot). `personal-communication` is included because its per-item date
635    /// and term must stay together and not be collapsed across items.
636    fn requires_full_group_item_rendering(
637        &self,
638        mode: &citum_schema::citation::CitationMode,
639        reference: &Reference,
640    ) -> bool {
641        matches!(mode, citum_schema::citation::CitationMode::NonIntegral)
642            && matches!(
643                reference.ref_type().as_str(),
644                "legal-case" | "treaty" | "hearing" | "personal-communication"
645            )
646    }
647
648    /// Render just the author part for citation grouping.
649    pub(crate) fn render_author_for_grouping_with_format<F>(
650        &self,
651        reference: &Reference,
652        item: &crate::reference::CitationItem,
653        template: &[TemplateComponent],
654        mode: &citum_schema::citation::CitationMode,
655        suppress_author: bool,
656        position: Option<&citum_schema::citation::Position>,
657    ) -> String
658    where
659        F: crate::render::format::OutputFormat<Output = String>,
660    {
661        let is_note_processing = self.config.processing.as_ref().is_some_and(|processing| {
662            matches!(processing, citum_schema::options::Processing::Note)
663        });
664        if is_note_processing
665            && matches!(
666                position,
667                Some(
668                    citum_schema::citation::Position::Ibid
669                        | citum_schema::citation::Position::IbidWithLocator
670                )
671            )
672            && !template.iter().any(has_contributor_component)
673        {
674            return String::new();
675        }
676
677        let options = self.citation_render_options(mode.clone(), suppress_author, None, None);
678
679        // Try to use the first semantically relevant component (including nested lists)
680        // so disambiguation hints and component-specific formatting are preserved.
681        // This ensures substitution, shortening, and mode-dependent conjunctions are respected.
682        if let Some(comp) = template.first().and_then(find_grouping_component) {
683            let base_hints = self
684                .hints
685                .get(reference.id().as_deref().unwrap_or_default())
686                .cloned()
687                .unwrap_or_default();
688            // Inject citation position so subsequent et-al thresholds are applied.
689            let hints = ProcHints {
690                position: position.cloned(),
691                integral_name_state: item.integral_name_state,
692                ..base_hints
693            };
694            if let Some(vals) = comp.values::<F>(reference, &hints, &options)
695                && !vals.value.is_empty()
696            {
697                return vals.value;
698            }
699        }
700
701        // Fallback for cases where first component isn't suitable or returned empty
702        if let Some(authors) = reference.author() {
703            let names_vec = self.resolve_contributor_names(&authors);
704            F::default().text(&crate::values::format_contributors_short(
705                &names_vec, &options,
706            ))
707        } else {
708            String::new()
709        }
710    }
711
712    /// Render the prose anchor for an integral citation without any trailing note text.
713    pub(crate) fn render_integral_anchor_with_format<F>(
714        &self,
715        items: &[crate::reference::CitationItem],
716        spec: &citum_schema::CitationSpec,
717        inter_delimiter: &str,
718        suppress_author: bool,
719        position: Option<&citum_schema::citation::Position>,
720    ) -> Result<String, ProcessorError>
721    where
722        F: crate::render::format::OutputFormat<Output = String>,
723    {
724        let groups = group_citation_items_by_author(self, items);
725
726        let mut rendered_groups = Vec::new();
727        let fmt = F::default();
728        for (_author_key, group) in groups {
729            #[allow(
730                clippy::indexing_slicing,
731                reason = "group is non-empty by construction"
732            )]
733            let first_item = group[0];
734            let reference = self
735                .bibliography
736                .get(&first_item.id)
737                .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
738            let item_language = crate::values::effective_item_language(reference);
739            let template = spec.resolve_template_for_language(item_language.as_deref());
740            let effective_template = template.as_deref().unwrap_or(&[]);
741            let author_part = self.render_author_for_grouping_with_format::<F>(
742                reference,
743                first_item,
744                effective_template,
745                &citum_schema::citation::CitationMode::Integral,
746                suppress_author,
747                position,
748            );
749            if !author_part.is_empty() {
750                rendered_groups.push(author_part);
751            }
752        }
753
754        Ok(fmt.join(rendered_groups, inter_delimiter))
755    }
756
757    /// Get the citation number for a reference, assigning one if not yet cited.
758    #[must_use]
759    pub fn get_or_assign_citation_number(&self, ref_id: &str) -> usize {
760        let mut numbers = self.citation_numbers.borrow_mut();
761        let next_num = numbers.len() + 1;
762        *numbers.entry(ref_id.to_string()).or_insert(next_num)
763    }
764
765    /// Process a bibliography entry.
766    #[must_use]
767    pub fn process_bibliography_entry(
768        &self,
769        reference: &Reference,
770        entry_number: usize,
771    ) -> Option<ProcTemplate> {
772        self.process_bibliography_entry_with_format::<crate::render::plain::PlainText>(
773            reference,
774            entry_number,
775        )
776    }
777
778    /// Process a bibliography entry with specific format.
779    #[must_use]
780    pub fn process_bibliography_entry_with_format<F>(
781        &self,
782        reference: &Reference,
783        entry_number: usize,
784    ) -> Option<ProcTemplate>
785    where
786        F: crate::render::format::OutputFormat<Output = String>,
787    {
788        let bib_spec = self.style.bibliography.as_ref()?;
789
790        let item_language = crate::values::effective_item_language(reference);
791        let default_template = bib_spec
792            .resolve_template_for_language(item_language.as_deref())
793            .map(Cow::Owned);
794
795        let ref_type = reference.ref_type();
796        let template = resolve_type_variant(bib_spec.type_variants.as_ref(), &ref_type)
797            .map(Cow::Borrowed)
798            .or(default_template)?;
799
800        let template = self.apply_anonymous_entry_bibliography_policy(reference, template)?;
801        let template = self.apply_article_journal_bibliography_policy(reference, template);
802
803        self.process_template_request_with_format::<F>(
804            reference,
805            TemplateRenderRequest {
806                template: template.as_ref(),
807                context: RenderContext::Bibliography,
808                mode: citum_schema::citation::CitationMode::NonIntegral,
809                suppress_author: false,
810                locator_raw: None,
811                citation_number: entry_number,
812                position: None,
813                note_start_text_case: None,
814                integral_name_state: None,
815                org_abbreviation_state: None,
816                first_reference_note_number: None,
817            },
818        )
819    }
820
821    /// Process a template for a reference using plain text format.
822    ///
823    /// Accepts a [`TemplateRenderParams`] bundle rather than individual arguments
824    /// to keep the call site readable and avoid argument-count lint issues.
825    #[must_use]
826    pub fn process_template_with_number(
827        &self,
828        reference: &Reference,
829        params: TemplateRenderParams<'_>,
830    ) -> Option<ProcTemplate> {
831        self.process_template_with_number_with_format::<crate::render::plain::PlainText>(
832            reference, params,
833        )
834    }
835
836    /// Process a template for a reference with a specific output format.
837    ///
838    /// Accepts a [`TemplateRenderParams`] bundle rather than individual arguments
839    /// to keep the call site readable and avoid argument-count lint issues.
840    pub fn process_template_with_number_with_format<F>(
841        &self,
842        reference: &Reference,
843        params: TemplateRenderParams<'_>,
844    ) -> Option<ProcTemplate>
845    where
846        F: crate::render::format::OutputFormat<Output = String>,
847    {
848        self.process_template_request_with_format::<F>(
849            reference,
850            TemplateRenderRequest {
851                template: params.template,
852                context: params.context,
853                mode: params.mode,
854                suppress_author: params.suppress_author,
855                locator_raw: params.locator_raw,
856                citation_number: params.citation_number,
857                position: params.position.cloned(),
858                note_start_text_case: params.note_start_text_case,
859                integral_name_state: params.integral_name_state,
860                org_abbreviation_state: params.org_abbreviation_state,
861                first_reference_note_number: None,
862            },
863        )
864    }
865
866    /// Process a template request with a specific output format.
867    #[must_use]
868    pub fn process_template_request_with_format<F>(
869        &self,
870        reference: &Reference,
871        request: TemplateRenderRequest<'_>,
872    ) -> Option<ProcTemplate>
873    where
874        F: crate::render::format::OutputFormat<Output = String>,
875    {
876        let TemplateRenderRequest {
877            template,
878            context,
879            mode,
880            suppress_author,
881            locator_raw,
882            citation_number,
883            position,
884            note_start_text_case,
885            integral_name_state,
886            org_abbreviation_state,
887            first_reference_note_number,
888        } = request;
889        let ref_type = reference.ref_type();
890        let options = RenderOptions {
891            config: self.config,
892            bibliography_config: self.bibliography_config.clone(),
893            locale: self.locale,
894            context,
895            mode,
896            suppress_author,
897            locator_raw,
898            ref_type: Some(ref_type.clone()),
899            show_semantics: self.show_semantics,
900            current_template_index: None,
901            abbreviation_map: self.abbreviation_map,
902        };
903        // Only carry the first-reference note number (and its suppression side-effect)
904        // when the template actually renders it.  Suppressing a `disambiguate-only`
905        // title without emitting the note number as a replacement identifier would
906        // silently reintroduce ambiguity for colliding works.
907        let effective_first_ref_note = if template_uses_first_ref_note_number(template) {
908            first_reference_note_number
909        } else {
910            None
911        };
912        let hint = self.build_template_render_hint(HintInputs {
913            reference,
914            context: options.context,
915            citation_number,
916            position,
917            integral_name_state,
918            org_abbreviation_state,
919            first_reference_note_number: effective_first_ref_note,
920        });
921        let mut components =
922            self.render_template_components::<F>(reference, &ref_type, &options, &hint, template);
923
924        self.apply_sentence_initial_context::<F>(&mut components, context, note_start_text_case);
925
926        (!components.is_empty()).then_some(components)
927    }
928
929    /// Render each top-level template component for `reference`, threading a
930    /// fresh `TemplateRenderContext` per index so the source position is
931    /// preserved in AST-injection mode.
932    fn render_template_components<F>(
933        &self,
934        reference: &Reference,
935        ref_type: &str,
936        options: &RenderOptions<'_>,
937        hint: &ProcHints,
938        template: &[TemplateComponent],
939    ) -> Vec<ProcTemplateComponent>
940    where
941        F: crate::render::format::OutputFormat<Output = String>,
942    {
943        let mut tracker = TemplateComponentTracker::default();
944        let mut components = Vec::with_capacity(template.len());
945        let mut component_options = options.clone();
946        for (template_index, component) in template.iter().enumerate() {
947            component_options.current_template_index =
948                self.inject_ast_indices.then_some(template_index);
949            let ctx = TemplateRenderContext {
950                reference,
951                ref_type,
952                options: &component_options,
953                hint,
954                template_index,
955            };
956            if let Some(component) =
957                self.render_template_component_with_format::<F>(&ctx, component, &mut tracker)
958            {
959                components.push(component);
960            }
961        }
962        components
963    }
964
965    fn build_template_render_hint(&self, inputs: HintInputs<'_>) -> ProcHints {
966        let HintInputs {
967            reference,
968            context,
969            citation_number,
970            position,
971            integral_name_state,
972            org_abbreviation_state,
973            first_reference_note_number,
974        } = inputs;
975        let default_hint = ProcHints::default();
976        let base_hint = self
977            .hints
978            .get(reference.id().as_deref().unwrap_or_default())
979            .unwrap_or(&default_hint);
980        let is_subsequent = matches!(position, Some(citum_schema::citation::Position::Subsequent));
981        ProcHints {
982            citation_number: (citation_number > 0).then_some(citation_number),
983            citation_sub_label: if context == RenderContext::Citation {
984                reference
985                    .id()
986                    .as_deref()
987                    .and_then(|id| self.citation_sub_label_for_ref(id))
988            } else {
989                None
990            },
991            position,
992            integral_name_state,
993            org_abbreviation_state,
994            first_reference_note_number: if is_subsequent {
995                first_reference_note_number
996            } else {
997                None
998            },
999            suppress_disambiguation_title: is_subsequent && first_reference_note_number.is_some(),
1000            ..base_hint.clone()
1001        }
1002    }
1003
1004    fn render_template_component_with_format<F>(
1005        &self,
1006        ctx: &TemplateRenderContext<'_>,
1007        component: &TemplateComponent,
1008        tracker: &mut TemplateComponentTracker,
1009    ) -> Option<ProcTemplateComponent>
1010    where
1011        F: crate::render::format::OutputFormat<Output = String>,
1012    {
1013        if let TemplateComponent::Group(group) = component {
1014            return self.render_group_component_with_format::<F>(ctx, group, tracker);
1015        }
1016
1017        let resolved_component = component;
1018        let var_key = get_variable_key(resolved_component);
1019        if tracker.should_skip(var_key.as_deref()) {
1020            return None;
1021        }
1022
1023        let mut values = resolved_component.values::<F>(ctx.reference, ctx.hint, ctx.options)?;
1024        // Suppress affixes when a component resolves to no meaningful content.
1025        // A whitespace-only value carries no data, so its prefix/suffix must
1026        // not leak into output (e.g. a ". In " prefix on an empty editor list).
1027        if values.value.trim().is_empty() {
1028            return None;
1029        }
1030        self.apply_issued_no_date_fallback(
1031            ctx.reference,
1032            ctx.options,
1033            resolved_component,
1034            &mut values,
1035        );
1036        self.apply_entry_link_fallback(ctx.reference, ctx.options, &mut values);
1037
1038        let item_language =
1039            crate::values::effective_component_language(ctx.reference, resolved_component);
1040        tracker.mark_rendered(var_key, values.substituted_key.as_deref());
1041
1042        Some(ProcTemplateComponent {
1043            template_component: resolved_component.clone(),
1044            template_index: self.inject_ast_indices.then_some(ctx.template_index),
1045            value: values.value,
1046            prefix: values.prefix,
1047            suffix: values.suffix,
1048            url: values.url,
1049            ref_type: Some(ctx.ref_type.to_string()),
1050            config: Some(ctx.options.config.clone()),
1051            bibliography_config: ctx.options.bibliography_config.clone(),
1052            item_language,
1053            sentence_initial: false,
1054            pre_formatted: values.pre_formatted,
1055        })
1056    }
1057
1058    fn render_group_component_with_format<F>(
1059        &self,
1060        ctx: &TemplateRenderContext<'_>,
1061        group: &citum_schema::template::TemplateGroup,
1062        tracker: &mut TemplateComponentTracker,
1063    ) -> Option<ProcTemplateComponent>
1064    where
1065        F: crate::render::format::OutputFormat<Output = String>,
1066    {
1067        let fmt = F::default();
1068        let values = self.render_group_child_values(&fmt, ctx, group, tracker)?;
1069        let delimiter = group
1070            .delimiter
1071            .as_ref()
1072            .unwrap_or(&citum_schema::template::DelimiterPunctuation::Comma)
1073            .to_string_with_space();
1074        let group_component = TemplateComponent::Group(group.clone());
1075        Some(ProcTemplateComponent {
1076            template_component: group_component.clone(),
1077            template_index: self.inject_ast_indices.then_some(ctx.template_index),
1078            value: fmt.join(values, &delimiter),
1079            prefix: None,
1080            suffix: None,
1081            url: None,
1082            ref_type: Some(ctx.ref_type.to_string()),
1083            config: Some(ctx.options.config.clone()),
1084            bibliography_config: ctx.options.bibliography_config.clone(),
1085            item_language: crate::values::effective_component_language(
1086                ctx.reference,
1087                &group_component,
1088            ),
1089            sentence_initial: false,
1090            pre_formatted: true,
1091        })
1092    }
1093
1094    /// Render the children of a template group into rendered strings, dropping
1095    /// empty values. Returns `None` when no child carries meaningful content
1096    /// (i.e. only term-only siblings produced output). Borrows the parent
1097    /// `fmt` so a stateful `OutputFormat` sees a single instance for both
1098    /// child rendering and the final `join` in the caller.
1099    fn render_group_child_values<F>(
1100        &self,
1101        fmt: &F,
1102        ctx: &TemplateRenderContext<'_>,
1103        group: &citum_schema::template::TemplateGroup,
1104        tracker: &mut TemplateComponentTracker,
1105    ) -> Option<Vec<String>>
1106    where
1107        F: crate::render::format::OutputFormat<Output = String>,
1108    {
1109        let mut has_meaningful_content = false;
1110        let mut values = Vec::with_capacity(group.group.len());
1111
1112        for item in &group.group {
1113            let Some(rendered) =
1114                self.render_template_component_with_format::<F>(ctx, item, tracker)
1115            else {
1116                continue;
1117            };
1118            let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1119                &rendered,
1120                fmt,
1121                ctx.options.show_semantics,
1122            );
1123            if rendered_str.trim().is_empty() {
1124                continue;
1125            }
1126            if !is_term_only_component(item) {
1127                has_meaningful_content = true;
1128            }
1129            values.push(rendered_str);
1130        }
1131
1132        (has_meaningful_content && !values.is_empty()).then_some(values)
1133    }
1134
1135    fn apply_issued_no_date_fallback(
1136        &self,
1137        reference: &Reference,
1138        options: &RenderOptions<'_>,
1139        component: &TemplateComponent,
1140        values: &mut crate::values::ProcValues<String>,
1141    ) {
1142        if !matches!(
1143            component,
1144            TemplateComponent::Date(citum_schema::template::TemplateDate {
1145                date: citum_schema::template::DateVariable::Issued,
1146                ..
1147            })
1148        ) || reference.csl_issued_date().is_some()
1149            || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1150        {
1151            return;
1152        }
1153
1154        if let Some(long) = options.locale.resolved_general_term(
1155            &citum_schema::locale::GeneralTerm::NoDate,
1156            &citum_schema::locale::TermForm::Long,
1157            None,
1158        ) {
1159            values.value = long;
1160        }
1161    }
1162
1163    fn apply_entry_link_fallback(
1164        &self,
1165        reference: &Reference,
1166        options: &RenderOptions<'_>,
1167        values: &mut crate::values::ProcValues<String>,
1168    ) {
1169        if values.url.is_some() {
1170            return;
1171        }
1172
1173        let Some(links) = &options.config.links else {
1174            return;
1175        };
1176        use citum_schema::options::LinkAnchor;
1177        if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1178            values.url = crate::values::resolve_url(links, reference);
1179        }
1180    }
1181
1182    /// Apply the substitution string to the primary contributor component.
1183    pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1184        self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1185            proc, substitute,
1186        );
1187    }
1188
1189    /// Apply the substitution string to the primary contributor component with specific format.
1190    pub fn apply_author_substitution_with_format<F>(
1191        &self,
1192        proc: &mut ProcTemplate,
1193        substitute: &str,
1194    ) where
1195        F: crate::render::format::OutputFormat<Output = String>,
1196    {
1197        if let Some(component) = proc
1198            .iter_mut()
1199            .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1200        {
1201            let fmt = F::default();
1202            component.value = fmt.text(substitute);
1203        }
1204    }
1205
1206    fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1207        match self
1208            .style
1209            .info
1210            .source
1211            .as_ref()
1212            .map(|source| source.csl_id.as_str())
1213        {
1214            Some("http://www.zotero.org/styles/harvard-cite-them-right") => {
1215                citum_schema::locale::TermForm::Long
1216            }
1217            _ => citum_schema::locale::TermForm::Short,
1218        }
1219    }
1220
1221    fn render_group_item_from_template_with_format<F>(
1222        &self,
1223        reference: &Reference,
1224        item_request: GroupItemRenderRequest<'_>,
1225    ) -> Option<String>
1226    where
1227        F: crate::render::format::OutputFormat<Output = String>,
1228    {
1229        let request = self.citation_render_request(
1230            item_request.item,
1231            item_request.template,
1232            item_request.mode,
1233            item_request.suppress_author,
1234            item_request.position,
1235            item_request.note_start_text_case,
1236        );
1237        self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1238    }
1239}
1240
1241/// Return `true` when `template` (or any nested group) contains a
1242/// `number: first-reference-note-number` component.
1243///
1244/// Used to gate `suppress_disambiguation_title`: if the style's template does
1245/// not render the note-number identifier, there is nothing to replace the
1246/// suppressed title and ambiguity would silently be reintroduced.
1247pub(super) fn template_uses_first_ref_note_number(template: &[TemplateComponent]) -> bool {
1248    template.iter().any(|c| match c {
1249        TemplateComponent::Number(n) => {
1250            n.number == citum_schema::template::NumberVariable::FirstReferenceNoteNumber
1251        }
1252        TemplateComponent::Group(g) => template_uses_first_ref_note_number(&g.group),
1253        _ => false,
1254    })
1255}
1256
1257pub(super) fn filter_author_from_template(
1258    template: &[TemplateComponent],
1259) -> (Vec<TemplateComponent>, Option<String>, bool) {
1260    let mut filtered: Vec<TemplateComponent> =
1261        template.iter().filter_map(strip_author_component).collect();
1262    let stripped_leading_affix = filtered.first().and_then(leading_group_affix);
1263    let leading_affix = stripped_leading_affix.clone().or_else(|| {
1264        filtered
1265            .first()
1266            .and_then(|_| template.first().and_then(author_group_delimiter_affix))
1267    });
1268    if let Some(first) = filtered.first_mut() {
1269        strip_leading_group_affixes(first);
1270    }
1271    (filtered, leading_affix, stripped_leading_affix.is_some())
1272}
1273
1274fn author_group_delimiter_affix(component: &TemplateComponent) -> Option<String> {
1275    let TemplateComponent::Group(group) = component else {
1276        return None;
1277    };
1278    group
1279        .group
1280        .first()
1281        .is_some_and(component_starts_with_author)
1282        .then_some(group.delimiter.as_ref())
1283        .flatten()
1284        .map(citum_schema::template::DelimiterPunctuation::to_string_with_space)
1285        .filter(|delimiter| !delimiter.is_empty())
1286}
1287
1288fn component_starts_with_author(component: &TemplateComponent) -> bool {
1289    match component {
1290        TemplateComponent::Contributor(contributor) => {
1291            contributor.contributor == citum_schema::template::ContributorRole::Author
1292        }
1293        TemplateComponent::Group(group) => group
1294            .group
1295            .first()
1296            .is_some_and(component_starts_with_author),
1297        _ => false,
1298    }
1299}