Wave 6: Integration tests, golden tests, index invariant tests, diff command (bd-rex, bd-2gp, bd-1ck)

This commit is contained in:
teernisse
2026-02-12 14:58:25 -05:00
parent 346fef9135
commit 398311ca4c
27 changed files with 2122 additions and 172 deletions

View File

@@ -9,6 +9,7 @@ use fs2::FileExt;
use regex::Regex;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::LazyLock;
use crate::errors::SwaggerCliError;
@@ -46,8 +47,11 @@ impl CacheMetadata {
///
/// Accepts: alphanumeric start, then alphanumeric/dot/dash/underscore, 1-64 chars.
/// Rejects: path separators, `..`, leading dots, Windows reserved device names.
static ALIAS_PATTERN: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$").expect("valid regex"));
pub fn validate_alias(alias: &str) -> Result<(), SwaggerCliError> {
let pattern = Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$").expect("valid regex");
let pattern = &*ALIAS_PATTERN;
if !pattern.is_match(alias) {
return Err(SwaggerCliError::Usage(format!(

View File

@@ -134,12 +134,45 @@ impl Config {
let contents =
toml::to_string_pretty(self).map_err(|e| SwaggerCliError::Config(e.to_string()))?;
std::fs::write(path, contents).map_err(|e| {
SwaggerCliError::Config(format!("failed to write {}: {e}", path.display()))
// Atomic write: write to .tmp, fsync, then rename to avoid corruption on crash
let tmp_path = path.with_extension("toml.tmp");
std::fs::write(&tmp_path, &contents).map_err(|e| {
SwaggerCliError::Config(format!("failed to write {}: {e}", tmp_path.display()))
})?;
// Best-effort fsync
if let Ok(file) = std::fs::File::open(&tmp_path) {
let _ = file.sync_all();
}
std::fs::rename(&tmp_path, path).map_err(|e| {
SwaggerCliError::Config(format!(
"failed to rename {} -> {}: {e}",
tmp_path.display(),
path.display()
))
})
}
}
/// Resolve a credential source to its string value.
///
/// Used by fetch and sync commands to obtain auth tokens from config profiles.
pub fn resolve_credential(source: &CredentialSource) -> Result<String, SwaggerCliError> {
match source {
CredentialSource::Literal { value } => Ok(value.clone()),
CredentialSource::EnvVar { name } => std::env::var(name).map_err(|_| {
SwaggerCliError::Auth(format!(
"environment variable '{name}' not set (required by auth profile)"
))
}),
CredentialSource::Keyring { service, account } => Err(SwaggerCliError::Auth(format!(
"keyring credential lookup not yet implemented (service={service}, account={account})"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;

353
src/core/diff.rs Normal file
View File

@@ -0,0 +1,353 @@
use std::collections::BTreeSet;
use serde::Serialize;
use super::spec::{IndexedEndpoint, SpecIndex};
/// Key that uniquely identifies an endpoint: (METHOD, path).
type EndpointKey = (String, String);
fn endpoint_key(ep: &IndexedEndpoint) -> EndpointKey {
(ep.method.to_uppercase(), ep.path.clone())
}
/// Checks whether two endpoints differ structurally (beyond just path+method).
fn endpoints_differ(left: &IndexedEndpoint, right: &IndexedEndpoint) -> bool {
left.summary != right.summary
|| left.deprecated != right.deprecated
|| left.parameters.len() != right.parameters.len()
|| left.request_body_required != right.request_body_required
|| left.request_body_content_types != right.request_body_content_types
|| left.security_schemes != right.security_schemes
|| left.security_required != right.security_required
|| left.tags != right.tags
|| left.operation_id != right.operation_id
|| left.description != right.description
|| params_differ(left, right)
}
fn params_differ(left: &IndexedEndpoint, right: &IndexedEndpoint) -> bool {
if left.parameters.len() != right.parameters.len() {
return true;
}
for (lp, rp) in left.parameters.iter().zip(right.parameters.iter()) {
if lp.name != rp.name || lp.location != rp.location || lp.required != rp.required {
return true;
}
}
false
}
#[derive(Debug, Serialize)]
pub struct DiffResult {
pub endpoints: EndpointDiff,
pub schemas: SchemaDiff,
pub summary: DiffSummary,
}
#[derive(Debug, Serialize)]
pub struct EndpointDiff {
/// Each entry: [method, path]
pub added: Vec<[String; 2]>,
/// Each entry: [method, path]
pub removed: Vec<[String; 2]>,
/// Each entry: [method, path]
pub modified: Vec<[String; 2]>,
}
#[derive(Debug, Serialize)]
pub struct SchemaDiff {
pub added: Vec<String>,
pub removed: Vec<String>,
}
#[derive(Debug, Serialize)]
pub struct DiffSummary {
pub total_changes: usize,
pub has_breaking: bool,
}
/// Compare two spec indexes and produce a structural diff.
///
/// Breaking changes: removed endpoints.
pub fn diff_indexes(left: &SpecIndex, right: &SpecIndex) -> DiffResult {
// Build lookup maps by endpoint key
let left_map: std::collections::BTreeMap<EndpointKey, &IndexedEndpoint> = left
.endpoints
.iter()
.map(|ep| (endpoint_key(ep), ep))
.collect();
let right_map: std::collections::BTreeMap<EndpointKey, &IndexedEndpoint> = right
.endpoints
.iter()
.map(|ep| (endpoint_key(ep), ep))
.collect();
let left_keys: BTreeSet<_> = left_map.keys().cloned().collect();
let right_keys: BTreeSet<_> = right_map.keys().cloned().collect();
let added: Vec<[String; 2]> = right_keys
.difference(&left_keys)
.map(|(method, path)| [method.clone(), path.clone()])
.collect();
let removed: Vec<[String; 2]> = left_keys
.difference(&right_keys)
.map(|(method, path)| [method.clone(), path.clone()])
.collect();
let modified: Vec<[String; 2]> = left_keys
.intersection(&right_keys)
.filter(|key| {
let l = left_map[key];
let r = right_map[key];
endpoints_differ(l, r)
})
.map(|(method, path)| [method.clone(), path.clone()])
.collect();
// Schema diff by name
let left_schemas: BTreeSet<&str> = left.schemas.iter().map(|s| s.name.as_str()).collect();
let right_schemas: BTreeSet<&str> = right.schemas.iter().map(|s| s.name.as_str()).collect();
let schemas_added: Vec<String> = right_schemas
.difference(&left_schemas)
.map(|s| s.to_string())
.collect();
let schemas_removed: Vec<String> = left_schemas
.difference(&right_schemas)
.map(|s| s.to_string())
.collect();
let has_breaking = !removed.is_empty();
let total_changes =
added.len() + removed.len() + modified.len() + schemas_added.len() + schemas_removed.len();
DiffResult {
endpoints: EndpointDiff {
added,
removed,
modified,
},
schemas: SchemaDiff {
added: schemas_added,
removed: schemas_removed,
},
summary: DiffSummary {
total_changes,
has_breaking,
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::spec::{IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, SpecIndex};
fn make_endpoint(path: &str, method: &str, summary: &str) -> IndexedEndpoint {
IndexedEndpoint {
path: path.to_string(),
method: method.to_string(),
summary: Some(summary.to_string()),
description: None,
operation_id: None,
tags: vec![],
deprecated: false,
parameters: vec![],
request_body_required: false,
request_body_content_types: vec![],
security_schemes: vec![],
security_required: false,
operation_ptr: String::new(),
}
}
fn make_index(endpoints: Vec<IndexedEndpoint>, schemas: Vec<IndexedSchema>) -> SpecIndex {
SpecIndex {
index_version: 1,
generation: 1,
content_hash: "sha256:test".into(),
openapi: "3.0.3".into(),
info: IndexInfo {
title: "Test".into(),
version: "1.0.0".into(),
},
endpoints,
schemas,
tags: vec![],
}
}
#[test]
fn test_identical_indexes_no_changes() {
let eps = vec![
make_endpoint("/pets", "GET", "List pets"),
make_endpoint("/pets", "POST", "Create pet"),
];
let schemas = vec![IndexedSchema {
name: "Pet".into(),
schema_ptr: "#/components/schemas/Pet".into(),
}];
let left = make_index(eps.clone(), schemas.clone());
let right = make_index(eps, schemas);
let result = diff_indexes(&left, &right);
assert_eq!(result.summary.total_changes, 0);
assert!(!result.summary.has_breaking);
assert!(result.endpoints.added.is_empty());
assert!(result.endpoints.removed.is_empty());
assert!(result.endpoints.modified.is_empty());
assert!(result.schemas.added.is_empty());
assert!(result.schemas.removed.is_empty());
}
#[test]
fn test_added_endpoint() {
let left = make_index(vec![make_endpoint("/pets", "GET", "List pets")], vec![]);
let right = make_index(
vec![
make_endpoint("/pets", "GET", "List pets"),
make_endpoint("/pets", "POST", "Create pet"),
],
vec![],
);
let result = diff_indexes(&left, &right);
assert_eq!(result.endpoints.added.len(), 1);
assert_eq!(result.endpoints.added[0], ["POST", "/pets"]);
assert_eq!(result.endpoints.removed.len(), 0);
assert!(!result.summary.has_breaking);
}
#[test]
fn test_removed_endpoint_is_breaking() {
let left = make_index(
vec![
make_endpoint("/pets", "GET", "List pets"),
make_endpoint("/pets", "POST", "Create pet"),
],
vec![],
);
let right = make_index(vec![make_endpoint("/pets", "GET", "List pets")], vec![]);
let result = diff_indexes(&left, &right);
assert_eq!(result.endpoints.removed.len(), 1);
assert_eq!(result.endpoints.removed[0], ["POST", "/pets"]);
assert!(result.summary.has_breaking);
}
#[test]
fn test_modified_endpoint() {
let left = make_index(vec![make_endpoint("/pets", "GET", "List pets")], vec![]);
let mut modified_ep = make_endpoint("/pets", "GET", "List all pets");
modified_ep.deprecated = true;
let right = make_index(vec![modified_ep], vec![]);
let result = diff_indexes(&left, &right);
assert_eq!(result.endpoints.modified.len(), 1);
assert_eq!(result.endpoints.modified[0], ["GET", "/pets"]);
assert_eq!(result.endpoints.added.len(), 0);
assert_eq!(result.endpoints.removed.len(), 0);
}
#[test]
fn test_schema_added() {
let left = make_index(vec![], vec![]);
let right = make_index(
vec![],
vec![IndexedSchema {
name: "Pet".into(),
schema_ptr: "#/components/schemas/Pet".into(),
}],
);
let result = diff_indexes(&left, &right);
assert_eq!(result.schemas.added, vec!["Pet"]);
assert!(result.schemas.removed.is_empty());
}
#[test]
fn test_schema_removed() {
let left = make_index(
vec![],
vec![IndexedSchema {
name: "Pet".into(),
schema_ptr: "#/components/schemas/Pet".into(),
}],
);
let right = make_index(vec![], vec![]);
let result = diff_indexes(&left, &right);
assert!(result.schemas.added.is_empty());
assert_eq!(result.schemas.removed, vec!["Pet"]);
}
#[test]
fn test_total_changes_count() {
let left = make_index(
vec![
make_endpoint("/pets", "GET", "List pets"),
make_endpoint("/pets", "DELETE", "Delete pet"),
],
vec![IndexedSchema {
name: "Pet".into(),
schema_ptr: "#/components/schemas/Pet".into(),
}],
);
let right = make_index(
vec![
make_endpoint("/pets", "GET", "List all pets"), // modified
make_endpoint("/pets", "POST", "Create pet"), // added
// DELETE removed
],
vec![
// Pet removed
IndexedSchema {
name: "NewPet".into(),
schema_ptr: "#/components/schemas/NewPet".into(),
}, // added
],
);
let result = diff_indexes(&left, &right);
// 1 added ep + 1 removed ep + 1 modified ep + 1 added schema + 1 removed schema = 5
assert_eq!(result.summary.total_changes, 5);
assert!(result.summary.has_breaking); // DELETE was removed
}
#[test]
fn test_parameter_change_detected() {
let mut left_ep = make_endpoint("/pets", "GET", "List pets");
left_ep.parameters = vec![IndexedParam {
name: "limit".into(),
location: "query".into(),
required: false,
description: None,
}];
let mut right_ep = make_endpoint("/pets", "GET", "List pets");
right_ep.parameters = vec![IndexedParam {
name: "limit".into(),
location: "query".into(),
required: true, // changed from false to true
description: None,
}];
let left = make_index(vec![left_ep], vec![]);
let right = make_index(vec![right_ep], vec![]);
let result = diff_indexes(&left, &right);
assert_eq!(result.endpoints.modified.len(), 1);
}
#[test]
fn test_empty_indexes() {
let left = make_index(vec![], vec![]);
let right = make_index(vec![], vec![]);
let result = diff_indexes(&left, &right);
assert_eq!(result.summary.total_changes, 0);
assert!(!result.summary.has_breaking);
}
}

View File

@@ -196,6 +196,9 @@ impl AsyncHttpClient {
etag: Option<&str>,
last_modified: Option<&str>,
) -> Result<ConditionalFetchResult, SwaggerCliError> {
// Check network policy before any HTTP request
check_remote_fetch(self.network_policy)?;
let parsed = validate_url(url, self.allow_insecure_http)?;
let host = parsed
@@ -245,9 +248,10 @@ impl AsyncHttpClient {
s if s == StatusCode::TOO_MANY_REQUESTS || s.is_server_error() => {
attempts += 1;
if attempts > self.max_retries {
return Err(SwaggerCliError::Network(
client.get(url).send().await.unwrap_err(),
));
return Err(SwaggerCliError::InvalidSpec(format!(
"request to '{url}' failed after {} retries (last status: {status})",
self.max_retries,
)));
}
let delay = self.retry_delay(&response, attempts);
tokio::time::sleep(delay).await;
@@ -303,9 +307,10 @@ impl AsyncHttpClient {
s if s == StatusCode::TOO_MANY_REQUESTS || s.is_server_error() => {
attempts += 1;
if attempts > self.max_retries {
return Err(SwaggerCliError::Network(
client.get(url).send().await.unwrap_err(),
));
return Err(SwaggerCliError::InvalidSpec(format!(
"request to '{url}' failed after {} retries (last status: {status})",
self.max_retries,
)));
}
let delay = self.retry_delay(&response, attempts);
tokio::time::sleep(delay).await;

View File

@@ -1,5 +1,6 @@
pub mod cache;
pub mod config;
pub mod diff;
pub mod http;
pub mod indexer;
pub mod network;