use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::io::Read;
use anyhow::{anyhow, Context, Result};
use serde::Serialize;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use super::{local_schema_map, SCHEMA_VERSION, UPSTREAM_TARBALL_SHA256};
const NPM_LATEST_URL: &str = "https://registry.npmjs.org/@atlaskit/adf-schema/latest";
pub const NPM_LATEST_URL_ENV: &str = "OMNI_DEV_ADF_SCHEMA_LATEST_URL";
#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
pub struct ParentDrift {
pub added_children: BTreeSet<String>,
pub removed_children: BTreeSet<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
pub struct DriftReport {
pub upstream_version: String,
pub upstream_tarball_sha256: String,
pub local_version: String,
pub local_tarball_sha256: String,
pub version_changed: bool,
pub per_parent: BTreeMap<String, ParentDrift>,
pub added_parents: BTreeSet<String>,
pub removed_parents: BTreeSet<String>,
}
impl DriftReport {
#[must_use]
pub fn has_content_drift(&self) -> bool {
!self.per_parent.is_empty()
|| !self.added_parents.is_empty()
|| !self.removed_parents.is_empty()
}
#[must_use]
pub fn has_any_drift(&self) -> bool {
self.version_changed || self.has_content_drift()
}
#[must_use]
pub fn render_markdown(&self) -> String {
let mut out = String::new();
out.push_str("# ADF schema drift report\n\n");
out.push_str("## Version\n\n");
out.push_str(&format!(
"- Upstream `@atlaskit/adf-schema`: `{}`\n",
self.upstream_version
));
out.push_str(&format!(
"- Upstream tarball SHA-256: `{}`\n",
self.upstream_tarball_sha256
));
out.push_str(&format!(
"- Local `SCHEMA_VERSION`: `{}`\n",
self.local_version
));
out.push_str(&format!(
"- Local `UPSTREAM_TARBALL_SHA256`: `{}`\n",
self.local_tarball_sha256
));
out.push_str(&format!(
"- Version changed: **{}**\n\n",
self.version_changed
));
out.push_str("## Content-model drift\n\n");
if !self.has_content_drift() {
out.push_str("No content-model changes — version bump only.\n\n");
} else {
if !self.added_parents.is_empty() {
out.push_str("### New parents (upstream only)\n\n");
for p in &self.added_parents {
out.push_str(&format!("- `{p}`\n"));
}
out.push('\n');
}
if !self.removed_parents.is_empty() {
out.push_str("### Removed parents (local only)\n\n");
for p in &self.removed_parents {
out.push_str(&format!("- `{p}`\n"));
}
out.push('\n');
}
if !self.per_parent.is_empty() {
out.push_str("### Per-parent diffs\n\n");
for (parent, drift) in &self.per_parent {
out.push_str(&format!("#### `{parent}`\n\n"));
if !drift.added_children.is_empty() {
out.push_str("Added children (upstream only):\n");
for c in &drift.added_children {
out.push_str(&format!("- `{c}`\n"));
}
out.push('\n');
}
if !drift.removed_children.is_empty() {
out.push_str("Removed children (local only):\n");
for c in &drift.removed_children {
out.push_str(&format!("- `{c}`\n"));
}
out.push('\n');
}
}
}
}
out.push_str("---\n");
out.push_str(
"_Generated by the `adf-schema-drift` job. To refresh the snapshot, \
update `CONTENT_ENTRIES`, `SCHEMA_VERSION`, and `UPSTREAM_TARBALL_SHA256` in \
`src/atlassian/adf_schema/mod.rs`._\n",
);
out
}
#[must_use]
pub fn render_json(&self) -> Value {
serde_json::to_value(self).unwrap_or(Value::Null)
}
}
pub async fn fetch_latest_drift_report() -> Result<DriftReport> {
let url = std::env::var(NPM_LATEST_URL_ENV).unwrap_or_else(|_| NPM_LATEST_URL.to_string());
fetch_drift_report_from_url(&url).await
}
async fn fetch_drift_report_from_url(latest_url: &str) -> Result<DriftReport> {
let client = reqwest::Client::builder()
.user_agent(concat!(
"omni-dev-adf-schema-drift/",
env!("CARGO_PKG_VERSION")
))
.build()
.context("building HTTP client")?;
let meta: Value = client
.get(latest_url)
.send()
.await
.context("fetching npm registry latest dist-tag")?
.error_for_status()
.context("npm registry returned a non-2xx status for latest dist-tag")?
.json()
.await
.context("parsing npm latest dist-tag JSON")?;
let upstream_version = meta
.get("version")
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("npm latest dist-tag JSON has no `version` field"))?
.to_string();
let tarball_url = meta
.get("dist")
.and_then(|d| d.get("tarball"))
.and_then(Value::as_str)
.ok_or_else(|| anyhow!("npm latest dist-tag JSON has no `dist.tarball` field"))?
.to_string();
let tarball_bytes = client
.get(&tarball_url)
.send()
.await
.with_context(|| format!("fetching tarball {tarball_url}"))?
.error_for_status()
.with_context(|| format!("npm tarball {tarball_url} returned a non-2xx status"))?
.bytes()
.await
.context("reading tarball bytes")?;
let upstream_sha = hex_encode(&Sha256::digest(&tarball_bytes));
let full_json = extract_full_json_from_tarball(&tarball_bytes)
.context("extracting dist/json-schema/v1/full.json from tarball")?;
diff_against_upstream_json_schema(&full_json, &upstream_version, &upstream_sha)
}
pub fn diff_against_upstream_json_schema(
full: &Value,
upstream_version: &str,
upstream_sha256: &str,
) -> Result<DriftReport> {
let upstream = parse_upstream_full_json(full)?;
let local = local_schema_map();
let local_version_npm = strip_transcription_date(SCHEMA_VERSION);
let version_changed = upstream_version != local_version_npm;
let upstream_parents: BTreeSet<&str> = upstream.keys().map(String::as_str).collect();
let local_parents: BTreeSet<&str> = local.keys().copied().collect();
let added_parents: BTreeSet<String> = upstream_parents
.difference(&local_parents)
.map(|s| (*s).to_string())
.collect();
let removed_parents: BTreeSet<String> = local_parents
.difference(&upstream_parents)
.map(|s| (*s).to_string())
.collect();
let mut per_parent: BTreeMap<String, ParentDrift> = BTreeMap::new();
for parent in upstream_parents.intersection(&local_parents).copied() {
let upstream_children: &BTreeSet<String> = upstream
.get(parent)
.ok_or_else(|| anyhow!("internal: parent `{parent}` missing from upstream map"))?;
let local_children: &BTreeSet<&'static str> = local
.get(parent)
.ok_or_else(|| anyhow!("internal: parent `{parent}` missing from local map"))?;
let added_children: BTreeSet<String> = upstream_children
.iter()
.filter(|c| !local_children.contains(c.as_str()))
.cloned()
.collect();
let removed_children: BTreeSet<String> = local_children
.iter()
.filter(|c| !upstream_children.contains(**c))
.map(|s| (*s).to_string())
.collect();
if !added_children.is_empty() || !removed_children.is_empty() {
per_parent.insert(
parent.to_string(),
ParentDrift {
added_children,
removed_children,
},
);
}
}
Ok(DriftReport {
upstream_version: upstream_version.to_string(),
upstream_tarball_sha256: upstream_sha256.to_string(),
local_version: SCHEMA_VERSION.to_string(),
local_tarball_sha256: UPSTREAM_TARBALL_SHA256.to_string(),
version_changed,
per_parent,
added_parents,
removed_parents,
})
}
pub fn parse_upstream_full_json(full: &Value) -> Result<BTreeMap<String, BTreeSet<String>>> {
let definitions = full
.get("definitions")
.and_then(Value::as_object)
.ok_or_else(|| anyhow!("upstream JSON has no `definitions` object"))?;
let mut def_to_bare: BTreeMap<String, Option<String>> = BTreeMap::new();
for (name, def) in definitions {
def_to_bare.insert(name.clone(), find_bare_type(def));
}
loop {
let mut changed = false;
for (name, def) in definitions {
if def_to_bare
.get(name)
.is_some_and(std::option::Option::is_some)
{
continue;
}
if let Some(inherited) = inherited_bare_type_via_allof(def, &def_to_bare) {
def_to_bare.insert(name.clone(), Some(inherited));
changed = true;
}
}
if !changed {
break;
}
}
let mut result: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for (name, def) in definitions {
let Some(Some(bare)) = def_to_bare.get(name) else {
continue;
};
let children = definition_content_children(name, def, definitions, &def_to_bare);
if !children.is_empty() {
result.entry(bare.clone()).or_default().extend(children);
}
}
Ok(result)
}
fn inherited_bare_type_via_allof(
def: &Value,
def_to_bare: &BTreeMap<String, Option<String>>,
) -> Option<String> {
let Value::Object(obj) = def else { return None };
let Some(Value::Array(arr)) = obj.get("allOf") else {
return None;
};
for item in arr {
let Some(s) = item.get("$ref").and_then(Value::as_str) else {
continue;
};
let Some(target) = s.strip_prefix("#/definitions/") else {
continue;
};
if let Some(Some(bare)) = def_to_bare.get(target) {
return Some(bare.clone());
}
}
None
}
fn find_bare_type(def: &Value) -> Option<String> {
fn walk(v: &Value) -> Option<String> {
let Value::Object(obj) = v else { return None };
if let Some(Value::Object(props)) = obj.get("properties") {
if let Some(Value::Object(t)) = props.get("type") {
if let Some(Value::String(s)) = t.get("const") {
return Some(s.clone());
}
if let Some(Value::Array(arr)) = t.get("enum") {
if let Some(Value::String(s)) = arr.first() {
return Some(s.clone());
}
}
}
}
for key in ["allOf", "anyOf", "oneOf"] {
if let Some(Value::Array(arr)) = obj.get(key) {
for x in arr {
if let Some(s) = walk(x) {
return Some(s);
}
}
}
}
None
}
walk(def)
}
fn definition_content_children(
def_name: &str,
def: &Value,
definitions: &Map<String, Value>,
def_to_bare: &BTreeMap<String, Option<String>>,
) -> BTreeSet<String> {
let mut subtrees: Vec<&Value> = Vec::new();
find_content_subtrees(def, &mut subtrees);
let mut refs = Vec::new();
for subtree in subtrees {
collect_refs(subtree, &mut refs);
}
let mut out = BTreeSet::new();
for r in refs {
let mut visited = HashSet::new();
visited.insert(def_name.to_string());
resolve_ref_to_bare_types(&r, definitions, def_to_bare, &mut visited, &mut out);
}
out
}
fn find_content_subtrees<'a>(value: &'a Value, out: &mut Vec<&'a Value>) {
let Value::Object(obj) = value else { return };
if let Some(Value::Object(props)) = obj.get("properties") {
if let Some(content) = props.get("content") {
out.push(content);
}
}
for key in ["allOf", "oneOf", "anyOf"] {
if let Some(Value::Array(arr)) = obj.get(key) {
for item in arr {
find_content_subtrees(item, out);
}
}
}
}
fn resolve_ref_to_bare_types(
target_def_name: &str,
definitions: &Map<String, Value>,
def_to_bare: &BTreeMap<String, Option<String>>,
visited: &mut HashSet<String>,
out: &mut BTreeSet<String>,
) {
if let Some(Some(bare)) = def_to_bare.get(target_def_name) {
out.insert(bare.clone());
return;
}
if !visited.insert(target_def_name.to_string()) {
return;
}
let Some(target_def) = definitions.get(target_def_name) else {
return;
};
let mut refs = Vec::new();
collect_refs(target_def, &mut refs);
for r in refs {
resolve_ref_to_bare_types(&r, definitions, def_to_bare, visited, out);
}
}
fn collect_refs(node: &Value, out: &mut Vec<String>) {
match node {
Value::Object(obj) => {
if let Some(Value::String(r)) = obj.get("$ref") {
if let Some(name) = r.strip_prefix("#/definitions/") {
out.push(name.to_string());
}
}
for (key, v) in obj {
if key == "marks" || key == "attrs" {
continue;
}
collect_refs(v, out);
}
}
Value::Array(arr) => {
for v in arr {
collect_refs(v, out);
}
}
_ => {}
}
}
fn extract_full_json_from_tarball(bytes: &[u8]) -> Result<Value> {
let gz = flate2::read::GzDecoder::new(bytes);
let mut archive = tar::Archive::new(gz);
for entry in archive.entries().context("opening tarball entries")? {
let mut entry = entry.context("reading tarball entry header")?;
let path_buf = entry
.path()
.context("decoding tarball entry path")?
.into_owned();
if path_buf == std::path::Path::new("package/dist/json-schema/v1/full.json") {
let mut buf = String::new();
entry
.read_to_string(&mut buf)
.context("reading full.json")?;
return serde_json::from_str(&buf).context("parsing full.json");
}
}
Err(anyhow!(
"tarball does not contain package/dist/json-schema/v1/full.json"
))
}
fn strip_transcription_date(s: &str) -> &str {
if s.len() < 11 {
return s;
}
let (head, tail) = s.split_at(s.len() - 11);
let Some(rest) = tail.strip_prefix('-') else {
return s;
};
let parts: Vec<&str> = rest.split('-').collect();
let looks_like_date = parts.len() == 3
&& parts[0].len() == 4
&& parts[1].len() == 2
&& parts[2].len() == 2
&& parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit()));
if looks_like_date {
head
} else {
s
}
}
#[must_use]
pub fn hex_encode(bytes: &[u8]) -> String {
use std::fmt::Write;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut s, b| {
let _ = write!(s, "{b:02x}");
s
})
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use serde_json::json;
fn synthesise_full_json_from_local() -> Value {
let local = local_schema_map();
let parents: BTreeSet<&str> = local.keys().copied().collect();
let mut all_types: BTreeSet<&str> = parents.clone();
for children in local.values() {
for c in children {
all_types.insert(*c);
}
}
let leaves: BTreeSet<&str> = all_types.difference(&parents).copied().collect();
let mut definitions = serde_json::Map::new();
for (parent, children) in &local {
let any_of: Vec<Value> = children
.iter()
.map(|c| json!({"$ref": format!("#/definitions/{c}_node")}))
.collect();
definitions.insert(
format!("{parent}_node"),
json!({
"properties": {
"type": {"const": parent},
"content": {
"type": "array",
"items": {"anyOf": any_of}
}
}
}),
);
}
for leaf in &leaves {
definitions.insert(
format!("{leaf}_node"),
json!({
"properties": {
"type": {"const": leaf}
}
}),
);
}
json!({"definitions": Value::Object(definitions)})
}
#[test]
fn parses_anyof_refs_into_allowed_children_set() {
let full = json!({
"definitions": {
"blockquote_node": {
"properties": {
"type": {"const": "blockquote"},
"content": {
"type": "array",
"items": {
"anyOf": [
{"$ref": "#/definitions/paragraph_node"},
{"$ref": "#/definitions/codeBlock_node"}
]
}
}
}
},
"paragraph_node": {"properties": {"type": {"const": "paragraph"}}},
"codeBlock_node": {"properties": {"type": {"const": "codeBlock"}}}
}
});
let parsed = parse_upstream_full_json(&full).unwrap();
let bq = parsed.get("blockquote").expect("blockquote parsed");
let expected: BTreeSet<String> = ["codeBlock", "paragraph"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(*bq, expected);
}
#[test]
fn alias_definition_is_flattened_transitively() {
let full = json!({
"definitions": {
"tableCell_node": {
"properties": {
"type": {"const": "tableCell"},
"content": {
"type": "array",
"items": {"$ref": "#/definitions/table_cell_content"}
}
}
},
"table_cell_content": {
"anyOf": [
{"$ref": "#/definitions/paragraph_node"},
{"$ref": "#/definitions/heading_node"}
]
},
"paragraph_node": {"properties": {"type": {"const": "paragraph"}}},
"heading_node": {"properties": {"type": {"const": "heading"}}}
}
});
let parsed = parse_upstream_full_json(&full).unwrap();
let cell = parsed.get("tableCell").expect("tableCell parsed");
let expected: BTreeSet<String> = ["heading", "paragraph"]
.into_iter()
.map(String::from)
.collect();
assert_eq!(*cell, expected);
}
#[test]
fn report_is_empty_when_input_matches_local_entries() {
let full = synthesise_full_json_from_local();
let report = diff_against_upstream_json_schema(
&full,
strip_transcription_date(SCHEMA_VERSION),
"fixture",
)
.unwrap();
assert!(
!report.has_content_drift(),
"synthesised-from-local fixture should produce no content drift, got: {report:#?}"
);
assert!(report.added_parents.is_empty());
assert!(report.removed_parents.is_empty());
assert!(report.per_parent.is_empty());
assert!(!report.version_changed);
}
#[test]
fn report_flags_added_and_removed_children() {
let mut full = synthesise_full_json_from_local();
let bq_items = full
.pointer_mut("/definitions/blockquote_node/properties/content/items/anyOf")
.unwrap()
.as_array_mut()
.unwrap();
bq_items.push(json!({"$ref": "#/definitions/madeUpBlock_node"}));
full.pointer_mut("/definitions")
.unwrap()
.as_object_mut()
.unwrap()
.insert(
"madeUpBlock_node".to_string(),
json!({"properties": {"type": {"const": "madeUpBlock"}}}),
);
let panel_items = full
.pointer_mut("/definitions/panel_node/properties/content/items/anyOf")
.unwrap()
.as_array_mut()
.unwrap();
panel_items.retain(|v| {
v.get("$ref").and_then(Value::as_str) != Some("#/definitions/paragraph_node")
});
let report =
diff_against_upstream_json_schema(&full, "fixture-version", "fixture").unwrap();
assert!(report.has_content_drift());
let bq = report
.per_parent
.get("blockquote")
.expect("blockquote drift present");
assert_eq!(
bq.added_children,
std::iter::once("madeUpBlock").map(String::from).collect()
);
assert!(bq.removed_children.is_empty());
let panel = report.per_parent.get("panel").expect("panel drift present");
assert!(panel.added_children.is_empty());
assert_eq!(
panel.removed_children,
std::iter::once("paragraph").map(String::from).collect()
);
}
#[test]
fn report_flags_added_and_removed_parents() {
let mut full = synthesise_full_json_from_local();
full.pointer_mut("/definitions")
.unwrap()
.as_object_mut()
.unwrap()
.remove("expand_node");
full.pointer_mut("/definitions")
.unwrap()
.as_object_mut()
.unwrap()
.insert(
"futureBlock_node".to_string(),
json!({
"properties": {
"type": {"const": "futureBlock"},
"content": {
"type": "array",
"items": {
"anyOf": [
{"$ref": "#/definitions/paragraph_node"}
]
}
}
}
}),
);
let report = diff_against_upstream_json_schema(&full, "fixture", "fixture").unwrap();
assert!(report.added_parents.contains("futureBlock"));
assert!(report.removed_parents.contains("expand"));
}
#[test]
fn version_changed_distinguishes_npm_version_from_transcription_date() {
let full = synthesise_full_json_from_local();
let r = diff_against_upstream_json_schema(
&full,
strip_transcription_date(SCHEMA_VERSION),
"fixture",
)
.unwrap();
assert!(!r.version_changed);
let r = diff_against_upstream_json_schema(&full, "999.0.0", "fixture").unwrap();
assert!(r.version_changed);
}
#[test]
fn strip_transcription_date_handles_yyyy_mm_dd_suffix() {
assert_eq!(strip_transcription_date("52.9.5-2026-05-10"), "52.9.5");
assert_eq!(strip_transcription_date("52.9.5"), "52.9.5");
assert_eq!(strip_transcription_date("52.9.5-rc1"), "52.9.5-rc1");
assert_eq!(
strip_transcription_date("52.9.5-rc1-2026-05-10"),
"52.9.5-rc1"
);
assert_eq!(strip_transcription_date(""), "");
}
#[test]
fn strip_transcription_date_returns_input_when_suffix_lacks_leading_dash() {
assert_eq!(strip_transcription_date("abcdefghijkl"), "abcdefghijkl");
}
#[test]
fn strip_transcription_date_returns_input_when_suffix_is_not_a_date() {
assert_eq!(strip_transcription_date("abc-1234-XX-YY"), "abc-1234-XX-YY");
}
#[test]
fn render_markdown_is_terse_when_no_drift() {
let full = synthesise_full_json_from_local();
let report = diff_against_upstream_json_schema(
&full,
strip_transcription_date(SCHEMA_VERSION),
"fixture",
)
.unwrap();
let md = report.render_markdown();
assert!(md.contains("No content-model changes"));
assert!(!md.contains("Per-parent diffs"));
}
#[test]
fn render_markdown_includes_per_parent_diffs() {
let mut full = synthesise_full_json_from_local();
let bq_items = full
.pointer_mut("/definitions/blockquote_node/properties/content/items/anyOf")
.unwrap()
.as_array_mut()
.unwrap();
bq_items.push(json!({"$ref": "#/definitions/text_node"}));
let report = diff_against_upstream_json_schema(&full, "fixture", "fixture").unwrap();
let md = report.render_markdown();
assert!(md.contains("Per-parent diffs"));
assert!(md.contains("`blockquote`"));
assert!(md.contains("text"));
}
#[test]
fn render_json_is_serializable() {
let full = synthesise_full_json_from_local();
let report = diff_against_upstream_json_schema(&full, "fixture", "fixture").unwrap();
let v = report.render_json();
assert!(v.is_object());
assert!(v.get("upstream_version").is_some());
assert!(v.get("per_parent").is_some());
}
#[test]
fn find_bare_type_recognises_enum_array() {
let def = json!({"properties": {"type": {"enum": ["paragraph"]}}});
assert_eq!(find_bare_type(&def).as_deref(), Some("paragraph"));
}
#[test]
fn find_bare_type_walks_into_oneof_for_nested_const() {
let def = json!({
"oneOf": [
{"properties": {"type": {"const": "nestedExpand"}}},
{"properties": {"type": {"const": "ignoredVariant"}}}
]
});
assert_eq!(find_bare_type(&def).as_deref(), Some("nestedExpand"));
}
#[test]
fn find_bare_type_returns_none_for_nodes_with_no_type_const() {
let def = json!({"anyOf": [{"$ref": "#/definitions/x"}]});
assert_eq!(find_bare_type(&def), None);
}
#[test]
fn find_bare_type_returns_none_when_enum_is_empty_or_non_string() {
let empty = json!({"properties": {"type": {"enum": []}}});
assert_eq!(find_bare_type(&empty), None);
let non_string = json!({"properties": {"type": {"enum": [42]}}});
assert_eq!(find_bare_type(&non_string), None);
}
#[test]
fn find_bare_type_returns_none_on_non_object() {
assert_eq!(find_bare_type(&json!(null)), None);
assert_eq!(find_bare_type(&json!([])), None);
assert_eq!(find_bare_type(&json!("string")), None);
}
#[test]
fn inheritance_via_allof_finds_known_base() {
let def = json!({
"allOf": [
{"$ref": "#/definitions/paragraph_node"},
{"properties": {"marks": {}}}
]
});
let mut def_to_bare = BTreeMap::new();
def_to_bare.insert("paragraph_node".to_string(), Some("paragraph".to_string()));
assert_eq!(
inherited_bare_type_via_allof(&def, &def_to_bare).as_deref(),
Some("paragraph")
);
}
#[test]
fn inheritance_via_allof_returns_none_when_no_allof() {
let def = json!({"anyOf": [{"$ref": "#/definitions/x_node"}]});
let mut def_to_bare = BTreeMap::new();
def_to_bare.insert("x_node".to_string(), Some("x".to_string()));
assert_eq!(inherited_bare_type_via_allof(&def, &def_to_bare), None);
}
#[test]
fn inheritance_via_allof_returns_none_when_target_unknown() {
let def = json!({"allOf": [{"$ref": "#/definitions/unknown_node"}]});
let def_to_bare: BTreeMap<String, Option<String>> = BTreeMap::new();
assert_eq!(inherited_bare_type_via_allof(&def, &def_to_bare), None);
}
#[test]
fn inheritance_via_allof_skips_items_without_ref_or_with_external_ref() {
let def = json!({
"allOf": [
{"properties": {"marks": {}}}, {"$ref": "https://example.com/external.json"}, {"$ref": "#/definitions/paragraph_node"} ]
});
let mut def_to_bare = BTreeMap::new();
def_to_bare.insert("paragraph_node".to_string(), Some("paragraph".to_string()));
assert_eq!(
inherited_bare_type_via_allof(&def, &def_to_bare).as_deref(),
Some("paragraph")
);
}
#[test]
fn inheritance_via_allof_returns_none_when_only_non_ref_items() {
let def = json!({
"allOf": [
{"properties": {"marks": {}}},
{"$ref": "https://example.com/external.json"}
]
});
let def_to_bare: BTreeMap<String, Option<String>> = BTreeMap::new();
assert_eq!(inherited_bare_type_via_allof(&def, &def_to_bare), None);
}
#[test]
fn inheritance_via_allof_returns_none_for_non_object_input() {
let def_to_bare: BTreeMap<String, Option<String>> = BTreeMap::new();
assert_eq!(
inherited_bare_type_via_allof(&json!(null), &def_to_bare),
None
);
assert_eq!(
inherited_bare_type_via_allof(&json!([]), &def_to_bare),
None
);
assert_eq!(
inherited_bare_type_via_allof(&json!("string"), &def_to_bare),
None
);
}
#[test]
fn inheritance_via_allof_handles_marks_overlay_pattern() {
let full = json!({
"definitions": {
"paragraph_node": {
"properties": {
"type": {"const": "paragraph"},
"content": {
"type": "array",
"items": {"$ref": "#/definitions/formatted_text_inline_node"}
}
}
},
"text_node": {"properties": {"type": {"const": "text"}}},
"formatted_text_inline_node": {
"allOf": [
{"$ref": "#/definitions/text_node"},
{
"properties": {
"marks": {
"type": "array",
"items": {
"anyOf": [
{"$ref": "#/definitions/link_mark"}
]
}
}
}
}
]
},
"link_mark": {"properties": {"type": {"const": "link"}}}
}
});
let parsed = parse_upstream_full_json(&full).unwrap();
let p = parsed.get("paragraph").expect("paragraph present");
let expected: BTreeSet<String> = std::iter::once("text").map(String::from).collect();
assert_eq!(*p, expected);
}
#[test]
fn parse_returns_error_when_definitions_missing() {
let full = json!({"foo": "bar"});
let err = parse_upstream_full_json(&full).unwrap_err();
assert!(err.to_string().contains("definitions"));
}
#[test]
fn diff_propagates_parse_error_when_definitions_missing() {
let err = diff_against_upstream_json_schema(&json!({}), "1.0.0", "sha").unwrap_err();
assert!(err.to_string().contains("definitions"));
}
#[test]
fn collect_refs_skips_marks_and_attrs_subtrees() {
let v = json!({
"properties": {
"content": {"$ref": "#/definitions/keep_me"},
"marks": {"$ref": "#/definitions/skip_me_mark"},
"attrs": {"$ref": "#/definitions/skip_me_attrs"}
}
});
let mut refs = Vec::new();
collect_refs(&v, &mut refs);
assert!(refs.contains(&"keep_me".to_string()));
assert!(!refs.contains(&"skip_me_mark".to_string()));
assert!(!refs.contains(&"skip_me_attrs".to_string()));
}
#[test]
fn collect_refs_handles_arrays_of_refs() {
let v = json!([
{"$ref": "#/definitions/a"},
{"$ref": "#/definitions/b"},
{"$ref": "https://example.com/schema"}, ]);
let mut refs = Vec::new();
collect_refs(&v, &mut refs);
assert_eq!(refs, vec!["a".to_string(), "b".to_string()]);
}
#[test]
fn find_content_subtrees_returns_nothing_for_non_object_input() {
let null = json!(null);
let array = json!([]);
let string = json!("string");
let mut subtrees = Vec::new();
find_content_subtrees(&null, &mut subtrees);
find_content_subtrees(&array, &mut subtrees);
find_content_subtrees(&string, &mut subtrees);
assert!(subtrees.is_empty());
}
#[test]
fn find_content_subtrees_picks_up_content_nested_in_allof() {
let def = json!({
"allOf": [
{"$ref": "#/definitions/mediaSingle_node"},
{
"properties": {
"content": {
"type": "array",
"items": [
{"$ref": "#/definitions/media_node"},
{"$ref": "#/definitions/caption_node"}
]
}
}
}
]
});
let mut subtrees = Vec::new();
find_content_subtrees(&def, &mut subtrees);
assert_eq!(subtrees.len(), 1);
}
#[test]
fn resolve_handles_alias_cycles_without_infinite_loop() {
let mut definitions = serde_json::Map::new();
definitions.insert(
"alias_a".to_string(),
json!({"anyOf": [{"$ref": "#/definitions/alias_b"}]}),
);
definitions.insert(
"alias_b".to_string(),
json!({"anyOf": [{"$ref": "#/definitions/alias_a"}]}),
);
let mut def_to_bare = BTreeMap::new();
def_to_bare.insert("alias_a".to_string(), None);
def_to_bare.insert("alias_b".to_string(), None);
let mut visited = HashSet::new();
let mut out = BTreeSet::new();
resolve_ref_to_bare_types(
"alias_a",
&definitions,
&def_to_bare,
&mut visited,
&mut out,
);
assert!(out.is_empty());
}
#[test]
fn resolve_returns_silently_when_target_not_in_definitions() {
let definitions = serde_json::Map::new();
let mut def_to_bare = BTreeMap::new();
def_to_bare.insert("ghost".to_string(), None);
let mut visited = HashSet::new();
let mut out = BTreeSet::new();
resolve_ref_to_bare_types("ghost", &definitions, &def_to_bare, &mut visited, &mut out);
assert!(out.is_empty());
}
fn report_with(
added_parents: BTreeSet<String>,
removed_parents: BTreeSet<String>,
per_parent: BTreeMap<String, ParentDrift>,
version_changed: bool,
) -> DriftReport {
DriftReport {
upstream_version: "9.9.9".to_string(),
upstream_tarball_sha256: "up-sha".to_string(),
local_version: "1.0.0-2026-01-01".to_string(),
local_tarball_sha256: "local-sha".to_string(),
version_changed,
per_parent,
added_parents,
removed_parents,
}
}
#[test]
fn render_markdown_renders_added_parents_section() {
let added: BTreeSet<String> = std::iter::once("futureNode").map(String::from).collect();
let report = report_with(added, BTreeSet::new(), BTreeMap::new(), true);
let md = report.render_markdown();
assert!(md.contains("New parents (upstream only)"));
assert!(md.contains("`futureNode`"));
assert!(!md.contains("Removed parents"));
}
#[test]
fn render_markdown_renders_removed_parents_section() {
let removed: BTreeSet<String> = std::iter::once("oldNode").map(String::from).collect();
let report = report_with(BTreeSet::new(), removed, BTreeMap::new(), false);
let md = report.render_markdown();
assert!(md.contains("Removed parents (local only)"));
assert!(md.contains("`oldNode`"));
assert!(!md.contains("New parents"));
}
#[test]
fn render_markdown_renders_only_added_children_when_no_removed() {
let mut per = BTreeMap::new();
per.insert(
"blockquote".to_string(),
ParentDrift {
added_children: std::iter::once("newChild").map(String::from).collect(),
removed_children: BTreeSet::new(),
},
);
let report = report_with(BTreeSet::new(), BTreeSet::new(), per, false);
let md = report.render_markdown();
assert!(md.contains("Added children"));
assert!(!md.contains("Removed children"));
assert!(md.contains("`newChild`"));
}
#[test]
fn render_markdown_renders_only_removed_children_when_no_added() {
let mut per = BTreeMap::new();
per.insert(
"panel".to_string(),
ParentDrift {
added_children: BTreeSet::new(),
removed_children: std::iter::once("goneChild").map(String::from).collect(),
},
);
let report = report_with(BTreeSet::new(), BTreeSet::new(), per, false);
let md = report.render_markdown();
assert!(!md.contains("Added children"));
assert!(md.contains("Removed children"));
assert!(md.contains("`goneChild`"));
}
#[test]
fn has_any_drift_true_when_only_version_changed() {
let report = report_with(BTreeSet::new(), BTreeSet::new(), BTreeMap::new(), true);
assert!(!report.has_content_drift());
assert!(report.has_any_drift());
}
#[test]
fn has_any_drift_true_when_only_added_parents() {
let added: BTreeSet<String> = std::iter::once("x").map(String::from).collect();
let report = report_with(added, BTreeSet::new(), BTreeMap::new(), false);
assert!(report.has_content_drift());
assert!(report.has_any_drift());
}
#[test]
fn has_any_drift_true_when_only_removed_parents() {
let removed: BTreeSet<String> = std::iter::once("x").map(String::from).collect();
let report = report_with(BTreeSet::new(), removed, BTreeMap::new(), false);
assert!(report.has_content_drift());
assert!(report.has_any_drift());
}
fn build_synthetic_tarball(entries: &[(&str, &[u8])]) -> Vec<u8> {
let mut gz = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
{
let mut builder = tar::Builder::new(&mut gz);
for (path, body) in entries {
let mut header = tar::Header::new_gnu();
header.set_path(path).unwrap();
header.set_size(body.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder.append(&header, *body).unwrap();
}
builder.finish().unwrap();
}
gz.finish().unwrap()
}
#[test]
fn extract_full_json_succeeds_when_path_present() {
let body = serde_json::to_vec(&json!({"definitions": {}})).unwrap();
let bytes =
build_synthetic_tarball(&[("package/dist/json-schema/v1/full.json", body.as_slice())]);
let parsed = extract_full_json_from_tarball(&bytes).unwrap();
assert!(parsed.get("definitions").is_some());
}
#[test]
fn extract_full_json_errors_when_path_missing() {
let bytes = build_synthetic_tarball(&[("package/README.md", b"hello")]);
let err = extract_full_json_from_tarball(&bytes).unwrap_err();
assert!(err.to_string().contains("does not contain"));
}
#[test]
fn extract_full_json_errors_on_invalid_gzip() {
let bytes = b"not a gzip stream";
let err = extract_full_json_from_tarball(bytes).unwrap_err();
let _ = err;
}
#[test]
fn extract_full_json_errors_when_payload_is_not_json() {
let bytes =
build_synthetic_tarball(&[("package/dist/json-schema/v1/full.json", b"not json{")]);
let err = extract_full_json_from_tarball(&bytes).unwrap_err();
assert!(err.to_string().contains("parsing full.json"));
}
#[tokio::test]
async fn fetch_drift_report_from_url_handles_clean_upstream() {
let server = wiremock::MockServer::start().await;
let full = synthesise_full_json_from_local();
let tarball = build_synthetic_tarball(&[(
"package/dist/json-schema/v1/full.json",
serde_json::to_vec(&full).unwrap().as_slice(),
)]);
let tarball_url = format!("{}/-/adf-schema-fixture.tgz", server.uri());
wiremock::Mock::given(wiremock::matchers::path("/latest"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
"version": strip_transcription_date(SCHEMA_VERSION),
"dist": {"tarball": tarball_url}
})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::path("/-/adf-schema-fixture.tgz"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_bytes(tarball))
.mount(&server)
.await;
let report = fetch_drift_report_from_url(&format!("{}/latest", server.uri()))
.await
.unwrap();
assert!(!report.version_changed);
assert!(!report.has_content_drift());
}
#[tokio::test]
async fn fetch_drift_report_from_url_errors_when_metadata_lacks_version() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::path("/latest"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.set_body_json(json!({"dist": {"tarball": "x"}})),
)
.mount(&server)
.await;
let err = fetch_drift_report_from_url(&format!("{}/latest", server.uri()))
.await
.unwrap_err();
assert!(err.to_string().contains("`version` field"));
}
#[tokio::test]
async fn fetch_drift_report_from_url_errors_when_metadata_lacks_tarball() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::path("/latest"))
.respond_with(
wiremock::ResponseTemplate::new(200).set_body_json(json!({"version": "1.0.0"})),
)
.mount(&server)
.await;
let err = fetch_drift_report_from_url(&format!("{}/latest", server.uri()))
.await
.unwrap_err();
assert!(err.to_string().contains("`dist.tarball` field"));
}
#[tokio::test]
async fn fetch_drift_report_from_url_errors_when_metadata_is_not_json() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::path("/latest"))
.respond_with(
wiremock::ResponseTemplate::new(200)
.insert_header("content-type", "application/json")
.set_body_string("not json{"),
)
.mount(&server)
.await;
let err = fetch_drift_report_from_url(&format!("{}/latest", server.uri()))
.await
.unwrap_err();
assert!(err.to_string().contains("parsing npm latest dist-tag JSON"));
}
#[tokio::test]
async fn fetch_drift_report_from_url_errors_on_connection_refused() {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = listener.local_addr().unwrap().port();
drop(listener);
let url = format!("http://127.0.0.1:{port}/latest");
let err = fetch_drift_report_from_url(&url).await.unwrap_err();
assert!(
err.to_string().contains("fetching npm registry"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn fetch_drift_report_from_url_errors_on_npm_5xx() {
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::path("/latest"))
.respond_with(wiremock::ResponseTemplate::new(503))
.mount(&server)
.await;
let err = fetch_drift_report_from_url(&format!("{}/latest", server.uri()))
.await
.unwrap_err();
assert!(err
.to_string()
.contains("non-2xx status for latest dist-tag"));
}
#[tokio::test]
async fn fetch_drift_report_from_url_errors_on_tarball_5xx() {
let server = wiremock::MockServer::start().await;
let tarball_url = format!("{}/-/x.tgz", server.uri());
wiremock::Mock::given(wiremock::matchers::path("/latest"))
.respond_with(wiremock::ResponseTemplate::new(200).set_body_json(json!({
"version": "1.0.0",
"dist": {"tarball": tarball_url}
})))
.mount(&server)
.await;
wiremock::Mock::given(wiremock::matchers::path("/-/x.tgz"))
.respond_with(wiremock::ResponseTemplate::new(503))
.mount(&server)
.await;
let err = fetch_drift_report_from_url(&format!("{}/latest", server.uri()))
.await
.unwrap_err();
assert!(err.to_string().contains("non-2xx status"));
}
}