calepin 0.0.51

A Rust CLI for preprocessing Typst documents with executable code chunks
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{anyhow, bail, Result};

use super::config::{LanguageConfig, WebsiteConfig};
use super::paths::join_normalized_under_root;
use super::url::{clean_url_prefix, is_safe_output_route};
use super::util::clean_optional_string;

#[derive(Debug, Clone)]
pub(super) struct LanguageInfo {
    pub(super) code: String,
    pub(super) label: String,
    pub(super) content_dir: PathBuf,
    pub(super) url_prefix: String,
    pub(super) is_default: bool,
}

pub(super) fn configured_languages(
    src_dir: &Path,
    config: &WebsiteConfig,
) -> Result<Option<Vec<LanguageInfo>>> {
    if config.languages.is_empty() {
        return Ok(None);
    }
    let default_language = default_language_code(config)?;
    let mut languages = config
        .languages
        .iter()
        .map(|(code, language)| language_info(src_dir, code, language, &default_language))
        .collect::<Result<Vec<_>>>()?;

    reject_duplicate_url_prefixes(&languages)?;
    reject_duplicate_content_dirs(&languages)?;
    sort_languages(&mut languages);
    Ok(Some(languages))
}

fn default_language_code(config: &WebsiteConfig) -> Result<String> {
    for code in config.languages.keys() {
        validate_language_code(code)?;
    }
    let default_language = match config.default_language.as_deref().map(str::trim) {
        Some("") => bail!("default_language must not be empty"),
        Some(default_language) => {
            validate_language_code(default_language)?;
            default_language.to_string()
        }
        None if config.languages.len() == 1 => config.languages.keys().next().cloned().unwrap(),
        None => {
            bail!("set default_language when more than one language is configured in [languages]")
        }
    };
    if !config.languages.contains_key(&default_language) {
        bail!("default_language `{default_language}` is not present in [languages]");
    }
    Ok(default_language)
}

fn language_info(
    src_dir: &Path,
    code: &str,
    language: &LanguageConfig,
    default_language: &str,
) -> Result<LanguageInfo> {
    let is_default = code == default_language;
    let content_dir = language
        .content_dir
        .clone()
        .unwrap_or_else(|| default_content_dir(code, is_default));
    let content_dir = language_content_dir(src_dir, code, content_dir)?;
    let url_prefix = language_url_prefix(code, language, is_default)?;

    Ok(LanguageInfo {
        code: code.to_string(),
        label: clean_optional_string(language.label.as_deref()).unwrap_or_else(|| code.to_string()),
        content_dir,
        url_prefix,
        is_default,
    })
}

fn default_content_dir(code: &str, is_default: bool) -> PathBuf {
    if is_default {
        PathBuf::from(".")
    } else {
        PathBuf::from(code)
    }
}

fn language_url_prefix(code: &str, language: &LanguageConfig, is_default: bool) -> Result<String> {
    let url_prefix = clean_optional_string(language.url_prefix.as_deref()).unwrap_or_else(|| {
        if is_default {
            String::new()
        } else {
            code.to_string()
        }
    });
    let url_prefix = clean_url_prefix(&url_prefix);
    if !is_safe_output_route(&url_prefix) {
        bail!(
            "url_prefix for language `{code}` must stay inside the output directory: `{url_prefix}`"
        );
    }
    Ok(url_prefix)
}

fn reject_duplicate_url_prefixes(languages: &[LanguageInfo]) -> Result<()> {
    let mut url_prefixes = BTreeMap::new();
    for language in languages {
        if let Some(previous) =
            url_prefixes.insert(language.url_prefix.as_str(), language.code.as_str())
        {
            bail!(
                "url_prefix for language `{}` duplicates language `{previous}` after cleaning: `{}`",
                language.code,
                language.url_prefix
            );
        }
    }
    Ok(())
}

fn reject_duplicate_content_dirs(languages: &[LanguageInfo]) -> Result<()> {
    let mut content_dirs = BTreeMap::new();
    for language in languages {
        if let Some(previous) =
            content_dirs.insert(language.content_dir.clone(), language.code.as_str())
        {
            bail!(
                "content_dir for language `{}` duplicates language `{previous}`: {}",
                language.code,
                language.content_dir.display()
            );
        }
    }
    Ok(())
}

fn sort_languages(languages: &mut [LanguageInfo]) {
    languages.sort_by(|left, right| {
        (!left.is_default, left.code.as_str()).cmp(&(!right.is_default, right.code.as_str()))
    });
}

fn validate_language_code(code: &str) -> Result<()> {
    if code.trim().is_empty() {
        bail!("language code must not be empty");
    }
    if code != code.trim() {
        bail!("language code `{code}` must not start or end with whitespace");
    }
    if matches!(code, "." | "..") || code.contains('/') || code.contains('\\') {
        bail!("language code `{code}` must be a single path segment");
    }
    Ok(())
}

fn language_content_dir(src_dir: &Path, code: &str, content_dir: PathBuf) -> Result<PathBuf> {
    if content_dir == Path::new(".") {
        return Ok(src_dir.to_path_buf());
    }
    let what = invalid_content_dir(code, &content_dir).to_string();
    join_normalized_under_root(src_dir, &content_dir, &what)
}

fn invalid_content_dir(code: &str, content_dir: &Path) -> anyhow::Error {
    anyhow!(
        "content_dir for language `{code}` must stay inside the source directory: {}",
        content_dir.display()
    )
}