Skip to main content

sofie_model/
config.rs

1use std::collections::BTreeMap;
2use std::path::{Path, PathBuf};
3
4use syn::{Attribute, Fields, Item, ItemStruct, Type};
5
6use crate::ModelError;
7use crate::code::derives;
8use crate::syntax::{doc_lines, first_type_argument, line_of, parsed_or_read, type_last_name};
9
10pub const DATABASE_URL: &str = "DATABASE_URL";
11
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct ModuleConfig {
14    pub type_name: String,
15    pub file: PathBuf,
16    pub line: usize,
17    pub declared: bool,
18    pub keys: Vec<ConfigKey>,
19}
20
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub struct ConfigKey {
23    pub name: String,
24    pub doc: Vec<String>,
25    pub kind: KeyKind,
26    pub required: bool,
27    pub fallback: Option<String>,
28    /// Declared `Secret<…>`, so it is never logged or printed.
29    pub secret: bool,
30}
31
32#[derive(Clone, Copy, Debug, PartialEq, Eq)]
33pub enum KeyKind {
34    /// A value type's own rules are checked at boot.
35    Text,
36    Boolean,
37    Integer,
38    Count,
39    Decimal,
40    Switch,
41    Address,
42}
43
44impl KeyKind {
45    fn of(type_name: &str) -> Self {
46        match type_name {
47            "bool" => Self::Boolean,
48            "i8" | "i16" | "i32" | "i64" | "i128" | "isize" => Self::Integer,
49            "u8" | "u16" | "u32" | "u64" | "u128" | "usize" => Self::Count,
50            "f32" | "f64" => Self::Decimal,
51            _ => Self::Text,
52        }
53    }
54
55    /// Must match how the loader parses it.
56    #[must_use]
57    pub fn accepts(self, value: &str) -> bool {
58        match self {
59            Self::Text => true,
60            Self::Boolean => value.parse::<bool>().is_ok(),
61            Self::Integer => value.parse::<i128>().is_ok(),
62            Self::Count => value.parse::<u128>().is_ok(),
63            Self::Decimal => value.parse::<f64>().is_ok(),
64            Self::Switch => matches!(value, "on" | "off"),
65            Self::Address => value.parse::<std::net::SocketAddr>().is_ok(),
66        }
67    }
68
69    #[must_use]
70    pub fn description(self) -> &'static str {
71        match self {
72            Self::Text => "text",
73            Self::Boolean => "`true` or `false`",
74            Self::Integer => "a whole number",
75            Self::Count => "a whole number of 0 or more",
76            Self::Decimal => "a number",
77            Self::Switch => "`on` or `off`",
78            Self::Address => "an address such as 127.0.0.1:8080",
79        }
80    }
81}
82
83pub(crate) fn read_config(
84    directory: &Path,
85    crate_name: &str,
86    syntax: &BTreeMap<PathBuf, syn::File>,
87) -> Result<Option<ModuleConfig>, ModelError> {
88    let file = directory.join("src").join("config.rs");
89    if !file.is_file() {
90        return Ok(None);
91    }
92    let parsed = parsed_or_read(syntax, &file)?;
93    let structs = structs_of(&parsed);
94    let type_name = config_type_name(crate_name);
95    let root = structs.get(&type_name).ok_or_else(|| {
96        ModelError::in_file(
97            &file,
98            format!("declare the module's config as `#[derive(sofie::Config)] pub struct {type_name} {{ … }}`"),
99        )
100    })?;
101    let mut keys = Vec::new();
102    Group {
103        structs: &structs,
104        file: &file,
105    }
106    .collect(root, &crate_name.to_ascii_uppercase(), false, &mut keys)?;
107    Ok(Some(ModuleConfig {
108        type_name,
109        line: line_of(&root.ident),
110        declared: syntax.contains_key(&file),
111        file,
112        keys,
113    }))
114}
115
116pub(crate) fn settings_keys(
117    syntax: &BTreeMap<PathBuf, syn::File>,
118    settings: Option<&str>,
119    prefix: &str,
120) -> Result<Vec<ConfigKey>, ModelError> {
121    let mut keys = Vec::new();
122    for (file, parsed) in syntax {
123        let structs = structs_of(parsed);
124        if let Some(root) = settings.and_then(|name| structs.get(name)) {
125            Group {
126                structs: &structs,
127                file,
128            }
129            .collect(root, prefix, false, &mut keys)?;
130            break;
131        }
132    }
133    Ok(keys)
134}
135
136fn structs_of(parsed: &syn::File) -> BTreeMap<String, &ItemStruct> {
137    parsed
138        .items
139        .iter()
140        .filter_map(|item| match item {
141            Item::Struct(declared) if derives(&declared.attrs, "Config") => {
142                Some((declared.ident.to_string(), declared))
143            }
144            _ => None,
145        })
146        .collect()
147}
148
149#[must_use]
150pub fn config_type_name(crate_name: &str) -> String {
151    crate::naming::pascal_case(crate_name) + "Config"
152}
153
154struct Group<'file> {
155    structs: &'file BTreeMap<String, &'file ItemStruct>,
156    file: &'file Path,
157}
158
159impl Group<'_> {
160    fn collect(
161        &self,
162        declared: &ItemStruct,
163        prefix: &str,
164        optional: bool,
165        keys: &mut Vec<ConfigKey>,
166    ) -> Result<(), ModelError> {
167        let Fields::Named(fields) = &declared.fields else {
168            return Err(ModelError::new(
169                self.file,
170                line_of(&declared.ident),
171                "a config struct has named fields, one per key",
172            ));
173        };
174        for field in &fields.named {
175            let Some(ident) = &field.ident else { continue };
176            let name = format!("{prefix}_{}", ident.to_string().to_ascii_uppercase());
177            let (field_type, is_option) = unwrapped(&field.ty, "Option");
178            let (field_type, secret) = unwrapped(field_type, "Secret");
179            let optional = optional || is_option;
180            let type_name = type_last_name(field_type).unwrap_or_default();
181            if let Some(nested) = self.structs.get(&type_name) {
182                self.collect(nested, &name, optional, keys)?;
183            } else if matches!(type_name.as_str(), "Database" | "Queue") {
184                keys.push(ConfigKey {
185                    secret: true,
186                    ..key(
187                        format!("{name}_URL"),
188                        &field.attrs,
189                        KeyKind::Text,
190                        false,
191                        Some(DATABASE_URL),
192                    )
193                });
194            } else {
195                keys.push(ConfigKey {
196                    secret,
197                    ..key(name, &field.attrs, KeyKind::of(&type_name), !optional, None)
198                });
199            }
200        }
201        Ok(())
202    }
203}
204
205fn key(
206    name: String,
207    attributes: &[Attribute],
208    kind: KeyKind,
209    required: bool,
210    fallback: Option<&str>,
211) -> ConfigKey {
212    ConfigKey {
213        name,
214        doc: doc_lines(attributes),
215        kind,
216        required,
217        fallback: fallback.map(str::to_owned),
218        secret: false,
219    }
220}
221
222fn unwrapped<'a>(written: &'a Type, wrapper: &str) -> (&'a Type, bool) {
223    if let Type::Path(path) = written
224        && let Some(last) = path.path.segments.last()
225        && last.ident == wrapper
226        && let Some(inner) = first_type_argument(last)
227    {
228        return (inner, true);
229    }
230    (written, false)
231}
232
233#[cfg(test)]
234mod tests {
235    use super::config_type_name;
236
237    #[test]
238    fn the_config_type_is_named_after_the_module() {
239        assert_eq!(config_type_name("order"), "OrderConfig");
240        assert_eq!(config_type_name("sofie_identity"), "SofieIdentityConfig");
241    }
242}