Skip to main content

xtalforge_core/
params.rs

1//! Typed parameter descriptions shared by modules, engines and builders
2//! (Task 3.8.1).
3
4use std::collections::BTreeMap;
5
6use serde::{Deserialize, Serialize};
7use serde_json::Value;
8
9use crate::Error;
10
11/// The type and constraints of a parameter.
12#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
13#[serde(tag = "type", rename_all = "snake_case")]
14pub enum ParamType {
15    /// A whole number with optional bounds.
16    #[serde(rename = "integer")]
17    Integer {
18        /// Minimum, inclusive.
19        min: Option<i64>,
20        /// Maximum, inclusive.
21        max: Option<i64>,
22    },
23    /// A floating-point number with optional bounds and unit.
24    #[serde(rename = "number")]
25    Float {
26        /// Minimum, inclusive.
27        min: Option<f64>,
28        /// Maximum, inclusive.
29        max: Option<f64>,
30        /// Unit shown to the user, for example `Angstrom`.
31        unit: Option<String>,
32    },
33    /// A boolean switch.
34    #[serde(rename = "boolean")]
35    Boolean,
36    /// One of a fixed set of strings.
37    Choice {
38        /// Allowed values.
39        choices: Vec<String>,
40    },
41    /// Free text.
42    #[serde(rename = "string")]
43    Text,
44    /// Three floating-point numbers.
45    #[serde(rename = "array")]
46    Vector3 {
47        /// Unit shown to the user.
48        unit: Option<String>,
49    },
50    /// A filesystem path (a string on the browser).
51    Path {
52        /// Whether the path must already exist.
53        must_exist: bool,
54    },
55}
56
57/// One described parameter.
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct ParamSpec {
60    /// Stable identifier, `snake_case`.
61    pub name: String,
62    /// Type and constraints.
63    pub kind: ParamType,
64    /// Default value, already valid for the type.
65    pub default: Value,
66    /// Help text.
67    pub help: String,
68    /// Whether the caller must provide it.
69    pub required: bool,
70    /// Optional grouping label for a user interface.
71    pub group: Option<String>,
72}
73
74impl ParamSpec {
75    /// Validates a value against this specification.
76    ///
77    /// # Errors
78    ///
79    /// Returns [`Error::InvalidInput`] with a message naming the parameter.
80    pub fn validate(&self, value: &Value) -> Result<(), Error> {
81        let fail = |reason: &str| {
82            Err(Error::InvalidInput(format!(
83                "parameter {}: {reason}",
84                self.name
85            )))
86        };
87        match &self.kind {
88            ParamType::Integer { min, max } => {
89                let Some(number) = value.as_i64() else {
90                    return fail("expected an integer");
91                };
92                if min.is_some_and(|min| number < min) {
93                    return fail("below the minimum");
94                }
95                if max.is_some_and(|max| number > max) {
96                    return fail("above the maximum");
97                }
98            }
99            ParamType::Float { min, max, .. } => {
100                let Some(number) = value.as_f64() else {
101                    return fail("expected a number");
102                };
103                if min.is_some_and(|min| number < min) {
104                    return fail("below the minimum");
105                }
106                if max.is_some_and(|max| number > max) {
107                    return fail("above the maximum");
108                }
109            }
110            ParamType::Boolean => {
111                if !value.is_boolean() {
112                    return fail("expected true or false");
113                }
114            }
115            ParamType::Choice { choices } => {
116                let Some(text) = value.as_str() else {
117                    return fail("expected a choice string");
118                };
119                if !choices.iter().any(|choice| choice == text) {
120                    return fail("not one of the allowed choices");
121                }
122            }
123            ParamType::Text => {
124                if !value.is_string() {
125                    return fail("expected text");
126                }
127            }
128            ParamType::Vector3 { .. } => {
129                let Some(values) = value.as_array() else {
130                    return fail("expected three numbers");
131                };
132                if values.len() != 3 || !values.iter().all(Value::is_number) {
133                    return fail("expected three numbers");
134                }
135            }
136            ParamType::Path { .. } => {
137                if !value.is_string() {
138                    return fail("expected a path string");
139                }
140            }
141        }
142        Ok(())
143    }
144}
145
146/// A set of parameter descriptions and their current values.
147#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
148pub struct ParamSet {
149    /// Descriptions in declaration order.
150    pub specs: Vec<ParamSpec>,
151    /// Current values; missing entries use the default.
152    pub values: BTreeMap<String, Value>,
153}
154
155impl ParamSet {
156    /// Creates an empty set.
157    #[must_use]
158    pub fn new() -> Self {
159        Self {
160            specs: Vec::new(),
161            values: BTreeMap::new(),
162        }
163    }
164
165    /// Adds a specification and its default value.
166    ///
167    /// # Errors
168    ///
169    /// Returns [`Error::InvalidInput`] for a duplicate name or a default that
170    /// violates the specification.
171    pub fn with_param(mut self, spec: ParamSpec) -> Result<Self, Error> {
172        if self.specs.iter().any(|existing| existing.name == spec.name) {
173            return Err(Error::InvalidInput(format!(
174                "duplicate parameter {}",
175                spec.name
176            )));
177        }
178        spec.validate(&spec.default)?;
179        self.values.insert(spec.name.clone(), spec.default.clone());
180        self.specs.push(spec);
181        Ok(self)
182    }
183
184    /// Sets a value after validation.
185    ///
186    /// # Errors
187    ///
188    /// Returns [`Error::InvalidInput`] for an unknown parameter or an invalid
189    /// value.
190    pub fn set(&mut self, name: &str, value: Value) -> Result<(), Error> {
191        let spec = self
192            .specs
193            .iter()
194            .find(|spec| spec.name == name)
195            .ok_or_else(|| Error::InvalidInput(format!("unknown parameter {name}")))?;
196        spec.validate(&value)?;
197        self.values.insert(name.to_owned(), value);
198        Ok(())
199    }
200
201    /// Gets a value.
202    #[must_use]
203    pub fn get(&self, name: &str) -> Option<&Value> {
204        self.values.get(name)
205    }
206
207    /// Checks that every required parameter has a value.
208    ///
209    /// # Errors
210    ///
211    /// Returns [`Error::InvalidInput`] naming the missing parameter.
212    pub fn check_required(&self) -> Result<(), Error> {
213        for spec in &self.specs {
214            if spec.required && !self.values.contains_key(&spec.name) {
215                return Err(Error::InvalidInput(format!(
216                    "missing required parameter {}",
217                    spec.name
218                )));
219            }
220        }
221        Ok(())
222    }
223
224    /// A JSON schema-like object for the agent layer.
225    #[must_use]
226    pub fn schema(&self) -> Value {
227        let properties: BTreeMap<&str, Value> = self
228            .specs
229            .iter()
230            .map(|spec| (spec.name.as_str(), spec.kind.clone().into_value(spec)))
231            .collect();
232        serde_json::json!({
233            "type": "object",
234            "properties": properties,
235            "required": self.specs.iter().filter(|spec| spec.required).map(|spec| spec.name.as_str()).collect::<Vec<_>>(),
236        })
237    }
238}
239
240impl Default for ParamSet {
241    fn default() -> Self {
242        Self::new()
243    }
244}
245
246impl ParamType {
247    fn into_value(self, spec: &ParamSpec) -> Value {
248        let mut object = serde_json::to_value(&self).unwrap_or(Value::Null);
249        if let Some(map) = object.as_object_mut() {
250            map.insert("default".to_owned(), spec.default.clone());
251            map.insert("description".to_owned(), Value::String(spec.help.clone()));
252            if let Some(group) = &spec.group {
253                map.insert("group".to_owned(), Value::String(group.clone()));
254            }
255        }
256        object
257    }
258}
259
260impl ParamSet {
261    /// Serializes the descriptions and current values.
262    ///
263    /// # Errors
264    ///
265    /// Returns [`Error::InvalidInput`] when serialization fails.
266    pub fn to_json(&self) -> Result<String, Error> {
267        serde_json::to_string_pretty(self).map_err(|error| {
268            Error::InvalidInput(format!("could not serialize parameters: {error}"))
269        })
270    }
271
272    /// Parses a stored parameter set and validates every value.
273    ///
274    /// # Errors
275    ///
276    /// Returns [`Error::InvalidInput`] for invalid JSON, unknown fields or a
277    /// value that violates its specification.
278    pub fn from_json(text: &str) -> Result<Self, Error> {
279        let set: Self = serde_json::from_str(text)
280            .map_err(|error| Error::InvalidInput(format!("invalid parameters: {error}")))?;
281        for (name, value) in &set.values {
282            let spec = set
283                .specs
284                .iter()
285                .find(|spec| spec.name == *name)
286                .ok_or_else(|| Error::InvalidInput(format!("unknown parameter {name}")))?;
287            spec.validate(value)?;
288        }
289        Ok(set)
290    }
291}