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