use assert_cmd::Command;
use predicates::prelude::*;
use std::io::Write;
const OAS3: &str = "tests/fixtures/petstore_oas3.json";
const OAS2: &str = "tests/fixtures/petstore_oas2.json";
const OAS3_SCHEMA_REFS: &str = "tests/fixtures/schema_refs_oas3.json";
const OAS3_MULTI_AUTH: &str = "tests/fixtures/multi_auth_oas3.json";
const OAS2_MULTI_AUTH: &str = "tests/fixtures/multi_auth_oas2.json";
const OAS3_EXAMPLES: &str = "tests/fixtures/examples_oas3.json";
const OAS3_REF_BODY: &str = "tests/fixtures/ref_request_body_oas3.json";
const OAS3_YAML: &str = "tests/fixtures/petstore_oas3.yaml";
const OAS2_YAML: &str = "tests/fixtures/petstore_oas2.yaml";
const OAS3_SCHEMA_REFS_YAML: &str = "tests/fixtures/schema_refs_oas3.yaml";
const MULTI_TAG: &str = "tests/fixtures/multi_tag_oas3.json";
const OAS2_HTTP_SCHEME: &str = "tests/fixtures/http_scheme_oas2.json";
const REF_PARAMETER: &str = "tests/fixtures/ref_parameter.json";
const REF_PATH_ITEM: &str = "tests/fixtures/ref_path_item.json";
const TYPE_ARRAY: &str = "tests/fixtures/type_array_nullable.json";
const MISSING_RESPONSES: &str = "tests/fixtures/missing_responses.json";
const OVERRIDE_PARAM: &str = "tests/fixtures/override_param.json";
const UNKNOWN_TAG: &str = "tests/fixtures/unknown_tag.json";
const STATS_LONG_SERVICE: &str = "tests/fixtures/stats_long_service_oas3.json";
fn vimanam() -> Command {
Command::cargo_bin("vimanam").unwrap()
}
#[test]
fn version_flag_reports_crate_version() {
vimanam()
.arg("--version")
.assert()
.success()
.stdout(predicate::str::contains(env!("CARGO_PKG_VERSION")));
}
#[test]
fn summary_lists_services_and_operations() {
vimanam()
.arg(OAS3)
.assert()
.success()
.stdout(predicate::str::contains("# Petstore API"))
.stdout(predicate::str::contains("- Pets"))
.stdout(predicate::str::contains("- Store"))
.stdout(predicate::str::contains("* ListPets"));
}
#[test]
fn basic_detail_writes_endpoint_sections() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic"])
.assert()
.success()
.stdout(predicate::str::contains("### Pets_ListPets"))
.stdout(predicate::str::contains("**Operation:** GET /pets"))
.stdout(predicate::str::contains("**Operation:** POST /pets"));
}
#[test]
fn optional_request_body_is_documented() {
vimanam()
.arg(OAS3)
.args(["--detail", "standard"])
.assert()
.success()
.stdout(predicate::str::contains(
"| `requestBody` | body | No | Pet to add |",
));
}
#[test]
fn required_only_excludes_non_required_parameters() {
vimanam()
.arg(OAS3)
.args(["--detail", "standard", "--required-only"])
.assert()
.success()
.stdout(predicate::str::contains("| `petId` | path | Yes |"))
.stdout(predicate::str::contains("| `limit` |").not());
}
#[test]
fn required_path_param_is_documented() {
vimanam()
.arg(OAS3)
.args(["--detail", "standard"])
.assert()
.success()
.stdout(predicate::str::contains(
"| `petId` | path | Yes | ID of the pet |",
));
}
#[test]
fn exclude_deprecated_hides_endpoint() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic"])
.assert()
.success()
.stdout(predicate::str::contains("Store_ListOrders"));
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--exclude-deprecated"])
.assert()
.success()
.stdout(predicate::str::contains("Store_ListOrders").not());
}
#[test]
fn method_filter_excludes_other_methods() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--method-filter", "GET"])
.assert()
.success()
.stdout(predicate::str::contains("Pets_ListPets"))
.stdout(predicate::str::contains("Pets_CreatePet").not());
}
#[test]
fn method_filter_is_case_insensitive() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--method-filter", "get"])
.assert()
.success()
.stdout(predicate::str::contains("Pets_ListPets"))
.stdout(predicate::str::contains("Pets_CreatePet").not());
}
#[test]
fn service_filter_is_case_insensitive() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--service-filter", "pets"])
.assert()
.success()
.stdout(predicate::str::contains("Pets_ListPets"))
.stdout(predicate::str::contains("Store_ListOrders").not());
}
#[test]
fn path_filter_excludes_other_paths() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--path-filter", "/store"])
.assert()
.success()
.stdout(predicate::str::contains("Store_ListOrders"))
.stdout(predicate::str::contains("Pets_ListPets").not());
}
#[test]
fn include_auth_shows_servers_and_schemes() {
vimanam()
.arg(OAS3)
.arg("--include-auth")
.assert()
.success()
.stdout(predicate::str::contains(
"https://api.petstore.example.com/v1",
))
.stdout(predicate::str::contains("apiKeyAuth"));
}
#[test]
fn flat_grouping_lists_all_endpoints() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--flat"])
.assert()
.success()
.stdout(predicate::str::contains("## Endpoints"))
.stdout(predicate::str::contains("### Pets_ListPets"))
.stdout(predicate::str::contains("### Store_ListOrders"));
}
#[test]
fn oas2_spec_is_supported() {
vimanam()
.arg(OAS2)
.args(["--detail", "standard", "--include-auth"])
.assert()
.success()
.stdout(predicate::str::contains("# Petstore Legacy API"))
.stdout(predicate::str::contains(
"https://legacy.petstore.example.com/v2",
))
.stdout(predicate::str::contains("Pets_CreatePet"))
.stdout(predicate::str::contains(
"| 200 | application/json | Created |",
));
}
#[test]
fn oas2_http_scheme_is_respected() {
vimanam()
.arg(OAS2_HTTP_SCHEME)
.arg("--include-auth")
.assert()
.success()
.stdout(predicate::str::contains("http://internal.example.com/v1"))
.stdout(predicate::str::contains("https://internal.example.com").not());
}
#[test]
fn output_flag_writes_file() {
let dir = tempfile::tempdir().unwrap();
let out_path = dir.path().join("out.md");
vimanam()
.arg(OAS3)
.args(["-o", out_path.to_str().unwrap()])
.assert()
.success();
let content = std::fs::read_to_string(&out_path).unwrap();
assert!(content.contains("# Petstore API"));
}
#[test]
fn yaml_spec_is_parsed() {
vimanam()
.arg(OAS3_YAML)
.args(["--detail", "basic"])
.assert()
.success()
.stdout(predicate::str::contains("# Petstore API"))
.stdout(predicate::str::contains("### Pets_ListPets"))
.stdout(predicate::str::contains("**Operation:** GET /pets"));
}
#[test]
fn yaml_and_json_produce_identical_output() {
let render = |spec: &str| {
vimanam()
.arg(spec)
.args(["--detail", "full", "--include-schemas", "--include-auth"])
.output()
.unwrap()
.stdout
};
assert_eq!(
render(OAS3),
render(OAS3_YAML),
"YAML and JSON inputs produced different output"
);
}
#[test]
fn yaml_and_json_produce_identical_output_oas2() {
let render = |spec: &str| {
vimanam()
.arg(spec)
.args(["--detail", "full", "--include-schemas", "--include-auth"])
.output()
.unwrap()
.stdout
};
assert_eq!(
render(OAS2),
render(OAS2_YAML),
"OAS2 YAML and JSON inputs produced different output"
);
}
#[test]
fn yaml_and_json_produce_identical_output_schema_refs() {
let render = |spec: &str| {
vimanam()
.arg(spec)
.args(["--detail", "full", "--include-schemas"])
.output()
.unwrap()
.stdout
};
assert_eq!(
render(OAS3_SCHEMA_REFS),
render(OAS3_SCHEMA_REFS_YAML),
"schema_refs YAML and JSON inputs produced different output"
);
}
#[test]
fn yaml_extension_is_case_insensitive() {
let yaml = std::fs::read_to_string(OAS3_YAML).unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("spec.YAML");
std::fs::write(&path, yaml).unwrap();
vimanam()
.arg(&path)
.assert()
.success()
.stdout(predicate::str::contains("# Petstore API"));
}
#[test]
fn yaml_content_with_unknown_extension_falls_back() {
let yaml = std::fs::read_to_string(OAS3_YAML).unwrap();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("spec.txt");
std::fs::write(&path, yaml).unwrap();
vimanam()
.arg(&path)
.assert()
.success()
.stdout(predicate::str::contains("# Petstore API"));
}
#[test]
fn invalid_yaml_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("broken.yaml");
std::fs::write(&path, "openapi: \"3.0.0\"\n bad: : indentation:").unwrap();
vimanam()
.arg(&path)
.assert()
.failure()
.stderr(predicate::str::contains("Error:"));
}
#[test]
fn yaml_without_openapi_fields_fails() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notspec.yaml");
std::fs::write(&path, "hello: world\n").unwrap();
vimanam()
.arg(&path)
.assert()
.failure()
.stderr(predicate::str::contains(
"Missing 'swagger' or 'openapi' field",
));
}
#[test]
fn invalid_json_fails() {
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "this is not json").unwrap();
vimanam()
.arg(file.path())
.assert()
.failure()
.stderr(predicate::str::contains("Error:"));
}
#[test]
fn json_without_openapi_fields_fails() {
let mut file = tempfile::NamedTempFile::new().unwrap();
write!(file, "{{\"hello\": \"world\"}}").unwrap();
vimanam()
.arg(file.path())
.assert()
.failure()
.stderr(predicate::str::contains("Error:"));
}
#[test]
fn output_is_deterministic() {
let run = || {
vimanam()
.arg(OAS3)
.args([
"--detail",
"full",
"--include-schemas",
"--include-auth",
"--sort",
"none",
])
.output()
.unwrap()
.stdout
};
let first = run();
for _ in 0..4 {
assert_eq!(first, run(), "output differed between identical runs");
}
}
#[test]
fn full_detail_links_schema_refs_to_definitions() {
vimanam()
.arg(OAS3_SCHEMA_REFS)
.args(["--detail", "full", "--include-schemas"])
.assert()
.success()
.stdout(predicate::str::contains(
"| `request` | [CreatePetRequest](#schema-createpetrequest) | - | - |",
))
.stdout(predicate::str::contains(
"| `response` | [Pet](#schema-pet) | - | - |",
))
.stdout(predicate::str::contains("## Schema Definitions"))
.stdout(predicate::str::contains(
"### CreatePetRequest {#schema-createpetrequest}",
))
.stdout(predicate::str::contains(
"| `CreatePetRequest.name` | string | Yes | Pet name |",
))
.stdout(predicate::str::contains(
"| `CreatePetRequest.category` | [Category](#schema-category) | Yes |",
))
.stdout(predicate::str::contains(
"| `Category.id` | string | Yes | Category identifier |",
))
.stdout(predicate::str::contains(
"| `Pet.allOf[1].id` | string | Yes | Pet identifier |",
));
}
#[test]
fn shared_schema_is_expanded_once() {
let output = String::from_utf8(
vimanam()
.arg(OAS3_SCHEMA_REFS)
.args(["--detail", "full", "--include-schemas"])
.output()
.unwrap()
.stdout,
)
.unwrap();
let definitions = output
.matches("### CreatePetRequest {#schema-createpetrequest}")
.count();
assert_eq!(
definitions, 1,
"CreatePetRequest expanded {definitions} times"
);
}
#[test]
fn linked_mode_handles_self_reference_with_a_link() {
vimanam()
.arg(OAS3_SCHEMA_REFS)
.args(["--detail", "full", "--include-schemas"])
.assert()
.success()
.stdout(predicate::str::contains("### Node {#schema-node}"))
.stdout(predicate::str::contains(
"| `Node.next` | [Node](#schema-node) | No |",
))
.stdout(predicate::str::contains("Cycle detected").not());
}
#[test]
fn inline_schemas_expands_refs_at_each_use_site() {
vimanam()
.arg(OAS3_SCHEMA_REFS)
.args(["--detail", "full", "--include-schemas", "--inline-schemas"])
.assert()
.success()
.stdout(predicate::str::contains(
"| `request.name` | string | Yes | Pet name |",
))
.stdout(predicate::str::contains(
"| `request.category.id` | string | Yes | Category identifier |",
))
.stdout(predicate::str::contains(
"| `response.allOf[1].id` | string | Yes | Pet identifier |",
))
.stdout(predicate::str::contains("request.variant.oneOf[0]"))
.stdout(predicate::str::contains("## Schema Definitions").not());
}
#[test]
fn include_schemas_warning_uses_lowercase_detail_name() {
vimanam()
.arg(OAS3)
.args(["--detail", "standard", "--include-schemas"])
.assert()
.success()
.stderr(predicate::str::contains(
"--include-schemas has no effect at --detail standard; use --detail full.",
));
}
#[test]
fn include_schemas_at_full_detail_emits_no_warning() {
vimanam()
.arg(OAS3)
.args(["--detail", "full", "--include-schemas"])
.assert()
.success()
.stderr(predicate::str::contains("no effect").not());
}
#[test]
fn inline_schemas_without_include_schemas_warns() {
vimanam()
.arg(OAS3)
.args(["--detail", "full", "--inline-schemas"])
.assert()
.success()
.stderr(predicate::str::contains(
"--inline-schemas has no effect without --include-schemas.",
));
}
#[test]
fn required_only_at_basic_detail_warns() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--required-only"])
.assert()
.success()
.stderr(predicate::str::contains(
"--required-only has no effect at --detail basic; use --detail standard or full.",
));
}
#[test]
fn required_only_at_standard_detail_emits_no_warning() {
vimanam()
.arg(OAS3)
.args(["--detail", "standard", "--required-only"])
.assert()
.success()
.stderr(predicate::str::contains("no effect").not());
}
#[test]
fn toc_flag_is_accepted_and_last_one_wins() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--toc"])
.assert()
.success()
.stdout(predicate::str::contains("## Services"));
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--no-toc", "--toc"])
.assert()
.success()
.stdout(predicate::str::contains("## Services"));
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--toc", "--no-toc"])
.assert()
.success()
.stdout(predicate::str::contains("## Services").not());
}
#[test]
fn multi_tag_endpoint_gets_unique_anchors_per_service() {
let output = String::from_utf8(
vimanam()
.arg(MULTI_TAG)
.args(["--detail", "basic"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
output.contains("### DeletePet {#pets-delete-pets-petid}"),
"missing Pets-scoped anchor:\n{output}"
);
assert!(
output.contains("### DeletePet {#admin-delete-pets-petid}"),
"missing Admin-scoped anchor:\n{output}"
);
assert!(
output.contains("* [DeletePet](#pets-delete-pets-petid)")
&& output.contains("* [DeletePet](#admin-delete-pets-petid)"),
"TOC links do not match per-service anchors:\n{output}"
);
}
#[test]
fn multiple_security_schemes_preserve_spec_order() {
let run = || {
String::from_utf8(
vimanam()
.arg(OAS3_MULTI_AUTH)
.arg("--include-auth")
.output()
.unwrap()
.stdout,
)
.unwrap()
};
let output = run();
let zebra = output.find("zebraAuth").expect("zebraAuth missing");
let api_key = output.find("apiKeyAuth").expect("apiKeyAuth missing");
let middle = output.find("middleAuth").expect("middleAuth missing");
assert!(
zebra < api_key && api_key < middle,
"security schemes not in spec order: {output}"
);
for _ in 0..4 {
assert_eq!(output, run(), "authentication order differed between runs");
}
}
#[test]
fn oas2_security_schemes_preserve_spec_order() {
let output = String::from_utf8(
vimanam()
.arg(OAS2_MULTI_AUTH)
.arg("--include-auth")
.output()
.unwrap()
.stdout,
)
.unwrap();
let zebra = output.find("zebraAuth").expect("zebraAuth missing");
let api_key = output.find("apiKey").expect("apiKey missing");
let middle = output.find("middleAuth").expect("middleAuth missing");
assert!(
zebra < api_key && api_key < middle,
"OAS2 security schemes not in spec order: {output}"
);
}
#[test]
fn group_by_method_groups_by_http_method() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--group-by", "method"])
.assert()
.success()
.stdout(predicate::str::contains("## GET"))
.stdout(predicate::str::contains("## POST"));
}
#[test]
fn toc_order_matches_body_order() {
let output = String::from_utf8(
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--sort", "alpha"])
.output()
.unwrap()
.stdout,
)
.unwrap();
let toc_list = output
.find("[Pets_ListPets]")
.expect("ListPets TOC link missing");
let toc_create = output
.find("[Pets_CreatePet]")
.expect("CreatePet TOC link missing");
let body_list = output
.find("### Pets_ListPets")
.expect("ListPets section missing");
let body_create = output
.find("### Pets_CreatePet")
.expect("CreatePet section missing");
assert!(toc_list < toc_create, "TOC order unexpected: {output}");
assert!(body_list < body_create, "body order unexpected: {output}");
}
#[test]
fn include_examples_renders_request_and_response() {
vimanam()
.arg(OAS3_EXAMPLES)
.args(["--detail", "full", "--include-examples"])
.assert()
.success()
.stdout(predicate::str::contains("#### Examples"))
.stdout(predicate::str::contains("**Request**"))
.stdout(predicate::str::contains("\"name\": \"Fluffy\""))
.stdout(predicate::str::contains("Response `201`"))
.stdout(predicate::str::contains("\"id\": 7"));
}
#[test]
fn include_examples_only_at_full_detail() {
vimanam()
.arg(OAS3_EXAMPLES)
.args(["--detail", "standard", "--include-examples"])
.assert()
.success()
.stdout(predicate::str::contains("#### Examples").not());
}
#[test]
fn ref_request_body_is_resolved() {
vimanam()
.arg(OAS3_REF_BODY)
.args(["--detail", "standard"])
.assert()
.success()
.stdout(predicate::str::contains(
"| `requestBody` | body | Yes | Pet to add |",
));
vimanam()
.arg(OAS3_REF_BODY)
.args(["--detail", "full", "--include-schemas"])
.assert()
.success()
.stdout(predicate::str::contains("## Schema Definitions"))
.stdout(predicate::str::contains(
"| `Pet.name` | string | Yes | Pet name |",
));
}
#[test]
fn group_by_path_groups_by_path() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--group-by", "path"])
.assert()
.success()
.stdout(predicate::str::contains("## Paths"))
.stdout(predicate::str::contains("## /pets/{petId}"))
.stdout(predicate::str::contains("## /store/orders"))
.stdout(predicate::str::contains("### Pets_ListPets"))
.stdout(predicate::str::contains("### Pets_CreatePet"));
}
#[test]
fn max_tokens_steps_down_detail_level() {
vimanam()
.arg(OAS3)
.args([
"--detail",
"full",
"--include-schemas",
"--max-tokens",
"40",
])
.assert()
.success()
.stderr(predicate::str::contains("token budget"))
.stderr(predicate::str::contains("--detail summary"));
}
#[test]
fn max_tokens_keeps_detail_when_it_fits() {
vimanam()
.arg(OAS3)
.args(["--detail", "basic", "--max-tokens", "100000"])
.assert()
.success()
.stdout(predicate::str::contains("### Pets_ListPets"))
.stderr(predicate::str::is_empty());
}
#[test]
fn inline_schema_expansion_detects_ref_cycles() {
vimanam()
.arg(OAS3_SCHEMA_REFS)
.args(["--detail", "full", "--include-schemas", "--inline-schemas"])
.assert()
.success()
.stdout(predicate::str::contains(
"Cycle detected while expanding schema reference",
));
}
#[test]
fn ref_parameter_is_resolved() {
vimanam()
.arg(REF_PARAMETER)
.args(["--detail", "standard"])
.assert()
.success()
.stdout(predicate::str::contains(
"| `limit` | query | No | Max results |",
));
}
#[test]
fn path_item_ref_yields_operation() {
vimanam()
.arg(REF_PATH_ITEM)
.args(["--detail", "basic"])
.assert()
.success()
.stdout(predicate::str::contains("Things_ListThings"))
.stdout(predicate::str::contains("**Operation:** GET /things"));
}
#[test]
fn type_array_parameter_parses() {
vimanam()
.arg(TYPE_ARRAY)
.args(["--detail", "standard"])
.assert()
.success()
.stdout(predicate::str::contains(
"| `q` | query | No | Search term |",
));
}
#[test]
fn operation_missing_responses_still_parses() {
vimanam()
.arg(MISSING_RESPONSES)
.args(["--detail", "basic"])
.assert()
.success()
.stdout(predicate::str::contains("A_NoResponses"))
.stdout(predicate::str::contains("B_HasResponses"));
}
#[test]
fn duplicate_parameter_is_deduplicated() {
let output = vimanam()
.arg(OVERRIDE_PARAM)
.args(["--detail", "standard"])
.assert()
.success()
.get_output()
.stdout
.clone();
let text = String::from_utf8(output).unwrap();
let id_rows = text.matches("| `id` | path |").count();
assert_eq!(id_rows, 1, "expected a single `id` row, got {id_rows}");
assert!(text.contains("Operation-level id wins"));
}
#[test]
fn unknown_operation_tag_keeps_its_own_service() {
vimanam()
.arg(UNKNOWN_TAG)
.args(["--detail", "basic"])
.assert()
.success()
.stdout(predicate::str::contains("## Gamma"))
.stdout(predicate::str::contains("W_Get"))
.stdout(predicate::str::contains(
"## Alpha {#alpha}\n\nNo endpoints found for this service.",
));
}
#[test]
fn completions_are_generated_for_each_supported_shell() {
let shells = [
("bash", "complete -F _vimanam"),
("zsh", "#compdef vimanam"),
("fish", "complete -c vimanam"),
("powershell", "Register-ArgumentCompleter"),
("elvish", "edit:completion:arg-completer[vimanam]"),
];
for (shell, marker) in shells {
vimanam()
.args(["completions", shell])
.assert()
.success()
.stdout(predicate::str::contains(marker))
.stderr(predicate::str::is_empty());
}
vimanam()
.args(["completions", "bash"])
.assert()
.success()
.stdout(predicate::str::contains("--group-by"));
}
#[test]
fn completions_rejects_unsupported_shell() {
vimanam()
.args(["completions", "nushell"])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("invalid value 'nushell'"));
}
#[test]
fn completions_requires_a_shell() {
vimanam()
.arg("completions")
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("<SHELL>"));
}
#[test]
fn no_arguments_still_requires_input_file() {
vimanam()
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("<FILE>"));
}
#[test]
fn completions_conflicts_with_conversion_arguments() {
vimanam()
.args([OAS3, "completions", "bash"])
.assert()
.failure()
.stderr(predicate::str::contains("cannot be used with"));
}
const HYGIENE: &str = "tests/fixtures/hygiene_oas3.json";
const HYGIENE_MULTI_BODY: &str = "tests/fixtures/hygiene_multi_body_oas3.json";
#[test]
fn hygiene_report_is_appended_by_default() {
vimanam()
.arg(OAS3)
.assert()
.success()
.stdout(predicate::str::contains(
"\n---\n\n## Spec Hygiene Report\n",
))
.stdout(predicate::str::contains(
"**4 endpoints** across **2 services**",
))
.stdout(predicate::str::contains("| Deprecated | 1 |"))
.stdout(predicate::str::contains(
"### Deprecated (1)\n- `GET /store/orders`\n",
));
}
#[test]
fn no_report_suppresses_hygiene_report() {
vimanam()
.arg(OAS3)
.arg("--no-report")
.assert()
.success()
.stdout(predicate::str::contains("# Petstore API"))
.stdout(predicate::str::contains("Spec Hygiene Report").not())
.stdout(predicate::str::contains("\n---\n").not());
}
#[test]
fn hygiene_report_lists_every_check() {
let output = String::from_utf8(
vimanam()
.arg(HYGIENE)
.args(["--detail", "standard"])
.output()
.unwrap()
.stdout,
)
.unwrap();
let expected = "
---
## Spec Hygiene Report
**6 endpoints** across **2 services**
| Check | Count |
|-------|------:|
| Missing description | 1 |
| Missing operationId | 1 |
| No responses documented | 1 |
| Deprecated | 2 |
| Untagged (no service tag) | 1 |
| Duplicate operationIds | 1 |
| Parameters without description | 3 |
### Missing description (1)
- `GET /health`
### Missing operationId (1)
- `GET /health`
### No responses documented (1)
- `POST /users`
### Deprecated (2)
- `GET /ping`
- `DELETE /users/{id}`
### Untagged (no service tag) (1)
- `GET /health`
### Duplicate operationIds (1)
- `getUser`
- `DELETE /users/{id}`
- `GET /users/{id}`
### Parameters without description (3)
- `GET /users` — `limit`
- `POST /users` — `requestBody`
- `DELETE /users/{id}` — `id`
";
assert!(
output.ends_with(expected),
"report did not match.\nexpected tail:\n{expected}\nactual output:\n{output}"
);
assert!(output.starts_with("# Hygiene API\n"), "{output}");
}
#[test]
fn hygiene_report_respects_service_filter() {
vimanam()
.arg(HYGIENE)
.args(["--service-filter", "Health"])
.assert()
.success()
.stdout(predicate::str::contains(
"**1 endpoint** across **1 service**",
))
.stdout(predicate::str::contains("| Deprecated | 1 |"))
.stdout(predicate::str::contains(
"| Untagged (no service tag) | 0 |",
))
.stdout(predicate::str::contains("| Duplicate operationIds | 0 |"))
.stdout(predicate::str::contains(
"### Deprecated (1)\n- `GET /ping`\n",
))
.stdout(predicate::str::contains("GET /health").not());
}
#[test]
fn hygiene_report_respects_exclude_deprecated() {
vimanam()
.arg(HYGIENE)
.arg("--exclude-deprecated")
.assert()
.success()
.stdout(predicate::str::contains(
"**4 endpoints** across **1 service**",
))
.stdout(predicate::str::contains("| Deprecated | 0 |"))
.stdout(predicate::str::contains("| Duplicate operationIds | 0 |"))
.stdout(predicate::str::contains(
"| Parameters without description | 2 |",
))
.stdout(predicate::str::contains("### Deprecated").not())
.stdout(predicate::str::contains("### Duplicate operationIds").not());
}
#[test]
fn hygiene_report_is_appended_under_max_tokens() {
vimanam()
.arg(OAS3)
.args(["--detail", "full", "--max-tokens", "40"])
.assert()
.success()
.stderr(predicate::str::contains("token budget"))
.stdout(predicate::str::contains("## Spec Hygiene Report"));
}
#[test]
fn hygiene_report_is_written_to_output_file() {
let dir = tempfile::tempdir().unwrap();
let out_path = dir.path().join("out.md");
vimanam()
.arg(HYGIENE)
.args(["-o", out_path.to_str().unwrap()])
.assert()
.success();
let content = std::fs::read_to_string(&out_path).unwrap();
assert!(content.starts_with("# Hygiene API\n"), "{content}");
assert!(
content.contains("\n---\n\n## Spec Hygiene Report\n"),
"{content}"
);
assert!(
content.contains("| Duplicate operationIds | 1 |"),
"{content}"
);
}
#[test]
fn hygiene_report_is_deterministic() {
let run = || {
vimanam()
.arg(HYGIENE)
.args(["--detail", "full", "--sort", "none"])
.output()
.unwrap()
.stdout
};
let first = run();
for _ in 0..4 {
assert_eq!(first, run(), "report differed between identical runs");
}
}
#[test]
fn hygiene_report_counts_multi_media_type_body_once() {
let output = String::from_utf8(
vimanam()
.arg(HYGIENE_MULTI_BODY)
.args(["--detail", "standard"])
.output()
.unwrap()
.stdout,
)
.unwrap();
assert!(
output.contains("`requestBody (application/json)` | body |"),
"{output}"
);
assert!(
output.contains("`requestBody (application/xml)` | body |"),
"{output}"
);
assert!(
output.contains("| Parameters without description | 1 |"),
"{output}"
);
assert!(
output.ends_with(
"### Parameters without description (1)\n- `POST /items` — `requestBody (application/json)`\n"
),
"{output}"
);
assert_eq!(
output.matches("- `POST /items` — `requestBody").count(),
1,
"{output}"
);
}
#[test]
fn hygiene_report_is_separated_from_body_by_one_blank_line() {
for detail in ["summary", "basic", "standard", "full"] {
vimanam()
.arg(OAS3)
.args(["--detail", detail])
.assert()
.success()
.stdout(predicate::str::contains(
"\n\n---\n\n## Spec Hygiene Report",
))
.stdout(predicate::str::contains("\n\n\n---").not());
}
}
#[test]
fn hygiene_report_counts_multi_tag_endpoint_once() {
vimanam()
.arg(MULTI_TAG)
.assert()
.success()
.stdout(predicate::str::contains(
"**1 endpoint** across **2 services**",
));
}
#[test]
fn hygiene_report_on_empty_filtered_set_is_all_zero() {
let output = String::from_utf8(
vimanam()
.arg(OAS3)
.args(["--path-filter", "/nope"])
.output()
.unwrap()
.stdout,
)
.unwrap();
let expected = "\
## Spec Hygiene Report
**0 endpoints** across **0 services**
| Check | Count |
|-------|------:|
| Missing description | 0 |
| Missing operationId | 0 |
| No responses documented | 0 |
| Deprecated | 0 |
| Untagged (no service tag) | 0 |
| Duplicate operationIds | 0 |
| Parameters without description | 0 |
";
assert!(output.ends_with(expected), "{output}");
assert!(!output.contains("\n### "), "{output}");
}
fn stats_output(args: &[&str]) -> String {
let output = vimanam().args(args).output().unwrap();
assert!(output.status.success(), "{output:?}");
assert!(
output.stderr.is_empty(),
"stats wrote to stderr: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8(output.stdout).unwrap()
}
fn stats_rows(table: &str) -> Vec<(String, usize, usize)> {
let mut lines = table.lines();
assert_eq!(
lines
.next()
.map(|line| line.split_whitespace().collect::<Vec<_>>()),
Some(vec!["SERVICE", "ENDPOINTS", "~TOKENS"]),
"{table}"
);
lines
.map(|line| {
let mut fields = line.split_whitespace().rev();
let tokens: usize = fields.next().unwrap().parse().unwrap();
let endpoints: usize = fields.next().unwrap().parse().unwrap();
let name: Vec<&str> = fields.rev().collect();
(name.join(" "), endpoints, tokens)
})
.collect()
}
#[test]
fn stats_lists_each_service_with_endpoint_counts_and_total() {
let table = stats_output(&[OAS3, "--stats"]);
let rows = stats_rows(&table);
assert_eq!(rows.len(), 3, "{table}");
assert_eq!((rows[0].0.as_str(), rows[0].1), ("Pets", 3));
assert_eq!((rows[1].0.as_str(), rows[1].1), ("Store", 1));
assert_eq!((rows[2].0.as_str(), rows[2].1), ("TOTAL", 4));
for (name, _, tokens) in &rows {
assert!(*tokens > 0, "{name} has no token estimate: {table}");
}
assert!(table.ends_with('\n') && !table.ends_with("\n\n"), "{table}");
assert!(!table.contains("\n\n"), "{table}");
assert!(!table.contains('#') && !table.contains('|'), "{table}");
}
#[test]
fn stats_columns_are_aligned() {
let table = stats_output(&[OAS3, "--stats"]);
let lines: Vec<&str> = table.lines().collect();
assert_eq!(lines[0], "SERVICE ENDPOINTS ~TOKENS");
assert!(lines[1].starts_with("Pets 3 "), "{table}");
assert!(lines[2].starts_with("Store 1 "), "{table}");
assert!(lines[3].starts_with("TOTAL 4 "), "{table}");
let width = lines[0].len();
assert!(lines.iter().all(|line| line.len() == width), "{table}");
}
#[test]
fn stats_is_deterministic() {
let first = stats_output(&[OAS3, "--stats", "--detail", "full", "--include-schemas"]);
let second = stats_output(&[OAS3, "--stats", "--detail", "full", "--include-schemas"]);
assert_eq!(first, second);
}
#[test]
fn stats_tokens_grow_with_detail_level() {
let summary = stats_rows(&stats_output(&[OAS3, "--stats", "--detail", "summary"]));
let full = stats_rows(&stats_output(&[
OAS3,
"--stats",
"--detail",
"full",
"--include-schemas",
]));
for (row, summary_row) in full.iter().zip(&summary) {
assert_eq!(row.0, summary_row.0);
assert!(
row.2 > summary_row.2,
"{}: full {} <= summary {}",
row.0,
row.2,
summary_row.2
);
}
}
#[test]
fn stats_respects_service_filter() {
let rows = stats_rows(&stats_output(&[
OAS3,
"--stats",
"--service-filter",
"store",
]));
assert_eq!(rows.len(), 2);
assert_eq!((rows[0].0.as_str(), rows[0].1), ("Store", 1));
assert_eq!((rows[1].0.as_str(), rows[1].1), ("TOTAL", 1));
}
#[test]
fn stats_counts_multi_tag_endpoint_in_each_service_but_once_in_total() {
let rows = stats_rows(&stats_output(&[MULTI_TAG, "--stats"]));
assert_eq!(rows.len(), 3);
assert_eq!((rows[0].0.as_str(), rows[0].1), ("Pets", 1));
assert_eq!((rows[1].0.as_str(), rows[1].1), ("Admin", 1));
assert_eq!((rows[2].0.as_str(), rows[2].1), ("TOTAL", 1));
}
#[test]
fn stats_respects_exclude_deprecated() {
let all = stats_rows(&stats_output(&[HYGIENE, "--stats"]));
assert_eq!(all.len(), 3);
assert_eq!((all[0].0.as_str(), all[0].1), ("Users", 5));
assert_eq!((all[1].0.as_str(), all[1].1), ("Health", 1));
assert_eq!((all[2].0.as_str(), all[2].1), ("TOTAL", 6));
let live = stats_rows(&stats_output(&[HYGIENE, "--stats", "--exclude-deprecated"]));
assert_eq!(live.len(), 2);
assert_eq!((live[0].0.as_str(), live[0].1), ("Users", 4));
assert_eq!((live[1].0.as_str(), live[1].1), ("TOTAL", 4));
}
#[test]
fn stats_on_empty_filtered_set_is_header_and_zero_total() {
let table = stats_output(&[OAS3, "--stats", "--path-filter", "/nope"]);
assert_eq!(
table,
"\
SERVICE ENDPOINTS ~TOKENS
TOTAL 0 0
"
);
}
#[test]
fn stats_never_includes_hygiene_report() {
let table = stats_output(&[OAS3, "--stats"]);
assert!(!table.contains("Spec Hygiene Report"), "{table}");
assert!(!table.contains("---"), "{table}");
let with_flag = stats_output(&[OAS3, "--stats", "--no-report"]);
assert_eq!(table, with_flag);
}
fn rendered_tokens(args: &[&str]) -> usize {
let output = vimanam().args(args).arg("--no-report").output().unwrap();
assert!(output.status.success(), "{output:?}");
String::from_utf8(output.stdout)
.unwrap()
.chars()
.count()
.div_ceil(4)
}
fn assert_stats_match_real_render(render_args: &[&str]) {
let mut stats_args = vec![OAS3, "--stats"];
stats_args.extend_from_slice(render_args);
let table = stats_output(&stats_args);
let rows = stats_rows(&table);
assert!(rows.len() > 1, "{table}");
for (name, _, tokens) in &rows {
let mut args = vec![OAS3];
args.extend_from_slice(render_args);
if name != "TOTAL" {
args.extend_from_slice(&["--service-filter", name]);
}
assert_eq!(
*tokens,
rendered_tokens(&args),
"{name} estimate disagrees with a real render: {table}"
);
}
}
#[test]
fn stats_row_tokens_match_real_render() {
assert_stats_match_real_render(&["--detail", "full", "--include-schemas"]);
}
#[test]
fn stats_row_tokens_match_real_render_under_flat() {
assert_stats_match_real_render(&["--flat", "--detail", "standard"]);
}
#[test]
fn stats_pads_service_column_to_a_long_name() {
let table = stats_output(&[STATS_LONG_SERVICE, "--stats"]);
let lines: Vec<&str> = table.lines().collect();
let name = "Long Service Name";
assert_eq!(lines.len(), 3, "{table}");
assert!(
lines[0].starts_with(&format!(
"{:<width$} ENDPOINTS",
"SERVICE",
width = name.len()
)),
"{table}"
);
assert!(lines[1].starts_with(&format!("{name} ")), "{table}");
assert!(
lines[2].starts_with(&format!("{:<width$} ", "TOTAL", width = name.len())),
"{table}"
);
let width = lines[0].chars().count();
assert!(
lines.iter().all(|line| line.chars().count() == width),
"{table}"
);
let rows = stats_rows(&table);
assert_eq!((rows[0].0.as_str(), rows[0].1), (name, 2));
assert_eq!((rows[1].0.as_str(), rows[1].1), ("TOTAL", 2));
}
#[test]
fn stats_conflicts_with_output() {
vimanam()
.args([OAS3, "--stats", "-o", "stats.txt"])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("cannot be used with"));
}
#[test]
fn stats_conflicts_with_max_tokens() {
vimanam()
.args([OAS3, "--stats", "--max-tokens", "100"])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("cannot be used with"));
}
const DIFF_OLD: &str = "tests/fixtures/diff_old_oas3.json";
const DIFF_NEW: &str = "tests/fixtures/diff_new_oas3.json";
const DIFF_SUMMARY: &str =
"**Summary:** 1 endpoint added, 1 removed, 4 changed; 4 breaking, 8 non-breaking, 1 to review";
fn diff_run(args: &[&str]) -> (String, i32) {
let output = vimanam().arg("diff").args(args).output().unwrap();
(
String::from_utf8(output.stdout).unwrap(),
output.status.code().unwrap(),
)
}
fn load_json(path: &str) -> serde_json::Value {
serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap()
}
fn write_spec(dir: &tempfile::TempDir, name: &str, spec: &serde_json::Value) -> String {
let path = dir.path().join(name);
std::fs::write(&path, serde_json::to_string_pretty(spec).unwrap()).unwrap();
path.to_str().unwrap().to_string()
}
#[test]
fn diff_reports_added_removed_changed() {
let (stdout, code) = diff_run(&[DIFF_OLD, DIFF_NEW]);
assert_eq!(code, 0, "{stdout}");
assert!(
stdout.starts_with("# API Diff: Widgets API 1.0.0 → 1.1.0\n\n"),
"{stdout}"
);
assert!(stdout.contains(&format!("\n{DIFF_SUMMARY}\n")), "{stdout}");
let breaking = "\
## Breaking changes (4)
| Change | Endpoint | Detail |
|--------|----------|--------|
| Parameter newly required | `GET /widgets/{id}` | `fields` (query) |
| Response removed | `GET /widgets/{id}` | 404 |
| operationId changed | `DELETE /widgets/{id}` | `Widgets_DeleteWidget` → `Widgets_RemoveWidget` |
| Endpoint removed | `GET /legacy` | was deprecated |
";
assert!(stdout.contains(breaking), "{stdout}");
let non_breaking = "\
## Non-breaking changes (8)
| Change | Endpoint | Detail |
|--------|----------|--------|
| Response schema changed | `GET /widgets` | 200 `/properties/pricing` added |
| Response schema changed | `GET /widgets` | 200 `/properties/pricing` added to `required` |
| Response schema changed | `POST /widgets` | 201 `/properties/pricing` added |
| Response schema changed | `POST /widgets` | 201 `/properties/pricing` added to `required` |
| Response schema changed | `GET /widgets/{id}` | 200 `/properties/pricing` added |
| Response schema changed | `GET /widgets/{id}` | 200 `/properties/pricing` added to `required` |
| Marked deprecated | `DELETE /widgets/{id}` | - |
| Endpoint added | `GET /widgets/{id}/history` | - |
";
assert!(stdout.contains(non_breaking), "{stdout}");
assert!(!stdout.contains("## Deltas"), "{stdout}");
}
#[test]
fn diff_detects_schema_drift_behind_shared_ref() {
let old = load_json(DIFF_OLD);
let new = load_json(DIFF_NEW);
assert_eq!(
old.pointer("/paths/~1widgets/get"),
new.pointer("/paths/~1widgets/get"),
"fixture precondition: the GET /widgets operation object must be identical"
);
assert_ne!(
old.pointer("/components/schemas/Widget"),
new.pointer("/components/schemas/Widget"),
"fixture precondition: the shared Widget schema must differ"
);
let (stdout, _) = diff_run(&[DIFF_OLD, DIFF_NEW]);
assert!(
stdout.contains(
"| Response schema changed | `GET /widgets` | 200 `/properties/pricing` added |"
),
"{stdout}"
);
assert!(
stdout.contains(
"| Response schema changed | `GET /widgets` | 200 `/properties/pricing` added to `required` |"
),
"{stdout}"
);
}
#[test]
fn diff_classifies_format_change_as_review() {
let (stdout, _) = diff_run(&[DIFF_OLD, DIFF_NEW]);
let review = "\
## Needs review (1)
| Change | Endpoint | Detail |
|--------|----------|--------|
| Request schema changed | `POST /widgets` | `/properties/weight/format` changed `float` → `double` |
";
assert!(stdout.contains(review), "{stdout}");
}
#[test]
fn diff_ignores_description_only_changes() {
let old = load_json(DIFF_OLD);
let new = load_json(DIFF_NEW);
assert_ne!(
old.pointer("/paths/~1widgets/post/parameters/0/description"),
new.pointer("/paths/~1widgets/post/parameters/0/description"),
"fixture precondition: X-Trace description differs"
);
let (stdout, _) = diff_run(&[DIFF_OLD, DIFF_NEW]);
assert!(!stdout.contains("X-Trace"), "{stdout}");
assert!(!stdout.contains("/properties/name"), "{stdout}");
assert!(!stdout.contains("description"), "{stdout}");
}
#[test]
fn diff_fail_on_breaking_exits_3() {
let (_, code) = diff_run(&[DIFF_OLD, DIFF_NEW]);
assert_eq!(code, 0);
let (stdout, code) = diff_run(&[DIFF_OLD, DIFF_NEW, "--fail-on-breaking"]);
assert_eq!(code, 3, "{stdout}");
assert!(stdout.contains("## Breaking changes (4)"), "{stdout}");
assert!(stdout.contains("## Needs review (1)"), "{stdout}");
}
#[test]
fn diff_fail_on_breaking_passes_without_breaking_changes() {
let dir = tempfile::tempdir().unwrap();
let mut edited = load_json(DIFF_OLD);
*edited
.pointer_mut("/components/schemas/WidgetInput/properties/weight/format")
.unwrap() = serde_json::json!("double");
edited
.pointer_mut("/paths/~1widgets/get/parameters")
.unwrap()
.as_array_mut()
.unwrap()
.push(serde_json::json!({
"name": "offset",
"in": "query",
"schema": {"type": "integer"}
}));
let edited_path = write_spec(&dir, "edited.json", &edited);
let (stdout, code) = diff_run(&[DIFF_OLD, &edited_path, "--fail-on-breaking"]);
assert_eq!(code, 0, "{stdout}");
assert!(!stdout.contains("## Breaking changes"), "{stdout}");
assert!(
stdout.contains("| Parameter added | `GET /widgets` | `offset` (query), optional |"),
"{stdout}"
);
assert!(stdout.contains("## Needs review (1)"), "{stdout}");
}
#[test]
fn diff_no_changes_exits_0_and_says_so() {
let (stdout, code) = diff_run(&[DIFF_OLD, DIFF_OLD]);
assert_eq!(code, 0);
assert_eq!(
stdout,
"# API Diff: Widgets API 1.0.0 → 1.0.0\n\n**Summary:** No changes.\n"
);
let (stdout, code) = diff_run(&[OAS3_SCHEMA_REFS, OAS3_SCHEMA_REFS, "--fail-on-breaking"]);
assert_eq!(code, 0);
assert!(stdout.ends_with("**Summary:** No changes.\n"), "{stdout}");
}
#[test]
fn diff_report_shows_hygiene_and_token_deltas() {
let (stdout, code) = diff_run(&[DIFF_OLD, DIFF_NEW, "--report"]);
assert_eq!(code, 0, "{stdout}");
let deltas_header = "\
## Deltas
| Check | Old | New | Δ |
|-------|----:|----:|--:|
";
assert!(stdout.contains(deltas_header), "{stdout}");
assert!(
stdout.contains("| Missing description | 0 | 1 | +1 |"),
"{stdout}"
);
assert!(stdout.contains("| Deprecated | 1 | 1 | 0 |"), "{stdout}");
assert!(
stdout.contains("| Parameters without description | 0 | 0 | 0 |"),
"{stdout}"
);
let token_line = stdout
.lines()
.find(|line| line.starts_with("Token estimate (--detail full --include-schemas): "))
.unwrap_or_else(|| panic!("no token estimate line in:\n{stdout}"));
let numbers: Vec<usize> = token_line
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.map(|s| s.parse().unwrap())
.collect();
let (old_tokens, new_tokens) = (numbers[0], numbers[1]);
assert!(new_tokens > old_tokens, "{token_line}");
assert!(
token_line.ends_with(&format!("(+{})", new_tokens - old_tokens)),
"{token_line}"
);
assert!(stdout.ends_with(&format!("{token_line}\n")), "{stdout}");
let (stdout, _) = diff_run(&[DIFF_OLD, DIFF_OLD, "--report"]);
assert!(
stdout.contains("**Summary:** No changes.\n\n## Deltas\n"),
"{stdout}"
);
}
#[test]
fn diff_output_is_deterministic() {
let first = diff_run(&[DIFF_OLD, DIFF_NEW, "--report"]);
for _ in 0..4 {
assert_eq!(first, diff_run(&[DIFF_OLD, DIFF_NEW, "--report"]));
}
}
#[test]
fn diff_writes_to_output_file() {
let dir = tempfile::tempdir().unwrap();
let out_path = dir.path().join("diff.md");
vimanam()
.args(["diff", DIFF_OLD, DIFF_NEW, "-o", out_path.to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::is_empty());
let content = std::fs::read_to_string(&out_path).unwrap();
let (stdout, _) = diff_run(&[DIFF_OLD, DIFF_NEW]);
assert_eq!(content, stdout);
assert!(content.starts_with("# API Diff: Widgets API"), "{content}");
vimanam()
.args([
"diff",
DIFF_OLD,
DIFF_NEW,
"--fail-on-breaking",
"-o",
out_path.to_str().unwrap(),
])
.assert()
.failure()
.code(3);
}
#[test]
fn diff_rejects_conversion_flags() {
vimanam()
.args([OAS3, "diff", DIFF_OLD, DIFF_NEW])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("cannot be used with"));
vimanam()
.args(["--detail", "full", "diff", DIFF_OLD, DIFF_NEW])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("cannot be used with"));
}
#[test]
fn diff_requires_two_specs() {
vimanam()
.args(["diff", DIFF_OLD])
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("<NEW>"));
}
#[test]
fn diff_unparseable_spec_exits_1() {
vimanam()
.args([
"diff",
DIFF_OLD,
"tests/fixtures/does_not_exist.json",
"--fail-on-breaking",
])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("Failed to parse OpenAPI file"));
}
#[test]
fn diff_works_for_oas2() {
let (stdout, code) = diff_run(&[OAS2, OAS2]);
assert_eq!(code, 0);
assert_eq!(
stdout,
"# API Diff: Petstore Legacy API 2.0.0 → 2.0.0\n\n**Summary:** No changes.\n"
);
let dir = tempfile::tempdir().unwrap();
let mut old = load_json(OAS2);
old.pointer_mut("/paths/~1pets/post/parameters")
.unwrap()
.as_array_mut()
.unwrap()
.push(serde_json::json!({"name": "limit", "in": "query", "type": "integer"}));
let mut new = old.clone();
*new.pointer_mut("/paths/~1pets/post/parameters/1/type")
.unwrap() = serde_json::json!("string");
*new.pointer_mut("/definitions/Pet").unwrap() = serde_json::json!({
"type": "object",
"properties": {"name": {"type": "string"}}
});
let old_path = write_spec(&dir, "old.json", &old);
let new_path = write_spec(&dir, "new.json", &new);
let (stdout, code) = diff_run(&[&old_path, &new_path, "--fail-on-breaking"]);
assert_eq!(code, 3, "{stdout}");
assert!(
stdout.contains(
"| Parameter schema changed | `POST /pets` | `limit` (query) `/type` changed `integer` → `string` |"
),
"{stdout}"
);
assert!(
stdout.contains("| Request schema changed | `POST /pets` | `/properties/name` added |"),
"{stdout}"
);
assert!(
stdout
.contains("| Response schema changed | `POST /pets` | 200 `/properties/name` added |"),
"{stdout}"
);
}