diff --git a/CHANGELOG.md b/CHANGELOG.md
index fd503a0..39d28c1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
+## [1.6.1] — 2026-07-30
+
+### Fixed
+
+#### Codegen (all emitters)
+- **Doc-comment escaping** — schema/operation descriptions from real-world specs (GitHub, Stripe) contain embedded newlines, backticks, and `/*`/`*/` sequences that broke generated code: continuation lines lost their comment prefix (→ parse errors), `*/` closed block comments (→ "unterminated block comment"), and backticks tripped token errors. The Rust (`rust_doc`/`rust_doc_indented`) and Go (`go_doc`/`go_doc_label`) emitters now escape these and prefix every line. **Result: the 4 `*_generates_and_compiles_{rust,go}` regression tests for GitHub + Stripe specs now pass** (they were failing).
+- **Reserved-name collisions** — a spec model whose name matches a built-in SDK type (e.g. GitHub's `ValidationError`) would redeclare it and break compilation. The Go and Rust emitters now suffix colliding model names with `Model`.
+
+#### Rust emitter
+- **Generated SDK now compiles** — fixed six template bugs that silently broke every generated Rust SDK since the interceptors/validation_middleware/ServiceContainer modules were added in 1.6.0: a response-interceptor loop nested inside the response-transformer loop; a missing `logger` field on `ClientBuilder`; telemetry call-site signature mismatches (`&Method`/`Duration` vs `&str`/`u128`); a two-arg `Result` used where the crate alias is one-arg; a `do_once` parameter-order mismatch; and `serde_json::to_vec` moving instead of borrowing. The example SDK was stale and never regenerated to catch this.
+- **Generated README expanded** — `emit_readme` now documents interceptors, runtime validation (`validation_middleware`), and the `ServiceContainer` telemetry/DI helpers.
+
+#### TypeScript emitter
+- **Telemetry is now wired** — the `TelemetryHooks` interface and `MetricsCollector` were generated but never stored or invoked by `ApiClient`. The client now reads `opts.telemetry`/`container.telemetry` and calls `onRequestStart`/`onRequestEnd`/`onRequestError`/`onRetry`/`onCacheHit`/`onCacheMiss` at the right points in the request lifecycle.
+- **Per-model validators typecheck** — generated object validators called `validateValue` with three arguments (missing the `errors` accumulator) and returned its `void` result as `ValidationError[]`. Now mirrors the enum-validator pattern.
+
+#### Testing
+- **Compile-the-generated-SDK regression tests** — added to the Rust (`cargo check`), TypeScript (`tsc --noEmit`), and Go (`go build ./...`) emitters. These regenerate the SDK into a temp crate and build it, so future emitter template breakage fails CI instead of shipping silently. All three emitters verified against both `fixtures/` specs (petstore + sample-api); each test skips cleanly only when its toolchain is genuinely absent.
+
+---
+
## [1.6.0] — 2026-07-27
### Added
@@ -262,6 +283,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Version Links
+- [v1.6.1](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.6.1)
- [v1.6.0](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.6.0)
- [v1.5.0](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.5.0)
- [v1.4.0](https://github.com/amafjarkasi/specforge-openapi-sdk-codegen/releases/tag/v1.4.0)
diff --git a/README.md b/README.md
index 27f8187..216fda9 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@
-
+
@@ -85,7 +85,7 @@ Each generated SDK is a standalone project — no shared runtime dependency, no
**5. Full IDE integration**
-The [VS Code extension](vscode-extension/) provides 23 commands, auto-validation on save, context menus, keyboard shortcuts, and a status bar — everything you need to work with OpenAPI specs without leaving your editor.
+The [VS Code extension](vscode-extension/) provides 24 commands, auto-validation on save, context menus, keyboard shortcuts, and a status bar — everything you need to work with OpenAPI specs without leaving your editor.
**6. Extensible via WASM plugins**
@@ -183,9 +183,9 @@ Every generated SDK is a **complete, production-ready client** — not just type
**WASM plugins**
- `specforge-plugin` crate with `Plugin` trait and `export_plugin!` macro
- Receives full IR as JSON, returns generated files
-- Compile to `wasm32-wasi` for any language emitter
+- Compile to `wasm32-wasip1` for any language emitter
-### CLI subcommands (22 commands)
+### CLI subcommands
| Command | What it does |
|---|---|
@@ -202,9 +202,8 @@ Every generated SDK is a **complete, production-ready client** — not just type
| `specforge versions` | List API versions in a spec directory |
| `specforge workspace` | Generate SDKs for all specs in a workspace config |
| `specforge workspace-init` | Generate a workspace config from a directory |
-| `specforge dashboard` | Generate HTML metrics dashboard from SDK telemetry |
-| `specforge security` | Analyze auth requirements and detect issues |
-| `specforge graph` | Generate schema dependency diagrams (Mermaid/DOT) |
+| `specforge profile` | Profile the performance of API endpoints from an OpenAPI spec |
+| `specforge plugin` | Manage WASM emitter plugins |
| `specforge analyze` | Detect unused schemas, duplicates, optimization opportunities |
| `specforge mock` | Start a local mock server from spec examples |
| `specforge export` | Export Swagger Editor-compatible spec (inline `$ref`) |
@@ -219,11 +218,13 @@ Every generated SDK is a **complete, production-ready client** — not just type
| `specforge market add` | Add a spec to the marketplace |
| `specforge changelog` | Auto-generate CHANGELOG from spec changes |
+> Run `specforge help` to see the full, current list — the CLI is the source of truth.
+
### Quality gates
-- **227 unit tests** across core, emitters, and CLI
+- **259 unit tests** across core, emitters, and CLI
- **21 test fixtures** (0.02MB to 14MB): petstore, GitHub, Stripe, Kubernetes, Atlassian, OpenAI, Vercel, Linode, Bitbucket, Adyen, Notion, Spotify, Adobe AEM, CircleCI, Okta, and more
-- **Compile gates**: generated Go must `go build`, generated Rust must `cargo check`
+- **Compile gates**: every emitter has a test that regenerates the SDK into a temp crate and compiles it — Go (`go build`), Rust (`cargo check`), TypeScript (`tsc --noEmit`). On top of that, `regression.rs` generates + compiles SDKs from real-world specs (GitHub ~9MB, Stripe ~7.6MB) in all three languages.
- **E2E smoke**: mock server × list/show/create + auth/retry/pagination (all 3 langs)
- **E2E advanced**: concurrency serialisation, dedupe single-flight, middleware rewrite, idempotency-key on POST, SSE parse (all 3 langs)
- **Performance benchmarks**: criterion benchmarks + 13 perf tests (petstore < 100ms, GitHub/Stripe < 10s)
@@ -632,7 +633,7 @@ sdk-ts/
├── README.md
└── src/
├── index.ts # createClient() + re-exports
- ├── client.ts # fetch core
+ ├── client.ts # fetch core (ApiClient + retry/dedupe/telemetry)
├── auth.ts
├── retry.ts
├── paginate.ts
@@ -641,6 +642,14 @@ sdk-ts/
├── middleware.ts
├── idempotency.ts # Idempotency-Key generation
├── streaming.ts # streamBytes / streamLines / streamSse
+ ├── cache.ts # ETag/conditional GET response cache
+ ├── ratelimit.ts # token-bucket + sliding-window rate limiters
+ ├── telemetry.ts # TelemetryHooks interface + MetricsCollector
+ ├── logging.ts # pluggable Logger (ConsoleLogger)
+ ├── interceptors.ts # request/response body interceptors
+ ├── validate.ts # generated per-model validators
+ ├── validation-middleware.ts # schema validation as middleware
+ ├── service_container.ts # DI grouping (fetch/cache/logger/telemetry)
├── errors.ts # discriminated-union ApiError
├── models/.ts # one file per schema (+ oneOf guards)
└── api/.ts # one class per tag
@@ -680,17 +689,24 @@ sdk-rs/
├── README.md
└── src/
├── lib.rs
- ├── client.rs # Client + ClientBuilder + request_stream
- ├── error.rs
- ├── retry.rs
- ├── paginate.rs
- ├── concurrency.rs
- ├── dedup.rs
- ├── middleware.rs
- ├── idempotency.rs
- ├── streaming.rs # SseStream
- ├── models.rs
- └── api/.rs
+ ├── api/ # one module per tag, one fn per operation
+ ├── cache.rs # ETag/conditional GET response cache
+ ├── client.rs # Client + ClientBuilder + ServiceContainer + request_stream
+ ├── concurrency.rs # async in-flight semaphore
+ ├── dedup.rs # coalesce identical in-flight safe requests
+ ├── error.rs # typed Error enum
+ ├── idempotency.rs # auto Idempotency-Key for safe retries
+ ├── interceptors.rs # request/response body interceptors
+ ├── logging.rs # pluggable Logger trait + Console/Noop impls
+ ├── middleware.rs # request/response middleware chain
+ ├── models.rs # one struct/enum per schema + validators
+ ├── paginate.rs # cursor/offset pagination helpers
+ ├── ratelimit.rs # token-bucket + sliding-window rate limiters
+ ├── retry.rs # exponential backoff + jitter
+ ├── streaming.rs # SseStream for server-sent events
+ ├── telemetry.rs # TelemetryHooks trait + MetricsCollector
+ ├── validate.rs # generated per-model validators
+ └── validation_middleware.rs # schema validation as middleware
```
@@ -706,7 +722,7 @@ plugin/
└── README.md
```
-Build: `cargo build --target wasm32-wasi --release`
+Build: `cargo build --target wasm32-wasip1 --release`
@@ -1039,7 +1055,7 @@ Commands: `generate`, `check`, `diff`, `emit`
The [specforge VS Code extension](vscode-extension/) provides a full IDE experience for working with OpenAPI specs:
-**23 commands** accessible via `Ctrl+Shift+P`:
+**24 commands** accessible via `Ctrl+Shift+P`:
- **Generate SDK** — Pick language, generate to output directory
- **Check Spec** — Validate with `--strict` mode
- **Diff Spec** — Compare two spec versions
diff --git a/crates/specforge-cli/src/main.rs b/crates/specforge-cli/src/main.rs
index f8cdcd8..3a97aa0 100644
--- a/crates/specforge-cli/src/main.rs
+++ b/crates/specforge-cli/src/main.rs
@@ -12,7 +12,7 @@ use serde_json::Value as JsonValue;
use tracing::{info, warn};
use tracing_subscriber::EnvFilter;
-use specforge_core::{diff, export_spec, generate_changelog, generate_demo_spec, lint, lint_config, merge_specs, parse_file, profile_api, resolve, resolve_spec_path, scan_versions, apply_versioning, ChangelogFormat, ChangelogOptions, DiffSeverity, ExportOptions, LintConfig, MarketplaceIndex, PluginIndex, ProfileOptions, RuleSeverity, Severity, SpecforgeConfig, VersioningConfig, VersionStrategy};
+use specforge_core::{diff, generate_changelog, lint, lint_config, merge_specs, parse_file, profile_api, resolve, resolve_spec_path, scan_versions, apply_versioning, ChangelogFormat, ChangelogOptions, DiffSeverity, LintConfig, MarketplaceIndex, PluginIndex, ProfileOptions, RuleSeverity, Severity, SpecforgeConfig, VersioningConfig, VersionStrategy};
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum LogLevel {
@@ -870,7 +870,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -884,7 +884,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -898,7 +898,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -906,7 +906,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -914,7 +914,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -922,7 +922,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -930,7 +930,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -941,7 +941,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -949,7 +949,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -957,7 +957,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -975,7 +975,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -990,7 +990,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -998,7 +998,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1006,7 +1006,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1014,7 +1014,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1029,7 +1029,7 @@ fn main() -> ExitCode {
}
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1037,7 +1037,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1045,7 +1045,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1053,7 +1053,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1061,7 +1061,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1069,7 +1069,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1077,7 +1077,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1085,7 +1085,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1093,7 +1093,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1101,7 +1101,7 @@ fn main() -> ExitCode {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("error: {e:#}");
- let _ = warn!("{e:#}");
+ warn!("{e:#}");
ExitCode::FAILURE
}
},
@@ -1781,7 +1781,7 @@ fn run_merge(cli: &MergeArgs) -> Result<()> {
info!("reading spec: {}", path.display());
let text = std::fs::read_to_string(path)
.with_context(|| format!("failed to read spec at {}", path.display()))?;
- let val: serde_json::Value = if path.extension().map_or(false, |e| e == "json") {
+ let val: serde_json::Value = if path.extension().is_some_and(|e| e == "json") {
serde_json::from_str(&text)
.with_context(|| format!("failed to parse {} as JSON", path.display()))?
} else {
@@ -2095,12 +2095,42 @@ fn print_analysis_text(report: &specforge_core::AnalysisReport) {
eprintln!("Operations: {}", report.total_operations);
eprintln!("IR size: {:.1} KB", report.total_size_bytes as f64 / 1024.0);
eprintln!();
- if !report.unused_schemas.is_empty() { eprintln!("Unused schemas ({}):", report.unused_schemas.len()); for name in &report.unused_schemas { eprintln!(" - {name}"); } eprintln!(); }
- if !report.duplicate_schemas.is_empty() { eprintln!("Duplicate schemas ({} pair(s)):", report.duplicate_schemas.len()); for (a, b) in &report.duplicate_schemas { eprintln!(" - {a} <-> {b}"); } eprintln!(); }
- if !report.large_schemas.is_empty() { eprintln!("Large schemas (>20 properties):"); for (name, count) in &report.large_schemas { eprintln!(" - {name}: {count} properties"); } eprintln!(); }
- if !report.deep_refs.is_empty() { eprintln!("Deep reference chains:"); for (name, depth) in &report.deep_refs { eprintln!(" - {name}: depth {depth}"); } eprintln!(); }
- if report.recommendations.is_empty() { eprintln!("No issues found. Spec looks healthy."); }
- else { eprintln!("Recommendations ({}):", report.recommendations.len()); for (i, rec) in report.recommendations.iter().enumerate() { eprintln!(" {}. {rec}", i + 1); } }
+ if !report.unused_schemas.is_empty() {
+ eprintln!("Unused schemas ({}):", report.unused_schemas.len());
+ for name in &report.unused_schemas {
+ eprintln!(" - {name}");
+ }
+ eprintln!();
+ }
+ if !report.duplicate_schemas.is_empty() {
+ eprintln!("Duplicate schemas ({} pair(s)):", report.duplicate_schemas.len());
+ for (a, b) in &report.duplicate_schemas {
+ eprintln!(" - {a} <-> {b}");
+ }
+ eprintln!();
+ }
+ if !report.large_schemas.is_empty() {
+ eprintln!("Large schemas (>20 properties):");
+ for (name, count) in &report.large_schemas {
+ eprintln!(" - {name}: {count} properties");
+ }
+ eprintln!();
+ }
+ if !report.deep_refs.is_empty() {
+ eprintln!("Deep reference chains:");
+ for (name, depth) in &report.deep_refs {
+ eprintln!(" - {name}: depth {depth}");
+ }
+ eprintln!();
+ }
+ if report.recommendations.is_empty() {
+ eprintln!("No issues found. Spec looks healthy.");
+ } else {
+ eprintln!("Recommendations ({}):", report.recommendations.len());
+ for (i, rec) in report.recommendations.iter().enumerate() {
+ eprintln!(" {}. {rec}", i + 1);
+ }
+ }
}
fn print_analysis_markdown(report: &specforge_core::AnalysisReport) {
println!("# Spec Bundle Analysis");
@@ -2111,12 +2141,47 @@ fn print_analysis_markdown(report: &specforge_core::AnalysisReport) {
println!("| Operations | {} |", report.total_operations);
println!("| IR size | {:.1} KB |", report.total_size_bytes as f64 / 1024.0);
println!();
- if !report.unused_schemas.is_empty() { println!("## Unused Schemas ({})", report.unused_schemas.len()); println!(); for name in &report.unused_schemas { println!("- `{name}`"); } println!(); }
- if !report.duplicate_schemas.is_empty() { println!("## Duplicate Schemas ({} pair(s))", report.duplicate_schemas.len()); println!(); for (a, b) in &report.duplicate_schemas { println!("- `{a}` <-> `{b}`"); } println!(); }
- if !report.large_schemas.is_empty() { println!("## Large Schemas (>20 properties)"); println!(); for (name, count) in &report.large_schemas { println!("- `{name}`: {count} properties"); } println!(); }
- if !report.deep_refs.is_empty() { println!("## Deep Reference Chains"); println!(); for (name, depth) in &report.deep_refs { println!("- `{name}`: depth {depth}"); } println!(); }
- if !report.recommendations.is_empty() { println!("## Recommendations"); println!(); for (i, rec) in report.recommendations.iter().enumerate() { println!("{}. {rec}", i + 1); } }
- else { println!("No issues found. Spec looks healthy."); }
+ if !report.unused_schemas.is_empty() {
+ println!("## Unused Schemas ({})", report.unused_schemas.len());
+ println!();
+ for name in &report.unused_schemas {
+ println!("- `{name}`");
+ }
+ println!();
+ }
+ if !report.duplicate_schemas.is_empty() {
+ println!("## Duplicate Schemas ({} pair(s))", report.duplicate_schemas.len());
+ println!();
+ for (a, b) in &report.duplicate_schemas {
+ println!("- `{a}` <-> `{b}`");
+ }
+ println!();
+ }
+ if !report.large_schemas.is_empty() {
+ println!("## Large Schemas (>20 properties)");
+ println!();
+ for (name, count) in &report.large_schemas {
+ println!("- `{name}`: {count} properties");
+ }
+ println!();
+ }
+ if !report.deep_refs.is_empty() {
+ println!("## Deep Reference Chains");
+ println!();
+ for (name, depth) in &report.deep_refs {
+ println!("- `{name}`: depth {depth}");
+ }
+ println!();
+ }
+ if !report.recommendations.is_empty() {
+ println!("## Recommendations");
+ println!();
+ for (i, rec) in report.recommendations.iter().enumerate() {
+ println!("{}. {rec}", i + 1);
+ }
+ } else {
+ println!("No issues found. Spec looks healthy.");
+ }
}
/// Recursively upgrade OpenAPI 3.0 constructs to 3.1 equivalents.
fn upgrade_30_to_31(json: &mut JsonValue) {
@@ -2521,11 +2586,10 @@ fn run_market_list(index: &MarketplaceIndex, cli: &MarketListArgs) -> Result<()>
.unwrap_or(20)
.max(20);
eprintln!(
- " {:10} {:>5} {}",
+ " {:10} {:>5} DESCRIPTION",
"NAME",
"DOWNLOADS",
"RATING",
- "DESCRIPTION",
width = name_w,
);
eprintln!(
@@ -2593,7 +2657,7 @@ fn run_market_add(index: &MarketplaceIndex, cli: &MarketAddArgs) -> Result<()> {
// Build a new index: start from existing built-in entries and append.
let mut new_index = index.clone();
new_index.entries.push(entry);
- new_index.entries.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ new_index.entries.sort_by_key(|a| std::cmp::Reverse(a.downloads));
let json =
serde_json::to_string_pretty(&new_index).context("failed to serialize marketplace index")?;
@@ -2836,12 +2900,11 @@ fn run_plugin_list(index: &PluginIndex, cli: &PluginListArgs) -> Result<()> {
.unwrap_or(20)
.max(20);
eprintln!(
- " {:10} {:>5} {:>12} {}",
+ " {:10} {:>5} {:>12} DESCRIPTION",
"NAME",
"DOWNLOADS",
"RATING",
"LANGUAGE",
- "DESCRIPTION",
width = name_w,
);
eprintln!(
diff --git a/crates/specforge-cli/tests/e2e_advanced.rs b/crates/specforge-cli/tests/e2e_advanced.rs
index 29763fc..863c5cb 100644
--- a/crates/specforge-cli/tests/e2e_advanced.rs
+++ b/crates/specforge-cli/tests/e2e_advanced.rs
@@ -10,6 +10,9 @@ use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
+
+/// A parsed HTTP request: method, path, headers, body.
+type ParsedRequest = (String, String, HashMap, Vec);
use std::time::Duration;
// ─── Shared helpers (mirrored lightly from e2e_smoke) ────────────────────────
@@ -98,7 +101,7 @@ fn write_response(stream: &mut TcpStream, status: u16, ct: &str, body: &str, ext
stream.flush()
}
-fn parse_http(stream: &mut TcpStream) -> std::io::Result<(String, String, HashMap, Vec)> {
+fn parse_http(stream: &mut TcpStream) -> std::io::Result {
stream.set_read_timeout(Some(Duration::from_secs(5)))?;
stream.set_write_timeout(Some(Duration::from_secs(5)))?;
let mut buf = [0u8; 32768];
diff --git a/crates/specforge-cli/tests/e2e_smoke.rs b/crates/specforge-cli/tests/e2e_smoke.rs
index 91adeab..ae47bf3 100644
--- a/crates/specforge-cli/tests/e2e_smoke.rs
+++ b/crates/specforge-cli/tests/e2e_smoke.rs
@@ -10,6 +10,12 @@ use std::collections::HashMap;
use std::io::{Read, Write};
use std::net::{SocketAddr, TcpListener, TcpStream};
use std::path::{Path, PathBuf};
+
+/// A parsed HTTP request: method, path, version, headers, body.
+type ParsedRequest = (String, String, String, HashMap, Vec);
+
+/// A language test leg: its label and the function that runs it for a crate dir.
+type LangLeg = (&'static str, Box Result<(), String>>);
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::thread;
@@ -45,7 +51,7 @@ fn escape_json(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
-fn parse_request(stream: &mut TcpStream) -> std::io::Result<(String, String, String, HashMap, Vec)> {
+fn parse_request(stream: &mut TcpStream) -> std::io::Result {
stream.set_read_timeout(Some(Duration::from_secs(3)))?;
stream.set_write_timeout(Some(Duration::from_secs(3)))?;
@@ -1024,7 +1030,7 @@ console.log("ts-runtime-ok", all.length, page.items.length);
fn run_langs(
label: &str,
root: &Path,
- legs: &[(&str, Box Result<(), String>>)],
+ legs: &[LangLeg],
) {
let mut failures = Vec::new();
let mut ran = 0;
diff --git a/crates/specforge-cli/tests/edge_cases.rs b/crates/specforge-cli/tests/edge_cases.rs
index 63577a7..a0f4989 100644
--- a/crates/specforge-cli/tests/edge_cases.rs
+++ b/crates/specforge-cli/tests/edge_cases.rs
@@ -504,7 +504,7 @@ fn edge_case_ref_only_schema() {
);
let doc = parse_resolve_inline(yaml);
assert!(
- doc.schemas.models.len() >= 1,
+ !doc.schemas.models.is_empty(),
"expected at least 1 schema"
);
assert!(
diff --git a/crates/specforge-cli/tests/sdk_integration.rs b/crates/specforge-cli/tests/sdk_integration.rs
index 5129a8f..ee51b8c 100644
--- a/crates/specforge-cli/tests/sdk_integration.rs
+++ b/crates/specforge-cli/tests/sdk_integration.rs
@@ -189,10 +189,8 @@ fn start_mock() -> (SocketAddr, thread::JoinHandle<()>) {
];
let handle = thread::spawn(move || {
- for conn in listener.incoming() {
- if let Ok(mut stream) = conn {
- handle_request(&mut stream, &pets);
- }
+ for mut stream in listener.incoming().flatten() {
+ handle_request(&mut stream, &pets);
}
});
thread::sleep(Duration::from_millis(20));
diff --git a/crates/specforge-core/src/analyzer.rs b/crates/specforge-core/src/analyzer.rs
index d158f67..6c37914 100644
--- a/crates/specforge-core/src/analyzer.rs
+++ b/crates/specforge-core/src/analyzer.rs
@@ -1,4 +1,5 @@
use crate::ir::{Document, Model, Type};
+use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, serde::Serialize)]
@@ -55,7 +56,7 @@ pub fn analyze_spec(doc: &Document) -> AnalysisReport {
if obj.properties.len() > 20 { report.large_schemas.push((name.clone(), obj.properties.len())); }
}
}
- report.large_schemas.sort_by(|a, b| b.1.cmp(&a.1));
+ report.large_schemas.sort_by_key(|a| Reverse(a.1));
let mut fps: HashMap, Vec> = HashMap::new();
for (name, model) in doc.schemas.iter() {
if let Model::Object(obj) = model {
@@ -78,7 +79,7 @@ pub fn analyze_spec(doc: &Document) -> AnalysisReport {
let depth = ref_depth(name, &ref_graph, &mut memo);
if depth > 2 { report.deep_refs.push((name.clone(), depth)); }
}
- report.deep_refs.sort_by(|a, b| b.1.cmp(&a.1));
+ report.deep_refs.sort_by_key(|a| Reverse(a.1));
if !report.unused_schemas.is_empty() {
report.recommendations.push(format!("Remove {} unused schema(s): {}", report.unused_schemas.len(), report.unused_schemas.join(", ")));
}
diff --git a/crates/specforge-core/src/changelog.rs b/crates/specforge-core/src/changelog.rs
index 7085e17..a36b0e8 100644
--- a/crates/specforge-core/src/changelog.rs
+++ b/crates/specforge-core/src/changelog.rs
@@ -237,6 +237,7 @@ fn classify_finding(finding: &diff::DiffFinding) -> ChangeImpact {
}
/// Build per-operation changelog entries from the diff between old and new documents.
+#[cfg(test)]
fn build_operation_entries(
old_doc: &Document,
new_doc: &Document,
@@ -590,7 +591,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String
let mut out = String::new();
// Header
- out.push_str(&format!("# Changelog\n\n"));
+ out.push_str("# Changelog\n\n");
out.push_str(&format!("## [{version}] -- {today}\n\n"));
// Suggested version bump
@@ -602,9 +603,7 @@ fn render_markdown(result: &ChangelogResult, opts: &ChangelogOptions) -> String
}
// Summary
- out.push_str(&format!(
- "### Summary\n\n"
- ));
+ out.push_str("### Summary\n\n");
out.push_str(&format!(
"- **{}** operation(s) changed\n",
result.operation_entries.len()
diff --git a/crates/specforge-core/src/demo.rs b/crates/specforge-core/src/demo.rs
index 5b82d53..490eae2 100644
--- a/crates/specforge-core/src/demo.rs
+++ b/crates/specforge-core/src/demo.rs
@@ -6,6 +6,12 @@
//! point for new APIs.
/// Generate the demo Petstore spec as a YAML string.
+///
+/// # Panics
+///
+/// Panics if the internally-built spec fails to serialize to YAML. Since the
+/// spec is constructed from known-valid `serde_json::Value`s, this should never
+/// happen; a panic here indicates an internal bug.
pub fn generate_demo_spec() -> String {
// We build the spec as a serde_json::Value for reliable escaping,
// then serialize to YAML.
diff --git a/crates/specforge-core/src/deprecation.rs b/crates/specforge-core/src/deprecation.rs
index 6539c5f..155e7e9 100644
--- a/crates/specforge-core/src/deprecation.rs
+++ b/crates/specforge-core/src/deprecation.rs
@@ -189,7 +189,7 @@ fn extract_alternative(text: &str) -> Option {
let after = &text[pos + pattern.len()..];
// Take until end of sentence or end of text.
let end = after
- .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';')
+ .find(['.', ',', '\n', ';'])
.unwrap_or(after.len());
let alt = after[..end].trim();
if !alt.is_empty() {
@@ -202,7 +202,7 @@ fn extract_alternative(text: &str) -> Option {
if let Some(pos) = lower.find("migrate to ") {
let after = &text[pos + 11..];
let end = after
- .find(|c: char| c == '.' || c == ',' || c == '\n' || c == ';')
+ .find(['.', ',', '\n', ';'])
.unwrap_or(after.len());
let alt = after[..end].trim();
if !alt.is_empty() {
@@ -217,6 +217,12 @@ fn extract_alternative(text: &str) -> Option {
///
/// Produces a Markdown document listing deprecated operations, removed
/// operations, new required parameters, and schema changes.
+///
+/// # Panics
+///
+/// Panics if a schema name present in both documents' schema sets is missing
+/// from the schema map of either document. This cannot happen for documents
+/// produced by [`resolve`](crate::resolve), which keep both in sync.
pub fn generate_migration_guide(
old_doc: &Document,
new_doc: &Document,
diff --git a/crates/specforge-core/src/diff.rs b/crates/specforge-core/src/diff.rs
index 505a942..a14f49f 100644
--- a/crates/specforge-core/src/diff.rs
+++ b/crates/specforge-core/src/diff.rs
@@ -5,7 +5,14 @@ use serde::Serialize;
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum DiffSeverity { Breaking, Info }
-impl std::fmt::Display for DiffSeverity { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { DiffSeverity::Breaking => write!(f, "breaking"), DiffSeverity::Info => write!(f, "info") } } }
+impl std::fmt::Display for DiffSeverity {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ DiffSeverity::Breaking => write!(f, "breaking"),
+ DiffSeverity::Info => write!(f, "info"),
+ }
+ }
+}
#[derive(Debug, Clone, Serialize)]
pub struct DiffFinding { pub severity: DiffSeverity, pub message: String, pub path: String }
@@ -13,12 +20,42 @@ impl std::fmt::Display for DiffFinding { fn fmt(&self, f: &mut std::fmt::Formatt
#[derive(Debug, Clone, Serialize)]
pub struct PropertyChange { pub property: String, pub change: PropertyChangeKind }
-impl std::fmt::Display for PropertyChange { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match &self.change { PropertyChangeKind::Added { ty } => write!(f, "+ {}: {} (new property)", self.property, ty), PropertyChangeKind::AddedRequired { ty } => write!(f, "+ {}: {} (new property, required)", self.property, ty), PropertyChangeKind::Removed { ty } => write!(f, "- {}: {} (removed)", self.property, ty), PropertyChangeKind::TypeChanged { old_type, new_type } => write!(f, "~ {}: {} → {}", self.property, old_type, new_type), PropertyChangeKind::RequiredChanged { old_required, new_required } => { let o = if *old_required { "required" } else { "optional" }; let n = if *new_required { "required" } else { "optional" }; write!(f, "~ {}: {} → {} (required changed)", self.property, o, n) } } } }
+impl std::fmt::Display for PropertyChange {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match &self.change {
+ PropertyChangeKind::Added { ty } => {
+ write!(f, "+ {}: {} (new property)", self.property, ty)
+ }
+ PropertyChangeKind::AddedRequired { ty } => {
+ write!(f, "+ {}: {} (new property, required)", self.property, ty)
+ }
+ PropertyChangeKind::Removed { ty } => {
+ write!(f, "- {}: {} (removed)", self.property, ty)
+ }
+ PropertyChangeKind::TypeChanged { old_type, new_type } => {
+ write!(f, "~ {}: {} → {}", self.property, old_type, new_type)
+ }
+ PropertyChangeKind::RequiredChanged { old_required, new_required } => {
+ let o = if *old_required { "required" } else { "optional" };
+ let n = if *new_required { "required" } else { "optional" };
+ write!(f, "~ {}: {} → {} (required changed)", self.property, o, n)
+ }
+ }
+ }
+}
#[derive(Debug, Clone, Serialize)]
pub enum PropertyChangeKind { Added { ty: String }, AddedRequired { ty: String }, Removed { ty: String }, TypeChanged { old_type: String, new_type: String }, RequiredChanged { old_required: bool, new_required: bool } }
#[derive(Debug, Clone, Serialize)]
pub struct SchemaDiffDetail { pub name: String, pub changes: Vec }
-impl std::fmt::Display for SchemaDiffDetail { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { writeln!(f, "Schema {}:", self.name)?; for c in &self.changes { writeln!(f, " {c}")?; } Ok(()) } }
+impl std::fmt::Display for SchemaDiffDetail {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ writeln!(f, "Schema {}:", self.name)?;
+ for c in &self.changes {
+ writeln!(f, " {c}")?;
+ }
+ Ok(())
+ }
+}
#[derive(Debug, Clone, Serialize)]
pub struct DiffResult { pub findings: Vec, pub schema_diffs: Vec }
#[derive(Debug, Clone, Serialize)]
@@ -28,9 +65,36 @@ pub struct DiffSummary { pub breaking_count: usize, pub info_count: usize }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffFormat { Text, Markdown, Json }
-fn type_to_string(ty: &Type) -> String { match ty { Type::Scalar(s) => match s { crate::ir::Scalar::String => "string".into(), crate::ir::Scalar::DateTime => "date-time".into(), crate::ir::Scalar::Uuid => "uuid".into(), crate::ir::Scalar::Integer => "integer".into(), crate::ir::Scalar::Integer64 => "integer (int64)".into(), crate::ir::Scalar::Float => "number".into(), crate::ir::Scalar::Boolean => "boolean".into(), _ => "other".into() }, Type::Reference { name, .. } => name.clone(), Type::StringEnum { .. } => "string (enum)".into(), Type::Array { .. } => "array".into(), Type::Map { .. } => "object".into(), Type::Composition(_) => "composition".into(), Type::Any => "any".into(), Type::Unknown => "unknown".into() } }
+fn type_to_string(ty: &Type) -> String {
+ match ty {
+ Type::Scalar(s) => match s {
+ crate::ir::Scalar::String => "string".into(),
+ crate::ir::Scalar::DateTime => "date-time".into(),
+ crate::ir::Scalar::Uuid => "uuid".into(),
+ crate::ir::Scalar::Integer => "integer".into(),
+ crate::ir::Scalar::Integer64 => "integer (int64)".into(),
+ crate::ir::Scalar::Float => "number".into(),
+ crate::ir::Scalar::Boolean => "boolean".into(),
+ _ => "other".into(),
+ },
+ Type::Reference { name, .. } => name.clone(),
+ Type::StringEnum { .. } => "string (enum)".into(),
+ Type::Array { .. } => "array".into(),
+ Type::Map { .. } => "object".into(),
+ Type::Composition(_) => "composition".into(),
+ Type::Any => "any".into(),
+ Type::Unknown => "unknown".into(),
+ }
+}
pub fn diff(old: &Document, new: &Document) -> Vec { diff_detailed(old, new).findings }
-pub fn diff_detailed(old: &Document, new: &Document) -> DiffResult { let mut findings = Vec::new(); let mut schema_diffs = Vec::new(); diff_ir_version(old, new, &mut findings); diff_operations(old, new, &mut findings); diff_schemas(old, new, &mut findings, &mut schema_diffs); DiffResult { findings, schema_diffs } }
+pub fn diff_detailed(old: &Document, new: &Document) -> DiffResult {
+ let mut findings = Vec::new();
+ let mut schema_diffs = Vec::new();
+ diff_ir_version(old, new, &mut findings);
+ diff_operations(old, new, &mut findings);
+ diff_schemas(old, new, &mut findings, &mut schema_diffs);
+ DiffResult { findings, schema_diffs }
+}
/// Check for IR version mismatches between old and new documents.
/// A major version difference indicates breaking IR schema changes.
@@ -68,24 +132,450 @@ fn ir_major_version(version: &str) -> u64 {
.and_then(|s| s.parse().ok())
.unwrap_or(0)
}
-fn diff_operations(old: &Document, new: &Document, findings: &mut Vec) { let old_ops: std::collections::HashMap = old.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); let new_ops: std::collections::HashMap = new.operations.iter().map(|op| (op.operation_id.clone(), op)).collect(); for id in old_ops.keys() { if !new_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("operation removed: {id}"), path: format!("operations.{id}") }); } } for id in new_ops.keys() { if !old_ops.contains_key(id) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("operation added: {id}"), path: format!("operations.{id}") }); } } for id in old_ops.keys() { if let (Some(o), Some(n)) = (old_ops.get(id), new_ops.get(id)) { diff_operation(o, n, findings); } } }
-fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) { let path = format!("operations.{}", old.operation_id); for p in &new.parameters { if p.required && !old.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required parameter: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } for p in &old.parameters { if !new.parameters.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("parameter removed: {}", p.name), path: format!("{path}.params.{}", p.name) }); } } if new.request_body.is_some() && old.request_body.is_none() { if new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: "new required request body".into(), path: format!("{path}.requestBody") }); } } for r in &old.responses { if !new.responses.iter().any(|x| x.status == r.status) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("response removed: {}", r.status), path: format!("{path}.responses.{}", r.status) }); } } }
-fn diff_schemas(old: &Document, new: &Document, findings: &mut Vec, schema_diffs: &mut Vec) { let old_s: std::collections::HashSet = old.schemas.iter().map(|(k, _)| k.clone()).collect(); let new_s: std::collections::HashSet = new.schemas.iter().map(|(k, _)| k.clone()).collect(); for n in &old_s { if !new_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("schema removed: {n}"), path: format!("schemas.{n}") }); } } for n in &new_s { if !old_s.contains(n) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("schema added: {n}"), path: format!("schemas.{n}") }); } } for n in old_s.intersection(&new_s) { if let Some(d) = diff_schema(n, old.schemas.get(n).unwrap(), new.schemas.get(n).unwrap(), findings) { schema_diffs.push(d); } } }
-fn diff_schema(name: &str, old: &Model, new: &Model, findings: &mut Vec) -> Option { let (op, np) = match (old, new) { (Model::Object(o), Model::Object(n)) => (&o.properties, &n.properties), _ => return None }; let path = format!("schemas.{name}"); let mut changes = Vec::new(); for p in np { if p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("new required property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::AddedRequired { ty: type_to_string(&p.ty) } }); } } for p in np { if !p.required && !op.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Info, message: format!("new optional property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Added { ty: type_to_string(&p.ty) } }); } } for p in op { if !np.iter().any(|x| x.name == p.name) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("property removed: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::Removed { ty: type_to_string(&p.ty) } }); } } for p in op { if let Some(np) = np.iter().find(|x| x.name == p.name) { if type_changed(&p.ty, &np.ty) { findings.push(DiffFinding { severity: DiffSeverity::Breaking, message: format!("type changed for property: {}", p.name), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::TypeChanged { old_type: type_to_string(&p.ty), new_type: type_to_string(&np.ty) } }); } else if p.required != np.required { findings.push(DiffFinding { severity: if np.required { DiffSeverity::Breaking } else { DiffSeverity::Info }, message: format!("required changed for property: {} ({} → {})", p.name, if p.required { "required" } else { "optional" }, if np.required { "required" } else { "optional" }), path: format!("{path}.{}", p.name) }); changes.push(PropertyChange { property: p.name.clone(), change: PropertyChangeKind::RequiredChanged { old_required: p.required, new_required: np.required } }); } } } if changes.is_empty() { None } else { Some(SchemaDiffDetail { name: name.to_string(), changes }) } }
-fn type_changed(old: &Type, new: &Type) -> bool { match (old, new) { (Type::Scalar(a), Type::Scalar(b)) => a != b, (Type::Reference { name: a, .. }, Type::Reference { name: b, .. }) => a != b, (Type::StringEnum { variants: a, .. }, Type::StringEnum { variants: b, .. }) => a != b, (Type::Array { item: a, .. }, Type::Array { item: b, .. }) => type_changed(a, b), _ => std::mem::discriminant(old) != std::mem::discriminant(new) } }
+fn diff_operations(old: &Document, new: &Document, findings: &mut Vec) {
+ let old_ops: std::collections::HashMap =
+ old.operations.iter().map(|op| (op.operation_id.clone(), op)).collect();
+ let new_ops: std::collections::HashMap =
+ new.operations.iter().map(|op| (op.operation_id.clone(), op)).collect();
+
+ // Removed operations are breaking.
+ for id in old_ops.keys() {
+ if !new_ops.contains_key(id) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("operation removed: {id}"),
+ path: format!("operations.{id}"),
+ });
+ }
+ }
+ // Added operations are informational.
+ for id in new_ops.keys() {
+ if !old_ops.contains_key(id) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Info,
+ message: format!("operation added: {id}"),
+ path: format!("operations.{id}"),
+ });
+ }
+ }
+ // Operations present in both may have changed.
+ for id in old_ops.keys() {
+ if let (Some(o), Some(n)) = (old_ops.get(id), new_ops.get(id)) {
+ diff_operation(o, n, findings);
+ }
+ }
+}
-pub fn format_text(findings: &[DiffFinding]) -> String { let mut out = String::new(); for finding in findings { match finding.severity { DiffSeverity::Breaking => out.push_str(&format!("breaking: {finding}\n")), DiffSeverity::Info => out.push_str(&format!("info: {finding}\n")) } } let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(); let ic = findings.len() - bc; if !findings.is_empty() { out.push_str(&format!("\n{bc} breaking change(s), {ic} info finding(s)\n")); } out }
+fn diff_operation(old: &Operation, new: &Operation, findings: &mut Vec) {
+ let path = format!("operations.{}", old.operation_id);
+
+ // New required parameters are breaking.
+ for p in &new.parameters {
+ if p.required && !old.parameters.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("new required parameter: {}", p.name),
+ path: format!("{path}.params.{}", p.name),
+ });
+ }
+ }
+ // Removed parameters are breaking.
+ for p in &old.parameters {
+ if !new.parameters.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("parameter removed: {}", p.name),
+ path: format!("{path}.params.{}", p.name),
+ });
+ }
+ }
+ // A newly required request body is breaking.
+ if new.request_body.is_some()
+ && old.request_body.is_none()
+ && new.request_body.as_ref().map(|rb| rb.required).unwrap_or(false)
+ {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: "new required request body".into(),
+ path: format!("{path}.requestBody"),
+ });
+ }
+ // Removed response codes are breaking.
+ for r in &old.responses {
+ if !new.responses.iter().any(|x| x.status == r.status) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("response removed: {}", r.status),
+ path: format!("{path}.responses.{}", r.status),
+ });
+ }
+ }
+}
+fn diff_schemas(
+ old: &Document,
+ new: &Document,
+ findings: &mut Vec,
+ schema_diffs: &mut Vec,
+) {
+ let old_s: std::collections::HashSet =
+ old.schemas.iter().map(|(k, _)| k.clone()).collect();
+ let new_s: std::collections::HashSet =
+ new.schemas.iter().map(|(k, _)| k.clone()).collect();
-pub fn format_colored(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> String { const RED: &str = "\x1b[31m"; const GREEN: &str = "\x1b[32m"; const YELLOW: &str = "\x1b[33m"; const BOLD: &str = "\x1b[1m"; const DIM: &str = "\x1b[2m"; const RESET: &str = "\x1b[0m"; let mut out = String::new(); for f in findings { match f.severity { DiffSeverity::Breaking => out.push_str(&format!("{RED}{BOLD}breaking{RESET} {RED}{}\n{RESET}", f.message)), DiffSeverity::Info => out.push_str(&format!("{GREEN}{BOLD}info{RESET} {GREEN}{}\n{RESET}", f.message)) } } if !schema_diffs.is_empty() { out.push('\n'); for d in schema_diffs { out.push_str(&format!("{BOLD}Schema {}:{RESET}\n", d.name)); for c in &d.changes { let (col, pre) = match &c.change { PropertyChangeKind::Added { .. } | PropertyChangeKind::AddedRequired { .. } => (GREEN, "+"), PropertyChangeKind::Removed { .. } => (RED, "-"), _ => (YELLOW, "~") }; out.push_str(&format!(" {col}{pre} {c}{RESET}\n")); } } } let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(); let ic = findings.len() - bc; if !findings.is_empty() { out.push_str(&format!("\n{DIM}{bc} breaking change(s), {ic} info finding(s){RESET}\n")); } out }
+ // Removed schemas are breaking.
+ for n in &old_s {
+ if !new_s.contains(n) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("schema removed: {n}"),
+ path: format!("schemas.{n}"),
+ });
+ }
+ }
+ // Added schemas are informational.
+ for n in &new_s {
+ if !old_s.contains(n) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Info,
+ message: format!("schema added: {n}"),
+ path: format!("schemas.{n}"),
+ });
+ }
+ }
+ // Schemas present in both may have property-level changes.
+ for n in old_s.intersection(&new_s) {
+ if let Some(d) = diff_schema(
+ n,
+ old.schemas.get(n).unwrap(),
+ new.schemas.get(n).unwrap(),
+ findings,
+ ) {
+ schema_diffs.push(d);
+ }
+ }
+}
-pub fn format_markdown(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail], old_version: &str, new_version: &str) -> String { let mut out = String::new(); out.push_str(&format!("# API Changes: {old_version} → {new_version}\n\n")); let brk: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).collect(); let inf: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).collect(); out.push_str("## Breaking Changes\n\n"); if brk.is_empty() { out.push_str("_None._\n\n"); } else { for f in &brk { out.push_str(&format!("- ❌ {}\n", fmt_md(f))); } out.push('\n'); }
- out.push_str("## Non-Breaking Changes\n\n"); if inf.is_empty() { out.push_str("_None._\n\n"); } else { for f in &inf { out.push_str(&format!("- ✅ {}\n", fmt_md(f))); } out.push('\n'); }
- if !schema_diffs.is_empty() { out.push_str("## Schema Changes\n\n"); for d in schema_diffs { out.push_str(&format!("### `{}`\n\n| Change | Property | Details |\n|--------|----------|--------|\n", d.name)); for c in &d.changes { let (icon, det) = match &c.change { PropertyChangeKind::Added { ty } => ("Added", format!("new property (`{ty}`)")), PropertyChangeKind::AddedRequired { ty } => ("Added", format!("new required property (`{ty}`)")), PropertyChangeKind::Removed { ty } => ("Removed", format!("was `{ty}`")), PropertyChangeKind::TypeChanged { old_type, new_type } => ("Changed", format!("`{old_type}` → `{new_type}`")), PropertyChangeKind::RequiredChanged { old_required, new_required } => { let o = if *old_required { "required" } else { "optional" }; let n = if *new_required { "required" } else { "optional" }; ("Changed", format!("{o} → {n}")) } }; out.push_str(&format!("| {icon} | `{}` | {det} |\n", c.property)); } out.push('\n'); } } out.push_str("## Summary\n\n"); out.push_str(&format!("- **{}** breaking change(s)\n- **{}** non-breaking change(s)\n", brk.len(), inf.len())); if !schema_diffs.is_empty() { out.push_str(&format!("- **{}** schema(s) modified\n", schema_diffs.len())); } out }
+fn diff_schema(
+ name: &str,
+ old: &Model,
+ new: &Model,
+ findings: &mut Vec,
+) -> Option {
+ let (op, np) = match (old, new) {
+ (Model::Object(o), Model::Object(n)) => (&o.properties, &n.properties),
+ _ => return None,
+ };
+ let path = format!("schemas.{name}");
+ let mut changes = Vec::new();
+
+ // New required properties are breaking.
+ for p in np {
+ if p.required && !op.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("new required property: {}", p.name),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::AddedRequired { ty: type_to_string(&p.ty) },
+ });
+ }
+ }
+ // New optional properties are informational.
+ for p in np {
+ if !p.required && !op.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Info,
+ message: format!("new optional property: {}", p.name),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::Added { ty: type_to_string(&p.ty) },
+ });
+ }
+ }
+ // Removed properties are breaking.
+ for p in op {
+ if !np.iter().any(|x| x.name == p.name) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("property removed: {}", p.name),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::Removed { ty: type_to_string(&p.ty) },
+ });
+ }
+ }
+ // Type or required-status changes for shared properties.
+ for p in op {
+ if let Some(np) = np.iter().find(|x| x.name == p.name) {
+ if type_changed(&p.ty, &np.ty) {
+ findings.push(DiffFinding {
+ severity: DiffSeverity::Breaking,
+ message: format!("type changed for property: {}", p.name),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::TypeChanged {
+ old_type: type_to_string(&p.ty),
+ new_type: type_to_string(&np.ty),
+ },
+ });
+ } else if p.required != np.required {
+ findings.push(DiffFinding {
+ severity: if np.required { DiffSeverity::Breaking } else { DiffSeverity::Info },
+ message: format!(
+ "required changed for property: {} ({} → {})",
+ p.name,
+ if p.required { "required" } else { "optional" },
+ if np.required { "required" } else { "optional" }
+ ),
+ path: format!("{path}.{}", p.name),
+ });
+ changes.push(PropertyChange {
+ property: p.name.clone(),
+ change: PropertyChangeKind::RequiredChanged {
+ old_required: p.required,
+ new_required: np.required,
+ },
+ });
+ }
+ }
+ }
+
+ if changes.is_empty() { None } else { Some(SchemaDiffDetail { name: name.to_string(), changes }) }
+}
+fn type_changed(old: &Type, new: &Type) -> bool {
+ match (old, new) {
+ (Type::Scalar(a), Type::Scalar(b)) => a != b,
+ (Type::Reference { name: a, .. }, Type::Reference { name: b, .. }) => a != b,
+ (Type::StringEnum { variants: a, .. }, Type::StringEnum { variants: b, .. }) => a != b,
+ (Type::Array { item: a, .. }, Type::Array { item: b, .. }) => type_changed(a, b),
+ _ => std::mem::discriminant(old) != std::mem::discriminant(new),
+ }
+}
+
+pub fn format_text(findings: &[DiffFinding]) -> String {
+ let mut out = String::new();
+ for finding in findings {
+ match finding.severity {
+ DiffSeverity::Breaking => out.push_str(&format!("breaking: {finding}\n")),
+ DiffSeverity::Info => out.push_str(&format!("info: {finding}\n")),
+ }
+ }
+ let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count();
+ let ic = findings.len() - bc;
+ if !findings.is_empty() {
+ out.push_str(&format!("\n{bc} breaking change(s), {ic} info finding(s)\n"));
+ }
+ out
+}
+
+pub fn format_colored(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> String {
+ const RED: &str = "\x1b[31m";
+ const GREEN: &str = "\x1b[32m";
+ const YELLOW: &str = "\x1b[33m";
+ const BOLD: &str = "\x1b[1m";
+ const DIM: &str = "\x1b[2m";
+ const RESET: &str = "\x1b[0m";
+
+ let mut out = String::new();
+ for f in findings {
+ match f.severity {
+ DiffSeverity::Breaking => {
+ out.push_str(&format!("{RED}{BOLD}breaking{RESET} {RED}{}\n{RESET}", f.message))
+ }
+ DiffSeverity::Info => {
+ out.push_str(&format!("{GREEN}{BOLD}info{RESET} {GREEN}{}\n{RESET}", f.message))
+ }
+ }
+ }
+ if !schema_diffs.is_empty() {
+ out.push('\n');
+ for d in schema_diffs {
+ out.push_str(&format!("{BOLD}Schema {}:{RESET}\n", d.name));
+ for c in &d.changes {
+ let (col, pre) = match &c.change {
+ PropertyChangeKind::Added { .. } | PropertyChangeKind::AddedRequired { .. } => (GREEN, "+"),
+ PropertyChangeKind::Removed { .. } => (RED, "-"),
+ _ => (YELLOW, "~"),
+ };
+ out.push_str(&format!(" {col}{pre} {c}{RESET}\n"));
+ }
+ }
+ }
+ let bc = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count();
+ let ic = findings.len() - bc;
+ if !findings.is_empty() {
+ out.push_str(&format!("\n{DIM}{bc} breaking change(s), {ic} info finding(s){RESET}\n"));
+ }
+ out
+}
+
+pub fn format_markdown(
+ findings: &[DiffFinding],
+ schema_diffs: &[SchemaDiffDetail],
+ old_version: &str,
+ new_version: &str,
+) -> String {
+ let mut out = String::new();
+ out.push_str(&format!("# API Changes: {old_version} → {new_version}\n\n"));
+
+ let brk: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).collect();
+ let inf: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).collect();
+
+ // Breaking changes section.
+ out.push_str("## Breaking Changes\n\n");
+ if brk.is_empty() {
+ out.push_str("_None._\n\n");
+ } else {
+ for f in &brk {
+ out.push_str(&format!("- ❌ {}\n", fmt_md(f)));
+ }
+ out.push('\n');
+ }
+
+ // Non-breaking changes section.
+ out.push_str("## Non-Breaking Changes\n\n");
+ if inf.is_empty() {
+ out.push_str("_None._\n\n");
+ } else {
+ for f in &inf {
+ out.push_str(&format!("- ✅ {}\n", fmt_md(f)));
+ }
+ out.push('\n');
+ }
+
+ // Schema changes section (table per schema).
+ if !schema_diffs.is_empty() {
+ out.push_str("## Schema Changes\n\n");
+ for d in schema_diffs {
+ out.push_str(&format!(
+ "### `{}`\n\n| Change | Property | Details |\n|--------|----------|--------|\n",
+ d.name
+ ));
+ for c in &d.changes {
+ let (icon, det) = match &c.change {
+ PropertyChangeKind::Added { ty } => ("Added", format!("new property (`{ty}`)")),
+ PropertyChangeKind::AddedRequired { ty } => {
+ ("Added", format!("new required property (`{ty}`)"))
+ }
+ PropertyChangeKind::Removed { ty } => ("Removed", format!("was `{ty}`")),
+ PropertyChangeKind::TypeChanged { old_type, new_type } => {
+ ("Changed", format!("`{old_type}` → `{new_type}`"))
+ }
+ PropertyChangeKind::RequiredChanged { old_required, new_required } => {
+ let o = if *old_required { "required" } else { "optional" };
+ let n = if *new_required { "required" } else { "optional" };
+ ("Changed", format!("{o} → {n}"))
+ }
+ };
+ out.push_str(&format!("| {icon} | `{}` | {det} |\n", c.property));
+ }
+ out.push('\n');
+ }
+ }
+
+ // Summary section.
+ out.push_str("## Summary\n\n");
+ out.push_str(&format!(
+ "- **{}** breaking change(s)\n- **{}** non-breaking change(s)\n",
+ brk.len(),
+ inf.len()
+ ));
+ if !schema_diffs.is_empty() {
+ out.push_str(&format!("- **{}** schema(s) modified\n", schema_diffs.len()));
+ }
+ out
+}
fn fmt_md(f: &DiffFinding) -> String { fmt_md_msg(&f.message) }
-fn fmt_md_msg(msg: &str) -> String { if let Some(rest) = msg.strip_prefix("IR version changed: ") { return format!("IR version changed: {rest}"); } if let Some(id) = msg.strip_prefix("operation removed: ") { return format!("Operation `{id}` removed"); } if let Some(id) = msg.strip_prefix("operation added: ") { return format!("Operation `{id}` added"); } if let Some(n) = msg.strip_prefix("schema removed: ") { return format!("Schema `{n}` removed"); } if let Some(n) = msg.strip_prefix("schema added: ") { return format!("Schema `{n}` added"); } if let Some(p) = msg.strip_prefix("new required property: ") { return format!("New required property `{p}`"); } if let Some(p) = msg.strip_prefix("new optional property: ") { return format!("New optional property `{p}`"); } if let Some(p) = msg.strip_prefix("property removed: ") { return format!("Property `{p}` removed"); } if let Some(p) = msg.strip_prefix("type changed for property: ") { return format!("Type changed for property `{p}`"); } if let Some(p) = msg.strip_prefix("new required parameter: ") { return format!("New required parameter `{p}`"); } if let Some(p) = msg.strip_prefix("parameter removed: ") { return format!("Parameter `{p}` removed"); } if let Some(p) = msg.strip_prefix("response removed: ") { return format!("Response `{p}` removed"); } if let Some(p) = msg.strip_prefix("required changed for property: ") { return format!("Required status changed for property `{p}`"); } if msg == "new required request body" { return "New required request body".into(); } let mut c = msg.chars(); match c.next() { None => String::new(), Some(ch) => { let mut s = ch.to_uppercase().to_string(); s.extend(c); s } } }
+fn fmt_md_msg(msg: &str) -> String {
+ // Recognized diff messages are rewritten into human-readable Markdown.
+ // Each prefix maps to a templated sentence; anything else is returned
+ // with its first character uppercased.
+ if let Some(rest) = msg.strip_prefix("IR version changed: ") {
+ return format!("IR version changed: {rest}");
+ }
+ if let Some(id) = msg.strip_prefix("operation removed: ") {
+ return format!("Operation `{id}` removed");
+ }
+ if let Some(id) = msg.strip_prefix("operation added: ") {
+ return format!("Operation `{id}` added");
+ }
+ if let Some(n) = msg.strip_prefix("schema removed: ") {
+ return format!("Schema `{n}` removed");
+ }
+ if let Some(n) = msg.strip_prefix("schema added: ") {
+ return format!("Schema `{n}` added");
+ }
+ if let Some(p) = msg.strip_prefix("new required property: ") {
+ return format!("New required property `{p}`");
+ }
+ if let Some(p) = msg.strip_prefix("new optional property: ") {
+ return format!("New optional property `{p}`");
+ }
+ if let Some(p) = msg.strip_prefix("property removed: ") {
+ return format!("Property `{p}` removed");
+ }
+ if let Some(p) = msg.strip_prefix("type changed for property: ") {
+ return format!("Type changed for property `{p}`");
+ }
+ if let Some(p) = msg.strip_prefix("new required parameter: ") {
+ return format!("New required parameter `{p}`");
+ }
+ if let Some(p) = msg.strip_prefix("parameter removed: ") {
+ return format!("Parameter `{p}` removed");
+ }
+ if let Some(p) = msg.strip_prefix("response removed: ") {
+ return format!("Response `{p}` removed");
+ }
+ if let Some(p) = msg.strip_prefix("required changed for property: ") {
+ return format!("Required status changed for property `{p}`");
+ }
+ if msg == "new required request body" {
+ return "New required request body".into();
+ }
+ let mut c = msg.chars();
+ match c.next() {
+ None => String::new(),
+ Some(ch) => {
+ let mut s = ch.to_uppercase().to_string();
+ s.extend(c);
+ s
+ }
+ }
+}
-pub fn format_json(findings: &[DiffFinding], schema_diffs: &[SchemaDiffDetail]) -> Result { let breaking: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).cloned().collect(); let info: Vec<_> = findings.iter().filter(|f| f.severity == DiffSeverity::Info).cloned().collect(); serde_json::to_string_pretty(&DiffJsonOutput { breaking, info, schema_diffs: schema_diffs.to_vec(), summary: DiffSummary { breaking_count: findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(), info_count: findings.iter().filter(|f| f.severity == DiffSeverity::Info).count() } }) }
+pub fn format_json(
+ findings: &[DiffFinding],
+ schema_diffs: &[SchemaDiffDetail],
+) -> Result {
+ let breaking: Vec<_> = findings
+ .iter()
+ .filter(|f| f.severity == DiffSeverity::Breaking)
+ .cloned()
+ .collect();
+ let info: Vec<_> = findings
+ .iter()
+ .filter(|f| f.severity == DiffSeverity::Info)
+ .cloned()
+ .collect();
+ serde_json::to_string_pretty(&DiffJsonOutput {
+ breaking,
+ info,
+ schema_diffs: schema_diffs.to_vec(),
+ summary: DiffSummary {
+ breaking_count: findings.iter().filter(|f| f.severity == DiffSeverity::Breaking).count(),
+ info_count: findings.iter().filter(|f| f.severity == DiffSeverity::Info).count(),
+ },
+ })
+}
#[cfg(test)]
mod tests {
diff --git a/crates/specforge-core/src/graph.rs b/crates/specforge-core/src/graph.rs
index befa49a..f7e7d91 100644
--- a/crates/specforge-core/src/graph.rs
+++ b/crates/specforge-core/src/graph.rs
@@ -62,15 +62,49 @@ fn dlbl(l: &str) -> String { l.replace('\\', "\\\\").replace('"', "\\\"").replac
fn render_mermaid(g: &Graph) -> String {
let mut o = String::from("graph TD\n");
- for n in &g.nodes { let id = mid(&n.id); match n.shape { NodeShape::Default => writeln!(o, " {id}[\"{}\"]", mlbl(&n.label)).unwrap(), NodeShape::Diamond => writeln!(o, " {id}{{\"{}\"}}", mlbl(&n.label)).unwrap() } }
- for e in &g.edges { let f = mid(&e.from); let t = mid(&e.to); match &e.label { Some(l) => writeln!(o, " {f} -->|{l}| {t}").unwrap(), None => writeln!(o, " {f} --> {t}").unwrap() } }
+ for n in &g.nodes {
+ let id = mid(&n.id);
+ match n.shape {
+ NodeShape::Default => writeln!(o, " {id}[\"{}\"]", mlbl(&n.label)).unwrap(),
+ NodeShape::Diamond => writeln!(o, " {id}{{\"{}\"}}", mlbl(&n.label)).unwrap(),
+ }
+ }
+ for e in &g.edges {
+ let f = mid(&e.from);
+ let t = mid(&e.to);
+ match &e.label {
+ Some(l) => writeln!(o, " {f} -->|{l}| {t}").unwrap(),
+ None => writeln!(o, " {f} --> {t}").unwrap(),
+ }
+ }
o
}
fn render_dot(g: &Graph, title: &str) -> String {
- let mut o = format!("digraph \"{title}\" {{\n rankdir=TB;\n node [shape=box, style=filled, fillcolor=\"#f0f0f0\"];\n edge [color=\"#555555\"];\n\n");
- for n in &g.nodes { let id = did(&n.id); let l = dlbl(&n.label); match n.shape { NodeShape::Default => writeln!(o, " {id} [label=\"{l}\"];").unwrap(), NodeShape::Diamond => writeln!(o, " {id} [label=\"{l}\", shape=diamond, fillcolor=\"#e8e0f0\"];").unwrap() } }
+ let mut o = format!(
+ "digraph \"{title}\" {{\n rankdir=TB;\n node [shape=box, style=filled, fillcolor=\"#f0f0f0\"];\n edge [color=\"#555555\"];\n\n"
+ );
+ for n in &g.nodes {
+ let id = did(&n.id);
+ let l = dlbl(&n.label);
+ match n.shape {
+ NodeShape::Default => writeln!(o, " {id} [label=\"{l}\"];").unwrap(),
+ NodeShape::Diamond => writeln!(
+ o,
+ " {id} [label=\"{l}\", shape=diamond, fillcolor=\"#e8e0f0\"];"
+ )
+ .unwrap(),
+ }
+ }
o.push('\n');
- for e in &g.edges { let f = did(&e.from); let t = did(&e.to); match &e.label { Some(l) => writeln!(o, " {f} -> {t} [label=\"{l}\"];").unwrap(), None => writeln!(o, " {f} -> {t};").unwrap() } }
- o.push_str("}\n"); o
+ for e in &g.edges {
+ let f = did(&e.from);
+ let t = did(&e.to);
+ match &e.label {
+ Some(l) => writeln!(o, " {f} -> {t} [label=\"{l}\"];").unwrap(),
+ None => writeln!(o, " {f} -> {t};").unwrap(),
+ }
+ }
+ o.push_str("}\n");
+ o
}
diff --git a/crates/specforge-core/src/ir.rs b/crates/specforge-core/src/ir.rs
index 2b44697..c6b05cb 100644
--- a/crates/specforge-core/src/ir.rs
+++ b/crates/specforge-core/src/ir.rs
@@ -33,11 +33,41 @@ impl Model { pub fn name(&self) -> &str { match self { Model::Object(o) => &o.na
#[derive(Debug, Clone, Default, serde::Serialize)]
pub struct SchemaRegistry { pub models: IndexMap }
-impl SchemaRegistry { pub fn get(&self, name: &str) -> Option<&Model> { self.models.get(name) } pub fn iter(&self) -> impl Iterator { self.models.iter() } }
+impl SchemaRegistry {
+ pub fn get(&self, name: &str) -> Option<&Model> {
+ self.models.get(name)
+ }
+ pub fn iter(&self) -> impl Iterator {
+ self.models.iter()
+ }
+}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
pub enum HttpMethod { Get, Post, Put, Patch, Delete, Head, Options }
-impl HttpMethod { pub fn as_str(self) -> &'static str { match self { HttpMethod::Get => "get", HttpMethod::Post => "post", HttpMethod::Put => "put", HttpMethod::Patch => "patch", HttpMethod::Delete => "delete", HttpMethod::Head => "head", HttpMethod::Options => "options" } } pub fn upper(self) -> &'static str { match self { HttpMethod::Get => "GET", HttpMethod::Post => "POST", HttpMethod::Put => "PUT", HttpMethod::Patch => "PATCH", HttpMethod::Delete => "DELETE", HttpMethod::Head => "HEAD", HttpMethod::Options => "OPTIONS" } } }
+impl HttpMethod {
+ pub fn as_str(self) -> &'static str {
+ match self {
+ HttpMethod::Get => "get",
+ HttpMethod::Post => "post",
+ HttpMethod::Put => "put",
+ HttpMethod::Patch => "patch",
+ HttpMethod::Delete => "delete",
+ HttpMethod::Head => "head",
+ HttpMethod::Options => "options",
+ }
+ }
+ pub fn upper(self) -> &'static str {
+ match self {
+ HttpMethod::Get => "GET",
+ HttpMethod::Post => "POST",
+ HttpMethod::Put => "PUT",
+ HttpMethod::Patch => "PATCH",
+ HttpMethod::Delete => "DELETE",
+ HttpMethod::Head => "HEAD",
+ HttpMethod::Options => "OPTIONS",
+ }
+ }
+}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub enum ParamLocation { Path, Query, Header }
diff --git a/crates/specforge-core/src/marketplace.rs b/crates/specforge-core/src/marketplace.rs
index 24fe69c..7ffd21f 100644
--- a/crates/specforge-core/src/marketplace.rs
+++ b/crates/specforge-core/src/marketplace.rs
@@ -5,6 +5,7 @@
use anyhow::{bail, Context as _};
use serde::{Deserialize, Serialize};
+use std::cmp::Reverse;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
@@ -40,6 +41,11 @@ impl MarketplaceIndex {
}
/// Return the built-in curated marketplace index bundled with this binary.
+ ///
+ /// # Panics
+ ///
+ /// Panics if the bundled `assets/marketplace-index.json` is malformed. This
+ /// is a compile-time-baked asset, so a panic here indicates a corrupt build.
pub fn built_in() -> Self {
let json = include_str!("../../../assets/marketplace-index.json");
serde_json::from_str(json).expect("bundled marketplace-index.json is valid JSON")
@@ -56,7 +62,7 @@ impl MarketplaceIndex {
map.insert(&entry.name, entry);
}
self.entries = map.into_values().cloned().collect();
- self.entries.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ self.entries.sort_by_key(|a| Reverse(a.downloads));
}
/// Search entries by a free-text query (matches name, description, tags, author).
@@ -82,7 +88,7 @@ impl MarketplaceIndex {
/// Return all entries sorted by downloads (descending).
pub fn sorted_by_downloads(&self) -> Vec<&SpecEntry> {
let mut v: Vec<&SpecEntry> = self.entries.iter().collect();
- v.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ v.sort_by_key(|a| Reverse(a.downloads));
v
}
@@ -175,6 +181,11 @@ impl PluginIndex {
}
/// Return the built-in curated plugin index bundled with this binary.
+ ///
+ /// # Panics
+ ///
+ /// Panics if the bundled `assets/plugin-index.json` is malformed. This
+ /// is a compile-time-baked asset, so a panic here indicates a corrupt build.
pub fn built_in() -> Self {
let json = include_str!("../../../assets/plugin-index.json");
serde_json::from_str(json).expect("bundled plugin-index.json is valid JSON")
@@ -191,7 +202,7 @@ impl PluginIndex {
map.insert(&entry.name, entry);
}
self.plugins = map.into_values().cloned().collect();
- self.plugins.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ self.plugins.sort_by_key(|a| Reverse(a.downloads));
}
/// Search plugins by a free-text query (matches name, description, language, author).
@@ -217,7 +228,7 @@ impl PluginIndex {
/// Return all plugins sorted by downloads (descending).
pub fn sorted_by_downloads(&self) -> Vec<&PluginEntry> {
let mut v: Vec<&PluginEntry> = self.plugins.iter().collect();
- v.sort_by(|a, b| b.downloads.cmp(&a.downloads));
+ v.sort_by_key(|a| Reverse(a.downloads));
v
}
@@ -311,7 +322,7 @@ mod tests {
#[test]
fn search_matches_name() {
- let mut index = MarketplaceIndex {
+ let index = MarketplaceIndex {
entries: vec![sample_entry("github"), sample_entry("stripe")],
};
let results = index.search("github");
diff --git a/crates/specforge-core/src/mock.rs b/crates/specforge-core/src/mock.rs
index 08f160a..934256e 100644
--- a/crates/specforge-core/src/mock.rs
+++ b/crates/specforge-core/src/mock.rs
@@ -121,7 +121,7 @@ fn parse_request_line(
) -> Option<(String, String)> {
let mut line = String::new();
reader.read_line(&mut line).ok()?;
- let mut parts = line.trim().split_whitespace();
+ let mut parts = line.split_whitespace();
let method = parts.next()?.to_uppercase();
let path = parts.next()?.to_string();
Some((method, path))
diff --git a/crates/specforge-core/src/profiler.rs b/crates/specforge-core/src/profiler.rs
index 12a19e4..625d005 100644
--- a/crates/specforge-core/src/profiler.rs
+++ b/crates/specforge-core/src/profiler.rs
@@ -365,7 +365,7 @@ pub fn format_text(report: &ProfileReport) -> String {
out.push_str(&format!(" Requests: {}\n", r.total_requests));
out.push_str(&format!(" Success/Error: {} / {}\n", r.success_count, r.error_count));
out.push_str(&format!(" Error rate: {:.1}%\n", r.error_rate * 100.0));
- out.push_str(&format!(" Latency (ms):\n"));
+ out.push_str(" Latency (ms):\n");
out.push_str(&format!(" avg: {:.1}\n", r.avg_latency_ms));
out.push_str(&format!(" p50: {:.1}\n", r.p50_latency_ms));
out.push_str(&format!(" p95: {:.1}\n", r.p95_latency_ms));
@@ -425,7 +425,7 @@ pub fn format_markdown(report: &ProfileReport) -> String {
out.push_str(&format!("**Overall throughput:** {:.1} rps\n\n", report.overall_throughput_rps));
if !report.recommendations.is_empty() {
- out.push_str(&format!("## Recommendations\n\n"));
+ out.push_str("## Recommendations\n\n");
for (i, rec) in report.recommendations.iter().enumerate() {
out.push_str(&format!("{}. {}\n", i + 1, rec));
}
diff --git a/crates/specforge-core/src/resolve.rs b/crates/specforge-core/src/resolve.rs
index cb1e284..0477429 100644
--- a/crates/specforge-core/src/resolve.rs
+++ b/crates/specforge-core/src/resolve.rs
@@ -19,7 +19,7 @@ use crate::error::ResolveError;
use crate::ir::{
Composition, CompositionKind, Discriminator, Document, EnumModel, EnumVariant, HttpMethod,
Model, ObjectModel, Operation, Parameter as IrParameter, ParamLocation, Property, RequestBody,
- Response, RetryPolicy, Scalar, SchemaRegistry, SecurityScheme, Type, Webhook, IR_VERSION,
+ Response, Scalar, SchemaRegistry, SecurityScheme, Type, Webhook, IR_VERSION,
};
/// Load and fully resolve a parsed OpenAPI document into the IR.
@@ -246,34 +246,32 @@ fn properties_from_allof_member(
ReferenceOr::Reference { reference } => {
let ref_name = ref_name(reference);
// First, try to find the schema in components and extract inline.
- if let Some(schema_or) = components.schemas.get(&ref_name) {
- if let ReferenceOr::Item(schema) = schema_or {
- if let SchemaKind::Type(OApiType::Object(ObjectType {
- properties,
- required,
- ..
- })) = &schema.schema_kind
- {
- let required_set: std::collections::HashSet =
- required.iter().cloned().collect();
- let props = properties
- .iter()
- .map(|(prop_name, prop_schema_or)| {
- let ty = ref_or_boxed_schema_to_type(prop_schema_or);
- let description = match prop_schema_or {
- ReferenceOr::Item(s) => s.schema_data.description.clone(),
- ReferenceOr::Reference { .. } => None,
- };
- Property {
- name: prop_name.clone(),
- ty,
- required: required_set.contains(prop_name),
- description,
- }
- })
- .collect();
- return Ok((props, required_set));
- }
+ if let Some(ReferenceOr::Item(schema)) = components.schemas.get(&ref_name) {
+ if let SchemaKind::Type(OApiType::Object(ObjectType {
+ properties,
+ required,
+ ..
+ })) = &schema.schema_kind
+ {
+ let required_set: std::collections::HashSet =
+ required.iter().cloned().collect();
+ let props = properties
+ .iter()
+ .map(|(prop_name, prop_schema_or)| {
+ let ty = ref_or_boxed_schema_to_type(prop_schema_or);
+ let description = match prop_schema_or {
+ ReferenceOr::Item(s) => s.schema_data.description.clone(),
+ ReferenceOr::Reference { .. } => None,
+ };
+ Property {
+ name: prop_name.clone(),
+ ty,
+ required: required_set.contains(prop_name),
+ description,
+ }
+ })
+ .collect();
+ return Ok((props, required_set));
}
}
// Fallback: check the already-built registry.
@@ -493,7 +491,7 @@ fn type_to_ir(t: &OApiType, nullable: bool) -> Type {
let item = arr
.items
.as_ref()
- .map(|i| ref_or_boxed_schema_to_type(i))
+ .map(ref_or_boxed_schema_to_type)
.unwrap_or(Type::Unknown);
Type::Array {
item: Box::new(item),
@@ -526,8 +524,8 @@ fn ref_or_boxed_schema_to_type(schema_or: &ReferenceOr>) -> Type {
/// Handle a `Box>` — used for `additionalProperties` and
/// array `items`. (openapiv3 is inconsistent about which side the Box is on.)
-fn boxed_ref_or_schema_to_type(boxed: &Box>) -> Type {
- match boxed.as_ref() {
+fn boxed_ref_or_schema_to_type(boxed: &ReferenceOr) -> Type {
+ match boxed {
ReferenceOr::Item(s) => schema_to_type(s).unwrap_or(Type::Unknown),
ReferenceOr::Reference { reference } => Type::Reference {
name: ref_name(reference),
@@ -766,14 +764,12 @@ fn resolve_responses(
body,
});
}
- if let Some(default) = &responses.default {
- if let ReferenceOr::Item(r) = default {
- out.push(Response {
- status: "default".to_string(),
- description: Some(r.description.clone()),
- body: json_body(&r.content),
- });
- }
+ if let Some(ReferenceOr::Item(r)) = &responses.default {
+ out.push(Response {
+ status: "default".to_string(),
+ description: Some(r.description.clone()),
+ body: json_body(&r.content),
+ });
}
Ok(out)
}
@@ -789,7 +785,7 @@ fn json_body(content: &IndexMap) -> Option {
content
.get("application/json")
.and_then(|m| m.schema.as_ref())
- .map(|s| ref_or_schema_to_type(s))
+ .map(ref_or_schema_to_type)
}
fn ref_or_schema_to_type(schema_or: &ReferenceOr) -> Type {
diff --git a/crates/specforge-core/src/schema31.rs b/crates/specforge-core/src/schema31.rs
index de3060e..c1c0099 100644
--- a/crates/specforge-core/src/schema31.rs
+++ b/crates/specforge-core/src/schema31.rs
@@ -88,7 +88,7 @@ mod tests {
#[test]
fn roundtrip() {
let json = serde_json::json!({"type": ["string", "null"], "const": "yes", "x-custom": "value"});
- let s = serde_json::to_value(&Schema31::from_json(&json).unwrap()).unwrap();
+ let s = serde_json::to_value(Schema31::from_json(&json).unwrap()).unwrap();
assert_eq!(s["type"], serde_json::json!(["string", "null"]));
}
}
diff --git a/crates/specforge-core/src/security.rs b/crates/specforge-core/src/security.rs
index ca38832..cc2db90 100644
--- a/crates/specforge-core/src/security.rs
+++ b/crates/specforge-core/src/security.rs
@@ -12,7 +12,7 @@ pub struct OperationSecurity { pub operation_id: String, pub method: String, pub
pub struct SecurityIssue { pub severity: String, pub message: String, pub path: String }
pub fn analyze_security(doc: &Document) -> SecurityReport {
- let schemes: Vec = doc.security.iter().map(|s| scheme_info_from_ir(s)).collect();
+ let schemes: Vec = doc.security.iter().map(scheme_info_from_ir).collect();
let has_global_auth = !doc.security.is_empty();
let operations = doc.operations.iter().map(|op| OperationSecurity {
operation_id: op.operation_id.clone(), method: op.method.as_str().to_string(), path: op.path.clone(),
@@ -42,7 +42,14 @@ pub fn analyze_security_detailed(doc: &Document, spec: &OpenAPI) -> SecurityRepo
Some(Some(r)) => { let n: Vec = r.iter().flat_map(|x| x.keys().cloned()).collect(); (true, n.first().cloned(), true) }
_ => (has_global_auth, global_names.first().cloned(), false),
};
- operations.push(OperationSecurity { operation_id: ir_op.operation_id.clone(), method: ir_op.method.as_str().to_string(), path: ir_op.path.clone(), requires_auth: req_auth, scheme: sch, has_override: ov });
+ operations.push(OperationSecurity {
+ operation_id: ir_op.operation_id.clone(),
+ method: ir_op.method.as_str().to_string(),
+ path: ir_op.path.clone(),
+ requires_auth: req_auth,
+ scheme: sch,
+ has_override: ov,
+ });
}
let mut issues = Vec::new();
analyze_common_issues(&schemes, &operations, &mut issues);
@@ -51,47 +58,198 @@ pub fn analyze_security_detailed(doc: &Document, spec: &OpenAPI) -> SecurityRepo
}
fn scheme_info_from_ir(s: &SecurityScheme) -> SecuritySchemeInfo {
- match s { SecurityScheme::HttpBearer => SecuritySchemeInfo { name: "BearerAuth".into(), kind: "bearer".into(), header: Some("Authorization".into()), bearer_format: None },
- SecurityScheme::ApiKey { header } => SecuritySchemeInfo { name: format!("ApiKey({})", header), kind: "apikey".into(), header: Some(header.clone()), bearer_format: None } }
+ match s {
+ SecurityScheme::HttpBearer => SecuritySchemeInfo {
+ name: "BearerAuth".into(),
+ kind: "bearer".into(),
+ header: Some("Authorization".into()),
+ bearer_format: None,
+ },
+ SecurityScheme::ApiKey { header } => SecuritySchemeInfo {
+ name: format!("ApiKey({})", header),
+ kind: "apikey".into(),
+ header: Some(header.clone()),
+ bearer_format: None,
+ },
+ }
}
fn scheme_info_from_raw(name: &str, s: &OApiSecurityScheme) -> SecuritySchemeInfo {
- match s { OApiSecurityScheme::HTTP { scheme, bearer_format, .. } => SecuritySchemeInfo { name: name.into(), kind: scheme.to_lowercase(), header: if scheme.eq_ignore_ascii_case("bearer") { Some("Authorization".into()) } else { None }, bearer_format: bearer_format.clone() },
- OApiSecurityScheme::APIKey { name: kn, .. } => SecuritySchemeInfo { name: name.into(), kind: "apikey".into(), header: Some(kn.clone()), bearer_format: None },
- OApiSecurityScheme::OAuth2 { .. } => SecuritySchemeInfo { name: name.into(), kind: "oauth2".into(), header: None, bearer_format: None },
- OApiSecurityScheme::OpenIDConnect { .. } => SecuritySchemeInfo { name: name.into(), kind: "openidconnect".into(), header: None, bearer_format: None } }
+ match s {
+ OApiSecurityScheme::HTTP { scheme, bearer_format, .. } => SecuritySchemeInfo {
+ name: name.into(),
+ kind: scheme.to_lowercase(),
+ header: if scheme.eq_ignore_ascii_case("bearer") {
+ Some("Authorization".into())
+ } else {
+ None
+ },
+ bearer_format: bearer_format.clone(),
+ },
+ OApiSecurityScheme::APIKey { name: kn, .. } => SecuritySchemeInfo {
+ name: name.into(),
+ kind: "apikey".into(),
+ header: Some(kn.clone()),
+ bearer_format: None,
+ },
+ OApiSecurityScheme::OAuth2 { .. } => SecuritySchemeInfo {
+ name: name.into(),
+ kind: "oauth2".into(),
+ header: None,
+ bearer_format: None,
+ },
+ OApiSecurityScheme::OpenIDConnect { .. } => SecuritySchemeInfo {
+ name: name.into(),
+ kind: "openidconnect".into(),
+ header: None,
+ bearer_format: None,
+ },
+ }
+}
+fn scheme_label(s: &SecurityScheme) -> String {
+ match s {
+ SecurityScheme::HttpBearer => "bearer".into(),
+ SecurityScheme::ApiKey { header } => format!("apikey ({})", header),
+ }
+}
+
+fn extract_scheme_names(security: &Option>) -> Vec {
+ let Some(reqs) = security else { return Vec::new() };
+ reqs.iter().flat_map(|r| r.keys().cloned()).collect()
}
-fn scheme_label(s: &SecurityScheme) -> String { match s { SecurityScheme::HttpBearer => "bearer".into(), SecurityScheme::ApiKey { header } => format!("apikey ({})", header) } }
-fn extract_scheme_names(security: &Option>) -> Vec { let Some(reqs) = security else { return Vec::new() }; reqs.iter().flat_map(|r| r.keys().cloned()).collect() }
-fn op_key(method: &str, path: &str) -> String { format!("{} {}", method.to_uppercase(), path) }
+
+fn op_key(method: &str, path: &str) -> String {
+ format!("{} {}", method.to_uppercase(), path)
+}
+
fn build_op_security_map(spec: &OpenAPI) -> BTreeMap>> {
let mut map = BTreeMap::new();
- { let paths = &spec.paths;
+ let paths = &spec.paths;
for (path, item_or) in &paths.paths {
- let item = match item_or { openapiv3::ReferenceOr::Item(i) => i, _ => continue };
- for (m, op_opt) in [("get", &item.get), ("post", &item.post), ("put", &item.put), ("patch", &item.patch), ("delete", &item.delete), ("head", &item.head), ("options", &item.options)] {
- if let Some(op) = op_opt { map.insert(format!("{} {}", m.to_uppercase(), path), op.security.clone()); }
+ let item = match item_or {
+ openapiv3::ReferenceOr::Item(i) => i,
+ _ => continue,
+ };
+ for (m, op_opt) in [
+ ("get", &item.get),
+ ("post", &item.post),
+ ("put", &item.put),
+ ("patch", &item.patch),
+ ("delete", &item.delete),
+ ("head", &item.head),
+ ("options", &item.options),
+ ] {
+ if let Some(op) = op_opt {
+ map.insert(format!("{} {}", m.to_uppercase(), path), op.security.clone());
+ }
}
- } }
+ }
map
}
-fn analyze_common_issues(schemes: &[SecuritySchemeInfo], operations: &[OperationSecurity], issues: &mut Vec) {
- if schemes.is_empty() { issues.push(SecurityIssue { severity: "warning".into(), message: "No security schemes defined in the specification".into(), path: "/".into() }); }
- for op in operations { if matches!(op.method.as_str(), "POST" | "PUT" | "PATCH" | "DELETE") && !op.requires_auth {
- issues.push(SecurityIssue { severity: "warning".into(), message: format!("Write operation {} {} does not require authentication", op.method, op.path), path: op.path.clone() }); } }
- let auth: Vec