Skip to main content

fuzzy_parser/
schema.rs

1//! Schema definitions for fuzzy repair
2//!
3//! This module provides schema types that callers use to define
4//! valid field names, type discriminators, and expected value shapes
5//! for fuzzy matching and coercion.
6//!
7//! # Design
8//!
9//! Schemas are built from three layers:
10//!
11//! - [`FieldKind`] — the expected shape of a single field *value*
12//!   (enum set, nested object, coercion target, ...). Nested kinds make
13//!   the schema recursive: objects inside arrays inside objects can all
14//!   be described and repaired.
15//! - [`ObjectSchema`] — a set of named fields ([`FieldDef`]) for one object.
16//! - [`TaggedEnumSchema`] — a discriminated union: a tag field selects
17//!   which [`ObjectSchema`] applies.
18//!
19//! All schema types own their strings, so schemas can be built at runtime
20//! (e.g. from a config file or an API definition), not only from `'static`
21//! literals.
22
23/// Expected shape of a single field value.
24///
25/// After a field's *name* has been repaired, its `FieldKind` decides what
26/// repair (if any) is applied to the field's *value*:
27///
28/// - Fuzzy correction against a closed set ([`FieldKind::Enum`],
29///   [`FieldKind::EnumArray`])
30/// - Recursive repair with a nested schema ([`FieldKind::Object`],
31///   [`FieldKind::ObjectArray`])
32/// - Type coercion of string-encoded scalars ([`FieldKind::Integer`],
33///   [`FieldKind::Number`], [`FieldKind::Bool`], [`FieldKind::String`])
34///
35/// Values that don't match the expected shape (e.g. a non-parseable string
36/// for [`FieldKind::Integer`]) are left untouched — no lossy repair is made.
37///
38/// This enum is `#[non_exhaustive]`: new repair kinds may be added in minor
39/// releases, so external `match` expressions need a wildcard arm.
40#[derive(Debug, Clone, Default, PartialEq)]
41#[non_exhaustive]
42pub enum FieldKind {
43    /// No value repair; the value is left untouched.
44    #[default]
45    Any,
46    /// A string constrained to a closed set of values; fuzzy-corrected.
47    Enum(Vec<String>),
48    /// An array of strings, each constrained to a closed set; fuzzy-corrected.
49    EnumArray(Vec<String>),
50    /// A nested object repaired recursively with its own schema.
51    Object(ObjectSchema),
52    /// An array of objects, each repaired recursively with the same schema.
53    ObjectArray(ObjectSchema),
54    /// A nested tagged enum (discriminated union) repaired with its own
55    /// [`TaggedEnumSchema`]: tag value, field names, and field values.
56    TaggedEnum(TaggedEnumSchema),
57    /// An array of tagged enums, each repaired with the same schema
58    /// (e.g. a list of DSL intents).
59    TaggedEnumArray(TaggedEnumSchema),
60    /// Coerce string-encoded integers to numbers (`"42"` → `42`).
61    Integer,
62    /// Coerce string-encoded numbers to numbers (`"4.2"` → `4.2`).
63    Number,
64    /// Coerce string-encoded booleans to booleans (`"true"` → `true`).
65    Bool,
66    /// Coerce scalar numbers / booleans to their string rendering (`42` → `"42"`).
67    String,
68}
69
70impl FieldKind {
71    /// Build an [`FieldKind::Enum`] from any iterator of string-likes.
72    pub fn enum_of<I, S>(values: I) -> Self
73    where
74        I: IntoIterator<Item = S>,
75        S: AsRef<str>,
76    {
77        Self::Enum(values.into_iter().map(|s| s.as_ref().to_string()).collect())
78    }
79
80    /// Build an [`FieldKind::EnumArray`] from any iterator of string-likes.
81    pub fn enum_array<I, S>(values: I) -> Self
82    where
83        I: IntoIterator<Item = S>,
84        S: AsRef<str>,
85    {
86        Self::EnumArray(values.into_iter().map(|s| s.as_ref().to_string()).collect())
87    }
88}
89
90/// A named field with an expected value shape.
91#[derive(Debug, Clone, PartialEq)]
92pub struct FieldDef {
93    /// The field name (used for fuzzy field-name repair).
94    pub name: String,
95    /// The expected shape of the field's value.
96    pub kind: FieldKind,
97}
98
99impl FieldDef {
100    /// Create a new field definition.
101    pub fn new(name: impl AsRef<str>, kind: FieldKind) -> Self {
102        Self {
103            name: name.as_ref().to_string(),
104            kind,
105        }
106    }
107}
108
109/// Schema for an object with known fields.
110///
111/// Field *names* are fuzzy-repaired against the defined names; field
112/// *values* are repaired according to each field's [`FieldKind`].
113/// Because [`FieldKind`] can contain nested `ObjectSchema`s, repair
114/// recurses to any depth.
115///
116/// # Example
117///
118/// ```
119/// use fuzzy_parser::{FieldKind, ObjectSchema};
120///
121/// let schema = ObjectSchema::new(["name"])
122///     .with_field_kind("timeout", FieldKind::Integer)
123///     .with_field_kind("derives", FieldKind::enum_array(["Debug", "Clone"]))
124///     .with_field_kind(
125///         "inner",
126///         FieldKind::Object(ObjectSchema::new(["host", "port"])),
127///     );
128/// ```
129#[derive(Debug, Clone, Default, PartialEq)]
130pub struct ObjectSchema {
131    /// The defined fields.
132    pub fields: Vec<FieldDef>,
133}
134
135impl ObjectSchema {
136    /// Create a schema from field names (all fields accept any value shape).
137    pub fn new<I, S>(valid_fields: I) -> Self
138    where
139        I: IntoIterator<Item = S>,
140        S: AsRef<str>,
141    {
142        Self {
143            fields: valid_fields
144                .into_iter()
145                .map(|name| FieldDef::new(name, FieldKind::Any))
146                .collect(),
147        }
148    }
149
150    /// Create an empty schema (add fields with the builder methods).
151    pub fn empty() -> Self {
152        Self::default()
153    }
154
155    /// Add a field that accepts any value shape.
156    pub fn with_field(self, name: impl AsRef<str>) -> Self {
157        self.with_field_kind(name, FieldKind::Any)
158    }
159
160    /// Add a field with an expected value shape.
161    ///
162    /// If a field with the same name already exists, its kind is replaced.
163    pub fn with_field_kind(mut self, name: impl AsRef<str>, kind: FieldKind) -> Self {
164        let name = name.as_ref();
165        if let Some(def) = self.fields.iter_mut().find(|d| d.name == name) {
166            def.kind = kind;
167        } else {
168            self.fields.push(FieldDef::new(name, kind));
169        }
170        self
171    }
172
173    /// Check if a field name is valid.
174    pub fn is_valid_field(&self, field: &str) -> bool {
175        self.fields.iter().any(|d| d.name == field)
176    }
177
178    /// Iterate over the defined field names.
179    pub fn field_names(&self) -> impl Iterator<Item = &str> {
180        self.fields.iter().map(|d| d.name.as_str())
181    }
182
183    /// Get the expected value shape for a field, if defined.
184    pub fn kind_of(&self, field: &str) -> Option<&FieldKind> {
185        self.fields.iter().find(|d| d.name == field).map(|d| &d.kind)
186    }
187}
188
189/// Schema for a tagged enum (discriminated union)
190///
191/// Used for types with a discriminator field (e.g., tag: "type", "kind").
192/// Each valid tag value maps to an [`ObjectSchema`] describing that
193/// variant's fields. Fields registered globally (via
194/// [`with_enum_array`](Self::with_enum_array),
195/// [`with_nested_object`](Self::with_nested_object) or
196/// [`with_field_kind`](Self::with_field_kind)) apply to every variant.
197///
198/// # Example (static, closure-based)
199///
200/// ```
201/// use fuzzy_parser::TaggedEnumSchema;
202///
203/// let schema = TaggedEnumSchema::new(
204///     "type",
205///     &["AddDerive", "RemoveDerive"],
206///     |tag| match tag {
207///         "AddDerive" | "RemoveDerive" => Some(&["target", "derives"][..]),
208///         _ => None,
209///     },
210/// )
211/// .with_enum_array("derives", &["Debug", "Clone", "Serialize"])
212/// .with_nested_object("config", &["timeout", "retries"]);
213/// ```
214///
215/// # Example (dynamic, built at runtime)
216///
217/// ```
218/// use fuzzy_parser::{FieldKind, ObjectSchema, TaggedEnumSchema};
219///
220/// // Field names can come from runtime data (config, API spec, ...).
221/// let variant = String::from("AddDerive");
222/// let schema = TaggedEnumSchema::with_tag("type").with_variant(
223///     variant,
224///     ObjectSchema::new(["target"])
225///         .with_field_kind("derives", FieldKind::enum_array(["Debug", "Clone"]))
226///         .with_field_kind("timeout", FieldKind::Integer),
227/// );
228/// ```
229#[derive(Debug, Clone, Default, PartialEq)]
230pub struct TaggedEnumSchema {
231    /// The discriminator field name (e.g., "type", "kind")
232    pub tag_field: String,
233    /// The variants: (tag value, schema for that variant's fields)
234    pub variants: Vec<(String, ObjectSchema)>,
235    /// Fields that apply to every variant (checked after variant fields)
236    pub global_fields: Vec<FieldDef>,
237}
238
239impl TaggedEnumSchema {
240    /// Create a new tagged enum schema from a field-resolver closure.
241    ///
242    /// The closure is evaluated once per valid tag at construction time,
243    /// so the schema itself owns its data and carries no generic parameter.
244    pub fn new<F>(tag_field: impl AsRef<str>, valid_tags: &[&str], fields_for_tag: F) -> Self
245    where
246        F: Fn(&str) -> Option<&'static [&'static str]>,
247    {
248        let variants = valid_tags
249            .iter()
250            .map(|tag| {
251                let fields = fields_for_tag(tag)
252                    .map(|fs| ObjectSchema::new(fs.iter().copied()))
253                    .unwrap_or_default();
254                (tag.to_string(), fields)
255            })
256            .collect();
257        Self {
258            tag_field: tag_field.as_ref().to_string(),
259            variants,
260            global_fields: Vec::new(),
261        }
262    }
263
264    /// Create an empty schema for dynamic construction.
265    ///
266    /// Add variants with [`with_variant`](Self::with_variant).
267    pub fn with_tag(tag_field: impl AsRef<str>) -> Self {
268        Self {
269            tag_field: tag_field.as_ref().to_string(),
270            variants: Vec::new(),
271            global_fields: Vec::new(),
272        }
273    }
274
275    /// Add (or replace) a variant with its field schema.
276    pub fn with_variant(mut self, tag: impl AsRef<str>, schema: ObjectSchema) -> Self {
277        let tag = tag.as_ref();
278        if let Some(entry) = self.variants.iter_mut().find(|(t, _)| t == tag) {
279            entry.1 = schema;
280        } else {
281            self.variants.push((tag.to_string(), schema));
282        }
283        self
284    }
285
286    /// Add a global enum array field for repair (applies to every variant).
287    ///
288    /// Values in this array field will be fuzzy-matched against `valid_values`.
289    ///
290    /// # Example
291    ///
292    /// ```
293    /// use fuzzy_parser::TaggedEnumSchema;
294    ///
295    /// let schema = TaggedEnumSchema::new("type", &["AddDerive"], |_| Some(&["derives"][..]))
296    ///     .with_enum_array("derives", &["Debug", "Clone", "Serialize"]);
297    /// // Now "Debg" in derives array will be corrected to "Debug"
298    /// ```
299    pub fn with_enum_array<I, S>(self, field: impl AsRef<str>, valid_values: I) -> Self
300    where
301        I: IntoIterator<Item = S>,
302        S: AsRef<str>,
303    {
304        self.with_field_kind(field, FieldKind::enum_array(valid_values))
305    }
306
307    /// Add a global nested object field for repair (applies to every variant).
308    ///
309    /// Field names in this nested object will be fuzzy-matched against
310    /// `valid_fields`. For deeper nesting or value shapes inside the nested
311    /// object, use [`with_field_kind`](Self::with_field_kind) with
312    /// [`FieldKind::Object`] instead.
313    ///
314    /// # Example
315    ///
316    /// ```
317    /// use fuzzy_parser::TaggedEnumSchema;
318    ///
319    /// let schema = TaggedEnumSchema::new("type", &["Configure"], |_| Some(&["config"][..]))
320    ///     .with_nested_object("config", &["timeout", "retries", "enabled"]);
321    /// // Now "timout" in config object will be corrected to "timeout"
322    /// ```
323    pub fn with_nested_object<I, S>(self, field: impl AsRef<str>, valid_fields: I) -> Self
324    where
325        I: IntoIterator<Item = S>,
326        S: AsRef<str>,
327    {
328        self.with_field_kind(field, FieldKind::Object(ObjectSchema::new(valid_fields)))
329    }
330
331    /// Add (or replace) a global field with an expected value shape.
332    ///
333    /// Global fields apply to every variant, after the variant's own
334    /// field kinds.
335    pub fn with_field_kind(mut self, field: impl AsRef<str>, kind: FieldKind) -> Self {
336        let field = field.as_ref();
337        if let Some(def) = self.global_fields.iter_mut().find(|d| d.name == field) {
338            def.kind = kind;
339        } else {
340            self.global_fields.push(FieldDef::new(field, kind));
341        }
342        self
343    }
344
345    /// Check if a tag value is valid
346    pub fn is_valid_tag(&self, tag: &str) -> bool {
347        self.variants.iter().any(|(t, _)| t == tag)
348    }
349
350    /// Iterate over the valid tag values.
351    pub fn tag_values(&self) -> impl Iterator<Item = &str> {
352        self.variants.iter().map(|(t, _)| t.as_str())
353    }
354
355    /// Get the field schema for a tag value, if the tag is valid.
356    pub fn variant_schema(&self, tag: &str) -> Option<&ObjectSchema> {
357        self.variants.iter().find(|(t, _)| t == tag).map(|(_, s)| s)
358    }
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn test_tagged_enum_schema() {
367        let schema =
368            TaggedEnumSchema::new("type", &["AddDerive", "RemoveDerive"], |tag| match tag {
369                "AddDerive" => Some(&["target", "derives"]),
370                "RemoveDerive" => Some(&["target", "derives"]),
371                _ => None,
372            });
373
374        assert!(schema.is_valid_tag("AddDerive"));
375        assert!(!schema.is_valid_tag("InvalidType"));
376        let fields: Vec<&str> = schema
377            .variant_schema("AddDerive")
378            .unwrap()
379            .field_names()
380            .collect();
381        assert_eq!(fields, vec!["target", "derives"]);
382    }
383
384    #[test]
385    fn test_object_schema() {
386        let schema = ObjectSchema::new(["name", "value", "is_pub"]);
387
388        assert!(schema.is_valid_field("name"));
389        assert!(!schema.is_valid_field("invalid"));
390    }
391
392    #[test]
393    fn test_dynamic_schema_from_owned_strings() {
394        // Schema built entirely from runtime (non-'static) data
395        let tag_field = String::from("kind");
396        let tags = vec![String::from("Create"), String::from("Delete")];
397        let fields = vec![String::from("name"), String::from("path")];
398
399        let mut schema = TaggedEnumSchema::with_tag(&tag_field);
400        for tag in &tags {
401            schema = schema.with_variant(tag, ObjectSchema::new(&fields));
402        }
403
404        assert!(schema.is_valid_tag("Create"));
405        assert!(schema.variant_schema("Delete").unwrap().is_valid_field("path"));
406    }
407
408    #[test]
409    fn test_with_field_kind_replaces_existing() {
410        let schema = ObjectSchema::new(["timeout"])
411            .with_field_kind("timeout", FieldKind::Integer);
412
413        assert_eq!(schema.fields.len(), 1);
414        assert_eq!(schema.kind_of("timeout"), Some(&FieldKind::Integer));
415    }
416
417    #[test]
418    fn test_recursive_schema_shape() {
419        let schema = ObjectSchema::empty().with_field_kind(
420            "outer",
421            FieldKind::Object(
422                ObjectSchema::empty()
423                    .with_field_kind("inner", FieldKind::Object(ObjectSchema::new(["leaf"]))),
424            ),
425        );
426
427        let FieldKind::Object(outer) = schema.kind_of("outer").unwrap() else {
428            panic!("expected object kind");
429        };
430        let FieldKind::Object(inner) = outer.kind_of("inner").unwrap() else {
431            panic!("expected object kind");
432        };
433        assert!(inner.is_valid_field("leaf"));
434    }
435}