Wave 2: CLI skeleton, cache write, config system, spec indexer (bd-3d2, bd-1ie, bd-1sb, bd-189)
This commit is contained in:
23
src/cli/aliases.rs
Normal file
23
src/cli/aliases.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// Manage spec aliases
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// List all aliases
|
||||
#[arg(long)]
|
||||
pub list: bool,
|
||||
|
||||
/// Remove an alias
|
||||
#[arg(long)]
|
||||
pub remove: Option<String>,
|
||||
|
||||
/// Rename an alias (old=new)
|
||||
#[arg(long)]
|
||||
pub rename: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("aliases not yet implemented".into()))
|
||||
}
|
||||
23
src/cli/cache_cmd.rs
Normal file
23
src/cli/cache_cmd.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// Manage the spec cache
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Show cache location
|
||||
#[arg(long)]
|
||||
pub path: bool,
|
||||
|
||||
/// Clear the entire cache
|
||||
#[arg(long)]
|
||||
pub clear: bool,
|
||||
|
||||
/// Show cache size
|
||||
#[arg(long)]
|
||||
pub size: bool,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("cache not yet implemented".into()))
|
||||
}
|
||||
18
src/cli/diff.rs
Normal file
18
src/cli/diff.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// Compare two versions of a spec
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Alias of the spec to diff
|
||||
pub alias: String,
|
||||
|
||||
/// Revision to compare against (default: previous)
|
||||
#[arg(long)]
|
||||
pub rev: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("diff not yet implemented".into()))
|
||||
}
|
||||
15
src/cli/doctor.rs
Normal file
15
src/cli/doctor.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// Check cache health and diagnose issues
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Attempt to fix issues automatically
|
||||
#[arg(long)]
|
||||
pub fix: bool,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("doctor not yet implemented".into()))
|
||||
}
|
||||
26
src/cli/fetch.rs
Normal file
26
src/cli/fetch.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// Fetch and cache an OpenAPI spec
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// URL of the OpenAPI spec
|
||||
pub url: String,
|
||||
|
||||
/// Alias for the cached spec
|
||||
#[arg(long)]
|
||||
pub alias: Option<String>,
|
||||
|
||||
/// Overwrite existing alias
|
||||
#[arg(long)]
|
||||
pub force: bool,
|
||||
|
||||
/// Auth profile name from config
|
||||
#[arg(long)]
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("fetch not yet implemented".into()))
|
||||
}
|
||||
26
src/cli/list.rs
Normal file
26
src/cli/list.rs
Normal file
@@ -0,0 +1,26 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// List endpoints from a cached spec
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Alias of the cached spec
|
||||
pub alias: String,
|
||||
|
||||
/// Filter by HTTP method
|
||||
#[arg(long)]
|
||||
pub method: Option<String>,
|
||||
|
||||
/// Filter by tag
|
||||
#[arg(long)]
|
||||
pub tag: Option<String>,
|
||||
|
||||
/// Filter by path pattern
|
||||
#[arg(long)]
|
||||
pub path: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("list not yet implemented".into()))
|
||||
}
|
||||
@@ -1 +1,75 @@
|
||||
// CLI command definitions
|
||||
pub mod aliases;
|
||||
pub mod cache_cmd;
|
||||
pub mod diff;
|
||||
pub mod doctor;
|
||||
pub mod fetch;
|
||||
pub mod list;
|
||||
pub mod schemas;
|
||||
pub mod search;
|
||||
pub mod show;
|
||||
pub mod sync_cmd;
|
||||
pub mod tags;
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
/// A fast, cache-first CLI for exploring OpenAPI specs
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "swagger-cli", version, about)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
|
||||
/// Output machine-readable JSON
|
||||
#[arg(long, global = true)]
|
||||
pub robot: bool,
|
||||
|
||||
/// Pretty-print JSON output
|
||||
#[arg(long, global = true)]
|
||||
pub pretty: bool,
|
||||
|
||||
/// Network mode: auto, online-only, offline
|
||||
#[arg(long, global = true, default_value = "auto")]
|
||||
pub network: String,
|
||||
|
||||
/// Path to config file
|
||||
#[arg(long, global = true, env = "SWAGGER_CLI_CONFIG")]
|
||||
pub config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Fetch and cache an OpenAPI spec
|
||||
Fetch(fetch::Args),
|
||||
|
||||
/// List endpoints from a cached spec
|
||||
List(list::Args),
|
||||
|
||||
/// Show details of a specific endpoint
|
||||
Show(show::Args),
|
||||
|
||||
/// Search endpoints by keyword
|
||||
Search(search::Args),
|
||||
|
||||
/// List or show schemas from a cached spec
|
||||
Schemas(schemas::Args),
|
||||
|
||||
/// List tags from a cached spec
|
||||
Tags(tags::Args),
|
||||
|
||||
/// Manage spec aliases
|
||||
Aliases(aliases::Args),
|
||||
|
||||
/// Re-fetch and update a cached spec
|
||||
Sync(sync_cmd::Args),
|
||||
|
||||
/// Check cache health and diagnose issues
|
||||
Doctor(doctor::Args),
|
||||
|
||||
/// Manage the spec cache
|
||||
Cache(cache_cmd::Args),
|
||||
|
||||
/// Compare two versions of a spec
|
||||
Diff(diff::Args),
|
||||
}
|
||||
|
||||
18
src/cli/schemas.rs
Normal file
18
src/cli/schemas.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// List or show schemas from a cached spec
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Alias of the cached spec
|
||||
pub alias: String,
|
||||
|
||||
/// Specific schema name to show
|
||||
#[arg(long)]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("schemas not yet implemented".into()))
|
||||
}
|
||||
21
src/cli/search.rs
Normal file
21
src/cli/search.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// Search endpoints by keyword
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Alias of the cached spec
|
||||
pub alias: String,
|
||||
|
||||
/// Search query
|
||||
pub query: String,
|
||||
|
||||
/// Maximum number of results
|
||||
#[arg(long, default_value = "20")]
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("search not yet implemented".into()))
|
||||
}
|
||||
17
src/cli/show.rs
Normal file
17
src/cli/show.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// Show details of a specific endpoint
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Alias of the cached spec
|
||||
pub alias: String,
|
||||
|
||||
/// Operation ID or path to show
|
||||
pub endpoint: String,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("show not yet implemented".into()))
|
||||
}
|
||||
18
src/cli/sync_cmd.rs
Normal file
18
src/cli/sync_cmd.rs
Normal file
@@ -0,0 +1,18 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// Re-fetch and update a cached spec
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Alias to sync
|
||||
pub alias: String,
|
||||
|
||||
/// Auth profile name from config
|
||||
#[arg(long)]
|
||||
pub auth: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("sync not yet implemented".into()))
|
||||
}
|
||||
14
src/cli/tags.rs
Normal file
14
src/cli/tags.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use clap::Args as ClapArgs;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
/// List tags from a cached spec
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Alias of the cached spec
|
||||
pub alias: String,
|
||||
}
|
||||
|
||||
pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> {
|
||||
Err(SwaggerCliError::Usage("tags not yet implemented".into()))
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
use std::fs::{self, File, OpenOptions};
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use fs2::FileExt;
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
use super::spec::SpecIndex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheMetadata {
|
||||
@@ -29,11 +42,247 @@ impl CacheMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate an alias string for use as a cache directory name.
|
||||
///
|
||||
/// Accepts: alphanumeric start, then alphanumeric/dot/dash/underscore, 1-64 chars.
|
||||
/// Rejects: path separators, `..`, leading dots, Windows reserved device names.
|
||||
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");
|
||||
|
||||
if !pattern.is_match(alias) {
|
||||
return Err(SwaggerCliError::Usage(format!(
|
||||
"Invalid alias '{alias}': must be 1-64 chars, start with alphanumeric, \
|
||||
contain only alphanumeric/dot/dash/underscore"
|
||||
)));
|
||||
}
|
||||
|
||||
if alias.contains('/') || alias.contains('\\') {
|
||||
return Err(SwaggerCliError::Usage(format!(
|
||||
"Invalid alias '{alias}': path separators not allowed"
|
||||
)));
|
||||
}
|
||||
|
||||
if alias.contains("..") {
|
||||
return Err(SwaggerCliError::Usage(format!(
|
||||
"Invalid alias '{alias}': directory traversal not allowed"
|
||||
)));
|
||||
}
|
||||
|
||||
if alias.starts_with('.') {
|
||||
return Err(SwaggerCliError::Usage(format!(
|
||||
"Invalid alias '{alias}': leading dot not allowed"
|
||||
)));
|
||||
}
|
||||
|
||||
let stem = alias.split('.').next().unwrap_or(alias);
|
||||
let reserved = [
|
||||
"CON", "PRN", "NUL", "AUX", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
"COM9", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
|
||||
];
|
||||
if reserved.iter().any(|r| r.eq_ignore_ascii_case(stem)) {
|
||||
return Err(SwaggerCliError::Usage(format!(
|
||||
"Invalid alias '{alias}': reserved device name"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Compute a SHA-256 hash of the given bytes, returning "sha256:{hex}".
|
||||
pub fn compute_hash(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
let result = hasher.finalize();
|
||||
format!("sha256:{:x}", result)
|
||||
}
|
||||
|
||||
/// Manages the on-disk cache directory structure and write protocol.
|
||||
pub struct CacheManager {
|
||||
cache_dir: PathBuf,
|
||||
}
|
||||
|
||||
const LOCK_TIMEOUT_MS: u64 = 1000;
|
||||
const LOCK_POLL_MS: u64 = 50;
|
||||
|
||||
impl CacheManager {
|
||||
pub fn new(cache_dir: PathBuf) -> Self {
|
||||
Self { cache_dir }
|
||||
}
|
||||
|
||||
/// Return the directory for a given alias within the cache.
|
||||
pub fn alias_dir(&self, alias: &str) -> PathBuf {
|
||||
self.cache_dir.join(alias)
|
||||
}
|
||||
|
||||
/// Ensure the cache root and alias subdirectory exist.
|
||||
pub fn ensure_dirs(&self, alias: &str) -> Result<(), SwaggerCliError> {
|
||||
let dir = self.alias_dir(alias);
|
||||
fs::create_dir_all(&dir).map_err(|e| {
|
||||
SwaggerCliError::Cache(format!(
|
||||
"Failed to create cache directory {}: {e}",
|
||||
dir.display()
|
||||
))
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Acquire an exclusive file lock on `{alias_dir}/.lock` with bounded timeout.
|
||||
fn acquire_lock(&self, alias: &str) -> Result<File, SwaggerCliError> {
|
||||
let lock_path = self.alias_dir(alias).join(".lock");
|
||||
let lock_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&lock_path)
|
||||
.map_err(|e| {
|
||||
SwaggerCliError::Cache(format!(
|
||||
"Failed to open lock file {}: {e}",
|
||||
lock_path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
let deadline = Instant::now() + Duration::from_millis(LOCK_TIMEOUT_MS);
|
||||
|
||||
loop {
|
||||
match lock_file.try_lock_exclusive() {
|
||||
Ok(()) => return Ok(lock_file),
|
||||
Err(_) if Instant::now() >= deadline => {
|
||||
return Err(SwaggerCliError::CacheLocked(alias.to_string()));
|
||||
}
|
||||
Err(_) => {
|
||||
thread::sleep(Duration::from_millis(LOCK_POLL_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write spec data to cache using a crash-consistent protocol.
|
||||
///
|
||||
/// Each file is written to a `.tmp` suffix, fsynced, then renamed atomically.
|
||||
/// `meta.json` is written last as the commit marker.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn write_cache(
|
||||
&self,
|
||||
alias: &str,
|
||||
raw_source_bytes: &[u8],
|
||||
raw_json_bytes: &[u8],
|
||||
index: &SpecIndex,
|
||||
url: Option<String>,
|
||||
spec_version: &str,
|
||||
spec_title: &str,
|
||||
source_format: &str,
|
||||
etag: Option<String>,
|
||||
last_modified: Option<String>,
|
||||
previous_generation: Option<u64>,
|
||||
) -> Result<CacheMetadata, SwaggerCliError> {
|
||||
validate_alias(alias)?;
|
||||
self.ensure_dirs(alias)?;
|
||||
let _lock = self.acquire_lock(alias)?;
|
||||
|
||||
let dir = self.alias_dir(alias);
|
||||
let content_hash = compute_hash(raw_source_bytes);
|
||||
let raw_hash = compute_hash(raw_json_bytes);
|
||||
let generation = previous_generation.map_or(1, |g| g + 1);
|
||||
|
||||
let index_bytes =
|
||||
serde_json::to_vec_pretty(index).map_err(|e| SwaggerCliError::Cache(e.to_string()))?;
|
||||
let index_hash = compute_hash(&index_bytes);
|
||||
|
||||
// Phase 1: Write each file to .tmp, fsync, rename
|
||||
write_atomic(&dir.join("raw.source"), raw_source_bytes)?;
|
||||
write_atomic(&dir.join("raw.json"), raw_json_bytes)?;
|
||||
write_atomic(&dir.join("index.json"), &index_bytes)?;
|
||||
|
||||
// Phase 2: Write meta.json LAST as commit marker
|
||||
let now = Utc::now();
|
||||
let meta = CacheMetadata {
|
||||
alias: alias.to_string(),
|
||||
url,
|
||||
fetched_at: now,
|
||||
last_accessed: now,
|
||||
content_hash,
|
||||
raw_hash,
|
||||
etag,
|
||||
last_modified,
|
||||
spec_version: spec_version.to_string(),
|
||||
spec_title: spec_title.to_string(),
|
||||
endpoint_count: index.endpoints.len(),
|
||||
schema_count: index.schemas.len(),
|
||||
raw_size_bytes: raw_source_bytes.len() as u64,
|
||||
source_format: source_format.to_string(),
|
||||
index_version: index.index_version,
|
||||
generation,
|
||||
index_hash,
|
||||
};
|
||||
|
||||
let meta_bytes =
|
||||
serde_json::to_vec_pretty(&meta).map_err(|e| SwaggerCliError::Cache(e.to_string()))?;
|
||||
write_atomic(&dir.join("meta.json"), &meta_bytes)?;
|
||||
|
||||
// Best-effort directory fsync (Unix only)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(dir_fd) = File::open(&dir) {
|
||||
let _ = dir_fd.sync_all();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `data` to `path.tmp`, fsync, then rename to `path`.
|
||||
fn write_atomic(path: &std::path::Path, data: &[u8]) -> Result<(), SwaggerCliError> {
|
||||
let tmp_path = path.with_extension(format!(
|
||||
"{}.tmp",
|
||||
path.extension()
|
||||
.map_or("".into(), |e| e.to_string_lossy().into_owned())
|
||||
));
|
||||
|
||||
let mut file = File::create(&tmp_path).map_err(|e| {
|
||||
SwaggerCliError::Cache(format!("Failed to create {}: {e}", tmp_path.display()))
|
||||
})?;
|
||||
|
||||
file.write_all(data).map_err(|e| {
|
||||
SwaggerCliError::Cache(format!("Failed to write {}: {e}", tmp_path.display()))
|
||||
})?;
|
||||
|
||||
file.sync_all().map_err(|e| {
|
||||
SwaggerCliError::Cache(format!("Failed to sync {}: {e}", tmp_path.display()))
|
||||
})?;
|
||||
|
||||
fs::rename(&tmp_path, path).map_err(|e| {
|
||||
SwaggerCliError::Cache(format!(
|
||||
"Failed to rename {} -> {}: {e}",
|
||||
tmp_path.display(),
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
fn make_test_index() -> SpecIndex {
|
||||
SpecIndex {
|
||||
index_version: 1,
|
||||
generation: 1,
|
||||
content_hash: "sha256:test".into(),
|
||||
openapi: "3.0.3".into(),
|
||||
info: super::super::spec::IndexInfo {
|
||||
title: "Test".into(),
|
||||
version: "1.0.0".into(),
|
||||
},
|
||||
endpoints: vec![],
|
||||
schemas: vec![],
|
||||
tags: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_metadata_serialization_roundtrip() {
|
||||
let meta = CacheMetadata {
|
||||
@@ -109,4 +358,79 @@ mod tests {
|
||||
};
|
||||
assert!(meta.is_stale(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_alias_accepts_valid() {
|
||||
assert!(validate_alias("petstore").is_ok());
|
||||
assert!(validate_alias("my-api").is_ok());
|
||||
assert!(validate_alias("v1.0").is_ok());
|
||||
assert!(validate_alias("API_2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_alias_rejects_traversal() {
|
||||
assert!(validate_alias("../etc").is_err());
|
||||
assert!(validate_alias(".hidden").is_err());
|
||||
assert!(validate_alias("/etc").is_err());
|
||||
assert!(validate_alias("a\\b").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_alias_rejects_reserved() {
|
||||
assert!(validate_alias("CON").is_err());
|
||||
assert!(validate_alias("con").is_err());
|
||||
assert!(validate_alias("NUL").is_err());
|
||||
assert!(validate_alias("COM1").is_err());
|
||||
assert!(validate_alias("LPT1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_alias_rejects_too_long() {
|
||||
let long_alias = "a".repeat(65);
|
||||
assert!(validate_alias(&long_alias).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_hash_deterministic() {
|
||||
let data = b"hello world";
|
||||
let h1 = compute_hash(data);
|
||||
let h2 = compute_hash(data);
|
||||
assert_eq!(h1, h2);
|
||||
assert!(h1.starts_with("sha256:"));
|
||||
assert_eq!(h1.len(), 7 + 64); // "sha256:" + 64 hex chars
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_write_cache_creates_all_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let manager = CacheManager::new(tmp.path().to_path_buf());
|
||||
let index = make_test_index();
|
||||
|
||||
let meta = manager
|
||||
.write_cache(
|
||||
"testapi",
|
||||
b"openapi: 3.0.3",
|
||||
b"{\"openapi\":\"3.0.3\"}",
|
||||
&index,
|
||||
Some("https://example.com/api.json".into()),
|
||||
"1.0.0",
|
||||
"Test API",
|
||||
"yaml",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let alias_dir = tmp.path().join("testapi");
|
||||
assert!(alias_dir.join("raw.source").exists());
|
||||
assert!(alias_dir.join("raw.json").exists());
|
||||
assert!(alias_dir.join("index.json").exists());
|
||||
assert!(alias_dir.join("meta.json").exists());
|
||||
|
||||
assert_eq!(meta.alias, "testapi");
|
||||
assert_eq!(meta.generation, 1);
|
||||
assert_eq!(meta.source_format, "yaml");
|
||||
assert!(meta.content_hash.starts_with("sha256:"));
|
||||
}
|
||||
}
|
||||
|
||||
247
src/core/config.rs
Normal file
247
src/core/config.rs
Normal file
@@ -0,0 +1,247 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub default_alias: Option<String>,
|
||||
|
||||
#[serde(default = "default_stale_threshold_days")]
|
||||
pub stale_threshold_days: u32,
|
||||
|
||||
#[serde(default)]
|
||||
pub auth_profiles: BTreeMap<String, AuthConfig>,
|
||||
|
||||
#[serde(default)]
|
||||
pub display: DisplayConfig,
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_alias: None,
|
||||
stale_threshold_days: default_stale_threshold_days(),
|
||||
auth_profiles: BTreeMap::new(),
|
||||
display: DisplayConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_stale_threshold_days() -> u32 {
|
||||
30
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AuthConfig {
|
||||
pub auth_type: AuthType,
|
||||
pub credential: CredentialSource,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum AuthType {
|
||||
Bearer,
|
||||
ApiKey { header: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "source")]
|
||||
pub enum CredentialSource {
|
||||
Literal { value: String },
|
||||
EnvVar { name: String },
|
||||
Keyring { service: String, account: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct DisplayConfig {
|
||||
#[serde(default)]
|
||||
pub color: Option<bool>,
|
||||
|
||||
#[serde(default)]
|
||||
pub unicode: Option<bool>,
|
||||
}
|
||||
|
||||
/// Resolve the config file path.
|
||||
///
|
||||
/// Precedence: cli_override > SWAGGER_CLI_CONFIG > SWAGGER_CLI_HOME/config/config.toml > XDG
|
||||
pub fn config_path(cli_override: Option<&Path>) -> PathBuf {
|
||||
if let Some(p) = cli_override {
|
||||
return p.to_path_buf();
|
||||
}
|
||||
|
||||
if let Ok(p) = std::env::var("SWAGGER_CLI_CONFIG") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
|
||||
if let Ok(home) = std::env::var("SWAGGER_CLI_HOME") {
|
||||
return PathBuf::from(home).join("config").join("config.toml");
|
||||
}
|
||||
|
||||
if let Some(dirs) = ProjectDirs::from("", "", "swagger-cli") {
|
||||
return dirs.config_dir().join("config.toml");
|
||||
}
|
||||
|
||||
PathBuf::from("config.toml")
|
||||
}
|
||||
|
||||
/// Resolve the cache directory.
|
||||
///
|
||||
/// Precedence: SWAGGER_CLI_CACHE > SWAGGER_CLI_HOME/cache > XDG cache dir
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
if let Ok(p) = std::env::var("SWAGGER_CLI_CACHE") {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
|
||||
if let Ok(home) = std::env::var("SWAGGER_CLI_HOME") {
|
||||
return PathBuf::from(home).join("cache");
|
||||
}
|
||||
|
||||
if let Some(dirs) = ProjectDirs::from("", "", "swagger-cli") {
|
||||
return dirs.cache_dir().to_path_buf();
|
||||
}
|
||||
|
||||
PathBuf::from("cache")
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn load(path: &Path) -> Result<Config, SwaggerCliError> {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(contents) => {
|
||||
toml::from_str(&contents).map_err(|e| SwaggerCliError::Config(e.to_string()))
|
||||
}
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
|
||||
Err(e) => Err(SwaggerCliError::Config(format!(
|
||||
"failed to read {}: {e}",
|
||||
path.display()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &Path) -> Result<(), SwaggerCliError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| {
|
||||
SwaggerCliError::Config(format!(
|
||||
"failed to create directory {}: {e}",
|
||||
parent.display()
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
let contents =
|
||||
toml::to_string_pretty(self).map_err(|e| SwaggerCliError::Config(e.to_string()))?;
|
||||
|
||||
std::fs::write(path, contents).map_err(|e| {
|
||||
SwaggerCliError::Config(format!("failed to write {}: {e}", path.display()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_config_default_values() {
|
||||
let config = Config::default();
|
||||
assert_eq!(config.stale_threshold_days, 30);
|
||||
assert!(config.auth_profiles.is_empty());
|
||||
assert!(config.default_alias.is_none());
|
||||
assert!(config.display.color.is_none());
|
||||
assert!(config.display.unicode.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_roundtrip() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("config.toml");
|
||||
|
||||
let mut config = Config {
|
||||
default_alias: Some("petstore".into()),
|
||||
stale_threshold_days: 14,
|
||||
..Config::default()
|
||||
};
|
||||
config.auth_profiles.insert(
|
||||
"prod".into(),
|
||||
AuthConfig {
|
||||
auth_type: AuthType::Bearer,
|
||||
credential: CredentialSource::EnvVar {
|
||||
name: "API_TOKEN".into(),
|
||||
},
|
||||
},
|
||||
);
|
||||
config.auth_profiles.insert(
|
||||
"staging".into(),
|
||||
AuthConfig {
|
||||
auth_type: AuthType::ApiKey {
|
||||
header: "X-Api-Key".into(),
|
||||
},
|
||||
credential: CredentialSource::Keyring {
|
||||
service: "swagger-cli".into(),
|
||||
account: "staging".into(),
|
||||
},
|
||||
},
|
||||
);
|
||||
config.display.color = Some(true);
|
||||
config.display.unicode = Some(false);
|
||||
|
||||
config.save(&path).unwrap();
|
||||
let loaded = Config::load(&path).unwrap();
|
||||
|
||||
assert_eq!(loaded.default_alias, config.default_alias);
|
||||
assert_eq!(loaded.stale_threshold_days, config.stale_threshold_days);
|
||||
assert_eq!(loaded.auth_profiles.len(), config.auth_profiles.len());
|
||||
assert!(loaded.auth_profiles.contains_key("prod"));
|
||||
assert!(loaded.auth_profiles.contains_key("staging"));
|
||||
assert_eq!(loaded.display.color, Some(true));
|
||||
assert_eq!(loaded.display.unicode, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_path_precedence() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// CLI override takes highest precedence over all env-based resolution
|
||||
let override_path = tmp.path().join("override.toml");
|
||||
let result = config_path(Some(&override_path));
|
||||
assert_eq!(result, override_path);
|
||||
|
||||
// Without CLI override, result ends with config.toml (from env or XDG)
|
||||
let result = config_path(None);
|
||||
assert!(
|
||||
result.to_string_lossy().ends_with("config.toml"),
|
||||
"expected path ending in config.toml, got: {}",
|
||||
result.display()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_load_missing_returns_default() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("nonexistent").join("config.toml");
|
||||
|
||||
let config = Config::load(&path).unwrap();
|
||||
assert_eq!(config.stale_threshold_days, 30);
|
||||
assert!(config.auth_profiles.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_credential_source_serde() {
|
||||
let source = CredentialSource::EnvVar {
|
||||
name: "MY_TOKEN".into(),
|
||||
};
|
||||
let serialized = toml::to_string(&source).unwrap();
|
||||
assert!(serialized.contains("source"));
|
||||
assert!(serialized.contains("EnvVar"));
|
||||
|
||||
let deserialized: CredentialSource = toml::from_str(&serialized).unwrap();
|
||||
match deserialized {
|
||||
CredentialSource::EnvVar { name } => assert_eq!(name, "MY_TOKEN"),
|
||||
_ => panic!("expected EnvVar variant"),
|
||||
}
|
||||
}
|
||||
}
|
||||
648
src/core/indexer.rs
Normal file
648
src/core/indexer.rs
Normal file
@@ -0,0 +1,648 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::core::spec::{
|
||||
IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, IndexedTag, SpecIndex,
|
||||
};
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum Format {
|
||||
Json,
|
||||
Yaml,
|
||||
}
|
||||
|
||||
/// Detect whether raw bytes are JSON or YAML.
|
||||
///
|
||||
/// Priority: content-type header > file extension > content sniffing.
|
||||
pub fn detect_format(
|
||||
bytes: &[u8],
|
||||
filename_hint: Option<&str>,
|
||||
content_type_hint: Option<&str>,
|
||||
) -> Format {
|
||||
if let Some(ct) = content_type_hint {
|
||||
let ct_lower = ct.to_ascii_lowercase();
|
||||
if ct_lower.contains("json") {
|
||||
return Format::Json;
|
||||
}
|
||||
if ct_lower.contains("yaml") || ct_lower.contains("yml") {
|
||||
return Format::Yaml;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(name) = filename_hint {
|
||||
let name_lower = name.to_ascii_lowercase();
|
||||
if name_lower.ends_with(".json") {
|
||||
return Format::Json;
|
||||
}
|
||||
if name_lower.ends_with(".yaml") || name_lower.ends_with(".yml") {
|
||||
return Format::Yaml;
|
||||
}
|
||||
}
|
||||
|
||||
// Content sniffing: try JSON first (stricter), fall back to YAML.
|
||||
if serde_json::from_slice::<serde_json::Value>(bytes).is_ok() {
|
||||
Format::Json
|
||||
} else {
|
||||
Format::Yaml
|
||||
}
|
||||
}
|
||||
|
||||
/// If the input is YAML, parse then re-serialize as JSON.
|
||||
/// If JSON, validate it parses.
|
||||
pub fn normalize_to_json(bytes: &[u8], format: Format) -> Result<Vec<u8>, SwaggerCliError> {
|
||||
match format {
|
||||
Format::Json => {
|
||||
let _: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
Ok(bytes.to_vec())
|
||||
}
|
||||
Format::Yaml => {
|
||||
let value: serde_json::Value = serde_yaml::from_slice(bytes)
|
||||
.map_err(|e| SwaggerCliError::InvalidSpec(format!("YAML parse error: {e}")))?;
|
||||
let json_bytes = serde_json::to_vec(&value)?;
|
||||
Ok(json_bytes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a `SpecIndex` from a parsed JSON OpenAPI document.
|
||||
pub fn build_index(
|
||||
raw_json: &serde_json::Value,
|
||||
content_hash: &str,
|
||||
generation: u64,
|
||||
) -> Result<SpecIndex, SwaggerCliError> {
|
||||
let openapi = raw_json
|
||||
.get("openapi")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let info_obj = raw_json.get("info");
|
||||
let title = info_obj
|
||||
.and_then(|i| i.get("title"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let version = info_obj
|
||||
.and_then(|i| i.get("version"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Root-level security schemes (names only).
|
||||
let root_security = extract_security_scheme_names(raw_json.get("security"));
|
||||
|
||||
let mut endpoints = Vec::new();
|
||||
let mut tag_counts: HashMap<String, usize> = HashMap::new();
|
||||
|
||||
if let Some(paths) = raw_json.get("paths").and_then(|p| p.as_object()) {
|
||||
for (path, path_item) in paths {
|
||||
let path_obj = match path_item.as_object() {
|
||||
Some(o) => o,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Path-level parameters apply to all operations under this path.
|
||||
let path_params = path_obj
|
||||
.get("parameters")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| extract_params(arr))
|
||||
.unwrap_or_default();
|
||||
|
||||
for (method, operation) in path_obj {
|
||||
if !is_http_method(method) {
|
||||
continue;
|
||||
}
|
||||
let op = match operation.as_object() {
|
||||
Some(o) => o,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let method_upper = method.to_ascii_uppercase();
|
||||
let path_encoded = json_pointer_encode(path);
|
||||
let method_lower = method.to_ascii_lowercase();
|
||||
let operation_ptr = format!("/paths/{path_encoded}/{method_lower}");
|
||||
|
||||
// Merge path-level + operation-level parameters (operation wins on conflict).
|
||||
let op_params = op
|
||||
.get("parameters")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| extract_params(arr))
|
||||
.unwrap_or_default();
|
||||
let parameters = merge_params(&path_params, &op_params);
|
||||
|
||||
let tags: Vec<String> = op
|
||||
.get("tags")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|t| t.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
for tag in &tags {
|
||||
*tag_counts.entry(tag.clone()).or_insert(0) += 1;
|
||||
}
|
||||
|
||||
let deprecated = op
|
||||
.get("deprecated")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let summary = op.get("summary").and_then(|v| v.as_str()).map(String::from);
|
||||
let description = op
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
let operation_id = op
|
||||
.get("operationId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let (request_body_required, request_body_content_types) =
|
||||
extract_request_body(op.get("requestBody"));
|
||||
|
||||
// Security: operation-level overrides root. An explicit empty array
|
||||
// means "no auth required".
|
||||
let (security_schemes, security_required) = if let Some(op_sec) = op.get("security")
|
||||
{
|
||||
let schemes = extract_security_scheme_names(Some(op_sec));
|
||||
let required = !schemes.is_empty();
|
||||
(schemes, required)
|
||||
} else {
|
||||
let required = !root_security.is_empty();
|
||||
(root_security.clone(), required)
|
||||
};
|
||||
|
||||
if !resolve_pointer(raw_json, &operation_ptr) {
|
||||
return Err(SwaggerCliError::InvalidSpec(format!(
|
||||
"JSON pointer does not resolve: {operation_ptr}"
|
||||
)));
|
||||
}
|
||||
|
||||
endpoints.push(IndexedEndpoint {
|
||||
path: path.clone(),
|
||||
method: method_upper,
|
||||
summary,
|
||||
description,
|
||||
operation_id,
|
||||
tags,
|
||||
deprecated,
|
||||
parameters,
|
||||
request_body_required,
|
||||
request_body_content_types,
|
||||
security_schemes,
|
||||
security_required,
|
||||
operation_ptr,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort endpoints: path ASC then method rank ASC.
|
||||
endpoints.sort_by(|a, b| {
|
||||
a.path
|
||||
.cmp(&b.path)
|
||||
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
||||
});
|
||||
|
||||
// Schemas from components.schemas.
|
||||
let mut schemas: Vec<IndexedSchema> = Vec::new();
|
||||
if let Some(components_schemas) = raw_json
|
||||
.pointer("/components/schemas")
|
||||
.and_then(|v| v.as_object())
|
||||
{
|
||||
for name in components_schemas.keys() {
|
||||
let schema_ptr = format!("/components/schemas/{}", json_pointer_encode(name));
|
||||
if !resolve_pointer(raw_json, &schema_ptr) {
|
||||
return Err(SwaggerCliError::InvalidSpec(format!(
|
||||
"JSON pointer does not resolve: {schema_ptr}"
|
||||
)));
|
||||
}
|
||||
schemas.push(IndexedSchema {
|
||||
name: name.clone(),
|
||||
schema_ptr,
|
||||
});
|
||||
}
|
||||
}
|
||||
schemas.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
// Collect tag descriptions from the top-level `tags` array (if present).
|
||||
let mut tag_descriptions: HashMap<String, Option<String>> = HashMap::new();
|
||||
if let Some(tags_arr) = raw_json.get("tags").and_then(|v| v.as_array()) {
|
||||
for tag_obj in tags_arr {
|
||||
if let Some(name) = tag_obj.get("name").and_then(|v| v.as_str()) {
|
||||
let desc = tag_obj
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
tag_descriptions.insert(name.to_string(), desc);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut tags: Vec<IndexedTag> = tag_counts
|
||||
.into_iter()
|
||||
.map(|(name, count)| {
|
||||
let description = tag_descriptions.get(&name).cloned().unwrap_or(None);
|
||||
IndexedTag {
|
||||
name,
|
||||
description,
|
||||
endpoint_count: count,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
tags.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
Ok(SpecIndex {
|
||||
index_version: 1,
|
||||
generation,
|
||||
content_hash: content_hash.to_string(),
|
||||
openapi,
|
||||
info: IndexInfo { title, version },
|
||||
endpoints,
|
||||
schemas,
|
||||
tags,
|
||||
})
|
||||
}
|
||||
|
||||
/// Return the sort rank for an HTTP method.
|
||||
pub fn method_rank(method: &str) -> u8 {
|
||||
match method.to_ascii_uppercase().as_str() {
|
||||
"GET" => 0,
|
||||
"POST" => 1,
|
||||
"PUT" => 2,
|
||||
"PATCH" => 3,
|
||||
"DELETE" => 4,
|
||||
"OPTIONS" => 5,
|
||||
"HEAD" => 6,
|
||||
"TRACE" => 7,
|
||||
_ => 99,
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 6901 JSON pointer encoding for a single segment: `~` -> `~0`, `/` -> `~1`.
|
||||
pub fn json_pointer_encode(segment: &str) -> String {
|
||||
segment.replace('~', "~0").replace('/', "~1")
|
||||
}
|
||||
|
||||
/// Check whether a JSON pointer resolves within `value`.
|
||||
pub fn resolve_pointer(value: &serde_json::Value, pointer: &str) -> bool {
|
||||
value.pointer(pointer).is_some()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn is_http_method(key: &str) -> bool {
|
||||
matches!(
|
||||
key.to_ascii_lowercase().as_str(),
|
||||
"get" | "post" | "put" | "patch" | "delete" | "options" | "head" | "trace"
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_params(arr: &[serde_json::Value]) -> Vec<IndexedParam> {
|
||||
arr.iter()
|
||||
.filter_map(|p| {
|
||||
let name = p.get("name")?.as_str()?.to_string();
|
||||
let location = p.get("in")?.as_str()?.to_string();
|
||||
let required = p.get("required").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let description = p
|
||||
.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
Some(IndexedParam {
|
||||
name,
|
||||
location,
|
||||
required,
|
||||
description,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Merge path-level and operation-level parameters. Operation params override
|
||||
/// path params with the same (name, location) pair.
|
||||
fn merge_params(path_params: &[IndexedParam], op_params: &[IndexedParam]) -> Vec<IndexedParam> {
|
||||
let mut merged: Vec<IndexedParam> = path_params.to_vec();
|
||||
for op_p in op_params {
|
||||
if let Some(existing) = merged
|
||||
.iter_mut()
|
||||
.find(|p| p.name == op_p.name && p.location == op_p.location)
|
||||
{
|
||||
*existing = op_p.clone();
|
||||
} else {
|
||||
merged.push(op_p.clone());
|
||||
}
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
fn extract_request_body(rb: Option<&serde_json::Value>) -> (bool, Vec<String>) {
|
||||
let Some(rb) = rb else {
|
||||
return (false, Vec::new());
|
||||
};
|
||||
let required = rb
|
||||
.get("required")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let content_types = rb
|
||||
.get("content")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| obj.keys().cloned().collect())
|
||||
.unwrap_or_default();
|
||||
(required, content_types)
|
||||
}
|
||||
|
||||
fn extract_security_scheme_names(security: Option<&serde_json::Value>) -> Vec<String> {
|
||||
let Some(arr) = security.and_then(|v| v.as_array()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let mut names: Vec<String> = Vec::new();
|
||||
for item in arr {
|
||||
if let Some(obj) = item.as_object() {
|
||||
for key in obj.keys() {
|
||||
if !names.contains(key) {
|
||||
names.push(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_format_json() {
|
||||
let bytes = b"{}";
|
||||
assert_eq!(
|
||||
detect_format(bytes, None, Some("application/json")),
|
||||
Format::Json,
|
||||
);
|
||||
assert_eq!(detect_format(bytes, Some("spec.json"), None), Format::Json,);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_format_yaml() {
|
||||
let bytes = b"openapi: '3.0.0'";
|
||||
assert_eq!(
|
||||
detect_format(bytes, None, Some("application/x-yaml")),
|
||||
Format::Yaml,
|
||||
);
|
||||
assert_eq!(detect_format(bytes, Some("spec.yaml"), None), Format::Yaml,);
|
||||
assert_eq!(detect_format(bytes, Some("spec.yml"), None), Format::Yaml,);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_format_sniffing() {
|
||||
// Valid JSON -> detected as JSON even without hints.
|
||||
let json_bytes = br#"{"openapi":"3.0.0"}"#;
|
||||
assert_eq!(detect_format(json_bytes, None, None), Format::Json);
|
||||
|
||||
// Invalid JSON but valid YAML -> falls back to YAML.
|
||||
let yaml_bytes = b"openapi: '3.0.0'\ninfo:\n title: Test";
|
||||
assert_eq!(detect_format(yaml_bytes, None, None), Format::Yaml);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_yaml_normalization_roundtrip() {
|
||||
let yaml = br#"
|
||||
openapi: "3.0.0"
|
||||
info:
|
||||
title: Test API
|
||||
version: "1.0"
|
||||
paths: {}
|
||||
"#;
|
||||
let json_bytes = normalize_to_json(yaml, Format::Yaml).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_slice(&json_bytes).unwrap();
|
||||
assert_eq!(parsed["openapi"], "3.0.0");
|
||||
assert_eq!(parsed["info"]["title"], "Test API");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_pointer_encoding() {
|
||||
assert_eq!(json_pointer_encode("/pet/{petId}"), "~1pet~1{petId}");
|
||||
assert_eq!(json_pointer_encode("simple"), "simple");
|
||||
assert_eq!(json_pointer_encode("a~b/c"), "a~0b~1c");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_method_rank_ordering() {
|
||||
assert_eq!(method_rank("GET"), 0);
|
||||
assert_eq!(method_rank("POST"), 1);
|
||||
assert_eq!(method_rank("PUT"), 2);
|
||||
assert_eq!(method_rank("PATCH"), 3);
|
||||
assert_eq!(method_rank("DELETE"), 4);
|
||||
assert_eq!(method_rank("OPTIONS"), 5);
|
||||
assert_eq!(method_rank("HEAD"), 6);
|
||||
assert_eq!(method_rank("TRACE"), 7);
|
||||
assert_eq!(method_rank("CUSTOM"), 99);
|
||||
|
||||
// Case-insensitive.
|
||||
assert_eq!(method_rank("get"), 0);
|
||||
assert_eq!(method_rank("Post"), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_index_basic() {
|
||||
let spec: serde_json::Value = serde_json::json!({
|
||||
"openapi": "3.0.3",
|
||||
"info": { "title": "Pet Store", "version": "1.0.0" },
|
||||
"paths": {
|
||||
"/pets": {
|
||||
"get": {
|
||||
"operationId": "listPets",
|
||||
"summary": "List all pets",
|
||||
"tags": ["pets"],
|
||||
"parameters": [
|
||||
{ "name": "limit", "in": "query", "required": false }
|
||||
],
|
||||
"responses": { "200": { "description": "OK" } }
|
||||
},
|
||||
"post": {
|
||||
"operationId": "createPet",
|
||||
"summary": "Create a pet",
|
||||
"tags": ["pets"],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": { "application/json": {} }
|
||||
},
|
||||
"responses": { "201": { "description": "Created" } }
|
||||
}
|
||||
},
|
||||
"/pets/{petId}": {
|
||||
"get": {
|
||||
"operationId": "showPetById",
|
||||
"summary": "Get a pet",
|
||||
"tags": ["pets"],
|
||||
"parameters": [
|
||||
{ "name": "petId", "in": "path", "required": true }
|
||||
],
|
||||
"responses": { "200": { "description": "OK" } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Pet": { "type": "object" },
|
||||
"Error": { "type": "object" }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let index = build_index(&spec, "sha256:abc", 42).unwrap();
|
||||
assert_eq!(index.index_version, 1);
|
||||
assert_eq!(index.generation, 42);
|
||||
assert_eq!(index.content_hash, "sha256:abc");
|
||||
assert_eq!(index.openapi, "3.0.3");
|
||||
assert_eq!(index.info.title, "Pet Store");
|
||||
assert_eq!(index.info.version, "1.0.0");
|
||||
|
||||
// 3 endpoints total.
|
||||
assert_eq!(index.endpoints.len(), 3);
|
||||
|
||||
// Sorted: /pets GET < /pets POST < /pets/{petId} GET.
|
||||
assert_eq!(index.endpoints[0].path, "/pets");
|
||||
assert_eq!(index.endpoints[0].method, "GET");
|
||||
assert_eq!(index.endpoints[1].path, "/pets");
|
||||
assert_eq!(index.endpoints[1].method, "POST");
|
||||
assert_eq!(index.endpoints[2].path, "/pets/{petId}");
|
||||
|
||||
// POST /pets has request body.
|
||||
assert!(index.endpoints[1].request_body_required);
|
||||
assert_eq!(
|
||||
index.endpoints[1].request_body_content_types,
|
||||
vec!["application/json"]
|
||||
);
|
||||
|
||||
// Schemas sorted: Error < Pet.
|
||||
assert_eq!(index.schemas.len(), 2);
|
||||
assert_eq!(index.schemas[0].name, "Error");
|
||||
assert_eq!(index.schemas[1].name, "Pet");
|
||||
|
||||
// Single tag with count 3.
|
||||
assert_eq!(index.tags.len(), 1);
|
||||
assert_eq!(index.tags[0].name, "pets");
|
||||
assert_eq!(index.tags[0].endpoint_count, 3);
|
||||
|
||||
// Verify pointers resolve.
|
||||
for ep in &index.endpoints {
|
||||
assert!(
|
||||
resolve_pointer(&spec, &ep.operation_ptr),
|
||||
"Pointer should resolve: {}",
|
||||
ep.operation_ptr,
|
||||
);
|
||||
}
|
||||
for schema in &index.schemas {
|
||||
assert!(
|
||||
resolve_pointer(&spec, &schema.schema_ptr),
|
||||
"Pointer should resolve: {}",
|
||||
schema.schema_ptr,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_security_inheritance() {
|
||||
let spec: serde_json::Value = serde_json::json!({
|
||||
"openapi": "3.0.3",
|
||||
"info": { "title": "Auth Test", "version": "1.0.0" },
|
||||
"security": [{ "api_key": [] }],
|
||||
"paths": {
|
||||
"/secured": {
|
||||
"get": {
|
||||
"summary": "Inherits root security",
|
||||
"responses": { "200": { "description": "OK" } }
|
||||
}
|
||||
},
|
||||
"/public": {
|
||||
"get": {
|
||||
"summary": "Explicitly no auth",
|
||||
"security": [],
|
||||
"responses": { "200": { "description": "OK" } }
|
||||
}
|
||||
},
|
||||
"/custom": {
|
||||
"get": {
|
||||
"summary": "Custom auth",
|
||||
"security": [{ "bearer": [] }],
|
||||
"responses": { "200": { "description": "OK" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let index = build_index(&spec, "sha256:test", 1).unwrap();
|
||||
|
||||
// /custom -> custom security.
|
||||
let custom = index
|
||||
.endpoints
|
||||
.iter()
|
||||
.find(|e| e.path == "/custom")
|
||||
.unwrap();
|
||||
assert_eq!(custom.security_schemes, vec!["bearer"]);
|
||||
assert!(custom.security_required);
|
||||
|
||||
// /public -> empty security array means no auth.
|
||||
let public = index
|
||||
.endpoints
|
||||
.iter()
|
||||
.find(|e| e.path == "/public")
|
||||
.unwrap();
|
||||
assert!(public.security_schemes.is_empty());
|
||||
assert!(!public.security_required);
|
||||
|
||||
// /secured -> inherits root security.
|
||||
let secured = index
|
||||
.endpoints
|
||||
.iter()
|
||||
.find(|e| e.path == "/secured")
|
||||
.unwrap();
|
||||
assert_eq!(secured.security_schemes, vec!["api_key"]);
|
||||
assert!(secured.security_required);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_pointer_valid_and_invalid() {
|
||||
let val: serde_json::Value = serde_json::json!({
|
||||
"a": { "b": { "c": 1 } }
|
||||
});
|
||||
assert!(resolve_pointer(&val, "/a/b/c"));
|
||||
assert!(resolve_pointer(&val, "/a/b"));
|
||||
assert!(!resolve_pointer(&val, "/a/b/d"));
|
||||
assert!(!resolve_pointer(&val, "/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_index_from_fixture() {
|
||||
let fixture = include_str!("../../tests/fixtures/petstore.json");
|
||||
let spec: serde_json::Value = serde_json::from_str(fixture).unwrap();
|
||||
let index = build_index(&spec, "sha256:fixture", 1).unwrap();
|
||||
|
||||
assert_eq!(index.openapi, "3.0.3");
|
||||
assert_eq!(index.info.title, "Petstore");
|
||||
assert!(!index.endpoints.is_empty());
|
||||
assert!(!index.schemas.is_empty());
|
||||
|
||||
// Verify sort order: endpoints sorted by path then method rank.
|
||||
for window in index.endpoints.windows(2) {
|
||||
let ordering = window[0]
|
||||
.path
|
||||
.cmp(&window[1].path)
|
||||
.then_with(|| method_rank(&window[0].method).cmp(&method_rank(&window[1].method)));
|
||||
assert!(
|
||||
ordering.is_le(),
|
||||
"Endpoints not sorted: {} {} > {} {}",
|
||||
window[0].path,
|
||||
window[0].method,
|
||||
window[1].path,
|
||||
window[1].method,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,4 @@
|
||||
pub mod cache;
|
||||
pub mod config;
|
||||
pub mod indexer;
|
||||
pub mod spec;
|
||||
|
||||
133
src/errors.rs
133
src/errors.rs
@@ -89,19 +89,21 @@ impl SwaggerCliError {
|
||||
pub fn suggestion(&self) -> Option<String> {
|
||||
match self {
|
||||
Self::Usage(_) => Some("Run 'swagger-cli --help' for usage information".into()),
|
||||
Self::CacheLocked(alias) => {
|
||||
Some(format!("Another process is writing to '{alias}'. Wait or check for stale locks."))
|
||||
Self::CacheLocked(alias) => Some(format!(
|
||||
"Another process is writing to '{alias}'. Wait or check for stale locks."
|
||||
)),
|
||||
Self::Network(_) => {
|
||||
Some("Check your network connection or use --network offline".into())
|
||||
}
|
||||
Self::Network(_) => Some("Check your network connection or use --network offline".into()),
|
||||
Self::InvalidSpec(_) => {
|
||||
Some("Verify the URL points to a valid OpenAPI 3.x JSON or YAML spec".into())
|
||||
}
|
||||
Self::AliasNotFound(alias) => {
|
||||
Some(format!("Run 'swagger-cli aliases --list' to see available aliases. '{alias}' was not found."))
|
||||
}
|
||||
Self::AliasExists(alias) => {
|
||||
Some(format!("Use 'swagger-cli fetch --alias {alias} --force' to overwrite"))
|
||||
}
|
||||
Self::AliasNotFound(alias) => Some(format!(
|
||||
"Run 'swagger-cli aliases --list' to see available aliases. '{alias}' was not found."
|
||||
)),
|
||||
Self::AliasExists(alias) => Some(format!(
|
||||
"Use 'swagger-cli fetch --alias {alias} --force' to overwrite"
|
||||
)),
|
||||
Self::Cache(_) => Some("Try 'swagger-cli doctor --fix' to repair the cache".into()),
|
||||
Self::CacheIntegrity(_) => {
|
||||
Some("Cache data is corrupted. Run 'swagger-cli doctor --fix' or re-fetch.".into())
|
||||
@@ -133,40 +135,114 @@ mod tests {
|
||||
fn test_error_exit_codes() {
|
||||
let cases: Vec<(SwaggerCliError, u8, &str)> = vec![
|
||||
(SwaggerCliError::Usage("bad".into()), 2, "USAGE_ERROR"),
|
||||
(SwaggerCliError::InvalidSpec("bad".into()), 5, "INVALID_SPEC"),
|
||||
(SwaggerCliError::AliasExists("pet".into()), 6, "ALIAS_EXISTS"),
|
||||
(
|
||||
SwaggerCliError::InvalidSpec("bad".into()),
|
||||
5,
|
||||
"INVALID_SPEC",
|
||||
),
|
||||
(
|
||||
SwaggerCliError::AliasExists("pet".into()),
|
||||
6,
|
||||
"ALIAS_EXISTS",
|
||||
),
|
||||
(SwaggerCliError::Auth("bad token".into()), 7, "AUTH_ERROR"),
|
||||
(SwaggerCliError::AliasNotFound("missing".into()), 8, "ALIAS_NOT_FOUND"),
|
||||
(SwaggerCliError::CacheLocked("pet".into()), 9, "CACHE_LOCKED"),
|
||||
(
|
||||
SwaggerCliError::AliasNotFound("missing".into()),
|
||||
8,
|
||||
"ALIAS_NOT_FOUND",
|
||||
),
|
||||
(
|
||||
SwaggerCliError::CacheLocked("pet".into()),
|
||||
9,
|
||||
"CACHE_LOCKED",
|
||||
),
|
||||
(SwaggerCliError::Cache("corrupt".into()), 10, "CACHE_ERROR"),
|
||||
(SwaggerCliError::Config("bad".into()), 11, "CONFIG_ERROR"),
|
||||
(SwaggerCliError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "missing")), 12, "IO_ERROR"),
|
||||
(SwaggerCliError::Json(serde_json::from_str::<serde_json::Value>("bad").unwrap_err()), 13, "JSON_ERROR"),
|
||||
(SwaggerCliError::CacheIntegrity("mismatch".into()), 14, "CACHE_INTEGRITY"),
|
||||
(SwaggerCliError::OfflineMode("no net".into()), 15, "OFFLINE_MODE"),
|
||||
(SwaggerCliError::PolicyBlocked("private".into()), 16, "POLICY_BLOCKED"),
|
||||
(
|
||||
SwaggerCliError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "missing")),
|
||||
12,
|
||||
"IO_ERROR",
|
||||
),
|
||||
(
|
||||
SwaggerCliError::Json(
|
||||
serde_json::from_str::<serde_json::Value>("bad").unwrap_err(),
|
||||
),
|
||||
13,
|
||||
"JSON_ERROR",
|
||||
),
|
||||
(
|
||||
SwaggerCliError::CacheIntegrity("mismatch".into()),
|
||||
14,
|
||||
"CACHE_INTEGRITY",
|
||||
),
|
||||
(
|
||||
SwaggerCliError::OfflineMode("no net".into()),
|
||||
15,
|
||||
"OFFLINE_MODE",
|
||||
),
|
||||
(
|
||||
SwaggerCliError::PolicyBlocked("private".into()),
|
||||
16,
|
||||
"POLICY_BLOCKED",
|
||||
),
|
||||
];
|
||||
|
||||
for (error, expected_code, expected_str_code) in cases {
|
||||
assert_eq!(error.exit_code(), expected_code, "exit_code mismatch for {expected_str_code}");
|
||||
assert_eq!(error.code(), expected_str_code, "code() mismatch for exit code {expected_code}");
|
||||
assert_eq!(
|
||||
error.exit_code(),
|
||||
expected_code,
|
||||
"exit_code mismatch for {expected_str_code}"
|
||||
);
|
||||
assert_eq!(
|
||||
error.code(),
|
||||
expected_str_code,
|
||||
"code() mismatch for exit code {expected_code}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggestions_present_where_expected() {
|
||||
assert!(SwaggerCliError::Usage("x".into()).suggestion().is_some());
|
||||
assert!(SwaggerCliError::AliasNotFound("pet".into()).suggestion().is_some());
|
||||
assert!(SwaggerCliError::AliasExists("pet".into()).suggestion().is_some());
|
||||
assert!(SwaggerCliError::CacheLocked("pet".into()).suggestion().is_some());
|
||||
assert!(
|
||||
SwaggerCliError::AliasNotFound("pet".into())
|
||||
.suggestion()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
SwaggerCliError::AliasExists("pet".into())
|
||||
.suggestion()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
SwaggerCliError::CacheLocked("pet".into())
|
||||
.suggestion()
|
||||
.is_some()
|
||||
);
|
||||
// Network variant tested separately in async test
|
||||
assert!(SwaggerCliError::InvalidSpec("x".into()).suggestion().is_some());
|
||||
assert!(
|
||||
SwaggerCliError::InvalidSpec("x".into())
|
||||
.suggestion()
|
||||
.is_some()
|
||||
);
|
||||
assert!(SwaggerCliError::Cache("x".into()).suggestion().is_some());
|
||||
assert!(SwaggerCliError::CacheIntegrity("x".into()).suggestion().is_some());
|
||||
assert!(
|
||||
SwaggerCliError::CacheIntegrity("x".into())
|
||||
.suggestion()
|
||||
.is_some()
|
||||
);
|
||||
assert!(SwaggerCliError::Config("x".into()).suggestion().is_some());
|
||||
assert!(SwaggerCliError::Auth("x".into()).suggestion().is_some());
|
||||
assert!(SwaggerCliError::OfflineMode("x".into()).suggestion().is_some());
|
||||
assert!(SwaggerCliError::PolicyBlocked("x".into()).suggestion().is_some());
|
||||
assert!(
|
||||
SwaggerCliError::OfflineMode("x".into())
|
||||
.suggestion()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
SwaggerCliError::PolicyBlocked("x".into())
|
||||
.suggestion()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -174,7 +250,8 @@ mod tests {
|
||||
let io_err = SwaggerCliError::Io(std::io::Error::new(std::io::ErrorKind::NotFound, "gone"));
|
||||
assert!(io_err.suggestion().is_none());
|
||||
|
||||
let json_err = SwaggerCliError::Json(serde_json::from_str::<serde_json::Value>("bad").unwrap_err());
|
||||
let json_err =
|
||||
SwaggerCliError::Json(serde_json::from_str::<serde_json::Value>("bad").unwrap_err());
|
||||
assert!(json_err.suggestion().is_none());
|
||||
}
|
||||
|
||||
|
||||
85
src/main.rs
85
src/main.rs
@@ -1,8 +1,91 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::process::ExitCode;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use swagger_cli::cli::{Cli, Commands};
|
||||
use swagger_cli::errors::SwaggerCliError;
|
||||
use swagger_cli::output::robot;
|
||||
|
||||
fn pre_scan_robot() -> bool {
|
||||
std::env::args().any(|a| a == "--robot")
|
||||
}
|
||||
|
||||
fn command_name(cli: &Cli) -> &'static str {
|
||||
match &cli.command {
|
||||
Commands::Fetch(_) => "fetch",
|
||||
Commands::List(_) => "list",
|
||||
Commands::Show(_) => "show",
|
||||
Commands::Search(_) => "search",
|
||||
Commands::Schemas(_) => "schemas",
|
||||
Commands::Tags(_) => "tags",
|
||||
Commands::Aliases(_) => "aliases",
|
||||
Commands::Sync(_) => "sync",
|
||||
Commands::Doctor(_) => "doctor",
|
||||
Commands::Cache(_) => "cache",
|
||||
Commands::Diff(_) => "diff",
|
||||
}
|
||||
}
|
||||
|
||||
fn output_robot_error(err: &SwaggerCliError, command: &str, duration: Duration) {
|
||||
robot::robot_error(
|
||||
err.code(),
|
||||
&err.to_string(),
|
||||
err.suggestion(),
|
||||
command,
|
||||
duration,
|
||||
);
|
||||
}
|
||||
|
||||
fn output_human_error(err: &SwaggerCliError) {
|
||||
swagger_cli::output::human::print_error(err);
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> ExitCode {
|
||||
ExitCode::SUCCESS
|
||||
let is_robot = pre_scan_robot();
|
||||
let start = Instant::now();
|
||||
|
||||
let cli = match Cli::try_parse() {
|
||||
Ok(cli) => cli,
|
||||
Err(err) => {
|
||||
if is_robot {
|
||||
let parse_err = SwaggerCliError::Usage(err.to_string());
|
||||
output_robot_error(&parse_err, "unknown", start.elapsed());
|
||||
return parse_err.to_exit_code();
|
||||
}
|
||||
err.exit();
|
||||
}
|
||||
};
|
||||
|
||||
let cmd = command_name(&cli);
|
||||
let robot = cli.robot;
|
||||
|
||||
let result = match &cli.command {
|
||||
Commands::Fetch(args) => swagger_cli::cli::fetch::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::Search(args) => swagger_cli::cli::search::execute(args, robot).await,
|
||||
Commands::Schemas(args) => swagger_cli::cli::schemas::execute(args, robot).await,
|
||||
Commands::Tags(args) => swagger_cli::cli::tags::execute(args, robot).await,
|
||||
Commands::Aliases(args) => swagger_cli::cli::aliases::execute(args, robot).await,
|
||||
Commands::Sync(args) => swagger_cli::cli::sync_cmd::execute(args, robot).await,
|
||||
Commands::Doctor(args) => swagger_cli::cli::doctor::execute(args, robot).await,
|
||||
Commands::Cache(args) => swagger_cli::cli::cache_cmd::execute(args, robot).await,
|
||||
Commands::Diff(args) => swagger_cli::cli::diff::execute(args, robot).await,
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(err) => {
|
||||
if robot {
|
||||
output_robot_error(&err, cmd, start.elapsed());
|
||||
} else {
|
||||
output_human_error(&err);
|
||||
}
|
||||
err.to_exit_code()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
src/output/human.rs
Normal file
14
src/output/human.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use std::io::IsTerminal;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
|
||||
pub fn is_tty() -> bool {
|
||||
std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
pub fn print_error(err: &SwaggerCliError) {
|
||||
eprintln!("error: {err}");
|
||||
if let Some(suggestion) = err.suggestion() {
|
||||
eprintln!(" hint: {suggestion}");
|
||||
}
|
||||
}
|
||||
@@ -1 +1,47 @@
|
||||
// Output formatting
|
||||
pub mod human;
|
||||
pub mod robot;
|
||||
pub mod table;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RobotEnvelope<T: Serialize> {
|
||||
pub ok: bool,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub data: Option<T>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<RobotError>,
|
||||
pub meta: BTreeMap<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct RobotError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub suggestion: Option<String>,
|
||||
}
|
||||
|
||||
impl<T: Serialize> RobotEnvelope<T> {
|
||||
pub fn success(data: T, meta: BTreeMap<String, serde_json::Value>) -> Self {
|
||||
Self {
|
||||
ok: true,
|
||||
data: Some(data),
|
||||
error: None,
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RobotEnvelope<()> {
|
||||
pub fn error(error: RobotError, meta: BTreeMap<String, serde_json::Value>) -> Self {
|
||||
Self {
|
||||
ok: false,
|
||||
data: None,
|
||||
error: Some(error),
|
||||
meta,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
51
src/output/robot.rs
Normal file
51
src/output/robot.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use super::{RobotEnvelope, RobotError};
|
||||
|
||||
fn build_meta(command: &str, duration: Duration) -> BTreeMap<String, serde_json::Value> {
|
||||
let mut meta = BTreeMap::new();
|
||||
meta.insert("schema_version".into(), serde_json::Value::Number(1.into()));
|
||||
meta.insert(
|
||||
"tool_version".into(),
|
||||
serde_json::Value::String(env!("CARGO_PKG_VERSION").to_string()),
|
||||
);
|
||||
meta.insert(
|
||||
"command".into(),
|
||||
serde_json::Value::String(command.to_string()),
|
||||
);
|
||||
meta.insert(
|
||||
"duration_ms".into(),
|
||||
serde_json::Value::Number(((duration.as_millis().min(u64::MAX as u128)) as u64).into()),
|
||||
);
|
||||
meta
|
||||
}
|
||||
|
||||
pub fn robot_success<T: Serialize>(data: T, command: &str, duration: Duration) {
|
||||
let meta = build_meta(command, duration);
|
||||
let envelope = RobotEnvelope::success(data, meta);
|
||||
let json = serde_json::to_string(&envelope).expect("serialization should not fail");
|
||||
println!("{json}");
|
||||
}
|
||||
|
||||
pub fn robot_error(
|
||||
code: &str,
|
||||
message: &str,
|
||||
suggestion: Option<String>,
|
||||
command: &str,
|
||||
duration: Duration,
|
||||
) {
|
||||
let meta = build_meta(command, duration);
|
||||
let envelope = RobotEnvelope::<()>::error(
|
||||
RobotError {
|
||||
code: code.to_string(),
|
||||
message: message.to_string(),
|
||||
suggestion,
|
||||
},
|
||||
meta,
|
||||
);
|
||||
let json = serde_json::to_string(&envelope).expect("serialization should not fail");
|
||||
eprintln!("{json}");
|
||||
}
|
||||
14
src/output/table.rs
Normal file
14
src/output/table.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use tabled::settings::Style;
|
||||
use tabled::{Table, Tabled};
|
||||
|
||||
pub fn render_table<T: Tabled>(rows: &[T]) -> String {
|
||||
Table::new(rows).with(Style::rounded()).to_string()
|
||||
}
|
||||
|
||||
pub fn render_table_or_empty<T: Tabled>(rows: &[T], empty_msg: &str) -> String {
|
||||
if rows.is_empty() {
|
||||
empty_msg.to_string()
|
||||
} else {
|
||||
render_table(rows)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user