feat: add interactive WIP terminal client

This commit is contained in:
2026-09-30 10:35:05 +09:00
parent 347b92b28a
commit db2a51c824
14 changed files with 5141 additions and 13 deletions
Generated
+726 -10
View File
@@ -2,12 +2,30 @@
# It is not intended for manual editing. # It is not intended for manual editing.
version = 4 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]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64"
version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.2" version = "2.13.2"
@@ -20,12 +38,119 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" 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]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.5" version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" 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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.7" version = "0.2.7"
@@ -37,6 +162,12 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "either"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
[[package]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -50,7 +181,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -59,6 +190,29 @@ version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" 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]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@@ -68,6 +222,17 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.3" version = "0.4.3"
@@ -85,12 +250,29 @@ version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" 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]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.17.1" version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "http" name = "http"
version = "1.5.0" version = "1.5.0"
@@ -101,6 +283,12 @@ dependencies = [
"itoa", "itoa",
] ]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]] [[package]]
name = "icu_collections" name = "icu_collections"
version = "2.3.0" version = "2.3.0"
@@ -184,6 +372,12 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@@ -212,7 +406,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [ dependencies = [
"equivalent", "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]] [[package]]
@@ -227,6 +452,12 @@ version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "linux-raw-sys"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab"
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.12.1" version = "0.12.1"
@@ -239,18 +470,93 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" 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]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.3" version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" 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]] [[package]]
name = "once_cell" name = "once_cell"
version = "1.21.4" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@@ -290,6 +596,63 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" 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]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.5" version = "1.1.5"
@@ -299,10 +662,63 @@ dependencies = [
"bitflags", "bitflags",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys 0.12.1",
"windows-sys", "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]] [[package]]
name = "serde" name = "serde"
version = "1.0.229" version = "1.0.229"
@@ -354,6 +770,49 @@ dependencies = [
"serde_core", "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]] [[package]]
name = "smallvec" name = "smallvec"
version = "1.16.2" version = "1.16.2"
@@ -366,6 +825,46 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" 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]] [[package]]
name = "syn" name = "syn"
version = "2.0.119" version = "2.0.119"
@@ -412,10 +911,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix 1.1.5",
"windows-sys", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -497,6 +996,70 @@ version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" 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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@@ -509,27 +1072,88 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 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]] [[package]]
name = "winapi-util" name = "winapi-util"
version = "0.1.11" version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ 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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.61.2" version = "0.61.2"
@@ -539,6 +1163,70 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "winnow" name = "winnow"
version = "1.0.4" version = "1.0.4"
@@ -558,7 +1246,7 @@ name = "wip-fs-server"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"http", "http",
"rustix", "rustix 1.1.5",
"tempfile", "tempfile",
"wip-host", "wip-host",
"wip-https", "wip-https",
@@ -579,7 +1267,7 @@ dependencies = [
name = "wip-https" name = "wip-https"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64", "base64 0.22.1",
"http", "http",
"serde", "serde",
"serde_json", "serde_json",
@@ -618,6 +1306,22 @@ dependencies = [
"wip-protocol", "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]] [[package]]
name = "writeable" name = "writeable"
version = "0.6.4" version = "0.6.4"
@@ -668,6 +1372,12 @@ dependencies = [
"synstructure", "synstructure",
] ]
[[package]]
name = "zeroize"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"
version = "0.2.5" version = "0.2.5"
@@ -701,6 +1411,12 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "zmij" name = "zmij"
version = "1.0.23" version = "1.0.23"
+21 -3
View File
@@ -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 under `crates/*`. It provides a transport-independent protocol model, the
standard framework-independent WIP over HTTPS v1 binding, a stateful standard framework-independent WIP over HTTPS v1 binding, a stateful
presentation-independent client runtime, a Rust interface-authoring facade, a presentation-independent client runtime, a Rust interface-authoring facade, a
request-scoped host runtime, a bounded Linux filesystem projection server, and a request-scoped host runtime, a bounded Linux filesystem projection server, an
private public-API conformance consumer. The repository root is not itself a interactive terminal client, and a private public-API conformance consumer. The
package. repository root is not itself a package.
## Crate graph and responsibilities ## Crate graph and responsibilities
```text ```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-fs-server (request-time filesystem projection and loopback listener)
├── wip-https standard routes/codecs/envelopes/batching ├── wip-https standard routes/codecs/envelopes/batching
├── wip-host request-scoped publication and dispatch ├── 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-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-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-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` | 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-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. | | `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 comparison and backend commit atomic and return a validator describing the state
that becomes committed. 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 ## Filesystem projection server
[`crates/wip-fs-server`](crates/wip-fs-server) contains the practical server [`crates/wip-fs-server`](crates/wip-fs-server) contains the practical server
+22
View File
@@ -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" }
+151
View File
@@ -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.
+919
View File
@@ -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>,
/// Object observation state.
pub state: ViewState,
/// Direct child paths in Host order.
pub children: Vec<String>,
/// 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<String>, 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<InterfaceDescriptor>,
/// 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<String>,
}
/// 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<String, Value>,
},
}
/// 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<String, Value>,
},
/// 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<String, EntryView>,
/// Selected Entry path.
pub selected_path: String,
/// Focused pane.
pub focus: Focus,
/// Interface descriptor views by opaque reference.
pub interfaces: BTreeMap<String, InterfaceView>,
/// 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<u64, InFlightKind>,
/// Recent result and diagnostic history, newest first.
pub history: Vec<HistoryItem>,
/// 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<String>) -> 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<Action> {
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<Action> {
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<Action> {
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<Action> {
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<InFlightKind> {
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<ObjectObservation>) {
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<String>) {
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<String> {
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<String>, visible: &mut Vec<String>) {
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<OperationRow> {
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::<BTreeSet<_>>())
.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<String>,
}
/// 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
}
}
+315
View File
@@ -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<Secret>,
/// 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<Self, ConfigError> {
let arguments = std::env::args().skip(1).collect::<Vec<_>>();
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<I, S, F>(
arguments: I,
endpoint_env: Option<String>,
security_context_env: Option<String>,
mut read_secret: F,
) -> Result<Self, ConfigError>
where
I: IntoIterator<Item = S>,
S: Into<String>,
F: FnMut(&str) -> Option<String>,
{
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::<u64>()
.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<Item = String>,
option: &str,
) -> Result<String, ConfigError> {
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()
);
}
}
+628
View File
@@ -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<String>,
}
/// 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<ArgumentField>,
/// 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<String>,
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<BTreeMap<String, Value>, 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::<Vec<_>>()
.join(", ")
),
TypeExpr::List { items } => format!("List<{}>", type_name(items)),
TypeExpr::Enum { cases } => format!(
"Enum<{}>",
cases
.iter()
.map(|case| case.name.as_str())
.collect::<Vec<_>>()
.join(" | ")
),
TypeExpr::Union { cases } => format!(
"Union<{}>",
cases
.iter()
.map(|case| case.name.as_str())
.collect::<Vec<_>>()
.join(" | ")
),
}
}
fn parse_value(
descriptor: &InterfaceDescriptor,
r#type: &TypeExpr,
input: &str,
field: &str,
) -> Result<Value, String> {
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::<bool>()
.map(Value::Boolean)
.map_err(|_| format!("{field}: expected true or false"))?,
TypeExpr::Integer => input
.parse::<i64>()
.map(Value::Integer)
.map_err(|_| format!("{field}: expected a signed integer"))?,
TypeExpr::Number => {
let number = input
.parse::<f64>()
.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::<String>(input).unwrap_or_else(|_| input.to_owned())
}
fn json_to_typed(
descriptor: &InterfaceDescriptor,
r#type: &TypeExpr,
json: &serde_json::Value,
field: &str,
) -> Result<Value, String> {
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::<Result<Vec<_>, _>>()
.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<Value, String> {
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::<Result<Vec<_>, _>>()
.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::<Result<BTreeMap<_, _>, _>>()
.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<ParameterDeclaration>,
types: Vec<TypeDeclaration>,
) -> 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,
}
}
}
+10
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
fn main() {
if let Err(error) = wip_tui::runtime::run_from_env() {
eprintln!("wip-tui: {error}");
std::process::exit(1);
}
}
+715
View File
@@ -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::<Vec<_>>()
.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(&parameter.r#type)
)
})
.collect::<Vec<_>>()
.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::<String>();
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::<Vec<_>>();
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::<Vec<_>>();
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::<Vec<_>>()
.chunks(width as usize)
.map(|line| line.concat())
.collect::<Vec<_>>()
.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, "/");
}
}
File diff suppressed because it is too large Load Diff
+132
View File
@@ -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<CrosstermBackend<Stdout>>,
previous_hook: Arc<PanicHook>,
}
impl TerminalGuard {
/// Enters terminal UI mode, restoring partial setup on failure.
pub fn enter() -> io::Result<Self> {
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<PanicHook> = 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<CrosstermBackend<Stdout>> {
&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<InputKey> {
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)
);
}
}
+213
View File
@@ -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<HttpTransport>) {
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<HttpTransport>, 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<HttpTransport>) {
runtime.handle_key(InputKey::Enter);
assert!(matches!(runtime.app().modal, Modal::Confirm { .. }));
runtime.handle_key(InputKey::Enter);
}
+247
View File
@@ -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<WipFsServer>,
sequence: Arc<AtomicUsize>,
delayed_after_startup: bool,
unknown_calls: bool,
fail_call_prepare: bool,
panic_calls: bool,
}
impl ScriptedTransport {
fn new(server: Arc<WipFsServer>) -> 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<Vec<u8>>;
fn prepare(&self, request: Request<Vec<u8>>) -> Result<Self::Prepared, TransportError> {
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<Response<Vec<u8>>, 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<ScriptedTransport>) {
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<ScriptedTransport>) {
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));
}
}