citum-engine 0.53.1

Citum citation and bibliography processor
Documentation
/*
SPDX-License-Identifier: MIT OR Apache-2.0
SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus
*/

use citum_schema::options::{Config, bibliography::BibliographyConfig};
use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};

/// A processed template component with its rendered value.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ProcTemplateComponent {
    /// The original template component (for rendering instructions).
    pub template_component: TemplateComponent,
    /// The 0-based source index in the active layout template, when requested.
    pub template_index: Option<usize>,
    /// The processed values.
    pub value: String,
    /// Optional prefix from value extraction.
    pub prefix: Option<String>,
    /// Optional suffix from value extraction.
    pub suffix: Option<String>,
    /// Optional URL for hyperlinking.
    pub url: Option<String>,
    /// Reference type for type-specific overrides.
    pub ref_type: Option<String>,
    /// Optional global configuration.
    pub config: Option<Config>,
    /// Optional bibliography-only configuration.
    pub bibliography_config: Option<BibliographyConfig>,
    /// Effective language for this rendered component.
    pub item_language: Option<String>,
    /// Whether this component begins a sentence according to processor-owned render context.
    pub sentence_initial: bool,
    /// Whether the value is already pre-formatted (e.g. from a List or substitution).
    pub pre_formatted: bool,
}

/// A processed template (list of rendered components).
pub type ProcTemplate = Vec<ProcTemplateComponent>;

/// A processed bibliography entry.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct ProcEntry {
    /// The reference ID.
    pub id: String,
    /// The processed template components.
    pub template: ProcTemplate,
    /// Metadata for interactivity (tooltips, etc.)
    pub metadata: super::format::ProcEntryMetadata,
}

use super::format::{OutputFormat, SemanticAttribute};
use super::plain::PlainText;

/// Resolve the semantic CSS class for a rendered component based on its template type.
fn resolve_semantic_class(component: &ProcTemplateComponent) -> Option<String> {
    use citum_schema::template::{DateVariable, SimpleVariable};
    match &component.template_component {
        TemplateComponent::Title(t) => match t.title {
            TitleType::Primary => Some("citum-title".to_string()),
            TitleType::ParentMonograph | TitleType::ParentSerial => {
                Some("citum-container-title".to_string())
            }
            _ => Some("citum-title".to_string()),
        },
        TemplateComponent::Contributor(c) => Some(format!("citum-{}", c.contributor.as_str())),
        TemplateComponent::Date(d) => Some(format!(
            "citum-{}",
            match d.date {
                DateVariable::Issued => "issued",
                DateVariable::Accessed => "accessed",
                DateVariable::OriginalPublished => "original-published",
                DateVariable::Submitted => "submitted",
                DateVariable::EventDate => "event-date",
            }
        )),
        TemplateComponent::Number(n) => Some(format!("citum-{}", n.number.as_key())),
        TemplateComponent::Variable(v) => Some(format!(
            "citum-{}",
            match v.variable {
                SimpleVariable::Doi => "doi",
                SimpleVariable::Url => "url",
                SimpleVariable::Isbn => "isbn",
                SimpleVariable::Issn => "issn",
                SimpleVariable::Pmid => "pmid",
                SimpleVariable::Note => "note",
                SimpleVariable::Publisher => "publisher",
                SimpleVariable::PublisherPlace => "publisher-place",
                SimpleVariable::ContainerTitleShort => "container-title-short",
                SimpleVariable::Archive => "archive",
                _ => "variable",
            }
        )),
        _ => None,
    }
}

/// Render a single component to string using the default `PlainText` format.
#[must_use]
pub fn render_component(component: &ProcTemplateComponent) -> String {
    PlainText.finish(render_component_with_format::<PlainText>(component))
}

/// Render a single component using a specific output format.
#[must_use]
pub fn render_component_with_format<F: OutputFormat<Output = String>>(
    component: &ProcTemplateComponent,
) -> F::Output {
    render_component_with_format_and_renderer::<F>(component, &F::default(), true)
}

