diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index fbb2d0b..8a33518 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,18 +1,18 @@ {"id":"bd-132","title":"Add semantic search with embeddings","description":"## What\nAdd semantic/fuzzy search as alternative to text search. Compute embeddings for endpoint descriptions and schema names during index building. Use cosine similarity for ranking.\n\n## Acceptance Criteria\n- [ ] --semantic flag enables embedding-based search\n- [ ] Results ranked by cosine similarity\n- [ ] Fallback to text search when embeddings unavailable\n\n## Files\n- CREATE: src/core/embeddings.rs\n- MODIFY: src/core/search.rs (add semantic search mode)","status":"open","priority":4,"issue_type":"task","created_at":"2026-02-12T16:31:57.268115Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:42:45.253877Z","compaction_level":0,"original_size":0,"labels":["future","phase3"],"dependencies":[{"issue_id":"bd-132","depends_on_id":"bd-2e4","type":"blocks","created_at":"2026-02-12T16:42:45.253860Z","created_by":"tayloreernisse"},{"issue_id":"bd-132","depends_on_id":"bd-3aq","type":"parent-child","created_at":"2026-02-12T16:31:57.269278Z","created_by":"tayloreernisse"}]} {"id":"bd-161","title":"Epic: Sync and Updates","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-12T16:22:23.251895Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:22:23.253943Z","compaction_level":0,"original_size":0,"labels":["epic"]} {"id":"bd-16o","title":"Wire fetch command with local file, stdin, and remote URL support","description":"## Background\nThe fetch command is the primary entry point for getting specs into swagger-cli. It orchestrates: HTTP download (or local file/stdin read), format detection, YAML normalization, index building, pointer validation, and crash-consistent cache write. It supports auth headers, bearer tokens, auth profiles, --force overwrite, and robot output.\n\n## Approach\nImplement src/cli/fetch.rs with FetchArgs struct and async execute() function:\n\n**FetchArgs (clap derive):**\n- url: String (positional, required — can be URL, file path, or \"-\" for stdin)\n- alias: String (--alias, required)\n- header: Vec (--header, repeatable)\n- auth_header: Vec (--auth-header, alias for --header)\n- bearer: Option (--bearer)\n- auth_profile: Option (--auth-profile)\n- force: bool (--force)\n- timeout_ms: u64 (--timeout-ms, default 10000)\n- max_bytes: u64 (--max-bytes, default 26214400)\n- retries: u32 (--retries, default 2)\n- input_format: Option (--input-format, values: auto/json/yaml)\n- resolve_external_refs: bool (--resolve-external-refs)\n- ref_allow_host: Vec (--ref-allow-host, repeatable)\n- ref_max_depth: u32 (--ref-max-depth, default 3)\n- ref_max_bytes: u64 (--ref-max-bytes, default 10MB)\n- allow_private_host: Vec (--allow-private-host, repeatable)\n- allow_insecure_http: bool (--allow-insecure-http)\n\n**Execute flow:**\n1. Validate alias format (validate_alias)\n2. Check if alias exists — error unless --force\n3. Resolve auth (merge --auth-profile with explicit headers, --bearer)\n4. Determine source: URL (http/https), local file (file:// or path), or stdin (-)\n5. For URL: use AsyncHttpClient.fetch_spec() with SSRF/HTTPS policy\n6. For local file: canonicalize path, read bytes directly (no network policy)\n7. For stdin: read all bytes from stdin\n8. Detect format, normalize to JSON\n9. Parse as serde_json::Value\n10. Build index (build_index)\n11. Write cache (write_cache with all artifacts)\n12. Output robot JSON or human success message\n\n## Acceptance Criteria\n- [ ] `swagger-cli fetch ./petstore.json --alias pet` succeeds (local file)\n- [ ] `swagger-cli fetch https://... --alias pet --robot` outputs JSON with ok:true, data.alias, data.endpoint_count\n- [ ] `swagger-cli fetch - --alias stdin-api` reads from stdin\n- [ ] Alias validation rejects \"../bad\" before any network call\n- [ ] Existing alias without --force returns ALIAS_EXISTS (exit 6)\n- [ ] --force overwrites existing alias\n- [ ] --bearer TOKEN adds Authorization: Bearer TOKEN header\n- [ ] --auth-profile loads from config.toml\n- [ ] Robot output includes: alias, url, version, title, endpoint_count, schema_count, cached_at, source_format, cache_dir, files, content_hash\n- [ ] Auth header values never appear in output or error messages\n\n## Files\n- MODIFY: src/cli/fetch.rs (FetchArgs, execute, auth resolution, source routing)\n- MODIFY: src/output/robot.rs (add output_fetch function)\n- MODIFY: src/output/human.rs (add output_fetch function)\n\n## TDD Anchor\nRED: Write integration test `test_fetch_local_file` — use assert_cmd to run `swagger-cli fetch tests/fixtures/petstore.json --alias test-pet --robot` with SWAGGER_CLI_HOME set to tempdir. Assert exit 0, stdout JSON has ok:true.\nGREEN: Implement full fetch pipeline.\nVERIFY: `cargo test test_fetch_local_file`\n\nAdditional tests:\n- test_fetch_alias_exists_error\n- test_fetch_force_overwrites\n- test_fetch_stdin\n- test_fetch_bearer_auth\n- test_fetch_yaml_file (if YAML fixture exists)\n\n## Edge Cases\n- stdin (\"-\") is not a URL — don't try to HTTP-fetch it\n- Local file paths must be canonicalized (resolve symlinks, relative paths) before reading\n- file:// URLs must be converted to local paths (strip scheme)\n- If auth-profile references an EnvVar source, resolve the env var at fetch time\n- Robot output: redact --bearer and --auth-header values even in success output\n\n## Dependency Context\nUses AsyncHttpClient from bd-3b6 (async HTTP client with SSRF protection). Uses build_index, detect_format, normalize_to_json from bd-189 (spec format detection and index building). Uses CacheManager.write_cache and validate_alias from bd-1ie (cache write path). Uses Config for auth profiles from bd-1sb (configuration system).","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:26:35.220966Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:55:46.975549Z","compaction_level":0,"original_size":0,"labels":["fetch","phase1"],"dependencies":[{"issue_id":"bd-16o","depends_on_id":"bd-189","type":"blocks","created_at":"2026-02-12T16:34:06.059316Z","created_by":"tayloreernisse"},{"issue_id":"bd-16o","depends_on_id":"bd-1ie","type":"blocks","created_at":"2026-02-12T16:34:06.154074Z","created_by":"tayloreernisse"},{"issue_id":"bd-16o","depends_on_id":"bd-1sb","type":"blocks","created_at":"2026-02-12T16:34:06.248426Z","created_by":"tayloreernisse"},{"issue_id":"bd-16o","depends_on_id":"bd-3b6","type":"blocks","created_at":"2026-02-12T16:34:06.005417Z","created_by":"tayloreernisse"},{"issue_id":"bd-16o","depends_on_id":"bd-3d2","type":"blocks","created_at":"2026-02-12T16:26:35.222663Z","created_by":"tayloreernisse"},{"issue_id":"bd-16o","depends_on_id":"bd-3ny","type":"parent-child","created_at":"2026-02-12T16:26:35.222248Z","created_by":"tayloreernisse"}]} -{"id":"bd-189","title":"Implement spec format detection, YAML normalization, and index building","description":"## Background\nAfter downloading raw spec bytes, swagger-cli must detect the input format (JSON vs YAML), normalize YAML to JSON, parse the spec as serde_json::Value, and build a SpecIndex from it. The index building is the heart of the query performance -- it pre-extracts endpoints, schemas, and tags into a compact, sorted structure with JSON pointers back to raw.json. All pointers must be validated.\n\n## Approach\nCreate src/core/indexer.rs with:\n\n**Format detection:** `detect_format(bytes, filename_hint, content_type_hint) -> Format` where Format is Json or Yaml. Priority: explicit --input-format flag > Content-Type header > file extension > content sniffing (try JSON parse first, fall back to YAML).\n\n**YAML normalization:** `normalize_to_json(bytes, format) -> Result>` -- if YAML, parse with serde_yaml then serialize to serde_json. If JSON, pass through (validate it parses).\n\n**Index building:** `build_index(raw_json: &serde_json::Value, content_hash: &str, generation: u64) -> Result`:\n1. Extract info.title, info.version, openapi version\n2. Iterate paths.* -> methods -> build IndexedEndpoint with: path, method (uppercased), summary, description, operation_id, tags, deprecated, parameters (name/location/required/desc), request_body_required, request_body_content_types, security_schemes (effective: operation-level overrides root-level), security_required, operation_ptr (JSON pointer format: /paths/~1pet~1{petId}/get)\n3. Iterate components.schemas.* -> build IndexedSchema with name, schema_ptr\n4. Compute tags from endpoints + root-level tags -> IndexedTag with endpoint_count\n5. Sort deterministically: endpoints by (path ASC, method_rank ASC), schemas by (name ASC), tags by (name ASC)\n6. Validate ALL operation_ptr and schema_ptr resolve in the raw Value\n7. Set index_version to current version constant (1)\n\n**Canonical method ranking:** GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, OPTIONS=5, HEAD=6, TRACE=7, unknown=99.\n\n**JSON pointer encoding:** Path segments use RFC 6901: `~` -> `~0`, `/` -> `~1`. So `/pet/{petId}` becomes `/paths/~1pet~1{petId}`.\n\n## Acceptance Criteria\n- [ ] JSON input detected and passed through correctly\n- [ ] YAML input detected and normalized to equivalent JSON\n- [ ] build_index extracts correct endpoint count from petstore spec (19 endpoints)\n- [ ] Endpoints sorted by path ASC, method_rank ASC (deterministic)\n- [ ] Schemas sorted by name ASC\n- [ ] Tags have correct endpoint_count\n- [ ] All operation_ptr values resolve in raw Value (validated during build_index)\n- [ ] Invalid pointer causes fetch failure (not silent corruption)\n- [ ] JSON pointer encoding handles /pet/{petId} correctly (escapes /)\n- [ ] Security inheritance: operation without security inherits root; operation with empty [] means no auth\n\n## Files\n- CREATE: src/core/indexer.rs (detect_format, normalize_to_json, build_index, method_rank, json_pointer_encode, validate_pointers)\n- MODIFY: src/core/mod.rs (pub mod indexer;)\n\n## TDD Anchor\nRED: Write `test_build_index_petstore` -- load tests/fixtures/petstore.json as Value, call build_index, assert endpoint_count == 19 and endpoints are sorted.\nGREEN: Implement full index building.\nVERIFY: `cargo test test_build_index_petstore`\n\nAdditional tests:\n- test_detect_format_json, test_detect_format_yaml\n- test_yaml_normalization_roundtrip\n- test_json_pointer_encoding\n- test_method_rank_ordering\n- test_security_inheritance\n- test_invalid_pointer_rejected\n\n## Edge Cases\n- Some specs have paths with special chars in operation IDs -- don't assume alphanumeric\n- serde_yaml may produce different JSON than direct JSON parse (number types, null handling) -- normalize consistently\n- Large specs (8MB+ GitHub) should still build index in <1s\n- OpenAPI 3.1 may use `webhooks` key -- ignore for MVP (only extract from `paths`)\n- Tags defined at root level but not used by any operation should still appear with endpoint_count=0\n\n## Dependency Context\nUses SpecIndex, IndexedEndpoint, IndexedSchema, IndexedTag, IndexedParam types from bd-ilo (error types + core models bead).","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:26:35.194671Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:45:39.762612Z","compaction_level":0,"original_size":0,"labels":["fetch","phase1"],"dependencies":[{"issue_id":"bd-189","depends_on_id":"bd-3ny","type":"parent-child","created_at":"2026-02-12T16:26:35.195659Z","created_by":"tayloreernisse"},{"issue_id":"bd-189","depends_on_id":"bd-ilo","type":"blocks","created_at":"2026-02-12T16:26:35.196142Z","created_by":"tayloreernisse"}]} +{"id":"bd-189","title":"Implement spec format detection, YAML normalization, and index building","description":"## Background\nAfter downloading raw spec bytes, swagger-cli must detect the input format (JSON vs YAML), normalize YAML to JSON, parse the spec as serde_json::Value, and build a SpecIndex from it. The index building is the heart of the query performance -- it pre-extracts endpoints, schemas, and tags into a compact, sorted structure with JSON pointers back to raw.json. All pointers must be validated.\n\n## Approach\nCreate src/core/indexer.rs with:\n\n**Format detection:** `detect_format(bytes, filename_hint, content_type_hint) -> Format` where Format is Json or Yaml. Priority: explicit --input-format flag > Content-Type header > file extension > content sniffing (try JSON parse first, fall back to YAML).\n\n**YAML normalization:** `normalize_to_json(bytes, format) -> Result>` -- if YAML, parse with serde_yaml then serialize to serde_json. If JSON, pass through (validate it parses).\n\n**Index building:** `build_index(raw_json: &serde_json::Value, content_hash: &str, generation: u64) -> Result`:\n1. Extract info.title, info.version, openapi version\n2. Iterate paths.* -> methods -> build IndexedEndpoint with: path, method (uppercased), summary, description, operation_id, tags, deprecated, parameters (name/location/required/desc), request_body_required, request_body_content_types, security_schemes (effective: operation-level overrides root-level), security_required, operation_ptr (JSON pointer format: /paths/~1pet~1{petId}/get)\n3. Iterate components.schemas.* -> build IndexedSchema with name, schema_ptr\n4. Compute tags from endpoints + root-level tags -> IndexedTag with endpoint_count\n5. Sort deterministically: endpoints by (path ASC, method_rank ASC), schemas by (name ASC), tags by (name ASC)\n6. Validate ALL operation_ptr and schema_ptr resolve in the raw Value\n7. Set index_version to current version constant (1)\n\n**Canonical method ranking:** GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, OPTIONS=5, HEAD=6, TRACE=7, unknown=99.\n\n**JSON pointer encoding:** Path segments use RFC 6901: `~` -> `~0`, `/` -> `~1`. So `/pet/{petId}` becomes `/paths/~1pet~1{petId}`.\n\n## Acceptance Criteria\n- [ ] JSON input detected and passed through correctly\n- [ ] YAML input detected and normalized to equivalent JSON\n- [ ] build_index extracts correct endpoint count from petstore spec (19 endpoints)\n- [ ] Endpoints sorted by path ASC, method_rank ASC (deterministic)\n- [ ] Schemas sorted by name ASC\n- [ ] Tags have correct endpoint_count\n- [ ] All operation_ptr values resolve in raw Value (validated during build_index)\n- [ ] Invalid pointer causes fetch failure (not silent corruption)\n- [ ] JSON pointer encoding handles /pet/{petId} correctly (escapes /)\n- [ ] Security inheritance: operation without security inherits root; operation with empty [] means no auth\n\n## Files\n- CREATE: src/core/indexer.rs (detect_format, normalize_to_json, build_index, method_rank, json_pointer_encode, validate_pointers)\n- MODIFY: src/core/mod.rs (pub mod indexer;)\n\n## TDD Anchor\nRED: Write `test_build_index_petstore` -- load tests/fixtures/petstore.json as Value, call build_index, assert endpoint_count == 19 and endpoints are sorted.\nGREEN: Implement full index building.\nVERIFY: `cargo test test_build_index_petstore`\n\nAdditional tests:\n- test_detect_format_json, test_detect_format_yaml\n- test_yaml_normalization_roundtrip\n- test_json_pointer_encoding\n- test_method_rank_ordering\n- test_security_inheritance\n- test_invalid_pointer_rejected\n\n## Edge Cases\n- Some specs have paths with special chars in operation IDs -- don't assume alphanumeric\n- serde_yaml may produce different JSON than direct JSON parse (number types, null handling) -- normalize consistently\n- Large specs (8MB+ GitHub) should still build index in <1s\n- OpenAPI 3.1 may use `webhooks` key -- ignore for MVP (only extract from `paths`)\n- Tags defined at root level but not used by any operation should still appear with endpoint_count=0\n\n## Dependency Context\nUses SpecIndex, IndexedEndpoint, IndexedSchema, IndexedTag, IndexedParam types from bd-ilo (error types + core models bead).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:26:35.194671Z","created_by":"tayloreernisse","updated_at":"2026-02-12T17:41:12.926777Z","closed_at":"2026-02-12T17:41:12.926730Z","close_reason":"Format detection, YAML normalization, index building with pointer validation","compaction_level":0,"original_size":0,"labels":["fetch","phase1"],"dependencies":[{"issue_id":"bd-189","depends_on_id":"bd-3ny","type":"parent-child","created_at":"2026-02-12T16:26:35.195659Z","created_by":"tayloreernisse"},{"issue_id":"bd-189","depends_on_id":"bd-ilo","type":"blocks","created_at":"2026-02-12T16:26:35.196142Z","created_by":"tayloreernisse"}]} {"id":"bd-1bp","title":"Implement fetch-time external ref bundling","description":"## Background\nOptional fetch-time external ref bundling. When --resolve-external-refs is passed during fetch, external $ref targets are fetched and inlined into raw.json. Requires explicit --ref-allow-host allowlist. Bounded by --ref-max-depth and --ref-max-bytes.\n\n## Approach\nAfter fetching the main spec and parsing as Value:\n1. Walk the Value tree looking for $ref values that don't start with \"#/\"\n2. For each external ref: parse URL, check host against --ref-allow-host allowlist (reject if not listed)\n3. Fetch external ref content (uses AsyncHttpClient, respects SSRF policy)\n4. Track total bytes fetched (abort at --ref-max-bytes)\n5. Track resolution depth (abort at --ref-max-depth for ref chains)\n6. Replace the $ref object with the fetched content\n7. Store bundled result as raw.json; original (with $ref pointers) as raw.source\n\n## Acceptance Criteria\n- [ ] --resolve-external-refs without --ref-allow-host returns USAGE_ERROR\n- [ ] External refs to allowed hosts are fetched and inlined\n- [ ] External refs to disallowed hosts are rejected with PolicyBlocked\n- [ ] --ref-max-depth limits chain resolution\n- [ ] --ref-max-bytes limits total fetched content\n- [ ] Bundled raw.json has all external refs resolved; raw.source preserves originals\n\n## Edge Cases\n- **Circular external refs:** A.yaml $refs B.yaml which $refs A.yaml. Must detect and break cycles (return error or annotate).\n- **Relative refs:** `$ref: \"./schemas/Pet.yaml\"` must resolve relative to the base spec URL, not CWD.\n- **Mixed internal + external refs:** Only resolve external refs (not starting with #/). Internal refs stay as-is.\n- **External ref returns non-JSON/YAML:** Return InvalidSpec error for that ref, don't fail the entire fetch.\n- **--ref-max-bytes exceeded:** Abort cleanly and report how many refs were resolved before the limit.\n\n## Files\n- CREATE: src/core/external_refs.rs (resolve_external_refs, walk_refs, fetch_ref)\n- MODIFY: src/cli/fetch.rs (integrate external ref resolution after main fetch)\n- MODIFY: src/core/mod.rs (pub mod external_refs;)\n\n## TDD Anchor\nRED: Write `test_external_ref_resolution` — create a spec with external $ref, mock the external URL, run fetch --resolve-external-refs --ref-allow-host localhost, verify ref is inlined.\nGREEN: Implement ref walking and fetching.\nVERIFY: `cargo test test_external_ref_resolution`\n\n## Dependency Context\nUses AsyncHttpClient from bd-3b6. Extends fetch pipeline from bd-16o.","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:29:50.213098Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:58:12.383898Z","compaction_level":0,"original_size":0,"labels":["global","phase2"],"dependencies":[{"issue_id":"bd-1bp","depends_on_id":"bd-16o","type":"blocks","created_at":"2026-02-12T16:29:50.214577Z","created_by":"tayloreernisse"},{"issue_id":"bd-1bp","depends_on_id":"bd-3ll","type":"parent-child","created_at":"2026-02-12T16:29:50.214143Z","created_by":"tayloreernisse"}]} {"id":"bd-1ck","title":"Implement diff command with structural comparison and CI gates","description":"## Background\nThe diff command compares two spec states structurally. It reports added, removed, and modified endpoints and schemas by comparing normalized indexes. Supports alias-vs-alias, alias-vs-URL, and --fail-on breaking for CI gates.\n\n## Approach\nImplement src/cli/diff.rs with DiffArgs and async execute():\n\n**DiffArgs:** left (String, positional), right (String, positional), fail_on (Option — \"breaking\"), details (bool).\n\n**Source resolution:** LEFT and RIGHT can be alias names or URLs. If URL, fetch into a temp location, build index, use for comparison (don't persist to cache).\n\n**Diff computation (reuse from sync):**\n- Compare endpoint sets by (path, method): added = in right not left, removed = in left not right, modified = in both but different (compare summary, tags, deprecated, parameters, security)\n- Compare schema sets by name: added, removed, modified (compare schema_ptr target content hashes? or just name-based for Phase 2)\n- Summary: total_changes, has_breaking (Phase 2: just structural; Phase 3: semantic classification)\n\n**--fail-on breaking:** If breaking changes detected, exit non-zero (exit code 17). Phase 2 heuristic: removed endpoint = breaking, removed required parameter = breaking. Added endpoint/schema = non-breaking.\n\n## Acceptance Criteria\n- [ ] `diff alias1 alias2 --robot` reports structural changes\n- [ ] Added/removed/modified endpoints correctly identified\n- [ ] --fail-on breaking exits non-zero when breaking changes exist\n- [ ] URL as right-side source works (temp fetch, not persisted)\n- [ ] Robot output: left, right, changes.endpoints, changes.schemas, summary\n\n## Edge Cases\n- **Same spec on both sides:** Return ok:true with changed:false, empty change lists. Not an error.\n- **URL-as-source fails to fetch:** Return Network error, not a diff-specific error.\n- **Breaking change heuristic false positives:** Endpoint renamed (removed old + added new) looks like a breaking removal. Phase 2 is heuristic-only — document this limitation.\n- **Very large diff (thousands of changes):** Apply same 200-item cap as sync --details with truncated flag.\n- **Schema comparison:** Phase 2 is name-based only (added/removed by name). Modified schema detection is Phase 3 (requires deep structural comparison).\n\n## Files\n- MODIFY: src/cli/diff.rs (DiffArgs, execute, compute_diff, classify_breaking)\n- MODIFY: src/output/robot.rs (add output_diff)\n- MODIFY: src/output/human.rs (add output_diff)\n\n## TDD Anchor\nRED: Write `test_diff_detects_added_endpoint` — fetch petstore twice (one modified), run diff, assert added endpoint appears.\nGREEN: Implement index comparison.\nVERIFY: `cargo test test_diff_detects_added`\n\n## Dependency Context\nReuses index diff logic from sync command (bd-3f4). Uses AsyncHttpClient for URL-as-source (bd-3b6). Uses indexer for building temp index from URL (bd-189).","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:30:58.972480Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:58:11.727380Z","compaction_level":0,"original_size":0,"labels":["diff","phase2"],"dependencies":[{"issue_id":"bd-1ck","depends_on_id":"bd-189","type":"blocks","created_at":"2026-02-12T16:30:58.976840Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ck","depends_on_id":"bd-21m","type":"parent-child","created_at":"2026-02-12T16:30:58.975608Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ck","depends_on_id":"bd-3f4","type":"blocks","created_at":"2026-02-12T16:30:58.976270Z","created_by":"tayloreernisse"}]} {"id":"bd-1cv","title":"Implement global network policy (offline/auto/online-only)","description":"## Background\nThe --network global flag controls whether swagger-cli makes network calls. \"auto\" (default) allows network when needed. \"offline\" blocks all network calls (fetch/sync fail with OFFLINE_MODE). \"online-only\" flags when network would be needed but is not available. This is critical for CI reproducibility and agent sandboxing.\n\n## Approach\n\n### NetworkPolicy Enum (src/core/network.rs)\nCreate `NetworkPolicy` enum with three variants:\n- **Auto** (default): Allow network calls when needed, no restrictions. Standard behavior.\n- **Offline**: Block ALL outbound network calls preemptively. Any command that would make a network request (fetch, sync) returns `SwaggerCliError::OfflineMode` immediately without attempting the call. Commands that are index-only (list, search, show, tags) work normally.\n- **OnlineOnly**: Allow network calls but surface a clear, distinct error if a network call would be needed AND DNS resolution fails or the host is unreachable. The error is different from OfflineMode — it indicates \"you requested online-only mode but network is unavailable\" rather than \"network calls are blocked by policy.\"\n\n### Implementation\n1. Parse --network flag in CLI (already in Cli struct from skeleton bead)\n2. Create `NetworkPolicy` enum and `check_network_policy()` function in `src/core/network.rs`\n3. Check policy before any HTTP call in AsyncHttpClient — if Offline, return `SwaggerCliError::OfflineMode` without making the request\n4. For OnlineOnly: attempt the request, but if it fails due to network issues, return a specific `SwaggerCliError::NetworkUnavailable` (different exit code or error code than OfflineMode)\n5. `SWAGGER_CLI_NETWORK` env var as alternative to --network flag (flag takes precedence)\n\n### Scope boundaries\n- **Local file sources (file:// paths, stdin):** Network policy does NOT apply. These are local I/O operations. `fetch ./local-spec.yaml --network offline` should succeed because no network call is made.\n- **Stdin sources:** Same as local files — no network needed, policy irrelevant.\n- **sync --dry-run in offline mode:** ALLOWED. Dry-run compares cached state without actually fetching, so no network call is needed. Returns cached state comparison only.\n\n## Acceptance Criteria\n- [ ] NetworkPolicy enum with Auto, Offline, OnlineOnly variants in src/core/network.rs\n- [ ] --network offline causes fetch (remote URL) to fail with OFFLINE_MODE error (exit 15)\n- [ ] --network offline allows fetch from local file path (no network needed)\n- [ ] --network offline allows fetch from stdin (no network needed)\n- [ ] --network offline allows list/search/show/tags (index-only, no network needed)\n- [ ] --network offline allows sync --dry-run (cached comparison only, no network)\n- [ ] --network online-only surfaces clear error when network is unavailable (distinct from OFFLINE_MODE)\n- [ ] --network auto allows all commands (default behavior, no restrictions)\n- [ ] SWAGGER_CLI_NETWORK=offline env var works same as --network offline flag\n- [ ] Flag takes precedence over env var when both are set\n- [ ] Robot error for offline mode has code OFFLINE_MODE with suggestion to remove --network flag or unset env var\n- [ ] Robot error for online-only network failure has code NETWORK_UNAVAILABLE with distinct suggestion\n\n## Edge Cases\n- **sync --dry-run in offline mode:** Allowed — returns cached state comparison only, no actual fetch happens. This is a read-only operation on cached data.\n- **Local file + offline mode:** Allowed — `fetch ./spec.yaml --network offline` succeeds because it is local I/O, not a network call.\n- **OnlineOnly vs Offline distinction:** Offline blocks proactively (never attempts the call). OnlineOnly attempts the call and fails with a specific error if the network is down. This matters for agents that want to know \"was this blocked by policy or by actual network unavailability?\"\n- **Mixed sources:** If a future version supports specs that reference remote $ref URLs but the base spec is local, the network policy should apply to the remote $ref resolution (not the local file read).\n\n## Files\n- CREATE: src/core/network.rs (NetworkPolicy enum, check_network_policy function)\n- MODIFY: src/core/http.rs (check policy before fetch)\n- MODIFY: src/cli/fetch.rs (check policy at start, skip for local/stdin sources)\n- MODIFY: src/cli/sync.rs (check policy at start, allow --dry-run in offline)\n\n## TDD Anchor\nRED: Write `test_offline_blocks_fetch` — set SWAGGER_CLI_NETWORK=offline, run fetch with remote URL, assert exit 15 and OFFLINE_MODE error.\nRED: Write `test_offline_allows_local_file` — set offline, fetch local file, assert success.\nRED: Write `test_offline_allows_list` — set offline, run list on cached alias, assert success.\nRED: Write `test_online_only_network_unavailable` — set online-only, mock DNS failure, assert NETWORK_UNAVAILABLE error.\nGREEN: Implement policy check.\nVERIFY: `cargo test test_offline_blocks_fetch`\n\n## Dependency Context\nModifies AsyncHttpClient in bd-3b6 (async HTTP client) to check network policy before requests. Uses SwaggerCliError variants (OfflineMode) from bd-ilo (error types and core data models).","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:29:50.156478Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:56:01.445917Z","compaction_level":0,"original_size":0,"labels":["global","phase2"],"dependencies":[{"issue_id":"bd-1cv","depends_on_id":"bd-16o","type":"blocks","created_at":"2026-02-12T16:29:50.158591Z","created_by":"tayloreernisse"},{"issue_id":"bd-1cv","depends_on_id":"bd-3b6","type":"blocks","created_at":"2026-02-12T16:29:50.158232Z","created_by":"tayloreernisse"},{"issue_id":"bd-1cv","depends_on_id":"bd-3ll","type":"parent-child","created_at":"2026-02-12T16:29:50.157784Z","created_by":"tayloreernisse"}]} {"id":"bd-1d4","title":"Implement cache lifecycle command with stats, prune, and LRU eviction","description":"## Background\nThe cache command manages cache growth with stats, pruning, and LRU eviction. Separate from doctor (which validates health). Uses coalesced last_accessed timestamps for LRU ordering.\n\n## Approach\nImplement src/cli/cache.rs with CacheArgs and execute():\n\n**CacheArgs:** stats (bool, default action), prune_stale (bool), prune_threshold (u32, default 90 days), max_total_mb (Option), dry_run (bool).\n\n**Operations:**\n- Stats: list_aliases with size computation, show per-alias and total bytes\n- Prune: find aliases older than threshold, delete (or dry-run report)\n- LRU eviction: sort by last_accessed ASC, delete oldest until total < max_total_mb (or dry-run)\n\n## Acceptance Criteria\n- [ ] cache --stats shows per-alias sizes and total\n- [ ] cache --prune-stale deletes aliases >90 days old\n- [ ] cache --prune-threshold 30 overrides default\n- [ ] cache --max-total-mb 500 evicts oldest-accessed aliases\n- [ ] --dry-run shows what would be pruned without deleting\n- [ ] Robot output: aliases[], total_bytes, pruned[], evicted[]\n\n## Edge Cases\n- **No aliases cached:** cache --stats returns ok:true with total_bytes:0, empty aliases array.\n- **Concurrent prune + fetch:** If a fetch writes a new alias while prune is deleting, the new alias should not be affected. Prune operates on snapshot of alias list taken at start.\n- **last_accessed coalescing:** LRU ordering uses coalesced last_accessed (10-min granularity). Hot-read aliases within the same 10-min window have identical last_accessed — tie-break by fetched_at.\n- **--max-total-mb smaller than single largest alias:** Evict everything except the largest, then warn that target cannot be reached.\n- **Alias being synced during prune:** Skip locked aliases with warning.\n\n## Files\n- MODIFY: src/cli/cache.rs (CacheArgs, execute, prune, evict)\n- MODIFY: src/output/robot.rs (add output_cache)\n- MODIFY: src/output/human.rs (add output_cache)\n\n## TDD Anchor\nRED: Write `test_cache_prune_stale` — create alias with old fetched_at, run cache --prune-stale --robot, assert alias appears in pruned[].\nGREEN: Implement stale detection and pruning.\nVERIFY: `cargo test test_cache_prune_stale`\n\n## Dependency Context\nUses CacheManager (list_aliases, delete_alias) from bd-3ea. Uses is_stale from CacheMetadata.","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:29:50.122830Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:58:11.481108Z","compaction_level":0,"original_size":0,"labels":["health","phase2"],"dependencies":[{"issue_id":"bd-1d4","depends_on_id":"bd-1y0","type":"parent-child","created_at":"2026-02-12T16:29:50.123937Z","created_by":"tayloreernisse"},{"issue_id":"bd-1d4","depends_on_id":"bd-3d2","type":"blocks","created_at":"2026-02-12T16:29:50.124722Z","created_by":"tayloreernisse"},{"issue_id":"bd-1d4","depends_on_id":"bd-3ea","type":"blocks","created_at":"2026-02-12T16:29:50.124351Z","created_by":"tayloreernisse"}]} {"id":"bd-1dj","title":"Implement show command with ref expansion and pointer navigation","description":"## Background\nThe show command displays full details for a specific endpoint. Unlike list/search, show MUST load raw.json to extract the complete operation subtree (parameters with schemas, request body, response schemas, etc.). It uses the operation_ptr from the index to locate the exact JSON node in raw.json, avoiding full-spec parsing. Optional --expand-refs resolves internal $ref pointers with cycle detection.\n\n## Approach\nImplement src/cli/show.rs with ShowArgs and execute():\n\n**ShowArgs:** alias (Option), path (String, positional), method (Option), format (String, default \"pretty\"), expand_refs (bool), max_depth (u32, default 3).\n\n**Execute flow:**\n1. Resolve alias, load_index\n2. Find matching endpoint(s) by path in index\n3. If multiple methods and --method not specified → USAGE_ERROR listing available methods\n4. Get operation_ptr from matched IndexedEndpoint\n5. CacheManager::load_raw(alias, &meta) — loads raw.json as Value, validates raw_hash\n6. Navigate to operation subtree using JSON pointer (operation_ptr)\n7. If --expand-refs: recursively resolve internal $ref pointers (starting with #/) with cycle detection (max_depth). External refs get annotated as {\"$external_ref\": \"...\"}. Circular refs get {\"$circular_ref\": \"...\"}. Add warnings to meta.warnings[].\n8. Output robot JSON or human formatted details\n\n**JSON Pointer navigation:** Parse `/paths/~1pet~1{petId}/get` → navigate Value tree. `~1` → `/`, `~0` → `~`.\n\n**Ref expansion:** Walk the Value tree. When encountering `{\"$ref\": \"#/components/schemas/Pet\"}`, resolve by navigating the pointer in raw Value. Track visited refs for cycle detection. Stop at max_depth.\n\n## Acceptance Criteria\n- [ ] `swagger-cli show petstore \"/pet/{petId}\" --robot` returns full operation details\n- [ ] Multiple methods without --method returns USAGE_ERROR with available methods\n- [ ] --method POST selects specific method\n- [ ] operation_ptr correctly navigates to the right subtree in raw.json\n- [ ] --expand-refs resolves internal refs up to max_depth\n- [ ] Circular refs produce $circular_ref annotation (not infinite loop)\n- [ ] External refs produce $external_ref annotation + warning\n- [ ] raw_hash mismatch returns CacheIntegrity error\n- [ ] Robot output includes: path, method, summary, description, tags, operation_id, parameters, request_body, responses, security\n\n## Files\n- MODIFY: src/cli/show.rs (ShowArgs, execute, pointer navigation, ref expansion)\n- CREATE: src/core/refs.rs (expand_refs, resolve_pointer, cycle detection)\n- MODIFY: src/core/mod.rs (pub mod refs;)\n- MODIFY: src/output/robot.rs (add output_show)\n- MODIFY: src/output/human.rs (add output_show)\n\n## TDD Anchor\nRED: Write `test_show_endpoint_details` — fetch petstore fixture, run show \"/pet/{petId}\" --method GET --robot, parse JSON, assert data.path == \"/pet/{petId}\" and data.method == \"GET\".\nGREEN: Implement pointer navigation and output.\nVERIFY: `cargo test test_show_endpoint_details`\n\nAdditional tests:\n- test_show_multiple_methods_error\n- test_expand_refs_basic\n- test_expand_refs_circular_detection\n- test_expand_refs_external_annotation\n\n## Edge Cases\n- JSON pointer decoding: `~1` → `/`, `~0` → `~` (order matters: decode ~1 first, then ~0)\n- Path matching should be exact (not regex) — \"/pet/{petId}\" must match literally\n- Some operations may have no request body, no parameters, or no security — handle None gracefully\n- ref expansion must handle refs-to-refs (transitive resolution)\n\n## Dependency Context\nUses CacheManager.load_index and load_raw from bd-3ea (cache read). Uses index types from bd-ilo. Requires a fetched spec in cache.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:27:27.091022Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:27:27.093228Z","compaction_level":0,"original_size":0,"labels":["phase1","query"],"dependencies":[{"issue_id":"bd-1dj","depends_on_id":"bd-3d2","type":"blocks","created_at":"2026-02-12T16:27:27.093222Z","created_by":"tayloreernisse"},{"issue_id":"bd-1dj","depends_on_id":"bd-3ea","type":"blocks","created_at":"2026-02-12T16:27:27.092774Z","created_by":"tayloreernisse"},{"issue_id":"bd-1dj","depends_on_id":"bd-epk","type":"parent-child","created_at":"2026-02-12T16:27:27.092375Z","created_by":"tayloreernisse"}]} -{"id":"bd-1ie","title":"Implement cache write path with crash-consistent protocol and alias validation","description":"## Background\nThe cache write path is the most safety-critical code in swagger-cli. It implements a crash-consistent multi-file commit protocol: raw.source -> raw.json -> index.json are written as .tmp files with fsync, renamed atomically, then meta.json is written LAST as the commit marker. Per-alias file locking prevents concurrent write corruption. Alias names are validated against a strict regex to prevent path traversal.\n\n## Approach\n**Alias validation:** Implement `validate_alias()` that checks against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, rejects path separators (/ \\), `..`, reserved device names (CON, PRN, NUL, AUX, COM1-9, LPT1-9 case-insensitive), and leading dots. Return SwaggerCliError::Usage on failure.\n\n**Cache directory management:** Implement `CacheManager` struct with `new()` (uses Config::cache_dir()), `new_with_path()` (for tests), `alias_dir()`, `ensure_dirs()`. Cache layout: `{cache_dir}/aliases/{alias}/`.\n\n**File locking:** Use `fs2::FileExt::try_lock_exclusive()` on `{alias_dir}/.lock` with a bounded timeout (default 1000ms, poll every 50ms). Return SwaggerCliError::CacheLocked on timeout.\n\n**Hash computation:** Implement `compute_hash(bytes: &[u8]) -> String` returning `\"sha256:{hex}\"` using sha2 crate.\n\n**Crash-consistent write:** Implement `CacheManager::write_cache()` that:\n1. Acquires exclusive lock on .lock (bounded timeout)\n2. Computes content_hash, raw_hash, next generation, index_hash\n3. Writes raw.source.tmp, raw.json.tmp, index.json.tmp (each with sync_all before rename)\n4. Renames each .tmp to final name (sync_all after each rename)\n5. Writes meta.json.tmp LAST (commit marker)\n6. Renames meta.json.tmp -> meta.json (sync_all)\n7. Best-effort fsync on alias directory fd (Unix only)\n8. Releases lock\n\n**Parameters for write_cache:** Takes alias, raw_source_bytes, raw_json_bytes, index (SpecIndex), url, spec_version, spec_title, source_format, etag, last_modified, previous_generation (Option).\n\n## Acceptance Criteria\n- [ ] validate_alias(\"petstore\") -> Ok\n- [ ] validate_alias(\"../etc/passwd\") -> Err(Usage)\n- [ ] validate_alias(\"CON\") -> Err(Usage) (case-insensitive)\n- [ ] validate_alias(\".hidden\") -> Err(Usage)\n- [ ] validate_alias(\"a\".repeat(65)) -> Err(Usage)\n- [ ] write_cache creates all 4 files + .lock in correct directory\n- [ ] meta.json is the last file written (verified by checking file mtimes or write order)\n- [ ] compute_hash produces deterministic sha256:hex output\n- [ ] Lock timeout returns CacheLocked error (not hang)\n- [ ] Hash values are deterministic (same input -> same sha256 output)\n- [ ] Files survive process kill between steps (no partial meta.json)\n\n## Files\n- CREATE: src/core/cache.rs (CacheManager, validate_alias, compute_hash, write_cache, lock helpers)\n- MODIFY: src/core/mod.rs (pub mod cache;)\n\n## TDD Anchor\nRED: Write `test_validate_alias_rejects_traversal` -- assert validate_alias(\"../etc\") returns Err with Usage variant.\nGREEN: Implement regex validation + blocklist checks.\nVERIFY: `cargo test test_validate_alias`\n\nAdditional tests:\n- test_validate_alias_accepts_valid (petstore, my-api, v1.0, API_2)\n- test_validate_alias_rejects_reserved (CON, con, NUL, COM1)\n- test_write_cache_creates_all_files\n- test_compute_hash_deterministic\n\n## Edge Cases\n- On macOS, fsync on directory fd may not be supported -- handle gracefully (best-effort)\n- Lock file must be created if it doesn't exist (open with create flag)\n- If alias directory doesn't exist, create it before acquiring lock\n- sync_all() is critical -- without it, data may be in OS page cache but not on disk after rename\n- Generation starts at 1 for new aliases, increments from previous for updates\n\n## Dependency Context\nUses SpecIndex and CacheMetadata types from bd-ilo (error types and core data models). Uses SwaggerCliError variants (CacheLocked, Cache, Usage) from bd-ilo.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:25:15.503359Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:55:51.728315Z","compaction_level":0,"original_size":0,"labels":["infrastructure","phase1"],"dependencies":[{"issue_id":"bd-1ie","depends_on_id":"bd-hcb","type":"parent-child","created_at":"2026-02-12T16:25:15.504778Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ie","depends_on_id":"bd-ilo","type":"blocks","created_at":"2026-02-12T16:25:15.505170Z","created_by":"tayloreernisse"}]} +{"id":"bd-1ie","title":"Implement cache write path with crash-consistent protocol and alias validation","description":"## Background\nThe cache write path is the most safety-critical code in swagger-cli. It implements a crash-consistent multi-file commit protocol: raw.source -> raw.json -> index.json are written as .tmp files with fsync, renamed atomically, then meta.json is written LAST as the commit marker. Per-alias file locking prevents concurrent write corruption. Alias names are validated against a strict regex to prevent path traversal.\n\n## Approach\n**Alias validation:** Implement `validate_alias()` that checks against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, rejects path separators (/ \\), `..`, reserved device names (CON, PRN, NUL, AUX, COM1-9, LPT1-9 case-insensitive), and leading dots. Return SwaggerCliError::Usage on failure.\n\n**Cache directory management:** Implement `CacheManager` struct with `new()` (uses Config::cache_dir()), `new_with_path()` (for tests), `alias_dir()`, `ensure_dirs()`. Cache layout: `{cache_dir}/aliases/{alias}/`.\n\n**File locking:** Use `fs2::FileExt::try_lock_exclusive()` on `{alias_dir}/.lock` with a bounded timeout (default 1000ms, poll every 50ms). Return SwaggerCliError::CacheLocked on timeout.\n\n**Hash computation:** Implement `compute_hash(bytes: &[u8]) -> String` returning `\"sha256:{hex}\"` using sha2 crate.\n\n**Crash-consistent write:** Implement `CacheManager::write_cache()` that:\n1. Acquires exclusive lock on .lock (bounded timeout)\n2. Computes content_hash, raw_hash, next generation, index_hash\n3. Writes raw.source.tmp, raw.json.tmp, index.json.tmp (each with sync_all before rename)\n4. Renames each .tmp to final name (sync_all after each rename)\n5. Writes meta.json.tmp LAST (commit marker)\n6. Renames meta.json.tmp -> meta.json (sync_all)\n7. Best-effort fsync on alias directory fd (Unix only)\n8. Releases lock\n\n**Parameters for write_cache:** Takes alias, raw_source_bytes, raw_json_bytes, index (SpecIndex), url, spec_version, spec_title, source_format, etag, last_modified, previous_generation (Option).\n\n## Acceptance Criteria\n- [ ] validate_alias(\"petstore\") -> Ok\n- [ ] validate_alias(\"../etc/passwd\") -> Err(Usage)\n- [ ] validate_alias(\"CON\") -> Err(Usage) (case-insensitive)\n- [ ] validate_alias(\".hidden\") -> Err(Usage)\n- [ ] validate_alias(\"a\".repeat(65)) -> Err(Usage)\n- [ ] write_cache creates all 4 files + .lock in correct directory\n- [ ] meta.json is the last file written (verified by checking file mtimes or write order)\n- [ ] compute_hash produces deterministic sha256:hex output\n- [ ] Lock timeout returns CacheLocked error (not hang)\n- [ ] Hash values are deterministic (same input -> same sha256 output)\n- [ ] Files survive process kill between steps (no partial meta.json)\n\n## Files\n- CREATE: src/core/cache.rs (CacheManager, validate_alias, compute_hash, write_cache, lock helpers)\n- MODIFY: src/core/mod.rs (pub mod cache;)\n\n## TDD Anchor\nRED: Write `test_validate_alias_rejects_traversal` -- assert validate_alias(\"../etc\") returns Err with Usage variant.\nGREEN: Implement regex validation + blocklist checks.\nVERIFY: `cargo test test_validate_alias`\n\nAdditional tests:\n- test_validate_alias_accepts_valid (petstore, my-api, v1.0, API_2)\n- test_validate_alias_rejects_reserved (CON, con, NUL, COM1)\n- test_write_cache_creates_all_files\n- test_compute_hash_deterministic\n\n## Edge Cases\n- On macOS, fsync on directory fd may not be supported -- handle gracefully (best-effort)\n- Lock file must be created if it doesn't exist (open with create flag)\n- If alias directory doesn't exist, create it before acquiring lock\n- sync_all() is critical -- without it, data may be in OS page cache but not on disk after rename\n- Generation starts at 1 for new aliases, increments from previous for updates\n\n## Dependency Context\nUses SpecIndex and CacheMetadata types from bd-ilo (error types and core data models). Uses SwaggerCliError variants (CacheLocked, Cache, Usage) from bd-ilo.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:25:15.503359Z","created_by":"tayloreernisse","updated_at":"2026-02-12T17:41:12.870780Z","closed_at":"2026-02-12T17:41:12.870733Z","close_reason":"CacheManager with validate_alias, compute_hash, crash-consistent write_cache","compaction_level":0,"original_size":0,"labels":["infrastructure","phase1"],"dependencies":[{"issue_id":"bd-1ie","depends_on_id":"bd-hcb","type":"parent-child","created_at":"2026-02-12T16:25:15.504778Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ie","depends_on_id":"bd-ilo","type":"blocks","created_at":"2026-02-12T16:25:15.505170Z","created_by":"tayloreernisse"}]} {"id":"bd-1ky","title":"Implement sync --all with async concurrency and resumable execution","description":"## Background\nsync --all synchronizes all aliases concurrently. It uses bounded async concurrency (--jobs, default 4) with per-host throttling (--per-host, default 2) to avoid abusive request patterns. Supports resumable execution via checkpoint files and a failure budget (--max-failures) to limit blast radius.\n\n## Approach\nBuild on the single-alias sync from the previous bead:\n\n**Async concurrency:** Use tokio::sync::Semaphore for global concurrency (--jobs). Use a per-host semaphore map (HashMap) for --per-host throttling. Process aliases via futures::stream::StreamExt::buffer_unordered.\n\n**Resumable sync (--resume):** Write a checkpoint file ({cache_dir}/sync-checkpoint.json) after each alias completes. Contains: aliases_completed, aliases_failed, started_at. On --resume, skip already-completed aliases.\n\n**Failure budget (--max-failures):** Track failure count. When exceeded, abort remaining aliases and report partial results.\n\n**Retry-After:** Honor response header (seconds or HTTP-date format). Use exponential backoff + jitter when header absent.\n\n**Per-alias output:** Collect results from all aliases. Report per-alias success/failure in robot output. Don't abort on single-alias failure (unless failure budget exceeded).\n\n## Acceptance Criteria\n- [ ] sync --all processes all aliases concurrently\n- [ ] --jobs limits concurrent syncs (verified: never more than N in-flight)\n- [ ] --per-host limits requests to same host\n- [ ] --resume skips already-completed aliases\n- [ ] --max-failures aborts after N failures\n- [ ] Per-alias success/failure reported in robot output\n- [ ] Retry-After header honored\n\n## Edge Cases\n- **Empty aliases (no specs cached):** sync --all with no aliases should succeed with empty results, not error.\n- **All aliases fail:** If every alias fails sync, the overall command should still exit 0 with per-alias failure details (unless --max-failures triggered, then exit non-zero).\n- **Checkpoint file corruption:** If sync-checkpoint.json is malformed, delete it and start fresh (don't error).\n- **Per-host semaphore with many aliases to same host:** 10 aliases all pointing to api.example.com with --per-host 2 means only 2 concurrent to that host, even if --jobs allows more.\n- **Retry-After as HTTP-date:** Parse both formats: seconds (e.g., \"120\") and HTTP-date (e.g., \"Thu, 01 Jan 2026 00:00:00 GMT\").\n\n## Files\n- MODIFY: src/cli/sync.rs (add sync_all, concurrency control, checkpoint, failure budget)\n\n## TDD Anchor\nRED: Write `test_sync_all_concurrent` — set up 4 aliases, run sync --all --jobs 2 --robot, verify all 4 synced and output includes per-alias results.\nGREEN: Implement concurrent sync with semaphore.\nVERIFY: `cargo test test_sync_all_concurrent`\n\n## Dependency Context\nExtends single-alias sync from bd-3f4 (sync command). Uses tokio Semaphore for concurrency control. Uses CacheManager.list_aliases from bd-3ea (cache read path).","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:28:47.465114Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:58:09.820419Z","compaction_level":0,"original_size":0,"labels":["phase2","sync"],"dependencies":[{"issue_id":"bd-1ky","depends_on_id":"bd-161","type":"parent-child","created_at":"2026-02-12T16:28:47.466140Z","created_by":"tayloreernisse"},{"issue_id":"bd-1ky","depends_on_id":"bd-3f4","type":"blocks","created_at":"2026-02-12T16:34:06.315322Z","created_by":"tayloreernisse"}]} {"id":"bd-1lj","title":"Create CI/CD pipeline and cargo-deny configuration","description":"## Background\nThe CI/CD pipeline runs tests, linting, security audits, and multi-platform builds on GitLab CI. It also configures cargo-deny for license/advisory policy enforcement.\n\n## Approach\n\n### .gitlab-ci.yml Structure\n\n**Stages:** test, build, release\n\n**Test stage jobs:**\n- `test:unit` — `cargo test --lib` (unit tests only, no integration tests)\n- `test:integration` — `cargo test --test '*'` (integration tests in tests/ directory)\n- `lint` — `cargo fmt --check` + `cargo clippy -- -D warnings` (warnings are errors)\n- `security:deps` — `cargo-deny check` (license + advisory) + `cargo-audit` (RUSTSEC advisory DB)\n\n**Build stage:**\n- Build template (YAML anchor or extends) shared across 4 target jobs:\n - `build:aarch64-apple-darwin` — Apple Silicon macOS\n - `build:x86_64-apple-darwin` — Intel macOS\n - `build:x86_64-unknown-linux-gnu` — x86 Linux\n - `build:aarch64-unknown-linux-gnu` — ARM Linux\n- Each job: `cargo build --release --locked --target $TARGET`, upload binary as artifact\n\n**Release stage:**\n- `release` job (runs on tagged commits only):\n - Collect all 4 binaries from build artifacts\n - Generate `SHA256SUMS` file: `sha256sum swagger-cli-* > SHA256SUMS`\n - Sign with minisign: `minisign -S -m SHA256SUMS` (produces SHA256SUMS.minisig)\n - Upload all files to GitLab Package Registry via `curl` to Package Registry API\n- `docker` job (runs on tagged commits):\n - `docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG .`\n - `docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG`\n - Also tag as `:latest`\n\n### deny.toml Structure\n\n**[licenses]:**\n- `unlicensed = \"deny\"` — all deps must have a license\n- `allow = [\"MIT\", \"Apache-2.0\", \"BSD-2-Clause\", \"BSD-3-Clause\", \"ISC\", \"Zlib\", \"Unicode-3.0\", \"Unicode-DFS-2016\", \"OpenSSL\"]` — common OSS license allow-list\n- `copyleft = \"deny\"` — no copyleft licenses without explicit exception\n\n**[advisories]:**\n- `vulnerability = \"deny\"` — fail on known vulnerabilities\n- `unmaintained = \"warn\"` — warn but don't fail on unmaintained crates\n- `unsound = \"warn\"` — warn on unsound crates\n- `yanked = \"deny\"` — fail on yanked versions\n- `notice = \"warn\"` — warn on advisories with notices\n\n**[bans]:**\n- `multiple-versions = \"warn\"` — warn if same crate appears in multiple versions\n- `wildcards = \"deny\"` — no wildcard version specs\n- Specific ban list empty initially (add problematic crates as discovered)\n\n## Acceptance Criteria\n- [ ] .gitlab-ci.yml has test, build, release stages in correct order\n- [ ] test:unit runs `cargo test --lib`\n- [ ] test:integration runs `cargo test --test '*'`\n- [ ] lint job runs both fmt --check and clippy -D warnings\n- [ ] security:deps runs both cargo-deny check and cargo-audit\n- [ ] All 4 platform targets defined as separate build jobs\n- [ ] Build jobs use shared template/anchor to avoid duplication\n- [ ] Release job generates SHA256SUMS from all binaries\n- [ ] Release job signs SHA256SUMS with minisign\n- [ ] Release job uploads to GitLab Package Registry\n- [ ] Docker job builds and pushes to CI_REGISTRY\n- [ ] deny.toml license allow-list covers common OSS licenses\n- [ ] deny.toml advisory policy denies known vulnerabilities\n- [ ] `cargo deny check` passes locally after creation\n\n## Files\n- CREATE: .gitlab-ci.yml\n- CREATE: deny.toml\n\n## TDD Anchor\nValidate: `cargo deny check` passes locally (after deny.toml is created).\nVERIFY: `cargo deny check 2>&1 | head -5`\n\n## Edge Cases\n- **Cross-compilation toolchains:** macOS targets require macOS runners or cross-compilation tools. Verify CI has the right runners or use `cross` tool.\n- **cargo-deny not installed:** The security:deps job must install cargo-deny and cargo-audit before running. Use `cargo install` with version pinning.\n- **Minisign key management:** The release job needs the minisign private key as a CI secret variable. Document the required CI variables.\n- **Tag-only release trigger:** Release jobs must have `rules: - if: $CI_COMMIT_TAG` to avoid running on every push.\n- **Large binary artifacts:** Rust release binaries can be 10MB+. Verify CI artifact storage limits.","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:31:32.392078Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:56:20.412480Z","compaction_level":0,"original_size":0,"labels":["ci","phase2"],"dependencies":[{"issue_id":"bd-1lj","depends_on_id":"bd-1lo","type":"parent-child","created_at":"2026-02-12T16:31:32.394604Z","created_by":"tayloreernisse"},{"issue_id":"bd-1lj","depends_on_id":"bd-a7e","type":"blocks","created_at":"2026-02-12T16:31:32.395053Z","created_by":"tayloreernisse"}]} {"id":"bd-1lo","title":"Epic: Distribution and CI","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-12T16:22:28.089610Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:22:28.090122Z","compaction_level":0,"original_size":0,"labels":["epic"]} {"id":"bd-1rk","title":"Implement cross-alias discovery (--all-aliases)","description":"## Background\n--all-aliases on list and search queries across every cached alias, returning results with an additional \"alias\" field per result. Useful for agents managing multiple APIs.\n\n## Approach\n1. In list execute: if --all-aliases, iterate all aliases via list_aliases(), load each index, filter/sort per-alias, merge results with \"alias\" field added to each endpoint item\n2. In search execute: same pattern — search each alias's index, merge results, re-sort by score\n3. Robot output: each endpoint/result includes \"alias\" string field identifying which spec it came from\n\n**Sorting rules:**\n- For `list --all-aliases`: merged endpoints sorted by (alias ASC, path ASC, method_rank ASC) where method_rank follows standard HTTP method ordering (GET=0, POST=1, PUT=2, PATCH=3, DELETE=4, etc.)\n- For `search --all-aliases`: merged results sorted by (score DESC, then normal tie-breaking within same score) — the alias field is just additional metadata, it does not affect ranking\n\n**Robot output envelope:**\n- `data.endpoints[]` or `data.results[]` — each item includes `\"alias\": \"petstore\"` field\n- `data.aliases_searched: string[]` — lists which aliases were queried (e.g., `[\"petstore\", \"github-api\", \"stripe\"]`)\n- This allows agents to detect if an alias was missing/skipped vs simply had no matching results\n\n**Error handling for individual alias failures:**\n- If one alias fails to load (corrupted index, missing files), do NOT abort the entire operation\n- Skip the failed alias and continue with remaining aliases\n- Add warning to `meta.warnings[]` array: `{\"alias\": \"broken-api\", \"code\": \"INDEX_LOAD_FAILED\", \"message\": \"...\"}`\n- Robot output still returns ok:true with partial results (this is a degraded success, not an error)\n- If ALL aliases fail, then return an error\n\n## Acceptance Criteria\n- [ ] `list --all-aliases --robot` returns endpoints from all aliases with alias field on each item\n- [ ] `search \"pet\" --all-aliases --robot` searches across all aliases with alias field on each result\n- [ ] List results sorted by alias ASC, then path ASC, then method_rank ASC\n- [ ] Search results sorted by score DESC (alias does not affect ranking)\n- [ ] Robot output includes data.aliases_searched listing all queried aliases\n- [ ] If one alias fails to load, operation continues with remaining aliases and meta.warnings populated\n- [ ] If all aliases fail, returns error (not partial success)\n- [ ] Human output groups or labels results by alias for readability\n\n## Edge Cases\n- **Single alias cached:** --all-aliases works but is equivalent to normal query (aliases_searched has one entry)\n- **No aliases cached:** Return error — no specs available\n- **One alias fails to load:** Skip with warning in meta.warnings, return results from healthy aliases\n- **All aliases fail:** Return error with details about each failure\n- **Duplicate endpoint paths across aliases:** Both appear in results, distinguished by alias field\n\n## Files\n- MODIFY: src/cli/list.rs (add --all-aliases logic)\n- MODIFY: src/cli/search.rs (add --all-aliases logic)\n\n## TDD Anchor\nRED: Write `test_list_all_aliases` — fetch 2 different specs, run list --all-aliases --robot, assert results contain endpoints from both with alias field.\nRED: Write `test_search_all_aliases` — fetch 2 specs, run search --all-aliases --robot, assert results from both with correct score-based sorting.\nRED: Write `test_all_aliases_partial_failure` — corrupt one alias index, run list --all-aliases, assert partial results returned with warning.\nGREEN: Implement cross-alias iteration.\nVERIFY: `cargo test test_list_all_aliases`\n\n## Dependency Context\nRequires list command (bd-3km) and search command (bd-acf) to be implemented first.","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:29:50.184624Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:45:28.367812Z","compaction_level":0,"original_size":0,"labels":["global","phase2"],"dependencies":[{"issue_id":"bd-1rk","depends_on_id":"bd-3km","type":"blocks","created_at":"2026-02-12T16:29:50.187908Z","created_by":"tayloreernisse"},{"issue_id":"bd-1rk","depends_on_id":"bd-3ll","type":"parent-child","created_at":"2026-02-12T16:29:50.187260Z","created_by":"tayloreernisse"},{"issue_id":"bd-1rk","depends_on_id":"bd-acf","type":"blocks","created_at":"2026-02-12T16:29:50.188740Z","created_by":"tayloreernisse"}]} -{"id":"bd-1sb","title":"Implement configuration system with path resolution and auth profiles","description":"## Background\nswagger-cli uses TOML config files for user preferences and auth profiles, stored in XDG config dir. The config system handles path resolution with a specific precedence: --config > SWAGGER_CLI_CONFIG > SWAGGER_CLI_HOME > XDG defaults. Auth profiles allow loading credentials from config.toml instead of passing raw tokens on the command line.\n\n## Approach\nImplement Config, AuthConfig, CredentialSource, AuthType, DisplayConfig structs in src/core/config.rs per the PRD. Config::load() reads from config_path(), Config::save() writes with toml::to_string_pretty(). Both config_path() and cache_dir() implement the D7 override precedence using env vars SWAGGER_CLI_HOME, SWAGGER_CLI_CONFIG, SWAGGER_CLI_CACHE, and directories::ProjectDirs. CredentialSource is a tagged enum (Literal, EnvVar, Keyring) with serde tag=\"source\". AuthType is an enum (Bearer, ApiKey { header }). Default stale_threshold_days is 30.\n\n## Acceptance Criteria\n- [ ] Config::load() returns default Config when no file exists\n- [ ] Config::save() writes valid TOML that round-trips through load()\n- [ ] config_path() respects: --config flag > SWAGGER_CLI_CONFIG env > SWAGGER_CLI_HOME/config/config.toml > XDG\n- [ ] cache_dir() respects: SWAGGER_CLI_CACHE > SWAGGER_CLI_HOME/cache > XDG cache dir\n- [ ] AuthConfig with CredentialSource::EnvVar serializes/deserializes correctly\n- [ ] Default config has stale_threshold_days=30, empty auth_profiles, no default_alias\n- [ ] All tests hermetic (use SWAGGER_CLI_HOME with tempdir)\n\n## Files\n- CREATE: src/core/config.rs (Config, AuthConfig, CredentialSource, AuthType, DisplayConfig, Default impl, load/save, path resolution)\n- MODIFY: src/core/mod.rs (pub mod config;)\n\n## TDD Anchor\nRED: Write `test_config_path_precedence` — set SWAGGER_CLI_HOME to a tempdir, verify config_path() returns tempdir/config/config.toml. Then set SWAGGER_CLI_CONFIG to a specific path, verify it takes precedence.\nGREEN: Implement config_path() with env var checks.\nVERIFY: `cargo test test_config_path_precedence`\n\n## Edge Cases\n- CredentialSource::Keyring is Phase 2 — include the enum variant but document it as unimplemented\n- Config file might not exist (return default, don't error)\n- SWAGGER_CLI_HOME dir might not exist yet (create parent dirs on save)\n- toml crate is named `toml`, not `serde_toml`","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:25:15.475935Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:34:06.198329Z","compaction_level":0,"original_size":0,"labels":["infrastructure","phase1"],"dependencies":[{"issue_id":"bd-1sb","depends_on_id":"bd-hcb","type":"parent-child","created_at":"2026-02-12T16:25:15.477635Z","created_by":"tayloreernisse"},{"issue_id":"bd-1sb","depends_on_id":"bd-ilo","type":"blocks","created_at":"2026-02-12T16:25:15.478018Z","created_by":"tayloreernisse"}]} +{"id":"bd-1sb","title":"Implement configuration system with path resolution and auth profiles","description":"## Background\nswagger-cli uses TOML config files for user preferences and auth profiles, stored in XDG config dir. The config system handles path resolution with a specific precedence: --config > SWAGGER_CLI_CONFIG > SWAGGER_CLI_HOME > XDG defaults. Auth profiles allow loading credentials from config.toml instead of passing raw tokens on the command line.\n\n## Approach\nImplement Config, AuthConfig, CredentialSource, AuthType, DisplayConfig structs in src/core/config.rs per the PRD. Config::load() reads from config_path(), Config::save() writes with toml::to_string_pretty(). Both config_path() and cache_dir() implement the D7 override precedence using env vars SWAGGER_CLI_HOME, SWAGGER_CLI_CONFIG, SWAGGER_CLI_CACHE, and directories::ProjectDirs. CredentialSource is a tagged enum (Literal, EnvVar, Keyring) with serde tag=\"source\". AuthType is an enum (Bearer, ApiKey { header }). Default stale_threshold_days is 30.\n\n## Acceptance Criteria\n- [ ] Config::load() returns default Config when no file exists\n- [ ] Config::save() writes valid TOML that round-trips through load()\n- [ ] config_path() respects: --config flag > SWAGGER_CLI_CONFIG env > SWAGGER_CLI_HOME/config/config.toml > XDG\n- [ ] cache_dir() respects: SWAGGER_CLI_CACHE > SWAGGER_CLI_HOME/cache > XDG cache dir\n- [ ] AuthConfig with CredentialSource::EnvVar serializes/deserializes correctly\n- [ ] Default config has stale_threshold_days=30, empty auth_profiles, no default_alias\n- [ ] All tests hermetic (use SWAGGER_CLI_HOME with tempdir)\n\n## Files\n- CREATE: src/core/config.rs (Config, AuthConfig, CredentialSource, AuthType, DisplayConfig, Default impl, load/save, path resolution)\n- MODIFY: src/core/mod.rs (pub mod config;)\n\n## TDD Anchor\nRED: Write `test_config_path_precedence` — set SWAGGER_CLI_HOME to a tempdir, verify config_path() returns tempdir/config/config.toml. Then set SWAGGER_CLI_CONFIG to a specific path, verify it takes precedence.\nGREEN: Implement config_path() with env var checks.\nVERIFY: `cargo test test_config_path_precedence`\n\n## Edge Cases\n- CredentialSource::Keyring is Phase 2 — include the enum variant but document it as unimplemented\n- Config file might not exist (return default, don't error)\n- SWAGGER_CLI_HOME dir might not exist yet (create parent dirs on save)\n- toml crate is named `toml`, not `serde_toml`","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:25:15.475935Z","created_by":"tayloreernisse","updated_at":"2026-02-12T17:41:12.898251Z","closed_at":"2026-02-12T17:41:12.898204Z","close_reason":"Config with TOML load/save, path precedence, auth profiles","compaction_level":0,"original_size":0,"labels":["infrastructure","phase1"],"dependencies":[{"issue_id":"bd-1sb","depends_on_id":"bd-hcb","type":"parent-child","created_at":"2026-02-12T16:25:15.477635Z","created_by":"tayloreernisse"},{"issue_id":"bd-1sb","depends_on_id":"bd-ilo","type":"blocks","created_at":"2026-02-12T16:25:15.478018Z","created_by":"tayloreernisse"}]} {"id":"bd-1x5","title":"Implement reliability stress tests and performance benchmarks","description":"## Background\nReliability tests validate the crash-consistency, concurrency safety, and determinism claims. These are the hardest tests but most important for production confidence. Includes fault injection (simulated crash at each write step), multi-process lock contention (32 concurrent processes), and property-based tests (proptest for deterministic ordering).\n\n## Approach\n**Crash consistency (tests/reliability/crash_consistency_test.rs):**\n- test_crash_before_meta_rename: write raw+index but not meta → read protocol detects → doctor --fix repairs\n- test_crash_after_raw_before_index: write raw but not index → doctor --fix rebuilds\n\n**Lock contention (tests/reliability/lock_contention_test.rs):**\n- test_concurrent_fetch_32_processes: spawn 32 threads, all fetch same alias with --force → verify all exit 0 or 9 (CACHE_LOCKED), no panics, final state passes doctor\n\n**Property-based (tests/reliability/property_test.rs with proptest):**\n- index_ordering_deterministic: random endpoints → build_index → build_index from shuffled → same JSON\n- hash_deterministic: same bytes → same hash\n\nAdd proptest = '1.0' to [dev-dependencies] in Cargo.toml alongside existing test deps.\n\n**Benchmarks (benches/perf.rs with Criterion):**\n- bench_load_index, bench_list_endpoints, bench_search_query on 500+ endpoint spec\n\n## Acceptance Criteria\n- [ ] Crash consistency tests verify doctor can repair all simulated crash states\n- [ ] 32-process contention test passes without deadlocks or corruption\n- [ ] Property-based tests verify deterministic ordering\n- [ ] Benchmarks establish baseline performance numbers\n- [ ] All reliability tests pass on CI\n\n## Files\n- CREATE: tests/reliability/crash_consistency_test.rs\n- CREATE: tests/reliability/lock_contention_test.rs\n- CREATE: tests/reliability/property_test.rs\n- CREATE: benches/perf.rs\n- MODIFY: Cargo.toml (add [[bench]] for criterion, add proptest dev-dep)\n\n## TDD Anchor\nRun: `cargo test --test crash_consistency && cargo test --test lock_contention && cargo test --test property`\nVERIFY: `cargo bench -- --test`\n\n## Dependency Context\nUses CacheManager write/read protocol from cache beads. Uses build_index from indexer bead. Uses compute_hash. Tests the claims made in the cache architecture.\n\n## Edge Cases\n- **Crash test cleanup:** Crash simulation tests must clean up temp files even on test failure. Use Drop guards or panic hooks.\n- **32-process test may exceed file descriptor limits:** On CI, ulimit may be low. Test should check and skip with a clear message if fd limit < 256.\n- **Property test shrinking:** proptest shrinking can be slow for complex inputs. Set max shrink iterations to avoid CI timeouts.\n- **Benchmark stability:** Criterion requires multiple iterations. First run creates baseline. Tests should not fail on benchmark regression — only warn.\n- **Lock contention test timing:** 32 threads racing for a lock is timing-dependent. Use a barrier to ensure all threads start simultaneously.","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:30:59.112199Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:56:19.044503Z","compaction_level":0,"original_size":0,"labels":["phase2","testing"],"dependencies":[{"issue_id":"bd-1x5","depends_on_id":"bd-189","type":"blocks","created_at":"2026-02-12T16:30:59.114670Z","created_by":"tayloreernisse"},{"issue_id":"bd-1x5","depends_on_id":"bd-1ie","type":"blocks","created_at":"2026-02-12T16:30:59.113671Z","created_by":"tayloreernisse"},{"issue_id":"bd-1x5","depends_on_id":"bd-3ea","type":"blocks","created_at":"2026-02-12T16:30:59.114152Z","created_by":"tayloreernisse"},{"issue_id":"bd-1x5","depends_on_id":"bd-p7g","type":"parent-child","created_at":"2026-02-12T16:30:59.113195Z","created_by":"tayloreernisse"}]} {"id":"bd-1y0","title":"Epic: Health and Cache Lifecycle","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-12T16:22:24.038779Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:22:24.039351Z","compaction_level":0,"original_size":0,"labels":["epic"]} {"id":"bd-21m","title":"Epic: Diff Command","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-12T16:22:26.500265Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:22:26.501933Z","compaction_level":0,"original_size":0,"labels":["epic"]} @@ -26,7 +26,7 @@ {"id":"bd-3aq","title":"Epic: Phase 3 Future","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-12T16:22:29.339564Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:22:29.340289Z","compaction_level":0,"original_size":0,"labels":["epic"]} {"id":"bd-3b6","title":"Build async HTTP client with SSRF protection and streaming download","description":"## Background\nswagger-cli fetches OpenAPI specs over HTTPS with strict security controls. The HTTP client must enforce SSRF protection (blocking private/loopback/link-local/multicast IPs), require HTTPS by default, support streaming downloads with max-bytes enforcement, and handle retries with backoff. This is async (tokio + reqwest).\n\n## Approach\nCreate src/core/http.rs with:\n\n**AsyncHttpClient struct:** Wraps reqwest::Client configured with rustls-tls, connect timeout (5s), overall timeout (configurable, default 10s), redirect policy (max 5). Provides `fetch_spec()` async method.\n\n**SSRF Protection:** Before connecting, resolve the hostname and check the IP against blocked CIDR ranges: 127.0.0.0/8, ::1, 169.254.0.0/16, fe80::/10, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, multicast (224.0.0.0/4, ff00::/8). Also check resolved IP AFTER redirects (DNS rebinding defense). Return PolicyBlocked error for violations. Accept --allow-private-host exceptions.\n\n**HTTPS Enforcement:** Reject http:// URLs unless --allow-insecure-http is set. Return PolicyBlocked.\n\n**Streaming Download:** Use response.chunk() in a loop, counting bytes. Abort when exceeding max_bytes (default 25MB). This prevents OOM on huge specs.\n\n**Retries:** Retry on 5xx and network errors up to N times (default 2) with exponential backoff + jitter. Honor Retry-After header. Do NOT retry 4xx (except 429 rate limit).\n\n**Auth Headers:** Accept Vec of (name, value) header pairs. Redact auth values in any error messages.\n\n## Acceptance Criteria\n- [ ] fetch_spec(\"https://...\") returns body bytes on success\n- [ ] Loopback IP (127.0.0.1) is blocked with PolicyBlocked error\n- [ ] Private IP (10.0.0.1) is blocked with PolicyBlocked error\n- [ ] Link-local (169.254.169.254) is blocked with PolicyBlocked error\n- [ ] http:// URL without --allow-insecure-http returns PolicyBlocked\n- [ ] Download exceeding max_bytes aborts with InvalidSpec error\n- [ ] 401/403 returns Auth error (not retried)\n- [ ] 500 is retried up to retry count\n- [ ] Auth header values are not included in error messages\n\n## Files\n- CREATE: src/core/http.rs (AsyncHttpClient, SSRF checks, streaming download, retries)\n- MODIFY: src/core/mod.rs (pub mod http;)\n\n## TDD Anchor\nRED: Write `test_ssrf_blocks_loopback` — call the IP validation function with 127.0.0.1, assert it returns Err(PolicyBlocked).\nGREEN: Implement CIDR range checking.\nVERIFY: `cargo test test_ssrf_blocks`\n\nAdditional tests (use mockito for HTTP):\n- test_fetch_success_https\n- test_fetch_rejects_http\n- test_fetch_max_bytes_abort\n- test_fetch_retries_on_500\n- test_fetch_no_retry_on_401\n- test_auth_header_redacted_in_errors\n\n## Edge Cases\n- DNS resolution must happen BEFORE connecting — use `tokio::net::lookup_host()` or reqwest's resolve API\n- DNS rebinding: a hostname might resolve to public IP initially, then private IP on redirect. Check IP at EACH hop.\n- IPv6 mapped IPv4 addresses (::ffff:127.0.0.1) must also be caught\n- Retry-After header may be seconds or HTTP-date — parse both formats\n- connect_timeout (5s) is separate from overall timeout (10s)\n\n## Dependency Context\nUses SwaggerCliError variants (Network, Auth, PolicyBlocked, InvalidSpec) from bd-ilo (error types and core data models).","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:26:35.163338Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:55:53.972326Z","compaction_level":0,"original_size":0,"labels":["fetch","phase1","security"],"dependencies":[{"issue_id":"bd-3b6","depends_on_id":"bd-3d2","type":"blocks","created_at":"2026-02-12T16:26:35.167736Z","created_by":"tayloreernisse"},{"issue_id":"bd-3b6","depends_on_id":"bd-3ny","type":"parent-child","created_at":"2026-02-12T16:26:35.167093Z","created_by":"tayloreernisse"}]} {"id":"bd-3bl","title":"Implement tags command","description":"## Background\nThe tags command is simple — it lists OpenAPI tags with their endpoint counts and descriptions. Pure index-backed, fast.\n\n## Approach\nImplement src/cli/tags.rs with TagsArgs (alias only) and execute(). Load index, output tags from index.tags (already sorted and counted during index building). Robot: data.tags[] with name, description, endpoint_count. Human: formatted list with \"X total\" in header.\n\n## Acceptance Criteria\n- [ ] `tags petstore --robot` returns JSON with data.tags array\n- [ ] Each tag has name (string), description (string|null), endpoint_count (integer)\n- [ ] Tags sorted by name ASC (pre-sorted in index)\n- [ ] Human output shows tag name, count, and description\n- [ ] Human output shows \"X total\" count in header line\n- [ ] Tags with no description show null in robot output, empty/omitted in human output\n- [ ] Empty tags list (spec with no tags defined) returns ok:true with data.tags as empty array\n- [ ] Robot meta includes standard fields (schema_version, tool_version, command, duration_ms)\n\n## Edge Cases\n- **Spec with no tags:** Return ok:true, data.tags: [], meta.total: 0. Human output: \"0 total\" header, no rows.\n- **Tags with empty descriptions:** Tag defined in spec with `description: \"\"` — treat as null/empty in output (same as missing description).\n- **Orphaned tags:** Tags defined at root level in the OpenAPI spec but not referenced by any operation. These should still appear in output with endpoint_count: 0 (they exist in the spec, the command reports what the spec declares).\n\n## Files\n- MODIFY: src/cli/tags.rs (TagsArgs, execute)\n- MODIFY: src/output/robot.rs (add output_tags)\n- MODIFY: src/output/human.rs (add output_tags)\n\n## TDD Anchor\nRED: Write `test_tags_list` — fetch petstore, run tags --robot, assert data.tags has expected tag count.\nRED: Write `test_tags_empty` — use a spec with no tags, assert data.tags is empty array.\nRED: Write `test_tags_no_description` — use a spec with a tag that has no description, assert description is null in robot output.\nGREEN: Implement tags command.\nVERIFY: `cargo test test_tags_list`\n\n## Dependency Context\nUses SpecIndex and IndexedTag types from bd-ilo (error types and core data models). Uses CacheManager.load_index from bd-3ea (cache read path).","status":"open","priority":3,"issue_type":"task","created_at":"2026-02-12T16:28:05.366529Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:55:58.303891Z","compaction_level":0,"original_size":0,"labels":["phase2","query"],"dependencies":[{"issue_id":"bd-3bl","depends_on_id":"bd-3d2","type":"blocks","created_at":"2026-02-12T16:28:05.368603Z","created_by":"tayloreernisse"},{"issue_id":"bd-3bl","depends_on_id":"bd-3ea","type":"blocks","created_at":"2026-02-12T16:28:05.368039Z","created_by":"tayloreernisse"},{"issue_id":"bd-3bl","depends_on_id":"bd-jek","type":"parent-child","created_at":"2026-02-12T16:28:05.367634Z","created_by":"tayloreernisse"}]} -{"id":"bd-3d2","title":"Build CLI skeleton with clap and output formatting framework","description":"## Background\nswagger-cli needs a CLI parser (clap) that routes to subcommands and an output framework that handles both human-readable and robot JSON formatting. The CLI skeleton defines the top-level Cli struct with global flags (--robot, --pretty, --network, --config) and the Commands enum with all 11 subcommands. The output framework provides consistent formatting for all commands.\n\n## Approach\n**CLI (src/cli/mod.rs):**\n- Define `Cli` struct with `#[derive(Parser)]`: command (Commands subcommand), robot (bool, global), pretty (bool, global), network (String, global, default \"auto\"), config (Option, global, env SWAGGER_CLI_CONFIG)\n- Define `Commands` enum with all 11 variants: Fetch, List, Show, Search, Schemas, Tags, Aliases, Sync, Doctor, Cache, Diff\n- Create stub modules for each command (src/cli/fetch.rs, list.rs, etc.) with empty `FetchArgs` structs and `pub async fn execute()` signatures returning `Result<(), SwaggerCliError>`\n- Note: ALL execute functions use `async fn` signatures (we use tokio runtime throughout). Fetch, sync, and diff perform actual async I/O; query commands (list, show, search, etc.) are async in signature but may not await internally.\n\n**Main (src/main.rs):**\n- Pre-scan argv for `--robot` before clap parsing (handles parse errors in robot JSON)\n- `#[tokio::main] async fn main()` that tries Cli::try_parse_from, routes to command execute, handles errors\n- `output_robot_error()` and `output_human_error()` functions per PRD\n\n**Output (src/output/):**\n- `mod.rs`: Common traits/helpers, `RobotEnvelope` struct with ok, data, meta fields\n- `robot.rs`: Generic `robot_success()` and `robot_error()` functions that build the envelope with schema_version=1, tool_version from CARGO_PKG_VERSION, command name, duration_ms\n- `human.rs`: Stub formatters, TTY detection for color/unicode\n- `table.rs`: Table formatting helpers using `tabled` crate\n\n## Acceptance Criteria\n- [ ] `cargo build` succeeds with the full CLI skeleton\n- [ ] `swagger-cli --help` shows all 11 subcommands\n- [ ] `swagger-cli --version` prints version from Cargo.toml\n- [ ] `swagger-cli nonexistent --robot` outputs JSON error to stderr with code USAGE_ERROR and exits 2\n- [ ] `swagger-cli fetch --robot` (missing required args) outputs JSON error to stderr\n- [ ] RobotEnvelope serializes with ok, data, meta.schema_version, meta.tool_version, meta.command, meta.duration_ms\n- [ ] All command stubs return `Ok(())` (do nothing yet)\n\n## Files\n- CREATE: src/cli/mod.rs (Cli, Commands, pub mods)\n- CREATE: src/cli/fetch.rs, list.rs, show.rs, search.rs, schemas.rs, tags.rs, aliases.rs, sync.rs, doctor.rs, cache.rs, diff.rs (stub args + execute)\n- CREATE: src/output/mod.rs, src/output/robot.rs, src/output/human.rs, src/output/table.rs\n- MODIFY: src/main.rs (full entry point with error handling)\n- MODIFY: src/lib.rs (pub mod cli; pub mod output;)\n\n## TDD Anchor\nRED: Write test `test_robot_error_on_bad_command` using `assert_cmd` -- run `swagger-cli nonexistent --robot`, parse stderr as JSON, assert the JSON contains code=\"USAGE_ERROR\" and the process exits with code 2.\nGREEN: Implement main.rs pre-scan and robot error output.\nVERIFY: `cargo test test_robot_error_on_bad_command`\n\n## Edge Cases\n- Pre-scan argv for --robot MUST happen before clap parsing, otherwise clap's error output is plaintext even when agent expects JSON\n- Global --robot flag must be accessible from all subcommands (use `#[arg(long, global = true)]`)\n- duration_ms in robot envelope: use `std::time::Instant::now()` at start, elapsed at output\n- BTreeMap (not HashMap) for any constructed JSON objects to ensure deterministic key ordering\n\n## Dependency Context\nUses SwaggerCliError (exit_code, code, suggestion) from bd-ilo (error types and core data models).","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:24:12.604507Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:55:49.272227Z","compaction_level":0,"original_size":0,"labels":["foundation","phase1"],"dependencies":[{"issue_id":"bd-3d2","depends_on_id":"bd-3e0","type":"parent-child","created_at":"2026-02-12T16:24:12.606348Z","created_by":"tayloreernisse"},{"issue_id":"bd-3d2","depends_on_id":"bd-ilo","type":"blocks","created_at":"2026-02-12T16:24:12.607227Z","created_by":"tayloreernisse"}]} +{"id":"bd-3d2","title":"Build CLI skeleton with clap and output formatting framework","description":"## Background\nswagger-cli needs a CLI parser (clap) that routes to subcommands and an output framework that handles both human-readable and robot JSON formatting. The CLI skeleton defines the top-level Cli struct with global flags (--robot, --pretty, --network, --config) and the Commands enum with all 11 subcommands. The output framework provides consistent formatting for all commands.\n\n## Approach\n**CLI (src/cli/mod.rs):**\n- Define `Cli` struct with `#[derive(Parser)]`: command (Commands subcommand), robot (bool, global), pretty (bool, global), network (String, global, default \"auto\"), config (Option, global, env SWAGGER_CLI_CONFIG)\n- Define `Commands` enum with all 11 variants: Fetch, List, Show, Search, Schemas, Tags, Aliases, Sync, Doctor, Cache, Diff\n- Create stub modules for each command (src/cli/fetch.rs, list.rs, etc.) with empty `FetchArgs` structs and `pub async fn execute()` signatures returning `Result<(), SwaggerCliError>`\n- Note: ALL execute functions use `async fn` signatures (we use tokio runtime throughout). Fetch, sync, and diff perform actual async I/O; query commands (list, show, search, etc.) are async in signature but may not await internally.\n\n**Main (src/main.rs):**\n- Pre-scan argv for `--robot` before clap parsing (handles parse errors in robot JSON)\n- `#[tokio::main] async fn main()` that tries Cli::try_parse_from, routes to command execute, handles errors\n- `output_robot_error()` and `output_human_error()` functions per PRD\n\n**Output (src/output/):**\n- `mod.rs`: Common traits/helpers, `RobotEnvelope` struct with ok, data, meta fields\n- `robot.rs`: Generic `robot_success()` and `robot_error()` functions that build the envelope with schema_version=1, tool_version from CARGO_PKG_VERSION, command name, duration_ms\n- `human.rs`: Stub formatters, TTY detection for color/unicode\n- `table.rs`: Table formatting helpers using `tabled` crate\n\n## Acceptance Criteria\n- [ ] `cargo build` succeeds with the full CLI skeleton\n- [ ] `swagger-cli --help` shows all 11 subcommands\n- [ ] `swagger-cli --version` prints version from Cargo.toml\n- [ ] `swagger-cli nonexistent --robot` outputs JSON error to stderr with code USAGE_ERROR and exits 2\n- [ ] `swagger-cli fetch --robot` (missing required args) outputs JSON error to stderr\n- [ ] RobotEnvelope serializes with ok, data, meta.schema_version, meta.tool_version, meta.command, meta.duration_ms\n- [ ] All command stubs return `Ok(())` (do nothing yet)\n\n## Files\n- CREATE: src/cli/mod.rs (Cli, Commands, pub mods)\n- CREATE: src/cli/fetch.rs, list.rs, show.rs, search.rs, schemas.rs, tags.rs, aliases.rs, sync.rs, doctor.rs, cache.rs, diff.rs (stub args + execute)\n- CREATE: src/output/mod.rs, src/output/robot.rs, src/output/human.rs, src/output/table.rs\n- MODIFY: src/main.rs (full entry point with error handling)\n- MODIFY: src/lib.rs (pub mod cli; pub mod output;)\n\n## TDD Anchor\nRED: Write test `test_robot_error_on_bad_command` using `assert_cmd` -- run `swagger-cli nonexistent --robot`, parse stderr as JSON, assert the JSON contains code=\"USAGE_ERROR\" and the process exits with code 2.\nGREEN: Implement main.rs pre-scan and robot error output.\nVERIFY: `cargo test test_robot_error_on_bad_command`\n\n## Edge Cases\n- Pre-scan argv for --robot MUST happen before clap parsing, otherwise clap's error output is plaintext even when agent expects JSON\n- Global --robot flag must be accessible from all subcommands (use `#[arg(long, global = true)]`)\n- duration_ms in robot envelope: use `std::time::Instant::now()` at start, elapsed at output\n- BTreeMap (not HashMap) for any constructed JSON objects to ensure deterministic key ordering\n\n## Dependency Context\nUses SwaggerCliError (exit_code, code, suggestion) from bd-ilo (error types and core data models).","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-12T16:24:12.604507Z","created_by":"tayloreernisse","updated_at":"2026-02-12T17:41:12.843580Z","closed_at":"2026-02-12T17:41:12.843535Z","close_reason":"CLI skeleton with 11 subcommands, robot/human output, main.rs routing","compaction_level":0,"original_size":0,"labels":["foundation","phase1"],"dependencies":[{"issue_id":"bd-3d2","depends_on_id":"bd-3e0","type":"parent-child","created_at":"2026-02-12T16:24:12.606348Z","created_by":"tayloreernisse"},{"issue_id":"bd-3d2","depends_on_id":"bd-ilo","type":"blocks","created_at":"2026-02-12T16:24:12.607227Z","created_by":"tayloreernisse"}]} {"id":"bd-3e0","title":"Epic: Project Foundation","status":"open","priority":1,"issue_type":"task","created_at":"2026-02-12T16:22:16.954888Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:22:16.956168Z","compaction_level":0,"original_size":0,"labels":["epic"]} {"id":"bd-3ea","title":"Implement cache read path with integrity validation","description":"## Background\nThe cache read path is used by every query command (list, search, tags, schemas, aliases, doctor). It loads index.json and meta.json, validates their integrity (generation match, hash match, index_version match), and provides the data to commands. The `show` command additionally loads raw.json. Coalesced last_accessed updates reduce write amplification for hot-read bursts.\n\n## Approach\nImplement on CacheManager:\n- `load_index(alias) -> Result<(SpecIndex, CacheMetadata)>`: Read meta.json first (commit marker). If missing -> AliasNotFound or CacheIntegrity. Read index.json. Validate: meta.index_version == index.index_version, meta.generation == index.generation, meta.index_hash == sha256(index.json bytes). Mismatch -> CacheIntegrity error. Update last_accessed if stale >10min (best-effort, no lock required).\n- `load_raw(alias, meta: &CacheMetadata) -> Result`: Read raw.json, parse as Value. Validate meta.raw_hash == sha256(raw.json bytes). Return Value.\n- `list_aliases() -> Result>`: Iterate alias directories, read meta.json from each. Skip broken/partial aliases (log warning).\n- `delete_alias(alias) -> Result<()>`: Remove alias directory after acquiring lock.\n- `default_alias() -> Option`: Load config, return default_alias.\n- `alias_exists(alias) -> bool`: Check if meta.json exists in alias dir.\n\nCoalesced last_accessed: When load_index reads meta, check if meta.last_accessed is >10min old. If so, update only the last_accessed field in meta.json (best-effort write, no lock, ignore errors).\n\n## Acceptance Criteria\n- [ ] load_index succeeds for a valid cache (all 4 files present, hashes match)\n- [ ] load_index returns CacheIntegrity when generation mismatches\n- [ ] load_index returns CacheIntegrity when index_hash mismatches\n- [ ] load_index returns AliasNotFound when alias directory doesn't exist\n- [ ] load_raw validates raw_hash and returns parsed Value\n- [ ] list_aliases returns metadata for all valid aliases, skips broken ones\n- [ ] delete_alias removes the entire alias directory\n- [ ] last_accessed is updated at most once per 10 minutes\n\n## Files\n- MODIFY: src/core/cache.rs (add load_index, load_raw, list_aliases, delete_alias, default_alias, alias_exists, coalesced last_accessed)\n\n## TDD Anchor\nRED: Write `test_load_index_integrity_check` -- create a valid cache, then tamper with index.json (change a byte). Assert load_index returns CacheIntegrity error.\nGREEN: Implement hash validation in load_index().\nVERIFY: `cargo test test_load_index_integrity_check`\n\nAdditional tests:\n- test_load_index_success\n- test_load_index_missing_meta\n- test_load_raw_validates_hash\n- test_list_aliases_skips_broken\n- test_coalesced_last_accessed (verify not written within 10min window)\n\n## Edge Cases\n- list_aliases must not panic if an alias directory has no meta.json -- skip with warning\n- Coalesced write is best-effort: if it fails (permissions, concurrent write), silently ignore\n- load_raw is only called by show and schemas --show -- never by list/search/tags\n- Empty cache directory (no aliases) should return empty Vec, not error\n\n## Dependency Context\nUses CacheManager and write_cache() from bd-1ie (cache write path). Uses CacheMetadata, SpecIndex, and SwaggerCliError from bd-ilo (error types and core data models).","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:25:15.526245Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:55:44.766573Z","compaction_level":0,"original_size":0,"labels":["infrastructure","phase1"],"dependencies":[{"issue_id":"bd-3ea","depends_on_id":"bd-1ie","type":"blocks","created_at":"2026-02-12T16:34:05.956814Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ea","depends_on_id":"bd-hcb","type":"parent-child","created_at":"2026-02-12T16:25:15.528127Z","created_by":"tayloreernisse"},{"issue_id":"bd-3ea","depends_on_id":"bd-ilo","type":"blocks","created_at":"2026-02-12T16:25:15.528786Z","created_by":"tayloreernisse"}]} {"id":"bd-3f4","title":"Implement sync command with change detection and index-based diffs","description":"## Background\nThe sync command checks if a remote spec has changed and re-fetches if needed. Change detection uses ETag/Last-Modified headers and content hash comparison. Index-based diffs compute added/removed/modified endpoints and schemas by comparing old vs new indexes. This is async (network calls).\n\n## Approach\nImplement src/cli/sync.rs with SyncArgs and async execute():\n\n**Single alias sync flow:**\n1. Load existing meta (get ETag, Last-Modified, content_hash)\n2. Fetch remote spec with conditional headers (If-None-Match, If-Modified-Since)\n3. If 304 Not Modified → report \"no changes\"\n4. If 200: compute new content_hash, compare with stored\n5. If hash matches → report \"no changes\" (content identical despite no 304)\n6. If changed: normalize, build new index, compare old vs new index\n7. Compute diff: added/removed/modified endpoints and schemas (compare by path+method / name)\n8. If not --dry-run: write new cache\n9. Output change summary\n\n**SyncArgs:** alias (Option), all (bool), dry_run (bool), force (bool — re-fetch regardless), details (bool — include change lists), jobs (usize, default 4), per_host (usize, default 2), resume (bool), max_failures (Option).\n\n**--details output:** Capped at 200 items per category. Include truncated:bool flag.\n\n## Acceptance Criteria\n- [ ] Sync detects \"no changes\" via 304 response\n- [ ] Sync detects changes via content hash mismatch\n- [ ] --dry-run checks without writing\n- [ ] --force re-fetches regardless\n- [ ] --details includes added/removed/modified endpoint/schema lists (capped at 200)\n- [ ] Robot output: changed (bool), local_version, remote_version, changes.endpoints/schemas counts\n- [ ] --force with --dry-run: fetches and computes diff but doesn't write\n\n## Edge Cases\n- **304 Not Modified but content actually changed:** Server returns 304 incorrectly. Content hash comparison catches this (hash mismatch despite 304 = treat as changed).\n- **Huge index diff:** If thousands of endpoints changed, --details output must respect the 200-item cap with truncated:true flag.\n- **Server returns different Content-Type:** e.g., returns HTML error page instead of JSON. Format detection catches this — invalid spec error.\n- **ETag/Last-Modified missing on first fetch:** meta.etag and meta.last_modified will be None. Sync without conditional headers — always downloads full content.\n- **--force with --dry-run:** Fetch the content but don't write. Report what would change. This combo must be supported.\n\n## Files\n- MODIFY: src/cli/sync.rs (SyncArgs, execute, single_alias_sync, compute_index_diff)\n- MODIFY: src/output/robot.rs (add output_sync)\n- MODIFY: src/output/human.rs (add output_sync)\n\n## TDD Anchor\nRED: Write `test_sync_no_changes` — fetch petstore fixture locally, run sync --robot (mock server returns same content), assert changed==false.\nGREEN: Implement hash-based change detection.\nVERIFY: `cargo test test_sync_no_changes`\n\n## Dependency Context\nUses AsyncHttpClient from bd-3b6 for fetching. Uses indexer from bd-189 for rebuilding. Uses CacheManager from bd-3ea/bd-1ie for read/write. Requires a fetched spec in cache.","status":"open","priority":2,"issue_type":"task","created_at":"2026-02-12T16:28:47.430949Z","created_by":"tayloreernisse","updated_at":"2026-02-12T16:58:08.755348Z","compaction_level":0,"original_size":0,"labels":["phase2","sync"],"dependencies":[{"issue_id":"bd-3f4","depends_on_id":"bd-161","type":"parent-child","created_at":"2026-02-12T16:28:47.432443Z","created_by":"tayloreernisse"},{"issue_id":"bd-3f4","depends_on_id":"bd-16o","type":"blocks","created_at":"2026-02-12T16:28:47.432950Z","created_by":"tayloreernisse"},{"issue_id":"bd-3f4","depends_on_id":"bd-189","type":"blocks","created_at":"2026-02-12T16:28:47.433412Z","created_by":"tayloreernisse"}]} diff --git a/Cargo.toml b/Cargo.toml index b5f416f..bc1d852 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] anyhow = "1" chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } colored = "3" directories = "6" fs2 = "0.4" diff --git a/src/cli/aliases.rs b/src/cli/aliases.rs new file mode 100644 index 0000000..2436304 --- /dev/null +++ b/src/cli/aliases.rs @@ -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, + + /// Rename an alias (old=new) + #[arg(long)] + pub rename: Option, +} + +pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { + Err(SwaggerCliError::Usage("aliases not yet implemented".into())) +} diff --git a/src/cli/cache_cmd.rs b/src/cli/cache_cmd.rs new file mode 100644 index 0000000..819f7cf --- /dev/null +++ b/src/cli/cache_cmd.rs @@ -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())) +} diff --git a/src/cli/diff.rs b/src/cli/diff.rs new file mode 100644 index 0000000..5d72b87 --- /dev/null +++ b/src/cli/diff.rs @@ -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, +} + +pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { + Err(SwaggerCliError::Usage("diff not yet implemented".into())) +} diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs new file mode 100644 index 0000000..94f1278 --- /dev/null +++ b/src/cli/doctor.rs @@ -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())) +} diff --git a/src/cli/fetch.rs b/src/cli/fetch.rs new file mode 100644 index 0000000..05f5200 --- /dev/null +++ b/src/cli/fetch.rs @@ -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, + + /// Overwrite existing alias + #[arg(long)] + pub force: bool, + + /// Auth profile name from config + #[arg(long)] + pub auth: Option, +} + +pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { + Err(SwaggerCliError::Usage("fetch not yet implemented".into())) +} diff --git a/src/cli/list.rs b/src/cli/list.rs new file mode 100644 index 0000000..e931b4e --- /dev/null +++ b/src/cli/list.rs @@ -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, + + /// Filter by tag + #[arg(long)] + pub tag: Option, + + /// Filter by path pattern + #[arg(long)] + pub path: Option, +} + +pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { + Err(SwaggerCliError::Usage("list not yet implemented".into())) +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index a3127f6..3269ea1 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -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, +} + +#[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), +} diff --git a/src/cli/schemas.rs b/src/cli/schemas.rs new file mode 100644 index 0000000..226cce4 --- /dev/null +++ b/src/cli/schemas.rs @@ -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, +} + +pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { + Err(SwaggerCliError::Usage("schemas not yet implemented".into())) +} diff --git a/src/cli/search.rs b/src/cli/search.rs new file mode 100644 index 0000000..6f262ff --- /dev/null +++ b/src/cli/search.rs @@ -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())) +} diff --git a/src/cli/show.rs b/src/cli/show.rs new file mode 100644 index 0000000..d5ba24c --- /dev/null +++ b/src/cli/show.rs @@ -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())) +} diff --git a/src/cli/sync_cmd.rs b/src/cli/sync_cmd.rs new file mode 100644 index 0000000..9a579f2 --- /dev/null +++ b/src/cli/sync_cmd.rs @@ -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, +} + +pub async fn execute(_args: &Args, _robot: bool) -> Result<(), SwaggerCliError> { + Err(SwaggerCliError::Usage("sync not yet implemented".into())) +} diff --git a/src/cli/tags.rs b/src/cli/tags.rs new file mode 100644 index 0000000..f751792 --- /dev/null +++ b/src/cli/tags.rs @@ -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())) +} diff --git a/src/core/cache.rs b/src/core/cache.rs index d1443fc..07c3d00 100644 --- a/src/core/cache.rs +++ b/src/core/cache.rs @@ -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 { + 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, + spec_version: &str, + spec_title: &str, + source_format: &str, + etag: Option, + last_modified: Option, + previous_generation: Option, + ) -> Result { + 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:")); + } } diff --git a/src/core/config.rs b/src/core/config.rs new file mode 100644 index 0000000..9c0a298 --- /dev/null +++ b/src/core/config.rs @@ -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, + + #[serde(default = "default_stale_threshold_days")] + pub stale_threshold_days: u32, + + #[serde(default)] + pub auth_profiles: BTreeMap, + + #[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, + + #[serde(default)] + pub unicode: Option, +} + +/// 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 { + 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"), + } + } +} diff --git a/src/core/indexer.rs b/src/core/indexer.rs new file mode 100644 index 0000000..d9352ce --- /dev/null +++ b/src/core/indexer.rs @@ -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::(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, 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 { + 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 = 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 = 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 = 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> = 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 = 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 { + 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 { + let mut merged: Vec = 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) { + 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 { + let Some(arr) = security.and_then(|v| v.as_array()) else { + return Vec::new(); + }; + let mut names: Vec = 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, + ); + } + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index c11b956..d0201fa 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,2 +1,4 @@ pub mod cache; +pub mod config; +pub mod indexer; pub mod spec; diff --git a/src/errors.rs b/src/errors.rs index 3c4cc25..86472f6 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -89,19 +89,21 @@ impl SwaggerCliError { pub fn suggestion(&self) -> Option { 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::("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::("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::("bad").unwrap_err()); + let json_err = + SwaggerCliError::Json(serde_json::from_str::("bad").unwrap_err()); assert!(json_err.suggestion().is_none()); } diff --git a/src/main.rs b/src/main.rs index c782c11..bc88157 100644 --- a/src/main.rs +++ b/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() + } + } } diff --git a/src/output/human.rs b/src/output/human.rs new file mode 100644 index 0000000..aac1ee7 --- /dev/null +++ b/src/output/human.rs @@ -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}"); + } +} diff --git a/src/output/mod.rs b/src/output/mod.rs index 34403c4..614abaf 100644 --- a/src/output/mod.rs +++ b/src/output/mod.rs @@ -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 { + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub meta: BTreeMap, +} + +#[derive(Debug, Serialize)] +pub struct RobotError { + pub code: String, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub suggestion: Option, +} + +impl RobotEnvelope { + pub fn success(data: T, meta: BTreeMap) -> Self { + Self { + ok: true, + data: Some(data), + error: None, + meta, + } + } +} + +impl RobotEnvelope<()> { + pub fn error(error: RobotError, meta: BTreeMap) -> Self { + Self { + ok: false, + data: None, + error: Some(error), + meta, + } + } +} diff --git a/src/output/robot.rs b/src/output/robot.rs new file mode 100644 index 0000000..75a1870 --- /dev/null +++ b/src/output/robot.rs @@ -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 { + 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(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, + 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}"); +} diff --git a/src/output/table.rs b/src/output/table.rs new file mode 100644 index 0000000..ab6fbd8 --- /dev/null +++ b/src/output/table.rs @@ -0,0 +1,14 @@ +use tabled::settings::Style; +use tabled::{Table, Tabled}; + +pub fn render_table(rows: &[T]) -> String { + Table::new(rows).with(Style::rounded()).to_string() +} + +pub fn render_table_or_empty(rows: &[T], empty_msg: &str) -> String { + if rows.is_empty() { + empty_msg.to_string() + } else { + render_table(rows) + } +} diff --git a/tests/fixtures/petstore.json b/tests/fixtures/petstore.json new file mode 100644 index 0000000..60c750e --- /dev/null +++ b/tests/fixtures/petstore.json @@ -0,0 +1,173 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Petstore", + "version": "1.0.0", + "description": "A minimal Petstore API for testing." + }, + "security": [ + { "api_key": [] } + ], + "tags": [ + { "name": "pets", "description": "Pet operations" }, + { "name": "store", "description": "Store operations" } + ], + "paths": { + "/pets": { + "get": { + "operationId": "listPets", + "summary": "List all pets", + "tags": ["pets"], + "parameters": [ + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum number of items to return" + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "Pagination offset" + } + ], + "responses": { + "200": { + "description": "A list of pets", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "$ref": "#/components/schemas/Pet" } + } + } + } + } + } + }, + "post": { + "operationId": "createPet", + "summary": "Create a pet", + "tags": ["pets"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/NewPet" } + } + } + }, + "responses": { + "201": { + "description": "Pet created", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + } + } + } + }, + "/pets/{petId}": { + "parameters": [ + { + "name": "petId", + "in": "path", + "required": true, + "description": "The ID of the pet" + } + ], + "get": { + "operationId": "showPetById", + "summary": "Get a pet by ID", + "tags": ["pets"], + "responses": { + "200": { + "description": "A single pet", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Pet" } + } + } + }, + "404": { + "description": "Pet not found", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + } + }, + "delete": { + "operationId": "deletePet", + "summary": "Delete a pet", + "tags": ["pets"], + "deprecated": true, + "responses": { + "204": { "description": "Pet deleted" } + } + } + }, + "/store/inventory": { + "get": { + "operationId": "getInventory", + "summary": "Get store inventory", + "tags": ["store"], + "security": [], + "responses": { + "200": { + "description": "Inventory counts", + "content": { + "application/json": { + "schema": { + "type": "object", + "additionalProperties": { "type": "integer" } + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": ["id", "name"], + "properties": { + "id": { "type": "integer", "format": "int64" }, + "name": { "type": "string" }, + "tag": { "type": "string" } + } + }, + "NewPet": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "tag": { "type": "string" } + } + }, + "Error": { + "type": "object", + "required": ["code", "message"], + "properties": { + "code": { "type": "integer", "format": "int32" }, + "message": { "type": "string" } + } + } + }, + "securitySchemes": { + "api_key": { + "type": "apiKey", + "name": "X-API-Key", + "in": "header" + } + } + } +}