Skip to main content

wikioxide/config/
presets.rs

1//! Vendored per-wiki presets and the JSON loading path (feature `presets`).
2//!
3//! Preset files are generated by `cargo xtask gen-siteconfig <wiki>` from the live
4//! `action=query&meta=siteinfo` output, trimmed to this crate's schema, and committed
5//! under `crates/wikioxide/presets/`. They are parsed once, on first use.
6
7use std::sync::OnceLock;
8
9use super::{
10    ExtTagContent, MagicLinks, MagicWordDef, MonthNames, NamespaceDef, SiteConfig,
11    SiteConfigBuilder,
12};
13use crate::error::ConfigError;
14
15impl SiteConfig {
16    /// Loads a configuration from preset JSON (the schema written by
17    /// `cargo xtask gen-siteconfig`, **not** raw `meta=siteinfo` output).
18    pub fn from_preset_json(json: &str) -> Result<SiteConfig, ConfigError> {
19        let doc: PresetDoc =
20            serde_json::from_str(json).map_err(|e| ConfigError::Json(e.to_string()))?;
21        doc.into_builder().build()
22    }
23
24    /// English Wikipedia (`en.wikipedia.org`) preset.
25    #[must_use]
26    pub fn enwiki() -> &'static SiteConfig {
27        static CELL: OnceLock<SiteConfig> = OnceLock::new();
28        load_vendored(&CELL, include_str!("../../presets/enwiki.json"))
29    }
30
31    /// Russian Wikipedia (`ru.wikipedia.org`) preset.
32    #[must_use]
33    pub fn ruwiki() -> &'static SiteConfig {
34        static CELL: OnceLock<SiteConfig> = OnceLock::new();
35        load_vendored(&CELL, include_str!("../../presets/ruwiki.json"))
36    }
37
38    /// A plain-MediaWiki preset: canonical English namespaces, core magic words and
39    /// extension tags, default protocols. Suitable for wikis you have no siteinfo for.
40    #[must_use]
41    pub fn generic() -> &'static SiteConfig {
42        static CELL: OnceLock<SiteConfig> = OnceLock::new();
43        load_vendored(&CELL, include_str!("../../presets/generic.json"))
44    }
45}
46
47/// Vendored JSON is a compile-time artifact validated by the `every_preset_loads` test —
48/// a failure here is a build defect of this crate, not caller input.
49#[allow(
50    clippy::expect_used,
51    reason = "vendored preset validity is enforced by tests"
52)]
53fn load_vendored(cell: &'static OnceLock<SiteConfig>, json: &'static str) -> &'static SiteConfig {
54    cell.get_or_init(|| {
55        SiteConfig::from_preset_json(json).expect("vendored preset JSON must be valid")
56    })
57}
58
59fn default_true() -> bool {
60    true
61}
62
63#[derive(serde::Deserialize)]
64struct PresetDoc {
65    lang: String,
66    site_name: String,
67    namespaces: Vec<PresetNamespace>,
68    #[serde(default)]
69    magic_words: Vec<PresetMagicWord>,
70    #[serde(default)]
71    ext_tags: Vec<PresetExtTag>,
72    #[serde(default)]
73    protocols: Option<Vec<String>>,
74    #[serde(default)]
75    link_trail: Option<String>,
76    #[serde(default)]
77    interwiki: Vec<String>,
78    #[serde(default)]
79    magic_links: PresetMagicLinks,
80    #[serde(default)]
81    months: Option<PresetMonths>,
82    #[serde(default)]
83    lang_converter: bool,
84}
85
86/// The three localized month-name lists (each 12 entries, January first). Missing or
87/// wrong-length lists fall back to the English default.
88#[derive(serde::Deserialize)]
89struct PresetMonths {
90    #[serde(default)]
91    genitive: Vec<String>,
92    #[serde(default)]
93    nominative: Vec<String>,
94    #[serde(default)]
95    abbreviations: Vec<String>,
96}
97
98/// Converts a month list to a fixed `[String; 12]`, falling back to `fallback` when the
99/// list is not exactly twelve entries.
100fn months_or(list: Vec<String>, fallback: &[String; 12]) -> [String; 12] {
101    <[String; 12]>::try_from(list).unwrap_or_else(|_| fallback.clone())
102}
103
104impl PresetDoc {
105    fn into_builder(self) -> SiteConfigBuilder {
106        let mut builder = SiteConfig::builder(self.lang, self.site_name);
107        for ns in self.namespaces {
108            builder = builder.namespace(NamespaceDef::new(
109                ns.id,
110                ns.canonical,
111                ns.localized,
112                ns.aliases,
113                ns.first_letter,
114            ));
115        }
116        for word in self.magic_words {
117            builder = builder.magic_word(MagicWordDef::new(
118                word.id,
119                word.aliases,
120                word.case_sensitive,
121            ));
122        }
123        for tag in self.ext_tags {
124            let content = if tag.inline {
125                ExtTagContent::InlineWikitext
126            } else {
127                ExtTagContent::Opaque
128            };
129            builder = builder.ext_tag(tag.name, content);
130        }
131        if let Some(protocols) = self.protocols {
132            builder = builder.protocols(protocols);
133        }
134        if let Some(trail) = self.link_trail {
135            builder = builder.link_trail(trail);
136        }
137        if let Some(months) = self.months {
138            let def = MonthNames::default();
139            builder = builder.months(MonthNames {
140                genitive: months_or(months.genitive, &def.genitive),
141                nominative: months_or(months.nominative, &def.nominative),
142                abbreviations: months_or(months.abbreviations, &def.abbreviations),
143            });
144        }
145        builder
146            .interwiki(self.interwiki)
147            .magic_links(MagicLinks {
148                rfc: self.magic_links.rfc,
149                pmid: self.magic_links.pmid,
150                isbn: self.magic_links.isbn,
151            })
152            .lang_converter(self.lang_converter)
153    }
154}
155
156#[derive(serde::Deserialize)]
157struct PresetNamespace {
158    id: i32,
159    #[serde(default)]
160    canonical: String,
161    #[serde(default)]
162    localized: String,
163    #[serde(default)]
164    aliases: Vec<String>,
165    #[serde(default = "default_true")]
166    first_letter: bool,
167}
168
169#[derive(serde::Deserialize)]
170struct PresetMagicWord {
171    id: String,
172    aliases: Vec<String>,
173    #[serde(default)]
174    case_sensitive: bool,
175}
176
177#[derive(serde::Deserialize)]
178struct PresetExtTag {
179    name: String,
180    #[serde(default)]
181    inline: bool,
182}
183
184#[derive(serde::Deserialize)]
185struct PresetMagicLinks {
186    #[serde(default = "default_true")]
187    rfc: bool,
188    #[serde(default = "default_true")]
189    pmid: bool,
190    #[serde(default = "default_true")]
191    isbn: bool,
192}
193
194impl Default for PresetMagicLinks {
195    fn default() -> Self {
196        Self {
197            rfc: true,
198            pmid: true,
199            isbn: true,
200        }
201    }
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::title::{Namespace, Title};
208
209    #[test]
210    fn every_preset_loads() {
211        for config in [
212            SiteConfig::generic(),
213            SiteConfig::enwiki(),
214            SiteConfig::ruwiki(),
215        ] {
216            assert!(!config.lang().is_empty());
217            assert!(config.resolve_namespace("Template").is_some());
218            assert!(config.resolve_namespace("Category").is_some());
219        }
220        // OnceLock caches: the second call returns the same instance.
221        assert!(std::ptr::eq(SiteConfig::generic(), SiteConfig::generic()));
222    }
223
224    #[test]
225    fn ruwiki_preset_speaks_russian() {
226        let ru = SiteConfig::ruwiki();
227        assert_eq!(ru.lang(), "ru");
228        assert_eq!(ru.resolve_namespace("Шаблон").map(Namespace::id), Some(10));
229        assert_eq!(
230            ru.resolve_namespace("Изображение").map(Namespace::id),
231            Some(6),
232            "legacy File alias"
233        );
234        let title = Title::parse("категория:Персоналии", ru).unwrap();
235        assert_eq!(title.text(), "Категория:Персоналии");
236        assert!(ru.trail_accepts('я'), "Cyrillic link trail");
237        assert!(
238            ru.redirect_aliases()
239                .iter()
240                .any(|a| a.contains("перенаправление")),
241            "localized redirect keyword"
242        );
243    }
244
245    #[test]
246    fn enwiki_preset_has_module_namespace_and_ascii_trail() {
247        let en = SiteConfig::enwiki();
248        assert_eq!(en.lang(), "en");
249        assert_eq!(en.resolve_namespace("Module").map(Namespace::id), Some(828));
250        assert!(en.trail_accepts('s'));
251        assert!(!en.trail_accepts('я'));
252        assert!(en.ext_tag("ref").is_some());
253        assert!(en.ext_tag("nowiki").is_some());
254    }
255
256    #[test]
257    fn invalid_preset_json_is_reported() {
258        let err = SiteConfig::from_preset_json("{not json").unwrap_err();
259        assert!(matches!(err, ConfigError::Json(_)));
260
261        let semantic = SiteConfig::from_preset_json(
262            r#"{"lang":"xx","site_name":"X","namespaces":[{"id":10,"canonical":"Template","localized":"Template"}]}"#,
263        )
264        .unwrap_err();
265        assert!(matches!(semantic, ConfigError::MissingMainNamespace));
266    }
267
268    #[test]
269    fn minimal_preset_uses_defaults() {
270        let config = SiteConfig::from_preset_json(
271            r#"{"lang":"xx","site_name":"X","namespaces":[{"id":0}]}"#,
272        )
273        .unwrap();
274        assert!(config.protocols().iter().any(|p| p == "https://"));
275        assert!(config.trail_accepts('a'), "default trail a-z");
276        assert!(config.magic_links().isbn);
277        assert!(!config.lang_converter());
278        // No months in the JSON → English default.
279        assert_eq!(config.months().genitive[0], "January");
280    }
281
282    #[test]
283    fn vendored_presets_carry_localized_month_names() {
284        // Russian signatures use the genitive: "8 мая 2026".
285        assert_eq!(SiteConfig::ruwiki().months().genitive[4], "мая");
286        assert_eq!(SiteConfig::ruwiki().months().nominative[4], "май");
287        assert_eq!(SiteConfig::enwiki().months().genitive[0], "January");
288        // Generic preset carries no months → English default.
289        assert_eq!(SiteConfig::generic().months().nominative[11], "December");
290    }
291
292    #[test]
293    fn preset_months_load_and_fall_back_per_list() {
294        let full = |names: &[&str]| {
295            names
296                .iter()
297                .map(|n| format!("\"{n}\""))
298                .collect::<Vec<_>>()
299                .join(",")
300        };
301        let genitive = full(&[
302            "gа", "gб", "gв", "gг", "gд", "gе", "gж", "gз", "gи", "gк", "gл", "gм",
303        ]);
304        // Genitive has 12 entries (used); nominative is malformed (11) → falls back to
305        // English; abbreviations absent → English.
306        let json = format!(
307            r#"{{"lang":"xx","site_name":"X","namespaces":[{{"id":0}}],
308                "months":{{"genitive":[{genitive}],"nominative":["only","eleven","эх","4","5","6","7","8","9","10","11"]}}}}"#
309        );
310        let config = SiteConfig::from_preset_json(&json).unwrap();
311        assert_eq!(config.months().genitive[0], "gа");
312        assert_eq!(config.months().genitive[11], "gм");
313        assert_eq!(
314            config.months().nominative[0],
315            "January",
316            "wrong-length list falls back to English"
317        );
318        assert_eq!(
319            config.months().abbreviations[0],
320            "Jan",
321            "absent list is English"
322        );
323    }
324}