1use std::sync::OnceLock;
8
9use super::{
10 ExtTagContent, MagicLinks, MagicWordDef, MonthNames, NamespaceDef, SiteConfig,
11 SiteConfigBuilder,
12};
13use crate::error::ConfigError;
14
15impl SiteConfig {
16 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 #[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 #[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 #[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#[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#[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
98fn 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 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 assert_eq!(config.months().genitive[0], "January");
280 }
281
282 #[test]
283 fn vendored_presets_carry_localized_month_names() {
284 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 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 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}