Wave 6: Integration tests, golden tests, index invariant tests, diff command (bd-rex, bd-2gp, bd-1ck)
This commit is contained in:
File diff suppressed because one or more lines are too long
83
docs/robot-schema/v1/error.schema.json
Normal file
83
docs/robot-schema/v1/error.schema.json
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://swagger-cli.dev/robot-schema/v1/error.schema.json",
|
||||||
|
"title": "Robot Error Response",
|
||||||
|
"description": "Envelope for all error robot-mode responses from swagger-cli. Written to stderr.",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["ok", "error", "meta"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"const": false
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"$ref": "#/$defs/error"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/$defs/meta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"error": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["code", "message"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"code": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Machine-readable error code.",
|
||||||
|
"enum": [
|
||||||
|
"USAGE_ERROR",
|
||||||
|
"NETWORK_ERROR",
|
||||||
|
"INVALID_SPEC",
|
||||||
|
"ALIAS_EXISTS",
|
||||||
|
"AUTH_ERROR",
|
||||||
|
"ALIAS_NOT_FOUND",
|
||||||
|
"CACHE_LOCKED",
|
||||||
|
"CACHE_ERROR",
|
||||||
|
"CONFIG_ERROR",
|
||||||
|
"IO_ERROR",
|
||||||
|
"JSON_ERROR",
|
||||||
|
"CACHE_INTEGRITY",
|
||||||
|
"OFFLINE_MODE",
|
||||||
|
"POLICY_BLOCKED"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"message": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Human-readable error description."
|
||||||
|
},
|
||||||
|
"suggestion": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional actionable suggestion for resolving the error."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["schema_version", "tool_version", "command", "duration_ms"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1,
|
||||||
|
"description": "Robot envelope schema version. Always 1 for v1."
|
||||||
|
},
|
||||||
|
"tool_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SemVer version of swagger-cli that produced this output."
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the command that was executed."
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Wall-clock execution time in milliseconds."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
62
docs/robot-schema/v1/success.schema.json
Normal file
62
docs/robot-schema/v1/success.schema.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://swagger-cli.dev/robot-schema/v1/success.schema.json",
|
||||||
|
"title": "Robot Success Response",
|
||||||
|
"description": "Envelope for all successful robot-mode responses from swagger-cli.",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["ok", "data", "meta"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"ok": {
|
||||||
|
"type": "boolean",
|
||||||
|
"const": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Command-specific payload. Shape varies by command."
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"$ref": "#/$defs/meta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"meta": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["schema_version", "tool_version", "command", "duration_ms"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"schema_version": {
|
||||||
|
"type": "integer",
|
||||||
|
"const": 1,
|
||||||
|
"description": "Robot envelope schema version. Always 1 for v1."
|
||||||
|
},
|
||||||
|
"tool_version": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "SemVer version of swagger-cli that produced this output."
|
||||||
|
},
|
||||||
|
"command": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the command that was executed.",
|
||||||
|
"enum": [
|
||||||
|
"fetch",
|
||||||
|
"list",
|
||||||
|
"show",
|
||||||
|
"search",
|
||||||
|
"schemas",
|
||||||
|
"tags",
|
||||||
|
"aliases",
|
||||||
|
"sync",
|
||||||
|
"doctor",
|
||||||
|
"cache",
|
||||||
|
"diff"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"duration_ms": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "Wall-clock execution time in milliseconds."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ use crate::core::config::cache_dir;
|
|||||||
use crate::errors::SwaggerCliError;
|
use crate::errors::SwaggerCliError;
|
||||||
use crate::output::robot::robot_success;
|
use crate::output::robot::robot_success;
|
||||||
use crate::output::table::render_table_or_empty;
|
use crate::output::table::render_table_or_empty;
|
||||||
|
use crate::utils::dir_size;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CLI args
|
// CLI args
|
||||||
@@ -117,19 +118,6 @@ struct StatsRow {
|
|||||||
// Helpers
|
// 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 {
|
fn human_bytes(bytes: u64) -> String {
|
||||||
const KB: u64 = 1024;
|
const KB: u64 = 1024;
|
||||||
const MB: u64 = KB * 1024;
|
const MB: u64 = KB * 1024;
|
||||||
|
|||||||
243
src/cli/diff.rs
243
src/cli/diff.rs
@@ -1,18 +1,247 @@
|
|||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
use clap::Args as ClapArgs;
|
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::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)]
|
#[derive(Debug, ClapArgs)]
|
||||||
pub struct Args {
|
pub struct Args {
|
||||||
/// Alias of the spec to diff
|
/// Left alias (baseline)
|
||||||
pub alias: String,
|
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)]
|
#[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");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use crate::core::indexer::{build_index, resolve_pointer};
|
|||||||
use crate::core::spec::SpecIndex;
|
use crate::core::spec::SpecIndex;
|
||||||
use crate::errors::SwaggerCliError;
|
use crate::errors::SwaggerCliError;
|
||||||
use crate::output::robot;
|
use crate::output::robot;
|
||||||
|
use crate::utils::dir_size;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CLI arguments
|
// CLI arguments
|
||||||
@@ -103,22 +104,6 @@ struct AliasCheckResult {
|
|||||||
// Helpers
|
// 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
|
/// Discover all alias directory names in the cache dir, including those
|
||||||
/// without a valid meta.json (which list_aliases would skip).
|
/// without a valid meta.json (which list_aliases would skip).
|
||||||
fn discover_alias_dirs(cache_root: &PathBuf) -> Vec<String> {
|
fn discover_alias_dirs(cache_root: &PathBuf) -> Vec<String> {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use serde::Serialize;
|
|||||||
use tokio::io::AsyncReadExt;
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
use crate::core::cache::{CacheManager, compute_hash, validate_alias};
|
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::http::AsyncHttpClient;
|
||||||
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
|
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
|
||||||
use crate::core::network::{NetworkPolicy, resolve_policy};
|
use crate::core::network::{NetworkPolicy, resolve_policy};
|
||||||
@@ -121,21 +121,6 @@ fn classify_source(url: &str) -> SourceKind {
|
|||||||
// Auth header resolution
|
// 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.
|
/// Build the list of auth headers from CLI flags and config auth profile.
|
||||||
///
|
///
|
||||||
/// Precedence: --bearer and --header flags override auth profile values.
|
/// Precedence: --bearer and --header flags override auth profile values.
|
||||||
@@ -211,6 +196,7 @@ async fn fetch_inner(
|
|||||||
cache_path: PathBuf,
|
cache_path: PathBuf,
|
||||||
robot_mode: bool,
|
robot_mode: bool,
|
||||||
network_policy: NetworkPolicy,
|
network_policy: NetworkPolicy,
|
||||||
|
config_override: Option<&std::path::Path>,
|
||||||
) -> Result<(), SwaggerCliError> {
|
) -> Result<(), SwaggerCliError> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
@@ -224,7 +210,7 @@ async fn fetch_inner(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Load config and resolve auth headers
|
// 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)?;
|
let auth_headers = resolve_auth_headers(args, &cfg)?;
|
||||||
|
|
||||||
// 4. Fetch raw bytes based on source kind
|
// 4. Fetch raw bytes based on source kind
|
||||||
@@ -353,10 +339,15 @@ async fn fetch_inner(
|
|||||||
// Public entry point
|
// 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 cache = cache_dir();
|
||||||
let policy = resolve_policy("auto")?;
|
let policy = resolve_policy(network_flag)?;
|
||||||
fetch_inner(args, cache, robot_mode, policy).await
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::core::config::CredentialSource;
|
||||||
|
|
||||||
// -- Source classification -----------------------------------------------
|
// -- Source classification -----------------------------------------------
|
||||||
|
|
||||||
@@ -650,7 +642,7 @@ mod tests {
|
|||||||
|
|
||||||
let args = make_test_args(spec_path.to_str().unwrap(), "localtest");
|
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:?}");
|
assert!(result.is_ok(), "execute failed: {result:?}");
|
||||||
|
|
||||||
let cm = CacheManager::new(cache_path);
|
let cm = CacheManager::new(cache_path);
|
||||||
@@ -693,7 +685,7 @@ paths:
|
|||||||
|
|
||||||
let args = make_test_args(spec_path.to_str().unwrap(), "yamltest");
|
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:?}");
|
assert!(result.is_ok(), "execute failed: {result:?}");
|
||||||
|
|
||||||
let cm = CacheManager::new(cache_path);
|
let cm = CacheManager::new(cache_path);
|
||||||
@@ -722,12 +714,12 @@ paths:
|
|||||||
let args = make_test_args(spec_path.to_str().unwrap(), "dupetest");
|
let args = make_test_args(spec_path.to_str().unwrap(), "dupetest");
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto)
|
fetch_inner(&args, cache_path.clone(), false, NetworkPolicy::Auto, None)
|
||||||
.await
|
.await
|
||||||
.is_ok()
|
.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());
|
assert!(result.is_err());
|
||||||
match result.unwrap_err() {
|
match result.unwrap_err() {
|
||||||
SwaggerCliError::AliasExists(alias) => assert_eq!(alias, "dupetest"),
|
SwaggerCliError::AliasExists(alias) => assert_eq!(alias, "dupetest"),
|
||||||
@@ -752,9 +744,15 @@ paths:
|
|||||||
|
|
||||||
let args_v1 = make_test_args(spec_path.to_str().unwrap(), "forcetest");
|
let args_v1 = make_test_args(spec_path.to_str().unwrap(), "forcetest");
|
||||||
assert!(
|
assert!(
|
||||||
fetch_inner(&args_v1, cache_path.clone(), false, NetworkPolicy::Auto)
|
fetch_inner(
|
||||||
.await
|
&args_v1,
|
||||||
.is_ok()
|
cache_path.clone(),
|
||||||
|
false,
|
||||||
|
NetworkPolicy::Auto,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
);
|
);
|
||||||
|
|
||||||
let spec_v2 = serde_json::json!({
|
let spec_v2 = serde_json::json!({
|
||||||
@@ -767,9 +765,15 @@ paths:
|
|||||||
let mut args_v2 = make_test_args(spec_path.to_str().unwrap(), "forcetest");
|
let mut args_v2 = make_test_args(spec_path.to_str().unwrap(), "forcetest");
|
||||||
args_v2.force = true;
|
args_v2.force = true;
|
||||||
assert!(
|
assert!(
|
||||||
fetch_inner(&args_v2, cache_path.clone(), false, NetworkPolicy::Auto)
|
fetch_inner(
|
||||||
.await
|
&args_v2,
|
||||||
.is_ok()
|
cache_path.clone(),
|
||||||
|
false,
|
||||||
|
NetworkPolicy::Auto,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
);
|
);
|
||||||
|
|
||||||
let cm = CacheManager::new(cache_path);
|
let cm = CacheManager::new(cache_path);
|
||||||
@@ -795,7 +799,7 @@ paths:
|
|||||||
|
|
||||||
let args = make_test_args(spec_path.to_str().unwrap(), "robottest");
|
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:?}");
|
assert!(result.is_ok(), "robot mode execute failed: {result:?}");
|
||||||
|
|
||||||
let cm = CacheManager::new(cache_path);
|
let cm = CacheManager::new(cache_path);
|
||||||
@@ -819,7 +823,7 @@ paths:
|
|||||||
|
|
||||||
let args = make_test_args(spec_path.to_str().unwrap(), "../bad-alias");
|
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());
|
assert!(result.is_err());
|
||||||
match result.unwrap_err() {
|
match result.unwrap_err() {
|
||||||
SwaggerCliError::Usage(msg) => {
|
SwaggerCliError::Usage(msg) => {
|
||||||
@@ -847,7 +851,7 @@ paths:
|
|||||||
let url = format!("file://{}", spec_path.to_str().unwrap());
|
let url = format!("file://{}", spec_path.to_str().unwrap());
|
||||||
let args = make_test_args(&url, "fileprefixtest");
|
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:?}");
|
assert!(result.is_ok(), "file:// prefix failed: {result:?}");
|
||||||
|
|
||||||
let cm = CacheManager::new(cache_path);
|
let cm = CacheManager::new(cache_path);
|
||||||
@@ -863,7 +867,7 @@ paths:
|
|||||||
|
|
||||||
let args = make_test_args("file:///nonexistent/path/spec.json", "nofile");
|
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!(result.is_err());
|
||||||
assert!(
|
assert!(
|
||||||
matches!(result.unwrap_err(), SwaggerCliError::Io(_)),
|
matches!(result.unwrap_err(), SwaggerCliError::Io(_)),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use tabled::Tabled;
|
|||||||
|
|
||||||
use crate::core::cache::CacheManager;
|
use crate::core::cache::CacheManager;
|
||||||
use crate::core::config::cache_dir;
|
use crate::core::config::cache_dir;
|
||||||
|
use crate::core::indexer::method_rank;
|
||||||
use crate::errors::SwaggerCliError;
|
use crate::errors::SwaggerCliError;
|
||||||
use crate::output::robot;
|
use crate::output::robot;
|
||||||
use crate::output::table::render_table_or_empty;
|
use crate::output::table::render_table_or_empty;
|
||||||
@@ -88,23 +89,6 @@ struct EndpointRow {
|
|||||||
summary: String,
|
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
|
// Execute
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -602,7 +586,9 @@ mod tests {
|
|||||||
assert_eq!(method_rank("PATCH"), 3);
|
assert_eq!(method_rank("PATCH"), 3);
|
||||||
assert_eq!(method_rank("DELETE"), 4);
|
assert_eq!(method_rank("DELETE"), 4);
|
||||||
assert_eq!(method_rank("OPTIONS"), 5);
|
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]
|
#[test]
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use serde_json::Value;
|
|||||||
|
|
||||||
use crate::core::cache::CacheManager;
|
use crate::core::cache::CacheManager;
|
||||||
use crate::core::config::cache_dir;
|
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::errors::SwaggerCliError;
|
||||||
use crate::output::robot::robot_success;
|
use crate::output::robot::robot_success;
|
||||||
|
|
||||||
@@ -47,34 +47,6 @@ pub struct ShowOutput {
|
|||||||
pub security: Value,
|
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> {
|
pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> {
|
||||||
let start = Instant::now();
|
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)?;
|
let raw = cm.load_raw(&args.alias, &meta)?;
|
||||||
|
|
||||||
// Navigate to operation subtree
|
// Navigate to operation subtree
|
||||||
let operation = navigate_pointer(&raw, &endpoint.operation_ptr).ok_or_else(|| {
|
let operation = resolve_json_pointer(&raw, &endpoint.operation_ptr)
|
||||||
SwaggerCliError::Cache(format!(
|
.cloned()
|
||||||
"Failed to navigate to operation at pointer '{}' in raw spec for alias '{}'",
|
.ok_or_else(|| {
|
||||||
endpoint.operation_ptr, args.alias
|
SwaggerCliError::Cache(format!(
|
||||||
))
|
"Failed to navigate to operation at pointer '{}' in raw spec for alias '{}'",
|
||||||
})?;
|
endpoint.operation_ptr, args.alias
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut operation = operation;
|
let mut operation = operation;
|
||||||
|
|
||||||
@@ -374,7 +348,7 @@ mod tests {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Navigate to GET /pets/{petId}
|
// 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());
|
assert!(result.is_some());
|
||||||
let op = result.unwrap();
|
let op = result.unwrap();
|
||||||
assert_eq!(op["summary"], "Get a pet");
|
assert_eq!(op["summary"], "Get a pet");
|
||||||
@@ -382,23 +356,23 @@ mod tests {
|
|||||||
assert_eq!(op["parameters"][0]["name"], "petId");
|
assert_eq!(op["parameters"][0]["name"], "petId");
|
||||||
|
|
||||||
// Navigate to DELETE /pets/{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());
|
assert!(result.is_some());
|
||||||
let op = result.unwrap();
|
let op = result.unwrap();
|
||||||
assert_eq!(op["summary"], "Delete a pet");
|
assert_eq!(op["summary"], "Delete a pet");
|
||||||
|
|
||||||
// Navigate to GET /pets
|
// 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());
|
assert!(result.is_some());
|
||||||
let op = result.unwrap();
|
let op = result.unwrap();
|
||||||
assert_eq!(op["summary"], "List pets");
|
assert_eq!(op["summary"], "List pets");
|
||||||
|
|
||||||
// Invalid pointer
|
// Invalid pointer
|
||||||
let result = navigate_pointer(&raw, "/paths/~1nonexistent/get");
|
let result = resolve_json_pointer(&raw, "/paths/~1nonexistent/get");
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
|
|
||||||
// Empty pointer
|
// Empty pointer
|
||||||
let result = navigate_pointer(&raw, "");
|
let result = resolve_json_pointer(&raw, "");
|
||||||
assert!(result.is_none());
|
assert!(result.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ use clap::Args as ClapArgs;
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::core::cache::{CacheManager, CacheMetadata, compute_hash, validate_alias};
|
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::http::{AsyncHttpClient, ConditionalFetchResult};
|
||||||
use crate::core::indexer::{Format, build_index, detect_format, normalize_to_json};
|
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::core::spec::SpecIndex;
|
||||||
use crate::errors::SwaggerCliError;
|
use crate::errors::SwaggerCliError;
|
||||||
use crate::output::robot;
|
use crate::output::robot;
|
||||||
@@ -203,24 +204,6 @@ fn compute_diff(old: &SpecIndex, new: &SpecIndex) -> (ChangeSummary, ChangeDetai
|
|||||||
(summary, details)
|
(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)
|
// Core sync logic (testable with explicit cache path)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -229,6 +212,8 @@ async fn sync_inner(
|
|||||||
args: &Args,
|
args: &Args,
|
||||||
cache_path: PathBuf,
|
cache_path: PathBuf,
|
||||||
robot_mode: bool,
|
robot_mode: bool,
|
||||||
|
network_policy: NetworkPolicy,
|
||||||
|
config_override: Option<&std::path::Path>,
|
||||||
) -> Result<(), SwaggerCliError> {
|
) -> Result<(), SwaggerCliError> {
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
|
|
||||||
@@ -246,8 +231,10 @@ async fn sync_inner(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// 2. Build HTTP client
|
// 2. Build HTTP client
|
||||||
let cfg = Config::load(&config_path(None))?;
|
let cfg = Config::load(&config_path(config_override))?;
|
||||||
let mut builder = AsyncHttpClient::builder().allow_insecure_http(url.starts_with("http://"));
|
let mut builder = AsyncHttpClient::builder()
|
||||||
|
.allow_insecure_http(url.starts_with("http://"))
|
||||||
|
.network_policy(network_policy);
|
||||||
|
|
||||||
if let Some(profile_name) = &args.auth {
|
if let Some(profile_name) = &args.auth {
|
||||||
let profile = cfg.auth_profiles.get(profile_name).ok_or_else(|| {
|
let profile = cfg.auth_profiles.get(profile_name).ok_or_else(|| {
|
||||||
@@ -434,7 +421,12 @@ fn output_changes(
|
|||||||
// Public entry point
|
// 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 {
|
if args.all {
|
||||||
return Err(SwaggerCliError::Usage(
|
return Err(SwaggerCliError::Usage(
|
||||||
"sync --all is not yet implemented".into(),
|
"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();
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use fs2::FileExt;
|
|||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
use crate::errors::SwaggerCliError;
|
use crate::errors::SwaggerCliError;
|
||||||
|
|
||||||
@@ -46,8 +47,11 @@ impl CacheMetadata {
|
|||||||
///
|
///
|
||||||
/// Accepts: alphanumeric start, then alphanumeric/dot/dash/underscore, 1-64 chars.
|
/// Accepts: alphanumeric start, then alphanumeric/dot/dash/underscore, 1-64 chars.
|
||||||
/// Rejects: path separators, `..`, leading dots, Windows reserved device names.
|
/// 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> {
|
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) {
|
if !pattern.is_match(alias) {
|
||||||
return Err(SwaggerCliError::Usage(format!(
|
return Err(SwaggerCliError::Usage(format!(
|
||||||
|
|||||||
@@ -134,12 +134,45 @@ impl Config {
|
|||||||
let contents =
|
let contents =
|
||||||
toml::to_string_pretty(self).map_err(|e| SwaggerCliError::Config(e.to_string()))?;
|
toml::to_string_pretty(self).map_err(|e| SwaggerCliError::Config(e.to_string()))?;
|
||||||
|
|
||||||
std::fs::write(path, contents).map_err(|e| {
|
// Atomic write: write to .tmp, fsync, then rename to avoid corruption on crash
|
||||||
SwaggerCliError::Config(format!("failed to write {}: {e}", path.display()))
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
353
src/core/diff.rs
Normal file
353
src/core/diff.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,6 +196,9 @@ impl AsyncHttpClient {
|
|||||||
etag: Option<&str>,
|
etag: Option<&str>,
|
||||||
last_modified: Option<&str>,
|
last_modified: Option<&str>,
|
||||||
) -> Result<ConditionalFetchResult, SwaggerCliError> {
|
) -> 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 parsed = validate_url(url, self.allow_insecure_http)?;
|
||||||
|
|
||||||
let host = parsed
|
let host = parsed
|
||||||
@@ -245,9 +248,10 @@ impl AsyncHttpClient {
|
|||||||
s if s == StatusCode::TOO_MANY_REQUESTS || s.is_server_error() => {
|
s if s == StatusCode::TOO_MANY_REQUESTS || s.is_server_error() => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if attempts > self.max_retries {
|
if attempts > self.max_retries {
|
||||||
return Err(SwaggerCliError::Network(
|
return Err(SwaggerCliError::InvalidSpec(format!(
|
||||||
client.get(url).send().await.unwrap_err(),
|
"request to '{url}' failed after {} retries (last status: {status})",
|
||||||
));
|
self.max_retries,
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
let delay = self.retry_delay(&response, attempts);
|
let delay = self.retry_delay(&response, attempts);
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
@@ -303,9 +307,10 @@ impl AsyncHttpClient {
|
|||||||
s if s == StatusCode::TOO_MANY_REQUESTS || s.is_server_error() => {
|
s if s == StatusCode::TOO_MANY_REQUESTS || s.is_server_error() => {
|
||||||
attempts += 1;
|
attempts += 1;
|
||||||
if attempts > self.max_retries {
|
if attempts > self.max_retries {
|
||||||
return Err(SwaggerCliError::Network(
|
return Err(SwaggerCliError::InvalidSpec(format!(
|
||||||
client.get(url).send().await.unwrap_err(),
|
"request to '{url}' failed after {} retries (last status: {status})",
|
||||||
));
|
self.max_retries,
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
let delay = self.retry_delay(&response, attempts);
|
let delay = self.retry_delay(&response, attempts);
|
||||||
tokio::time::sleep(delay).await;
|
tokio::time::sleep(delay).await;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
pub mod cache;
|
pub mod cache;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
|
pub mod diff;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod indexer;
|
pub mod indexer;
|
||||||
pub mod network;
|
pub mod network;
|
||||||
|
|||||||
11
src/main.rs
11
src/main.rs
@@ -63,15 +63,22 @@ async fn main() -> ExitCode {
|
|||||||
let cmd = command_name(&cli);
|
let cmd = command_name(&cli);
|
||||||
let robot = cli.robot;
|
let robot = cli.robot;
|
||||||
|
|
||||||
|
let network_flag = cli.network.as_str();
|
||||||
|
let config_override = cli.config.as_deref();
|
||||||
|
|
||||||
let result = match &cli.command {
|
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::List(args) => swagger_cli::cli::list::execute(args, robot).await,
|
||||||
Commands::Show(args) => swagger_cli::cli::show::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::Search(args) => swagger_cli::cli::search::execute(args, robot).await,
|
||||||
Commands::Schemas(args) => swagger_cli::cli::schemas::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::Tags(args) => swagger_cli::cli::tags::execute(args, robot).await,
|
||||||
Commands::Aliases(args) => swagger_cli::cli::aliases::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::Doctor(args) => swagger_cli::cli::doctor::execute(args, robot).await,
|
||||||
Commands::Cache(args) => swagger_cli::cli::cache_cmd::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,
|
Commands::Diff(args) => swagger_cli::cli::diff::execute(args, robot).await,
|
||||||
|
|||||||
@@ -1,11 +1,5 @@
|
|||||||
use std::io::IsTerminal;
|
|
||||||
|
|
||||||
use crate::errors::SwaggerCliError;
|
use crate::errors::SwaggerCliError;
|
||||||
|
|
||||||
pub fn is_tty() -> bool {
|
|
||||||
std::io::stdout().is_terminal()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn print_error(err: &SwaggerCliError) {
|
pub fn print_error(err: &SwaggerCliError) {
|
||||||
eprintln!("error: {err}");
|
eprintln!("error: {err}");
|
||||||
if let Some(suggestion) = err.suggestion() {
|
if let Some(suggestion) = err.suggestion() {
|
||||||
|
|||||||
17
src/utils.rs
17
src/utils.rs
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
72
tests/fixtures/golden/list.json
vendored
Normal file
72
tests/fixtures/golden/list.json
vendored
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"applied_filters": {},
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"deprecated": false,
|
||||||
|
"method": "GET",
|
||||||
|
"operation_id": "listPets",
|
||||||
|
"path": "/pets",
|
||||||
|
"summary": "List all pets",
|
||||||
|
"tags": [
|
||||||
|
"pets"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deprecated": false,
|
||||||
|
"method": "POST",
|
||||||
|
"operation_id": "createPet",
|
||||||
|
"path": "/pets",
|
||||||
|
"summary": "Create a pet",
|
||||||
|
"tags": [
|
||||||
|
"pets"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deprecated": false,
|
||||||
|
"method": "GET",
|
||||||
|
"operation_id": "showPetById",
|
||||||
|
"path": "/pets/{petId}",
|
||||||
|
"summary": "Get a pet by ID",
|
||||||
|
"tags": [
|
||||||
|
"pets"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deprecated": true,
|
||||||
|
"method": "DELETE",
|
||||||
|
"operation_id": "deletePet",
|
||||||
|
"path": "/pets/{petId}",
|
||||||
|
"summary": "Delete a pet",
|
||||||
|
"tags": [
|
||||||
|
"pets"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"deprecated": false,
|
||||||
|
"method": "GET",
|
||||||
|
"operation_id": "getInventory",
|
||||||
|
"path": "/store/inventory",
|
||||||
|
"summary": "Get store inventory",
|
||||||
|
"tags": [
|
||||||
|
"store"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"filtered": 5,
|
||||||
|
"meta": {
|
||||||
|
"alias": "petstore",
|
||||||
|
"cached_at": "MASKED_TIMESTAMP",
|
||||||
|
"duration_ms": 0,
|
||||||
|
"spec_version": "1.0.0"
|
||||||
|
},
|
||||||
|
"total": 5
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"command": "list",
|
||||||
|
"duration_ms": 0,
|
||||||
|
"schema_version": 1,
|
||||||
|
"tool_version": "MASKED"
|
||||||
|
},
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
23
tests/fixtures/golden/schemas_list.json
vendored
Normal file
23
tests/fixtures/golden/schemas_list.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"schemas": [
|
||||||
|
{
|
||||||
|
"name": "Error"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "NewPet"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Pet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 3
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"command": "schemas",
|
||||||
|
"duration_ms": 0,
|
||||||
|
"schema_version": 1,
|
||||||
|
"tool_version": "MASKED"
|
||||||
|
},
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
31
tests/fixtures/golden/schemas_show.json
vendored
Normal file
31
tests/fixtures/golden/schemas_show.json
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"name": "Pet",
|
||||||
|
"schema": {
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"format": "int64",
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"tag": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"command": "schemas",
|
||||||
|
"duration_ms": 0,
|
||||||
|
"schema_version": 1,
|
||||||
|
"tool_version": "MASKED"
|
||||||
|
},
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
110
tests/fixtures/golden/search.json
vendored
Normal file
110
tests/fixtures/golden/search.json
vendored
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"field": "path",
|
||||||
|
"snippet": "/pets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "summary",
|
||||||
|
"snippet": "List all pets"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"method": "GET",
|
||||||
|
"name": "/pets",
|
||||||
|
"rank": 1,
|
||||||
|
"score": 3000,
|
||||||
|
"summary": "List all pets",
|
||||||
|
"type": "endpoint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"field": "path",
|
||||||
|
"snippet": "/pets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "summary",
|
||||||
|
"snippet": "Create a pet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"method": "POST",
|
||||||
|
"name": "/pets",
|
||||||
|
"rank": 2,
|
||||||
|
"score": 3000,
|
||||||
|
"summary": "Create a pet",
|
||||||
|
"type": "endpoint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"field": "path",
|
||||||
|
"snippet": "/pets/{petId}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "summary",
|
||||||
|
"snippet": "Get a pet by ID"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"method": "GET",
|
||||||
|
"name": "/pets/{petId}",
|
||||||
|
"rank": 3,
|
||||||
|
"score": 3000,
|
||||||
|
"summary": "Get a pet by ID",
|
||||||
|
"type": "endpoint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"field": "path",
|
||||||
|
"snippet": "/pets/{petId}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"field": "summary",
|
||||||
|
"snippet": "Delete a pet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"method": "DELETE",
|
||||||
|
"name": "/pets/{petId}",
|
||||||
|
"rank": 4,
|
||||||
|
"score": 3000,
|
||||||
|
"summary": "Delete a pet",
|
||||||
|
"type": "endpoint"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"field": "schema_name",
|
||||||
|
"snippet": "NewPet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "NewPet",
|
||||||
|
"rank": 5,
|
||||||
|
"score": 1600,
|
||||||
|
"type": "schema"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
{
|
||||||
|
"field": "schema_name",
|
||||||
|
"snippet": "Pet"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "Pet",
|
||||||
|
"rank": 6,
|
||||||
|
"score": 1600,
|
||||||
|
"type": "schema"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 6
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"command": "search",
|
||||||
|
"duration_ms": 0,
|
||||||
|
"schema_version": 1,
|
||||||
|
"tool_version": "MASKED"
|
||||||
|
},
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
51
tests/fixtures/golden/show.json
vendored
Normal file
51
tests/fixtures/golden/show.json
vendored
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"deprecated": false,
|
||||||
|
"description": null,
|
||||||
|
"method": "GET",
|
||||||
|
"operation_id": "listPets",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"description": "Maximum number of items to return",
|
||||||
|
"in": "query",
|
||||||
|
"name": "limit",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Pagination offset",
|
||||||
|
"in": "query",
|
||||||
|
"name": "offset",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"path": "/pets",
|
||||||
|
"request_body": null,
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Pet"
|
||||||
|
},
|
||||||
|
"type": "array"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"description": "A list of pets"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [],
|
||||||
|
"summary": "List all pets",
|
||||||
|
"tags": [
|
||||||
|
"pets"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"command": "show",
|
||||||
|
"duration_ms": 0,
|
||||||
|
"schema_version": 1,
|
||||||
|
"tool_version": "MASKED"
|
||||||
|
},
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
24
tests/fixtures/golden/tags.json
vendored
Normal file
24
tests/fixtures/golden/tags.json
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"tags": [
|
||||||
|
{
|
||||||
|
"description": "Pet operations",
|
||||||
|
"endpoint_count": 4,
|
||||||
|
"name": "pets"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "Store operations",
|
||||||
|
"endpoint_count": 1,
|
||||||
|
"name": "store"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 2
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"command": "tags",
|
||||||
|
"duration_ms": 0,
|
||||||
|
"schema_version": 1,
|
||||||
|
"tool_version": "MASKED"
|
||||||
|
},
|
||||||
|
"ok": true
|
||||||
|
}
|
||||||
321
tests/golden_test.rs
Normal file
321
tests/golden_test.rs
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
mod helpers;
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// Recursively mask dynamic fields (timestamps, durations, versions) for stable comparison.
|
||||||
|
fn normalize_value(value: &mut Value) {
|
||||||
|
match value {
|
||||||
|
Value::Object(map) => {
|
||||||
|
for (key, val) in map.iter_mut() {
|
||||||
|
match key.as_str() {
|
||||||
|
"duration_ms" if val.is_number() => *val = Value::Number(0.into()),
|
||||||
|
"tool_version" if val.is_string() => *val = Value::String("MASKED".into()),
|
||||||
|
"cached_at" | "fetched_at" | "last_accessed" if val.is_string() => {
|
||||||
|
*val = Value::String("MASKED_TIMESTAMP".into())
|
||||||
|
}
|
||||||
|
_ => normalize_value(val),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Array(arr) => {
|
||||||
|
for item in arr.iter_mut() {
|
||||||
|
normalize_value(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize robot JSON for golden comparison: mask all dynamic fields.
|
||||||
|
fn normalize_robot(mut json: Value) -> Value {
|
||||||
|
normalize_value(&mut json);
|
||||||
|
json
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a robot success envelope has the correct structural invariants.
|
||||||
|
fn assert_robot_success_structure(json: &Value, expected_command: &str) {
|
||||||
|
// "ok" must be bool true
|
||||||
|
assert_eq!(
|
||||||
|
json.get("ok").and_then(|v| v.as_bool()),
|
||||||
|
Some(true),
|
||||||
|
"ok field must be true"
|
||||||
|
);
|
||||||
|
|
||||||
|
// "data" must be an object
|
||||||
|
assert!(
|
||||||
|
json.get("data").is_some_and(|v| v.is_object()),
|
||||||
|
"data field must be an object"
|
||||||
|
);
|
||||||
|
|
||||||
|
// "meta" must be an object with required fields
|
||||||
|
let meta = json.get("meta").expect("meta field is required");
|
||||||
|
assert!(meta.is_object(), "meta must be an object");
|
||||||
|
|
||||||
|
// meta.schema_version must be number == 1
|
||||||
|
let sv = meta
|
||||||
|
.get("schema_version")
|
||||||
|
.expect("meta.schema_version required");
|
||||||
|
assert!(sv.is_number(), "meta.schema_version must be a number");
|
||||||
|
assert_eq!(sv.as_u64(), Some(1), "meta.schema_version must equal 1");
|
||||||
|
|
||||||
|
// meta.tool_version must be a string
|
||||||
|
let tv = meta
|
||||||
|
.get("tool_version")
|
||||||
|
.expect("meta.tool_version required");
|
||||||
|
assert!(tv.is_string(), "meta.tool_version must be a string");
|
||||||
|
|
||||||
|
// meta.command must be a string matching expected
|
||||||
|
let cmd = meta.get("command").expect("meta.command required");
|
||||||
|
assert!(cmd.is_string(), "meta.command must be a string");
|
||||||
|
assert_eq!(
|
||||||
|
cmd.as_str().unwrap(),
|
||||||
|
expected_command,
|
||||||
|
"meta.command must match the command run"
|
||||||
|
);
|
||||||
|
|
||||||
|
// meta.duration_ms must be a number
|
||||||
|
let dur = meta.get("duration_ms").expect("meta.duration_ms required");
|
||||||
|
assert!(dur.is_number(), "meta.duration_ms must be a number");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assert that a robot error envelope has the correct structural invariants.
|
||||||
|
fn assert_robot_error_structure(json: &Value) {
|
||||||
|
// "ok" must be bool false
|
||||||
|
assert_eq!(
|
||||||
|
json.get("ok").and_then(|v| v.as_bool()),
|
||||||
|
Some(false),
|
||||||
|
"ok field must be false on error"
|
||||||
|
);
|
||||||
|
|
||||||
|
// "data" should be absent or null
|
||||||
|
let data = json.get("data");
|
||||||
|
assert!(
|
||||||
|
data.is_none() || data.unwrap().is_null(),
|
||||||
|
"data field must be null or absent on error"
|
||||||
|
);
|
||||||
|
|
||||||
|
// "error" must be an object with code and message
|
||||||
|
let error = json.get("error").expect("error field is required");
|
||||||
|
assert!(error.is_object(), "error must be an object");
|
||||||
|
assert!(
|
||||||
|
error.get("code").is_some_and(|v| v.is_string()),
|
||||||
|
"error.code must be a string"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
error.get("message").is_some_and(|v| v.is_string()),
|
||||||
|
"error.message must be a string"
|
||||||
|
);
|
||||||
|
|
||||||
|
// "meta" structural invariants (same as success)
|
||||||
|
let meta = json.get("meta").expect("meta field is required");
|
||||||
|
assert!(meta.is_object(), "meta must be an object");
|
||||||
|
assert!(
|
||||||
|
meta.get("schema_version").is_some_and(|v| v.is_number()),
|
||||||
|
"meta.schema_version must be a number"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
meta.get("tool_version").is_some_and(|v| v.is_string()),
|
||||||
|
"meta.tool_version must be a string"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
meta.get("command").is_some_and(|v| v.is_string()),
|
||||||
|
"meta.command must be a string"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
meta.get("duration_ms").is_some_and(|v| v.is_number()),
|
||||||
|
"meta.duration_ms must be a number"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load a golden snapshot file, parse as JSON.
|
||||||
|
fn load_golden(name: &str) -> Value {
|
||||||
|
let path = helpers::fixture_path(&format!("golden/{name}"));
|
||||||
|
let content = std::fs::read_to_string(&path)
|
||||||
|
.unwrap_or_else(|e| panic!("failed to read golden file {}: {e}", path.display()));
|
||||||
|
serde_json::from_str(&content)
|
||||||
|
.unwrap_or_else(|e| panic!("failed to parse golden file {}: {e}", path.display()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write a golden snapshot file if it does not exist (first-run bootstrap).
|
||||||
|
fn write_golden_if_missing(name: &str, value: &Value) {
|
||||||
|
let path = helpers::fixture_path(&format!("golden/{name}"));
|
||||||
|
if !path.exists() {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).expect("failed to create golden dir");
|
||||||
|
}
|
||||||
|
let pretty =
|
||||||
|
serde_json::to_string_pretty(value).expect("failed to serialize golden snapshot");
|
||||||
|
std::fs::write(&path, pretty).expect("failed to write golden snapshot");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Golden structure tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_golden_list_robot_structure() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["list", "petstore", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_robot_success_structure(&json, "list");
|
||||||
|
|
||||||
|
// Snapshot comparison
|
||||||
|
let normalized = normalize_robot(json);
|
||||||
|
write_golden_if_missing("list.json", &normalized);
|
||||||
|
let golden = load_golden("list.json");
|
||||||
|
assert_eq!(
|
||||||
|
normalized, golden,
|
||||||
|
"list robot output does not match golden snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_golden_show_robot_structure() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&["show", "petstore", "/pets", "--method", "GET", "--robot"],
|
||||||
|
)
|
||||||
|
.success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_robot_success_structure(&json, "show");
|
||||||
|
|
||||||
|
let normalized = normalize_robot(json);
|
||||||
|
write_golden_if_missing("show.json", &normalized);
|
||||||
|
let golden = load_golden("show.json");
|
||||||
|
assert_eq!(
|
||||||
|
normalized, golden,
|
||||||
|
"show robot output does not match golden snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_golden_search_robot_structure() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["search", "petstore", "pet", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_robot_success_structure(&json, "search");
|
||||||
|
|
||||||
|
let normalized = normalize_robot(json);
|
||||||
|
write_golden_if_missing("search.json", &normalized);
|
||||||
|
let golden = load_golden("search.json");
|
||||||
|
assert_eq!(
|
||||||
|
normalized, golden,
|
||||||
|
"search robot output does not match golden snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_golden_schemas_list_robot_structure() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["schemas", "petstore", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_robot_success_structure(&json, "schemas");
|
||||||
|
|
||||||
|
let normalized = normalize_robot(json);
|
||||||
|
write_golden_if_missing("schemas_list.json", &normalized);
|
||||||
|
let golden = load_golden("schemas_list.json");
|
||||||
|
assert_eq!(
|
||||||
|
normalized, golden,
|
||||||
|
"schemas list robot output does not match golden snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_golden_schemas_show_robot_structure() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["schemas", "petstore", "--show", "Pet", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_robot_success_structure(&json, "schemas");
|
||||||
|
|
||||||
|
let normalized = normalize_robot(json);
|
||||||
|
write_golden_if_missing("schemas_show.json", &normalized);
|
||||||
|
let golden = load_golden("schemas_show.json");
|
||||||
|
assert_eq!(
|
||||||
|
normalized, golden,
|
||||||
|
"schemas show robot output does not match golden snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_golden_tags_robot_structure() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["tags", "petstore", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_robot_success_structure(&json, "tags");
|
||||||
|
|
||||||
|
let normalized = normalize_robot(json);
|
||||||
|
write_golden_if_missing("tags.json", &normalized);
|
||||||
|
let golden = load_golden("tags.json");
|
||||||
|
assert_eq!(
|
||||||
|
normalized, golden,
|
||||||
|
"tags robot output does not match golden snapshot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_golden_aliases_robot_structure() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["aliases", "--list", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_robot_success_structure(&json, "aliases");
|
||||||
|
|
||||||
|
// Aliases output has dynamic timestamps so we only check structure,
|
||||||
|
// not golden snapshot equality. Normalize and check key shape.
|
||||||
|
let data = json.get("data").unwrap();
|
||||||
|
assert!(data.get("aliases").is_some_and(|v| v.is_array()));
|
||||||
|
assert!(data.get("count").is_some_and(|v| v.is_number()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_golden_error_structure() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
// Do NOT fetch -- alias does not exist
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["list", "nonexistent", "--robot"]);
|
||||||
|
|
||||||
|
// Command should fail with non-zero exit
|
||||||
|
let a = a.failure();
|
||||||
|
|
||||||
|
// Error JSON goes to stderr
|
||||||
|
let stderr = std::str::from_utf8(&a.get_output().stderr).expect("stderr not UTF-8");
|
||||||
|
let json: Value = serde_json::from_str(stderr.trim()).expect("stderr is not valid robot JSON");
|
||||||
|
|
||||||
|
assert_robot_error_structure(&json);
|
||||||
|
|
||||||
|
// Verify error code
|
||||||
|
assert_eq!(
|
||||||
|
json["error"]["code"].as_str(),
|
||||||
|
Some("ALIAS_NOT_FOUND"),
|
||||||
|
"error code should be ALIAS_NOT_FOUND"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify meta.command
|
||||||
|
assert_eq!(
|
||||||
|
json["meta"]["command"].as_str(),
|
||||||
|
Some("list"),
|
||||||
|
"meta.command should be list"
|
||||||
|
);
|
||||||
|
}
|
||||||
84
tests/index_invariant_test.rs
Normal file
84
tests/index_invariant_test.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
mod helpers;
|
||||||
|
|
||||||
|
/// Delete the raw.json file from a cached alias to prove index-only reads.
|
||||||
|
fn delete_raw_json(env: &helpers::TestEnv, alias: &str) {
|
||||||
|
let raw_path = env.home_dir.join("cache").join(alias).join("raw.json");
|
||||||
|
assert!(
|
||||||
|
raw_path.exists(),
|
||||||
|
"raw.json must exist before deletion: {}",
|
||||||
|
raw_path.display()
|
||||||
|
);
|
||||||
|
std::fs::remove_file(&raw_path).expect("failed to delete raw.json");
|
||||||
|
assert!(!raw_path.exists(), "raw.json should be gone after deletion");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Index-only reads: these commands must work WITHOUT raw.json
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_does_not_read_raw_json() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
delete_raw_json(&env, "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["list", "petstore", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
assert!(json["data"]["total"].as_u64().unwrap() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_does_not_read_raw_json() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
delete_raw_json(&env, "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["search", "petstore", "pet", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tags_does_not_read_raw_json() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
delete_raw_json(&env, "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["tags", "petstore", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_schemas_list_does_not_read_raw_json() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
delete_raw_json(&env, "petstore");
|
||||||
|
|
||||||
|
let a = helpers::run_cmd(&env, &["schemas", "petstore", "--robot"]).success();
|
||||||
|
let json = helpers::parse_robot_json(&a.get_output().stdout);
|
||||||
|
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Negative case: show REQUIRES raw.json
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_show_requires_raw_json() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
delete_raw_json(&env, "petstore");
|
||||||
|
|
||||||
|
// show needs raw.json to resolve the operation pointer
|
||||||
|
helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&["show", "petstore", "/pets", "--method", "GET", "--robot"],
|
||||||
|
)
|
||||||
|
.failure();
|
||||||
|
}
|
||||||
@@ -104,3 +104,521 @@ fn test_fetch_minimal_fixture() {
|
|||||||
assert_eq!(json["ok"], true);
|
assert_eq!(json["ok"], true);
|
||||||
assert_eq!(json["data"]["total"], 3);
|
assert_eq!(json["data"]["total"], 3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Fetch tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fetch_success_robot() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
let fixture = helpers::fixture_path("petstore.json");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&[
|
||||||
|
"fetch",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--alias",
|
||||||
|
"ps",
|
||||||
|
"--robot",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
assert_eq!(json["data"]["alias"], "ps");
|
||||||
|
assert!(json["data"]["endpoint_count"].as_u64().unwrap() > 0);
|
||||||
|
assert!(json["data"]["schema_count"].as_u64().unwrap() > 0);
|
||||||
|
assert!(
|
||||||
|
json["data"]["content_hash"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("sha256:")
|
||||||
|
);
|
||||||
|
assert_eq!(json["data"]["source_format"], "json");
|
||||||
|
assert_eq!(json["meta"]["command"], "fetch");
|
||||||
|
assert!(json["meta"]["schema_version"].as_u64().is_some());
|
||||||
|
assert!(json["meta"]["duration_ms"].as_u64().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fetch_invalid_json() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
|
||||||
|
// Create a non-JSON, non-YAML file that is not a valid OpenAPI spec
|
||||||
|
let bad_file = env.home_dir.join("not-a-spec.json");
|
||||||
|
std::fs::write(&bad_file, b"this is not json or yaml").unwrap();
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&[
|
||||||
|
"fetch",
|
||||||
|
bad_file.to_str().unwrap(),
|
||||||
|
"--alias",
|
||||||
|
"bad",
|
||||||
|
"--robot",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should fail -- the file is not valid JSON or YAML
|
||||||
|
let output = assert.get_output();
|
||||||
|
assert!(!output.status.success());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fetch_alias_exists_error() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "dupe");
|
||||||
|
|
||||||
|
let fixture = helpers::fixture_path("petstore.json");
|
||||||
|
let assert = helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&[
|
||||||
|
"fetch",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--alias",
|
||||||
|
"dupe",
|
||||||
|
"--robot",
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let output = assert.get_output();
|
||||||
|
assert!(!output.status.success());
|
||||||
|
|
||||||
|
// Exit code 6 = ALIAS_EXISTS
|
||||||
|
let stderr_json = helpers::parse_robot_json(&output.stderr);
|
||||||
|
assert_eq!(stderr_json["ok"], false);
|
||||||
|
assert_eq!(stderr_json["error"]["code"], "ALIAS_EXISTS");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fetch_force_overwrite() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "overwrite-me");
|
||||||
|
|
||||||
|
let fixture = helpers::fixture_path("minimal.json");
|
||||||
|
let assert = helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&[
|
||||||
|
"fetch",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--alias",
|
||||||
|
"overwrite-me",
|
||||||
|
"--force",
|
||||||
|
"--robot",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
assert_eq!(json["data"]["alias"], "overwrite-me");
|
||||||
|
// After force overwrite, the title should be from minimal.json
|
||||||
|
assert_eq!(json["data"]["title"], "Minimal API");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fetch_yaml_success() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
let fixture = helpers::fixture_path("petstore.yaml");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&[
|
||||||
|
"fetch",
|
||||||
|
fixture.to_str().unwrap(),
|
||||||
|
"--alias",
|
||||||
|
"yaml-test",
|
||||||
|
"--robot",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
assert_eq!(json["data"]["source_format"], "yaml");
|
||||||
|
assert!(json["data"]["endpoint_count"].as_u64().unwrap() > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_fetch_stdin() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
let fixture = helpers::fixture_path("petstore.json");
|
||||||
|
let content = std::fs::read(&fixture).unwrap();
|
||||||
|
|
||||||
|
#[allow(deprecated)]
|
||||||
|
let assert = assert_cmd::Command::cargo_bin("swagger-cli")
|
||||||
|
.expect("binary not found")
|
||||||
|
.env("SWAGGER_CLI_HOME", &env.home_dir)
|
||||||
|
.args(["fetch", "-", "--alias", "stdin-test", "--robot"])
|
||||||
|
.write_stdin(content)
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
assert_eq!(json["data"]["alias"], "stdin-test");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// List tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_filter_by_method() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert =
|
||||||
|
helpers::run_cmd(&env, &["list", "petstore", "--method", "GET", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let endpoints = json["data"]["endpoints"].as_array().unwrap();
|
||||||
|
assert!(!endpoints.is_empty());
|
||||||
|
for ep in endpoints {
|
||||||
|
assert_eq!(ep["method"], "GET");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_filter_by_tag() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert =
|
||||||
|
helpers::run_cmd(&env, &["list", "petstore", "--tag", "store", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let endpoints = json["data"]["endpoints"].as_array().unwrap();
|
||||||
|
assert!(!endpoints.is_empty());
|
||||||
|
for ep in endpoints {
|
||||||
|
let tags = ep["tags"].as_array().unwrap();
|
||||||
|
let tag_strings: Vec<&str> = tags.iter().filter_map(|t| t.as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
tag_strings
|
||||||
|
.iter()
|
||||||
|
.any(|t| t.to_lowercase().contains("store")),
|
||||||
|
"Expected 'store' tag in {tag_strings:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_path_regex() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert =
|
||||||
|
helpers::run_cmd(&env, &["list", "petstore", "--path", "pet.*", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let endpoints = json["data"]["endpoints"].as_array().unwrap();
|
||||||
|
assert!(!endpoints.is_empty());
|
||||||
|
for ep in endpoints {
|
||||||
|
let path = ep["path"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
path.contains("pet"),
|
||||||
|
"Expected path to match 'pet.*', got: {path}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_invalid_regex_error() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(&env, &["list", "petstore", "--path", "[invalid", "--robot"]);
|
||||||
|
|
||||||
|
let output = assert.get_output();
|
||||||
|
assert!(!output.status.success());
|
||||||
|
|
||||||
|
let stderr_json = helpers::parse_robot_json(&output.stderr);
|
||||||
|
assert_eq!(stderr_json["ok"], false);
|
||||||
|
assert_eq!(stderr_json["error"]["code"], "USAGE_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_limit() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(&env, &["list", "petstore", "--limit", "2", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let endpoints = json["data"]["endpoints"].as_array().unwrap();
|
||||||
|
assert!(endpoints.len() <= 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_list_all_flag() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(&env, &["list", "petstore", "--all", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let endpoints = json["data"]["endpoints"].as_array().unwrap();
|
||||||
|
let total = json["data"]["total"].as_u64().unwrap() as usize;
|
||||||
|
// With --all, endpoints shown should equal total (no truncation)
|
||||||
|
assert_eq!(endpoints.len(), total);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Show tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_show_endpoint_details() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&["show", "petstore", "/pets", "--method", "GET", "--robot"],
|
||||||
|
)
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
assert_eq!(json["data"]["path"], "/pets");
|
||||||
|
assert_eq!(json["data"]["method"], "GET");
|
||||||
|
assert!(json["data"]["summary"].as_str().is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_show_method_disambiguation() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
// /pets has both GET and POST -- omitting --method should error
|
||||||
|
let assert = helpers::run_cmd(&env, &["show", "petstore", "/pets", "--robot"]);
|
||||||
|
|
||||||
|
let output = assert.get_output();
|
||||||
|
assert!(!output.status.success());
|
||||||
|
|
||||||
|
let stderr_json = helpers::parse_robot_json(&output.stderr);
|
||||||
|
assert_eq!(stderr_json["ok"], false);
|
||||||
|
assert_eq!(stderr_json["error"]["code"], "USAGE_ERROR");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Search tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_basic() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(&env, &["search", "petstore", "pet", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let total = json["data"]["total"].as_u64().unwrap();
|
||||||
|
assert!(total > 0, "Expected search results for 'pet'");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_no_results() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(
|
||||||
|
&env,
|
||||||
|
&["search", "petstore", "zzzznonexistent99999", "--robot"],
|
||||||
|
)
|
||||||
|
.success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
assert_eq!(json["data"]["total"], 0);
|
||||||
|
assert!(json["data"]["results"].as_array().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_search_case_insensitive() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
// Default search is case-insensitive; "PET" should match "pet" paths
|
||||||
|
let assert = helpers::run_cmd(&env, &["search", "petstore", "PET", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let total = json["data"]["total"].as_u64().unwrap();
|
||||||
|
assert!(
|
||||||
|
total > 0,
|
||||||
|
"Expected case-insensitive search for 'PET' to find results"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Schemas tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_schemas_list() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(&env, &["schemas", "petstore", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let schemas = json["data"]["schemas"].as_array().unwrap();
|
||||||
|
assert!(!schemas.is_empty());
|
||||||
|
|
||||||
|
let names: Vec<&str> = schemas.iter().filter_map(|s| s["name"].as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
names.contains(&"Pet"),
|
||||||
|
"Expected 'Pet' schema, got: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names.contains(&"Error"),
|
||||||
|
"Expected 'Error' schema, got: {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_schemas_show() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert =
|
||||||
|
helpers::run_cmd(&env, &["schemas", "petstore", "--show", "Pet", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
assert_eq!(json["data"]["name"], "Pet");
|
||||||
|
assert!(json["data"]["schema"].is_object());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Tags tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_tags_list() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "petstore");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(&env, &["tags", "petstore", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let tags = json["data"]["tags"].as_array().unwrap();
|
||||||
|
assert!(!tags.is_empty());
|
||||||
|
|
||||||
|
let names: Vec<&str> = tags.iter().filter_map(|t| t["name"].as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
names.contains(&"pets"),
|
||||||
|
"Expected 'pets' tag, got: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names.contains(&"store"),
|
||||||
|
"Expected 'store' tag, got: {names:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Each tag should have an endpoint_count
|
||||||
|
for tag in tags {
|
||||||
|
assert!(
|
||||||
|
tag["endpoint_count"].as_u64().is_some(),
|
||||||
|
"Expected endpoint_count on tag: {tag}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Aliases tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_aliases_list() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "alias-one");
|
||||||
|
helpers::fetch_fixture(&env, "minimal.json", "alias-two");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(&env, &["aliases", "--list", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
let aliases = json["data"]["aliases"].as_array().unwrap();
|
||||||
|
let names: Vec<&str> = aliases.iter().filter_map(|a| a["name"].as_str()).collect();
|
||||||
|
assert!(
|
||||||
|
names.contains(&"alias-one"),
|
||||||
|
"Expected 'alias-one', got: {names:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
names.contains(&"alias-two"),
|
||||||
|
"Expected 'alias-two', got: {names:?}"
|
||||||
|
);
|
||||||
|
assert_eq!(json["data"]["count"].as_u64().unwrap(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_aliases_set_default() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "my-default");
|
||||||
|
|
||||||
|
let assert =
|
||||||
|
helpers::run_cmd(&env, &["aliases", "--set-default", "my-default", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
assert_eq!(json["data"]["name"], "my-default");
|
||||||
|
|
||||||
|
// Verify by listing
|
||||||
|
let list_assert = helpers::run_cmd(&env, &["aliases", "--list", "--robot"]).success();
|
||||||
|
let list_json = helpers::parse_robot_json(&list_assert.get_output().stdout);
|
||||||
|
assert_eq!(list_json["data"]["default_alias"], "my-default");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===========================================================================
|
||||||
|
// Doctor tests
|
||||||
|
// ===========================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_doctor_healthy() {
|
||||||
|
let env = helpers::TestEnv::new();
|
||||||
|
helpers::fetch_fixture(&env, "petstore.json", "healthy-spec");
|
||||||
|
|
||||||
|
let assert = helpers::run_cmd(&env, &["doctor", "--robot"]).success();
|
||||||
|
|
||||||
|
let json = helpers::parse_robot_json(&assert.get_output().stdout);
|
||||||
|
assert_eq!(json["ok"], true);
|
||||||
|
|
||||||
|
// health should be "healthy" or "warning" (warning is ok if stale threshold is very low)
|
||||||
|
let health = json["data"]["health"].as_str().unwrap();
|
||||||
|
assert!(
|
||||||
|
health == "healthy" || health == "warning",
|
||||||
|
"Expected healthy or warning, got: {health}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let aliases = json["data"]["aliases"].as_array().unwrap();
|
||||||
|
assert!(!aliases.is_empty());
|
||||||
|
|
||||||
|
let spec_report = aliases
|
||||||
|
.iter()
|
||||||
|
.find(|a| a["name"] == "healthy-spec")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(spec_report["status"], "healthy");
|
||||||
|
assert!(spec_report["endpoint_count"].as_u64().unwrap() > 0);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user