Skip to main content

afrim_config/
lib.rs

1#![deny(missing_docs)]
2//! Library to manage the configuration of the afrim input method.
3//!
4//! It's based on the top of the [`toml`] crate.
5//!
6//! # Example
7//!
8//! ```no_run
9//! use afrim_config::Config;
10//! use std::path::Path;
11//!
12//! let filepath = Path::new("./data/config_sample.toml");
13//! let conf = Config::from_file(&filepath).unwrap();
14//!
15//! # assert_eq!(conf.extract_data().keys().len(), 23);
16//! # #[cfg(feature = "rhai")]
17//! # assert_eq!(conf.extract_translators().unwrap().keys().len(), 2);
18//! # assert_eq!(conf.extract_translation().keys().len(), 4);
19//! ```
20//!
21//! In case that you want control the filesystem (reading of file), you can use the
22//! [`Config::from_filesystem`] method.
23//!
24//! # Example
25//!
26//! ```
27//! use afrim_config::{Config, FileSystem};
28//! use std::{error, path::Path, string::String};
29//!
30//! // Implements a custom filesystem.
31//! struct File {
32//!     source: String,
33//! }
34//!
35//! impl File {
36//!     pub fn new(source: String) -> Self {
37//!         Self { source }
38//!     }
39//! }
40//!
41//! impl FileSystem for File {
42//!     fn read_to_string(&self, filepath: &Path) -> Result<String, std::io::Error> {
43//!         Ok(self.source.to_string())
44//!     }
45//! }
46//!
47//! // Sets the config file.
48//! let config_file = File::new(r#"
49//! [core]
50//! auto_commit = false
51//!
52//! [data]
53//! "n*" = "ŋ"
54//! "#.to_owned());
55//!
56//! // Loads the config file.
57//! let config = Config::from_filesystem(&Path::new("."), &config_file).unwrap();
58//!
59//! assert_eq!(config.core.clone().unwrap().auto_commit, Some(false));
60//! // Note that the auto_capitalize is enabled by default.
61//! assert_eq!(
62//!     Vec::from_iter(config.extract_data().into_iter()),
63//!     vec![("n*".to_owned(), "ŋ".to_owned()), ("N*".to_owned(), "Ŋ".to_owned())]
64//! );
65//! ```
66
67use 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
75/// Trait to customize the filesystem.
76pub trait FileSystem {
77    /// Alternative to the fs::read_to_string.
78    fn read_to_string(&self, filepath: &Path) -> Result<String, std::io::Error>;
79}
80
81// Representation of the std::fs.
82struct 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/// Holds information about a configuration.
91///
92/// # Example
93///
94/// ```no_run
95/// # use afrim_config::{Config, FileSystem};
96/// # use std::{error, path::Path, string::String};
97/// #
98/// # // Implements a custom filesystem.
99/// # struct File {
100/// #     source: String,
101/// # }
102/// #
103/// # impl File {
104/// #     pub fn new(source: String) -> Self {
105/// #         Self { source }
106/// #     }
107/// # }
108/// #
109/// # impl FileSystem for File {
110/// #     fn read_to_string(&self, filepath: &Path) -> Result<String, std::io::Error> {
111/// #         Ok(self.source.to_string())
112/// #     }
113/// # }
114/// #
115/// # // Sets the config file.
116/// # let config_file = File::new(r#"
117/// [info]
118/// description = "Sample Config File"
119/// version = "2023-10-02"
120///
121/// [data]
122/// 2a_ = "á̠"
123/// ".?" = { value = "ʔ", alias = ["?."] }
124/// emoji = { path = "./emoji.toml" }
125///
126/// [translation]
127/// hey = "hi"
128/// hi = { value = "hello", alias = ["hey"] }
129/// hola = { values = ["hello"], alias = [] }
130/// dictionary = { path = "./dictionary.toml" }
131///
132/// [translators]
133/// date = "./scripts/datetime/date.rhai"
134/// # "#.to_owned());
135///
136/// # // Loads the config file.
137/// # Config::from_filesystem(&Path::new("."), &config_file).unwrap();
138/// ```
139#[derive(Deserialize, Debug, Clone)]
140pub struct Config {
141    /// The core config.
142    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/// Core information about a configuration.
150///
151/// # Example
152///
153/// ```
154/// # use afrim_config::{Config, FileSystem};
155/// # use std::{error, path::Path, string::String};
156/// #
157/// # // Implements a custom filesystem.
158/// # struct File {
159/// #     source: String,
160/// # }
161/// #
162/// # impl File {
163/// #     pub fn new(source: String) -> Self {
164/// #         Self { source }
165/// #     }
166/// # }
167/// #
168/// # impl FileSystem for File {
169/// #     fn read_to_string(&self, filepath: &Path) -> Result<String, std::io::Error> {
170/// #         Ok(self.source.to_string())
171/// #     }
172/// # }
173/// #
174/// # // Sets the config file.
175/// # let config_file = File::new(r#"
176/// [core]
177/// buffer_size = 32
178/// auto_capitalize = false
179/// page_size = 10
180/// auto_commit = true
181/// # "#.to_owned());
182/// #
183/// # // Loads the config file.
184/// # Config::from_filesystem(&Path::new("."), &config_file).unwrap();
185/// ```
186#[derive(Deserialize, Debug, Clone)]
187pub struct CoreConfig {
188    /// The size of the memory (history).
189    /// The number of elements that should be tracked.
190    pub buffer_size: Option<usize>,
191    auto_capitalize: Option<bool>,
192    /// The max numbers of predicates to display.
193    pub page_size: Option<usize>,
194    /// Whether the predicate should be automatically committed.
195    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    /// Load the configuration from a file.
239    pub fn from_file(filepath: &Path) -> Result<Self> {
240        Self::from_filesystem(filepath, &StdFileSystem {})
241    }
242
243    /// Loads the configuration from a file in using a specified filesystem.
244    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        // Data
258        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        // Translators
287        #[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        // Translation
315        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    /// Extracts the data from the configuration.
351    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    /// Extracts the translators from the configuration.
369    #[cfg(feature = "rhai")]
370    pub fn extract_translators(&self) -> Result<IndexMap<String, AST>> {
371        self.extract_translators_using_filesystem(&StdFileSystem {})
372    }
373
374    /// Extracts the translators from the configuration using the specified filesystem..
375    #[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    /// Extracts the translation from the configuration.
412    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        // data and core not provided
456        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        // parsing error
461        let conf = Config::from_file(Path::new("./data/invalid_file.toml"));
462        assert!(conf.is_err());
463
464        // config file not found
465        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        // invalid data
472        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        // invalid translator
480        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        // translators not provided
488        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        // scripts parsing error
493        let conf = Config::from_file(Path::new("./data/bad_script2.toml")).unwrap();
494        assert!(conf.extract_translators().is_err());
495
496        // script file not found
497        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}