/// Render a single component using a specific output format and an existing renderer instance.
pub fn render_component_with_format_and_renderer<F: OutputFormat<Output = String>>(
    component: &ProcTemplateComponent,
    fmt: &F,
    show_semantics: bool,
) -> F::Output {
    // Get merged rendering (global config + local settings + overrides)
    let rendering = get_effective_rendering(component);

    // Check if suppressed
    if rendering.suppress == Some(true) {
        return fmt.text("");
    }

    let prefix = rendering.prefix.as_deref().unwrap_or_default();
    let suffix = rendering.suffix.as_deref().unwrap_or_default();
    let inner_prefix = rendering
        .wrap
        .as_ref()
        .and_then(|w| w.inner_prefix.as_deref())
        .unwrap_or_default();
    let inner_suffix = rendering
        .wrap
        .as_ref()
        .and_then(|w| w.inner_suffix.as_deref())
        .unwrap_or_default();

    let mut output = if component.pre_formatted {
        // If already pre-formatted (e.g. from a List), don't escape again.
        // We just need to convert the String back to Output (which is String here).
        fmt.join(vec![component.value.clone()], "")
    } else {
        fmt.text(&component.value)
    };

    // Order of application:
    // 1. Text styles (emph, strong, etc.)
    // 2. Links
    // 3. Inner affixes
    // 4. Wrap
    // 5. Outer affixes
    // 6. Semantic classes (last, to wrap everything)

    // 1. Apply text styles
    if rendering.emph == Some(true) {
        output = fmt.emph(output);
    }
    if rendering.strong == Some(true) {
        output = fmt.strong(output);
    }
    if rendering.small_caps == Some(true) {
        output = fmt.small_caps(output);
    }
    if rendering.vertical_align == Some(citum_schema::VerticalAlign::Superscript) {
        output = fmt.superscript(output);
    }
    if rendering.quote == Some(true) {
        output = fmt.quote(output);
    }

    // 2. Apply links if URL is present
    if let Some(url) = &component.url {
        output = fmt.link(url, output);
    }

    // 3. Inner affixes + extracted val prefix/suffix
    let total_inner_prefix = format!(
        "{}{}",
        inner_prefix,
        component.prefix.as_deref().unwrap_or_default()
    );
    let total_inner_suffix = format!(
        "{}{}",
        component.suffix.as_deref().unwrap_or_default(),
        inner_suffix
    );

    if !total_inner_prefix.is_empty() || !total_inner_suffix.is_empty() {
        output = fmt.inner_affix(&total_inner_prefix, output, &total_inner_suffix);
    }

    // 4. Wrap
    if let Some(wrap_config) = rendering.wrap.as_ref() {
        output = fmt.wrap_punctuation(&wrap_config.punctuation, output);
    }

    // 5. Outer affixes
    if !prefix.is_empty() || !suffix.is_empty() {
        output = fmt.affix(prefix, output, suffix);
    }

    // 6. Apply semantic class based on component type
    if show_semantics && let Some(class) = resolve_semantic_class(component) {
        let semantic_attributes = component
            .template_index
            .map(|index| {
                vec![SemanticAttribute {
                    name: "data-index",
                    value: index.to_string(),
                }]
            })
            .unwrap_or_default();
        output = fmt.semantic_with_attributes(&class, output, &semantic_attributes);
    }

    output
}

/// Get effective rendering, applying global config, then local template settings, then type-specific overrides.
#[must_use]
pub fn get_effective_rendering(component: &ProcTemplateComponent) -> Rendering {
    let mut effective = Rendering::default();

    // 1. Layer global config
    if let Some(config) = &component.config {
        match &component.template_component {
            TemplateComponent::Title(t) => {
                if let Some(global_title) = get_title_category_rendering(
                    &t.title,
                    component.ref_type.as_deref(),
                    component.item_language.as_deref(),
                    config,
                ) {
                    effective.merge(&global_title);
                }
            }
            TemplateComponent::Contributor(c) => {
                if let Some(contributors_config) = &config.contributors
                    && let Some(role_config) = &contributors_config.role
                    && let Some(role_rendering) = role_config.role_rendering(&c.contributor)
                {
                    effective.merge(&role_rendering.to_rendering());
                }
            }
            // Add other component types here as we expand Config
            _ => {}
        }
    }

    // 2. Layer local template rendering
    effective.merge(component.template_component.rendering());

    if component.ref_type.as_deref() == Some("dataset")
        && component.value.starts_with('[')
        && matches!(
            component.template_component,
            TemplateComponent::Title(TemplateTitle {
                title: TitleType::Primary,
                ..
            })
        )
        && effective.suffix.as_deref() == Some(" [Dataset].")
    {
        effective.suffix = Some(".".to_string());
    }

    effective
}

