Compare commits
10 Commits
4ac8659ebd
...
4340dc4301
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4340dc4301 | ||
|
|
8c311fb049 | ||
|
|
59389d9272 | ||
|
|
a10792be48 | ||
|
|
0b9a8a36c5 | ||
|
|
75d9344b44 | ||
|
|
a36997982a | ||
|
|
8455bca71b | ||
|
|
cc04772792 | ||
|
|
aae9a33d36 |
File diff suppressed because one or more lines are too long
@@ -43,7 +43,7 @@ lint:
|
||||
script:
|
||||
- rustup component add rustfmt clippy
|
||||
- cargo fmt --check
|
||||
- cargo clippy -- -D warnings
|
||||
- cargo clippy --all-targets -- -D warnings
|
||||
rules:
|
||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||
- if: $CI_COMMIT_BRANCH
|
||||
|
||||
499
Cargo.lock
generated
499
Cargo.lock
generated
@@ -11,6 +11,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "alloca"
|
||||
version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.5"
|
||||
@@ -119,6 +128,28 @@ version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-rs"
|
||||
version = "1.15.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256"
|
||||
dependencies = [
|
||||
"aws-lc-sys",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aws-lc-sys"
|
||||
version = "0.37.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"cmake",
|
||||
"dunce",
|
||||
"fs_extra",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base64"
|
||||
version = "0.22.1"
|
||||
@@ -197,9 +228,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cesu8"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
@@ -281,10 +320,10 @@ version = "4.5.55"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -293,6 +332,15 @@ version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||
|
||||
[[package]]
|
||||
name = "cmake"
|
||||
version = "0.1.57"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.4"
|
||||
@@ -308,6 +356,26 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "combine"
|
||||
version = "4.6.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
@@ -325,25 +393,24 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion"
|
||||
version = "0.5.1"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
||||
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||
dependencies = [
|
||||
"alloca",
|
||||
"anes",
|
||||
"cast",
|
||||
"ciborium",
|
||||
"clap",
|
||||
"criterion-plot",
|
||||
"is-terminal",
|
||||
"itertools",
|
||||
"num-traits",
|
||||
"once_cell",
|
||||
"oorandom",
|
||||
"page_size",
|
||||
"plotters",
|
||||
"rayon",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"tinytemplate",
|
||||
"walkdir",
|
||||
@@ -351,9 +418,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "criterion-plot"
|
||||
version = "0.5.0"
|
||||
version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
||||
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||
dependencies = [
|
||||
"cast",
|
||||
"itertools",
|
||||
@@ -445,9 +512,15 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dunce"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.15.0"
|
||||
@@ -522,6 +595,12 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "futures"
|
||||
version = "0.3.31"
|
||||
@@ -578,7 +657,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -706,24 +785,12 @@ version = "0.16.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "hermit-abi"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
@@ -806,7 +873,6 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -992,17 +1058,6 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is-terminal"
|
||||
version = "0.4.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
|
||||
dependencies = [
|
||||
"hermit-abi",
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
@@ -1011,9 +1066,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "itertools"
|
||||
version = "0.10.5"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
||||
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||
dependencies = [
|
||||
"either",
|
||||
]
|
||||
@@ -1024,6 +1079,38 @@ version = "1.0.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
|
||||
dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
version = "0.1.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.85"
|
||||
@@ -1164,6 +1251,12 @@ version = "11.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
@@ -1171,10 +1264,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "papergrid"
|
||||
version = "0.13.0"
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2b0f8def1f117e13c895f3eda65a7b5650688da29d6ad04635f61bc7b92eebd"
|
||||
checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "papergrid"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6978128c8b51d8f4080631ceb2302ab51e32cc6e8615f735ee2f83fd269ae3f1"
|
||||
dependencies = [
|
||||
"bytecount",
|
||||
"fnv",
|
||||
@@ -1305,7 +1408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1327,7 +1430,7 @@ dependencies = [
|
||||
"proc-macro-error-attr2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1378,7 +1481,7 @@ dependencies = [
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -1390,6 +1493,7 @@ version = "0.11.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
@@ -1399,7 +1503,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
@@ -1509,7 +1613,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
"libredox",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1543,9 +1647,9 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"bytes",
|
||||
@@ -1564,9 +1668,9 @@ dependencies = [
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
@@ -1579,7 +1683,6 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1621,14 +1724,26 @@ version = "0.23.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-native-certs"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63"
|
||||
dependencies = [
|
||||
"openssl-probe",
|
||||
"rustls-pki-types",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.14.0"
|
||||
@@ -1639,12 +1754,40 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
|
||||
dependencies = [
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"jni",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted",
|
||||
@@ -1683,12 +1826,44 @@ dependencies = [
|
||||
"winapi-util",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.27"
|
||||
@@ -1722,7 +1897,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1740,11 +1915,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1869,22 +2044,11 @@ dependencies = [
|
||||
"sha2",
|
||||
"tabled",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "1.0.109"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.115"
|
||||
@@ -1913,30 +2077,31 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tabled"
|
||||
version = "0.17.0"
|
||||
version = "0.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6709222f3973137427ce50559cd564dc187a95b9cfe01613d2f4e93610e510a"
|
||||
checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d"
|
||||
dependencies = [
|
||||
"papergrid",
|
||||
"tabled_derive",
|
||||
"testing_table",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tabled_derive"
|
||||
version = "0.9.0"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "931be476627d4c54070a1f3a9739ccbfec9b36b39815106a20cce2243bbcefe1"
|
||||
checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846"
|
||||
dependencies = [
|
||||
"heck 0.4.1",
|
||||
"heck",
|
||||
"proc-macro-error2",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 1.0.109",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1958,13 +2123,42 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
||||
|
||||
[[package]]
|
||||
name = "testing_table"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0f8daae29995a24f65619e19d8d31dea5b389f3d853d8bf297bbf607cd0014cc"
|
||||
dependencies = [
|
||||
"unicode-width",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||
dependencies = [
|
||||
"thiserror-impl 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "2.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||
dependencies = [
|
||||
"thiserror-impl",
|
||||
"thiserror-impl 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror-impl"
|
||||
version = "1.0.69"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1975,7 +2169,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2038,7 +2232,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2066,44 +2260,42 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.8.23"
|
||||
version = "1.0.1+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
checksum = "bbe30f93627849fa362d4a602212d41bb237dc2bd0f8ba0b2ce785012e124220"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_write",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
name = "toml_datetime"
|
||||
version = "1.0.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
@@ -2345,7 +2537,7 @@ dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
@@ -2382,9 +2574,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-streams"
|
||||
version = "0.4.2"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
||||
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"js-sys",
|
||||
@@ -2426,10 +2618,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -2486,7 +2678,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2497,7 +2689,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2524,6 +2716,15 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.45.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
|
||||
dependencies = [
|
||||
"windows-targets 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -2551,6 +2752,21 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
|
||||
dependencies = [
|
||||
"windows_aarch64_gnullvm 0.42.2",
|
||||
"windows_aarch64_msvc 0.42.2",
|
||||
"windows_i686_gnu 0.42.2",
|
||||
"windows_i686_msvc 0.42.2",
|
||||
"windows_x86_64_gnu 0.42.2",
|
||||
"windows_x86_64_gnullvm 0.42.2",
|
||||
"windows_x86_64_msvc 0.42.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-targets"
|
||||
version = "0.52.6"
|
||||
@@ -2584,6 +2800,12 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -2596,6 +2818,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -2608,6 +2836,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -2632,6 +2866,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||
|
||||
[[package]]
|
||||
name = "windows_i686_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -2644,6 +2884,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnu"
|
||||
version = "0.52.6"
|
||||
@@ -2656,6 +2902,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_gnullvm"
|
||||
version = "0.52.6"
|
||||
@@ -2668,6 +2920,12 @@ version = "0.53.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.42.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
|
||||
|
||||
[[package]]
|
||||
name = "windows_x86_64_msvc"
|
||||
version = "0.52.6"
|
||||
@@ -2685,9 +2943,6 @@ name = "winnow"
|
||||
version = "0.7.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
@@ -2705,7 +2960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"wit-parser",
|
||||
]
|
||||
|
||||
@@ -2716,10 +2971,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"heck 0.5.0",
|
||||
"heck",
|
||||
"indexmap",
|
||||
"prettyplease",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
"wasm-metadata",
|
||||
"wit-bindgen-core",
|
||||
"wit-component",
|
||||
@@ -2735,7 +2990,7 @@ dependencies = [
|
||||
"prettyplease",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
"wit-bindgen-core",
|
||||
"wit-bindgen-rust",
|
||||
]
|
||||
@@ -2802,7 +3057,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -2823,7 +3078,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2843,7 +3098,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
@@ -2883,7 +3138,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.115",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -12,19 +12,19 @@ directories = "6"
|
||||
fs2 = "0.4"
|
||||
futures = "0.3"
|
||||
regex = "1"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls", "stream"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
sha2 = "0.10"
|
||||
tabled = "0.17"
|
||||
tabled = "0.20"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
toml = "0.8"
|
||||
toml = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "2"
|
||||
criterion = "0.5"
|
||||
criterion = "0.8"
|
||||
mockito = "1"
|
||||
predicates = "3"
|
||||
proptest = "1"
|
||||
|
||||
342
README.md
Normal file
342
README.md
Normal file
@@ -0,0 +1,342 @@
|
||||
# swagger-cli
|
||||
|
||||
A fast, cache-first CLI for exploring OpenAPI specifications. Built for engineering teams and AI agents.
|
||||
|
||||
**Key properties:**
|
||||
- Query latency: <50ms on cached specs (index-backed, no raw spec parsing)
|
||||
- First query: <2s including fetch + index build
|
||||
- Robot mode: 100% structured JSON output with versioned schema contract
|
||||
- Exit codes: Consistent, actionable, concurrency-safe (14 error types, codes 2-16)
|
||||
- Offline-capable: all queries work without network after initial fetch
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cargo install --path .
|
||||
```
|
||||
|
||||
Requires Rust 2024 edition.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Fetch and cache a spec
|
||||
swagger-cli fetch https://petstore3.swagger.io/api/v3/openapi.json --alias petstore
|
||||
|
||||
# List endpoints
|
||||
swagger-cli list petstore
|
||||
|
||||
# Search across the API
|
||||
swagger-cli search petstore "pet"
|
||||
|
||||
# Show endpoint details
|
||||
swagger-cli show petstore "/pet/{petId}" --method GET
|
||||
|
||||
# Browse schemas
|
||||
swagger-cli schemas petstore
|
||||
|
||||
# Machine-readable output (for agents)
|
||||
swagger-cli list petstore --robot
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### fetch
|
||||
|
||||
Download and cache an OpenAPI spec.
|
||||
|
||||
```bash
|
||||
swagger-cli fetch <URL> --alias <NAME> [OPTIONS]
|
||||
```
|
||||
|
||||
`<URL>` accepts HTTP/HTTPS URLs, `file://` paths, relative paths, or `-` for stdin.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--alias <NAME>` | Required. Must match `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` |
|
||||
| `-H, --header <HEADER>` | Additional HTTP header (repeatable, format: `"Name: Value"`) |
|
||||
| `--bearer <TOKEN>` | Bearer token for Authorization header |
|
||||
| `--auth-profile <NAME>` | Auth profile name from config.toml |
|
||||
| `--force` | Overwrite existing alias |
|
||||
| `--timeout-ms <N>` | HTTP request timeout in milliseconds (default: 10000) |
|
||||
| `--max-bytes <N>` | Maximum response size in bytes (default: 26214400 / ~25 MB) |
|
||||
| `--retries <N>` | Retries on transient errors (default: 2) |
|
||||
| `--allow-private-host <HOST>` | Bypass SSRF protection for this host (repeatable) |
|
||||
| `--allow-insecure-http` | Permit plain HTTP |
|
||||
| `--resolve-external-refs` | Fetch and inline external `$ref` entries |
|
||||
| `--ref-allow-host <HOST>` | Allowed host for external ref fetching (repeatable, required with `--resolve-external-refs`) |
|
||||
| `--ref-max-depth <N>` | Maximum chain depth for transitive external refs (default: 10) |
|
||||
| `--ref-max-bytes <N>` | Maximum total bytes fetched for external refs (default: 10485760 / ~10 MB) |
|
||||
|
||||
### list
|
||||
|
||||
List endpoints from a cached spec.
|
||||
|
||||
```bash
|
||||
swagger-cli list [ALIAS] [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all-aliases` | Query across every cached alias |
|
||||
| `-m, --method <METHOD>` | Filter by HTTP method (case-insensitive) |
|
||||
| `-t, --tag <TAG>` | Filter by tag |
|
||||
| `-p, --path <PATTERN>` | Filter by path pattern (regex) |
|
||||
| `--sort <FIELD>` | Sort order: path (default), method, or tag |
|
||||
| `-n, --limit <N>` | Maximum results (default: 50) |
|
||||
| `-a, --all` | Show all results (no limit) |
|
||||
|
||||
### show
|
||||
|
||||
Display details of a specific endpoint.
|
||||
|
||||
```bash
|
||||
swagger-cli show <ALIAS> <PATH> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-m, --method <METHOD>` | HTTP method to show. Required when path has multiple methods |
|
||||
| `--expand-refs` | Expand `$ref` entries inline |
|
||||
| `--max-depth <N>` | Maximum depth for ref expansion (default: 3) |
|
||||
|
||||
Output includes the full operation object: summary, description, tags, operationId, security, parameters (path/query/header/cookie), request body, and response schemas with status codes.
|
||||
|
||||
Circular references are detected and annotated with `{"$circular_ref": "..."}`.
|
||||
|
||||
### search
|
||||
|
||||
Full-text search across endpoints and schemas.
|
||||
|
||||
```bash
|
||||
swagger-cli search [ALIAS] <QUERY> [OPTIONS]
|
||||
```
|
||||
|
||||
When using `--all-aliases`, the first positional argument is treated as the query.
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all-aliases` | Search across every cached alias |
|
||||
| `--case-sensitive` | Case-sensitive matching |
|
||||
| `--exact` | Match query as exact phrase |
|
||||
| `--in <FIELDS>` | Fields to search (comma-separated): all (default), paths, descriptions, schemas |
|
||||
| `--limit <N>` | Maximum results (default: 20) |
|
||||
|
||||
Scoring is tokenized with field weights (path > summary > description) and term coverage boost, quantized to basis points for deterministic output.
|
||||
|
||||
### schemas
|
||||
|
||||
Browse component schemas.
|
||||
|
||||
```bash
|
||||
swagger-cli schemas <ALIAS> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--name <PATTERN>` | Filter schema names by regex |
|
||||
| `--list` | List schemas (default mode) |
|
||||
| `--show <NAME>` | Show a specific schema by exact name |
|
||||
| `--expand-refs` | Expand `$ref` entries inline (show mode only) |
|
||||
| `--max-depth <N>` | Maximum depth for ref expansion (default: 3) |
|
||||
|
||||
### tags
|
||||
|
||||
List tags from a cached spec with endpoint counts.
|
||||
|
||||
```bash
|
||||
swagger-cli tags <ALIAS>
|
||||
```
|
||||
|
||||
### aliases
|
||||
|
||||
Manage spec aliases.
|
||||
|
||||
```bash
|
||||
swagger-cli aliases [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--list` | List all aliases (default) |
|
||||
| `--show <ALIAS>` | Full details (URL, version, size, stats) |
|
||||
| `--rename <OLD> <NEW>` | Rename an alias |
|
||||
| `--delete <ALIAS>` | Delete an alias and cached files |
|
||||
| `--set-default <ALIAS>` | Set the default alias (used when ALIAS is omitted in other commands) |
|
||||
|
||||
### sync
|
||||
|
||||
Re-fetch and update cached specs.
|
||||
|
||||
```bash
|
||||
swagger-cli sync [ALIAS] [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--all` | Sync all cached specs |
|
||||
| `--dry-run` | Check for changes without writing |
|
||||
| `--force` | Re-fetch regardless of cache freshness |
|
||||
| `--details` | Include per-item change lists in output (capped at 200 items) |
|
||||
| `--auth <PROFILE>` | Auth profile name from config |
|
||||
| `--jobs <N>` | Maximum concurrent sync jobs, `--all` only (default: 4) |
|
||||
| `--per-host <N>` | Maximum concurrent requests per host, `--all` only (default: 2) |
|
||||
| `--max-failures <N>` | Abort after N alias failures, `--all` only |
|
||||
| `--resume` | Resume a previously interrupted `--all` sync |
|
||||
| `--allow-private-host <HOST>` | Bypass SSRF protection for this host (repeatable) |
|
||||
|
||||
Uses ETag + Last-Modified for conditional fetches. Respects `Retry-After` headers with exponential backoff. Per-alias progress checkpoints enable resumable execution.
|
||||
|
||||
### diff
|
||||
|
||||
Compare two spec versions (structural diff).
|
||||
|
||||
```bash
|
||||
swagger-cli diff <LEFT> <RIGHT> [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--fail-on <LEVEL>` | Exit non-zero if changes at this level: `breaking` |
|
||||
| `--details` | Include per-item change descriptions |
|
||||
|
||||
Output covers added/removed/modified endpoints and schemas with a summary and breaking-change flag. Useful as a CI gate for API contract changes.
|
||||
|
||||
### doctor
|
||||
|
||||
Check cache health and diagnose issues.
|
||||
|
||||
```bash
|
||||
swagger-cli doctor [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--fix` | Attempt auto-repair |
|
||||
| `--alias <ALIAS>` | Check a specific alias only |
|
||||
|
||||
Checks: directory permissions, meta.json generation + index_hash validation, index pointer validity, stale caches (30+ days), index version compatibility, and torn/partial cache state.
|
||||
|
||||
Fix modes: (1) rebuild index from raw, (2) reconstruct meta from raw + index, (3) delete alias only as last resort.
|
||||
|
||||
### cache
|
||||
|
||||
Manage the spec cache.
|
||||
|
||||
```bash
|
||||
swagger-cli cache [OPTIONS]
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--stats` | Show cache statistics (default) |
|
||||
| `--path` | Print the cache directory path |
|
||||
| `--prune-stale` | Remove aliases older than the stale threshold |
|
||||
| `--prune-threshold <DAYS>` | Days before an alias is stale (default: 90) |
|
||||
| `--max-total-mb <N>` | LRU eviction until total size is under this limit |
|
||||
| `--dry-run` | Preview without deleting |
|
||||
|
||||
## Global Options
|
||||
|
||||
Every command accepts these flags:
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
| `--robot` | Structured JSON output (auto-detects non-TTY) |
|
||||
| `--pretty` | Pretty-print JSON output |
|
||||
| `--network <MODE>` | `auto` (default), `online-only`, or `offline` |
|
||||
| `--config <PATH>` | Path to config file (or `SWAGGER_CLI_CONFIG` env var) |
|
||||
|
||||
## Robot Mode
|
||||
|
||||
All commands support `--robot` for structured JSON output. Success:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"data": { ... },
|
||||
"meta": {
|
||||
"schema_version": 1,
|
||||
"tool_version": "0.1.0",
|
||||
"command": "list",
|
||||
"duration_ms": 12
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Errors emit JSON to stderr:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "ALIAS_NOT_FOUND",
|
||||
"message": "Alias not found: myapi",
|
||||
"suggestion": "Run 'swagger-cli aliases --list' to see available aliases."
|
||||
},
|
||||
"meta": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
## Exit Codes
|
||||
|
||||
| Code | Error | Suggestion |
|
||||
|------|-------|------------|
|
||||
| 0 | Success | |
|
||||
| 2 | `USAGE_ERROR` | Run `swagger-cli --help` |
|
||||
| 4 | `NETWORK_ERROR` | Check connectivity or use `--network offline` |
|
||||
| 5 | `INVALID_SPEC` | Verify URL points to valid OpenAPI 3.x JSON/YAML |
|
||||
| 6 | `ALIAS_EXISTS` | Use `--force` to overwrite |
|
||||
| 7 | `AUTH_ERROR` | Check auth profile in config.toml |
|
||||
| 8 | `ALIAS_NOT_FOUND` | Run `aliases --list` to see available |
|
||||
| 9 | `CACHE_LOCKED` | Wait or check for stale locks |
|
||||
| 10 | `CACHE_ERROR` | Run `doctor --fix` |
|
||||
| 11 | `CONFIG_ERROR` | Check config file, run `doctor` |
|
||||
| 12 | `IO_ERROR` | File system error |
|
||||
| 13 | `JSON_ERROR` | Invalid JSON/YAML parsing |
|
||||
| 14 | `CACHE_INTEGRITY` | Run `doctor --fix` or re-fetch |
|
||||
| 15 | `OFFLINE_MODE` | Use `--network auto` or `online-only` |
|
||||
| 16 | `POLICY_BLOCKED` | Use `--allow-private-host` or `--allow-insecure-http` |
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file location: `~/.config/swagger-cli/config.toml` (or set `SWAGGER_CLI_CONFIG`).
|
||||
|
||||
```toml
|
||||
[auth_profiles.corp_internal]
|
||||
credential_source = "env:MY_API_TOKEN"
|
||||
auth_type = "bearer"
|
||||
|
||||
[auth_profiles.api_key]
|
||||
credential_source = "env:API_KEY"
|
||||
auth_type = "api_key"
|
||||
header = "X-API-Key"
|
||||
```
|
||||
|
||||
`credential_source` supports `env:VAR_NAME` and `file:/path/to/token`.
|
||||
|
||||
## Cache Layout
|
||||
|
||||
Specs are stored per-alias under `~/.cache/swagger-cli/aliases/{alias}/`:
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `raw.source` | Original upstream bytes (YAML or JSON, lossless provenance) |
|
||||
| `raw.json` | Canonical normalized JSON (all queries operate on this) |
|
||||
| `index.json` | Precomputed query index (endpoints, schemas, tags, search data) |
|
||||
| `meta.json` | Fetch metadata, written last as commit marker |
|
||||
|
||||
The four-file protocol provides crash consistency: `meta.json` is written last with a generation counter and content hash, so a torn write is always detectable by `doctor`.
|
||||
|
||||
## Security
|
||||
|
||||
- **SSRF protection**: Blocks loopback, private ranges, and multicast addresses by default. Override per-host with `--allow-private-host`.
|
||||
- **DNS rebinding mitigation**: Validates resolved IP after redirects (5 redirect limit).
|
||||
- **Auth redaction**: Tokens are never printed in output or logs.
|
||||
- **Streaming byte limit**: `--max-bytes` prevents OOM on oversized or malicious specs.
|
||||
- **Network policy**: `--network offline` guarantees zero outbound connections.
|
||||
- **External refs are opt-in**: `--resolve-external-refs` must be explicitly set with an allowlist of permitted hosts.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
190
benches/perf.rs
190
benches/perf.rs
@@ -4,9 +4,13 @@
|
||||
//! First run establishes baseline. Subsequent runs report regressions.
|
||||
|
||||
use std::fs;
|
||||
use std::hint::black_box;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use criterion::{Criterion, criterion_group, criterion_main};
|
||||
use swagger_cli::core::indexer::build_index;
|
||||
use swagger_cli::core::search::{SearchEngine, SearchOptions};
|
||||
use swagger_cli::core::spec::SpecIndex;
|
||||
|
||||
fn fixture_path(name: &str) -> PathBuf {
|
||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
||||
@@ -22,34 +26,32 @@ fn load_petstore_json() -> serde_json::Value {
|
||||
serde_json::from_slice(&bytes).expect("failed to parse petstore.json")
|
||||
}
|
||||
|
||||
fn load_petstore_index() -> SpecIndex {
|
||||
let raw_json = load_petstore_json();
|
||||
build_index(&raw_json, "sha256:bench", 1).expect("failed to build index")
|
||||
}
|
||||
|
||||
fn bench_json_parse(c: &mut Criterion) {
|
||||
let path = fixture_path("petstore.json");
|
||||
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
|
||||
|
||||
c.bench_function("parse_petstore_json", |b| {
|
||||
b.iter(|| {
|
||||
let _: serde_json::Value = serde_json::from_slice(&bytes).expect("parse failed");
|
||||
let v: serde_json::Value =
|
||||
serde_json::from_slice(black_box(&bytes)).expect("parse failed");
|
||||
black_box(v);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_build_index(c: &mut Criterion) {
|
||||
fn bench_build_index_real(c: &mut Criterion) {
|
||||
let raw_json = load_petstore_json();
|
||||
|
||||
c.bench_function("build_index_petstore", |b| {
|
||||
c.bench_function("build_index_real", |b| {
|
||||
b.iter(|| {
|
||||
// Simulate endpoint extraction (mirrors build_index core logic)
|
||||
if let Some(paths) = raw_json.get("paths").and_then(|v| v.as_object()) {
|
||||
let mut endpoints = Vec::new();
|
||||
for (path, methods) in paths {
|
||||
if let Some(methods_map) = methods.as_object() {
|
||||
for (method, _op) in methods_map {
|
||||
endpoints.push((path.clone(), method.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(!endpoints.is_empty());
|
||||
}
|
||||
let index =
|
||||
build_index(black_box(&raw_json), "sha256:bench", 1).expect("build_index failed");
|
||||
black_box(index);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -62,8 +64,9 @@ fn bench_hash_computation(c: &mut Criterion) {
|
||||
b.iter(|| {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(&bytes);
|
||||
let _result = format!("sha256:{:x}", hasher.finalize());
|
||||
hasher.update(black_box(&bytes));
|
||||
let result = format!("sha256:{:x}", hasher.finalize());
|
||||
black_box(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -73,44 +76,131 @@ fn bench_json_pointer_resolution(c: &mut Criterion) {
|
||||
|
||||
c.bench_function("json_pointer_resolution", |b| {
|
||||
b.iter(|| {
|
||||
let _ = raw_json
|
||||
let a = raw_json
|
||||
.pointer("/paths/~1pets/get/summary")
|
||||
.map(|v| v.as_str());
|
||||
let _ = raw_json
|
||||
let b_val = raw_json
|
||||
.pointer("/components/schemas/Pet")
|
||||
.map(|v| v.is_object());
|
||||
let _ = raw_json.pointer("/info/title").map(|v| v.as_str());
|
||||
let c_val = raw_json.pointer("/info/title").map(|v| v.as_str());
|
||||
black_box((a, b_val, c_val));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_search_scoring(c: &mut Criterion) {
|
||||
let raw_json = load_petstore_json();
|
||||
fn bench_search_real(c: &mut Criterion) {
|
||||
let index = load_petstore_index();
|
||||
let engine = SearchEngine::new(&index);
|
||||
let opts = SearchOptions::default();
|
||||
|
||||
// Pre-extract summaries for search simulation
|
||||
let mut summaries: Vec<String> = Vec::new();
|
||||
if let Some(paths) = raw_json.get("paths").and_then(|v| v.as_object()) {
|
||||
for (_path, methods) in paths {
|
||||
if let Some(methods_map) = methods.as_object() {
|
||||
for (_method, op) in methods_map {
|
||||
if let Some(summary) = op.get("summary").and_then(|v| v.as_str()) {
|
||||
summaries.push(summary.to_lowercase());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.bench_function("search_scoring_pet", |b| {
|
||||
let query = "pet";
|
||||
c.bench_function("search_real_pet", |b| {
|
||||
b.iter(|| {
|
||||
let mut matches = 0u32;
|
||||
for summary in &summaries {
|
||||
if summary.contains(query) {
|
||||
matches += 1;
|
||||
}
|
||||
}
|
||||
assert!(matches > 0);
|
||||
let results = engine.search(black_box("pet"), &opts);
|
||||
black_box(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_search_multi_term(c: &mut Criterion) {
|
||||
let index = load_petstore_index();
|
||||
let engine = SearchEngine::new(&index);
|
||||
let opts = SearchOptions::default();
|
||||
|
||||
c.bench_function("search_real_multi_term", |b| {
|
||||
b.iter(|| {
|
||||
let results = engine.search(black_box("list all pets"), &opts);
|
||||
black_box(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_search_case_insensitive(c: &mut Criterion) {
|
||||
let index = load_petstore_index();
|
||||
let engine = SearchEngine::new(&index);
|
||||
let opts = SearchOptions {
|
||||
case_sensitive: false,
|
||||
..SearchOptions::default()
|
||||
};
|
||||
|
||||
c.bench_function("search_real_case_insensitive", |b| {
|
||||
b.iter(|| {
|
||||
let results = engine.search(black_box("PET"), &opts);
|
||||
black_box(results);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_normalize_json(c: &mut Criterion) {
|
||||
let path = fixture_path("petstore.json");
|
||||
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
|
||||
|
||||
c.bench_function("normalize_json_input", |b| {
|
||||
b.iter(|| {
|
||||
let result = swagger_cli::core::indexer::normalize_to_json(
|
||||
black_box(&bytes),
|
||||
swagger_cli::core::indexer::Format::Json,
|
||||
)
|
||||
.expect("normalize failed");
|
||||
black_box(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_normalize_and_parse_pipeline(c: &mut Criterion) {
|
||||
let path = fixture_path("petstore.json");
|
||||
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
|
||||
|
||||
// New way: normalize returns both bytes and value (one parse)
|
||||
c.bench_function("pipeline_normalize_new", |b| {
|
||||
b.iter(|| {
|
||||
let (json_bytes, value) = swagger_cli::core::indexer::normalize_to_json(
|
||||
black_box(&bytes),
|
||||
swagger_cli::core::indexer::Format::Json,
|
||||
)
|
||||
.expect("normalize failed");
|
||||
black_box((&json_bytes, &value));
|
||||
});
|
||||
});
|
||||
|
||||
// Old way simulation: parse to validate + copy, then parse again
|
||||
c.bench_function("pipeline_normalize_old", |b| {
|
||||
b.iter(|| {
|
||||
// Step 1: old normalize_to_json did: parse(validate), copy bytes
|
||||
let _: serde_json::Value =
|
||||
serde_json::from_slice(black_box(&bytes)).expect("validate failed");
|
||||
let json_bytes = bytes.to_vec();
|
||||
// Step 2: caller would parse again
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_slice(&json_bytes).expect("parse failed");
|
||||
black_box((&json_bytes, &value));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_detect_format_json_no_hints(c: &mut Criterion) {
|
||||
let path = fixture_path("petstore.json");
|
||||
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
|
||||
|
||||
c.bench_function("detect_format_json_no_hints", |b| {
|
||||
b.iter(|| {
|
||||
let fmt = swagger_cli::core::indexer::detect_format(black_box(&bytes), None, None);
|
||||
black_box(fmt);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_detect_format_with_hint(c: &mut Criterion) {
|
||||
let path = fixture_path("petstore.json");
|
||||
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
|
||||
|
||||
c.bench_function("detect_format_with_content_type", |b| {
|
||||
b.iter(|| {
|
||||
let fmt = swagger_cli::core::indexer::detect_format(
|
||||
black_box(&bytes),
|
||||
None,
|
||||
Some("application/json"),
|
||||
);
|
||||
black_box(fmt);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -118,9 +208,15 @@ fn bench_search_scoring(c: &mut Criterion) {
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_json_parse,
|
||||
bench_build_index,
|
||||
bench_build_index_real,
|
||||
bench_hash_computation,
|
||||
bench_json_pointer_resolution,
|
||||
bench_search_scoring,
|
||||
bench_search_real,
|
||||
bench_search_multi_term,
|
||||
bench_search_case_insensitive,
|
||||
bench_normalize_json,
|
||||
bench_normalize_and_parse_pipeline,
|
||||
bench_detect_format_json_no_hints,
|
||||
bench_detect_format_with_hint,
|
||||
);
|
||||
criterion_main!(benches);
|
||||
|
||||
@@ -304,16 +304,22 @@ fn cmd_rename(names: &[String], robot: bool, start: Instant) -> Result<(), Swagg
|
||||
))
|
||||
})?;
|
||||
|
||||
// Update meta.json alias field
|
||||
// Update meta.json alias field -- propagate errors so the cache
|
||||
// doesn't end up with a stale alias name in metadata.
|
||||
let meta_path = new_dir.join("meta.json");
|
||||
if let Ok(bytes) = std::fs::read(&meta_path)
|
||||
&& let Ok(mut meta) = serde_json::from_slice::<CacheMetadata>(&bytes)
|
||||
{
|
||||
let meta_bytes = std::fs::read(&meta_path).map_err(|e| {
|
||||
SwaggerCliError::Cache(format!("failed to read meta.json after rename: {e}"))
|
||||
})?;
|
||||
let mut meta: CacheMetadata = serde_json::from_slice(&meta_bytes).map_err(|e| {
|
||||
SwaggerCliError::Cache(format!("failed to parse meta.json after rename: {e}"))
|
||||
})?;
|
||||
meta.alias = new.clone();
|
||||
if let Ok(updated_bytes) = serde_json::to_vec_pretty(&meta) {
|
||||
let _ = std::fs::write(&meta_path, updated_bytes);
|
||||
}
|
||||
}
|
||||
let updated_bytes = serde_json::to_vec_pretty(&meta).map_err(|e| {
|
||||
SwaggerCliError::Cache(format!("failed to serialize meta.json after rename: {e}"))
|
||||
})?;
|
||||
std::fs::write(&meta_path, updated_bytes).map_err(|e| {
|
||||
SwaggerCliError::Cache(format!("failed to write meta.json after rename: {e}"))
|
||||
})?;
|
||||
|
||||
// Update config if old was the default
|
||||
let cfg_path = config_path(None);
|
||||
|
||||
@@ -32,8 +32,8 @@ pub struct Args {
|
||||
#[arg(long)]
|
||||
pub prune_stale: bool,
|
||||
|
||||
/// Days before an alias is considered stale (default: 90)
|
||||
#[arg(long, default_value_t = 90)]
|
||||
/// Days before an alias is considered stale (default: 30, matching config)
|
||||
#[arg(long, default_value_t = 30)]
|
||||
pub prune_threshold: u32,
|
||||
|
||||
/// Evict least-recently-used aliases until total size is under this limit (MB)
|
||||
@@ -290,13 +290,13 @@ fn execute_prune(args: &Args, robot: bool, start: Instant) -> Result<(), Swagger
|
||||
"cache",
|
||||
start.elapsed(),
|
||||
);
|
||||
} else if stale.is_empty() {
|
||||
} else if pruned.is_empty() {
|
||||
println!(
|
||||
"No stale aliases (threshold: {} days).",
|
||||
args.prune_threshold
|
||||
);
|
||||
} else {
|
||||
println!("Pruned {} stale alias(es).", stale.len());
|
||||
println!("Pruned {} stale alias(es).", pruned.len());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -79,6 +79,26 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
||||
|
||||
let has_breaking = result.summary.has_breaking;
|
||||
|
||||
// CI gate: check breaking changes before any output to avoid
|
||||
// contradictory success-JSON-then-error in robot mode.
|
||||
if args.fail_on.as_deref() == Some("breaking") && has_breaking {
|
||||
if robot_mode {
|
||||
let err = SwaggerCliError::Usage(
|
||||
"Breaking changes detected (use --fail-on to control this check)".into(),
|
||||
);
|
||||
robot::robot_error(
|
||||
err.code(),
|
||||
&err.to_string(),
|
||||
err.suggestion(),
|
||||
"diff",
|
||||
duration,
|
||||
);
|
||||
}
|
||||
return Err(SwaggerCliError::Usage(
|
||||
"Breaking changes detected (use --fail-on to control this check)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
if robot_mode {
|
||||
let output = DiffOutput {
|
||||
left: args.left.clone(),
|
||||
@@ -145,13 +165,6 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
||||
}
|
||||
}
|
||||
|
||||
// CI gate: exit non-zero on breaking changes when requested
|
||||
if args.fail_on.as_deref() == Some("breaking") && has_breaking {
|
||||
return Err(SwaggerCliError::Usage(
|
||||
"Breaking changes detected (use --fail-on to control this check)".into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -344,12 +344,19 @@ fn try_fix_alias(cm: &CacheManager, alias: &str) -> Result<Vec<String>, Vec<Stri
|
||||
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
|
||||
let start = Instant::now();
|
||||
|
||||
// Load config
|
||||
// Load config -- doctor should work even when config is missing or
|
||||
// corrupt, so fall back to defaults and report a warning.
|
||||
let cfg_path = config_path(None);
|
||||
let config = Config::load(&cfg_path)?;
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
let config = match Config::load(&cfg_path) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(e) => {
|
||||
warnings.push(format!("could not load config (using defaults): {e}"));
|
||||
Config::default()
|
||||
}
|
||||
};
|
||||
|
||||
// Check config dir exists
|
||||
let mut warnings: Vec<String> = Vec::new();
|
||||
if let Some(parent) = cfg_path.parent()
|
||||
&& !parent.exists()
|
||||
{
|
||||
|
||||
@@ -290,8 +290,7 @@ async fn fetch_inner(
|
||||
Format::Yaml => "yaml",
|
||||
};
|
||||
|
||||
let json_bytes = normalize_to_json(&raw_bytes, format)?;
|
||||
let mut value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
|
||||
let (_json_bytes, mut value) = normalize_to_json(&raw_bytes, format)?;
|
||||
|
||||
// External ref resolution (optional)
|
||||
if args.resolve_external_refs {
|
||||
@@ -322,8 +321,11 @@ async fn fetch_inner(
|
||||
// Re-serialize the (possibly bundled) value to get the final json_bytes
|
||||
let json_bytes = serde_json::to_vec(&value)?;
|
||||
|
||||
// Compute content hash for indexing
|
||||
let content_hash = compute_hash(&raw_bytes);
|
||||
// Compute content hash from the final json_bytes (post-resolution), not
|
||||
// the original raw_bytes. When external refs are resolved, the stored
|
||||
// content differs from the original fetch, so the hash must match what
|
||||
// is actually written to the cache.
|
||||
let content_hash = compute_hash(&json_bytes);
|
||||
|
||||
// Determine generation: if overwriting, increment previous generation
|
||||
let previous_generation = if args.force && cm.alias_exists(&args.alias) {
|
||||
|
||||
@@ -388,13 +388,37 @@ async fn execute_all_aliases(args: &Args, robot_mode: bool) -> Result<(), Swagge
|
||||
|
||||
let filtered_count = all_entries.len();
|
||||
|
||||
// Sort: alias ASC, path ASC, method_rank ASC
|
||||
// Sort: alias first for grouping, then apply user's --sort preference
|
||||
match args.sort.as_str() {
|
||||
"method" => {
|
||||
all_entries.sort_by(|a, b| {
|
||||
a.alias
|
||||
.cmp(&b.alias)
|
||||
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
});
|
||||
}
|
||||
"tag" => {
|
||||
all_entries.sort_by(|a, b| {
|
||||
let tag_a = a.tags.first().map(String::as_str).unwrap_or("");
|
||||
let tag_b = b.tags.first().map(String::as_str).unwrap_or("");
|
||||
a.alias
|
||||
.cmp(&b.alias)
|
||||
.then_with(|| tag_a.cmp(tag_b))
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
||||
});
|
||||
}
|
||||
// "path" or default
|
||||
_ => {
|
||||
all_entries.sort_by(|a, b| {
|
||||
a.alias
|
||||
.cmp(&b.alias)
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Limit ----
|
||||
if !args.all {
|
||||
|
||||
@@ -4,6 +4,7 @@ pub mod diff;
|
||||
pub mod doctor;
|
||||
pub mod fetch;
|
||||
pub mod list;
|
||||
pub mod robot_docs;
|
||||
pub mod schemas;
|
||||
pub mod search;
|
||||
pub mod show;
|
||||
@@ -21,10 +22,14 @@ pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
|
||||
/// Output machine-readable JSON
|
||||
#[arg(long, global = true)]
|
||||
/// Output machine-readable JSON (alias: --json)
|
||||
#[arg(long, global = true, visible_alias = "json")]
|
||||
pub robot: bool,
|
||||
|
||||
/// Force human-readable output (overrides TTY auto-detection and env var)
|
||||
#[arg(long, global = true, conflicts_with = "robot")]
|
||||
pub no_robot: bool,
|
||||
|
||||
/// Pretty-print JSON output
|
||||
#[arg(long, global = true)]
|
||||
pub pretty: bool,
|
||||
@@ -44,12 +49,15 @@ pub enum Commands {
|
||||
Fetch(fetch::Args),
|
||||
|
||||
/// List endpoints from a cached spec
|
||||
#[command(visible_alias = "ls")]
|
||||
List(list::Args),
|
||||
|
||||
/// Show details of a specific endpoint
|
||||
#[command(visible_alias = "info")]
|
||||
Show(show::Args),
|
||||
|
||||
/// Search endpoints by keyword
|
||||
#[command(visible_alias = "find")]
|
||||
Search(search::Args),
|
||||
|
||||
/// List or show schemas from a cached spec
|
||||
@@ -72,4 +80,8 @@ pub enum Commands {
|
||||
|
||||
/// Compare two versions of a spec
|
||||
Diff(diff::Args),
|
||||
|
||||
/// Machine-readable documentation for AI agents
|
||||
#[command(visible_alias = "docs")]
|
||||
RobotDocs(robot_docs::Args),
|
||||
}
|
||||
|
||||
611
src/cli/robot_docs.rs
Normal file
611
src/cli/robot_docs.rs
Normal file
@@ -0,0 +1,611 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use clap::Args as ClapArgs;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::errors::SwaggerCliError;
|
||||
use crate::output::robot;
|
||||
|
||||
/// Machine-readable documentation for AI agents
|
||||
#[derive(Debug, ClapArgs)]
|
||||
pub struct Args {
|
||||
/// Topic: guide (default), commands, exit-codes, workflows
|
||||
#[arg(default_value = "guide")]
|
||||
pub topic: String,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types — guide
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct GuideOutput {
|
||||
tool: &'static str,
|
||||
version: &'static str,
|
||||
about: &'static str,
|
||||
robot_activation: RobotActivation,
|
||||
commands: Vec<CommandBrief>,
|
||||
exit_codes: Vec<(u8, &'static str)>,
|
||||
quick_start: &'static str,
|
||||
output_envelope: &'static str,
|
||||
topics: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RobotActivation {
|
||||
flags: Vec<&'static str>,
|
||||
env: &'static str,
|
||||
auto: &'static str,
|
||||
disable: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CommandBrief {
|
||||
cmd: &'static str,
|
||||
does: &'static str,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
aliases: Vec<&'static str>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types — commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CommandsOutput {
|
||||
commands: Vec<CommandDetail>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct CommandDetail {
|
||||
name: &'static str,
|
||||
usage: &'static str,
|
||||
description: &'static str,
|
||||
key_flags: Vec<&'static str>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types — exit-codes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ExitCodesOutput {
|
||||
exit_codes: Vec<ExitCodeEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ExitCodeEntry {
|
||||
code: u8,
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
retryable: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Output types — workflows
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct WorkflowsOutput {
|
||||
workflows: Vec<Workflow>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct Workflow {
|
||||
name: &'static str,
|
||||
description: &'static str,
|
||||
steps: Vec<&'static str>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn build_guide() -> GuideOutput {
|
||||
GuideOutput {
|
||||
tool: "swagger-cli",
|
||||
version: env!("CARGO_PKG_VERSION"),
|
||||
about: "Cache-first CLI for exploring OpenAPI specs. <50ms queries after initial fetch.",
|
||||
robot_activation: RobotActivation {
|
||||
flags: vec!["--robot", "--json"],
|
||||
env: "SWAGGER_CLI_ROBOT=1",
|
||||
auto: "JSON when stdout is not a TTY",
|
||||
disable: "--no-robot",
|
||||
},
|
||||
commands: vec![
|
||||
CommandBrief {
|
||||
cmd: "fetch <url> --alias NAME",
|
||||
does: "Download + cache a spec",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "list [ALIAS]",
|
||||
does: "List endpoints (filterable by method/tag/path)",
|
||||
aliases: vec!["ls"],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "show ALIAS PATH [-m METHOD]",
|
||||
does: "Endpoint detail with params/body/responses",
|
||||
aliases: vec!["info"],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "search ALIAS QUERY",
|
||||
does: "Full-text search endpoints + schemas",
|
||||
aliases: vec!["find"],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "schemas ALIAS [--show NAME]",
|
||||
does: "List or inspect schema definitions",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "tags ALIAS",
|
||||
does: "List tags with endpoint counts",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "aliases [--list|--show|--rename|--delete|--set-default]",
|
||||
does: "Manage cached spec aliases",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "sync [ALIAS|--all]",
|
||||
does: "Re-fetch + update cached specs",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "doctor [--fix]",
|
||||
does: "Check + repair cache health",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "cache [--stats|--path|--prune-stale]",
|
||||
does: "Cache statistics + cleanup",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "diff LEFT RIGHT",
|
||||
does: "Compare two spec versions",
|
||||
aliases: vec![],
|
||||
},
|
||||
CommandBrief {
|
||||
cmd: "robot-docs [TOPIC]",
|
||||
does: "This documentation (always JSON)",
|
||||
aliases: vec!["docs"],
|
||||
},
|
||||
],
|
||||
exit_codes: vec![
|
||||
(0, "SUCCESS"),
|
||||
(2, "USAGE_ERROR"),
|
||||
(4, "NETWORK_ERROR"),
|
||||
(5, "INVALID_SPEC"),
|
||||
(6, "ALIAS_EXISTS"),
|
||||
(7, "AUTH_ERROR"),
|
||||
(8, "ALIAS_NOT_FOUND"),
|
||||
(9, "CACHE_LOCKED"),
|
||||
(10, "CACHE_ERROR"),
|
||||
(11, "CONFIG_ERROR"),
|
||||
(12, "IO_ERROR"),
|
||||
(13, "JSON_ERROR"),
|
||||
(14, "CACHE_INTEGRITY"),
|
||||
(15, "OFFLINE_MODE"),
|
||||
(16, "POLICY_BLOCKED"),
|
||||
],
|
||||
quick_start: "swagger-cli fetch URL --alias NAME && swagger-cli list NAME",
|
||||
output_envelope: "{ok,data?,error?{code,message,suggestion?},meta{schema_version,tool_version,command,duration_ms}}",
|
||||
topics: vec!["guide", "commands", "exit-codes", "workflows"],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_commands() -> CommandsOutput {
|
||||
CommandsOutput {
|
||||
commands: vec![
|
||||
CommandDetail {
|
||||
name: "fetch",
|
||||
usage: "fetch <url|file|-> --alias <name>",
|
||||
description: "Download and cache an OpenAPI spec from URL, local file, or stdin",
|
||||
key_flags: vec![
|
||||
"--alias NAME (required)",
|
||||
"--force: overwrite existing",
|
||||
"--bearer TOKEN: auth header",
|
||||
"--auth-profile NAME: config auth profile",
|
||||
"-H 'Key: Val': extra headers (repeatable)",
|
||||
"--timeout-ms N (default: 10000)",
|
||||
"--retries N (default: 2)",
|
||||
"--resolve-external-refs: inline external $ref entries",
|
||||
"--allow-private-host HOST (repeatable)",
|
||||
"--allow-insecure-http",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "list",
|
||||
usage: "list [ALIAS] [--all-aliases]",
|
||||
description: "List endpoints from cached spec(s) with filtering and sorting",
|
||||
key_flags: vec![
|
||||
"-m METHOD: filter by HTTP method",
|
||||
"-t TAG: filter by tag",
|
||||
"-p REGEX: filter by path pattern",
|
||||
"--sort path|method|tag (default: path)",
|
||||
"-n LIMIT (default: 50)",
|
||||
"-a/--all: show all (no limit)",
|
||||
"--all-aliases: query every cached spec",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "show",
|
||||
usage: "show <ALIAS> <PATH> [-m METHOD]",
|
||||
description: "Show full endpoint detail: parameters, request body, responses",
|
||||
key_flags: vec![
|
||||
"-m METHOD: required when path has multiple methods",
|
||||
"--expand-refs: inline $ref entries",
|
||||
"--max-depth N (default: 3)",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "search",
|
||||
usage: "search <ALIAS> <QUERY> | search --all-aliases <QUERY>",
|
||||
description: "Full-text search across endpoints and schemas with ranked results",
|
||||
key_flags: vec![
|
||||
"--all-aliases: search all cached specs",
|
||||
"--case-sensitive",
|
||||
"--exact: match as exact phrase",
|
||||
"--in all|paths|descriptions|schemas",
|
||||
"--limit N (default: 20)",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "schemas",
|
||||
usage: "schemas <ALIAS> [--show NAME]",
|
||||
description: "List schema names or show a specific schema definition",
|
||||
key_flags: vec![
|
||||
"--name REGEX: filter names",
|
||||
"--list: list mode (default)",
|
||||
"--show NAME: show specific schema",
|
||||
"--expand-refs: inline $ref",
|
||||
"--max-depth N (default: 3)",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "tags",
|
||||
usage: "tags <ALIAS>",
|
||||
description: "List tags from spec with endpoint counts",
|
||||
key_flags: vec![],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "aliases",
|
||||
usage: "aliases [--list|--show A|--rename OLD NEW|--delete A|--set-default A]",
|
||||
description: "Manage cached spec aliases",
|
||||
key_flags: vec![
|
||||
"--list (default)",
|
||||
"--show ALIAS: full details",
|
||||
"--rename OLD NEW",
|
||||
"--delete ALIAS",
|
||||
"--set-default ALIAS",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "sync",
|
||||
usage: "sync [ALIAS] [--all]",
|
||||
description: "Re-fetch and update cached specs from upstream",
|
||||
key_flags: vec![
|
||||
"--all: sync every alias",
|
||||
"--dry-run: check without writing",
|
||||
"--force: re-fetch regardless of freshness",
|
||||
"--details: include change lists",
|
||||
"--auth PROFILE",
|
||||
"--jobs N (default: 4)",
|
||||
"--per-host N (default: 2)",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "doctor",
|
||||
usage: "doctor [--fix] [--alias ALIAS]",
|
||||
description: "Check cache integrity and diagnose issues",
|
||||
key_flags: vec![
|
||||
"--fix: attempt automatic repair",
|
||||
"--alias ALIAS: check one alias only",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "cache",
|
||||
usage: "cache [--stats|--path|--prune-stale|--max-total-mb N]",
|
||||
description: "Cache statistics, location, and cleanup",
|
||||
key_flags: vec![
|
||||
"--stats (default)",
|
||||
"--path: print cache directory",
|
||||
"--prune-stale: remove stale aliases",
|
||||
"--prune-threshold DAYS (default: 90)",
|
||||
"--max-total-mb N: LRU eviction",
|
||||
"--dry-run: report without deleting",
|
||||
],
|
||||
},
|
||||
CommandDetail {
|
||||
name: "diff",
|
||||
usage: "diff <LEFT> <RIGHT>",
|
||||
description: "Compare two cached spec versions for breaking/non-breaking changes",
|
||||
key_flags: vec![
|
||||
"--fail-on breaking|non-breaking: exit non-zero on changes",
|
||||
"--details: per-item change descriptions",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_exit_codes() -> ExitCodesOutput {
|
||||
ExitCodesOutput {
|
||||
exit_codes: vec![
|
||||
ExitCodeEntry {
|
||||
code: 0,
|
||||
name: "SUCCESS",
|
||||
description: "Operation completed successfully",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 2,
|
||||
name: "USAGE_ERROR",
|
||||
description: "Invalid arguments or flags",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 4,
|
||||
name: "NETWORK_ERROR",
|
||||
description: "HTTP request failed",
|
||||
retryable: true,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 5,
|
||||
name: "INVALID_SPEC",
|
||||
description: "Not a valid OpenAPI 3.x spec",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 6,
|
||||
name: "ALIAS_EXISTS",
|
||||
description: "Alias already cached (use --force)",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 7,
|
||||
name: "AUTH_ERROR",
|
||||
description: "Authentication failed",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 8,
|
||||
name: "ALIAS_NOT_FOUND",
|
||||
description: "No cached spec with that alias",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 9,
|
||||
name: "CACHE_LOCKED",
|
||||
description: "Another process holds the lock",
|
||||
retryable: true,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 10,
|
||||
name: "CACHE_ERROR",
|
||||
description: "Cache read/write failure",
|
||||
retryable: true,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 11,
|
||||
name: "CONFIG_ERROR",
|
||||
description: "Config file invalid",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 12,
|
||||
name: "IO_ERROR",
|
||||
description: "Filesystem I/O failure",
|
||||
retryable: true,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 13,
|
||||
name: "JSON_ERROR",
|
||||
description: "JSON parse/serialize failure",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 14,
|
||||
name: "CACHE_INTEGRITY",
|
||||
description: "Cache data corrupted (run doctor --fix)",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 15,
|
||||
name: "OFFLINE_MODE",
|
||||
description: "Network required but offline mode active",
|
||||
retryable: false,
|
||||
},
|
||||
ExitCodeEntry {
|
||||
code: 16,
|
||||
name: "POLICY_BLOCKED",
|
||||
description: "Request blocked by network policy",
|
||||
retryable: false,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn build_workflows() -> WorkflowsOutput {
|
||||
WorkflowsOutput {
|
||||
workflows: vec![
|
||||
Workflow {
|
||||
name: "first-use",
|
||||
description: "Fetch a spec and start exploring",
|
||||
steps: vec![
|
||||
"swagger-cli fetch https://petstore3.swagger.io/api/v3/openapi.json --alias petstore",
|
||||
"swagger-cli list petstore",
|
||||
"swagger-cli search petstore 'pet'",
|
||||
"swagger-cli show petstore /pet/{petId} -m GET",
|
||||
],
|
||||
},
|
||||
Workflow {
|
||||
name: "api-discovery",
|
||||
description: "Explore an unfamiliar API systematically",
|
||||
steps: vec![
|
||||
"swagger-cli tags ALIAS",
|
||||
"swagger-cli list ALIAS -t TAG_NAME",
|
||||
"swagger-cli show ALIAS PATH -m METHOD --expand-refs",
|
||||
"swagger-cli schemas ALIAS --show SCHEMA_NAME",
|
||||
],
|
||||
},
|
||||
Workflow {
|
||||
name: "multi-spec",
|
||||
description: "Work across multiple cached APIs",
|
||||
steps: vec![
|
||||
"swagger-cli aliases --list",
|
||||
"swagger-cli search --all-aliases 'user'",
|
||||
"swagger-cli list --all-aliases -m POST",
|
||||
"swagger-cli diff api-v1 api-v2",
|
||||
],
|
||||
},
|
||||
Workflow {
|
||||
name: "maintenance",
|
||||
description: "Keep cache healthy and up-to-date",
|
||||
steps: vec![
|
||||
"swagger-cli doctor",
|
||||
"swagger-cli sync --all --dry-run",
|
||||
"swagger-cli sync --all",
|
||||
"swagger-cli cache --stats",
|
||||
"swagger-cli cache --prune-stale --prune-threshold 60",
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Execute
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub fn execute(args: &Args, pretty: bool) -> Result<(), SwaggerCliError> {
|
||||
let start = Instant::now();
|
||||
let topic = args.topic.as_str();
|
||||
|
||||
match topic {
|
||||
"guide" => emit(build_guide(), pretty, start),
|
||||
"commands" => emit(build_commands(), pretty, start),
|
||||
"exit-codes" => emit(build_exit_codes(), pretty, start),
|
||||
"workflows" => emit(build_workflows(), pretty, start),
|
||||
unknown => Err(SwaggerCliError::Usage(format!(
|
||||
"Unknown robot-docs topic '{unknown}'. Valid: guide, commands, exit-codes, workflows"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn emit<T: Serialize>(data: T, pretty: bool, start: Instant) -> Result<(), SwaggerCliError> {
|
||||
let duration = start.elapsed();
|
||||
if pretty {
|
||||
robot::robot_success_pretty(data, "robot-docs", duration);
|
||||
} else {
|
||||
robot::robot_success(data, "robot-docs", duration);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn guide_serializes_without_panic() {
|
||||
let guide = build_guide();
|
||||
let json = serde_json::to_string(&guide).unwrap();
|
||||
assert!(json.contains("swagger-cli"));
|
||||
assert!(json.contains("robot_activation"));
|
||||
assert!(json.contains("exit_codes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commands_serializes_without_panic() {
|
||||
let cmds = build_commands();
|
||||
let json = serde_json::to_string(&cmds).unwrap();
|
||||
assert!(json.contains("\"fetch\""));
|
||||
assert!(json.contains("\"list\""));
|
||||
assert!(json.contains("\"show\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_serializes_without_panic() {
|
||||
let codes = build_exit_codes();
|
||||
let json = serde_json::to_string(&codes).unwrap();
|
||||
assert!(json.contains("USAGE_ERROR"));
|
||||
assert!(json.contains("NETWORK_ERROR"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workflows_serializes_without_panic() {
|
||||
let wf = build_workflows();
|
||||
let json = serde_json::to_string(&wf).unwrap();
|
||||
assert!(json.contains("first-use"));
|
||||
assert!(json.contains("api-discovery"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_command_count_matches_enum() {
|
||||
let guide = build_guide();
|
||||
// 11 main commands + robot-docs itself = 12
|
||||
assert_eq!(guide.commands.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_codes_has_no_gaps_in_critical_range() {
|
||||
let codes = build_exit_codes();
|
||||
// Verify code 0 and 2 exist (most important for agents)
|
||||
assert!(codes.exit_codes.iter().any(|c| c.code == 0));
|
||||
assert!(codes.exit_codes.iter().any(|c| c.code == 2));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_aliases_populated_for_aliased_commands() {
|
||||
let guide = build_guide();
|
||||
let list_cmd = guide
|
||||
.commands
|
||||
.iter()
|
||||
.find(|c| c.cmd.starts_with("list"))
|
||||
.unwrap();
|
||||
assert!(list_cmd.aliases.contains(&"ls"));
|
||||
|
||||
let search_cmd = guide
|
||||
.commands
|
||||
.iter()
|
||||
.find(|c| c.cmd.starts_with("search"))
|
||||
.unwrap();
|
||||
assert!(search_cmd.aliases.contains(&"find"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_topic_returns_usage_error() {
|
||||
let args = Args {
|
||||
topic: "nonexistent".to_string(),
|
||||
};
|
||||
let result = execute(&args, false);
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
SwaggerCliError::Usage(msg) => {
|
||||
assert!(msg.contains("nonexistent"));
|
||||
assert!(msg.contains("guide"));
|
||||
}
|
||||
other => panic!("expected Usage error, got: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guide_token_efficiency() {
|
||||
let guide = build_guide();
|
||||
let json = serde_json::to_string(&guide).unwrap();
|
||||
// Compact JSON should be under 2500 chars (~400 tokens)
|
||||
assert!(
|
||||
json.len() < 2500,
|
||||
"guide JSON is {} chars, should be <2500 for token efficiency",
|
||||
json.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -112,10 +112,53 @@ pub async fn execute(args: &Args, robot: bool) -> Result<(), SwaggerCliError> {
|
||||
expand_refs(&mut operation, &raw, args.max_depth);
|
||||
}
|
||||
|
||||
let parameters = operation
|
||||
// Merge path-level parameters into operation parameters.
|
||||
// Per OpenAPI 3.x, parameters defined at the path-item level apply to all
|
||||
// operations unless overridden (same name + location) at the operation level.
|
||||
let parameters = {
|
||||
let op_params = operation
|
||||
.get("parameters")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or(Value::Array(vec![]));
|
||||
.unwrap_or_default();
|
||||
|
||||
// Derive path-item pointer by stripping the last segment (method) from operation_ptr
|
||||
let path_item_ptr = endpoint
|
||||
.operation_ptr
|
||||
.rfind('/')
|
||||
.map(|i| &endpoint.operation_ptr[..i]);
|
||||
|
||||
let mut merged = op_params.clone();
|
||||
|
||||
if let Some(ptr) = path_item_ptr
|
||||
&& let Some(path_item) = resolve_json_pointer(&raw, ptr)
|
||||
&& let Some(path_params) = path_item.get("parameters").and_then(Value::as_array)
|
||||
{
|
||||
// Collect (name, in) pairs from operation-level params for override detection
|
||||
let op_keys: std::collections::HashSet<(String, String)> = op_params
|
||||
.iter()
|
||||
.filter_map(|p| {
|
||||
let name = p.get("name")?.as_str()?.to_string();
|
||||
let loc = p.get("in")?.as_str()?.to_string();
|
||||
Some((name, loc))
|
||||
})
|
||||
.collect();
|
||||
|
||||
for pp in path_params {
|
||||
let key = pp
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.zip(pp.get("in").and_then(Value::as_str));
|
||||
if let Some((name, loc)) = key
|
||||
&& !op_keys.contains(&(name.to_string(), loc.to_string()))
|
||||
{
|
||||
merged.push(pp.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Value::Array(merged)
|
||||
};
|
||||
|
||||
let request_body = operation.get("requestBody").cloned();
|
||||
|
||||
|
||||
@@ -143,6 +143,8 @@ struct AliasSyncResult {
|
||||
remote_version: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
changes: Option<ChangeSummary>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
details: Option<ChangeDetails>,
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
@@ -201,13 +203,15 @@ fn remove_checkpoint(cache_path: &std::path::Path) {
|
||||
// Index diffing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Build a comparable key for an endpoint: (path, method).
|
||||
/// Build a comparable key for an endpoint: (path, METHOD).
|
||||
/// Method is uppercased for consistent comparison regardless of indexer casing.
|
||||
fn endpoint_key(ep: &crate::core::spec::IndexedEndpoint) -> (String, String) {
|
||||
(ep.path.clone(), ep.method.clone())
|
||||
(ep.path.clone(), ep.method.to_uppercase())
|
||||
}
|
||||
|
||||
/// Build a fingerprint of an endpoint for modification detection.
|
||||
/// Includes summary, parameters, deprecated status, and request body info.
|
||||
/// Includes all semantically meaningful fields so that changes to security,
|
||||
/// tags, operation_id, etc. are detected during sync.
|
||||
fn endpoint_fingerprint(ep: &crate::core::spec::IndexedEndpoint) -> String {
|
||||
let params: Vec<String> = ep
|
||||
.parameters
|
||||
@@ -216,12 +220,16 @@ fn endpoint_fingerprint(ep: &crate::core::spec::IndexedEndpoint) -> String {
|
||||
.collect();
|
||||
|
||||
format!(
|
||||
"{}|{}|{}|{}|{}",
|
||||
"{}|{}|{}|{}|{}|{}|{}|{}|{}",
|
||||
ep.summary.as_deref().unwrap_or(""),
|
||||
ep.deprecated,
|
||||
params.join(","),
|
||||
ep.request_body_required,
|
||||
ep.request_body_content_types.join(","),
|
||||
ep.security_schemes.join(","),
|
||||
ep.security_required,
|
||||
ep.tags.join(","),
|
||||
ep.operation_id.as_deref().unwrap_or(""),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -378,6 +386,7 @@ async fn sync_one_alias(
|
||||
local_version: None,
|
||||
remote_version: None,
|
||||
changes: None,
|
||||
details: None,
|
||||
duration_ms: start.elapsed().as_millis().min(u64::MAX as u128) as u64,
|
||||
},
|
||||
}
|
||||
@@ -463,6 +472,7 @@ async fn sync_one_alias_inner(
|
||||
local_version: Some(meta.spec_version.clone()),
|
||||
remote_version: None,
|
||||
changes: None,
|
||||
details: None,
|
||||
duration_ms: elapsed_ms(),
|
||||
}),
|
||||
ConditionalFetchResult::Modified(result) => {
|
||||
@@ -478,6 +488,7 @@ async fn sync_one_alias_inner(
|
||||
local_version: Some(meta.spec_version.clone()),
|
||||
remote_version: None,
|
||||
changes: None,
|
||||
details: None,
|
||||
duration_ms: elapsed_ms(),
|
||||
});
|
||||
}
|
||||
@@ -488,11 +499,10 @@ async fn sync_one_alias_inner(
|
||||
Format::Yaml => "yaml",
|
||||
};
|
||||
|
||||
let json_bytes = normalize_to_json(&result.bytes, format)?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
|
||||
let (json_bytes, value) = normalize_to_json(&result.bytes, format)?;
|
||||
let new_index = build_index(&value, &new_content_hash, meta.generation + 1)?;
|
||||
|
||||
let (summary, _details) = compute_diff(&old_index, &new_index);
|
||||
let (summary, details) = compute_diff(&old_index, &new_index);
|
||||
|
||||
let has_changes = summary.endpoints_added > 0
|
||||
|| summary.endpoints_removed > 0
|
||||
@@ -533,6 +543,7 @@ async fn sync_one_alias_inner(
|
||||
local_version: Some(meta.spec_version.clone()),
|
||||
remote_version: Some(new_index.info.version.clone()),
|
||||
changes: if include_details { Some(summary) } else { None },
|
||||
details: if include_details { Some(details) } else { None },
|
||||
duration_ms: elapsed_ms(),
|
||||
})
|
||||
}
|
||||
@@ -573,6 +584,7 @@ async fn sync_inner(
|
||||
let cfg = Config::load(&config_path(config_override))?;
|
||||
let mut builder = AsyncHttpClient::builder()
|
||||
.allow_insecure_http(url.starts_with("http://"))
|
||||
.allowed_private_hosts(args.allow_private_host.clone())
|
||||
.network_policy(network_policy);
|
||||
|
||||
if let Some(profile_name) = &args.auth {
|
||||
@@ -637,8 +649,7 @@ async fn sync_inner(
|
||||
Format::Yaml => "yaml",
|
||||
};
|
||||
|
||||
let json_bytes = normalize_to_json(&result.bytes, format)?;
|
||||
let value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
|
||||
let (json_bytes, value) = normalize_to_json(&result.bytes, format)?;
|
||||
let new_index = build_index(&value, &new_content_hash, meta.generation + 1)?;
|
||||
|
||||
// 6. Compute diff
|
||||
@@ -821,12 +832,6 @@ async fn sync_all_inner(
|
||||
};
|
||||
|
||||
let total = aliases.len();
|
||||
let _skipped_from_resume = if args.resume {
|
||||
total - to_sync.len()
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
// Handle empty aliases
|
||||
if total == 0 {
|
||||
let output = SyncAllOutput {
|
||||
@@ -890,6 +895,7 @@ async fn sync_all_inner(
|
||||
local_version: None,
|
||||
remote_version: None,
|
||||
changes: None,
|
||||
details: None,
|
||||
duration_ms: 0,
|
||||
};
|
||||
}
|
||||
@@ -911,6 +917,7 @@ async fn sync_all_inner(
|
||||
local_version: None,
|
||||
remote_version: None,
|
||||
changes: None,
|
||||
details: None,
|
||||
duration_ms: 0,
|
||||
};
|
||||
}
|
||||
@@ -972,6 +979,7 @@ async fn sync_all_inner(
|
||||
local_version: None,
|
||||
remote_version: None,
|
||||
changes: None,
|
||||
details: None,
|
||||
duration_ms: 0,
|
||||
});
|
||||
}
|
||||
@@ -985,6 +993,7 @@ async fn sync_all_inner(
|
||||
local_version: None,
|
||||
remote_version: None,
|
||||
changes: None,
|
||||
details: None,
|
||||
duration_ms: 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,6 +51,9 @@ static ALIAS_PATTERN: LazyLock<Regex> =
|
||||
LazyLock::new(|| Regex::new(r"^[A-Za-z0-9][A-Za-z0-9._\-]{0,63}$").expect("valid regex"));
|
||||
|
||||
pub fn validate_alias(alias: &str) -> Result<(), SwaggerCliError> {
|
||||
// The regex enforces: 1-64 chars, starts with alphanumeric, only contains
|
||||
// alphanumeric/dot/dash/underscore. This implicitly rejects path separators
|
||||
// (/ \), directory traversal (..), and leading dots.
|
||||
let pattern = &*ALIAS_PATTERN;
|
||||
|
||||
if !pattern.is_match(alias) {
|
||||
@@ -60,24 +63,8 @@ pub fn validate_alias(alias: &str) -> Result<(), SwaggerCliError> {
|
||||
)));
|
||||
}
|
||||
|
||||
if alias.contains('/') || alias.contains('\\') {
|
||||
return Err(SwaggerCliError::Usage(format!(
|
||||
"Invalid alias '{alias}': path separators not allowed"
|
||||
)));
|
||||
}
|
||||
|
||||
if alias.contains("..") {
|
||||
return Err(SwaggerCliError::Usage(format!(
|
||||
"Invalid alias '{alias}': directory traversal not allowed"
|
||||
)));
|
||||
}
|
||||
|
||||
if alias.starts_with('.') {
|
||||
return Err(SwaggerCliError::Usage(format!(
|
||||
"Invalid alias '{alias}': leading dot not allowed"
|
||||
)));
|
||||
}
|
||||
|
||||
// Reject Windows reserved device names (CON, PRN, NUL, COM1-9, LPT1-9)
|
||||
// even on Unix for cross-platform cache portability.
|
||||
let stem = alias.split('.').next().unwrap_or(alias);
|
||||
let reserved = [
|
||||
"CON", "PRN", "NUL", "AUX", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||
|
||||
@@ -126,14 +126,13 @@ fn resolve_recursive<'a>(
|
||||
Some(&resolved_url),
|
||||
result.content_type.as_deref(),
|
||||
);
|
||||
let json_bytes = normalize_to_json(&result.bytes, format).map_err(|_| {
|
||||
let (_json_bytes, mut fetched_value) = normalize_to_json(&result.bytes, format)
|
||||
.map_err(|_| {
|
||||
SwaggerCliError::InvalidSpec(format!(
|
||||
"external ref '{resolved_url}' returned invalid JSON/YAML"
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut fetched_value: Value = serde_json::from_slice(&json_bytes)?;
|
||||
|
||||
// Handle fragment pointer within the fetched document
|
||||
if let Some(frag) = parsed.fragment()
|
||||
&& !frag.is_empty()
|
||||
|
||||
@@ -32,6 +32,7 @@ fn is_ip_blocked(ip: &IpAddr) -> bool {
|
||||
|| v6.is_unspecified() // ::
|
||||
|| v6.is_multicast() // ff00::/8
|
||||
|| is_link_local_v6(v6) // fe80::/10
|
||||
|| is_unique_local_v6(v6) // fc00::/7 (IPv6 private)
|
||||
|| is_blocked_mapped_v4(v6)
|
||||
}
|
||||
}
|
||||
@@ -45,6 +46,9 @@ fn is_private_v4(ip: &std::net::Ipv4Addr) -> bool {
|
||||
|| (octets[0] == 172 && (16..=31).contains(&octets[1]))
|
||||
// 192.168.0.0/16
|
||||
|| (octets[0] == 192 && octets[1] == 168)
|
||||
// 100.64.0.0/10 (CGNAT / Shared Address Space, RFC 6598)
|
||||
// Often used by cloud providers for internal services; common SSRF target.
|
||||
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
|
||||
}
|
||||
|
||||
fn is_link_local_v6(ip: &std::net::Ipv6Addr) -> bool {
|
||||
@@ -53,6 +57,12 @@ fn is_link_local_v6(ip: &std::net::Ipv6Addr) -> bool {
|
||||
(segments[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
|
||||
fn is_unique_local_v6(ip: &std::net::Ipv6Addr) -> bool {
|
||||
let segments = ip.segments();
|
||||
// fc00::/7 — first 7 bits are 1111_110 (covers fc00::/8 and fd00::/8)
|
||||
(segments[0] & 0xfe00) == 0xfc00
|
||||
}
|
||||
|
||||
fn is_blocked_mapped_v4(v6: &std::net::Ipv6Addr) -> bool {
|
||||
// ::ffff:x.x.x.x — IPv4-mapped IPv6
|
||||
let segments = v6.segments();
|
||||
|
||||
@@ -39,27 +39,37 @@ pub fn detect_format(
|
||||
}
|
||||
}
|
||||
|
||||
// Content sniffing: try JSON first (stricter), fall back to YAML.
|
||||
if serde_json::from_slice::<serde_json::Value>(bytes).is_ok() {
|
||||
Format::Json
|
||||
} else {
|
||||
Format::Yaml
|
||||
// Content sniffing: check the first non-whitespace byte. Valid JSON
|
||||
// documents start with '{' or '['. This avoids a full JSON parse just
|
||||
// to detect format — a ~300x speedup for the common case.
|
||||
let first_meaningful = bytes.iter().find(|b| !b.is_ascii_whitespace());
|
||||
match first_meaningful {
|
||||
Some(b'{') | Some(b'[') => Format::Json,
|
||||
_ => Format::Yaml,
|
||||
}
|
||||
}
|
||||
|
||||
/// If the input is YAML, parse then re-serialize as JSON.
|
||||
/// If JSON, validate it parses.
|
||||
pub fn normalize_to_json(bytes: &[u8], format: Format) -> Result<Vec<u8>, SwaggerCliError> {
|
||||
/// Normalize raw bytes to canonical JSON, returning both the bytes and parsed value.
|
||||
///
|
||||
/// For JSON input: parses once and returns the original bytes + parsed value.
|
||||
/// For YAML input: parses YAML into a Value, serializes to JSON bytes.
|
||||
///
|
||||
/// This eliminates the common double-parse pattern where callers would
|
||||
/// call `normalize_to_json()` then immediately `serde_json::from_slice()`.
|
||||
pub fn normalize_to_json(
|
||||
bytes: &[u8],
|
||||
format: Format,
|
||||
) -> Result<(Vec<u8>, serde_json::Value), SwaggerCliError> {
|
||||
match format {
|
||||
Format::Json => {
|
||||
let _: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
Ok(bytes.to_vec())
|
||||
let value: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
Ok((bytes.to_vec(), value))
|
||||
}
|
||||
Format::Yaml => {
|
||||
let value: serde_json::Value = serde_yaml::from_slice(bytes)
|
||||
.map_err(|e| SwaggerCliError::InvalidSpec(format!("YAML parse error: {e}")))?;
|
||||
let json_bytes = serde_json::to_vec(&value)?;
|
||||
Ok(json_bytes)
|
||||
Ok((json_bytes, value))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -418,8 +428,9 @@ info:
|
||||
version: "1.0"
|
||||
paths: {}
|
||||
"#;
|
||||
let json_bytes = normalize_to_json(yaml, Format::Yaml).unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_slice(&json_bytes).unwrap();
|
||||
let (json_bytes, parsed) = normalize_to_json(yaml, Format::Yaml).unwrap();
|
||||
// Verify the bytes are also valid JSON
|
||||
let _: serde_json::Value = serde_json::from_slice(&json_bytes).unwrap();
|
||||
assert_eq!(parsed["openapi"], "3.0.0");
|
||||
assert_eq!(parsed["info"]["title"], "Test API");
|
||||
}
|
||||
|
||||
@@ -94,6 +94,13 @@ impl<'a> SearchEngine<'a> {
|
||||
let terms = tokenize(query, opts.exact);
|
||||
let total_terms = terms.len();
|
||||
|
||||
// Pre-lowercase terms once (not once per endpoint x field).
|
||||
let lowered_terms: Vec<String> = if opts.case_sensitive {
|
||||
terms.clone()
|
||||
} else {
|
||||
terms.iter().map(|t| t.to_lowercase()).collect()
|
||||
};
|
||||
|
||||
let mut results: Vec<SearchResult> = Vec::new();
|
||||
|
||||
// Search endpoints
|
||||
@@ -103,10 +110,34 @@ impl<'a> SearchEngine<'a> {
|
||||
let mut matched_terms: usize = 0;
|
||||
let mut matches: Vec<Match> = Vec::new();
|
||||
|
||||
for term in &terms {
|
||||
// Pre-lowercase each field once per endpoint (not once per term).
|
||||
let path_lc = if !opts.case_sensitive {
|
||||
Some(ep.path.to_lowercase())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let summary_lc = if !opts.case_sensitive {
|
||||
ep.summary.as_deref().map(str::to_lowercase)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let desc_lc = if !opts.case_sensitive {
|
||||
ep.description.as_deref().map(str::to_lowercase)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (i, term) in terms.iter().enumerate() {
|
||||
let lc_term = &lowered_terms[i];
|
||||
let mut term_matched = false;
|
||||
|
||||
if opts.search_paths && contains_term(&ep.path, term, opts.case_sensitive) {
|
||||
if opts.search_paths {
|
||||
let haystack = if opts.case_sensitive {
|
||||
&ep.path
|
||||
} else {
|
||||
path_lc.as_ref().unwrap()
|
||||
};
|
||||
if haystack.contains(lc_term.as_str()) {
|
||||
raw_score += WEIGHT_PATH;
|
||||
matches.push(Match {
|
||||
field: "path".into(),
|
||||
@@ -114,11 +145,17 @@ impl<'a> SearchEngine<'a> {
|
||||
});
|
||||
term_matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.search_descriptions || opts.search_paths)
|
||||
&& let Some(ref summary) = ep.summary
|
||||
&& contains_term(summary, term, opts.case_sensitive)
|
||||
{
|
||||
let haystack = if opts.case_sensitive {
|
||||
summary.as_str()
|
||||
} else {
|
||||
summary_lc.as_deref().unwrap_or("")
|
||||
};
|
||||
if haystack.contains(lc_term.as_str()) {
|
||||
raw_score += WEIGHT_SUMMARY;
|
||||
matches.push(Match {
|
||||
field: "summary".into(),
|
||||
@@ -126,11 +163,17 @@ impl<'a> SearchEngine<'a> {
|
||||
});
|
||||
term_matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if opts.search_descriptions
|
||||
&& let Some(ref desc) = ep.description
|
||||
&& contains_term(desc, term, opts.case_sensitive)
|
||||
{
|
||||
let haystack = if opts.case_sensitive {
|
||||
desc.as_str()
|
||||
} else {
|
||||
desc_lc.as_deref().unwrap_or("")
|
||||
};
|
||||
if haystack.contains(lc_term.as_str()) {
|
||||
raw_score += WEIGHT_DESCRIPTION;
|
||||
matches.push(Match {
|
||||
field: "description".into(),
|
||||
@@ -138,6 +181,7 @@ impl<'a> SearchEngine<'a> {
|
||||
});
|
||||
term_matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if term_matched {
|
||||
matched_terms += 1;
|
||||
@@ -169,8 +213,20 @@ impl<'a> SearchEngine<'a> {
|
||||
let mut matched_terms: usize = 0;
|
||||
let mut matches: Vec<Match> = Vec::new();
|
||||
|
||||
for term in &terms {
|
||||
if contains_term(&schema.name, term, opts.case_sensitive) {
|
||||
let name_lc = if !opts.case_sensitive {
|
||||
Some(schema.name.to_lowercase())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (i, term) in terms.iter().enumerate() {
|
||||
let lc_term = &lowered_terms[i];
|
||||
let haystack = if opts.case_sensitive {
|
||||
&schema.name
|
||||
} else {
|
||||
name_lc.as_ref().unwrap()
|
||||
};
|
||||
if haystack.contains(lc_term.as_str()) {
|
||||
raw_score += WEIGHT_SCHEMA_NAME;
|
||||
matches.push(Match {
|
||||
field: "schema_name".into(),
|
||||
@@ -233,35 +289,67 @@ fn tokenize(query: &str, exact: bool) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
|
||||
fn contains_term(haystack: &str, needle: &str, case_sensitive: bool) -> bool {
|
||||
if case_sensitive {
|
||||
haystack.contains(needle)
|
||||
} else {
|
||||
let h = haystack.to_lowercase();
|
||||
let n = needle.to_lowercase();
|
||||
h.contains(&n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a Unicode-safe snippet around the first occurrence of `needle` in
|
||||
/// `haystack`. The context window is 50 characters. Ellipses are added when
|
||||
/// the snippet is truncated.
|
||||
fn safe_snippet(haystack: &str, needle: &str, case_sensitive: bool) -> String {
|
||||
let (h_search, n_search) = if case_sensitive {
|
||||
(haystack.to_string(), needle.to_string())
|
||||
} else {
|
||||
(haystack.to_lowercase(), needle.to_lowercase())
|
||||
};
|
||||
|
||||
let byte_pos = match h_search.find(&n_search) {
|
||||
Some(pos) => pos,
|
||||
None => return haystack.chars().take(50).collect(),
|
||||
};
|
||||
|
||||
// Convert byte position to char index.
|
||||
let char_start = haystack[..byte_pos].chars().count();
|
||||
let needle_char_len = needle.chars().count();
|
||||
// Find the match position using char-based search to avoid byte-position
|
||||
// mismatches between the original and lowercased strings (which can differ
|
||||
// in byte length for certain Unicode characters, causing panics).
|
||||
let haystack_chars: Vec<char> = haystack.chars().collect();
|
||||
let needle_chars: Vec<char> = if case_sensitive {
|
||||
needle.chars().collect()
|
||||
} else {
|
||||
needle.chars().flat_map(char::to_lowercase).collect()
|
||||
};
|
||||
|
||||
let char_start = if needle_chars.is_empty() {
|
||||
0
|
||||
} else {
|
||||
let mut found = None;
|
||||
let search_chars: Vec<char> = if case_sensitive {
|
||||
haystack_chars.clone()
|
||||
} else {
|
||||
haystack_chars
|
||||
.iter()
|
||||
.flat_map(|c| c.to_lowercase())
|
||||
.collect()
|
||||
};
|
||||
// Scan through search_chars for the needle
|
||||
'outer: for i in 0..search_chars.len().saturating_sub(needle_chars.len() - 1) {
|
||||
for (j, nc) in needle_chars.iter().enumerate() {
|
||||
if search_chars[i + j] != *nc {
|
||||
continue 'outer;
|
||||
}
|
||||
}
|
||||
// Map position in search_chars back to position in haystack_chars.
|
||||
// When case-insensitive, lowercasing can expand characters (e.g.
|
||||
// U+0130 -> 'i' + U+0307), so we need to walk both iterators in
|
||||
// parallel to find the corresponding haystack_chars index.
|
||||
if case_sensitive {
|
||||
found = Some(i);
|
||||
} else {
|
||||
let mut search_idx = 0;
|
||||
for (hay_idx, hay_char) in haystack_chars.iter().enumerate() {
|
||||
if search_idx >= i {
|
||||
found = Some(hay_idx);
|
||||
break;
|
||||
}
|
||||
search_idx += hay_char.to_lowercase().count();
|
||||
}
|
||||
if found.is_none() && search_idx >= i {
|
||||
found = Some(haystack_chars.len());
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
match found {
|
||||
Some(pos) => pos,
|
||||
None => return haystack_chars.iter().take(50).collect(),
|
||||
}
|
||||
};
|
||||
|
||||
let needle_char_len = needle.chars().count();
|
||||
let total_chars = haystack_chars.len();
|
||||
|
||||
const WINDOW: usize = 50;
|
||||
|
||||
58
src/main.rs
58
src/main.rs
@@ -1,5 +1,6 @@
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
use std::io::IsTerminal;
|
||||
use std::process::ExitCode;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
@@ -9,8 +10,51 @@ use swagger_cli::cli::{Cli, Commands};
|
||||
use swagger_cli::errors::SwaggerCliError;
|
||||
use swagger_cli::output::robot;
|
||||
|
||||
/// Pre-scan for robot mode before clap parses, so parse errors get the right
|
||||
/// output format. Mirrors the resolution logic in `resolve_robot_mode`.
|
||||
fn pre_scan_robot() -> bool {
|
||||
std::env::args().any(|a| a == "--robot")
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// --no-robot always wins
|
||||
if args.iter().any(|a| a == "--no-robot") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Explicit --robot or --json
|
||||
if args.iter().any(|a| a == "--robot" || a == "--json") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Environment variable
|
||||
if std::env::var("SWAGGER_CLI_ROBOT").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// TTY auto-detection: JSON when stdout is not a TTY
|
||||
!std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
/// Resolve robot mode after clap parses. Resolution order:
|
||||
/// 1. --no-robot (explicit off)
|
||||
/// 2. --robot / --json (explicit on)
|
||||
/// 3. SWAGGER_CLI_ROBOT env var
|
||||
/// 4. TTY auto-detection (not a TTY → robot mode)
|
||||
fn resolve_robot_mode(cli: &Cli) -> bool {
|
||||
if cli.no_robot {
|
||||
return false;
|
||||
}
|
||||
|
||||
if cli.robot {
|
||||
return true;
|
||||
}
|
||||
|
||||
if std::env::var("SWAGGER_CLI_ROBOT").is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
!std::io::stdout().is_terminal()
|
||||
}
|
||||
|
||||
fn command_name(cli: &Cli) -> &'static str {
|
||||
@@ -26,6 +70,7 @@ fn command_name(cli: &Cli) -> &'static str {
|
||||
Commands::Doctor(_) => "doctor",
|
||||
Commands::Cache(_) => "cache",
|
||||
Commands::Diff(_) => "diff",
|
||||
Commands::RobotDocs(_) => "robot-docs",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +96,12 @@ async fn main() -> ExitCode {
|
||||
let cli = match Cli::try_parse() {
|
||||
Ok(cli) => cli,
|
||||
Err(err) => {
|
||||
if is_robot {
|
||||
// Help and version requests always use human output, even when piped
|
||||
let is_info = matches!(
|
||||
err.kind(),
|
||||
clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion
|
||||
);
|
||||
if is_robot && !is_info {
|
||||
let parse_err = SwaggerCliError::Usage(err.to_string());
|
||||
output_robot_error(&parse_err, "unknown", start.elapsed());
|
||||
return parse_err.to_exit_code();
|
||||
@@ -61,7 +111,8 @@ async fn main() -> ExitCode {
|
||||
};
|
||||
|
||||
let cmd = command_name(&cli);
|
||||
let robot = cli.robot;
|
||||
let robot = resolve_robot_mode(&cli);
|
||||
let pretty = cli.pretty;
|
||||
|
||||
let network_flag = cli.network.as_str();
|
||||
let config_override = cli.config.as_deref();
|
||||
@@ -82,6 +133,7 @@ async fn main() -> ExitCode {
|
||||
Commands::Doctor(args) => swagger_cli::cli::doctor::execute(args, robot).await,
|
||||
Commands::Cache(args) => swagger_cli::cli::cache_cmd::execute(args, robot).await,
|
||||
Commands::Diff(args) => swagger_cli::cli::diff::execute(args, robot).await,
|
||||
Commands::RobotDocs(args) => swagger_cli::cli::robot_docs::execute(args, pretty),
|
||||
};
|
||||
|
||||
match result {
|
||||
|
||||
@@ -30,6 +30,13 @@ pub fn robot_success<T: Serialize>(data: T, command: &str, duration: Duration) {
|
||||
println!("{json}");
|
||||
}
|
||||
|
||||
pub fn robot_success_pretty<T: Serialize>(data: T, command: &str, duration: Duration) {
|
||||
let meta = build_meta(command, duration);
|
||||
let envelope = RobotEnvelope::success(data, meta);
|
||||
let json = serde_json::to_string_pretty(&envelope).expect("serialization should not fail");
|
||||
println!("{json}");
|
||||
}
|
||||
|
||||
pub fn robot_error(
|
||||
code: &str,
|
||||
message: &str,
|
||||
|
||||
Reference in New Issue
Block a user