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

@@ -11,6 +11,7 @@ use crate::core::config::cache_dir;
use crate::errors::SwaggerCliError;
use crate::output::robot::robot_success;
use crate::output::table::render_table_or_empty;
use crate::utils::dir_size;
// ---------------------------------------------------------------------------
// CLI args
@@ -117,19 +118,6 @@ struct StatsRow {
// Helpers
// ---------------------------------------------------------------------------
/// Walk every file in `dir` (non-recursive) and sum metadata().len().
fn dir_size(dir: &std::path::Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
entries
.filter_map(Result::ok)
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
}
fn human_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;

View File

@@ -1,18 +1,247 @@
use std::time::Instant;
use clap::Args as ClapArgs;
use serde::Serialize;
use tabled::Tabled;
use crate::core::cache::CacheManager;
use crate::core::config::cache_dir;
use crate::core::diff::{self, DiffResult};
use crate::errors::SwaggerCliError;
use crate::output::robot;
use crate::output::table::render_table_or_empty;
/// Compare two versions of a spec
/// Compare two cached specs
#[derive(Debug, ClapArgs)]
pub struct Args {
/// Alias of the spec to diff
pub alias: String,
/// Left alias (baseline)
pub left: String,
/// Revision to compare against (default: previous)
/// Right alias (comparison)
pub right: String,
/// Exit non-zero if changes at this level: breaking
#[arg(long, value_name = "LEVEL")]
pub fail_on: Option<String>,
/// Include per-item change descriptions
#[arg(long)]
pub rev: Option<String>,
pub details: bool,
}
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
Err(SwaggerCliError::Usage("diff not yet implemented".into()))
// ---------------------------------------------------------------------------
// Robot output structs
// ---------------------------------------------------------------------------
#[derive(Debug, Serialize)]
struct DiffOutput {
left: String,
right: String,
changes: DiffResult,
}
// ---------------------------------------------------------------------------
// Human output row
// ---------------------------------------------------------------------------
#[derive(Tabled)]
struct ChangeRow {
#[tabled(rename = "TYPE")]
kind: String,
#[tabled(rename = "CHANGE")]
change: String,
#[tabled(rename = "ITEM")]
item: String,
}
// ---------------------------------------------------------------------------
// Execute
// ---------------------------------------------------------------------------
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
let start = Instant::now();
// Validate --fail-on value
if let Some(ref level) = args.fail_on
&& level != "breaking"
{
return Err(SwaggerCliError::Usage(format!(
"Invalid --fail-on level '{level}': only 'breaking' is supported"
)));
}
let cm = CacheManager::new(cache_dir());
let (left_index, _left_meta) = cm.load_index(&args.left)?;
let (right_index, _right_meta) = cm.load_index(&args.right)?;
let result = diff::diff_indexes(&left_index, &right_index);
let duration = start.elapsed();
let has_breaking = result.summary.has_breaking;
if robot_mode {
let output = DiffOutput {
left: args.left.clone(),
right: args.right.clone(),
changes: result,
};
robot::robot_success(output, "diff", duration);
} else {
println!("Diff: {} vs {}", args.left, args.right);
println!();
let mut rows: Vec<ChangeRow> = Vec::new();
for ep in &result.endpoints.added {
rows.push(ChangeRow {
kind: "endpoint".into(),
change: "added".into(),
item: format!("{} {}", ep[0], ep[1]),
});
}
for ep in &result.endpoints.removed {
rows.push(ChangeRow {
kind: "endpoint".into(),
change: "removed".into(),
item: format!("{} {}", ep[0], ep[1]),
});
}
for ep in &result.endpoints.modified {
rows.push(ChangeRow {
kind: "endpoint".into(),
change: "modified".into(),
item: format!("{} {}", ep[0], ep[1]),
});
}
for name in &result.schemas.added {
rows.push(ChangeRow {
kind: "schema".into(),
change: "added".into(),
item: name.clone(),
});
}
for name in &result.schemas.removed {
rows.push(ChangeRow {
kind: "schema".into(),
change: "removed".into(),
item: name.clone(),
});
}
let table = render_table_or_empty(&rows, "No differences found.");
println!("{table}");
if result.summary.total_changes > 0 {
println!();
println!(
"{} total change(s){}",
result.summary.total_changes,
if has_breaking {
" (includes breaking changes)"
} else {
""
}
);
}
}
// CI gate: exit non-zero on breaking changes when requested
if args.fail_on.as_deref() == Some("breaking") && has_breaking {
return Err(SwaggerCliError::Usage(
"Breaking changes detected (use --fail-on to control this check)".into(),
));
}
Ok(())
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::core::diff::diff_indexes;
use crate::core::spec::{IndexInfo, IndexedEndpoint, 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_diff_output_serialization() {
let left = make_index(vec![], vec![]);
let right = make_index(vec![make_endpoint("/pets", "GET", "List pets")], vec![]);
let changes = diff_indexes(&left, &right);
let output = DiffOutput {
left: "v1".into(),
right: "v2".into(),
changes,
};
let json = serde_json::to_string(&output).unwrap();
assert!(json.contains("\"left\":\"v1\""));
assert!(json.contains("\"right\":\"v2\""));
assert!(json.contains("\"added\""));
assert!(json.contains("\"total_changes\""));
}
#[test]
fn test_change_row_for_added_endpoint() {
let row = ChangeRow {
kind: "endpoint".into(),
change: "added".into(),
item: "POST /pets".into(),
};
assert_eq!(row.kind, "endpoint");
assert_eq!(row.change, "added");
assert_eq!(row.item, "POST /pets");
}
#[test]
fn test_fail_on_invalid_level() {
let args = Args {
left: "v1".into(),
right: "v2".into(),
fail_on: Some("minor".into()),
details: false,
};
// Validate --fail-on logic inline
if let Some(ref level) = args.fail_on {
assert_ne!(level, "breaking");
}
}
}

View File

@@ -12,6 +12,7 @@ use crate::core::indexer::{build_index, resolve_pointer};
use crate::core::spec::SpecIndex;
use crate::errors::SwaggerCliError;
use crate::output::robot;
use crate::utils::dir_size;
// ---------------------------------------------------------------------------
// CLI arguments
@@ -103,22 +104,6 @@ struct AliasCheckResult {
// Helpers
// ---------------------------------------------------------------------------
/// Compute total size of a directory (non-recursive into symlinks).
fn dir_size(path: &PathBuf) -> u64 {
let Ok(entries) = fs::read_dir(path) else {
return 0;
};
let mut total: u64 = 0;
for entry in entries.flatten() {
if let Ok(md) = entry.metadata()
&& md.is_file()
{
total += md.len();
}
}
total
}
/// Discover all alias directory names in the cache dir, including those
/// without a valid meta.json (which list_aliases would skip).
fn discover_alias_dirs(cache_root: &PathBuf) -> Vec<String> {

View File

@@ -7,7 +7,7 @@ use serde::Serialize;
use tokio::io::AsyncReadExt;
use crate::core::cache::{CacheManager, compute_hash, validate_alias};
use crate::core::config::{AuthType, Config, CredentialSource, cache_dir, config_path};
use crate::core::config::{AuthType, Config, cache_dir, config_path, resolve_credential};
use crate::core::http::AsyncHttpClient;
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
use crate::core::network::{NetworkPolicy, resolve_policy};
@@ -121,21 +121,6 @@ fn classify_source(url: &str) -> SourceKind {
// Auth header resolution
// ---------------------------------------------------------------------------
/// Resolve a credential source to its string value.
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})"
))),
}
}
/// Build the list of auth headers from CLI flags and config auth profile.
///
/// Precedence: --bearer and --header flags override auth profile values.
@@ -211,6 +196,7 @@ async fn fetch_inner(
cache_path: PathBuf,
robot_mode: bool,
network_policy: NetworkPolicy,
config_override: Option<&std::path::Path>,
) -> Result<(), SwaggerCliError> {
let start = Instant::now();
@@ -224,7 +210,7 @@ async fn fetch_inner(
}
// 3. Load config and resolve auth headers
let cfg = Config::load(&config_path(None))?;
let cfg = Config::load(&config_path(config_override))?;
let auth_headers = resolve_auth_headers(args, &cfg)?;
// 4. Fetch raw bytes based on source kind
@@ -353,10 +339,15 @@ async fn fetch_inner(
// Public entry point
// ---------------------------------------------------------------------------
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
pub async fn execute(
args: &Args,
robot_mode: bool,
network_flag: &str,
config_override: Option<&std::path::Path>,
) -> Result<(), SwaggerCliError> {
let cache = cache_dir();
let policy = resolve_policy("auto")?;
fetch_inner(args, cache, robot_mode, policy).await
let policy = resolve_policy(network_flag)?;
fetch_inner(args, cache, robot_mode, policy, config_override).await
}
// ---------------------------------------------------------------------------
@@ -366,6 +357,7 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
#[cfg(test)]
mod tests {
use super::*;
use crate::core::config::CredentialSource;
// -- Source classification -----------------------------------------------
@@ -650,7 +642,7 @@ mod tests {
let args = make_test_args(spec_path.to_str().unwrap(), "localtest");
let result = fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto).await;
let result = fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto, None).await;
assert!(result.is_ok(), "execute failed: {result:?}");
let cm = CacheManager::new(cache_path);
@@ -693,7 +685,7 @@ paths:
let args = make_test_args(spec_path.to_str().unwrap(), "yamltest");
let result = fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto).await;
let result = fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto, None).await;
assert!(result.is_ok(), "execute failed: {result:?}");
let cm = CacheManager::new(cache_path);
@@ -722,12 +714,12 @@ paths:
let args = make_test_args(spec_path.to_str().unwrap(), "dupetest");
assert!(
fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto)
fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto, None)
.await
.is_ok()
);
let result = fetch_inner(&args, cache_path, false, NetworkPolicy::Auto).await;
let result = fetch_inner(&args, cache_path, false, NetworkPolicy::Auto, None).await;
assert!(result.is_err());
match result.unwrap_err() {
SwaggerCliError::AliasExists(alias) => assert_eq!(alias, "dupetest"),
@@ -752,9 +744,15 @@ paths:
let args_v1 = make_test_args(spec_path.to_str().unwrap(), "forcetest");
assert!(
fetch_inner(&args_v1, cache_path.clone(), false, NetworkPolicy::Auto)
.await
.is_ok()
fetch_inner(
&args_v1,
cache_path.clone(),
false,
NetworkPolicy::Auto,
None
)
.await
.is_ok()
);
let spec_v2 = serde_json::json!({
@@ -767,9 +765,15 @@ paths:
let mut args_v2 = make_test_args(spec_path.to_str().unwrap(), "forcetest");
args_v2.force = true;
assert!(
fetch_inner(&args_v2, cache_path.clone(), false, NetworkPolicy::Auto)
.await
.is_ok()
fetch_inner(
&args_v2,
cache_path.clone(),
false,
NetworkPolicy::Auto,
None
)
.await
.is_ok()
);
let cm = CacheManager::new(cache_path);
@@ -795,7 +799,7 @@ paths:
let args = make_test_args(spec_path.to_str().unwrap(), "robottest");
let result = fetch_inner(&args, cache_path.clone(), true, NetworkPolicy::Auto).await;
let result = fetch_inner(&args, cache_path.clone(), true, NetworkPolicy::Auto, None).await;
assert!(result.is_ok(), "robot mode execute failed: {result:?}");
let cm = CacheManager::new(cache_path);
@@ -819,7 +823,7 @@ paths:
let args = make_test_args(spec_path.to_str().unwrap(), "../bad-alias");
let result = fetch_inner(&args, cache_path, false, NetworkPolicy::Auto).await;
let result = fetch_inner(&args, cache_path, false, NetworkPolicy::Auto, None).await;
assert!(result.is_err());
match result.unwrap_err() {
SwaggerCliError::Usage(msg) => {
@@ -847,7 +851,7 @@ paths:
let url = format!("file://{}", spec_path.to_str().unwrap());
let args = make_test_args(&url, "fileprefixtest");
let result = fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto).await;
let result = fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto, None).await;
assert!(result.is_ok(), "file:// prefix failed: {result:?}");
let cm = CacheManager::new(cache_path);
@@ -863,7 +867,7 @@ paths:
let args = make_test_args("file:///nonexistent/path/spec.json", "nofile");
let result = fetch_inner(&args, cache_path, false, NetworkPolicy::Auto).await;
let result = fetch_inner(&args, cache_path, false, NetworkPolicy::Auto, None).await;
assert!(result.is_err());
assert!(
matches!(result.unwrap_err(), SwaggerCliError::Io(_)),

View File

@@ -8,6 +8,7 @@ use tabled::Tabled;
use crate::core::cache::CacheManager;
use crate::core::config::cache_dir;
use crate::core::indexer::method_rank;
use crate::errors::SwaggerCliError;
use crate::output::robot;
use crate::output::table::render_table_or_empty;
@@ -88,23 +89,6 @@ struct EndpointRow {
summary: String,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Map an HTTP method string to a sort rank.
/// GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, everything else=5.
fn method_rank(method: &str) -> u8 {
match method.to_uppercase().as_str() {
"GET" => 0,
"POST" => 1,
"PUT" => 2,
"PATCH" => 3,
"DELETE" => 4,
_ => 5,
}
}
// ---------------------------------------------------------------------------
// Execute
// ---------------------------------------------------------------------------
@@ -602,7 +586,9 @@ mod tests {
assert_eq!(method_rank("PATCH"), 3);
assert_eq!(method_rank("DELETE"), 4);
assert_eq!(method_rank("OPTIONS"), 5);
assert_eq!(method_rank("HEAD"), 5);
assert_eq!(method_rank("HEAD"), 6);
assert_eq!(method_rank("TRACE"), 7);
assert_eq!(method_rank("FOOBAR"), 99);
}
#[test]

View File

@@ -6,7 +6,7 @@ use serde_json::Value;
use crate::core::cache::CacheManager;
use crate::core::config::cache_dir;
use crate::core::refs::expand_refs;
use crate::core::refs::{expand_refs, resolve_json_pointer};
use crate::errors::SwaggerCliError;
use crate::output::robot::robot_success;
@@ -47,34 +47,6 @@ pub struct ShowOutput {
pub security: Value,
}
/// Navigate a JSON value using a JSON Pointer (RFC 6901).
///
/// Unescapes `~1` -> `/` and `~0` -> `~` (decode ~1 first per spec).
fn navigate_pointer(root: &Value, pointer: &str) -> Option<Value> {
if pointer.is_empty() {
return None;
}
let stripped = pointer.strip_prefix('/')?;
let mut current = root;
for token in stripped.split('/') {
let unescaped = token.replace("~1", "/").replace("~0", "~");
match current {
Value::Object(map) => {
current = map.get(&unescaped)?;
}
Value::Array(arr) => {
let idx: usize = unescaped.parse().ok()?;
current = arr.get(idx)?;
}
_ => return None,
}
}
Some(current.clone())
}
pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> {
let start = Instant::now();
@@ -125,12 +97,14 @@ pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> {
let raw = cm.load_raw(&args.alias, &meta)?;
// Navigate to operation subtree
let operation = navigate_pointer(&raw, &endpoint.operation_ptr).ok_or_else(|| {
SwaggerCliError::Cache(format!(
"Failed to navigate to operation at pointer '{}' in raw spec for alias '{}'",
endpoint.operation_ptr, args.alias
))
})?;
let operation = resolve_json_pointer(&raw, &endpoint.operation_ptr)
.cloned()
.ok_or_else(|| {
SwaggerCliError::Cache(format!(
"Failed to navigate to operation at pointer '{}' in raw spec for alias '{}'",
endpoint.operation_ptr, args.alias
))
})?;
let mut operation = operation;
@@ -374,7 +348,7 @@ mod tests {
});
// Navigate to GET /pets/{petId}
let result = navigate_pointer(&raw, "/paths/~1pets~1{petId}/get");
let result = resolve_json_pointer(&raw, "/paths/~1pets~1{petId}/get");
assert!(result.is_some());
let op = result.unwrap();
assert_eq!(op["summary"], "Get a pet");
@@ -382,23 +356,23 @@ mod tests {
assert_eq!(op["parameters"][0]["name"], "petId");
// Navigate to DELETE /pets/{petId}
let result = navigate_pointer(&raw, "/paths/~1pets~1{petId}/delete");
let result = resolve_json_pointer(&raw, "/paths/~1pets~1{petId}/delete");
assert!(result.is_some());
let op = result.unwrap();
assert_eq!(op["summary"], "Delete a pet");
// Navigate to GET /pets
let result = navigate_pointer(&raw, "/paths/~1pets/get");
let result = resolve_json_pointer(&raw, "/paths/~1pets/get");
assert!(result.is_some());
let op = result.unwrap();
assert_eq!(op["summary"], "List pets");
// Invalid pointer
let result = navigate_pointer(&raw, "/paths/~1nonexistent/get");
let result = resolve_json_pointer(&raw, "/paths/~1nonexistent/get");
assert!(result.is_none());
// Empty pointer
let result = navigate_pointer(&raw, "");
let result = resolve_json_pointer(&raw, "");
assert!(result.is_none());
}

View File

@@ -7,9 +7,10 @@ use clap::Args as ClapArgs;
use serde::Serialize;
use crate::core::cache::{CacheManager, CacheMetadata, compute_hash, validate_alias};
use crate::core::config::{AuthType, Config, CredentialSource, config_path};
use crate::core::config::{AuthType, Config, config_path, resolve_credential};
use crate::core::http::{AsyncHttpClient, ConditionalFetchResult};
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
use crate::core::network::{NetworkPolicy, resolve_policy};
use crate::core::spec::SpecIndex;
use crate::errors::SwaggerCliError;
use crate::output::robot;
@@ -203,24 +204,6 @@ fn compute_diff(old: &SpecIndex, new: &SpecIndex) -> (ChangeSummary, ChangeDetai
(summary, details)
}
// ---------------------------------------------------------------------------
// Auth credential resolution
// ---------------------------------------------------------------------------
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})"
))),
}
}
// ---------------------------------------------------------------------------
// Core sync logic (testable with explicit cache path)
// ---------------------------------------------------------------------------
@@ -229,6 +212,8 @@ async fn sync_inner(
args: &Args,
cache_path: PathBuf,
robot_mode: bool,
network_policy: NetworkPolicy,
config_override: Option<&std::path::Path>,
) -> Result<(), SwaggerCliError> {
let start = Instant::now();
@@ -246,8 +231,10 @@ async fn sync_inner(
})?;
// 2. Build HTTP client
let cfg = Config::load(&config_path(None))?;
let mut builder = AsyncHttpClient::builder().allow_insecure_http(url.starts_with("http://"));
let cfg = Config::load(&config_path(config_override))?;
let mut builder = AsyncHttpClient::builder()
.allow_insecure_http(url.starts_with("http://"))
.network_policy(network_policy);
if let Some(profile_name) = &args.auth {
let profile = cfg.auth_profiles.get(profile_name).ok_or_else(|| {
@@ -434,7 +421,12 @@ fn output_changes(
// Public entry point
// ---------------------------------------------------------------------------
pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> {
pub async fn execute(
args: &Args,
robot: bool,
network_flag: &str,
config_override: Option<&std::path::Path>,
) -> Result<(), SwaggerCliError> {
if args.all {
return Err(SwaggerCliError::Usage(
"sync --all is not yet implemented".into(),
@@ -442,7 +434,8 @@ pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> {
}
let cache = crate::core::config::cache_dir();
sync_inner(args, cache, robot).await
let policy = resolve_policy(network_flag)?;
sync_inner(args, cache, robot, policy, config_override).await
}
// ---------------------------------------------------------------------------

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;

View File

@@ -63,15 +63,22 @@ async fn main() -> ExitCode {
let cmd = command_name(&cli);
let robot = cli.robot;
let network_flag = cli.network.as_str();
let config_override = cli.config.as_deref();
let result = match &cli.command {
Commands::Fetch(args) => swagger_cli::cli::fetch::execute(args, robot).await,
Commands::Fetch(args) => {
swagger_cli::cli::fetch::execute(args, robot, network_flag, config_override).await
}
Commands::List(args) => swagger_cli::cli::list::execute(args, robot).await,
Commands::Show(args) => swagger_cli::cli::show::execute(args, robot).await,
Commands::Search(args) => swagger_cli::cli::search::execute(args, robot).await,
Commands::Schemas(args) => swagger_cli::cli::schemas::execute(args, robot).await,
Commands::Tags(args) => swagger_cli::cli::tags::execute(args, robot).await,
Commands::Aliases(args) => swagger_cli::cli::aliases::execute(args, robot).await,
Commands::Sync(args) => swagger_cli::cli::sync_cmd::execute(args, robot).await,
Commands::Sync(args) => {
swagger_cli::cli::sync_cmd::execute(args, robot, network_flag, config_override).await
}
Commands::Doctor(args) => swagger_cli::cli::doctor::execute(args, robot).await,
Commands::Cache(args) => swagger_cli::cli::cache_cmd::execute(args, robot).await,
Commands::Diff(args) => swagger_cli::cli::diff::execute(args, robot).await,

View File

@@ -1,11 +1,5 @@
use std::io::IsTerminal;
use crate::errors::SwaggerCliError;
pub fn is_tty() -> bool {
std::io::stdout().is_terminal()
}
pub fn print_error(err: &SwaggerCliError) {
eprintln!("error: {err}");
if let Some(suggestion) = err.suggestion() {

View File

@@ -1 +1,16 @@
// Utility functions
use std::path::Path;
/// Compute total size of files in a directory (non-recursive, skips symlinks).
///
/// Used by doctor and cache commands to report disk usage per alias.
pub fn dir_size(path: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(path) else {
return 0;
};
entries
.filter_map(Result::ok)
.filter_map(|e| e.metadata().ok())
.filter(|m| m.is_file())
.map(|m| m.len())
.sum()
}