wikioxide 0.1.0

MediaWiki wikitext parser producing typed domain models (templates, links, categories, warnings) with template expansion — no HTML, no network
Documentation
//! Vendored per-wiki presets and the JSON loading path (feature `presets`).
//!
//! Preset files are generated by `cargo xtask gen-siteconfig <wiki>` from the live
//! `action=query&meta=siteinfo` output, trimmed to this crate's schema, and committed
//! under `crates/wikioxide/presets/`. They are parsed once, on first use.

use std::sync::OnceLock;

use super::{
    ExtTagContent, MagicLinks, MagicWordDef, MonthNames, NamespaceDef, SiteConfig,
    SiteConfigBuilder,
};
use crate::error::ConfigError;

impl SiteConfig {
    /// Loads a configuration from preset JSON (the schema written by
    /// `cargo xtask gen-siteconfig`, **not** raw `meta=siteinfo` output).
    pub fn from_preset_json(json: &str) -> Result<SiteConfig, ConfigError> {
        let doc: PresetDoc =
            serde_json::from_str(json).map_err(|e| ConfigError::Json(e.to_string()))?;
        doc.into_builder().build()
    }

    /// English Wikipedia (`en.wikipedia.org`) preset.
    #[must_use]
    pub fn enwiki() -> &'static SiteConfig {
        static CELL: OnceLock<SiteConfig> = OnceLock::new();
        load_vendored(&CELL, include_str!("../../presets/enwiki.json"))
    }

    /// Russian Wikipedia (`ru.wikipedia.org`) preset.
    #[must_use]
    pub fn ruwiki() -> &'static SiteConfig {
        static CELL: OnceLock<SiteConfig> = OnceLock::new();
        load_vendored(&CELL, include_str!("../../presets/ruwiki.json"))
    }

    /// A plain-MediaWiki preset: canonical English namespaces, core magic words and
    /// extension tags, default protocols. Suitable for wikis you have no siteinfo for.
    #[must_use]
    pub fn generic() -> &'static SiteConfig {
        static CELL: OnceLock<SiteConfig> = OnceLock::new();
        load_vendored(&CELL, include_str!("../../presets/generic.json"))
    }
}

/// Vendored JSON is a compile-time artifact validated by the `every_preset_loads` test —
/// a failure here is a build defect of this crate, not caller input.
#[allow(
    clippy::expect_used,
    reason = "vendored preset validity is enforced by tests"
)]
fn load_vendored(cell: &'static OnceLock<SiteConfig>, json: &'static str) -> &'static SiteConfig {
    cell.get_or_init(|| {
        SiteConfig::from_preset_json(json).expect("vendored preset JSON must be valid")
    })
}

fn default_true() -> bool {
    true
}

#[derive(serde::Deserialize)]
struct PresetDoc {
    lang: String,
    site_name: String,
    namespaces: Vec<PresetNamespace>,
    #[serde(default)]
    magic_words: Vec<PresetMagicWord>,
    #[serde(default)]
    ext_tags: Vec<PresetExtTag>,
    #[serde(default)]
    protocols: Option<Vec<String>>,
    #[serde(default)]
    link_trail: Option<String>,
    #[serde(default)]
    interwiki: Vec<String>,
    #[serde(default)]
    magic_links: PresetMagicLinks,
    #[serde(default)]
    months: Option<PresetMonths>,
    #[serde(default)]
    lang_converter: bool,
}

/// The three localized month-name lists (each 12 entries, January first). Missing or
/// wrong-length lists fall back to the English default.
#[derive(serde::Deserialize)]
struct PresetMonths {
    #[serde(default)]
    genitive: Vec<String>,
    #[serde(default)]
    nominative: Vec<String>,
    #[serde(default)]
    abbreviations: Vec<String>,
}

/// Converts a month list to a fixed `[String; 12]`, falling back to `fallback` when the
/// list is not exactly twelve entries.
fn months_or(list: Vec<String>, fallback: &[String; 12]) -> [String; 12] {
    <[String; 12]>::try_from(list).unwrap_or_else(|_| fallback.clone())
}

impl PresetDoc {
    fn into_builder(self) -> SiteConfigBuilder {
        let mut builder = SiteConfig::builder(self.lang, self.site_name);
        for ns in self.namespaces {
            builder = builder.namespace(NamespaceDef::new(
                ns.id,
                ns.canonical,
                ns.localized,
                ns.aliases,
                ns.first_letter,
            ));
        }
        for word in self.magic_words {
            builder = builder.magic_word(MagicWordDef::new(
                word.id,
                word.aliases,
                word.case_sensitive,
            ));
        }
        for tag in self.ext_tags {
            let content = if tag.inline {
                ExtTagContent::InlineWikitext
            } else {
                ExtTagContent::Opaque
            };
            builder = builder.ext_tag(tag.name, content);
        }
        if let Some(protocols) = self.protocols {
            builder = builder.protocols(protocols);
        }
        if let Some(trail) = self.link_trail {
            builder = builder.link_trail(trail);
        }
        if let Some(months) = self.months {
            let def = MonthNames::default();
            builder = builder.months(MonthNames {
                genitive: months_or(months.genitive, &def.genitive),
                nominative: months_or(months.nominative, &def.nominative),
                abbreviations: months_or(months.abbreviations, &def.abbreviations),
            });
        }
        builder
            .interwiki(self.interwiki)
            .magic_links(MagicLinks {
                rfc: self.magic_links.rfc,
                pmid: self.magic_links.pmid,
                isbn: self.magic_links.isbn,
            })
            .lang_converter(self.lang_converter)
    }
}

