diff --git a/.gitignore b/.gitignore index ec0a0742..4c045323 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,9 @@ /node_modules/ -# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# `command-signatures` now builds an executable (`command-signatures list`), so per the note +# below, Cargo.lock is tracked instead of ignored. # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html -Cargo.lock # These are backup files generated by rustfmt **/*.rs.bk diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..dfb69e7b --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1265 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html-escape" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rust-embed" +version = "6.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a36224c3276f8c4ebc8c20f158eca7ca4359c8db89991c4925132aaaf6702661" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "6.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b94b81e5b2c284684141a2fb9e2a31be90638caf040bf9afbc5a0416afe1ac" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "7.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d38ff6bf570dc3bb7100fce9f7b60c33fa71d80e88da3f2580df4ff2bdded74" +dependencies = [ + "globset", + "sha2", + "walkdir", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.1", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "warp-command-signatures" +version = "0.0.0" +dependencies = [ + "cfg-if", + "clap", + "itertools", + "lazy_static", + "log", + "rayon", + "regex", + "rust-embed", + "serde", + "serde_json", + "serde_with", + "tempfile", + "warp-completion-metadata", +] + +[[package]] +name = "warp-completion-metadata" +version = "0.0.0" +dependencies = [ + "html-escape", + "itertools", + "log", + "serde", + "serde_json", + "serde_with", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/command-signatures/Cargo.toml b/command-signatures/Cargo.toml index 456f6bff..1f1df778 100644 --- a/command-signatures/Cargo.toml +++ b/command-signatures/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true [dependencies] cfg-if = "1.0.0" +clap = { version = "4", features = ["derive"] } itertools = "0.10.0" lazy_static = "1.4.0" log = "0.4" @@ -19,6 +20,14 @@ serde_json = "1.0" serde_with = "^3.21" warp-completion-metadata = {path = "../completion-metadata"} +[dev-dependencies] +tempfile = "3" + [features] default = ["embed-signatures"] embed-signatures = ["rayon", "rust-embed"] + +[[bin]] +name = "command-signatures" +path = "src/bin/command-signatures.rs" +required-features = ["embed-signatures"] diff --git a/command-signatures/src/bin/command-signatures.rs b/command-signatures/src/bin/command-signatures.rs new file mode 100644 index 00000000..b5158ac7 --- /dev/null +++ b/command-signatures/src/bin/command-signatures.rs @@ -0,0 +1,114 @@ +//! A thin CLI over `warp_command_signatures`'s reusable listing API. + +use std::io::{self, Write}; +use std::path::PathBuf; +use std::process::ExitCode; + +use clap::{Parser, Subcommand}; + +use warp_command_signatures::{list_signatures, SignatureSource, SignatureSummary}; + +#[derive(Parser)] +#[command( + name = "command-signatures", + about = "Utilities for working with Warp command completion signatures" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + /// List command signatures as a text table or a JSON array. + List { + /// Read signatures from this Fig-compatible JSON file instead of the embedded + /// repository signatures. Replaces the embedded source rather than merging with it. + #[arg(long)] + file: Option, + + /// Emit a JSON array instead of a text table. + #[arg(long)] + json: bool, + }, +} + +fn main() -> ExitCode { + match Cli::parse().command { + Command::List { file, json } => run_list(file, json), + } +} + +fn run_list(file: Option, json: bool) -> ExitCode { + let source = match file { + Some(path) => SignatureSource::File(path), + None => SignatureSource::Embedded, + }; + + let summaries = match list_signatures(source) { + Ok(summaries) => summaries, + Err(err) => { + eprintln!("error: {err}"); + return ExitCode::FAILURE; + } + }; + + let render_result = if json { + render_json(&summaries) + } else { + render_text(&summaries) + }; + + // A closed output pipe (e.g. piping into `head`) should terminate cleanly rather than + // panicking on a broken-pipe write error. + match render_result { + Ok(()) => ExitCode::SUCCESS, + Err(_) => ExitCode::SUCCESS, + } +} + +fn render_text(summaries: &[SignatureSummary]) -> io::Result<()> { + let stdout = io::stdout(); + let mut out = stdout.lock(); + + if summaries.is_empty() { + return writeln!(out, "No signatures found."); + } + + writeln!(out, "NAME\tSUBCOMMANDS\tDESCRIPTION")?; + for summary in summaries { + let name = normalize_text_field(&summary.name); + let description = summary + .description + .as_deref() + .map(normalize_text_field) + .unwrap_or_default(); + writeln!(out, "{name}\t{}\t{description}", summary.subcommand_count)?; + } + Ok(()) +} + +/// Replaces every control character (including tabs, carriage returns, newlines, ESC, vertical +/// tab, form feed, and other C0/C1 controls) plus the Unicode line separator (U+2028) and +/// paragraph separator (U+2029) with a space. This is more than TSV framing strictly requires: +/// it also blocks terminal escape sequences and other layout-affecting characters an externally +/// supplied name or description could otherwise inject into the text output. +fn normalize_text_field(field: &str) -> String { + field + .chars() + .map(|c| { + if c.is_control() || c == '\u{2028}' || c == '\u{2029}' { + ' ' + } else { + c + } + }) + .collect() +} + +fn render_json(summaries: &[SignatureSummary]) -> io::Result<()> { + let stdout = io::stdout(); + let mut out = stdout.lock(); + serde_json::to_writer(&mut out, summaries).map_err(io::Error::from)?; + writeln!(out) +} diff --git a/command-signatures/src/lib.rs b/command-signatures/src/lib.rs index b0a74f1d..9d0fbdf7 100644 --- a/command-signatures/src/lib.rs +++ b/command-signatures/src/lib.rs @@ -1,10 +1,14 @@ #[cfg(feature = "embed-signatures")] mod assets; mod generators; +#[cfg(feature = "embed-signatures")] +pub mod listing; pub mod overrides; pub mod powershell_autogenerator; pub use generators::dynamic_command_signature_data; +#[cfg(feature = "embed-signatures")] +pub use listing::{list_signatures, ListSignaturesError, SignatureSource, SignatureSummary}; #[cfg(feature = "embed-signatures")] use assets::Assets; diff --git a/command-signatures/src/listing.rs b/command-signatures/src/listing.rs new file mode 100644 index 00000000..ffe4bbcf --- /dev/null +++ b/command-signatures/src/listing.rs @@ -0,0 +1,590 @@ +//! Reusable listing of Fig-compatible command signatures. +//! +//! This module backs the `command-signatures list` CLI subcommand, but is a public library API +//! so any Rust caller can list either the signatures embedded into this crate or an external, +//! Fig-compatible JSON document. + +use std::cell::Cell; +use std::fmt; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use serde::de::{self, Deserializer as _, SeqAccess, Visitor}; +use warp_completion_metadata::fig_types::Command; +use warp_completion_metadata::Signature; + +/// Maximum size, in bytes, of an external signatures file that [`list_signatures`] will read. +pub const MAX_EXTERNAL_FILE_BYTES: u64 = 10 * 1024 * 1024; + +/// Maximum nesting depth of JSON containers (`{}`/`[]`) accepted in an external signatures file. +/// The root object or array is depth 1; each nested container adds 1. +pub const MAX_JSON_NESTING_DEPTH: usize = 64; + +/// Maximum number of command objects accepted in a top-level array in an external signatures +/// file. +pub const MAX_EXTERNAL_COMMANDS: usize = 10_000; + +/// Where [`list_signatures`] should read command signatures from. +#[derive(Debug, Clone)] +pub enum SignatureSource { + /// The signatures embedded into this binary at compile time. + Embedded, + /// An external, Fig-compatible JSON document at the given path. Replaces the embedded + /// source rather than merging with it. + File(PathBuf), +} + +/// One row of [`list_signatures`] output. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct SignatureSummary { + pub name: String, + pub description: Option, + pub subcommand_count: usize, +} + +/// An error encountered while listing signatures from an external file. +/// +/// Each variant retains the display path and, where applicable, the underlying error so callers +/// do not need to parse a formatted string. +#[derive(Debug)] +pub enum ListSignaturesError { + /// The file could not be read, e.g. it did not exist, was a directory, or was not + /// accessible. + Read { + path: PathBuf, + source: std::io::Error, + }, + /// The file's contents were malformed JSON or did not satisfy the Fig-compatible command + /// schema. + Parse { + path: PathBuf, + source: serde_json::Error, + }, + /// The file exceeded [`MAX_EXTERNAL_FILE_BYTES`]. + InputTooLarge { path: PathBuf }, + /// The file's JSON nesting exceeded [`MAX_JSON_NESTING_DEPTH`]. + NestingTooDeep { path: PathBuf }, + /// The file's top-level array contained more than [`MAX_EXTERNAL_COMMANDS`] commands. + TooManyCommands { path: PathBuf }, +} + +impl fmt::Display for ListSignaturesError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Read { path, source } => write!( + f, + "failed to read signatures file '{}': {source}", + path.display() + ), + Self::Parse { path, source } => write!( + f, + "failed to parse signatures file '{}': {source}", + path.display() + ), + Self::InputTooLarge { path } => write!( + f, + "signatures file '{}' exceeds maximum size of {MAX_EXTERNAL_FILE_BYTES} bytes", + path.display() + ), + Self::NestingTooDeep { path } => write!( + f, + "signatures file '{}' exceeds maximum JSON nesting depth of {MAX_JSON_NESTING_DEPTH}", + path.display() + ), + Self::TooManyCommands { path } => write!( + f, + "signatures file '{}' contains more than {MAX_EXTERNAL_COMMANDS} commands", + path.display() + ), + } + } +} + +impl std::error::Error for ListSignaturesError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Read { source, .. } => Some(source), + Self::Parse { source, .. } => Some(source), + Self::InputTooLarge { .. } + | Self::NestingTooDeep { .. } + | Self::TooManyCommands { .. } => None, + } + } +} + +/// Lists and summarizes the command signatures from `source`, sorted deterministically. +/// +/// This never executes a generator or other shell command referenced by a signature: it only +/// inspects the static schema. +pub fn list_signatures( + source: SignatureSource, +) -> Result, ListSignaturesError> { + let signatures = match source { + SignatureSource::Embedded => crate::commands(), + SignatureSource::File(path) => read_external_signatures(&path)?, + }; + Ok(summarize(signatures)) +} + +/// Converts signatures into sorted summaries. Rows are ordered case-insensitively by name, with +/// the original name as a tie-breaker, so output is deterministic even when the source order is +/// not (embedded loading is parallel). +fn summarize(signatures: Vec) -> Vec { + let mut summaries: Vec = signatures + .iter() + .map(|signature| SignatureSummary { + name: signature.name().to_string(), + description: signature.description.clone(), + subcommand_count: signature.subcommands().len(), + }) + .collect(); + summaries.sort_by(|a, b| { + a.name + .to_lowercase() + .cmp(&b.name.to_lowercase()) + .then_with(|| a.name.cmp(&b.name)) + }); + summaries +} + +fn is_json_whitespace(byte: u8) -> bool { + matches!(byte, b' ' | b'\t' | b'\n' | b'\r') +} + +fn is_json_whitespace_only(bytes: &[u8]) -> bool { + bytes.iter().all(|&byte| is_json_whitespace(byte)) +} + +/// Trims leading and trailing JSON whitespace. The caller is responsible for handling an +/// all-whitespace (or empty) input before calling this. +fn trim_json_whitespace(bytes: &[u8]) -> &[u8] { + let start = bytes + .iter() + .position(|&byte| !is_json_whitespace(byte)) + .unwrap_or(bytes.len()); + let end = bytes + .iter() + .rposition(|&byte| !is_json_whitespace(byte)) + .map_or(start, |index| index + 1); + &bytes[start..end] +} + +/// Reads `path` through a bounded reader capped at [`MAX_EXTERNAL_FILE_BYTES`] + 1, so a +/// maliciously large file is never fully materialized in memory. +fn read_bounded(path: &Path) -> Result, ListSignaturesError> { + let map_read_err = |source: std::io::Error| ListSignaturesError::Read { + path: path.to_path_buf(), + source, + }; + + let file = std::fs::File::open(path).map_err(map_read_err)?; + let mut bounded = file.take(MAX_EXTERNAL_FILE_BYTES + 1); + let mut buffer = Vec::new(); + bounded.read_to_end(&mut buffer).map_err(map_read_err)?; + + if buffer.len() as u64 > MAX_EXTERNAL_FILE_BYTES { + return Err(ListSignaturesError::InputTooLarge { + path: path.to_path_buf(), + }); + } + + Ok(buffer) +} + +/// Scans `bytes` once with a string- and escape-aware structural scanner to bound JSON nesting +/// depth before any recursive JSON materialization. JSON delimiters inside strings, including +/// escaped quotes and backslashes, do not contribute to the nesting count. +fn check_nesting_depth(bytes: &[u8], path: &Path) -> Result<(), ListSignaturesError> { + let mut depth: usize = 0; + let mut in_string = false; + let mut escaped = false; + + for &byte in bytes { + if in_string { + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + in_string = false; + } + continue; + } + + match byte { + b'"' => in_string = true, + b'{' | b'[' => { + depth += 1; + if depth > MAX_JSON_NESTING_DEPTH { + return Err(ListSignaturesError::NestingTooDeep { + path: path.to_path_buf(), + }); + } + } + b'}' | b']' => depth = depth.saturating_sub(1), + _ => {} + } + } + + Ok(()) +} + +/// An object containing only JSON whitespace between its braces, such as `{}` or `{ }`, is +/// treated as an empty collection for graceful compatibility with common empty JSON documents. +fn is_empty_object(trimmed: &[u8]) -> bool { + trimmed.len() >= 2 + && trimmed[0] == b'{' + && trimmed[trimmed.len() - 1] == b'}' + && is_json_whitespace_only(&trimmed[1..trimmed.len() - 1]) +} + +/// Parses a non-array root: either the empty-object exception, or one `Command`. A non-object, +/// non-array root (`null`, a boolean, a number, or a string) fails the `Command` deserialization +/// naturally and is reported as a parse error. +fn parse_non_array_root(trimmed: &[u8], path: &Path) -> Result, ListSignaturesError> { + if is_empty_object(trimmed) { + return Ok(Vec::new()); + } + + let command: Command = + serde_json::from_slice(trimmed).map_err(|source| ListSignaturesError::Parse { + path: path.to_path_buf(), + source, + })?; + Ok(vec![command]) +} + +/// A `Visitor` that deserializes a JSON array of `Command` values one element at a time, +/// probing for at most one element beyond [`MAX_EXTERNAL_COMMANDS`] rather than deserializing an +/// unbounded array and counting afterward. +/// +/// `too_many` is a side channel, set only by this visitor's own code, that lets the caller +/// distinguish a limit violation from an ordinary parse failure without inspecting formatted +/// error text (which untrusted input could otherwise forge to select its own diagnostic). +struct BoundedCommandSeq<'a> { + too_many: &'a Cell, +} + +impl<'de, 'a> Visitor<'de> for BoundedCommandSeq<'a> { + type Value = Vec; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "an array of Fig-compatible command objects") + } + + fn visit_seq(self, mut seq: A) -> Result + where + A: SeqAccess<'de>, + { + let mut commands = Vec::new(); + while let Some(command) = seq.next_element::()? { + if commands.len() == MAX_EXTERNAL_COMMANDS { + self.too_many.set(true); + return Err(de::Error::custom("too many commands in top-level array")); + } + commands.push(command); + } + Ok(commands) + } +} + +/// Parses an array root through the bounded sequence visitor above. +fn parse_array_root(trimmed: &[u8], path: &Path) -> Result, ListSignaturesError> { + let mut deserializer = serde_json::Deserializer::from_slice(trimmed); + let too_many = Cell::new(false); + let commands = deserializer + .deserialize_seq(BoundedCommandSeq { + too_many: &too_many, + }) + .map_err(|source: serde_json::Error| { + if too_many.get() { + ListSignaturesError::TooManyCommands { + path: path.to_path_buf(), + } + } else { + ListSignaturesError::Parse { + path: path.to_path_buf(), + source, + } + } + })?; + // Require the parser to consume the complete document so trailing non-whitespace bytes are + // malformed, matching the behavior `serde_json::from_slice` provides for other roots. + deserializer + .end() + .map_err(|source| ListSignaturesError::Parse { + path: path.to_path_buf(), + source, + })?; + Ok(commands) +} + +/// Reads, validates, and parses an external signatures file into signatures. Empty bytes, +/// whitespace-only bytes, `[]`, and `{}` are all successful, empty collections. +fn read_external_signatures(path: &Path) -> Result, ListSignaturesError> { + let bytes = read_bounded(path)?; + + if is_json_whitespace_only(&bytes) { + return Ok(Vec::new()); + } + + check_nesting_depth(&bytes, path)?; + + let trimmed = trim_json_whitespace(&bytes); + let commands = if trimmed.first() == Some(&b'[') { + parse_array_root(trimmed, path)? + } else { + parse_non_array_root(trimmed, path)? + }; + + // Convert every accepted `Command` with the existing `Vec::::from` implementation + // used for embedded assets, so external and embedded sources share one conversion path. + Ok(commands + .into_iter() + .flat_map(Vec::::from) + .collect()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn write_fixture(contents: &[u8]) -> tempfile::NamedTempFile { + let mut file = tempfile::NamedTempFile::new().expect("failed to create temp file"); + file.write_all(contents).expect("failed to write fixture"); + file.flush().expect("failed to flush fixture"); + file + } + + fn list_file(contents: &[u8]) -> Result, ListSignaturesError> { + let file = write_fixture(contents); + list_signatures(SignatureSource::File(file.path().to_path_buf())) + } + + #[test] + fn empty_bytes_are_an_empty_collection() { + assert_eq!(list_file(b"").unwrap(), Vec::new()); + } + + #[test] + fn whitespace_only_bytes_are_an_empty_collection() { + assert_eq!(list_file(b" \n\t\r\n ").unwrap(), Vec::new()); + } + + #[test] + fn empty_array_is_an_empty_collection() { + assert_eq!(list_file(b"[]").unwrap(), Vec::new()); + } + + #[test] + fn empty_object_is_an_empty_collection() { + assert_eq!(list_file(b"{}").unwrap(), Vec::new()); + assert_eq!(list_file(b"{ \n\t }").unwrap(), Vec::new()); + } + + #[test] + fn valid_object_with_no_names_is_an_empty_collection() { + assert_eq!(list_file(br#"{"name":[]}"#).unwrap(), Vec::new()); + } + + #[test] + fn single_command_object_produces_one_row() { + let summaries = list_file( + br#"{"name":"foo","description":"does foo things","subcommands":[{"name":"bar"}]}"#, + ) + .unwrap(); + assert_eq!( + summaries, + vec![SignatureSummary { + name: "foo".to_string(), + description: Some("does foo things".to_string()), + subcommand_count: 1, + }] + ); + } + + #[test] + fn command_array_flattens_all_entries() { + let summaries = + list_file(br#"[{"name":"foo"},{"name":"bar"},{"name":["baz","qux"]}]"#).unwrap(); + let names: Vec<&str> = summaries.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, vec!["bar", "baz", "foo", "qux"]); + } + + #[test] + fn ordering_is_case_insensitive_with_original_name_tie_break() { + let summaries = + list_file(br#"[{"name":"Bravo"},{"name":"alpha"},{"name":"bravo"}]"#).unwrap(); + let names: Vec<&str> = summaries.iter().map(|s| s.name.as_str()).collect(); + assert_eq!(names, vec!["alpha", "Bravo", "bravo"]); + } + + #[test] + fn malformed_json_is_a_parse_error() { + let err = list_file(b"{not json}").unwrap_err(); + assert!(matches!(err, ListSignaturesError::Parse { .. })); + } + + #[test] + fn scalar_root_is_a_parse_error() { + for input in [&b"null"[..], b"true", b"42", b"\"a string\""] { + let err = list_file(input).unwrap_err(); + assert!( + matches!(err, ListSignaturesError::Parse { .. }), + "expected a parse error for {input:?}" + ); + } + } + + #[test] + fn non_empty_object_missing_name_is_a_parse_error() { + let err = list_file(br#"{"description":"missing name"}"#).unwrap_err(); + assert!(matches!(err, ListSignaturesError::Parse { .. })); + } + + #[test] + fn array_with_invalid_member_is_a_parse_error() { + let err = list_file(br#"[{"name":"foo"},1]"#).unwrap_err(); + assert!(matches!(err, ListSignaturesError::Parse { .. })); + } + + #[test] + fn trailing_bytes_are_a_parse_error() { + let err = list_file(br#"{"name":"foo"} garbage"#).unwrap_err(); + assert!(matches!(err, ListSignaturesError::Parse { .. })); + let err = list_file(br#"[{"name":"foo"}] garbage"#).unwrap_err(); + assert!(matches!(err, ListSignaturesError::Parse { .. })); + } + + #[test] + fn nonexistent_path_is_a_read_error() { + let err = list_signatures(SignatureSource::File(PathBuf::from( + "/nonexistent/path/does-not-exist.json", + ))) + .unwrap_err(); + assert!(matches!(err, ListSignaturesError::Read { .. })); + } + + #[test] + fn directory_path_is_a_read_error() { + let dir = tempfile::tempdir().expect("failed to create temp dir"); + let err = list_signatures(SignatureSource::File(dir.path().to_path_buf())).unwrap_err(); + assert!(matches!(err, ListSignaturesError::Read { .. })); + } + + #[test] + fn exactly_max_bytes_is_accepted_but_one_more_is_too_large() { + // A padded, otherwise-valid document: an object with a name, then padding via a long + // description so the exact byte count can be controlled. + let prefix = br#"{"name":"foo","description":""#; + let suffix = br#""}"#; + let max_len = MAX_EXTERNAL_FILE_BYTES as usize; + let padding_len = max_len - prefix.len() - suffix.len(); + let mut exact = Vec::with_capacity(max_len); + exact.extend_from_slice(prefix); + exact.extend(vec![b'a'; padding_len]); + exact.extend_from_slice(suffix); + assert_eq!(exact.len(), max_len); + let summaries = list_file(&exact).unwrap(); + assert_eq!(summaries.len(), 1); + + let mut too_large = exact; + // Insert one more padding byte before the closing quote, keeping the document otherwise + // valid JSON, so the failure is unambiguously about size and not shape. + let insert_at = too_large.len() - suffix.len(); + too_large.insert(insert_at, b'a'); + assert_eq!(too_large.len(), max_len + 1); + let err = list_file(&too_large).unwrap_err(); + assert!(matches!(err, ListSignaturesError::InputTooLarge { .. })); + } + + fn nested_array_json(depth: usize) -> Vec { + let mut json = vec![b'['; depth]; + json.extend(vec![b']'; depth]); + json + } + + #[test] + fn depth_64_parses_normally_depth_65_is_too_deep() { + // At exactly depth 64 nesting is accepted; this document is a well-formed but + // schema-invalid array-of-arrays, so it reaches ordinary parsing and fails there instead + // of on the depth check. + let err = list_file(&nested_array_json(64)).unwrap_err(); + assert!(matches!(err, ListSignaturesError::Parse { .. })); + + let err = list_file(&nested_array_json(65)).unwrap_err(); + assert!(matches!(err, ListSignaturesError::NestingTooDeep { .. })); + } + + #[test] + fn depth_scanner_ignores_delimiters_inside_strings() { + // A single string value containing many brackets should not trip the depth check. + let mut json = br#"{"name":"foo","description":""#.to_vec(); + json.extend(vec![b'['; 200]); + json.extend(vec![b']'; 200]); + json.extend(br#""}"#); + let summaries = list_file(&json).unwrap(); + assert_eq!(summaries.len(), 1); + } + + fn command_array_json(count: usize) -> Vec { + let mut json = vec![b'[']; + for i in 0..count { + if i > 0 { + json.push(b','); + } + json.extend(format!(r#"{{"name":"cmd{i}"}}"#).into_bytes()); + } + json.push(b']'); + json + } + + #[test] + fn exactly_max_commands_is_accepted_but_one_more_is_too_many() { + let summaries = list_file(&command_array_json(MAX_EXTERNAL_COMMANDS)).unwrap(); + assert_eq!(summaries.len(), MAX_EXTERNAL_COMMANDS); + + let err = list_file(&command_array_json(MAX_EXTERNAL_COMMANDS + 1)).unwrap_err(); + assert!(matches!(err, ListSignaturesError::TooManyCommands { .. })); + } + + #[test] + fn schema_invalid_document_embedding_limit_diagnostic_text_is_still_a_parse_error() { + // A schema-invalid document (`priority` must be a number, not a string) that happens to + // embed text resembling the too-many-commands diagnostic. The array has far fewer than + // `MAX_EXTERNAL_COMMANDS` entries, so this must be reported as a parse error rather than + // a limit violation: the error variant must not be selected by inspecting formatted + // error text, which untrusted input could otherwise forge. + let err = + list_file(br#"[{"name":"x","priority":"warp-command-signatures: too many commands"}]"#) + .unwrap_err(); + assert!(matches!(err, ListSignaturesError::Parse { .. })); + } + + #[test] + fn text_normalization_preserves_one_row_per_signature() { + let summaries = list_file(br#"[{"name":"a"},{"name":"b"},{"name":"c"}]"#).unwrap(); + assert_eq!(summaries.len(), 3); + } + + #[test] + fn json_summary_serializes_with_documented_field_names() { + let summary = SignatureSummary { + name: "foo".to_string(), + description: None, + subcommand_count: 0, + }; + let json = serde_json::to_string(&summary).unwrap(); + assert_eq!( + json, + r#"{"name":"foo","description":null,"subcommand_count":0}"# + ); + } + + #[test] + fn embedded_source_lists_the_repository_signatures() { + let summaries = list_signatures(SignatureSource::Embedded).unwrap(); + assert!(!summaries.is_empty()); + } +} diff --git a/command-signatures/tests/cli_list.rs b/command-signatures/tests/cli_list.rs new file mode 100644 index 00000000..a2e28264 --- /dev/null +++ b/command-signatures/tests/cli_list.rs @@ -0,0 +1,322 @@ +//! Process-level integration tests for `command-signatures list`. +//! +//! Each case asserts the exit code, complete standard output, and complete standard error, and +//! confirms standard error never contains a panic diagnostic. + +use std::io::Write; +use std::path::Path; +use std::process::{Command, Output}; + +fn bin() -> Command { + Command::new(env!("CARGO_BIN_EXE_command-signatures")) +} + +fn write_fixture(dir: &Path, name: &str, contents: &[u8]) -> std::path::PathBuf { + let path = dir.join(name); + let mut file = std::fs::File::create(&path).expect("failed to create fixture"); + file.write_all(contents).expect("failed to write fixture"); + path +} + +fn assert_no_panic(output: &Output) { + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + !stderr.contains("panicked at"), + "stderr contained a panic diagnostic: {stderr}" + ); + assert!( + !stderr.contains("index out of bounds"), + "stderr contained an index-out-of-bounds diagnostic: {stderr}" + ); +} + +fn run_list(file: &Path, extra_args: &[&str]) -> Output { + let output = bin() + .arg("list") + .arg("--file") + .arg(file) + .args(extra_args) + .output() + .expect("failed to run command-signatures list"); + assert_no_panic(&output); + output +} + +#[test] +fn empty_file_is_successful_and_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture(dir.path(), "empty.json", b""); + + let output = run_list(&path, &[]); + assert!(output.status.success()); + assert_eq!(output.stdout, b"No signatures found.\n"); + assert!(output.stderr.is_empty()); + + let json_output = run_list(&path, &["--json"]); + assert!(json_output.status.success()); + assert_eq!(json_output.stdout, b"[]\n"); + assert!(json_output.stderr.is_empty()); +} + +#[test] +fn whitespace_only_file_is_successful_and_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture(dir.path(), "whitespace.json", b" \n\t\r\n "); + + let output = run_list(&path, &[]); + assert!(output.status.success()); + assert_eq!(output.stdout, b"No signatures found.\n"); + assert!(output.stderr.is_empty()); +} + +#[test] +fn empty_array_is_successful_and_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture(dir.path(), "array.json", b"[]"); + + let output = run_list(&path, &[]); + assert!(output.status.success()); + assert_eq!(output.stdout, b"No signatures found.\n"); + + let json_output = run_list(&path, &["--json"]); + assert!(json_output.status.success()); + assert_eq!(json_output.stdout, b"[]\n"); +} + +#[test] +fn empty_object_is_successful_and_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture(dir.path(), "object.json", b"{}"); + + let output = run_list(&path, &[]); + assert!(output.status.success()); + assert_eq!(output.stdout, b"No signatures found.\n"); + + let json_output = run_list(&path, &["--json"]); + assert!(json_output.status.success()); + assert_eq!(json_output.stdout, b"[]\n"); +} + +#[test] +fn valid_object_with_no_names_is_successful_and_empty() { + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture(dir.path(), "no_names.json", br#"{"name":[]}"#); + + let output = run_list(&path, &[]); + assert!(output.status.success()); + assert_eq!(output.stdout, b"No signatures found.\n"); +} + +#[test] +fn malformed_json_fails_with_parse_prefix() { + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture(dir.path(), "malformed.json", b"{not json}"); + + let output = run_list(&path, &[]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.starts_with(&format!( + "error: failed to parse signatures file '{}': ", + path.display() + )), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn non_empty_object_without_name_fails_with_parse_prefix() { + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture( + dir.path(), + "missing_name.json", + br#"{"description":"missing name"}"#, + ); + + let output = run_list(&path, &[]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.starts_with("error: failed to parse signatures file")); +} + +#[test] +fn nonexistent_path_fails_with_read_prefix() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("does-not-exist.json"); + + let output = run_list(&path, &[]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.starts_with(&format!( + "error: failed to read signatures file '{}': ", + path.display() + )), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn oversized_file_fails_with_exact_diagnostic() { + let dir = tempfile::tempdir().unwrap(); + // One byte over the 10 MiB limit, kept simple (not valid JSON, since the size check happens + // before parsing). + let contents = vec![b'a'; 10 * 1024 * 1024 + 1]; + let path = write_fixture(dir.path(), "oversized.json", &contents); + + let output = run_list(&path, &[]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!( + output.stderr, + format!( + "error: signatures file '{}' exceeds maximum size of 10485760 bytes\n", + path.display() + ) + .into_bytes() + ); +} + +#[test] +fn depth_65_fails_with_exact_diagnostic() { + let dir = tempfile::tempdir().unwrap(); + let mut contents = vec![b'['; 65]; + contents.extend(vec![b']'; 65]); + let path = write_fixture(dir.path(), "too_deep.json", &contents); + + let output = run_list(&path, &[]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!( + output.stderr, + format!( + "error: signatures file '{}' exceeds maximum JSON nesting depth of 64\n", + path.display() + ) + .into_bytes() + ); +} + +#[test] +fn ten_thousand_and_one_commands_fails_with_exact_diagnostic() { + let dir = tempfile::tempdir().unwrap(); + let mut contents = vec![b'[']; + for i in 0..10_001 { + if i > 0 { + contents.push(b','); + } + contents.extend(format!(r#"{{"name":"cmd{i}"}}"#).into_bytes()); + } + contents.push(b']'); + let path = write_fixture(dir.path(), "too_many.json", &contents); + + let output = run_list(&path, &[]); + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty()); + assert_eq!( + output.stderr, + format!( + "error: signatures file '{}' contains more than 10000 commands\n", + path.display() + ) + .into_bytes() + ); +} + +#[test] +fn valid_single_object_and_array_produce_sorted_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture( + dir.path(), + "single.json", + br#"{"name":"zeta","description":"last one","subcommands":[{"name":"a"},{"name":"b"}]}"#, + ); + + let output = run_list(&path, &[]); + assert!(output.status.success()); + assert_eq!( + output.stdout, + b"NAME\tSUBCOMMANDS\tDESCRIPTION\nzeta\t2\tlast one\n".to_vec() + ); + + let json_output = run_list(&path, &["--json"]); + assert!(json_output.status.success()); + let expected = br#"[{"name":"zeta","description":"last one","subcommand_count":2}]"#.to_vec(); + let mut expected_with_newline = expected; + expected_with_newline.push(b'\n'); + assert_eq!(json_output.stdout, expected_with_newline); + + let array_path = write_fixture( + dir.path(), + "array_of_two.json", + br#"[{"name":"beta"},{"name":"alpha","description":"first"}]"#, + ); + let array_output = run_list(&array_path, &[]); + assert!(array_output.status.success()); + assert_eq!( + array_output.stdout, + b"NAME\tSUBCOMMANDS\tDESCRIPTION\nalpha\t0\tfirst\nbeta\t0\t\n".to_vec() + ); +} + +#[test] +fn control_characters_in_name_and_description_cannot_forge_columns_or_rows() { + // A name and description containing every kind of layout-affecting character (tab, newline, + // carriage return, ESC, vertical tab, form feed, and the Unicode line separator U+2028 / + // paragraph separator U+2029) must not be able to inject extra tab-separated columns or + // extra rows into the text output, and ESC must not reach the terminal as an escape + // sequence: this is untrusted `--file` content, and the approved spec requires exactly one + // line per signature. Built with `serde_json::json!` so every character is JSON-escaped + // correctly regardless of how the JSON crate chooses to represent it. + let name = "a\tb\nc\rd\u{1b}e\u{0b}f\u{0c}g\u{2028}h\u{2029}i"; + let description = "p\tq\nr\rs\u{1b}t\u{0b}u\u{0c}v\u{2028}w\u{2029}x"; + let contents = serde_json::json!({ "name": name, "description": description }).to_string(); + + let dir = tempfile::tempdir().unwrap(); + let path = write_fixture(dir.path(), "control_chars.json", contents.as_bytes()); + + let output = run_list(&path, &[]); + assert!(output.status.success()); + assert_eq!( + output.stdout, + b"NAME\tSUBCOMMANDS\tDESCRIPTION\na b c d e f g h i\t0\tp q r s t u v w x\n".to_vec() + ); + // Exactly one header line and one data line: no extra rows or columns were injected. + assert_eq!(output.stdout.iter().filter(|&&b| b == b'\n').count(), 2); + let data_line = output.stdout.split(|&b| b == b'\n').nth(1).unwrap(); + assert_eq!(data_line.iter().filter(|&&b| b == b'\t').count(), 2); + // ESC (0x1B) must never reach stdout, so it cannot be interpreted as a terminal escape + // sequence. + assert!(!output.stdout.contains(&0x1B)); +} + +#[test] +fn default_embedded_source_lists_repository_signatures() { + let output = bin().arg("list").output().unwrap(); + assert!(output.status.success()); + assert_no_panic(&output); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.starts_with("NAME\tSUBCOMMANDS\tDESCRIPTION\n")); + assert!(stdout.lines().count() > 1); + + let json_output = bin().arg("list").arg("--json").output().unwrap(); + assert!(json_output.status.success()); + let parsed: serde_json::Value = serde_json::from_slice(&json_output.stdout).unwrap(); + let array = parsed.as_array().expect("expected a JSON array"); + assert!(!array.is_empty()); +} + +#[test] +fn unknown_subcommand_is_a_usage_error() { + let output = bin().arg("frobnicate").output().unwrap(); + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn missing_file_value_is_a_usage_error() { + let output = bin().arg("list").arg("--file").output().unwrap(); + assert_eq!(output.status.code(), Some(2)); +}