1use std::collections::{BTreeMap, BTreeSet};
30use std::path::{Path, PathBuf};
31
32use mf2_locale_data::number::Selection;
33use mf2_resource::LineIndex;
34use serde::Deserialize;
35
36use crate::error::{Error, Result};
37use crate::lint::{Level, Lint};
38
39pub const FILE_NAME: &str = "mf2.toml";
41
42#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
44#[serde(deny_unknown_fields, default)]
45#[non_exhaustive]
46pub struct Config {
47 pub source_locale: String,
49 pub fallback: BTreeMap<String, Vec<String>>,
53 pub catalog: CatalogConfig,
55 pub locale_data: LocaleDataConfig,
57 pub lints: BTreeMap<Lint, Level>,
59 pub functions: BTreeMap<String, String>,
62}
63
64impl Default for Config {
65 fn default() -> Self {
66 Config {
67 source_locale: "en".to_owned(),
68 fallback: BTreeMap::new(),
69 catalog: CatalogConfig::default(),
70 locale_data: LocaleDataConfig::default(),
71 lints: BTreeMap::new(),
72 functions: BTreeMap::new(),
73 }
74 }
75}
76
77#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
79#[serde(deny_unknown_fields, default)]
80#[non_exhaustive]
81pub struct CatalogConfig {
82 pub strip: BTreeSet<Strip>,
85 pub missing: Missing,
87}
88
89impl Default for CatalogConfig {
90 fn default() -> Self {
91 CatalogConfig {
92 strip: [Strip::Cold, Strip::Ids].into_iter().collect(),
93 missing: Missing::Fallback,
94 }
95 }
96}
97
98#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Deserialize)]
100#[serde(rename_all = "kebab-case")]
101#[non_exhaustive]
102pub enum Strip {
103 Cold,
105 Ids,
107}
108
109#[derive(Clone, Copy, PartialEq, Eq, Debug, Default, Deserialize)]
111#[serde(rename_all = "kebab-case")]
112#[non_exhaustive]
113pub enum Missing {
114 #[default]
116 Fallback,
117 Id,
119 Empty,
121}
122
123#[derive(Clone, Debug, PartialEq, Eq, Default, Deserialize)]
125#[serde(deny_unknown_fields, default)]
126#[non_exhaustive]
127pub struct LocaleDataConfig {
128 pub currencies: DataSet,
130 pub units: DataSet,
132}
133
134#[derive(Clone, Debug, PartialEq, Eq, Default)]
136#[non_exhaustive]
137pub enum DataSet {
138 #[default]
142 Used,
143 All,
145 Listed(BTreeSet<String>),
149}
150
151impl DataSet {
152 pub fn with_used(&self, used: &Selection) -> Selection {
157 match (self, used) {
158 (DataSet::All, _) => Selection::All,
159 (DataSet::Used, used) => used.clone(),
160 (DataSet::Listed(listed), Selection::All) => Selection::Listed(listed.clone()),
161 (DataSet::Listed(listed), Selection::Listed(used)) => {
162 Selection::Listed(listed.iter().chain(used).cloned().collect())
163 }
164 }
165 }
166}
167
168impl<'de> Deserialize<'de> for DataSet {
169 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
170 #[derive(Deserialize)]
171 #[serde(untagged)]
172 enum Raw {
173 Word(String),
174 List(BTreeSet<String>),
175 }
176 match Raw::deserialize(d)? {
177 Raw::Word(w) if w == "used" => Ok(DataSet::Used),
178 Raw::Word(w) if w == "all" => Ok(DataSet::All),
179 Raw::Word(w) => Err(serde::de::Error::custom(format!(
180 "expected \"used\", \"all\" or a list of codes, not {w:?}"
181 ))),
182 Raw::List(list) => Ok(DataSet::Listed(list)),
183 }
184 }
185}
186
187impl Config {
188 pub fn load(dir: &Path) -> Result<Config> {
190 let path = dir.join(FILE_NAME);
191 match std::fs::read_to_string(&path) {
192 Ok(text) => Config::parse(&text, &path),
193 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
194 Err(source) => Err(Error::io(path, source)),
195 }
196 }
197
198 pub fn parse(text: &str, path: &Path) -> Result<Config> {
200 let config: Config = toml::from_str(text).map_err(|e| config_error(text, path, &e))?;
201 config.validate(path)?;
202 Ok(config)
203 }
204
205 pub fn to_toml(&self) -> String {
207 toml::to_string_pretty(self).unwrap_or_default()
208 }
209
210 pub fn level(&self, lint: Lint) -> Level {
212 self.lints
213 .get(&lint)
214 .copied()
215 .unwrap_or_else(|| lint.default_level())
216 }
217
218 pub fn chain(&self, locale: &str) -> Vec<String> {
222 let mut chain: Vec<String> = match self.fallback.get(locale) {
223 Some(listed) => listed.clone(),
224 None => truncations(locale),
225 };
226 if locale != self.source_locale && !chain.contains(&self.source_locale) {
227 chain.push(self.source_locale.clone());
228 }
229 chain.retain(|l| l != locale);
230 let mut seen = BTreeSet::new();
231 chain.retain(|l| seen.insert(l.clone()));
232 chain
233 }
234
235 fn validate(&self, path: &Path) -> Result<()> {
236 let bad = |message: String| Error::Config {
237 path: path.to_path_buf(),
238 message,
239 };
240 if self.source_locale.is_empty() {
241 return Err(bad("source_locale must be a BCP 47 tag".to_owned()));
242 }
243 for (&lint, &level) in &self.lints {
244 if level < lint.floor() {
245 return Err(bad(format!(
246 "[lints] {lint} = {level:?}: this lint cannot be set below \
247 \"{}\" — the rest of the build relies on it",
248 lint.floor()
249 )));
250 }
251 }
252 for (locale, chain) in &self.fallback {
253 if chain.iter().any(|l| l == locale) {
254 return Err(bad(format!(
255 "[fallback] {locale:?}: a locale may not fall back to itself"
256 )));
257 }
258 }
259 for (identifier, path_to_fn) in &self.functions {
260 if identifier.is_empty() || path_to_fn.is_empty() {
261 return Err(bad(format!(
262 "[functions] {identifier:?}: needs a Rust path to a \
263 `&'static dyn Function`"
264 )));
265 }
266 }
267 Ok(())
268 }
269}
270
271fn truncations(locale: &str) -> Vec<String> {
273 let mut out = Vec::new();
274 let mut rest = locale;
275 while let Some(cut) = rest.rfind('-') {
276 rest = &rest[..cut];
277 if !rest.is_empty() {
278 out.push(rest.to_owned());
279 }
280 }
281 out
282}
283
284fn config_error(text: &str, path: &Path, e: &toml::de::Error) -> Error {
289 let message = match e.span() {
290 Some(span) => {
291 let index = LineIndex::new(text);
292 let at = index.position(text, u32::try_from(span.start).unwrap_or(u32::MAX));
293 format!("{}:{}: {}", at.line, at.column, e.message())
294 }
295 None => e.message().to_owned(),
296 };
297 Error::Config {
298 path: path.to_path_buf(),
299 message,
300 }
301}
302
303mod serialize {
305 use super::{CatalogConfig, Config, DataSet, LocaleDataConfig, Missing, Strip};
306 use serde::ser::{Serialize, SerializeMap, SerializeSeq, Serializer};
307
308 impl Serialize for Config {
309 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
310 let mut m = s.serialize_map(None)?;
311 m.serialize_entry("source_locale", &self.source_locale)?;
312 if !self.fallback.is_empty() {
313 m.serialize_entry("fallback", &self.fallback)?;
314 }
315 m.serialize_entry("catalog", &self.catalog)?;
316 m.serialize_entry("locale_data", &self.locale_data)?;
317 if !self.lints.is_empty() {
318 let named: std::collections::BTreeMap<&str, String> = self
319 .lints
320 .iter()
321 .map(|(l, v)| (l.name(), v.to_string()))
322 .collect();
323 m.serialize_entry("lints", &named)?;
324 }
325 if !self.functions.is_empty() {
326 m.serialize_entry("functions", &self.functions)?;
327 }
328 m.end()
329 }
330 }
331
332 impl Serialize for CatalogConfig {
333 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
334 let mut m = s.serialize_map(Some(2))?;
335 m.serialize_entry("strip", &self.strip)?;
336 m.serialize_entry("missing", &self.missing)?;
337 m.end()
338 }
339 }
340
341 impl Serialize for LocaleDataConfig {
342 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
343 let mut m = s.serialize_map(Some(2))?;
344 m.serialize_entry("currencies", &self.currencies)?;
345 m.serialize_entry("units", &self.units)?;
346 m.end()
347 }
348 }
349
350 impl Serialize for Strip {
351 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
352 s.serialize_str(match self {
353 Strip::Cold => "cold",
354 Strip::Ids => "ids",
355 })
356 }
357 }
358
359 impl Serialize for Missing {
360 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
361 s.serialize_str(match self {
362 Missing::Fallback => "fallback",
363 Missing::Id => "id",
364 Missing::Empty => "empty",
365 })
366 }
367 }
368
369 impl Serialize for DataSet {
370 fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
371 match self {
372 DataSet::Used => s.serialize_str("used"),
373 DataSet::All => s.serialize_str("all"),
374 DataSet::Listed(list) => {
375 let mut seq = s.serialize_seq(Some(list.len()))?;
376 for code in list {
377 seq.serialize_element(code)?;
378 }
379 seq.end()
380 }
381 }
382 }
383 }
384}
385
386#[derive(Clone, Debug, PartialEq, Eq)]
388pub struct Layout {
389 pub root: PathBuf,
391 pub locales: PathBuf,
393}
394
395impl Layout {
396 pub fn new(root: impl Into<PathBuf>) -> Layout {
398 let root = root.into();
399 let locales = root.join("locales");
400 Layout { root, locales }
401 }
402
403 pub fn locales(&self) -> Result<Vec<String>> {
406 let mut tags = BTreeSet::new();
407 let dir = std::fs::read_dir(&self.locales)
408 .map_err(|source| Error::io(self.locales.clone(), source))?;
409 for entry in dir {
410 let entry = entry.map_err(|source| Error::io(self.locales.clone(), source))?;
411 let path = entry.path();
412 let kind = entry
413 .file_type()
414 .map_err(|source| Error::io(path.clone(), source))?;
415 let name = entry.file_name();
416 let name = name.to_string_lossy();
417 let tag = if kind.is_dir() {
418 name.as_ref()
419 } else if let Some(tag) = name.strip_suffix(".json") {
420 tag
421 } else {
422 continue;
423 };
424 if !is_tag(tag) {
425 return Err(Error::Layout(format!(
426 "{}: {tag:?} is not a locale tag — `locales/` holds one \
427 directory or one .json file per tag, and nothing else",
428 self.locales.display()
429 )));
430 }
431 tags.insert(tag.to_owned());
432 }
433 if tags.is_empty() {
434 return Err(Error::Layout(format!(
435 "{}: no locales — expected a directory or a .json file per tag",
436 self.locales.display()
437 )));
438 }
439 Ok(tags.into_iter().collect())
440 }
441}
442
443fn is_tag(name: &str) -> bool {
451 !name.is_empty()
452 && name.len() <= 64
453 && name
454 .split('-')
455 .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_alphanumeric()))
456}
457
458#[cfg(test)]
459mod tests {
460 use super::*;
461
462 fn set(codes: &[&str]) -> BTreeSet<String> {
463 codes.iter().map(|c| (*c).to_owned()).collect()
464 }
465
466 #[test]
467 fn an_explicit_list_wins_over_a_variables_every_code() {
468 let listed = DataSet::Listed(set(&["EUR", "USD"]));
469 assert_eq!(
470 listed.with_used(&Selection::All),
471 Selection::Listed(set(&["EUR", "USD"]))
472 );
473 assert_eq!(
474 listed.with_used(&Selection::Listed(set(&["JPY"]))),
475 Selection::Listed(set(&["EUR", "JPY", "USD"]))
476 );
477 assert_eq!(DataSet::Used.with_used(&Selection::All), Selection::All);
478 assert_eq!(
479 DataSet::All.with_used(&Selection::Listed(set(&["JPY"]))),
480 Selection::All
481 );
482 }
483}