Skip to main content

meerkat_core/
schema.rs

1//! Meerkat-native schema abstraction and normalization.
2//!
3//! Provider-specific lowering lives in the adapter crates
4//! (`meerkat-client/src/anthropic.rs`, `meerkat-client/src/gemini.rs`).
5
6use crate::Provider;
7use serde::{Deserialize, Serialize};
8use serde_json::{Map, Value};
9
10/// Schema format versions supported by Meerkat.
11#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
12#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum SchemaFormat {
15    #[default]
16    MeerkatV1,
17}
18
19/// Compatibility mode for provider lowering.
20#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum SchemaCompat {
24    #[default]
25    Lossy,
26    Strict,
27}
28
29/// Warnings emitted during schema lowering.
30#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub struct SchemaWarning {
34    pub provider: Provider,
35    pub path: String,
36    pub message: String,
37}
38
39/// Errors for schema parsing/compilation.
40#[derive(Debug, thiserror::Error)]
41pub enum SchemaError {
42    #[error("Schema must be a JSON object at the root")]
43    InvalidRoot,
44    #[error("Schema contains unsupported features for {provider:?}: {warnings:?}")]
45    UnsupportedFeatures {
46        provider: Provider,
47        warnings: Vec<SchemaWarning>,
48    },
49}
50
51/// A Meerkat-native JSON schema.
52#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct MeerkatSchema(Value);
56
57impl MeerkatSchema {
58    /// Create a new Meerkat schema with normalization.
59    pub fn new(schema: Value) -> Result<Self, SchemaError> {
60        if !schema.is_object() {
61            return Err(SchemaError::InvalidRoot);
62        }
63        let mut normalized = schema;
64        normalize_schema(&mut normalized);
65        Ok(Self(normalized))
66    }
67
68    /// Access the underlying JSON value.
69    pub fn as_value(&self) -> &Value {
70        &self.0
71    }
72}
73
74/// Provider-compiled schema and warnings.
75#[derive(Debug, Clone)]
76pub struct CompiledSchema {
77    pub schema: Value,
78    pub warnings: Vec<SchemaWarning>,
79}
80
81fn normalize_schema(value: &mut Value) {
82    match value {
83        Value::Object(obj) => {
84            let is_object_type = match obj.get("type") {
85                Some(Value::String(t)) => t == "object",
86                Some(Value::Array(types)) => types.iter().any(|t| t.as_str() == Some("object")),
87                _ => obj.contains_key("properties") || obj.contains_key("required"),
88            };
89
90            if is_object_type {
91                obj.entry("properties".to_string())
92                    .or_insert_with(|| Value::Object(Map::new()));
93                obj.entry("required".to_string())
94                    .or_insert_with(|| Value::Array(Vec::new()));
95            }
96
97            for value in obj.values_mut() {
98                normalize_schema(value);
99            }
100        }
101        Value::Array(items) => {
102            for item in items.iter_mut() {
103                normalize_schema(item);
104            }
105        }
106        _ => {}
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::MeerkatSchema;
113    use serde_json::json;
114
115    #[test]
116    fn test_normalize_adds_properties_and_required() -> Result<(), Box<dyn std::error::Error>> {
117        let schema = json!({"type": "object"});
118        let schema = MeerkatSchema::new(schema)?;
119        assert!(schema.as_value().get("properties").is_some());
120        assert!(schema.as_value().get("required").is_some());
121        Ok(())
122    }
123
124    #[test]
125    fn test_invalid_root_rejected() {
126        assert!(MeerkatSchema::new(json!("string")).is_err());
127        assert!(MeerkatSchema::new(json!(42)).is_err());
128    }
129
130    #[test]
131    fn test_normalize_recurses_nested_objects() -> Result<(), Box<dyn std::error::Error>> {
132        let schema = json!({
133            "type": "object",
134            "properties": {
135                "profile": {
136                    "type": "object",
137                    "properties": {
138                        "city": {"type": "string"}
139                    }
140                },
141                "items": {
142                    "type": "array",
143                    "items": {
144                        "type": "object"
145                    }
146                },
147                "variant": {
148                    "anyOf": [
149                        {"type": "object"},
150                        {"type": "string"}
151                    ]
152                }
153            }
154        });
155
156        let schema = MeerkatSchema::new(schema)?;
157        let root = schema.as_value();
158
159        assert!(root.get("required").is_some());
160        assert!(root["properties"]["profile"].get("required").is_some());
161        assert!(
162            root["properties"]["items"]["items"]
163                .get("properties")
164                .is_some()
165        );
166        assert!(
167            root["properties"]["variant"]["anyOf"][0]
168                .get("required")
169                .is_some()
170        );
171        Ok(())
172    }
173
174    #[test]
175    fn test_normalize_preserves_existing_object_shape() -> Result<(), Box<dyn std::error::Error>> {
176        let schema = json!({
177            "type": "object",
178            "properties": {
179                "name": {"type": "string"}
180            },
181            "required": ["name"]
182        });
183
184        let schema = MeerkatSchema::new(schema)?;
185        let root = schema.as_value();
186
187        assert_eq!(root["required"], json!(["name"]));
188        assert_eq!(root["properties"]["name"]["type"], "string");
189        Ok(())
190    }
191}