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;
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        if let Some(citation) = self.try_render_integral_group_with_format::<F>(
267            group,
268            params.spec,
269            params.mode,
270            params.suppress_author,
271            params.position,
272        )? {
273            return Ok(vec![citation]);
274        }
275
276        if self.requires_full_group_item_rendering(params.mode, state.first_ref) {
277            return self.render_special_type_items::<F>(group, params);
278        }
279
280        Ok(self
281            .render_fallback_grouped_citation_with_format::<F>(
282                group,
283                state.first_ref,
284                state.first_item,
285                &state.template,
286                params,
287            )?
288            .into_iter()
289            .collect())
290    }
291
292    fn render_fallback_grouped_citation_with_format<F>(
293        &self,
294        group: &[&crate::reference::CitationItem],
295        first_ref: &Reference,
296        first_item: &crate::reference::CitationItem,
297        template: &[TemplateComponent],
298        params: &GroupRenderParams<'_>,
299    ) -> Result<Option<String>, ProcessorError>
300    where
301        F: crate::render::format::OutputFormat<Output = String>,
302    {
303        let fmt = F::default();
304        let author_part = self.render_author_for_grouping_with_format::<F>(
305            first_ref,
306            first_item,
307            template,
308            params.mode,
309            params.suppress_author,
310            params.position,
311        );
312        let (item_parts, group_delimiter) =
313            self.render_group_item_parts_with_format::<F>(&fmt, group, params)?;
314        let Some(content) = self.build_grouped_citation_content(
315            &author_part,
316            &item_parts,
317            params,
318            group_delimiter.as_deref(),
319        ) else {
320            return Ok(None);
321        };
322        let group_ids = group.iter().map(|item| item.id.clone()).collect();
323        let prefix = first_item.prefix.as_deref().unwrap_or("");
324        // Suffix is embedded in item_parts by render_group_item_parts_with_format when
325        // item_parts is non-empty. Apply it here only when item_parts was empty (author-only output).
326        let suffix = if item_parts.is_empty() {
327            first_item.suffix.as_deref()
328        } else {
329            None
330        };
331
332        Ok(Some(fmt.citation(
333            group_ids,
334            self.affix_content(&fmt, content, Some(prefix), suffix),
335        )))
336    }
337
338    fn build_grouped_citation_content(
339        &self,
340        author_part: &str,
341        item_parts: &[String],
342        params: &GroupRenderParams<'_>,
343        group_delimiter: Option<&str>,
344    ) -> Option<String> {
345        if !author_part.is_empty() && !item_parts.is_empty() {
346            let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
347            let joined_items = match params.mode {
348                citum_schema::citation::CitationMode::Integral => {
349                    self.join_integral_group_item_parts(item_parts, author_item_delimiter)
350                }
351                citum_schema::citation::CitationMode::NonIntegral => {
352                    let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
353                        ", "
354                    } else {
355                        author_item_delimiter
356                    };
357                    item_parts.join(repeated_item_delimiter)
358                }
359            };
360            return Some(match params.mode {
361                citum_schema::citation::CitationMode::Integral => self
362                    .format_integral_grouped_items(
363                        author_part,
364                        &joined_items,
365                        params.suppress_author,
366                    ),
367                citum_schema::citation::CitationMode::NonIntegral => self
368                    .format_non_integral_grouped_items(
369                        author_part,
370                        author_item_delimiter,
371                        &joined_items,
372                        params.suppress_author,
373                    ),
374            });
375        }
376
377        if !author_part.is_empty() {
378            return Some(author_part.to_string());
379        }
380
381        if !item_parts.is_empty() {
382            return Some(item_parts.join(params.intra_delimiter));
383        }
384
385        None
386    }
387
388    fn format_integral_grouped_items(
389        &self,
390        author_part: &str,
391        joined_items: &str,
392        suppress_author: bool,
393    ) -> String {
394        if suppress_author {
395            format!("({joined_items})")
396        } else {
397            format!("{author_part} ({joined_items})")
398        }
399    }
400
401    fn format_non_integral_grouped_items(
402        &self,
403        author_part: &str,
404        author_item_delimiter: &str,
405        joined_items: &str,
406        suppress_author: bool,
407    ) -> String {
408        if suppress_author {
409            return joined_items.to_string();
410        }
411
412        if let Some(adjusted) =
413            self.adjust_grouped_author_quote_punctuation(author_part, author_item_delimiter)
414        {
415            return format!("{adjusted}{joined_items}");
416        }
417
418        format!("{author_part}{author_item_delimiter}{joined_items}")
419    }
420
421    fn adjust_grouped_author_quote_punctuation(
422        &self,
423        author_part: &str,
424        author_item_delimiter: &str,
425    ) -> Option<String> {
426        if !self.config.punctuation_in_quote
427            || !author_item_delimiter.starts_with(',')
428            || !(author_part.ends_with('"') || author_part.ends_with('\u{201D}'))
429        {
430            return None;
431        }
432
433        let is_curly = author_part.ends_with('\u{201D}');
434        let quote_char = if is_curly { '\u{201D}' } else { '"' };
435        #[allow(clippy::string_slice, reason = "quote found at end")]
436        let trimmed = &author_part[..author_part.len() - quote_char.len_utf8()];
437        #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
438        Some(format!(
439            "{trimmed},{quote_char}{}",
440            &author_item_delimiter[1..]
441        ))
442    }
443
444    fn render_group_item_parts_with_format<F>(
445        &self,
446        fmt: &F,
447        group: &[&crate::reference::CitationItem],
448        params: &GroupRenderParams<'_>,
449    ) -> Result<(Vec<String>, Option<String>), ProcessorError>
450    where
451        F: crate::render::format::OutputFormat<Output = String>,
452    {
453        let mut item_parts = Vec::new();
454        let mut group_delimiter: Option<String> = None;
455        for (index, item) in group.iter().enumerate() {
456            let state = self.resolve_item_render_state(item, params.spec)?;
457            let (filtered_template, leading_affix) = filter_author_from_template(&state.template);
458            if group_delimiter.is_none() {
459                group_delimiter = leading_affix
460                    .as_ref()
461                    .filter(|value| !value.is_empty())
462                    .cloned();
463            }
464            let item_delimiter = if leading_affix.is_some() {
465                ""
466            } else {
467                params.intra_delimiter
468            };
469            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
470                state.reference,
471                GroupItemRenderRequest {
472                    item: state.item,
473                    template: &filtered_template,
474                    mode: params.mode,
475                    suppress_author: params.suppress_author,
476                    position: params.position,
477                    note_start_text_case: params.note_start_text_case,
478                    delimiter: item_delimiter,
479                },
480            ) && !item_str.is_empty()
481            {
482                let prefix = (index > 0).then_some(item.prefix.as_deref()).flatten();
483                item_parts.push(self.affix_content(fmt, item_str, prefix, item.suffix.as_deref()));
484            }
485        }
486        Ok((item_parts, group_delimiter))
487    }
488
489    fn resolve_group_render_state<'b>(
490        &'b self,
491        group: &'b [&'b crate::reference::CitationItem],
492        spec: &'b citum_schema::CitationSpec,
493    ) -> Result<GroupRenderState<'b>, ProcessorError> {
494        #[allow(clippy::indexing_slicing, reason = "groups are non-empty")]
495        let first_item = group[0];
496        let first_ref = self
497            .bibliography
498            .get(&first_item.id)
499            .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
500        let first_language = crate::values::effective_item_language(first_ref);
501        let default_template = spec
502            .resolve_template_for_language(first_language.as_deref())
503            .map(Cow::Owned);
504
505        let ref_type = first_ref.ref_type();
506        let first_template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
507            .map(Cow::Borrowed)
508            .or(default_template);
509
510        Ok(GroupRenderState {
511            first_item,
512            first_ref,
513            template: first_template.unwrap_or(Cow::Borrowed(&[])),
514        })
515    }
516
517    fn resolve_item_render_state<'b>(
518        &'b self,
519        item: &'b crate::reference::CitationItem,
520        spec: &'b citum_schema::CitationSpec,
521    ) -> Result<ItemRenderState<'b>, ProcessorError> {
522        let reference = self
523            .bibliography
524            .get(&item.id)
525            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
526        let item_language = crate::values::effective_item_language(reference);
527        let default_template = spec
528            .resolve_template_for_language(item_language.as_deref())
529            .map(Cow::Owned);
530
531        let ref_type = reference.ref_type();
532        let item_template = resolve_type_variant(spec.type_variants.as_ref(), &ref_type)
533            .map(Cow::Borrowed)
534            .or(default_template);
535
536        Ok(ItemRenderState {
537            item,
538            reference,
539            template: item_template.unwrap_or(Cow::Borrowed(&[])),
540        })
541    }
542
543    fn try_render_integral_group_with_format<F>(
544        &self,
545        group: &[&crate::reference::CitationItem],
546        spec: &citum_schema::CitationSpec,
547        mode: &citum_schema::citation::CitationMode,
548        suppress_author: bool,
549        position: Option<&citum_schema::citation::Position>,
550    ) -> Result<Option<String>, ProcessorError>
551    where
552        F: crate::render::format::OutputFormat<Output = String>,
553    {
554        if !matches!(mode, citum_schema::citation::CitationMode::Integral)
555            || !self.has_explicit_integral_template()
556        {
557            return Ok(None);
558        }
559
560        self.render_integral_explicit_group::<F>(group, spec, mode, suppress_author, position)
561    }
562
563    /// Returns true for non-integral citation types that must render as a single
564    /// unit via [`render_special_type_items`] rather than the split author+items
565    /// path used for standard author-date groups.
566    ///
567    /// Title-first types (`legal-case`, `treaty`, `hearing`) need this because
568    /// their type-variant template leads with a title component, not a
569    /// contributor. The grouped path strips only `Contributor::Author`, so the
570    /// title would render twice (plain in the author slot, emph in the item
571    /// slot). `personal-communication` is included because its per-item date
572    /// and term must stay together and not be collapsed across items.
573    fn requires_full_group_item_rendering(
574        &self,
575        mode: &citum_schema::citation::CitationMode,
576        reference: &Reference,
577    ) -> bool {
578        matches!(mode, citum_schema::citation::CitationMode::NonIntegral)
579            && matches!(
580                reference.ref_type().as_str(),
581                "legal-case" | "treaty" | "hearing" | "personal-communication"
582            )
583    }
584
585    /// Render just the author part for citation grouping.
586    pub(crate) fn render_author_for_grouping_with_format<F>(
587        &self,
588        reference: &Reference,
589        item: &crate::reference::CitationItem,
590        template: &[TemplateComponent],
591        mode: &citum_schema::citation::CitationMode,
592        suppress_author: bool,
593        position: Option<&citum_schema::citation::Position>,
594    ) -> String
595    where
596        F: crate::render::format::OutputFormat<Output = String>,
597    {
598        let is_note_processing = self.config.processing.as_ref().is_some_and(|processing| {
599            matches!(processing, citum_schema::options::Processing::Note)
600        });
601        if is_note_processing
602            && matches!(
603                position,
604                Some(
605                    citum_schema::citation::Position::Ibid
606                        | citum_schema::citation::Position::IbidWithLocator
607                )
608            )
609            && !template.iter().any(has_contributor_component)
610        {
611            return String::new();
612        }
613
614        let options = self.citation_render_options(mode.clone(), suppress_author, None, None);
615
616        // Try to use the first semantically relevant component (including nested lists)
617        // so disambiguation hints and component-specific formatting are preserved.
618        // This ensures substitution, shortening, and mode-dependent conjunctions are respected.
619        if let Some(comp) = template.first().and_then(find_grouping_component) {
620            let base_hints = self
621                .hints
622                .get(reference.id().as_deref().unwrap_or_default())
623                .cloned()
624                .unwrap_or_default();
625            // Inject citation position so subsequent et-al thresholds are applied.
626            let hints = ProcHints {
627                position: position.cloned(),
628                integral_name_state: item.integral_name_state,
629                ..base_hints
630            };
631            if let Some(vals) = comp.values::<F>(reference, &hints, &options)
632                && !vals.value.is_empty()
633            {
634                return vals.value;
635            }
636        }
637
638        // Fallback for cases where first component isn't suitable or returned empty
639        if let Some(authors) = reference.author() {
640            let names_vec = self.resolve_contributor_names(&authors);
641            F::default().text(&crate::values::format_contributors_short(
642                &names_vec, &options,
643            ))
644        } else {
645            String::new()
646        }
647    }
648
649    /// Render the prose anchor for an integral citation without any trailing note text.
650    pub(crate) fn render_integral_anchor_with_format<F>(
651        &self,
652        items: &[crate::reference::CitationItem],
653        spec: &citum_schema::CitationSpec,
654        inter_delimiter: &str,
655        suppress_author: bool,
656        position: Option<&citum_schema::citation::Position>,
657    ) -> Result<String, ProcessorError>
658    where
659        F: crate::render::format::OutputFormat<Output = String>,
660    {
661        let groups = group_citation_items_by_author(self, items);
662
663        let mut rendered_groups = Vec::new();
664        let fmt = F::default();
665        for (_author_key, group) in groups {
666            #[allow(
667                clippy::indexing_slicing,
668                reason = "group is non-empty by construction"
669            )]
670            let first_item = group[0];
671            let reference = self
672                .bibliography
673                .get(&first_item.id)
674                .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
675            let item_language = crate::values::effective_item_language(reference);
676            let template = spec.resolve_template_for_language(item_language.as_deref());
677            let effective_template = template.as_deref().unwrap_or(&[]);
678            let author_part = self.render_author_for_grouping_with_format::<F>(
679                reference,
680                first_item,
681                effective_template,
682                &citum_schema::citation::CitationMode::Integral,
683                suppress_author,
684                position,
685            );
686            if !author_part.is_empty() {
687                rendered_groups.push(author_part);
688            }
689        }
690
691        Ok(fmt.join(rendered_groups, inter_delimiter))
692    }
693
694    /// Get the citation number for a reference, assigning one if not yet cited.
695    #[must_use]
696    pub fn get_or_assign_citation_number(&self, ref_id: &str) -> usize {
697        let mut numbers = self.citation_numbers.borrow_mut();
698        let next_num = numbers.len() + 1;
699        *numbers.entry(ref_id.to_string()).or_insert(next_num)
700    }
701
702    /// Process a bibliography entry.
703    #[must_use]
704    pub fn process_bibliography_entry(
705        &self,
706        reference: &Reference,
707        entry_number: usize,
708    ) -> Option<ProcTemplate> {
709        self.process_bibliography_entry_with_format::<crate::render::plain::PlainText>(
710            reference,
711            entry_number,
712        )
713    }
714
715    /// Process a bibliography entry with specific format.
716    #[must_use]
717    pub fn process_bibliography_entry_with_format<F>(
718        &self,
719        reference: &Reference,
720        entry_number: usize,
721    ) -> Option<ProcTemplate>
722    where
723        F: crate::render::format::OutputFormat<Output = String>,
724    {
725        let bib_spec = self.style.bibliography.as_ref()?;
726
727        let item_language = crate::values::effective_item_language(reference);
728        let default_template = bib_spec
729            .resolve_template_for_language(item_language.as_deref())
730            .map(Cow::Owned);
731
732        let ref_type = reference.ref_type();
733        let template = resolve_type_variant(bib_spec.type_variants.as_ref(), &ref_type)
734            .map(Cow::Borrowed)
735            .or(default_template)?;
736
737        let template = self.apply_anonymous_entry_bibliography_policy(reference, template)?;
738        let template = self.apply_article_journal_bibliography_policy(reference, template);
739
740        self.process_template_request_with_format::<F>(
741            reference,
742            TemplateRenderRequest {
743                template: template.as_ref(),
744                context: RenderContext::Bibliography,
745                mode: citum_schema::citation::CitationMode::NonIntegral,
746                suppress_author: false,
747                locator_raw: None,
748                citation_number: entry_number,
749                position: None,
750                note_start_text_case: None,
751                integral_name_state: None,
752                org_abbreviation_state: None,
753                first_reference_note_number: None,
754            },
755        )
756    }
757
758    /// Process a template for a reference using plain text format.
759    ///
760    /// Accepts a [`TemplateRenderParams`] bundle rather than individual arguments
761    /// to keep the call site readable and avoid argument-count lint issues.
762    #[must_use]
763    pub fn process_template_with_number(
764        &self,
765        reference: &Reference,
766        params: TemplateRenderParams<'_>,
767    ) -> Option<ProcTemplate> {
768        self.process_template_with_number_with_format::<crate::render::plain::PlainText>(
769            reference, params,
770        )
771    }
772
773    /// Process a template for a reference with a specific output format.
774    ///
775    /// Accepts a [`TemplateRenderParams`] bundle rather than individual arguments
776    /// to keep the call site readable and avoid argument-count lint issues.
777    pub fn process_template_with_number_with_format<F>(
778        &self,
779        reference: &Reference,
780        params: TemplateRenderParams<'_>,
781    ) -> Option<ProcTemplate>
782    where
783        F: crate::render::format::OutputFormat<Output = String>,
784    {
785        self.process_template_request_with_format::<F>(
786            reference,
787            TemplateRenderRequest {
788                template: params.template,
789                context: params.context,
790                mode: params.mode,
791                suppress_author: params.suppress_author,
792                locator_raw: params.locator_raw,
793                citation_number: params.citation_number,
794                position: params.position.cloned(),
795                note_start_text_case: params.note_start_text_case,
796                integral_name_state: params.integral_name_state,
797                org_abbreviation_state: params.org_abbreviation_state,
798                first_reference_note_number: None,
799            },
800        )
801    }
802
803    /// Process a template request with a specific output format.
804    #[must_use]
805    pub fn process_template_request_with_format<F>(
806        &self,
807        reference: &Reference,
808        request: TemplateRenderRequest<'_>,
809    ) -> Option<ProcTemplate>
810    where
811        F: crate::render::format::OutputFormat<Output = String>,
812    {
813        let TemplateRenderRequest {
814            template,
815            context,
816            mode,
817            suppress_author,
818            locator_raw,
819            citation_number,
820            position,
821            note_start_text_case,
822            integral_name_state,
823            org_abbreviation_state,
824            first_reference_note_number,
825        } = request;
826        let ref_type = reference.ref_type();
827        let options = RenderOptions {
828            config: self.config,
829            bibliography_config: self.bibliography_config.clone(),
830            locale: self.locale,
831            context,
832            mode,
833            suppress_author,
834            locator_raw,
835            ref_type: Some(ref_type.clone()),
836            show_semantics: self.show_semantics,
837            current_template_index: None,
838            abbreviation_map: self.abbreviation_map,
839        };
840        // Only carry the first-reference note number (and its suppression side-effect)
841        // when the template actually renders it.  Suppressing a `disambiguate-only`
842        // title without emitting the note number as a replacement identifier would
843        // silently reintroduce ambiguity for colliding works.
844        let effective_first_ref_note = if template_uses_first_ref_note_number(template) {
845            first_reference_note_number
846        } else {
847            None
848        };
849        let hint = self.build_template_render_hint(HintInputs {
850            reference,
851            context: options.context,
852            citation_number,
853            position,
854            integral_name_state,
855            org_abbreviation_state,
856            first_reference_note_number: effective_first_ref_note,
857        });
858        let mut components =
859            self.render_template_components::<F>(reference, &ref_type, &options, &hint, template);
860
861        self.apply_sentence_initial_context::<F>(&mut components, context, note_start_text_case);
862
863        (!components.is_empty()).then_some(components)
864    }
865
866    /// Render each top-level template component for `reference`, threading a
867    /// fresh `TemplateRenderContext` per index so the source position is
868    /// preserved in AST-injection mode.
869    fn render_template_components<F>(
870        &self,
871        reference: &Reference,
872        ref_type: &str,
873        options: &RenderOptions<'_>,
874        hint: &ProcHints,
875        template: &[TemplateComponent],
876    ) -> Vec<ProcTemplateComponent>
877    where
878        F: crate::render::format::OutputFormat<Output = String>,
879    {
880        let mut tracker = TemplateComponentTracker::default();
881        let mut components = Vec::with_capacity(template.len());
882        let mut component_options = options.clone();
883        for (template_index, component) in template.iter().enumerate() {
884            component_options.current_template_index =
885                self.inject_ast_indices.then_some(template_index);
886            let ctx = TemplateRenderContext {
887                reference,
888                ref_type,
889                options: &component_options,
890                hint,
891                template_index,
892            };
893            if let Some(component) =
894                self.render_template_component_with_format::<F>(&ctx, component, &mut tracker)
895            {
896                components.push(component);
897            }
898        }
899        components
900    }
901
902    fn build_template_render_hint(&self, inputs: HintInputs<'_>) -> ProcHints {
903        let HintInputs {
904            reference,
905            context,
906            citation_number,
907            position,
908            integral_name_state,
909            org_abbreviation_state,
910            first_reference_note_number,
911        } = inputs;
912        let default_hint = ProcHints::default();
913        let base_hint = self
914            .hints
915            .get(reference.id().as_deref().unwrap_or_default())
916            .unwrap_or(&default_hint);
917        let is_subsequent = matches!(position, Some(citum_schema::citation::Position::Subsequent));
918        ProcHints {
919            citation_number: (citation_number > 0).then_some(citation_number),
920            citation_sub_label: if context == RenderContext::Citation {
921                reference
922                    .id()
923                    .as_deref()
924                    .and_then(|id| self.citation_sub_label_for_ref(id))
925            } else {
926                None
927            },
928            position,
929            integral_name_state,
930            org_abbreviation_state,
931            first_reference_note_number: if is_subsequent {
932                first_reference_note_number
933            } else {
934                None
935            },
936            suppress_disambiguation_title: is_subsequent && first_reference_note_number.is_some(),
937            ..base_hint.clone()
938        }
939    }
940
941    fn render_template_component_with_format<F>(
942        &self,
943        ctx: &TemplateRenderContext<'_>,
944        component: &TemplateComponent,
945        tracker: &mut TemplateComponentTracker,
946    ) -> Option<ProcTemplateComponent>
947    where
948        F: crate::render::format::OutputFormat<Output = String>,
949    {
950        if let TemplateComponent::Group(group) = component {
951            return self.render_group_component_with_format::<F>(ctx, group, tracker);
952        }
953
954        let resolved_component = component;
955        let var_key = get_variable_key(resolved_component);
956        if tracker.should_skip(var_key.as_deref()) {
957            return None;
958        }
959
960        let mut values = resolved_component.values::<F>(ctx.reference, ctx.hint, ctx.options)?;
961        // Suppress affixes when a component resolves to no meaningful content.
962        // A whitespace-only value carries no data, so its prefix/suffix must
963        // not leak into output (e.g. a ". In " prefix on an empty editor list).
964        if values.value.trim().is_empty() {
965            return None;
966        }
967        self.apply_issued_no_date_fallback(
968            ctx.reference,
969            ctx.options,
970            resolved_component,
971            &mut values,
972        );
973        self.apply_entry_link_fallback(ctx.reference, ctx.options, &mut values);
974
975        let item_language =
976            crate::values::effective_component_language(ctx.reference, resolved_component);
977        tracker.mark_rendered(var_key, values.substituted_key.as_deref());
978
979        Some(ProcTemplateComponent {
980            template_component: resolved_component.clone(),
981            template_index: self.inject_ast_indices.then_some(ctx.template_index),
982            value: values.value,
983            prefix: values.prefix,
984            suffix: values.suffix,
985            url: values.url,
986            ref_type: Some(ctx.ref_type.to_string()),
987            config: Some(ctx.options.config.clone()),
988            bibliography_config: ctx.options.bibliography_config.clone(),
989            item_language,
990            sentence_initial: false,
991            pre_formatted: values.pre_formatted,
992        })
993    }
994
995    fn render_group_component_with_format<F>(
996        &self,
997        ctx: &TemplateRenderContext<'_>,
998        group: &citum_schema::template::TemplateGroup,
999        tracker: &mut TemplateComponentTracker,
1000    ) -> Option<ProcTemplateComponent>
1001    where
1002        F: crate::render::format::OutputFormat<Output = String>,
1003    {
1004        let fmt = F::default();
1005        let values = self.render_group_child_values(&fmt, ctx, group, tracker)?;
1006        let delimiter = group
1007            .delimiter
1008            .as_ref()
1009            .unwrap_or(&citum_schema::template::DelimiterPunctuation::Comma)
1010            .to_string_with_space();
1011        let group_component = TemplateComponent::Group(group.clone());
1012        Some(ProcTemplateComponent {
1013            template_component: group_component.clone(),
1014            template_index: self.inject_ast_indices.then_some(ctx.template_index),
1015            value: fmt.join(values, &delimiter),
1016            prefix: None,
1017            suffix: None,
1018            url: None,
1019            ref_type: Some(ctx.ref_type.to_string()),
1020            config: Some(ctx.options.config.clone()),
1021            bibliography_config: ctx.options.bibliography_config.clone(),
1022            item_language: crate::values::effective_component_language(
1023                ctx.reference,
1024                &group_component,
1025            ),
1026            sentence_initial: false,
1027            pre_formatted: true,
1028        })
1029    }
1030
1031    /// Render the children of a template group into rendered strings, dropping
1032    /// empty values. Returns `None` when no child carries meaningful content
1033    /// (i.e. only term-only siblings produced output). Borrows the parent
1034    /// `fmt` so a stateful `OutputFormat` sees a single instance for both
1035    /// child rendering and the final `join` in the caller.
1036    fn render_group_child_values<F>(
1037        &self,
1038        fmt: &F,
1039        ctx: &TemplateRenderContext<'_>,
1040        group: &citum_schema::template::TemplateGroup,
1041        tracker: &mut TemplateComponentTracker,
1042    ) -> Option<Vec<String>>
1043    where
1044        F: crate::render::format::OutputFormat<Output = String>,
1045    {
1046        let mut has_meaningful_content = false;
1047        let mut values = Vec::with_capacity(group.group.len());
1048
1049        for item in &group.group {
1050            let Some(rendered) =
1051                self.render_template_component_with_format::<F>(ctx, item, tracker)
1052            else {
1053                continue;
1054            };
1055            let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1056                &rendered,
1057                fmt,
1058                ctx.options.show_semantics,
1059            );
1060            if rendered_str.trim().is_empty() {
1061                continue;
1062            }
1063            if !is_term_only_component(item) {
1064                has_meaningful_content = true;
1065            }
1066            values.push(rendered_str);
1067        }
1068
1069        (has_meaningful_content && !values.is_empty()).then_some(values)
1070    }
1071
1072    fn apply_issued_no_date_fallback(
1073        &self,
1074        reference: &Reference,
1075        options: &RenderOptions<'_>,
1076        component: &TemplateComponent,
1077        values: &mut crate::values::ProcValues<String>,
1078    ) {
1079        if !matches!(
1080            component,
1081            TemplateComponent::Date(citum_schema::template::TemplateDate {
1082                date: citum_schema::template::DateVariable::Issued,
1083                ..
1084            })
1085        ) || reference.csl_issued_date().is_some()
1086            || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1087        {
1088            return;
1089        }
1090
1091        if let Some(long) = options.locale.resolved_general_term(
1092            &citum_schema::locale::GeneralTerm::NoDate,
1093            &citum_schema::locale::TermForm::Long,
1094            None,
1095        ) {
1096            values.value = long;
1097        }
1098    }
1099
1100    fn apply_entry_link_fallback(
1101        &self,
1102        reference: &Reference,
1103        options: &RenderOptions<'_>,
1104        values: &mut crate::values::ProcValues<String>,
1105    ) {
1106        if values.url.is_some() {
1107            return;
1108        }
1109
1110        let Some(links) = &options.config.links else {
1111            return;
1112        };
1113        use citum_schema::options::LinkAnchor;
1114        if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1115            values.url = crate::values::resolve_url(links, reference);
1116        }
1117    }
1118
1119    /// Apply the substitution string to the primary contributor component.
1120    pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1121        self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1122            proc, substitute,
1123        );
1124    }
1125
1126    /// Apply the substitution string to the primary contributor component with specific format.
1127    pub fn apply_author_substitution_with_format<F>(
1128        &self,
1129        proc: &mut ProcTemplate,
1130        substitute: &str,
1131    ) where
1132        F: crate::render::format::OutputFormat<Output = String>,
1133    {
1134        if let Some(component) = proc
1135            .iter_mut()
1136            .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1137        {
1138            let fmt = F::default();
1139            component.value = fmt.text(substitute);
1140        }
1141    }
1142
1143    fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1144        match self
1145            .style
1146            .info
1147            .source
1148            .as_ref()
1149            .map(|source| source.csl_id.as_str())
1150        {
1151            Some("http://www.zotero.org/styles/harvard-cite-them-right") => {
1152                citum_schema::locale::TermForm::Long
1153            }
1154            _ => citum_schema::locale::TermForm::Short,
1155        }
1156    }
1157
1158    fn render_group_item_from_template_with_format<F>(
1159        &self,
1160        reference: &Reference,
1161        item_request: GroupItemRenderRequest<'_>,
1162    ) -> Option<String>
1163    where
1164        F: crate::render::format::OutputFormat<Output = String>,
1165    {
1166        let request = self.citation_render_request(
1167            item_request.item,
1168            item_request.template,
1169            item_request.mode,
1170            item_request.suppress_author,
1171            item_request.position,
1172            item_request.note_start_text_case,
1173        );
1174        self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1175    }
1176}
1177
1178/// Return `true` when `template` (or any nested group) contains a
1179/// `number: first-reference-note-number` component.
1180///
1181/// Used to gate `suppress_disambiguation_title`: if the style's template does
1182/// not render the note-number identifier, there is nothing to replace the
1183/// suppressed title and ambiguity would silently be reintroduced.
1184pub(super) fn template_uses_first_ref_note_number(template: &[TemplateComponent]) -> bool {
1185    template.iter().any(|c| match c {
1186        TemplateComponent::Number(n) => {
1187            n.number == citum_schema::template::NumberVariable::FirstReferenceNoteNumber
1188        }
1189        TemplateComponent::Group(g) => template_uses_first_ref_note_number(&g.group),
1190        _ => false,
1191    })
1192}
1193
1194pub(super) fn filter_author_from_template(
1195    template: &[TemplateComponent],
1196) -> (Vec<TemplateComponent>, Option<String>) {
1197    let mut filtered: Vec<TemplateComponent> =
1198        template.iter().filter_map(strip_author_component).collect();
1199    let leading_affix = filtered.first().and_then(leading_group_affix);
1200    if let Some(first) = filtered.first_mut() {
1201        strip_leading_group_affixes(first);
1202    }
1203    (filtered, leading_affix)
1204}