citum 0.70.0

Citum CLI: render, check, convert, and manage citation styles, references, and documents
/*
SPDX-License-Identifier: MIT OR Apache-2.0
SPDX-FileCopyrightText: © 2023-2026 Bruce D'Arcus and Citum contributors
*/

use citum_engine::Processor;
use citum_io::LoadedBibliography;
use citum_schema::{Locale, Style, locale::types::LocaleOverride};
use citum_store::{
    build_chain_with_file_locale_dir, build_standard_chain, load_locale_or_default,
    platform_config_dir,
};
use serde::Deserialize;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Deserialize)]
struct RegistrySourceRecord {
    name: String,
}

fn registry_sources_path() -> Option<PathBuf> {
    platform_config_dir().map(|dir| dir.join("registry-sources.json"))
}

fn configured_registry_path(name: &str) -> Option<PathBuf> {
    platform_config_dir().map(|dir| dir.join("registries").join(format!("{name}.yaml")))
}

fn configured_registry_names() -> Vec<String> {
    let Some(path) = registry_sources_path() else {
        return Vec::new();
    };
    let Ok(bytes) = fs::read(path) else {
        return Vec::new();
    };
    let Ok(records) = serde_json::from_slice::<Vec<RegistrySourceRecord>>(&bytes) else {
        return Vec::new();
    };
    records.into_iter().map(|record| record.name).collect()
}

fn registry_candidate_names() -> Vec<String> {
    let mut candidates = Vec::new();
    let local_path = Path::new("citum-registry.yaml");
    if local_path.exists()
        && let Ok(registry) = citum_schema::StyleRegistry::load_from_file(local_path)
    {
        candidates.extend(registry.styles.iter().flat_map(|entry| {
            std::iter::once(entry.id.clone()).chain(entry.aliases.iter().cloned())
        }));
    }
    for name in configured_registry_names() {
        let Some(path) = configured_registry_path(&name) else {
            continue;
        };
        let Ok(registry) = citum_schema::StyleRegistry::load_from_file(&path) else {
            continue;
        };
        candidates.extend(registry.styles.iter().flat_map(|entry| {
            std::iter::once(entry.id.clone()).chain(entry.aliases.iter().cloned())
        }));
    }
    candidates.extend(
        citum_schema::embedded::default_registry()
            .styles
            .iter()
            .flat_map(|entry| {
                std::iter::once(entry.id.clone()).chain(entry.aliases.iter().cloned())
            }),
    );
    candidates
}

pub(crate) fn load_locale_file(path: &Path) -> Result<Locale, Box<dyn Error>> {
    Locale::from_file(path)
        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err).into())
}

/// Construct a [`Processor`] from a style, bibliography, and optional locale.
///
/// When `locale_override` is supplied it takes precedence over the style's
/// `default_locale`. Otherwise the locale is resolved first from disk (for
/// file-based styles) and then from embedded data, falling back to the
/// hardcoded `en-US` defaults.
pub(crate) fn create_processor(
    style: Style,
    loaded: LoadedBibliography,
    style_input: &str,
    no_semantics: bool,
    locale_override: Option<&str>,
) -> Result<Processor, Box<dyn Error>> {
    let LoadedBibliography { references, sets } = loaded;
    let compound_sets = sets.unwrap_or_default();
    let effective_locale = locale_override
        .map(str::to_owned)
        .or_else(|| style.info.default_locale.clone());
    if let Some(ref locale_id) = effective_locale {
        // `FileResolver` accepts both plain paths and `file://` URIs; normalize
        // here so the file-locale branch fires for either spelling.
        let style_path_str = style_input.strip_prefix("file://").unwrap_or(style_input);
        let path = Path::new(style_path_str);
        let style_is_file = path.is_file();
        // Build the resolver chain once. For file-based styles, prepend a
        // `FileLocaleResolver` rooted at the style's sibling `locales/` dir so
        // the long-standing on-disk lookup keeps working. For builtin aliases,
        // use the standard chain (user store → embedded). Falling back to the
        // standard chain on a build error preserves resolution if HTTP/Git
        // setup fails on this host.
        let chain = if style_is_file {
            build_chain_with_file_locale_dir(find_locales_dir(style_path_str))
                .or_else(|_| build_standard_chain())
        } else {
            build_standard_chain()
        }
        .map_err(|e| -> Box<dyn Error> { std::io::Error::other(e).into() })?;
        let mut locale = load_locale_or_default(&chain, locale_id);
        if locale_override.is_some() && locale.locale != *locale_id {
            return Err(std::io::Error::new(
                std::io::ErrorKind::NotFound,
                format!("locale not found: '{locale_id}'"),
            )
            .into());
        }
        if let Some(override_id) = locale_override
            .is_none()
            .then(|| {
                style
                    .options
                    .as_ref()
                    .and_then(|options| options.locale_override.as_deref())
            })
            .flatten()
        {
            let locale_override = if style_is_file {
                load_locale_override_for_file_style(override_id, style_path_str)?
                    .or_else(|| load_locale_override_builtin(override_id))
            } else {
                load_locale_override_builtin(override_id)
            };
            let locale_override = locale_override.ok_or_else(|| {
                std::io::Error::new(
                    std::io::ErrorKind::NotFound,
                    format!(
                    "locale override not found: '{override_id}' (expected under locales/overrides/)"
                    ),
                )
            })?;
            locale.apply_override(&locale_override);
        }
        let mut processor =
            Processor::try_with_locale_and_compound_sets(style, references, locale, compound_sets)?;
        processor.show_semantics = !no_semantics;
        Ok(processor)
    } else {
        let mut processor = Processor::try_with_compound_sets(style, references, compound_sets)?;
        processor.show_semantics = !no_semantics;
        Ok(processor)
    }
}

