bd-ilo: Implement error types and core data models
This commit is contained in:
@@ -41,7 +41,7 @@
|
||||
{"id":"bd-epk","title":"Epic: Query Commands Phase 1","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-12T16:22:20.420042Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:22:20.420513Z","compaction_level":0,"original_size":0,"labels":["epic"]}
|
||||
{"id":"bd-gvr","title":"Create Dockerfile and installation script","description":"## Background\nDockerfile for minimal Alpine-based image and install.sh for curl-based binary installation with checksum/signature verification.\n\n## Approach\n\n**Dockerfile (multi-stage build):**\n- Builder stage: `FROM rust:1.93-alpine` as builder, `apk add musl-dev`, `COPY . .`, `cargo build --release --locked --target x86_64-unknown-linux-musl`\n- Runtime stage: `FROM alpine:latest`, `apk add --no-cache ca-certificates`, `COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/swagger-cli /usr/local/bin/swagger-cli`\n- Pre-create XDG dirs: `mkdir -p /root/.config/swagger-cli /root/.cache/swagger-cli/aliases`\n- `ENTRYPOINT [\"swagger-cli\"]` (no CMD — user passes subcommands directly)\n\n**install.sh:**\n- Header: `#!/usr/bin/env bash`, `set -euo pipefail`\n- Secure temp directory: `mktemp -d` with cleanup trap (`trap \"rm -rf $TMPDIR\" EXIT`)\n- OS detection: `uname -s` → Darwin or Linux (reject others with clear error)\n- Arch detection: `uname -m` → arm64/aarch64 maps to aarch64, x86_64 stays x86_64 (reject others)\n- Download URL: GitLab Package Registry URL pattern, constructed from OS+arch variables\n- Download: `curl -fsSL` binary + SHA256SUMS file\n- Checksum verification (portable): Linux uses `sha256sum --check`, macOS uses `shasum -a 256 --check` — detect which is available\n- Optional minisign verification: if `minisign` is on PATH, download `.minisig` file and verify signature; if not on PATH, print info message and skip (not an error)\n- Install: `chmod +x`, move to `/usr/local/bin/` (or `~/.local/bin/` if no write access to /usr/local/bin)\n- PATH check: verify install dir is on PATH, print warning if not with suggested export command\n\n## Acceptance Criteria\n- [ ] Dockerfile builds successfully with `docker build .`\n- [ ] Container runs `swagger-cli --version` and exits 0\n- [ ] Docker image is minimal (Alpine-based runtime, no build tools in final image)\n- [ ] install.sh starts with `set -euo pipefail` and creates secure temp dir with cleanup trap\n- [ ] install.sh detects OS (Darwin/Linux) and architecture (arm64/x86_64) correctly\n- [ ] install.sh rejects unsupported OS/arch with clear error message\n- [ ] Checksum verification works on Linux (sha256sum) and macOS (shasum -a 256)\n- [ ] Checksum failure aborts install with non-zero exit\n- [ ] Optional minisign verification runs when minisign is available, skips gracefully when not\n- [ ] Binary installed to /usr/local/bin or ~/.local/bin with executable permissions\n- [ ] PATH warning printed if install directory not on PATH\n\n## Files\n- CREATE: Dockerfile\n- CREATE: install.sh\n\n## TDD Anchor\nVERIFY: `docker build -t swagger-cli-test . && docker run swagger-cli-test --version`\nVERIFY: `bash -n install.sh` (syntax check)\n\n## Edge Cases\n- **musl vs glibc:** Alpine uses musl. The Dockerfile must use the musl target, not the gnu target. Mixing causes runtime failures.\n- **Rootless Docker:** ENTRYPOINT should work regardless of UID. Don't assume /root/ — use $HOME or a configurable path.\n- **install.sh on minimal systems:** Some minimal Docker images don't have `curl`. The script should check for curl and error with a clear message.\n- **Interrupted install:** The trap ensures temp dir cleanup on any exit (EXIT, not just specific signals). Verify install.sh doesn't leave artifacts on Ctrl+C.\n- **Apple Silicon detection:** `uname -m` returns \"arm64\" on macOS but \"aarch64\" on Linux. Both must map to the aarch64 binary.","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:31:32.440225Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:56:19.763349Z","compaction_level":0,"original_size":0,"labels":["ci","phase2"],"dependencies":[{"issue_id":"bd-gvr","depends_on_id":"bd-1lo","type":"parent-child","created_at":"2026-02-12T16:31:32.444746Z","created_by":"tayloreernisse"},{"issue_id":"bd-gvr","depends_on_id":"bd-a7e","type":"blocks","created_at":"2026-02-12T16:31:32.445590Z","created_by":"tayloreernisse"}]}
|
||||
{"id":"bd-hcb","title":"Epic: Config and Cache Infrastructure","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-12T16:22:17.707241Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:22:17.707865Z","compaction_level":0,"original_size":0,"labels":["epic"]}
|
||||
{"id":"bd-ilo","title":"Implement error types and core data models","description":"## Background\nEvery command in swagger-cli returns typed errors that map to specific exit codes and robot JSON error codes. This bead defines the complete error type system and the core data models (SpecIndex, CacheMetadata, IndexedEndpoint, etc.) that all commands operate on. These types are the backbone of the entire application.\n\n## Approach\nImplement `SwaggerCliError` enum in `src/errors.rs` with variants: Usage, CacheLocked, Network, InvalidSpec, AliasNotFound, AliasExists, Cache, CacheIntegrity, Config, Auth, Io, Json, OfflineMode, PolicyBlocked. Each variant maps to an exit code (2-16), a string error code (USAGE_ERROR, CACHE_LOCKED, etc.), and an optional suggestion string. Use `thiserror::Error` for Display derivation.\n\nImplement core data models in `src/core/spec.rs`: SpecIndex (with index_version, generation, content_hash, openapi, info, endpoints, schemas, tags), IndexInfo, IndexedEndpoint (with path, method, summary, description, operation_id, tags, deprecated, parameters, request_body_required, request_body_content_types, security_schemes, security_required, operation_ptr), IndexedParam (name, location, required, description), IndexedSchema (name, schema_ptr), IndexedTag (name, description, endpoint_count). All derive Serialize, Deserialize, Debug, Clone.\n\nImplement CacheMetadata in `src/core/cache.rs` types section (or a separate types file): alias, url, fetched_at, last_accessed, content_hash, raw_hash, etag, last_modified, spec_version, spec_title, endpoint_count, schema_count, raw_size_bytes, source_format, index_version, generation, index_hash. Include `is_stale()` method.\n\n## Acceptance Criteria\n- [ ] SwaggerCliError has all 14 variants with correct exit_code(), code(), and suggestion() methods\n- [ ] SpecIndex and all index types compile and derive Serialize + Deserialize\n- [ ] CacheMetadata compiles with all fields and is_stale() works correctly\n- [ ] `cargo test --lib` passes for error mapping tests\n- [ ] No use of `any` type -- all fields have concrete types with explicit Rust type annotations\n\n## Files\n- CREATE: src/errors.rs (SwaggerCliError enum, exit_code/code/suggestion impls)\n- CREATE: src/core/spec.rs (SpecIndex, IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, IndexedTag)\n- CREATE: src/core/cache.rs (CacheMetadata struct and related types — CacheManager is added later by bd-1ie)\n- CREATE: src/core/mod.rs (pub mod spec; pub mod cache; pub mod config; pub mod search;)\n- MODIFY: src/lib.rs (add pub mod errors; ensure pub mod core;)\n\n## TDD Anchor\nRED: Write `test_error_exit_codes` that asserts each SwaggerCliError variant returns the correct exit code (Usage->2, Network->4, InvalidSpec->5, AliasExists->6, Auth->7, AliasNotFound->8, CacheLocked->9, Cache->10, Config->11, Io->12, Json->13, CacheIntegrity->14, OfflineMode->15, PolicyBlocked->16).\nGREEN: Implement all variants and methods.\nVERIFY: `cargo test test_error_exit_codes`\n\n## Edge Cases\n- AliasNotFound suggestion references `swagger-cli aliases --list` (not just the alias name)\n- suggestion() returns Option<String> -- some variants (Io, Json) have no suggestion\n- Network variant wraps reqwest::Error via #[from] -- but since we use async reqwest, ensure the Error type is the async one","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:23:46.561151Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:56:18.328851Z","compaction_level":0,"original_size":0,"labels":["foundation","phase1"],"dependencies":[{"issue_id":"bd-ilo","depends_on_id":"bd-3e0","type":"parent-child","created_at":"2026-02-12T16:23:46.566627Z","created_by":"tayloreernisse"},{"issue_id":"bd-ilo","depends_on_id":"bd-a7e","type":"blocks","created_at":"2026-02-12T16:23:46.566979Z","created_by":"tayloreernisse"}]}
|
||||
{"id":"bd-ilo","title":"Implement error types and core data models","description":"## Background\nEvery command in swagger-cli returns typed errors that map to specific exit codes and robot JSON error codes. This bead defines the complete error type system and the core data models (SpecIndex, CacheMetadata, IndexedEndpoint, etc.) that all commands operate on. These types are the backbone of the entire application.\n\n## Approach\nImplement `SwaggerCliError` enum in `src/errors.rs` with variants: Usage, CacheLocked, Network, InvalidSpec, AliasNotFound, AliasExists, Cache, CacheIntegrity, Config, Auth, Io, Json, OfflineMode, PolicyBlocked. Each variant maps to an exit code (2-16), a string error code (USAGE_ERROR, CACHE_LOCKED, etc.), and an optional suggestion string. Use `thiserror::Error` for Display derivation.\n\nImplement core data models in `src/core/spec.rs`: SpecIndex (with index_version, generation, content_hash, openapi, info, endpoints, schemas, tags), IndexInfo, IndexedEndpoint (with path, method, summary, description, operation_id, tags, deprecated, parameters, request_body_required, request_body_content_types, security_schemes, security_required, operation_ptr), IndexedParam (name, location, required, description), IndexedSchema (name, schema_ptr), IndexedTag (name, description, endpoint_count). All derive Serialize, Deserialize, Debug, Clone.\n\nImplement CacheMetadata in `src/core/cache.rs` types section (or a separate types file): alias, url, fetched_at, last_accessed, content_hash, raw_hash, etag, last_modified, spec_version, spec_title, endpoint_count, schema_count, raw_size_bytes, source_format, index_version, generation, index_hash. Include `is_stale()` method.\n\n## Acceptance Criteria\n- [ ] SwaggerCliError has all 14 variants with correct exit_code(), code(), and suggestion() methods\n- [ ] SpecIndex and all index types compile and derive Serialize + Deserialize\n- [ ] CacheMetadata compiles with all fields and is_stale() works correctly\n- [ ] `cargo test --lib` passes for error mapping tests\n- [ ] No use of `any` type -- all fields have concrete types with explicit Rust type annotations\n\n## Files\n- CREATE: src/errors.rs (SwaggerCliError enum, exit_code/code/suggestion impls)\n- CREATE: src/core/spec.rs (SpecIndex, IndexInfo, IndexedEndpoint, IndexedParam, IndexedSchema, IndexedTag)\n- CREATE: src/core/cache.rs (CacheMetadata struct and related types — CacheManager is added later by bd-1ie)\n- CREATE: src/core/mod.rs (pub mod spec; pub mod cache; pub mod config; pub mod search;)\n- MODIFY: src/lib.rs (add pub mod errors; ensure pub mod core;)\n\n## TDD Anchor\nRED: Write `test_error_exit_codes` that asserts each SwaggerCliError variant returns the correct exit code (Usage->2, Network->4, InvalidSpec->5, AliasExists->6, Auth->7, AliasNotFound->8, CacheLocked->9, Cache->10, Config->11, Io->12, Json->13, CacheIntegrity->14, OfflineMode->15, PolicyBlocked->16).\nGREEN: Implement all variants and methods.\nVERIFY: `cargo test test_error_exit_codes`\n\n## Edge Cases\n- AliasNotFound suggestion references `swagger-cli aliases --list` (not just the alias name)\n- suggestion() returns Option<String> -- some variants (Io, Json) have no suggestion\n- Network variant wraps reqwest::Error via #[from] -- but since we use async reqwest, ensure the Error type is the async one","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:23:46.561151Z","created_by":"tayloreernisse","updated_at":"2026-02-12T17:37:07.284171Z","closed_at":"2026-02-12T17:37:07.284059Z","close_reason":"Completed: SwaggerCliError with 14 variants, SpecIndex, CacheMetadata, all tests pass","compaction_level":0,"original_size":0,"labels":["foundation","phase1"],"dependencies":[{"issue_id":"bd-ilo","depends_on_id":"bd-3e0","type":"parent-child","created_at":"2026-02-12T16:23:46.566627Z","created_by":"tayloreernisse"},{"issue_id":"bd-ilo","depends_on_id":"bd-a7e","type":"blocks","created_at":"2026-02-12T16:23:46.566979Z","created_by":"tayloreernisse"}]}
|
||||
{"id":"bd-j23","title":"Add breaking-change classification for diff command","description":"## What\nExtend diff command with heuristic-based breaking-change classification. Removed endpoint = breaking. Removed required parameter = breaking. Added optional field = non-breaking. Changed type = breaking.\n\n## Acceptance Criteria\n- [ ] Each change classified as breaking/non-breaking/unknown\n- [ ] --fail-on breaking uses classification (not just structural)\n- [ ] classification field in robot output\n\n## Files\n- MODIFY: src/cli/diff.rs (add classification logic)","status":"open","priority":4,"issue_type":"task","created_at":"2026-02-12T16:31:57.292836Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:42:45.229543Z","compaction_level":0,"original_size":0,"labels":["future","phase3"],"dependencies":[{"issue_id":"bd-j23","depends_on_id":"bd-1ck","type":"blocks","created_at":"2026-02-12T16:31:57.294318Z","created_by":"tayloreernisse"},{"issue_id":"bd-j23","depends_on_id":"bd-2e4","type":"blocks","created_at":"2026-02-12T16:42:45.229527Z","created_by":"tayloreernisse"},{"issue_id":"bd-j23","depends_on_id":"bd-3aq","type":"parent-child","created_at":"2026-02-12T16:31:57.293815Z","created_by":"tayloreernisse"}]}
|
||||
{"id":"bd-jek","title":"Epic: Query Commands Phase 2","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-12T16:22:21.465792Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:22:21.466699Z","compaction_level":0,"original_size":0,"labels":["epic"]}
|
||||
{"id":"bd-lx6","title":"Create test fixtures and integration test helpers","description":"## Background\nAll integration tests need test fixtures (OpenAPI spec files) and helper functions to set up hermetic test environments. This bead creates the fixtures and test infrastructure that all other test beads depend on.\n\n## Approach\n1. Create tests/fixtures/petstore.json — download the standard Petstore v3 spec (JSON format, ~50KB, 19 endpoints)\n2. Create tests/fixtures/petstore.yaml — same spec in YAML format (for format normalization tests)\n3. Create tests/fixtures/minimal.json — minimal valid OpenAPI 3.0 spec (3 endpoints, for fast tests)\n4. Create tests/helpers/mod.rs — shared test utilities:\n - setup_test_env() → creates tempdir, sets SWAGGER_CLI_HOME, returns TestEnv struct with paths\n - fetch_fixture(env, fixture_name, alias) → runs swagger-cli fetch with local fixture file\n - run_cmd(args) → creates assert_cmd Command with SWAGGER_CLI_HOME set\n - parse_robot_json(output) → parses stdout as serde_json::Value\n\n## Acceptance Criteria\n- [ ] petstore.json is a valid OpenAPI 3.0 spec with 19+ endpoints\n- [ ] petstore.yaml is equivalent YAML version\n- [ ] minimal.json is valid OpenAPI 3.0 with 3 endpoints\n- [ ] setup_test_env() creates isolated tempdir with SWAGGER_CLI_HOME\n- [ ] fetch_fixture() successfully caches a fixture spec\n- [ ] All test helpers compile and can be used from integration tests\n\n## Files\n- CREATE: tests/fixtures/petstore.json (Petstore v3 spec)\n- CREATE: tests/fixtures/petstore.yaml (same in YAML)\n- CREATE: tests/fixtures/minimal.json (minimal 3-endpoint spec)\n- CREATE: tests/helpers/mod.rs (TestEnv, setup_test_env, fetch_fixture, run_cmd, parse_robot_json)\n\n## TDD Anchor\nRED: Write `test_fixture_is_valid_json` — parse petstore.json, assert it has \"openapi\" and \"paths\" keys.\nGREEN: Create the fixture file.\nVERIFY: `cargo test test_fixture_is_valid`\n\n## Edge Cases\n- Fixtures must use absolute paths (canonicalize) for file:// URLs\n- petstore.json should be a real Petstore spec, not a minimal stub\n- SWAGGER_CLI_HOME must be set BEFORE any command runs (use env() on assert_cmd)\n- Test helpers should clean up tempdirs (use TempDir which auto-cleans on drop)","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:30:59.014337Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:34:06.397049Z","compaction_level":0,"original_size":0,"labels":["phase1","testing"],"dependencies":[{"issue_id":"bd-lx6","depends_on_id":"bd-16o","type":"blocks","created_at":"2026-02-12T16:30:59.016051Z","created_by":"tayloreernisse"},{"issue_id":"bd-lx6","depends_on_id":"bd-p7g","type":"parent-child","created_at":"2026-02-12T16:30:59.015600Z","created_by":"tayloreernisse"}]}
|
||||
|
||||
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