schema_coerce/lib.rs
1//! # schema-coerce
2//!
3//! Coerce an LLM-emitted JSON value to a small field-schema.
4//!
5//! LLMs return `"42"` instead of `42`, `"true"` instead of `true`, or
6//! wrap the answer in `{ "result": {...} }`. This crate gives you a
7//! lossy `coerce(value, &schema)` that applies the obvious fixes:
8//!
9//! - String containing an integer → integer
10//! - String containing a float → float
11//! - String `"true"` / `"false"` / `"yes"` / `"no"` → bool
12//! - Object containing a single key whose value matches the schema →
13//! unwrap (handles wrappers like `{ "result": {...} }`)
14//! - Missing field → fill from default
15//!
16//! ## Example
17//!
18//! ```
19//! use schema_coerce::{coerce, Field, Type};
20//! use serde_json::json;
21//!
22//! let schema = vec![
23//! Field { name: "count", ty: Type::Int, default: None },
24//! Field { name: "ok", ty: Type::Bool, default: Some(json!(false)) },
25//! ];
26//! let raw = json!({ "count": "42", "ok": "yes" });
27//! let fixed = coerce(raw, &schema);
28//! assert_eq!(fixed["count"], json!(42));
29//! assert_eq!(fixed["ok"], json!(true));
30//! ```
31
32#![deny(missing_docs)]
33
34use serde_json::{json, Value};
35
36/// Expected JSON type for a field.
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Type {
39 /// Signed 64-bit integer.
40 Int,
41 /// 64-bit float.
42 Float,
43 /// Boolean.
44 Bool,
45 /// UTF-8 string.
46 String,
47 /// JSON array (no element-type enforcement).
48 Array,
49 /// JSON object (no shape enforcement).
50 Object,
51}
52
53/// One field in the schema.
54#[derive(Debug, Clone)]
55pub struct Field {
56 /// Field name in the output object.
57 pub name: &'static str,
58 /// Expected type.
59 pub ty: Type,
60 /// Optional default to fill in if the field is missing or
61 /// uncoercible.
62 pub default: Option<Value>,
63}
64
65/// Coerce `v` to an object matching `schema`.
66///
67/// If `v` is wrapped in a single-key object whose value is also an
68/// object, the wrapper is stripped first.
69pub fn coerce(mut v: Value, schema: &[Field]) -> Value {
70 v = unwrap_single_wrapper(v);
71
72 let mut out = serde_json::Map::new();
73 let src = v.as_object();
74
75 for f in schema {
76 let raw = src.and_then(|m| m.get(f.name));
77 let coerced = raw
78 .and_then(|x| coerce_value(x, f.ty))
79 .or_else(|| f.default.clone())
80 .unwrap_or(Value::Null);
81 out.insert(f.name.to_string(), coerced);
82 }
83
84 Value::Object(out)
85}
86
87/// Strip `{ "result": {...} }` style wrappers.
88fn unwrap_single_wrapper(v: Value) -> Value {
89 if let Value::Object(map) = &v {
90 if map.len() == 1 {
91 let only = map.values().next().unwrap();
92 if only.is_object() {
93 return only.clone();
94 }
95 }
96 }
97 v
98}
99
100fn coerce_value(v: &Value, ty: Type) -> Option<Value> {
101 match ty {
102 Type::Int => match v {
103 Value::Number(n) => n.as_i64().map(|i| json!(i)),
104 Value::String(s) => s.trim().parse::<i64>().ok().map(|i| json!(i)),
105 Value::Bool(b) => Some(json!(if *b { 1 } else { 0 })),
106 _ => None,
107 },
108 Type::Float => match v {
109 Value::Number(n) => n.as_f64().map(|f| json!(f)),
110 Value::String(s) => s.trim().parse::<f64>().ok().map(|f| json!(f)),
111 _ => None,
112 },
113 Type::Bool => match v {
114 Value::Bool(b) => Some(json!(*b)),
115 Value::String(s) => match s.trim().to_ascii_lowercase().as_str() {
116 "true" | "yes" | "y" | "1" | "on" => Some(json!(true)),
117 "false" | "no" | "n" | "0" | "off" => Some(json!(false)),
118 _ => None,
119 },
120 Value::Number(n) => n.as_i64().map(|i| json!(i != 0)),
121 _ => None,
122 },
123 Type::String => Some(match v {
124 Value::String(s) => json!(s),
125 _ => json!(v.to_string()),
126 }),
127 Type::Array => match v {
128 Value::Array(_) => Some(v.clone()),
129 _ => None,
130 },
131 Type::Object => match v {
132 Value::Object(_) => Some(v.clone()),
133 _ => None,
134 },
135 }
136}