use std::collections::{BTreeMap, BTreeSet};
use serde_json::Value;
use crate::{registry, resources::ResourceKind};
pub struct SchemaFixture {
pub provider: &'static str,
pub version: &'static str,
definitions: BTreeMap<String, BTreeSet<String>>,
}
impl SchemaFixture {
fn parse(provider: &'static str, version: &'static str, text: &str) -> Self {
let v: Value = serde_json::from_str(text).expect("fixture is valid JSON");
assert_eq!(
v["version"].as_str(),
Some(version),
"schema fixture for {provider} is stale: regenerate with `rigg dev api-fixture`"
);
let definitions = v["definitions"]
.as_object()
.expect("definitions")
.iter()
.map(|(k, arr)| {
(
k.clone(),
arr.as_array()
.unwrap()
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
)
})
.collect();
Self {
provider,
version,
definitions,
}
}
pub fn definition(&self, name: &str) -> Option<&BTreeSet<String>> {
self.definitions.get(name)
}
}
static SEARCH_STABLE: std::sync::OnceLock<SchemaFixture> = std::sync::OnceLock::new();
static SEARCH_PREVIEW: std::sync::OnceLock<SchemaFixture> = std::sync::OnceLock::new();
static COGNITIVE_ARM: std::sync::OnceLock<SchemaFixture> = std::sync::OnceLock::new();
pub fn fixture_for(kind: ResourceKind) -> &'static SchemaFixture {
match registry::meta(kind).domain {
registry::Domain::Search => match registry::meta(kind).channel {
registry::Channel::Stable => SEARCH_STABLE.get_or_init(|| {
SchemaFixture::parse(
"search-data",
registry::SEARCH_STABLE_API_VERSION,
include_str!("../fixtures/schema/search-data-2026-04-01.json"),
)
}),
registry::Channel::Preview => SEARCH_PREVIEW.get_or_init(|| {
SchemaFixture::parse(
"search-data",
registry::SEARCH_PREVIEW_API_VERSION,
include_str!("../fixtures/schema/search-data-2026-08-01-preview.json"),
)
}),
},
_ => COGNITIVE_ARM.get_or_init(|| {
SchemaFixture::parse(
"cognitiveservices-arm",
registry::ARM_COGNITIVE_API_VERSION,
include_str!("../fixtures/schema/cognitiveservices-arm-2026-05-01.json"),
)
}),
}
}
pub fn unknown_top_level_fields(kind: ResourceKind, doc: &Value) -> Vec<String> {
if registry::meta(kind).domain != registry::Domain::Search {
return Vec::new();
}
let name = registry::meta(kind).schema_definition;
if name.is_empty() {
return Vec::new();
}
let Some(props) = fixture_for(kind).definition(name) else {
return Vec::new();
};
doc.as_object()
.map(|m| {
m.keys()
.filter(|k| {
!k.starts_with("x-rigg-") && !k.starts_with("@odata") && !props.contains(*k)
})
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn extract_fixture(openapi: &Value) -> BTreeMap<String, BTreeSet<String>> {
let defs = openapi["definitions"]
.as_object()
.cloned()
.unwrap_or_default();
let props_of = |d: &Value| -> BTreeSet<String> {
d["properties"]
.as_object()
.map(|m| m.keys().cloned().collect())
.unwrap_or_default()
};
let mut out: BTreeMap<String, BTreeSet<String>> = defs
.iter()
.map(|(name, d)| {
let mut set = props_of(d);
if let Some(all) = d["allOf"].as_array() {
for part in all {
if let Some(r) = part["$ref"]
.as_str()
.and_then(|r| r.strip_prefix("#/definitions/"))
&& let Some(base) = defs.get(r)
{
set.extend(props_of(base));
}
set.extend(props_of(part));
}
}
(name.clone(), set)
})
.collect();
for (name, d) in &defs {
if d["discriminator"].as_str().is_none() {
continue;
}
let mut extra = BTreeSet::new();
for other in defs.values() {
let Some(all) = other["allOf"].as_array() else {
continue;
};
let extends_this = all.iter().any(|part| {
part["$ref"]
.as_str()
.and_then(|r| r.strip_prefix("#/definitions/"))
== Some(name.as_str())
});
if extends_this {
extra.extend(props_of(other));
for part in all {
extra.extend(props_of(part));
}
}
}
out.entry(name.clone()).or_default().extend(extra);
}
out
}
pub struct DefinitionDiff {
pub definition: String,
pub added: Vec<String>,
pub removed: Vec<String>,
pub enum_added: Vec<(String, String)>,
pub enum_removed: Vec<(String, String)>,
pub missing_in: Option<&'static str>,
}
pub fn diff_definitions(old: &Value, new: &Value, names: &[&str]) -> Vec<DefinitionDiff> {
let (fo, fn_) = (extract_fixture(old), extract_fixture(new));
let enums = |doc: &Value, def: &str| -> BTreeMap<String, BTreeSet<String>> {
doc["definitions"][def]["properties"]
.as_object()
.map(|m| {
m.iter()
.filter_map(|(k, v)| {
v["enum"].as_array().map(|e| {
(
k.clone(),
e.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
)
})
})
.collect()
})
.unwrap_or_default()
};
names
.iter()
.map(|n| {
let (a, b) = (fo.get(*n), fn_.get(*n));
let missing_in = match (a, b) {
(None, _) => Some("old"),
(_, None) => Some("new"),
_ => None,
};
let (a, b) = (
a.cloned().unwrap_or_default(),
b.cloned().unwrap_or_default(),
);
let (eo, en) = (enums(old, n), enums(new, n));
let mut enum_added = Vec::new();
let mut enum_removed = Vec::new();
for (k, vals) in &en {
for v in vals {
if !eo.get(k).is_some_and(|s| s.contains(v)) {
enum_added.push((k.clone(), v.clone()));
}
}
}
for (k, vals) in &eo {
for v in vals {
if !en.get(k).is_some_and(|s| s.contains(v)) {
enum_removed.push((k.clone(), v.clone()));
}
}
}
DefinitionDiff {
definition: n.to_string(),
added: b.difference(&a).cloned().collect(),
removed: a.difference(&b).cloned().collect(),
enum_added,
enum_removed,
missing_in,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn extract_resolves_allof_one_level() {
let doc = json!({"definitions": {
"Base": {"properties": {"name": {}, "description": {}}},
"Child": {"allOf": [{"$ref": "#/definitions/Base"}], "properties": {"extra": {}}}
}});
let f = extract_fixture(&doc);
assert_eq!(
f["Child"].iter().cloned().collect::<Vec<_>>(),
vec!["description", "extra", "name"]
);
}
#[test]
fn unknown_fields_reports_only_keys_missing_from_the_fixture() {
let doc =
json!({"name": "kb", "knowledgeSources": [], "retrievalMode": "x", "@odata.etag": "e"});
let unknown = unknown_top_level_fields(ResourceKind::KnowledgeBase, &doc);
assert_eq!(unknown, vec!["retrievalMode"]);
}
#[test]
fn unknown_fields_is_search_only_and_never_flags_foundry_arm_envelope_fields() {
for kind in [
ResourceKind::Deployment,
ResourceKind::Connection,
ResourceKind::Guardrail,
] {
let doc = crate::scaffold::scaffold(kind, "d", None).unwrap();
assert_eq!(
unknown_top_level_fields(kind, &doc),
Vec::<String>::new(),
"{kind:?}: canary must be a no-op outside Search"
);
}
}
#[test]
fn cognitive_arm_fixture_is_pinned_to_the_registry_api_version() {
assert_eq!(
fixture_for(ResourceKind::Deployment).version,
registry::ARM_COGNITIVE_API_VERSION
);
}
#[test]
fn diff_definitions_lists_added_removed_and_enum_changes() {
let old = json!({"definitions": {"A": {"properties": {"x": {}, "y": {"type": "string", "enum": ["p"]}}}}});
let new = json!({"definitions": {"A": {"properties": {"x": {}, "z": {}, "y": {"type": "string", "enum": ["p", "q"]}}}}});
let d = &diff_definitions(&old, &new, &["A"])[0];
assert_eq!(d.added, vec!["z"]);
assert!(d.removed.is_empty());
assert_eq!(d.enum_added, vec![("y".to_string(), "q".to_string())]);
}
#[test]
#[ignore]
fn regenerate_fixtures() {
let dir = std::env::var("RIGG_OPENAPI_DIR").expect("set RIGG_OPENAPI_DIR");
let dir = std::path::Path::new(&dir);
let out_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures/schema");
std::fs::create_dir_all(&out_dir).unwrap();
let sources: &[(&str, &str, &str, &str)] = &[
(
"search-2026-04-01.json",
"search-data",
registry::SEARCH_STABLE_API_VERSION,
"search-data-2026-04-01.json",
),
(
"search-2026-08-01-preview.json",
"search-data",
registry::SEARCH_PREVIEW_API_VERSION,
"search-data-2026-08-01-preview.json",
),
(
"cs-2026-05-01.json",
"cognitiveservices-arm",
registry::ARM_COGNITIVE_API_VERSION,
"cognitiveservices-arm-2026-05-01.json",
),
];
for (input, slug, version, output) in sources {
let text = std::fs::read_to_string(dir.join(input))
.unwrap_or_else(|e| panic!("reading {input}: {e}"));
let doc: Value = serde_json::from_str(&text).unwrap();
let defs = extract_fixture(&doc);
let value =
serde_json::json!({ "provider": slug, "version": version, "definitions": defs });
let out_file = out_dir.join(output);
std::fs::write(&out_file, serde_json::to_string_pretty(&value).unwrap()).unwrap();
println!("wrote {}", out_file.display());
}
}
}