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