Skip to main content

citum_engine/values/contributor/
mod.rs

1/*
2SPDX-License-Identifier: MIT OR Apache-2.0
3SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus
4*/
5
6//! Rendering logic for contributors (authors, editors, translators).
7//!
8//! This module handles contributor rendering with support for name ordering,
9//! role labels, et-al formatting, and multilingual name resolution.
10
11mod labels;
12pub mod names;
13mod substitute;
14
15use crate::reference::Reference;
16use crate::values::{ComponentValues, ProcHints, ProcValues, RenderContext, RenderOptions};
17use citum_schema::options::{IntegralNameForm, IntegralNameRule};
18use citum_schema::template::{ContributorForm, ContributorRole, TemplateContributor};
19
20#[cfg(test)]
21pub(crate) use names::{NameFormatContext, format_single_name};
22pub use names::{NamesOverrides, format_contributors_short, format_names};
23
24/// Resolve a contributor payload for a template contributor role.
25///
26/// This preserves the legacy `editor()` / `translator()` accessors for
27/// reference shapes that still store those roles outside the generic
28/// contributor-entry list.
29pub(super) fn contributor_for_role(
30    reference: &Reference,
31    role: &ContributorRole,
32) -> Option<citum_schema::reference::Contributor> {
33    match role {
34        ContributorRole::Author => reference.author(),
35        ContributorRole::Editor => reference.editor(),
36        ContributorRole::Translator => reference.translator(),
37        _ => contributor_role_to_reference_role(role).and_then(|role| reference.contributor(role)),
38    }
39}
40
41/// Map a template contributor role to the corresponding reference contributor role.
42pub(super) fn contributor_role_to_reference_role(
43    role: &ContributorRole,
44) -> Option<citum_schema::reference::ContributorRole> {
45    match role {
46        ContributorRole::Author => Some(citum_schema::reference::ContributorRole::Author),
47        ContributorRole::Editor => Some(citum_schema::reference::ContributorRole::Editor),
48        ContributorRole::Translator => Some(citum_schema::reference::ContributorRole::Translator),
49        ContributorRole::Recipient => Some(citum_schema::reference::ContributorRole::Recipient),
50        ContributorRole::Chair => Some(citum_schema::reference::ContributorRole::Unknown(
51            "chair".to_string(),
52        )),
53        ContributorRole::Interviewer => Some(citum_schema::reference::ContributorRole::Interviewer),
54        ContributorRole::Guest => Some(citum_schema::reference::ContributorRole::Guest),
55        ContributorRole::Director => Some(citum_schema::reference::ContributorRole::Director),
56        ContributorRole::Composer => Some(citum_schema::reference::ContributorRole::Composer),
57        ContributorRole::Illustrator => Some(citum_schema::reference::ContributorRole::Illustrator),
58        ContributorRole::Inventor => Some(citum_schema::reference::ContributorRole::Unknown(
59            "inventor".to_string(),
60        )),
61        ContributorRole::Counsel => Some(citum_schema::reference::ContributorRole::Unknown(
62            "counsel".to_string(),
63        )),
64        ContributorRole::CollectionEditor => Some(
65            citum_schema::reference::ContributorRole::Unknown("collection-editor".to_string()),
66        ),
67        ContributorRole::ContainerAuthor => Some(
68            citum_schema::reference::ContributorRole::Unknown("container-author".to_string()),
69        ),
70        ContributorRole::EditorialDirector => Some(
71            citum_schema::reference::ContributorRole::Unknown("editorial-director".to_string()),
72        ),
73        ContributorRole::TextualEditor => Some(citum_schema::reference::ContributorRole::Unknown(
74            "textual-editor".to_string(),
75        )),
76        ContributorRole::OriginalAuthor => Some(citum_schema::reference::ContributorRole::Unknown(
77            "original-author".to_string(),
78        )),
79        ContributorRole::ReviewedAuthor => Some(citum_schema::reference::ContributorRole::Unknown(
80            "reviewed-author".to_string(),
81        )),
82        ContributorRole::Interviewee | ContributorRole::Publisher => None,
83        _ => None,
84    }
85}
86
87/// Checks if a contributor role label should be omitted for a given reference.
88///
89/// Returns true if the role appears in the configuration's role.omit list.
90pub(super) fn is_role_label_omitted(options: &RenderOptions<'_>, role: &ContributorRole) -> bool {
91    options
92        .config
93        .contributors
94        .as_ref()
95        .and_then(|c| c.role.as_ref())
96        .is_some_and(|role_opts| {
97            role_opts
98                .omit
99                .iter()
100                .any(|entry| entry.eq_ignore_ascii_case(role.as_str()))
101        })
102}
103
104/// Format a role term with period stripping if configured.
105///
106/// Handles the repeated pattern of checking `should_strip_periods` and formatting
107/// the result with a given prefix and suffix pattern.
108pub(super) fn format_role_term<F: crate::render::format::OutputFormat<Output = String>>(
109    term: &str,
110    fmt: &F,
111    effective_rendering: &citum_schema::template::Rendering,
112    options: &RenderOptions<'_>,
113    prefix: &str,
114    suffix: &str,
115) -> String {
116    let term_str = if crate::values::should_strip_periods(effective_rendering, options) {
117        crate::values::strip_trailing_periods(term)
118    } else {
119        term.to_string()
120    };
121    fmt.text(&format!("{prefix}{term_str}{suffix}"))
122}
123
124/// Apply `FullThenShort` integral-citation subsequent-form rewrite to contributor form.
125fn apply_integral_subsequent_form(
126    component: &mut TemplateContributor,
127    hints: &ProcHints,
128    options: &RenderOptions<'_>,
129) {
130    if options.context != RenderContext::Citation {
131        return;
132    }
133    if !matches!(options.mode, citum_schema::citation::CitationMode::Integral) {
134        return;
135    }
136    if !matches!(component.contributor, ContributorRole::Author) {
137        return;
138    }
139    if !matches!(
140        hints.integral_name_state,
141        Some(citum_schema::citation::IntegralNameState::Subsequent)
142    ) {
143        return;
144    }
145    if !options
146        .config
147        .integral_names
148        .as_ref()
149        .is_some_and(|cfg| matches!(cfg.resolve().rule, IntegralNameRule::FullThenShort))
150    {
151        return;
152    }
153    let subsequent_form = options
154        .config
155        .integral_names
156        .as_ref()
157        .map_or(IntegralNameForm::Short, |cfg| cfg.resolve().subsequent_form);
158    component.form = match subsequent_form {
159        IntegralNameForm::Short => ContributorForm::Short,
160        IntegralNameForm::FamilyOnly => ContributorForm::FamilyOnly,
161    };
162}
163
164/// Build name overrides and format all names for a contributor component.
165fn format_contributor_names(
166    component: &TemplateContributor,
167    names_vec: &[crate::reference::FlatName],
168    effective_rendering: &citum_schema::template::Rendering,
169    options: &RenderOptions<'_>,
170    hints: &ProcHints,
171) -> String {
172    let effective_name_order = component.name_order.as_ref().or_else(|| {
173        options
174            .config
175            .contributors
176            .as_ref()?
177            .effective_role_name_order(&component.contributor)
178    });
179    let effective_shorten = component
180        .shorten
181        .as_ref()
182        .or_else(|| options.config.contributors.as_ref()?.shorten.as_ref());
183
184    // Priority chain for name_form:
185    // 1. component.name_form (TemplateContributor-level override - highest priority)
186    // 2. effective_rendering.name_form (from overrides, second priority)
187    // 3. config (options-level fallback)
188    let effective_name_form = component.name_form.or(effective_rendering.name_form);
189
190    let name_overrides = names::NamesOverrides {
191        name_order: effective_name_order,
192        sort_separator: component.sort_separator.as_ref(),
193        shorten: effective_shorten,
194        and: component.and.as_ref(),
195        initialize_with: effective_rendering.initialize_with.as_ref(),
196        name_form: effective_name_form,
197    };
198    names::format_names(names_vec, &component.form, options, &name_overrides, hints)
199}
200
201impl ComponentValues for TemplateContributor {
202    #[allow(
203        clippy::too_many_lines,
204        reason = "large match statement for contributor role dispatch"
205    )]
206    fn values<F: crate::render::format::OutputFormat<Output = String>>(
207        &self,
208        reference: &Reference,
209        hints: &ProcHints,
210        options: &RenderOptions<'_>,
211    ) -> Option<ProcValues<F::Output>> {
212        let fmt = F::default();
213
214        let mut component = self.clone();
215        let effective_rendering = self.rendering.clone();
216
217        // Apply integral-citation subsequent-form (FullThenShort rule)
218        apply_integral_subsequent_form(&mut component, hints, options);
219
220        // Respect explicit suppression before any contributor substitution logic.
221        if effective_rendering.suppress == Some(true) {
222            return None;
223        }
224
225        let contributor = match &component.contributor {
226            ContributorRole::Author => {
227                if options.suppress_author {
228                    None
229                } else {
230                    contributor_for_role(reference, &component.contributor)
231                }
232            }
233            _ => contributor_for_role(reference, &component.contributor),
234        };
235
236        // Resolve substitute config once for all substitute/suppression checks below.
237        let default_substitute = citum_schema::options::SubstituteConfig::default();
238        let substitute_config = options
239            .config
240            .substitute
241            .as_ref()
242            .unwrap_or(&default_substitute);
243        let substitute = substitute_config.resolve();
244
245        // Check if this role is suppressed by role-substitute configuration
246        if substitute::is_role_suppressed_by_substitute(
247            &component.contributor,
248            &substitute,
249            reference,
250        ) {
251            return None;
252        }
253
254        // Resolve multilingual names if configured
255        let names_vec = if let Some(contrib) = contributor {
256            substitute::resolve_multilingual_for_contrib(&contrib, options)
257        } else {
258            Vec::new()
259        };
260
261        // If author is suppressed, don't attempt substitution or formatting.
262        if names_vec.is_empty()
263            && matches!(component.contributor, ContributorRole::Author)
264            && options.suppress_author
265        {
266            return None;
267        }
268
269        // Handle substitution if author is empty.
270        if names_vec.is_empty() && matches!(component.contributor, ContributorRole::Author) {
271            return substitute::resolve_author_substitute::<F>(
272                &component,
273                hints,
274                options,
275                reference,
276                &effective_rendering,
277                &fmt,
278                &substitute,
279            );
280        }
281
282        // Handle role-substitute if this role is empty.
283        if names_vec.is_empty() {
284            return substitute::resolve_role_substitute::<F>(
285                &component.contributor,
286                &component,
287                hints,
288                options,
289                reference,
290                &effective_rendering,
291                &fmt,
292                &substitute,
293            );
294        }
295
296        let formatted =
297            format_contributor_names(&component, &names_vec, &effective_rendering, options, hints);
298
299        let role_omitted = is_role_label_omitted(options, &component.contributor);
300        let (role_prefix, role_suffix) = labels::resolve_role_labels::<F>(
301            &component,
302            reference,
303            names_vec.len(),
304            &effective_rendering,
305            options,
306            &fmt,
307            role_omitted,
308        );
309
310        let is_pre_formatted = role_prefix.is_some() || role_suffix.is_some();
311        let formatted = crate::values::apply_abbreviation(formatted, options.abbreviation_map);
312        let final_value = if is_pre_formatted {
313            fmt.text(&formatted)
314        } else {
315            formatted
316        };
317
318        Some(ProcValues {
319            value: final_value,
320            prefix: role_prefix,
321            suffix: role_suffix,
322            url: crate::values::resolve_effective_url(
323                component.links.as_ref(),
324                options.config.links.as_ref(),
325                reference,
326                citum_schema::options::LinkAnchor::Component,
327            ),
328            substituted_key: None,
329            pre_formatted: is_pre_formatted,
330        })
331    }
332}