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 and Citum contributors
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::SubsequentNameForm;
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 the integral-citation subsequent-form rewrite to a contributor on a
125/// `Subsequent` mention. No-op unless the style configures `integral-name-memory`.
126fn apply_integral_subsequent_form(
127    component: &mut TemplateContributor,
128    hints: &ProcHints,
129    options: &RenderOptions<'_>,
130) {
131    if options.context != RenderContext::Citation {
132        return;
133    }
134    if !matches!(options.mode, citum_schema::citation::CitationMode::Integral) {
135        return;
136    }
137    if !matches!(component.contributor, ContributorRole::Author) {
138        return;
139    }
140    if !matches!(
141        hints.integral_name_state,
142        Some(citum_schema::citation::IntegralNameState::Subsequent)
143    ) {
144        return;
145    }
146    let Some(memory) = options.config.integral_name_memory.as_ref() else {
147        return;
148    };
149    component.form = match memory.resolve().subsequent_form {
150        SubsequentNameForm::Short => ContributorForm::Short,
151        SubsequentNameForm::FamilyOnly => ContributorForm::FamilyOnly,
152    };
153}
154
155/// Build name overrides and format all names for a contributor component.
156fn format_contributor_names(
157    component: &TemplateContributor,
158    names_vec: &[crate::reference::FlatName],
159    effective_rendering: &citum_schema::template::Rendering,
160    options: &RenderOptions<'_>,
161    hints: &ProcHints,
162) -> String {
163    let effective_name_order = component.name_order.as_ref().or_else(|| {
164        options
165            .config
166            .contributors
167            .as_ref()?
168            .effective_role_name_order(&component.contributor)
169    });
170    let effective_shorten = component
171        .shorten
172        .as_ref()
173        .or_else(|| options.config.contributors.as_ref()?.shorten.as_ref());
174
175    // Priority chain for name_form:
176    // 1. component.name_form (TemplateContributor-level override - highest priority)
177    // 2. effective_rendering.name_form (from overrides, second priority)
178    // 3. config (options-level fallback)
179    let effective_name_form = component.name_form.or(effective_rendering.name_form);
180
181    let name_overrides = names::NamesOverrides {
182        name_order: effective_name_order,
183        sort_separator: component.sort_separator.as_ref(),
184        shorten: effective_shorten,
185        and: component.and.as_ref(),
186        initialize_with: effective_rendering.initialize_with.as_ref(),
187        name_form: effective_name_form,
188    };
189    names::format_names(names_vec, &component.form, options, &name_overrides, hints)
190}
191
192impl ComponentValues for TemplateContributor {
193    #[allow(
194        clippy::too_many_lines,
195        reason = "large match statement for contributor role dispatch"
196    )]
197    fn values<F: crate::render::format::OutputFormat<Output = String>>(
198        &self,
199        reference: &Reference,
200        hints: &ProcHints,
201        options: &RenderOptions<'_>,
202    ) -> Option<ProcValues<F::Output>> {
203        let fmt = F::default();
204
205        let mut component = self.clone();
206        let effective_rendering = self.rendering.clone();
207
208        // Apply integral-citation subsequent-form (FullThenShort rule)
209        apply_integral_subsequent_form(&mut component, hints, options);
210
211        // Respect explicit suppression before any contributor substitution logic.
212        if effective_rendering.suppress == Some(true) {
213            return None;
214        }
215
216        let contributor = match &component.contributor {
217            ContributorRole::Author => {
218                if options.suppress_author {
219                    None
220                } else {
221                    contributor_for_role(reference, &component.contributor)
222                }
223            }
224            _ => contributor_for_role(reference, &component.contributor),
225        };
226
227        // Resolve substitute config once for all substitute/suppression checks below.
228        let default_substitute = citum_schema::options::SubstituteConfig::default();
229        let substitute_config = options
230            .config
231            .substitute
232            .as_ref()
233            .unwrap_or(&default_substitute);
234        let substitute = substitute_config.resolve();
235
236        // Check if this role is suppressed by role-substitute configuration
237        if substitute::is_role_suppressed_by_substitute(
238            &component.contributor,
239            &substitute,
240            reference,
241        ) {
242            return None;
243        }
244
245        // Resolve multilingual names if configured
246        let names_vec = if let Some(contrib) = contributor {
247            substitute::resolve_multilingual_for_contrib(&contrib, options)
248        } else {
249            Vec::new()
250        };
251
252        // If author is suppressed, don't attempt substitution or formatting.
253        if names_vec.is_empty()
254            && matches!(component.contributor, ContributorRole::Author)
255            && options.suppress_author
256        {
257            return None;
258        }
259
260        // Handle substitution if author is empty.
261        if names_vec.is_empty() && matches!(component.contributor, ContributorRole::Author) {
262            return substitute::resolve_author_substitute::<F>(
263                &component,
264                hints,
265                options,
266                reference,
267                &effective_rendering,
268                &fmt,
269                &substitute,
270            );
271        }
272
273        // Handle role-substitute if this role is empty.
274        if names_vec.is_empty() {
275            return substitute::resolve_role_substitute::<F>(
276                &component.contributor,
277                &component,
278                hints,
279                options,
280                reference,
281                &effective_rendering,
282                &fmt,
283                &substitute,
284            );
285        }
286
287        let formatted =
288            format_contributor_names(&component, &names_vec, &effective_rendering, options, hints);
289
290        let role_omitted = is_role_label_omitted(options, &component.contributor);
291        let (role_prefix, role_suffix) = labels::resolve_role_labels::<F>(
292            &component,
293            reference,
294            names_vec.len(),
295            &effective_rendering,
296            options,
297            &fmt,
298            role_omitted,
299        );
300
301        let is_pre_formatted = role_prefix.is_some() || role_suffix.is_some();
302        let formatted = crate::values::apply_abbreviation(formatted, options.abbreviation_map);
303        let final_value = if is_pre_formatted {
304            fmt.text(&formatted)
305        } else {
306            formatted
307        };
308
309        Some(ProcValues {
310            value: final_value,
311            prefix: role_prefix,
312            suffix: role_suffix,
313            url: crate::values::resolve_effective_url(
314                component.links.as_ref(),
315                options.config.links.as_ref(),
316                reference,
317                citum_schema::options::LinkAnchor::Component,
318            ),
319            substituted_key: None,
320            pre_formatted: is_pre_formatted,
321        })
322    }
323}