1#![deny(missing_docs)]
2use anyhow::{anyhow, Context, Result};
68use indexmap::IndexMap;
69#[cfg(feature = "rhai")]
70use rhai::{Engine, AST};
71use serde::Deserialize;
72use std::{fs, path::Path};
73use toml::{self};
74
75pub trait FileSystem {
77 fn read_to_string(&self, filepath: &Path) -> Result<String, std::io::Error>;
79}
80
81struct StdFileSystem;
83
84impl FileSystem for StdFileSystem {
85 fn read_to_string(&self, filepath: &Path) -> Result<String, std::io::Error> {
86 fs::read_to_string(filepath)
87 }
88}
89
90#[derive(Deserialize, Debug, Clone)]
140pub struct Config {
141 pub core: Option<CoreConfig>,
143 data: Option<IndexMap<String, Data>>,
144 #[cfg(feature = "rhai")]
145 translators: Option<IndexMap<String, Data>>,
146 translation: Option<IndexMap<String, Data>>,
147}
148
149#[derive(Deserialize, Debug, Clone)]
187pub struct CoreConfig {
188 pub buffer_size: Option<usize>,
191 auto_capitalize: Option<bool>,
192 pub page_size: Option<usize>,
194 pub auto_commit: Option<bool>,
196}
197
198#[derive(Deserialize, Debug, Clone)]
199#[serde(untagged)]
200enum Data {
201 Simple(String),
202 Multi(Vec<String>),
203 File(DataFile),
204 Detailed(DetailedData),
205 MoreDetailed(MoreDetailedData),
206}
207
208#[derive(Deserialize, Debug, Clone)]
209struct DataFile {
210 path: String,
211}
212
213#[derive(Deserialize, Debug, Clone)]
214struct DetailedData {
215 value: String,
216 alias: Vec<String>,
217}
218
219#[derive(Deserialize, Debug, Clone)]
220struct MoreDetailedData {
221 values: Vec<String>,
222 alias: Vec<String>,
223}
224
225macro_rules! insert_with_auto_capitalize {
226 ( $data: expr, $auto_capitalize: expr, $key: expr, $value: expr ) => {
227 $data.insert($key.to_owned(), Data::Simple($value.to_owned()));
228
229 if $auto_capitalize && !$key.is_empty() && $key.chars().next().unwrap().is_lowercase() {
230 $data
231 .entry($key[0..1].to_uppercase() + &$key[1..])
232 .or_insert(Data::Simple($value.to_uppercase()));
233 }
234 };
235}
236
237impl Config {
238 pub fn from_file(filepath: &Path) -> Result<Self> {
240 Self::from_filesystem(filepath, &StdFileSystem {})
241 }
242
243 pub fn from_filesystem(filepath: &Path, fs: &impl FileSystem) -> Result<Self> {
245 let content = fs
246 .read_to_string(filepath)
247 .with_context(|| format!("Couldn't open file {filepath:?}."))?;
248 let mut config: Self = toml::from_str(&content)
249 .with_context(|| format!("Failed to parse configuration file {filepath:?}."))?;
250 let config_path = filepath.parent().unwrap();
251 let auto_capitalize = config
252 .core
253 .as_ref()
254 .and_then(|c| c.auto_capitalize)
255 .unwrap_or(true);
256
257 let mut data = IndexMap::new();
259
260 config
261 .data
262 .unwrap_or_default()
263 .iter()
264 .try_for_each(|(key, value)| -> Result<()> {
265 match value {
266 Data::File(DataFile { path }) => {
267 let filepath = config_path.join(path);
268 let conf = Config::from_filesystem(&filepath, fs)?;
269 data.extend(conf.data.unwrap_or_default());
270 }
271 Data::Simple(value) => {
272 insert_with_auto_capitalize!(data, auto_capitalize, key, value);
273 }
274 Data::Detailed(DetailedData { value, alias }) => {
275 alias.iter().chain([key.to_owned()].iter()).for_each(|key| {
276 insert_with_auto_capitalize!(data, auto_capitalize, key, value);
277 });
278 }
279 _ => Err(anyhow!("{value:?} not allowed in the data table."))
280 .with_context(|| format!("Invalid configuration file {filepath:?}."))?,
281 };
282 Ok(())
283 })?;
284 config.data = Some(data);
285
286 #[cfg(feature = "rhai")]
288 {
289 let mut translators = IndexMap::new();
290
291 config
292 .translators
293 .unwrap_or_default()
294 .into_iter()
295 .try_for_each(|(key, value)| -> Result<()> {
296 match value {
297 Data::File(DataFile { path }) => {
298 let filepath = config_path.join(path);
299 let conf = Config::from_filesystem(&filepath, fs)?;
300 translators.extend(conf.translators.unwrap_or_default());
301 }
302 Data::Simple(value) => {
303 let filepath = config_path.join(value).to_str().unwrap().to_string();
304 translators.insert(key, Data::Simple(filepath));
305 }
306 _ => Err(anyhow!("{value:?} not allowed in the translator table"))
307 .with_context(|| format!("Invalid configuration file {filepath:?}."))?,
308 };
309 Ok(())
310 })?;
311 config.translators = Some(translators);
312 }
313
314 let mut translation = IndexMap::new();
316
317 config
318 .translation
319 .unwrap_or_default()
320 .into_iter()
321 .try_for_each(|(key, value)| -> Result<()> {
322 match value {
323 Data::File(DataFile { path }) => {
324 let filepath = config_path.join(path);
325 let conf = Config::from_filesystem(&filepath, fs)?;
326 translation.extend(conf.translation.unwrap_or_default());
327 }
328 Data::Simple(_) | Data::Multi(_) => {
329 translation.insert(key, value);
330 }
331 Data::Detailed(DetailedData { value, alias }) => {
332 alias.iter().chain([key].iter()).for_each(|e| {
333 translation.insert(e.to_owned(), Data::Simple(value.to_owned()));
334 });
335 }
336 Data::MoreDetailed(MoreDetailedData { values, alias }) => {
337 alias.iter().chain([key].iter()).for_each(|key| {
338 translation.insert(key.to_owned(), Data::Multi(values.to_owned()));
339 });
340 }
341 };
342 Ok(())
343 })?;
344
345 config.translation = Some(translation);
346
347 Ok(config)
348 }
349
350 pub fn extract_data(&self) -> IndexMap<String, String> {
352 let empty = IndexMap::default();
353
354 self.data
355 .as_ref()
356 .unwrap_or(&empty)
357 .iter()
358 .filter_map(|(key, value)| {
359 let value = match value {
360 Data::Simple(value) => Some(value),
361 _ => None,
362 };
363 value.map(|value| (key.to_owned(), value.to_owned()))
364 })
365 .collect()
366 }
367
368 #[cfg(feature = "rhai")]
370 pub fn extract_translators(&self) -> Result<IndexMap<String, AST>> {
371 self.extract_translators_using_filesystem(&StdFileSystem {})
372 }
373
374 #[cfg(feature = "rhai")]
376 pub fn extract_translators_using_filesystem(
377 &self,
378 fs: &impl FileSystem,
379 ) -> Result<IndexMap<String, AST>> {
380 let empty = IndexMap::default();
381 let engine = Engine::new();
382
383 self.translators
384 .as_ref()
385 .unwrap_or(&empty)
386 .iter()
387 .filter_map(|(name, file_path)| {
388 let file_path = match file_path {
389 Data::Simple(file_path) => Some(file_path),
390 _ => None,
391 };
392
393 file_path.map(|file_path| {
394 let file_path = Path::new(&file_path);
395 let parent = file_path.parent().unwrap().to_str().unwrap();
396 let header = format!(r#"const DIR = {parent:?};"#);
397 let body = fs
398 .read_to_string(file_path)
399 .with_context(|| format!("Couldn't open script file {file_path:?}."))?;
400 let ast = engine
401 .compile(body)
402 .with_context(|| format!("Failed to parse script file {file_path:?}."))?;
403 let ast = engine.compile(header).unwrap().merge(&ast);
404
405 Ok((name.to_owned(), ast))
406 })
407 })
408 .collect()
409 }
410
411 pub fn extract_translation(&self) -> IndexMap<String, Vec<String>> {
413 let empty = IndexMap::new();
414
415 self.translation
416 .as_ref()
417 .unwrap_or(&empty)
418 .iter()
419 .filter_map(|(key, value)| {
420 let value = match value {
421 Data::Simple(value) => Some(vec![value.to_owned()]),
422 Data::Multi(value) => Some(value.to_owned()),
423 _ => None,
424 };
425
426 value.map(|value| (key.to_owned(), value))
427 })
428 .collect()
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use crate::Config;
435 use std::path::Path;
436
437 #[test]
438 fn from_file() {
439 let conf = Config::from_file(Path::new("./data/config_sample.toml")).unwrap();
440
441 assert_eq!(
442 conf.core.as_ref().map(|core| {
443 assert_eq!(core.buffer_size.unwrap(), 64);
444 assert!(!core.auto_capitalize.unwrap());
445 assert!(!core.auto_commit.unwrap());
446 assert_eq!(core.page_size.unwrap(), 10);
447 true
448 }),
449 Some(true)
450 );
451
452 let data = conf.extract_data();
453 assert_eq!(data.keys().len(), 23);
454
455 let conf = Config::from_file(Path::new("./data/blank_sample.toml")).unwrap();
457 let data = conf.extract_data();
458 assert_eq!(data.keys().len(), 0);
459
460 let conf = Config::from_file(Path::new("./data/invalid_file.toml"));
462 assert!(conf.is_err());
463
464 let conf = Config::from_file(Path::new("./data/not_found"));
466 assert!(conf.is_err());
467 }
468
469 #[test]
470 fn from_invalid_file() {
471 let conf = Config::from_file(Path::new("./data/invalid_data.toml"));
473 assert!(conf.is_err());
474 }
475
476 #[cfg(feature = "rhai")]
477 #[test]
478 fn from_file_with_translators() {
479 let conf = Config::from_file(Path::new("./data/invalid_translator.toml"));
481 assert!(conf.is_err());
482
483 let conf = Config::from_file(Path::new("./data/config_sample.toml")).unwrap();
484 let translators = conf.extract_translators().unwrap();
485 assert_eq!(translators.keys().len(), 2);
486
487 let conf = Config::from_file(Path::new("./data/blank_sample.toml")).unwrap();
489 let translators = conf.extract_translators().unwrap();
490 assert_eq!(translators.keys().len(), 0);
491
492 let conf = Config::from_file(Path::new("./data/bad_script2.toml")).unwrap();
494 assert!(conf.extract_translators().is_err());
495
496 let conf = Config::from_file(Path::new("./data/bad_script.toml")).unwrap();
498 assert!(conf.extract_translators().is_err());
499 }
500
501 #[test]
502 fn from_file_with_translation() {
503 let conf = Config::from_file(Path::new("./data/config_sample.toml")).unwrap();
504 let translation = conf.extract_translation();
505 assert_eq!(translation.keys().len(), 4);
506
507 let conf = Config::from_file(Path::new("./data/blank_sample.toml")).unwrap();
508 let translation = conf.extract_translation();
509 assert_eq!(translation.keys().len(), 0);
510 }
511
512 #[test]
513 fn from_filesystem() {
514 use crate::FileSystem;
515 use std::fs;
516
517 #[derive(Default)]
518 struct FilterFileSystem;
519
520 impl FileSystem for FilterFileSystem {
521 fn read_to_string(&self, filepath: &Path) -> Result<String, std::io::Error> {
522 let file_stem = filepath.file_stem().unwrap();
523
524 Ok(if file_stem == "data_sample" {
525 fs::read_to_string(filepath)?
526 } else {
527 String::new()
528 })
529 }
530 }
531
532 let fs = FilterFileSystem {};
533 let filepath = Path::new("./data/data_sample.toml");
534 let conf = Config::from_filesystem(filepath, &fs).unwrap();
535
536 assert_eq!(conf.extract_data().keys().len(), 13);
537 #[cfg(feature = "rhai")]
538 assert_eq!(conf.extract_translators().unwrap().keys().len(), 0);
539 assert_eq!(conf.extract_translation().keys().len(), 0);
540 }
541}