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