diff --git a/Cargo.lock b/Cargo.lock index 7717377..0205a3a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,30 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bitflags" version = "2.13.2" @@ -20,12 +38,119 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + [[package]] name = "displaydoc" version = "0.2.7" @@ -37,6 +162,12 @@ dependencies = [ "syn 3.0.6", ] +[[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" @@ -50,7 +181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -59,6 +190,29 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -68,6 +222,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.4.3" @@ -85,12 +250,29 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[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 = "http" version = "1.5.0" @@ -101,6 +283,12 @@ dependencies = [ "itoa", ] +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + [[package]] name = "icu_collections" version = "2.3.0" @@ -184,6 +372,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -212,7 +406,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", ] [[package]] @@ -227,6 +452,12 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -239,18 +470,93 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -290,6 +596,63 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.5" @@ -299,10 +662,63 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -354,6 +770,49 @@ dependencies = [ "serde_core", ] +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "smallvec" version = "1.16.2" @@ -366,6 +825,46 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.119" @@ -412,10 +911,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.5", + "windows-sys 0.61.2", ] [[package]] @@ -497,6 +996,70 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" +dependencies = [ + "base64 0.23.1", + "flate2", + "log", + "percent-encoding", + "rustls", + "rustls-pki-types", + "ureq-proto", + "utf8-zero", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" +dependencies = [ + "base64 0.23.1", + "http", + "httparse", + "log", +] + [[package]] name = "url" version = "2.5.8" @@ -509,27 +1072,88 @@ dependencies = [ "serde", ] +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + [[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -539,6 +1163,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.4" @@ -558,7 +1246,7 @@ name = "wip-fs-server" version = "0.1.0" dependencies = [ "http", - "rustix", + "rustix 1.1.5", "tempfile", "wip-host", "wip-https", @@ -579,7 +1267,7 @@ dependencies = [ name = "wip-https" version = "0.1.0" dependencies = [ - "base64", + "base64 0.22.1", "http", "serde", "serde_json", @@ -618,6 +1306,22 @@ dependencies = [ "wip-protocol", ] +[[package]] +name = "wip-tui" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "crossterm", + "ratatui", + "serde_json", + "tempfile", + "ureq", + "wip-client", + "wip-fs-server", + "wip-https", + "wip-protocol", +] + [[package]] name = "writeable" version = "0.6.4" @@ -668,6 +1372,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + [[package]] name = "zerotrie" version = "0.2.5" @@ -701,6 +1411,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/README.md b/README.md index 7c3a778..bc39333 100644 --- a/README.md +++ b/README.md @@ -6,13 +6,18 @@ The repository is a resolver-3 virtual Cargo workspace whose packages all live under `crates/*`. It provides a transport-independent protocol model, the standard framework-independent WIP over HTTPS v1 binding, a stateful presentation-independent client runtime, a Rust interface-authoring facade, a -request-scoped host runtime, a bounded Linux filesystem projection server, and a -private public-API conformance consumer. The repository root is not itself a -package. +request-scoped host runtime, a bounded Linux filesystem projection server, an +interactive terminal client, and a private public-API conformance consumer. The +repository root is not itself a package. ## Crate graph and responsibilities ```text +wip-tui (interactive Worldspace explorer and operation editor) +├── wip-client Known Space, request identity, validators, and calls +├── wip-https framework-neutral HTTP request/response messages +└── wip-protocol descriptors and logical values + wip-fs-server (request-time filesystem projection and loopback listener) ├── wip-https standard routes/codecs/envelopes/batching ├── wip-host request-scoped publication and dispatch @@ -38,6 +43,7 @@ wip-public-api-example (workspace-only executable, integration tests, doctest) | `wip-protocol` | Transport-independent descriptors, object/call/result types, logical `Value`s, validation, and stable protocol error categories. | Application DTOs, implementations, persistence, or wire encoding. | | `wip-https` | Standard HTTPS v1 endpoint/route derivation, UTF-8 JSON codecs, direct success bodies, error/status classification, and retrieval batches. | HTTP I/O, TLS termination, authentication schemes, server frameworks, filesystem projection, or client presentation/cache policy. | | `wip-client` | Endpoint/security-context-isolated Known Space, normalized object and interface observations, lazy entry trees, immutable operation call contexts, stale transitions, and bounded histories. | HTTP I/O, credentials, persistent cache, automatic operation retries, or presentation widgets/events. | +| `wip-tui` | Keyboard-only interactive Worldspace tree, selected Entry and qualified operation panes, descriptor-driven argument editing, bounded result/diagnostic rendering, terminal lifecycle, and HTTPS I/O workers over `wip-client`. | Independent cache/validator semantics, automatic operation retries, Host internals, backend paths, or persistent state. | | `wip-interface` | The public `WipType`/`interface` authoring API, Rust-to-protocol codecs, generated descriptors, and typed implementation dispatch. | Object publication, backend lifetime, or transport policy. | | `wip-interface-macros` | Procedural-macro expansion used by `wip-interface`. It is not an application dependency. | A supported direct user API. | | `wip-host` | Opaque interface registration, explicit object membership, validator checks, interface-qualified dispatch against one host-owned instance, and descriptor-guided argument decoding inside that same resolution boundary. | Backend atomicity, global object lookup, or network serving. | @@ -250,6 +256,18 @@ rolled back. Without that proof, post-invocation failures remain comparison and backend commit atomic and return a validator describing the state that becomes committed. +## Interactive terminal client + +[`crates/wip-tui`](crates/wip-tui) provides the `wip-tui` executable. It starts +from Worldspace `/`, lazily expands materialized tree nodes, displays current +object/interface/operation metadata, validates all canonical descriptor types in +a field editor, and requires a separate confirmation action before every call. +It uses the stateful `wip-client` runtime for request correlation, observed +validators, stale transitions, and unknown outcomes. HTTPS is the default; +loopback plain HTTP requires `--allow-http`. Full startup, TLS, key binding, +layout, validator, and filesystem-operation examples are in the +[package README](crates/wip-tui/README.md). + ## Filesystem projection server [`crates/wip-fs-server`](crates/wip-fs-server) contains the practical server diff --git a/crates/wip-tui/Cargo.toml b/crates/wip-tui/Cargo.toml new file mode 100644 index 0000000..a0796e3 --- /dev/null +++ b/crates/wip-tui/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "wip-tui" +version = "0.1.0" +edition = "2024" +description = "Interactive terminal client for exploring and operating WIP Worldspaces" +license = "MIT OR Apache-2.0" +readme = "README.md" +publish = false + +[dependencies] +base64 = "0.22" +crossterm = "0.28.1" +ratatui = "0.29.0" +serde_json = "1" +ureq = "3.1.2" +wip-client = { path = "../wip-client" } +wip-https = { path = "../wip-https" } +wip-protocol = { path = "../wip-protocol" } + +[dev-dependencies] +tempfile = "3" +wip-fs-server = { path = "../wip-fs-server" } diff --git a/crates/wip-tui/README.md b/crates/wip-tui/README.md new file mode 100644 index 0000000..5764ab4 --- /dev/null +++ b/crates/wip-tui/README.md @@ -0,0 +1,151 @@ +# wip-tui + +`wip-tui` is an interactive terminal client for exploring and operating a WIP +Worldspace. It is a presentation and terminal-interaction layer over +`wip-client`: Known Space, request identity, validators, immutable operation call +contexts, stale transitions, response classification, and bounded histories remain +owned by the client runtime. + +## Start + +HTTPS is the normal mode: + +```console +cargo run -p wip-tui -- --endpoint https://wip.example.test/api/wip +``` + +The endpoint may instead be supplied as `WIP_ENDPOINT`. A non-secret cache +partition identity may be supplied with `--security-context NAME` or +`WIP_SECURITY_CONTEXT`. The default request timeout is 30 seconds and can be +changed with `--timeout SECONDS`. + +An Authorization header can be read from an environment variable without placing +its value in arguments, application state, errors, snapshots, or debug output: + +```console +export WIP_AUTHORIZATION='Bearer …' +cargo run -p wip-tui -- https://wip.example.test/wip +``` + +Use `--authorization-env OTHER_VARIABLE` to select another variable. The value is +redacted from `Debug` and is never rendered. Shell and process environment security +remain the operator's responsibility. + +Plain HTTP is rejected by default. It is available only as an explicit loopback +development mode: + +```console +cargo run -p wip-tui -- http://127.0.0.1:8080/wip --allow-http +``` + +Non-loopback HTTP remains rejected even with `--allow-http`. For production, use a +real HTTPS endpoint and a trusted TLS deployment. + +### Filesystem server example + +In one terminal: + +```console +mkdir -p /tmp/wip-example +printf 'hello world' >/tmp/wip-example/notes.txt +cargo run -p wip-fs-server -- \ + --root /tmp/wip-example \ + --listen 127.0.0.1:8080 \ + --base-path /wip \ + --development-http \ + --read-write +``` + +In another: + +```console +cargo run -p wip-tui -- http://127.0.0.1:8080/wip --allow-http +``` + +Expand `/`, select `notes.txt`, focus the Interface/Operation pane, load the read +interface, and choose `read`. The editor labels `offset` and `length` as optional; +leave both blank to read the bounded file. `Bytes` results are shown as a bounded +hex preview. For `replace`, enter `contents` as RFC 4648 base64—for example, +`dXBkYXRlZA==` for `updated`—then review the target Entry, interface reference, +operation, and value on the confirmation screen before pressing Enter again. + +## Keyboard bindings + +All required actions are keyboard-accessible. + +| Key | Action | +| --- | --- | +| `↑`/`k`, `↓`/`j` | Move within the focused tree, operation list, history, or editor | +| `→`/`l`, `Enter` | Expand a tree node lazily; load/select an interface or operation | +| `←`/`h` | Collapse a node or move to its parent | +| `Tab` | Cycle Tree → Selected Entry → Operations → History | +| `r` | Reobserve the selected Entry and its direct subtree | +| `+` | Double the selected node limit and retry an incomplete/truncated tree | +| `c` | Cancel the newest retrieval; a dispatched operation is never presented as canceled | +| `R` | Reconnect after connection failure | +| `?` | Open help | +| `Esc` | Close a modal or return to the tree | +| `q`, `Ctrl-C` | Exit and restore the terminal | + +Inside an argument editor, printable keys are field input, arrows move fields, +Backspace edits, Enter validates, and Esc cancels. The confirmation screen requires +a second explicit Enter (or `e`) to execute once. There is no operation auto-retry. + +## Screen model + +- **Worldspace tree** starts at `/` and materializes direct children only when a + node is expanded. Text labels distinguish unloaded, loading, empty, stale, + failed, and truncated state; color is supplemental. A truncated node is never + presented as a complete tree. +- **Selected Entry** shows only public WIP path, object name, description, ordered + interfaces, and freshness. Validators, credentials, Host internals, and backend + paths are deliberately hidden. If current metadata disappears, old text is not + presented as current. +- **Interfaces / Operations** identifies every operation by `(interface reference, + operation name)`. It shows descriptor documentation, ordered parameters, + required/optional status, and result types. +- **Argument editor** supports Unit, Boolean, Integer, Number, String, base64 Bytes, + Record, List, Entry, Enum, Union, Json, and descriptor-local named types. Record, + List, Union, and Json values use JSON syntax. Union input is + `{"$case":"name","value":…}` when the selected case has a payload. Diagnostics + remain attached to the failing field. +- **Recent results / diagnostics** explicitly distinguishes success, Host failure, + invalid protocol response, transport failure, client-local failure, and unknown + operation outcome. Large values, bytes, nesting, and control characters are + bounded before rendering. Only descriptor-declared `Entry` results become + navigation targets; path-looking String or Json values do not. + +Terminals below 48×14 show a minimum-size screen instead of attempting the full +layout. + +## Validator and failure behavior + +Object and interface validators are observed and attached automatically by +`wip-client`; users neither copy nor edit them. A successful operation conservatively +marks related object observations stale because metadata may have changed. +Validator mismatch, interface mismatch, `OperationOutcomeUnknown`, undecodable +post-dispatch responses, and post-dispatch timeout/disconnect are displayed using +the runtime's authoritative transition. + +For an unknown outcome, the UI states that execution or side effects may have +occurred, marks the related Entry stale, and tells the user to refresh. Refreshing +does not recreate the call: the user must select the operation, review fresh input, +and explicitly execute again. The same no-auto-retry rule applies after validator +or interface mismatch. + +Retrieval work runs on I/O workers while the input/render loop continues. Responses +return with the original `RequestId`, so an old completion cannot populate another +Entry's details. Canceling a retrieval records a local cancellation and drops its +late response. Once an operation crosses dispatch, cancellation is never described +as undoing execution. + +## Tests + +The library form keeps reducers and rendering independent of a real terminal. +`ratatui::TestBackend` snapshots cover tree/detail/result states and the minimum-size +fallback. Unit tests cover reducer focus, selection, modal confirmation, key +translation, descriptor inputs, bounded values, and descriptor-directed Entry +results. The real HTTP integration test launches `wip-fs-server` on a temporary +root and covers tree selection, reads, validator-protected replacement, external +stale-validator rejection without retry, explicit refresh/re-execution, and +symlink confinement. diff --git a/crates/wip-tui/src/app.rs b/crates/wip-tui/src/app.rs new file mode 100644 index 0000000..fa8be24 --- /dev/null +++ b/crates/wip-tui/src/app.rs @@ -0,0 +1,919 @@ +//! Pure application state, selection, modal, focus, and key reducer. + +use std::collections::{BTreeMap, BTreeSet}; + +use wip_client::{InterfaceObservation, ObjectObservation, ObservationState, TreeObservation}; +use wip_protocol::{InterfaceDescriptor, Object, Value}; + +use crate::editor::ArgumentEditor; + +const MAX_PRESENTATION_ENTRIES: usize = 2048; +const MAX_PRESENTATION_INTERFACES: usize = 256; + +/// A terminal-independent key understood by the reducer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputKey { + /// Up arrow or `k`. + Up, + /// Down arrow or `j`. + Down, + /// Left arrow or `h`. + Left, + /// Right arrow or `l`. + Right, + /// Focus the next pane. + Tab, + /// Activate the selected item. + Enter, + /// Close the current modal or return to the tree. + Escape, + /// Delete one editor character. + Backspace, + /// Refresh the selected Entry. + Refresh, + /// Retry a truncated tree with a larger limit. + IncreaseLimit, + /// Cancel the newest cancel-safe retrieval. + CancelRead, + /// Show key help. + Help, + /// Quit the application. + Quit, + /// Reconnect after startup or transport failure. + Reconnect, + /// A printable editor character. + Character(char), +} + +/// Pane receiving navigation keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + /// Worldspace tree. + Tree, + /// Selected Entry metadata. + Entry, + /// Interfaces and operations. + Operations, + /// Recent result and diagnostic history. + History, +} + +impl Focus { + fn next(self) -> Self { + match self { + Self::Tree => Self::Entry, + Self::Entry => Self::Operations, + Self::Operations => Self::History, + Self::History => Self::Tree, + } + } +} + +/// Connection lifecycle shown without relying on color. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConnectionState { + /// No request has been attempted. + Disconnected, + /// Initial root request or reconnect is in progress. + Connecting, + /// The root was observed successfully. + Connected, + /// Connection failed; reconnect and exit remain available. + Failed(String), +} + +/// Presentation-safe observation state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ViewState { + /// No observation has been requested. + Unloaded, + /// One request is in flight. + Loading(u64), + /// The observation is current. + Fresh, + /// The retained value needs reobservation. + Stale, + /// The latest attempt failed. + Error(String), +} + +impl From<&ObservationState> for ViewState { + fn from(value: &ObservationState) -> Self { + match value { + ObservationState::Loading(id) => Self::Loading(id.get()), + ObservationState::Fresh => Self::Fresh, + ObservationState::Stale => Self::Stale, + ObservationState::Error(error) => Self::Error(error.to_string()), + } + } +} + +/// One materialized tree Entry. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EntryView { + /// Canonical WIP path. + pub path: String, + /// Latest object when retained by the client runtime. + pub object: Option, + /// Object observation state. + pub state: ViewState, + /// Direct child paths in Host order. + pub children: Vec, + /// Expansion observation state. + pub tree_state: ViewState, + /// Whether the user expanded this row. + pub expanded: bool, + /// Whether the observed child list is incomplete. + pub truncated: bool, + /// Current bounded expansion limit. + pub limit: u32, +} + +impl EntryView { + fn placeholder(path: impl Into, default_limit: u32) -> Self { + Self { + path: path.into(), + object: None, + state: ViewState::Unloaded, + children: Vec::new(), + tree_state: ViewState::Unloaded, + expanded: false, + truncated: false, + limit: default_limit, + } + } +} + +/// Cached interface descriptor state used by presentation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InterfaceView { + /// Opaque interface reference. + pub reference: String, + /// Descriptor when accepted by the client runtime. + pub descriptor: Option, + /// Descriptor observation state. + pub state: ViewState, +} + +/// Stable result/diagnostic category. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HistoryKind { + /// A descriptor-validated operation result. + Success, + /// A valid Host-declared failure. + DomainFailure, + /// An invalid protocol response. + ProtocolError, + /// Network or TLS failure. + TransportFailure, + /// Client-local validation or capacity failure. + ClientFailure, + /// Dispatch occurred but execution or commit is uncertain. + OutcomeUnknown, + /// Informational request state. + Information, +} + +/// One bounded recent result or diagnostic. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoryItem { + /// Explicit outcome category. + pub kind: HistoryKind, + /// Short bounded heading. + pub title: String, + /// Bounded, terminal-safe detail. + pub detail: String, + /// Navigation target only when declared as descriptor `Entry`. + pub entry_target: Option, +} + +/// Kind of request currently in flight. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum InFlightKind { + /// Object refresh. + Object(String), + /// Tree expansion or refresh. + Tree(String), + /// Descriptor fetch. + Interface(String), + /// Explicit operation call. + Operation { + /// Target path. + path: String, + /// Interface reference. + interface: String, + /// Operation name. + operation: String, + /// Whether the transport dispatch boundary was crossed. + dispatched: bool, + }, +} + +impl InFlightKind { + /// Whether dropping the response is a safe cancellation operation. + #[must_use] + pub fn cancel_safe(&self) -> bool { + !matches!(self, Self::Operation { .. }) + } +} + +/// Active overlay. +#[derive(Debug, Clone, PartialEq)] +pub enum Modal { + /// No overlay. + None, + /// Keyboard help. + Help, + /// Descriptor-driven field editor. + Editor(ArgumentEditor), + /// Explicit pre-execution confirmation. + Confirm { + /// Editor retained when confirmation is cancelled. + editor: ArgumentEditor, + /// Validated arguments that will be sent. + arguments: BTreeMap, + }, +} + +/// Side effect requested by the pure reducer. +#[derive(Debug, Clone, PartialEq)] +pub enum Action { + /// Expand one node lazily. + Expand { + /// Tree root path. + path: String, + /// Maximum returned nodes. + limit: u32, + }, + /// Reobserve an Entry and its direct children. + Refresh { + /// Selected path. + path: String, + /// Current tree limit. + limit: u32, + }, + /// Retrieve one interface descriptor. + FetchInterface(String), + /// Execute only after explicit confirmation. + Execute { + /// Entry target. + path: String, + /// Interface reference. + interface: String, + /// Operation name. + operation: String, + /// Descriptor-validated arguments. + arguments: BTreeMap, + }, + /// Cancel the newest safe retrieval. + CancelRead, + /// Reconnect and fetch the root. + Reconnect, + /// Navigate to a descriptor-declared Entry result. + NavigateEntry(String), + /// Exit normally. + Quit, +} + +/// Complete presentation model. +#[derive(Debug, Clone, PartialEq)] +pub struct App { + /// Effective endpoint label (never credential material). + pub endpoint: String, + /// Connection lifecycle. + pub connection: ConnectionState, + /// Materialized tree nodes by path. + pub entries: BTreeMap, + /// Selected Entry path. + pub selected_path: String, + /// Focused pane. + pub focus: Focus, + /// Interface descriptor views by opaque reference. + pub interfaces: BTreeMap, + /// Selected row in the flattened interface/operation view. + pub operation_cursor: usize, + /// Selected recent-history row. + pub history_cursor: usize, + /// Active overlay. + pub modal: Modal, + /// In-flight requests keyed by client request identity. + pub in_flight: BTreeMap, + /// Recent result and diagnostic history, newest first. + pub history: Vec, + /// Maximum retained presentation history. + pub history_limit: usize, + /// Default node limit for lazy expansion. + pub default_tree_limit: u32, + /// One-line status for local action feedback. + pub status: String, +} + +impl App { + /// Creates a disconnected model with `/` selected. + #[must_use] + pub fn new(endpoint: impl Into) -> Self { + let default_tree_limit = 64; + let mut entries = BTreeMap::new(); + entries.insert("/".into(), EntryView::placeholder("/", default_tree_limit)); + Self { + endpoint: endpoint.into(), + connection: ConnectionState::Disconnected, + entries, + selected_path: "/".into(), + focus: Focus::Tree, + interfaces: BTreeMap::new(), + operation_cursor: 0, + history_cursor: 0, + modal: Modal::None, + in_flight: BTreeMap::new(), + history: Vec::new(), + history_limit: 64, + default_tree_limit, + status: "press ? for help".into(), + } + } + + /// Applies one key and returns requested effects without performing I/O. + pub fn reduce(&mut self, key: InputKey) -> Vec { + match std::mem::replace(&mut self.modal, Modal::None) { + Modal::Help => { + if !matches!(key, InputKey::Escape | InputKey::Help) { + self.modal = Modal::Help; + } + return Vec::new(); + } + Modal::Editor(mut editor) => { + match key { + InputKey::Escape => {} + InputKey::Up => { + editor.previous_field(); + self.modal = Modal::Editor(editor); + } + InputKey::Down | InputKey::Tab => { + editor.next_field(); + self.modal = Modal::Editor(editor); + } + InputKey::Backspace => { + editor.backspace(); + self.modal = Modal::Editor(editor); + } + InputKey::Character(character) => { + editor.insert(character); + self.modal = Modal::Editor(editor); + } + InputKey::Enter => match editor.validate() { + Ok(arguments) => self.modal = Modal::Confirm { editor, arguments }, + Err(_) => self.modal = Modal::Editor(editor), + }, + _ => self.modal = Modal::Editor(editor), + } + return Vec::new(); + } + Modal::Confirm { editor, arguments } => match key { + InputKey::Escape => { + self.modal = Modal::Editor(editor); + return Vec::new(); + } + InputKey::Enter | InputKey::Character('e') => { + let action = Action::Execute { + path: self.selected_path.clone(), + interface: editor.interface.clone(), + operation: editor.operation.name.clone(), + arguments, + }; + self.status = "operation dispatched; automatic retry is disabled".into(); + return vec![action]; + } + _ => { + self.modal = Modal::Confirm { editor, arguments }; + return Vec::new(); + } + }, + Modal::None => {} + } + + if key == InputKey::Help { + self.modal = Modal::Help; + return Vec::new(); + } + if key == InputKey::Quit { + return vec![Action::Quit]; + } + if key == InputKey::Reconnect { + return vec![Action::Reconnect]; + } + if key == InputKey::CancelRead { + return vec![Action::CancelRead]; + } + if key == InputKey::Tab { + self.focus = self.focus.next(); + return Vec::new(); + } + + match self.focus { + Focus::Tree => self.reduce_tree(key), + Focus::Operations => self.reduce_operations(key), + Focus::History => self.reduce_history(key), + Focus::Entry => { + if key == InputKey::Escape { + self.focus = Focus::Tree; + } else if key == InputKey::Refresh { + return vec![self.refresh_action()]; + } + Vec::new() + } + } + } + + fn reduce_tree(&mut self, key: InputKey) -> Vec { + let visible = self.visible_paths(); + let position = visible + .iter() + .position(|path| path == &self.selected_path) + .unwrap_or(0); + match key { + InputKey::Up if position > 0 => self.selected_path = visible[position - 1].clone(), + InputKey::Down if position + 1 < visible.len() => { + self.selected_path = visible[position + 1].clone(); + } + InputKey::Right | InputKey::Enter => { + let node = self + .entries + .entry(self.selected_path.clone()) + .or_insert_with(|| { + EntryView::placeholder(&self.selected_path, self.default_tree_limit) + }); + node.expanded = true; + if !matches!(node.tree_state, ViewState::Fresh) || node.truncated { + return vec![Action::Expand { + path: node.path.clone(), + limit: node.limit, + }]; + } + } + InputKey::Left => { + let node = self + .entries + .get_mut(&self.selected_path) + .expect("selected Entry exists"); + if node.expanded { + node.expanded = false; + } else if self.selected_path != "/" { + self.selected_path = parent_path(&self.selected_path); + } + } + InputKey::Refresh => return vec![self.refresh_action()], + InputKey::IncreaseLimit => { + let node = self + .entries + .get_mut(&self.selected_path) + .expect("selected exists"); + node.limit = node.limit.saturating_mul(2).max(1); + node.expanded = true; + return vec![Action::Expand { + path: node.path.clone(), + limit: node.limit, + }]; + } + _ => {} + } + self.operation_cursor = 0; + Vec::new() + } + + fn reduce_operations(&mut self, key: InputKey) -> Vec { + let rows = self.operation_rows(); + match key { + InputKey::Up => self.operation_cursor = self.operation_cursor.saturating_sub(1), + InputKey::Down if self.operation_cursor + 1 < rows.len() => self.operation_cursor += 1, + InputKey::Refresh => return vec![self.refresh_action()], + InputKey::Escape => self.focus = Focus::Tree, + InputKey::Enter => { + if let Some(row) = rows.get(self.operation_cursor) { + if let Some(operation_name) = &row.operation { + if let Some(interface) = self.interfaces.get(&row.interface) + && let Some(descriptor) = &interface.descriptor + && let Some(operation) = descriptor + .operations + .iter() + .find(|operation| operation.name == *operation_name) + { + self.modal = Modal::Editor(ArgumentEditor::new( + row.interface.clone(), + descriptor.clone(), + operation.clone(), + )); + } + } else { + return vec![Action::FetchInterface(row.interface.clone())]; + } + } + } + _ => {} + } + Vec::new() + } + + fn reduce_history(&mut self, key: InputKey) -> Vec { + match key { + InputKey::Up => self.history_cursor = self.history_cursor.saturating_sub(1), + InputKey::Down if self.history_cursor + 1 < self.history.len() => { + self.history_cursor += 1; + } + InputKey::Enter => { + if let Some(path) = self + .history + .get(self.history_cursor) + .and_then(|item| item.entry_target.clone()) + { + return vec![Action::NavigateEntry(path)]; + } + } + InputKey::Escape => self.focus = Focus::Tree, + _ => {} + } + Vec::new() + } + + fn refresh_action(&self) -> Action { + let limit = self + .entries + .get(&self.selected_path) + .map_or(self.default_tree_limit, |entry| entry.limit); + Action::Refresh { + path: self.selected_path.clone(), + limit, + } + } + + /// Records a pending request separately by interaction type. + pub fn request_started(&mut self, id: u64, kind: InFlightKind) { + match &kind { + InFlightKind::Object(path) => { + self.ensure_entry(path).state = ViewState::Loading(id); + } + InFlightKind::Tree(path) => { + let entry = self.ensure_entry(path); + entry.state = ViewState::Loading(id); + entry.tree_state = ViewState::Loading(id); + } + InFlightKind::Interface(reference) => { + self.ensure_interface(reference).state = ViewState::Loading(id); + } + InFlightKind::Operation { .. } => {} + } + self.in_flight.insert(id, kind); + } + + /// Marks an operation as handed to the transport. + pub fn request_dispatched(&mut self, id: u64) { + if let Some(InFlightKind::Operation { dispatched, .. }) = self.in_flight.get_mut(&id) { + *dispatched = true; + } + } + + /// Removes a completed request identity. + pub fn request_finished(&mut self, id: u64) -> Option { + self.in_flight.remove(&id) + } + + /// Applies an object observation from `wip-client`. + pub fn apply_object(&mut self, observation: ObjectObservation) { + let entry = self.ensure_entry(&observation.path); + entry.object = observation.object; + entry.state = ViewState::from(&observation.state); + } + + /// Applies a lazy tree observation and ensures child placeholders exist. + pub fn apply_tree(&mut self, observation: TreeObservation) { + let children = observation.children.clone(); + let entry = self.ensure_entry(&observation.path); + entry.children = children.clone(); + entry.truncated = observation.truncated; + entry.tree_state = ViewState::from(&observation.state); + entry.expanded = true; + for path in children { + self.ensure_entry(&path); + } + } + + /// Applies one interface observation from `wip-client`. + pub fn apply_interface(&mut self, observation: InterfaceObservation) { + let view = self.ensure_interface(&observation.reference); + *view = InterfaceView { + reference: observation.reference, + descriptor: observation.descriptor, + state: ViewState::from(&observation.state), + }; + } + + /// Replaces current Entry details from the client's current snapshot. + pub fn sync_object(&mut self, path: &str, observation: Option) { + match observation { + Some(observation) => self.apply_object(observation), + None => { + let entry = self.ensure_entry(path); + entry.object = None; + entry.state = ViewState::Stale; + } + } + } + + /// Selects a known or newly discovered descriptor-declared Entry path. + pub fn navigate_entry(&mut self, path: impl Into) { + let path = path.into(); + self.ensure_entry(&path); + self.selected_path = path; + self.focus = Focus::Tree; + } + + /// Adds one bounded recent item. + pub fn push_history(&mut self, item: HistoryItem) { + self.history.insert(0, item); + self.history.truncate(self.history_limit); + self.history_cursor = 0; + } + + /// Returns visible tree paths in display order. + #[must_use] + pub fn visible_paths(&self) -> Vec { + let mut visible = Vec::new(); + let mut seen = BTreeSet::new(); + self.collect_visible("/", &mut seen, &mut visible); + if !seen.contains(&self.selected_path) && self.entries.contains_key(&self.selected_path) { + visible.push(self.selected_path.clone()); + } + visible + } + + fn collect_visible(&self, path: &str, seen: &mut BTreeSet, visible: &mut Vec) { + if !seen.insert(path.to_owned()) { + return; + } + visible.push(path.to_owned()); + let Some(entry) = self.entries.get(path) else { + return; + }; + if entry.expanded { + for child in &entry.children { + self.collect_visible(child, seen, visible); + } + } + } + + /// Returns flattened interface headers and operations for the selected Entry. + #[must_use] + pub fn operation_rows(&self) -> Vec { + let Some(object) = self + .entries + .get(&self.selected_path) + .filter(|entry| entry.state == ViewState::Fresh) + .and_then(|entry| entry.object.as_ref()) + else { + return Vec::new(); + }; + let mut rows = Vec::new(); + for reference in &object.interfaces { + rows.push(OperationRow { + interface: reference.clone(), + operation: None, + }); + if let Some(descriptor) = self + .interfaces + .get(reference) + .filter(|view| view.state == ViewState::Fresh) + .and_then(|view| view.descriptor.as_ref()) + { + rows.extend(descriptor.operations.iter().map(|operation| OperationRow { + interface: reference.clone(), + operation: Some(operation.name.clone()), + })); + } + } + rows + } + + fn ensure_entry(&mut self, path: &str) -> &mut EntryView { + if !self.entries.contains_key(path) && self.entries.len() >= MAX_PRESENTATION_ENTRIES { + let candidate = self + .entries + .iter() + .find(|(candidate, entry)| { + candidate.as_str() != "/" + && candidate.as_str() != self.selected_path + && matches!( + entry.state, + ViewState::Unloaded | ViewState::Stale | ViewState::Error(_) + ) + }) + .or_else(|| { + self.entries.iter().find(|(candidate, entry)| { + candidate.as_str() != "/" + && candidate.as_str() != self.selected_path + && !matches!(entry.state, ViewState::Loading(_)) + }) + }) + .map(|(candidate, _)| candidate.clone()); + if let Some(candidate) = candidate { + self.entries.remove(&candidate); + for entry in self.entries.values_mut() { + entry.children.retain(|child| child != &candidate); + } + } + } + self.entries + .entry(path.to_owned()) + .or_insert_with(|| EntryView::placeholder(path, self.default_tree_limit)) + } + + fn ensure_interface(&mut self, reference: &str) -> &mut InterfaceView { + if !self.interfaces.contains_key(reference) + && self.interfaces.len() >= MAX_PRESENTATION_INTERFACES + { + let protected = self + .entries + .get(&self.selected_path) + .and_then(|entry| entry.object.as_ref()) + .map(|object| object.interfaces.iter().cloned().collect::>()) + .unwrap_or_default(); + let candidate = self + .interfaces + .iter() + .find(|(candidate, interface)| { + !protected.contains(*candidate) + && !matches!(interface.state, ViewState::Loading(_)) + }) + .or_else(|| { + self.interfaces + .iter() + .find(|(_, interface)| !matches!(interface.state, ViewState::Loading(_))) + }) + .map(|(candidate, _)| candidate.clone()); + if let Some(candidate) = candidate { + self.interfaces.remove(&candidate); + } + } + self.interfaces + .entry(reference.to_owned()) + .or_insert_with(|| InterfaceView { + reference: reference.to_owned(), + descriptor: None, + state: ViewState::Unloaded, + }) + } +} + +/// One row in the combined Interface and Operation pane. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OperationRow { + /// Interface reference, which participates in operation identity. + pub interface: String, + /// Operation name, or `None` for the interface heading. + pub operation: Option, +} + +/// Returns the canonical parent of a non-root path. +#[must_use] +pub fn parent_path(path: &str) -> String { + match path.rsplit_once('/') { + Some(("", _)) | None => "/".into(), + Some((parent, _)) => parent.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wip_protocol::{INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, TypeExpr}; + + fn object(name: &str, interfaces: &[&str]) -> Object { + Object { + name: name.into(), + description: Some(format!("{name} description")), + interfaces: interfaces.iter().map(|value| (*value).into()).collect(), + r#ref: None, + validator: None, + } + } + + #[test] + fn tree_navigation_expansion_parent_and_limit_are_pure() { + let mut app = App::new("https://example.test/wip"); + app.apply_tree(TreeObservation { + path: "/".into(), + children: vec!["/a".into(), "/b".into()], + truncated: true, + revision: request_id(), + state: ObservationState::Fresh, + }); + assert_eq!(app.visible_paths(), ["/", "/a", "/b"]); + app.reduce(InputKey::Down); + assert_eq!(app.selected_path, "/a"); + app.reduce(InputKey::Left); + assert_eq!(app.selected_path, "/"); + assert!(matches!( + app.reduce(InputKey::IncreaseLimit).as_slice(), + [Action::Expand { path, limit: 128 }] if path == "/" + )); + } + + #[test] + fn focus_modal_and_explicit_confirmation_gate_execution() { + let mut app = App::new("https://example.test/wip"); + app.apply_object(ObjectObservation { + path: "/".into(), + object: Some(object("", &["iface"])), + validator: None, + state: ObservationState::Fresh, + }); + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: None, + types: vec![], + operations: vec![OperationDeclaration { + name: "ping".into(), + documentation: None, + parameters: vec![], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Unit, + }, + }], + }; + app.apply_interface(InterfaceObservation { + reference: "iface".into(), + descriptor: Some(descriptor), + validator: None, + state: ObservationState::Fresh, + }); + app.focus = Focus::Operations; + app.operation_cursor = 1; + assert!(app.reduce(InputKey::Enter).is_empty()); + assert!(matches!(app.modal, Modal::Editor(_))); + assert!(app.reduce(InputKey::Enter).is_empty()); + assert!(matches!(app.modal, Modal::Confirm { .. })); + assert!(matches!( + app.reduce(InputKey::Enter).as_slice(), + [Action::Execute { operation, .. }] if operation == "ping" + )); + } + + #[test] + fn presentation_maps_enforce_client_sized_hard_bounds() { + let mut app = App::new("https://example.test/wip"); + for index in 0..(MAX_PRESENTATION_ENTRIES + 100) { + app.navigate_entry(format!("/discovered-{index}")); + app.selected_path = "/".into(); + } + assert!(app.entries.len() <= MAX_PRESENTATION_ENTRIES); + assert!(app.entries.contains_key("/")); + + for index in 0..(MAX_PRESENTATION_INTERFACES + 50) { + app.apply_interface(InterfaceObservation { + reference: format!("interface-{index}"), + descriptor: None, + validator: None, + state: ObservationState::Fresh, + }); + } + assert!(app.interfaces.len() <= MAX_PRESENTATION_INTERFACES); + } + + #[test] + fn result_navigation_only_uses_explicit_entry_target() { + let mut app = App::new("https://example.test/wip"); + app.push_history(HistoryItem { + kind: HistoryKind::Success, + title: "string".into(), + detail: "/looks/like/a/path".into(), + entry_target: None, + }); + app.focus = Focus::History; + assert!(app.reduce(InputKey::Enter).is_empty()); + app.push_history(HistoryItem { + kind: HistoryKind::Success, + title: "Entry".into(), + detail: "/actual".into(), + entry_target: Some("/actual".into()), + }); + assert_eq!( + app.reduce(InputKey::Enter), + vec![Action::NavigateEntry("/actual".into())] + ); + } + + fn request_id() -> wip_client::RequestId { + // A request identity has no public constructor; derive one through the runtime. + let mut client = wip_client::Client::new( + wip_client::ClientLimits::new(1, 4, 4, 4, 4).unwrap(), + wip_https::Limits::new(1024, 1024, 8, 8).unwrap(), + ); + let session = client + .open_session( + "https://example.test/wip", + wip_client::SecurityContext::new("test"), + ) + .unwrap(); + client.prepare_fetch(&session, "/").unwrap().id + } +} diff --git a/crates/wip-tui/src/config.rs b/crates/wip-tui/src/config.rs new file mode 100644 index 0000000..cba53f6 --- /dev/null +++ b/crates/wip-tui/src/config.rs @@ -0,0 +1,315 @@ +//! Secure-by-default command-line and environment configuration. + +use std::error::Error; +use std::fmt::{self, Debug, Display, Formatter}; +use std::time::Duration; + +use wip_https::url::Url; + +/// Credential wrapper whose debug output is always redacted. +#[derive(Clone)] +pub struct Secret(String); + +impl Secret { + pub(crate) fn expose(&self) -> &str { + &self.0 + } +} + +impl Debug for Secret { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str("Secret([REDACTED])") + } +} + +/// Validated runtime configuration. +#[derive(Clone)] +pub struct Config { + /// Canonical logical HTTPS endpoint used by `wip-client`. + logical_endpoint: String, + /// Actual request origin; differs only for explicit loopback development HTTP. + transport_endpoint: String, + /// Non-secret cache partition identity. + security_context: String, + /// Bounded network timeout. + timeout: Duration, + /// Optional complete Authorization header value, never rendered or logged. + authorization: Option, + /// Whether loopback request URIs are deliberately downgraded to HTTP. + development_http: bool, +} + +impl Debug for Config { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("Config") + .field("logical_endpoint", &self.logical_endpoint) + .field("transport_endpoint", &self.transport_endpoint) + .field("security_context", &self.security_context) + .field("timeout", &self.timeout) + .field("authorization", &self.authorization) + .field("development_http", &self.development_http) + .finish() + } +} + +impl Config { + /// Returns the canonical HTTPS endpoint used for protocol request construction. + #[must_use] + pub fn logical_endpoint(&self) -> &str { + &self.logical_endpoint + } + + /// Returns the validated effective transport endpoint shown in the UI. + #[must_use] + pub fn transport_endpoint(&self) -> &str { + &self.transport_endpoint + } + + /// Returns the non-secret session cache partition identity. + #[must_use] + pub fn security_context(&self) -> &str { + &self.security_context + } + + /// Returns the end-to-end request timeout. + #[must_use] + pub const fn timeout(&self) -> Duration { + self.timeout + } + + /// Whether explicit loopback development HTTP is enabled. + #[must_use] + pub const fn development_http(&self) -> bool { + self.development_http + } + + pub(crate) fn authorization(&self) -> Option<&Secret> { + self.authorization.as_ref() + } + + /// Parses process arguments and supported environment variables. + /// + /// Endpoint precedence is `--endpoint`, a positional endpoint, then + /// `WIP_ENDPOINT`. Authorization is read only from the variable named by + /// `--authorization-env` (default `WIP_AUTHORIZATION`). + pub fn from_env() -> Result { + let arguments = std::env::args().skip(1).collect::>(); + let endpoint_env = std::env::var("WIP_ENDPOINT").ok(); + let security_context = std::env::var("WIP_SECURITY_CONTEXT").ok(); + Self::parse(arguments, endpoint_env, security_context, |name| { + std::env::var(name).ok() + }) + } + + /// Parses supplied values; exposed to make startup policy independently testable. + pub fn parse( + arguments: I, + endpoint_env: Option, + security_context_env: Option, + mut read_secret: F, + ) -> Result + where + I: IntoIterator, + S: Into, + F: FnMut(&str) -> Option, + { + let mut endpoint = None; + let mut allow_http = false; + let mut timeout = Duration::from_secs(30); + let mut security_context = security_context_env.unwrap_or_else(|| "anonymous".into()); + let mut authorization_env = "WIP_AUTHORIZATION".to_owned(); + let mut iter = arguments.into_iter().map(Into::into); + while let Some(argument) = iter.next() { + match argument.as_str() { + "--endpoint" => endpoint = Some(next_value(&mut iter, "--endpoint")?), + "--allow-http" => allow_http = true, + "--security-context" => { + security_context = next_value(&mut iter, "--security-context")?; + } + "--authorization-env" => { + authorization_env = next_value(&mut iter, "--authorization-env")?; + } + "--timeout" => { + let raw = next_value(&mut iter, "--timeout")?; + let seconds = raw + .parse::() + .ok() + .filter(|seconds| *seconds > 0) + .ok_or_else(|| { + ConfigError::Invalid("--timeout must be a positive integer".into()) + })?; + timeout = Duration::from_secs(seconds); + } + "--help" | "-h" => return Err(ConfigError::Help), + value if value.starts_with('-') => { + return Err(ConfigError::Invalid(format!("unknown option {value}"))); + } + value if endpoint.is_none() => endpoint = Some(value.to_owned()), + _ => return Err(ConfigError::Invalid("multiple endpoints supplied".into())), + } + } + let endpoint = endpoint + .or(endpoint_env) + .ok_or_else(|| ConfigError::Invalid("endpoint is required".into()))?; + let mut actual = Url::parse(&endpoint) + .map_err(|_| ConfigError::Invalid("endpoint is not a valid absolute URL".into()))?; + if actual.query().is_some() + || actual.fragment().is_some() + || actual.host_str().is_none() + || !actual.username().is_empty() + || actual.password().is_some() + { + return Err(ConfigError::Invalid( + "endpoint must be an absolute URL without userinfo, query, or fragment".into(), + )); + } + if !actual.path().ends_with('/') { + let path = format!("{}/", actual.path()); + actual.set_path(&path); + } + let development_http = actual.scheme() == "http"; + match actual.scheme() { + "https" => {} + "http" if allow_http && loopback_host(&actual) => {} + "http" if !allow_http => { + return Err(ConfigError::Invalid( + "plain HTTP requires the explicit --allow-http development option".into(), + )); + } + "http" => { + return Err(ConfigError::Invalid( + "development HTTP is restricted to loopback endpoints".into(), + )); + } + _ => return Err(ConfigError::Invalid("endpoint must use HTTPS".into())), + } + let transport_endpoint = actual.to_string(); + let mut logical = actual; + if development_http { + logical + .set_scheme("https") + .expect("http and https are interchangeable hierarchical schemes"); + } + // Let the binding enforce all remaining canonical endpoint rules. + let logical_endpoint = wip_https::Endpoint::parse(logical.as_str()) + .map_err(|error| ConfigError::Invalid(error.to_string()))? + .to_string(); + if security_context.is_empty() { + return Err(ConfigError::Invalid( + "security context must not be empty".into(), + )); + } + let authorization = read_secret(&authorization_env) + .filter(|value| !value.is_empty()) + .map(Secret); + Ok(Self { + logical_endpoint, + transport_endpoint, + security_context, + timeout, + authorization, + development_http, + }) + } + + /// Returns concise command usage without mentioning credential values. + #[must_use] + pub const fn usage() -> &'static str { + "usage: wip-tui [--endpoint] URL [--allow-http] [--security-context NAME] [--authorization-env VAR] [--timeout SECONDS]\n\ + URL may also be supplied as WIP_ENDPOINT; HTTPS is required unless loopback HTTP is explicitly enabled." + } +} + +fn next_value( + iter: &mut impl Iterator, + option: &str, +) -> Result { + iter.next() + .ok_or_else(|| ConfigError::Invalid(format!("{option} requires a value"))) +} + +fn loopback_host(url: &Url) -> bool { + match url.host() { + Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(), + Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(), + Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"), + None => false, + } +} + +/// Startup configuration failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigError { + /// Help was requested. + Help, + /// A safe, non-secret configuration diagnostic. + Invalid(String), +} + +impl Display for ConfigError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Help => formatter.write_str(Config::usage()), + Self::Invalid(detail) => write!(formatter, "{detail}\n{}", Config::usage()), + } + } +} + +impl Error for ConfigError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn https_is_default_and_secrets_are_redacted() { + let config = Config::parse( + ["https://example.test/wip", "--authorization-env", "TOKEN"], + None, + None, + |name| (name == "TOKEN").then(|| "Bearer secret-value".into()), + ) + .unwrap(); + assert_eq!(config.logical_endpoint(), "https://example.test/wip/"); + let debug = format!("{config:?}"); + assert!(debug.contains("[REDACTED]")); + assert!(!debug.contains("secret-value")); + } + + #[test] + fn endpoint_userinfo_is_rejected_before_it_can_reach_debug_or_render_state() { + for arguments in [ + vec!["https://user:password@example.test/wip"], + vec!["http://user:password@localhost:3000/wip", "--allow-http"], + ] { + let error = Config::parse(arguments, None, None, |_| None).unwrap_err(); + assert!(error.to_string().contains("without userinfo")); + assert!(!error.to_string().contains("password")); + } + } + + #[test] + fn http_requires_explicit_loopback_development_mode() { + assert!(Config::parse(["http://localhost:3000/wip"], None, None, |_| None).is_err()); + let config = Config::parse( + ["http://127.0.0.1:3000/wip", "--allow-http"], + None, + None, + |_| None, + ) + .unwrap(); + assert_eq!(config.logical_endpoint(), "https://127.0.0.1:3000/wip/"); + assert_eq!(config.transport_endpoint(), "http://127.0.0.1:3000/wip/"); + assert!(config.development_http()); + assert!( + Config::parse( + ["http://example.test/wip", "--allow-http"], + None, + None, + |_| None, + ) + .is_err() + ); + } +} diff --git a/crates/wip-tui/src/editor.rs b/crates/wip-tui/src/editor.rs new file mode 100644 index 0000000..d34b7b9 --- /dev/null +++ b/crates/wip-tui/src/editor.rs @@ -0,0 +1,628 @@ +//! Descriptor-driven operation argument editing and validation. + +use std::collections::BTreeMap; + +use base64::Engine as _; +use base64::engine::general_purpose::STANDARD; +use wip_protocol::{InterfaceDescriptor, OperationDeclaration, TypeExpr, Value}; + +const MAX_FIELD_INPUT_BYTES: usize = 256 * 1024; + +/// One editable top-level operation parameter. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArgumentField { + /// Parameter name. + pub name: String, + /// Whether the parameter must be supplied. + pub required: bool, + /// Canonical descriptor type. + pub r#type: TypeExpr, + /// User-entered text. + pub input: String, + /// Field-local validation diagnostic. + pub diagnostic: Option, +} + +/// Marker returned when one or more field diagnostics prevent submission. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InvalidArguments; + +/// Pure, terminal-independent editor built from an operation descriptor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ArgumentEditor { + /// Opaque interface reference selected for the call. + pub interface: String, + /// Selected operation declaration. + pub operation: OperationDeclaration, + /// Complete descriptor used to resolve named types. + pub descriptor: InterfaceDescriptor, + /// Top-level parameter fields in declaration order. + pub fields: Vec, + /// Index of the active field. + pub cursor: usize, +} + +impl ArgumentEditor { + /// Builds an editor whose fields preserve descriptor order. + #[must_use] + pub fn new( + interface: impl Into, + descriptor: InterfaceDescriptor, + operation: OperationDeclaration, + ) -> Self { + let fields = operation + .parameters + .iter() + .map(|parameter| ArgumentField { + name: parameter.name.clone(), + required: parameter.required, + r#type: parameter.r#type.clone(), + input: if parameter.required && parameter.r#type == TypeExpr::Unit { + "null".into() + } else { + String::new() + }, + diagnostic: None, + }) + .collect(); + Self { + interface: interface.into(), + operation, + descriptor, + fields, + cursor: 0, + } + } + + /// Moves to the preceding field without wrapping. + pub fn previous_field(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + /// Moves to the following field without wrapping. + pub fn next_field(&mut self) { + if self.cursor + 1 < self.fields.len() { + self.cursor += 1; + } + } + + /// Appends one printable character to the active field. + pub fn insert(&mut self, character: char) { + if !character.is_control() + && let Some(field) = self.fields.get_mut(self.cursor) + { + if field.input.len().saturating_add(character.len_utf8()) <= MAX_FIELD_INPUT_BYTES { + field.input.push(character); + field.diagnostic = None; + } else { + field.diagnostic = Some("field input reached the 256 KiB limit".into()); + } + } + } + + /// Removes one scalar value from the active field. + pub fn backspace(&mut self) { + if let Some(field) = self.fields.get_mut(self.cursor) { + field.input.pop(); + field.diagnostic = None; + } + } + + /// Validates all fields and returns protocol values when every field is valid. + /// + /// Invalid or missing values are retained as field-local diagnostics. Optional + /// blank fields are omitted from the resulting argument map. + pub fn validate(&mut self) -> Result, InvalidArguments> { + let mut arguments = BTreeMap::new(); + let mut invalid = false; + for field in &mut self.fields { + field.diagnostic = None; + if field.input.len() > MAX_FIELD_INPUT_BYTES { + field.diagnostic = Some("field input exceeds the 256 KiB limit".into()); + invalid = true; + continue; + } + if field.input.trim().is_empty() { + if field.required { + field.diagnostic = Some("required value is missing".into()); + invalid = true; + } + continue; + } + match parse_value( + &self.descriptor, + &field.r#type, + field.input.trim(), + &field.name, + ) { + Ok(value) => { + arguments.insert(field.name.clone(), value); + } + Err(error) => { + field.diagnostic = Some(error); + invalid = true; + } + } + } + if invalid { + return Err(InvalidArguments); + } + if let Err(error) = self + .descriptor + .validate_arguments(&self.operation.name, &arguments) + { + if let Some(field) = self.fields.get_mut(self.cursor) { + field.diagnostic = Some(error.to_string()); + } + return Err(InvalidArguments); + } + Ok(arguments) + } +} + +/// Returns a compact canonical spelling for a type expression. +#[must_use] +pub fn type_name(r#type: &TypeExpr) -> String { + match r#type { + TypeExpr::Unit => "Unit".into(), + TypeExpr::Boolean => "Boolean".into(), + TypeExpr::Integer => "Integer".into(), + TypeExpr::Number => "Number".into(), + TypeExpr::String => "String".into(), + TypeExpr::Bytes => "Bytes(base64)".into(), + TypeExpr::Json => "Json".into(), + TypeExpr::Entry => "Entry".into(), + TypeExpr::Named { name } => name.clone(), + TypeExpr::Record { fields } => format!( + "Record{{{}}}", + fields + .iter() + .map(|field| format!( + "{}{}: {}", + field.name, + if field.required { "" } else { "?" }, + type_name(&field.r#type) + )) + .collect::>() + .join(", ") + ), + TypeExpr::List { items } => format!("List<{}>", type_name(items)), + TypeExpr::Enum { cases } => format!( + "Enum<{}>", + cases + .iter() + .map(|case| case.name.as_str()) + .collect::>() + .join(" | ") + ), + TypeExpr::Union { cases } => format!( + "Union<{}>", + cases + .iter() + .map(|case| case.name.as_str()) + .collect::>() + .join(" | ") + ), + } +} + +fn parse_value( + descriptor: &InterfaceDescriptor, + r#type: &TypeExpr, + input: &str, + field: &str, +) -> Result { + if let TypeExpr::Named { name } = r#type { + let declaration = descriptor + .types + .iter() + .find(|declaration| declaration.name == *name) + .ok_or_else(|| format!("{field}: unknown named type {name}"))?; + return parse_value(descriptor, &declaration.definition, input, field); + } + let value = match r#type { + TypeExpr::Unit => { + if matches!(input, "null" | "()") { + Value::Unit + } else { + return Err(format!("{field}: expected null")); + } + } + TypeExpr::Boolean => input + .parse::() + .map(Value::Boolean) + .map_err(|_| format!("{field}: expected true or false"))?, + TypeExpr::Integer => input + .parse::() + .map(Value::Integer) + .map_err(|_| format!("{field}: expected a signed integer"))?, + TypeExpr::Number => { + let number = input + .parse::() + .map_err(|_| format!("{field}: expected a finite number"))?; + if !number.is_finite() { + return Err(format!("{field}: expected a finite number")); + } + Value::Number(number) + } + TypeExpr::String => Value::String(parse_string(input)), + TypeExpr::Bytes => Value::Bytes( + STANDARD + .decode(input) + .map_err(|_| format!("{field}: expected RFC 4648 base64"))?, + ), + TypeExpr::Entry => { + let path = parse_string(input); + wip_protocol::validate_path(&path) + .map_err(|error| format!("{field}: invalid Entry path: {error}"))?; + Value::String(path) + } + TypeExpr::Enum { cases } => { + let case = parse_string(input); + if !cases.iter().any(|candidate| candidate.name == case) { + return Err(format!("{field}: unknown enum case {case}")); + } + Value::String(case) + } + TypeExpr::Json => { + let json = serde_json::from_str(input) + .map_err(|error| format!("{field}: invalid JSON: {error}"))?; + json_to_untyped(&json, field)? + } + TypeExpr::Record { .. } | TypeExpr::List { .. } | TypeExpr::Union { .. } => { + let json = serde_json::from_str(input) + .map_err(|error| format!("{field}: invalid JSON: {error}"))?; + json_to_typed(descriptor, r#type, &json, field)? + } + TypeExpr::Named { .. } => unreachable!("named types handled above"), + }; + descriptor + .validate_value(r#type, &value) + .map_err(|error| format!("{field}: {error}"))?; + Ok(value) +} + +fn parse_string(input: &str) -> String { + serde_json::from_str::(input).unwrap_or_else(|_| input.to_owned()) +} + +fn json_to_typed( + descriptor: &InterfaceDescriptor, + r#type: &TypeExpr, + json: &serde_json::Value, + field: &str, +) -> Result { + if let TypeExpr::Named { name } = r#type { + let declaration = descriptor + .types + .iter() + .find(|declaration| declaration.name == *name) + .ok_or_else(|| format!("{field}: unknown named type {name}"))?; + return json_to_typed(descriptor, &declaration.definition, json, field); + } + match r#type { + TypeExpr::Unit if json.is_null() => Ok(Value::Unit), + TypeExpr::Boolean => json + .as_bool() + .map(Value::Boolean) + .ok_or_else(|| format!("{field}: expected boolean")), + TypeExpr::Integer => json + .as_i64() + .map(Value::Integer) + .ok_or_else(|| format!("{field}: expected integer")), + TypeExpr::Number => json + .as_f64() + .filter(|value| value.is_finite()) + .map(Value::Number) + .ok_or_else(|| format!("{field}: expected finite number")), + TypeExpr::String => json + .as_str() + .map(|value| Value::String(value.into())) + .ok_or_else(|| format!("{field}: expected string")), + TypeExpr::Bytes => json + .as_str() + .ok_or_else(|| format!("{field}: expected base64 string")) + .and_then(|value| { + STANDARD + .decode(value) + .map(Value::Bytes) + .map_err(|_| format!("{field}: expected RFC 4648 base64")) + }), + TypeExpr::Entry => { + let path = json + .as_str() + .ok_or_else(|| format!("{field}: expected Entry string"))?; + wip_protocol::validate_path(path) + .map_err(|error| format!("{field}: invalid Entry path: {error}"))?; + Ok(Value::String(path.into())) + } + TypeExpr::Enum { cases } => { + let case = json + .as_str() + .ok_or_else(|| format!("{field}: expected enum string"))?; + if !cases.iter().any(|candidate| candidate.name == case) { + return Err(format!("{field}: unknown enum case {case}")); + } + Ok(Value::String(case.into())) + } + TypeExpr::Json => json_to_untyped(json, field), + TypeExpr::List { items } => json + .as_array() + .ok_or_else(|| format!("{field}: expected array"))? + .iter() + .enumerate() + .map(|(index, item)| { + json_to_typed(descriptor, items, item, &format!("{field}[{index}]")) + }) + .collect::, _>>() + .map(Value::List), + TypeExpr::Record { fields } => { + let object = json + .as_object() + .ok_or_else(|| format!("{field}: expected object"))?; + for name in object.keys() { + if !fields.iter().any(|candidate| candidate.name == *name) { + return Err(format!("{field}.{name}: unknown field")); + } + } + let mut values = BTreeMap::new(); + for declaration in fields { + match object.get(&declaration.name) { + Some(value) => { + values.insert( + declaration.name.clone(), + json_to_typed( + descriptor, + &declaration.r#type, + value, + &format!("{field}.{}", declaration.name), + )?, + ); + } + None if declaration.required => { + return Err(format!( + "{field}.{}: required field is missing", + declaration.name + )); + } + None => {} + } + } + Ok(Value::Record(values)) + } + TypeExpr::Union { cases } => { + let object = json + .as_object() + .ok_or_else(|| format!("{field}: expected union object"))?; + if object.keys().any(|name| name != "$case" && name != "value") { + return Err(format!("{field}: union accepts only $case and value")); + } + let case_name = object + .get("$case") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("{field}.$case: required string is missing"))?; + let case = cases + .iter() + .find(|candidate| candidate.name == case_name) + .ok_or_else(|| format!("{field}.$case: unknown union case {case_name}"))?; + let mut values = BTreeMap::from([("$case".into(), Value::String(case_name.into()))]); + match (&case.payload, object.get("value")) { + (Some(payload), Some(value)) => { + values.insert( + "value".into(), + json_to_typed(descriptor, payload, value, &format!("{field}.value"))?, + ); + } + (Some(_), None) => { + return Err(format!("{field}.value: required payload is missing")); + } + (None, Some(_)) => { + return Err(format!("{field}.value: payload-free case has a value")); + } + (None, None) => {} + } + Ok(Value::Record(values)) + } + TypeExpr::Named { .. } => unreachable!("named types handled above"), + _ => Err(format!( + "{field}: value does not match {}", + type_name(r#type) + )), + } +} + +fn json_to_untyped(json: &serde_json::Value, field: &str) -> Result { + match json { + serde_json::Value::Null => Ok(Value::Unit), + serde_json::Value::Bool(value) => Ok(Value::Boolean(*value)), + serde_json::Value::Number(value) => { + if let Some(integer) = value.as_i64() { + Ok(Value::Integer(integer)) + } else { + value + .as_f64() + .filter(|number| number.is_finite()) + .map(Value::Number) + .ok_or_else(|| format!("{field}: number is not finite")) + } + } + serde_json::Value::String(value) => Ok(Value::String(value.clone())), + serde_json::Value::Array(values) => values + .iter() + .enumerate() + .map(|(index, value)| json_to_untyped(value, &format!("{field}[{index}]"))) + .collect::, _>>() + .map(Value::List), + serde_json::Value::Object(values) => values + .iter() + .map(|(name, value)| { + json_to_untyped(value, &format!("{field}.{name}")) + .map(|value| (name.clone(), value)) + }) + .collect::, _>>() + .map(Value::Record), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wip_protocol::{ + EnumCase, FieldDeclaration, INTERFACE_FORMAT_V1, ParameterDeclaration, ReturnDeclaration, + TypeDeclaration, UnionCase, + }; + + fn descriptor( + parameters: Vec, + types: Vec, + ) -> InterfaceDescriptor { + InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: None, + types, + operations: vec![OperationDeclaration { + name: "run".into(), + documentation: None, + parameters, + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Unit, + }, + }], + } + } + + #[test] + fn validates_every_canonical_shape_and_reports_fields() { + let parameters = vec![ + parameter("unit", true, TypeExpr::Unit), + parameter("flag", true, TypeExpr::Boolean), + parameter("count", true, TypeExpr::Integer), + parameter("number", true, TypeExpr::Number), + parameter("text", true, TypeExpr::String), + parameter("bytes", true, TypeExpr::Bytes), + parameter("entry", true, TypeExpr::Entry), + parameter("json", true, TypeExpr::Json), + parameter( + "list", + true, + TypeExpr::List { + items: Box::new(TypeExpr::Integer), + }, + ), + parameter( + "record", + true, + TypeExpr::Record { + fields: vec![FieldDeclaration { + name: "name".into(), + required: true, + documentation: None, + r#type: TypeExpr::String, + }], + }, + ), + parameter( + "choice", + true, + TypeExpr::Enum { + cases: vec![EnumCase { + name: "yes".into(), + documentation: None, + }], + }, + ), + parameter( + "union", + true, + TypeExpr::Union { + cases: vec![UnionCase { + name: "some".into(), + documentation: None, + payload: Some(TypeExpr::Integer), + }], + }, + ), + parameter( + "named", + true, + TypeExpr::Named { + name: "Alias".into(), + }, + ), + ]; + let descriptor = descriptor( + parameters, + vec![TypeDeclaration { + name: "Alias".into(), + documentation: None, + definition: TypeExpr::String, + }], + ); + let operation = descriptor.operations[0].clone(); + let mut editor = ArgumentEditor::new("test", descriptor, operation); + let inputs = [ + "null", + "true", + "7", + "2.5", + "hello", + "aGk=", + "/item", + "{\"safe\":true}", + "[1,2]", + "{\"name\":\"n\"}", + "yes", + "{\"$case\":\"some\",\"value\":4}", + "value", + ]; + for (field, input) in editor.fields.iter_mut().zip(inputs) { + field.input = input.into(); + } + let values = editor.validate().expect("valid values"); + assert_eq!(values["bytes"], Value::Bytes(b"hi".to_vec())); + assert_eq!(values["entry"], Value::String("/item".into())); + assert!(editor.fields.iter().all(|field| field.diagnostic.is_none())); + } + + #[test] + fn missing_invalid_and_optional_fields_are_field_local() { + let descriptor = descriptor( + vec![ + parameter("required", true, TypeExpr::Integer), + parameter("optional", false, TypeExpr::Boolean), + ], + vec![], + ); + let operation = descriptor.operations[0].clone(); + let mut editor = ArgumentEditor::new("test", descriptor, operation); + editor.fields[1].input = "not-a-bool".into(); + assert_eq!(editor.validate(), Err(InvalidArguments)); + assert!( + editor.fields[0] + .diagnostic + .as_deref() + .unwrap() + .contains("required") + ); + assert!( + editor.fields[1] + .diagnostic + .as_deref() + .unwrap() + .contains("true or false") + ); + editor.fields[0].input = "1".into(); + editor.fields[1].input.clear(); + assert_eq!(editor.validate().unwrap().len(), 1); + } + + fn parameter(name: &str, required: bool, r#type: TypeExpr) -> ParameterDeclaration { + ParameterDeclaration { + name: name.into(), + required, + documentation: None, + r#type, + } + } +} diff --git a/crates/wip-tui/src/lib.rs b/crates/wip-tui/src/lib.rs new file mode 100644 index 0000000..5823ce2 --- /dev/null +++ b/crates/wip-tui/src/lib.rs @@ -0,0 +1,10 @@ +//! Presentation and terminal interaction for stateful WIP client sessions. + +#![deny(missing_docs)] + +pub mod app; +pub mod config; +pub mod editor; +pub mod render; +pub mod runtime; +pub mod terminal; diff --git a/crates/wip-tui/src/main.rs b/crates/wip-tui/src/main.rs new file mode 100644 index 0000000..79fb997 --- /dev/null +++ b/crates/wip-tui/src/main.rs @@ -0,0 +1,6 @@ +fn main() { + if let Err(error) = wip_tui::runtime::run_from_env() { + eprintln!("wip-tui: {error}"); + std::process::exit(1); + } +} diff --git a/crates/wip-tui/src/render.rs b/crates/wip-tui/src/render.rs new file mode 100644 index 0000000..1089295 --- /dev/null +++ b/crates/wip-tui/src/render.rs @@ -0,0 +1,715 @@ +//! Deterministic bounded rendering for real and test terminal backends. + +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap}; + +use crate::app::{App, ConnectionState, Focus, HistoryKind, Modal, OperationRow, ViewState}; +use crate::editor::type_name; + +const MAX_RENDER_TEXT: usize = 512; + +/// Draws the complete application without mutating it. +pub fn draw(frame: &mut Frame<'_>, app: &App) { + let area = frame.area(); + if area.width < 48 || area.height < 14 { + frame.render_widget( + Paragraph::new("wip-tui\nTerminal is too small. Resize to at least 48 x 14.\nq: quit") + .block( + Block::default() + .borders(Borders::ALL) + .title(" Minimum size "), + ) + .alignment(Alignment::Center) + .wrap(Wrap { trim: true }), + area, + ); + return; + } + + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(2), + Constraint::Min(8), + Constraint::Length(2), + ]) + .split(area); + render_header(frame, vertical[0], app); + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(38), Constraint::Percentage(62)]) + .split(vertical[1]); + render_tree(frame, columns[0], app); + let right = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(31), + Constraint::Percentage(39), + Constraint::Percentage(30), + ]) + .split(columns[1]); + render_selected(frame, right[0], app); + render_operations(frame, right[1], app); + render_history(frame, right[2], app); + render_footer(frame, vertical[2], app); + render_modal(frame, app); +} + +fn render_header(frame: &mut Frame<'_>, area: Rect, app: &App) { + let connection = match &app.connection { + ConnectionState::Disconnected => "DISCONNECTED", + ConnectionState::Connecting => "CONNECTING", + ConnectionState::Connected => "CONNECTED", + ConnectionState::Failed(_) => "FAILED (R reconnect / q quit)", + }; + let text = format!( + " wip-tui {} [{}] in-flight: {}", + sanitize(&app.endpoint), + connection, + app.in_flight.len() + ); + frame.render_widget( + Paragraph::new(text).style(Style::default().add_modifier(Modifier::BOLD)), + area, + ); +} + +fn render_tree(frame: &mut Frame<'_>, area: Rect, app: &App) { + let rows = app.visible_paths().into_iter().map(|path| { + let node = app.entries.get(&path); + let selected = path == app.selected_path; + let depth = if path == "/" { + 0 + } else { + path.trim_start_matches('/').split('/').count() + }; + let marker = node.map_or('·', |node| if node.expanded { '▼' } else { '▶' }); + let label = node.and_then(|node| node.object.as_ref()).map_or_else( + || path.clone(), + |object| { + if path == "/" { + "/".into() + } else { + object.name.clone() + } + }, + ); + let mut suffix = node.map_or_else( + || " [unloaded]".into(), + |node| { + let tree = match &node.tree_state { + ViewState::Loading(_) => " [loading children]", + ViewState::Stale => " [stale children]", + ViewState::Error(_) => " [failed children]", + ViewState::Fresh if node.children.is_empty() => " [empty]", + _ => "", + }; + let incomplete = if node.truncated { + " [truncated: + to increase limit]" + } else { + "" + }; + let object = state_suffix(&node.state); + format!("{object}{tree}{incomplete}") + }, + ); + suffix = sanitize(&suffix); + let style = if selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + Style::default() + }; + ListItem::new(Line::from(vec![ + Span::raw(" ".repeat(depth)), + Span::raw(format!("{marker} {}", sanitize(&label))), + Span::styled(suffix, state_style(node.map(|value| &value.state))), + ])) + .style(style) + }); + frame.render_widget( + List::new(rows).block(focused_block(" Worldspace tree ", app.focus == Focus::Tree)), + area, + ); +} + +fn render_selected(frame: &mut Frame<'_>, area: Rect, app: &App) { + let mut lines = vec![Line::from(format!( + "Path: {}", + sanitize(&app.selected_path) + ))]; + if let Some(entry) = app.entries.get(&app.selected_path) { + lines.push(Line::from(format!("State: {}", state_label(&entry.state)))); + if entry.state == ViewState::Fresh { + if let Some(object) = &entry.object { + lines.push(Line::from(format!("Name: {}", sanitize(&object.name)))); + lines.push(Line::from(format!( + "Description: {}", + object + .description + .as_deref() + .map(sanitize) + .unwrap_or_else(|| "(none)".into()) + ))); + lines.push(Line::from(format!( + "Interfaces (ordered): {}", + if object.interfaces.is_empty() { + "(none)".into() + } else { + object + .interfaces + .iter() + .map(|value| sanitize(value)) + .collect::>() + .join(", ") + } + ))); + } + } else { + lines.push(Line::from( + "No current object metadata; refresh required (old metadata hidden).", + )); + } + if entry.truncated { + lines.push(Line::styled( + "TRUNCATED: this is not a complete subtree.", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )); + } + } + frame.render_widget( + Paragraph::new(lines) + .block(focused_block(" Selected Entry ", app.focus == Focus::Entry)) + .wrap(Wrap { trim: false }), + area, + ); +} + +fn render_operations(frame: &mut Frame<'_>, area: Rect, app: &App) { + let rows = app.operation_rows(); + let items = rows.iter().enumerate().map(|(index, row)| { + let (text, style) = operation_line(app, row); + let selected = app.focus == Focus::Operations && index == app.operation_cursor; + ListItem::new(text).style(if selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else { + style + }) + }); + let title = " Interfaces / Operations (Enter: load or edit) "; + if rows.is_empty() { + frame.render_widget( + Paragraph::new("No declared interfaces or no current object.") + .block(focused_block(title, app.focus == Focus::Operations)), + area, + ); + } else { + frame.render_widget( + List::new(items).block(focused_block(title, app.focus == Focus::Operations)), + area, + ); + } +} + +fn operation_line(app: &App, row: &OperationRow) -> (Line<'static>, Style) { + if let Some(name) = &row.operation { + let descriptor = app + .interfaces + .get(&row.interface) + .and_then(|view| view.descriptor.as_ref()); + let declaration = descriptor.and_then(|descriptor| { + descriptor + .operations + .iter() + .find(|operation| operation.name == *name) + }); + let signature = declaration.map_or_else( + || format!(" ({}, {})", row.interface, name), + |operation| { + let parameters = operation + .parameters + .iter() + .map(|parameter| { + format!( + "{}{}: {}", + parameter.name, + if parameter.required { "" } else { "?" }, + type_name(¶meter.r#type) + ) + }) + .collect::>() + .join(", "); + let documentation = operation + .documentation + .as_ref() + .map(|documentation| format!(" — {}", documentation.summary)) + .unwrap_or_default(); + format!( + " ({}, {}) ({parameters}) -> {}{}", + row.interface, + name, + type_name(&operation.returns.r#type), + documentation + ) + }, + ); + (Line::from(sanitize(&signature)), Style::default()) + } else { + let interface = app.interfaces.get(&row.interface); + let state = interface.map_or("unloaded".into(), |view| state_label(&view.state)); + let documentation = interface + .filter(|view| view.state == ViewState::Fresh) + .and_then(|view| view.descriptor.as_ref()) + .and_then(|descriptor| descriptor.documentation.as_ref()) + .map(|documentation| format!(" — {}", sanitize(&documentation.summary))) + .unwrap_or_default(); + ( + Line::from(format!( + "Interface {} [{}]{}", + sanitize(&row.interface), + state, + documentation + )), + Style::default().add_modifier(Modifier::BOLD), + ) + } +} + +fn render_history(frame: &mut Frame<'_>, area: Rect, app: &App) { + let items = app.history.iter().enumerate().map(|(index, item)| { + let label = match item.kind { + HistoryKind::Success => "SUCCESS", + HistoryKind::DomainFailure => "HOST FAILURE", + HistoryKind::ProtocolError => "PROTOCOL ERROR", + HistoryKind::TransportFailure => "TRANSPORT FAILURE", + HistoryKind::ClientFailure => "CLIENT FAILURE", + HistoryKind::OutcomeUnknown => "OUTCOME UNKNOWN", + HistoryKind::Information => "INFO", + }; + let link = item + .entry_target + .as_ref() + .map_or("", |_| " [Enter: go to Entry]"); + let text = format!( + "[{label}] {}: {}{link}", + sanitize(&item.title), + sanitize(&item.detail) + ); + let selected = app.focus == Focus::History && index == app.history_cursor; + ListItem::new(text).style(if selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else if item.kind == HistoryKind::OutcomeUnknown { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else { + Style::default() + }) + }); + let block = focused_block( + " Recent results / diagnostics ", + app.focus == Focus::History, + ); + if app.history.is_empty() { + frame.render_widget(Paragraph::new("No results yet.").block(block), area); + } else { + frame.render_widget(List::new(items).block(block), area); + } +} + +fn render_footer(frame: &mut Frame<'_>, area: Rect, app: &App) { + let help = " ↑↓/jk move ←→/hl parent/expand Tab focus Enter select r refresh + larger tree c cancel read ? help q quit "; + let text = format!("{}\n{}", help, sanitize(&app.status)); + frame.render_widget( + Paragraph::new(text).style(Style::default().fg(Color::DarkGray)), + area, + ); +} + +fn render_modal(frame: &mut Frame<'_>, app: &App) { + match &app.modal { + Modal::None => {} + Modal::Help => { + let area = centered(frame.area(), 76, 70); + frame.render_widget(Clear, area); + frame.render_widget( + Paragraph::new( + "Keyboard help\n\n↑/↓ or j/k move selection / editor field\n←/→ or h/l collapse/expand or parent\nTab next pane\nEnter load interface, edit operation, confirm execute\nr refresh selected Entry and subtree\n+ increase truncated tree limit and retry\nc cancel newest retrieval (never cancels a sent operation)\nR reconnect after failure\nEsc close modal\nq quit\n\nOperations are never sent until the confirmation screen is accepted.", + ) + .block(Block::default().borders(Borders::ALL).title(" Help ")) + .wrap(Wrap { trim: false }), + area, + ); + } + Modal::Editor(editor) => { + let area = centered(frame.area(), 86, 78); + frame.render_widget(Clear, area); + let mut lines = vec![Line::from(format!( + "Target: {} Operation: ({}, {})", + sanitize(&app.selected_path), + sanitize(&editor.interface), + sanitize(&editor.operation.name) + ))]; + if editor.fields.is_empty() { + lines.push(Line::from("(no parameters)")); + } + for (index, field) in editor.fields.iter().enumerate() { + let cursor = if index == editor.cursor { ">" } else { " " }; + let requirement = if field.required { + "required" + } else { + "optional" + }; + lines.push(Line::from(format!( + "{cursor} {} [{requirement}, {}] = {}", + sanitize(&field.name), + sanitize(&type_name(&field.r#type)), + sanitize(&field.input) + ))); + if let Some(diagnostic) = &field.diagnostic { + lines.push(Line::styled( + format!(" ERROR: {}", sanitize(diagnostic)), + Style::default().fg(Color::Red), + )); + } + } + lines.push(Line::from("")); + lines.push(Line::from("Enter: validate and review Esc: cancel")); + frame.render_widget( + Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Argument editor "), + ) + .wrap(Wrap { trim: false }), + area, + ); + } + Modal::Confirm { editor, arguments } => { + let area = centered(frame.area(), 82, 58); + frame.render_widget(Clear, area); + let mut lines = vec![ + Line::styled( + "EXPLICIT EXECUTION CONFIRMATION", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + Line::from(format!("Entry: {}", sanitize(&app.selected_path))), + Line::from(format!("Interface: {}", sanitize(&editor.interface))), + Line::from(format!("Operation: {}", sanitize(&editor.operation.name))), + Line::from("Input values:"), + ]; + for (name, value) in arguments { + lines.push(Line::from(format!( + " {} = {}", + sanitize(name), + sanitize(&bounded_value(value)) + ))); + } + lines.push(Line::from("")); + lines.push(Line::styled( + "Enter/e: EXECUTE ONCE Esc: back to editor", + Style::default().add_modifier(Modifier::BOLD), + )); + frame.render_widget( + Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL).title(" Confirm ")) + .wrap(Wrap { trim: false }), + area, + ); + } + } +} + +fn focused_block(title: &'static str, focused: bool) -> Block<'static> { + let style = if focused { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + Block::default() + .borders(Borders::ALL) + .title(title) + .border_style(style) +} + +fn centered(area: Rect, percent_x: u16, percent_y: u16) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1])[1] +} + +fn state_suffix(state: &ViewState) -> &'static str { + match state { + ViewState::Unloaded => " [unloaded]", + ViewState::Loading(_) => " [loading]", + ViewState::Fresh => "", + ViewState::Stale => " [stale: refresh required]", + ViewState::Error(_) => " [error]", + } +} + +fn state_label(state: &ViewState) -> String { + match state { + ViewState::Unloaded => "unloaded".into(), + ViewState::Loading(id) => format!("loading request #{id}"), + ViewState::Fresh => "fresh".into(), + ViewState::Stale => "stale — refresh required".into(), + ViewState::Error(error) => format!("error — {}", sanitize(error)), + } +} + +fn state_style(state: Option<&ViewState>) -> Style { + match state { + Some(ViewState::Stale) => Style::default().fg(Color::Yellow), + Some(ViewState::Error(_)) => Style::default().fg(Color::Red), + Some(ViewState::Loading(_)) => Style::default().fg(Color::Blue), + _ => Style::default(), + } +} + +/// Replaces terminal control characters and bounds user/remote text by Unicode scalars. +#[must_use] +pub fn sanitize(input: &str) -> String { + let mut output = String::new(); + for (count, character) in input.chars().enumerate() { + if count >= MAX_RENDER_TEXT { + output.push('…'); + break; + } + if character.is_control() && !matches!(character, '\n' | '\t') { + output.push('�'); + } else { + output.push(character); + } + } + output +} + +/// Formats a protocol value with strict byte and collection bounds. +#[must_use] +pub fn bounded_value(value: &wip_protocol::Value) -> String { + bounded_value_at(value, 0) +} + +fn bounded_value_at(value: &wip_protocol::Value, depth: usize) -> String { + if depth >= 8 { + return "…".into(); + } + match value { + wip_protocol::Value::Unit => "null".into(), + wip_protocol::Value::Boolean(value) => value.to_string(), + wip_protocol::Value::Integer(value) => value.to_string(), + wip_protocol::Value::Number(value) => value.to_string(), + wip_protocol::Value::String(value) => format!("{:?}", sanitize(value)), + wip_protocol::Value::Bytes(bytes) => { + let shown = bytes + .iter() + .take(32) + .map(|byte| format!("{byte:02x}")) + .collect::(); + if bytes.len() > 32 { + format!("bytes[{}]: {shown}…", bytes.len()) + } else { + format!("bytes[{}]: {shown}", bytes.len()) + } + } + wip_protocol::Value::Record(values) => { + let mut fields = values + .iter() + .take(16) + .map(|(name, value)| { + format!("{}: {}", sanitize(name), bounded_value_at(value, depth + 1)) + }) + .collect::>(); + if values.len() > 16 { + fields.push("…".into()); + } + format!("{{{}}}", fields.join(", ")) + } + wip_protocol::Value::List(values) => { + let mut items = values + .iter() + .take(16) + .map(|value| bounded_value_at(value, depth + 1)) + .collect::>(); + if values.len() > 16 { + items.push("…".into()); + } + format!("[{}]", items.join(", ")) + } + } +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + use super::*; + use crate::app::{EntryView, HistoryItem}; + use crate::editor::ArgumentEditor; + use wip_client::{InterfaceObservation, ObjectObservation, ObservationState}; + use wip_protocol::{ + INTERFACE_FORMAT_V1, InterfaceDescriptor, Object, OperationDeclaration, + ParameterDeclaration, ReturnDeclaration, TypeExpr, + }; + + fn rendered(app: &App, width: u16, height: u16) -> String { + let backend = TestBackend::new(width, height); + let mut terminal = Terminal::new(backend).unwrap(); + terminal.draw(|frame| draw(frame, app)).unwrap(); + terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::>() + .chunks(width as usize) + .map(|line| line.concat()) + .collect::>() + .join("\n") + } + + #[test] + fn snapshot_contains_non_color_state_markers_and_hides_validators() { + let mut app = App::new("https://example.test/wip/"); + app.connection = ConnectionState::Connected; + app.entries.get_mut("/").unwrap().truncated = true; + app.entries.get_mut("/").unwrap().state = ViewState::Stale; + app.push_history(HistoryItem { + kind: HistoryKind::OutcomeUnknown, + title: "replace".into(), + detail: "execution and side effects are unknown; refresh before deciding again".into(), + entry_target: None, + }); + let snapshot = rendered(&app, 110, 30); + assert!(snapshot.contains("truncated")); + assert!(snapshot.contains("stale")); + assert!(snapshot.contains("OUTCOME UNKNOWN")); + assert!(!snapshot.contains("validator")); + } + + #[test] + fn snapshots_cover_selected_operation_editor_and_failure_categories() { + let mut app = App::new("https://example.test/wip/"); + app.connection = ConnectionState::Connected; + app.apply_object(ObjectObservation { + path: "/".into(), + object: Some(Object { + name: String::new(), + description: Some("public root description".into()), + interfaces: vec!["demo-interface".into()], + r#ref: None, + validator: Some(b"must-not-render".to_vec()), + }), + validator: Some(b"must-not-render".to_vec()), + state: ObservationState::Fresh, + }); + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: None, + types: vec![], + operations: vec![OperationDeclaration { + name: "run".into(), + documentation: None, + parameters: vec![ParameterDeclaration { + name: "count".into(), + required: true, + documentation: None, + r#type: TypeExpr::Integer, + }], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::String, + }, + }], + }; + app.apply_interface(InterfaceObservation { + reference: "demo-interface".into(), + descriptor: Some(descriptor.clone()), + validator: Some(b"must-not-render".to_vec()), + state: ObservationState::Fresh, + }); + for (kind, title) in [ + (HistoryKind::Success, "result"), + (HistoryKind::ProtocolError, "invalid response"), + (HistoryKind::TransportFailure, "network"), + (HistoryKind::ClientFailure, "local"), + ] { + app.push_history(HistoryItem { + kind, + title: title.into(), + detail: "bounded detail".into(), + entry_target: None, + }); + } + let snapshot = rendered(&app, 140, 40); + assert!(snapshot.contains("public root description")); + assert!(snapshot.contains("(demo-interface, run)")); + assert!(snapshot.contains("SUCCESS")); + assert!(snapshot.contains("PROTOCOL ERROR")); + assert!(snapshot.contains("TRANSPORT FAILURE")); + assert!(snapshot.contains("CLIENT FAILURE")); + assert!(!snapshot.contains("must-not-render")); + + let mut editor = ArgumentEditor::new( + "demo-interface", + descriptor.clone(), + descriptor.operations[0].clone(), + ); + editor.fields[0].diagnostic = Some("expected integer".into()); + app.modal = Modal::Editor(editor); + let snapshot = rendered(&app, 140, 40); + assert!(snapshot.contains("Argument editor")); + assert!(snapshot.contains("count [required, Integer]")); + assert!(snapshot.contains("ERROR: expected integer")); + } + + #[test] + fn tiny_terminal_has_a_safe_fallback() { + let snapshot = rendered(&App::new("https://example.test/"), 30, 8); + assert!(snapshot.contains("too small")); + } + + #[test] + fn control_characters_and_large_bytes_are_bounded() { + assert_eq!(sanitize("safe\u{1b}[31m"), "safe�[31m"); + let display = bounded_value(&wip_protocol::Value::Bytes(vec![7; 1000])); + assert!(display.starts_with("bytes[1000]")); + assert!(display.ends_with('…')); + } + + #[test] + fn entry_view_type_remains_constructible_for_external_snapshots() { + let entry = EntryView { + path: "/".into(), + object: None, + state: ViewState::Unloaded, + children: vec![], + tree_state: ViewState::Unloaded, + expanded: false, + truncated: false, + limit: 1, + }; + assert_eq!(entry.path, "/"); + } +} diff --git a/crates/wip-tui/src/runtime.rs b/crates/wip-tui/src/runtime.rs new file mode 100644 index 0000000..fafe15d --- /dev/null +++ b/crates/wip-tui/src/runtime.rs @@ -0,0 +1,1036 @@ +//! Non-blocking session driver, bounded HTTP transport, and interactive event loop. + +use std::collections::BTreeSet; +use std::error::Error; +use std::fmt::{self, Display, Formatter}; +use std::sync::{Arc, mpsc}; +use std::thread; +use std::time::Duration; + +use crossterm::event::{self, Event, KeyEventKind}; +use wip_client::{ + CallOutcome, Client, ClientError, ClientErrorKind, ClientLimits, Completion, + DispatchedTransportFailure, OutcomeUnknownReason, PreparedRequest, RequestId, SecurityContext, + SessionId, +}; +use wip_https::Limits; +use wip_https::http::{Request, Response}; +use wip_protocol::{InterfaceDescriptor, TypeExpr, Value}; + +use crate::app::{ + Action, App, ConnectionState, HistoryItem, HistoryKind, InFlightKind, InputKey, Modal, +}; +use crate::config::{Config, ConfigError}; +use crate::render::{bounded_value, draw, sanitize}; +use crate::terminal::{TerminalGuard, translate_key}; + +/// Stable transport failure category. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TransportErrorKind { + /// The configured deadline elapsed. + Timeout, + /// Connection, TLS, DNS, or HTTP exchange failed. + Disconnect, + /// Response bytes exceeded the configured wire limit. + ResponseTooLarge, +} + +/// Credential-free transport failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TransportError { + /// Stable category used at the operation dispatch boundary. + pub kind: TransportErrorKind, + /// Safe presentation detail. + pub detail: String, +} + +impl Display for TransportError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.detail) + } +} + +impl Error for TransportError {} + +/// Two-phase blocking transport abstraction. +/// +/// [`Transport::prepare`] performs every fallible local conversion before an +/// operation crosses the dispatch boundary. The runtime calls +/// [`Transport::execute`] on a worker only after `wip-client` is marked dispatched. +pub trait Transport: Send + Sync + 'static { + /// Fully converted request ready to hand to network or server I/O. + type Prepared: Send + 'static; + + /// Performs fallible local request conversion without network dispatch. + fn prepare(&self, request: Request>) -> Result; + + /// Hands a prepared request to I/O and returns bounded response bytes. + fn execute(&self, request: Self::Prepared) -> Result>, TransportError>; +} + +/// HTTPS transport with redirects disabled and bounded response bodies. +#[derive(Clone)] +pub struct HttpTransport { + agent: ureq::Agent, + logical_endpoint: wip_https::Endpoint, + transport_endpoint: wip_https::url::Url, + authorization: Option, + max_response_bytes: usize, +} + +impl HttpTransport { + /// Builds the default TLS transport and revalidates its immutable origins. + pub fn new(config: &Config, max_response_bytes: usize) -> Result { + if max_response_bytes == 0 { + return Err(TransportError { + kind: TransportErrorKind::ResponseTooLarge, + detail: "transport response limit must be nonzero".into(), + }); + } + let logical_endpoint = + wip_https::Endpoint::parse(config.logical_endpoint()).map_err(|_| TransportError { + kind: TransportErrorKind::Disconnect, + detail: "logical endpoint failed transport validation".into(), + })?; + let transport_endpoint = + wip_https::url::Url::parse(config.transport_endpoint()).map_err(|_| { + TransportError { + kind: TransportErrorKind::Disconnect, + detail: "transport endpoint failed validation".into(), + } + })?; + validate_transport_origin(config, &logical_endpoint, &transport_endpoint)?; + let authorization = config + .authorization() + .map(|secret| { + wip_https::http::HeaderValue::from_str(secret.expose()).map_err(|_| { + TransportError { + kind: TransportErrorKind::Disconnect, + detail: + "authorization environment variable is not a valid HTTP header value" + .into(), + } + }) + }) + .transpose()?; + let agent: ureq::Agent = ureq::Agent::config_builder() + .timeout_global(Some(config.timeout())) + .max_redirects(0) + .http_status_as_error(false) + .build() + .into(); + Ok(Self { + agent, + logical_endpoint, + transport_endpoint, + authorization, + max_response_bytes, + }) + } + + fn effective_uri(&self, uri: &str) -> Result { + let candidate = wip_https::url::Url::parse(uri).map_err(|_| TransportError { + kind: TransportErrorKind::Disconnect, + detail: "refused a request with an invalid absolute URI".into(), + })?; + let route = self + .logical_endpoint + .recognize(&candidate) + .ok_or_else(|| TransportError { + kind: TransportErrorKind::Disconnect, + detail: "refused a request outside the configured WIP origin and routes".into(), + })?; + Ok(self + .transport_endpoint + .join(route.relative_reference()) + .expect("validated endpoint joins a fixed route") + .to_string()) + } +} + +fn validate_transport_origin( + config: &Config, + logical: &wip_https::Endpoint, + transport: &wip_https::url::Url, +) -> Result<(), TransportError> { + let common_invalid = transport.query().is_some() + || transport.fragment().is_some() + || transport.host_str().is_none() + || !transport.username().is_empty() + || transport.password().is_some(); + let valid = if config.development_http() { + let loopback = match transport.host() { + Some(wip_https::url::Host::Ipv4(address)) => address.is_loopback(), + Some(wip_https::url::Host::Ipv6(address)) => address.is_loopback(), + Some(wip_https::url::Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"), + None => false, + }; + let mut upgraded = transport.clone(); + let changed = upgraded.set_scheme("https").is_ok(); + !common_invalid + && transport.scheme() == "http" + && loopback + && changed + && &upgraded == logical.as_url() + } else { + !common_invalid && transport == logical.as_url() + }; + if valid { + Ok(()) + } else { + Err(TransportError { + kind: TransportErrorKind::Disconnect, + detail: "transport endpoint does not match the validated logical origin".into(), + }) + } +} + +impl Transport for HttpTransport { + type Prepared = Request>; + + fn prepare(&self, request: Request>) -> Result { + let (mut parts, body) = request.into_parts(); + let uri = self.effective_uri(&parts.uri.to_string())?; + parts.uri = uri.parse().map_err(|_| TransportError { + kind: TransportErrorKind::Disconnect, + detail: "transport could not construct the configured request URI".into(), + })?; + if let Some(authorization) = &self.authorization { + parts.headers.insert( + wip_https::http::header::AUTHORIZATION, + authorization.clone(), + ); + } + Ok(Request::from_parts(parts, body)) + } + + fn execute(&self, request: Self::Prepared) -> Result>, TransportError> { + let mut response = self.agent.run(request).map_err(classify_ureq_error)?; + let maximum = self.max_response_bytes; + let bytes = response + .body_mut() + .with_config() + .limit(u64::try_from(maximum.saturating_add(1)).unwrap_or(u64::MAX)) + .read_to_vec() + .map_err(|error| match error { + ureq::Error::BodyExceedsLimit(_) => TransportError { + kind: TransportErrorKind::ResponseTooLarge, + detail: "response body exceeded the configured wire limit".into(), + }, + other => classify_ureq_error(other), + })?; + if bytes.len() > maximum { + return Err(TransportError { + kind: TransportErrorKind::ResponseTooLarge, + detail: "response body exceeded the configured wire limit".into(), + }); + } + let (parts, _) = response.into_parts(); + Ok(Response::from_parts(parts, bytes)) + } +} + +fn classify_ureq_error(error: ureq::Error) -> TransportError { + match error { + ureq::Error::Timeout(_) => TransportError { + kind: TransportErrorKind::Timeout, + detail: "request timed out".into(), + }, + ureq::Error::BodyExceedsLimit(_) => TransportError { + kind: TransportErrorKind::ResponseTooLarge, + detail: "response body exceeded the configured wire limit".into(), + }, + _ => TransportError { + kind: TransportErrorKind::Disconnect, + detail: "HTTP/TLS transport failed".into(), + }, + } +} + +struct WorkerEvent { + id: RequestId, + response: Result>, TransportError>, +} + +/// Main-thread owner of `wip-client`, presentation state, and request correlation. +pub struct Runtime { + client: Client, + session: SessionId, + app: App, + transport: Arc, + sender: mpsc::Sender, + receiver: mpsc::Receiver, + ignored: BTreeSet, +} + +impl Runtime { + /// Creates an isolated runtime using explicit transport and standard bounds. + pub fn new(config: &Config, transport: T) -> Result { + let wire_limits = default_wire_limits(); + let mut client = Client::new( + ClientLimits::new(2, 2048, 256, 64, 128) + .map_err(|error| RuntimeError(error.to_string()))?, + wire_limits, + ); + let session = client + .open_session( + config.logical_endpoint(), + SecurityContext::new(config.security_context()), + ) + .map_err(|error| RuntimeError(error.to_string()))?; + let (sender, receiver) = mpsc::channel(); + Ok(Self { + client, + session, + app: App::new(config.transport_endpoint()), + transport: Arc::new(transport), + sender, + receiver, + ignored: BTreeSet::new(), + }) + } + + /// Returns immutable presentation state. + #[must_use] + pub fn app(&self) -> &App { + &self.app + } + + /// Returns mutable presentation state for embedding and deterministic tests. + pub fn app_mut(&mut self) -> &mut App { + &mut self.app + } + + /// Starts or restarts observation at Worldspace `/`. + pub fn connect(&mut self) { + self.app.connection = ConnectionState::Connecting; + let limit = self.app.default_tree_limit; + match self.client.prepare_tree(&self.session, "/", 1, limit) { + Ok(request) => self.dispatch(request, InFlightKind::Tree("/".into()), false), + Err(error) => self.local_error("connect", error), + } + } + + /// Applies a key through the reducer and starts any resulting effects. + pub fn handle_key(&mut self, key: InputKey) -> bool { + let actions = self.app.reduce(key); + let mut quit = false; + for action in actions { + quit |= self.perform(action); + } + quit + } + + /// Applies every currently ready network completion without blocking. + pub fn poll(&mut self) { + while let Ok(event) = self.receiver.try_recv() { + if self.ignored.remove(&event.id.get()) { + continue; + } + self.finish(event); + } + } + + /// Waits a bounded duration for at least one completion, then drains ready work. + pub fn wait_and_poll(&mut self, timeout: Duration) { + match self.receiver.recv_timeout(timeout) { + Ok(event) => { + if self.ignored.remove(&event.id.get()) { + // The canceled read's response is deliberately dropped. + } else { + self.finish(event); + } + self.poll(); + } + Err(mpsc::RecvTimeoutError::Timeout | mpsc::RecvTimeoutError::Disconnected) => {} + } + } + + fn perform(&mut self, action: Action) -> bool { + match action { + Action::Expand { path, limit } => { + match self.client.prepare_tree(&self.session, &path, 1, limit) { + Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), + Err(error) => self.local_error("tree expansion", error), + } + } + Action::Refresh { path, limit } => { + match self.client.prepare_fetch(&self.session, &path) { + Ok(request) => { + self.dispatch(request, InFlightKind::Object(path.clone()), false) + } + Err(error) => self.local_error("Entry refresh", error), + } + match self.client.prepare_tree(&self.session, &path, 1, limit) { + Ok(request) => self.dispatch(request, InFlightKind::Tree(path), false), + Err(error) => self.local_error("subtree refresh", error), + } + } + Action::FetchInterface(reference) => { + match self.client.prepare_interface(&self.session, &reference) { + Ok(request) => { + self.dispatch(request, InFlightKind::Interface(reference), false) + } + Err(error) => self.local_error("interface fetch", error), + } + } + Action::Execute { + path, + interface, + operation, + arguments, + } => match self.client.prepare_call( + &self.session, + &path, + &interface, + &operation, + arguments, + ) { + Ok(request) => self.dispatch( + request, + InFlightKind::Operation { + path, + interface, + operation, + dispatched: false, + }, + true, + ), + Err(error) => self.local_error("operation preparation", error), + }, + Action::CancelRead => self.cancel_newest_read(), + Action::Reconnect => self.connect(), + Action::NavigateEntry(path) => { + self.app.navigate_entry(path.clone()); + match self.client.prepare_fetch(&self.session, &path) { + Ok(request) => self.dispatch(request, InFlightKind::Object(path), false), + Err(error) => self.local_error("Entry navigation", error), + } + } + Action::Quit => return true, + } + false + } + + fn dispatch(&mut self, request: PreparedRequest, kind: InFlightKind, operation: bool) { + let id = request.id; + self.app.request_started(id.get(), kind.clone()); + self.reconcile_authoritative(); + let prepared = match self.transport.prepare(request.request) { + Ok(prepared) => prepared, + Err(error) => { + let result = self.client.fail_transport(id, error.detail); + self.app.request_finished(id.get()); + match result { + Ok(completion) => self.apply_completion(completion, Some(&kind)), + Err(error) => { + self.push_client_error(Some(&kind), &error); + self.sync_kind(Some(&kind)); + } + } + self.reconcile_authoritative(); + return; + } + }; + if operation { + match self.client.mark_dispatched(id) { + Ok(()) => self.app.request_dispatched(id.get()), + Err(error) => { + self.app.request_finished(id.get()); + self.local_error("operation dispatch", error); + return; + } + } + } + let transport = Arc::clone(&self.transport); + let sender = self.sender.clone(); + thread::spawn(move || { + let response = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + transport.execute(prepared) + })) + .unwrap_or_else(|_| { + Err(TransportError { + kind: TransportErrorKind::Disconnect, + detail: "transport worker failed".into(), + }) + }); + let _ = sender.send(WorkerEvent { id, response }); + }); + } + + fn cancel_newest_read(&mut self) { + let request = self + .app + .in_flight + .iter() + .rev() + .find(|(_, kind)| kind.cancel_safe()) + .map(|(id, _)| *id); + let Some(id) = request else { + self.app.status = "no cancel-safe retrieval is in flight".into(); + return; + }; + let Some(request_id) = self.request_id_for(id) else { + self.app.status = "request already completed".into(); + return; + }; + self.ignored.insert(id); + let kind = self.app.request_finished(id); + let _ = self + .client + .fail_transport(request_id, "retrieval canceled by user"); + self.sync_kind(kind.as_ref()); + self.reconcile_authoritative(); + self.app.push_history(HistoryItem { + kind: HistoryKind::Information, + title: "retrieval canceled".into(), + detail: + "the late response will be ignored; operation calls are never treated as canceled" + .into(), + entry_target: None, + }); + } + + fn request_id_for(&self, numeric: u64) -> Option { + // RequestId is intentionally opaque. Recover it from the current client's + // observation or pending call context rather than manufacturing identity. + for (id, kind) in &self.app.in_flight { + if *id != numeric { + continue; + } + match kind { + InFlightKind::Object(path) => { + if let Some(wip_client::ObservationState::Loading(id)) = self + .client + .object(&self.session, path) + .map(|value| value.state) + { + return Some(id); + } + } + InFlightKind::Tree(path) => { + if let Some(wip_client::ObservationState::Loading(id)) = self + .client + .tree(&self.session, path) + .map(|value| value.state.clone()) + { + return Some(id); + } + } + InFlightKind::Interface(reference) => { + if let Some(wip_client::ObservationState::Loading(id)) = self + .client + .interface(&self.session, reference) + .map(|value| value.state.clone()) + { + return Some(id); + } + } + InFlightKind::Operation { .. } => {} + } + } + None + } + + fn finish(&mut self, event: WorkerEvent) { + let kind = self.app.in_flight.get(&event.id.get()).cloned(); + if kind.is_none() { + return; + } + let result = match event.response { + Ok(response) => self.client.complete(event.id, response), + Err(error) => { + let dispatched_call = matches!( + kind, + Some(InFlightKind::Operation { + dispatched: true, + .. + }) + ); + if dispatched_call { + let failure = if error.kind == TransportErrorKind::Timeout { + DispatchedTransportFailure::Timeout + } else { + DispatchedTransportFailure::Disconnect + }; + self.client + .fail_dispatched_call(event.id, failure, error.detail) + } else { + self.client.fail_transport(event.id, error.detail) + } + } + }; + self.app.request_finished(event.id.get()); + match result { + Ok(completion) => self.apply_completion(completion, kind.as_ref()), + Err(error) => { + self.push_client_error(kind.as_ref(), &error); + self.sync_kind(kind.as_ref()); + if matches!(kind, Some(InFlightKind::Tree(ref path)) if path == "/") { + self.app.connection = ConnectionState::Failed(sanitize(&error.to_string())); + } + } + } + self.reconcile_authoritative(); + } + + fn apply_completion(&mut self, completion: Completion, kind: Option<&InFlightKind>) { + match completion { + Completion::Object(observation) => self.app.apply_object(observation), + Completion::Tree(observation) => { + let root = observation.path.clone(); + self.app.apply_tree(observation); + self.sync_all_objects(); + if root == "/" { + self.app.connection = ConnectionState::Connected; + } + } + Completion::Interface(observation) => self.app.apply_interface(observation), + Completion::Call(record) => { + let path = record.context.object().path.clone(); + self.push_call_history(&record); + self.sync_object(&path); + let reference = record.context.interface().reference.clone(); + if let Some(observation) = self.client.interface(&self.session, &reference).cloned() + { + self.app.apply_interface(observation); + } + } + Completion::ProtocolFailure(error) => { + self.app.push_history(HistoryItem { + kind: HistoryKind::DomainFailure, + title: format!("Host {:?}", error.code), + detail: sanitize(&error.message), + entry_target: None, + }); + self.sync_kind(kind); + } + Completion::StaleResponseRejected(id) => { + self.app.push_history(HistoryItem { + kind: HistoryKind::Information, + title: format!("stale response #{} ignored", id.get()), + detail: "a newer request still owns this observation".into(), + entry_target: None, + }); + self.sync_kind(kind); + } + } + } + + fn push_call_history(&mut self, record: &wip_client::CallRecord) { + let key = format!( + "({}, {}) on {}", + record.context.interface().reference, + record.context.operation().name, + record.context.object().path + ); + match &record.outcome { + CallOutcome::Success(response) => { + let entries = collect_entries( + record.context.descriptor(), + &record.context.operation().returns.r#type, + &response.result, + ); + self.app.push_history(HistoryItem { + kind: HistoryKind::Success, + title: key, + detail: bounded_value(&response.result), + entry_target: entries.first().cloned(), + }); + for entry in entries.into_iter().skip(1).rev() { + self.app.push_history(HistoryItem { + kind: HistoryKind::Information, + title: "declared Entry result".into(), + detail: entry.clone(), + entry_target: Some(entry), + }); + } + } + CallOutcome::ProtocolFailure(error) => self.app.push_history(HistoryItem { + kind: HistoryKind::DomainFailure, + title: format!("{key}: {:?}", error.code), + detail: format!( + "{}; no automatic retry was attempted", + sanitize(&error.message) + ), + entry_target: None, + }), + CallOutcome::Unknown { reason, failure } => { + let reason = match reason { + OutcomeUnknownReason::Protocol => "Host reported OperationOutcomeUnknown", + OutcomeUnknownReason::Timeout => "transport timed out after dispatch", + OutcomeUnknownReason::Disconnect => "transport disconnected after dispatch", + OutcomeUnknownReason::ResponseDecode => { + "response after dispatch was invalid or unsupported" + } + }; + let extra = failure + .as_ref() + .map(|failure| format!(" ({})", sanitize(&failure.to_string()))) + .unwrap_or_default(); + self.app.push_history(HistoryItem { + kind: HistoryKind::OutcomeUnknown, + title: key, + detail: format!( + "{reason}{extra}; execution and side effects are unknown, related Entry state is stale, refresh before a new explicit execution" + ), + entry_target: None, + }); + } + CallOutcome::NotDispatched(error) => self.app.push_history(HistoryItem { + kind: error_history_kind(error), + title: key, + detail: format!( + "not dispatched and therefore not executed: {}", + sanitize(&error.to_string()) + ), + entry_target: None, + }), + } + } + + fn push_client_error(&mut self, kind: Option<&InFlightKind>, error: &ClientError) { + let title = match kind { + Some(InFlightKind::Object(path)) => format!("fetch {path}"), + Some(InFlightKind::Tree(path)) => format!("tree {path}"), + Some(InFlightKind::Interface(reference)) => format!("interface {reference}"), + Some(InFlightKind::Operation { operation, .. }) => operation.clone(), + None => "request".into(), + }; + self.app.push_history(HistoryItem { + kind: error_history_kind(error), + title, + detail: sanitize(&error.to_string()), + entry_target: None, + }); + } + + fn local_error(&mut self, title: &str, error: ClientError) { + self.app.push_history(HistoryItem { + kind: error_history_kind(&error), + title: title.into(), + detail: sanitize(&error.to_string()), + entry_target: None, + }); + self.app.status = format!("{title} failed; see diagnostics"); + if title == "connect" { + self.app.connection = ConnectionState::Failed(sanitize(&error.to_string())); + } + } + + fn sync_kind(&mut self, kind: Option<&InFlightKind>) { + match kind { + Some(InFlightKind::Object(path)) => self.sync_object(path), + Some(InFlightKind::Tree(path)) => { + if let Some(tree) = self.client.tree(&self.session, path).cloned() { + self.app.apply_tree(tree); + } + self.sync_object(path); + } + Some(InFlightKind::Interface(reference)) => { + if let Some(interface) = self.client.interface(&self.session, reference).cloned() { + self.app.apply_interface(interface); + } + } + Some(InFlightKind::Operation { + path, interface, .. + }) => { + self.sync_object(path); + if let Some(observation) = self.client.interface(&self.session, interface).cloned() + { + self.app.apply_interface(observation); + } + } + None => {} + } + } + + fn reconcile_authoritative(&mut self) { + self.sync_all_objects(); + let evicted_interfaces = self + .app + .interfaces + .keys() + .filter(|reference| self.client.interface(&self.session, reference).is_none()) + .cloned() + .collect::>(); + for reference in evicted_interfaces { + self.app.interfaces.remove(&reference); + } + } + + fn sync_all_objects(&mut self) { + let paths = self + .app + .entries + .iter() + .filter(|(_, entry)| { + entry.object.is_some() || self.client.object(&self.session, &entry.path).is_some() + }) + .map(|(path, _)| path.clone()) + .collect::>(); + for path in paths { + self.sync_object(&path); + } + } + + fn sync_object(&mut self, path: &str) { + let observation = self.client.object(&self.session, path); + self.app.sync_object(path, observation); + } +} + +fn error_history_kind(error: &ClientError) -> HistoryKind { + match error.kind { + ClientErrorKind::InvalidResponse | ClientErrorKind::UnsupportedDescriptorFormat => { + HistoryKind::ProtocolError + } + ClientErrorKind::Transport => HistoryKind::TransportFailure, + _ => HistoryKind::ClientFailure, + } +} + +/// Recursively discovers paths only at descriptor-declared `Entry` positions. +#[must_use] +pub fn collect_entries( + descriptor: &InterfaceDescriptor, + r#type: &TypeExpr, + value: &Value, +) -> Vec { + let mut result = Vec::new(); + let mut seen = BTreeSet::new(); + collect_entries_at(descriptor, r#type, value, &mut seen, &mut result, 0); + result +} + +fn collect_entries_at( + descriptor: &InterfaceDescriptor, + r#type: &TypeExpr, + value: &Value, + seen: &mut BTreeSet, + result: &mut Vec, + depth: usize, +) { + if depth >= 64 { + return; + } + match (r#type, value) { + (TypeExpr::Entry, Value::String(path)) => { + if seen.insert(path.clone()) { + result.push(path.clone()); + } + } + (TypeExpr::Named { name }, value) => { + if let Some(declaration) = descriptor + .types + .iter() + .find(|declaration| declaration.name == *name) + { + collect_entries_at( + descriptor, + &declaration.definition, + value, + seen, + result, + depth + 1, + ); + } + } + (TypeExpr::List { items }, Value::List(values)) => { + for value in values { + collect_entries_at(descriptor, items, value, seen, result, depth + 1); + } + } + (TypeExpr::Record { fields }, Value::Record(values)) => { + for field in fields { + if let Some(value) = values.get(&field.name) { + collect_entries_at(descriptor, &field.r#type, value, seen, result, depth + 1); + } + } + } + (TypeExpr::Union { cases }, Value::Record(values)) => { + let case_name = values.get("$case"); + if let Some(Value::String(case_name)) = case_name + && let Some(payload) = cases + .iter() + .find(|case| case.name == *case_name) + .and_then(|case| case.payload.as_ref()) + && let Some(value) = values.get("value") + { + collect_entries_at(descriptor, payload, value, seen, result, depth + 1); + } + } + _ => {} + } +} + +/// Standard bounded wire policy used by the executable and integration tests. +#[must_use] +pub fn default_wire_limits() -> Limits { + Limits::new(256 * 1024, 2 * 1024 * 1024, 64, 64) + .expect("static runtime wire limits are nonzero") +} + +/// Runs the interactive executable using process configuration. +pub fn run_from_env() -> Result<(), RuntimeError> { + let config = match Config::from_env() { + Ok(config) => config, + Err(ConfigError::Help) => { + println!("{}", Config::usage()); + return Ok(()); + } + Err(error) => return Err(RuntimeError(error.to_string())), + }; + let wire = default_wire_limits(); + let transport = HttpTransport::new(&config, wire.max_response_bytes()) + .map_err(|error| RuntimeError(error.to_string()))?; + let mut runtime = Runtime::new(&config, transport)?; + runtime.connect(); + let mut terminal = TerminalGuard::enter().map_err(|error| RuntimeError(error.to_string()))?; + let tick = Duration::from_millis(50); + loop { + runtime.poll(); + terminal + .terminal_mut() + .draw(|frame| draw(frame, runtime.app())) + .map_err(|error| RuntimeError(error.to_string()))?; + if event::poll(tick).map_err(|error| RuntimeError(error.to_string()))? + && let Event::Key(key) = + event::read().map_err(|error| RuntimeError(error.to_string()))? + && matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) + { + let editing = matches!( + runtime.app().modal, + Modal::Editor(_) | Modal::Confirm { .. } + ); + if let Some(key) = translate_key(key, editing) + && runtime.handle_key(key) + { + break; + } + } + } + Ok(()) +} + +/// Safe top-level runtime failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RuntimeError(String); + +impl Display for RuntimeError { + fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } +} + +impl Error for RuntimeError {} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use wip_protocol::{ + FieldDeclaration, INTERFACE_FORMAT_V1, OperationDeclaration, ReturnDeclaration, + TypeDeclaration, + }; + + #[test] + fn entry_collection_is_descriptor_directed_and_nested() { + let descriptor = InterfaceDescriptor { + format: INTERFACE_FORMAT_V1.into(), + documentation: None, + types: vec![TypeDeclaration { + name: "Links".into(), + documentation: None, + definition: TypeExpr::List { + items: Box::new(TypeExpr::Entry), + }, + }], + operations: vec![OperationDeclaration { + name: "links".into(), + documentation: None, + parameters: vec![], + returns: ReturnDeclaration { + documentation: None, + r#type: TypeExpr::Record { + fields: vec![ + FieldDeclaration { + name: "entries".into(), + required: true, + documentation: None, + r#type: TypeExpr::Named { + name: "Links".into(), + }, + }, + FieldDeclaration { + name: "ordinary".into(), + required: true, + documentation: None, + r#type: TypeExpr::String, + }, + ], + }, + }, + }], + }; + let value = Value::Record(BTreeMap::from([ + ( + "entries".into(), + Value::List(vec![ + Value::String("/a".into()), + Value::String("/a".into()), + Value::String("/b".into()), + ]), + ), + ("ordinary".into(), Value::String("/not-an-entry".into())), + ])); + assert_eq!( + collect_entries( + &descriptor, + &descriptor.operations[0].returns.r#type, + &value + ), + ["/a", "/b"] + ); + } + + #[test] + fn authorization_is_attached_only_after_exact_origin_and_route_validation() { + let config = Config::parse( + ["https://example.test/wip", "--authorization-env", "TOKEN"], + None, + None, + |name| (name == "TOKEN").then(|| "Bearer sensitive".into()), + ) + .unwrap(); + let transport = HttpTransport::new(&config, 1024).unwrap(); + let off_origin = Request::builder() + .method("POST") + .uri("https://attacker.test/wip/v1/fetch") + .body(Vec::new()) + .unwrap(); + let error = transport.prepare(off_origin).unwrap_err(); + assert!(error.detail.contains("outside")); + assert!(!error.detail.contains("sensitive")); + + let endpoint = wip_https::Endpoint::parse(config.logical_endpoint()).unwrap(); + let valid = Request::builder() + .method("POST") + .uri(endpoint.route(wip_https::Route::Fetch).as_str()) + .body(Vec::new()) + .unwrap(); + let prepared = transport.prepare(valid).unwrap(); + assert_eq!( + prepared.headers()[wip_https::http::header::AUTHORIZATION], + "Bearer sensitive" + ); + } + + #[test] + fn transport_error_messages_do_not_echo_underlying_data() { + let error = classify_ureq_error(ureq::Error::BadUri("secret-token".into())); + assert_eq!(error.detail, "HTTP/TLS transport failed"); + assert!(!error.detail.contains("secret-token")); + } +} diff --git a/crates/wip-tui/src/terminal.rs b/crates/wip-tui/src/terminal.rs new file mode 100644 index 0000000..00d9171 --- /dev/null +++ b/crates/wip-tui/src/terminal.rs @@ -0,0 +1,132 @@ +//! Terminal lifecycle guard and crossterm input translation. + +use std::io::{self, Stdout}; +use std::sync::Arc; + +use crossterm::cursor::{Hide, Show}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; + +use crate::app::InputKey; + +type PanicHook = dyn for<'a> Fn(&std::panic::PanicHookInfo<'a>) + Send + Sync + 'static; + +/// RAII owner of raw mode, alternate screen, cursor visibility, and panic recovery. +pub struct TerminalGuard { + terminal: Terminal>, + previous_hook: Arc, +} + +impl TerminalGuard { + /// Enters terminal UI mode, restoring partial setup on failure. + pub fn enter() -> io::Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + if let Err(error) = execute!(stdout, EnterAlternateScreen, Hide) { + let _ = disable_raw_mode(); + return Err(error); + } + let terminal = match Terminal::new(CrosstermBackend::new(stdout)) { + Ok(terminal) => terminal, + Err(error) => { + restore_terminal(); + return Err(error); + } + }; + let previous_hook: Arc = std::panic::take_hook().into(); + let panic_hook = Arc::clone(&previous_hook); + let terminal_owner = std::thread::current().id(); + std::panic::set_hook(Box::new(move |information| { + if std::thread::current().id() == terminal_owner { + restore_terminal(); + } + panic_hook(information); + })); + Ok(Self { + terminal, + previous_hook, + }) + } + + /// Returns the ratatui terminal for drawing. + pub fn terminal_mut(&mut self) -> &mut Terminal> { + &mut self.terminal + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let previous = Arc::clone(&self.previous_hook); + std::panic::set_hook(Box::new(move |information| previous(information))); + restore_terminal(); + } +} + +fn restore_terminal() { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), Show, LeaveAlternateScreen); +} + +/// Converts one crossterm key event into a terminal-independent reducer key. +/// +/// When `editing` is true, printable command letters remain text input and only +/// Ctrl-C/Escape retain global meaning. +#[must_use] +pub fn translate_key(event: KeyEvent, editing: bool) -> Option { + if event.modifiers.contains(KeyModifiers::CONTROL) && event.code == KeyCode::Char('c') { + return Some(InputKey::Quit); + } + let key = match event.code { + KeyCode::Up => InputKey::Up, + KeyCode::Down => InputKey::Down, + KeyCode::Left => InputKey::Left, + KeyCode::Right => InputKey::Right, + KeyCode::Tab => InputKey::Tab, + KeyCode::Enter => InputKey::Enter, + KeyCode::Esc => InputKey::Escape, + KeyCode::Backspace => InputKey::Backspace, + KeyCode::Char(character) if editing => InputKey::Character(character), + KeyCode::Char('k') => InputKey::Up, + KeyCode::Char('j') => InputKey::Down, + KeyCode::Char('h') => InputKey::Left, + KeyCode::Char('l') => InputKey::Right, + KeyCode::Char('r') => InputKey::Refresh, + KeyCode::Char('R') => InputKey::Reconnect, + KeyCode::Char('+') | KeyCode::Char('=') => InputKey::IncreaseLimit, + KeyCode::Char('c') => InputKey::CancelRead, + KeyCode::Char('?') => InputKey::Help, + KeyCode::Char('q') => InputKey::Quit, + KeyCode::Char(character) => InputKey::Character(character), + _ => return None, + }; + Some(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn editing_keeps_command_letters_as_text_but_ctrl_c_quits() { + assert_eq!( + translate_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), true), + Some(InputKey::Character('q')) + ); + assert_eq!( + translate_key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE), false), + Some(InputKey::Quit) + ); + assert_eq!( + translate_key( + KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + true + ), + Some(InputKey::Quit) + ); + } +} diff --git a/crates/wip-tui/tests/filesystem_e2e.rs b/crates/wip-tui/tests/filesystem_e2e.rs new file mode 100644 index 0000000..50b54a6 --- /dev/null +++ b/crates/wip-tui/tests/filesystem_e2e.rs @@ -0,0 +1,213 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use tempfile::tempdir; +use wip_fs_server::{AccessMode, ServerConfig, TransportSecurity, WipFsServer}; +use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState}; +use wip_tui::config::Config; +use wip_tui::runtime::{HttpTransport, Runtime, default_wire_limits}; + +#[test] +fn real_http_filesystem_workflow_is_lazy_validator_safe_and_symlink_confined() { + let temporary = tempdir().expect("temporary root"); + let outside = tempdir().expect("outside root"); + std::fs::write(temporary.path().join("hello.txt"), b"hello world").expect("write file"); + std::fs::create_dir(temporary.path().join("nested")).expect("create nested directory"); + std::fs::write(outside.path().join("secret.txt"), b"outside-secret").expect("outside file"); + #[cfg(unix)] + std::os::unix::fs::symlink(outside.path(), temporary.path().join("outside-link")) + .expect("create symlink"); + + let mut server_config = ServerConfig::for_root(temporary.path()); + server_config.access = AccessMode::ReadWrite; + server_config.transport_security = TransportSecurity::DevelopmentHttp; + let bound = Arc::new( + WipFsServer::new(server_config) + .expect("build filesystem server") + .bind() + .expect("bind filesystem server"), + ); + let shutdown = Arc::new(AtomicBool::new(false)); + let worker = { + let bound = Arc::clone(&bound); + let shutdown = Arc::clone(&shutdown); + thread::spawn(move || bound.serve_until(shutdown)) + }; + let endpoint = format!("http://127.0.0.1:{}/wip", bound.local_addr().port()); + let config = Config::parse( + [endpoint, "--allow-http".into()], + None, + Some("filesystem-test".into()), + |_| None, + ) + .expect("development config"); + let transport = HttpTransport::new(&config, default_wire_limits().max_response_bytes()) + .expect("HTTP transport"); + let mut runtime = Runtime::new(&config, transport).expect("runtime"); + + runtime.connect(); + settle(&mut runtime); + assert_eq!(runtime.app().selected_path, "/"); + assert!(runtime.app().entries.contains_key("/hello.txt")); + assert!(runtime.app().entries.contains_key("/nested")); + assert!(runtime.app().entries.contains_key("/outside-link")); + + // Keyboard-select the file and load its read interface and descriptor. + runtime.app_mut().selected_path = "/hello.txt".into(); + runtime.app_mut().focus = Focus::Operations; + runtime.app_mut().operation_cursor = 0; + assert!(!runtime.handle_key(InputKey::Enter)); + settle(&mut runtime); + let read_reference = runtime.app().entries["/hello.txt"] + .object + .as_ref() + .expect("file object") + .interfaces[0] + .clone(); + assert_eq!( + runtime.app().interfaces[&read_reference] + .descriptor + .as_ref() + .expect("read descriptor") + .operations[0] + .name, + "read" + ); + + // Descriptor-driven read editor, explicit confirmation, and execution. + runtime.app_mut().operation_cursor = 1; + runtime.handle_key(InputKey::Enter); + assert!(matches!(runtime.app().modal, Modal::Editor(_))); + runtime.handle_key(InputKey::Enter); + assert!(matches!(runtime.app().modal, Modal::Confirm { .. })); + runtime.handle_key(InputKey::Enter); + settle(&mut runtime); + assert_eq!(runtime.app().history[0].kind, HistoryKind::Success); + assert!( + runtime.app().history[0] + .detail + .contains("68656c6c6f20776f726c64") + ); + + // Calls conservatively stale the object even for a read operation; refresh + // before constructing the separately confirmed write call. + runtime.app_mut().focus = Focus::Tree; + runtime.handle_key(InputKey::Refresh); + settle(&mut runtime); + runtime.app_mut().focus = Focus::Operations; + + // Load the separately-qualified write interface and replace with the + // automatically attached observed validator. + runtime.app_mut().operation_cursor = 2; + runtime.handle_key(InputKey::Enter); + settle(&mut runtime); + runtime.app_mut().operation_cursor = 3; + runtime.handle_key(InputKey::Enter); + set_only_field(&mut runtime, "dXBkYXRlZA=="); // "updated" + confirm_and_execute(&mut runtime); + settle(&mut runtime); + assert_eq!( + std::fs::read(temporary.path().join("hello.txt")).expect("updated file"), + b"updated" + ); + assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Stale); + + // Reobserve, prepare a replacement from that observation, then mutate the + // file externally before explicit execution. The stale validator is rejected, + // the Entry becomes stale, and no automatic call is created. + runtime.app_mut().focus = Focus::Tree; + runtime.handle_key(InputKey::Refresh); + settle(&mut runtime); + assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Fresh); + runtime.app_mut().focus = Focus::Operations; + runtime.app_mut().operation_cursor = 3; + runtime.handle_key(InputKey::Enter); + set_only_field(&mut runtime, "c3RhbGUtd3JpdGU="); + runtime.handle_key(InputKey::Enter); // confirmation fixes user input only + std::fs::write(temporary.path().join("hello.txt"), b"external").expect("external change"); + let history_before = runtime.app().history.len(); + runtime.handle_key(InputKey::Enter); + settle(&mut runtime); + assert_eq!( + std::fs::read(temporary.path().join("hello.txt")).expect("externally changed file"), + b"external" + ); + assert_eq!(runtime.app().entries["/hello.txt"].state, ViewState::Stale); + assert!(runtime.app().in_flight.is_empty()); + assert_eq!(runtime.app().history.len(), history_before + 1); + assert_eq!(runtime.app().history[0].kind, HistoryKind::DomainFailure); + assert!( + runtime.app().history[0] + .detail + .contains("no automatic retry") + ); + + // Refresh enables a new, separately confirmed execution. + runtime.app_mut().focus = Focus::Tree; + runtime.handle_key(InputKey::Refresh); + settle(&mut runtime); + runtime.app_mut().focus = Focus::Operations; + runtime.app_mut().operation_cursor = 3; + runtime.handle_key(InputKey::Enter); + set_only_field(&mut runtime, "ZmluYWw="); + confirm_and_execute(&mut runtime); + settle(&mut runtime); + assert_eq!( + std::fs::read(temporary.path().join("hello.txt")).expect("final file"), + b"final" + ); + + // Symlink inspection exposes the link payload only. It never traverses or + // materializes the target directory outside the configured root. + #[cfg(unix)] + { + runtime.app_mut().selected_path = "/outside-link".into(); + runtime.app_mut().focus = Focus::Operations; + runtime.app_mut().operation_cursor = 0; + runtime.handle_key(InputKey::Enter); + settle(&mut runtime); + runtime.app_mut().operation_cursor = 1; + runtime.handle_key(InputKey::Enter); + runtime.handle_key(InputKey::Enter); + runtime.handle_key(InputKey::Enter); + settle(&mut runtime); + assert_eq!(runtime.app().history[0].kind, HistoryKind::Success); + assert!(!runtime.app().history[0].detail.contains("outside-secret")); + assert!( + !runtime + .app() + .entries + .contains_key("/outside-link/secret.txt") + ); + } + + shutdown.store(true, Ordering::Release); + worker + .join() + .expect("join filesystem listener") + .expect("filesystem listener exits"); +} + +fn settle(runtime: &mut Runtime) { + let deadline = Instant::now() + Duration::from_secs(5); + while !runtime.app().in_flight.is_empty() { + assert!(Instant::now() < deadline, "runtime requests did not settle"); + runtime.wait_and_poll(Duration::from_millis(20)); + } +} + +fn set_only_field(runtime: &mut Runtime, input: &str) { + let Modal::Editor(editor) = &mut runtime.app_mut().modal else { + panic!("operation editor was not open") + }; + assert_eq!(editor.fields.len(), 1); + editor.fields[0].input = input.into(); +} + +fn confirm_and_execute(runtime: &mut Runtime) { + runtime.handle_key(InputKey::Enter); + assert!(matches!(runtime.app().modal, Modal::Confirm { .. })); + runtime.handle_key(InputKey::Enter); +} diff --git a/crates/wip-tui/tests/runtime_async.rs b/crates/wip-tui/tests/runtime_async.rs new file mode 100644 index 0000000..adf63b4 --- /dev/null +++ b/crates/wip-tui/tests/runtime_async.rs @@ -0,0 +1,247 @@ +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::thread; +use std::time::{Duration, Instant}; + +use tempfile::tempdir; +use wip_fs_server::{ServerConfig, WipFsServer}; +use wip_https::encode_protocol_error_response; +use wip_https::http::{Request, Response}; +use wip_protocol::{ProtocolError, ProtocolErrorCode, ProtocolInteraction}; +use wip_tui::app::{Focus, HistoryKind, InputKey, Modal, ViewState}; +use wip_tui::config::Config; +use wip_tui::runtime::{ + Runtime, Transport, TransportError, TransportErrorKind, default_wire_limits, +}; + +struct ScriptedTransport { + server: Arc, + sequence: Arc, + delayed_after_startup: bool, + unknown_calls: bool, + fail_call_prepare: bool, + panic_calls: bool, +} + +impl ScriptedTransport { + fn new(server: Arc) -> Self { + Self { + server, + sequence: Arc::new(AtomicUsize::new(0)), + delayed_after_startup: false, + unknown_calls: false, + fail_call_prepare: false, + panic_calls: false, + } + } +} + +impl Transport for ScriptedTransport { + type Prepared = Request>; + + fn prepare(&self, request: Request>) -> Result { + if self.fail_call_prepare && request.uri().path().ends_with("/v1/call_operation") { + return Err(TransportError { + kind: TransportErrorKind::Disconnect, + detail: "scripted local conversion failure".into(), + }); + } + Ok(request) + } + + fn execute(&self, request: Self::Prepared) -> Result>, TransportError> { + let sequence = self.sequence.fetch_add(1, Ordering::AcqRel); + if self.delayed_after_startup && sequence > 0 { + // Each refresh starts object then tree. Make the first pair lose to + // the second pair to exercise exact RequestId ownership. + let delay = if matches!(sequence, 1 | 2) { 80 } else { 5 }; + thread::sleep(Duration::from_millis(delay)); + } + if self.panic_calls && request.uri().path().ends_with("/v1/call_operation") { + panic!("scripted transport worker panic"); + } + if self.unknown_calls && request.uri().path().ends_with("/v1/call_operation") { + return Ok(encode_protocol_error_response( + ProtocolInteraction::CallOperation, + &ProtocolError { + code: ProtocolErrorCode::OperationOutcomeUnknown, + message: "scripted commit uncertainty".into(), + }, + default_wire_limits(), + ) + .expect("encode unknown outcome")); + } + Ok(self.server.handle_http(request)) + } +} + +#[test] +fn out_of_order_refresh_and_cancel_keep_request_identity_safe() { + let temporary = tempdir().expect("temporary root"); + std::fs::write(temporary.path().join("file"), b"contents").expect("file"); + let server = Arc::new( + WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"), + ); + let mut transport = ScriptedTransport::new(server); + transport.delayed_after_startup = true; + let config = config(); + let mut runtime = Runtime::new(&config, transport).expect("runtime"); + runtime.connect(); + settle(&mut runtime); + + runtime.app_mut().focus = Focus::Tree; + runtime.handle_key(InputKey::Refresh); + runtime.handle_key(InputKey::Refresh); + settle(&mut runtime); + assert_eq!(runtime.app().entries["/"].state, ViewState::Fresh); + assert!(runtime.app().history.iter().any(|item| { + item.kind == HistoryKind::Information && item.title.contains("stale response") + })); + + runtime.handle_key(InputKey::Refresh); + runtime.handle_key(InputKey::CancelRead); + runtime.handle_key(InputKey::CancelRead); + settle(&mut runtime); + assert!(runtime.app().in_flight.is_empty()); + assert!( + runtime + .app() + .history + .iter() + .any(|item| item.title == "retrieval canceled") + ); + + // When a refreshed tree omits a formerly selected Entry, the client marks + // it stale and the presentation no longer exposes old metadata/operations + // as current. + std::fs::remove_file(temporary.path().join("file")).expect("remove file"); + runtime.app_mut().selected_path = "/".into(); + runtime.handle_key(InputKey::Refresh); + settle(&mut runtime); + assert_eq!(runtime.app().entries["/file"].state, ViewState::Stale); + runtime.app_mut().selected_path = "/file".into(); + assert!(runtime.app().operation_rows().is_empty()); +} + +#[test] +fn local_transport_preparation_failure_is_definitely_not_dispatched() { + let temporary = tempdir().expect("temporary root"); + std::fs::write(temporary.path().join("file"), b"contents").expect("file"); + let server = Arc::new( + WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"), + ); + let mut transport = ScriptedTransport::new(server); + transport.fail_call_prepare = true; + let execute_calls = Arc::clone(&transport.sequence); + let config = config(); + let mut runtime = Runtime::new(&config, transport).expect("runtime"); + runtime.connect(); + settle(&mut runtime); + + open_read_editor(&mut runtime); + let calls_before_operation = execute_calls.load(Ordering::Acquire); + runtime.handle_key(InputKey::Enter); + runtime.handle_key(InputKey::Enter); + assert_eq!( + execute_calls.load(Ordering::Acquire), + calls_before_operation, + "preparation failure must not invoke transport I/O" + ); + assert!(runtime.app().in_flight.is_empty()); + assert_eq!(runtime.app().history[0].kind, HistoryKind::TransportFailure); + assert!(runtime.app().history[0].detail.contains("not dispatched")); + assert_eq!(runtime.app().entries["/file"].state, ViewState::Fresh); +} + +#[test] +fn worker_panic_settles_as_unknown_instead_of_stranding_the_main_loop() { + let temporary = tempdir().expect("temporary root"); + std::fs::write(temporary.path().join("file"), b"contents").expect("file"); + let server = Arc::new( + WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"), + ); + let mut transport = ScriptedTransport::new(server); + transport.panic_calls = true; + let config = config(); + let mut runtime = Runtime::new(&config, transport).expect("runtime"); + runtime.connect(); + settle(&mut runtime); + + open_read_editor(&mut runtime); + runtime.handle_key(InputKey::Enter); + runtime.handle_key(InputKey::Enter); + settle(&mut runtime); + assert!(runtime.app().in_flight.is_empty()); + assert_eq!(runtime.app().history[0].kind, HistoryKind::OutcomeUnknown); + assert!( + runtime.app().history[0] + .detail + .contains("transport worker failed") + ); +} + +#[test] +fn post_dispatch_unknown_is_prominent_stale_and_never_retried() { + let temporary = tempdir().expect("temporary root"); + std::fs::write(temporary.path().join("file"), b"contents").expect("file"); + let server = Arc::new( + WipFsServer::new(ServerConfig::for_root(temporary.path())).expect("filesystem server"), + ); + let mut transport = ScriptedTransport::new(server); + transport.unknown_calls = true; + let config = config(); + let mut runtime = Runtime::new(&config, transport).expect("runtime"); + runtime.connect(); + settle(&mut runtime); + + open_read_editor(&mut runtime); + runtime.handle_key(InputKey::Enter); + runtime.handle_key(InputKey::Enter); + settle(&mut runtime); + + assert_eq!(runtime.app().history[0].kind, HistoryKind::OutcomeUnknown); + assert!( + runtime.app().history[0] + .detail + .contains("side effects are unknown") + ); + assert_eq!(runtime.app().entries["/file"].state, ViewState::Stale); + assert!(runtime.app().in_flight.is_empty()); + let history = runtime.app().history.len(); + thread::sleep(Duration::from_millis(20)); + runtime.poll(); + assert_eq!( + runtime.app().history.len(), + history, + "no retry was manufactured" + ); +} + +fn config() -> Config { + Config::parse( + ["https://example.test/wip"], + None, + Some("scripted-test".into()), + |_| None, + ) + .expect("config") +} + +fn open_read_editor(runtime: &mut Runtime) { + runtime.app_mut().selected_path = "/file".into(); + runtime.app_mut().focus = Focus::Operations; + runtime.app_mut().operation_cursor = 0; + runtime.handle_key(InputKey::Enter); + settle(runtime); + runtime.app_mut().operation_cursor = 1; + runtime.handle_key(InputKey::Enter); + assert!(matches!(runtime.app().modal, Modal::Editor(_))); +} + +fn settle(runtime: &mut Runtime) { + let deadline = Instant::now() + Duration::from_secs(5); + while !runtime.app().in_flight.is_empty() { + assert!(Instant::now() < deadline, "scripted runtime did not settle"); + runtime.wait_and_poll(Duration::from_millis(10)); + } +}