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
11pub(crate) mod 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::Performer => Some(citum_schema::reference::ContributorRole::Performer),
56        ContributorRole::Director => Some(citum_schema::reference::ContributorRole::Director),
57        ContributorRole::Composer => Some(citum_schema::reference::ContributorRole::Composer),
58        ContributorRole::Writer => Some(citum_schema::reference::ContributorRole::Writer),
59        ContributorRole::Illustrator => Some(citum_schema::reference::ContributorRole::Illustrator),
60        ContributorRole::Inventor => Some(citum_schema::reference::ContributorRole::Unknown(
61            "inventor".to_string(),
62        )),
63        ContributorRole::Counsel => Some(citum_schema::reference::ContributorRole::Unknown(
64            "counsel".to_string(),
65        )),
66        ContributorRole::CollectionEditor => Some(
67            citum_schema::reference::ContributorRole::Unknown("collection-editor".to_string()),
68        ),
69        ContributorRole::ContainerAuthor => Some(
70            citum_schema::reference::ContributorRole::Unknown("container-author".to_string()),
71        ),
72        ContributorRole::EditorialDirector => Some(
73            citum_schema::reference::ContributorRole::Unknown("editorial-director".to_string()),
74        ),
75        ContributorRole::TextualEditor => Some(citum_schema::reference::ContributorRole::Unknown(
76            "textual-editor".to_string(),
77        )),
78        ContributorRole::OriginalAuthor => Some(citum_schema::reference::ContributorRole::Unknown(
79            "original-author".to_string(),
80        )),
81        ContributorRole::ReviewedAuthor => Some(citum_schema::reference::ContributorRole::Unknown(
82            "reviewed-author".to_string(),
83        )),
84        ContributorRole::Unknown(role) => Some(match role.as_str() {
85            "compiler" => citum_schema::reference::ContributorRole::Compiler,
86            "performer" => citum_schema::reference::ContributorRole::Performer,
87            "narrator" => citum_schema::reference::ContributorRole::Narrator,
88            "host" => citum_schema::reference::ContributorRole::Host,
89            "producer" | "executive-producer" => citum_schema::reference::ContributorRole::Producer,
90            "writer" => citum_schema::reference::ContributorRole::Writer,
91            _ => citum_schema::reference::ContributorRole::Unknown(role.clone()),
92        }),
93        ContributorRole::Interviewee | ContributorRole::Publisher => None,
94        _ => None,
95    }
96}
97
98/// Checks if a contributor role label should be omitted for a given reference.
99///
100/// Returns true if the role appears in the configuration's role.omit list.
101pub(super) fn is_role_label_omitted(options: &RenderOptions<'_>, role: &ContributorRole) -> bool {
102    options
103        .config
104        .contributors
105        .as_ref()
106        .and_then(|c| c.role.as_ref())
107        .is_some_and(|role_opts| {
108            role_opts
109                .omit
110                .iter()
111                .any(|entry| entry.eq_ignore_ascii_case(role.as_str()))
112        })
113}
114
115/// Format a role term with period stripping if configured.
116///
117/// Handles the repeated pattern of checking `should_strip_periods` and formatting
118/// the result with a given prefix and suffix pattern.
119pub(super) fn format_role_term<F: crate::render::format::OutputFormat<Output = String>>(
120    term: &str,
121    fmt: &F,
122    effective_rendering: &citum_schema::template::Rendering,
123    options: &RenderOptions<'_>,
124    prefix: &str,
125    suffix: &str,
126) -> String {
127    let term_str = if crate::values::should_strip_periods(effective_rendering, options) {
128        crate::values::strip_trailing_periods(term)
129    } else {
130        term.to_string()
131    };
132    // Locale role terms are stored lowercase (e.g. "translated by") since
133    // they usually sit mid-sentence. A `form: verb` component is marked
134    // `pre_formatted`, which skips the generic title/value text-case pass,
135    // so a style that positions the verb label as its own clause (e.g.
136    // after a `". "` prefix) must opt in here explicitly.
137    let term_str = match effective_rendering.text_case {
138        Some(citum_schema::options::titles::TextCase::CapitalizeFirst) => {
139            crate::values::text_case::capitalize_first_word(&term_str)
140        }
141        _ => term_str,
142    };
143    fmt.text(&format!("{prefix}{term_str}{suffix}"))
144}
145
146/// Apply the integral-citation subsequent-form rewrite to a contributor on a
147/// `Subsequent` mention. No-op unless the style configures `integral-name-memory`.
148fn apply_integral_subsequent_form(
149    component: &mut TemplateContributor,
150    hints: &ProcHints,
151    options: &RenderOptions<'_>,
152) {
153    if options.context != RenderContext::Citation {
154        return;
155    }
156    if !matches!(options.mode, citum_schema::citation::CitationMode::Integral) {
157        return;
158    }
159    if !matches!(component.contributor, ContributorRole::Author) {
160        return;
161    }
162    if !matches!(
163        hints.integral_name_state,
164        Some(citum_schema::citation::IntegralNameState::Subsequent)
165    ) {
166        return;
167    }
168    let Some(memory) = options.config.integral_name_memory.as_ref() else {
169        return;
170    };
171    component.form = match memory.resolve().subsequent_form {
172        SubsequentNameForm::Short => ContributorForm::Short,
173        SubsequentNameForm::FamilyOnly => ContributorForm::FamilyOnly,
174    };
175}
176
177/// Build name overrides and format all names for a contributor component.
178fn format_contributor_names(
179    component: &TemplateContributor,
180    names_vec: &[crate::reference::FlatName],
181    effective_rendering: &citum_schema::template::Rendering,
182    options: &RenderOptions<'_>,
183    hints: &ProcHints,
184) -> String {
185    let effective_name_order = component.name_order.as_ref().or_else(|| {
186        options
187            .config
188            .contributors
189            .as_ref()?
190            .effective_role_name_order(&component.contributor)
191    });
192    let effective_shorten = component
193        .shorten
194        .as_ref()
195        .or_else(|| options.config.contributors.as_ref()?.shorten.as_ref());
196
197    // Priority chain for name_form:
198    // 1. component.name_form (TemplateContributor-level override - highest priority)
199    // 2. effective_rendering.name_form (from overrides, second priority)
200    // 3. config (options-level fallback)
201    let effective_name_form = component.name_form.or(effective_rendering.name_form);
202
203    let name_overrides = names::NamesOverrides {
204        name_order: effective_name_order,
205        sort_separator: component.sort_separator.as_ref(),
206        shorten: effective_shorten,
207        and: component.and.as_ref(),
208        initialize_with: effective_rendering.initialize_with.as_ref(),
209        name_form: effective_name_form,
210    };
211    names::format_names(names_vec, &component.form, options, &name_overrides, hints)
212}
213
214impl ComponentValues for TemplateContributor {
215    #[allow(
216        clippy::too_many_lines,
217        reason = "large match statement for contributor role dispatch"
218    )]
219    fn values<F: crate::render::format::OutputFormat<Output = String>>(
220        &self,
221        reference: &Reference,
222        hints: &ProcHints,
223        options: &RenderOptions<'_>,
224    ) -> Option<ProcValues<F::Output>> {
225        let fmt = F::default();
226
227        let mut component = self.clone();
228        let effective_rendering = self.rendering.clone();
229
230        // Apply integral-citation subsequent-form (FullThenShort rule)
231        apply_integral_subsequent_form(&mut component, hints, options);
232
233        // Respect explicit suppression before any contributor substitution logic.
234        if effective_rendering.suppress == Some(true) {
235            return None;
236        }
237
238        let contributor = match &component.contributor {
239            ContributorRole::Author => {
240                if options.suppress_author {
241                    None
242                } else {
243                    contributor_for_role(reference, &component.contributor)
244                }
245            }
246            _ => contributor_for_role(reference, &component.contributor),
247        };
248
249        // Resolve substitute config once for all substitute/suppression checks below.
250        let default_substitute = citum_schema::options::SubstituteConfig::default();
251        let substitute_config = options
252            .config
253            .substitute
254            .as_ref()
255            .unwrap_or(&default_substitute);
256        let substitute = substitute_config.resolve();
257
258        // Check if this role is suppressed by role-substitute configuration
259        if substitute::is_role_suppressed_by_substitute(
260            &component.contributor,
261            &substitute,
262            reference,
263        ) {
264            return None;
265        }
266
267        // Resolve multilingual names if configured
268        let names_vec = if let Some(contrib) = contributor {
269            substitute::resolve_multilingual_for_contrib(&contrib, options)
270        } else {
271            Vec::new()
272        };
273
274        // If author is suppressed, don't attempt substitution or formatting.
275        if names_vec.is_empty()
276            && matches!(component.contributor, ContributorRole::Author)
277            && options.suppress_author
278        {
279            return None;
280        }
281
282        // Handle substitution if author is empty.
283        if names_vec.is_empty() && matches!(component.contributor, ContributorRole::Author) {
284            return substitute::resolve_author_substitute::<F>(
285                &component,
286                hints,
287                options,
288                reference,
289                &effective_rendering,
290                &fmt,
291                &substitute,
292            );
293        }
294
295        // Handle role-substitute if this role is empty.
296        if names_vec.is_empty() {
297            return substitute::resolve_role_substitute::<F>(
298                &component.contributor,
299                &component,
300                hints,
301                options,
302                reference,
303                &effective_rendering,
304                &fmt,
305                &substitute,
306            );
307        }
308
309        let formatted =
310            format_contributor_names(&component, &names_vec, &effective_rendering, options, hints);
311
312        let role_omitted = is_role_label_omitted(options, &component.contributor);
313        let (role_prefix, role_suffix) = labels::resolve_role_labels::<F>(
314            &component,
315            reference,
316            names_vec.len(),
317            &effective_rendering,
318            options,
319            &fmt,
320            role_omitted,
321        );
322
323        let is_pre_formatted = role_prefix.is_some() || role_suffix.is_some();
324        let formatted = crate::values::apply_abbreviation(formatted, options.abbreviation_map);
325        let final_value = if is_pre_formatted {
326            fmt.text(&formatted)
327        } else {
328            formatted
329        };
330
331        Some(ProcValues {
332            value: final_value,
333            prefix: role_prefix,
334            suffix: role_suffix,
335            url: crate::values::resolve_effective_url(
336                component.links.as_ref(),
337                options.config.links.as_ref(),
338                reference,
339                citum_schema::options::LinkAnchor::Component,
340            ),
341            substituted_key: None,
342            pre_formatted: is_pre_formatted,
343        })
344    }
345}