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