/// Load a style from a file path, user store, or fallback to builtin name/alias.
pub(crate) fn load_any_style(
    style_input: &str,
    _no_semantics: bool,
) -> Result<Style, Box<dyn Error>> {
    use citum_store::resolver::{ResolverError, StyleResolver};

    let chain = citum_store::build_standard_chain()
        .map_err(|e| -> Box<dyn Error> { Box::new(std::io::Error::other(e.to_string())) })?;

    match chain.resolve_style(style_input) {
        Ok(style) => {
            let mut resolved = style.try_into_resolved_with(Some(&chain))?;
            resolved.extends = None;
            Ok(resolved)
        }
        Err(ResolverError::StyleNotFound(_)) => {
            let candidates = registry_candidate_names();
            let suggestions: Vec<_> = candidates
                .iter()
                .filter(|name| strsim::jaro_winkler(style_input, name) > 0.8)
                .collect();

            let mut msg = format!("style not found: '{style_input}'");
            if suggestions.is_empty() {
                msg.push_str(
                    "\n\nUse `citum style list` to browse catalog styles, \
                     or pass a local path/direct URL.",
                );
            } else {
                msg.push_str("\n\nDid you mean one of these?");
                for s in suggestions {
                    msg.push_str("\n  - ");
                    msg.push_str(s);
                }
            }
            Err(msg.into())
        }
        Err(err) => Err(err.into()),
    }
}

/// Heuristically locate the `locales/` directory relative to a style file.
///
/// Checks the style's own directory and up to two parent directories, then falls
/// back to a `locales/` folder in the current working directory.  Returns `"."`
/// if no matching directory is found.
pub(crate) fn find_locales_dir(style_path: &str) -> PathBuf {
    let style_dir = Path::new(style_path).parent().unwrap_or(Path::new("."));
    let candidates = [
        style_dir.join("locales"),
        style_dir.join("../locales"),
        style_dir.join("../../locales"),
        PathBuf::from("locales"),
    ];

    for candidate in &candidates {
        if candidate.exists() && candidate.is_dir() {
            return candidate.clone();
        }
    }

    PathBuf::from(".")
}

pub(crate) fn load_locale_override_for_file_style(
    override_id: &str,
    style_path: &str,
) -> Result<Option<LocaleOverride>, Box<dyn Error>> {
    let overrides_dir = find_locales_dir(style_path).join("overrides");
    load_locale_override_from_dir(override_id, &overrides_dir)
}

pub(crate) fn load_locale_override_from_dir(
    override_id: &str,
    overrides_dir: &Path,
) -> Result<Option<LocaleOverride>, Box<dyn Error>> {
    for ext in ["yaml", "yml", "json", "cbor"] {
        let path = overrides_dir.join(format!("{override_id}.{ext}"));
        if path.exists() && path.is_file() {
            return load_locale_override_file(&path).map(Some);
        }
    }
    Ok(None)
}

pub(crate) fn load_locale_override_file(path: &Path) -> Result<LocaleOverride, Box<dyn Error>> {
    let bytes = fs::read(path)?;
    let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("yaml");
    parse_locale_override_bytes(&bytes, ext)
        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err).into())
}

pub(crate) fn load_locale_override_builtin(override_id: &str) -> Option<LocaleOverride> {
    let bytes = citum_schema::embedded::get_locale_override_bytes(override_id)?;
    parse_locale_override_bytes(bytes, "yaml").ok()
}

pub(crate) fn parse_locale_override_bytes(
    bytes: &[u8],
    ext: &str,
) -> Result<LocaleOverride, String> {
    use citum_schema::locale::raw::RawLocaleOverride;

    match ext {
        "cbor" => ciborium::de::from_reader::<RawLocaleOverride, _>(std::io::Cursor::new(bytes))
            .map(Into::into)
            .map_err(|e| format!("Failed to parse CBOR locale override: {e}")),
        "json" => serde_json::from_slice::<RawLocaleOverride>(bytes)
            .map(Into::into)
            .map_err(|e| format!("Failed to parse JSON locale override: {e}")),
        _ => serde_yaml::from_slice::<RawLocaleOverride>(bytes)
            .map(Into::into)
            .map_err(|e| format!("Failed to parse YAML locale override: {e}")),
    }
}