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}