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