1use 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
85pub 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
122pub 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 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 #[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}