Skip to main content

rigg_core/
schema.rs

1//! Pinned-version schema fixtures (property names per OpenAPI definition):
2//! the registry is checked against them, and pull/adopt report fields Azure
3//! returns that the pinned version does not know (an API-drift canary).
4
5use std::collections::{BTreeMap, BTreeSet};
6
7use serde_json::Value;
8
9use crate::{registry, resources::ResourceKind};
10
11pub struct SchemaFixture {
12    pub provider: &'static str,
13    pub version: &'static str,
14    definitions: BTreeMap<String, BTreeSet<String>>,
15}
16
17impl SchemaFixture {
18    fn parse(provider: &'static str, version: &'static str, text: &str) -> Self {
19        let v: Value = serde_json::from_str(text).expect("fixture is valid JSON");
20        assert_eq!(
21            v["version"].as_str(),
22            Some(version),
23            "schema fixture for {provider} is stale: regenerate with `rigg dev api-fixture`"
24        );
25        let definitions = v["definitions"]
26            .as_object()
27            .expect("definitions")
28            .iter()
29            .map(|(k, arr)| {
30                (
31                    k.clone(),
32                    arr.as_array()
33                        .unwrap()
34                        .iter()
35                        .filter_map(Value::as_str)
36                        .map(str::to_string)
37                        .collect(),
38                )
39            })
40            .collect();
41        Self {
42            provider,
43            version,
44            definitions,
45        }
46    }
47
48    pub fn definition(&self, name: &str) -> Option<&BTreeSet<String>> {
49        self.definitions.get(name)
50    }
51}
52
53static SEARCH_STABLE: std::sync::OnceLock<SchemaFixture> = std::sync::OnceLock::new();
54static SEARCH_PREVIEW: std::sync::OnceLock<SchemaFixture> = std::sync::OnceLock::new();
55static COGNITIVE_ARM: std::sync::OnceLock<SchemaFixture> = std::sync::OnceLock::new();
56
57pub fn fixture_for(kind: ResourceKind) -> &'static SchemaFixture {
58    match registry::meta(kind).domain {
59        registry::Domain::Search => match registry::meta(kind).channel {
60            registry::Channel::Stable => SEARCH_STABLE.get_or_init(|| {
61                SchemaFixture::parse(
62                    "search-data",
63                    registry::SEARCH_STABLE_API_VERSION,
64                    include_str!("../fixtures/schema/search-data-2026-04-01.json"),
65                )
66            }),
67            registry::Channel::Preview => SEARCH_PREVIEW.get_or_init(|| {
68                SchemaFixture::parse(
69                    "search-data",
70                    registry::SEARCH_PREVIEW_API_VERSION,
71                    include_str!("../fixtures/schema/search-data-2026-08-01-preview.json"),
72                )
73            }),
74        },
75        _ => COGNITIVE_ARM.get_or_init(|| {
76            SchemaFixture::parse(
77                "cognitiveservices-arm",
78                registry::ARM_COGNITIVE_API_VERSION,
79                include_str!("../fixtures/schema/cognitiveservices-arm-2026-05-01.json"),
80            )
81        }),
82    }
83}
84
85/// Top-level keys of `doc` that the pinned schema does not declare for
86/// `kind`. Empty for kinds without a fixture definition (agents).
87///
88/// This canary is Search-only for now: the `schema_definition` values kept
89/// for the Foundry ARM kinds (`Deployment`, `Connection`, `Guardrail`) name
90/// sub-objects of the ARM resource envelope (e.g. `Deployment`'s OpenAPI
91/// definition describes the resource body under `properties`, not the
92/// envelope itself), and one-level `allOf` resolution never reaches the
93/// shared `common-types` definitions that actually contribute `id`, `name`,
94/// `type`, and `systemData` (plus `properties` for connections). Comparing
95/// those envelopes against the sub-object fixture would always flag them as
96/// unknown, so this function is a no-op outside `Domain::Search` — the
97/// `schema_definition` values for the Foundry kinds are kept only because
98/// `api-diff` still uses them.
99pub fn unknown_top_level_fields(kind: ResourceKind, doc: &Value) -> Vec<String> {
100    if registry::meta(kind).domain != registry::Domain::Search {
101        return Vec::new();
102    }
103    let name = registry::meta(kind).schema_definition;
104    if name.is_empty() {
105        return Vec::new();
106    }
107    let Some(props) = fixture_for(kind).definition(name) else {
108        return Vec::new();
109    };
110    doc.as_object()
111        .map(|m| {
112            m.keys()
113                .filter(|k| {
114                    !k.starts_with("x-rigg-") && !k.starts_with("@odata") && !props.contains(*k)
115                })
116                .cloned()
117                .collect()
118        })
119        .unwrap_or_default()
120}
121
122/// `definitions` → property names, resolving `allOf` `$ref`s one level.
123///
124/// A definition with an OpenAPI `discriminator` (a polymorphic base, e.g.
125/// Search's `KnowledgeSource`) also picks up the properties every subtype
126/// adds via `allOf: [{$ref: <base>}, {properties: {...}}]` — e.g.
127/// `azureBlobParameters`, `searchIndexParameters` — since rigg treats the
128/// resource as one document shape across all `kind` variants rather than as
129/// separate per-kind types.
130pub fn extract_fixture(openapi: &Value) -> BTreeMap<String, BTreeSet<String>> {
131    let defs = openapi["definitions"]
132        .as_object()
133        .cloned()
134        .unwrap_or_default();
135    let props_of = |d: &Value| -> BTreeSet<String> {
136        d["properties"]
137            .as_object()
138            .map(|m| m.keys().cloned().collect())
139            .unwrap_or_default()
140    };
141    let mut out: BTreeMap<String, BTreeSet<String>> = defs
142        .iter()
143        .map(|(name, d)| {
144            let mut set = props_of(d);
145            if let Some(all) = d["allOf"].as_array() {
146                for part in all {
147                    if let Some(r) = part["$ref"]
148                        .as_str()
149                        .and_then(|r| r.strip_prefix("#/definitions/"))
150                        && let Some(base) = defs.get(r)
151                    {
152                        set.extend(props_of(base));
153                    }
154                    set.extend(props_of(part));
155                }
156            }
157            (name.clone(), set)
158        })
159        .collect();
160    for (name, d) in &defs {
161        if d["discriminator"].as_str().is_none() {
162            continue;
163        }
164        let mut extra = BTreeSet::new();
165        for other in defs.values() {
166            let Some(all) = other["allOf"].as_array() else {
167                continue;
168            };
169            let extends_this = all.iter().any(|part| {
170                part["$ref"]
171                    .as_str()
172                    .and_then(|r| r.strip_prefix("#/definitions/"))
173                    == Some(name.as_str())
174            });
175            if extends_this {
176                // The subtype's own additions can live inside the `allOf`
177                // array (`{$ref}, {properties: {...}}`) or as sibling keys
178                // of `allOf` on the subtype definition itself
179                // (`{properties: {...}, allOf: [{$ref}]}`) — cover both.
180                extra.extend(props_of(other));
181                for part in all {
182                    extra.extend(props_of(part));
183                }
184            }
185        }
186        out.entry(name.clone()).or_default().extend(extra);
187    }
188    out
189}
190
191pub struct DefinitionDiff {
192    pub definition: String,
193    pub added: Vec<String>,
194    pub removed: Vec<String>,
195    pub enum_added: Vec<(String, String)>,
196    pub enum_removed: Vec<(String, String)>,
197    pub missing_in: Option<&'static str>,
198}
199
200pub fn diff_definitions(old: &Value, new: &Value, names: &[&str]) -> Vec<DefinitionDiff> {
201    let (fo, fn_) = (extract_fixture(old), extract_fixture(new));
202    let enums = |doc: &Value, def: &str| -> BTreeMap<String, BTreeSet<String>> {
203        doc["definitions"][def]["properties"]
204            .as_object()
205            .map(|m| {
206                m.iter()
207                    .filter_map(|(k, v)| {
208                        v["enum"].as_array().map(|e| {
209                            (
210                                k.clone(),
211                                e.iter()
212                                    .filter_map(Value::as_str)
213                                    .map(str::to_string)
214                                    .collect(),
215                            )
216                        })
217                    })
218                    .collect()
219            })
220            .unwrap_or_default()
221    };
222    names
223        .iter()
224        .map(|n| {
225            let (a, b) = (fo.get(*n), fn_.get(*n));
226            let missing_in = match (a, b) {
227                (None, _) => Some("old"),
228                (_, None) => Some("new"),
229                _ => None,
230            };
231            let (a, b) = (
232                a.cloned().unwrap_or_default(),
233                b.cloned().unwrap_or_default(),
234            );
235            let (eo, en) = (enums(old, n), enums(new, n));
236            let mut enum_added = Vec::new();
237            let mut enum_removed = Vec::new();
238            for (k, vals) in &en {
239                for v in vals {
240                    if !eo.get(k).is_some_and(|s| s.contains(v)) {
241                        enum_added.push((k.clone(), v.clone()));
242                    }
243                }
244            }
245            for (k, vals) in &eo {
246                for v in vals {
247                    if !en.get(k).is_some_and(|s| s.contains(v)) {
248                        enum_removed.push((k.clone(), v.clone()));
249                    }
250                }
251            }
252            DefinitionDiff {
253                definition: n.to_string(),
254                added: b.difference(&a).cloned().collect(),
255                removed: a.difference(&b).cloned().collect(),
256                enum_added,
257                enum_removed,
258                missing_in,
259            }
260        })
261        .collect()
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267    use serde_json::json;
268
269    #[test]
270    fn extract_resolves_allof_one_level() {
271        let doc = json!({"definitions": {
272            "Base": {"properties": {"name": {}, "description": {}}},
273            "Child": {"allOf": [{"$ref": "#/definitions/Base"}], "properties": {"extra": {}}}
274        }});
275        let f = extract_fixture(&doc);
276        assert_eq!(
277            f["Child"].iter().cloned().collect::<Vec<_>>(),
278            vec!["description", "extra", "name"]
279        );
280    }
281
282    #[test]
283    fn unknown_fields_reports_only_keys_missing_from_the_fixture() {
284        let doc =
285            json!({"name": "kb", "knowledgeSources": [], "retrievalMode": "x", "@odata.etag": "e"});
286        let unknown = unknown_top_level_fields(ResourceKind::KnowledgeBase, &doc);
287        assert_eq!(unknown, vec!["retrievalMode"]);
288    }
289
290    #[test]
291    fn unknown_fields_is_search_only_and_never_flags_foundry_arm_envelope_fields() {
292        for kind in [
293            ResourceKind::Deployment,
294            ResourceKind::Connection,
295            ResourceKind::Guardrail,
296        ] {
297            let doc = crate::scaffold::scaffold(kind, "d", None).unwrap();
298            assert_eq!(
299                unknown_top_level_fields(kind, &doc),
300                Vec::<String>::new(),
301                "{kind:?}: canary must be a no-op outside Search"
302            );
303        }
304    }
305
306    #[test]
307    fn cognitive_arm_fixture_is_pinned_to_the_registry_api_version() {
308        assert_eq!(
309            fixture_for(ResourceKind::Deployment).version,
310            registry::ARM_COGNITIVE_API_VERSION
311        );
312    }
313
314    #[test]
315    fn diff_definitions_lists_added_removed_and_enum_changes() {
316        let old = json!({"definitions": {"A": {"properties": {"x": {}, "y": {"type": "string", "enum": ["p"]}}}}});
317        let new = json!({"definitions": {"A": {"properties": {"x": {}, "z": {}, "y": {"type": "string", "enum": ["p", "q"]}}}}});
318        let d = &diff_definitions(&old, &new, &["A"])[0];
319        assert_eq!(d.added, vec!["z"]);
320        assert!(d.removed.is_empty());
321        assert_eq!(d.enum_added, vec![("y".to_string(), "q".to_string())]);
322    }
323
324    /// One-off: regenerates the pinned schema fixtures from OpenAPI documents
325    /// on disk. Not run in CI — `RIGG_OPENAPI_DIR=<dir> cargo test -p
326    /// rigg-core regenerate_fixtures -- --ignored`.
327    #[test]
328    #[ignore]
329    fn regenerate_fixtures() {
330        let dir = std::env::var("RIGG_OPENAPI_DIR").expect("set RIGG_OPENAPI_DIR");
331        let dir = std::path::Path::new(&dir);
332        let out_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/schema");
333        std::fs::create_dir_all(&out_dir).unwrap();
334        let sources: &[(&str, &str, &str, &str)] = &[
335            (
336                "search-2026-04-01.json",
337                "search-data",
338                registry::SEARCH_STABLE_API_VERSION,
339                "search-data-2026-04-01.json",
340            ),
341            (
342                "search-2026-08-01-preview.json",
343                "search-data",
344                registry::SEARCH_PREVIEW_API_VERSION,
345                "search-data-2026-08-01-preview.json",
346            ),
347            (
348                "cs-2026-05-01.json",
349                "cognitiveservices-arm",
350                registry::ARM_COGNITIVE_API_VERSION,
351                "cognitiveservices-arm-2026-05-01.json",
352            ),
353        ];
354        for (input, slug, version, output) in sources {
355            let text = std::fs::read_to_string(dir.join(input))
356                .unwrap_or_else(|e| panic!("reading {input}: {e}"));
357            let doc: Value = serde_json::from_str(&text).unwrap();
358            let defs = extract_fixture(&doc);
359            let value =
360                serde_json::json!({ "provider": slug, "version": version, "definitions": defs });
361            let out_file = out_dir.join(output);
362            std::fs::write(&out_file, serde_json::to_string_pretty(&value).unwrap()).unwrap();
363            println!("wrote {}", out_file.display());
364        }
365    }
366}