Skip to main content

doido_core/inflector/
mod.rs

1pub mod config;
2pub mod inflections;
3pub(crate) mod rules;
4
5pub use config::InflectionConfig;
6pub use inflections::Inflections;
7
8use std::path::Path;
9use std::sync::OnceLock;
10
11static INFLECTIONS: OnceLock<Inflections> = OnceLock::new();
12
13/// Default location, relative to the project root, of the custom inflection file.
14pub const DEFAULT_CONFIG_PATH: &str = "config/inflection.yaml";
15
16/// Call this once at application boot, before any `Inflector::*` call.
17/// The closure receives the default English rules; add custom overrides there.
18///
19/// ```rust
20/// doido_core::inflector::init_inflections(|i| {
21///     i.irregular("goose", "geese");
22///     i.uncountable("bitcoin");
23/// });
24/// ```
25pub fn init_inflections<F: FnOnce(&mut Inflections)>(configure: F) {
26    let mut base = Inflections::default();
27    configure(&mut base);
28    // Silently ignore if already initialised (e.g. called twice in tests).
29    let _ = INFLECTIONS.set(base);
30}
31
32/// Errors raised while loading custom inflections from disk.
33#[derive(Debug, thiserror::Error)]
34pub enum LoadError {
35    #[error("failed to read inflection file `{path}`: {source}")]
36    Read {
37        path: String,
38        #[source]
39        source: std::io::Error,
40    },
41    #[error("failed to parse inflection file `{path}`: {source}")]
42    Parse {
43        path: String,
44        #[source]
45        source: serde_norway::Error,
46    },
47}
48
49/// Load custom inflections from a YAML file (default: `config/inflection.yaml`)
50/// layered on top of the default English rules, and install them globally.
51///
52/// A **missing** file is not an error — the default rules are installed and
53/// `Ok(false)` is returned. `Ok(true)` means custom rules were found and
54/// applied. Returns `Err` only when the file exists but cannot be read/parsed.
55///
56/// ```no_run
57/// // At application boot, from the project root:
58/// doido_core::inflector::load_inflections(doido_core::inflector::DEFAULT_CONFIG_PATH).unwrap();
59/// ```
60pub fn load_inflections(path: impl AsRef<Path>) -> Result<bool, LoadError> {
61    let path = path.as_ref();
62    let mut base = Inflections::default();
63
64    let found = match std::fs::read_to_string(path) {
65        Ok(contents) => {
66            let config =
67                InflectionConfig::from_yaml(&contents).map_err(|source| LoadError::Parse {
68                    path: path.display().to_string(),
69                    source,
70                })?;
71            config.apply(&mut base);
72            true
73        }
74        Err(e) if e.kind() == std::io::ErrorKind::NotFound => false,
75        Err(source) => {
76            return Err(LoadError::Read {
77                path: path.display().to_string(),
78                source,
79            })
80        }
81    };
82
83    // Silently ignore if already initialised (e.g. called twice in tests).
84    let _ = INFLECTIONS.set(base);
85    Ok(found)
86}
87
88fn global() -> &'static Inflections {
89    INFLECTIONS.get_or_init(Inflections::default)
90}
91
92/// Static facade over the application-global `Inflections`.
93/// All methods delegate to the global instance initialised by `init_inflections`
94/// (or default English rules if `init_inflections` was never called).
95pub struct Inflector;
96
97impl Inflector {
98    pub fn pluralize(s: &str) -> String {
99        global().pluralize(s)
100    }
101    pub fn singularize(s: &str) -> String {
102        global().singularize(s)
103    }
104    pub fn camelize(s: &str) -> String {
105        global().camelize(s)
106    }
107    pub fn camelize_lower(s: &str) -> String {
108        global().camelize_lower(s)
109    }
110    pub fn underscore(s: &str) -> String {
111        global().underscore(s)
112    }
113    pub fn dasherize(s: &str) -> String {
114        global().dasherize(s)
115    }
116    pub fn humanize(s: &str) -> String {
117        global().humanize(s)
118    }
119    pub fn tableize(s: &str) -> String {
120        global().tableize(s)
121    }
122    pub fn classify(s: &str) -> String {
123        global().classify(s)
124    }
125    pub fn foreign_key(s: &str) -> String {
126        global().foreign_key(s)
127    }
128    pub fn constantize(s: &str) -> String {
129        global().constantize(s)
130    }
131}