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                );
394                Some(fmt.wrap_punctuation(wrap_punct, inner, &marks, script, realization))
395            } else {
396                None
397            };
398        let Some(content) = self.build_grouped_citation_content(
399            &author_part,
400            &item_parts,
401            params,
402            group_delimiter.as_deref(),
403            pre_wrapped_years.as_deref(),
404        ) else {
405            return Ok(None);
406        };
407        let group_ids = group.iter().map(|item| item.id.clone()).collect();
408        let prefix = first_item.prefix.as_deref().unwrap_or("");
409        // Suffix is embedded in item_parts by render_group_item_parts_with_format when
410        // item_parts is non-empty. Apply it here only when item_parts was empty (author-only output).
411        let suffix = if item_parts.is_empty() {
412            first_item.suffix.as_deref()
413        } else {
414            None
415        };
416
417        Ok(Some(fmt.citation(
418            group_ids,
419            self.affix_content(
420                &fmt,
421                content,
422                Some(prefix),
423                suffix,
424                Some(first_item.id.as_str()),
425            ),
426        )))
427    }
428
429    fn build_grouped_citation_content(
430        &self,
431        author_part: &str,
432        item_parts: &[String],
433        params: &GroupRenderParams<'_>,
434        group_delimiter: Option<&str>,
435        pre_wrapped_years: Option<&str>,
436    ) -> Option<String> {
437        if !author_part.is_empty() && !item_parts.is_empty() {
438            let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
439            return Some(match params.mode {
440                citum_schema::citation::CitationMode::Integral => {
441                    // pre_wrapped_years is Some for collapsed multi-item integral groups
442                    // (format-aware wrap applied upstream). For single-item groups this
443                    // path is not reached (they use the explicit integral path instead).
444                    let wrapped = pre_wrapped_years.map(str::to_string).unwrap_or_else(|| {
445                        self.join_integral_group_item_parts(item_parts, author_item_delimiter)
446                    });
447                    self.format_integral_grouped_items(
448                        author_part,
449                        &wrapped,
450                        params.suppress_author,
451                    )
452                }
453                citum_schema::citation::CitationMode::NonIntegral => {
454                    let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
455                        ", "
456                    } else {
457                        author_item_delimiter
458                    };
459                    let joined_items = item_parts.join(repeated_item_delimiter);
460                    self.format_non_integral_grouped_items(
461                        author_part,
462                        author_item_delimiter,
463                        &joined_items,
464                        params.suppress_author,
465                    )
466                }
467            });
468        }
469
470        if !author_part.is_empty() {
471            return Some(author_part.to_string());
472        }
473
474        if !item_parts.is_empty() {
475            return Some(item_parts.join(params.intra_delimiter));
476        }
477
478        None
479    }
480
481    fn format_integral_grouped_items(
482        &self,
483        author_part: &str,
484        wrapped_content: &str,
485        suppress_author: bool,
486    ) -> String {
487        if suppress_author {
488            wrapped_content.to_string()
489        } else {
490            format!("{author_part} {wrapped_content}")
491        }
492    }
493
494    fn format_non_integral_grouped_items(
495        &self,
496        author_part: &str,
497        author_item_delimiter: &str,
498        joined_items: &str,
499        suppress_author: bool,
500    ) -> String {
501        if suppress_author {
502            return joined_items.to_string();
503        }
504
505        if let Some(adjusted) =
506            self.adjust_grouped_author_quote_punctuation(author_part, author_item_delimiter)
507        {
508            return format!("{adjusted}{joined_items}");
509        }
510
511        format!("{author_part}{author_item_delimiter}{joined_items}")
512    }
513
514    fn adjust_grouped_author_quote_punctuation(
515        &self,
516        author_part: &str,
517        author_item_delimiter: &str,
518    ) -> Option<String> {
519        if !self.config.punctuation_in_quote
520            || !author_item_delimiter.starts_with(',')
521            || !(author_part.ends_with('"') || author_part.ends_with('\u{201D}'))
522        {
523            return None;
524        }
525
526        let is_curly = author_part.ends_with('\u{201D}');
527        let quote_char = if is_curly { '\u{201D}' } else { '"' };
528        #[allow(clippy::string_slice, reason = "quote found at end")]
529        let trimmed = &author_part[..author_part.len() - quote_char.len_utf8()];
530        #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
531        Some(format!(
532            "{trimmed},{quote_char}{}",
533            &author_item_delimiter[1..]
534        ))
535    }
536
537    fn render_group_item_parts_with_format<F>(
538        &self,
539        fmt: &F,
540        group: &[&crate::reference::CitationItem],
541        params: &GroupRenderParams<'_>,
542    ) -> Result<(Vec<String>, Option<String>, Option<WrapConfig>), ProcessorError>
543    where
544        F: crate::render::format::OutputFormat<Output = String>,
545    {
546        let mut item_parts = Vec::new();
547        let mut group_delimiter: Option<String> = None;
548        // For integral multi-item same-author groups, capture the full WrapConfig
549        // (punctuation + inner_prefix/inner_suffix) from the first item's filtered
550        // template and strip the wrap from all items. The caller applies it once,
551        // format-aware, around the joined year string.
552        // Non-integral groups preserve per-item wraps (they may be the primary
553        // wrapping when no cluster-level wrap exists, e.g. author-date disambiguation).
554        let mut captured_year_wrap: Option<WrapConfig> = None;
555        let collapse_group = group.len() > 1
556            && matches!(params.mode, citum_schema::citation::CitationMode::Integral);
557        for (index, item) in group.iter().enumerate() {
558            let state = self.resolve_item_render_state(item, params.spec)?;
559            let (script, realization) = crate::values::punctuation_realization_context(
560                crate::values::effective_item_language(state.reference).as_deref(),
561                self.config.multilingual.as_ref(),
562            );
563            let (mut filtered_template, leading_affix, strip_item_delimiter) =
564                filter_author_from_template::<F>(&state.template, script, realization, fmt);
565            if collapse_group {
566                if index == 0 {
567                    // Capture the full WrapConfig from the first remaining component
568                    // (typically the date or date-group). Preserves inner_prefix and
569                    // inner_suffix alongside punctuation so the caller can apply the
570                    // wrap format-aware via fmt.inner_affix + fmt.wrap_punctuation.
571                    captured_year_wrap = filtered_template
572                        .first_mut()
573                        .and_then(|c| c.rendering_mut().wrap.take());
574                } else {
575                    // Strip the wrap on subsequent items to match the first item.
576                    if let Some(first) = filtered_template.first_mut() {
577                        first.rendering_mut().wrap = None;
578                    }
579                }
580            }
581            if group_delimiter.is_none() {
582                group_delimiter = leading_affix
583                    .as_ref()
584                    .filter(|value| !value.is_empty())
585                    .cloned();
586            }
587            let item_delimiter = if strip_item_delimiter {
588                ""
589            } else {
590                params.intra_delimiter
591            };
592            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
593                state.reference,
594                GroupItemRenderRequest {
595                    item: state.item,
596                    template: &filtered_template,
597                    mode: params.mode,
598                    suppress_author: params.suppress_author,
599                    position: params.position,
600                    note_start_text_case: params.note_start_text_case,
601                    delimiter: item_delimiter,
602                },
603            ) && !item_str.is_empty()
604            {
605                let prefix = (index > 0).then_some(item.prefix.as_deref()).flatten();
606                item_parts.push(self.affix_content(
607                    fmt,
608                    item_str,
609                    prefix,
610                    item.suffix.as_deref(),
611                    Some(item.id.as_str()),
612                ));
613            }
614        }
615        Ok((item_parts, group_delimiter, captured_year_wrap))
616    }
617
618    fn resolve_group_render_state<'b>(
619        &'b self,
620        group: &'b [&'b crate::reference::CitationItem],
621        spec: &'b citum_schema::CitationSpec,
622    ) -> Result<GroupRenderState<'b>, ProcessorError> {
623        #[allow(clippy::indexing_slicing, reason = "groups are non-empty")]
624        let first_item = group[0];
625        let first_ref = self
626            .bibliography
627            .get(&first_item.id)
628            .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
629        let first_language = crate::values::effective_item_language(first_ref);
630        let ref_type = first_ref.ref_type();
631        let localized = spec.resolve_localized_template(first_language.as_deref());
632        let first_template = localized
633            .as_ref()
634            .filter(|resolved| resolved.type_variants.is_some())
635            .cloned()
636            .map(|resolved| Cow::Owned(resolve_localized_type_variant(resolved, None, &ref_type)))
637            .or_else(|| {
638                resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
639            })
640            .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)));
641
642        Ok(GroupRenderState {
643            first_item,
644            first_ref,
645            template: first_template.unwrap_or(Cow::Borrowed(&[])),
646        })
647    }
648
649    fn resolve_item_render_state<'b>(
650        &'b self,
651        item: &'b crate::reference::CitationItem,
652        spec: &'b citum_schema::CitationSpec,
653    ) -> Result<ItemRenderState<'b>, ProcessorError> {
654        let reference = self
655            .bibliography
656            .get(&item.id)
657            .ok_or_else(|| ProcessorError::ReferenceNotFound(item.id.clone()))?;
658        let item_language = crate::values::effective_item_language(reference);
659        let ref_type = reference.ref_type();
660        let localized = spec.resolve_localized_template(item_language.as_deref());
661        let item_template = localized
662            .as_ref()
663            .filter(|resolved| resolved.type_variants.is_some())
664            .cloned()
665            .map(|resolved| Cow::Owned(resolve_localized_type_variant(resolved, None, &ref_type)))
666            .or_else(|| {
667                resolve_type_variant(spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
668            })
669            .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)));
670
671        Ok(ItemRenderState {
672            item,
673            reference,
674            template: item_template.unwrap_or(Cow::Borrowed(&[])),
675        })
676    }
677
678    fn try_render_integral_group_with_format<F>(
679        &self,
680        group: &[&crate::reference::CitationItem],
681        spec: &citum_schema::CitationSpec,
682        mode: &citum_schema::citation::CitationMode,
683        suppress_author: bool,
684        position: Option<&citum_schema::citation::Position>,
685    ) -> Result<Option<String>, ProcessorError>
686    where
687        F: crate::render::format::OutputFormat<Output = String>,
688    {
689        if !matches!(mode, citum_schema::citation::CitationMode::Integral)
690            || !self.has_explicit_integral_template()
691        {
692            return Ok(None);
693        }
694
695        self.render_integral_explicit_group::<F>(group, spec, mode, suppress_author, position)
696    }
697
698    /// Returns true for non-integral citation types that must render as a single
699    /// unit via [`render_special_type_items`] rather than the split author+items
700    /// path used for standard author-date groups.
701    ///
702    /// Title-first types (`legal-case`, `treaty`, `hearing`) need this because
703    /// their type-variant template leads with a title component, not a
704    /// contributor. The grouped path strips only `Contributor::Author`, so the
705    /// title would render twice (plain in the author slot, emph in the item
706    /// slot). `personal-communication` is included because its per-item date
707    /// and term must stay together and not be collapsed across items.
708    fn requires_full_group_item_rendering(
709        &self,
710        mode: &citum_schema::citation::CitationMode,
711        reference: &Reference,
712    ) -> bool {
713        matches!(mode, citum_schema::citation::CitationMode::NonIntegral)
714            && matches!(
715                reference.ref_type().as_str(),
716                "legal-case" | "treaty" | "hearing" | "personal-communication"
717            )
718    }
719
720    /// Render just the author part for citation grouping.
721    pub(crate) fn render_author_for_grouping_with_format<F>(
722        &self,
723        reference: &Reference,
724        item: &crate::reference::CitationItem,
725        template: &[TemplateComponent],
726        mode: &citum_schema::citation::CitationMode,
727        suppress_author: bool,
728        position: Option<&citum_schema::citation::Position>,
729    ) -> String
730    where
731        F: crate::render::format::OutputFormat<Output = String>,
732    {
733        let is_note_processing = self.config.processing.as_ref().is_some_and(|processing| {
734            matches!(processing, citum_schema::options::Processing::Note)
735        });
736        if is_note_processing
737            && matches!(
738                position,
739                Some(
740                    citum_schema::citation::Position::Ibid
741                        | citum_schema::citation::Position::IbidWithLocator
742                )
743            )
744            && !template.iter().any(has_contributor_component)
745        {
746            return String::new();
747        }
748
749        let options =
750            self.citation_render_options(reference, mode.clone(), suppress_author, None, None);
751
752        // Try to use the first semantically relevant component (including nested lists)
753        // so disambiguation hints and component-specific formatting are preserved.
754        // This ensures substitution, shortening, and mode-dependent conjunctions are respected.
755        if let Some(comp) = template.first().and_then(find_grouping_component) {
756            let base_hints = self
757                .hints
758                .get(reference.id().as_deref().unwrap_or_default())
759                .cloned()
760                .unwrap_or_default();
761            // Inject citation position so subsequent et-al thresholds are applied.
762            let hints = ProcHints {
763                position: position.cloned(),
764                integral_name_state: item.integral_name_state,
765                ..base_hints
766            };
767            if let Some(vals) = comp.values::<F>(reference, &hints, &options)
768                && !vals.value.is_empty()
769            {
770                return vals.value;
771            }
772        }
773
774        // Fallback for cases where first component isn't suitable or returned empty
775        if let Some(authors) = reference.author() {
776            let names_vec = self.resolve_contributor_names(&authors);
777            F::default().text(&crate::values::format_contributors_short(
778                &names_vec, &options,
779            ))
780        } else {
781            String::new()
782        }
783    }
784
785    /// Render the prose anchor for an integral citation without any trailing note text.
786    pub(crate) fn render_integral_anchor_with_format<F>(
787        &self,
788        items: &[crate::reference::CitationItem],
789        spec: &citum_schema::CitationSpec,
790        inter_delimiter: &str,
791        suppress_author: bool,
792        position: Option<&citum_schema::citation::Position>,
793    ) -> Result<String, ProcessorError>
794    where
795        F: crate::render::format::OutputFormat<Output = String>,
796    {
797        let groups = group_citation_items_by_author(self, items);
798
799        let mut rendered_groups = Vec::new();
800        let fmt = F::default();
801        for (_author_key, group) in groups {
802            #[allow(
803                clippy::indexing_slicing,
804                reason = "group is non-empty by construction"
805            )]
806            let first_item = group[0];
807            let reference = self
808                .bibliography
809                .get(&first_item.id)
810                .ok_or_else(|| ProcessorError::ReferenceNotFound(first_item.id.clone()))?;
811            let item_language = crate::values::effective_item_language(reference);
812            let template = spec.resolve_template_for_language(item_language.as_deref());
813            let effective_template = template.as_deref().unwrap_or(&[]);
814            let author_part = self.render_author_for_grouping_with_format::<F>(
815                reference,
816                first_item,
817                effective_template,
818                &citum_schema::citation::CitationMode::Integral,
819                suppress_author,
820                position,
821            );
822            if !author_part.is_empty() {
823                rendered_groups.push(author_part);
824            }
825        }
826
827        Ok(fmt.join(rendered_groups, inter_delimiter))
828    }
829
830    /// Get the citation number for a reference, assigning one if not yet cited.
831    #[must_use]
832    pub fn get_or_assign_citation_number(&self, ref_id: &str) -> usize {
833        let mut numbers = self
834            .citation_numbers
835            .write()
836            .unwrap_or_else(std::sync::PoisonError::into_inner);
837        let next_num = numbers.len() + 1;
838        *numbers.entry(ref_id.to_string()).or_insert(next_num)
839    }
840
841    /// Process a bibliography entry.
842    #[must_use]
843    pub fn process_bibliography_entry(
844        &self,
845        reference: &Reference,
846        entry_number: usize,
847    ) -> Option<ProcTemplate> {
848        self.process_bibliography_entry_with_format::<crate::render::plain::PlainText>(
849            reference,
850            entry_number,
851        )
852    }
853
854    /// Process a bibliography entry with specific format.
855    #[must_use]
856    pub fn process_bibliography_entry_with_format<F>(
857        &self,
858        reference: &Reference,
859        entry_number: usize,
860    ) -> Option<ProcTemplate>
861    where
862        F: crate::render::format::OutputFormat<Output = String>,
863    {
864        let bib_spec = self.style.bibliography.as_ref()?;
865
866        let item_language = crate::values::effective_item_language(reference);
867        let ref_type = reference.ref_type();
868        let localized = bib_spec.resolve_localized_template(item_language.as_deref());
869        let template = localized
870            .as_ref()
871            .filter(|resolved| resolved.type_variants.is_some())
872            .cloned()
873            .map(|resolved| Cow::Owned(resolve_localized_type_variant(resolved, None, &ref_type)))
874            .or_else(|| {
875                resolve_type_variant(bib_spec.type_variants.as_ref(), &ref_type).map(Cow::Borrowed)
876            })
877            .or_else(|| localized.map(|resolved| Cow::Owned(resolved.template)))?;
878
879        let template = self.apply_anonymous_entry_bibliography_policy(reference, template)?;
880        let template = self.apply_article_journal_bibliography_policy(reference, template);
881
882        self.process_template_request_with_format::<F>(
883            reference,
884            TemplateRenderRequest {
885                template: template.as_ref(),
886                context: RenderContext::Bibliography,
887                mode: citum_schema::citation::CitationMode::NonIntegral,
888                suppress_author: false,
889                locator_raw: None,
890                citation_number: entry_number,
891                position: None,
892                note_start_text_case: None,
893                integral_name_state: None,
894                org_abbreviation_state: None,
895                first_reference_note_number: None,
896            },
897        )
898    }
899
900    /// Process a template for a reference using plain text format.
901    ///
902    /// Accepts a [`TemplateRenderParams`] bundle rather than individual arguments
903    /// to keep the call site readable and avoid argument-count lint issues.
904    #[must_use]
905    pub fn process_template_with_number(
906        &self,
907        reference: &Reference,
908        params: TemplateRenderParams<'_>,
909    ) -> Option<ProcTemplate> {
910        self.process_template_with_number_with_format::<crate::render::plain::PlainText>(
911            reference, params,
912        )
913    }
914
915    /// Process a template for a reference with a specific output format.
916    ///
917    /// Accepts a [`TemplateRenderParams`] bundle rather than individual arguments
918    /// to keep the call site readable and avoid argument-count lint issues.
919    pub fn process_template_with_number_with_format<F>(
920        &self,
921        reference: &Reference,
922        params: TemplateRenderParams<'_>,
923    ) -> Option<ProcTemplate>
924    where
925        F: crate::render::format::OutputFormat<Output = String>,
926    {
927        self.process_template_request_with_format::<F>(
928            reference,
929            TemplateRenderRequest {
930                template: params.template,
931                context: params.context,
932                mode: params.mode,
933                suppress_author: params.suppress_author,
934                locator_raw: params.locator_raw,
935                citation_number: params.citation_number,
936                position: params.position.cloned(),
937                note_start_text_case: params.note_start_text_case,
938                integral_name_state: params.integral_name_state,
939                org_abbreviation_state: params.org_abbreviation_state,
940                first_reference_note_number: None,
941            },
942        )
943    }
944
945    /// Process a template request with a specific output format.
946    #[must_use]
947    pub fn process_template_request_with_format<F>(
948        &self,
949        reference: &Reference,
950        request: TemplateRenderRequest<'_>,
951    ) -> Option<ProcTemplate>
952    where
953        F: crate::render::format::OutputFormat<Output = String>,
954    {
955        let TemplateRenderRequest {
956            template,
957            context,
958            mode,
959            suppress_author,
960            locator_raw,
961            citation_number,
962            position,
963            note_start_text_case,
964            integral_name_state,
965            org_abbreviation_state,
966            first_reference_note_number,
967        } = request;
968        let ref_type = reference.ref_type();
969        let locale = self.locale_for_reference(reference, context);
970        let options = RenderOptions {
971            config: self.config.clone(),
972            bibliography_config: self.bibliography_config.clone(),
973            locale,
974            context,
975            mode,
976            suppress_author,
977            locator_raw,
978            ref_type: Some(ref_type.clone()),
979            show_semantics: self.show_semantics,
980            current_template_index: None,
981            abbreviation_map: self.abbreviation_map,
982        };
983        // Only carry the first-reference note number (and its suppression side-effect)
984        // when the template actually renders it.  Suppressing a `disambiguate-only`
985        // title without emitting the note number as a replacement identifier would
986        // silently reintroduce ambiguity for colliding works.
987        let effective_first_ref_note = if template_uses_first_ref_note_number(template) {
988            first_reference_note_number
989        } else {
990            None
991        };
992        let hint = self.build_template_render_hint(HintInputs {
993            reference,
994            context: options.context,
995            citation_number,
996            position,
997            integral_name_state,
998            org_abbreviation_state,
999            first_reference_note_number: effective_first_ref_note,
1000        });
1001        let mut components =
1002            self.render_template_components::<F>(reference, &ref_type, &options, &hint, template);
1003
1004        self.apply_sentence_initial_context::<F>(&mut components, context, note_start_text_case);
1005
1006        (!components.is_empty()).then_some(components)
1007    }
1008
1009    /// Render each top-level template component for `reference`, threading a
1010    /// fresh `TemplateRenderContext` per index so the source position is
1011    /// preserved in AST-injection mode.
1012    fn render_template_components<F>(
1013        &self,
1014        reference: &Reference,
1015        ref_type: &str,
1016        options: &RenderOptions<'_>,
1017        hint: &ProcHints,
1018        template: &[TemplateComponent],
1019    ) -> Vec<ProcTemplateComponent>
1020    where
1021        F: crate::render::format::OutputFormat<Output = String>,
1022    {
1023        let mut tracker = TemplateComponentTracker::default();
1024        let mut components = Vec::with_capacity(template.len());
1025        let mut component_options = options.clone();
1026        for (template_index, component) in template.iter().enumerate() {
1027            component_options.current_template_index =
1028                self.inject_ast_indices.then_some(template_index);
1029            let ctx = TemplateRenderContext {
1030                reference,
1031                ref_type,
1032                options: &component_options,
1033                hint,
1034                template_index,
1035            };
1036            if let Some(component) =
1037                self.render_template_component_with_format::<F>(&ctx, component, &mut tracker)
1038            {
1039                components.push(component);
1040            }
1041        }
1042        components
1043    }
1044
1045    fn build_template_render_hint(&self, inputs: HintInputs<'_>) -> ProcHints {
1046        let HintInputs {
1047            reference,
1048            context,
1049            citation_number,
1050            position,
1051            integral_name_state,
1052            org_abbreviation_state,
1053            first_reference_note_number,
1054        } = inputs;
1055        let default_hint = ProcHints::default();
1056        let base_hint = self
1057            .hints
1058            .get(reference.id().as_deref().unwrap_or_default())
1059            .unwrap_or(&default_hint);
1060        let is_subsequent = matches!(position, Some(citum_schema::citation::Position::Subsequent));
1061        ProcHints {
1062            citation_number: (citation_number > 0).then_some(citation_number),
1063            citation_sub_label: if context == RenderContext::Citation {
1064                reference
1065                    .id()
1066                    .as_deref()
1067                    .and_then(|id| self.citation_sub_label_for_ref(id))
1068            } else {
1069                None
1070            },
1071            position,
1072            integral_name_state,
1073            org_abbreviation_state,
1074            first_reference_note_number: if is_subsequent {
1075                first_reference_note_number
1076            } else {
1077                None
1078            },
1079            suppress_disambiguation_title: is_subsequent && first_reference_note_number.is_some(),
1080            ..base_hint.clone()
1081        }
1082    }
1083
1084    fn render_template_component_with_format<F>(
1085        &self,
1086        ctx: &TemplateRenderContext<'_>,
1087        component: &TemplateComponent,
1088        tracker: &mut TemplateComponentTracker,
1089    ) -> Option<ProcTemplateComponent>
1090    where
1091        F: crate::render::format::OutputFormat<Output = String>,
1092    {
1093        if let TemplateComponent::Group(group) = component {
1094            return self.render_group_component_with_format::<F>(ctx, group, tracker);
1095        }
1096
1097        let resolved_component = component;
1098        if resolved_component.rendering().suppress == Some(true) {
1099            return None;
1100        }
1101
1102        let var_key = get_variable_key(resolved_component);
1103        if tracker.should_skip(var_key.as_deref()) {
1104            return None;
1105        }
1106
1107        let mut values = resolved_component.values::<F>(ctx.reference, ctx.hint, ctx.options)?;
1108        // Suppress affixes when a component resolves to no meaningful content.
1109        // A whitespace-only value carries no data, so its prefix/suffix must
1110        // not leak into output (e.g. a ". In " prefix on an empty editor list).
1111        if values.value.trim().is_empty() {
1112            return None;
1113        }
1114        self.apply_issued_no_date_fallback(
1115            ctx.reference,
1116            ctx.options,
1117            resolved_component,
1118            &mut values,
1119        );
1120        self.apply_entry_link_fallback(ctx.reference, ctx.options, &mut values);
1121
1122        let item_language =
1123            crate::values::effective_component_language(ctx.reference, resolved_component);
1124        tracker.mark_rendered(var_key, values.substituted_key.as_deref());
1125
1126        Some(ProcTemplateComponent {
1127            template_component: resolved_component.clone(),
1128            template_index: self.inject_ast_indices.then_some(ctx.template_index),
1129            value: values.value,
1130            prefix: values.prefix,
1131            suffix: values.suffix,
1132            url: values.url,
1133            ref_type: Some(ctx.ref_type.to_string()),
1134            config: Some(ctx.options.config.clone()),
1135            bibliography_config: ctx.options.bibliography_config.clone(),
1136            item_language,
1137            quote_marks: crate::render::format::QuoteMarks::from(
1138                &ctx.options.locale.grammar_options,
1139            ),
1140            sentence_initial: false,
1141            pre_formatted: values.pre_formatted,
1142        })
1143    }
1144
1145    fn render_group_component_with_format<F>(
1146        &self,
1147        ctx: &TemplateRenderContext<'_>,
1148        group: &citum_schema::template::TemplateGroup,
1149        tracker: &mut TemplateComponentTracker,
1150    ) -> Option<ProcTemplateComponent>
1151    where
1152        F: crate::render::format::OutputFormat<Output = String>,
1153    {
1154        if group.rendering.suppress == Some(true) {
1155            return None;
1156        }
1157        if group
1158            .render_when
1159            .as_ref()
1160            .is_some_and(|condition| !group_condition_matches(ctx.reference, condition))
1161        {
1162            return None;
1163        }
1164
1165        let fmt = F::default();
1166        let mut group_tracker = tracker.clone();
1167        let values = self.render_group_child_values(&fmt, ctx, group, &mut group_tracker)?;
1168        let default_delimiter = citum_schema::template::DelimiterPunctuation::Comma;
1169        let punctuation = group.delimiter.as_ref().unwrap_or(&default_delimiter);
1170        let (script, realization) = crate::values::punctuation_realization_context(
1171            crate::values::effective_item_language(ctx.reference).as_deref(),
1172            ctx.options.config.multilingual.as_ref(),
1173        );
1174        let delimiter = crate::render::format::realize_punctuation(
1175            punctuation,
1176            script,
1177            realization,
1178            crate::render::format::PunctuationPosition::Separator,
1179        );
1180        let delimiter = if punctuation.is_semantic() {
1181            fmt.text(&delimiter)
1182        } else {
1183            delimiter.into_owned()
1184        };
1185        tracker.merge_from(group_tracker);
1186        let group_component = TemplateComponent::Group(group.clone());
1187        Some(ProcTemplateComponent {
1188            template_component: group_component.clone(),
1189            template_index: self.inject_ast_indices.then_some(ctx.template_index),
1190            value: fmt.join(values, &delimiter),
1191            prefix: None,
1192            suffix: None,
1193            url: None,
1194            ref_type: Some(ctx.ref_type.to_string()),
1195            config: Some(ctx.options.config.clone()),
1196            bibliography_config: ctx.options.bibliography_config.clone(),
1197            item_language: crate::values::effective_component_language(
1198                ctx.reference,
1199                &group_component,
1200            ),
1201            quote_marks: crate::render::format::QuoteMarks::from(
1202                &ctx.options.locale.grammar_options,
1203            ),
1204            sentence_initial: false,
1205            pre_formatted: true,
1206        })
1207    }
1208
1209    /// Render the children of a template group into rendered strings, dropping
1210    /// empty values. Returns `None` when no child carries meaningful content
1211    /// (i.e. only term-only siblings produced output). Borrows the parent
1212    /// `fmt` so a stateful `OutputFormat` sees a single instance for both
1213    /// child rendering and the final `join` in the caller.
1214    fn render_group_child_values<F>(
1215        &self,
1216        fmt: &F,
1217        ctx: &TemplateRenderContext<'_>,
1218        group: &citum_schema::template::TemplateGroup,
1219        tracker: &mut TemplateComponentTracker,
1220    ) -> Option<Vec<String>>
1221    where
1222        F: crate::render::format::OutputFormat<Output = String>,
1223    {
1224        let mut has_meaningful_content = false;
1225        let mut values = Vec::with_capacity(group.group.len());
1226
1227        for item in &group.group {
1228            let Some(rendered) =
1229                self.render_template_component_with_format::<F>(ctx, item, tracker)
1230            else {
1231                continue;
1232            };
1233            let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1234                &rendered,
1235                fmt,
1236                ctx.options.show_semantics,
1237            );
1238            if rendered_str.trim().is_empty() {
1239                continue;
1240            }
1241            if !is_term_only_component(item) {
1242                has_meaningful_content = true;
1243            }
1244            values.push(rendered_str);
1245        }
1246
1247        (has_meaningful_content && !values.is_empty()).then_some(values)
1248    }
1249
1250    fn apply_issued_no_date_fallback(
1251        &self,
1252        reference: &Reference,
1253        options: &RenderOptions<'_>,
1254        component: &TemplateComponent,
1255        values: &mut crate::values::ProcValues<String>,
1256    ) {
1257        if !matches!(
1258            component,
1259            TemplateComponent::Date(citum_schema::template::TemplateDate {
1260                date: citum_schema::template::DateVariable::Issued,
1261                ..
1262            })
1263        ) || reference.effective_issued_date().is_some()
1264            || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1265        {
1266            return;
1267        }
1268
1269        if let Some(long) = options.locale.resolved_general_term(
1270            &citum_schema::locale::GeneralTerm::NoDate,
1271            &citum_schema::locale::TermForm::Long,
1272            None,
1273        ) {
1274            values.value = long;
1275        }
1276    }
1277
1278    fn apply_entry_link_fallback(
1279        &self,
1280        reference: &Reference,
1281        options: &RenderOptions<'_>,
1282        values: &mut crate::values::ProcValues<String>,
1283    ) {
1284        if values.url.is_some() {
1285            return;
1286        }
1287
1288        let Some(links) = &options.config.links else {
1289            return;
1290        };
1291        use citum_schema::options::LinkAnchor;
1292        if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1293            values.url = crate::values::resolve_url(links, reference);
1294        }
1295    }
1296
1297    /// Apply the substitution string to the primary contributor component.
1298    pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1299        self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1300            proc, substitute,
1301        );
1302    }
1303
1304    /// Apply the substitution string to the primary contributor component with specific format.
1305    pub fn apply_author_substitution_with_format<F>(
1306        &self,
1307        proc: &mut ProcTemplate,
1308        substitute: &str,
1309    ) where
1310        F: crate::render::format::OutputFormat<Output = String>,
1311    {
1312        if let Some(component) = proc
1313            .iter_mut()
1314            .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1315        {
1316            let fmt = F::default();
1317            component.value = fmt.text(substitute);
1318        }
1319    }
1320
1321    /// Term form used for the "no date" fallback, from `options.dates.no-date-form`
1322    /// (default `short`).
1323    fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1324        match self
1325            .config
1326            .dates
1327            .as_ref()
1328            .and_then(|dates| dates.no_date_form)
1329        {
1330            Some(citum_schema::options::NoDateForm::Long) => citum_schema::locale::TermForm::Long,
1331            Some(citum_schema::options::NoDateForm::Short) | None => {
1332                citum_schema::locale::TermForm::Short
1333            }
1334        }
1335    }
1336
1337    fn render_group_item_from_template_with_format<F>(
1338        &self,
1339        reference: &Reference,
1340        item_request: GroupItemRenderRequest<'_>,
1341    ) -> Option<String>
1342    where
1343        F: crate::render::format::OutputFormat<Output = String>,
1344    {
1345        let request = self.citation_render_request(
1346            item_request.item,
1347            item_request.template,
1348            item_request.mode,
1349            item_request.suppress_author,
1350            item_request.position,
1351            item_request.note_start_text_case,
1352        );
1353        self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1354    }
1355}
1356
1357/// Return `true` when `template` (or any nested group) contains a
1358/// `number: first-reference-note-number` component.
1359///
1360/// Used to gate `suppress_disambiguation_title`: if the style's template does
1361/// not render the note-number identifier, there is nothing to replace the
1362/// suppressed title and ambiguity would silently be reintroduced.
1363pub(super) fn template_uses_first_ref_note_number(template: &[TemplateComponent]) -> bool {
1364    template.iter().any(|c| match c {
1365        TemplateComponent::Number(n) => {
1366            n.number == citum_schema::template::NumberVariable::FirstReferenceNoteNumber
1367        }
1368        TemplateComponent::Group(g) => template_uses_first_ref_note_number(&g.group),
1369        _ => false,
1370    })
1371}
1372
1373pub(super) fn filter_author_from_template<F>(
1374    template: &[TemplateComponent],
1375    script: crate::values::ScriptClass,
1376    realization: Option<&citum_schema::options::PunctuationRealization>,
1377    fmt: &F,
1378) -> (Vec<TemplateComponent>, Option<String>, bool)
1379where
1380    F: crate::render::format::OutputFormat<Output = String>,
1381{
1382    // The author part rendered by `render_author_for_grouping_with_format`
1383    // is the first grouping component of the leading template component —
1384    // any contributor role, not just author. Strip that exact contributor
1385    // from the item parts too, or a template leading with e.g. a translator
1386    // renders its names twice (once as author part, once in the item part).
1387    let grouping_role = template
1388        .first()
1389        .and_then(find_grouping_component)
1390        .and_then(|component| match component {
1391            TemplateComponent::Contributor(contributor)
1392                if contributor.contributor != citum_schema::template::ContributorRole::Author =>
1393            {
1394                Some(contributor.contributor.clone())
1395            }
1396            _ => None,
1397        });
1398    let mut filtered: Vec<TemplateComponent> =
1399        template.iter().filter_map(strip_author_component).collect();
1400    if let Some(role) = grouping_role
1401        && !filtered.is_empty()
1402    {
1403        let first = filtered.remove(0);
1404        if let (Some(remaining), _) = remove_first_contributor_with_role(first, &role) {
1405            filtered.insert(0, remaining);
1406        }
1407    }
1408    let stripped_leading_affix = filtered
1409        .first()
1410        .and_then(|first| leading_group_affix(first, script, realization, fmt));
1411    let leading_affix = stripped_leading_affix.clone().or_else(|| {
1412        filtered.first().and_then(|_| {
1413            template
1414                .first()
1415                .and_then(|first| author_group_delimiter_affix(first, script, realization, fmt))
1416        })
1417    });
1418    if let Some(first) = filtered.first_mut() {
1419        strip_leading_group_affixes(first);
1420    }
1421    (filtered, leading_affix, stripped_leading_affix.is_some())
1422}
1423
1424fn author_group_delimiter_affix<F>(
1425    component: &TemplateComponent,
1426    script: crate::values::ScriptClass,
1427    realization: Option<&citum_schema::options::PunctuationRealization>,
1428    fmt: &F,
1429) -> Option<String>
1430where
1431    F: crate::render::format::OutputFormat<Output = String>,
1432{
1433    let TemplateComponent::Group(group) = component else {
1434        return None;
1435    };
1436    group
1437        .group
1438        .first()
1439        .is_some_and(component_starts_with_author)
1440        .then_some(group.delimiter.as_ref())
1441        .flatten()
1442        .map(|punctuation| {
1443            let realized = crate::render::format::realize_punctuation(
1444                punctuation,
1445                script,
1446                realization,
1447                crate::render::format::PunctuationPosition::Separator,
1448            );
1449            if punctuation.is_semantic() {
1450                fmt.text(&realized)
1451            } else {
1452                realized.into_owned()
1453            }
1454        })
1455        .filter(|delimiter| !delimiter.is_empty())
1456}
1457
1458fn component_starts_with_author(component: &TemplateComponent) -> bool {
1459    match component {
1460        TemplateComponent::Contributor(contributor) => contributor
1461            .contributor
1462            .contains(&citum_schema::template::ContributorRole::Author),
1463        TemplateComponent::Group(group) => group
1464            .group
1465            .first()
1466            .is_some_and(component_starts_with_author),
1467        _ => false,
1468    }
1469}
1470
1471#[cfg(test)]
1472mod tests {
1473    use super::*;
1474    use citum_schema::template::{
1475        ContributorRole, DelimiterPunctuation, TemplateContributor, TemplateGroup,
1476    };
1477
1478    #[test]
1479    fn author_group_delimiter_affix_recognizes_merged_leading_author_component() {
1480        // given a group whose leading component is a merged [author, editor]
1481        // contributor list rather than a scalar author component
1482        let group = TemplateComponent::Group(TemplateGroup {
1483            group: vec![TemplateComponent::Contributor(TemplateContributor {
1484                contributor: vec![ContributorRole::Author, ContributorRole::Editor].into(),
1485                ..Default::default()
1486            })],
1487            delimiter: Some(DelimiterPunctuation::Comma),
1488            ..Default::default()
1489        });
1490
1491        // when resolving the leading author-group delimiter affix
1492        let affix = author_group_delimiter_affix(
1493            &group,
1494            crate::values::ScriptClass::Latin,
1495            None,
1496            &crate::render::plain::PlainText,
1497        );
1498
1499        // then the merged component is recognized as starting with author
1500        assert_eq!(affix, Some(", ".to_string()));
1501    }
1502
1503    #[test]
1504    fn author_group_delimiter_affix_ignores_merged_component_without_author() {
1505        // given a group whose leading component is a merged [editor,
1506        // translator] contributor list that never declares author
1507        let group = TemplateComponent::Group(TemplateGroup {
1508            group: vec![TemplateComponent::Contributor(TemplateContributor {
1509                contributor: vec![ContributorRole::Editor, ContributorRole::Translator].into(),
1510                ..Default::default()
1511            })],
1512            delimiter: Some(DelimiterPunctuation::Comma),
1513            ..Default::default()
1514        });
1515
1516        // when resolving the leading author-group delimiter affix
1517        let affix = author_group_delimiter_affix(
1518            &group,
1519            crate::values::ScriptClass::Latin,
1520            None,
1521            &crate::render::plain::PlainText,
1522        );
1523
1524        // then no affix is produced since the group does not start with author
1525        assert_eq!(affix, None);
1526    }
1527}