/// Resolve title-category-specific rendering overrides for a title component.
///
/// The returned rendering reflects title type, mapped reference category, and
/// optional language-specific overrides from the style configuration.
#[must_use]
pub fn get_title_category_rendering(
    title_type: &TitleType,
    ref_type: Option<&str>,
    language: Option<&str>,
    config: &Config,
) -> Option<Rendering> {
    let titles_config = config.titles.as_ref()?;

    // Use type_mapping if available to resolve category
    let mapped_category = ref_type.and_then(|rt| titles_config.type_mapping.get(rt));

    let rendering = match title_type {
        TitleType::ParentSerial => {
            if let Some(cat) = mapped_category {
                match cat.as_str() {
                    "periodical" => titles_config.periodical.as_ref(),
                    "serial" => titles_config.serial.as_ref(),
                    _ => titles_config.periodical.as_ref(),
                }
            } else if let Some(rt) = ref_type {
                if matches!(
                    rt,
                    "article-journal" | "article-magazine" | "article-newspaper"
                ) {
                    titles_config.periodical.as_ref()
                } else {
                    titles_config.serial.as_ref()
                }
            } else {
                titles_config.periodical.as_ref()
            }
        }
        TitleType::ParentMonograph => titles_config
            .container_monograph
            .as_ref()
            .or(titles_config.monograph.as_ref()),
        TitleType::Primary => {
            if let Some(cat) = mapped_category {
                match cat.as_str() {
                    "component" => titles_config.component.as_ref(),
                    "monograph" => titles_config.monograph.as_ref(),
                    _ => titles_config.default.as_ref(),
                }
            } else if let Some(rt) = ref_type {
                // Legacy hardcoded logic
                // "Component" titles: articles, chapters, entries - typically quoted
                if matches!(
                    rt,
                    "article-journal"
                        | "article-magazine"
                        | "article-newspaper"
                        | "chapter"
                        | "entry"
                        | "entry-dictionary"
                        | "entry-encyclopedia"
                        | "paper-conference"
                        | "post"
                        | "post-weblog"
                ) {
                    titles_config.component.as_ref()
                } else if matches!(rt, "book" | "thesis" | "report") {
                    titles_config.monograph.as_ref()
                } else {
                    titles_config.default.as_ref()
                }
            } else {
                titles_config.default.as_ref()
            }
        }
        _ => None,
    };

    let selected = rendering.or(titles_config.default.as_ref())?;
    let mut effective = selected.to_rendering();
    if let Some(override_rendering) = selected.locale_override(language) {
        effective.merge(&override_rendering.to_rendering());
    }
    Some(effective)
}

#[cfg(test)]
#[allow(
    clippy::unwrap_used,
    clippy::expect_used,
    clippy::panic,
    clippy::indexing_slicing,
    clippy::todo,
    clippy::unimplemented,
    clippy::unreachable,
    clippy::get_unwrap,
    reason = "Panicking is acceptable and often desired in tests."
)]
mod tests {
    use super::*;
    use citum_schema::template::{Rendering, TemplateComponent, TemplateTitle, TitleType};

    #[test]
    fn test_render_with_emphasis() {
        let component = ProcTemplateComponent {
            template_component: TemplateComponent::Title(TemplateTitle {
                title: TitleType::Primary,
                rendering: Rendering {
                    emph: Some(true),
                    ..Default::default()
                },
                ..Default::default()
            }),
            value: "The Structure of Scientific Revolutions".to_string(),
            ..Default::default()
        };

        let result = render_component(&component);
        assert_eq!(result, "_The Structure of Scientific Revolutions_");
    }
}