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_citation_number_component, is_term_only_component, resolve_localized_type_variant,
14    resolve_type_variant,
15};
16use super::group_citation_items_by_author;
17use crate::error::ProcessorError;
18use crate::reference::Reference;
19use crate::render::{ProcTemplate, ProcTemplateComponent};
20use crate::values::{ComponentValues, ProcHints, RenderContext, RenderOptions};
21use citum_schema::template::{
22    TemplateComponent, TemplateConditionField, TemplateGroupCondition, WrapConfig, WrapPunctuation,
23};
24use std::borrow::Cow;
25
26struct GroupRenderState<'a> {
27    first_item: &'a crate::reference::CitationItem,
28    first_ref: &'a Reference,
29    template: Cow<'a, [TemplateComponent]>,
30}
31
32struct ItemRenderState<'a> {
33    item: &'a crate::reference::CitationItem,
34    reference: &'a Reference,
35    template: Cow<'a, [TemplateComponent]>,
36}
37
38struct GroupItemRenderRequest<'a> {
39    item: &'a crate::reference::CitationItem,
40    template: &'a [TemplateComponent],
41    mode: &'a citum_schema::citation::CitationMode,
42    suppress_author: bool,
43    position: Option<&'a citum_schema::citation::Position>,
44    note_start_text_case: Option<citum_schema::NoteStartTextCase>,
45    delimiter: &'a str,
46}
47
48/// Resolved context for rendering a single template (or nested group)
49/// component. Bundles parameters that would otherwise inflate
50/// [`Renderer::render_template_component_with_format`] and
51/// [`Renderer::render_group_component_with_format`] past the clippy
52/// argument-count limit.
53struct TemplateRenderContext<'a> {
54    reference: &'a Reference,
55    ref_type: &'a str,
56    options: &'a RenderOptions<'a>,
57    hint: &'a ProcHints,
58    template_index: usize,
59}
60
61/// Inputs for [`Renderer::build_template_render_hint`]. Bundles the
62/// per-citation state that would otherwise push the method past the clippy
63/// argument-count limit.
64struct HintInputs<'a> {
65    reference: &'a Reference,
66    context: RenderContext,
67    citation_number: usize,
68    position: Option<citum_schema::citation::Position>,
69    integral_name_state: Option<citum_schema::citation::IntegralNameState>,
70    org_abbreviation_state: Option<citum_schema::citation::IntegralNameState>,
71    first_reference_note_number: Option<u32>,
72}
73
74fn group_condition_matches(reference: &Reference, condition: &TemplateGroupCondition) -> bool {
75    condition
76        .field_present
77        .as_ref()
78        .is_none_or(|field| condition_field_present(reference, field))
79        && condition
80            .field_absent
81            .as_ref()
82            .is_none_or(|field| !condition_field_present(reference, field))
83}
84
85fn condition_field_present(reference: &Reference, field: &TemplateConditionField) -> bool {
86    match field {
87        TemplateConditionField::Author => reference.author().is_some(),
88        TemplateConditionField::Editor => reference.editor().is_some(),
89        TemplateConditionField::Recipient => reference
90            .contributor(citum_schema::reference::ContributorRole::Recipient)
91            .is_some(),
92        TemplateConditionField::Translator => reference.translator().is_some(),
93        TemplateConditionField::Title => reference.title().is_some(),
94        TemplateConditionField::CollectionTitle => reference.collection_title().is_some(),
95        TemplateConditionField::Issued => reference.effective_issued_date().is_some(),
96        TemplateConditionField::OriginalPublished => reference.original_date().is_some(),
97        TemplateConditionField::Publisher => reference.publisher_str().is_some(),
98        TemplateConditionField::OriginalPublisher => reference.original_publisher_str().is_some(),
99        TemplateConditionField::OriginalPublisherPlace => {
100            reference.original_publisher_place().is_some()
101        }
102        TemplateConditionField::OriginalTitle => reference.original_title().is_some(),
103        TemplateConditionField::Doi => reference.doi().is_some(),
104        TemplateConditionField::Genre => reference.genre().is_some(),
105        TemplateConditionField::Archive => reference.archive().is_some(),
106        TemplateConditionField::ArchiveLocation => reference.archive_location().is_some(),
107        TemplateConditionField::VolumeOrIssue => {
108            reference.volume().is_some() || reference.issue().is_some()
109        }
110    }
111}
112
113impl Renderer<'_> {
114    fn strip_redundant_leading_group_punctuation<'a>(
115        &self,
116        value: &'a str,
117        delimiter: &str,
118    ) -> &'a str {
119        let Some(delimiter_char) = delimiter.chars().find(|ch| !ch.is_whitespace()) else {
120            return value;
121        };
122
123        let trimmed = value.trim_start();
124        if !trimmed.starts_with(delimiter_char) {
125            return value;
126        }
127
128        #[allow(clippy::string_slice, reason = "delimiter found at start")]
129        trimmed[delimiter_char.len_utf8()..].trim_start()
130    }
131
132    fn join_integral_group_item_parts(&self, item_parts: &[String], delimiter: &str) -> String {
133        let repeated_item_delimiter = if delimiter.trim().is_empty() {
134            ", "
135        } else {
136            delimiter
137        };
138
139        let mut joined = String::new();
140        for (index, part) in item_parts.iter().enumerate() {
141            if index > 0 {
142                joined.push_str(repeated_item_delimiter);
143            }
144
145            let normalized = if index == 0 {
146                part.as_str()
147            } else {
148                self.strip_redundant_leading_group_punctuation(part, repeated_item_delimiter)
149            };
150            joined.push_str(normalized);
151        }
152
153        joined
154    }
155
156    /// Render citation items with author grouping, using plain text format.
157    ///
158    /// # Errors
159    ///
160    /// Returns an error when a referenced item is missing or grouped rendering fails.
161    pub fn render_grouped_citation(
162        &self,
163        items: &[crate::reference::CitationItem],
164        spec: &citum_schema::CitationSpec,
165        mode: &citum_schema::citation::CitationMode,
166        intra_delimiter: &str,
167        suppress_author: bool,
168        position: Option<&citum_schema::citation::Position>,
169    ) -> Result<Vec<String>, ProcessorError> {
170        self.render_grouped_citation_with_format::<crate::render::plain::PlainText>(
171            items,
172            &GroupRenderParams {
173                spec,
174                mode,
175                intra_delimiter,
176                suppress_author,
177                position,
178                note_start_text_case: spec.note_start_text_case,
179            },
180        )
181    }
182
183    /// Render a group of items that must not be author-collapsed (legal cases,
184    /// personal communications). Returns the rendered citation strings.
185    fn render_special_type_items<F>(
186        &self,
187        group: &[&crate::reference::CitationItem],
188        params: &GroupRenderParams<'_>,
189    ) -> Result<Vec<String>, ProcessorError>
190    where
191        F: crate::render::format::OutputFormat<Output = String>,
192    {
193        let fmt = F::default();
194        let mut rendered_items = Vec::new();
195        for item in group {
196            let state = self.resolve_item_render_state(item, params.spec)?;
197            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
198                state.reference,
199                GroupItemRenderRequest {
200                    item: state.item,
201                    template: &state.template,
202                    mode: params.mode,
203                    suppress_author: params.suppress_author,
204                    position: params.position,
205                    note_start_text_case: params.note_start_text_case,
206                    delimiter: params.intra_delimiter,
207                },
208            ) && let Some((ids, content)) = self.build_citation_chunk(
209                &fmt,
210                vec![item.id.clone()],
211                item_str,
212                item.prefix.as_deref(),
213                item.suffix.as_deref(),
214            ) {
215                rendered_items.push(fmt.citation(ids, content));
216            }
217        }
218        Ok(rendered_items)
219    }
220
221    /// Render one citation group using the explicit integral template.
222    ///
223    /// Returns `Ok(Some(citation))` if the group rendered (caller should push and `continue`),
224    /// or `Ok(None)` if no items produced output (caller should fall through to other branches).
225    fn render_integral_explicit_group<F>(
226        &self,
227        group: &[&crate::reference::CitationItem],
228        spec: &citum_schema::CitationSpec,
229        mode: &citum_schema::citation::CitationMode,
230        suppress_author: bool,
231        position: Option<&citum_schema::citation::Position>,
232    ) -> Result<Option<String>, ProcessorError>
233    where
234        F: crate::render::format::OutputFormat<Output = String>,
235    {
236        let fmt = F::default();
237        let component_delimiter = spec.delimiter.as_deref().unwrap_or(" ");
238        let item_join_delim = spec.multi_cite_delimiter.as_deref().unwrap_or(", ");
239        let mut group_items_str = Vec::new();
240        let mut all_ids = Vec::new();
241
242        for item in group {
243            let state = self.resolve_item_render_state(item, spec)?;
244            if let Some(item_str) = self.render_group_item_from_template_with_format::<F>(
245                state.reference,
246                GroupItemRenderRequest {
247                    item: state.item,
248                    template: &state.template,
249                    mode,
250                    suppress_author,
251                    position,
252                    note_start_text_case: spec.note_start_text_case,
253                    delimiter: component_delimiter,
254                },
255            ) && !item_str.is_empty()
256            {
257                group_items_str.push(self.affix_content(
258                    &fmt,
259                    item_str,
260                    item.prefix.as_deref(),
261                    item.suffix.as_deref(),
262                    Some(item.id.as_str()),
263                ));
264                all_ids.push(item.id.clone());
265            }
266        }
267
268        if group_items_str.is_empty() {
269            return Ok(None);
270        }
271
272        let combined_str = group_items_str.join(item_join_delim);
273        Ok(Some(fmt.citation(all_ids, combined_str)))
274    }
275
276    /// This preserves per-item output when grouping rules require items to stay
277    /// separate, and otherwise applies the requested renderer format to the
278    /// grouped citation output.
279    ///
280    /// # Errors
281    ///
282    /// Returns an error when a referenced item is missing or grouped rendering
283    /// fails.
284    pub fn render_grouped_citation_with_format<F>(
285        &self,
286        items: &[crate::reference::CitationItem],
287        params: &GroupRenderParams<'_>,
288    ) -> Result<Vec<String>, ProcessorError>
289    where
290        F: crate::render::format::OutputFormat<Output = String>,
291    {
292        let groups = group_citation_items_by_author(self, items);
293        let mut rendered_groups = Vec::new();
294        for (_author_key, group) in groups {
295            rendered_groups
296                .extend(self.render_grouped_citation_group_with_format::<F>(&group, params)?);
297        }
298
299        Ok(rendered_groups)
300    }
301
302    fn render_grouped_citation_group_with_format<F>(
303        &self,
304        group: &[&crate::reference::CitationItem],
305        params: &GroupRenderParams<'_>,
306    ) -> Result<Vec<String>, ProcessorError>
307    where
308        F: crate::render::format::OutputFormat<Output = String>,
309    {
310        let state = self.resolve_group_render_state(group, params.spec)?;
311
312        // Multi-item same-author groups always collapse: one author name, all years
313        // joined, in both integral and non-integral modes. The year-group wrap is
314        // captured from the template and applied once around all years.
315        // Single-item groups use the per-item explicit integral path when available.
316        if group.len() == 1
317            && let Some(citation) = self.try_render_integral_group_with_format::<F>(
318                group,
319                params.spec,
320                params.mode,
321                params.suppress_author,
322                params.position,
323            )?
324        {
325            return Ok(vec![citation]);
326        }
327
328        if self.requires_full_group_item_rendering(params.mode, state.first_ref) {
329            return self.render_special_type_items::<F>(group, params);
330        }
331
332        Ok(self
333            .render_fallback_grouped_citation_with_format::<F>(
334                group,
335                state.first_ref,
336                state.first_item,
337                &state.template,
338                params,
339            )?
340            .into_iter()
341            .collect())
342    }
343
344    fn render_fallback_grouped_citation_with_format<F>(
345        &self,
346        group: &[&crate::reference::CitationItem],
347        first_ref: &Reference,
348        first_item: &crate::reference::CitationItem,
349        template: &[TemplateComponent],
350        params: &GroupRenderParams<'_>,
351    ) -> Result<Option<String>, ProcessorError>
352    where
353        F: crate::render::format::OutputFormat<Output = String>,
354    {
355        let fmt = F::default();
356        let author_part = self.render_author_for_grouping_with_format::<F>(
357            first_ref,
358            first_item,
359            template,
360            params.mode,
361            params.suppress_author,
362            params.position,
363        );
364        let (item_parts, group_delimiter, captured_year_wrap) =
365            self.render_group_item_parts_with_format::<F>(&fmt, group, params)?;
366        // Pre-compute a format-aware wrapped years string for integral collapsed groups.
367        // Using fmt.inner_affix + fmt.wrap_punctuation honours output-format-specific
368        // punctuation (e.g. LaTeX ``…'') and preserves WrapConfig.inner_prefix/suffix.
369        // Non-integral groups leave pre_wrapped_years as None and rely on the
370        // per-item template path in build_grouped_citation_content.
371        let pre_wrapped_years =
372            if matches!(params.mode, citum_schema::citation::CitationMode::Integral)
373                && !item_parts.is_empty()
374            {
375                let delimiter = group_delimiter.as_deref().unwrap_or(params.intra_delimiter);
376                let joined = self.join_integral_group_item_parts(&item_parts, delimiter);
377                let wrap_punct = captured_year_wrap
378                    .as_ref()
379                    .map(|w| &w.punctuation)
380                    .unwrap_or(&WrapPunctuation::Parentheses);
381                let inner_prefix = captured_year_wrap
382                    .as_ref()
383                    .and_then(|w| w.inner_prefix.as_deref())
384                    .unwrap_or("");
385                let inner_suffix = captured_year_wrap
386                    .as_ref()
387                    .and_then(|w| w.inner_suffix.as_deref())
388                    .unwrap_or("");
389                let inner = fmt.inner_affix(inner_prefix, joined, inner_suffix);
390                let marks = crate::render::format::QuoteMarks::from(&self.locale.grammar_options);
391                let (script, realization) = crate::values::punctuation_realization_context(
392                    crate::values::effective_item_language(first_ref).as_deref(),
393                    self.config.multilingual.as_ref(),
394                    self.locale.punctuation_realization.as_ref(),
395                );
396                Some(fmt.wrap_punctuation(
397                    wrap_punct,
398                    inner,
399                    &marks,
400                    script,
401                    realization.as_deref(),
402                ))
403            } else {
404                None
405            };
406        let Some(content) = self.build_grouped_citation_content(
407            &author_part,
408            &item_parts,
409            params,
410            group_delimiter.as_deref(),
411            pre_wrapped_years.as_deref(),
412        ) else {
413            return Ok(None);
414        };
415        let group_ids = group.iter().map(|item| item.id.clone()).collect();
416        let prefix = first_item.prefix.as_deref().unwrap_or("");
417        // Suffix is embedded in item_parts by render_group_item_parts_with_format when
418        // item_parts is non-empty. Apply it here only when item_parts was empty (author-only output).
419        let suffix = if item_parts.is_empty() {
420            first_item.suffix.as_deref()
421        } else {
422            None
423        };
424
425        Ok(Some(fmt.citation(
426            group_ids,
427            self.affix_content(
428                &fmt,
429                content,
430                Some(prefix),
431                suffix,
432                Some(first_item.id.as_str()),
433            ),
434        )))
435    }
436
437    fn build_grouped_citation_content(
438        &self,
439        author_part: &str,
440        item_parts: &[String],
441        params: &GroupRenderParams<'_>,
442        group_delimiter: Option<&str>,
443        pre_wrapped_years: Option<&str>,
444    ) -> Option<String> {
445        if !author_part.is_empty() && !item_parts.is_empty() {
446            let author_item_delimiter = group_delimiter.unwrap_or(params.intra_delimiter);
447            return Some(match params.mode {
448                citum_schema::citation::CitationMode::Integral => {
449                    // pre_wrapped_years is Some for collapsed multi-item integral groups
450                    // (format-aware wrap applied upstream). For single-item groups this
451                    // path is not reached (they use the explicit integral path instead).
452                    let wrapped = pre_wrapped_years.map(str::to_string).unwrap_or_else(|| {
453                        self.join_integral_group_item_parts(item_parts, author_item_delimiter)
454                    });
455                    self.format_integral_grouped_items(
456                        author_part,
457                        &wrapped,
458                        params.suppress_author,
459                    )
460                }
461                citum_schema::citation::CitationMode::NonIntegral => {
462                    let repeated_item_delimiter = if author_item_delimiter.trim().is_empty() {
463                        ", "
464                    } else {
465                        author_item_delimiter
466                    };
467                    let joined_items = item_parts.join(repeated_item_delimiter);
468                    self.format_non_integral_grouped_items(
469                        author_part,
470                        author_item_delimiter,
471                        &joined_items,
472                        params.suppress_author,
473                    )
474                }
475            });
476        }
477
478        if !author_part.is_empty() {
479            return Some(author_part.to_string());
480        }
481
482        if !item_parts.is_empty() {
483            return Some(item_parts.join(params.intra_delimiter));
484        }
485
486        None
487    }
488
489    fn format_integral_grouped_items(
490        &self,
491        author_part: &str,
492        wrapped_content: &str,
493        suppress_author: bool,
494    ) -> String {
495        if suppress_author {
496            wrapped_content.to_string()
497        } else {
498            format!("{author_part} {wrapped_content}")
499        }
500    }
501
502    fn format_non_integral_grouped_items(
503        &self,
504        author_part: &str,
505        author_item_delimiter: &str,
506        joined_items: &str,
507        suppress_author: bool,
508    ) -> String {
509        if suppress_author {
510            return joined_items.to_string();
511        }
512
513        if let Some(adjusted) =
514            self.adjust_grouped_author_quote_punctuation(author_part, author_item_delimiter)
515        {
516            return format!("{adjusted}{joined_items}");
517        }
518
519        format!("{author_part}{author_item_delimiter}{joined_items}")
520    }
521
522    fn adjust_grouped_author_quote_punctuation(
523        &self,
524        author_part: &str,
525        author_item_delimiter: &str,
526    ) -> Option<String> {
527        if !self.config.punctuation_in_quote || !author_item_delimiter.starts_with(',') {
528            return None;
529        }
530
531        let close_quote = crate::render::format::QuoteMarks::from(self.locale).close;
532        let mut adjusted = author_part.to_string();
533        if !crate::render::punctuation::move_punctuation_into_quote(
534            &mut adjusted,
535            ',',
536            &close_quote,
537        ) {
538            return None;
539        }
540        #[allow(clippy::string_slice, reason = "delimiter checked to start with ','")]
541        Some(format!("{adjusted}{}", &author_item_delimiter[1..]))
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            label_only: false,
1178        })
1179    }
1180
1181    fn render_group_component_with_format<F>(
1182        &self,
1183        ctx: &TemplateRenderContext<'_>,
1184        group: &citum_schema::template::TemplateGroup,
1185        tracker: &mut TemplateComponentTracker,
1186    ) -> Option<ProcTemplateComponent>
1187    where
1188        F: crate::render::format::OutputFormat<Output = String>,
1189    {
1190        if group.rendering.suppress == Some(true) {
1191            return None;
1192        }
1193        if group
1194            .render_when
1195            .as_ref()
1196            .is_some_and(|condition| !group_condition_matches(ctx.reference, condition))
1197        {
1198            return None;
1199        }
1200
1201        let fmt = F::default();
1202        let mut group_tracker = tracker.clone();
1203        let (values, label_only) =
1204            self.render_group_child_values(&fmt, ctx, group, &mut group_tracker)?;
1205        let default_delimiter = citum_schema::template::DelimiterPunctuation::Comma;
1206        let punctuation = group.delimiter.as_ref().unwrap_or(&default_delimiter);
1207        let (script, realization) = crate::values::punctuation_realization_context(
1208            crate::values::effective_item_language(ctx.reference).as_deref(),
1209            ctx.options.config.multilingual.as_ref(),
1210            ctx.options.locale.punctuation_realization.as_ref(),
1211        );
1212        let delimiter = crate::render::format::realize_punctuation(
1213            punctuation,
1214            script,
1215            realization.as_deref(),
1216            crate::render::format::PunctuationPosition::Separator,
1217        );
1218        let delimiter = if punctuation.is_semantic() {
1219            fmt.text(&delimiter)
1220        } else {
1221            delimiter.into_owned()
1222        };
1223        // Joining two empty strings surfaces any format-specific escaping
1224        // `fmt.join` would apply to the delimiter itself (e.g. LaTeX special
1225        // characters), so the boundary-aware join below sees the same
1226        // delimiter text `fmt.join` would have inserted.
1227        let escaped_delimiter = fmt.join(vec![String::new(), String::new()], &delimiter);
1228        let escaped_delimiter =
1229            crate::render::format::RealizedPunctuation::new(escaped_delimiter.into());
1230        let close_quote = crate::render::format::QuoteMarks::from(ctx.options.locale).close;
1231        let joined_value = crate::render::punctuation::join_with_quote_movement(
1232            values,
1233            &escaped_delimiter,
1234            ctx.options.config.punctuation_in_quote,
1235            &close_quote,
1236        );
1237        tracker.merge_from(group_tracker);
1238        let group_component = TemplateComponent::Group(group.clone());
1239        Some(ProcTemplateComponent {
1240            template_component: group_component.clone(),
1241            template_index: self.inject_ast_indices.then_some(ctx.template_index),
1242            value: joined_value,
1243            prefix: None,
1244            suffix: None,
1245            url: None,
1246            ref_type: Some(ctx.ref_type.to_string()),
1247            config: Some(ctx.options.config.clone()),
1248            bibliography_config: ctx.options.bibliography_config.clone(),
1249            item_language: crate::values::effective_component_language(
1250                ctx.reference,
1251                &group_component,
1252            ),
1253            quote_marks: crate::render::format::QuoteMarks::from(ctx.options.locale),
1254            sentence_initial: false,
1255            pre_formatted: true,
1256            label_only,
1257        })
1258    }
1259
1260    /// Render the children of a template group into rendered strings, dropping
1261    /// empty values. Returns `None` when no child carries meaningful content
1262    /// (i.e. only term-only siblings produced output) — except for the
1263    /// bibliography numeric-label pattern (`update_label_mode`'s injected
1264    /// `[label, following]` group), where the label alone is still real
1265    /// content that must render even when `following` is empty (e.g. no
1266    /// author); the second element of the returned tuple flags that case so
1267    /// the caller can mark the resulting component `label_only` instead of
1268    /// treating it as ordinary preceding content for separator purposes.
1269    /// Borrows the parent `fmt` so a stateful `OutputFormat` sees a single
1270    /// instance for both child rendering and the final `join` in the caller.
1271    fn render_group_child_values<F>(
1272        &self,
1273        fmt: &F,
1274        ctx: &TemplateRenderContext<'_>,
1275        group: &citum_schema::template::TemplateGroup,
1276        tracker: &mut TemplateComponentTracker,
1277    ) -> Option<(Vec<String>, bool)>
1278    where
1279        F: crate::render::format::OutputFormat<Output = String>,
1280    {
1281        let mut has_meaningful_content = false;
1282        let mut has_citation_number_label = false;
1283        let mut values = Vec::with_capacity(group.group.len());
1284
1285        for item in &group.group {
1286            let Some(rendered) =
1287                self.render_template_component_with_format::<F>(ctx, item, tracker)
1288            else {
1289                continue;
1290            };
1291            let rendered_str = crate::render::render_component_with_format_and_renderer::<F>(
1292                &rendered,
1293                fmt,
1294                ctx.options.show_semantics,
1295            );
1296            if rendered_str.trim().is_empty() {
1297                continue;
1298            }
1299            if is_citation_number_component(item) {
1300                has_citation_number_label = true;
1301            } else if !is_term_only_component(item) {
1302                has_meaningful_content = true;
1303            }
1304            values.push(rendered_str);
1305        }
1306
1307        if values.is_empty() || !(has_meaningful_content || has_citation_number_label) {
1308            return None;
1309        }
1310        Some((values, !has_meaningful_content && has_citation_number_label))
1311    }
1312
1313    fn apply_issued_no_date_fallback(
1314        &self,
1315        reference: &Reference,
1316        options: &RenderOptions<'_>,
1317        component: &TemplateComponent,
1318        values: &mut crate::values::ProcValues<String>,
1319    ) {
1320        if !matches!(
1321            component,
1322            TemplateComponent::Date(citum_schema::template::TemplateDate {
1323                date: citum_schema::template::DateVariable::Issued,
1324                ..
1325            })
1326        ) || reference.effective_issued_date().is_some()
1327            || self.preferred_no_date_term_form() != citum_schema::locale::TermForm::Long
1328        {
1329            return;
1330        }
1331
1332        if let Some(long) = options.locale.resolved_general_term(
1333            &citum_schema::locale::GeneralTerm::NoDate,
1334            &citum_schema::locale::TermForm::Long,
1335            None,
1336        ) {
1337            values.value = long;
1338        }
1339    }
1340
1341    fn apply_entry_link_fallback(
1342        &self,
1343        reference: &Reference,
1344        options: &RenderOptions<'_>,
1345        values: &mut crate::values::ProcValues<String>,
1346    ) {
1347        if values.url.is_some() {
1348            return;
1349        }
1350
1351        let Some(links) = &options.config.links else {
1352            return;
1353        };
1354        use citum_schema::options::LinkAnchor;
1355        if matches!(links.anchor, Some(LinkAnchor::Entry)) {
1356            values.url = crate::values::resolve_url(links, reference);
1357        }
1358    }
1359
1360    /// Apply the substitution string to the primary contributor component.
1361    pub fn apply_author_substitution(&self, proc: &mut ProcTemplate, substitute: &str) {
1362        self.apply_author_substitution_with_format::<crate::render::plain::PlainText>(
1363            proc, substitute,
1364        );
1365    }
1366
1367    /// Apply the substitution string to the primary contributor component with specific format.
1368    pub fn apply_author_substitution_with_format<F>(
1369        &self,
1370        proc: &mut ProcTemplate,
1371        substitute: &str,
1372    ) where
1373        F: crate::render::format::OutputFormat<Output = String>,
1374    {
1375        if let Some(component) = proc
1376            .iter_mut()
1377            .find(|c| matches!(c.template_component, TemplateComponent::Contributor(_)))
1378        {
1379            let fmt = F::default();
1380            component.value = fmt.text(substitute);
1381        }
1382    }
1383
1384    /// Term form used for the "no date" fallback, from `options.dates.no-date-form`
1385    /// (default `short`).
1386    fn preferred_no_date_term_form(&self) -> citum_schema::locale::TermForm {
1387        match self
1388            .config
1389            .dates
1390            .as_ref()
1391            .and_then(|dates| dates.no_date_form)
1392        {
1393            Some(citum_schema::options::NoDateForm::Long) => citum_schema::locale::TermForm::Long,
1394            Some(citum_schema::options::NoDateForm::Short) | None => {
1395                citum_schema::locale::TermForm::Short
1396            }
1397        }
1398    }
1399
1400    fn render_group_item_from_template_with_format<F>(
1401        &self,
1402        reference: &Reference,
1403        item_request: GroupItemRenderRequest<'_>,
1404    ) -> Option<String>
1405    where
1406        F: crate::render::format::OutputFormat<Output = String>,
1407    {
1408        let request = self.citation_render_request(
1409            item_request.item,
1410            item_request.template,
1411            item_request.mode,
1412            item_request.suppress_author,
1413            item_request.position,
1414            item_request.note_start_text_case,
1415        );
1416        self.render_item_from_template_with_format::<F>(reference, request, item_request.delimiter)
1417    }
1418}
1419
1420/// Return `true` when `template` (or any nested group) contains a
1421/// `number: first-reference-note-number` component.
1422///
1423/// Used to gate `suppress_disambiguation_title`: if the style's template does
1424/// not render the note-number identifier, there is nothing to replace the
1425/// suppressed title and ambiguity would silently be reintroduced.
1426pub(super) fn template_uses_first_ref_note_number(template: &[TemplateComponent]) -> bool {
1427    template.iter().any(|c| match c {
1428        TemplateComponent::Number(n) => {
1429            n.number == citum_schema::template::NumberVariable::FirstReferenceNoteNumber
1430        }
1431        TemplateComponent::Group(g) => template_uses_first_ref_note_number(&g.group),
1432        _ => false,
1433    })
1434}
1435
1436pub(super) fn filter_author_from_template<F>(
1437    template: &[TemplateComponent],
1438    script: crate::values::ScriptClass,
1439    realization: Option<&citum_schema::options::PunctuationRealization>,
1440    fmt: &F,
1441) -> (Vec<TemplateComponent>, Option<String>, bool)
1442where
1443    F: crate::render::format::OutputFormat<Output = String>,
1444{
1445    // The author part rendered by `render_author_for_grouping_with_format`
1446    // is the first grouping component of the leading template component —
1447    // any contributor role, not just author. Strip that exact contributor
1448    // from the item parts too, or a template leading with e.g. a translator
1449    // renders its names twice (once as author part, once in the item part).
1450    let grouping_role = template
1451        .first()
1452        .and_then(find_grouping_component)
1453        .and_then(|component| match component {
1454            TemplateComponent::Contributor(contributor)
1455                if contributor.contributor != citum_schema::template::ContributorRole::Author =>
1456            {
1457                Some(contributor.contributor.clone())
1458            }
1459            _ => None,
1460        });
1461    let mut filtered: Vec<TemplateComponent> =
1462        template.iter().filter_map(strip_author_component).collect();
1463    if let Some(role) = grouping_role
1464        && !filtered.is_empty()
1465    {
1466        let first = filtered.remove(0);
1467        if let (Some(remaining), _) = remove_first_contributor_with_role(first, &role) {
1468            filtered.insert(0, remaining);
1469        }
1470    }
1471    let stripped_leading_affix = filtered
1472        .first()
1473        .and_then(|first| leading_group_affix(first, script, realization, fmt));
1474    let leading_affix = stripped_leading_affix.clone().or_else(|| {
1475        filtered.first().and_then(|_| {
1476            template
1477                .first()
1478                .and_then(|first| author_group_delimiter_affix(first, script, realization, fmt))
1479        })
1480    });
1481    if let Some(first) = filtered.first_mut() {
1482        strip_leading_group_affixes(first);
1483    }
1484    (filtered, leading_affix, stripped_leading_affix.is_some())
1485}
1486
1487fn author_group_delimiter_affix<F>(
1488    component: &TemplateComponent,
1489    script: crate::values::ScriptClass,
1490    realization: Option<&citum_schema::options::PunctuationRealization>,
1491    fmt: &F,
1492) -> Option<String>
1493where
1494    F: crate::render::format::OutputFormat<Output = String>,
1495{
1496    let TemplateComponent::Group(group) = component else {
1497        return None;
1498    };
1499    group
1500        .group
1501        .first()
1502        .is_some_and(component_starts_with_author)
1503        .then_some(group.delimiter.as_ref())
1504        .flatten()
1505        .map(|punctuation| {
1506            let realized = crate::render::format::realize_punctuation(
1507                punctuation,
1508                script,
1509                realization,
1510                crate::render::format::PunctuationPosition::Separator,
1511            );
1512            if punctuation.is_semantic() {
1513                fmt.text(&realized)
1514            } else {
1515                realized.into_owned()
1516            }
1517        })
1518        .filter(|delimiter| !delimiter.is_empty())
1519}
1520
1521fn component_starts_with_author(component: &TemplateComponent) -> bool {
1522    match component {
1523        TemplateComponent::Contributor(contributor) => contributor
1524            .contributor
1525            .contains(&citum_schema::template::ContributorRole::Author),
1526        TemplateComponent::Group(group) => group
1527            .group
1528            .first()
1529            .is_some_and(component_starts_with_author),
1530        _ => false,
1531    }
1532}
1533
1534#[cfg(test)]
1535mod tests {
1536    use super::*;
1537    use citum_schema::template::{
1538        ContributorRole, DelimiterPunctuation, TemplateContributor, TemplateGroup,
1539    };
1540
1541    #[test]
1542    fn author_group_delimiter_affix_recognizes_merged_leading_author_component() {
1543        // given a group whose leading component is a merged [author, editor]
1544        // contributor list rather than a scalar author component
1545        let group = TemplateComponent::Group(TemplateGroup {
1546            group: vec![TemplateComponent::Contributor(TemplateContributor {
1547                contributor: vec![ContributorRole::Author, ContributorRole::Editor].into(),
1548                ..Default::default()
1549            })],
1550            delimiter: Some(DelimiterPunctuation::Comma),
1551            ..Default::default()
1552        });
1553
1554        // when resolving the leading author-group delimiter affix
1555        let affix = author_group_delimiter_affix(
1556            &group,
1557            crate::values::ScriptClass::Latin,
1558            None,
1559            &crate::render::plain::PlainText,
1560        );
1561
1562        // then the merged component is recognized as starting with author
1563        assert_eq!(affix, Some(", ".to_string()));
1564    }
1565
1566    #[test]
1567    fn author_group_delimiter_affix_ignores_merged_component_without_author() {
1568        // given a group whose leading component is a merged [editor,
1569        // translator] contributor list that never declares author
1570        let group = TemplateComponent::Group(TemplateGroup {
1571            group: vec![TemplateComponent::Contributor(TemplateContributor {
1572                contributor: vec![ContributorRole::Editor, ContributorRole::Translator].into(),
1573                ..Default::default()
1574            })],
1575            delimiter: Some(DelimiterPunctuation::Comma),
1576            ..Default::default()
1577        });
1578
1579        // when resolving the leading author-group delimiter affix
1580        let affix = author_group_delimiter_affix(
1581            &group,
1582            crate::values::ScriptClass::Latin,
1583            None,
1584            &crate::render::plain::PlainText,
1585        );
1586
1587        // then no affix is produced since the group does not start with author
1588        assert_eq!(affix, None);
1589    }
1590}