Skip to main content

blut_graph_core/
config.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3use alloc::collections::{BTreeMap, BTreeSet};
4use alloc::string::String;
5use alloc::vec::Vec;
6use core::fmt;
7
8use serde::{Deserialize, Serialize};
9
10/// Exact, architecture-independent values accepted by semantic node instances.
11#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
12#[serde(rename_all = "kebab-case")]
13pub enum ConfigValue {
14    Bool(bool),
15    I64(i64),
16    U64(u64),
17    Text(String),
18    Bytes(Vec<u8>),
19}
20
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "kebab-case")]
23pub enum ConfigType {
24    Bool,
25    I64 { minimum: i64, maximum: i64 },
26    U64 { minimum: u64, maximum: u64 },
27    Text { max_bytes: u32 },
28    Choice { values: Vec<String> },
29    Bytes { max_bytes: u32 },
30}
31
32#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
33pub struct ConfigField {
34    pub name: String,
35    pub value_type: ConfigType,
36    pub required: bool,
37    pub default: Option<ConfigValue>,
38}
39
40#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
41pub struct ConfigSchema {
42    pub fields: Vec<ConfigField>,
43}
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub enum ConfigError {
47    DuplicateField(String),
48    InvalidField(String),
49    UnknownField(String),
50    MissingField(String),
51    InvalidValue(String),
52}
53
54impl fmt::Display for ConfigError {
55    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56        write!(f, "{self:?}")
57    }
58}
59
60#[cfg(feature = "std")]
61impl std::error::Error for ConfigError {}
62
63impl ConfigSchema {
64    /// Normalize set-like schema content and reject ambiguous declarations.
65    pub fn normalize(&mut self) -> Result<(), ConfigError> {
66        self.fields
67            .sort_by(|left, right| left.name.cmp(&right.name));
68        if self
69            .fields
70            .windows(2)
71            .any(|pair| pair[0].name == pair[1].name)
72        {
73            return Err(ConfigError::DuplicateField(
74                self.fields
75                    .windows(2)
76                    .find(|pair| pair[0].name == pair[1].name)
77                    .expect("duplicate was detected")[0]
78                    .name
79                    .clone(),
80            ));
81        }
82        for field in &mut self.fields {
83            if field.name.is_empty() || (field.required && field.default.is_some()) {
84                return Err(ConfigError::InvalidField(field.name.clone()));
85            }
86            if let ConfigType::Choice { values } = &mut field.value_type {
87                values.sort_unstable();
88                values.dedup();
89                if values.is_empty() || values.iter().any(String::is_empty) {
90                    return Err(ConfigError::InvalidField(field.name.clone()));
91                }
92            }
93            if !valid_type(&field.value_type)
94                || field
95                    .default
96                    .as_ref()
97                    .is_some_and(|value| !valid_value(&field.value_type, value))
98            {
99                return Err(ConfigError::InvalidField(field.name.clone()));
100            }
101        }
102        Ok(())
103    }
104
105    /// Validate an instance and materialize defaults into one canonical map.
106    pub fn canonicalize(
107        &self,
108        supplied: &BTreeMap<String, ConfigValue>,
109    ) -> Result<BTreeMap<String, ConfigValue>, ConfigError> {
110        let fields: BTreeMap<_, _> = self
111            .fields
112            .iter()
113            .map(|field| (field.name.as_str(), field))
114            .collect();
115        if let Some(unknown) = supplied
116            .keys()
117            .find(|name| !fields.contains_key(name.as_str()))
118        {
119            return Err(ConfigError::UnknownField(unknown.clone()));
120        }
121        let mut normalized = BTreeMap::new();
122        for field in &self.fields {
123            match supplied.get(&field.name).or(field.default.as_ref()) {
124                Some(value) if valid_value(&field.value_type, value) => {
125                    normalized.insert(field.name.clone(), value.clone());
126                }
127                Some(_) => return Err(ConfigError::InvalidValue(field.name.clone())),
128                None if field.required => {
129                    return Err(ConfigError::MissingField(field.name.clone()));
130                }
131                None => {}
132            }
133        }
134        Ok(normalized)
135    }
136}
137
138fn valid_type(value_type: &ConfigType) -> bool {
139    match value_type {
140        ConfigType::I64 { minimum, maximum } => minimum <= maximum,
141        ConfigType::U64 { minimum, maximum } => minimum <= maximum,
142        ConfigType::Text { max_bytes } | ConfigType::Bytes { max_bytes } => *max_bytes > 0,
143        ConfigType::Choice { values } => {
144            !values.is_empty()
145                && values.iter().all(|value| !value.is_empty())
146                && values.iter().collect::<BTreeSet<_>>().len() == values.len()
147        }
148        ConfigType::Bool => true,
149    }
150}
151
152fn valid_value(value_type: &ConfigType, value: &ConfigValue) -> bool {
153    match (value_type, value) {
154        (ConfigType::Bool, ConfigValue::Bool(_)) => true,
155        (ConfigType::I64 { minimum, maximum }, ConfigValue::I64(value)) => {
156            value >= minimum && value <= maximum
157        }
158        (ConfigType::U64 { minimum, maximum }, ConfigValue::U64(value)) => {
159            value >= minimum && value <= maximum
160        }
161        (ConfigType::Text { max_bytes }, ConfigValue::Text(value)) => {
162            value.len() <= *max_bytes as usize
163        }
164        (ConfigType::Choice { values }, ConfigValue::Text(value)) => values.contains(value),
165        (ConfigType::Bytes { max_bytes }, ConfigValue::Bytes(value)) => {
166            value.len() <= *max_bytes as usize
167        }
168        _ => false,
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use alloc::vec;
175
176    use super::*;
177
178    #[test]
179    fn schema_canonicalizes_order_choices_and_defaults() {
180        let mut schema = ConfigSchema {
181            fields: vec![
182                ConfigField {
183                    name: "mode".into(),
184                    value_type: ConfigType::Choice {
185                        values: vec!["safe".into(), "fast".into(), "safe".into()],
186                    },
187                    required: false,
188                    default: Some(ConfigValue::Text("safe".into())),
189                },
190                ConfigField {
191                    name: "channels".into(),
192                    value_type: ConfigType::U64 {
193                        minimum: 1,
194                        maximum: 256,
195                    },
196                    required: true,
197                    default: None,
198                },
199            ],
200        };
201        schema.normalize().unwrap();
202        assert_eq!(schema.fields[0].name, "channels");
203        assert_eq!(
204            schema.canonicalize(&BTreeMap::from([("channels".into(), ConfigValue::U64(64))])),
205            Ok(BTreeMap::from([
206                ("channels".into(), ConfigValue::U64(64)),
207                ("mode".into(), ConfigValue::Text("safe".into())),
208            ]))
209        );
210    }
211
212    #[test]
213    fn instance_rejects_unknown_missing_and_out_of_range_values() {
214        let schema = ConfigSchema {
215            fields: vec![ConfigField {
216                name: "channels".into(),
217                value_type: ConfigType::U64 {
218                    minimum: 1,
219                    maximum: 256,
220                },
221                required: true,
222                default: None,
223            }],
224        };
225        assert!(matches!(
226            schema.canonicalize(&BTreeMap::new()),
227            Err(ConfigError::MissingField(_))
228        ));
229        assert!(matches!(
230            schema.canonicalize(&BTreeMap::from([(
231                "channels".into(),
232                ConfigValue::U64(257)
233            )])),
234            Err(ConfigError::InvalidValue(_))
235        ));
236        assert!(matches!(
237            schema.canonicalize(&BTreeMap::from([(
238                "surprise".into(),
239                ConfigValue::Bool(true)
240            )])),
241            Err(ConfigError::UnknownField(_))
242        ));
243    }
244}