use super::types::{
ApiCompatIssue, CompatIssueLocation, CompatRenderStatus, DocumentBasePath,
DocumentPath, OperationIdMap, PathTree, PathTreeKey, SubpathChange,
unescape_pointer_component,
};
use drift::{Change, ChangeClass, ChangeInfo, ChangePath};
impl ApiCompatIssue {
fn new(
blessed_spec: &serde_json::Value,
generated_spec: &serde_json::Value,
paths: Vec<ChangePath>,
change_infos: Vec<ChangeInfo>,
blessed_op_ids: &OperationIdMap<'_>,
generated_op_ids: &OperationIdMap<'_>,
) -> Self {
let first = paths.first().expect("non-empty paths from drift");
let (blessed_base_ptr, _) = first.old.base_and_subpath();
let (generated_base_ptr, _) = first.new.base_and_subpath();
let blessed_base = DocumentBasePath::classify(
DocumentPath::parse(blessed_base_ptr),
blessed_op_ids,
);
let generated_base = DocumentBasePath::classify(
DocumentPath::parse(generated_base_ptr),
generated_op_ids,
);
let blessed_value = (!blessed_base.is_paths_root())
.then(|| get_json_value(blessed_base_ptr, blessed_spec))
.flatten();
let generated_value = (!generated_base.is_paths_root())
.then(|| get_json_value(generated_base_ptr, generated_spec))
.flatten();
let changes =
change_infos.into_iter().map(SubpathChange::from_info).collect();
let tree = PathTree::build(&paths, blessed_op_ids);
Self {
blessed_base,
generated_base,
changes,
tree,
blessed_value,
generated_value,
}
}
}
#[derive(Debug, Default)]
pub(crate) struct CompatDedupMap<'a> {
entries: Vec<RawEntry<'a>>,
}
#[derive(Debug)]
struct RawEntry<'a> {
issue: &'a ApiCompatIssue,
first_occurrence: CompatIssueLocation<'a>,
count: usize,
}
impl<'a> CompatDedupMap<'a> {
pub(crate) fn insert(
&mut self,
location: CompatIssueLocation<'a>,
issue: &'a ApiCompatIssue,
) {
if let Some(entry) =
self.entries.iter_mut().find(|e| e.issue.is_same_change_as(issue))
{
entry.count += 1;
} else {
self.entries.push(RawEntry {
issue,
first_occurrence: location,
count: 1,
});
}
}
pub(crate) fn finalize(self) -> FinalizedCompatDedupMap<'a> {
let mut next_anchor = 1;
let entries = self
.entries
.into_iter()
.map(|raw| {
if raw.count > 1 {
let anchor = next_anchor;
next_anchor += 1;
FinalizedEntry::MultiSite {
issue: raw.issue,
first_occurrence: raw.first_occurrence,
anchor,
}
} else {
FinalizedEntry::Singleton { issue: raw.issue }
}
})
.collect();
FinalizedCompatDedupMap { entries }
}
}
#[derive(Debug)]
pub(crate) struct FinalizedCompatDedupMap<'a> {
entries: Vec<FinalizedEntry<'a>>,
}
#[derive(Debug)]
enum FinalizedEntry<'a> {
Singleton {
issue: &'a ApiCompatIssue,
},
MultiSite {
issue: &'a ApiCompatIssue,
first_occurrence: CompatIssueLocation<'a>,
anchor: usize,
},
}
impl<'a> FinalizedEntry<'a> {
fn issue(&self) -> &'a ApiCompatIssue {
match self {
Self::Singleton { issue } | Self::MultiSite { issue, .. } => issue,
}
}
}
impl FinalizedCompatDedupMap<'_> {
pub(crate) fn status_for(
&self,
issue: &ApiCompatIssue,
current: CompatIssueLocation<'_>,
) -> CompatRenderStatus {
let entry = self
.entries
.iter()
.find(|e| e.issue().is_same_change_as(issue))
.expect("every issue passed to status_for was inserted");
match entry {
FinalizedEntry::Singleton { .. } => {
CompatRenderStatus::FirstOccurrence { anchor: None }
}
FinalizedEntry::MultiSite { first_occurrence, anchor, .. } => {
if *first_occurrence == current {
CompatRenderStatus::FirstOccurrence {
anchor: Some(*anchor),
}
} else {
CompatRenderStatus::Duplicate { anchor: *anchor }
}
}
}
}
}
impl SubpathChange {
fn from_info(info: ChangeInfo) -> Self {
Self {
class: info.class,
message: info.message,
old_subpath: DocumentPath::parse(&info.old_subpath),
new_subpath: DocumentPath::parse(&info.new_subpath),
}
}
}
impl PathTree {
fn build(paths: &[ChangePath], op_ids: &OperationIdMap<'_>) -> Self {
let mut tree = PathTree::default();
for path in paths {
let ref_chain = path
.old
.iter()
.skip(1)
.map(|entry| PathTreeKey::parse(entry, op_ids));
tree.insert(ref_chain);
}
tree
}
}
fn extract_operation_ids(doc: &serde_json::Value) -> OperationIdMap<'_> {
let mut out = OperationIdMap::new();
let Some(paths) = doc.pointer("/paths").and_then(|v| v.as_object()) else {
return out;
};
for (route, item) in paths {
let Some(item) = item.as_object() else { continue };
for (method, op) in item {
let Some(op) = op.as_object() else { continue };
let Some(op_id) = op.get("operationId").and_then(|v| v.as_str())
else {
continue;
};
let base = DocumentPath {
segments: vec![
"paths".to_string(),
route.clone(),
method.clone(),
],
};
out.insert(base, op_id);
}
}
out
}
fn get_json_value(
pointer: &str,
spec: &serde_json::Value,
) -> Option<serde_json::Value> {
let pointer = pointer.trim_start_matches('#');
spec.pointer(pointer).map(|v| {
let last_component = pointer.split('/').next_back().unwrap_or("");
surround_with_map(last_component, v)
})
}
fn surround_with_map(
last_component: &str,
value: &serde_json::Value,
) -> serde_json::Value {
let mut map = serde_json::Map::new();
map.insert(unescape_pointer_component(last_component), value.clone());
serde_json::Value::Object(map)
}
fn escape_json_pointer(s: &str) -> String {
s.replace('~', "~0").replace('/', "~1")
}
fn normalize_old_websocket_responses(
blessed: &mut serde_json::Value,
generated: &serde_json::Value,
) {
let old_ws_responses = serde_json::json!({
"default": {
"description": "",
"content": {
"*/*": { "schema": {} }
}
}
});
let new_ws_responses = serde_json::json!({
"101": {
"description":
"Negotiating protocol upgrade from HTTP/1.1 to WebSocket"
},
"4XX": {
"$ref": "#/components/responses/Error"
},
"5XX": {
"$ref": "#/components/responses/Error"
}
});
let Some(blessed_paths) =
blessed.pointer_mut("/paths").and_then(|v| v.as_object_mut())
else {
return;
};
for (path, item) in blessed_paths.iter_mut() {
let Some(item) = item.as_object_mut() else { continue };
for (method, operation) in item.iter_mut() {
let Some(op) = operation.as_object_mut() else {
continue;
};
if !op.contains_key("x-dropshot-websocket") {
continue;
}
if op.get("responses") != Some(&old_ws_responses) {
continue;
}
let Some(gen_op) = generated
.pointer(&format!(
"/paths/{}/{}",
escape_json_pointer(path),
method,
))
.and_then(|v| v.as_object())
else {
continue;
};
if !gen_op.contains_key("x-dropshot-websocket") {
continue;
}
if gen_op.get("responses") != Some(&new_ws_responses) {
continue;
}
op.insert("responses".to_string(), new_ws_responses.clone());
}
}
}
pub(crate) fn api_compatible(
blessed: &serde_json::Value,
generated: &serde_json::Value,
) -> anyhow::Result<Vec<ApiCompatIssue>> {
let mut blessed = blessed.clone();
normalize_old_websocket_responses(&mut blessed, generated);
let blessed_op_ids = extract_operation_ids(&blessed);
let generated_op_ids = extract_operation_ids(generated);
let changes = drift::compare(&blessed, generated)?;
let mut issues = Vec::new();
for Change { paths, changes: change_infos } in changes {
let non_trivial: Vec<_> = change_infos
.into_iter()
.filter(|c| match c.class {
ChangeClass::BackwardIncompatible
| ChangeClass::ForwardIncompatible
| ChangeClass::Incompatible
| ChangeClass::Unhandled => true,
ChangeClass::Trivial => false,
})
.collect();
if non_trivial.is_empty() {
continue;
}
issues.push(ApiCompatIssue::new(
&blessed,
generated,
paths,
non_trivial,
&blessed_op_ids,
&generated_op_ids,
));
}
issues.sort_by(|a, b| {
(&a.blessed_base, &a.generated_base)
.cmp(&(&b.blessed_base, &b.generated_base))
});
Ok(issues)
}
#[cfg(test)]
mod tests {
use super::*;
use dropshot_api_manager_types::ApiIdent;
use std::collections::BTreeSet;
#[test]
fn test_normalize_old_websocket_responses() {
let mut blessed = serde_json::json!({
"paths": {
"/subscribe": {
"get": {
"operationId": "subscribe",
"responses": {
"default": {
"description": "",
"content": {
"*/*": { "schema": {} }
}
}
},
"x-dropshot-websocket": {}
}
},
"/health": {
"get": {
"operationId": "health_check",
"responses": {
"200": {
"description": "OK"
}
}
}
}
}
});
let generated = serde_json::json!({
"paths": {
"/subscribe": {
"get": {
"operationId": "subscribe",
"responses": {
"101": {
"description": "Negotiating protocol upgrade from HTTP/1.1 to WebSocket"
},
"4XX": {
"$ref": "#/components/responses/Error"
},
"5XX": {
"$ref": "#/components/responses/Error"
}
},
"x-dropshot-websocket": {}
}
},
"/health": {
"get": {
"operationId": "health_check",
"responses": {
"200": {
"description": "OK"
}
}
}
}
}
});
let original_blessed = blessed.clone();
normalize_old_websocket_responses(&mut blessed, &generated);
assert_eq!(
blessed.pointer("/paths/~1subscribe/get/responses"),
generated.pointer("/paths/~1subscribe/get/responses"),
"websocket responses should be normalized to new format",
);
assert_eq!(
blessed.pointer("/paths/~1health/get/responses"),
original_blessed.pointer("/paths/~1health/get/responses"),
"non-websocket responses should not be modified",
);
}
#[test]
fn test_normalize_already_new_format_is_noop() {
let mut spec = serde_json::json!({
"paths": {
"/subscribe": {
"get": {
"operationId": "subscribe",
"responses": {
"101": {
"description": "Negotiating protocol upgrade"
},
"4XX": { "$ref": "#/components/responses/Error" },
"5XX": { "$ref": "#/components/responses/Error" }
},
"x-dropshot-websocket": {}
}
}
}
});
let original = spec.clone();
normalize_old_websocket_responses(&mut spec, &original);
assert_eq!(spec, original);
}
#[test]
fn test_normalize_no_websocket_endpoints_is_noop() {
let mut spec = serde_json::json!({
"paths": {
"/health": {
"get": {
"operationId": "health",
"responses": { "200": { "description": "OK" } }
}
}
}
});
let original = spec.clone();
normalize_old_websocket_responses(&mut spec, &original);
assert_eq!(spec, original);
}
#[test]
fn test_normalize_missing_generated_path_leaves_blessed_unchanged() {
let mut blessed = serde_json::json!({
"paths": {
"/subscribe": {
"get": {
"operationId": "subscribe",
"responses": {
"default": {
"description": "",
"content": { "*/*": { "schema": {} } }
}
},
"x-dropshot-websocket": {}
}
}
}
});
let generated = serde_json::json!({
"paths": {}
});
let original = blessed.clone();
normalize_old_websocket_responses(&mut blessed, &generated);
assert_eq!(blessed, original);
}
#[test]
fn test_api_compatible_old_ws_format() {
let blessed = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Test", "version": "1.0.0" },
"paths": {
"/subscribe": {
"get": {
"operationId": "subscribe",
"responses": {
"default": {
"description": "",
"content": {
"*/*": { "schema": {} }
}
}
},
"x-dropshot-websocket": {}
}
}
},
"components": {
"responses": {
"Error": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"schemas": {
"Error": {
"description": "Error information from a response.",
"type": "object",
"properties": {
"message": { "type": "string" },
"request_id": { "type": "string" }
},
"required": ["message", "request_id"]
}
}
}
});
let generated = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Test", "version": "1.0.0" },
"paths": {
"/subscribe": {
"get": {
"operationId": "subscribe",
"responses": {
"101": {
"description": "Negotiating protocol upgrade from HTTP/1.1 to WebSocket"
},
"4XX": {
"$ref": "#/components/responses/Error"
},
"5XX": {
"$ref": "#/components/responses/Error"
}
},
"x-dropshot-websocket": {}
}
}
},
"components": {
"responses": {
"Error": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"schemas": {
"Error": {
"description": "Error information from a response.",
"type": "object",
"properties": {
"message": { "type": "string" },
"request_id": { "type": "string" }
},
"required": ["message", "request_id"]
}
}
}
});
let issues = api_compatible(&blessed, &generated).unwrap();
assert!(
issues.is_empty(),
"old ws format should be compatible after normalization, \
but got: {issues:?}",
);
}
#[test]
fn test_normalize_ws_to_http_still_detects_incompatibility() {
let blessed = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Test", "version": "1.0.0" },
"paths": {
"/subscribe": {
"get": {
"operationId": "subscribe",
"responses": {
"default": {
"description": "",
"content": {
"*/*": { "schema": {} }
}
}
},
"x-dropshot-websocket": {}
}
}
},
"components": {
"responses": {
"Error": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"schemas": {
"Error": {
"description": "Error information from a response.",
"type": "object",
"properties": {
"message": { "type": "string" },
"request_id": { "type": "string" }
},
"required": ["message", "request_id"]
}
}
}
});
let generated = serde_json::json!({
"openapi": "3.0.3",
"info": { "title": "Test", "version": "1.0.0" },
"paths": {
"/subscribe": {
"get": {
"operationId": "subscribe",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": { "type": "string" }
}
}
},
"4XX": {
"$ref": "#/components/responses/Error"
},
"5XX": {
"$ref": "#/components/responses/Error"
}
}
}
}
},
"components": {
"responses": {
"Error": {
"description": "Error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Error"
}
}
}
}
},
"schemas": {
"Error": {
"description": "Error information from a response.",
"type": "object",
"properties": {
"message": { "type": "string" },
"request_id": { "type": "string" }
},
"required": ["message", "request_id"]
}
}
}
});
let issues = api_compatible(&blessed, &generated).unwrap();
assert!(
!issues.is_empty(),
"websocket-to-HTTP change should be detected as incompatible",
);
}
fn component_base(p: &str) -> DocumentBasePath {
DocumentBasePath::Component(DocumentPath::parse(p))
}
fn synthetic_issue(
base: &str,
message: &str,
blessed_value: serde_json::Value,
generated_value: serde_json::Value,
) -> ApiCompatIssue {
ApiCompatIssue {
blessed_base: component_base(base),
generated_base: component_base(base),
changes: BTreeSet::from([SubpathChange {
class: ChangeClass::Incompatible,
message: message.into(),
old_subpath: DocumentPath::parse("properties/value"),
new_subpath: DocumentPath::parse("properties/value"),
}]),
tree: PathTree::default(),
blessed_value: Some(blessed_value),
generated_value: Some(generated_value),
}
}
struct OwnedLoc {
api: ApiIdent,
version: semver::Version,
}
impl OwnedLoc {
fn new(api: &str, version: &str) -> Self {
Self {
api: ApiIdent::from(api.to_string()),
version: version.parse().unwrap(),
}
}
fn as_loc(&self) -> CompatIssueLocation<'_> {
CompatIssueLocation { api: &self.api, version: &self.version }
}
}
#[track_caller]
fn assert_status(
dedup: &FinalizedCompatDedupMap<'_>,
issue: &ApiCompatIssue,
current: CompatIssueLocation<'_>,
expected: CompatRenderStatus,
) {
let actual = dedup.status_for(issue, current);
assert_eq!(actual, expected);
}
#[test]
fn test_dedup_basic() {
let issue_a = synthetic_issue(
"#/components/schemas/Error",
"schema types changed",
serde_json::json!({"Error": {"type": "string"}}),
serde_json::json!({"Error": {"type": "integer"}}),
);
let issue_b = synthetic_issue(
"#/components/schemas/Error",
"schema types changed",
serde_json::json!({"Error": {"type": "string"}}),
serde_json::json!({"Error": {"type": "integer"}}),
);
let foo = OwnedLoc::new("foo", "1.0.0");
let bar = OwnedLoc::new("bar", "1.0.0");
let mut dedup = CompatDedupMap::default();
dedup.insert(foo.as_loc(), &issue_a);
dedup.insert(bar.as_loc(), &issue_b);
let dedup = dedup.finalize();
assert_status(
&dedup,
&issue_a,
foo.as_loc(),
CompatRenderStatus::FirstOccurrence { anchor: Some(1) },
);
assert_status(
&dedup,
&issue_b,
bar.as_loc(),
CompatRenderStatus::Duplicate { anchor: 1 },
);
}
#[test]
fn test_dedup_distinguishes_by_value() {
let issue_a = synthetic_issue(
"#/components/schemas/Error",
"schema types changed",
serde_json::json!({"Error": {"type": "string"}}),
serde_json::json!({"Error": {"type": "integer"}}),
);
let issue_b = synthetic_issue(
"#/components/schemas/Error",
"schema types changed",
serde_json::json!({"Error": {"type": "object"}}),
serde_json::json!({"Error": {"type": "array"}}),
);
let foo = OwnedLoc::new("foo", "1.0.0");
let bar = OwnedLoc::new("bar", "1.0.0");
let mut dedup = CompatDedupMap::default();
dedup.insert(foo.as_loc(), &issue_a);
dedup.insert(bar.as_loc(), &issue_b);
let dedup = dedup.finalize();
assert_status(
&dedup,
&issue_a,
foo.as_loc(),
CompatRenderStatus::FirstOccurrence { anchor: None },
);
assert_status(
&dedup,
&issue_b,
bar.as_loc(),
CompatRenderStatus::FirstOccurrence { anchor: None },
);
}
#[test]
fn test_dedup_across_versions_of_same_api() {
let issue = synthetic_issue(
"#/components/schemas/Error",
"schema types changed",
serde_json::json!({"Error": {"type": "string"}}),
serde_json::json!({"Error": {"type": "integer"}}),
);
let v1 = OwnedLoc::new("foo", "1.0.0");
let v2 = OwnedLoc::new("foo", "2.0.0");
let mut dedup = CompatDedupMap::default();
dedup.insert(v1.as_loc(), &issue);
dedup.insert(v2.as_loc(), &issue);
let dedup = dedup.finalize();
assert_status(
&dedup,
&issue,
v1.as_loc(),
CompatRenderStatus::FirstOccurrence { anchor: Some(1) },
);
assert_status(
&dedup,
&issue,
v2.as_loc(),
CompatRenderStatus::Duplicate { anchor: 1 },
);
}
#[test]
fn test_dedup_asymmetric_versions_across_apis() {
let issue = synthetic_issue(
"#/components/schemas/Error",
"schema types changed",
serde_json::json!({"Error": {"type": "string"}}),
serde_json::json!({"Error": {"type": "integer"}}),
);
let a_v1 = OwnedLoc::new("api_a", "1.0.0");
let a_v2 = OwnedLoc::new("api_a", "2.0.0");
let a_v3 = OwnedLoc::new("api_a", "3.0.0");
let b_v2 = OwnedLoc::new("api_b", "2.0.0");
let mut dedup = CompatDedupMap::default();
dedup.insert(a_v1.as_loc(), &issue);
dedup.insert(a_v2.as_loc(), &issue);
dedup.insert(a_v3.as_loc(), &issue);
dedup.insert(b_v2.as_loc(), &issue);
let dedup = dedup.finalize();
assert_status(
&dedup,
&issue,
a_v1.as_loc(),
CompatRenderStatus::FirstOccurrence { anchor: Some(1) },
);
for loc in [&a_v2, &a_v3, &b_v2] {
assert_status(
&dedup,
&issue,
loc.as_loc(),
CompatRenderStatus::Duplicate { anchor: 1 },
);
}
}
#[test]
fn test_dedup_ignores_tree() {
let ops = OperationIdMap::new();
let mut tree_a = PathTree::default();
tree_a.insert([PathTreeKey::parse(
"#/components/schemas/Wrapper/properties/a/$ref",
&ops,
)]);
let mut tree_b = PathTree::default();
tree_b.insert([PathTreeKey::parse(
"#/components/schemas/Wrapper/properties/b/$ref",
&ops,
)]);
let make_issue = |tree: PathTree| ApiCompatIssue {
blessed_base: component_base("#/components/schemas/SubType"),
generated_base: component_base("#/components/schemas/SubType"),
changes: BTreeSet::from([SubpathChange {
class: ChangeClass::Incompatible,
message: "schema types changed".into(),
old_subpath: DocumentPath::parse("properties/value"),
new_subpath: DocumentPath::parse("properties/value"),
}]),
tree,
blessed_value: Some(
serde_json::json!({"SubType": {"type": "string"}}),
),
generated_value: Some(
serde_json::json!({"SubType": {"type": "integer"}}),
),
};
let issue_a = make_issue(tree_a);
let issue_b = make_issue(tree_b);
assert!(
issue_a.is_same_change_as(&issue_b),
"issues identical except for tree should dedup",
);
}
#[test]
fn test_anchor_numbering_skips_single_occurrence() {
let multi_a = synthetic_issue(
"#/components/schemas/MultiA",
"schema types changed",
serde_json::json!({"MultiA": {"type": "string"}}),
serde_json::json!({"MultiA": {"type": "integer"}}),
);
let solo = synthetic_issue(
"#/components/schemas/Solo",
"schema types changed",
serde_json::json!({"Solo": {"type": "string"}}),
serde_json::json!({"Solo": {"type": "integer"}}),
);
let multi_b = synthetic_issue(
"#/components/schemas/MultiB",
"schema types changed",
serde_json::json!({"MultiB": {"type": "string"}}),
serde_json::json!({"MultiB": {"type": "integer"}}),
);
let foo = OwnedLoc::new("foo", "1.0.0");
let bar = OwnedLoc::new("bar", "1.0.0");
let mut dedup = CompatDedupMap::default();
dedup.insert(foo.as_loc(), &multi_a);
dedup.insert(bar.as_loc(), &multi_a);
dedup.insert(foo.as_loc(), &solo);
dedup.insert(foo.as_loc(), &multi_b);
dedup.insert(bar.as_loc(), &multi_b);
let dedup = dedup.finalize();
assert_status(
&dedup,
&multi_a,
foo.as_loc(),
CompatRenderStatus::FirstOccurrence { anchor: Some(1) },
);
assert_status(
&dedup,
&solo,
foo.as_loc(),
CompatRenderStatus::FirstOccurrence { anchor: None },
);
assert_status(
&dedup,
&multi_b,
foo.as_loc(),
CompatRenderStatus::FirstOccurrence { anchor: Some(2) },
);
}
#[test]
fn test_dedup_change_order_independent() {
fn make_change(message: &str, subpath: &str) -> SubpathChange {
SubpathChange {
class: ChangeClass::Incompatible,
message: message.into(),
old_subpath: DocumentPath::parse(subpath),
new_subpath: DocumentPath::parse(subpath),
}
}
fn issue_with_changes(
changes: impl IntoIterator<Item = SubpathChange>,
) -> ApiCompatIssue {
ApiCompatIssue {
blessed_base: component_base("#/components/schemas/User"),
generated_base: component_base("#/components/schemas/User"),
changes: changes.into_iter().collect(),
tree: PathTree::default(),
blessed_value: Some(serde_json::json!({"User": {}})),
generated_value: Some(serde_json::json!({"User": {}})),
}
}
let a_changes = [
make_change("a changed", "properties/a"),
make_change("b changed", "properties/b"),
];
let b_changes = [
make_change("b changed", "properties/b"),
make_change("a changed", "properties/a"),
];
let issue_a = issue_with_changes(a_changes);
let issue_b = issue_with_changes(b_changes);
assert!(
issue_a.is_same_change_as(&issue_b),
"issues with same change set in different order should dedup",
);
let foo = OwnedLoc::new("foo", "1.0.0");
let bar = OwnedLoc::new("bar", "1.0.0");
let mut dedup = CompatDedupMap::default();
dedup.insert(foo.as_loc(), &issue_a);
dedup.insert(bar.as_loc(), &issue_b);
let dedup = dedup.finalize();
assert_status(
&dedup,
&issue_b,
bar.as_loc(),
CompatRenderStatus::Duplicate { anchor: 1 },
);
}
#[test]
#[should_panic(expected = "every issue passed to status_for was inserted")]
fn test_status_for_panics_on_uninserted() {
let issue = synthetic_issue(
"#/components/schemas/Error",
"schema types changed",
serde_json::json!({"Error": {"type": "string"}}),
serde_json::json!({"Error": {"type": "integer"}}),
);
let foo = OwnedLoc::new("foo", "1.0.0");
let dedup = CompatDedupMap::default().finalize();
let _ = dedup.status_for(&issue, foo.as_loc());
}
}