bd-ilo: Implement error types and core data models
This commit is contained in:
112
src/core/cache.rs
Normal file
112
src/core/cache.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CacheMetadata {
|
||||
pub alias: String,
|
||||
pub url: Option<String>,
|
||||
pub fetched_at: DateTime<Utc>,
|
||||
pub last_accessed: DateTime<Utc>,
|
||||
pub content_hash: String,
|
||||
pub raw_hash: String,
|
||||
pub etag: Option<String>,
|
||||
pub last_modified: Option<String>,
|
||||
pub spec_version: String,
|
||||
pub spec_title: String,
|
||||
pub endpoint_count: usize,
|
||||
pub schema_count: usize,
|
||||
pub raw_size_bytes: u64,
|
||||
pub source_format: String,
|
||||
pub index_version: u32,
|
||||
pub generation: u64,
|
||||
pub index_hash: String,
|
||||
}
|
||||
|
||||
impl CacheMetadata {
|
||||
pub fn is_stale(&self, stale_threshold_days: u32) -> bool {
|
||||
let age = Utc::now() - self.fetched_at;
|
||||
age.num_days() > i64::from(stale_threshold_days)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_cache_metadata_serialization_roundtrip() {
|
||||
let meta = CacheMetadata {
|
||||
alias: "petstore".into(),
|
||||
url: Some("https://example.com/openapi.json".into()),
|
||||
fetched_at: Utc::now(),
|
||||
last_accessed: Utc::now(),
|
||||
content_hash: "sha256:abc".into(),
|
||||
raw_hash: "sha256:def".into(),
|
||||
etag: Some("\"abc123\"".into()),
|
||||
last_modified: Some("Wed, 21 Oct 2025 07:28:00 GMT".into()),
|
||||
spec_version: "1.0.0".into(),
|
||||
spec_title: "Petstore".into(),
|
||||
endpoint_count: 19,
|
||||
schema_count: 8,
|
||||
raw_size_bytes: 42_000,
|
||||
source_format: "json".into(),
|
||||
index_version: 1,
|
||||
generation: 1,
|
||||
index_hash: "sha256:idx".into(),
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&meta).unwrap();
|
||||
let deserialized: CacheMetadata = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.alias, "petstore");
|
||||
assert_eq!(deserialized.endpoint_count, 19);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_stale_fresh() {
|
||||
let meta = CacheMetadata {
|
||||
alias: "test".into(),
|
||||
url: None,
|
||||
fetched_at: Utc::now(),
|
||||
last_accessed: Utc::now(),
|
||||
content_hash: String::new(),
|
||||
raw_hash: String::new(),
|
||||
etag: None,
|
||||
last_modified: None,
|
||||
spec_version: String::new(),
|
||||
spec_title: String::new(),
|
||||
endpoint_count: 0,
|
||||
schema_count: 0,
|
||||
raw_size_bytes: 0,
|
||||
source_format: "json".into(),
|
||||
index_version: 1,
|
||||
generation: 1,
|
||||
index_hash: String::new(),
|
||||
};
|
||||
assert!(!meta.is_stale(30));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_stale_old() {
|
||||
let meta = CacheMetadata {
|
||||
alias: "test".into(),
|
||||
url: None,
|
||||
fetched_at: Utc::now() - Duration::days(31),
|
||||
last_accessed: Utc::now(),
|
||||
content_hash: String::new(),
|
||||
raw_hash: String::new(),
|
||||
etag: None,
|
||||
last_modified: None,
|
||||
spec_version: String::new(),
|
||||
spec_title: String::new(),
|
||||
endpoint_count: 0,
|
||||
schema_count: 0,
|
||||
raw_size_bytes: 0,
|
||||
source_format: "json".into(),
|
||||
index_version: 1,
|
||||
generation: 1,
|
||||
index_hash: String::new(),
|
||||
};
|
||||
assert!(meta.is_stale(30));
|
||||
}
|
||||
}
|
||||
@@ -1 +1,2 @@
|
||||
// Core business logic modules
|
||||
pub mod cache;
|
||||
pub mod spec;
|
||||
|
||||
112
src/core/spec.rs
Normal file
112
src/core/spec.rs
Normal file
@@ -0,0 +1,112 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SpecIndex {
|
||||
pub index_version: u32,
|
||||
pub generation: u64,
|
||||
pub content_hash: String,
|
||||
pub openapi: String,
|
||||
pub info: IndexInfo,
|
||||
pub endpoints: Vec<IndexedEndpoint>,
|
||||
pub schemas: Vec<IndexedSchema>,
|
||||
pub tags: Vec<IndexedTag>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndexInfo {
|
||||
pub title: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndexedEndpoint {
|
||||
pub path: String,
|
||||
pub method: String,
|
||||
pub summary: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub operation_id: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub deprecated: bool,
|
||||
pub parameters: Vec<IndexedParam>,
|
||||
pub request_body_required: bool,
|
||||
pub request_body_content_types: Vec<String>,
|
||||
pub security_schemes: Vec<String>,
|
||||
pub security_required: bool,
|
||||
pub operation_ptr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndexedParam {
|
||||
pub name: String,
|
||||
pub location: String,
|
||||
pub required: bool,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndexedSchema {
|
||||
pub name: String,
|
||||
pub schema_ptr: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IndexedTag {
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub endpoint_count: usize,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_spec_index_serialization_roundtrip() {
|
||||
let index = SpecIndex {
|
||||
index_version: 1,
|
||||
generation: 1,
|
||||
content_hash: "sha256:abc123".into(),
|
||||
openapi: "3.0.3".into(),
|
||||
info: IndexInfo {
|
||||
title: "Petstore".into(),
|
||||
version: "1.0.0".into(),
|
||||
},
|
||||
endpoints: vec![IndexedEndpoint {
|
||||
path: "/pet".into(),
|
||||
method: "GET".into(),
|
||||
summary: Some("List pets".into()),
|
||||
description: None,
|
||||
operation_id: Some("listPets".into()),
|
||||
tags: vec!["pet".into()],
|
||||
deprecated: false,
|
||||
parameters: vec![IndexedParam {
|
||||
name: "limit".into(),
|
||||
location: "query".into(),
|
||||
required: false,
|
||||
description: Some("Max items".into()),
|
||||
}],
|
||||
request_body_required: false,
|
||||
request_body_content_types: vec![],
|
||||
security_schemes: vec!["api_key".into()],
|
||||
security_required: true,
|
||||
operation_ptr: "/paths/~1pet/get".into(),
|
||||
}],
|
||||
schemas: vec![IndexedSchema {
|
||||
name: "Pet".into(),
|
||||
schema_ptr: "/components/schemas/Pet".into(),
|
||||
}],
|
||||
tags: vec![IndexedTag {
|
||||
name: "pet".into(),
|
||||
description: Some("Pet operations".into()),
|
||||
endpoint_count: 1,
|
||||
}],
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&index).unwrap();
|
||||
let deserialized: SpecIndex = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.info.title, "Petstore");
|
||||
assert_eq!(deserialized.endpoints.len(), 1);
|
||||
assert_eq!(deserialized.schemas.len(), 1);
|
||||
assert_eq!(deserialized.tags.len(), 1);
|
||||
}
|
||||
}
|
||||
198
src/errors.rs
198
src/errors.rs
@@ -1 +1,197 @@
|
||||
// Error types - implemented by bd-ilo
|
||||
use std::process::ExitCode;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SwaggerCliError {
|
||||
#[error("Usage error: {0}")]
|
||||
Usage(String),
|
||||
|
||||
#[error("Cache locked: {0}")]
|
||||
CacheLocked(String),
|
||||
|
||||
#[error("Network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
|
||||
#[error("Invalid spec: {0}")]
|
||||
InvalidSpec(String),
|
||||
|
||||
#[error("Alias not found: {0}")]
|
||||
AliasNotFound(String),
|
||||
|
||||
#[error("Alias already exists: {0}")]
|
||||
AliasExists(String),
|
||||
|
||||
#[error("Cache error: {0}")]
|
||||
Cache(String),
|
||||
|
||||
#[error("Cache integrity error: {0}")]
|
||||
CacheIntegrity(String),
|
||||
|
||||
#[error("Config error: {0}")]
|
||||
Config(String),
|
||||
|
||||
#[error("Auth error: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("Offline mode: {0}")]
|
||||
OfflineMode(String),
|
||||
|
||||
#[error("Policy blocked: {0}")]
|
||||
PolicyBlocked(String),
|
||||
}
|
||||
|
||||
impl SwaggerCliError {
|
||||
pub fn exit_code(&self) -> u8 {
|
||||
match self {
|
||||
Self::Usage(_) => 2,
|
||||
Self::Network(_) => 4,
|
||||
Self::InvalidSpec(_) => 5,
|
||||
Self::AliasExists(_) => 6,
|
||||
Self::Auth(_) => 7,
|
||||
Self::AliasNotFound(_) => 8,
|
||||
Self::CacheLocked(_) => 9,
|
||||
Self::Cache(_) => 10,
|
||||
Self::Config(_) => 11,
|
||||
Self::Io(_) => 12,
|
||||
Self::Json(_) => 13,
|
||||
Self::CacheIntegrity(_) => 14,
|
||||
Self::OfflineMode(_) => 15,
|
||||
Self::PolicyBlocked(_) => 16,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Usage(_) => "USAGE_ERROR",
|
||||
Self::Network(_) => "NETWORK_ERROR",
|
||||
Self::InvalidSpec(_) => "INVALID_SPEC",
|
||||
Self::AliasExists(_) => "ALIAS_EXISTS",
|
||||
Self::Auth(_) => "AUTH_ERROR",
|
||||
Self::AliasNotFound(_) => "ALIAS_NOT_FOUND",
|
||||
Self::CacheLocked(_) => "CACHE_LOCKED",
|
||||
Self::Cache(_) => "CACHE_ERROR",
|
||||
Self::Config(_) => "CONFIG_ERROR",
|
||||
Self::Io(_) => "IO_ERROR",
|
||||
Self::Json(_) => "JSON_ERROR",
|
||||
Self::CacheIntegrity(_) => "CACHE_INTEGRITY",
|
||||
Self::OfflineMode(_) => "OFFLINE_MODE",
|
||||
Self::PolicyBlocked(_) => "POLICY_BLOCKED",
|
||||
}
|
||||
}
|
||||
|
||||
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::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::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())
|
||||
}
|
||||
Self::Config(_) => {
|
||||
Some("Check your config file. Run 'swagger-cli doctor' for diagnostics.".into())
|
||||
}
|
||||
Self::Auth(_) => Some("Check your auth profile in config.toml".into()),
|
||||
Self::OfflineMode(_) => {
|
||||
Some("Use --network auto or --network online-only to allow network access".into())
|
||||
}
|
||||
Self::PolicyBlocked(_) => {
|
||||
Some("Use --allow-private-host or --allow-insecure-http to override".into())
|
||||
}
|
||||
Self::Io(_) | Self::Json(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_exit_code(&self) -> ExitCode {
|
||||
ExitCode::from(self.exit_code())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
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::Auth("bad token".into()), 7, "AUTH_ERROR"),
|
||||
(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"),
|
||||
];
|
||||
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
// Network variant tested separately in async test
|
||||
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::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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_suggestions_none_for_io_json() {
|
||||
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());
|
||||
assert!(json_err.suggestion().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_alias_not_found_suggestion_references_list() {
|
||||
let err = SwaggerCliError::AliasNotFound("myapi".into());
|
||||
let suggestion = err.suggestion().unwrap();
|
||||
assert!(suggestion.contains("swagger-cli aliases --list"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_network_error_exit_code() {
|
||||
// Connect to a port that definitely isn't listening
|
||||
let err = reqwest::get("http://127.0.0.1:1").await.unwrap_err();
|
||||
let swagger_err = SwaggerCliError::Network(err);
|
||||
assert_eq!(swagger_err.exit_code(), 4);
|
||||
assert_eq!(swagger_err.code(), "NETWORK_ERROR");
|
||||
assert!(swagger_err.suggestion().is_some());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user