#[derive(serde::Deserialize)]
struct PresetNamespace {
    id: i32,
    #[serde(default)]
    canonical: String,
    #[serde(default)]
    localized: String,
    #[serde(default)]
    aliases: Vec<String>,
    #[serde(default = "default_true")]
    first_letter: bool,
}

#[derive(serde::Deserialize)]
struct PresetMagicWord {
    id: String,
    aliases: Vec<String>,
    #[serde(default)]
    case_sensitive: bool,
}

#[derive(serde::Deserialize)]
struct PresetExtTag {
    name: String,
    #[serde(default)]
    inline: bool,
}

#[derive(serde::Deserialize)]
struct PresetMagicLinks {
    #[serde(default = "default_true")]
    rfc: bool,
    #[serde(default = "default_true")]
    pmid: bool,
    #[serde(default = "default_true")]
    isbn: bool,
}

impl Default for PresetMagicLinks {
    fn default() -> Self {
        Self {
            rfc: true,
            pmid: true,
            isbn: true,
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::title::{Namespace, Title};

    #[test]
    fn every_preset_loads() {
        for config in [
            SiteConfig::generic(),
            SiteConfig::enwiki(),
            SiteConfig::ruwiki(),
        ] {
            assert!(!config.lang().is_empty());
            assert!(config.resolve_namespace("Template").is_some());
            assert!(config.resolve_namespace("Category").is_some());
        }
        // OnceLock caches: the second call returns the same instance.
        assert!(std::ptr::eq(SiteConfig::generic(), SiteConfig::generic()));
    }

    #[test]
    fn ruwiki_preset_speaks_russian() {
        let ru = SiteConfig::ruwiki();
        assert_eq!(ru.lang(), "ru");
        assert_eq!(ru.resolve_namespace("Шаблон").map(Namespace::id), Some(10));
        assert_eq!(
            ru.resolve_namespace("Изображение").map(Namespace::id),
            Some(6),
            "legacy File alias"
        );
        let title = Title::parse("категория:Персоналии", ru).unwrap();
        assert_eq!(title.text(), "Категория:Персоналии");
        assert!(ru.trail_accepts('я'), "Cyrillic link trail");
        assert!(
            ru.redirect_aliases()
                .iter()
                .any(|a| a.contains("перенаправление")),
            "localized redirect keyword"
        );
    }

    #[test]
    fn enwiki_preset_has_module_namespace_and_ascii_trail() {
        let en = SiteConfig::enwiki();
        assert_eq!(en.lang(), "en");
        assert_eq!(en.resolve_namespace("Module").map(Namespace::id), Some(828));
        assert!(en.trail_accepts('s'));
        assert!(!en.trail_accepts('я'));
        assert!(en.ext_tag("ref").is_some());
        assert!(en.ext_tag("nowiki").is_some());
    }

    #[test]
    fn invalid_preset_json_is_reported() {
        let err = SiteConfig::from_preset_json("{not json").unwrap_err();
        assert!(matches!(err, ConfigError::Json(_)));

        let semantic = SiteConfig::from_preset_json(
            r#"{"lang":"xx","site_name":"X","namespaces":[{"id":10,"canonical":"Template","localized":"Template"}]}"#,
        )
        .unwrap_err();
        assert!(matches!(semantic, ConfigError::MissingMainNamespace));
    }

    #[test]
    fn minimal_preset_uses_defaults() {
        let config = SiteConfig::from_preset_json(
            r#"{"lang":"xx","site_name":"X","namespaces":[{"id":0}]}"#,
        )
        .unwrap();
        assert!(config.protocols().iter().any(|p| p == "https://"));
        assert!(config.trail_accepts('a'), "default trail a-z");
        assert!(config.magic_links().isbn);
        assert!(!config.lang_converter());
        // No months in the JSON → English default.
        assert_eq!(config.months().genitive[0], "January");
    }

    #[test]
    fn vendored_presets_carry_localized_month_names() {
        // Russian signatures use the genitive: "8 мая 2026".
        assert_eq!(SiteConfig::ruwiki().months().genitive[4], "мая");
        assert_eq!(SiteConfig::ruwiki().months().nominative[4], "май");
        assert_eq!(SiteConfig::enwiki().months().genitive[0], "January");
        // Generic preset carries no months → English default.
        assert_eq!(SiteConfig::generic().months().nominative[11], "December");
    }

    #[test]
    fn preset_months_load_and_fall_back_per_list() {
        let full = |names: &[&str]| {
            names
                .iter()
                .map(|n| format!("\"{n}\""))
                .collect::<Vec<_>>()
                .join(",")
        };
        let genitive = full(&[
            "gа", "gб", "gв", "gг", "gд", "gе", "gж", "gз", "gи", "gк", "gл", "gм",
        ]);
        // Genitive has 12 entries (used); nominative is malformed (11) → falls back to
        // English; abbreviations absent → English.
        let json = format!(
            r#"{{"lang":"xx","site_name":"X","namespaces":[{{"id":0}}],
                "months":{{"genitive":[{genitive}],"nominative":["only","eleven","эх","4","5","6","7","8","9","10","11"]}}}}"#
        );
        let config = SiteConfig::from_preset_json(&json).unwrap();
        assert_eq!(config.months().genitive[0], "gа");
        assert_eq!(config.months().genitive[11], "gм");
        assert_eq!(
            config.months().nominative[0],
            "January",
            "wrong-length list falls back to English"
        );
        assert_eq!(
            config.months().abbreviations[0],
            "Jan",
            "absent list is English"
        );
    }
}