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}