use std::sync::OnceLock;
use super::{
ExtTagContent, MagicLinks, MagicWordDef, MonthNames, NamespaceDef, SiteConfig,
SiteConfigBuilder,
};
use crate::error::ConfigError;
impl SiteConfig {
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()
}
#[must_use]
pub fn enwiki() -> &'static SiteConfig {
static CELL: OnceLock<SiteConfig> = OnceLock::new();
load_vendored(&CELL, include_str!("../../presets/enwiki.json"))
}
#[must_use]
pub fn ruwiki() -> &'static SiteConfig {
static CELL: OnceLock<SiteConfig> = OnceLock::new();
load_vendored(&CELL, include_str!("../../presets/ruwiki.json"))
}
#[must_use]
pub fn generic() -> &'static SiteConfig {
static CELL: OnceLock<SiteConfig> = OnceLock::new();
load_vendored(&CELL, include_str!("../../presets/generic.json"))
}
}
#[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,
}
#[derive(serde::Deserialize)]
struct PresetMonths {
#[serde(default)]
genitive: Vec<String>,
#[serde(default)]
nominative: Vec<String>,
#[serde(default)]
abbreviations: Vec<String>,
}
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());
}
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());
assert_eq!(config.months().genitive[0], "January");
}
#[test]
fn vendored_presets_carry_localized_month_names() {
assert_eq!(SiteConfig::ruwiki().months().genitive[4], "мая");
assert_eq!(SiteConfig::ruwiki().months().nominative[4], "май");
assert_eq!(SiteConfig::enwiki().months().genitive[0], "January");
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м",
]);
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"
);
}
}