Compare commits
11 Commits
4ac8659ebd
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dfcb4c93eb | ||
|
|
4340dc4301 | ||
|
|
8c311fb049 | ||
|
|
59389d9272 | ||
|
|
a10792be48 | ||
|
|
0b9a8a36c5 | ||
|
|
75d9344b44 | ||
|
|
a36997982a | ||
|
|
8455bca71b | ||
|
|
cc04772792 | ||
|
|
aae9a33d36 |
File diff suppressed because one or more lines are too long
3
.gitignore
vendored
3
.gitignore
vendored
@@ -3,3 +3,6 @@
|
|||||||
|
|
||||||
# bv (beads viewer) local config and caches
|
# bv (beads viewer) local config and caches
|
||||||
.bv/
|
.bv/
|
||||||
|
|
||||||
|
# Ideas and planning documents (local only)
|
||||||
|
ideas/
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ lint:
|
|||||||
script:
|
script:
|
||||||
- rustup component add rustfmt clippy
|
- rustup component add rustfmt clippy
|
||||||
- cargo fmt --check
|
- cargo fmt --check
|
||||||
- cargo clippy -- -D warnings
|
- cargo clippy --all-targets -- -D warnings
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
- if: $CI_COMMIT_BRANCH
|
- if: $CI_COMMIT_BRANCH
|
||||||
|
|||||||
499
Cargo.lock
generated
499
Cargo.lock
generated
@@ -11,6 +11,15 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alloca"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@@ -119,6 +128,28 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
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]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@@ -197,9 +228,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
|
checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"find-msvc-tools",
|
"find-msvc-tools",
|
||||||
|
"jobserver",
|
||||||
|
"libc",
|
||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cesu8"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cfg-if"
|
name = "cfg-if"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -281,10 +320,10 @@ version = "4.5.55"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.5.0",
|
"heck",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -293,6 +332,15 @@ version = "1.0.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cmake"
|
||||||
|
version = "0.1.57"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.4"
|
version = "1.0.4"
|
||||||
@@ -308,6 +356,26 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"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]]
|
[[package]]
|
||||||
name = "core-foundation-sys"
|
name = "core-foundation-sys"
|
||||||
version = "0.8.7"
|
version = "0.8.7"
|
||||||
@@ -325,25 +393,24 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "criterion"
|
name = "criterion"
|
||||||
version = "0.5.1"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f"
|
checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"alloca",
|
||||||
"anes",
|
"anes",
|
||||||
"cast",
|
"cast",
|
||||||
"ciborium",
|
"ciborium",
|
||||||
"clap",
|
"clap",
|
||||||
"criterion-plot",
|
"criterion-plot",
|
||||||
"is-terminal",
|
|
||||||
"itertools",
|
"itertools",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
"once_cell",
|
|
||||||
"oorandom",
|
"oorandom",
|
||||||
|
"page_size",
|
||||||
"plotters",
|
"plotters",
|
||||||
"rayon",
|
"rayon",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_derive",
|
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tinytemplate",
|
"tinytemplate",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
@@ -351,9 +418,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "criterion-plot"
|
name = "criterion-plot"
|
||||||
version = "0.5.0"
|
version = "0.8.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1"
|
checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cast",
|
"cast",
|
||||||
"itertools",
|
"itertools",
|
||||||
@@ -445,9 +512,15 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"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]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.15.0"
|
version = "1.15.0"
|
||||||
@@ -522,6 +595,12 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fs_extra"
|
||||||
|
version = "1.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.31"
|
version = "0.3.31"
|
||||||
@@ -578,7 +657,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -706,24 +785,12 @@ version = "0.16.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "heck"
|
|
||||||
version = "0.4.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "heck"
|
name = "heck"
|
||||||
version = "0.5.0"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hermit-abi"
|
|
||||||
version = "0.5.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.0"
|
version = "1.4.0"
|
||||||
@@ -806,7 +873,6 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
"webpki-roots",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -992,17 +1058,6 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@@ -1011,9 +1066,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "itertools"
|
name = "itertools"
|
||||||
version = "0.10.5"
|
version = "0.13.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
|
checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"either",
|
"either",
|
||||||
]
|
]
|
||||||
@@ -1024,6 +1079,38 @@ version = "1.0.17"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
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]]
|
[[package]]
|
||||||
name = "js-sys"
|
name = "js-sys"
|
||||||
version = "0.3.85"
|
version = "0.3.85"
|
||||||
@@ -1164,6 +1251,12 @@ version = "11.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "option-ext"
|
name = "option-ext"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@@ -1171,10 +1264,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "papergrid"
|
name = "page_size"
|
||||||
version = "0.13.0"
|
version = "0.6.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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 = [
|
dependencies = [
|
||||||
"bytecount",
|
"bytecount",
|
||||||
"fnv",
|
"fnv",
|
||||||
@@ -1305,7 +1408,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1327,7 +1430,7 @@ dependencies = [
|
|||||||
"proc-macro-error-attr2",
|
"proc-macro-error-attr2",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1378,7 +1481,7 @@ dependencies = [
|
|||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"rustls",
|
"rustls",
|
||||||
"socket2",
|
"socket2",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -1390,6 +1493,7 @@ version = "0.11.13"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"bytes",
|
"bytes",
|
||||||
"getrandom 0.3.4",
|
"getrandom 0.3.4",
|
||||||
"lru-slab",
|
"lru-slab",
|
||||||
@@ -1399,7 +1503,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"slab",
|
"slab",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tinyvec",
|
"tinyvec",
|
||||||
"tracing",
|
"tracing",
|
||||||
"web-time",
|
"web-time",
|
||||||
@@ -1509,7 +1613,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
"libredox",
|
"libredox",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1543,9 +1647,9 @@ checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "reqwest"
|
name = "reqwest"
|
||||||
version = "0.12.28"
|
version = "0.13.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
@@ -1564,9 +1668,9 @@ dependencies = [
|
|||||||
"quinn",
|
"quinn",
|
||||||
"rustls",
|
"rustls",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
"rustls-platform-verifier",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
@@ -1579,7 +1683,6 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
"webpki-roots",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1621,14 +1724,26 @@ version = "0.23.36"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"ring",
|
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"rustls-webpki",
|
"rustls-webpki",
|
||||||
"subtle",
|
"subtle",
|
||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-pki-types"
|
name = "rustls-pki-types"
|
||||||
version = "1.14.0"
|
version = "1.14.0"
|
||||||
@@ -1639,12 +1754,40 @@ dependencies = [
|
|||||||
"zeroize",
|
"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]]
|
[[package]]
|
||||||
name = "rustls-webpki"
|
name = "rustls-webpki"
|
||||||
version = "0.103.9"
|
version = "0.103.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"aws-lc-rs",
|
||||||
"ring",
|
"ring",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
"untrusted",
|
"untrusted",
|
||||||
@@ -1683,12 +1826,44 @@ dependencies = [
|
|||||||
"winapi-util",
|
"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]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
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]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.27"
|
version = "1.0.27"
|
||||||
@@ -1722,7 +1897,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1740,11 +1915,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde_spanned"
|
name = "serde_spanned"
|
||||||
version = "0.6.9"
|
version = "1.0.4"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
|
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1869,22 +2044,11 @@ dependencies = [
|
|||||||
"sha2",
|
"sha2",
|
||||||
"tabled",
|
"tabled",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"thiserror",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"toml",
|
"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]]
|
[[package]]
|
||||||
name = "syn"
|
name = "syn"
|
||||||
version = "2.0.115"
|
version = "2.0.115"
|
||||||
@@ -1913,30 +2077,31 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tabled"
|
name = "tabled"
|
||||||
version = "0.17.0"
|
version = "0.20.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c6709222f3973137427ce50559cd564dc187a95b9cfe01613d2f4e93610e510a"
|
checksum = "e39a2ee1fbcd360805a771e1b300f78cc88fec7b8d3e2f71cd37bbf23e725c7d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"papergrid",
|
"papergrid",
|
||||||
"tabled_derive",
|
"tabled_derive",
|
||||||
|
"testing_table",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tabled_derive"
|
name = "tabled_derive"
|
||||||
version = "0.9.0"
|
version = "0.11.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "931be476627d4c54070a1f3a9739ccbfec9b36b39815106a20cce2243bbcefe1"
|
checksum = "0ea5d1b13ca6cff1f9231ffd62f15eefd72543dab5e468735f1a456728a02846"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"heck 0.4.1",
|
"heck",
|
||||||
"proc-macro-error2",
|
"proc-macro-error2",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 1.0.109",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1958,13 +2123,42 @@ version = "0.5.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683"
|
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]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "2.0.18"
|
version = "2.0.18"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -1975,7 +2169,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2038,7 +2232,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2066,44 +2260,42 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml"
|
name = "toml"
|
||||||
version = "0.8.23"
|
version = "1.0.1+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
checksum = "bbe30f93627849fa362d4a602212d41bb237dc2bd0f8ba0b2ce785012e124220"
|
||||||
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"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde",
|
"serde_core",
|
||||||
"serde_spanned",
|
"serde_spanned",
|
||||||
"toml_datetime",
|
"toml_datetime",
|
||||||
"toml_write",
|
"toml_parser",
|
||||||
|
"toml_writer",
|
||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "toml_write"
|
name = "toml_datetime"
|
||||||
version = "0.1.2"
|
version = "1.0.0+spec-1.1.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
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]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
@@ -2345,7 +2537,7 @@ dependencies = [
|
|||||||
"bumpalo",
|
"bumpalo",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
"wasm-bindgen-shared",
|
"wasm-bindgen-shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2382,9 +2574,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasm-streams"
|
name = "wasm-streams"
|
||||||
version = "0.4.2"
|
version = "0.5.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
|
checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -2426,10 +2618,10 @@ dependencies = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-root-certs"
|
||||||
version = "1.0.6"
|
version = "1.0.6"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
]
|
]
|
||||||
@@ -2486,7 +2678,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2497,7 +2689,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2524,6 +2716,15 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@@ -2551,6 +2752,21 @@ dependencies = [
|
|||||||
"windows-link",
|
"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]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -2584,6 +2800,12 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"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]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -2596,6 +2818,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -2608,6 +2836,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -2632,6 +2866,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.42.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -2644,6 +2884,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -2656,6 +2902,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -2668,6 +2920,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
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]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@@ -2685,9 +2943,6 @@ name = "winnow"
|
|||||||
version = "0.7.14"
|
version = "0.7.14"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
@@ -2705,7 +2960,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"heck 0.5.0",
|
"heck",
|
||||||
"wit-parser",
|
"wit-parser",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2716,10 +2971,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"heck 0.5.0",
|
"heck",
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"prettyplease",
|
"prettyplease",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
"wasm-metadata",
|
"wasm-metadata",
|
||||||
"wit-bindgen-core",
|
"wit-bindgen-core",
|
||||||
"wit-component",
|
"wit-component",
|
||||||
@@ -2735,7 +2990,7 @@ dependencies = [
|
|||||||
"prettyplease",
|
"prettyplease",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
"wit-bindgen-core",
|
"wit-bindgen-core",
|
||||||
"wit-bindgen-rust",
|
"wit-bindgen-rust",
|
||||||
]
|
]
|
||||||
@@ -2802,7 +3057,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2823,7 +3078,7 @@ checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2843,7 +3098,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
"synstructure",
|
"synstructure",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2883,7 +3138,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn 2.0.115",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -12,19 +12,19 @@ directories = "6"
|
|||||||
fs2 = "0.4"
|
fs2 = "0.4"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
regex = "1"
|
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 = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
tabled = "0.17"
|
tabled = "0.20"
|
||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
tokio = { version = "1", features = ["full"] }
|
tokio = { version = "1", features = ["full"] }
|
||||||
toml = "0.8"
|
toml = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "2"
|
assert_cmd = "2"
|
||||||
criterion = "0.5"
|
criterion = "0.8"
|
||||||
mockito = "1"
|
mockito = "1"
|
||||||
predicates = "3"
|
predicates = "3"
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
|||||||
427
IMPLEMENTATION_PLAN.md
Normal file
427
IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
# swagger-cli Refactoring Plan: 5 System Weakness Remediations
|
||||||
|
|
||||||
|
## Epic 1: Output Sink Abstraction
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Every command handler takes `robot: bool`, manually branches on it 29+ times, and contains its own formatting logic. The `output/` module is a hollow shell (human.rs is 8 lines). Adding new output formats (YAML is on the roadmap) requires touching every command. Output concerns are tangled into business logic.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Introduce an `OutputMode` enum and an `emit()` function that each command calls once with its typed data. Commands produce data; the output layer decides presentation.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
**New type in `src/output/mod.rs`:**
|
||||||
|
```rust
|
||||||
|
pub enum OutputMode {
|
||||||
|
Robot,
|
||||||
|
Human,
|
||||||
|
// Future: Yaml, Table, etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**New `emit` function pattern:**
|
||||||
|
Each command defines a `CommandOutput` trait impl or uses a generic `emit<T: Serialize + HumanDisplay>()` function. The `HumanDisplay` trait provides the human-readable rendering.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub trait HumanDisplay {
|
||||||
|
fn display_human(&self, w: &mut dyn Write) -> std::io::Result<()>;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn emit<T: Serialize + HumanDisplay>(
|
||||||
|
data: &T,
|
||||||
|
mode: OutputMode,
|
||||||
|
command: &str,
|
||||||
|
duration: Duration,
|
||||||
|
) {
|
||||||
|
match mode {
|
||||||
|
OutputMode::Robot => robot::robot_success(data, command, duration),
|
||||||
|
OutputMode::Human => {
|
||||||
|
let mut stdout = std::io::stdout().lock();
|
||||||
|
data.display_human(&mut stdout).expect("stdout write");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Steps
|
||||||
|
|
||||||
|
1. **Create `OutputMode` enum and `HumanDisplay` trait** in `src/output/mod.rs`
|
||||||
|
2. **Create `emit()` function** in `src/output/mod.rs`
|
||||||
|
3. **Convert `tags` command first** (simplest command, 168 lines) as proof-of-concept:
|
||||||
|
- Impl `HumanDisplay` for `TagsOutput`
|
||||||
|
- Replace `if robot_mode { ... } else { ... }` with `emit(&output, mode, "tags", duration)`
|
||||||
|
- Remove `robot: bool` parameter from `execute()`, replace with `OutputMode`
|
||||||
|
4. **Propagate OutputMode through main.rs** -- replace `let robot = resolve_robot_mode(&cli)` with `let mode = resolve_output_mode(&cli)` returning `OutputMode`
|
||||||
|
5. **Convert remaining simple commands** one at a time (show, schemas, diff, search, cache_cmd, aliases, doctor, list)
|
||||||
|
6. **Convert fetch and sync** (most complex, last)
|
||||||
|
7. **Delete dead code** -- `robot: bool` parameters, inline formatting blocks
|
||||||
|
|
||||||
|
### TDD Plan
|
||||||
|
|
||||||
|
**RED tests to write first (in `src/output/mod.rs` tests):**
|
||||||
|
- `test_emit_robot_mode_produces_valid_json` -- call `emit()` with Robot mode, capture stdout, parse as `RobotEnvelope`
|
||||||
|
- `test_emit_human_mode_produces_readable_text` -- call `emit()` with Human mode, verify no JSON in output
|
||||||
|
- `test_human_display_tags_output` -- verify `TagsOutput::display_human()` produces table with correct headers
|
||||||
|
|
||||||
|
**GREEN:** Implement `OutputMode`, `HumanDisplay`, `emit()` to pass tests.
|
||||||
|
|
||||||
|
**REFACTOR:** Convert each command one at a time, running full test suite between each.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- Zero `if robot` / `if !robot` branches in command handlers
|
||||||
|
- `output/human.rs` contains all human formatting logic (not 8 lines)
|
||||||
|
- Adding a new output format requires zero changes to command handlers
|
||||||
|
- All existing integration tests and golden tests pass unchanged
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `src/output/mod.rs` -- add `OutputMode`, `HumanDisplay`, `emit()`
|
||||||
|
- `src/output/human.rs` -- move all human formatting here
|
||||||
|
- `src/main.rs` -- `resolve_output_mode()` replaces `resolve_robot_mode()`
|
||||||
|
- `src/cli/tags.rs` -- proof of concept conversion
|
||||||
|
- `src/cli/show.rs`, `src/cli/schemas.rs`, `src/cli/diff.rs`, `src/cli/search.rs`, `src/cli/cache_cmd.rs`, `src/cli/aliases.rs`, `src/cli/doctor.rs`, `src/cli/list.rs` -- convert each
|
||||||
|
- `src/cli/fetch.rs`, `src/cli/sync_cmd.rs` -- convert last (most complex)
|
||||||
|
- `src/cli/mod.rs` -- no changes needed (Cli struct keeps `robot` flag, OutputMode resolves from it)
|
||||||
|
|
||||||
|
### Dependency Note
|
||||||
|
This epic should be completed before Epic 2 (sync split) since sync_cmd conversion is part of this work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Epic 2: Split sync_cmd.rs
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
`sync_cmd.rs` is 1,843 lines containing 5+ distinct responsibilities: CLI args, diff computation, checkpoint/resume, per-host rate limiting, concurrent job pool, single-alias sync, batch sync, and output formatting. It's the most complex module and the hardest to reason about.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Split into a `sync/` directory with focused modules while preserving the existing public API (`execute()` function).
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
**New directory: `src/cli/sync/`**
|
||||||
|
|
||||||
|
| File | Responsibility | Lines (est.) |
|
||||||
|
|------|---------------|-------------|
|
||||||
|
| `mod.rs` | Re-exports, `Args` struct, `execute()` entry point | ~100 |
|
||||||
|
| `types.rs` | All Serialize structs: `SyncOutput`, `AliasSyncResult`, `SyncAllOutput`, `ChangeSummary`, `ChangeDetails`, `EndpointKey`, `SchemaDiff`, `EndpointDiff` | ~100 |
|
||||||
|
| `diff.rs` | `compute_diff()`, `endpoint_key()`, `endpoint_fingerprint()` | ~120 |
|
||||||
|
| `checkpoint.rs` | `SyncCheckpoint`, `load_checkpoint()`, `save_checkpoint()`, `remove_checkpoint()` | ~60 |
|
||||||
|
| `throttle.rs` | `PerHostThrottle`, `extract_host()` | ~50 |
|
||||||
|
| `single.rs` | `sync_inner()`, `sync_one_alias()`, `sync_one_alias_inner()`, `output_no_changes()`, `output_changes()` | ~250 |
|
||||||
|
| `batch.rs` | `sync_all_inner()` with concurrent stream logic | ~300 |
|
||||||
|
|
||||||
|
### Implementation Steps
|
||||||
|
|
||||||
|
1. **Create `src/cli/sync/` directory**
|
||||||
|
2. **Move types first** -- extract all `#[derive(Serialize)]` structs into `types.rs`
|
||||||
|
3. **Extract `diff.rs`** -- `compute_diff()`, `endpoint_key()`, `endpoint_fingerprint()`, `MAX_DETAIL_ITEMS`
|
||||||
|
4. **Extract `checkpoint.rs`** -- checkpoint load/save/remove + `CHECKPOINT_FILE` const
|
||||||
|
5. **Extract `throttle.rs`** -- `PerHostThrottle` and `extract_host()`
|
||||||
|
6. **Extract `single.rs`** -- single-alias sync logic and output helpers
|
||||||
|
7. **Extract `batch.rs`** -- `sync_all_inner()` concurrent execution
|
||||||
|
8. **Create `mod.rs`** -- `Args` struct, `execute()`, re-exports
|
||||||
|
9. **Update `src/cli/mod.rs`** -- replace `pub mod sync_cmd` with `pub mod sync`
|
||||||
|
10. **Update `src/main.rs`** -- `Commands::Sync` references
|
||||||
|
|
||||||
|
### TDD Plan
|
||||||
|
|
||||||
|
**This is a pure refactor -- no new behavior.** The TDD approach is:
|
||||||
|
- Run full test suite before starting: `cargo test`
|
||||||
|
- After each extraction step, verify: `cargo test && cargo clippy --all-targets -- -D warnings`
|
||||||
|
- No new tests needed (existing tests in `sync_cmd::tests` move to `sync/diff.rs` tests)
|
||||||
|
|
||||||
|
**Specific test verification after each step:**
|
||||||
|
- `test_diff_no_changes`, `test_diff_added_endpoint`, `test_diff_removed_endpoint`, `test_diff_modified_endpoint`, `test_diff_added_schema`, `test_diff_removed_schema`, `test_diff_endpoint_modified_by_params` -- move to `sync/diff.rs`
|
||||||
|
- Integration tests (`tests/integration_test.rs`) that test `sync` command -- must pass unchanged
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- `sync_cmd.rs` is deleted, replaced by `sync/` directory
|
||||||
|
- No file in `sync/` exceeds 350 lines
|
||||||
|
- All existing tests pass with zero modifications
|
||||||
|
- `pub async fn execute()` signature unchanged
|
||||||
|
- `cargo clippy --all-targets -- -D warnings` passes
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- **Delete:** `src/cli/sync_cmd.rs`
|
||||||
|
- **Create:** `src/cli/sync/mod.rs`, `types.rs`, `diff.rs`, `checkpoint.rs`, `throttle.rs`, `single.rs`, `batch.rs`
|
||||||
|
- **Modify:** `src/cli/mod.rs` (module declaration), `src/main.rs` (import path if needed)
|
||||||
|
|
||||||
|
### Dependency Note
|
||||||
|
Should be done after Epic 1 (output sink) since that will have already simplified the output formatting within sync. If done before Epic 1, the output formatting moves to `single.rs` and `batch.rs` and then needs to be re-extracted during Epic 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Epic 3: spawn_blocking for Cache I/O
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
`CacheManager` uses `std::fs` operations and `fs2::FileExt` (flock) -- all blocking I/O. But command handlers are `async fn` running on tokio. Under `sync --all --jobs=4`, multiple concurrent tasks hit blocking cache I/O, potentially starving the tokio runtime.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Wrap cache operations in `tokio::task::spawn_blocking()`. Create async wrapper methods on a new `AsyncCacheManager` or add async variants directly to `CacheManager`.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
**Option A (recommended): Async wrapper struct**
|
||||||
|
```rust
|
||||||
|
pub struct AsyncCache {
|
||||||
|
inner: CacheManager,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsyncCache {
|
||||||
|
pub async fn load_index(&self, alias: &str) -> Result<(SpecIndex, CacheMetadata), SwaggerCliError> {
|
||||||
|
let inner = self.inner.clone(); // CacheManager is just a PathBuf, cheap
|
||||||
|
let alias = alias.to_string();
|
||||||
|
tokio::task::spawn_blocking(move || inner.load_index(&alias))
|
||||||
|
.await
|
||||||
|
.map_err(|e| SwaggerCliError::Cache(format!("task join error: {e}")))?
|
||||||
|
}
|
||||||
|
// ... same pattern for write_cache, load_raw, list_aliases, etc.
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This preserves the existing sync `CacheManager` for tests and simple cases while providing an async-safe interface for the runtime.
|
||||||
|
|
||||||
|
### Implementation Steps
|
||||||
|
|
||||||
|
1. **Derive `Clone` for `CacheManager`** (it's just a `PathBuf` wrapper -- trivial)
|
||||||
|
2. **Create `AsyncCache` in `src/core/cache.rs`** with async wrappers for each public method:
|
||||||
|
- `load_index()`, `load_raw()`, `write_cache()`, `list_aliases()`, `ensure_dirs()`, `alias_dir()`, `delete_alias_dir()`, `update_last_accessed()`
|
||||||
|
3. **Write tests for `AsyncCache`** verifying it produces identical results to sync `CacheManager`
|
||||||
|
4. **Convert command handlers** to use `AsyncCache` instead of `CacheManager`:
|
||||||
|
- Start with `tags.rs` (simplest)
|
||||||
|
- Then `list.rs`, `show.rs`, `search.rs`, `schemas.rs`
|
||||||
|
- Then `sync_cmd.rs` / `sync/` (most critical -- this is where contention happens)
|
||||||
|
- Then `fetch.rs`, `doctor.rs`, `cache_cmd.rs`, `aliases.rs`
|
||||||
|
|
||||||
|
### TDD Plan
|
||||||
|
|
||||||
|
**RED tests first:**
|
||||||
|
```rust
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_async_cache_load_index_matches_sync() {
|
||||||
|
// Setup: write a spec to cache using sync CacheManager
|
||||||
|
// Act: load via AsyncCache
|
||||||
|
// Assert: result matches sync CacheManager::load_index()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_async_cache_write_then_load_roundtrip() {
|
||||||
|
// Write via AsyncCache, load via AsyncCache, verify integrity
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_async_cache_concurrent_reads_no_panic() {
|
||||||
|
// Spawn 10 concurrent load_index tasks on same alias
|
||||||
|
// All should succeed (shared read lock)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**GREEN:** Implement `AsyncCache` wrapper.
|
||||||
|
|
||||||
|
**REFACTOR:** Convert command handlers one at a time.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- All cache I/O from async command handlers goes through `spawn_blocking`
|
||||||
|
- Existing sync `CacheManager` preserved for non-async tests
|
||||||
|
- `cargo test` passes (including lock_contention_test.rs)
|
||||||
|
- No direct `std::fs` calls from async contexts in command handlers
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- `src/core/cache.rs` -- add `Clone` derive, add `AsyncCache` struct
|
||||||
|
- All `src/cli/*.rs` files -- replace `CacheManager::new()` with `AsyncCache::new()`
|
||||||
|
|
||||||
|
### Dependency Note
|
||||||
|
Independent of Epics 1 and 2. Can be done in parallel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Epic 4: Property Tests for Search Engine and Parser
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Property tests currently cover only 4 trivial properties (hash determinism, JSON roundtrip, index ordering, hash format). The search engine (722 LOC with scoring, tokenization, Unicode snippet handling) and the OpenAPI parser/indexer (659 LOC) have zero property test coverage.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Add targeted property tests using proptest for the search engine and format detection/parsing.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
**New test file: `tests/property_search_test.rs`**
|
||||||
|
|
||||||
|
Property tests for search engine:
|
||||||
|
1. **Score monotonicity:** adding a matching field to an endpoint should never decrease its score
|
||||||
|
2. **Deterministic ordering:** same query + same index = same result order, always
|
||||||
|
3. **Limit respected:** result count <= opts.limit for any query
|
||||||
|
4. **Coverage boost property:** matching N/N terms scores >= matching 1/N terms
|
||||||
|
5. **Case insensitivity:** lowercased query on lowercased data = same results as mixed case
|
||||||
|
6. **Empty query safety:** any whitespace-only string returns empty
|
||||||
|
7. **Unicode safety:** search never panics on arbitrary Unicode input (including emoji, RTL, zero-width chars)
|
||||||
|
8. **Snippet bounds:** snippet length never exceeds 50 chars + ellipsis markers
|
||||||
|
|
||||||
|
**New test file: `tests/property_indexer_test.rs`**
|
||||||
|
|
||||||
|
Property tests for indexer:
|
||||||
|
1. **Format detection idempotent:** detect_format(bytes, hint, ct) called twice = same result
|
||||||
|
2. **JSON sniffing correct:** bytes starting with `{` or `[` always detected as JSON
|
||||||
|
3. **Normalize roundtrip:** normalize_to_json(bytes, Json) preserves semantic content
|
||||||
|
4. **build_index never panics on valid OpenAPI:** generate random valid-ish OpenAPI structures, verify build_index produces a valid SpecIndex
|
||||||
|
5. **Index endpoint count matches paths:** number of IndexedEndpoints = sum of methods across all paths
|
||||||
|
6. **Content hash deterministic:** same bytes -> same hash, every time
|
||||||
|
|
||||||
|
### Implementation Steps
|
||||||
|
|
||||||
|
1. **Create `tests/property_search_test.rs`** with proptest strategies for generating `SpecIndex` and `SearchOptions`
|
||||||
|
2. **Implement search property tests** (8 tests above)
|
||||||
|
3. **Create `tests/property_indexer_test.rs`** with proptest strategies for generating OpenAPI-like JSON
|
||||||
|
4. **Implement indexer property tests** (6 tests above)
|
||||||
|
5. **Run with extended case count** to verify: `cargo test -- --ignored` or `PROPTEST_CASES=1000 cargo test`
|
||||||
|
|
||||||
|
### TDD Plan
|
||||||
|
|
||||||
|
These ARE the tests. Write them, then verify they pass against the existing implementation. If any fail, that's a real bug to fix.
|
||||||
|
|
||||||
|
**Proptest strategy for SpecIndex generation:**
|
||||||
|
```rust
|
||||||
|
fn arb_indexed_endpoint() -> impl Strategy<Value = IndexedEndpoint> {
|
||||||
|
(
|
||||||
|
"/[a-z]{1,5}(/[a-z]{1,5}){0,3}", // path
|
||||||
|
prop_oneof!["GET", "POST", "PUT", "DELETE", "PATCH"], // method
|
||||||
|
proptest::option::of("[A-Za-z ]{1,50}"), // summary
|
||||||
|
proptest::option::of("[A-Za-z ]{1,100}"), // description
|
||||||
|
).prop_map(|(path, method, summary, description)| {
|
||||||
|
IndexedEndpoint { path, method, summary, description, /* ... defaults ... */ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn arb_spec_index() -> impl Strategy<Value = SpecIndex> {
|
||||||
|
proptest::collection::vec(arb_indexed_endpoint(), 0..50)
|
||||||
|
.prop_map(|endpoints| SpecIndex { endpoints, /* ... */ })
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- 14 new property tests (8 search + 6 indexer)
|
||||||
|
- All pass with default proptest case count (256)
|
||||||
|
- No new dependencies (proptest already in dev-deps)
|
||||||
|
- Tests catch at least one real edge case (likely Unicode snippet handling)
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- **Create:** `tests/property_search_test.rs`, `tests/property_indexer_test.rs`
|
||||||
|
- **Possibly modify:** `src/core/search.rs` if property tests reveal bugs
|
||||||
|
|
||||||
|
### Dependency Note
|
||||||
|
Fully independent. Can be done in parallel with all other epics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Epic 5: Shared Command Pipeline
|
||||||
|
|
||||||
|
### Problem
|
||||||
|
Every command handler independently: captures `Instant::now()`, creates `CacheManager::new(cache_dir())`, loads index with `cm.load_index(alias)`, does work, formats output, passes duration. This is ~15-20 lines of identical scaffolding per command. Adding cross-cutting concerns (telemetry, logging, caching headers) requires touching every command.
|
||||||
|
|
||||||
|
### Solution
|
||||||
|
Create a `CommandContext` struct that encapsulates the common setup, and a `run_command()` helper that handles timing and output.
|
||||||
|
|
||||||
|
### Design
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// Shared context created once per command invocation.
|
||||||
|
pub struct CommandContext {
|
||||||
|
pub cache: AsyncCache, // or CacheManager
|
||||||
|
pub mode: OutputMode,
|
||||||
|
pub start: Instant,
|
||||||
|
pub network_policy: NetworkPolicy,
|
||||||
|
pub config_override: Option<PathBuf>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandContext {
|
||||||
|
pub fn new(mode: OutputMode, network_flag: &str, config_override: Option<&Path>) -> Result<Self, SwaggerCliError> {
|
||||||
|
Ok(Self {
|
||||||
|
cache: AsyncCache::new(cache_dir()),
|
||||||
|
mode,
|
||||||
|
start: Instant::now(),
|
||||||
|
network_policy: resolve_policy(network_flag)?,
|
||||||
|
config_override: config_override.map(PathBuf::from),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_index(&self, alias: &str) -> Result<(SpecIndex, CacheMetadata), SwaggerCliError> {
|
||||||
|
self.cache.load_index(alias)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn elapsed(&self) -> Duration {
|
||||||
|
self.start.elapsed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Usage in commands:**
|
||||||
|
```rust
|
||||||
|
pub async fn execute(args: &Args, ctx: &CommandContext) -> Result<(), SwaggerCliError> {
|
||||||
|
let (index, meta) = ctx.load_index(&args.alias)?;
|
||||||
|
let output = build_output(&index);
|
||||||
|
emit(&output, ctx.mode, "tags", ctx.elapsed());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Implementation Steps
|
||||||
|
|
||||||
|
1. **Define `CommandContext` struct** in `src/cli/context.rs`
|
||||||
|
2. **Write tests** for `CommandContext::new()` and helper methods
|
||||||
|
3. **Update `main.rs`** to create `CommandContext` and pass to commands
|
||||||
|
4. **Convert `tags` first** (simplest) as proof of concept
|
||||||
|
5. **Convert all other commands** to accept `&CommandContext`
|
||||||
|
6. **Remove duplicated `CacheManager::new(cache_dir())` + `Instant::now()` from each handler**
|
||||||
|
|
||||||
|
### TDD Plan
|
||||||
|
|
||||||
|
**RED tests first:**
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn test_command_context_creates_valid_cache() {
|
||||||
|
let ctx = CommandContext::new(OutputMode::Robot, "auto", None).unwrap();
|
||||||
|
// cache dir should be the platform default
|
||||||
|
assert!(ctx.cache.cache_dir().exists() || true); // dir may not exist in test
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_context_elapsed_increases() {
|
||||||
|
let ctx = CommandContext::new(OutputMode::Human, "auto", None).unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
assert!(ctx.elapsed().as_millis() >= 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_context_offline_policy() {
|
||||||
|
let ctx = CommandContext::new(OutputMode::Robot, "offline", None).unwrap();
|
||||||
|
assert_eq!(ctx.network_policy, NetworkPolicy::Offline);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
- `CommandContext` created once in `main.rs`, passed to all commands
|
||||||
|
- No command handler creates its own `CacheManager` or `Instant`
|
||||||
|
- Each command's `execute()` signature includes `&CommandContext`
|
||||||
|
- All tests pass unchanged
|
||||||
|
|
||||||
|
### Files Changed
|
||||||
|
- **Create:** `src/cli/context.rs`
|
||||||
|
- **Modify:** `src/cli/mod.rs` (add `pub mod context`)
|
||||||
|
- **Modify:** `src/main.rs` (create CommandContext, pass to dispatch)
|
||||||
|
- **Modify:** All `src/cli/*.rs` execute functions (accept `&CommandContext`)
|
||||||
|
|
||||||
|
### Dependency Note
|
||||||
|
Depends on Epic 1 (OutputMode) and Epic 3 (AsyncCache). Should be done last.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Execution Order
|
||||||
|
|
||||||
|
```
|
||||||
|
Epic 4 (property tests) ─────────────────────────────────────┐
|
||||||
|
Epic 3 (spawn_blocking) ─────────────────────────────────────┤
|
||||||
|
Epic 1 (output sink) ──> Epic 2 (split sync) ──> Epic 5 (pipeline)
|
||||||
|
```
|
||||||
|
|
||||||
|
Epics 3 and 4 are fully independent and can run in parallel with everything.
|
||||||
|
Epic 1 should precede Epic 2 (simplifies sync output formatting before split).
|
||||||
|
Epic 5 depends on Epics 1 and 3 (needs OutputMode and AsyncCache).
|
||||||
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.
|
//! First run establishes baseline. Subsequent runs report regressions.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
|
use std::hint::black_box;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use criterion::{Criterion, criterion_group, criterion_main};
|
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 {
|
fn fixture_path(name: &str) -> PathBuf {
|
||||||
let manifest_dir = env!("CARGO_MANIFEST_DIR");
|
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")
|
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) {
|
fn bench_json_parse(c: &mut Criterion) {
|
||||||
let path = fixture_path("petstore.json");
|
let path = fixture_path("petstore.json");
|
||||||
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
|
let bytes = fs::read(&path).expect("failed to read petstore.json fixture");
|
||||||
|
|
||||||
c.bench_function("parse_petstore_json", |b| {
|
c.bench_function("parse_petstore_json", |b| {
|
||||||
b.iter(|| {
|
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();
|
let raw_json = load_petstore_json();
|
||||||
|
|
||||||
c.bench_function("build_index_petstore", |b| {
|
c.bench_function("build_index_real", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| {
|
||||||
// Simulate endpoint extraction (mirrors build_index core logic)
|
let index =
|
||||||
if let Some(paths) = raw_json.get("paths").and_then(|v| v.as_object()) {
|
build_index(black_box(&raw_json), "sha256:bench", 1).expect("build_index failed");
|
||||||
let mut endpoints = Vec::new();
|
black_box(index);
|
||||||
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());
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -62,8 +64,9 @@ fn bench_hash_computation(c: &mut Criterion) {
|
|||||||
b.iter(|| {
|
b.iter(|| {
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(&bytes);
|
hasher.update(black_box(&bytes));
|
||||||
let _result = format!("sha256:{:x}", hasher.finalize());
|
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| {
|
c.bench_function("json_pointer_resolution", |b| {
|
||||||
b.iter(|| {
|
b.iter(|| {
|
||||||
let _ = raw_json
|
let a = raw_json
|
||||||
.pointer("/paths/~1pets/get/summary")
|
.pointer("/paths/~1pets/get/summary")
|
||||||
.map(|v| v.as_str());
|
.map(|v| v.as_str());
|
||||||
let _ = raw_json
|
let b_val = raw_json
|
||||||
.pointer("/components/schemas/Pet")
|
.pointer("/components/schemas/Pet")
|
||||||
.map(|v| v.is_object());
|
.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) {
|
fn bench_search_real(c: &mut Criterion) {
|
||||||
let raw_json = load_petstore_json();
|
let index = load_petstore_index();
|
||||||
|
let engine = SearchEngine::new(&index);
|
||||||
|
let opts = SearchOptions::default();
|
||||||
|
|
||||||
// Pre-extract summaries for search simulation
|
c.bench_function("search_real_pet", |b| {
|
||||||
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";
|
|
||||||
b.iter(|| {
|
b.iter(|| {
|
||||||
let mut matches = 0u32;
|
let results = engine.search(black_box("pet"), &opts);
|
||||||
for summary in &summaries {
|
black_box(results);
|
||||||
if summary.contains(query) {
|
});
|
||||||
matches += 1;
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
assert!(matches > 0);
|
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!(
|
criterion_group!(
|
||||||
benches,
|
benches,
|
||||||
bench_json_parse,
|
bench_json_parse,
|
||||||
bench_build_index,
|
bench_build_index_real,
|
||||||
bench_hash_computation,
|
bench_hash_computation,
|
||||||
bench_json_pointer_resolution,
|
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);
|
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");
|
let meta_path = new_dir.join("meta.json");
|
||||||
if let Ok(bytes) = std::fs::read(&meta_path)
|
let meta_bytes = std::fs::read(&meta_path).map_err(|e| {
|
||||||
&& let Ok(mut meta) = serde_json::from_slice::<CacheMetadata>(&bytes)
|
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();
|
meta.alias = new.clone();
|
||||||
if let Ok(updated_bytes) = serde_json::to_vec_pretty(&meta) {
|
let updated_bytes = serde_json::to_vec_pretty(&meta).map_err(|e| {
|
||||||
let _ = std::fs::write(&meta_path, updated_bytes);
|
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
|
// Update config if old was the default
|
||||||
let cfg_path = config_path(None);
|
let cfg_path = config_path(None);
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ pub struct Args {
|
|||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
pub prune_stale: bool,
|
pub prune_stale: bool,
|
||||||
|
|
||||||
/// Days before an alias is considered stale (default: 90)
|
/// Days before an alias is considered stale (default: 30, matching config)
|
||||||
#[arg(long, default_value_t = 90)]
|
#[arg(long, default_value_t = 30)]
|
||||||
pub prune_threshold: u32,
|
pub prune_threshold: u32,
|
||||||
|
|
||||||
/// Evict least-recently-used aliases until total size is under this limit (MB)
|
/// 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",
|
"cache",
|
||||||
start.elapsed(),
|
start.elapsed(),
|
||||||
);
|
);
|
||||||
} else if stale.is_empty() {
|
} else if pruned.is_empty() {
|
||||||
println!(
|
println!(
|
||||||
"No stale aliases (threshold: {} days).",
|
"No stale aliases (threshold: {} days).",
|
||||||
args.prune_threshold
|
args.prune_threshold
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
println!("Pruned {} stale alias(es).", stale.len());
|
println!("Pruned {} stale alias(es).", pruned.len());
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,6 +79,26 @@ pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliErro
|
|||||||
|
|
||||||
let has_breaking = result.summary.has_breaking;
|
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 {
|
if robot_mode {
|
||||||
let output = DiffOutput {
|
let output = DiffOutput {
|
||||||
left: args.left.clone(),
|
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(())
|
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> {
|
pub async fn execute(args: &Args, robot_mode: bool) -> Result<(), SwaggerCliError> {
|
||||||
let start = Instant::now();
|
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 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
|
// Check config dir exists
|
||||||
let mut warnings: Vec<String> = Vec::new();
|
|
||||||
if let Some(parent) = cfg_path.parent()
|
if let Some(parent) = cfg_path.parent()
|
||||||
&& !parent.exists()
|
&& !parent.exists()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -290,8 +290,7 @@ async fn fetch_inner(
|
|||||||
Format::Yaml => "yaml",
|
Format::Yaml => "yaml",
|
||||||
};
|
};
|
||||||
|
|
||||||
let json_bytes = normalize_to_json(&raw_bytes, format)?;
|
let (_json_bytes, mut value) = normalize_to_json(&raw_bytes, format)?;
|
||||||
let mut value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
|
|
||||||
|
|
||||||
// External ref resolution (optional)
|
// External ref resolution (optional)
|
||||||
if args.resolve_external_refs {
|
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
|
// Re-serialize the (possibly bundled) value to get the final json_bytes
|
||||||
let json_bytes = serde_json::to_vec(&value)?;
|
let json_bytes = serde_json::to_vec(&value)?;
|
||||||
|
|
||||||
// Compute content hash for indexing
|
// Compute content hash from the final json_bytes (post-resolution), not
|
||||||
let content_hash = compute_hash(&raw_bytes);
|
// 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
|
// Determine generation: if overwriting, increment previous generation
|
||||||
let previous_generation = if args.force && cm.alias_exists(&args.alias) {
|
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();
|
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| {
|
all_entries.sort_by(|a, b| {
|
||||||
a.alias
|
a.alias
|
||||||
.cmp(&b.alias)
|
.cmp(&b.alias)
|
||||||
.then_with(|| a.path.cmp(&b.path))
|
.then_with(|| a.path.cmp(&b.path))
|
||||||
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
.then_with(|| method_rank(&a.method).cmp(&method_rank(&b.method)))
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Limit ----
|
// ---- Limit ----
|
||||||
if !args.all {
|
if !args.all {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod diff;
|
|||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
pub mod fetch;
|
pub mod fetch;
|
||||||
pub mod list;
|
pub mod list;
|
||||||
|
pub mod robot_docs;
|
||||||
pub mod schemas;
|
pub mod schemas;
|
||||||
pub mod search;
|
pub mod search;
|
||||||
pub mod show;
|
pub mod show;
|
||||||
@@ -21,10 +22,14 @@ pub struct Cli {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Commands,
|
pub command: Commands,
|
||||||
|
|
||||||
/// Output machine-readable JSON
|
/// Output machine-readable JSON (alias: --json)
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true, visible_alias = "json")]
|
||||||
pub robot: bool,
|
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
|
/// Pretty-print JSON output
|
||||||
#[arg(long, global = true)]
|
#[arg(long, global = true)]
|
||||||
pub pretty: bool,
|
pub pretty: bool,
|
||||||
@@ -44,12 +49,15 @@ pub enum Commands {
|
|||||||
Fetch(fetch::Args),
|
Fetch(fetch::Args),
|
||||||
|
|
||||||
/// List endpoints from a cached spec
|
/// List endpoints from a cached spec
|
||||||
|
#[command(visible_alias = "ls")]
|
||||||
List(list::Args),
|
List(list::Args),
|
||||||
|
|
||||||
/// Show details of a specific endpoint
|
/// Show details of a specific endpoint
|
||||||
|
#[command(visible_alias = "info")]
|
||||||
Show(show::Args),
|
Show(show::Args),
|
||||||
|
|
||||||
/// Search endpoints by keyword
|
/// Search endpoints by keyword
|
||||||
|
#[command(visible_alias = "find")]
|
||||||
Search(search::Args),
|
Search(search::Args),
|
||||||
|
|
||||||
/// List or show schemas from a cached spec
|
/// List or show schemas from a cached spec
|
||||||
@@ -72,4 +80,8 @@ pub enum Commands {
|
|||||||
|
|
||||||
/// Compare two versions of a spec
|
/// Compare two versions of a spec
|
||||||
Diff(diff::Args),
|
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);
|
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")
|
.get("parameters")
|
||||||
|
.and_then(Value::as_array)
|
||||||
.cloned()
|
.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();
|
let request_body = operation.get("requestBody").cloned();
|
||||||
|
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ struct AliasSyncResult {
|
|||||||
remote_version: Option<String>,
|
remote_version: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
changes: Option<ChangeSummary>,
|
changes: Option<ChangeSummary>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
details: Option<ChangeDetails>,
|
||||||
duration_ms: u64,
|
duration_ms: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,13 +203,15 @@ fn remove_checkpoint(cache_path: &std::path::Path) {
|
|||||||
// Index diffing
|
// 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) {
|
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.
|
/// 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 {
|
fn endpoint_fingerprint(ep: &crate::core::spec::IndexedEndpoint) -> String {
|
||||||
let params: Vec<String> = ep
|
let params: Vec<String> = ep
|
||||||
.parameters
|
.parameters
|
||||||
@@ -216,12 +220,16 @@ fn endpoint_fingerprint(ep: &crate::core::spec::IndexedEndpoint) -> String {
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
"{}|{}|{}|{}|{}",
|
"{}|{}|{}|{}|{}|{}|{}|{}|{}",
|
||||||
ep.summary.as_deref().unwrap_or(""),
|
ep.summary.as_deref().unwrap_or(""),
|
||||||
ep.deprecated,
|
ep.deprecated,
|
||||||
params.join(","),
|
params.join(","),
|
||||||
ep.request_body_required,
|
ep.request_body_required,
|
||||||
ep.request_body_content_types.join(","),
|
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,
|
local_version: None,
|
||||||
remote_version: None,
|
remote_version: None,
|
||||||
changes: None,
|
changes: None,
|
||||||
|
details: None,
|
||||||
duration_ms: start.elapsed().as_millis().min(u64::MAX as u128) as u64,
|
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()),
|
local_version: Some(meta.spec_version.clone()),
|
||||||
remote_version: None,
|
remote_version: None,
|
||||||
changes: None,
|
changes: None,
|
||||||
|
details: None,
|
||||||
duration_ms: elapsed_ms(),
|
duration_ms: elapsed_ms(),
|
||||||
}),
|
}),
|
||||||
ConditionalFetchResult::Modified(result) => {
|
ConditionalFetchResult::Modified(result) => {
|
||||||
@@ -478,6 +488,7 @@ async fn sync_one_alias_inner(
|
|||||||
local_version: Some(meta.spec_version.clone()),
|
local_version: Some(meta.spec_version.clone()),
|
||||||
remote_version: None,
|
remote_version: None,
|
||||||
changes: None,
|
changes: None,
|
||||||
|
details: None,
|
||||||
duration_ms: elapsed_ms(),
|
duration_ms: elapsed_ms(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -488,11 +499,10 @@ async fn sync_one_alias_inner(
|
|||||||
Format::Yaml => "yaml",
|
Format::Yaml => "yaml",
|
||||||
};
|
};
|
||||||
|
|
||||||
let json_bytes = normalize_to_json(&result.bytes, format)?;
|
let (json_bytes, value) = normalize_to_json(&result.bytes, format)?;
|
||||||
let value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
|
|
||||||
let new_index = build_index(&value, &new_content_hash, meta.generation + 1)?;
|
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
|
let has_changes = summary.endpoints_added > 0
|
||||||
|| summary.endpoints_removed > 0
|
|| summary.endpoints_removed > 0
|
||||||
@@ -533,6 +543,7 @@ async fn sync_one_alias_inner(
|
|||||||
local_version: Some(meta.spec_version.clone()),
|
local_version: Some(meta.spec_version.clone()),
|
||||||
remote_version: Some(new_index.info.version.clone()),
|
remote_version: Some(new_index.info.version.clone()),
|
||||||
changes: if include_details { Some(summary) } else { None },
|
changes: if include_details { Some(summary) } else { None },
|
||||||
|
details: if include_details { Some(details) } else { None },
|
||||||
duration_ms: elapsed_ms(),
|
duration_ms: elapsed_ms(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -573,6 +584,7 @@ async fn sync_inner(
|
|||||||
let cfg = Config::load(&config_path(config_override))?;
|
let cfg = Config::load(&config_path(config_override))?;
|
||||||
let mut builder = AsyncHttpClient::builder()
|
let mut builder = AsyncHttpClient::builder()
|
||||||
.allow_insecure_http(url.starts_with("http://"))
|
.allow_insecure_http(url.starts_with("http://"))
|
||||||
|
.allowed_private_hosts(args.allow_private_host.clone())
|
||||||
.network_policy(network_policy);
|
.network_policy(network_policy);
|
||||||
|
|
||||||
if let Some(profile_name) = &args.auth {
|
if let Some(profile_name) = &args.auth {
|
||||||
@@ -637,8 +649,7 @@ async fn sync_inner(
|
|||||||
Format::Yaml => "yaml",
|
Format::Yaml => "yaml",
|
||||||
};
|
};
|
||||||
|
|
||||||
let json_bytes = normalize_to_json(&result.bytes, format)?;
|
let (json_bytes, value) = normalize_to_json(&result.bytes, format)?;
|
||||||
let value: serde_json::Value = serde_json::from_slice(&json_bytes)?;
|
|
||||||
let new_index = build_index(&value, &new_content_hash, meta.generation + 1)?;
|
let new_index = build_index(&value, &new_content_hash, meta.generation + 1)?;
|
||||||
|
|
||||||
// 6. Compute diff
|
// 6. Compute diff
|
||||||
@@ -821,12 +832,6 @@ async fn sync_all_inner(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let total = aliases.len();
|
let total = aliases.len();
|
||||||
let _skipped_from_resume = if args.resume {
|
|
||||||
total - to_sync.len()
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Handle empty aliases
|
// Handle empty aliases
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
let output = SyncAllOutput {
|
let output = SyncAllOutput {
|
||||||
@@ -890,6 +895,7 @@ async fn sync_all_inner(
|
|||||||
local_version: None,
|
local_version: None,
|
||||||
remote_version: None,
|
remote_version: None,
|
||||||
changes: None,
|
changes: None,
|
||||||
|
details: None,
|
||||||
duration_ms: 0,
|
duration_ms: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -911,6 +917,7 @@ async fn sync_all_inner(
|
|||||||
local_version: None,
|
local_version: None,
|
||||||
remote_version: None,
|
remote_version: None,
|
||||||
changes: None,
|
changes: None,
|
||||||
|
details: None,
|
||||||
duration_ms: 0,
|
duration_ms: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -972,6 +979,7 @@ async fn sync_all_inner(
|
|||||||
local_version: None,
|
local_version: None,
|
||||||
remote_version: None,
|
remote_version: None,
|
||||||
changes: None,
|
changes: None,
|
||||||
|
details: None,
|
||||||
duration_ms: 0,
|
duration_ms: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -985,6 +993,7 @@ async fn sync_all_inner(
|
|||||||
local_version: None,
|
local_version: None,
|
||||||
remote_version: None,
|
remote_version: None,
|
||||||
changes: None,
|
changes: None,
|
||||||
|
details: None,
|
||||||
duration_ms: 0,
|
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"));
|
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> {
|
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;
|
let pattern = &*ALIAS_PATTERN;
|
||||||
|
|
||||||
if !pattern.is_match(alias) {
|
if !pattern.is_match(alias) {
|
||||||
@@ -60,24 +63,8 @@ pub fn validate_alias(alias: &str) -> Result<(), SwaggerCliError> {
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if alias.contains('/') || alias.contains('\\') {
|
// Reject Windows reserved device names (CON, PRN, NUL, COM1-9, LPT1-9)
|
||||||
return Err(SwaggerCliError::Usage(format!(
|
// even on Unix for cross-platform cache portability.
|
||||||
"Invalid alias '{alias}': path separators not allowed"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if alias.contains("..") {
|
|
||||||
return Err(SwaggerCliError::Usage(format!(
|
|
||||||
"Invalid alias '{alias}': directory traversal not allowed"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
if alias.starts_with('.') {
|
|
||||||
return Err(SwaggerCliError::Usage(format!(
|
|
||||||
"Invalid alias '{alias}': leading dot not allowed"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
|
|
||||||
let stem = alias.split('.').next().unwrap_or(alias);
|
let stem = alias.split('.').next().unwrap_or(alias);
|
||||||
let reserved = [
|
let reserved = [
|
||||||
"CON", "PRN", "NUL", "AUX", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
"CON", "PRN", "NUL", "AUX", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8",
|
||||||
|
|||||||
@@ -126,14 +126,13 @@ fn resolve_recursive<'a>(
|
|||||||
Some(&resolved_url),
|
Some(&resolved_url),
|
||||||
result.content_type.as_deref(),
|
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!(
|
SwaggerCliError::InvalidSpec(format!(
|
||||||
"external ref '{resolved_url}' returned invalid JSON/YAML"
|
"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
|
// Handle fragment pointer within the fetched document
|
||||||
if let Some(frag) = parsed.fragment()
|
if let Some(frag) = parsed.fragment()
|
||||||
&& !frag.is_empty()
|
&& !frag.is_empty()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ fn is_ip_blocked(ip: &IpAddr) -> bool {
|
|||||||
|| v6.is_unspecified() // ::
|
|| v6.is_unspecified() // ::
|
||||||
|| v6.is_multicast() // ff00::/8
|
|| v6.is_multicast() // ff00::/8
|
||||||
|| is_link_local_v6(v6) // fe80::/10
|
|| is_link_local_v6(v6) // fe80::/10
|
||||||
|
|| is_unique_local_v6(v6) // fc00::/7 (IPv6 private)
|
||||||
|| is_blocked_mapped_v4(v6)
|
|| 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]))
|
|| (octets[0] == 172 && (16..=31).contains(&octets[1]))
|
||||||
// 192.168.0.0/16
|
// 192.168.0.0/16
|
||||||
|| (octets[0] == 192 && octets[1] == 168)
|
|| (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 {
|
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
|
(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 {
|
fn is_blocked_mapped_v4(v6: &std::net::Ipv6Addr) -> bool {
|
||||||
// ::ffff:x.x.x.x — IPv4-mapped IPv6
|
// ::ffff:x.x.x.x — IPv4-mapped IPv6
|
||||||
let segments = v6.segments();
|
let segments = v6.segments();
|
||||||
|
|||||||
@@ -39,27 +39,37 @@ pub fn detect_format(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content sniffing: try JSON first (stricter), fall back to YAML.
|
// Content sniffing: check the first non-whitespace byte. Valid JSON
|
||||||
if serde_json::from_slice::<serde_json::Value>(bytes).is_ok() {
|
// documents start with '{' or '['. This avoids a full JSON parse just
|
||||||
Format::Json
|
// to detect format — a ~300x speedup for the common case.
|
||||||
} else {
|
let first_meaningful = bytes.iter().find(|b| !b.is_ascii_whitespace());
|
||||||
Format::Yaml
|
match first_meaningful {
|
||||||
|
Some(b'{') | Some(b'[') => Format::Json,
|
||||||
|
_ => Format::Yaml,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// If the input is YAML, parse then re-serialize as JSON.
|
/// Normalize raw bytes to canonical JSON, returning both the bytes and parsed value.
|
||||||
/// If JSON, validate it parses.
|
///
|
||||||
pub fn normalize_to_json(bytes: &[u8], format: Format) -> Result<Vec<u8>, SwaggerCliError> {
|
/// 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 {
|
match format {
|
||||||
Format::Json => {
|
Format::Json => {
|
||||||
let _: serde_json::Value = serde_json::from_slice(bytes)?;
|
let value: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||||
Ok(bytes.to_vec())
|
Ok((bytes.to_vec(), value))
|
||||||
}
|
}
|
||||||
Format::Yaml => {
|
Format::Yaml => {
|
||||||
let value: serde_json::Value = serde_yaml::from_slice(bytes)
|
let value: serde_json::Value = serde_yaml::from_slice(bytes)
|
||||||
.map_err(|e| SwaggerCliError::InvalidSpec(format!("YAML parse error: {e}")))?;
|
.map_err(|e| SwaggerCliError::InvalidSpec(format!("YAML parse error: {e}")))?;
|
||||||
let json_bytes = serde_json::to_vec(&value)?;
|
let json_bytes = serde_json::to_vec(&value)?;
|
||||||
Ok(json_bytes)
|
Ok((json_bytes, value))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -418,8 +428,9 @@ info:
|
|||||||
version: "1.0"
|
version: "1.0"
|
||||||
paths: {}
|
paths: {}
|
||||||
"#;
|
"#;
|
||||||
let json_bytes = normalize_to_json(yaml, Format::Yaml).unwrap();
|
let (json_bytes, parsed) = normalize_to_json(yaml, Format::Yaml).unwrap();
|
||||||
let parsed: serde_json::Value = serde_json::from_slice(&json_bytes).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["openapi"], "3.0.0");
|
||||||
assert_eq!(parsed["info"]["title"], "Test API");
|
assert_eq!(parsed["info"]["title"], "Test API");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,6 +94,13 @@ impl<'a> SearchEngine<'a> {
|
|||||||
let terms = tokenize(query, opts.exact);
|
let terms = tokenize(query, opts.exact);
|
||||||
let total_terms = terms.len();
|
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();
|
let mut results: Vec<SearchResult> = Vec::new();
|
||||||
|
|
||||||
// Search endpoints
|
// Search endpoints
|
||||||
@@ -103,10 +110,34 @@ impl<'a> SearchEngine<'a> {
|
|||||||
let mut matched_terms: usize = 0;
|
let mut matched_terms: usize = 0;
|
||||||
let mut matches: Vec<Match> = Vec::new();
|
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;
|
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;
|
raw_score += WEIGHT_PATH;
|
||||||
matches.push(Match {
|
matches.push(Match {
|
||||||
field: "path".into(),
|
field: "path".into(),
|
||||||
@@ -114,11 +145,17 @@ impl<'a> SearchEngine<'a> {
|
|||||||
});
|
});
|
||||||
term_matched = true;
|
term_matched = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (opts.search_descriptions || opts.search_paths)
|
if (opts.search_descriptions || opts.search_paths)
|
||||||
&& let Some(ref summary) = ep.summary
|
&& 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;
|
raw_score += WEIGHT_SUMMARY;
|
||||||
matches.push(Match {
|
matches.push(Match {
|
||||||
field: "summary".into(),
|
field: "summary".into(),
|
||||||
@@ -126,11 +163,17 @@ impl<'a> SearchEngine<'a> {
|
|||||||
});
|
});
|
||||||
term_matched = true;
|
term_matched = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if opts.search_descriptions
|
if opts.search_descriptions
|
||||||
&& let Some(ref desc) = ep.description
|
&& 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;
|
raw_score += WEIGHT_DESCRIPTION;
|
||||||
matches.push(Match {
|
matches.push(Match {
|
||||||
field: "description".into(),
|
field: "description".into(),
|
||||||
@@ -138,6 +181,7 @@ impl<'a> SearchEngine<'a> {
|
|||||||
});
|
});
|
||||||
term_matched = true;
|
term_matched = true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if term_matched {
|
if term_matched {
|
||||||
matched_terms += 1;
|
matched_terms += 1;
|
||||||
@@ -169,8 +213,20 @@ impl<'a> SearchEngine<'a> {
|
|||||||
let mut matched_terms: usize = 0;
|
let mut matched_terms: usize = 0;
|
||||||
let mut matches: Vec<Match> = Vec::new();
|
let mut matches: Vec<Match> = Vec::new();
|
||||||
|
|
||||||
for term in &terms {
|
let name_lc = if !opts.case_sensitive {
|
||||||
if contains_term(&schema.name, term, 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;
|
raw_score += WEIGHT_SCHEMA_NAME;
|
||||||
matches.push(Match {
|
matches.push(Match {
|
||||||
field: "schema_name".into(),
|
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
|
/// Build a Unicode-safe snippet around the first occurrence of `needle` in
|
||||||
/// `haystack`. The context window is 50 characters. Ellipses are added when
|
/// `haystack`. The context window is 50 characters. Ellipses are added when
|
||||||
/// the snippet is truncated.
|
/// the snippet is truncated.
|
||||||
fn safe_snippet(haystack: &str, needle: &str, case_sensitive: bool) -> String {
|
fn safe_snippet(haystack: &str, needle: &str, case_sensitive: bool) -> String {
|
||||||
let (h_search, n_search) = if case_sensitive {
|
// Find the match position using char-based search to avoid byte-position
|
||||||
(haystack.to_string(), needle.to_string())
|
// mismatches between the original and lowercased strings (which can differ
|
||||||
} else {
|
// in byte length for certain Unicode characters, causing panics).
|
||||||
(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();
|
|
||||||
let haystack_chars: Vec<char> = haystack.chars().collect();
|
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();
|
let total_chars = haystack_chars.len();
|
||||||
|
|
||||||
const WINDOW: usize = 50;
|
const WINDOW: usize = 50;
|
||||||
|
|||||||
58
src/main.rs
58
src/main.rs
@@ -1,5 +1,6 @@
|
|||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
|
|
||||||
|
use std::io::IsTerminal;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
@@ -9,8 +10,51 @@ use swagger_cli::cli::{Cli, Commands};
|
|||||||
use swagger_cli::errors::SwaggerCliError;
|
use swagger_cli::errors::SwaggerCliError;
|
||||||
use swagger_cli::output::robot;
|
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 {
|
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 {
|
fn command_name(cli: &Cli) -> &'static str {
|
||||||
@@ -26,6 +70,7 @@ fn command_name(cli: &Cli) -> &'static str {
|
|||||||
Commands::Doctor(_) => "doctor",
|
Commands::Doctor(_) => "doctor",
|
||||||
Commands::Cache(_) => "cache",
|
Commands::Cache(_) => "cache",
|
||||||
Commands::Diff(_) => "diff",
|
Commands::Diff(_) => "diff",
|
||||||
|
Commands::RobotDocs(_) => "robot-docs",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +96,12 @@ async fn main() -> ExitCode {
|
|||||||
let cli = match Cli::try_parse() {
|
let cli = match Cli::try_parse() {
|
||||||
Ok(cli) => cli,
|
Ok(cli) => cli,
|
||||||
Err(err) => {
|
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());
|
let parse_err = SwaggerCliError::Usage(err.to_string());
|
||||||
output_robot_error(&parse_err, "unknown", start.elapsed());
|
output_robot_error(&parse_err, "unknown", start.elapsed());
|
||||||
return parse_err.to_exit_code();
|
return parse_err.to_exit_code();
|
||||||
@@ -61,7 +111,8 @@ async fn main() -> ExitCode {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let cmd = command_name(&cli);
|
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 network_flag = cli.network.as_str();
|
||||||
let config_override = cli.config.as_deref();
|
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::Doctor(args) => swagger_cli::cli::doctor::execute(args, robot).await,
|
||||||
Commands::Cache(args) => swagger_cli::cli::cache_cmd::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::Diff(args) => swagger_cli::cli::diff::execute(args, robot).await,
|
||||||
|
Commands::RobotDocs(args) => swagger_cli::cli::robot_docs::execute(args, pretty),
|
||||||
};
|
};
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
|
|||||||
@@ -30,6 +30,13 @@ pub fn robot_success<T: Serialize>(data: T, command: &str, duration: Duration) {
|
|||||||
println!("{json}");
|
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(
|
pub fn robot_error(
|
||||||
code: &str,
|
code: &str,
|
||||||
message: &str,
|
message: &str,
|
||||||
|
|||||||
BIN
tests/.DS_Store
vendored
Normal file
BIN
tests/.DS_Store
vendored
Normal file
Binary file not shown.
Reference in New Issue
Block a user