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