cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 7b757841e8ad137ddaf57851ab4dd92de1bd36fd
parent 42439e555d3ac65ea572aa990081a508a571960c
Author: triesap <tyson@radroots.org>
Date:   Tue,  4 Aug 2026 13:36:17 +0000

refactor: consume the release facade only

- replace private runtime composition with the radroots facade
- keep local health and profile inspection artifact-backed
- fail closed for operations without final SDK orchestration
- remove retired engine source and sibling dependency paths

Diffstat:
MCargo.lock | 4077+++++++++++--------------------------------------------------------------------
MCargo.toml | 47+++--------------------------------------------
MREADME | 38+++++++++++++++++++-------------------
Mdocs/migration/crates-release-v1.md | 19++++++++++---------
Dsrc/cli/global.rs | 219-------------------------------------------------------------------------------
Dsrc/cli/input.rs | 340-------------------------------------------------------------------------------
Msrc/cli/mod.rs | 16----------------
Dsrc/generated/runtime_contract_registry.rs | 859-------------------------------------------------------------------------------
Dsrc/generated/runtime_contract_target.rs | 61-------------------------------------------------------------
Msrc/lib.rs | 5-----
Msrc/main.rs | 847+++++++++----------------------------------------------------------------------
Dsrc/ops/adapter.rs | 34----------------------------------
Dsrc/ops/context.rs | 209-------------------------------------------------------------------------------
Dsrc/ops/error.rs | 978-------------------------------------------------------------------------------
Dsrc/ops/exec/basket.rs | 1091-------------------------------------------------------------------------------
Dsrc/ops/exec/core.rs | 792-------------------------------------------------------------------------------
Dsrc/ops/exec/farm.rs | 303-------------------------------------------------------------------------------
Dsrc/ops/exec/listing.rs | 507-------------------------------------------------------------------------------
Dsrc/ops/exec/market.rs | 708-------------------------------------------------------------------------------
Dsrc/ops/exec/mod.rs | 17-----------------
Dsrc/ops/exec/runtime.rs | 445-------------------------------------------------------------------------------
Dsrc/ops/exec/trade.rs | 377-------------------------------------------------------------------------------
Dsrc/ops/exec/validation.rs | 48------------------------------------------------
Dsrc/ops/mod.rs | 14--------------
Dsrc/ops/request.rs | 52----------------------------------------------------
Dsrc/ops/result.rs | 98-------------------------------------------------------------------------------
Dsrc/ops/target.rs | 186-------------------------------------------------------------------------------
Dsrc/out/envelope.rs | 726-------------------------------------------------------------------------------
Dsrc/out/mod.rs | 2--
Dsrc/out/terminal/actions.rs | 328-------------------------------------------------------------------------------
Dsrc/out/terminal/errors.rs | 55-------------------------------------------------------
Dsrc/out/terminal/layout.rs | 231-------------------------------------------------------------------------------
Dsrc/out/terminal/mod.rs | 8--------
Dsrc/out/terminal/registry.rs | 340-------------------------------------------------------------------------------
Dsrc/out/terminal/renderer.rs | 276-------------------------------------------------------------------------------
Dsrc/out/terminal/renderers/common.rs | 209-------------------------------------------------------------------------------
Dsrc/out/terminal/renderers/mod.rs | 2--
Dsrc/out/terminal/renderers/v1.rs | 25-------------------------
Dsrc/out/terminal/tables.rs | 123-------------------------------------------------------------------------------
Dsrc/out/terminal/values.rs | 150-------------------------------------------------------------------------------
Dsrc/registry/mod.rs | 191-------------------------------------------------------------------------------
Dsrc/runtime/account.rs | 894-------------------------------------------------------------------------------
Dsrc/runtime/config.rs | 4111-------------------------------------------------------------------------------
Dsrc/runtime/farm.rs | 2051-------------------------------------------------------------------------------
Dsrc/runtime/farm_config.rs | 623-------------------------------------------------------------------------------
Dsrc/runtime/find.rs | 235-------------------------------------------------------------------------------
Dsrc/runtime/hyf.rs | 1118-------------------------------------------------------------------------------
Dsrc/runtime/listing.rs | 3067-------------------------------------------------------------------------------
Dsrc/runtime/logging.rs | 57---------------------------------------------------------
Dsrc/runtime/mesh.rs | 96-------------------------------------------------------------------------------
Dsrc/runtime/mod.rs | 61-------------------------------------------------------------
Dsrc/runtime/paths.rs | 250-------------------------------------------------------------------------------
Dsrc/runtime/provider.rs | 458-------------------------------------------------------------------------------
Dsrc/runtime/runtime_store.rs | 193-------------------------------------------------------------------------------
Dsrc/runtime/sdk.rs | 390-------------------------------------------------------------------------------
Dsrc/runtime/signer.rs | 622-------------------------------------------------------------------------------
Dsrc/runtime/signing.rs | 619-------------------------------------------------------------------------------
Dsrc/runtime/store.rs | 640-------------------------------------------------------------------------------
Dsrc/runtime/sync.rs | 655-------------------------------------------------------------------------------
Dsrc/runtime/trade.rs | 985-------------------------------------------------------------------------------
Dsrc/runtime/transport.rs | 532-------------------------------------------------------------------------------
Dsrc/view/mod.rs | 1-
Dsrc/view/runtime.rs | 2800-------------------------------------------------------------------------------
Mtests/target_cli.rs | 155++++++++++++++-----------------------------------------------------------------
64 files changed, 698 insertions(+), 34968 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3,69 +3,6 @@ version = 4 [[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aead" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" -dependencies = [ - "crypto-common", - "generic-array", -] - -[[package]] -name = "aes" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "ahash" -version = "0.8.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] name = "anstream" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -101,7 +38,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -112,22 +49,10 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] -name = "anyhow" -version = "1.0.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "arraydeque" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" - -[[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -149,69 +74,6 @@ dependencies = [ ] [[package]] -name = "async-trait" -version = "0.1.91" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] - -[[package]] -name = "async-utility" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "188f83b9a198af8c336e505611edb00d6d2ac5c694241c5a4f9a12316938cfe9" -dependencies = [ - "futures-util", - "gloo-timers", - "tokio", - "wasm-bindgen-futures", -] - -[[package]] -name = "async-wsocket" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" -dependencies = [ - "async-utility", - "futures", - "futures-util", - "js-sys", - "tokio", - "tokio-rustls", - "tokio-socks", - "tokio-tungstenite", - "url", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "atoi" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" -dependencies = [ - "num-traits", -] - -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] name = "autocfg" version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -225,84 +87,15 @@ checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" [[package]] name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - -[[package]] -name = "bech32" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" - -[[package]] -name = "bip39" -version = "2.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" -dependencies = [ - "bitcoin_hashes", - "serde", - "unicode-normalization", -] - -[[package]] -name = "bitcoin-consensus-encoding" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" -dependencies = [ - "bitcoin-internals", - "hex-conservative 1.2.0", - "serde", -] - -[[package]] -name = "bitcoin-internals" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" - -[[package]] -name = "bitcoin-io" -version = "0.1.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" -dependencies = [ - "bitcoin-consensus-encoding", -] - -[[package]] -name = "bitcoin_hashes" -version = "0.14.101" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" -dependencies = [ - "bitcoin-io", - "hex-conservative 0.2.2", - "serde", -] - -[[package]] name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" -dependencies = [ - "serde_core", -] [[package]] name = "block-buffer" @@ -314,15 +107,6 @@ dependencies = [ ] [[package]] -name = "block-padding" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" -dependencies = [ - "generic-array", -] - -[[package]] name = "bstr" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -340,27 +124,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "byteorder" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cbc" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" -dependencies = [ - "cipher", -] - -[[package]] name = "cc" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -377,71 +140,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" -dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", -] - -[[package]] -name = "chacha20" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" -dependencies = [ - "cfg-if", - "cpufeatures 0.3.0", - "rand_core 0.10.1", -] - -[[package]] -name = "chacha20poly1305" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" -dependencies = [ - "aead", - "chacha20 0.9.1", - "cipher", - "poly1305", - "zeroize", -] - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "cipher" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" -dependencies = [ - "crypto-common", - "inout", - "zeroize", -] - -[[package]] name = "clap" version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -488,3177 +186,907 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] -name = "config" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" -dependencies = [ - "async-trait", - "convert_case", - "json5", - "nom", - "pathdiff", - "ron", - "rust-ini", - "serde", - "serde_json", - "toml", - "yaml-rust2", -] - -[[package]] name = "const-oid" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" [[package]] -name = "const-random" -version = "0.1.18" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "const-random-macro", + "libc", ] [[package]] -name = "const-random-macro" -version = "0.1.16" +name = "crypto-bigint" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" dependencies = [ - "getrandom 0.2.17", - "once_cell", - "tiny-keccak", + "generic-array", + "rand_core", + "subtle", + "zeroize", ] [[package]] -name = "convert_case" -version = "0.6.0" +name = "crypto-common" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ - "unicode-segmentation", + "generic-array", + "typenum", ] [[package]] -name = "core-foundation" -version = "0.9.4" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "core-foundation-sys", - "libc", + "const-oid", + "zeroize", ] [[package]] -name = "core-foundation" -version = "0.10.1" +name = "difflib" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" [[package]] -name = "core-foundation-sys" -version = "0.8.7" +name = "digest" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] [[package]] -name = "cpufeatures" -version = "0.2.17" +name = "displaydoc" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ - "libc", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "cpufeatures" -version = "0.3.0" +name = "elliptic-curve" +version = "0.13.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ - "libc", + "base16ct", + "crypto-bigint", + "ff", + "generic-array", + "group", + "rand_core", + "sec1", + "subtle", + "zeroize", ] [[package]] -name = "crc" -version = "3.4.0" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ - "crc-catalog", + "libc", + "windows-sys", ] [[package]] -name = "crc-catalog" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] -name = "crc32fast" -version = "1.5.0" +name = "fallible-streaming-iterator" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" -dependencies = [ - "cfg-if", -] +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] -name = "crossbeam-channel" -version = "0.5.16" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" -dependencies = [ - "crossbeam-utils", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "crossbeam-queue" -version = "0.3.13" +name = "ff" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" dependencies = [ - "crossbeam-utils", + "rand_core", + "subtle", ] [[package]] -name = "crossbeam-utils" -version = "0.8.22" +name = "find-msvc-tools" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] -name = "crunchy" -version = "0.2.4" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] -name = "crypto-bigint" -version = "0.5.5" +name = "form_urlencoded" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "subtle", - "zeroize", + "percent-encoding", ] [[package]] -name = "crypto-common" -version = "0.1.7" +name = "fs2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" dependencies = [ - "generic-array", - "rand_core 0.6.4", - "typenum", + "libc", + "winapi", ] [[package]] -name = "data-encoding" -version = "2.11.1" +name = "generic-array" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] [[package]] -name = "dbus" -version = "0.9.12" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ + "cfg-if", "libc", - "libdbus-sys", - "windows-sys 0.61.2", + "r-efi", ] [[package]] -name = "dbus-secret-service" -version = "4.1.0" +name = "group" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" dependencies = [ - "dbus", - "openssl", - "zeroize", + "ff", + "rand_core", + "subtle", ] [[package]] -name = "der" -version = "0.7.10" +name = "hashbrown" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "const-oid", - "zeroize", + "foldhash", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "heck" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "difflib" -version = "0.4.0" +name = "hex" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "digest" -version = "0.10.7" +name = "icu_collections" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" dependencies = [ - "block-buffer", - "crypto-common", - "subtle", + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", ] [[package]] -name = "displaydoc" -version = "0.2.7" +name = "icu_locale_core" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", ] [[package]] -name = "dlv-list" -version = "0.5.2" +name = "icu_normalizer" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" dependencies = [ - "const-random", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", ] [[package]] -name = "dotenvy" -version = "0.15.7" +name = "icu_normalizer_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" [[package]] -name = "either" -version = "1.17.0" +name = "icu_properties" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" dependencies = [ - "serde", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", ] [[package]] -name = "elliptic-curve" -version = "0.13.8" +name = "icu_properties_data" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" -dependencies = [ - "base16ct", - "crypto-bigint", - "ff", - "generic-array", - "group", - "rand_core 0.6.4", - "sec1", - "subtle", - "zeroize", -] +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" [[package]] -name = "encoding_rs" -version = "0.8.35" +name = "icu_provider" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" dependencies = [ - "cfg-if", + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", ] [[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" +name = "idna" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" dependencies = [ - "libc", - "windows-sys 0.61.2", + "idna_adapter", + "smallvec", + "utf8_iter", ] [[package]] -name = "event-listener" -version = "5.4.2" +name = "idna_adapter" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ - "parking", - "pin-project-lite", + "icu_normalizer", + "icu_properties", ] [[package]] -name = "fallible-iterator" -version = "0.3.0" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] -name = "fastrand" -version = "2.5.0" +name = "jiff-tzdb" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" [[package]] -name = "ff" -version = "0.13.1" +name = "js-sys" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ - "rand_core 0.6.4", - "subtle", + "cfg-if", + "wasm-bindgen", ] [[package]] -name = "filetime" -version = "0.2.29" +name = "k256" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ "cfg-if", - "libc", + "elliptic-curve", ] [[package]] -name = "find-msvc-tools" -version = "0.1.9" +name = "libc" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] -name = "flate2" -version = "1.1.9" +name = "libsqlite3-sys" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ - "crc32fast", - "miniz_oxide", + "cc", + "pkg-config", + "vcpkg", ] [[package]] -name = "flume" -version = "0.12.0" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" -dependencies = [ - "futures-core", - "futures-sink", - "spin", -] +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "foldhash" -version = "0.2.0" +name = "litemap" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] -name = "foreign-types" -version = "0.3.2" +name = "mediatype" +version = "0.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] +checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" [[package]] -name = "foreign-types-shared" -version = "0.1.1" +name = "memchr" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] -name = "form_urlencoded" -version = "1.2.2" +name = "num-traits" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ - "percent-encoding", + "autocfg", ] [[package]] -name = "fs2" -version = "0.4.3" +name = "once_cell" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "futures" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-channel" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" -dependencies = [ - "futures-core", - "futures-sink", -] - -[[package]] -name = "futures-core" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" - -[[package]] -name = "futures-executor" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-intrusive" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" -dependencies = [ - "futures-core", - "lock_api", - "parking_lot", -] - -[[package]] -name = "futures-io" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" - -[[package]] -name = "futures-sink" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" - -[[package]] -name = "futures-task" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" - -[[package]] -name = "futures-util" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" -dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", - "zeroize", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "wasi", - "wasm-bindgen", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "js-sys", - "libc", - "r-efi 6.0.0", - "rand_core 0.10.1", - "wasm-bindgen", -] - -[[package]] -name = "gloo-timers" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" -dependencies = [ - "futures-channel", - "futures-core", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "group" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" -dependencies = [ - "ff", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "hashbrown" -version = "0.14.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "hashlink" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "hashlink" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" -dependencies = [ - "hashbrown 0.16.1", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hex" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" - -[[package]] -name = "hex-conservative" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hex-conservative" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" -dependencies = [ - "arrayvec", -] - -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - -[[package]] -name = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "tokio", - "tokio-rustls", - "tower-service", - "webpki-roots 1.0.9", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" -dependencies = [ - "unicode-bidi", - "unicode-normalization", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "inout" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" -dependencies = [ - "block-padding", - "generic-array", -] - -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "ipnet" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jiff-tzdb" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" - -[[package]] -name = "js-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "json5" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" -dependencies = [ - "pest", - "pest_derive", - "serde", -] - -[[package]] -name = "k256" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" -dependencies = [ - "cfg-if", - "elliptic-curve", -] - -[[package]] -name = "keyring" -version = "3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" -dependencies = [ - "byteorder", - "dbus-secret-service", - "linux-keyutils", - "log", - "openssl", - "security-framework 2.11.1", - "security-framework 3.7.0", - "windows-sys 0.60.2", - "zeroize", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libdbus-sys" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" -dependencies = [ - "cc", - "pkg-config", -] - -[[package]] -name = "libsqlite3-sys" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" -dependencies = [ - "cc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "linux-keyutils" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" -dependencies = [ - "bitflags", - "libc", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[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.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - -[[package]] -name = "lru-slab" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "mediatype" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "120fa187be19d9962f0926633453784691731018a2bf936ddb4e29101b79c4a7" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", - "simd-adler32", -] - -[[package]] -name = "mio" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "negentropy" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0efe882e02d206d8d279c20eb40e03baf7cb5136a1476dc084a324fbc3ec42d" - -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - -[[package]] -name = "nostr" -version = "0.44.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" -dependencies = [ - "aes", - "base64 0.22.1", - "bech32", - "bip39", - "bitcoin_hashes", - "cbc", - "chacha20 0.9.1", - "chacha20poly1305", - "getrandom 0.2.17", - "hex", - "instant", - "scrypt", - "secp256k1", - "serde", - "serde_json", - "unicode-normalization", - "url", - "url-fork", -] - -[[package]] -name = "nostr-database" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" -dependencies = [ - "lru", - "nostr", - "tokio", -] - -[[package]] -name = "nostr-gossip" -version = "0.44.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" -dependencies = [ - "nostr", -] - -[[package]] -name = "nostr-relay-pool" -version = "0.44.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" -dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", -] - -[[package]] -name = "nostr-sdk" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" -dependencies = [ - "async-utility", - "nostr", - "nostr-database", - "nostr-gossip", - "nostr-relay-pool", - "tokio", - "tracing", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "opaque-debug" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" - -[[package]] -name = "openssl" -version = "0.10.81" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "openssl-src" -version = "300.6.1+3.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" -dependencies = [ - "cc", -] - -[[package]] -name = "openssl-sys" -version = "0.9.117" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" -dependencies = [ - "cc", - "libc", - "openssl-src", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "ordered-multimap" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" -dependencies = [ - "dlv-list", - "hashbrown 0.14.5", -] - -[[package]] -name = "parking" -version = "2.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" - -[[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 = "password-hash" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" -dependencies = [ - "base64ct", - "rand_core 0.6.4", - "subtle", -] - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" - -[[package]] -name = "pbkdf2" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" -dependencies = [ - "digest", - "hmac", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pest" -version = "2.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" -dependencies = [ - "memchr", - "ucd-trie", -] - -[[package]] -name = "pest_derive" -version = "2.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" -dependencies = [ - "pest", - "pest_generator", -] - -[[package]] -name = "pest_generator" -version = "2.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" -dependencies = [ - "pest", - "pest_meta", - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "pest_meta" -version = "2.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" -dependencies = [ - "pest", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" - -[[package]] -name = "poly1305" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" -dependencies = [ - "cpufeatures 0.2.17", - "opaque-debug", - "universal-hash", -] - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "predicates" -version = "3.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" -dependencies = [ - "anstyle", - "difflib", - "predicates-core", -] - -[[package]] -name = "predicates-core" -version = "1.0.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" - -[[package]] -name = "predicates-tree" -version = "1.0.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" -dependencies = [ - "predicates-core", - "termtree", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quinn" -version = "0.11.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" -dependencies = [ - "bytes", - "cfg_aliases", - "pin-project-lite", - "quinn-proto", - "quinn-udp", - "rustc-hash", - "rustls", - "socket2", - "thiserror 2.0.19", - "tokio", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-proto" -version = "0.11.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" -dependencies = [ - "bytes", - "getrandom 0.4.3", - "lru-slab", - "rand 0.10.2", - "rand_pcg", - "ring", - "rustc-hash", - "rustls", - "rustls-pki-types", - "slab", - "thiserror 2.0.19", - "tinyvec", - "tracing", - "web-time", -] - -[[package]] -name = "quinn-udp" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" -dependencies = [ - "cfg_aliases", - "libc", - "once_cell", - "socket2", - "tracing", - "windows-sys 0.61.2", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "radroots_blossom" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9088b79d81fc7e89a8ec14592b2c176000e25bf7e04a91f2cf188f2b3059842d" -dependencies = [ - "mediatype", - "serde", - "sha2", - "unicode-general-category", - "url", -] - -[[package]] -name = "radroots_cli" -version = "0.1.0" -dependencies = [ - "assert_cmd", - "chacha20poly1305", - "chrono", - "clap", - "flate2", - "getrandom 0.2.17", - "nostr", - "nostr-sdk", - "radroots_core", - "radroots_event", - "radroots_event_codec", - "radroots_identity", - "radroots_log", - "radroots_mesh", - "radroots_nostr", - "radroots_nostr_accounts", - "radroots_nostr_connect", - "radroots_protected_store", - "radroots_protocol", - "radroots_replica_schema", - "radroots_replica_store", - "radroots_replica_sync", - "radroots_runtime", - "radroots_runtime_contract_v1", - "radroots_runtime_paths", - "radroots_runtime_store", - "radroots_sdk", - "radroots_secret_vault", - "radroots_signing", - "radroots_sql_core", - "radroots_storage", - "radroots_storage_sqlite", - "radroots_sync", - "radroots_trade", - "radroots_transport", - "radroots_transport_nostr", - "serde", - "serde_json", - "tar", - "tempfile", - "thiserror 2.0.19", - "tokio", - "toml", - "tungstenite", - "url", - "zeroize", -] - -[[package]] -name = "radroots_core" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e025474387307ad31b9badba61d40a6780772c76bf797d108802394fc454ace3" -dependencies = [ - "rust_decimal", - "serde", -] - -[[package]] -name = "radroots_event" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02af0e49a14deed13c93b35f10705c7dd6f4abbe70c925a67af614d55decf3a3" -dependencies = [ - "hex", - "jiff-tzdb", - "radroots_blossom", - "radroots_core", - "radroots_identity", - "radroots_protocol", - "serde", - "serde_json", - "sha2", - "unicode-general-category", - "url", -] - -[[package]] -name = "radroots_event_codec" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "730541f773566e0adf46705f36be06f6441410c2e1c9da5b147e2aa7c004eb20" -dependencies = [ - "radroots_blossom", - "radroots_core", - "radroots_event", - "radroots_identity", - "radroots_protocol", - "secp256k1", - "serde", - "serde_json", -] - -[[package]] -name = "radroots_geonames" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb32a26e5e06f15cbf79e1556873e456a2e0d4a4bb01eec22bc3e2a97db03960" -dependencies = [ - "fs2", - "rusqlite", - "sha2", - "tempfile", - "url", -] - -[[package]] -name = "radroots_identity" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bc11dc4df1afba04add840f7a1231bee15d989931808ea7ffef20a4277af9a6" -dependencies = [ - "k256", - "serde", - "thiserror 2.0.19", -] - -[[package]] -name = "radroots_log" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588181ad5ca25e01e775bf953c6861dd4545ea08c6db691df8a4eb753eca62f6" -dependencies = [ - "chrono", - "serde_json", - "thiserror 1.0.69", - "tracing", - "tracing-appender", - "tracing-subscriber", -] - -[[package]] -name = "radroots_mesh" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b4a4fc196644ae4b6d2cb023bcf4074883edfb7db8f0731b462dfecf49b96ad" -dependencies = [ - "radroots_transport", -] - -[[package]] -name = "radroots_nostr" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d988d9ea535c33adb6f26704f8cc29170d07bb5fc16486a5abdb38c27e824dfa" -dependencies = [ - "nostr", - "radroots_event", - "radroots_event_codec", - "radroots_identity", - "radroots_signing", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "radroots_nostr_accounts" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29fc51468b787b9578a6b3a18d3a6c11e15e40a37966734c3839199519419428" -dependencies = [ - "nostr", - "radroots_identity", - "radroots_nostr_signer", - "radroots_protected_store", - "radroots_runtime", - "radroots_secret_vault", - "serde", - "serde_json", - "thiserror 1.0.69", - "zeroize", -] - -[[package]] -name = "radroots_nostr_connect" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f998e609addddbd54dc139a93da734e331e80666cfb5d6d4ce6d80f3632a94" -dependencies = [ - "nostr", - "radroots_event", - "radroots_identity", - "radroots_nostr", - "radroots_protocol", - "serde", - "serde_json", - "thiserror 1.0.69", - "url", -] - -[[package]] -name = "radroots_nostr_signer" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08e7f193bbf32f4f14c620de249defe242b3ef61439c0b1f1bd6625fdaf49037" -dependencies = [ - "hex", - "nostr", - "radroots_identity", - "radroots_nostr", - "radroots_nostr_connect", - "radroots_runtime", - "serde", - "serde_json", - "sha2", - "thiserror 1.0.69", - "url", - "uuid", -] - -[[package]] -name = "radroots_protected_store" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9715a7c7b7c259257393e2486f3f5ffc34891d958e8bf1a11e403d7d5484a6e" -dependencies = [ - "chacha20poly1305", - "getrandom 0.2.17", - "radroots_secret_vault", - "serde", - "serde_json", - "zeroize", -] - -[[package]] -name = "radroots_protocol" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adfbada19f5ddf856da0df008f54e5ebf37737863f5df067c26d4ac40ce5aa18" -dependencies = [ - "serde", -] - -[[package]] -name = "radroots_protocol_contract_v1" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f718ed748702f7f2b00af58bc6a6cb1b2030fe7f3b8bbcd7835bfe6741f66e" -dependencies = [ - "serde", -] - -[[package]] -name = "radroots_replica_schema" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c17d02275bd4e5e97e9bd9571bdbe778da73a94dfb531de2656917a0869d998d" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "radroots_replica_store" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d159430b5ffda4abd0129fad668c733860c26408b06fdec0f00ec08b8961e297" -dependencies = [ - "hex", - "radroots_replica_schema", - "radroots_sql_core", - "serde", - "serde_json", - "sha2", -] - -[[package]] -name = "radroots_replica_sync" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34a043971a295e66d5db56ec347da11467e2ca1f83c67d910c2242c756158a5d" -dependencies = [ - "base64 0.22.1", - "hex", - "radroots_core", - "radroots_event", - "radroots_event_codec", - "radroots_replica_schema", - "radroots_replica_store", - "radroots_sql_core", - "serde", - "serde_json", - "sha2", - "uuid", -] - -[[package]] -name = "radroots_runtime" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b35b19bdc24751b0cc0a2ab835ea94a31d0409fec2ea53b9438430f1f351c9d" -dependencies = [ - "anyhow", - "chacha20poly1305", - "config", - "getrandom 0.2.17", - "radroots_log", - "radroots_protected_store", - "radroots_runtime_paths", - "radroots_secret_vault", - "serde", - "serde_json", - "tempfile", - "thiserror 1.0.69", - "tokio", - "toml", - "tracing", - "zeroize", -] - -[[package]] -name = "radroots_runtime_contract_v1" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecb8d0655dac16f64b0df451cfa2c600d375934b2054c5a8822ad7637dd3083e" -dependencies = [ - "radroots_protocol_contract_v1", - "serde", -] - -[[package]] -name = "radroots_runtime_paths" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a930344bed69f7f43625eb33969c02290be5fb69c424dda3e34fe9cbaa75c120" -dependencies = [ - "serde", - "thiserror 1.0.69", -] - -[[package]] -name = "radroots_runtime_store" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a6a1f494999d2c4d1f64b293ec9d775a44136d8cd8d65bc0629fe035421f406" -dependencies = [ - "radroots_sql_core", - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] -name = "radroots_sdk" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38a596e3aeff8f0529e446f5fa014b6519dcf8b7a4312d2836348119b0506f6" -dependencies = [ - "radroots_core", - "radroots_event", - "radroots_event_codec", - "radroots_geonames", - "radroots_identity", - "radroots_nostr", - "radroots_nostr_connect", - "radroots_protocol", - "radroots_secrets", - "radroots_signing", - "radroots_storage", - "radroots_storage_sqlite", - "radroots_sync", - "radroots_trade", - "radroots_transport", - "radroots_transport_nostr", - "reqwest", - "serde", - "serde_json", -] - -[[package]] -name = "radroots_secret_vault" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8f323939c546a0e680c916d33dfcf6092cc4f2ab8195c02007ead012b215653" -dependencies = [ - "keyring", -] - -[[package]] -name = "radroots_secrets" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f586ada77097ae1d806d4cc966d5e8161e6e28e55e57935e989bb0b0a9a14ad" -dependencies = [ - "chacha20poly1305", - "zeroize", -] - -[[package]] -name = "radroots_signing" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d5ed02d28a3e166f4e10c90a78b1c0eae7235ed39a145c9530617ab7e188d0" -dependencies = [ - "radroots_event", - "radroots_identity", - "radroots_protocol", - "serde", -] - -[[package]] -name = "radroots_sql_core" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f5bdb02012b0570df7411d87a1a61c87abd36dd0dca63fffefeed9525069f54" -dependencies = [ - "chrono", - "futures-executor", - "serde", - "serde_json", - "sqlx", - "uuid", -] - -[[package]] -name = "radroots_storage" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7b3118e52ab4c54a38739aa74465a6ae0ed57bd0138f8ea0f60a659fb72a6ee" -dependencies = [ - "radroots_event", - "radroots_protocol", - "radroots_trade", - "radroots_transport", - "serde", -] - -[[package]] -name = "radroots_storage_sqlite" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d162bc66eb22ee13fbe7fc5eae02581bcff3ab062efac66a32cf334cf941210" -dependencies = [ - "fs2", - "radroots_event_codec", - "radroots_secrets", - "radroots_storage", - "sha2", - "sqlx", -] - -[[package]] -name = "radroots_sync" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbb2beab3b3b33590e72b1ae759d46580ef92f645be3e65dff8dcca6249d8a0" -dependencies = [ - "radroots_event", - "radroots_event_codec", - "radroots_protocol", - "radroots_signing", - "radroots_storage", - "radroots_trade", - "radroots_transport", - "serde", - "sha2", -] - -[[package]] -name = "radroots_trade" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baea4d0b37c59f04f063964c8696d4b3ad64eac26b374fc5c1d59808053bfc9e" -dependencies = [ - "base64 0.22.1", - "hex", - "radroots_core", - "radroots_event", - "radroots_event_codec", - "radroots_identity", - "serde", - "serde_json", - "sha2", -] - -[[package]] -name = "radroots_transport" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcd67a15b7c49483534985dad0307c7318c9eca19b6f3005a7bf73dccdbc42a4" -dependencies = [ - "radroots_event", - "radroots_identity", - "radroots_protocol", - "serde", - "sha2", -] - -[[package]] -name = "radroots_transport_nostr" -version = "0.1.0-alpha" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71f7efee834ae72dfbc1bf8f99cd2cafe606d50eb9505c739774cfc543209584" -dependencies = [ - "async-wsocket", - "nostr-relay-pool", - "nostr-sdk", - "radroots_event_codec", - "radroots_nostr", - "radroots_protocol", - "radroots_transport", - "serde_json", - "tokio", - "tokio-tungstenite", - "url", -] - -[[package]] -name = "rand" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20 0.10.1", - "getrandom 0.4.3", - "rand_core 0.10.1", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "rand_pcg" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" -dependencies = [ - "rand_core 0.10.1", -] - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex-automata" -version = "0.4.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "reqwest" -version = "0.12.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" -dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-util", - "js-sys", - "log", - "percent-encoding", - "pin-project-lite", - "quinn", - "rustls", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 1.0.9", -] - -[[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 = "ron" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" -dependencies = [ - "base64 0.21.7", - "bitflags", - "serde", - "serde_derive", -] - -[[package]] -name = "rsqlite-vfs" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" -dependencies = [ - "hashbrown 0.16.1", - "thiserror 2.0.19", -] - -[[package]] -name = "rusqlite" -version = "0.39.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" -dependencies = [ - "bitflags", - "fallible-iterator", - "fallible-streaming-iterator", - "libsqlite3-sys", - "smallvec", - "sqlite-wasm-rs", -] - -[[package]] -name = "rust-ini" -version = "0.20.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" -dependencies = [ - "cfg-if", - "ordered-multimap", -] - -[[package]] -name = "rust_decimal" -version = "1.42.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" -dependencies = [ - "arrayvec", - "num-traits", - "serde", - "wasm-bindgen", -] - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "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 = [ - "web-time", - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -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 = "salsa20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" -dependencies = [ - "cipher", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "scrypt" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" -dependencies = [ - "password-hash", - "pbkdf2", - "salsa20", - "sha2", -] - -[[package]] -name = "sec1" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" -dependencies = [ - "base16ct", - "der", - "generic-array", - "subtle", - "zeroize", -] - -[[package]] -name = "secp256k1" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" -dependencies = [ - "rand 0.8.7", - "secp256k1-sys", - "serde", -] - -[[package]] -name = "secp256k1-sys" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" -dependencies = [ - "cc", -] - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] -name = "serde_core" -version = "1.0.229" +name = "once_cell_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] -name = "serde_derive" -version = "1.0.229" +name = "percent-encoding" +version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", -] +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] -name = "serde_json" -version = "1.0.151" +name = "pin-project-lite" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] -name = "serde_spanned" -version = "0.6.9" +name = "pkg-config" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", -] +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "potential_utf" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "zerovec", ] [[package]] -name = "sha1" -version = "0.10.7" +name = "predicates" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", + "anstyle", + "difflib", + "predicates-core", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "predicates-core" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "digest", -] +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] -name = "sharded-slab" -version = "0.1.7" +name = "predicates-tree" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ - "lazy_static", + "predicates-core", + "termtree", ] [[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "errno", - "libc", + "unicode-ident", ] [[package]] -name = "simd-adler32" -version = "0.3.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.5" +name = "quote" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ - "libc", - "windows-sys 0.61.2", + "proc-macro2", ] [[package]] -name = "spin" -version = "0.9.9" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" -dependencies = [ - "lock_api", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "sqlite-wasm-rs" -version = "0.5.5" +name = "radroots" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +checksum = "df239d81b548496352953820b7efcc240b35759a5eab2af4e52194ccd83593cd" dependencies = [ - "cc", - "js-sys", - "rsqlite-vfs", - "wasm-bindgen", + "radroots_core", + "radroots_event", + "radroots_identity", + "radroots_sdk", + "radroots_trade", + "radroots_transport", ] [[package]] -name = "sqlx" -version = "0.9.0" +name = "radroots_blossom" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "378620ccc25c62c89d8be1c819e76a88d59bdcc3304733330788948e619bfd71" +checksum = "786aabfb3b884f1c01a2060c3bba57fed7d83ba6e7cddc2ce4e07d33fcc5a67d" dependencies = [ - "sqlx-core", - "sqlx-macros", - "sqlx-sqlite", + "mediatype", + "serde", + "sha2", + "unicode-general-category", + "url", ] [[package]] -name = "sqlx-core" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +name = "radroots_cli" +version = "0.1.0" dependencies = [ - "base64 0.22.1", - "bytes", - "cfg-if", - "crc", - "crossbeam-queue", - "either", - "event-listener", - "futures-core", - "futures-intrusive", - "futures-io", - "futures-util", - "hashbrown 0.16.1", - "hashlink 0.11.1", - "indexmap", - "log", - "memchr", - "percent-encoding", - "serde", - "sha2", - "smallvec", - "thiserror 2.0.19", + "assert_cmd", + "clap", + "radroots", + "serde_json", "tokio", - "tokio-stream", - "tracing", - "url", ] [[package]] -name = "sqlx-macros" -version = "0.9.0" +name = "radroots_core" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd2b84f2bc39a5705ef27ec785a11c934a41bbd4a24941e257927cddc26b60bf" +checksum = "e43ac2e488444ced1cddd6785d0a43552f887440c3f0506d9d2f90c7be30b26c" dependencies = [ - "proc-macro2", - "quote", - "sqlx-core", - "sqlx-macros-core", - "syn 2.0.119", + "rust_decimal", + "serde", ] [[package]] -name = "sqlx-macros-core" -version = "0.9.0" +name = "radroots_event" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb8d96de5fdc85a5c4ec813432b523ec637e80ba98f046555f75f7908ddac7c3" +checksum = "9b1cfbf17b7c94ee0182c48042438ccc3c6c5979043a6ef300004935780408a2" dependencies = [ - "cfg-if", - "dotenvy", - "either", - "heck", "hex", - "proc-macro2", - "quote", + "jiff-tzdb", + "radroots_blossom", + "radroots_core", + "radroots_identity", + "radroots_protocol", "serde", "serde_json", "sha2", - "sqlx-core", - "sqlx-sqlite", - "syn 2.0.119", - "tokio", + "unicode-general-category", "url", ] [[package]] -name = "sqlx-sqlite" -version = "0.9.0" +name = "radroots_event_codec" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "488e99c397a62007e4229aec669a179816339afc6d2620ca6fa420dbee2e982c" +checksum = "b3344dee67769f76754d8713312f57f54301e9eac00584c26c51fb1b1aab9042" dependencies = [ - "atoi", - "flume", - "form_urlencoded", - "futures-channel", - "futures-core", - "futures-executor", - "futures-intrusive", - "futures-util", - "libsqlite3-sys", - "log", - "percent-encoding", + "radroots_blossom", + "radroots_core", + "radroots_event", + "radroots_identity", + "radroots_protocol", + "secp256k1", "serde", - "sqlx-core", - "thiserror 2.0.19", - "tracing", - "url", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "tar" -version = "0.4.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" -dependencies = [ - "filetime", - "libc", - "xattr", + "serde_json", ] [[package]] -name = "tempfile" -version = "3.27.0" +name = "radroots_geonames" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +checksum = "5964b621444c1c4748a9b3df8a82e8652364ed66962d694d8673ea186cf0712c" dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", + "fs2", + "rusqlite", + "sha2", + "tempfile", + "url", ] [[package]] -name = "termtree" -version = "0.5.1" +name = "radroots_identity" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +checksum = "95d406e0fa84faee0a87493358b4c45151271a12b0bc3d57aa0167b968680eea" +dependencies = [ + "k256", + "serde", + "thiserror", +] [[package]] -name = "thiserror" -version = "1.0.69" +name = "radroots_protocol" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "979c9e8217ccb11cfe1af3bf85145f3abcd4234dfc45f74801398598fd542d5a" dependencies = [ - "thiserror-impl 1.0.69", + "serde", ] [[package]] -name = "thiserror" -version = "2.0.19" +name = "radroots_sdk" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "0b791cce95667c25a19a13c19b3ae1e30d1ae5301b20d7195955ce0774076e48" dependencies = [ - "thiserror-impl 2.0.19", + "radroots_core", + "radroots_event", + "radroots_event_codec", + "radroots_geonames", + "radroots_identity", + "radroots_protocol", + "radroots_signing", + "radroots_storage", + "radroots_trade", + "radroots_transport", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "radroots_signing" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "a3fa5a21d9e3aa6910303b866bcbd466243b6a797d5005fb591cd6e497a06c0b" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "radroots_event", + "radroots_identity", + "radroots_protocol", ] [[package]] -name = "thiserror-impl" -version = "2.0.19" +name = "radroots_storage" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "591d16fa94a6a802b9c0c254b853af9a3d16fe16345432ab2a0a7908b707a37b" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "radroots_event", + "radroots_protocol", + "radroots_trade", + "radroots_transport", ] [[package]] -name = "thread_local" -version = "1.1.10" +name = "radroots_trade" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +checksum = "696aad8bd5fdd85e9d6f2c5c8ef4a35d6662ec7dfd654852fb3a76fe982734f5" dependencies = [ - "cfg-if", + "base64", + "hex", + "radroots_core", + "radroots_event", + "radroots_event_codec", + "radroots_identity", + "serde", + "serde_json", + "sha2", ] [[package]] -name = "time" -version = "0.3.55" +name = "radroots_transport" +version = "0.1.0-alpha" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +checksum = "e3b0adaa62f7649abb500b0e9b9d7d8f875c3ed42da780aea920776f1c5b7417" dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", + "radroots_event", + "radroots_identity", + "radroots_protocol", + "sha2", ] [[package]] -name = "time-core" -version = "0.1.9" +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "regex-automata" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" [[package]] -name = "time-macros" -version = "0.2.32" +name = "rsqlite-vfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ - "num-conv", - "time-core", + "hashbrown", + "thiserror", ] [[package]] -name = "tiny-keccak" -version = "2.0.2" +name = "rusqlite" +version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "crunchy", + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", ] [[package]] -name = "tinystr" -version = "0.8.3" +name = "rust_decimal" +version = "1.42.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "be2a24f50780bc85f09cc6ac299bdf1424302742d77221106859c9d8b102126a" dependencies = [ - "displaydoc", - "zerovec", + "arrayvec", + "num-traits", + "serde", + "wasm-bindgen", ] [[package]] -name = "tinyvec" -version = "1.12.0" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "tinyvec_macros", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", ] [[package]] -name = "tinyvec_macros" -version = "0.1.1" +name = "rustversion" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] -name = "tokio" -version = "1.53.1" +name = "sec1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" dependencies = [ - "bytes", - "libc", - "mio", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", + "base16ct", + "der", + "generic-array", + "subtle", + "zeroize", ] [[package]] -name = "tokio-macros" -version = "2.7.2" +name = "secp256k1" +version = "0.29.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.3", + "secp256k1-sys", ] [[package]] -name = "tokio-rustls" -version = "0.26.4" +name = "secp256k1-sys" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" dependencies = [ - "rustls", - "tokio", + "cc", ] [[package]] -name = "tokio-socks" -version = "0.5.3" +name = "serde" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ - "either", - "futures-util", - "thiserror 1.0.69", - "tokio", + "serde_core", + "serde_derive", ] [[package]] -name = "tokio-stream" -version = "0.1.19" +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", + "serde_derive", ] [[package]] -name = "tokio-tungstenite" -version = "0.26.2" +name = "serde_derive" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ - "futures-util", - "log", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tungstenite", - "webpki-roots 0.26.11", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "toml" -version = "0.8.23" +name = "serde_json" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "itoa", + "memchr", "serde", - "serde_spanned", - "toml_datetime", - "toml_edit", + "serde_core", + "zmij", ] [[package]] -name = "toml_datetime" -version = "0.6.11" +name = "sha2" +version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "serde", + "cfg-if", + "cpufeatures", + "digest", ] [[package]] -name = "toml_edit" -version = "0.22.27" +name = "shlex" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" -dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", - "toml_write", - "winnow", -] +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] -name = "toml_write" -version = "0.1.2" +name = "smallvec" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] -name = "tower" -version = "0.5.3" +name = "sqlite-wasm-rs" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", ] [[package]] -name = "tower-http" -version = "0.6.11" +name = "stable_deref_trait" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", - "url", -] +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "tower-layer" -version = "0.3.3" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "tower-service" -version = "0.3.3" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] -name = "tracing" -version = "0.1.44" +name = "syn" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ - "log", - "pin-project-lite", - "tracing-attributes", - "tracing-core", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "tracing-appender" -version = "0.2.5" +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.19", - "time", - "tracing-subscriber", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] -name = "tracing-attributes" -version = "0.1.31" +name = "synstructure" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", @@ -3666,67 +1094,73 @@ dependencies = [ ] [[package]] -name = "tracing-core" -version = "0.1.36" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ + "fastrand", + "getrandom", "once_cell", - "valuable", + "rustix", + "windows-sys", ] [[package]] -name = "tracing-log" -version = "0.2.0" +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ - "log", - "once_cell", - "tracing-core", + "thiserror-impl", ] [[package]] -name = "tracing-subscriber" -version = "0.3.23" +name = "thiserror-impl" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", ] [[package]] -name = "try-lock" -version = "0.2.5" +name = "tokio" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "pin-project-lite", + "tokio-macros", +] [[package]] -name = "tungstenite" -version = "0.26.2" +name = "tokio-macros" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ - "bytes", - "data-encoding", - "http", - "httparse", - "log", - "rand 0.9.5", - "rustls", - "rustls-pki-types", - "sha1", - "thiserror 2.0.19", - "utf-8", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] @@ -3736,18 +1170,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] -name = "ucd-trie" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" - -[[package]] -name = "unicode-bidi" -version = "0.3.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" - -[[package]] name = "unicode-general-category" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3760,68 +1182,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] -name = "unicode-normalization" -version = "0.1.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" -dependencies = [ - "tinyvec", -] - -[[package]] -name = "unicode-segmentation" -version = "1.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" - -[[package]] -name = "universal-hash" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" -dependencies = [ - "crypto-common", - "subtle", -] - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] name = "url" version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", - "idna 1.1.0", - "percent-encoding", - "serde", - "serde_derive", -] - -[[package]] -name = "url-fork" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fa3323c39b8e786154d3000b70ae9af0e9bd746c9791456da0d4a1f68ad89d6" -dependencies = [ - "form_urlencoded", - "idna 0.5.0", + "idna", "percent-encoding", "serde", ] [[package]] -name = "utf-8" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" - -[[package]] name = "utf8_iter" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3834,23 +1206,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] -name = "uuid" -version = "1.24.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] name = "vcpkg" version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3872,30 +1227,6 @@ dependencies = [ ] [[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] name = "wasm-bindgen" version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3910,16 +1241,6 @@ dependencies = [ ] [[package]] -name = "wasm-bindgen-futures" -version = "0.4.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] name = "wasm-bindgen-macro" version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3952,44 +1273,6 @@ dependencies = [ ] [[package]] -name = "web-sys" -version = "0.3.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "web-time" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - -[[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" @@ -4012,83 +1295,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] name = "windows-sys" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4098,177 +1310,12 @@ dependencies = [ ] [[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[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_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[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_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] name = "writeable" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" [[package]] -name = "xattr" -version = "1.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" -dependencies = [ - "libc", - "rustix", -] - -[[package]] -name = "yaml-rust2" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" -dependencies = [ - "arraydeque", - "encoding_rs", - "hashlink 0.8.4", -] - -[[package]] name = "yoke" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4292,26 +1339,6 @@ dependencies = [ ] [[package]] -name = "zerocopy" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] name = "zerofrom" version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4337,20 +1364,6 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml @@ -22,54 +22,13 @@ unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } [features] default = [] -test-fixture-geonames-asset = ["radroots_sdk/geonames"] +test-fixture-geonames-asset = ["radroots/geonames"] [dependencies] -chacha20poly1305 = "0.10" -chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } clap = { version = "4.5", features = ["derive"] } -getrandom = "0.2" -nostr = { version = "0.44.2", features = ["nip44"] } -nostr-sdk = "0.44.1" -radroots_core = { version = "=0.1.0-alpha", features = ["std", "serde"] } -radroots_event = "=0.1.0-alpha" -radroots_event_codec = { version = "=0.1.0-alpha", features = ["json"] } -radroots_identity = "=0.1.0-alpha" -radroots_runtime_store = "=0.1.0-alpha" -radroots_log = "=0.1.0-alpha" -radroots_mesh = { version = "=0.1.0-alpha", default-features = false } -radroots_nostr_accounts = { version = "=0.1.0-alpha", features = ["os-keyring"] } -radroots_nostr = { version = "=0.1.0-alpha", features = ["events", "signing"] } -radroots_nostr_connect = "=0.1.0-alpha" -radroots_protected_store = { version = "=0.1.0-alpha", features = ["std"] } -radroots_replica_store = "=0.1.0-alpha" -radroots_replica_schema = "=0.1.0-alpha" -radroots_replica_sync = { version = "=0.1.0-alpha", features = ["legacy-ingest"] } -radroots_transport = { version = "=0.1.0-alpha", default-features = false } -radroots_transport_nostr = { version = "=0.1.0-alpha", default-features = false } -radroots_runtime = "=0.1.0-alpha" -radroots_runtime_contract_v1 = "=0.1.0-alpha" -radroots_runtime_paths = "=0.1.0-alpha" -radroots_sdk = { version = "=0.1.0-alpha", features = ["full"] } -radroots_secret_vault = { version = "=0.1.0-alpha", features = ["std", "os-keyring"] } -radroots_signing = { version = "=0.1.0-alpha", features = ["std"] } -radroots_sql_core = { version = "=0.1.0-alpha", features = ["native"] } -radroots_storage = "=0.1.0-alpha" -radroots_storage_sqlite = "=0.1.0-alpha" -radroots_sync = "=0.1.0-alpha" -radroots_trade = "=0.1.0-alpha" -radroots_protocol = "=0.1.0-alpha" -serde = { version = "1.0", features = ["derive"] } +radroots = "=0.1.0-alpha" serde_json = "1.0" -thiserror = "2.0" -tokio = { version = "1", features = ["rt-multi-thread", "sync", "time"] } -toml = "0.8" -url = "2.5" -zeroize = "1.8" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } [dev-dependencies] assert_cmd = "2.0" -flate2 = "1" -tar = "0.4" -tempfile = "3.17" -tungstenite = "0.26.2" diff --git a/README b/README @@ -1,10 +1,8 @@ # Radroots CLI -`radroots` is the local-first command-line client for Radroots accounts, farms, -listings, markets, baskets, trades, transport, and synchronization. The CLI is -a downstream consumer of the versioned `radroots_sdk` package and retains only -host concerns such as configuration, presentation, local process integration, -and command orchestration. +`radroots` is the local-first command-line host for the release-v1 Radroots +crate graph. It consumes the canonical `radroots` facade and retains only +command parsing and presentation concerns. The CLI is pre-release software. Its current package graph uses exact alpha versions so a lockfile and the matching Radroots package set travel together. @@ -23,20 +21,21 @@ and opt into the ignored local override file described in [`docs/engineering/local-overrides.md`](docs/engineering/local-overrides.md). Release and packaged-consumer checks never use sibling path overrides. -## Command model +## Release-v1 command model Commands are organized by resource. Inspect any level with `--help`: ```sh -radroots health inspect -radroots account list --format json -radroots transport status inspect -radroots store inspect --offline -radroots sync status -radroots market product search apples --format ndjson -radroots listing create --dry-run +radroots profile inspect --format json +radroots health inspect --format json ``` +Only local profile and health inspection are executable in the initial crate +release. The complete resource vocabulary remains parseable so automation gets +a stable, structured `unsupported_operation` response while private predecessor +runtimes are retired. No command silently falls back to a sibling checkout or +legacy engine. + Global controls include: - `--format terminal|json|ndjson` for stable output envelopes; @@ -67,15 +66,16 @@ RADROOTS_CLI_OUTPUT_FORMAT=terminal RADROOTS_CLI_ACCOUNT_SECRET_BACKEND=encrypted_file ``` -Use `radroots profile inspect` and `radroots health inspect` to see resolved -paths and actionable readiness state before attempting a write. Account secrets -are never accepted as command-line values; import them from a protected file or -use the configured host secret backend. +Use `radroots profile inspect` and `radroots health inspect` to verify that the +registry-backed facade can construct and close its local-only memory client. +Write operations remain fail-closed until their canonical SDK orchestration is +enabled in a future release. ## Package migration -The v1 crate migration replaced compatibility namespaces and CLI-owned generic -signing, transport, storage, and sync engines with the final SDK composition. +The v1 crate migration removed compatibility namespaces and the CLI-owned +generic signing, transport, storage, and sync engines. The initial CLI host +uses only the final facade composition. See [`docs/migration/crates-release-v1.md`](docs/migration/crates-release-v1.md) for renamed commands, configuration changes, and downstream package rules. diff --git a/docs/migration/crates-release-v1.md b/docs/migration/crates-release-v1.md @@ -6,7 +6,7 @@ engines, and implicit workspace paths are no longer supported. ## Dependency migration -- Depend on `radroots_sdk = "=0.1.0-alpha"` for the supported application API. +- Depend on `radroots = "=0.1.0-alpha"` for the supported ordinary application API. - Keep exact versions and a committed lockfile during the coordinated alpha. - Do not add sibling `path` dependencies to a production manifest. - Local contributors may use the explicit ignored patch file documented in @@ -19,11 +19,11 @@ or merge those repositories. ## Runtime migration -The CLI now composes the canonical SDK and shared crates for event codecs, -signing, Nostr transport, storage, and sync. CLI code continues to own command -parsing, configuration precedence, terminal and structured presentation, -process integration, and local host policy. It must not reintroduce a generic -relay pagination loop, ingest reducer, outbox engine, or signer protocol. +The CLI now composes only the canonical `radroots` facade. CLI code owns +command parsing and terminal or structured presentation; storage, signing, +transport, and sync behavior remain with the final crates. It must not +reintroduce a generic relay pagination loop, ingest reducer, outbox engine, or +signer protocol. The old environment and TOML groups are rejected instead of silently mapped. Start from `.env.example`, inspect the resolved profile, and address health @@ -32,11 +32,12 @@ actions in order: ```sh radroots profile inspect --format json radroots health inspect --format json -radroots signer status --format json -radroots transport status inspect --format json -radroots sync status --format json ``` +Other resource commands remain parseable but fail closed with +`unsupported_operation` until a future release enables their canonical SDK +orchestration. This is an intentional breaking change from the private runtime. + Automation should use `--no-input`, explicit online/offline policy, and stable idempotency and correlation identifiers for writes. Retired commands and flags fail during parsing; use the resource-oriented command tree shown by diff --git a/src/cli/global.rs b/src/cli/global.rs @@ -1,219 +0,0 @@ -use std::path::PathBuf; - -use crate::runtime::config::OutputFormat; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RuntimeOutputFormatArg { - Terminal, - Json, - Ndjson, -} - -impl RuntimeOutputFormatArg { - pub fn as_output_format(self) -> OutputFormat { - match self { - Self::Terminal => OutputFormat::Terminal, - Self::Json => OutputFormat::Json, - Self::Ndjson => OutputFormat::Ndjson, - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct RuntimeInvocationArgs { - pub output_format: Option<RuntimeOutputFormatArg>, - pub json: bool, - pub ndjson: bool, - pub env_file: Option<PathBuf>, - pub quiet: bool, - pub verbose: bool, - pub trace: bool, - pub dry_run: bool, - pub no_input: bool, - pub yes: bool, - pub log_filter: Option<String>, - pub log_dir: Option<PathBuf>, - pub log_stdout: bool, - pub no_log_stdout: bool, - pub account: Option<String>, - pub identity_path: Option<PathBuf>, - pub signer: Option<String>, - pub myc_executable: Option<PathBuf>, - pub myc_status_timeout_ms: Option<u64>, - pub hyf_enabled: bool, - pub no_hyf_enabled: bool, - pub hyf_executable: Option<PathBuf>, -} - -#[derive(Debug, Clone, Copy)] -pub enum LocalExportFormatArg { - Json, - Ndjson, -} - -impl LocalExportFormatArg { - pub fn as_str(self) -> &'static str { - match self { - Self::Json => "json", - Self::Ndjson => "ndjson", - } - } -} - -#[derive(Debug, Clone)] -pub struct SyncWatchArgs { - pub frames: usize, - pub interval_ms: u64, -} - -#[derive(Debug, Clone)] -pub struct FindQueryArgs { - pub query: Vec<String>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FarmScopeArg { - User, - Workspace, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FarmFieldArg { - Name, - DisplayName, - About, - Website, - Picture, - Banner, - Location, - City, - Region, - Country, - Geohash, - Delivery, -} - -#[derive(Debug, Clone, Default)] -pub struct FarmScopedArgs { - pub scope: Option<FarmScopeArg>, -} - -#[derive(Debug, Clone, Default)] -pub struct FarmCreateArgs { - pub scope: Option<FarmScopeArg>, - pub farm_d_tag: Option<String>, - pub name: Option<String>, - pub display_name: Option<String>, - pub about: Option<String>, - pub website: Option<String>, - pub picture: Option<String>, - pub banner: Option<String>, - pub location: Option<String>, - pub city: Option<String>, - pub region: Option<String>, - pub country: Option<String>, - pub geohash: Option<String>, - pub delivery_method: Option<String>, -} - -#[derive(Debug, Clone)] -pub struct FarmRebindArgs { - pub scope: Option<FarmScopeArg>, - pub selector: String, -} - -#[derive(Debug, Clone)] -pub struct FarmUpdateArgs { - pub scope: Option<FarmScopeArg>, - pub field: FarmFieldArg, - pub value: Vec<String>, -} - -#[derive(Debug, Clone)] -pub struct FarmPrivateLocationSetArgs { - pub farm_d_tag: Option<String>, - pub input: FarmPrivateLocationSetInput, - pub label: Option<String>, -} - -#[derive(Debug, Clone)] -pub enum FarmPrivateLocationSetInput { - Exact { - latitude: f64, - longitude: f64, - }, - City { - city: String, - region: Option<String>, - country: Option<String>, - }, - Query(String), - GeonamesId(i64), -} - -#[derive(Debug, Clone, Default)] -pub struct FarmPrivateLocationKeyArgs { - pub farm_d_tag: Option<String>, -} - -#[derive(Debug, Clone, Default)] -pub struct FarmPublishArgs { - pub scope: Option<FarmScopeArg>, - pub idempotency_key: Option<String>, - pub print_event: bool, -} - -#[derive(Debug, Clone, Default)] -pub struct ListingCreateArgs { - pub output: Option<PathBuf>, - pub key: Option<String>, - pub title: Option<String>, - pub category: Option<String>, - pub summary: Option<String>, - pub bin_id: Option<String>, - pub quantity_amount: Option<String>, - pub quantity_unit: Option<String>, - pub price_amount: Option<String>, - pub price_currency: Option<String>, - pub price_per_amount: Option<String>, - pub price_per_unit: Option<String>, - pub available: Option<String>, - pub label: Option<String>, - pub discount_id: Option<String>, - pub discount_label: Option<String>, - pub discount_kind: Option<String>, - pub discount_value: Option<String>, - pub discount_amount: Option<String>, - pub discount_currency: Option<String>, -} - -#[derive(Debug, Clone)] -pub struct ListingFileArgs { - pub file: PathBuf, -} - -#[derive(Debug, Clone)] -pub struct ListingAppRecordExportArgs { - pub record_id: String, - pub output: Option<PathBuf>, -} - -#[derive(Debug, Clone)] -pub struct ListingRebindArgs { - pub file: PathBuf, - pub selector: String, - pub farm_d_tag: Option<String>, -} - -#[derive(Debug, Clone)] -pub struct ListingMutationArgs { - pub file: PathBuf, - pub idempotency_key: Option<String>, - pub print_event: bool, - pub offline: bool, -} - -#[derive(Debug, Clone)] -pub struct RecordLookupArgs { - pub key: String, -} diff --git a/src/cli/input.rs b/src/cli/input.rs @@ -1,340 +0,0 @@ -use std::path::Path; - -use crate::cli::global::{RuntimeInvocationArgs, RuntimeOutputFormatArg}; -use crate::cli::{TargetCliArgs, TargetCommand, TargetOutputFormat}; -use crate::ops::OperationData; -use serde_json::Value; - -pub fn runtime_invocation_args_from_target(args: &TargetCliArgs) -> RuntimeInvocationArgs { - RuntimeInvocationArgs { - output_format: args.format.map(|format| match format { - TargetOutputFormat::Terminal => RuntimeOutputFormatArg::Terminal, - TargetOutputFormat::Json => RuntimeOutputFormatArg::Json, - TargetOutputFormat::Ndjson => RuntimeOutputFormatArg::Ndjson, - }), - json: false, - ndjson: false, - env_file: None, - quiet: args.quiet, - verbose: args.verbose, - trace: args.trace, - dry_run: args.dry_run, - no_input: args.no_input, - yes: args.yes, - log_filter: None, - log_dir: None, - log_stdout: false, - no_log_stdout: false, - account: args.account_id.clone(), - identity_path: None, - signer: None, - myc_executable: None, - myc_status_timeout_ms: None, - hyf_enabled: false, - no_hyf_enabled: false, - hyf_executable: None, - } -} - -pub fn operation_id_from_target(args: &TargetCliArgs) -> &'static str { - args.command.operation_id() -} - -pub fn target_operation_input(command: &TargetCommand) -> OperationData { - use crate::cli::{ - AccountCommand, BasketCommand, BasketItemCommand, FarmCommand, ListingCommand, - MarketCommand, StoreCommand, TradeCancellationCommand, TradeCandidateCommand, TradeCommand, - TradeEvidenceCommand, TradeOperationCommand, TradePrivateArtifactCommand, - TradeProposalCommand, TradeRevisionCommand, TransportCapabilityCommand, TransportCommand, - TransportConfigCommand, TransportDeliveryCommand, ValidationCommand, - }; - - let mut input = OperationData::new(); - match command { - TargetCommand::Account(args) => match &args.command { - AccountCommand::Import(args) => { - insert_path(&mut input, "path", &args.path); - if args.default { - input.insert("default".to_owned(), Value::Bool(true)); - } - } - AccountCommand::Select(args) => insert_string(&mut input, "selector", &args.selector), - AccountCommand::Remove(args) => insert_string(&mut input, "selector", &args.selector), - AccountCommand::Create | AccountCommand::List => {} - }, - TargetCommand::Farm(args) => match &args.command { - FarmCommand::Create(args) => { - insert_string(&mut input, "farm_d_tag", &args.farm_d_tag); - insert_string(&mut input, "name", &args.name); - insert_string(&mut input, "display_name", &args.display_name); - insert_string(&mut input, "about", &args.about); - insert_string(&mut input, "website", &args.website); - insert_string(&mut input, "picture", &args.picture); - insert_string(&mut input, "banner", &args.banner); - insert_string(&mut input, "location", &args.location); - insert_string(&mut input, "city", &args.city); - insert_string(&mut input, "region", &args.region); - insert_string(&mut input, "country", &args.country); - insert_string(&mut input, "geohash", &args.geohash); - insert_string(&mut input, "delivery_method", &args.delivery_method); - } - FarmCommand::Update(args) => { - insert_string(&mut input, "field", &args.field); - insert_string(&mut input, "value", &args.value); - } - FarmCommand::Get | FarmCommand::List | FarmCommand::Publish => {} - }, - TargetCommand::Listing(args) => match &args.command { - ListingCommand::Create(args) => { - insert_path(&mut input, "output", &args.output); - insert_string(&mut input, "key", &args.key); - insert_string(&mut input, "title", &args.title); - insert_string(&mut input, "category", &args.category); - insert_string(&mut input, "summary", &args.summary); - insert_string(&mut input, "bin_id", &args.bin_id); - insert_string(&mut input, "quantity_amount", &args.quantity_amount); - insert_string(&mut input, "quantity_unit", &args.quantity_unit); - insert_string(&mut input, "price_amount", &args.price_amount); - insert_string(&mut input, "price_currency", &args.price_currency); - insert_string(&mut input, "price_per_amount", &args.price_per_amount); - insert_string(&mut input, "price_per_unit", &args.price_per_unit); - insert_string(&mut input, "available", &args.available); - insert_string(&mut input, "label", &args.label); - insert_string(&mut input, "discount_id", &args.discount_id); - insert_string(&mut input, "discount_label", &args.discount_label); - insert_string(&mut input, "discount_kind", &args.discount_kind); - insert_string(&mut input, "discount_value", &args.discount_value); - insert_string(&mut input, "discount_amount", &args.discount_amount); - insert_string(&mut input, "discount_currency", &args.discount_currency); - } - ListingCommand::Get(args) => insert_string(&mut input, "key", &args.key), - ListingCommand::Update(args) - | ListingCommand::Publish(args) - | ListingCommand::Pause(args) - | ListingCommand::Withdraw(args) => insert_path(&mut input, "file", &args.file), - ListingCommand::List => {} - }, - TargetCommand::Market(args) => match &args.command { - MarketCommand::Pull => {} - MarketCommand::Search(args) => { - insert_string_array(&mut input, "query", args.query.as_slice()) - } - MarketCommand::Get(args) => insert_string(&mut input, "key", &args.key), - }, - TargetCommand::Store(args) => match &args.command { - StoreCommand::Restore(args) => { - insert_path(&mut input, "source", &Some(args.source.clone())); - insert_path(&mut input, "destination", &args.destination); - if args.overwrite { - input.insert("overwrite".to_owned(), Value::Bool(true)); - } - } - StoreCommand::Inspect | StoreCommand::Backup => {} - }, - TargetCommand::Basket(args) => match &args.command { - BasketCommand::Create(args) => { - insert_string(&mut input, "basket_id", &args.basket_id); - insert_string(&mut input, "listing", &args.listing); - insert_string(&mut input, "listing_addr", &args.listing_addr); - insert_string(&mut input, "bin_id", &args.bin_id); - insert_string(&mut input, "quantity", &args.quantity); - } - BasketCommand::Get(args) => insert_string(&mut input, "basket_id", &args.basket_id), - BasketCommand::Item(item) => match &item.command { - BasketItemCommand::Add(args) | BasketItemCommand::Update(args) => { - insert_string(&mut input, "basket_id", &args.basket_id); - insert_string(&mut input, "item_id", &args.item_id); - insert_string(&mut input, "listing", &args.listing); - insert_string(&mut input, "listing_addr", &args.listing_addr); - insert_string(&mut input, "bin_id", &args.bin_id); - insert_string(&mut input, "quantity", &args.quantity); - } - BasketItemCommand::Remove(args) => { - insert_string(&mut input, "basket_id", &args.basket_id); - insert_string(&mut input, "item_id", &args.item_id); - } - }, - BasketCommand::Quote(args) => insert_string(&mut input, "basket_id", &args.basket_id), - BasketCommand::List => {} - }, - TargetCommand::Trade(args) => match &args.command { - TradeCommand::Proposal(args) => match &args.command { - TradeProposalCommand::Submit(args) => { - insert_path_required(&mut input, "file", &args.file) - } - }, - TradeCommand::Revision(args) => match &args.command { - TradeRevisionCommand::Propose(args) => { - insert_path_required(&mut input, "file", &args.file) - } - }, - TradeCommand::Candidate(args) => match &args.command { - TradeCandidateCommand::Decide(args) => { - insert_path_required(&mut input, "file", &args.file); - insert_bool( - &mut input, - "acknowledge_private_terms", - args.acknowledge_private_terms, - ); - } - }, - TradeCommand::Cancellation(args) => match &args.command { - TradeCancellationCommand::Submit(args) => { - insert_path_required(&mut input, "file", &args.file) - } - }, - TradeCommand::Operation(args) => match &args.command { - TradeOperationCommand::Resume(args) => { - insert_path_required(&mut input, "file", &args.file); - if let Some(operation_kind) = args.operation_kind { - input.insert( - "operation_kind".to_owned(), - Value::String(operation_kind.as_sdk_operation_kind().to_owned()), - ); - } - insert_bool( - &mut input, - "acknowledge_private_terms", - args.acknowledge_private_terms, - ); - } - }, - TradeCommand::Get(args) => insert_string(&mut input, "trade_id", &args.trade_id), - TradeCommand::List(args) => { - insert_u32(&mut input, "limit", args.limit); - insert_string(&mut input, "cursor", &args.cursor); - } - TradeCommand::Evidence(args) => match &args.command { - TradeEvidenceCommand::Refresh(args) => { - insert_string(&mut input, "trade_id", &args.trade_id) - } - TradeEvidenceCommand::Inspect(args) => { - insert_string(&mut input, "trade_id", &args.trade_id); - insert_u32(&mut input, "limit", args.limit); - insert_string(&mut input, "cursor", &args.cursor); - } - }, - TradeCommand::PrivateArtifact(args) => match &args.command { - TradePrivateArtifactCommand::Seal(args) => { - insert_string(&mut input, "trade_id", &args.trade_id); - insert_string(&mut input, "artifact_id", &args.artifact_id); - insert_string(&mut input, "schema_id", &args.schema_id); - insert_path(&mut input, "input", &args.input); - input.insert( - "kind".to_owned(), - Value::String(args.kind.as_str().to_owned()), - ); - insert_string(&mut input, "candidate_id", &args.candidate_id); - insert_string(&mut input, "retention_class", &args.retention_class); - insert_i64(&mut input, "expires_at_ms", args.expires_at_ms); - } - TradePrivateArtifactCommand::Open(args) => { - input.insert( - "artifact_id".to_owned(), - Value::String(args.artifact_id.clone()), - ); - insert_path(&mut input, "output", &args.output); - } - TradePrivateArtifactCommand::Delete(args) => { - input.insert( - "artifact_id".to_owned(), - Value::String(args.artifact_id.clone()), - ); - } - }, - }, - TargetCommand::Validation(args) => match &args.command { - ValidationCommand::Status => {} - }, - TargetCommand::Transport(args) => match &args.command { - TransportCommand::Config(config) => match &config.command { - TransportConfigCommand::Update(args) => { - input.insert( - "kind".to_owned(), - Value::String(args.kind.as_str().to_owned()), - ); - insert_string_array(&mut input, "nostr_relays", args.nostr_relay.as_slice()); - if let Some(behavior) = args.reticulum_behavior { - input.insert( - "reticulum_behavior".to_owned(), - Value::String(behavior.as_str().to_owned()), - ); - } - insert_string(&mut input, "reticulum_scope", &args.reticulum_scope); - insert_string( - &mut input, - "reticulum_agent_endpoint", - &args.reticulum_agent_endpoint, - ); - } - TransportConfigCommand::Inspect => {} - }, - TransportCommand::Capability(capability) => match capability.command { - TransportCapabilityCommand::List => {} - }, - TransportCommand::Delivery(delivery) => match delivery.command { - TransportDeliveryCommand::Inspect | TransportDeliveryCommand::Retry => {} - }, - TransportCommand::Status(_) => {} - }, - _ => {} - } - input -} - -fn insert_string(input: &mut OperationData, key: &str, value: &Option<String>) { - if let Some(value) = value - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - input.insert(key.to_owned(), Value::String(value.to_owned())); - } -} - -fn insert_string_array(input: &mut OperationData, key: &str, values: &[String]) { - let values = values - .iter() - .map(String::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(|value| Value::String(value.to_owned())) - .collect::<Vec<_>>(); - if !values.is_empty() { - input.insert(key.to_owned(), Value::Array(values)); - } -} - -fn insert_bool(input: &mut OperationData, key: &str, value: bool) { - if value { - input.insert(key.to_owned(), Value::Bool(true)); - } -} - -fn insert_path(input: &mut OperationData, key: &str, value: &Option<std::path::PathBuf>) { - if let Some(value) = value { - input.insert( - key.to_owned(), - Value::String(value.to_string_lossy().into_owned()), - ); - } -} - -fn insert_path_required(input: &mut OperationData, key: &str, value: &Path) { - input.insert( - key.to_owned(), - Value::String(value.to_string_lossy().into_owned()), - ); -} - -fn insert_u32(input: &mut OperationData, key: &str, value: Option<u32>) { - if let Some(value) = value { - input.insert(key.to_owned(), Value::Number(value.into())); - } -} - -fn insert_i64(input: &mut OperationData, key: &str, value: Option<i64>) { - if let Some(value) = value { - input.insert(key.to_owned(), Value::Number(value.into())); - } -} diff --git a/src/cli/mod.rs b/src/cli/mod.rs @@ -1,11 +1,8 @@ -pub mod global; - pub mod account; pub mod basket; pub mod diagnostics; pub mod farm; pub mod health; -pub mod input; pub mod listing; pub mod market; pub mod profile; @@ -254,19 +251,6 @@ mod tests { use clap::{CommandFactory, Parser}; use super::{TargetCliArgs, TargetCommand, TargetOutputFormat}; - use crate::registry::OPERATION_REGISTRY; - - #[test] - fn target_parser_accepts_every_generated_registry_path() { - for operation in OPERATION_REGISTRY { - let parsed = TargetCliArgs::try_parse_from(operation.cli_path.split_whitespace()) - .unwrap_or_else(|error| { - panic!("{} failed to parse: {error}", operation.cli_path); - }); - assert_eq!(parsed.command.operation_id(), operation.operation_id); - } - } - #[test] fn target_parser_exposes_only_v1_namespaces() { let actual = TargetCliArgs::command() diff --git a/src/generated/runtime_contract_registry.rs b/src/generated/runtime_contract_registry.rs @@ -1,859 +0,0 @@ -// @generated by radroots_sdk_xtask. Do not edit by hand. - -pub const OPERATION_REGISTRY: &[OperationSpec] = &[ - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ProfileInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[0], - operation_id: "profile.inspect", - cli_path: "radroots profile inspect", - namespace: "profile", - mcp_tool: "profile_inspect", - rust_request: "ProfileInspectRequest", - rust_result: "ProfileInspectResult", - json_kind: "profile.inspect", - description: "Inspect the active runtime profile.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ProfileReset, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[1], - operation_id: "profile.reset", - cli_path: "radroots profile reset", - namespace: "profile", - mcp_tool: "profile_reset", - rust_request: "ProfileResetRequest", - rust_result: "ProfileResetResult", - json_kind: "profile.reset", - description: "Reset the active runtime profile stores.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::AccountCreate, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[2], - operation_id: "account.create", - cli_path: "radroots account create", - namespace: "account", - mcp_tool: "account_create", - rust_request: "AccountCreateRequest", - rust_result: "AccountCreateResult", - json_kind: "account.create", - description: "Create a local account identity.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::AccountImport, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[3], - operation_id: "account.import", - cli_path: "radroots account import", - namespace: "account", - mcp_tool: "account_import", - rust_request: "AccountImportRequest", - rust_result: "AccountImportResult", - json_kind: "account.import", - description: "Import an existing account identity.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::AccountSelect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[4], - operation_id: "account.select", - cli_path: "radroots account select <selector>", - namespace: "account", - mcp_tool: "account_select", - rust_request: "AccountSelectRequest", - rust_result: "AccountSelectResult", - json_kind: "account.select", - description: "Select the active account context.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::AccountList, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[5], - operation_id: "account.list", - cli_path: "radroots account list", - namespace: "account", - mcp_tool: "account_list", - rust_request: "AccountListRequest", - rust_result: "AccountListResult", - json_kind: "account.list", - description: "List known local accounts.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::AccountRemove, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[6], - operation_id: "account.remove", - cli_path: "radroots account remove <selector>", - namespace: "account", - mcp_tool: "account_remove", - rust_request: "AccountRemoveRequest", - rust_result: "AccountRemoveResult", - json_kind: "account.remove", - description: "Remove an account from local configuration and storage.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::SignerStatus, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[7], - operation_id: "signer.status", - cli_path: "radroots signer status", - namespace: "signer", - mcp_tool: "signer_status", - rust_request: "SignerStatusRequest", - rust_result: "SignerStatusResult", - json_kind: "signer.status", - description: "Inspect signer readiness.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::StoreInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[8], - operation_id: "store.inspect", - cli_path: "radroots store inspect", - namespace: "store", - mcp_tool: "store_inspect", - rust_request: "StoreInspectRequest", - rust_result: "StoreInspectResult", - json_kind: "store.inspect", - description: "Inspect local runtime stores.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::StoreBackup, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[9], - operation_id: "store.backup", - cli_path: "radroots store backup", - namespace: "store", - mcp_tool: "store_backup", - rust_request: "StoreBackupRequest", - rust_result: "StoreBackupResult", - json_kind: "store.backup", - description: "Create a local runtime-store backup.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::StoreRestore, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[10], - operation_id: "store.restore", - cli_path: "radroots store restore <source>", - namespace: "store", - mcp_tool: "store_restore", - rust_request: "StoreRestoreRequest", - rust_result: "StoreRestoreResult", - json_kind: "store.restore", - description: "Restore local runtime stores from a backup.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::FarmCreate, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[11], - operation_id: "farm.create", - cli_path: "radroots farm create", - namespace: "farm", - mcp_tool: "farm_create", - rust_request: "FarmCreateRequest", - rust_result: "FarmCreateResult", - json_kind: "farm.create", - description: "Create farm profile data.", - role: OperationRole::Seller, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::FarmUpdate, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[12], - operation_id: "farm.update", - cli_path: "radroots farm update", - namespace: "farm", - mcp_tool: "farm_update", - rust_request: "FarmUpdateRequest", - rust_result: "FarmUpdateResult", - json_kind: "farm.update", - description: "Update farm profile data.", - role: OperationRole::Seller, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::FarmPublish, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[13], - operation_id: "farm.publish", - cli_path: "radroots farm publish", - namespace: "farm", - mcp_tool: "farm_publish", - rust_request: "FarmPublishRequest", - rust_result: "FarmPublishResult", - json_kind: "farm.publish", - description: "Publish farm profile data.", - role: OperationRole::Seller, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::FarmGet, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[14], - operation_id: "farm.get", - cli_path: "radroots farm get", - namespace: "farm", - mcp_tool: "farm_get", - rust_request: "FarmGetRequest", - rust_result: "FarmGetResult", - json_kind: "farm.get", - description: "Get farm profile data.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::FarmList, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[15], - operation_id: "farm.list", - cli_path: "radroots farm list", - namespace: "farm", - mcp_tool: "farm_list", - rust_request: "FarmListRequest", - rust_result: "FarmListResult", - json_kind: "farm.list", - description: "List farm profile data.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ListingCreate, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[16], - operation_id: "listing.create", - cli_path: "radroots listing create", - namespace: "listing", - mcp_tool: "listing_create", - rust_request: "ListingCreateRequest", - rust_result: "ListingCreateResult", - json_kind: "listing.create", - description: "Create a listing draft.", - role: OperationRole::Seller, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ListingUpdate, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[17], - operation_id: "listing.update", - cli_path: "radroots listing update <file>", - namespace: "listing", - mcp_tool: "listing_update", - rust_request: "ListingUpdateRequest", - rust_result: "ListingUpdateResult", - json_kind: "listing.update", - description: "Update a listing.", - role: OperationRole::Seller, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ListingPublish, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[18], - operation_id: "listing.publish", - cli_path: "radroots listing publish <file>", - namespace: "listing", - mcp_tool: "listing_publish", - rust_request: "ListingPublishRequest", - rust_result: "ListingPublishResult", - json_kind: "listing.publish", - description: "Publish a listing.", - role: OperationRole::Seller, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ListingPause, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[19], - operation_id: "listing.pause", - cli_path: "radroots listing pause <file>", - namespace: "listing", - mcp_tool: "listing_pause", - rust_request: "ListingPauseRequest", - rust_result: "ListingPauseResult", - json_kind: "listing.pause", - description: "Pause a listing.", - role: OperationRole::Seller, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ListingWithdraw, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[20], - operation_id: "listing.withdraw", - cli_path: "radroots listing withdraw <file>", - namespace: "listing", - mcp_tool: "listing_withdraw", - rust_request: "ListingWithdrawRequest", - rust_result: "ListingWithdrawResult", - json_kind: "listing.withdraw", - description: "Withdraw a listing.", - role: OperationRole::Seller, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ListingGet, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[21], - operation_id: "listing.get", - cli_path: "radroots listing get <key>", - namespace: "listing", - mcp_tool: "listing_get", - rust_request: "ListingGetRequest", - rust_result: "ListingGetResult", - json_kind: "listing.get", - description: "Get a listing.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ListingList, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[22], - operation_id: "listing.list", - cli_path: "radroots listing list", - namespace: "listing", - mcp_tool: "listing_list", - rust_request: "ListingListRequest", - rust_result: "ListingListResult", - json_kind: "listing.list", - description: "List listings.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::MarketPull, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[23], - operation_id: "market.pull", - cli_path: "radroots market pull", - namespace: "market", - mcp_tool: "market_pull", - rust_request: "MarketPullRequest", - rust_result: "MarketPullResult", - json_kind: "market.pull", - description: "Pull market data into the local projection.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::MarketSearch, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[24], - operation_id: "market.search", - cli_path: "radroots market search <query>", - namespace: "market", - mcp_tool: "market_search", - rust_request: "MarketSearchRequest", - rust_result: "MarketSearchResult", - json_kind: "market.search", - description: "Search local market projections.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::MarketGet, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[25], - operation_id: "market.get", - cli_path: "radroots market get <key>", - namespace: "market", - mcp_tool: "market_get", - rust_request: "MarketGetRequest", - rust_result: "MarketGetResult", - json_kind: "market.get", - description: "Get market listing details.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::BasketCreate, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[26], - operation_id: "basket.create", - cli_path: "radroots basket create", - namespace: "basket", - mcp_tool: "basket_create", - rust_request: "BasketCreateRequest", - rust_result: "BasketCreateResult", - json_kind: "basket.create", - description: "Create a basket.", - role: OperationRole::Buyer, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::BasketGet, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[27], - operation_id: "basket.get", - cli_path: "radroots basket get <basket-id>", - namespace: "basket", - mcp_tool: "basket_get", - rust_request: "BasketGetRequest", - rust_result: "BasketGetResult", - json_kind: "basket.get", - description: "Get a basket.", - role: OperationRole::Buyer, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::BasketList, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[28], - operation_id: "basket.list", - cli_path: "radroots basket list", - namespace: "basket", - mcp_tool: "basket_list", - rust_request: "BasketListRequest", - rust_result: "BasketListResult", - json_kind: "basket.list", - description: "List baskets.", - role: OperationRole::Buyer, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::BasketItemAdd, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[29], - operation_id: "basket.item.add", - cli_path: "radroots basket item add <basket-id>", - namespace: "basket", - mcp_tool: "basket_item_add", - rust_request: "BasketItemAddRequest", - rust_result: "BasketItemAddResult", - json_kind: "basket.item.add", - description: "Add an item to a basket.", - role: OperationRole::Buyer, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::BasketItemUpdate, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[30], - operation_id: "basket.item.update", - cli_path: "radroots basket item update <basket-id>", - namespace: "basket", - mcp_tool: "basket_item_update", - rust_request: "BasketItemUpdateRequest", - rust_result: "BasketItemUpdateResult", - json_kind: "basket.item.update", - description: "Update a basket item.", - role: OperationRole::Buyer, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::BasketItemRemove, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[31], - operation_id: "basket.item.remove", - cli_path: "radroots basket item remove <basket-id>", - namespace: "basket", - mcp_tool: "basket_item_remove", - rust_request: "BasketItemRemoveRequest", - rust_result: "BasketItemRemoveResult", - json_kind: "basket.item.remove", - description: "Remove a basket item.", - role: OperationRole::Buyer, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::BasketQuote, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[32], - operation_id: "basket.quote", - cli_path: "radroots basket quote <basket-id>", - namespace: "basket", - mcp_tool: "basket_quote", - rust_request: "BasketQuoteRequest", - rust_result: "BasketQuoteResult", - json_kind: "basket.quote", - description: "Quote a basket.", - role: OperationRole::Buyer, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeProposalSubmit, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[33], - operation_id: "trade.proposal.submit", - cli_path: "radroots trade proposal submit <file>", - namespace: "trade", - mcp_tool: "trade_proposal_submit", - rust_request: "TradeProposalSubmitRequest", - rust_result: "TradeProposalSubmitResult", - json_kind: "trade.proposal.submit", - description: "Submit a trade proposal from a complete candidate draft.", - role: OperationRole::Buyer, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeRevisionPropose, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[34], - operation_id: "trade.revision.propose", - cli_path: "radroots trade revision propose <file>", - namespace: "trade", - mcp_tool: "trade_revision_propose", - rust_request: "TradeRevisionProposeRequest", - rust_result: "TradeRevisionProposeResult", - json_kind: "trade.revision.propose", - description: "Propose a complete replacement candidate for a trade.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeCandidateDecide, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[35], - operation_id: "trade.candidate.decide", - cli_path: "radroots trade candidate decide <file>", - namespace: "trade", - mcp_tool: "trade_candidate_decide", - rust_request: "TradeCandidateDecideRequest", - rust_result: "TradeCandidateDecideResult", - json_kind: "trade.candidate.decide", - description: "Decide a candidate proposal.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeCancellationSubmit, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[36], - operation_id: "trade.cancellation.submit", - cli_path: "radroots trade cancellation submit <file>", - namespace: "trade", - mcp_tool: "trade_cancellation_submit", - rust_request: "TradeCancellationSubmitRequest", - rust_result: "TradeCancellationSubmitResult", - json_kind: "trade.cancellation.submit", - description: "Submit a trade cancellation mutation.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeOperationResume, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[37], - operation_id: "trade.operation.resume", - cli_path: "radroots trade operation resume <file>", - namespace: "trade", - mcp_tool: "trade_operation_resume", - rust_request: "TradeOperationResumeRequest", - rust_result: "TradeOperationResumeResult", - json_kind: "trade.operation.resume", - description: "Resume a prepared trade operation envelope.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeGet, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[38], - operation_id: "trade.get", - cli_path: "radroots trade get <key>", - namespace: "trade", - mcp_tool: "trade_get", - rust_request: "TradeGetRequest", - rust_result: "TradeGetResult", - json_kind: "trade.get", - description: "Get SDK trade projection details.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeList, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[39], - operation_id: "trade.list", - cli_path: "radroots trade list", - namespace: "trade", - mcp_tool: "trade_list", - rust_request: "TradeListRequest", - rust_result: "TradeListResult", - json_kind: "trade.list", - description: "List SDK trade projections.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeEvidenceRefresh, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[40], - operation_id: "trade.evidence.refresh", - cli_path: "radroots trade evidence refresh <trade-id>", - namespace: "trade", - mcp_tool: "trade_evidence_refresh", - rust_request: "TradeEvidenceRefreshRequest", - rust_result: "TradeEvidenceRefreshResult", - json_kind: "trade.evidence.refresh", - description: "Refresh SDK trade evidence projections.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradeEvidenceInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[41], - operation_id: "trade.evidence.inspect", - cli_path: "radroots trade evidence inspect <trade-id>", - namespace: "trade", - mcp_tool: "trade_evidence_inspect", - rust_request: "TradeEvidenceInspectRequest", - rust_result: "TradeEvidenceInspectResult", - json_kind: "trade.evidence.inspect", - description: "Inspect protected trade evidence metadata.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradePrivateArtifactSeal, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[42], - operation_id: "trade.private_artifact.seal", - cli_path: "radroots trade private-artifact seal <trade-id>", - namespace: "trade", - mcp_tool: "trade_private_artifact_seal", - rust_request: "TradePrivateArtifactSealRequest", - rust_result: "TradePrivateArtifactSealResult", - json_kind: "trade.private_artifact.seal", - description: "Seal a protected private trade artifact.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradePrivateArtifactOpen, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[43], - operation_id: "trade.private_artifact.open", - cli_path: "radroots trade private-artifact open <artifact-id>", - namespace: "trade", - mcp_tool: "trade_private_artifact_open", - rust_request: "TradePrivateArtifactOpenRequest", - rust_result: "TradePrivateArtifactOpenResult", - json_kind: "trade.private_artifact.open", - description: "Open a protected private trade artifact.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TradePrivateArtifactDelete, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[44], - operation_id: "trade.private_artifact.delete", - cli_path: "radroots trade private-artifact delete <artifact-id>", - namespace: "trade", - mcp_tool: "trade_private_artifact_delete", - rust_request: "TradePrivateArtifactDeleteRequest", - rust_result: "TradePrivateArtifactDeleteResult", - json_kind: "trade.private_artifact.delete", - description: "Delete a protected private trade artifact.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::ValidationStatus, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[45], - operation_id: "validation.status", - cli_path: "radroots validation status", - namespace: "validation", - mcp_tool: "validation_status", - rust_request: "ValidationStatusRequest", - rust_result: "ValidationStatusResult", - json_kind: "validation.status", - description: "Inspect validation status.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::SyncStatus, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[46], - operation_id: "sync.status", - cli_path: "radroots sync status", - namespace: "sync", - mcp_tool: "sync_status", - rust_request: "SyncStatusRequest", - rust_result: "SyncStatusResult", - json_kind: "sync.status", - description: "Inspect synchronization status.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::SyncPull, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[47], - operation_id: "sync.pull", - cli_path: "radroots sync pull", - namespace: "sync", - mcp_tool: "sync_pull", - rust_request: "SyncPullRequest", - rust_result: "SyncPullResult", - json_kind: "sync.pull", - description: "Pull synchronization events.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::SyncPush, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[48], - operation_id: "sync.push", - cli_path: "radroots sync push", - namespace: "sync", - mcp_tool: "sync_push", - rust_request: "SyncPushRequest", - rust_result: "SyncPushResult", - json_kind: "sync.push", - description: "Push queued synchronization events.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::HealthInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[49], - operation_id: "health.inspect", - cli_path: "radroots health inspect", - namespace: "health", - mcp_tool: "health_inspect", - rust_request: "HealthInspectRequest", - rust_result: "HealthInspectResult", - json_kind: "health.inspect", - description: "Inspect runtime health.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TransportCapabilityList, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[50], - operation_id: "transport.capability.list", - cli_path: "radroots transport capability list", - namespace: "transport", - mcp_tool: "transport_capability_list", - rust_request: "TransportCapabilityListRequest", - rust_result: "TransportCapabilityListResult", - json_kind: "transport.capability.list", - description: "List transport capabilities.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TransportConfigInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[51], - operation_id: "transport.config.inspect", - cli_path: "radroots transport config inspect", - namespace: "transport", - mcp_tool: "transport_config_inspect", - rust_request: "TransportConfigInspectRequest", - rust_result: "TransportConfigInspectResult", - json_kind: "transport.config.inspect", - description: "Inspect transport configuration.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TransportConfigUpdate, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[52], - operation_id: "transport.config.update", - cli_path: "radroots transport config update --kind local-only", - namespace: "transport", - mcp_tool: "transport_config_update", - rust_request: "TransportConfigUpdateRequest", - rust_result: "TransportConfigUpdateResult", - json_kind: "transport.config.update", - description: "Update transport configuration.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TransportStatusInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[53], - operation_id: "transport.status.inspect", - cli_path: "radroots transport status inspect", - namespace: "transport", - mcp_tool: "transport_status_inspect", - rust_request: "TransportStatusInspectRequest", - rust_result: "TransportStatusInspectResult", - json_kind: "transport.status.inspect", - description: "Inspect transport status.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TransportDeliveryInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[54], - operation_id: "transport.delivery.inspect", - cli_path: "radroots transport delivery inspect", - namespace: "transport", - mcp_tool: "transport_delivery_inspect", - rust_request: "TransportDeliveryInspectRequest", - rust_result: "TransportDeliveryInspectResult", - json_kind: "transport.delivery.inspect", - description: "Inspect transport delivery.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::TransportDeliveryRetry, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[55], - operation_id: "transport.delivery.retry", - cli_path: "radroots transport delivery retry", - namespace: "transport", - mcp_tool: "transport_delivery_retry", - rust_request: "TransportDeliveryRetryRequest", - rust_result: "TransportDeliveryRetryResult", - json_kind: "transport.delivery.retry", - description: "Retry transport delivery.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: true, - }, - OperationSpec { - runtime_operation_id: RuntimeOperationIdV1::DiagnosticsInspect, - descriptor: radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1[56], - operation_id: "diagnostics.inspect", - cli_path: "radroots diagnostics inspect", - namespace: "diagnostics", - mcp_tool: "diagnostics_inspect", - rust_request: "DiagnosticsInspectRequest", - rust_result: "DiagnosticsInspectResult", - json_kind: "diagnostics.inspect", - description: "Inspect runtime diagnostics.", - role: OperationRole::Any, - supports_json: true, - supports_ndjson: false, - }, -]; diff --git a/src/generated/runtime_contract_target.rs b/src/generated/runtime_contract_target.rs @@ -1,61 +0,0 @@ -// @generated by radroots_sdk_xtask. Do not edit by hand. - -target_operation_contracts! { - ProfileInspect => (ProfileInspectRequest, ProfileInspectResult, "profile.inspect"), - ProfileReset => (ProfileResetRequest, ProfileResetResult, "profile.reset"), - AccountCreate => (AccountCreateRequest, AccountCreateResult, "account.create"), - AccountImport => (AccountImportRequest, AccountImportResult, "account.import"), - AccountSelect => (AccountSelectRequest, AccountSelectResult, "account.select"), - AccountList => (AccountListRequest, AccountListResult, "account.list"), - AccountRemove => (AccountRemoveRequest, AccountRemoveResult, "account.remove"), - SignerStatus => (SignerStatusRequest, SignerStatusResult, "signer.status"), - StoreInspect => (StoreInspectRequest, StoreInspectResult, "store.inspect"), - StoreBackup => (StoreBackupRequest, StoreBackupResult, "store.backup"), - StoreRestore => (StoreRestoreRequest, StoreRestoreResult, "store.restore"), - FarmCreate => (FarmCreateRequest, FarmCreateResult, "farm.create"), - FarmUpdate => (FarmUpdateRequest, FarmUpdateResult, "farm.update"), - FarmPublish => (FarmPublishRequest, FarmPublishResult, "farm.publish"), - FarmGet => (FarmGetRequest, FarmGetResult, "farm.get"), - FarmList => (FarmListRequest, FarmListResult, "farm.list"), - ListingCreate => (ListingCreateRequest, ListingCreateResult, "listing.create"), - ListingUpdate => (ListingUpdateRequest, ListingUpdateResult, "listing.update"), - ListingPublish => (ListingPublishRequest, ListingPublishResult, "listing.publish"), - ListingPause => (ListingPauseRequest, ListingPauseResult, "listing.pause"), - ListingWithdraw => (ListingWithdrawRequest, ListingWithdrawResult, "listing.withdraw"), - ListingGet => (ListingGetRequest, ListingGetResult, "listing.get"), - ListingList => (ListingListRequest, ListingListResult, "listing.list"), - MarketPull => (MarketPullRequest, MarketPullResult, "market.pull"), - MarketSearch => (MarketSearchRequest, MarketSearchResult, "market.search"), - MarketGet => (MarketGetRequest, MarketGetResult, "market.get"), - BasketCreate => (BasketCreateRequest, BasketCreateResult, "basket.create"), - BasketGet => (BasketGetRequest, BasketGetResult, "basket.get"), - BasketList => (BasketListRequest, BasketListResult, "basket.list"), - BasketItemAdd => (BasketItemAddRequest, BasketItemAddResult, "basket.item.add"), - BasketItemUpdate => (BasketItemUpdateRequest, BasketItemUpdateResult, "basket.item.update"), - BasketItemRemove => (BasketItemRemoveRequest, BasketItemRemoveResult, "basket.item.remove"), - BasketQuote => (BasketQuoteRequest, BasketQuoteResult, "basket.quote"), - TradeProposalSubmit => (TradeProposalSubmitRequest, TradeProposalSubmitResult, "trade.proposal.submit"), - TradeRevisionPropose => (TradeRevisionProposeRequest, TradeRevisionProposeResult, "trade.revision.propose"), - TradeCandidateDecide => (TradeCandidateDecideRequest, TradeCandidateDecideResult, "trade.candidate.decide"), - TradeCancellationSubmit => (TradeCancellationSubmitRequest, TradeCancellationSubmitResult, "trade.cancellation.submit"), - TradeOperationResume => (TradeOperationResumeRequest, TradeOperationResumeResult, "trade.operation.resume"), - TradeGet => (TradeGetRequest, TradeGetResult, "trade.get"), - TradeList => (TradeListRequest, TradeListResult, "trade.list"), - TradeEvidenceRefresh => (TradeEvidenceRefreshRequest, TradeEvidenceRefreshResult, "trade.evidence.refresh"), - TradeEvidenceInspect => (TradeEvidenceInspectRequest, TradeEvidenceInspectResult, "trade.evidence.inspect"), - TradePrivateArtifactSeal => (TradePrivateArtifactSealRequest, TradePrivateArtifactSealResult, "trade.private_artifact.seal"), - TradePrivateArtifactOpen => (TradePrivateArtifactOpenRequest, TradePrivateArtifactOpenResult, "trade.private_artifact.open"), - TradePrivateArtifactDelete => (TradePrivateArtifactDeleteRequest, TradePrivateArtifactDeleteResult, "trade.private_artifact.delete"), - ValidationStatus => (ValidationStatusRequest, ValidationStatusResult, "validation.status"), - SyncStatus => (SyncStatusRequest, SyncStatusResult, "sync.status"), - SyncPull => (SyncPullRequest, SyncPullResult, "sync.pull"), - SyncPush => (SyncPushRequest, SyncPushResult, "sync.push"), - HealthInspect => (HealthInspectRequest, HealthInspectResult, "health.inspect"), - TransportCapabilityList => (TransportCapabilityListRequest, TransportCapabilityListResult, "transport.capability.list"), - TransportConfigInspect => (TransportConfigInspectRequest, TransportConfigInspectResult, "transport.config.inspect"), - TransportConfigUpdate => (TransportConfigUpdateRequest, TransportConfigUpdateResult, "transport.config.update"), - TransportStatusInspect => (TransportStatusInspectRequest, TransportStatusInspectResult, "transport.status.inspect"), - TransportDeliveryInspect => (TransportDeliveryInspectRequest, TransportDeliveryInspectResult, "transport.delivery.inspect"), - TransportDeliveryRetry => (TransportDeliveryRetryRequest, TransportDeliveryRetryResult, "transport.delivery.retry"), - DiagnosticsInspect => (DiagnosticsInspectRequest, DiagnosticsInspectResult, "diagnostics.inspect"), -} diff --git a/src/lib.rs b/src/lib.rs @@ -1,8 +1,3 @@ #![forbid(unsafe_code)] pub mod cli; -pub mod ops; -pub mod out; -pub mod registry; -pub mod runtime; -pub mod view; diff --git a/src/main.rs b/src/main.rs @@ -1,776 +1,117 @@ #![forbid(unsafe_code)] -use std::io::Write; use std::process::ExitCode; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; use clap::Parser; -use radroots_cli::{ops, registry, runtime}; -use radroots_runtime_contract_v1::{ - ExecutionModeV1, RUNTIME_CONTRACT_NAME_V1, RUNTIME_CONTRACT_VERSION_V1, RuntimeContractErrorV1, - RuntimeRequestEnvelopeV1, +use radroots_cli::cli::{ + HealthCommand, ProfileCommand, TargetCliArgs, TargetCommand, TargetOutputFormat, }; -use serde_json::json; +use serde_json::{Value, json}; -use radroots_cli::cli::input::runtime_invocation_args_from_target; -use radroots_cli::cli::{TargetCliArgs, TargetOutputFormat}; -use radroots_cli::ops::exec::{ - BasketOperationService, CoreOperationService, FarmOperationService, ListingOperationService, - MarketOperationService, RuntimeOperationService, TradeOperationService, - ValidationOperationService, -}; -use radroots_cli::ops::{ - OperationAdapter, OperationAdapterError, OperationNetworkMode, OperationOutputFormat, - OperationRequest, OperationRequestPayload, OperationResultPayload, OperationService, - TargetOperationRequest, -}; -use radroots_cli::out::envelope::{CliExitCode, EnvelopeContext, OutputEnvelope, OutputError}; -use radroots_cli::out::terminal::errors::terminal_error_document; -use radroots_cli::out::terminal::registry::TerminalRendererRegistryError; -use radroots_cli::out::terminal::registry::terminal_renderer_registry; -use radroots_cli::out::terminal::renderer::{ - TerminalRenderContext, TerminalVerbosity, render_terminal_document, -}; -use radroots_cli::registry::{ - NetworkRequirement, OPERATION_REGISTRY, network_requirement, - requires_delivery_capable_transport_profile, requires_local_signer_mode, -}; -use radroots_cli::runtime::config::{ - OutputFormat as RuntimeOutputFormat, RuntimeConfig, SignerBackend, TransportProfileKind, - Verbosity, -}; -use radroots_cli::runtime::logging::initialize_logging; - -static REQUEST_SEQUENCE: AtomicU64 = AtomicU64::new(0); +const OUTPUT_SCHEMA: &str = "radroots.cli.output.v1"; -fn main() -> ExitCode { - match run() { - Ok(exit_code) => exit_code, - Err(error) => { - let _ = writeln!(std::io::stderr(), "{error}"); - error.exit_code() - } - } -} - -fn run() -> Result<ExitCode, runtime::RuntimeError> { - debug_assert!(registry::registry_linkage_is_valid()); - debug_assert!(ops::adapter_registry_linkage_is_valid()); +#[tokio::main] +async fn main() -> ExitCode { let args = TargetCliArgs::parse(); - let mut request = - TargetOperationRequest::from_target_args(&args).map_err(operation_config_error)?; - let pre_runtime_render_config = - render_config_from_target_args(&args, args.format.unwrap_or(TargetOutputFormat::Terminal)); - if let Err(error) = validate_pre_runtime_request_contract(&request) { - let envelope = failure_envelope(&request, error); - return render_envelope(&envelope, &pre_runtime_render_config); - } - let config = match RuntimeConfig::from_system(&runtime_invocation_args_from_target(&args)) { - Ok(config) => config, - Err(error) => { - let envelope = runtime_config_failure_envelope(&request, error); - return render_envelope(&envelope, &pre_runtime_render_config); - } - }; - request.set_output_format(OperationOutputFormat::from(config.output.format)); - let runtime_render_config = render_config_from_runtime(&config); - let logging = match initialize_logging(&config.logging) { - Ok(logging) => logging, - Err(error) => { - let envelope = runtime_config_failure_envelope(&request, error.into()); - return render_envelope(&envelope, &runtime_render_config); - } - }; - let envelope = match validate_request_contract(&request, &config) { - Ok(()) => execute_request(request, &config, &logging), - Err(error) => failure_envelope(&request, error), - }; - render_envelope(&envelope, &runtime_render_config) -} - -fn execute_request( - request: TargetOperationRequest, - config: &RuntimeConfig, - logging: &runtime::logging::LoggingState, -) -> OutputEnvelope { - match request { - TargetOperationRequest::ProfileReset(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::ProfileInspect(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::AccountCreate(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::AccountImport(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::AccountList(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::AccountRemove(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::AccountSelect(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::StoreInspect(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::StoreBackup(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::StoreRestore(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - TargetOperationRequest::SignerStatus(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::TransportCapabilityList(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::TransportConfigInspect(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::TransportConfigUpdate(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::TransportStatusInspect(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::TransportDeliveryInspect(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::TransportDeliveryRetry(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::SyncStatus(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::SyncPull(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::SyncPush(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::DiagnosticsInspect(request) => { - execute_with(RuntimeOperationService::new(config), request) - } - TargetOperationRequest::FarmCreate(request) => { - execute_with(FarmOperationService::new(config), request) - } - TargetOperationRequest::FarmGet(request) => { - execute_with(FarmOperationService::new(config), request) - } - TargetOperationRequest::FarmUpdate(request) => { - execute_with(FarmOperationService::new(config), request) - } - TargetOperationRequest::FarmList(request) => { - execute_with(FarmOperationService::new(config), request) - } - TargetOperationRequest::FarmPublish(request) => { - execute_with(FarmOperationService::new(config), request) - } - TargetOperationRequest::ListingCreate(request) => { - execute_with(ListingOperationService::new(config), request) - } - TargetOperationRequest::ListingGet(request) => { - execute_with(ListingOperationService::new(config), request) - } - TargetOperationRequest::ListingList(request) => { - execute_with(ListingOperationService::new(config), request) - } - TargetOperationRequest::ListingUpdate(request) => { - execute_with(ListingOperationService::new(config), request) - } - TargetOperationRequest::ListingPublish(request) => { - execute_with(ListingOperationService::new(config), request) - } - TargetOperationRequest::ListingPause(request) => { - execute_with(ListingOperationService::new(config), request) - } - TargetOperationRequest::ListingWithdraw(request) => { - execute_with(ListingOperationService::new(config), request) - } - TargetOperationRequest::MarketPull(request) => { - execute_with(MarketOperationService::new(config), request) - } - TargetOperationRequest::MarketSearch(request) => { - execute_with(MarketOperationService::new(config), request) - } - TargetOperationRequest::MarketGet(request) => { - execute_with(MarketOperationService::new(config), request) - } - TargetOperationRequest::BasketCreate(request) => { - execute_with(BasketOperationService::new(config), request) - } - TargetOperationRequest::BasketGet(request) => { - execute_with(BasketOperationService::new(config), request) - } - TargetOperationRequest::BasketList(request) => { - execute_with(BasketOperationService::new(config), request) - } - TargetOperationRequest::BasketItemAdd(request) => { - execute_with(BasketOperationService::new(config), request) - } - TargetOperationRequest::BasketItemUpdate(request) => { - execute_with(BasketOperationService::new(config), request) - } - TargetOperationRequest::BasketItemRemove(request) => { - execute_with(BasketOperationService::new(config), request) - } - TargetOperationRequest::BasketQuote(request) => { - execute_with(BasketOperationService::new(config), request) - } - TargetOperationRequest::TradeProposalSubmit(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradeRevisionPropose(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradeCandidateDecide(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradeCancellationSubmit(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradeOperationResume(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradeGet(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradeList(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradeEvidenceRefresh(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradeEvidenceInspect(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradePrivateArtifactSeal(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradePrivateArtifactOpen(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::TradePrivateArtifactDelete(request) => { - execute_with(TradeOperationService::new(config), request) - } - TargetOperationRequest::ValidationStatus(request) => { - execute_with(ValidationOperationService::new(config), request) - } - TargetOperationRequest::HealthInspect(request) => { - execute_with(CoreOperationService::new(config, logging), request) - } - } -} - -fn execute_with<S, P>(service: S, request: OperationRequest<P>) -> OutputEnvelope -where - S: OperationService<P>, - P: OperationRequestPayload, - S::Result: OperationResultPayload, -{ - let operation_id = request.operation_id().to_owned(); - let envelope_context = request - .context - .envelope_context(next_request_id(&operation_id)); - match OperationAdapter::new(service) - .execute(request) - .and_then(|result| result.to_envelope(envelope_context.clone())) - { - Ok(envelope) => envelope, - Err(error) => { - OutputEnvelope::failure(operation_id, error.to_output_error(), envelope_context) - } - } -} - -fn validate_request_contract( - request: &TargetOperationRequest, - config: &RuntimeConfig, -) -> Result<(), OperationAdapterError> { - validate_pre_runtime_request_contract(request)?; - validate_transport_profile_contract(request, config)?; - validate_signer_mode_contract(request, config)?; - validate_network_contract(request, config)?; - Ok(()) -} - -fn validate_pre_runtime_request_contract( - request: &TargetOperationRequest, -) -> Result<(), OperationAdapterError> { - let spec = request.spec(); - if matches!( - request.context().output_format, - OperationOutputFormat::Ndjson - ) && !spec.supports_ndjson - { - return Err(OperationAdapterError::InvalidInput { - operation_id: spec.operation_id.to_owned(), - message: format!("`{}` does not support --format ndjson", spec.cli_path), - }); - } - if request.context().dry_run && !spec.supports_dry_run() { - return Err(OperationAdapterError::InvalidInput { - operation_id: spec.operation_id.to_owned(), - message: format!("`{}` does not support --dry-run", spec.cli_path), - }); - } - validate_idempotency_contract(request)?; - if !request.context().dry_run - && spec.requires_approval() - && !request.context().has_operator_approval() - { - return Err(OperationAdapterError::approval_required(spec.operation_id)); - } - Ok(()) -} - -fn validate_idempotency_contract( - request: &TargetOperationRequest, -) -> Result<(), OperationAdapterError> { - let spec = request.spec(); - RuntimeRequestEnvelopeV1 { - contract: RUNTIME_CONTRACT_NAME_V1.to_owned(), - contract_version: RUNTIME_CONTRACT_VERSION_V1, - operation_id: spec.runtime_operation_id, - operation_schema_version: spec.descriptor.schema_version, - request_id: "cli-pre-runtime-contract-validation".to_owned(), - actor_pubkey: None, - idempotency_key: request.context().idempotency_key.clone(), - approval: request.context().approval_proof.clone(), - execution_mode: ExecutionModeV1::Embedded, - request_json: "{}".to_owned(), - } - .validate() - .map_err(|error| idempotency_contract_error(spec.operation_id, error)) -} - -fn idempotency_contract_error( - operation_id: &str, - error: RuntimeContractErrorV1, -) -> OperationAdapterError { - OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message: error.to_string(), - } -} - -fn validate_signer_mode_contract( - request: &TargetOperationRequest, - config: &RuntimeConfig, -) -> Result<(), OperationAdapterError> { - let spec = request.spec(); - if matches!(config.signer.backend, SignerBackend::Myc) - && requires_local_signer_mode_for_transport_profile(spec.operation_id, config) - { - return Err(OperationAdapterError::SignerModeDeferred { - operation_id: spec.operation_id.to_owned(), - message: format!( - "`{}` cannot run with signer mode `myc`; use signer mode `local`", - spec.cli_path - ), - }); - } - Ok(()) -} - -fn validate_network_contract( - request: &TargetOperationRequest, - config: &RuntimeConfig, -) -> Result<(), OperationAdapterError> { - let spec = request.spec(); - let requirement = network_requirement(spec.operation_id); - match request.context().network_mode { - OperationNetworkMode::Default => Ok(()), - OperationNetworkMode::Offline => { - if allows_offline_local_mutation(spec.operation_id) { - return Ok(()); - } - if let NetworkRequirement::External { - dry_run_requires_network, - } = requirement - && (!request.context().dry_run || dry_run_requires_network) - { - return Err(OperationAdapterError::OfflineForbidden { - operation_id: spec.operation_id.to_owned(), - message: format!( - "`{}` requires transport, provider, or workflow network access", - spec.cli_path - ), - }); - } - Ok(()) - } - OperationNetworkMode::Online => { - if let NetworkRequirement::External { - dry_run_requires_network, - } = requirement - && (!request.context().dry_run || dry_run_requires_network) - && requires_pre_runtime_transport_target(spec.operation_id) - && let Some(reason) = transport_profile_delivery_unavailable_reason(config) - { - return Err(OperationAdapterError::NetworkUnavailable { - operation_id: spec.operation_id.to_owned(), - message: format!( - "`{}` requires a delivery-capable transport profile for online execution: {reason}", - spec.cli_path, - ), - }); - } - Ok(()) - } - } -} - -fn requires_local_signer_mode_for_transport_profile( - operation_id: &str, - config: &RuntimeConfig, -) -> bool { - let _ = config; - requires_local_signer_mode(operation_id) -} - -fn requires_pre_runtime_transport_target(operation_id: &str) -> bool { - !requires_delivery_capable_transport_profile(operation_id) -} - -fn allows_offline_local_mutation(operation_id: &str) -> bool { - matches!(operation_id, "listing.publish") -} - -fn validate_transport_profile_contract( - request: &TargetOperationRequest, - config: &RuntimeConfig, -) -> Result<(), OperationAdapterError> { - let spec = request.spec(); - if !requires_delivery_capable_transport_profile(spec.operation_id) { - return Ok(()); - } - if request.context().dry_run - || matches!( - request.context().network_mode, - OperationNetworkMode::Offline - ) - { - return Ok(()); - } - if matches!( - config.transport.profile, - TransportProfileKind::LocalOnly | TransportProfileKind::Reticulum - ) { - return Err(OperationAdapterError::NetworkUnavailable { - operation_id: spec.operation_id.to_owned(), - message: format!( - "`{}` requires a delivery-capable transport profile; active profile `{}` cannot deliver", - spec.cli_path, - config.transport.profile.as_str() - ), - }); - } - if matches!( - config.transport.profile, - TransportProfileKind::Nostr | TransportProfileKind::MultiTarget - ) && config.transport.nostr_relay_urls.is_empty() - { - return Err(OperationAdapterError::NetworkUnavailable { - operation_id: spec.operation_id.to_owned(), - message: format!( - "`{}` requires at least one configured Nostr relay in the active transport profile", - spec.cli_path - ), - }); - } - Ok(()) -} - -fn transport_profile_delivery_unavailable_reason(config: &RuntimeConfig) -> Option<String> { - match config.transport.profile { - TransportProfileKind::Nostr | TransportProfileKind::MultiTarget => { - config.transport.nostr_relay_urls.is_empty().then(|| { - format!( - "active {} transport profile has no configured Nostr relay targets", - config.transport.profile.as_str() - ) - }) - } - TransportProfileKind::LocalOnly => { - Some("active local_only transport profile cannot deliver".to_owned()) - } - TransportProfileKind::Reticulum => { - Some("active reticulum transport profile cannot deliver".to_owned()) - } - } -} - -fn failure_envelope( - request: &TargetOperationRequest, - error: OperationAdapterError, -) -> OutputEnvelope { - OutputEnvelope::failure( - request.operation_id(), - error.to_output_error(), - request - .context() - .envelope_context(next_request_id(request.operation_id())), - ) -} - -fn runtime_config_failure_envelope( - request: &TargetOperationRequest, - error: runtime::RuntimeError, -) -> OutputEnvelope { - OutputEnvelope::failure( - request.operation_id(), - OutputError::new( - "invalid_input", - error.to_string(), - CliExitCode::InvalidInput, + let operation_id = args.command.operation_id(); + let format = args.format.unwrap_or(TargetOutputFormat::Terminal); + + let result = execute(&args.command).await; + let (status, data, error, exit_code) = match result { + Ok(data) => ("ok", data, Value::Null, ExitCode::SUCCESS), + Err(message) => ( + "error", + Value::Null, + json!({ + "code": "unsupported_operation", + "message": message, + "recovery": "use `radroots health inspect` to inspect the release-v1 host surface" + }), + ExitCode::from(2), ), - request - .context() - .envelope_context(next_request_id(request.operation_id())), - ) -} - -fn next_request_id(operation_id: &str) -> String { - let sequence = REQUEST_SEQUENCE.fetch_add(1, Ordering::Relaxed); - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - format!( - "req_{}_{}_{}_{}", - operation_id.replace('.', "_"), - std::process::id(), - timestamp, - sequence - ) -} - -#[derive(Debug, Clone)] -struct EnvelopeRenderConfig { - format: RenderOutputFormat, - terminal: TerminalRenderContext, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RenderOutputFormat { - Terminal, - Json, - Ndjson, -} - -fn render_config_from_target_args( - args: &TargetCliArgs, - format: TargetOutputFormat, -) -> EnvelopeRenderConfig { - EnvelopeRenderConfig { - format: match format { - TargetOutputFormat::Terminal => RenderOutputFormat::Terminal, - TargetOutputFormat::Json => RenderOutputFormat::Json, - TargetOutputFormat::Ndjson => RenderOutputFormat::Ndjson, - }, - terminal: TerminalRenderContext { - verbosity: terminal_verbosity_from_flags(args.quiet, args.verbose, args.trace), - width: 80, - dry_run: args.dry_run, - }, - } -} - -fn render_config_from_runtime(config: &RuntimeConfig) -> EnvelopeRenderConfig { - EnvelopeRenderConfig { - format: match config.output.format { - RuntimeOutputFormat::Terminal => RenderOutputFormat::Terminal, - RuntimeOutputFormat::Json => RenderOutputFormat::Json, - RuntimeOutputFormat::Ndjson => RenderOutputFormat::Ndjson, - }, - terminal: TerminalRenderContext { - verbosity: terminal_verbosity_from_runtime(config.output.verbosity), - width: 80, - dry_run: config.output.dry_run, - }, - } -} - -fn terminal_verbosity_from_flags(quiet: bool, verbose: bool, trace: bool) -> TerminalVerbosity { - if trace { - TerminalVerbosity::Trace - } else if verbose { - TerminalVerbosity::Verbose - } else if quiet { - TerminalVerbosity::Quiet - } else { - TerminalVerbosity::Normal - } -} - -fn terminal_verbosity_from_runtime(verbosity: Verbosity) -> TerminalVerbosity { - match verbosity { - Verbosity::Quiet => TerminalVerbosity::Quiet, - Verbosity::Normal => TerminalVerbosity::Normal, - Verbosity::Verbose => TerminalVerbosity::Verbose, - Verbosity::Trace => TerminalVerbosity::Trace, - } -} + }; -fn render_envelope( - envelope: &OutputEnvelope, - config: &EnvelopeRenderConfig, -) -> Result<ExitCode, runtime::RuntimeError> { - match config.format { - RenderOutputFormat::Terminal => render_terminal_envelope(envelope, &config.terminal), - RenderOutputFormat::Json => { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - serde_json::to_writer_pretty(&mut handle, envelope)?; - writeln!(handle)?; - Ok(envelope_exit_code(envelope)) - } - RenderOutputFormat::Ndjson => { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - for frame in envelope.to_ndjson_frames() { - serde_json::to_writer(&mut handle, &frame)?; - writeln!(handle)?; + let envelope = json!({ + "schema": OUTPUT_SCHEMA, + "operation_id": operation_id, + "status": status, + "data": data, + "error": error, + }); + render(format, operation_id, status, &envelope); + exit_code +} + +async fn execute(command: &TargetCommand) -> Result<Value, String> { + match command { + TargetCommand::Profile(args) if matches!(args.command, ProfileCommand::Inspect) => { + inspect_local_profile().await + } + TargetCommand::Health(args) if matches!(args.command, HealthCommand::Inspect) => { + inspect_health().await + } + _ => Err(format!( + "`{}` is not available in the crates-release-v1 CLI host", + command.operation_id() + )), + } +} + +async fn inspect_local_profile() -> Result<Value, String> { + let client = radroots::client::memory() + .build() + .map_err(|error| format!("failed to construct the local SDK client: {error}"))?; + let profile = radroots::client::local_only(); + client + .close() + .await + .map_err(|error| format!("failed to close the local SDK client: {error}"))?; + + Ok(json!({ + "profile": "local_only", + "network_enabled": !profile.is_local_only(), + "storage": "memory", + "artifact_graph": "registry" + })) +} + +async fn inspect_health() -> Result<Value, String> { + let profile = inspect_local_profile().await?; + Ok(json!({ + "ready": true, + "release": "crates-v1", + "profile": profile + })) +} + +fn render(format: TargetOutputFormat, operation_id: &str, status: &str, envelope: &Value) { + match format { + TargetOutputFormat::Json | TargetOutputFormat::Ndjson => println!("{envelope}"), + TargetOutputFormat::Terminal => { + if status == "ok" { + println!("{operation_id}: ready"); + } else { + let message = envelope["error"]["message"] + .as_str() + .unwrap_or("operation failed"); + eprintln!("{operation_id}: {message}"); } - Ok(envelope_exit_code(envelope)) } } } -fn render_terminal_envelope( - envelope: &OutputEnvelope, - cx: &TerminalRenderContext, -) -> Result<ExitCode, runtime::RuntimeError> { - let registry = terminal_renderer_registry(); - if let Err(error) = registry.validate_against_operations(OPERATION_REGISTRY) { - let failure = terminal_registry_failure_envelope(envelope, error); - render_terminal_failure_document(&failure, cx)?; - return Ok(envelope_exit_code(&failure)); - } - let renderer = match registry.renderer_for(envelope.operation_id.as_str()) { - Ok(renderer) => renderer, - Err(error) => { - let failure = terminal_registry_failure_envelope(envelope, error); - render_terminal_failure_document(&failure, cx)?; - return Ok(envelope_exit_code(&failure)); - } - }; - let document = renderer.render(envelope, cx); - let rendered = render_terminal_document(&document, cx); - if envelope.errors.is_empty() { - let stdout = std::io::stdout(); - let mut handle = stdout.lock(); - writeln!(handle, "{rendered}")?; - } else { - let stderr = std::io::stderr(); - let mut handle = stderr.lock(); - writeln!(handle, "{rendered}")?; - } - Ok(envelope_exit_code(envelope)) -} - -fn render_terminal_failure_document( - envelope: &OutputEnvelope, - cx: &TerminalRenderContext, -) -> Result<(), runtime::RuntimeError> { - let document = terminal_error_document(envelope); - let rendered = render_terminal_document(&document, cx); - let stderr = std::io::stderr(); - let mut handle = stderr.lock(); - writeln!(handle, "{rendered}")?; - Ok(()) -} - -fn terminal_registry_failure_envelope( - envelope: &OutputEnvelope, - error: TerminalRendererRegistryError, -) -> OutputEnvelope { - let mut output_error = OutputError::new( - "internal_error", - format!( - "terminal renderer registry invariant failed for {}", - envelope.operation_id - ), - CliExitCode::InternalError, - ); - output_error.detail = Some(json!({ - "class": "internal", - "operation_id": envelope.operation_id, - "violations": error.violations().iter().map(|violation| { - json!({ - "kind": violation.kind(), - "operation_id": violation.operation_id(), - "count": violation.count(), - }) - }).collect::<Vec<_>>(), - })); - let mut context = EnvelopeContext::new(envelope.request_id.clone(), envelope.dry_run); - context.output_format = envelope.output_format; - context.correlation_id = envelope.correlation_id.clone(); - context.idempotency_key = envelope.idempotency_key.clone(); - context.actor = envelope.actor.clone(); - OutputEnvelope::failure(envelope.operation_id.clone(), output_error, context) -} - -fn envelope_exit_code(envelope: &OutputEnvelope) -> ExitCode { - envelope - .errors - .first() - .map(|error| ExitCode::from(error.exit_code)) - .unwrap_or_else(|| ExitCode::from(0)) -} - -fn operation_config_error(error: OperationAdapterError) -> runtime::RuntimeError { - runtime::RuntimeError::Config(error.to_string()) -} - #[cfg(test)] mod tests { - use serde_json::Value; - - use radroots_cli::out::envelope::{EnvelopeContext, OutputEnvelope, OutputStatus}; - use radroots_cli::out::terminal::errors::terminal_error_document; - use radroots_cli::out::terminal::registry::TerminalRendererRegistry; - use radroots_cli::out::terminal::renderer::{TerminalRenderContext, render_terminal_document}; - use super::*; #[test] - fn terminal_registry_failure_envelope_is_structured_internal_error() { - let original = OutputEnvelope::success( - "profile.inspect", - Value::Null, - EnvelopeContext::new("req_test", false), - ); - let error = TerminalRendererRegistry::new() - .validate_operation_ids(["profile.inspect"]) - .expect_err("missing renderer should fail"); - let failure = terminal_registry_failure_envelope(&original, error); - - assert_eq!(failure.status, OutputStatus::Error); - assert_eq!(failure.reason_code.as_deref(), Some("internal_error")); - assert_eq!(failure.errors[0].code, "internal_error"); - let detail = failure.errors[0].detail.as_ref().expect("error detail"); - assert_eq!(detail["violations"][0]["kind"], "missing"); - - let rendered = render_terminal_document( - &terminal_error_document(&failure), - &TerminalRenderContext::default(), - ); - assert!(rendered.starts_with("✕ Command failed\n")); - assert!( - rendered.contains( - "Reason terminal renderer registry invariant failed for profile.inspect" - ) - ); - assert!(!rendered.contains("RuntimeError::Config")); + fn unsupported_operations_fail_closed() { + let args = TargetCliArgs::try_parse_from(["radroots", "account", "list"]) + .expect("valid resource command"); + let runtime = tokio::runtime::Runtime::new().expect("runtime"); + let error = runtime + .block_on(execute(&args.command)) + .expect_err("unsupported operation"); + assert!(error.contains("account.list")); } } diff --git a/src/ops/adapter.rs b/src/ops/adapter.rs @@ -1,34 +0,0 @@ -use super::error::OperationAdapterError; -use super::request::{OperationRequest, OperationRequestPayload}; -use super::result::{OperationResult, OperationResultPayload}; - -pub trait OperationService<P: OperationRequestPayload> { - type Result: OperationResultPayload; - - fn execute( - &self, - request: OperationRequest<P>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError>; -} - -#[derive(Debug, Clone)] -pub struct OperationAdapter<S> { - service: S, -} - -impl<S> OperationAdapter<S> { - pub fn new(service: S) -> Self { - Self { service } - } - - pub fn execute<P>( - &self, - request: OperationRequest<P>, - ) -> Result<OperationResult<<S as OperationService<P>>::Result>, OperationAdapterError> - where - P: OperationRequestPayload, - S: OperationService<P>, - { - self.service.execute(request) - } -} diff --git a/src/ops/context.rs b/src/ops/context.rs @@ -1,209 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use radroots_runtime_contract_v1::{ApprovalProofV1, RuntimeContractErrorV1}; - -use crate::cli::{TargetCliArgs, TargetOutputFormat}; -use crate::ops::OperationAdapterError; -use crate::out::envelope::{EnvelopeActor, EnvelopeContext, OutputFormat as EnvelopeOutputFormat}; -use crate::runtime::config::OutputFormat as RuntimeOutputFormat; - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum OperationOutputFormat { - #[default] - Terminal, - Json, - Ndjson, -} - -impl From<TargetOutputFormat> for OperationOutputFormat { - fn from(format: TargetOutputFormat) -> Self { - match format { - TargetOutputFormat::Terminal => Self::Terminal, - TargetOutputFormat::Json => Self::Json, - TargetOutputFormat::Ndjson => Self::Ndjson, - } - } -} - -impl From<RuntimeOutputFormat> for OperationOutputFormat { - fn from(format: RuntimeOutputFormat) -> Self { - match format { - RuntimeOutputFormat::Terminal => Self::Terminal, - RuntimeOutputFormat::Json => Self::Json, - RuntimeOutputFormat::Ndjson => Self::Ndjson, - } - } -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum OperationNetworkMode { - #[default] - Default, - Offline, - Online, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum OperationInputMode { - #[default] - PromptingAllowed, - NoInput, -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct OperationContext { - pub output_format: OperationOutputFormat, - pub account_id: Option<String>, - pub network_mode: OperationNetworkMode, - pub dry_run: bool, - pub idempotency_key: Option<String>, - pub correlation_id: Option<String>, - pub approval_proof: Option<ApprovalProofV1>, - pub yes: bool, - pub input_mode: OperationInputMode, - pub quiet: bool, - pub verbose: bool, - pub trace: bool, -} - -impl OperationContext { - pub fn from_target_args( - args: &TargetCliArgs, - operation_id: &'static str, - ) -> Result<Self, OperationAdapterError> { - Ok(Self { - output_format: args - .format - .map(OperationOutputFormat::from) - .unwrap_or_default(), - account_id: args.account_id.clone(), - network_mode: if args.offline { - OperationNetworkMode::Offline - } else if args.online { - OperationNetworkMode::Online - } else { - OperationNetworkMode::Default - }, - dry_run: args.dry_run, - idempotency_key: args.idempotency_key.clone(), - correlation_id: args.correlation_id.clone(), - approval_proof: parse_approval_proof(args.approval_proof.as_deref(), operation_id)?, - yes: args.yes, - input_mode: if args.no_input { - OperationInputMode::NoInput - } else { - OperationInputMode::PromptingAllowed - }, - quiet: args.quiet, - verbose: args.verbose, - trace: args.trace, - }) - } - - pub fn envelope_context(&self, request_id: impl Into<String>) -> EnvelopeContext { - let mut context = EnvelopeContext::new(request_id, self.dry_run); - context.output_format = match self.output_format { - OperationOutputFormat::Terminal => EnvelopeOutputFormat::Terminal, - OperationOutputFormat::Json => EnvelopeOutputFormat::Json, - OperationOutputFormat::Ndjson => EnvelopeOutputFormat::Ndjson, - }; - context.correlation_id = self.correlation_id.clone(); - context.idempotency_key = self.idempotency_key.clone(); - context.actor = self.account_id.as_ref().map(|account_id| EnvelopeActor { - account_id: account_id.clone(), - role: "account".to_owned(), - }); - context - } - - pub fn has_operator_approval(&self) -> bool { - self.yes || self.approval_proof.is_some() - } -} - -fn parse_approval_proof( - value: Option<&str>, - operation_id: &'static str, -) -> Result<Option<ApprovalProofV1>, OperationAdapterError> { - let Some(value) = value.map(str::trim) else { - return Ok(None); - }; - if value.is_empty() { - return Err(invalid_approval( - operation_id, - "approval proof JSON must not be empty", - )); - } - let proof = serde_json::from_str::<ApprovalProofV1>(value).map_err(|error| { - invalid_approval( - operation_id, - format!("approval proof must be valid ApprovalProofV1 JSON: {error}"), - ) - })?; - validate_approval_proof(&proof, operation_id)?; - Ok(Some(proof)) -} - -fn validate_approval_proof( - proof: &ApprovalProofV1, - operation_id: &'static str, -) -> Result<(), OperationAdapterError> { - if proof.operation_id.as_str() != operation_id { - return Err(invalid_approval( - operation_id, - format!( - "approval proof operation `{}` does not match request operation `{operation_id}`", - proof.operation_id.as_str() - ), - )); - } - proof - .request_digest - .validate() - .map_err(|error| invalid_approval(operation_id, runtime_contract_error(error)))?; - for (field, value) in [ - ("proof_id", proof.proof_id.as_str()), - ("signer_pubkey", proof.signer_pubkey.as_str()), - ("signature", proof.signature.as_str()), - ] { - if value.trim().is_empty() { - return Err(invalid_approval( - operation_id, - format!("approval proof `{field}` must not be empty"), - )); - } - } - if proof.signed_at_unix_ms == 0 { - return Err(invalid_approval( - operation_id, - "approval proof `signed_at_unix_ms` must be nonzero", - )); - } - if let Some(expires_at_unix_ms) = proof.expires_at_unix_ms { - let now_unix_ms = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_millis() as u64) - .unwrap_or_default(); - if expires_at_unix_ms <= now_unix_ms { - return Err(OperationAdapterError::ApprovalRequired { - operation_id: operation_id.to_owned(), - message: "approval proof has expired".to_owned(), - }); - } - } - Ok(()) -} - -fn invalid_approval( - operation_id: &'static str, - message: impl Into<String>, -) -> OperationAdapterError { - OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message: message.into(), - } -} - -fn runtime_contract_error(error: RuntimeContractErrorV1) -> String { - error.to_string() -} diff --git a/src/ops/error.rs b/src/ops/error.rs @@ -1,978 +0,0 @@ -use std::io::ErrorKind; - -use radroots_protocol::error::v1::{Class as ErrorClass, RecoveryAction}; -use radroots_sdk::Error as SdkError; -use serde_json::{Map, Value, json}; - -use crate::out::envelope::{CliExitCode, OutputError}; -use crate::runtime::RuntimeError; -use crate::runtime::account::AccountRuntimeFailure; -use crate::runtime::sdk::CliSdkAdapterError; -use crate::view::runtime::CommandDisposition; - -#[derive(Debug, thiserror::Error, PartialEq, Eq)] -pub enum OperationAdapterError { - #[error("unknown operation `{0}`")] - UnknownOperation(String), - #[error( - "operation `{operation_id}` registry request `{registry_request}` does not match adapter request `{adapter_request}`" - )] - RequestTypeMismatch { - operation_id: String, - registry_request: String, - adapter_request: String, - }, - #[error( - "operation `{operation_id}` registry result `{registry_result}` does not match adapter result `{adapter_result}`" - )] - ResultTypeMismatch { - operation_id: String, - registry_result: String, - adapter_result: String, - }, - #[error("failed to serialize operation result: {0}")] - Serialization(String), - #[error("invalid operation input for `{operation_id}`: {message}")] - InvalidInput { - operation_id: String, - message: String, - }, - #[error("resource not found for `{operation_id}`: {message}")] - NotFound { - operation_id: String, - message: String, - }, - #[error("validation failed for `{operation_id}`: {message}")] - ValidationFailed { - operation_id: String, - message: String, - }, - #[error("approval required for `{operation_id}`: {message}")] - ApprovalRequired { - operation_id: String, - message: String, - }, - #[error("operation `{operation_id}` is forbidden while offline: {message}")] - OfflineForbidden { - operation_id: String, - message: String, - }, - #[error("operation `{operation_id}` cannot run online: {message}")] - NetworkUnavailable { - operation_id: String, - message: String, - }, - #[error("account unresolved for `{operation_id}`: {message}")] - AccountUnresolved { - operation_id: String, - message: String, - }, - #[error("account is watch-only for `{operation_id}`: {message}")] - AccountWatchOnly { - operation_id: String, - message: String, - }, - #[error("account mismatch for `{operation_id}`: {message}")] - AccountMismatch { - operation_id: String, - message: String, - }, - #[error("signer unconfigured for `{operation_id}`: {message}")] - SignerUnconfigured { - operation_id: String, - message: String, - }, - #[error("signer unavailable for `{operation_id}`: {message}")] - SignerUnavailable { - operation_id: String, - message: String, - }, - #[error("signer mode deferred for `{operation_id}`: {message}")] - SignerModeDeferred { - operation_id: String, - message: String, - }, - #[error("provider unconfigured for `{operation_id}`: {message}")] - ProviderUnconfigured { - operation_id: String, - message: String, - }, - #[error("provider unavailable for `{operation_id}`: {message}")] - ProviderUnavailable { - operation_id: String, - message: String, - }, - #[error("operation `{operation_id}` is unavailable: {message}")] - OperationUnavailable { - operation_id: String, - message: String, - }, - #[error("operation `{operation_id}` is not implemented: {message}")] - NotImplemented { - operation_id: String, - message: String, - }, - #[error("operation `{}` failed: {}", .0.operation_id, .0.message)] - DetailedFailure(Box<OperationDetailedFailure>), - #[error("operation runtime error: {0}")] - Runtime(String), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OperationDetailedFailure { - pub operation_id: String, - pub code: String, - pub class: String, - pub message: String, - pub exit_code: CliExitCode, - pub detail_json: String, -} - -impl OperationAdapterError { - fn detailed_failure( - operation_id: String, - code: String, - class: String, - message: String, - exit_code: CliExitCode, - detail_json: String, - ) -> Self { - Self::DetailedFailure(Box::new(OperationDetailedFailure { - operation_id, - code, - class, - message, - exit_code, - detail_json, - })) - } - - pub fn approval_required(operation_id: &str) -> Self { - Self::ApprovalRequired { - operation_id: operation_id.to_owned(), - message: "missing required V1 approval proof or --yes confirmation".to_owned(), - } - } - - pub fn from_command_disposition( - operation_id: &str, - disposition: CommandDisposition, - message: String, - ) -> Self { - match disposition { - CommandDisposition::Success => Self::Runtime(message), - CommandDisposition::NotFound => Self::NotFound { - operation_id: operation_id.to_owned(), - message, - }, - CommandDisposition::ValidationFailed => Self::ValidationFailed { - operation_id: operation_id.to_owned(), - message, - }, - CommandDisposition::Unconfigured => Self::unconfigured(operation_id, message), - CommandDisposition::ExternalUnavailable => Self::unavailable(operation_id, message), - CommandDisposition::Unsupported => Self::InvalidInput { - operation_id: operation_id.to_owned(), - message, - }, - CommandDisposition::InternalError => Self::Runtime(message), - } - } - - pub fn unconfigured(operation_id: &str, message: String) -> Self { - classify_runtime_failure( - operation_id, - message, - RuntimeFailureAvailability::Unconfigured, - ) - } - - pub fn operation_unavailable_with_detail( - operation_id: &str, - message: String, - detail: Value, - ) -> Self { - Self::detailed_failure( - operation_id.to_owned(), - "operation_unavailable".to_owned(), - "operation".to_owned(), - message, - CliExitCode::RuntimeUnavailable, - detail.to_string(), - ) - } - - pub fn not_found_with_detail(operation_id: &str, message: String, detail: Value) -> Self { - Self::detailed_failure( - operation_id.to_owned(), - "not_found".to_owned(), - "resource".to_owned(), - message, - CliExitCode::NotFound, - detail.to_string(), - ) - } - - pub fn not_implemented(operation_id: &str, message: String) -> Self { - Self::NotImplemented { - operation_id: operation_id.to_owned(), - message, - } - } - - pub fn not_implemented_with_detail(operation_id: &str, message: String, detail: Value) -> Self { - Self::detailed_failure( - operation_id.to_owned(), - "not_implemented".to_owned(), - "operation".to_owned(), - message, - CliExitCode::RuntimeUnavailable, - detail.to_string(), - ) - } - - pub fn network_unavailable_with_detail( - operation_id: &str, - message: String, - detail: Value, - ) -> Self { - Self::detailed_failure( - operation_id.to_owned(), - "network_unavailable".to_owned(), - "network".to_owned(), - message, - CliExitCode::SyncOrNetworkFailure, - detail.to_string(), - ) - } - - pub fn validation_failed_with_detail( - operation_id: &str, - message: String, - detail: Value, - ) -> Self { - Self::detailed_failure( - operation_id.to_owned(), - "validation_failed".to_owned(), - "validation".to_owned(), - message, - CliExitCode::ValidationFailed, - detail.to_string(), - ) - } - - pub fn unavailable(operation_id: &str, message: String) -> Self { - classify_runtime_failure( - operation_id, - message, - RuntimeFailureAvailability::Unavailable, - ) - } - - pub fn runtime_failure(operation_id: &str, error: RuntimeError) -> Self { - let message = error.to_string(); - let lowered = message.to_ascii_lowercase(); - match &error { - RuntimeError::Io(io_error) if io_error.kind() == ErrorKind::NotFound => { - Self::NotFound { - operation_id: operation_id.to_owned(), - message, - } - } - RuntimeError::Config(_) if looks_like_not_found(&lowered) => Self::NotFound { - operation_id: operation_id.to_owned(), - message, - }, - RuntimeError::Account(failure) => account_runtime_failure(operation_id, failure), - RuntimeError::Config(_) - if contains_any( - &lowered, - &[ - "no local account", - "account selector", - "account selection", - "account mismatch", - "did not match any local account", - "unresolved account", - ], - ) => - { - classify_runtime_failure( - operation_id, - message, - RuntimeFailureAvailability::Unconfigured, - ) - } - RuntimeError::Config(_) if looks_like_signer_failure(&lowered) => { - Self::SignerUnconfigured { - operation_id: operation_id.to_owned(), - message, - } - } - RuntimeError::Config(_) if looks_like_validation_failure(&lowered) => { - Self::ValidationFailed { - operation_id: operation_id.to_owned(), - message, - } - } - RuntimeError::Network(_) if looks_like_auth_failure(&lowered) => { - auth_runtime_failure(operation_id, message, &lowered) - } - RuntimeError::Network(_) if looks_like_signer_failure(&lowered) => { - Self::SignerUnavailable { - operation_id: operation_id.to_owned(), - message, - } - } - RuntimeError::Network(_) if looks_like_provider_failure(&lowered) => { - Self::ProviderUnavailable { - operation_id: operation_id.to_owned(), - message, - } - } - RuntimeError::Network(_) if looks_like_operation_failure(&lowered) => { - Self::OperationUnavailable { - operation_id: operation_id.to_owned(), - message, - } - } - RuntimeError::Network(_) => Self::NetworkUnavailable { - operation_id: operation_id.to_owned(), - message, - }, - RuntimeError::Accounts(_) => classify_runtime_failure( - operation_id, - message, - RuntimeFailureAvailability::Unavailable, - ), - _ => Self::Runtime(message), - } - } - - pub fn sdk_adapter_failure(operation_id: &str, error: CliSdkAdapterError) -> Self { - match error { - CliSdkAdapterError::Runtime(error) => Self::runtime_failure(operation_id, error), - CliSdkAdapterError::Sdk(error) => Self::sdk_failure(operation_id, error), - CliSdkAdapterError::Sync(error) => Self::Runtime(error.to_string()), - CliSdkAdapterError::Storage(error) => Self::Runtime(error.to_string()), - CliSdkAdapterError::Transport(error) => Self::NetworkUnavailable { - operation_id: operation_id.to_owned(), - message: error.to_string(), - }, - CliSdkAdapterError::Io(error) => Self::Runtime(error.to_string()), - } - } - - pub fn sdk_failure(operation_id: &str, error: SdkError) -> Self { - let report = error.to_report(); - let code = report.code().as_str().to_owned(); - let class = sdk_error_class_name(report.class()).to_owned(); - let message = report.message().as_str().to_owned(); - let exit_code = sdk_error_exit_code(report.class()); - let mut detail = serde_json::to_value(&report).unwrap_or_else(|_| json!({})); - let actions = sdk_recovery_next_actions(operation_id, report.recovery_actions()); - if !actions.is_empty() - && let Some(detail) = detail.as_object_mut() - { - detail.insert( - "actions".to_owned(), - Value::Array(actions.into_iter().map(Value::String).collect()), - ); - } - Self::detailed_failure( - operation_id.to_owned(), - code, - class, - message, - exit_code, - detail.to_string(), - ) - } - - pub fn to_output_error(&self) -> OutputError { - match self { - Self::ApprovalRequired { message, .. } => OutputError::new( - "approval_required", - message.clone(), - CliExitCode::ApprovalRequiredOrDenied, - ), - Self::InvalidInput { message, .. } => { - OutputError::new("invalid_input", message.clone(), CliExitCode::InvalidInput) - } - Self::NotFound { - operation_id, - message, - } => runtime_output_error( - "not_found", - operation_id, - "resource", - message, - CliExitCode::NotFound, - ), - Self::ValidationFailed { - operation_id, - message, - } => runtime_output_error( - "validation_failed", - operation_id, - "validation", - message, - CliExitCode::ValidationFailed, - ), - Self::OfflineForbidden { - operation_id, - message, - } => runtime_output_error( - "offline_forbidden", - operation_id, - "network", - message, - CliExitCode::SyncOrNetworkFailure, - ), - Self::NetworkUnavailable { - operation_id, - message, - } => runtime_output_error( - "network_unavailable", - operation_id, - "network", - message, - CliExitCode::SyncOrNetworkFailure, - ), - Self::AccountUnresolved { - operation_id, - message, - } => runtime_output_error( - "account_unresolved", - operation_id, - "account", - message, - CliExitCode::AuthorizationFailed, - ), - Self::AccountWatchOnly { - operation_id, - message, - } => runtime_output_error( - "account_watch_only", - operation_id, - "account", - message, - CliExitCode::SignerUnavailable, - ), - Self::AccountMismatch { - operation_id, - message, - } => runtime_output_error( - "account_mismatch", - operation_id, - "account", - message, - CliExitCode::AuthorizationFailed, - ), - Self::SignerUnconfigured { - operation_id, - message, - } => runtime_output_error( - "signer_unconfigured", - operation_id, - "signer", - message, - CliExitCode::SignerUnavailable, - ), - Self::SignerUnavailable { - operation_id, - message, - } => runtime_output_error( - "signer_unavailable", - operation_id, - "signer", - message, - CliExitCode::SignerUnavailable, - ), - Self::SignerModeDeferred { - operation_id, - message, - } => runtime_output_error( - "signer_mode_deferred", - operation_id, - "signer", - message, - CliExitCode::SignerUnavailable, - ), - Self::ProviderUnconfigured { - operation_id, - message, - } => runtime_output_error( - "provider_unconfigured", - operation_id, - "provider", - message, - CliExitCode::RuntimeUnavailable, - ), - Self::ProviderUnavailable { - operation_id, - message, - } => runtime_output_error( - "provider_unavailable", - operation_id, - "provider", - message, - CliExitCode::RuntimeUnavailable, - ), - Self::OperationUnavailable { - operation_id, - message, - } => runtime_output_error( - "operation_unavailable", - operation_id, - "operation", - message, - CliExitCode::RuntimeUnavailable, - ), - Self::NotImplemented { - operation_id, - message, - } => runtime_output_error( - "not_implemented", - operation_id, - "operation", - message, - CliExitCode::RuntimeUnavailable, - ), - Self::DetailedFailure(failure) => runtime_output_error_with_detail( - failure.code.as_str(), - failure.operation_id.as_str(), - failure.class.as_str(), - failure.message.as_str(), - failure.exit_code, - failure.detail_json.as_str(), - ), - Self::UnknownOperation(operation_id) => OutputError::new( - "unknown_operation", - format!("unknown operation `{operation_id}`"), - CliExitCode::InvalidInput, - ), - Self::RequestTypeMismatch { .. } | Self::ResultTypeMismatch { .. } => OutputError::new( - "contract_mismatch", - self.to_string(), - CliExitCode::InternalError, - ), - Self::Serialization(message) => OutputError::new( - "serialization_failed", - message.clone(), - CliExitCode::InternalError, - ), - Self::Runtime(message) => { - OutputError::new("runtime_error", message.clone(), CliExitCode::InternalError) - } - } - } -} - -fn sdk_error_exit_code(class: ErrorClass) -> CliExitCode { - match class { - ErrorClass::Authorization | ErrorClass::Security => CliExitCode::AuthorizationFailed, - ErrorClass::Validation | ErrorClass::Contract | ErrorClass::Conflict => { - CliExitCode::InvalidInput - } - ErrorClass::Storage - | ErrorClass::Resource - | ErrorClass::Capability - | ErrorClass::Maintenance => CliExitCode::RuntimeUnavailable, - ErrorClass::Network | ErrorClass::Sync => CliExitCode::SyncOrNetworkFailure, - _ => CliExitCode::InternalError, - } -} - -fn sdk_error_class_name(class: ErrorClass) -> &'static str { - match class { - ErrorClass::Validation => "validation", - ErrorClass::Contract => "contract", - ErrorClass::Storage => "storage", - ErrorClass::Resource => "resource", - ErrorClass::Conflict => "conflict", - ErrorClass::Operation => "operation", - ErrorClass::Authorization => "authorization", - ErrorClass::Signer => "signer", - ErrorClass::Network => "network", - ErrorClass::Sync => "sync", - ErrorClass::Runtime => "runtime", - ErrorClass::Projection => "projection", - ErrorClass::Query => "query", - ErrorClass::Capability => "capability", - ErrorClass::Privacy => "privacy", - ErrorClass::Security => "security", - ErrorClass::Maintenance => "maintenance", - ErrorClass::Internal => "internal", - ErrorClass::Unknown => "unknown", - } -} - -fn sdk_recovery_next_actions( - operation_id: &str, - recovery_actions: &[RecoveryAction], -) -> Vec<String> { - recovery_actions - .iter() - .filter_map(|action| match action { - RecoveryAction::RetryOperationWithSameIdempotencyKey | RecoveryAction::FixRequest => { - Some(operation_retry_action(operation_id)) - } - RecoveryAction::InspectLocalStores => Some("radroots store inspect".to_owned()), - RecoveryAction::ConfigureStorage => Some("radroots store init".to_owned()), - RecoveryAction::InspectGeoNamesAsset | RecoveryAction::ConfigureGeoNamesCache => { - Some("radroots health inspect".to_owned()) - } - RecoveryAction::ConfigureTransportTargets => { - Some("radroots transport config inspect".to_owned()) - } - RecoveryAction::ConfigureSigner => Some("radroots signer status".to_owned()), - RecoveryAction::SelectAuthorizedActor => Some("radroots account list".to_owned()), - RecoveryAction::CompleteSignerAuthentication => { - Some("radroots signer status".to_owned()) - } - RecoveryAction::RetryAfterTransportFailure | RecoveryAction::RetryGeoNamesDownload => { - Some(operation_retry_action(operation_id)) - } - RecoveryAction::EnableRequiredFeature | RecoveryAction::RecreateClient => { - Some("radroots health inspect".to_owned()) - } - }) - .fold(Vec::new(), |mut actions, action| { - if !actions.contains(&action) { - actions.push(action); - } - actions - }) -} - -fn operation_retry_action(operation_id: &str) -> String { - format!("radroots {}", operation_id.replace('.', " ")) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RuntimeFailureAvailability { - Unconfigured, - Unavailable, -} - -fn account_runtime_failure( - operation_id: &str, - failure: &AccountRuntimeFailure, -) -> OperationAdapterError { - let message = failure.message().to_owned(); - match failure { - AccountRuntimeFailure::Unresolved(_) => account_failure_output( - operation_id, - "account_unresolved", - message, - CliExitCode::AuthorizationFailed, - failure.detail_json(), - || OperationAdapterError::AccountUnresolved { - operation_id: operation_id.to_owned(), - message: failure.message().to_owned(), - }, - ), - AccountRuntimeFailure::WatchOnly(_) => account_failure_output( - operation_id, - "account_watch_only", - message, - CliExitCode::SignerUnavailable, - failure.detail_json(), - || OperationAdapterError::AccountWatchOnly { - operation_id: operation_id.to_owned(), - message: failure.message().to_owned(), - }, - ), - AccountRuntimeFailure::Mismatch(_) => account_failure_output( - operation_id, - "account_mismatch", - message, - CliExitCode::AuthorizationFailed, - failure.detail_json(), - || OperationAdapterError::AccountMismatch { - operation_id: operation_id.to_owned(), - message: failure.message().to_owned(), - }, - ), - } -} - -fn account_failure_output( - operation_id: &str, - code: &str, - message: String, - exit_code: CliExitCode, - detail_json: Option<&str>, - default_error: impl FnOnce() -> OperationAdapterError, -) -> OperationAdapterError { - match detail_json { - Some(detail_json) => OperationAdapterError::detailed_failure( - operation_id.to_owned(), - code.to_owned(), - "account".to_owned(), - message, - exit_code, - detail_json.to_owned(), - ), - None => default_error(), - } -} - -fn auth_runtime_failure( - operation_id: &str, - message: String, - lowered: &str, -) -> OperationAdapterError { - let unauthorized = contains_any( - lowered, - &[ - "unauthorized", - "forbidden", - "permission denied", - "invalid token", - "bearer token rejected", - "http 401", - "http 403", - "status 401", - "status 403", - ], - ); - OperationAdapterError::detailed_failure( - operation_id.to_owned(), - if unauthorized { - "auth_unauthorized".to_owned() - } else { - "auth_unavailable".to_owned() - }, - "auth".to_owned(), - message, - CliExitCode::AuthorizationFailed, - Value::Null.to_string(), - ) -} - -fn classify_runtime_failure( - operation_id: &str, - message: String, - availability: RuntimeFailureAvailability, -) -> OperationAdapterError { - let lowered = message.to_ascii_lowercase(); - if contains_any(&lowered, &["watch_only", "watch-only", "watch only"]) { - return OperationAdapterError::AccountWatchOnly { - operation_id: operation_id.to_owned(), - message, - }; - } - if contains_any(&lowered, &["account mismatch"]) { - return OperationAdapterError::AccountMismatch { - operation_id: operation_id.to_owned(), - message, - }; - } - if contains_any( - &lowered, - &[ - "no account", - "no local account", - "account selector", - "account selection", - "did not match any local account", - "unresolved account", - "selected account", - ], - ) { - return OperationAdapterError::AccountUnresolved { - operation_id: operation_id.to_owned(), - message, - }; - } - if contains_any( - &lowered, - &[ - "signer", - "sign_event", - "remote_nip46", - "nip46", - "secret-backed", - "secret backed", - ], - ) { - return match availability { - RuntimeFailureAvailability::Unconfigured => OperationAdapterError::SignerUnconfigured { - operation_id: operation_id.to_owned(), - message, - }, - RuntimeFailureAvailability::Unavailable => OperationAdapterError::SignerUnavailable { - operation_id: operation_id.to_owned(), - message, - }, - }; - } - if contains_any(&lowered, &["provider", "write-plane", "write plane", "rpc"]) { - return match availability { - RuntimeFailureAvailability::Unconfigured => { - OperationAdapterError::ProviderUnconfigured { - operation_id: operation_id.to_owned(), - message, - } - } - RuntimeFailureAvailability::Unavailable => OperationAdapterError::ProviderUnavailable { - operation_id: operation_id.to_owned(), - message, - }, - }; - } - OperationAdapterError::OperationUnavailable { - operation_id: operation_id.to_owned(), - message, - } -} - -fn contains_any(value: &str, needles: &[&str]) -> bool { - needles.iter().any(|needle| value.contains(needle)) -} - -fn looks_like_auth_failure(value: &str) -> bool { - contains_any( - value, - &[ - "authentication", - "daemon execution auth", - "authorization", - "authorize", - "unauthorized", - "forbidden", - "bearer token", - "invalid token", - "permission denied", - "status 401", - "status 403", - "http 401", - "http 403", - ], - ) -} - -fn looks_like_signer_failure(value: &str) -> bool { - contains_any( - value, - &[ - "signer", - "sign_event", - "sign event", - "signer session", - "nip46", - "nip-46", - "remote_nip46", - ], - ) -} - -fn looks_like_provider_failure(value: &str) -> bool { - contains_any( - value, - &[ - "provider unavailable", - "provider unconfigured", - "provider runtime", - "provider failed", - "execution provider", - ], - ) -} - -fn looks_like_operation_failure(value: &str) -> bool { - contains_any( - value, - &[ - "method not found", - "unknown method", - "unsupported method", - "unsupported operation", - "operation unavailable", - "operation disabled", - "publish execution disabled", - "publish.event is disabled", - ], - ) -} - -fn looks_like_not_found(value: &str) -> bool { - contains_any( - value, - &[ - "not found", - "no such file or directory", - "path not found", - "missing file", - ], - ) -} - -fn looks_like_validation_failure(value: &str) -> bool { - contains_any( - value, - &[ - "invalid", - "parse ", - "parse:", - "must not", - "must be", - "validation", - "failed to import account", - ], - ) -} - -fn runtime_output_error( - code: &str, - operation_id: &str, - class: &str, - message: &str, - exit_code: CliExitCode, -) -> OutputError { - let mut error = OutputError::new(code, message.to_owned(), exit_code); - error.detail = Some(json!({ - "operation_id": operation_id, - "class": class, - })); - error -} - -fn runtime_output_error_with_detail( - code: &str, - operation_id: &str, - class: &str, - message: &str, - exit_code: CliExitCode, - detail_json: &str, -) -> OutputError { - let mut error = OutputError::new(code, message.to_owned(), exit_code); - let mut detail = serde_json::from_str::<Map<String, Value>>(detail_json).unwrap_or_default(); - detail.insert( - "operation_id".to_owned(), - Value::from(operation_id.to_owned()), - ); - detail.insert("class".to_owned(), Value::from(class.to_owned())); - error.detail = Some(Value::Object(detail)); - error -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn final_sdk_error_maps_from_its_protocol_report() { - let sdk_error = radroots_sdk::ClientBuilder::new() - .build() - .expect_err("missing storage must fail"); - let error = OperationAdapterError::sdk_failure("store.inspect", sdk_error); - - let output = error.to_output_error(); - let detail = output.detail.expect("detail"); - assert_eq!(detail["operation_id"], "store.inspect"); - assert!(detail["class"].is_string()); - } -} diff --git a/src/ops/exec/basket.rs b/src/ops/exec/basket.rs @@ -1,1091 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use radroots_replica_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany}; -use radroots_replica_store::{ReplicaSql, trade_product}; -use radroots_sql_core::SqlxSqliteExecutor; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -use crate::ops::{ - BasketCreateRequest, BasketCreateResult, BasketGetRequest, BasketGetResult, - BasketItemAddRequest, BasketItemAddResult, BasketItemRemoveRequest, BasketItemRemoveResult, - BasketItemUpdateRequest, BasketItemUpdateResult, BasketListRequest, BasketListResult, - BasketQuoteRequest, BasketQuoteResult, OperationAdapterError, OperationRequest, - OperationRequestData, OperationRequestPayload, OperationResult, OperationResultData, - OperationService, -}; -use crate::runtime::config::RuntimeConfig; -use crate::runtime::trade::{TradeCandidateDraftCreateArgs, TradeCandidateDraftView}; - -const BASKET_KIND: &str = "basket_v1"; -const BASKET_SOURCE: &str = "local baskets - local first"; -const BASKET_QUOTE_SOURCE: &str = "local baskets - deterministic quote"; -const BASKETS_DIR: &str = "baskets"; - -static BASKET_COUNTER: AtomicU64 = AtomicU64::new(0); - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct BasketDocument { - version: u32, - kind: String, - basket: BasketState, - #[serde(default, skip_serializing_if = "Option::is_none")] - quote: Option<BasketQuote>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct BasketState { - basket_id: String, - created_at_unix: u64, - updated_at_unix: u64, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - items: Vec<BasketItem>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - adjustments: Vec<BasketAdjustment>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct BasketItem { - item_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - listing: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - listing_addr: Option<String>, - bin_id: String, - quantity: u32, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct BasketAdjustment { - id: String, - effect: String, - amount: String, - currency: String, - reason: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct BasketQuote { - quote_id: String, - quote_version: u32, - trade_id: String, - trade_file: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - economics: Option<Value>, - ready_for_submit: bool, - created_at_unix: u64, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - issues: Vec<BasketIssue>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct BasketIssue { - code: String, - field: String, - message: String, -} - -#[derive(Debug, Clone)] -struct LoadedBasket { - file: PathBuf, - document: BasketDocument, -} - -#[derive(Debug, Clone)] -struct BasketProductBinState { - primary_bin_id: Option<String>, - verified_primary_bin_id: Option<String>, -} - -#[derive(Debug, Clone)] -enum BasketProductResolution { - Resolved(BasketProductBinState), - Unresolved, - Ambiguous(usize), -} - -pub struct BasketOperationService<'a> { - config: &'a RuntimeConfig, -} - -impl<'a> BasketOperationService<'a> { - pub fn new(config: &'a RuntimeConfig) -> Self { - Self { config } - } -} - -impl OperationService<BasketCreateRequest> for BasketOperationService<'_> { - type Result = BasketCreateResult; - - fn execute( - &self, - request: OperationRequest<BasketCreateRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let basket_id = string_input(&request, "basket_id").unwrap_or_else(next_basket_id); - let initial_item = optional_item_from_request(&request, None)?; - let file = basket_lookup_path(self.config, basket_id.as_str()); - if file.exists() { - return Err(invalid_input( - request.operation_id(), - format!("basket `{basket_id}` already exists"), - )); - } - if request.context.dry_run { - return json_operation_result::<BasketCreateResult>(json!({ - "state": "dry_run", - "source": BASKET_SOURCE, - "basket_id": basket_id, - "file": file.display().to_string(), - "item_count": initial_item.as_ref().map(|_| 1).unwrap_or(0), - "actions": ["radroots basket create"], - })); - } - - let now = now_unix(); - let document = BasketDocument { - version: 1, - kind: BASKET_KIND.to_owned(), - basket: BasketState { - basket_id, - created_at_unix: now, - updated_at_unix: now, - items: initial_item.into_iter().collect(), - adjustments: Vec::new(), - }, - quote: None, - }; - save_basket(file.as_path(), &document)?; - json_operation_result::<BasketCreateResult>(basket_view( - self.config, - &document, - file.as_path(), - None, - )?) - } -} - -impl OperationService<BasketGetRequest> for BasketOperationService<'_> { - type Result = BasketGetResult; - - fn execute( - &self, - request: OperationRequest<BasketGetRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let lookup = required_basket_id(&request)?; - let Some(loaded) = load_basket_optional(self.config, lookup.as_str())? else { - return json_operation_result::<BasketGetResult>(missing_basket_view( - self.config, - lookup.as_str(), - )); - }; - json_operation_result::<BasketGetResult>(basket_view( - self.config, - &loaded.document, - loaded.file.as_path(), - None, - )?) - } -} - -impl OperationService<BasketListRequest> for BasketOperationService<'_> { - type Result = BasketListResult; - - fn execute( - &self, - _request: OperationRequest<BasketListRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let baskets = list_basket_summaries(self.config)?; - json_operation_result::<BasketListResult>(json!({ - "state": if baskets.is_empty() { "empty" } else { "ready" }, - "source": BASKET_SOURCE, - "count": baskets.len(), - "baskets": baskets, - "actions": if baskets.is_empty() { - vec!["radroots basket create".to_owned()] - } else { - Vec::new() - }, - })) - } -} - -impl OperationService<BasketItemAddRequest> for BasketOperationService<'_> { - type Result = BasketItemAddResult; - - fn execute( - &self, - request: OperationRequest<BasketItemAddRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let basket_id = required_basket_id(&request)?; - let mut loaded = - load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; - let item = required_item_from_request(&request, Some(next_item_id(&loaded.document)))?; - if request.context.dry_run { - return json_operation_result::<BasketItemAddResult>(json!({ - "state": "dry_run", - "source": BASKET_SOURCE, - "basket_id": basket_id, - "item": item, - "actions": ["radroots basket item add"], - })); - } - - loaded.document.basket.items.push(item); - touch_basket(&mut loaded.document); - loaded.document.quote = None; - save_basket(loaded.file.as_path(), &loaded.document)?; - json_operation_result::<BasketItemAddResult>(basket_view( - self.config, - &loaded.document, - loaded.file.as_path(), - Some("updated"), - )?) - } -} - -impl OperationService<BasketItemUpdateRequest> for BasketOperationService<'_> { - type Result = BasketItemUpdateResult; - - fn execute( - &self, - request: OperationRequest<BasketItemUpdateRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let basket_id = required_basket_id(&request)?; - let item_id = required_string(&request, "item_id")?; - let mut loaded = - load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; - let Some(index) = loaded - .document - .basket - .items - .iter() - .position(|item| item.item_id == item_id) - else { - return Err(invalid_input( - request.operation_id(), - format!("basket item `{item_id}` was not found"), - )); - }; - - let updated = - update_item_from_request(&request, loaded.document.basket.items[index].clone())?; - if request.context.dry_run { - return json_operation_result::<BasketItemUpdateResult>(json!({ - "state": "dry_run", - "source": BASKET_SOURCE, - "basket_id": basket_id, - "item": updated, - "actions": ["radroots basket item update"], - })); - } - - loaded.document.basket.items[index] = updated; - touch_basket(&mut loaded.document); - loaded.document.quote = None; - save_basket(loaded.file.as_path(), &loaded.document)?; - json_operation_result::<BasketItemUpdateResult>(basket_view( - self.config, - &loaded.document, - loaded.file.as_path(), - Some("updated"), - )?) - } -} - -impl OperationService<BasketItemRemoveRequest> for BasketOperationService<'_> { - type Result = BasketItemRemoveResult; - - fn execute( - &self, - request: OperationRequest<BasketItemRemoveRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let basket_id = required_basket_id(&request)?; - let item_id = required_string(&request, "item_id")?; - let mut loaded = - load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; - let Some(index) = loaded - .document - .basket - .items - .iter() - .position(|item| item.item_id == item_id) - else { - return Err(invalid_input( - request.operation_id(), - format!("basket item `{item_id}` was not found"), - )); - }; - - if request.context.dry_run { - return json_operation_result::<BasketItemRemoveResult>(json!({ - "state": "dry_run", - "source": BASKET_SOURCE, - "basket_id": basket_id, - "item_id": item_id, - "actions": ["radroots basket item remove"], - })); - } - - loaded.document.basket.items.remove(index); - touch_basket(&mut loaded.document); - loaded.document.quote = None; - save_basket(loaded.file.as_path(), &loaded.document)?; - json_operation_result::<BasketItemRemoveResult>(basket_view( - self.config, - &loaded.document, - loaded.file.as_path(), - Some("updated"), - )?) - } -} - -impl OperationService<BasketQuoteRequest> for BasketOperationService<'_> { - type Result = BasketQuoteResult; - - fn execute( - &self, - request: OperationRequest<BasketQuoteRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let basket_id = required_basket_id(&request)?; - let mut loaded = - load_required_basket(self.config, basket_id.as_str(), request.operation_id())?; - let issues = basket_issues(self.config, &loaded.document)?; - if !issues.is_empty() { - let actions = basket_actions(&loaded.document, issues.as_slice()); - return json_operation_result::<BasketQuoteResult>(json!({ - "state": "unconfigured", - "source": BASKET_QUOTE_SOURCE, - "basket_id": basket_id, - "file": loaded.file.display().to_string(), - "ready_for_quote": false, - "issues": issues, - "actions": actions, - })); - } - - let item = loaded - .document - .basket - .items - .first() - .expect("validated basket has one item") - .clone(); - if request.context.dry_run { - let trade = crate::runtime::trade::scaffold_proposal_draft_preflight( - self.config, - &TradeCandidateDraftCreateArgs { - listing: item.listing.clone(), - listing_addr: item.listing_addr.clone(), - bin_id: item.bin_id.clone(), - quantity: item.quantity, - }, - ) - .map_err(|error| { - OperationAdapterError::runtime_failure(request.operation_id(), error) - })?; - return json_operation_result::<BasketQuoteResult>(json!({ - "state": "dry_run", - "source": BASKET_QUOTE_SOURCE, - "basket_id": basket_id, - "file": loaded.file.display().to_string(), - "item": item, - "trade": trade, - "actions": [format!("radroots basket quote {basket_id}")], - })); - } - - let trade = crate::runtime::trade::scaffold_proposal_draft( - self.config, - &TradeCandidateDraftCreateArgs { - listing: item.listing.clone(), - listing_addr: item.listing_addr.clone(), - bin_id: item.bin_id.clone(), - quantity: item.quantity, - }, - ) - .map_err(|error| OperationAdapterError::runtime_failure(request.operation_id(), error))?; - let quote_economics = serde_json::to_value(&trade.economics).ok(); - let quote = BasketQuote { - quote_id: format!("quote_{}", trade.trade_id), - quote_version: 1, - trade_id: trade.trade_id.clone(), - trade_file: trade.file.clone(), - economics: quote_economics, - ready_for_submit: trade.ready_for_submit, - created_at_unix: now_unix(), - issues: quote_issues_from_trade(&trade), - }; - loaded.document.quote = Some(quote.clone()); - touch_basket(&mut loaded.document); - save_basket(loaded.file.as_path(), &loaded.document)?; - - json_operation_result::<BasketQuoteResult>(json!({ - "state": "quoted", - "source": BASKET_QUOTE_SOURCE, - "basket_id": loaded.document.basket.basket_id, - "file": loaded.file.display().to_string(), - "quote": quote, - "trade": trade, - "actions": quote_actions(&trade), - })) - } -} - -fn optional_item_from_request<P>( - request: &OperationRequest<P>, - item_id: Option<String>, -) -> Result<Option<BasketItem>, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - if string_input(request, "listing").is_none() - && string_input(request, "listing_addr").is_none() - && string_input(request, "bin_id").is_none() - { - return Ok(None); - } - required_item_from_request(request, item_id).map(Some) -} - -fn required_item_from_request<P>( - request: &OperationRequest<P>, - item_id: Option<String>, -) -> Result<BasketItem, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - let listing = string_input(request, "listing"); - let listing_addr = string_input(request, "listing_addr"); - if listing.is_none() && listing_addr.is_none() { - return Err(invalid_input( - request.operation_id(), - "missing required `listing` or `listing_addr` input".to_owned(), - )); - } - let bin_id = required_string(request, "bin_id")?; - let quantity = quantity_input(request)?.unwrap_or(1); - if quantity == 0 { - return Err(invalid_input( - request.operation_id(), - "`quantity` must be greater than 0".to_owned(), - )); - } - - Ok(BasketItem { - item_id: item_id - .or_else(|| string_input(request, "item_id")) - .unwrap_or_else(|| "item_1".to_owned()), - listing, - listing_addr, - bin_id, - quantity, - }) -} - -fn update_item_from_request<P>( - request: &OperationRequest<P>, - mut item: BasketItem, -) -> Result<BasketItem, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - let mut changed = false; - if let Some(listing) = string_input(request, "listing") { - item.listing = Some(listing); - changed = true; - } - if let Some(listing_addr) = string_input(request, "listing_addr") { - item.listing_addr = Some(listing_addr); - changed = true; - } - if let Some(bin_id) = string_input(request, "bin_id") { - item.bin_id = bin_id; - changed = true; - } - if let Some(quantity) = quantity_input(request)? { - if quantity == 0 { - return Err(invalid_input( - request.operation_id(), - "`quantity` must be greater than 0".to_owned(), - )); - } - item.quantity = quantity; - changed = true; - } - if !changed { - return Err(invalid_input( - request.operation_id(), - "no item update input was provided".to_owned(), - )); - } - Ok(item) -} - -fn basket_view( - config: &RuntimeConfig, - document: &BasketDocument, - file: &Path, - state: Option<&str>, -) -> Result<Value, OperationAdapterError> { - let issues = basket_issues(config, document)?; - let ready_for_quote = issues.is_empty(); - let actions = basket_actions(document, issues.as_slice()); - Ok(json!({ - "state": state.unwrap_or("ready"), - "source": BASKET_SOURCE, - "basket_id": document.basket.basket_id, - "file": file.display().to_string(), - "item_count": document.basket.items.len(), - "items": document.basket.items, - "adjustment_count": document.basket.adjustments.len(), - "adjustments": document.basket.adjustments, - "quote": document.quote, - "ready_for_quote": ready_for_quote, - "issues": issues, - "actions": actions, - })) -} - -fn missing_basket_view(config: &RuntimeConfig, lookup: &str) -> Value { - json!({ - "state": "missing", - "source": BASKET_SOURCE, - "lookup": lookup, - "file": basket_lookup_path(config, lookup).display().to_string(), - "reason": format!("basket `{lookup}` was not found"), - "actions": ["radroots basket list", "radroots basket create"], - }) -} - -fn list_basket_summaries(config: &RuntimeConfig) -> Result<Vec<Value>, OperationAdapterError> { - let dir = baskets_dir(config); - if !dir.exists() { - return Ok(Vec::new()); - } - - let mut baskets = Vec::new(); - for entry in fs::read_dir(&dir).map_err(|error| { - OperationAdapterError::Runtime(format!("read basket directory {}: {error}", dir.display())) - })? { - let entry = entry.map_err(|error| { - OperationAdapterError::Runtime(format!( - "read basket directory {}: {error}", - dir.display() - )) - })?; - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - let loaded = load_basket_path(path.as_path())?; - let issues = basket_issues(config, &loaded.document)?; - let ready_for_quote = issues.is_empty(); - baskets.push(json!({ - "basket_id": loaded.document.basket.basket_id, - "state": if ready_for_quote { "ready" } else { "unconfigured" }, - "file": loaded.file.display().to_string(), - "item_count": loaded.document.basket.items.len(), - "adjustment_count": loaded.document.basket.adjustments.len(), - "ready_for_quote": ready_for_quote, - "issues": issues, - "quote": loaded.document.quote, - "updated_at_unix": loaded.document.basket.updated_at_unix, - })); - } - baskets.sort_by(|left, right| { - right["updated_at_unix"] - .as_u64() - .cmp(&left["updated_at_unix"].as_u64()) - .then_with(|| { - left["basket_id"] - .as_str() - .unwrap_or_default() - .cmp(right["basket_id"].as_str().unwrap_or_default()) - }) - }); - Ok(baskets) -} - -fn basket_issues( - config: &RuntimeConfig, - document: &BasketDocument, -) -> Result<Vec<BasketIssue>, OperationAdapterError> { - let mut issues = Vec::new(); - if document.basket.items.is_empty() { - issues.push(basket_issue( - "basket_items_missing", - "basket.items", - "basket must contain one item before quote creation", - )); - } - if document.basket.items.len() > 1 { - issues.push(basket_issue( - "basket_items_unsupported", - "basket.items", - "basket quotes support exactly one item", - )); - } - for item in &document.basket.items { - if item.listing.is_none() && item.listing_addr.is_none() { - issues.push(basket_issue( - "basket_item_listing_missing", - format!("basket.items.{}.listing", item.item_id), - "item must include listing or listing_addr", - )); - } - if item.bin_id.trim().is_empty() { - issues.push(basket_issue( - "basket_item_bin_missing", - format!("basket.items.{}.bin_id", item.item_id), - "item must include bin_id", - )); - } - if item.quantity == 0 { - issues.push(basket_issue( - "basket_item_quantity_invalid", - format!("basket.items.{}.quantity", item.item_id), - "item quantity must be greater than 0", - )); - } - } - if issues.is_empty() { - issues.extend(basket_market_issues(config, document)?); - } - Ok(issues) -} - -fn basket_market_issues( - config: &RuntimeConfig, - document: &BasketDocument, -) -> Result<Vec<BasketIssue>, OperationAdapterError> { - if !config.local.replica_store_path.exists() { - return Ok(vec![basket_issue( - "basket_market_replica_missing", - "local.replica_store", - "current local replica data is required before quote creation; run `radroots store inspect` and `radroots market pull`", - )]); - } - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path).map_err(|error| { - OperationAdapterError::Runtime(format!( - "open local replica {}: {error}", - config.local.replica_store_path.display() - )) - })?; - let mut issues = Vec::new(); - for item in &document.basket.items { - let product = match basket_product_bin_state(config, &executor, item)? { - BasketProductResolution::Resolved(product) => product, - BasketProductResolution::Unresolved => { - issues.push(basket_issue( - "basket_item_listing_unresolved", - basket_item_listing_field(item), - "basket item listing is not active in the current local replica; run `radroots market pull` before quote creation", - )); - continue; - } - BasketProductResolution::Ambiguous(count) => { - issues.push(basket_issue( - "basket_item_listing_ambiguous", - basket_item_listing_field(item), - format!( - "basket item listing matched {count} active local replica rows; choose a unique listing before quote creation" - ), - )); - continue; - } - }; - let Some(primary_bin_id) = product.primary_bin_id.as_deref().and_then(non_empty_ref) else { - issues.push(basket_issue( - "listing_primary_bin_missing", - format!("basket.items.{}.bin_id", item.item_id), - "current local replica listing primary bin is required before quote creation", - )); - continue; - }; - let Some(verified_primary_bin_id) = product - .verified_primary_bin_id - .as_deref() - .and_then(non_empty_ref) - else { - issues.push(basket_issue( - "listing_primary_bin_invalid", - format!("basket.items.{}.bin_id", item.item_id), - format!("current local replica primary bin `{primary_bin_id}` is not verified"), - )); - continue; - }; - if verified_primary_bin_id != primary_bin_id { - issues.push(basket_issue( - "listing_primary_bin_invalid", - format!("basket.items.{}.bin_id", item.item_id), - format!( - "current local replica primary bin `{primary_bin_id}` does not match verified primary bin `{verified_primary_bin_id}`" - ), - )); - continue; - } - if item.bin_id != primary_bin_id { - issues.push(basket_issue( - "order_bin_unknown", - format!("basket.items.{}.bin_id", item.item_id), - format!( - "basket bin `{}` is not in the current local listing bin set; expected primary bin `{primary_bin_id}`", - item.bin_id - ), - )); - } - } - Ok(issues) -} - -fn basket_product_bin_state( - config: &RuntimeConfig, - executor: &SqlxSqliteExecutor, - item: &BasketItem, -) -> Result<BasketProductResolution, OperationAdapterError> { - if let Some(listing_addr) = item.listing_addr.as_deref().and_then(non_empty_ref) { - let product_rows = trade_product::find_many( - executor, - &ITradeProductFindMany { - filter: Some(trade_product_listing_addr_filter(listing_addr)), - }, - ) - .map_err(|error| { - OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}")) - })? - .results; - let product = match product_rows.as_slice() { - [] => return Ok(BasketProductResolution::Unresolved), - [product] => product, - rows => return Ok(BasketProductResolution::Ambiguous(rows.len())), - }; - return Ok(BasketProductResolution::Resolved(BasketProductBinState { - primary_bin_id: product.primary_bin_id.clone(), - verified_primary_bin_id: product.verified_primary_bin_id.clone(), - })); - } - - let Some(listing_lookup) = item.listing.as_deref().and_then(non_empty_ref) else { - return Ok(BasketProductResolution::Unresolved); - }; - let lookup_executor = - SqlxSqliteExecutor::open(&config.local.replica_store_path).map_err(|error| { - OperationAdapterError::Runtime(format!( - "open local replica {}: {error}", - config.local.replica_store_path.display() - )) - })?; - let rows = ReplicaSql::new(lookup_executor) - .trade_product_lookup(listing_lookup) - .map_err(|error| { - OperationAdapterError::Runtime(format!("resolve listing product state: {error:?}")) - })?; - let product = match rows.as_slice() { - [] => return Ok(BasketProductResolution::Unresolved), - [product] => product, - rows => return Ok(BasketProductResolution::Ambiguous(rows.len())), - }; - Ok(BasketProductResolution::Resolved(BasketProductBinState { - primary_bin_id: product.primary_bin_id.clone(), - verified_primary_bin_id: product.verified_primary_bin_id.clone(), - })) -} - -fn basket_item_listing_field(item: &BasketItem) -> String { - if item - .listing_addr - .as_deref() - .and_then(non_empty_ref) - .is_some() - { - format!("basket.items.{}.listing_addr", item.item_id) - } else { - format!("basket.items.{}.listing", item.item_id) - } -} - -fn basket_issue( - code: impl Into<String>, - field: impl Into<String>, - message: impl Into<String>, -) -> BasketIssue { - BasketIssue { - code: code.into(), - field: field.into(), - message: message.into(), - } -} - -fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter { - ITradeProductFieldsFilter { - id: None, - created_at: None, - updated_at: None, - key: None, - category: None, - title: None, - summary: None, - process: None, - lot: None, - profile: None, - year: None, - qty_amt: None, - qty_amt_exact: None, - qty_unit: None, - qty_label: None, - qty_avail: None, - price_amt: None, - price_amt_exact: None, - price_currency: None, - price_qty_amt: None, - price_qty_amt_exact: None, - price_qty_unit: None, - listing_addr: Some(listing_addr.to_owned()), - primary_bin_id: None, - verified_primary_bin_id: None, - notes: None, - } -} - -fn non_empty_ref(value: &str) -> Option<&str> { - let value = value.trim(); - if value.is_empty() { None } else { Some(value) } -} - -fn basket_actions(document: &BasketDocument, issues: &[BasketIssue]) -> Vec<String> { - let basket_id = document.basket.basket_id.as_str(); - if document.basket.items.is_empty() { - return vec![format!("radroots basket item add {basket_id}")]; - } - if issues.is_empty() { - vec![ - format!("radroots basket validate {basket_id}"), - format!("radroots basket quote create {basket_id}"), - ] - } else { - vec![format!("radroots basket get {basket_id}")] - } -} - -fn quote_actions(trade: &TradeCandidateDraftView) -> Vec<String> { - if trade.ready_for_submit { - vec![format!("radroots trade proposal submit {}", trade.file)] - } else { - let mut actions = vec![format!("radroots trade get {}", trade.trade_id)]; - actions.extend(trade.actions.iter().cloned()); - actions - } -} - -fn quote_issues_from_trade(trade: &TradeCandidateDraftView) -> Vec<BasketIssue> { - trade - .issues - .iter() - .map(|issue| BasketIssue { - code: issue.code.clone(), - field: issue.field.clone(), - message: issue.message.clone(), - }) - .collect() -} - -fn load_required_basket( - config: &RuntimeConfig, - lookup: &str, - operation_id: &str, -) -> Result<LoadedBasket, OperationAdapterError> { - load_basket_optional(config, lookup)?.ok_or_else(|| { - invalid_input( - operation_id, - format!("basket `{lookup}` was not found; run `radroots basket create` first"), - ) - }) -} - -fn load_basket_optional( - config: &RuntimeConfig, - lookup: &str, -) -> Result<Option<LoadedBasket>, OperationAdapterError> { - let path = basket_lookup_path(config, lookup); - if !path.exists() { - return Ok(None); - } - load_basket_path(path.as_path()).map(Some) -} - -fn load_basket_path(path: &Path) -> Result<LoadedBasket, OperationAdapterError> { - let contents = fs::read_to_string(path).map_err(|error| { - OperationAdapterError::Runtime(format!("read basket {}: {error}", path.display())) - })?; - let document = serde_json::from_str::<BasketDocument>(contents.as_str()).map_err(|error| { - OperationAdapterError::Runtime(format!("parse basket {}: {error}", path.display())) - })?; - Ok(LoadedBasket { - file: path.to_path_buf(), - document, - }) -} - -fn save_basket(path: &Path, document: &BasketDocument) -> Result<(), OperationAdapterError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - OperationAdapterError::Runtime(format!( - "create basket directory {}: {error}", - parent.display() - )) - })?; - } - let contents = serde_json::to_string_pretty(document) - .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?; - fs::write(path, contents).map_err(|error| { - OperationAdapterError::Runtime(format!("write basket {}: {error}", path.display())) - }) -} - -fn baskets_dir(config: &RuntimeConfig) -> PathBuf { - config.paths.app_data_root.join(BASKETS_DIR) -} - -fn basket_lookup_path(config: &RuntimeConfig, lookup: &str) -> PathBuf { - let candidate = PathBuf::from(lookup); - if candidate.is_absolute() || lookup.contains(std::path::MAIN_SEPARATOR) { - return candidate; - } - let file_name = if lookup.ends_with(".json") { - lookup.to_owned() - } else { - format!("{lookup}.json") - }; - baskets_dir(config).join(file_name) -} - -fn touch_basket(document: &mut BasketDocument) { - document.basket.updated_at_unix = now_unix(); -} - -fn next_item_id(document: &BasketDocument) -> String { - for index in 1.. { - let candidate = format!("item_{index}"); - if document - .basket - .items - .iter() - .all(|item| item.item_id != candidate) - { - return candidate; - } - } - unreachable!("unbounded item id search should always return") -} - -fn next_basket_id() -> String { - let sequence = BASKET_COUNTER.fetch_add(1, Ordering::Relaxed) + 1; - format!("basket_{}_{}", now_unix(), sequence) -} - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - -fn required_basket_id<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, "basket_id") - .or_else(|| string_input(request, "key")) - .ok_or_else(|| { - invalid_input( - request.operation_id(), - "missing required `basket_id` input".to_owned(), - ) - }) -} - -fn required_string<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<String, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, key).ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("missing required `{key}` input"), - ) - }) -} - -fn quantity_input<P>(request: &OperationRequest<P>) -> Result<Option<u32>, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - let value = request - .payload - .input() - .get("quantity") - .or_else(|| request.payload.input().get("bin_count")); - let Some(value) = value else { - return Ok(None); - }; - match value { - Value::Number(number) => number - .as_u64() - .and_then(|value| u32::try_from(value).ok()) - .map(Some) - .ok_or_else(|| { - invalid_input( - request.operation_id(), - "`quantity` input must fit in u32".to_owned(), - ) - }), - Value::String(value) => value.parse::<u32>().map(Some).map_err(|error| { - invalid_input( - request.operation_id(), - format!("`quantity` input must be a u32: {error}"), - ) - }), - _ => Err(invalid_input( - request.operation_id(), - "`quantity` input must be a number or string".to_owned(), - )), - } -} - -fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - OperationResult::new(R::from_value(value)) -} - -fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> -where - P: OperationRequestPayload + OperationRequestData, -{ - request - .payload - .input() - .get(key) - .and_then(Value::as_str) - .map(str::to_owned) -} - -fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { - OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message, - } -} diff --git a/src/ops/exec/core.rs b/src/ops/exec/core.rs @@ -1,792 +0,0 @@ -use std::path::PathBuf; - -use crate::runtime::config::RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE; -use serde::Serialize; -use serde_json::{Value, json}; - -use crate::ops::{ - AccountCreateRequest, AccountCreateResult, AccountImportRequest, AccountImportResult, - AccountListRequest, AccountListResult, AccountRemoveRequest, AccountRemoveResult, - AccountSelectRequest, AccountSelectResult, HealthInspectRequest, HealthInspectResult, - OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, - OperationResult, OperationResultData, OperationService, ProfileInspectRequest, - ProfileInspectResult, ProfileResetRequest, ProfileResetResult, StoreBackupRequest, - StoreBackupResult, StoreInspectRequest, StoreInspectResult, StoreRestoreRequest, - StoreRestoreResult, -}; -use crate::out::envelope::OutputWarning; -use crate::runtime::RuntimeError; -use crate::runtime::account::{ - AccountResolution, AccountRuntimeFailure, account_resolution_view, account_summary_view, - create_default_account, import_public_identity, preview_account_removal, - preview_public_identity_import, remove_account, resolve_account_resolution, - resolve_account_selector, secret_backend_status, select_account, snapshot, -}; -use crate::runtime::config::{RuntimeConfig, SignerBackend, TransportProfileKind}; -use crate::runtime::logging::LoggingState; -use crate::runtime::sdk::CliSdkAdapterError; -use crate::runtime::signer::resolve_signer_status; -use crate::view::runtime::{ - CommandDisposition, LocalBackupView, LocalRestoreView, PublishProviderRuntimeView, - PublishRelayRuntimeView, PublishRuntimeView, -}; - -pub struct CoreOperationService<'a> { - config: &'a RuntimeConfig, - logging: &'a LoggingState, -} - -impl<'a> CoreOperationService<'a> { - pub fn new(config: &'a RuntimeConfig, logging: &'a LoggingState) -> Self { - Self { config, logging } - } -} - -impl OperationService<ProfileResetRequest> for CoreOperationService<'_> { - type Result = ProfileResetResult; - - fn execute( - &self, - request: OperationRequest<ProfileResetRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - if request.context.dry_run { - let local = map_runtime(crate::runtime::store::init_preflight(self.config))?; - return json_operation_result::<ProfileResetResult>(json!({ - "state": local.state, - "profile": self.config.paths.profile, - "local": local, - })); - } - - let local = map_runtime(crate::runtime::store::init(self.config))?; - json_operation_result::<ProfileResetResult>(json!({ - "state": local.state, - "profile": self.config.paths.profile, - "local": local, - })) - } -} - -impl OperationService<ProfileInspectRequest> for CoreOperationService<'_> { - type Result = ProfileInspectResult; - - fn execute( - &self, - _request: OperationRequest<ProfileInspectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - json_operation_result::<ProfileInspectResult>(json!({ - "profile": self.config.paths.profile, - "profile_source": self.config.paths.profile_source, - "root_source": self.config.paths.root_source, - "app_namespace": self.config.paths.app_namespace, - "workspace_config_path": self.config.paths.workspace_config_path.as_ref().map(|path| path.display().to_string()), - "app_config_path": self.config.paths.app_config_path.display().to_string(), - "app_data_root": self.config.paths.app_data_root.display().to_string(), - "shared_cache_root": self.config.paths.shared_cache_root.display().to_string(), - "app_logs_root": self.config.paths.app_logs_root.display().to_string(), - "local_root": self.config.local.root.display().to_string(), - "replica_store_path": self.config.local.replica_store_path.display().to_string(), - })) - } -} - -impl OperationService<HealthInspectRequest> for CoreOperationService<'_> { - type Result = HealthInspectResult; - - fn execute( - &self, - request: OperationRequest<HealthInspectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let store = map_sdk_adapter( - request.operation_id(), - crate::runtime::store::status(self.config), - )?; - let account = map_runtime(resolve_account_resolution(self.config))?; - let publish = publish_runtime_view(self.config, true, &account); - let signer = signer_health_view(self.config, &account); - let state = health_status_state(&store.state, &publish); - let actions = health_actions(self.config, store.state.as_str(), &account, &publish); - json_operation_result::<HealthInspectResult>(json!({ - "state": state, - "store": store, - "account_resolution": account_resolution_view(&account), - "signer": signer, - "publish": publish, - "logging": { - "initialized": self.logging.initialized, - "current_file": self.logging.current_file.as_ref().map(|path| path.display().to_string()), - }, - "actions": actions, - })) - } -} - -impl OperationService<AccountCreateRequest> for CoreOperationService<'_> { - type Result = AccountCreateResult; - - fn execute( - &self, - request: OperationRequest<AccountCreateRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - if request.context.dry_run { - let secret_backend = secret_backend_status(self.config); - if secret_backend.state != "ready" { - return Err(OperationAdapterError::OperationUnavailable { - operation_id: request.operation_id().to_owned(), - message: secret_backend - .reason - .unwrap_or_else(|| "account secret backend is not available".to_owned()), - }); - } - return json_operation_result::<AccountCreateResult>(json!({ - "state": "dry_run", - "store_path": self.config.account.store_path.display().to_string(), - "secrets_dir": self.config.account.secrets_dir.display().to_string(), - "secret_backend": { - "state": secret_backend.state, - "active_backend": secret_backend.active_backend, - }, - })); - } - - let result = map_runtime(create_default_account(self.config))?; - json_operation_result::<AccountCreateResult>(json!({ - "state": "created", - "account": account_summary_view(&result.account), - })) - } -} - -impl OperationService<AccountImportRequest> for CoreOperationService<'_> { - type Result = AccountImportResult; - - fn execute( - &self, - request: OperationRequest<AccountImportRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let path = required_path(&request, "path")?; - let make_default = bool_input(&request, "default").unwrap_or(false); - if request.context.dry_run { - let account = map_expected_runtime( - request.operation_id(), - preview_public_identity_import(self.config, path.as_path(), make_default), - )?; - return json_operation_result::<AccountImportResult>(json!({ - "state": "dry_run", - "path": path.display().to_string(), - "default": make_default, - "account": account_summary_view(&account), - })); - } - - let account = map_expected_runtime( - request.operation_id(), - import_public_identity(self.config, path.as_path(), make_default), - )?; - json_operation_result::<AccountImportResult>(json!({ - "state": "imported", - "account": account_summary_view(&account), - })) - } -} - -impl OperationService<AccountListRequest> for CoreOperationService<'_> { - type Result = AccountListResult; - - fn execute( - &self, - _request: OperationRequest<AccountListRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let snapshot = map_runtime(snapshot(self.config))?; - let accounts = snapshot - .accounts - .iter() - .map(account_summary_view) - .collect::<Vec<_>>(); - json_operation_result::<AccountListResult>(json!({ - "source": crate::runtime::account::SHARED_ACCOUNT_STORE_SOURCE, - "count": accounts.len(), - "accounts": accounts, - })) - } -} - -impl OperationService<AccountRemoveRequest> for CoreOperationService<'_> { - type Result = AccountRemoveResult; - - fn execute( - &self, - request: OperationRequest<AccountRemoveRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let selector = required_string(&request, "selector")?; - if request.context.dry_run { - let preview = - preview_account_removal(self.config, selector.as_str()).map_err(|error| { - OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) - })?; - return json_operation_result::<AccountRemoveResult>(json!({ - "state": "dry_run", - "removed_account": account_summary_view(&preview.account), - "default_would_clear": preview.default_would_clear, - "remaining_account_count": preview.remaining_account_count, - })); - } - - let resolved_farm_config = - map_runtime(crate::runtime::farm_config::load(self.config, None))?; - let result = remove_account(self.config, selector.as_str()).map_err(|error| { - OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) - })?; - let removed_account_id = result.removed_account.record.id().to_string(); - let farm_orphan_warning = - account_remove_farm_orphan_warning(resolved_farm_config.as_ref(), &removed_account_id); - let mut result_value = json!({ - "state": "removed", - "removed_account": account_summary_view(&result.removed_account), - "default_cleared": result.default_cleared, - "remaining_account_count": result.remaining_account_count, - }); - if let Some(warning) = farm_orphan_warning.as_ref() { - result_value["warnings"] = json!([warning.result_value()]); - result_value["actions"] = json!(warning.actions.clone()); - } - let mut operation_result = json_operation_result::<AccountRemoveResult>(result_value)?; - if let Some(warning) = farm_orphan_warning { - operation_result.warnings.push(warning.output_warning()); - } - Ok(operation_result) - } -} - -impl OperationService<AccountSelectRequest> for CoreOperationService<'_> { - type Result = AccountSelectResult; - - fn execute( - &self, - request: OperationRequest<AccountSelectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let selector = required_string(&request, "selector")?; - if request.context.dry_run { - let account = - resolve_account_selector(self.config, selector.as_str()).map_err(|error| { - OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) - })?; - return json_operation_result::<AccountSelectResult>(json!({ - "state": "dry_run", - "account": account_summary_view(&account), - })); - } - - let account = select_account(self.config, selector.as_str()).map_err(|error| { - OperationAdapterError::unconfigured(request.operation_id(), error.to_string()) - })?; - json_operation_result::<AccountSelectResult>(json!({ - "state": "default", - "account": account_summary_view(&account), - })) - } -} - -impl OperationService<StoreInspectRequest> for CoreOperationService<'_> { - type Result = StoreInspectResult; - - fn execute( - &self, - request: OperationRequest<StoreInspectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = map_sdk_adapter( - request.operation_id(), - crate::runtime::store::status(self.config), - )?; - serialized_operation_result::<StoreInspectResult, _>(&view) - } -} - -impl OperationService<StoreBackupRequest> for CoreOperationService<'_> { - type Result = StoreBackupResult; - - fn execute( - &self, - request: OperationRequest<StoreBackupRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let output = optional_path(&request, "output") - .unwrap_or_else(|| self.config.local.backups_dir.join("sdk-store-backup")); - if request.context.dry_run { - let view = map_sdk_adapter( - request.operation_id(), - crate::runtime::store::backup_preflight(self.config, output.as_path()), - )?; - return local_backup_result(request.operation_id(), &view); - } - - let view = map_sdk_adapter( - request.operation_id(), - crate::runtime::store::backup(self.config, output.as_path()), - )?; - local_backup_result(request.operation_id(), &view) - } -} - -impl OperationService<StoreRestoreRequest> for CoreOperationService<'_> { - type Result = StoreRestoreResult; - - fn execute( - &self, - request: OperationRequest<StoreRestoreRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let source = required_path(&request, "source")?; - let destination = optional_path(&request, "destination"); - let overwrite = bool_input(&request, "overwrite").unwrap_or(false); - - let view = map_sdk_adapter( - request.operation_id(), - crate::runtime::store::restore( - self.config, - source.as_path(), - destination.as_deref(), - overwrite, - request.context.dry_run, - ), - )?; - local_restore_result(request.operation_id(), &view) - } -} - -fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, - T: Serialize, -{ - OperationResult::new(R::from_serializable(value)?) -} - -fn json_operation_result<R>(value: Value) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - OperationResult::new(R::from_value(value)) -} - -fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { - result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) -} - -fn map_sdk_adapter<T>( - operation_id: &str, - result: Result<T, CliSdkAdapterError>, -) -> Result<T, OperationAdapterError> { - result.map_err(|error| OperationAdapterError::sdk_adapter_failure(operation_id, error)) -} - -fn map_expected_runtime<T>( - operation_id: &str, - result: Result<T, RuntimeError>, -) -> Result<T, OperationAdapterError> { - result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error)) -} - -fn local_backup_result( - operation_id: &str, - view: &LocalBackupView, -) -> Result<OperationResult<StoreBackupResult>, OperationAdapterError> { - match view.disposition() { - CommandDisposition::Success => serialized_operation_result::<StoreBackupResult, _>(view), - disposition => Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - view.reason.clone().unwrap_or_else(|| match disposition { - CommandDisposition::Success => "store backup succeeded".to_owned(), - CommandDisposition::NotFound => "store backup target was not found".to_owned(), - CommandDisposition::ValidationFailed => "store backup validation failed".to_owned(), - CommandDisposition::Unconfigured => "store backup is unconfigured".to_owned(), - CommandDisposition::ExternalUnavailable => "store backup is unavailable".to_owned(), - CommandDisposition::Unsupported => "store backup is unsupported".to_owned(), - CommandDisposition::InternalError => "store backup failed".to_owned(), - }), - )), - } -} - -fn local_restore_result( - operation_id: &str, - view: &LocalRestoreView, -) -> Result<OperationResult<StoreRestoreResult>, OperationAdapterError> { - match view.disposition() { - CommandDisposition::Success => serialized_operation_result::<StoreRestoreResult, _>(view), - disposition => Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - view.reason.clone().unwrap_or_else(|| match disposition { - CommandDisposition::Success => "store restore succeeded".to_owned(), - CommandDisposition::NotFound => "store restore source was not found".to_owned(), - CommandDisposition::ValidationFailed => { - "store restore validation failed".to_owned() - } - CommandDisposition::Unconfigured => "store restore is unconfigured".to_owned(), - CommandDisposition::ExternalUnavailable => { - "store restore is unavailable".to_owned() - } - CommandDisposition::Unsupported => "store restore is unsupported".to_owned(), - CommandDisposition::InternalError => "store restore failed".to_owned(), - }), - )), - } -} - -fn publish_runtime_view( - config: &RuntimeConfig, - signed_write_required: bool, - account: &AccountResolution, -) -> PublishRuntimeView { - let relay_ready = !config.transport.nostr_relay_urls.is_empty(); - let source = config.transport.source.as_str().to_owned(); - let relay = PublishRelayRuntimeView { - ready: relay_ready, - count: config.transport.nostr_relay_urls.len(), - source: config.transport.source.as_str().to_owned(), - }; - - match config.transport.profile { - TransportProfileKind::Nostr | TransportProfileKind::MultiTarget => { - let (state, executable, reason) = - nostr_publish_readiness(config, relay_ready, signed_write_required, account); - PublishRuntimeView { - transport: config.transport.profile.as_str().to_owned(), - source, - transport_family: config.transport.profile.transport_family().to_owned(), - state: state.to_owned(), - executable, - reason: reason.clone(), - signed_write_required, - relay, - provider: PublishProviderRuntimeView { - provider_runtime_id: config.transport.profile.as_str().to_owned(), - state: state.to_owned(), - source: config.transport.source.as_str().to_owned(), - reason, - }, - } - } - TransportProfileKind::LocalOnly => PublishRuntimeView { - transport: config.transport.profile.as_str().to_owned(), - source, - transport_family: config.transport.profile.transport_family().to_owned(), - state: "local_only".to_owned(), - executable: false, - reason: Some( - "local_only transport profile does not perform network publish".to_owned(), - ), - signed_write_required, - relay, - provider: PublishProviderRuntimeView { - provider_runtime_id: "local_only".to_owned(), - state: "local_only".to_owned(), - source: config.transport.source.as_str().to_owned(), - reason: Some("local_only transport profile writes only to local state".to_owned()), - }, - }, - TransportProfileKind::Reticulum => PublishRuntimeView { - transport: config.transport.profile.as_str().to_owned(), - source, - transport_family: config.transport.profile.transport_family().to_owned(), - state: "unavailable".to_owned(), - executable: false, - reason: Some(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE.to_owned()), - signed_write_required, - relay, - provider: PublishProviderRuntimeView { - provider_runtime_id: "reticulum".to_owned(), - state: "unavailable".to_owned(), - source: config.transport.source.as_str().to_owned(), - reason: Some(RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE.to_owned()), - }, - }, - } -} - -fn nostr_publish_readiness( - config: &RuntimeConfig, - relay_ready: bool, - signed_write_required: bool, - account: &AccountResolution, -) -> (&'static str, bool, Option<String>) { - if !relay_ready { - return ( - "unconfigured", - false, - Some( - "Nostr transport profile requires at least one configured Nostr relay for writes" - .to_owned(), - ), - ); - } - - if !signed_write_required { - return ("ready", true, None); - } - - if matches!(config.signer.backend, SignerBackend::Myc) { - let signer = resolve_signer_status(config); - return if signer.state == "ready" { - ("ready", true, None) - } else { - ("unconfigured", false, signer.reason) - }; - } - - let Some(resolved_account) = account.resolved_account.as_ref() else { - return ( - "unconfigured", - false, - Some( - "Nostr transport profile requires a selected or default write-capable local account for signed writes" - .to_owned(), - ), - ); - }; - - if !resolved_account.write_capable { - return ( - "unconfigured", - false, - Some(AccountRuntimeFailure::watch_only(&resolved_account.record.id()).to_string()), - ); - } - - ("ready", true, None) -} - -fn signer_health_view(config: &RuntimeConfig, account: &AccountResolution) -> Value { - match config.signer.backend { - SignerBackend::Local => { - let write_capable = account - .resolved_account - .as_ref() - .map(|account| account.write_capable) - .unwrap_or(false); - json!({ - "state": if write_capable { "ready" } else { "unconfigured" }, - "backend": config.signer.backend.as_str(), - "write_capable_account": write_capable, - "reason": if write_capable { - Value::Null - } else { - json!("local signer requires a selected or default write-capable local account") - }, - }) - } - SignerBackend::Myc => { - let signer = resolve_signer_status(config); - json!({ - "state": signer.state, - "backend": config.signer.backend.as_str(), - "write_capable_account": signer.reason.is_none(), - "reason": signer.reason, - "binding_state": signer.binding.state, - }) - } - } -} - -fn health_status_state(store_state: &str, publish: &PublishRuntimeView) -> &'static str { - if store_state == "ready" && publish_runtime_ready(publish) { - "ready" - } else { - "needs_attention" - } -} - -fn publish_runtime_ready(publish: &PublishRuntimeView) -> bool { - !publish.signed_write_required || publish.executable -} - -fn health_actions( - config: &RuntimeConfig, - store_state: &str, - account: &AccountResolution, - publish: &PublishRuntimeView, -) -> Vec<String> { - let mut actions = Vec::new(); - if store_state != "ready" { - push_unique(&mut actions, "radroots store inspect"); - } - if let Some(resolved) = account.resolved_account.as_ref() { - if !resolved.write_capable { - push_unique(&mut actions, "radroots account create"); - } - } else { - push_unique(&mut actions, "radroots account create"); - } - for action in publish_recovery_actions(config, account, publish) { - push_unique(&mut actions, action); - } - actions -} - -#[derive(Debug, Clone)] -struct AccountRemoveFarmOrphanWarning { - message: String, - subject_account_id: String, - farm_config_scope: String, - farm_config_path: String, - actions: Vec<String>, -} - -impl AccountRemoveFarmOrphanWarning { - const CODE: &'static str = "farm_bound_seller_orphaned"; - - fn result_value(&self) -> Value { - json!({ - "code": Self::CODE, - "message": self.message.clone(), - "subject_account_id": self.subject_account_id.clone(), - "farm_config": { - "scope": self.farm_config_scope.clone(), - "path": self.farm_config_path.clone(), - }, - "actions": self.actions.clone(), - }) - } - - fn output_warning(&self) -> OutputWarning { - OutputWarning { - code: Self::CODE.to_owned(), - message: self.message.clone(), - } - } -} - -fn account_remove_farm_orphan_warning( - resolved: Option<&crate::runtime::farm_config::ResolvedFarmConfig>, - removed_account_id: &str, -) -> Option<AccountRemoveFarmOrphanWarning> { - let resolved = resolved?; - if resolved.document.selection.account != removed_account_id { - return None; - } - Some(AccountRemoveFarmOrphanWarning { - message: format!( - "removed account `{removed_account_id}` is still bound as the farm seller account" - ), - subject_account_id: removed_account_id.to_owned(), - farm_config_scope: resolved.scope.as_str().to_owned(), - farm_config_path: resolved.path.display().to_string(), - actions: crate::runtime::farm::farm_bound_seller_recovery_actions("<selector>"), - }) -} - -fn publish_recovery_actions( - config: &RuntimeConfig, - account: &AccountResolution, - publish: &PublishRuntimeView, -) -> Vec<String> { - if publish.state == "ready" { - return Vec::new(); - } - - let mut actions = Vec::new(); - match config.transport.profile { - TransportProfileKind::Nostr | TransportProfileKind::MultiTarget => { - if config.transport.nostr_relay_urls.is_empty() { - push_unique( - &mut actions, - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com", - ); - } - if publish.signed_write_required { - if matches!(config.signer.backend, SignerBackend::Myc) { - push_unique(&mut actions, "radroots signer status"); - } else if let Some(resolved) = account.resolved_account.as_ref() { - if !resolved.write_capable { - push_unique(&mut actions, "radroots account create"); - } - } else { - push_unique(&mut actions, "radroots account create"); - } - } - } - TransportProfileKind::LocalOnly => { - push_unique( - &mut actions, - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com", - ); - } - TransportProfileKind::Reticulum => { - push_unique( - &mut actions, - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com", - ); - } - } - actions -} - -fn push_unique(actions: &mut Vec<String>, action: impl Into<String>) { - let action = action.into(); - if !actions.contains(&action) { - actions.push(action); - } -} - -fn required_string<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<String, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, key).ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("missing required `{key}` input"), - ) - }) -} - -fn required_path<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<PathBuf, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - optional_path(request, key).ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("missing required `{key}` input"), - ) - }) -} - -fn optional_path<P>(request: &OperationRequest<P>, key: &str) -> Option<PathBuf> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, key).map(PathBuf::from) -} - -fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> -where - P: OperationRequestPayload + OperationRequestData, -{ - request - .payload - .input() - .get(key) - .and_then(Value::as_str) - .map(str::to_owned) -} - -fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> -where - P: OperationRequestPayload + OperationRequestData, -{ - request.payload.input().get(key).and_then(Value::as_bool) -} - -fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { - OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message, - } -} diff --git a/src/ops/exec/farm.rs b/src/ops/exec/farm.rs @@ -1,303 +0,0 @@ -use serde::Serialize; -use serde_json::Value; - -use crate::cli::global::{ - FarmCreateArgs, FarmFieldArg, FarmPublishArgs, FarmScopeArg, FarmScopedArgs, FarmUpdateArgs, -}; -use crate::ops::{ - FarmCreateRequest, FarmCreateResult, FarmGetRequest, FarmGetResult, FarmListRequest, - FarmListResult, FarmPublishRequest, FarmPublishResult, FarmUpdateRequest, FarmUpdateResult, - OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, - OperationResult, OperationResultData, OperationService, -}; -use crate::runtime::RuntimeError; -use crate::runtime::config::{RuntimeConfig, TransportProfileKind}; -use crate::view::runtime::{CommandDisposition, FarmPublishView}; - -pub struct FarmOperationService<'a> { - config: &'a RuntimeConfig, -} - -impl<'a> FarmOperationService<'a> { - pub fn new(config: &'a RuntimeConfig) -> Self { - Self { config } - } -} - -impl OperationService<FarmCreateRequest> for FarmOperationService<'_> { - type Result = FarmCreateResult; - - fn execute( - &self, - request: OperationRequest<FarmCreateRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = FarmCreateArgs { - scope: scope_input(&request)?, - farm_d_tag: string_input(&request, "farm_d_tag"), - name: string_input(&request, "name"), - display_name: string_input(&request, "display_name"), - about: string_input(&request, "about"), - website: string_input(&request, "website"), - picture: string_input(&request, "picture"), - banner: string_input(&request, "banner"), - location: string_input(&request, "location"), - city: string_input(&request, "city"), - region: string_input(&request, "region"), - country: string_input(&request, "country"), - geohash: string_input(&request, "geohash"), - delivery_method: string_input(&request, "delivery_method"), - }; - if request.context.dry_run { - let view = - crate::runtime::farm::init_preflight(self.config, &args).map_err(|error| { - OperationAdapterError::runtime_failure(request.operation_id(), error) - })?; - return serialized_operation_result::<FarmCreateResult, _>(&view); - } - - let view = crate::runtime::farm::init(self.config, &args).map_err(|error| { - OperationAdapterError::runtime_failure(request.operation_id(), error) - })?; - serialized_operation_result::<FarmCreateResult, _>(&view) - } -} - -impl OperationService<FarmGetRequest> for FarmOperationService<'_> { - type Result = FarmGetResult; - - fn execute( - &self, - request: OperationRequest<FarmGetRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = FarmScopedArgs { - scope: scope_input(&request)?, - }; - let view = map_runtime(crate::runtime::farm::get(self.config, &args))?; - serialized_operation_result::<FarmGetResult, _>(&view) - } -} - -impl OperationService<FarmUpdateRequest> for FarmOperationService<'_> { - type Result = FarmUpdateResult; - - fn execute( - &self, - request: OperationRequest<FarmUpdateRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - farm_set::<FarmUpdateResult>(&request, self.config, profile_field(&request)?) - } -} - -impl OperationService<FarmListRequest> for FarmOperationService<'_> { - type Result = FarmListResult; - - fn execute( - &self, - request: OperationRequest<FarmListRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = FarmScopedArgs { - scope: scope_input(&request)?, - }; - let view = map_runtime(crate::runtime::farm::status(self.config, &args))?; - serialized_operation_result::<FarmListResult, _>(&view) - } -} - -impl OperationService<FarmPublishRequest> for FarmOperationService<'_> { - type Result = FarmPublishResult; - - fn execute( - &self, - request: OperationRequest<FarmPublishRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = FarmPublishArgs { - scope: scope_input(&request)?, - idempotency_key: request - .context - .idempotency_key - .clone() - .or_else(|| string_input(&request, "idempotency_key")), - print_event: bool_input(&request, "print_event").unwrap_or(false), - }; - if matches!( - self.config.transport.profile, - TransportProfileKind::Nostr | TransportProfileKind::MultiTarget - ) { - require_nostr_delivery_target(&request, self.config)?; - } - - let view = crate::runtime::farm::publish(self.config, &args).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - farm_publish_result(request.operation_id(), &view) - } -} - -fn farm_set<R>( - request: &OperationRequest<impl OperationRequestData>, - config: &RuntimeConfig, - field: FarmFieldArg, -) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - let value = required_string(request, "value")?; - let args = FarmUpdateArgs { - scope: scope_input(request)?, - field, - value: vec![value.clone()], - }; - if request.context.dry_run { - let view = map_runtime(crate::runtime::farm::set_preflight(config, &args))?; - return serialized_operation_result::<R, _>(&view); - } - - let view = map_runtime(crate::runtime::farm::set(config, &args))?; - serialized_operation_result::<R, _>(&view) -} - -fn profile_field( - request: &OperationRequest<impl OperationRequestData>, -) -> Result<FarmFieldArg, OperationAdapterError> { - match string_input(request, "field").as_deref() { - Some("name") | None => Ok(FarmFieldArg::Name), - Some("display_name") | Some("display-name") => Ok(FarmFieldArg::DisplayName), - Some("about") => Ok(FarmFieldArg::About), - Some("website") => Ok(FarmFieldArg::Website), - Some("picture") => Ok(FarmFieldArg::Picture), - Some("banner") => Ok(FarmFieldArg::Banner), - Some(other) => Err(invalid_input( - request.operation_id(), - format!("profile field `{other}` is not supported"), - )), - } -} - -fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, - T: Serialize, -{ - OperationResult::new(R::from_serializable(value)?) -} - -fn farm_publish_result( - operation_id: &str, - view: &FarmPublishView, -) -> Result<OperationResult<FarmPublishResult>, OperationAdapterError> { - match view.disposition() { - CommandDisposition::Success => serialized_operation_result::<FarmPublishResult, _>(view), - CommandDisposition::ExternalUnavailable - if farm_publish_transport_delivery_unavailable(view) => - { - Err(OperationAdapterError::network_unavailable_with_detail( - operation_id, - view.reason.clone().unwrap_or_else(|| { - format!("farm publish finished with state `{}`", view.state) - }), - serde_json::to_value(view).unwrap_or(Value::Null), - )) - } - disposition => Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - view.reason.clone().unwrap_or_else(|| match disposition { - CommandDisposition::Success => "farm publish succeeded".to_owned(), - CommandDisposition::NotFound => "farm publish target was not found".to_owned(), - CommandDisposition::ValidationFailed => "farm publish validation failed".to_owned(), - CommandDisposition::Unconfigured => "farm publish is unconfigured".to_owned(), - CommandDisposition::ExternalUnavailable => "farm publish is unavailable".to_owned(), - CommandDisposition::Unsupported => "farm publish is unsupported".to_owned(), - CommandDisposition::InternalError => "farm publish failed".to_owned(), - }), - )), - } -} - -fn farm_publish_transport_delivery_unavailable(view: &FarmPublishView) -> bool { - view.state == "partial" - || !view.profile.failed_transport_targets.is_empty() - || !view.farm.failed_transport_targets.is_empty() -} - -fn require_nostr_delivery_target<P>( - request: &OperationRequest<P>, - config: &RuntimeConfig, -) -> Result<(), OperationAdapterError> -where - P: OperationRequestPayload, -{ - if !config.transport.nostr_relay_urls.is_empty() { - return Ok(()); - } - - Err(OperationAdapterError::NetworkUnavailable { - operation_id: request.operation_id().to_owned(), - message: format!( - "`{}` requires at least one configured Nostr relay in the active transport profile", - request.spec.cli_path - ), - }) -} - -fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { - result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) -} - -fn scope_input<P>( - request: &OperationRequest<P>, -) -> Result<Option<FarmScopeArg>, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - match string_input(request, "scope").as_deref() { - Some("user") => Ok(Some(FarmScopeArg::User)), - Some("workspace") => Ok(Some(FarmScopeArg::Workspace)), - Some(other) => Err(invalid_input( - request.operation_id(), - format!("scope must be `user` or `workspace`, got `{other}`"), - )), - None => Ok(None), - } -} - -fn required_string<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<String, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, key).ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("missing required `{key}` input"), - ) - }) -} - -fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> -where - P: OperationRequestPayload + OperationRequestData, -{ - request - .payload - .input() - .get(key) - .and_then(Value::as_str) - .map(str::to_owned) -} - -fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> -where - P: OperationRequestPayload + OperationRequestData, -{ - request.payload.input().get(key).and_then(Value::as_bool) -} - -fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { - OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message, - } -} diff --git a/src/ops/exec/listing.rs b/src/ops/exec/listing.rs @@ -1,507 +0,0 @@ -use std::path::PathBuf; - -use serde::Serialize; -use serde_json::Value; - -use crate::cli::global::{ListingCreateArgs, ListingMutationArgs, RecordLookupArgs}; -use crate::ops::{ - ListingCreateRequest, ListingCreateResult, ListingGetRequest, ListingGetResult, - ListingListRequest, ListingListResult, ListingPauseRequest, ListingPauseResult, - ListingPublishRequest, ListingPublishResult, ListingUpdateRequest, ListingUpdateResult, - ListingWithdrawRequest, ListingWithdrawResult, OperationAdapterError, OperationNetworkMode, - OperationRequest, OperationRequestData, OperationRequestPayload, OperationResult, - OperationResultData, OperationService, -}; -use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; -use crate::view::runtime::{CommandDisposition, ListingMutationView}; - -pub struct ListingOperationService<'a> { - config: &'a RuntimeConfig, -} - -impl<'a> ListingOperationService<'a> { - pub fn new(config: &'a RuntimeConfig) -> Self { - Self { config } - } -} - -impl OperationService<ListingCreateRequest> for ListingOperationService<'_> { - type Result = ListingCreateResult; - - fn execute( - &self, - request: OperationRequest<ListingCreateRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = ListingCreateArgs { - output: optional_path(&request, "output"), - key: string_input(&request, "key"), - title: string_input(&request, "title"), - category: string_input(&request, "category"), - summary: string_input(&request, "summary"), - bin_id: string_input(&request, "bin_id"), - quantity_amount: string_input(&request, "quantity_amount"), - quantity_unit: string_input(&request, "quantity_unit"), - price_amount: string_input(&request, "price_amount"), - price_currency: string_input(&request, "price_currency"), - price_per_amount: string_input(&request, "price_per_amount"), - price_per_unit: string_input(&request, "price_per_unit"), - available: string_input(&request, "available"), - label: string_input(&request, "label"), - discount_id: string_input(&request, "discount_id"), - discount_label: string_input(&request, "discount_label"), - discount_kind: string_input(&request, "discount_kind"), - discount_value: string_input(&request, "discount_value"), - discount_amount: string_input(&request, "discount_amount"), - discount_currency: string_input(&request, "discount_currency"), - }; - if request.context.dry_run { - let view = map_runtime( - request.operation_id(), - crate::runtime::listing::scaffold_preflight(self.config, &args), - )?; - return serialized_operation_result::<ListingCreateResult, _>(&view); - } - - let view = map_runtime( - request.operation_id(), - crate::runtime::listing::scaffold(self.config, &args), - )?; - serialized_operation_result::<ListingCreateResult, _>(&view) - } -} - -impl OperationService<ListingGetRequest> for ListingOperationService<'_> { - type Result = ListingGetResult; - - fn execute( - &self, - request: OperationRequest<ListingGetRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = RecordLookupArgs { - key: required_string(&request, "key")?, - }; - let view = map_runtime( - request.operation_id(), - crate::runtime::listing::get(self.config, &args), - )?; - serialized_operation_result::<ListingGetResult, _>(&view) - } -} - -impl OperationService<ListingListRequest> for ListingOperationService<'_> { - type Result = ListingListResult; - - fn execute( - &self, - request: OperationRequest<ListingListRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = map_runtime( - request.operation_id(), - crate::runtime::listing::list(self.config), - )?; - serialized_operation_result::<ListingListResult, _>(&view) - } -} - -impl OperationService<ListingUpdateRequest> for ListingOperationService<'_> { - type Result = ListingUpdateResult; - - fn execute( - &self, - request: OperationRequest<ListingUpdateRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = mutation_args(&request)?; - let config = mutation_config(self.config, &request); - let view = crate::runtime::listing::update(&config, &args).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - mutation_result::<ListingUpdateResult>(request.operation_id(), &view) - } -} - -impl OperationService<ListingPublishRequest> for ListingOperationService<'_> { - type Result = ListingPublishResult; - - fn execute( - &self, - request: OperationRequest<ListingPublishRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = mutation_args(&request)?; - let config = mutation_config(self.config, &request); - let view = crate::runtime::listing::publish_via_sdk(&config, &args).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - mutation_result::<ListingPublishResult>(request.operation_id(), &view) - } -} - -impl OperationService<ListingPauseRequest> for ListingOperationService<'_> { - type Result = ListingPauseResult; - - fn execute( - &self, - request: OperationRequest<ListingPauseRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = mutation_args(&request)?; - let config = mutation_config(self.config, &request); - let view = crate::runtime::listing::pause(&config, &args).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - mutation_result::<ListingPauseResult>(request.operation_id(), &view) - } -} - -impl OperationService<ListingWithdrawRequest> for ListingOperationService<'_> { - type Result = ListingWithdrawResult; - - fn execute( - &self, - request: OperationRequest<ListingWithdrawRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = mutation_args(&request)?; - let config = mutation_config(self.config, &request); - let view = crate::runtime::listing::withdraw(&config, &args).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - mutation_result::<ListingWithdrawResult>(request.operation_id(), &view) - } -} - -fn mutation_config<P>(config: &RuntimeConfig, request: &OperationRequest<P>) -> RuntimeConfig -where - P: OperationRequestPayload, -{ - let mut config = config.clone(); - if request.context.dry_run { - config.output.dry_run = true; - } - config -} - -fn mutation_args<P>( - request: &OperationRequest<P>, -) -> Result<ListingMutationArgs, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - Ok(ListingMutationArgs { - file: required_path(request, "file")?, - idempotency_key: request - .context - .idempotency_key - .clone() - .or_else(|| string_input(request, "idempotency_key")), - print_event: bool_input(request, "print_event").unwrap_or(false), - offline: matches!(request.context.network_mode, OperationNetworkMode::Offline), - }) -} - -fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, - T: Serialize, -{ - OperationResult::new(R::from_serializable(value)?) -} - -fn mutation_result<R>( - operation_id: &str, - view: &ListingMutationView, -) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - match view.disposition() { - CommandDisposition::Success => serialized_operation_result::<R, _>(view), - CommandDisposition::ExternalUnavailable if listing_transport_delivery_unavailable(view) => { - Err(OperationAdapterError::network_unavailable_with_detail( - operation_id, - view.reason.clone().unwrap_or_else(|| { - format!( - "listing {} finished with state `{}`", - view.operation, view.state - ) - }), - serde_json::to_value(view).unwrap_or(Value::Null), - )) - } - disposition => Err(OperationAdapterError::from_command_disposition( - operation_id, - disposition, - view.reason.clone().unwrap_or_else(|| { - format!( - "listing {} finished with state `{}`", - view.operation, view.state - ) - }), - )), - } -} - -fn listing_transport_delivery_unavailable(view: &ListingMutationView) -> bool { - matches!( - view.source.as_str(), - "Nostr transport publish · local key" | "SDK listing publish · configured signer" - ) && (view.reason.as_deref().is_some_and(|reason| { - reason.contains("configured Nostr relay") - || reason.contains("Nostr transport connection failed") - || reason.contains("SDK transport publish") - }) || !view.target_transport_endpoints.is_empty() - || !view.attempted_transport_endpoints.is_empty() - || !view.failed_transport_targets.is_empty()) -} - -fn map_runtime<T>( - operation_id: &str, - result: Result<T, RuntimeError>, -) -> Result<T, OperationAdapterError> { - result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error)) -} - -fn required_string<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<String, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, key).ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("missing required `{key}` input"), - ) - }) -} - -fn required_path<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<PathBuf, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - optional_path(request, key).ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("missing required `{key}` input"), - ) - }) -} - -fn optional_path<P>(request: &OperationRequest<P>, key: &str) -> Option<PathBuf> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, key).map(PathBuf::from) -} - -fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> -where - P: OperationRequestPayload + OperationRequestData, -{ - request - .payload - .input() - .get(key) - .and_then(Value::as_str) - .map(str::to_owned) -} - -fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> -where - P: OperationRequestPayload + OperationRequestData, -{ - request.payload.input().get(key).and_then(Value::as_bool) -} - -fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { - OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message, - } -} - -#[cfg(test)] -mod tests { - use radroots_secret_vault::RadrootsSecretBackend; - use serde_json::{Map, Value}; - use std::path::{Path, PathBuf}; - use tempfile::tempdir; - - use super::ListingOperationService; - use crate::ops::{ - ListingCreateRequest, ListingListRequest, ListingPublishRequest, ListingWithdrawRequest, - OperationAdapter, OperationContext, OperationData, OperationRequest, - }; - use crate::runtime::config::{ - AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, - LocalConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, RpcConfig, - RuntimeConfig, SignerBackend, SignerConfig, Verbosity, - }; - - #[test] - fn listing_service_requires_seller_actor_for_create_dry_run() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(ListingOperationService::new(&config)); - let context = OperationContext { - dry_run: true, - ..Default::default() - }; - let request = OperationRequest::new( - context.clone(), - ListingCreateRequest::from_data(data(&[("key", "eggs"), ("title", "Eggs")])), - ) - .expect("listing create request"); - let error = service - .execute(request) - .expect_err("listing create seller actor"); - let output_error = error.to_output_error(); - - assert_eq!(output_error.code, "account_unresolved"); - assert!(output_error.detail.expect("detail")["seller_actor_source"] == "resolved_account"); - } - - #[test] - fn listing_service_exposes_listing_list_operation() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(ListingOperationService::new(&config)); - let request = - OperationRequest::new(OperationContext::default(), ListingListRequest::default()) - .expect("listing list request"); - let envelope = service - .execute(request) - .expect("listing list result") - .to_envelope(OperationContext::default().envelope_context("req_listing_list")) - .expect("listing list envelope"); - - assert_eq!(envelope.operation_id, "listing.list"); - assert_eq!(envelope.result["state"], "empty"); - assert_eq!(envelope.result["count"], 0); - } - - #[test] - fn listing_publish_and_withdraw_errors_do_not_use_retired_approval_language() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(ListingOperationService::new(&config)); - let retired_approval_language = ["approval", "token"].join("_"); - let publish = OperationRequest::new( - OperationContext::default(), - ListingPublishRequest::from_data(data(&[("file", "listing.toml")])), - ) - .expect("listing publish request"); - let publish_error = service.execute(publish).expect_err("publish preflight"); - assert!(!format!("{publish_error}").contains(retired_approval_language.as_str())); - - let context = OperationContext { - dry_run: true, - ..Default::default() - }; - let withdraw = OperationRequest::new( - context.clone(), - ListingWithdrawRequest::from_data(data(&[("file", "listing.toml")])), - ) - .expect("listing withdraw request"); - let withdraw_error = service.execute(withdraw).expect_err("withdraw preflight"); - assert!(!format!("{withdraw_error}").contains(retired_approval_language.as_str())); - } - - fn sample_config(root: &Path) -> RuntimeConfig { - let data = root.join("data"); - let cache = root.join("cache"); - let logs = root.join("logs"); - let secrets = root.join("secrets"); - RuntimeConfig { - output: OutputConfig { - format: OutputFormat::Terminal, - verbosity: Verbosity::Normal, - dry_run: false, - }, - interaction: InteractionConfig { - input_enabled: true, - assume_yes: false, - stdin_tty: false, - stdout_tty: false, - prompts_allowed: false, - confirmations_allowed: false, - }, - paths: PathsConfig { - profile: "interactive_user".into(), - profile_source: "test".into(), - allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], - root_source: "test".into(), - repo_local_root: None, - repo_local_root_source: None, - subordinate_path_override_source: "runtime_config".into(), - app_namespace: "apps/cli".into(), - shared_accounts_namespace: "shared/accounts".into(), - shared_identities_namespace: "shared/identities".into(), - app_config_path: root.join("config/apps/cli/config.toml"), - workspace_config_path: None, - app_data_root: data.join("apps/cli"), - shared_cache_root: cache.clone(), - app_logs_root: logs.join("apps/cli"), - shared_accounts_data_root: data.join("shared/accounts"), - shared_accounts_secrets_root: secrets.join("shared/accounts"), - default_identity_path: secrets.join("shared/identities/default.json"), - }, - logging: LoggingConfig { - filter: "info".into(), - directory: None, - stdout: false, - }, - account: AccountConfig { - selector: None, - store_path: data.join("shared/accounts/store.json"), - secrets_dir: secrets.join("shared/accounts"), - secret_backend: RadrootsSecretBackend::EncryptedFile, - }, - account_secret_contract: AccountSecretContractConfig { - default_backend: "host_vault".into(), - allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], - host_vault_policy: Some("desktop".into()), - uses_protected_store: true, - }, - identity: IdentityConfig { - path: secrets.join("shared/identities/default.json"), - }, - signer: SignerConfig { - backend: SignerBackend::Local, - }, - transport: crate::runtime::config::TransportConfig::local_only(), - local: LocalConfig { - root: data.join("apps/cli/replica"), - replica_store_path: data.join("apps/cli/replica/replica.sqlite"), - backups_dir: data.join("apps/cli/replica/backups"), - exports_dir: data.join("apps/cli/replica/exports"), - }, - myc: MycConfig { - executable: PathBuf::from("myc"), - status_timeout_ms: 2_000, - }, - hyf: HyfConfig { - enabled: false, - executable: PathBuf::from("hyfd"), - }, - mesh: crate::runtime::config::MeshConfig::disabled(), - rpc: RpcConfig { - url: "http://127.0.0.1:7070".into(), - }, - rhi: crate::runtime::config::RhiConfig { - validator_set: None, - require_cryptographic_proof: false, - }, - capability_bindings: Vec::new(), - } - } - - fn data(entries: &[(&str, &str)]) -> OperationData { - entries - .iter() - .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) - .collect::<Map<String, Value>>() - } -} diff --git a/src/ops/exec/market.rs b/src/ops/exec/market.rs @@ -1,708 +0,0 @@ -use serde::Serialize; -use serde_json::Value; - -use crate::cli::global::{FindQueryArgs, RecordLookupArgs}; -use crate::ops::{ - MarketGetRequest, MarketGetResult, MarketPullRequest, MarketPullResult, MarketSearchRequest, - MarketSearchResult, OperationAdapterError, OperationRequest, OperationRequestData, - OperationRequestPayload, OperationResult, OperationResultData, OperationService, -}; -use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; -use crate::view::runtime::{FindView, ListingGetView, SyncActionView}; - -pub struct MarketOperationService<'a> { - config: &'a RuntimeConfig, -} - -impl<'a> MarketOperationService<'a> { - pub fn new(config: &'a RuntimeConfig) -> Self { - Self { config } - } -} - -impl OperationService<MarketPullRequest> for MarketOperationService<'_> { - type Result = MarketPullResult; - - fn execute( - &self, - _request: OperationRequest<MarketPullRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = market_refresh_view(map_runtime(crate::runtime::sync::market_refresh( - self.config, - ))?); - serialized_operation_result::<MarketPullResult, _>(&view) - } -} - -impl OperationService<MarketSearchRequest> for MarketOperationService<'_> { - type Result = MarketSearchResult; - - fn execute( - &self, - request: OperationRequest<MarketSearchRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = FindQueryArgs { - query: required_query_terms(&request)?, - }; - let view = market_product_search_view(map_runtime(crate::runtime::find::search( - self.config, - &args, - ))?); - serialized_operation_result::<MarketSearchResult, _>(&view) - } -} - -impl OperationService<MarketGetRequest> for MarketOperationService<'_> { - type Result = MarketGetResult; - - fn execute( - &self, - request: OperationRequest<MarketGetRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = RecordLookupArgs { - key: required_lookup(&request)?, - }; - let view = market_listing_get_view(map_runtime(crate::runtime::listing::get( - self.config, - &args, - ))?); - serialized_operation_result::<MarketGetResult, _>(&view) - } -} - -fn market_refresh_view(mut view: SyncActionView) -> SyncActionView { - view.actions = match view.state.as_str() { - "ready" => vec!["radroots market search tomatoes".to_owned()], - "unavailable" => vec!["radroots sync status".to_owned()], - "unconfigured" => { - let mut actions = Vec::new(); - if view.replica_store == "missing" { - actions.push("radroots store inspect".to_owned()); - } - if view.configured_transport_target_count == 0 { - actions.push("radroots transport config update --kind nostr --nostr-relay wss://relay.example.com".to_owned()); - } - if actions.is_empty() { - actions.extend(std::mem::take(&mut view.actions)); - } - actions - } - _ => std::mem::take(&mut view.actions), - }; - view -} - -fn market_product_search_view(mut view: FindView) -> FindView { - view.actions = match view.state.as_str() { - "ready" => view - .results - .first() - .map(|result| { - let mut actions = vec![format!("radroots market get {}", result.product_key)]; - if result.readiness.checkout_enabled { - actions.push("radroots basket create".to_owned()); - } - actions - }) - .unwrap_or_default(), - "empty" => vec![ - "radroots market pull".to_owned(), - "radroots market search eggs".to_owned(), - ], - "unconfigured" => vec![ - "radroots store inspect".to_owned(), - "radroots market pull".to_owned(), - ], - _ => std::mem::take(&mut view.actions), - }; - view -} - -fn market_listing_get_view(mut view: ListingGetView) -> ListingGetView { - view.actions = match view.state.as_str() { - "ready" => { - if view.readiness.checkout_enabled { - vec!["radroots basket create".to_owned()] - } else { - Vec::new() - } - } - "missing" => vec![ - "radroots market search tomatoes".to_owned(), - "radroots market pull".to_owned(), - ], - "unconfigured" => vec![ - "radroots store inspect".to_owned(), - "radroots market pull".to_owned(), - ], - _ => std::mem::take(&mut view.actions), - }; - view -} - -fn required_query_terms<P>( - request: &OperationRequest<P>, -) -> Result<Vec<String>, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - let input = request.payload.input(); - let Some(value) = input.get("query").or_else(|| input.get("terms")) else { - return Err(invalid_input( - request.operation_id(), - "missing required `query` input".to_owned(), - )); - }; - let terms = match value { - Value::String(value) => value - .split_whitespace() - .map(str::trim) - .filter(|term| !term.is_empty()) - .map(str::to_owned) - .collect::<Vec<_>>(), - Value::Array(values) => values - .iter() - .map(|value| { - value.as_str().map(str::to_owned).ok_or_else(|| { - invalid_input( - request.operation_id(), - "`query` array entries must be strings".to_owned(), - ) - }) - }) - .collect::<Result<Vec<_>, _>>()?, - _ => { - return Err(invalid_input( - request.operation_id(), - "`query` input must be a string or string array".to_owned(), - )); - } - }; - - if terms.is_empty() { - return Err(invalid_input( - request.operation_id(), - "`query` input must not be empty".to_owned(), - )); - } - Ok(terms) -} - -fn required_lookup<P>(request: &OperationRequest<P>) -> Result<String, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, "key") - .or_else(|| string_input(request, "listing_id")) - .or_else(|| string_input(request, "listing")) - .ok_or_else(|| { - invalid_input( - request.operation_id(), - "missing required `key` input".to_owned(), - ) - }) -} - -fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, - T: Serialize, -{ - OperationResult::new(R::from_serializable(value)?) -} - -fn map_runtime<T>(result: Result<T, RuntimeError>) -> Result<T, OperationAdapterError> { - result.map_err(|error| OperationAdapterError::Runtime(error.to_string())) -} - -fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> -where - P: OperationRequestPayload + OperationRequestData, -{ - request - .payload - .input() - .get(key) - .and_then(Value::as_str) - .map(str::to_owned) -} - -fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { - OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message, - } -} - -#[cfg(test)] -mod tests { - use radroots_secret_vault::RadrootsSecretBackend; - use serde_json::{Map, Value}; - use std::path::{Path, PathBuf}; - use tempfile::tempdir; - - use super::{MarketOperationService, market_listing_get_view, market_product_search_view}; - use crate::ops::{ - MarketGetRequest, MarketPullRequest, MarketSearchRequest, OperationAdapter, - OperationContext, OperationData, OperationRequest, - }; - use crate::runtime::config::{ - AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, - LocalConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, RpcConfig, - RuntimeConfig, SignerBackend, SignerConfig, Verbosity, - }; - use crate::view::runtime::{ - FindPriceView, FindQuantityView, FindResultProvenanceView, FindResultView, FindView, - ListingGetView, MarketReadinessView, SyncFreshnessView, - }; - - const LISTING_ADDR: &str = "30402:1111111111111111111111111111111111111111111111111111111111111111:AAAAAAAAAAAAAAAAAAAAAg"; - - #[test] - fn market_refresh_preserves_unconfigured_ingest_truth() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(MarketOperationService::new(&config)); - let request = - OperationRequest::new(OperationContext::default(), MarketPullRequest::default()) - .expect("market refresh request"); - let envelope = service - .execute(request) - .expect("market refresh result") - .to_envelope(OperationContext::default().envelope_context("req_market_refresh")) - .expect("market refresh envelope"); - - assert_eq!(envelope.operation_id, "market.pull"); - assert_eq!(envelope.result["state"], "unconfigured"); - assert_eq!(envelope.result["direction"], "pull"); - assert_eq!( - envelope.result["actions"][0], - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com" - ); - } - - #[test] - fn market_refresh_supports_dry_run() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(MarketOperationService::new(&config)); - let context = OperationContext { - dry_run: true, - ..Default::default() - }; - let request = OperationRequest::new(context.clone(), MarketPullRequest::default()) - .expect("market refresh request"); - let envelope = service - .execute(request) - .expect("market refresh dry run") - .to_envelope(context.envelope_context("req_market_refresh")) - .expect("market refresh envelope"); - - assert_eq!(envelope.operation_id, "market.pull"); - assert!(envelope.dry_run); - assert_eq!(envelope.result["state"], "unconfigured"); - assert_eq!(envelope.result["replica_store"], "canonical"); - assert_eq!(envelope.result["direction"], "pull"); - } - - #[test] - fn market_refresh_dry_run_skips_relay_fetch_when_store_is_ready() { - let dir = tempdir().expect("tempdir"); - let mut config = sample_config(dir.path()); - config.output.dry_run = true; - config.transport = crate::runtime::config::TransportConfig::from_nostr_relay_urls(vec![ - "wss://relay.example.com".to_owned(), - ]); - crate::runtime::store::init(&config).expect("store init"); - - let service = OperationAdapter::new(MarketOperationService::new(&config)); - let context = OperationContext { - dry_run: true, - ..Default::default() - }; - let request = OperationRequest::new(context.clone(), MarketPullRequest::default()) - .expect("market refresh request"); - let envelope = service - .execute(request) - .expect("market refresh dry run") - .to_envelope(context.envelope_context("req_market_refresh")) - .expect("market refresh envelope"); - - assert_eq!(envelope.operation_id, "market.pull"); - assert_eq!(envelope.result["state"], "dry_run"); - assert_eq!( - envelope.result["target_transport_endpoints"][0], - "wss://relay.example.com" - ); - assert_eq!(envelope.result["fetched_count"], 0); - assert_eq!(envelope.result["ingested_count"], 0); - assert_eq!(envelope.result["skipped_count"], 0); - assert_eq!(envelope.result["unsupported_count"], 0); - assert!( - envelope.result["reason"] - .as_str() - .expect("reason") - .contains("dry run") - ); - } - - #[test] - fn market_refresh_no_relay_action_is_actionable() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - crate::runtime::store::init(&config).expect("store init"); - let service = OperationAdapter::new(MarketOperationService::new(&config)); - let request = - OperationRequest::new(OperationContext::default(), MarketPullRequest::default()) - .expect("market refresh request"); - let envelope = service - .execute(request) - .expect("market refresh result") - .to_envelope(OperationContext::default().envelope_context("req_market_refresh")) - .expect("market refresh envelope"); - - assert_eq!(envelope.result["state"], "unconfigured"); - assert_eq!( - envelope.result["actions"][0], - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com" - ); - } - - #[test] - fn market_product_search_uses_find_runtime_without_top_level_find() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(MarketOperationService::new(&config)); - let request = OperationRequest::new( - OperationContext::default(), - MarketSearchRequest::from_data(data(&[("query", "eggs")])), - ) - .expect("market product search request"); - let envelope = service - .execute(request) - .expect("market product search result") - .to_envelope(OperationContext::default().envelope_context("req_market_search")) - .expect("market product search envelope"); - - assert_eq!(envelope.operation_id, "market.search"); - assert_eq!(envelope.result["state"], "unconfigured"); - assert_eq!(envelope.result["query"], "eggs"); - assert_eq!(envelope.result["actions"][0], "radroots store inspect"); - } - - #[test] - fn market_listing_get_requires_lookup_key() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(MarketOperationService::new(&config)); - let request = - OperationRequest::new(OperationContext::default(), MarketGetRequest::default()) - .expect("market listing get request"); - let error = service.execute(request).expect_err("key required"); - - assert!(format!("{error}").contains("`key`")); - } - - #[test] - fn market_listing_get_wraps_listing_runtime_with_target_actions() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path()); - let service = OperationAdapter::new(MarketOperationService::new(&config)); - let request = OperationRequest::new( - OperationContext::default(), - MarketGetRequest::from_data(data(&[("key", "eggs")])), - ) - .expect("market listing get request"); - let envelope = service - .execute(request) - .expect("market listing get result") - .to_envelope(OperationContext::default().envelope_context("req_market_listing")) - .expect("market listing get envelope"); - - assert_eq!(envelope.operation_id, "market.get"); - assert_eq!(envelope.result["state"], "unconfigured"); - assert_eq!(envelope.result["actions"][0], "radroots store inspect"); - } - - #[test] - fn market_ready_actions_do_not_emit_incomplete_basket_item_add_commands() { - let search = market_product_search_view(FindView { - state: "ready".to_owned(), - source: "test".to_owned(), - query: "eggs".to_owned(), - count: 1, - relay_count: 1, - replica_store: "ready".to_owned(), - freshness: freshness(), - results: vec![FindResultView { - id: "listing_eggs".to_owned(), - product_key: "eggs".to_owned(), - readiness: readiness_enabled(), - listing_addr: Some(LISTING_ADDR.to_owned()), - primary_bin_id: Some("bin-1".to_owned()), - title: "Eggs".to_owned(), - category: "eggs".to_owned(), - summary: None, - location_primary: None, - available: quantity(), - price: price(), - provenance: provenance(), - hyf: None, - }], - hyf: None, - reason: None, - actions: Vec::new(), - }); - - assert_eq!( - search.actions, - vec![ - "radroots market get eggs".to_owned(), - "radroots basket create".to_owned() - ] - ); - - let listing = market_listing_get_view(ListingGetView { - state: "ready".to_owned(), - source: "test".to_owned(), - lookup: "eggs".to_owned(), - readiness: readiness_enabled(), - listing_id: Some("listing_eggs".to_owned()), - product_key: Some("eggs".to_owned()), - listing_addr: Some(LISTING_ADDR.to_owned()), - primary_bin_id: Some("bin-1".to_owned()), - title: Some("Eggs".to_owned()), - category: Some("eggs".to_owned()), - description: None, - location_primary: None, - available: Some(quantity()), - price: Some(price()), - provenance: provenance(), - reason: None, - actions: Vec::new(), - }); - - assert_eq!(listing.actions, vec!["radroots basket create".to_owned()]); - assert!( - search - .actions - .iter() - .chain(listing.actions.iter()) - .all(|action| !action.starts_with("radroots basket item add ")) - ); - } - - #[test] - fn market_ready_actions_require_checkout_enabled() { - let disabled_search = market_product_search_view(FindView { - state: "ready".to_owned(), - source: "test".to_owned(), - query: "eggs".to_owned(), - count: 1, - relay_count: 1, - replica_store: "ready".to_owned(), - freshness: freshness(), - results: vec![FindResultView { - id: "listing_eggs".to_owned(), - product_key: "eggs".to_owned(), - readiness: readiness_disabled(), - listing_addr: Some(LISTING_ADDR.to_owned()), - primary_bin_id: Some("bin-1".to_owned()), - title: "Eggs".to_owned(), - category: "eggs".to_owned(), - summary: None, - location_primary: None, - available: quantity(), - price: price(), - provenance: provenance(), - hyf: None, - }], - hyf: None, - reason: None, - actions: Vec::new(), - }); - - assert_eq!( - disabled_search.actions, - vec!["radroots market get eggs".to_owned()] - ); - - let disabled_listing = market_listing_get_view(ListingGetView { - state: "ready".to_owned(), - source: "test".to_owned(), - lookup: "eggs".to_owned(), - readiness: readiness_disabled(), - listing_id: Some("listing_eggs".to_owned()), - product_key: Some("eggs".to_owned()), - listing_addr: Some(LISTING_ADDR.to_owned()), - primary_bin_id: Some("bin-1".to_owned()), - title: Some("Eggs".to_owned()), - category: Some("eggs".to_owned()), - description: None, - location_primary: None, - available: Some(quantity()), - price: Some(price()), - provenance: provenance(), - reason: None, - actions: Vec::new(), - }); - - assert!(disabled_listing.actions.is_empty()); - } - - fn freshness() -> SyncFreshnessView { - SyncFreshnessView { - state: "fresh".to_owned(), - display: "fresh".to_owned(), - age_seconds: Some(0), - last_event_at: Some(0), - run: None, - } - } - - fn provenance() -> FindResultProvenanceView { - FindResultProvenanceView { - origin: "fixture".to_owned(), - freshness: "fresh".to_owned(), - relay_count: 1, - } - } - - fn quantity() -> FindQuantityView { - FindQuantityView { - total_amount: 1.0, - total_unit: "each".to_owned(), - label: None, - available_amount: Some(1), - } - } - - fn price() -> FindPriceView { - FindPriceView { - amount: 6.0, - currency: "USD".to_owned(), - per_amount: 1.0, - per_unit: "each".to_owned(), - } - } - - fn readiness_enabled() -> MarketReadinessView { - MarketReadinessView { - protocol_valid: true, - marketplace_eligible: true, - checkout_enabled: true, - primary_bin_verified: true, - reason_codes: Vec::new(), - } - } - - fn readiness_disabled() -> MarketReadinessView { - MarketReadinessView { - protocol_valid: true, - marketplace_eligible: true, - checkout_enabled: false, - primary_bin_verified: true, - reason_codes: vec![ - "listing_checkout_disabled".to_owned(), - "listing_inventory_unavailable".to_owned(), - ], - } - } - - fn sample_config(root: &Path) -> RuntimeConfig { - let data = root.join("data"); - let cache = root.join("cache"); - let logs = root.join("logs"); - let secrets = root.join("secrets"); - RuntimeConfig { - output: OutputConfig { - format: OutputFormat::Terminal, - verbosity: Verbosity::Normal, - dry_run: false, - }, - interaction: InteractionConfig { - input_enabled: true, - assume_yes: false, - stdin_tty: false, - stdout_tty: false, - prompts_allowed: false, - confirmations_allowed: false, - }, - paths: PathsConfig { - profile: "interactive_user".into(), - profile_source: "test".into(), - allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], - root_source: "test".into(), - repo_local_root: None, - repo_local_root_source: None, - subordinate_path_override_source: "runtime_config".into(), - app_namespace: "apps/cli".into(), - shared_accounts_namespace: "shared/accounts".into(), - shared_identities_namespace: "shared/identities".into(), - app_config_path: root.join("config/apps/cli/config.toml"), - workspace_config_path: None, - app_data_root: data.join("apps/cli"), - shared_cache_root: cache.clone(), - app_logs_root: logs.join("apps/cli"), - shared_accounts_data_root: data.join("shared/accounts"), - shared_accounts_secrets_root: secrets.join("shared/accounts"), - default_identity_path: secrets.join("shared/identities/default.json"), - }, - logging: LoggingConfig { - filter: "info".into(), - directory: None, - stdout: false, - }, - account: AccountConfig { - selector: None, - store_path: data.join("shared/accounts/store.json"), - secrets_dir: secrets.join("shared/accounts"), - secret_backend: RadrootsSecretBackend::EncryptedFile, - }, - account_secret_contract: AccountSecretContractConfig { - default_backend: "host_vault".into(), - allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], - host_vault_policy: Some("desktop".into()), - uses_protected_store: true, - }, - identity: IdentityConfig { - path: secrets.join("shared/identities/default.json"), - }, - signer: SignerConfig { - backend: SignerBackend::Local, - }, - transport: crate::runtime::config::TransportConfig::local_only(), - local: LocalConfig { - root: data.join("apps/cli/replica"), - replica_store_path: data.join("apps/cli/replica/replica.sqlite"), - backups_dir: data.join("apps/cli/replica/backups"), - exports_dir: data.join("apps/cli/replica/exports"), - }, - myc: MycConfig { - executable: PathBuf::from("myc"), - status_timeout_ms: 2_000, - }, - hyf: HyfConfig { - enabled: false, - executable: PathBuf::from("hyfd"), - }, - mesh: crate::runtime::config::MeshConfig::disabled(), - rpc: RpcConfig { - url: "http://127.0.0.1:7070".into(), - }, - rhi: crate::runtime::config::RhiConfig { - validator_set: None, - require_cryptographic_proof: false, - }, - capability_bindings: Vec::new(), - } - } - - fn data(entries: &[(&str, &str)]) -> OperationData { - entries - .iter() - .map(|(key, value)| ((*key).to_owned(), Value::String((*value).to_owned()))) - .collect::<Map<String, Value>>() - } -} diff --git a/src/ops/exec/mod.rs b/src/ops/exec/mod.rs @@ -1,17 +0,0 @@ -pub mod basket; -pub mod core; -pub mod farm; -pub mod listing; -pub mod market; -pub mod runtime; -pub mod trade; -pub mod validation; - -pub use basket::BasketOperationService; -pub use core::CoreOperationService; -pub use farm::FarmOperationService; -pub use listing::ListingOperationService; -pub use market::MarketOperationService; -pub use runtime::RuntimeOperationService; -pub use trade::TradeOperationService; -pub use validation::ValidationOperationService; diff --git a/src/ops/exec/runtime.rs b/src/ops/exec/runtime.rs @@ -1,445 +0,0 @@ -use serde::Serialize; -use serde_json::{Value, json}; - -use crate::ops::{ - DiagnosticsInspectRequest, DiagnosticsInspectResult, OperationAdapterError, OperationRequest, - OperationRequestData, OperationResult, OperationResultData, OperationService, - SignerStatusRequest, SignerStatusResult, SyncPullRequest, SyncPullResult, SyncPushRequest, - SyncPushResult, SyncStatusRequest, SyncStatusResult, TransportCapabilityListRequest, - TransportCapabilityListResult, TransportConfigInspectRequest, TransportConfigInspectResult, - TransportConfigUpdateRequest, TransportConfigUpdateResult, TransportDeliveryInspectRequest, - TransportDeliveryInspectResult, TransportDeliveryRetryRequest, TransportDeliveryRetryResult, - TransportStatusInspectRequest, TransportStatusInspectResult, -}; -use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; -use crate::view::runtime::{CommandDisposition, SyncActionView, SyncStatusView}; - -pub struct RuntimeOperationService<'a> { - config: &'a RuntimeConfig, -} - -impl<'a> RuntimeOperationService<'a> { - pub fn new(config: &'a RuntimeConfig) -> Self { - Self { config } - } -} - -impl OperationService<SignerStatusRequest> for RuntimeOperationService<'_> { - type Result = SignerStatusResult; - - fn execute( - &self, - _request: OperationRequest<SignerStatusRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = crate::runtime::signer::resolve_signer_status(self.config); - serialized_operation_result::<SignerStatusResult, _>(&view) - } -} - -impl OperationService<TransportConfigInspectRequest> for RuntimeOperationService<'_> { - type Result = TransportConfigInspectResult; - - fn execute( - &self, - _request: OperationRequest<TransportConfigInspectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = crate::runtime::transport::profile(self.config); - serialized_operation_result::<TransportConfigInspectResult, _>(&view) - } -} - -impl OperationService<TransportConfigUpdateRequest> for RuntimeOperationService<'_> { - type Result = TransportConfigUpdateResult; - - fn execute( - &self, - request: OperationRequest<TransportConfigUpdateRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = map_runtime( - request.operation_id(), - crate::runtime::transport::set_profile(self.config, request.payload.input()), - )?; - serialized_operation_result::<TransportConfigUpdateResult, _>(&view) - } -} - -impl OperationService<TransportCapabilityListRequest> for RuntimeOperationService<'_> { - type Result = TransportCapabilityListResult; - - fn execute( - &self, - _request: OperationRequest<TransportCapabilityListRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let status = crate::runtime::transport::status(self.config); - let view = json!({ - "state": "ready", - "source": "Runtime Contract V1 transport capability registry", - "active_profile": status.active_profile, - "transports": status.transports, - }); - serialized_operation_result::<TransportCapabilityListResult, _>(&view) - } -} - -impl OperationService<TransportStatusInspectRequest> for RuntimeOperationService<'_> { - type Result = TransportStatusInspectResult; - - fn execute( - &self, - _request: OperationRequest<TransportStatusInspectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = crate::runtime::transport::status(self.config); - serialized_operation_result::<TransportStatusInspectResult, _>(&view) - } -} - -impl OperationService<TransportDeliveryInspectRequest> for RuntimeOperationService<'_> { - type Result = TransportDeliveryInspectResult; - - fn execute( - &self, - _request: OperationRequest<TransportDeliveryInspectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = crate::runtime::transport::outbox_status(self.config).map_err(|error| { - OperationAdapterError::sdk_adapter_failure("transport.delivery.inspect", error) - })?; - serialized_operation_result::<TransportDeliveryInspectResult, _>(&view) - } -} - -impl OperationService<TransportDeliveryRetryRequest> for RuntimeOperationService<'_> { - type Result = TransportDeliveryRetryResult; - - fn execute( - &self, - request: OperationRequest<TransportDeliveryRetryRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = crate::runtime::transport::outbox_push(self.config).map_err(|error| { - OperationAdapterError::sdk_adapter_failure(request.operation_id(), error) - })?; - serialized_operation_result::<TransportDeliveryRetryResult, _>(&view) - } -} - -impl OperationService<SyncStatusRequest> for RuntimeOperationService<'_> { - type Result = SyncStatusResult; - - fn execute( - &self, - _request: OperationRequest<SyncStatusRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = crate::runtime::sync::status(self.config) - .map_err(|error| OperationAdapterError::sdk_adapter_failure("sync.status", error))?; - sync_status_result(&view) - } -} - -impl OperationService<SyncPullRequest> for RuntimeOperationService<'_> { - type Result = SyncPullResult; - - fn execute( - &self, - _request: OperationRequest<SyncPullRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = map_runtime("sync.pull", crate::runtime::sync::pull(self.config))?; - sync_action_result::<SyncPullResult>("sync.pull", &view) - } -} - -impl OperationService<SyncPushRequest> for RuntimeOperationService<'_> { - type Result = SyncPushResult; - - fn execute( - &self, - _request: OperationRequest<SyncPushRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let view = crate::runtime::sync::push(self.config) - .map_err(|error| OperationAdapterError::sdk_adapter_failure("sync.push", error))?; - sync_action_result::<SyncPushResult>("sync.push", &view) - } -} - -impl OperationService<DiagnosticsInspectRequest> for RuntimeOperationService<'_> { - type Result = DiagnosticsInspectResult; - - fn execute( - &self, - _request: OperationRequest<DiagnosticsInspectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let signer = crate::runtime::signer::resolve_signer_status(self.config); - let transport = crate::runtime::transport::status(self.config); - let sync = match crate::runtime::sync::status(self.config) { - Ok(view) => serde_json::to_value(view).unwrap_or_else(|_| json!({ "state": "error" })), - Err(error) => json!({ - "state": "unavailable", - "reason": error.to_string(), - }), - }; - let state = if sync.get("state").and_then(Value::as_str) == Some("unavailable") { - "degraded" - } else { - "ready" - }; - let view = json!({ - "state": state, - "source": "Runtime Contract V1 diagnostics", - "signer": signer, - "transport": transport, - "sync": sync, - }); - serialized_operation_result::<DiagnosticsInspectResult, _>(&view) - } -} - -fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, - T: Serialize, -{ - OperationResult::new(R::from_serializable(value)?) -} - -fn sync_status_result( - view: &SyncStatusView, -) -> Result<OperationResult<SyncStatusResult>, OperationAdapterError> { - match view.disposition() { - CommandDisposition::Success => serialized_operation_result::<SyncStatusResult, _>(view), - disposition => Err(sync_view_error( - "sync.status", - disposition, - view, - view.reason.as_deref(), - )), - } -} - -fn sync_action_result<R>( - operation_id: &str, - view: &SyncActionView, -) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, -{ - match view.disposition() { - CommandDisposition::Success => serialized_operation_result::<R, _>(view), - disposition => Err(sync_view_error( - operation_id, - disposition, - view, - view.reason.as_deref(), - )), - } -} - -fn sync_view_error<T>( - operation_id: &str, - disposition: CommandDisposition, - view: &T, - reason: Option<&str>, -) -> OperationAdapterError -where - T: Serialize, -{ - let detail = serde_json::to_value(view).unwrap_or_else(|_| Value::Object(Default::default())); - let message = reason - .map(str::to_owned) - .unwrap_or_else(|| format!("`{operation_id}` is not ready")); - match disposition { - CommandDisposition::Unconfigured => { - OperationAdapterError::operation_unavailable_with_detail(operation_id, message, detail) - } - CommandDisposition::ExternalUnavailable => { - OperationAdapterError::network_unavailable_with_detail(operation_id, message, detail) - } - CommandDisposition::Unsupported => OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message, - }, - CommandDisposition::ValidationFailed => OperationAdapterError::ValidationFailed { - operation_id: operation_id.to_owned(), - message, - }, - CommandDisposition::NotFound => OperationAdapterError::NotFound { - operation_id: operation_id.to_owned(), - message, - }, - CommandDisposition::InternalError | CommandDisposition::Success => { - OperationAdapterError::Runtime(message) - } - } -} - -fn map_runtime<T>( - operation_id: &str, - result: Result<T, RuntimeError>, -) -> Result<T, OperationAdapterError> { - result.map_err(|error| OperationAdapterError::runtime_failure(operation_id, error)) -} - -#[cfg(test)] -mod tests { - use radroots_secret_vault::RadrootsSecretBackend; - use std::path::{Path, PathBuf}; - use tempfile::tempdir; - - use super::RuntimeOperationService; - use crate::ops::{ - OperationAdapter, OperationContext, OperationRequest, SignerStatusRequest, - SyncStatusRequest, TransportConfigInspectRequest, - }; - use crate::runtime::config::{ - AccountConfig, AccountSecretContractConfig, HyfConfig, IdentityConfig, InteractionConfig, - LocalConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, PathsConfig, RpcConfig, - RuntimeConfig, SignerBackend, SignerConfig, Verbosity, - }; - - #[test] - fn runtime_service_backs_signer_and_transport_profile() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path(), vec!["wss://relay.test".into()]); - let service = OperationAdapter::new(RuntimeOperationService::new(&config)); - - let signer = - OperationRequest::new(OperationContext::default(), SignerStatusRequest::default()) - .expect("signer status request"); - let signer_envelope = service - .execute(signer) - .expect("signer status result") - .to_envelope(OperationContext::default().envelope_context("req_signer")) - .expect("signer envelope"); - assert_eq!(signer_envelope.operation_id, "signer.status"); - assert_eq!(signer_envelope.result["state"], "unconfigured"); - - let profile = OperationRequest::new( - OperationContext::default(), - TransportConfigInspectRequest::default(), - ) - .expect("transport profile request"); - let profile_envelope = service - .execute(profile) - .expect("transport profile result") - .to_envelope(OperationContext::default().envelope_context("req_transport")) - .expect("transport profile envelope"); - assert_eq!(profile_envelope.operation_id, "transport.config.inspect"); - assert_eq!(profile_envelope.result["state"], "configured"); - assert_eq!(profile_envelope.result["profile_id"], "nostr"); - } - - #[test] - fn runtime_service_backs_sync_status() { - let dir = tempdir().expect("tempdir"); - let config = sample_config(dir.path(), Vec::new()); - let service = OperationAdapter::new(RuntimeOperationService::new(&config)); - - let sync = OperationRequest::new(OperationContext::default(), SyncStatusRequest::default()) - .expect("sync status request"); - let envelope = service - .execute(sync) - .expect("sync status result") - .to_envelope(OperationContext::default().envelope_context("req_sync_status")) - .expect("sync status envelope"); - - assert_eq!(envelope.operation_id, "sync.status"); - assert_eq!(envelope.result["state"], "degraded"); - assert_eq!(envelope.result["source"], "canonical SDK sync engine"); - assert_eq!(envelope.result["replica_store"], "canonical"); - assert_eq!(envelope.result["queue"]["pending_count"], 0); - assert_eq!(envelope.result["queue"]["total_count"], 0); - assert_eq!( - envelope.result["actions"][0], - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com" - ); - } - - fn sample_config(root: &Path, relays: Vec<String>) -> RuntimeConfig { - let data = root.join("data"); - let cache = root.join("cache"); - let logs = root.join("logs"); - let secrets = root.join("secrets"); - RuntimeConfig { - output: OutputConfig { - format: OutputFormat::Terminal, - verbosity: Verbosity::Normal, - dry_run: false, - }, - interaction: InteractionConfig { - input_enabled: true, - assume_yes: false, - stdin_tty: false, - stdout_tty: false, - prompts_allowed: false, - confirmations_allowed: false, - }, - paths: PathsConfig { - profile: "interactive_user".into(), - profile_source: "test".into(), - allowed_profiles: vec!["interactive_user".into(), "repo_local".into()], - root_source: "test".into(), - repo_local_root: None, - repo_local_root_source: None, - subordinate_path_override_source: "runtime_config".into(), - app_namespace: "apps/cli".into(), - shared_accounts_namespace: "shared/accounts".into(), - shared_identities_namespace: "shared/identities".into(), - app_config_path: root.join("config/apps/cli/config.toml"), - workspace_config_path: None, - app_data_root: data.join("apps/cli"), - shared_cache_root: cache.clone(), - app_logs_root: logs.join("apps/cli"), - shared_accounts_data_root: data.join("shared/accounts"), - shared_accounts_secrets_root: secrets.join("shared/accounts"), - default_identity_path: secrets.join("shared/identities/default.json"), - }, - logging: LoggingConfig { - filter: "info".into(), - directory: None, - stdout: false, - }, - account: AccountConfig { - selector: None, - store_path: data.join("shared/accounts/store.json"), - secrets_dir: secrets.join("shared/accounts"), - secret_backend: RadrootsSecretBackend::EncryptedFile, - }, - account_secret_contract: AccountSecretContractConfig { - default_backend: "host_vault".into(), - allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], - host_vault_policy: Some("desktop".into()), - uses_protected_store: true, - }, - identity: IdentityConfig { - path: secrets.join("shared/identities/default.json"), - }, - signer: SignerConfig { - backend: SignerBackend::Local, - }, - transport: crate::runtime::config::TransportConfig::from_nostr_relay_urls( - relays.clone(), - ), - local: LocalConfig { - root: data.join("apps/cli/replica"), - replica_store_path: data.join("apps/cli/replica/replica.sqlite"), - backups_dir: data.join("apps/cli/replica/backups"), - exports_dir: data.join("apps/cli/replica/exports"), - }, - myc: MycConfig { - executable: PathBuf::from("myc"), - status_timeout_ms: 2_000, - }, - hyf: HyfConfig { - enabled: false, - executable: PathBuf::from("hyfd"), - }, - mesh: crate::runtime::config::MeshConfig::disabled(), - rpc: RpcConfig { - url: "http://127.0.0.1:7070".into(), - }, - rhi: crate::runtime::config::RhiConfig { - validator_set: None, - require_cryptographic_proof: false, - }, - capability_bindings: Vec::new(), - } - } -} diff --git a/src/ops/exec/trade.rs b/src/ops/exec/trade.rs @@ -1,377 +0,0 @@ -use std::path::PathBuf; - -use serde::Serialize; -use serde_json::{Value, json}; - -use crate::ops::{ - OperationAdapterError, OperationRequest, OperationRequestData, OperationRequestPayload, - OperationResult, OperationResultData, OperationService, TradeCancellationSubmitRequest, - TradeCancellationSubmitResult, TradeCandidateDecideRequest, TradeCandidateDecideResult, - TradeEvidenceInspectRequest, TradeEvidenceInspectResult, TradeEvidenceRefreshRequest, - TradeEvidenceRefreshResult, TradeGetRequest, TradeGetResult, TradeListRequest, TradeListResult, - TradeOperationResumeRequest, TradeOperationResumeResult, TradePrivateArtifactDeleteRequest, - TradePrivateArtifactDeleteResult, TradePrivateArtifactOpenRequest, - TradePrivateArtifactOpenResult, TradePrivateArtifactSealRequest, - TradePrivateArtifactSealResult, TradeProposalSubmitRequest, TradeProposalSubmitResult, - TradeRevisionProposeRequest, TradeRevisionProposeResult, -}; -use crate::runtime::config::RuntimeConfig; -use crate::runtime::trade::{ - TradeEnvelopeFileRuntimeArgs, TradeEvidenceInspectRuntimeArgs, TradeIdRuntimeArgs, - TradePageRuntimeArgs, TradePrivateArtifactDeleteRuntimeArgs, - TradePrivateArtifactOpenRuntimeArgs, TradePrivateArtifactSealRuntimeArgs, -}; - -pub struct TradeOperationService<'a> { - config: &'a RuntimeConfig, -} - -impl<'a> TradeOperationService<'a> { - pub fn new(config: &'a RuntimeConfig) -> Self { - Self { config } - } -} - -impl OperationService<TradeProposalSubmitRequest> for TradeOperationService<'_> { - type Result = TradeProposalSubmitResult; - - fn execute( - &self, - request: OperationRequest<TradeProposalSubmitRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = envelope_args(&request)?; - let receipt = crate::runtime::trade::submit_proposal(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&receipt) - } -} - -impl OperationService<TradeRevisionProposeRequest> for TradeOperationService<'_> { - type Result = TradeRevisionProposeResult; - - fn execute( - &self, - request: OperationRequest<TradeRevisionProposeRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = envelope_args(&request)?; - let receipt = crate::runtime::trade::propose_revision(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&receipt) - } -} - -impl OperationService<TradeCandidateDecideRequest> for TradeOperationService<'_> { - type Result = TradeCandidateDecideResult; - - fn execute( - &self, - request: OperationRequest<TradeCandidateDecideRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = envelope_args(&request)?; - let receipt = crate::runtime::trade::decide_candidate(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&receipt) - } -} - -impl OperationService<TradeCancellationSubmitRequest> for TradeOperationService<'_> { - type Result = TradeCancellationSubmitResult; - - fn execute( - &self, - request: OperationRequest<TradeCancellationSubmitRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = envelope_args(&request)?; - let receipt = crate::runtime::trade::cancel_trade(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&receipt) - } -} - -impl OperationService<TradeOperationResumeRequest> for TradeOperationService<'_> { - type Result = TradeOperationResumeResult; - - fn execute( - &self, - request: OperationRequest<TradeOperationResumeRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = envelope_args(&request)?; - let receipt = crate::runtime::trade::resume_operation(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&receipt) - } -} - -impl OperationService<TradeGetRequest> for TradeOperationService<'_> { - type Result = TradeGetResult; - - fn execute( - &self, - request: OperationRequest<TradeGetRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = TradeIdRuntimeArgs { - trade_id: required_string(&request, "trade_id")?, - }; - let view = crate::runtime::trade::get_trade(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&view) - } -} - -impl OperationService<TradeListRequest> for TradeOperationService<'_> { - type Result = TradeListResult; - - fn execute( - &self, - request: OperationRequest<TradeListRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = TradePageRuntimeArgs { - limit: u32_input(&request, "limit")?, - cursor: string_input(&request, "cursor"), - }; - let page = crate::runtime::trade::list_trades(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&page) - } -} - -impl OperationService<TradeEvidenceRefreshRequest> for TradeOperationService<'_> { - type Result = TradeEvidenceRefreshResult; - - fn execute( - &self, - request: OperationRequest<TradeEvidenceRefreshRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = TradeIdRuntimeArgs { - trade_id: required_string(&request, "trade_id")?, - }; - let receipt = crate::runtime::trade::refresh_evidence(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&receipt) - } -} - -impl OperationService<TradeEvidenceInspectRequest> for TradeOperationService<'_> { - type Result = TradeEvidenceInspectResult; - - fn execute( - &self, - request: OperationRequest<TradeEvidenceInspectRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = TradeEvidenceInspectRuntimeArgs { - trade_id: required_string(&request, "trade_id")?, - limit: u32_input(&request, "limit")?, - cursor: string_input(&request, "cursor"), - }; - let page = crate::runtime::trade::inspect_evidence(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&page) - } -} - -impl OperationService<TradePrivateArtifactSealRequest> for TradeOperationService<'_> { - type Result = TradePrivateArtifactSealResult; - - fn execute( - &self, - request: OperationRequest<TradePrivateArtifactSealRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = TradePrivateArtifactSealRuntimeArgs { - trade_id: required_string(&request, "trade_id")?, - artifact_id: required_string(&request, "artifact_id")?, - schema_id: required_string(&request, "schema_id")?, - input: required_path(&request, "input")?, - kind: string_input(&request, "kind").unwrap_or_else(|| "binding_terms".to_owned()), - candidate_id: string_input(&request, "candidate_id"), - retention_class: string_input(&request, "retention_class"), - expires_at_ms: i64_input(&request, "expires_at_ms")?, - }; - let receipt = crate::runtime::trade::seal_private_artifact(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&receipt) - } -} - -impl OperationService<TradePrivateArtifactOpenRequest> for TradeOperationService<'_> { - type Result = TradePrivateArtifactOpenResult; - - fn execute( - &self, - request: OperationRequest<TradePrivateArtifactOpenRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = TradePrivateArtifactOpenRuntimeArgs { - artifact_id: required_string(&request, "artifact_id")?, - output: required_path(&request, "output")?, - }; - let view = crate::runtime::trade::open_private_artifact(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - if view.state == "missing" { - return Err(OperationAdapterError::not_found_with_detail( - request.operation_id(), - format!("private artifact `{}` was not found", view.artifact_id), - json!({ - "artifact_id": view.artifact_id, - "output": view.output, - }), - )); - } - serialized_target_result(&view) - } -} - -impl OperationService<TradePrivateArtifactDeleteRequest> for TradeOperationService<'_> { - type Result = TradePrivateArtifactDeleteResult; - - fn execute( - &self, - request: OperationRequest<TradePrivateArtifactDeleteRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let args = TradePrivateArtifactDeleteRuntimeArgs { - artifact_id: required_string(&request, "artifact_id")?, - }; - let receipt = crate::runtime::trade::delete_private_artifact(self.config, &args) - .map_err(|error| sdk_failure(request.operation_id(), error))?; - serialized_target_result(&receipt) - } -} - -fn envelope_args<P>( - request: &OperationRequest<P>, -) -> Result<TradeEnvelopeFileRuntimeArgs, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - Ok(TradeEnvelopeFileRuntimeArgs { - file: required_path(request, "file")?, - idempotency_key: request - .context - .idempotency_key - .clone() - .or_else(|| string_input(request, "idempotency_key")), - acknowledge_private_terms: bool_input(request, "acknowledge_private_terms") - .unwrap_or(false), - operation_kind: string_input(request, "operation_kind"), - }) -} - -fn serialized_target_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, - T: Serialize, -{ - OperationResult::new(R::from_serializable(value)?) -} - -fn required_string<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<String, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, key).ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("missing required `{key}` input"), - ) - }) -} - -fn string_input<P>(request: &OperationRequest<P>, key: &str) -> Option<String> -where - P: OperationRequestPayload + OperationRequestData, -{ - request - .payload - .input() - .get(key) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) -} - -fn bool_input<P>(request: &OperationRequest<P>, key: &str) -> Option<bool> -where - P: OperationRequestPayload + OperationRequestData, -{ - request.payload.input().get(key).and_then(Value::as_bool) -} - -fn u32_input<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<Option<u32>, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - request - .payload - .input() - .get(key) - .map(|value| { - value - .as_u64() - .and_then(|value| u32::try_from(value).ok()) - .ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("`{key}` must be an unsigned 32-bit integer"), - ) - }) - }) - .transpose() -} - -fn i64_input<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<Option<i64>, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - request - .payload - .input() - .get(key) - .map(|value| { - value.as_i64().ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("`{key}` must be a signed 64-bit integer"), - ) - }) - }) - .transpose() -} - -fn required_path<P>( - request: &OperationRequest<P>, - key: &str, -) -> Result<PathBuf, OperationAdapterError> -where - P: OperationRequestPayload + OperationRequestData, -{ - string_input(request, key) - .map(PathBuf::from) - .ok_or_else(|| { - invalid_input( - request.operation_id(), - format!("missing required `{key}` input"), - ) - }) -} - -fn sdk_failure( - operation_id: &str, - error: crate::runtime::sdk::CliSdkAdapterError, -) -> OperationAdapterError { - OperationAdapterError::sdk_adapter_failure(operation_id, error) -} - -fn invalid_input(operation_id: &str, message: String) -> OperationAdapterError { - OperationAdapterError::InvalidInput { - operation_id: operation_id.to_owned(), - message, - } -} diff --git a/src/ops/exec/validation.rs b/src/ops/exec/validation.rs @@ -1,48 +0,0 @@ -use serde::Serialize; -use serde_json::json; - -use crate::ops::{ - OperationAdapterError, OperationRequest, OperationResult, OperationResultData, - OperationService, ValidationStatusRequest, ValidationStatusResult, -}; -use crate::runtime::config::RuntimeConfig; - -pub struct ValidationOperationService<'a> { - config: &'a RuntimeConfig, -} - -impl<'a> ValidationOperationService<'a> { - pub fn new(config: &'a RuntimeConfig) -> Self { - Self { config } - } -} - -impl OperationService<ValidationStatusRequest> for ValidationOperationService<'_> { - type Result = ValidationStatusResult; - - fn execute( - &self, - _request: OperationRequest<ValidationStatusRequest>, - ) -> Result<OperationResult<Self::Result>, OperationAdapterError> { - let state = if self.config.rhi.validator_set.is_some() { - "ready" - } else { - "unconfigured" - }; - let view = json!({ - "state": state, - "source": "Runtime Contract V1 validation configuration", - "validator_set_configured": self.config.rhi.validator_set.is_some(), - "cryptographic_proof_required": self.config.rhi.require_cryptographic_proof, - }); - serialized_operation_result::<ValidationStatusResult, _>(&view) - } -} - -fn serialized_operation_result<R, T>(value: &T) -> Result<OperationResult<R>, OperationAdapterError> -where - R: OperationResultData, - T: Serialize, -{ - OperationResult::new(R::from_serializable(value)?) -} diff --git a/src/ops/mod.rs b/src/ops/mod.rs @@ -1,14 +0,0 @@ -mod adapter; -mod context; -mod error; -pub mod exec; -mod request; -mod result; -mod target; - -pub use adapter::*; -pub use context::*; -pub use error::OperationAdapterError; -pub use request::*; -pub use result::*; -pub use target::*; diff --git a/src/ops/request.rs b/src/ops/request.rs @@ -1,52 +0,0 @@ -use std::fmt::Debug; - -use serde_json::{Map, Value}; - -use super::context::OperationContext; -use super::error::OperationAdapterError; -use crate::registry::{OperationSpec, get_operation}; - -pub type OperationData = Map<String, Value>; - -pub trait OperationRequestPayload: Debug + Clone + PartialEq + 'static { - const OPERATION_ID: &'static str; - const REQUEST_TYPE: &'static str; -} - -pub trait OperationRequestData: OperationRequestPayload { - fn input(&self) -> &OperationData; -} - -#[derive(Debug, Clone, PartialEq)] -pub struct OperationRequest<P: OperationRequestPayload> { - pub spec: &'static OperationSpec, - pub context: OperationContext, - pub payload: P, -} - -impl<P: OperationRequestPayload> OperationRequest<P> { - pub fn new(context: OperationContext, payload: P) -> Result<Self, OperationAdapterError> { - let spec = get_operation(P::OPERATION_ID) - .ok_or_else(|| OperationAdapterError::UnknownOperation(P::OPERATION_ID.to_owned()))?; - if spec.rust_request != P::REQUEST_TYPE { - return Err(OperationAdapterError::RequestTypeMismatch { - operation_id: P::OPERATION_ID.to_owned(), - registry_request: spec.rust_request.to_owned(), - adapter_request: P::REQUEST_TYPE.to_owned(), - }); - } - Ok(Self { - spec, - context, - payload, - }) - } - - pub fn operation_id(&self) -> &'static str { - P::OPERATION_ID - } - - pub fn request_type_name(&self) -> &'static str { - P::REQUEST_TYPE - } -} diff --git a/src/ops/result.rs b/src/ops/result.rs @@ -1,98 +0,0 @@ -use std::fmt::Debug; - -use serde::Serialize; -use serde_json::Value; - -use super::error::OperationAdapterError; -use super::request::OperationData; -use crate::out::envelope::{ - EnvelopeContext, NextAction, OutputEnvelope, OutputWarning, next_actions_from_result_value, -}; -use crate::registry::{OperationSpec, get_operation}; - -pub trait OperationResultPayload: Debug + Clone + PartialEq + Serialize + 'static { - const OPERATION_ID: &'static str; - const RESULT_TYPE: &'static str; -} - -pub trait OperationResultData: OperationResultPayload + Sized { - fn from_data(data: OperationData) -> Self; - - fn from_value(value: Value) -> Self { - Self::from_data(value_to_data(value)) - } - - fn from_serializable<T: Serialize>(value: &T) -> Result<Self, OperationAdapterError> { - Ok(Self::from_value(serde_json::to_value(value).map_err( - |error| OperationAdapterError::Serialization(error.to_string()), - )?)) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct OperationResult<P: OperationResultPayload> { - pub spec: &'static OperationSpec, - pub payload: P, - pub warnings: Vec<OutputWarning>, - pub next_actions: Vec<NextAction>, -} - -impl<P: OperationResultPayload> OperationResult<P> { - pub fn new(payload: P) -> Result<Self, OperationAdapterError> { - let spec = get_operation(P::OPERATION_ID) - .ok_or_else(|| OperationAdapterError::UnknownOperation(P::OPERATION_ID.to_owned()))?; - if spec.rust_result != P::RESULT_TYPE { - return Err(OperationAdapterError::ResultTypeMismatch { - operation_id: P::OPERATION_ID.to_owned(), - registry_result: spec.rust_result.to_owned(), - adapter_result: P::RESULT_TYPE.to_owned(), - }); - } - Ok(Self { - spec, - payload, - warnings: Vec::new(), - next_actions: Vec::new(), - }) - } - - pub fn operation_id(&self) -> &'static str { - P::OPERATION_ID - } - - pub fn result_type_name(&self) -> &'static str { - P::RESULT_TYPE - } - - pub fn to_envelope( - &self, - context: EnvelopeContext, - ) -> Result<OutputEnvelope, OperationAdapterError> { - let result = serde_json::to_value(&self.payload) - .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?; - let next_actions = if self.next_actions.is_empty() { - next_actions_from_result(&result) - } else { - self.next_actions.clone() - }; - let mut envelope = OutputEnvelope::success(self.operation_id(), result, context); - envelope.warnings = self.warnings.clone(); - envelope.next_actions = next_actions; - Ok(envelope) - } -} - -fn next_actions_from_result(result: &Value) -> Vec<NextAction> { - next_actions_from_result_value(result) -} - -pub(crate) fn value_to_data(value: Value) -> OperationData { - match value { - Value::Object(map) => map, - other => { - let mut map = OperationData::new(); - map.insert("value".to_owned(), other); - map - } - } -} diff --git a/src/ops/target.rs b/src/ops/target.rs @@ -1,186 +0,0 @@ -use serde::Serialize; -use serde_json::Value; - -use super::context::{OperationContext, OperationOutputFormat}; -use super::error::OperationAdapterError; -use super::request::{ - OperationData, OperationRequest, OperationRequestData, OperationRequestPayload, -}; -use super::result::{OperationResult, OperationResultData, OperationResultPayload, value_to_data}; -use crate::cli::TargetCliArgs; -use crate::registry::{OPERATION_REGISTRY, OperationSpec}; - -macro_rules! target_operation_contracts { - ($( $variant:ident => ($request:ident, $result:ident, $operation_id:literal) ),+ $(,)?) => { - #[derive(Debug, Clone, PartialEq)] - pub enum TargetOperationRequest { - $( $variant(OperationRequest<$request>), )+ - } - - impl TargetOperationRequest { - pub fn from_target_args(args: &TargetCliArgs) -> Result<Self, OperationAdapterError> { - let operation_id = crate::cli::input::operation_id_from_target(args); - Self::from_operation_id_with_input( - operation_id, - OperationContext::from_target_args(args, operation_id)?, - crate::cli::input::target_operation_input(&args.command), - ) - } - - pub fn from_operation_id( - operation_id: &'static str, - context: OperationContext, - ) -> Result<Self, OperationAdapterError> { - Self::from_operation_id_with_input(operation_id, context, OperationData::new()) - } - - fn from_operation_id_with_input( - operation_id: &'static str, - context: OperationContext, - input: OperationData, - ) -> Result<Self, OperationAdapterError> { - match operation_id { - $( $operation_id => Ok(Self::$variant(OperationRequest::new(context, $request::from_data(input))?)), )+ - _ => Err(OperationAdapterError::UnknownOperation(operation_id.to_owned())), - } - } - - pub fn operation_id(&self) -> &'static str { - match self { - $( Self::$variant(request) => request.operation_id(), )+ - } - } - - pub fn spec(&self) -> &'static OperationSpec { - match self { - $( Self::$variant(request) => request.spec, )+ - } - } - - pub fn context(&self) -> &OperationContext { - match self { - $( Self::$variant(request) => &request.context, )+ - } - } - - pub fn set_output_format(&mut self, output_format: OperationOutputFormat) { - match self { - $( Self::$variant(request) => request.context.output_format = output_format, )+ - } - } - - pub fn request_type_name(&self) -> &'static str { - match self { - $( Self::$variant(request) => request.request_type_name(), )+ - } - } - - pub fn request_type_for_operation(operation_id: &str) -> Option<&'static str> { - match operation_id { - $( $operation_id => Some(stringify!($request)), )+ - _ => None, - } - } - } - - #[derive(Debug, Clone, PartialEq)] - pub enum TargetOperationResult { - $( $variant(OperationResult<$result>), )+ - } - - impl TargetOperationResult { - pub fn operation_id(&self) -> &'static str { - match self { - $( Self::$variant(result) => result.operation_id(), )+ - } - } - - pub fn result_type_name(&self) -> &'static str { - match self { - $( Self::$variant(result) => result.result_type_name(), )+ - } - } - - pub fn result_type_for_operation(operation_id: &str) -> Option<&'static str> { - match operation_id { - $( $operation_id => Some(stringify!($result)), )+ - _ => None, - } - } - } - - $( - #[derive(Debug, Default, Clone, PartialEq, Serialize)] - pub struct $request { - #[serde(flatten)] - pub input: OperationData, - } - - impl $request { - pub fn from_data(input: OperationData) -> Self { - Self { input } - } - } - - impl OperationRequestPayload for $request { - const OPERATION_ID: &'static str = $operation_id; - const REQUEST_TYPE: &'static str = stringify!($request); - } - - impl OperationRequestData for $request { - fn input(&self) -> &OperationData { - &self.input - } - } - - #[derive(Debug, Default, Clone, PartialEq, Serialize)] - pub struct $result { - #[serde(flatten)] - pub data: OperationData, - } - - impl $result { - pub fn from_data(data: OperationData) -> Self { - Self { data } - } - - pub fn from_value(value: Value) -> Self { - Self { - data: value_to_data(value), - } - } - - pub fn from_serializable<T: Serialize>( - value: &T, - ) -> Result<Self, OperationAdapterError> { - Ok(Self::from_value( - serde_json::to_value(value) - .map_err(|error| OperationAdapterError::Serialization(error.to_string()))?, - )) - } - } - - impl OperationResultPayload for $result { - const OPERATION_ID: &'static str = $operation_id; - const RESULT_TYPE: &'static str = stringify!($result); - } - - impl OperationResultData for $result { - fn from_data(data: OperationData) -> Self { - Self { data } - } - } - )+ - }; -} - -include!("../generated/runtime_contract_target.rs"); - -pub fn adapter_registry_linkage_is_valid() -> bool { - OPERATION_REGISTRY.iter().all(|operation| { - TargetOperationRequest::request_type_for_operation(operation.operation_id) - == Some(operation.rust_request) - && TargetOperationResult::result_type_for_operation(operation.operation_id) - == Some(operation.rust_result) - }) -} diff --git a/src/out/envelope.rs b/src/out/envelope.rs @@ -1,726 +0,0 @@ -use serde::Serialize; -use serde_json::{Value, json}; - -pub const OUTPUT_SCHEMA_VERSION: &str = "radroots.cli.output.v1"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct EnvelopeContext { - pub request_id: String, - pub correlation_id: Option<String>, - pub idempotency_key: Option<String>, - pub output_format: OutputFormat, - pub dry_run: bool, - pub actor: Option<EnvelopeActor>, -} - -impl EnvelopeContext { - pub fn new(request_id: impl Into<String>, dry_run: bool) -> Self { - Self { - request_id: request_id.into(), - correlation_id: None, - idempotency_key: None, - output_format: OutputFormat::Terminal, - dry_run, - actor: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct EnvelopeActor { - pub account_id: String, - pub role: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct OutputEnvelope { - pub schema_version: &'static str, - pub operation_id: String, - pub kind: String, - pub status: OutputStatus, - pub output_format: OutputFormat, - pub request_id: String, - pub correlation_id: Option<String>, - pub idempotency_key: Option<String>, - pub dry_run: bool, - pub actor: Option<EnvelopeActor>, - pub resource: Option<OutputResource>, - pub result: Value, - pub reason_code: Option<String>, - pub warnings: Vec<OutputWarning>, - pub errors: Vec<OutputError>, - pub next_actions: Vec<NextAction>, -} - -impl OutputEnvelope { - pub fn success( - operation_id: impl Into<String>, - result: Value, - context: EnvelopeContext, - ) -> Self { - let operation_id = operation_id.into(); - let resource = output_resource_from_value(&result); - let reason_code = output_reason_code_from_value(&result); - Self { - schema_version: OUTPUT_SCHEMA_VERSION, - kind: operation_id.clone(), - operation_id, - status: OutputStatus::Ok, - output_format: context.output_format, - request_id: context.request_id, - correlation_id: context.correlation_id, - idempotency_key: context.idempotency_key, - dry_run: context.dry_run, - actor: context.actor, - resource, - result, - reason_code, - warnings: Vec::new(), - errors: Vec::new(), - next_actions: Vec::new(), - } - } - - pub fn failure( - operation_id: impl Into<String>, - error: OutputError, - context: EnvelopeContext, - ) -> Self { - let operation_id = operation_id.into(); - let next_actions = next_actions_from_error_detail(&error); - let resource = error.detail.as_ref().and_then(output_resource_from_value); - let reason_code = Some(error.reason_code.clone()); - Self { - schema_version: OUTPUT_SCHEMA_VERSION, - kind: operation_id.clone(), - operation_id, - status: OutputStatus::Error, - output_format: context.output_format, - request_id: context.request_id, - correlation_id: context.correlation_id, - idempotency_key: context.idempotency_key, - dry_run: context.dry_run, - actor: context.actor, - resource, - result: Value::Null, - reason_code, - warnings: Vec::new(), - errors: vec![error], - next_actions, - } - } - - pub fn to_ndjson_frames(&self) -> Vec<NdjsonFrame> { - let started = NdjsonFrame::new( - self.operation_id.clone(), - self.request_id.clone(), - 0, - NdjsonFrameType::Started, - json!({ - "state": "started", - "status": self.status, - "output_format": self.output_format, - "dry_run": self.dry_run, - "correlation_id": &self.correlation_id, - "idempotency_key": &self.idempotency_key, - "actor": &self.actor, - "resource": &self.resource, - }), - ); - let mut terminal = NdjsonFrame::new( - self.operation_id.clone(), - self.request_id.clone(), - 1, - if self.errors.is_empty() { - NdjsonFrameType::Completed - } else { - NdjsonFrameType::Error - }, - json!({ - "status": self.status, - "reason_code": &self.reason_code, - "output_format": self.output_format, - "resource": &self.resource, - "result": &self.result, - "next_actions": &self.next_actions, - "dry_run": self.dry_run, - "correlation_id": &self.correlation_id, - "idempotency_key": &self.idempotency_key, - "actor": &self.actor, - }), - ); - terminal.warnings = self.warnings.clone(); - terminal.errors = self.errors.clone(); - vec![started, terminal] - } -} - -fn output_reason_code_from_value(value: &Value) -> Option<String> { - value - .get("reason_code") - .and_then(Value::as_str) - .filter(|reason_code| !reason_code.trim().is_empty()) - .map(str::to_owned) -} - -fn output_resource_from_value(value: &Value) -> Option<OutputResource> { - let object = value.as_object()?; - if let Some(resource) = object.get("resource").and_then(declared_output_resource) { - return Some(resource); - } - output_resource_from_fields(object).or_else(|| { - let nested_fields = [ - "account", - "resolved_account", - "default_account", - "bound_account", - "farm", - "listing", - "basket", - "quote", - "trade", - ]; - nested_fields - .into_iter() - .filter_map(|field| { - object - .get(field) - .and_then(|value| nested_output_resource(field, value)) - }) - .next() - }) -} - -fn nested_output_resource(field: &str, value: &Value) -> Option<OutputResource> { - let mut resource = output_resource_from_value(value)?; - if resource.kind == "resource" { - resource.kind = match field { - "resolved_account" | "default_account" | "bound_account" => "account", - "order" => "trade", - other => other, - } - .to_owned(); - } - Some(resource) -} - -fn declared_output_resource(value: &Value) -> Option<OutputResource> { - let object = value.as_object()?; - let kind = object - .get("kind") - .and_then(Value::as_str) - .filter(|kind| !kind.trim().is_empty())?; - let id = object - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty())?; - Some(OutputResource { - kind: kind.to_owned(), - id: id.to_owned(), - }) -} - -fn output_resource_from_fields(object: &serde_json::Map<String, Value>) -> Option<OutputResource> { - [ - ("account_id", "account"), - ("id", "resource"), - ("farm_id", "farm"), - ("seller_account_id", "account"), - ("buyer_account_id", "account"), - ("listing_id", "listing"), - ("listing_address", "listing"), - ("listing_addr", "listing"), - ("basket_id", "basket"), - ("order_id", "trade"), - ] - .into_iter() - .find_map(|(field, kind)| { - object - .get(field) - .and_then(Value::as_str) - .filter(|id| !id.trim().is_empty()) - .map(|id| OutputResource { - kind: kind.to_owned(), - id: id.to_owned(), - }) - }) -} - -pub fn next_actions_from_result_value(result: &Value) -> Vec<NextAction> { - next_actions_from_actions_value(result.get("actions")) -} - -fn next_actions_from_error_detail(error: &OutputError) -> Vec<NextAction> { - next_actions_from_actions_value( - error - .detail - .as_ref() - .and_then(|detail| detail.get("actions")), - ) -} - -fn next_actions_from_actions_value(actions_value: Option<&Value>) -> Vec<NextAction> { - actions_value - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .filter_map(next_action_from_action_string) - .fold(Vec::<NextAction>::new(), |mut actions, action| { - if !actions.contains(&action) { - actions.push(action); - } - actions - }) -} - -fn next_action_from_action_string(action: &str) -> Option<NextAction> { - let action = action.trim(); - if action == "configure signer.remote_nip46 signer_session_ref" { - return Some(NextAction { - kind: NextActionKind::OperatorConfig, - label: "configure signer session binding".to_owned(), - command: None, - description: Some(action.to_owned()), - env_var: None, - config_key: Some("signer.remote_nip46.signer_session_ref".to_owned()), - }); - } - if action - == "configure RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_SECRET_ID" - { - return Some(NextAction { - kind: NextActionKind::OperatorConfig, - label: "configure radrootsd execution token source".to_owned(), - command: None, - description: Some(action.to_owned()), - env_var: Some("RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_FILE".to_owned()), - config_key: None, - }); - } - let command = action.trim().strip_prefix("run ").unwrap_or(action).trim(); - if !command.starts_with("radroots ") { - return None; - } - Some(NextAction { - kind: NextActionKind::CliCommand, - label: next_action_label(command), - command: Some(command.to_owned()), - description: None, - env_var: None, - config_key: None, - }) -} - -fn next_action_label(command: &str) -> String { - let parts = command.split_whitespace().collect::<Vec<_>>(); - let mut index = usize::from(parts.first().is_some_and(|part| *part == "radroots")); - let mut labels = Vec::new(); - while index < parts.len() { - let part = parts[index]; - if part.starts_with("--") { - index += 1; - if matches!( - part, - "--format" - | "--account-id" - | "--idempotency-key" - | "--correlation-id" - | "--approval-proof" - ) && index < parts.len() - { - index += 1; - } - continue; - } - labels.push(part); - index += 1; - } - if labels.is_empty() { - "radroots".to_owned() - } else { - labels.join(" ") - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct OutputWarning { - pub code: String, - pub message: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum OutputStatus { - Ok, - Error, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum OutputFormat { - Terminal, - Json, - Ndjson, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct OutputResource { - pub kind: String, - pub id: String, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct OutputError { - pub code: String, - pub reason_code: String, - pub message: String, - pub exit_code: u8, - pub detail: Option<Value>, -} - -impl OutputError { - pub fn new( - code: impl Into<String>, - message: impl Into<String>, - exit_code: CliExitCode, - ) -> Self { - let code = code.into(); - Self { - reason_code: code.clone(), - code, - message: message.into(), - exit_code: exit_code.code(), - detail: None, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CliExitCode { - Success, - InternalError, - InvalidInput, - RuntimeUnavailable, - NotFound, - AuthorizationFailed, - ApprovalRequiredOrDenied, - SignerUnavailable, - SyncOrNetworkFailure, - Conflict, - ValidationFailed, - UnsafeOperationRefused, -} - -impl CliExitCode { - pub fn code(self) -> u8 { - match self { - Self::Success => 0, - Self::InternalError => 1, - Self::InvalidInput => 2, - Self::RuntimeUnavailable => 3, - Self::NotFound => 4, - Self::AuthorizationFailed => 5, - Self::ApprovalRequiredOrDenied => 6, - Self::SignerUnavailable => 7, - Self::SyncOrNetworkFailure => 8, - Self::Conflict => 9, - Self::ValidationFailed => 10, - Self::UnsafeOperationRefused => 11, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct NextAction { - pub kind: NextActionKind, - pub label: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub command: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub env_var: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub config_key: Option<String>, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum NextActionKind { - CliCommand, - OperatorConfig, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "snake_case")] -pub enum NdjsonFrameType { - Started, - Event, - Progress, - Warning, - Error, - Completed, - Heartbeat, -} - -#[derive(Debug, Clone, PartialEq, Serialize)] -pub struct NdjsonFrame { - pub schema_version: &'static str, - pub operation_id: String, - pub kind: String, - pub request_id: String, - pub sequence: u64, - pub frame_type: NdjsonFrameType, - pub payload: Value, - pub warnings: Vec<OutputWarning>, - pub errors: Vec<OutputError>, -} - -impl NdjsonFrame { - pub fn new( - operation_id: impl Into<String>, - request_id: impl Into<String>, - sequence: u64, - frame_type: NdjsonFrameType, - payload: Value, - ) -> Self { - let operation_id = operation_id.into(); - Self { - schema_version: OUTPUT_SCHEMA_VERSION, - kind: operation_id.clone(), - operation_id, - request_id: request_id.into(), - sequence, - frame_type, - payload, - warnings: Vec::new(), - errors: Vec::new(), - } - } -} - -#[cfg(test)] -mod tests { - use serde_json::{Value, json}; - - use super::{ - CliExitCode, EnvelopeContext, NdjsonFrame, NdjsonFrameType, NextActionKind, - OUTPUT_SCHEMA_VERSION, OutputEnvelope, OutputError, - }; - - #[test] - fn success_envelope_serializes_required_fields() { - let mut context = EnvelopeContext::new("req_test", true); - context.correlation_id = Some("corr_test".to_owned()); - context.idempotency_key = Some("idem_test".to_owned()); - let envelope = OutputEnvelope::success( - "listing.publish", - json!({ "listing_id": "listing_test" }), - context, - ); - let value = serde_json::to_value(envelope).expect("serialize envelope"); - - assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION); - assert_eq!(value["operation_id"], "listing.publish"); - assert_eq!(value["kind"], "listing.publish"); - assert_eq!(value["status"], "ok"); - assert_eq!(value["output_format"], "terminal"); - assert_eq!(value["request_id"], "req_test"); - assert_eq!(value["correlation_id"], "corr_test"); - assert_eq!(value["idempotency_key"], "idem_test"); - assert_eq!(value["dry_run"], true); - assert_eq!(value["resource"]["kind"], "listing"); - assert_eq!(value["resource"]["id"], "listing_test"); - assert_eq!(value["result"]["listing_id"], "listing_test"); - assert_eq!(value["reason_code"], Value::Null); - assert_eq!(value["warnings"].as_array().unwrap().len(), 0); - assert_eq!(value["errors"].as_array().unwrap().len(), 0); - assert_eq!(value["next_actions"].as_array().unwrap().len(), 0); - } - - #[test] - fn failure_envelope_carries_structured_error_and_exit_code() { - let error = OutputError::new( - "approval_required", - "operation requires approval proof", - CliExitCode::ApprovalRequiredOrDenied, - ); - let envelope = OutputEnvelope::failure( - "trade.proposal.submit", - error, - EnvelopeContext::new("req_order", false), - ); - let value = serde_json::to_value(envelope).expect("serialize envelope"); - - assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION); - assert_eq!(value["operation_id"], "trade.proposal.submit"); - assert_eq!(value["status"], "error"); - assert_eq!(value["reason_code"], "approval_required"); - assert_eq!(value["result"], Value::Null); - assert_eq!(value["errors"][0]["code"], "approval_required"); - assert_eq!(value["errors"][0]["reason_code"], "approval_required"); - assert_eq!(value["errors"][0]["exit_code"], 6); - } - - #[test] - fn failure_envelope_derives_next_actions_from_error_detail() { - let mut error = OutputError::new( - "not_found", - "order draft was not found", - CliExitCode::NotFound, - ); - error.detail = Some(json!({ - "actions": [ - "radroots trade list", - "run radroots basket create" - ] - })); - let envelope = OutputEnvelope::failure( - "trade.proposal.submit", - error, - EnvelopeContext::new("req_order", true), - ); - - assert_eq!(envelope.next_actions.len(), 2); - assert_eq!(envelope.next_actions[0].kind, NextActionKind::CliCommand); - assert_eq!(envelope.next_actions[0].label, "trade list"); - assert_eq!( - envelope.next_actions[0].command.as_deref(), - Some("radroots trade list") - ); - assert_eq!(envelope.next_actions[1].kind, NextActionKind::CliCommand); - assert_eq!(envelope.next_actions[1].label, "basket create"); - assert_eq!( - envelope.next_actions[1].command.as_deref(), - Some("radroots basket create") - ); - } - - #[test] - fn failure_envelope_derives_operator_config_next_actions() { - let mut error = OutputError::new( - "operation_unavailable", - "publish transport needs operator configuration", - CliExitCode::RuntimeUnavailable, - ); - error.detail = Some(json!({ - "actions": [ - "configure RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_SECRET_ID", - "configure signer.remote_nip46 signer_session_ref", - "configure RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_FILE or RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_SECRET_ID" - ] - })); - let envelope = OutputEnvelope::failure( - "transport.config.inspect", - error, - EnvelopeContext::new("req_config", false), - ); - let value = serde_json::to_value(&envelope).expect("serialize envelope"); - - assert_eq!(envelope.next_actions.len(), 2); - assert_eq!( - envelope.next_actions[0].kind, - NextActionKind::OperatorConfig - ); - assert_eq!( - envelope.next_actions[0].label, - "configure radrootsd execution token source" - ); - assert_eq!(envelope.next_actions[0].command, None); - assert_eq!( - envelope.next_actions[0].env_var.as_deref(), - Some("RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_FILE") - ); - assert_eq!( - envelope.next_actions[1].kind, - NextActionKind::OperatorConfig - ); - assert_eq!( - envelope.next_actions[1].label, - "configure signer session binding" - ); - assert_eq!(envelope.next_actions[1].command, None); - assert_eq!( - envelope.next_actions[1].config_key.as_deref(), - Some("signer.remote_nip46.signer_session_ref") - ); - assert_eq!(value["next_actions"][0]["kind"], "operator_config"); - assert_eq!(value["next_actions"][0]["command"], Value::Null); - assert_eq!(value["next_actions"][1]["kind"], "operator_config"); - assert_eq!(value["next_actions"][1]["command"], Value::Null); - } - - #[test] - fn ndjson_frames_serialize_one_json_object_per_line() { - let frames = [ - NdjsonFrame::new( - "sync.status", - "req_watch", - 0, - NdjsonFrameType::Started, - json!({ "state": "started" }), - ), - NdjsonFrame::new( - "sync.status", - "req_watch", - 1, - NdjsonFrameType::Event, - json!({ "state": "submitted" }), - ), - NdjsonFrame::new( - "sync.status", - "req_watch", - 2, - NdjsonFrameType::Completed, - json!({ "state": "complete" }), - ), - ]; - let rendered = frames - .iter() - .map(|frame| serde_json::to_string(frame).expect("serialize frame")) - .collect::<Vec<_>>() - .join("\n"); - - for line in rendered.lines() { - let value: Value = serde_json::from_str(line).expect("line is json"); - assert_eq!(value["schema_version"], OUTPUT_SCHEMA_VERSION); - assert_eq!(value["operation_id"], "sync.status"); - assert!(value["frame_type"].is_string()); - } - } - - #[test] - fn ndjson_terminal_frame_carries_status_reason_and_resource() { - let mut error = OutputError::new( - "not_implemented", - "operation is not implemented", - CliExitCode::RuntimeUnavailable, - ); - error.detail = Some(json!({ - "order_id": "ord_test", - })); - let envelope = OutputEnvelope::failure( - "test.operation", - error, - EnvelopeContext::new("req_test", false), - ); - let frames = envelope.to_ndjson_frames(); - - assert_eq!(frames[0].payload["status"], "error"); - assert_eq!(frames[0].payload["output_format"], "terminal"); - assert_eq!(frames[1].payload["status"], "error"); - assert_eq!(frames[1].payload["reason_code"], "not_implemented"); - assert_eq!(frames[1].payload["resource"]["kind"], "trade"); - assert_eq!(frames[1].payload["resource"]["id"], "ord_test"); - assert_eq!(frames[1].errors[0].reason_code, "not_implemented"); - } - - #[test] - fn exit_code_contract_matches_handoff_range() { - assert_eq!(CliExitCode::Success.code(), 0); - assert_eq!(CliExitCode::InvalidInput.code(), 2); - assert_eq!(CliExitCode::ApprovalRequiredOrDenied.code(), 6); - assert_eq!(CliExitCode::UnsafeOperationRefused.code(), 11); - } -} diff --git a/src/out/mod.rs b/src/out/mod.rs @@ -1,2 +0,0 @@ -pub mod envelope; -pub mod terminal; diff --git a/src/out/terminal/actions.rs b/src/out/terminal/actions.rs @@ -1,328 +0,0 @@ -use crate::out::envelope::{NextAction, NextActionKind}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TerminalActionKind { - Command, - Setup, - Placeholder, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalAction { - pub label: Option<String>, - pub command: Option<String>, - pub description: Option<String>, - pub kind: TerminalActionKind, -} - -impl TerminalAction { - pub fn command(command: impl Into<String>) -> Self { - Self { - label: None, - command: Some(command.into()), - description: None, - kind: TerminalActionKind::Command, - } - } - - pub fn setup(description: impl Into<String>) -> Self { - Self { - label: None, - command: None, - description: Some(description.into()), - kind: TerminalActionKind::Setup, - } - } - - pub fn placeholder(command: impl Into<String>) -> Self { - Self { - label: None, - command: Some(command.into()), - description: None, - kind: TerminalActionKind::Placeholder, - } - } - - pub fn render_line(&self) -> Option<String> { - self.command - .clone() - .or_else(|| self.description.clone()) - .or_else(|| self.label.clone()) - } -} - -pub fn terminal_actions_from_next_actions(actions: &[NextAction]) -> Vec<TerminalAction> { - actions - .iter() - .filter_map(terminal_action_from_next_action) - .fold(Vec::<TerminalAction>::new(), |mut unique, action| { - if !unique.contains(&action) { - unique.push(action); - } - unique - }) -} - -fn terminal_action_from_next_action(action: &NextAction) -> Option<TerminalAction> { - match action.kind { - NextActionKind::CliCommand => action - .command - .as_deref() - .map(terminal_action_from_command) - .or_else(|| action.description.clone().map(TerminalAction::setup)), - NextActionKind::OperatorConfig => action - .description - .clone() - .map(TerminalAction::setup) - .or_else(|| Some(TerminalAction::setup(action.label.clone()))), - } -} - -pub fn terminal_action_from_command(command: &str) -> TerminalAction { - let command = command.trim(); - if command_requires_placeholder(command) { - TerminalAction::placeholder(command_with_placeholder(command)) - } else { - TerminalAction::command(command) - } -} - -fn command_requires_placeholder(command: &str) -> bool { - matches!(command, "radroots listing publish") - || basket_item_add_requires_placeholder(command) - || basket_item_update_requires_placeholder(command) - || basket_adjustment_add_requires_placeholder(command) - || basket_adjustment_remove_requires_placeholder(command) - || basket_quote_create_requires_placeholder(command) -} - -fn command_with_placeholder(command: &str) -> String { - if basket_item_add_requires_placeholder(command) { - return basket_item_add_placeholder(command); - } - if basket_item_update_requires_placeholder(command) { - return basket_item_update_placeholder(command); - } - if basket_adjustment_add_requires_placeholder(command) { - return basket_adjustment_add_placeholder(command); - } - if basket_adjustment_remove_requires_placeholder(command) { - return basket_adjustment_remove_placeholder(command); - } - if basket_quote_create_requires_placeholder(command) { - return basket_quote_create_placeholder(command); - } - match command { - "radroots listing publish" => "radroots listing publish <file>", - other => other, - } - .to_owned() -} - -fn basket_item_add_requires_placeholder(command: &str) -> bool { - command_has_prefix(command, &["radroots", "basket", "item", "add"]) - && (!has_basket_arg(command, 4) - || (!has_flag(command, "--listing") && !has_flag(command, "--listing-addr")) - || !has_flag(command, "--bin-id")) -} - -fn basket_item_update_requires_placeholder(command: &str) -> bool { - command_has_prefix(command, &["radroots", "basket", "item", "update"]) - && (!has_basket_arg(command, 4) - || !has_flag(command, "--item-id") - || !has_any_flag( - command, - &["--listing", "--listing-addr", "--bin-id", "--quantity"], - )) -} - -fn basket_adjustment_add_requires_placeholder(command: &str) -> bool { - command_has_prefix(command, &["radroots", "basket", "adjustment", "add"]) - && (!has_basket_arg(command, 4) - || !has_flag(command, "--id") - || !has_flag(command, "--effect") - || !has_flag(command, "--amount") - || !has_flag(command, "--currency") - || !has_flag(command, "--reason")) -} - -fn basket_adjustment_remove_requires_placeholder(command: &str) -> bool { - command_has_prefix(command, &["radroots", "basket", "adjustment", "remove"]) - && (!has_basket_arg(command, 4) || !has_flag(command, "--id")) -} - -fn basket_quote_create_requires_placeholder(command: &str) -> bool { - command_has_prefix(command, &["radroots", "basket", "quote", "create"]) - && !has_basket_arg(command, 4) -} - -fn basket_item_add_placeholder(command: &str) -> String { - let basket = basket_arg(command, 4); - let listing = flag_value(command, "--listing") - .map(|value| format!("--listing {value}")) - .or_else(|| { - flag_value(command, "--listing-addr").map(|value| format!("--listing-addr {value}")) - }) - .unwrap_or_else(|| "--listing <product>".to_owned()); - let bin = flag_value(command, "--bin-id").unwrap_or_else(|| "<bin>".to_owned()); - let quantity = flag_value(command, "--quantity") - .map(|value| format!(" --quantity {value}")) - .unwrap_or_default(); - format!("radroots basket item add {basket} {listing} --bin-id {bin}{quantity}") -} - -fn basket_item_update_placeholder(command: &str) -> String { - let basket = basket_arg(command, 4); - let item = flag_value(command, "--item-id").unwrap_or_else(|| "<item>".to_owned()); - let update = if let Some(value) = flag_value(command, "--listing") { - format!("--listing {value}") - } else if let Some(value) = flag_value(command, "--listing-addr") { - format!("--listing-addr {value}") - } else if let Some(value) = flag_value(command, "--bin-id") { - format!("--bin-id {value}") - } else if let Some(value) = flag_value(command, "--quantity") { - format!("--quantity {value}") - } else { - "--quantity <quantity>".to_owned() - }; - format!("radroots basket item update {basket} --item-id {item} {update}") -} - -fn basket_adjustment_add_placeholder(command: &str) -> String { - let basket = basket_arg(command, 4); - let id = flag_value(command, "--id").unwrap_or_else(|| "<id>".to_owned()); - let effect = flag_value(command, "--effect").unwrap_or_else(|| "<effect>".to_owned()); - let amount = flag_value(command, "--amount").unwrap_or_else(|| "<amount>".to_owned()); - let currency = flag_value(command, "--currency").unwrap_or_else(|| "<currency>".to_owned()); - let reason = flag_value(command, "--reason").unwrap_or_else(|| "<reason>".to_owned()); - format!( - "radroots basket adjustment add {basket} --id {id} --effect {effect} --amount {amount} --currency {currency} --reason {reason}" - ) -} - -fn basket_adjustment_remove_placeholder(command: &str) -> String { - let basket = basket_arg(command, 4); - let id = flag_value(command, "--id").unwrap_or_else(|| "<id>".to_owned()); - format!("radroots basket adjustment remove {basket} --id {id}") -} - -fn basket_quote_create_placeholder(command: &str) -> String { - let basket = basket_arg(command, 4); - format!("radroots basket quote create {basket}") -} - -fn command_has_prefix(command: &str, prefix: &[&str]) -> bool { - let parts = command.split_whitespace().collect::<Vec<_>>(); - parts.len() >= prefix.len() - && parts - .iter() - .zip(prefix.iter()) - .all(|(part, prefix)| part == prefix) -} - -fn has_basket_arg(command: &str, index: usize) -> bool { - command - .split_whitespace() - .nth(index) - .is_some_and(|value| !value.starts_with("--")) -} - -fn basket_arg(command: &str, index: usize) -> String { - command - .split_whitespace() - .nth(index) - .filter(|value| !value.starts_with("--")) - .unwrap_or("<basket>") - .to_owned() -} - -fn has_any_flag(command: &str, flags: &[&str]) -> bool { - flags.iter().any(|flag| has_flag(command, flag)) -} - -fn has_flag(command: &str, flag: &str) -> bool { - flag_value(command, flag).is_some() -} - -fn flag_value(command: &str, flag: &str) -> Option<String> { - let mut parts = command.split_whitespace(); - while let Some(part) = parts.next() { - if part == flag { - return parts - .next() - .filter(|value| !value.starts_with("--")) - .map(str::to_owned); - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::out::envelope::NextAction; - - #[test] - fn exact_command_renders_as_command() { - let action = terminal_action_from_command("radroots store inspect"); - - assert_eq!(action.kind, TerminalActionKind::Command); - assert_eq!(action.command.as_deref(), Some("radroots store inspect")); - } - - #[test] - fn incomplete_command_is_placeholdered() { - let action = terminal_action_from_command("radroots basket item add"); - - assert_eq!(action.kind, TerminalActionKind::Placeholder); - assert_eq!( - action.command.as_deref(), - Some("radroots basket item add <basket> --listing <product> --bin-id <bin>") - ); - } - - #[test] - fn partially_bound_basket_item_add_is_placeholdered() { - let action = terminal_action_from_command("radroots basket item add basket_test"); - - assert_eq!(action.kind, TerminalActionKind::Placeholder); - assert_eq!( - action.command.as_deref(), - Some("radroots basket item add basket_test --listing <product> --bin-id <bin>") - ); - } - - #[test] - fn complete_basket_item_add_renders_as_command() { - let action = terminal_action_from_command( - "radroots basket item add basket_test --listing eggs --bin-id bin-1", - ); - - assert_eq!(action.kind, TerminalActionKind::Command); - assert_eq!( - action.command.as_deref(), - Some("radroots basket item add basket_test --listing eggs --bin-id bin-1") - ); - } - - #[test] - fn converts_operator_config_action_to_setup() { - let action = NextAction { - kind: NextActionKind::OperatorConfig, - label: "configure token".to_owned(), - command: None, - description: Some("configure RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_FILE".to_owned()), - env_var: Some("RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_FILE".to_owned()), - config_key: None, - }; - - let terminal = terminal_actions_from_next_actions(&[action]); - - assert_eq!(terminal.len(), 1); - assert_eq!(terminal[0].kind, TerminalActionKind::Setup); - assert_eq!( - terminal[0].description.as_deref(), - Some("configure RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_FILE") - ); - } -} diff --git a/src/out/terminal/errors.rs b/src/out/terminal/errors.rs @@ -1,55 +0,0 @@ -use crate::out::envelope::{OutputEnvelope, OutputError}; - -use super::actions::terminal_actions_from_next_actions; -use super::layout::{TerminalDocument, TerminalField, TerminalHeader, TerminalSymbol}; - -pub fn terminal_error_document(envelope: &OutputEnvelope) -> TerminalDocument { - let error = envelope.errors.first(); - let title = error - .map(error_title) - .unwrap_or_else(|| "Command failed".to_owned()); - let mut document = TerminalDocument::new(TerminalHeader::new(TerminalSymbol::Failure, title)); - if let Some(error) = error { - document - .fields - .push(TerminalField::essential("Reason", error.message.clone())); - document - .fields - .push(TerminalField::verbose("Code", error.reason_code.clone())); - } - document.next = terminal_actions_from_next_actions(&envelope.next_actions); - document -} - -fn error_title(error: &OutputError) -> String { - match error.code.as_str() { - "not_found" => "Not found", - "invalid_input" => "Invalid input", - "approval_required" => "Approval required", - "operation_unavailable" => "Unavailable", - _ => "Command failed", - } - .to_owned() -} - -#[cfg(test)] -mod tests { - use crate::out::envelope::{CliExitCode, EnvelopeContext, OutputEnvelope, OutputError}; - - use super::*; - - #[test] - fn builds_terminal_error_document() { - let error = OutputError::new("invalid_input", "missing input", CliExitCode::InvalidInput); - let envelope = OutputEnvelope::failure( - "trade.proposal.submit", - error, - EnvelopeContext::new("req_test", false), - ); - let document = terminal_error_document(&envelope); - - assert_eq!(document.header.title, "Invalid input"); - assert_eq!(document.fields[0].label, "Reason"); - assert_eq!(document.fields[0].value, "missing input"); - } -} diff --git a/src/out/terminal/layout.rs b/src/out/terminal/layout.rs @@ -1,231 +0,0 @@ -use super::actions::TerminalAction; -use super::tables::TerminalTable; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TerminalSymbol { - Success, - Neutral, - Attention, - Failure, -} - -impl TerminalSymbol { - pub fn glyph(self) -> &'static str { - match self { - Self::Success => "✓", - Self::Neutral => "◌", - Self::Attention => "!", - Self::Failure => "✕", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TerminalVisibility { - Essential, - Normal, - Verbose, - Trace, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalHeader { - pub symbol: TerminalSymbol, - pub title: String, -} - -impl TerminalHeader { - pub fn new(symbol: TerminalSymbol, title: impl Into<String>) -> Self { - Self { - symbol, - title: title.into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalField { - pub label: String, - pub value: String, - pub visibility: TerminalVisibility, -} - -impl TerminalField { - pub fn essential(label: impl Into<String>, value: impl Into<String>) -> Self { - Self { - label: label.into(), - value: value.into(), - visibility: TerminalVisibility::Essential, - } - } - - pub fn new(label: impl Into<String>, value: impl Into<String>) -> Self { - Self { - label: label.into(), - value: value.into(), - visibility: TerminalVisibility::Normal, - } - } - - pub fn verbose(label: impl Into<String>, value: impl Into<String>) -> Self { - Self { - label: label.into(), - value: value.into(), - visibility: TerminalVisibility::Verbose, - } - } - - pub fn trace(label: impl Into<String>, value: impl Into<String>) -> Self { - Self { - label: label.into(), - value: value.into(), - visibility: TerminalVisibility::Trace, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum TerminalSectionBody { - Lines(Vec<String>), - Fields(Vec<TerminalField>), - Table(TerminalTable), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalSection { - pub title: String, - pub body: TerminalSectionBody, - pub visibility: TerminalVisibility, -} - -impl TerminalSection { - pub fn lines(title: impl Into<String>, lines: Vec<String>) -> Self { - Self { - title: title.into(), - body: TerminalSectionBody::Lines(lines), - visibility: TerminalVisibility::Normal, - } - } - - pub fn fields(title: impl Into<String>, fields: Vec<TerminalField>) -> Self { - Self { - title: title.into(), - body: TerminalSectionBody::Fields(fields), - visibility: TerminalVisibility::Normal, - } - } - - pub fn table(title: impl Into<String>, table: TerminalTable) -> Self { - Self { - title: title.into(), - body: TerminalSectionBody::Table(table), - visibility: TerminalVisibility::Normal, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalWarning { - pub code: String, - pub message: String, -} - -impl TerminalWarning { - pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self { - Self { - code: code.into(), - message: message.into(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct TerminalReference { - pub request_id: Option<String>, - pub correlation_id: Option<String>, - pub idempotency_key: Option<String>, - pub event_id: Option<String>, - pub event_addr: Option<String>, - pub job_id: Option<String>, - pub path: Option<String>, - pub source: Option<String>, -} - -impl TerminalReference { - pub fn is_empty(&self) -> bool { - self.request_id.is_none() - && self.correlation_id.is_none() - && self.idempotency_key.is_none() - && self.event_id.is_none() - && self.event_addr.is_none() - && self.job_id.is_none() - && self.path.is_none() - && self.source.is_none() - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalDocument { - pub header: TerminalHeader, - pub fields: Vec<TerminalField>, - pub sections: Vec<TerminalSection>, - pub warnings: Vec<TerminalWarning>, - pub next: Vec<TerminalAction>, - pub reference: Option<TerminalReference>, -} - -impl TerminalDocument { - pub fn new(header: TerminalHeader) -> Self { - Self { - header, - fields: Vec::new(), - sections: Vec::new(), - warnings: Vec::new(), - next: Vec::new(), - reference: None, - } - } - - pub fn with_field(mut self, field: TerminalField) -> Self { - self.fields.push(field); - self - } - - pub fn with_section(mut self, section: TerminalSection) -> Self { - self.sections.push(section); - self - } - - pub fn with_next(mut self, action: TerminalAction) -> Self { - self.next.push(action); - self - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn symbols_match_terminal_contract() { - assert_eq!(TerminalSymbol::Success.glyph(), "✓"); - assert_eq!(TerminalSymbol::Neutral.glyph(), "◌"); - assert_eq!(TerminalSymbol::Attention.glyph(), "!"); - assert_eq!(TerminalSymbol::Failure.glyph(), "✕"); - } - - #[test] - fn document_defaults_to_empty_body() { - let document = TerminalDocument::new(TerminalHeader::new( - TerminalSymbol::Success, - "Workspace ready", - )); - - assert_eq!(document.header.title, "Workspace ready"); - assert!(document.fields.is_empty()); - assert!(document.sections.is_empty()); - assert!(document.warnings.is_empty()); - assert!(document.next.is_empty()); - assert!(document.reference.is_none()); - } -} diff --git a/src/out/terminal/mod.rs b/src/out/terminal/mod.rs @@ -1,8 +0,0 @@ -pub mod actions; -pub mod errors; -pub mod layout; -pub mod registry; -pub mod renderer; -pub mod renderers; -pub mod tables; -pub mod values; diff --git a/src/out/terminal/registry.rs b/src/out/terminal/registry.rs @@ -1,340 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; - -use crate::out::envelope::OutputEnvelope; -use crate::registry::OperationSpec; - -use super::layout::TerminalDocument; -use super::renderer::TerminalRenderContext; - -pub trait TerminalOperationRenderer { - fn render(&self, envelope: &OutputEnvelope, cx: &TerminalRenderContext) -> TerminalDocument; -} - -#[derive(Default)] -pub struct TerminalRendererRegistry { - entries: Vec<TerminalRendererEntry>, - violations: Vec<TerminalRendererRegistryViolation>, -} - -struct TerminalRendererEntry { - operation_id: &'static str, - renderer: &'static dyn TerminalOperationRenderer, -} - -impl TerminalRendererRegistry { - pub fn new() -> Self { - Self { - entries: Vec::new(), - violations: Vec::new(), - } - } - - pub fn register( - mut self, - operation_id: &'static str, - renderer: &'static dyn TerminalOperationRenderer, - ) -> Self { - if self.contains(operation_id) { - self.violations - .push(TerminalRendererRegistryViolation::duplicate(operation_id)); - } - self.entries.push(TerminalRendererEntry { - operation_id, - renderer, - }); - self - } - - pub fn contains(&self, operation_id: &str) -> bool { - self.entries - .iter() - .any(|entry| entry.operation_id == operation_id) - } - - pub fn get(&self, operation_id: &str) -> Option<&'static dyn TerminalOperationRenderer> { - let mut matches = self - .entries - .iter() - .filter(|entry| entry.operation_id == operation_id); - let renderer = matches.next()?.renderer; - matches.next().is_none().then_some(renderer) - } - - pub fn len(&self) -> usize { - self.entries.len() - } - - pub fn is_empty(&self) -> bool { - self.entries.is_empty() - } - - pub fn operation_ids(&self) -> impl Iterator<Item = &'static str> + '_ { - self.entries.iter().map(|entry| entry.operation_id) - } - - pub fn renderer_for( - &self, - operation_id: &str, - ) -> Result<&'static dyn TerminalOperationRenderer, TerminalRendererRegistryError> { - self.get(operation_id).ok_or_else(|| { - TerminalRendererRegistryError::from_violation( - TerminalRendererRegistryViolation::missing(operation_id.to_owned()), - ) - }) - } - - pub fn validate_against_operations( - &self, - operations: &[OperationSpec], - ) -> Result<(), TerminalRendererRegistryError> { - self.validate_operation_ids(operations.iter().map(|operation| operation.operation_id)) - } - - pub fn validate_operation_ids<'a>( - &self, - expected: impl IntoIterator<Item = &'a str>, - ) -> Result<(), TerminalRendererRegistryError> { - let expected = expected.into_iter().collect::<BTreeSet<_>>(); - let mut counts = BTreeMap::<&str, usize>::new(); - for operation_id in self.operation_ids() { - *counts.entry(operation_id).or_default() += 1; - } - let mut violations = self.violations.clone(); - for operation_id in &expected { - match counts.get(operation_id).copied().unwrap_or(0) { - 0 => { - violations.push(TerminalRendererRegistryViolation::missing( - (*operation_id).to_owned(), - )); - } - 1 => {} - count => { - violations.push(TerminalRendererRegistryViolation::duplicate_count( - operation_id.to_owned(), - count, - )); - } - } - } - for operation_id in counts.keys() { - if !expected.contains(operation_id) { - violations.push(TerminalRendererRegistryViolation::extra(*operation_id)); - } - } - violations.sort(); - violations.dedup(); - if violations.is_empty() { - Ok(()) - } else { - Err(TerminalRendererRegistryError { violations }) - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub enum TerminalRendererRegistryViolation { - DuplicateOperation { operation_id: String, count: usize }, - ExtraOperation { operation_id: String }, - MissingOperation { operation_id: String }, -} - -impl TerminalRendererRegistryViolation { - fn duplicate(operation_id: impl Into<String>) -> Self { - Self::DuplicateOperation { - operation_id: operation_id.into(), - count: 2, - } - } - - fn duplicate_count(operation_id: impl Into<String>, count: usize) -> Self { - Self::DuplicateOperation { - operation_id: operation_id.into(), - count, - } - } - - fn extra(operation_id: impl Into<String>) -> Self { - Self::ExtraOperation { - operation_id: operation_id.into(), - } - } - - fn missing(operation_id: impl Into<String>) -> Self { - Self::MissingOperation { - operation_id: operation_id.into(), - } - } - - pub fn kind(&self) -> &'static str { - match self { - Self::DuplicateOperation { .. } => "duplicate", - Self::ExtraOperation { .. } => "extra", - Self::MissingOperation { .. } => "missing", - } - } - - pub fn operation_id(&self) -> &str { - match self { - Self::DuplicateOperation { operation_id, .. } - | Self::ExtraOperation { operation_id } - | Self::MissingOperation { operation_id } => operation_id, - } - } - - pub fn count(&self) -> Option<usize> { - match self { - Self::DuplicateOperation { count, .. } => Some(*count), - Self::ExtraOperation { .. } | Self::MissingOperation { .. } => None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalRendererRegistryError { - violations: Vec<TerminalRendererRegistryViolation>, -} - -impl TerminalRendererRegistryError { - pub fn from_violation(violation: TerminalRendererRegistryViolation) -> Self { - Self { - violations: vec![violation], - } - } - - pub fn violations(&self) -> &[TerminalRendererRegistryViolation] { - &self.violations - } -} - -impl fmt::Display for TerminalRendererRegistryError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str("terminal renderer registry invariant failed")?; - for violation in &self.violations { - match violation { - TerminalRendererRegistryViolation::DuplicateOperation { - operation_id, - count, - } => write!( - formatter, - "; duplicate renderer for {operation_id} ({count})" - )?, - TerminalRendererRegistryViolation::ExtraOperation { operation_id } => { - write!(formatter, "; unexpected renderer for {operation_id}")? - } - TerminalRendererRegistryViolation::MissingOperation { operation_id } => { - write!(formatter, "; missing renderer for {operation_id}")? - } - } - } - Ok(()) - } -} - -pub fn terminal_renderer_registry() -> TerminalRendererRegistry { - let registry = TerminalRendererRegistry::new(); - crate::out::terminal::renderers::v1::register(registry) -} - -#[cfg(test)] -mod tests { - use crate::out::terminal::layout::{TerminalDocument, TerminalHeader, TerminalSymbol}; - use crate::registry::OPERATION_REGISTRY; - - use super::*; - - struct TestRenderer; - - impl TerminalOperationRenderer for TestRenderer { - fn render( - &self, - _envelope: &OutputEnvelope, - _cx: &TerminalRenderContext, - ) -> TerminalDocument { - TerminalDocument::new(TerminalHeader::new(TerminalSymbol::Success, "ok")) - } - } - - static TEST_RENDERER: TestRenderer = TestRenderer; - - #[test] - fn registers_operation_renderers() { - let registry = TerminalRendererRegistry::new().register("profile.inspect", &TEST_RENDERER); - - assert!(registry.contains("profile.inspect")); - assert_eq!(registry.len(), 1); - assert!(registry.get("profile.inspect").is_some()); - assert!(registry.get("missing").is_none()); - } - - #[test] - fn duplicate_operation_renderer_registration_fails() { - let registry = TerminalRendererRegistry::new() - .register("profile.inspect", &TEST_RENDERER) - .register("profile.inspect", &TEST_RENDERER); - let error = registry - .validate_operation_ids(["profile.inspect"]) - .expect_err("duplicate renderer registration should fail"); - - assert_eq!( - error.violations(), - &[TerminalRendererRegistryViolation::duplicate_count( - "profile.inspect", - 2 - )] - ); - assert!(registry.get("profile.inspect").is_none()); - } - - #[test] - fn missing_operation_renderer_registration_fails() { - let error = TerminalRendererRegistry::new() - .validate_operation_ids(["profile.inspect"]) - .expect_err("missing renderer registration should fail"); - - assert_eq!( - error.violations(), - &[TerminalRendererRegistryViolation::missing( - "profile.inspect" - )] - ); - } - - #[test] - fn extra_operation_renderer_registration_fails() { - let error = TerminalRendererRegistry::new() - .register("test.extra", &TEST_RENDERER) - .validate_operation_ids(["profile.inspect"]) - .expect_err("extra renderer registration should fail"); - - assert_eq!( - error.violations(), - &[ - TerminalRendererRegistryViolation::extra("test.extra"), - TerminalRendererRegistryViolation::missing("profile.inspect"), - ] - ); - } - - #[test] - fn registry_covers_registered_operations() { - let registry = terminal_renderer_registry(); - let expected = OPERATION_REGISTRY - .iter() - .map(|operation| operation.operation_id) - .collect::<BTreeSet<_>>(); - let actual = registry.operation_ids().collect::<BTreeSet<_>>(); - - assert_eq!(registry.len(), OPERATION_REGISTRY.len()); - assert_eq!(actual, expected); - registry - .validate_against_operations(OPERATION_REGISTRY) - .expect("terminal registry covers operation registry"); - for operation_id in expected { - assert!( - registry.contains(operation_id), - "missing terminal renderer for {operation_id}" - ); - } - } -} diff --git a/src/out/terminal/renderer.rs b/src/out/terminal/renderer.rs @@ -1,276 +0,0 @@ -use std::fmt::Write as _; - -use super::actions::TerminalAction; -use super::layout::{ - TerminalDocument, TerminalField, TerminalReference, TerminalSection, TerminalSectionBody, - TerminalVisibility, -}; -use super::tables::{TerminalTable, truncate_cell}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TerminalVerbosity { - Quiet, - Normal, - Verbose, - Trace, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalRenderContext { - pub verbosity: TerminalVerbosity, - pub width: usize, - pub dry_run: bool, -} - -impl Default for TerminalRenderContext { - fn default() -> Self { - Self { - verbosity: TerminalVerbosity::Normal, - width: 80, - dry_run: false, - } - } -} - -impl TerminalRenderContext { - pub fn is_quiet(&self) -> bool { - matches!(self.verbosity, TerminalVerbosity::Quiet) - } - - pub fn allows(&self, visibility: TerminalVisibility) -> bool { - match visibility { - TerminalVisibility::Essential => true, - TerminalVisibility::Normal => !self.is_quiet(), - TerminalVisibility::Verbose => { - matches!( - self.verbosity, - TerminalVerbosity::Verbose | TerminalVerbosity::Trace - ) - } - TerminalVisibility::Trace => matches!(self.verbosity, TerminalVerbosity::Trace), - } - } -} - -pub fn render_terminal_document(document: &TerminalDocument, cx: &TerminalRenderContext) -> String { - let mut output = String::new(); - let _ = writeln!( - output, - "{} {}", - document.header.symbol.glyph(), - document.header.title - ); - write_fields(&mut output, &document.fields, cx); - for section in &document.sections { - write_section(&mut output, section, cx); - } - if !cx.is_quiet() && !document.warnings.is_empty() { - let _ = writeln!(output); - let _ = writeln!(output, "Warnings"); - for warning in &document.warnings { - let _ = writeln!(output, " {}: {}", warning.code, warning.message); - } - } - if !cx.is_quiet() { - write_next(&mut output, &document.next); - } - if let Some(reference) = &document.reference - && cx.allows(TerminalVisibility::Verbose) - && !reference.is_empty() - { - write_reference(&mut output, reference); - } - output.trim_end_matches('\n').to_owned() -} - -fn write_fields(output: &mut String, fields: &[TerminalField], cx: &TerminalRenderContext) { - let fields = fields - .iter() - .filter(|field| cx.allows(field.visibility)) - .collect::<Vec<_>>(); - if fields.is_empty() { - return; - } - let width = fields - .iter() - .map(|field| field.label.chars().count()) - .max() - .unwrap_or(0); - let _ = writeln!(output); - for field in fields { - let _ = writeln!( - output, - " {label:<width$} {value}", - label = field.label, - width = width, - value = field.value - ); - } -} - -fn write_section(output: &mut String, section: &TerminalSection, cx: &TerminalRenderContext) { - if !cx.allows(section.visibility) { - return; - } - let _ = writeln!(output); - let _ = writeln!(output, "{}", section.title); - match &section.body { - TerminalSectionBody::Lines(lines) => { - for line in lines { - let _ = writeln!(output, " {line}"); - } - } - TerminalSectionBody::Fields(fields) => write_fields(output, fields, cx), - TerminalSectionBody::Table(table) => write_table(output, table, cx.width), - } -} - -fn write_table(output: &mut String, table: &TerminalTable, width: usize) { - if table.rows.is_empty() { - if let Some(empty) = &table.empty { - let _ = writeln!(output, " {empty}"); - } - return; - } - let widths = table.column_widths(width.saturating_sub(2)); - let labels = table - .columns - .iter() - .zip(widths.iter()) - .map(|(column, width)| { - format!( - "{:<width$}", - truncate_cell(&column.label, *width), - width = *width - ) - }) - .collect::<Vec<_>>() - .join(" "); - let _ = writeln!(output, " {labels}"); - for row in &table.rows { - let line = widths - .iter() - .enumerate() - .map(|(index, width)| { - let cell = row.cells.get(index).map(String::as_str).unwrap_or(""); - format!("{:<width$}", truncate_cell(cell, *width), width = *width) - }) - .collect::<Vec<_>>() - .join(" "); - let _ = writeln!(output, " {line}"); - } -} - -fn write_next(output: &mut String, actions: &[TerminalAction]) { - let visible = actions - .iter() - .filter_map(|action| action.render_line()) - .collect::<Vec<_>>(); - if visible.is_empty() { - return; - } - let _ = writeln!(output); - let _ = writeln!(output, "Next"); - for action in visible { - let _ = writeln!(output, " {action}"); - } -} - -fn write_reference(output: &mut String, reference: &TerminalReference) { - let fields = [ - ("Request", reference.request_id.as_ref()), - ("Correlation", reference.correlation_id.as_ref()), - ("Idempotency", reference.idempotency_key.as_ref()), - ("Event", reference.event_id.as_ref()), - ("Address", reference.event_addr.as_ref()), - ("Job", reference.job_id.as_ref()), - ("Path", reference.path.as_ref()), - ("Source", reference.source.as_ref()), - ] - .into_iter() - .filter_map(|(label, value)| value.map(|value| TerminalField::new(label, value))) - .collect::<Vec<_>>(); - let _ = writeln!(output); - let _ = writeln!(output, "Reference"); - write_fields(output, &fields, &TerminalRenderContext::default()); -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::out::terminal::actions::TerminalAction; - use crate::out::terminal::layout::{ - TerminalField, TerminalHeader, TerminalSection, TerminalSymbol, TerminalWarning, - }; - - #[test] - fn renders_canonical_receipt_layout() { - let document = TerminalDocument::new(TerminalHeader::new( - TerminalSymbol::Success, - "Listing published", - )) - .with_field(TerminalField::new("Listing", "AAAAAAAAAAAAAAAAAAAAAg")) - .with_field(TerminalField::new("Transport", "nostr")) - .with_field(TerminalField::new("Targets", "2 acknowledged · 0 failed")) - .with_field(TerminalField::new("Event", "9f3a…c12")) - .with_next(TerminalAction::command( - "radroots listing get AAAAAAAAAAAAAAAAAAAAAg", - )); - - assert_eq!( - render_terminal_document(&document, &TerminalRenderContext::default()), - "✓ Listing published\n\n Listing AAAAAAAAAAAAAAAAAAAAAg\n Transport nostr\n Targets 2 acknowledged · 0 failed\n Event 9f3a…c12\n\nNext\n radroots listing get AAAAAAAAAAAAAAAAAAAAAg" - ); - } - - #[test] - fn hides_reference_until_verbose() { - let mut document = - TerminalDocument::new(TerminalHeader::new(TerminalSymbol::Success, "Ready")); - document.reference = Some(TerminalReference { - request_id: Some("req_test".to_owned()), - ..TerminalReference::default() - }); - - assert_eq!( - render_terminal_document(&document, &TerminalRenderContext::default()), - "✓ Ready" - ); - - let cx = TerminalRenderContext { - verbosity: TerminalVerbosity::Verbose, - ..TerminalRenderContext::default() - }; - assert!(render_terminal_document(&document, &cx).contains("Reference")); - } - - #[test] - fn quiet_renders_only_header_and_essential_fields() { - let mut document = TerminalDocument::new(TerminalHeader::new( - TerminalSymbol::Failure, - "Command failed", - )) - .with_field(TerminalField::essential("Reason", "missing approval")) - .with_field(TerminalField::new("State", "not implemented")) - .with_section(TerminalSection::lines( - "Details", - vec!["extra context".to_owned()], - )) - .with_next(TerminalAction::command("radroots trade get ord_test")); - document.warnings = vec![TerminalWarning::new("warn_test", "suppressed")]; - document.reference = Some(TerminalReference { - request_id: Some("req_test".to_owned()), - ..TerminalReference::default() - }); - - let rendered = render_terminal_document( - &document, - &TerminalRenderContext { - verbosity: TerminalVerbosity::Quiet, - ..TerminalRenderContext::default() - }, - ); - - assert_eq!(rendered, "✕ Command failed\n\n Reason missing approval"); - } -} diff --git a/src/out/terminal/renderers/common.rs b/src/out/terminal/renderers/common.rs @@ -1,209 +0,0 @@ -use serde_json::Value; - -use crate::out::envelope::OutputEnvelope; -use crate::out::terminal::actions::terminal_actions_from_next_actions; -use crate::out::terminal::errors::terminal_error_document; -use crate::out::terminal::layout::{ - TerminalDocument, TerminalField, TerminalHeader, TerminalReference, TerminalSymbol, - TerminalWarning, -}; -use crate::out::terminal::values::{proof_summary, string_path, transport_label}; - -pub(crate) fn base_terminal_document(envelope: &OutputEnvelope) -> TerminalDocument { - let display = terminal_display_source(envelope); - let mut document = if envelope.errors.is_empty() { - let status = terminal_envelope_status(envelope); - TerminalDocument::new(TerminalHeader::new( - terminal_status_symbol(status, envelope.dry_run), - terminal_title(envelope.operation_id.as_str(), status), - )) - } else { - let mut document = terminal_error_document(envelope); - add_terminal_display_fields(&mut document, display, false); - document - }; - if envelope.errors.is_empty() { - add_terminal_display_fields(&mut document, display, true); - } - document.warnings = envelope - .warnings - .iter() - .map(|warning| TerminalWarning::new(warning.code.clone(), warning.message.clone())) - .collect(); - document.next = terminal_actions_from_next_actions(&envelope.next_actions); - document.reference = terminal_reference(envelope); - document -} - -fn push_field(document: &mut TerminalDocument, label: impl Into<String>, value: impl Into<String>) { - let label = label.into(); - let value = value.into(); - if value.trim().is_empty() { - return; - } - if document - .fields - .iter() - .any(|field| field.label == label && field.value == value) - { - return; - } - document.fields.push(TerminalField::new(label, value)); -} - -fn add_terminal_display_fields( - document: &mut TerminalDocument, - display: &Value, - include_reason: bool, -) { - if let Some(state) = terminal_state(display) { - push_field(document, "State", terminal_status_label(state)); - } - if let Some(mode) = terminal_transport_profile(display) { - push_field(document, "Transport", transport_label(mode)); - } - if let Some(state) = terminal_publish_state(display) { - push_field(document, "Publish", terminal_status_label(state)); - } - if let Some(proof) = proof_summary(display) { - push_field(document, "Proof", proof); - } - if include_reason && let Some(reason) = terminal_reason(display) { - push_field(document, "Reason", reason.to_owned()); - } -} - -fn terminal_reference(envelope: &OutputEnvelope) -> Option<TerminalReference> { - let reference = TerminalReference { - request_id: Some(envelope.request_id.clone()), - correlation_id: envelope.correlation_id.clone(), - idempotency_key: envelope.idempotency_key.clone(), - event_id: None, - event_addr: None, - job_id: None, - path: None, - source: None, - }; - (!reference.is_empty()).then_some(reference) -} - -fn terminal_display_source(envelope: &OutputEnvelope) -> &Value { - if !envelope.result.is_null() { - return &envelope.result; - } - envelope - .errors - .first() - .and_then(|error| error.detail.as_ref()) - .unwrap_or(&envelope.result) -} - -fn terminal_state(result: &Value) -> Option<&str> { - string_path(result, &["state"]) -} - -fn terminal_transport_profile(result: &Value) -> Option<&str> { - string_path(result, &["publish", "mode"]) - .or_else(|| string_path(result, &["checks", "publish", "mode"])) - .or_else(|| string_path(result, &["transport_profile"])) -} - -fn terminal_publish_state(result: &Value) -> Option<&str> { - string_path(result, &["publish", "state"]) - .or_else(|| string_path(result, &["checks", "publish", "state"])) - .or_else(|| string_path(result, &["publish_state"])) -} - -fn terminal_reason(result: &Value) -> Option<&str> { - string_path(result, &["reason"]) - .or_else(|| string_path(result, &["publish", "reason"])) - .or_else(|| string_path(result, &["checks", "publish", "reason"])) - .or_else(|| string_path(result, &["store", "reason"])) - .or_else(|| string_path(result, &["checks", "store", "reason"])) - .or_else(|| string_path(result, &["checks", "account", "reason"])) -} - -fn terminal_envelope_status(envelope: &OutputEnvelope) -> &str { - if !envelope.errors.is_empty() { - return "error"; - } - if let Some(state) = envelope - .result - .get("state") - .and_then(|value| value.as_str()) - { - return state; - } - if envelope.dry_run { - return "dry_run"; - } - "ok" -} - -fn terminal_status_symbol(status: &str, dry_run: bool) -> TerminalSymbol { - if dry_run || status == "dry_run" { - return TerminalSymbol::Neutral; - } - if terminal_status_needs_attention(status) { - TerminalSymbol::Attention - } else { - TerminalSymbol::Success - } -} - -fn terminal_status_needs_attention(status: &str) -> bool { - matches!( - status, - "needs_attention" - | "unconfigured" - | "unavailable" - | "blocked" - | "invalid" - | "not_ready" - | "partial" - | "degraded" - | "failed" - | "conflict" - | "missing" - ) -} - -fn terminal_title(operation_id: &str, status: &str) -> String { - format!( - "{} {}", - operation_title(operation_id), - terminal_status_label(status) - ) -} - -fn operation_title(operation_id: &str) -> String { - let mut words = operation_id - .split('.') - .flat_map(|part| part.split('_')) - .filter(|part| !part.is_empty()) - .map(str::to_owned) - .collect::<Vec<_>>(); - if let Some(first) = words.first_mut() { - *first = capitalize_ascii_word(first); - } - words.join(" ") -} - -fn terminal_status_label(status: &str) -> String { - status - .split('_') - .filter(|part| !part.is_empty()) - .collect::<Vec<_>>() - .join(" ") -} - -fn capitalize_ascii_word(word: &str) -> String { - let mut chars = word.chars(); - let Some(first) = chars.next() else { - return String::new(); - }; - let mut rendered = String::new(); - rendered.push(first.to_ascii_uppercase()); - rendered.extend(chars); - rendered -} diff --git a/src/out/terminal/renderers/mod.rs b/src/out/terminal/renderers/mod.rs @@ -1,2 +0,0 @@ -pub mod common; -pub mod v1; diff --git a/src/out/terminal/renderers/v1.rs b/src/out/terminal/renderers/v1.rs @@ -1,25 +0,0 @@ -use crate::out::envelope::OutputEnvelope; -use crate::out::terminal::layout::TerminalDocument; -use crate::out::terminal::registry::{TerminalOperationRenderer, TerminalRendererRegistry}; -use crate::out::terminal::renderer::TerminalRenderContext; -use crate::registry::OPERATION_REGISTRY; - -use super::common; - -pub fn register(registry: TerminalRendererRegistry) -> TerminalRendererRegistry { - OPERATION_REGISTRY - .iter() - .fold(registry, |registry, operation| { - registry.register(operation.operation_id, &V1_RENDERER) - }) -} - -struct V1Renderer; - -static V1_RENDERER: V1Renderer = V1Renderer; - -impl TerminalOperationRenderer for V1Renderer { - fn render(&self, envelope: &OutputEnvelope, _cx: &TerminalRenderContext) -> TerminalDocument { - common::base_terminal_document(envelope) - } -} diff --git a/src/out/terminal/tables.rs b/src/out/terminal/tables.rs @@ -1,123 +0,0 @@ -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalTableColumn { - pub label: String, - pub min_width: usize, - pub max_width: usize, -} - -impl TerminalTableColumn { - pub fn new(label: impl Into<String>, min_width: usize, max_width: usize) -> Self { - Self { - label: label.into(), - min_width, - max_width: max_width.max(min_width), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalTableRow { - pub cells: Vec<String>, -} - -impl TerminalTableRow { - pub fn new(cells: Vec<impl Into<String>>) -> Self { - Self { - cells: cells.into_iter().map(Into::into).collect(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TerminalTable { - pub columns: Vec<TerminalTableColumn>, - pub rows: Vec<TerminalTableRow>, - pub empty: Option<String>, -} - -impl TerminalTable { - pub fn new(columns: Vec<TerminalTableColumn>) -> Self { - Self { - columns, - rows: Vec::new(), - empty: None, - } - } - - pub fn with_row(mut self, row: TerminalTableRow) -> Self { - self.rows.push(row); - self - } - - pub fn with_empty(mut self, empty: impl Into<String>) -> Self { - self.empty = Some(empty.into()); - self - } - - pub fn column_widths(&self, width: usize) -> Vec<usize> { - if self.columns.is_empty() { - return Vec::new(); - } - let separators = self.columns.len().saturating_sub(1) * 2; - let available = width.saturating_sub(separators).max(self.columns.len()); - let mut widths = self - .columns - .iter() - .map(|column| column.min_width.min(column.max_width)) - .collect::<Vec<_>>(); - let min_total = widths.iter().sum::<usize>(); - let mut remaining = available.saturating_sub(min_total); - for (index, column) in self.columns.iter().enumerate() { - if remaining == 0 { - break; - } - let current = widths[index]; - let target = column.max_width.max(current); - let add = target.saturating_sub(current).min(remaining); - widths[index] += add; - remaining -= add; - } - widths - } -} - -pub fn truncate_cell(value: &str, width: usize) -> String { - if width == 0 { - return String::new(); - } - let chars = value.chars().collect::<Vec<_>>(); - if chars.len() <= width { - return value.to_owned(); - } - if width == 1 { - return "…".to_owned(); - } - chars - .into_iter() - .take(width.saturating_sub(1)) - .collect::<String>() - + "…" -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn truncates_cells_deterministically() { - assert_eq!(truncate_cell("abcdef", 4), "abc…"); - assert_eq!(truncate_cell("abcdef", 1), "…"); - assert_eq!(truncate_cell("abc", 4), "abc"); - } - - #[test] - fn computes_stable_column_widths() { - let table = TerminalTable::new(vec![ - TerminalTableColumn::new("Name", 4, 8), - TerminalTableColumn::new("State", 5, 10), - ]); - - assert_eq!(table.column_widths(20), vec![8, 10]); - assert_eq!(table.column_widths(10), vec![4, 5]); - } -} diff --git a/src/out/terminal/values.rs b/src/out/terminal/values.rs @@ -1,150 +0,0 @@ -use std::path::Path; - -use serde_json::Value; - -pub fn transport_label(value: &str) -> String { - match value { - "nostr" => "nostr", - "reticulum" => "reticulum", - "local" => "local", - other => other, - } - .to_owned() -} - -pub fn relay_summary(success: usize, failed: usize, success_label: &str) -> String { - format!("{success} {success_label} · {failed} failed") -} - -pub fn yes_no(value: bool) -> &'static str { - if value { "yes" } else { "no" } -} - -pub fn ready_blocked(value: bool) -> &'static str { - if value { "ready" } else { "blocked" } -} - -pub fn plural(count: usize, singular: &str, plural: &str) -> String { - if count == 1 { - format!("1 {singular}") - } else { - format!("{count} {plural}") - } -} - -pub fn quantity_label(amount: impl ToString, unit: Option<&str>) -> String { - match unit.filter(|unit| !unit.trim().is_empty()) { - Some(unit) => format!("{} {unit}", amount.to_string()), - None => format!("quantity {}", amount.to_string()), - } -} - -pub fn price_label(amount: impl ToString, currency: &str, unit: Option<&str>) -> String { - match unit.filter(|unit| !unit.trim().is_empty()) { - Some(unit) => format!("{} {currency} / {unit}", amount.to_string()), - None => format!("{} {currency}", amount.to_string()), - } -} - -pub fn compact_id(value: &str, head: usize, tail: usize) -> String { - let chars = value.chars().collect::<Vec<_>>(); - if chars.len() <= head + tail + 1 { - return value.to_owned(); - } - let start = chars.iter().take(head).collect::<String>(); - let end = chars - .iter() - .skip(chars.len().saturating_sub(tail)) - .collect::<String>(); - format!("{start}…{end}") -} - -pub fn path_label(path: &Path) -> String { - path.display().to_string() -} - -pub fn string_path<'a>(value: &'a Value, path: &[&str]) -> Option<&'a str> { - let mut current = value; - for segment in path { - current = current.get(*segment)?; - } - current.as_str().filter(|value| !value.trim().is_empty()) -} - -pub fn bool_path(value: &Value, path: &[&str]) -> Option<bool> { - let mut current = value; - for segment in path { - current = current.get(*segment)?; - } - current.as_bool() -} - -pub fn proof_summary(value: &Value) -> Option<String> { - if bool_path( - value, - &["proof_verification", "cryptographic_proof_verified"], - ) == Some(true) - { - let system = string_path(value, &["proof_verification", "proof_system"]) - .or_else(|| string_path(value, &["receipt", "proof", "system"])) - .unwrap_or("cryptographic proof"); - return Some(format!("verified · {system}")); - } - if string_path(value, &["proof_verification", "state"]).is_some() { - return Some("available".to_owned()); - } - None -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn formats_domain_values() { - assert_eq!(transport_label("nostr"), "nostr"); - assert_eq!( - relay_summary(2, 0, "acknowledged"), - "2 acknowledged · 0 failed" - ); - assert_eq!(quantity_label("12", Some("lb")), "12 lb"); - assert_eq!(quantity_label("3", None), "quantity 3"); - assert_eq!(price_label("6.00", "CAD", Some("1 lb")), "6.00 CAD / 1 lb"); - assert_eq!(plural(1, "relay", "relays"), "1 relay"); - assert_eq!(plural(2, "relay", "relays"), "2 relays"); - } - - #[test] - fn compacts_long_ids() { - assert_eq!(compact_id("1234567890abcdef", 4, 3), "1234…def"); - assert_eq!(compact_id("short", 4, 3), "short"); - } - - #[test] - fn proof_summary_requires_verified_true_for_verified_label() { - assert_eq!( - proof_summary(&json!({ - "proof_verification": { - "cryptographic_proof_verified": true, - "proof_system": "SP1" - } - })) - .as_deref(), - Some("verified · SP1") - ); - assert_eq!( - proof_summary(&json!({ - "proof_verification": { - "state": "valid", - "cryptographic_proof_verified": false, - "proof_system": "SP1" - } - })) - .as_deref(), - Some("available") - ); - assert_eq!(proof_summary(&json!({})), None); - } -} diff --git a/src/registry/mod.rs b/src/registry/mod.rs @@ -1,191 +0,0 @@ -use radroots_runtime_contract_v1::{ - ApprovalRequirementV1, DryRunSupportV1, IdempotencyPolicyV1, OperationMutabilityV1, - RuntimeOperationDescriptorV1, RuntimeOperationIdV1, SignerRequirementV1, - TransportCapabilityRouteV1, -}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct OperationSpec { - pub runtime_operation_id: RuntimeOperationIdV1, - pub descriptor: RuntimeOperationDescriptorV1, - pub operation_id: &'static str, - pub cli_path: &'static str, - pub namespace: &'static str, - pub mcp_tool: &'static str, - pub rust_request: &'static str, - pub rust_result: &'static str, - pub json_kind: &'static str, - pub description: &'static str, - pub role: OperationRole, - pub supports_json: bool, - pub supports_ndjson: bool, -} - -impl OperationSpec { - pub fn mutates(self) -> bool { - self.descriptor.mutability == OperationMutabilityV1::Mutation - } - - pub fn supports_dry_run(self) -> bool { - self.descriptor.dry_run == DryRunSupportV1::PureLocalPlan - } - - pub fn requires_idempotency(self) -> bool { - self.descriptor.idempotency == IdempotencyPolicyV1::RequiredUuidV7 - } - - pub fn forbids_idempotency(self) -> bool { - self.descriptor.idempotency == IdempotencyPolicyV1::Forbidden - } - - pub fn requires_approval(self) -> bool { - matches!( - self.descriptor.approval, - ApprovalRequirementV1::Required | ApprovalRequirementV1::ConditionalOrRequiredByMode - ) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OperationRole { - Any, - Buyer, - Seller, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum NetworkRequirement { - Local, - External { dry_run_requires_network: bool }, -} - -include!("../generated/runtime_contract_registry.rs"); - -pub fn get_operation(operation_id: &str) -> Option<&'static OperationSpec> { - OPERATION_REGISTRY - .iter() - .find(|operation| operation.operation_id == operation_id) -} - -pub fn runtime_operation_id(operation_id: &str) -> Option<RuntimeOperationIdV1> { - RuntimeOperationIdV1::parse(operation_id).ok() -} - -pub fn network_requirement(operation_id: &str) -> NetworkRequirement { - let Some(spec) = get_operation(operation_id) else { - return NetworkRequirement::Local; - }; - if requires_external_transport(spec.descriptor.transport_capability) { - NetworkRequirement::External { - dry_run_requires_network: spec.descriptor.dry_run != DryRunSupportV1::PureLocalPlan, - } - } else { - NetworkRequirement::Local - } -} - -pub fn requires_local_signer_mode(operation_id: &str) -> bool { - get_operation(operation_id).is_some_and(|operation| { - matches!( - operation.descriptor.signer, - SignerRequirementV1::Required | SignerRequirementV1::ConditionalRelayAuth - ) - }) -} - -pub fn requires_delivery_capable_transport_profile(operation_id: &str) -> bool { - get_operation(operation_id) - .is_some_and(|operation| operation.descriptor.transport_capability.deliver) -} - -pub fn registry_linkage_is_valid() -> bool { - OPERATION_REGISTRY.iter().all(|operation| { - get_operation(operation.operation_id).is_some() - && operation.operation_id == operation.json_kind - && operation.mcp_tool == operation.operation_id.replace('.', "_") - && operation.supports_json - && operation.descriptor.operation_id == operation.runtime_operation_id - && operation.descriptor.operation_id.as_str() == operation.operation_id - }) -} - -fn requires_external_transport(capability: TransportCapabilityRouteV1) -> bool { - capability.deliver || capability.fetch || capability.synchronize || capability.diagnostics -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use radroots_runtime_contract_v1::{OperationRiskV1, RUNTIME_OPERATION_DESCRIPTORS_V1}; - - use super::{OPERATION_REGISTRY, get_operation}; - - #[test] - fn registry_matches_runtime_contract_v1_catalog_exactly() { - let actual = OPERATION_REGISTRY - .iter() - .map(|operation| operation.operation_id) - .collect::<BTreeSet<_>>(); - let expected = RUNTIME_OPERATION_DESCRIPTORS_V1 - .iter() - .map(|descriptor| descriptor.operation_id.as_str()) - .collect::<BTreeSet<_>>(); - assert_eq!(actual, expected); - } - - #[test] - fn generated_registry_identity_fields_are_consistent() { - for operation in OPERATION_REGISTRY { - assert_eq!(operation.operation_id, operation.json_kind); - assert_eq!( - operation.operation_id, - operation.runtime_operation_id.as_str() - ); - assert_eq!(operation.mcp_tool, operation.operation_id.replace('.', "_")); - assert!(operation.cli_path.starts_with("radroots ")); - assert_eq!( - operation.namespace, - operation.operation_id.split('.').next().unwrap() - ); - assert!(operation.supports_json); - assert!(!operation.description.is_empty()); - assert_eq!( - get_operation(operation.operation_id) - .expect("operation") - .operation_id, - operation.operation_id - ); - } - } - - #[test] - fn generated_registry_uses_contract_policy() { - for operation in OPERATION_REGISTRY { - if operation.requires_approval() { - assert!( - matches!( - operation.descriptor.risk, - OperationRiskV1::High | OperationRiskV1::Critical - ), - "{}", - operation.operation_id - ); - } - if operation.mutates() { - assert!(operation.supports_dry_run(), "{}", operation.operation_id); - assert!( - operation.requires_idempotency(), - "{}", - operation.operation_id - ); - } else { - assert!( - operation.forbids_idempotency(), - "{}", - operation.operation_id - ); - } - } - } -} diff --git a/src/runtime/account.rs b/src/runtime/account.rs @@ -1,894 +0,0 @@ -use std::{fmt, path::Path, sync::Arc}; - -use radroots_identity::{ - AccountId, PublicIdentity, - account::{Record as AccountRecord, Status as AccountStatus}, -}; -use radroots_nostr_accounts::prelude::{RadrootsNostrAccountsError, RadrootsNostrAccountsManager}; -use radroots_protected_store::RadrootsProtectedFileSecretVault; -use radroots_secret_vault::{ - RadrootsHostVaultCapabilities, RadrootsResolvedSecretBackend, RadrootsSecretBackend, - RadrootsSecretBackendAvailability, RadrootsSecretBackendSelection, RadrootsSecretVault, - RadrootsSecretVaultError, RadrootsSecretVaultOsKeyring, -}; - -use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; -use crate::view::runtime::{AccountResolutionView, AccountSummaryView}; - -const HOST_VAULT_AVAILABILITY_OVERRIDE_ENV: &str = "RADROOTS_CLI_ACCOUNT_HOST_VAULT_AVAILABLE"; -const HOST_VAULT_SERVICE_NAME: &str = "org.radroots.cli.local-account"; -const HOST_VAULT_PROBE_SLOT: &str = "__radroots_cli_host_vault_probe__"; -pub const SHARED_ACCOUNT_STORE_SOURCE: &str = "shared account store · local first"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AccountRuntimeFailure { - Unresolved(AccountRuntimeFailureIssue), - WatchOnly(AccountRuntimeFailureIssue), - Mismatch(AccountRuntimeFailureIssue), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AccountRuntimeFailureIssue { - message: String, - detail_json: Option<String>, -} - -impl AccountRuntimeFailureIssue { - fn new(message: impl Into<String>) -> Self { - Self { - message: message.into(), - detail_json: None, - } - } - - fn with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self { - Self { - message: message.into(), - detail_json: Some(detail.to_string()), - } - } - - pub fn message(&self) -> &str { - self.message.as_str() - } -} - -impl AccountRuntimeFailure { - pub fn unresolved(message: impl Into<String>) -> Self { - Self::Unresolved(AccountRuntimeFailureIssue::new(message)) - } - - pub fn unresolved_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self { - Self::Unresolved(AccountRuntimeFailureIssue::with_detail(message, detail)) - } - - pub fn watch_only(account_id: &AccountId) -> Self { - Self::WatchOnly(AccountRuntimeFailureIssue::new(format!( - "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed" - ))) - } - - pub fn watch_only_with_detail( - account_id: impl fmt::Display, - detail: serde_json::Value, - ) -> Self { - Self::WatchOnly(AccountRuntimeFailureIssue::with_detail( - format!( - "resolved account `{account_id}` is watch_only and cannot sign because it is not secret-backed" - ), - detail, - )) - } - - pub fn mismatch(message: impl Into<String>) -> Self { - Self::Mismatch(AccountRuntimeFailureIssue::new(message)) - } - - pub fn mismatch_with_detail(message: impl Into<String>, detail: serde_json::Value) -> Self { - Self::Mismatch(AccountRuntimeFailureIssue::with_detail(message, detail)) - } - - pub fn message(&self) -> &str { - match self { - Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => { - issue.message.as_str() - } - } - } - - pub fn detail_json(&self) -> Option<&str> { - match self { - Self::Unresolved(issue) | Self::WatchOnly(issue) | Self::Mismatch(issue) => { - issue.detail_json.as_deref() - } - } - } -} - -impl fmt::Display for AccountRuntimeFailure { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(self.message()) - } -} - -impl std::error::Error for AccountRuntimeFailure {} - -#[derive(Debug, Clone)] -pub struct AccountSnapshot { - pub accounts: Vec<AccountRecordView>, -} - -#[derive(Debug, Clone)] -pub struct AccountRecordView { - pub record: AccountRecord, - pub is_default: bool, - pub custody: AccountCustody, - pub write_capable: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AccountCustody { - SecretBacked, - WatchOnly, -} - -impl AccountCustody { - pub fn as_str(self) -> &'static str { - match self { - Self::SecretBacked => "secret_backed", - Self::WatchOnly => "watch_only", - } - } - - pub fn signer_label(self) -> &'static str { - match self { - Self::SecretBacked => "local", - Self::WatchOnly => "watch_only", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct AccountRuntimeFacts { - custody: AccountCustody, - write_capable: bool, -} - -#[derive(Debug, Clone)] -pub struct AccountSecretBackendStatus { - pub state: String, - pub active_backend: Option<String>, - pub reason: Option<String>, -} - -#[derive(Debug, Clone)] -pub struct AccountCreateResult { - pub account: AccountRecordView, -} - -#[derive(Debug, Clone)] -pub struct AccountClearDefaultResult { - pub cleared_account: Option<AccountRecordView>, - pub remaining_account_count: usize, -} - -#[derive(Debug, Clone)] -pub struct AccountRemoveResult { - pub removed_account: AccountRecordView, - pub default_cleared: bool, - pub remaining_account_count: usize, -} - -#[derive(Debug, Clone)] -pub struct AccountRemovePreview { - pub account: AccountRecordView, - pub default_would_clear: bool, - pub remaining_account_count: usize, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AccountResolutionSource { - InvocationOverride, - DefaultAccount, - None, -} - -impl AccountResolutionSource { - pub fn as_str(self) -> &'static str { - match self { - Self::InvocationOverride => "invocation_override", - Self::DefaultAccount => "default_account", - Self::None => "none", - } - } -} - -#[derive(Debug, Clone)] -pub struct AccountResolution { - pub source: AccountResolutionSource, - pub resolved_account: Option<AccountRecordView>, - pub default_account: Option<AccountRecordView>, -} - -#[derive(Debug)] -pub struct AccountLocalSigner { - pub account: AccountRecordView, - pub signer: radroots_nostr::signing::LocalSigner, -} - -pub fn create_default_account(config: &RuntimeConfig) -> Result<AccountCreateResult, RuntimeError> { - let manager = account_manager(config)?; - let created_account_id = manager.generate_keys(None, false)?; - - let snapshot = snapshot(config)?; - let account = snapshot_account( - &snapshot, - &created_account_id, - "created account missing after account create", - )?; - - Ok(AccountCreateResult { account }) -} - -pub fn import_public_identity( - config: &RuntimeConfig, - path: &Path, - make_default: bool, -) -> Result<AccountRecordView, RuntimeError> { - let manager = account_manager(config)?; - let public_identity = load_public_identity_for_import(path)?; - let imported_account_id = - manager.upsert_public_identity(public_identity, None, make_default)?; - let snapshot = snapshot_from_manager(&manager)?; - snapshot_account( - &snapshot, - &imported_account_id, - "imported account missing after account import", - ) -} - -pub fn preview_public_identity_import( - config: &RuntimeConfig, - path: &Path, - make_default: bool, -) -> Result<AccountRecordView, RuntimeError> { - let public_identity = load_public_identity_for_import(path)?; - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - if let Some(existing) = snapshot - .accounts - .iter() - .find(|account| account.record.id() == AccountId::from(&public_identity)) - .cloned() - { - let mut account = existing; - if make_default { - account.is_default = true; - } - return Ok(account); - } - - Ok(AccountRecordView { - record: AccountRecord::new(public_identity, None, 0), - is_default: make_default, - custody: AccountCustody::WatchOnly, - write_capable: false, - }) -} - -pub fn preview_identity_secret_attachment( - config: &RuntimeConfig, - selector: &str, - path: &Path, - make_default: bool, -) -> Result<AccountRecordView, RuntimeError> { - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - let mut account = resolve_selector_account(&manager, &snapshot, selector)?; - let secret = load_secret_key_for_attachment(path)?; - validate_secret_matches_account(&account.record, &secret)?; - if make_default { - account.is_default = true; - } - account.custody = AccountCustody::SecretBacked; - account.write_capable = true; - Ok(account) -} - -pub fn attach_identity_secret( - config: &RuntimeConfig, - selector: &str, - path: &Path, - make_default: bool, -) -> Result<AccountRecordView, RuntimeError> { - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - let account = resolve_selector_account(&manager, &snapshot, selector)?; - let secret = load_secret_key_for_attachment(path)?; - validate_secret_matches_account(&account.record, &secret)?; - let keys = nostr::Keys::new(secret); - let attached = manager.attach_secret_keys(&account.record.id(), &keys, make_default)?; - let snapshot = snapshot_from_manager(&manager)?; - snapshot_account( - &snapshot, - &attached.id(), - "attached account missing after account secret attachment", - ) -} - -pub fn snapshot(config: &RuntimeConfig) -> Result<AccountSnapshot, RuntimeError> { - let manager = account_manager(config)?; - snapshot_from_manager(&manager) -} - -pub fn resolve_account(config: &RuntimeConfig) -> Result<Option<AccountRecordView>, RuntimeError> { - Ok(resolve_account_resolution(config)?.resolved_account) -} - -pub fn resolve_account_resolution( - config: &RuntimeConfig, -) -> Result<AccountResolution, RuntimeError> { - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - let default_account = snapshot - .accounts - .iter() - .find(|account| account.is_default) - .cloned(); - if let Some(selector) = config.account.selector.as_deref() { - let account = resolve_selector_account(&manager, &snapshot, selector)?; - return Ok(AccountResolution { - source: AccountResolutionSource::InvocationOverride, - resolved_account: Some(account), - default_account, - }); - } - - Ok(AccountResolution { - source: if default_account.is_some() { - AccountResolutionSource::DefaultAccount - } else { - AccountResolutionSource::None - }, - resolved_account: default_account.clone(), - default_account, - }) -} - -pub fn select_account( - config: &RuntimeConfig, - selector: &str, -) -> Result<AccountRecordView, RuntimeError> { - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - let account = resolve_selector_account(&manager, &snapshot, selector)?; - - manager.set_default_account(&account.record.id())?; - let snapshot = snapshot_from_manager(&manager)?; - snapshot - .accounts - .into_iter() - .find(|candidate| candidate.record.id() == account.record.id()) - .ok_or_else(|| { - RuntimeError::Accounts( - radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( - "default account missing after account use".to_owned(), - ), - ) - }) -} - -pub fn resolve_account_selector( - config: &RuntimeConfig, - selector: &str, -) -> Result<AccountRecordView, RuntimeError> { - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - resolve_selector_account(&manager, &snapshot, selector) -} - -pub fn clear_default_account( - config: &RuntimeConfig, -) -> Result<AccountClearDefaultResult, RuntimeError> { - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - let cleared_account = snapshot - .accounts - .iter() - .find(|account| account.is_default) - .cloned(); - manager.clear_default_account()?; - let remaining_account_count = snapshot_from_manager(&manager)?.accounts.len(); - Ok(AccountClearDefaultResult { - cleared_account, - remaining_account_count, - }) -} - -pub fn remove_account( - config: &RuntimeConfig, - selector: &str, -) -> Result<AccountRemoveResult, RuntimeError> { - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - let removed_account = resolve_selector_account(&manager, &snapshot, selector)?; - let default_cleared = removed_account.is_default; - manager.remove_account(&removed_account.record.id())?; - let remaining_account_count = snapshot_from_manager(&manager)?.accounts.len(); - Ok(AccountRemoveResult { - removed_account, - default_cleared, - remaining_account_count, - }) -} - -pub fn preview_account_removal( - config: &RuntimeConfig, - selector: &str, -) -> Result<AccountRemovePreview, RuntimeError> { - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - let account = resolve_selector_account(&manager, &snapshot, selector)?; - Ok(AccountRemovePreview { - default_would_clear: account.is_default, - remaining_account_count: snapshot.accounts.len().saturating_sub(1), - account, - }) -} - -pub fn resolved_account_signing_status( - config: &RuntimeConfig, -) -> Result<AccountStatus, RuntimeError> { - let manager = account_manager(config)?; - let resolution = resolve_account_resolution(config)?; - let Some(account) = resolution.resolved_account else { - return Ok(AccountStatus::NotConfigured); - }; - - Ok(match manager.get_signing_keys(&account.record.id())? { - Some(_) => AccountStatus::Ready { - account: account.record.clone(), - }, - None => AccountStatus::PublicOnly { - account: account.record.clone(), - }, - }) -} - -pub fn resolve_local_signing_identity( - config: &RuntimeConfig, -) -> Result<AccountLocalSigner, RuntimeError> { - let manager = account_manager(config)?; - let resolution = resolve_account_resolution(config)?; - let Some(account) = resolution.resolved_account else { - return Err(AccountRuntimeFailure::unresolved(unresolved_account_reason(config)?).into()); - }; - let Some(secret) = manager.export_secret_hex(&account.record.id())? else { - return Err(AccountRuntimeFailure::watch_only(&account.record.id()).into()); - }; - let secret = zeroize::Zeroizing::new(secret); - let secret = radroots_nostr::key::parse_secret_key(secret.trim()) - .map_err(|_| RuntimeError::Config("local account secret is invalid".to_owned()))?; - let signer = radroots_nostr::signing::LocalSigner::new(secret) - .map_err(|error| RuntimeError::Config(error.to_string()))?; - Ok(AccountLocalSigner { account, signer }) -} - -pub fn resolve_local_signing_identity_for_account( - config: &RuntimeConfig, - account_id: &str, -) -> Result<AccountLocalSigner, RuntimeError> { - let manager = account_manager(config)?; - let snapshot = snapshot_from_manager(&manager)?; - let Some(account) = snapshot - .accounts - .iter() - .find(|account| account.record.id().to_hex() == account_id) - .cloned() - else { - return Err(AccountRuntimeFailure::unresolved(format!( - "farm-bound seller account `{account_id}` is not present in the local account store" - )) - .into()); - }; - let Some(secret) = manager.export_secret_hex(&account.record.id())? else { - return Err(AccountRuntimeFailure::watch_only(&account.record.id()).into()); - }; - let secret = zeroize::Zeroizing::new(secret); - let secret = radroots_nostr::key::parse_secret_key(secret.trim()) - .map_err(|_| RuntimeError::Config("local account secret is invalid".to_owned()))?; - let signer = radroots_nostr::signing::LocalSigner::new(secret) - .map_err(|error| RuntimeError::Config(error.to_string()))?; - Ok(AccountLocalSigner { account, signer }) -} - -pub fn account_summary_view(account: &AccountRecordView) -> AccountSummaryView { - AccountSummaryView::from_account_runtime( - &account.record, - account.custody.signer_label(), - account.custody.as_str(), - account.write_capable, - account.is_default, - ) -} - -pub fn account_resolution_view(resolution: &AccountResolution) -> AccountResolutionView { - AccountResolutionView { - status: if resolution.resolved_account.is_some() { - "resolved" - } else { - "unresolved" - } - .to_owned(), - source: resolution.source.as_str().to_owned(), - resolved_account: resolution - .resolved_account - .as_ref() - .map(account_summary_view), - default_account: resolution - .default_account - .as_ref() - .map(account_summary_view), - } -} - -pub fn empty_account_resolution_view() -> AccountResolutionView { - AccountResolutionView { - status: "unresolved".to_owned(), - source: AccountResolutionSource::None.as_str().to_owned(), - resolved_account: None, - default_account: None, - } -} - -pub fn unresolved_account_reason(config: &RuntimeConfig) -> Result<String, RuntimeError> { - let snapshot = snapshot(config)?; - Ok(if snapshot.accounts.is_empty() { - format!( - "no local accounts found in {}", - config.account.store_path.display() - ) - } else { - format!( - "accounts exist in {} but no default account is configured and no invocation override was provided", - config.account.store_path.display() - ) - }) -} - -pub fn secret_backend_status(config: &RuntimeConfig) -> AccountSecretBackendStatus { - match resolve_secret_backend(config) { - Ok(resolved) => AccountSecretBackendStatus { - state: "ready".to_owned(), - active_backend: Some(resolved.backend.kind().to_string()), - reason: None, - }, - Err(SecretBackendResolutionError::Unavailable(reason)) => AccountSecretBackendStatus { - state: "unavailable".to_owned(), - active_backend: None, - reason: Some(reason), - }, - Err(SecretBackendResolutionError::Invalid(reason)) => AccountSecretBackendStatus { - state: "error".to_owned(), - active_backend: None, - reason: Some(reason), - }, - } -} - -pub fn load_secret_backend_secret( - config: &RuntimeConfig, - slot: &str, - service_name: &str, -) -> Result<Option<String>, RuntimeError> { - if slot.trim().is_empty() { - return Err(RuntimeError::Config( - "secret backend slot must not be empty".to_owned(), - )); - } - let resolved = resolve_secret_backend(config).map_err(secret_backend_resolution_error)?; - let vault = secret_vault_for_backend(config, resolved.backend, service_name)?; - vault.load_secret(slot).map_err(|error| { - RuntimeError::Config(format!( - "failed to load secret `{slot}` from account secret backend `{}`: {error}", - resolved.backend.kind() - )) - }) -} - -fn snapshot_from_manager( - manager: &RadrootsNostrAccountsManager, -) -> Result<AccountSnapshot, RuntimeError> { - let default_account_id = manager.default_account_id()?.map(|id| id.to_string()); - let mut accounts = Vec::new(); - for record in manager.list_accounts()? { - let is_default = default_account_id - .as_deref() - .is_some_and(|default| default == record.id().to_hex()); - let runtime = account_runtime_facts(manager, &record)?; - accounts.push(AccountRecordView { - record, - is_default, - custody: runtime.custody, - write_capable: runtime.write_capable, - }); - } - - Ok(AccountSnapshot { accounts }) -} - -fn snapshot_account( - snapshot: &AccountSnapshot, - account_id: &AccountId, - missing_message: &str, -) -> Result<AccountRecordView, RuntimeError> { - snapshot - .accounts - .iter() - .find(|account| account.record.id() == *account_id) - .cloned() - .ok_or_else(|| { - RuntimeError::Accounts( - radroots_nostr_accounts::prelude::RadrootsNostrAccountsError::InvalidState( - missing_message.to_owned(), - ), - ) - }) -} - -fn resolve_selector_account( - manager: &RadrootsNostrAccountsManager, - snapshot: &AccountSnapshot, - selector: &str, -) -> Result<AccountRecordView, RuntimeError> { - let record = manager - .resolve_account_selector(selector) - .map_err(|error| selector_runtime_error(selector, error))?; - snapshot - .accounts - .iter() - .find(|account| account.record.id() == record.id()) - .cloned() - .ok_or_else(|| { - RuntimeError::Accounts(RadrootsNostrAccountsError::InvalidState( - "resolved account missing from snapshot".to_owned(), - )) - }) -} - -fn selector_runtime_error(selector: &str, error: RadrootsNostrAccountsError) -> RuntimeError { - let normalized = selector.trim(); - match error { - RadrootsNostrAccountsError::InvalidAccountSelector(reason) => RuntimeError::Config(reason), - RadrootsNostrAccountsError::AccountNotFound(_) => { - AccountRuntimeFailure::unresolved(format!( - "account selector `{normalized}` did not match any local account" - )) - .into() - } - RadrootsNostrAccountsError::AmbiguousAccountSelector(_) => { - AccountRuntimeFailure::unresolved(format!( - "account selector `{normalized}` matched multiple local accounts; use account id or npub" - )) - .into() - } - other => RuntimeError::Accounts(other), - } -} - -fn account_runtime_facts( - manager: &RadrootsNostrAccountsManager, - record: &AccountRecord, -) -> Result<AccountRuntimeFacts, RuntimeError> { - Ok(if manager.get_signing_keys(&record.id())?.is_some() { - AccountRuntimeFacts { - custody: AccountCustody::SecretBacked, - write_capable: true, - } - } else { - AccountRuntimeFacts { - custody: AccountCustody::WatchOnly, - write_capable: false, - } - }) -} - -fn load_public_identity_for_import(path: &Path) -> Result<PublicIdentity, RuntimeError> { - let bytes = std::fs::read(path).map_err(|error| { - RuntimeError::Config(format!( - "failed to read account import {}: {error}", - path.display() - )) - })?; - serde_json::from_slice(&bytes).map_err(|error| { - RuntimeError::Config(format!( - "failed to import canonical public identity from {}: {error}", - path.display() - )) - }) -} - -fn load_secret_key_for_attachment(path: &Path) -> Result<nostr::SecretKey, RuntimeError> { - let secret = std::fs::read_to_string(path).map_err(|error| { - RuntimeError::Config(format!( - "failed to read account secret from {}: {error}", - path.display(), - )) - })?; - nostr::SecretKey::parse(secret.trim()).map_err(|_| { - RuntimeError::Config(format!( - "failed to import account secret from {}: invalid hex or nsec key", - path.display() - )) - }) -} - -fn validate_secret_matches_account( - record: &AccountRecord, - secret: &nostr::SecretKey, -) -> Result<(), RuntimeError> { - let secret_public_key_hex = nostr::Keys::new(secret.clone()).public_key().to_hex(); - let public_key_hex = record.public_identity().public_key().to_hex(); - if public_key_hex.eq_ignore_ascii_case(secret_public_key_hex.as_str()) { - return Ok(()); - } - - Err(AccountRuntimeFailure::mismatch(format!( - "account mismatch: resolved account `{}` public key `{}` does not match secret public key `{}`", - record.id(), public_key_hex, secret_public_key_hex - )) - .into()) -} - -fn account_manager(config: &RuntimeConfig) -> Result<RadrootsNostrAccountsManager, RuntimeError> { - let (manager, _) = RadrootsNostrAccountsManager::new_local_file_backed( - config.account.store_path.as_path(), - config.account.secrets_dir.as_path(), - account_secret_backend_selection(config), - secret_backend_availability()?, - HOST_VAULT_SERVICE_NAME, - )?; - Ok(manager) -} - -fn resolve_secret_backend( - config: &RuntimeConfig, -) -> Result<RadrootsResolvedSecretBackend, SecretBackendResolutionError> { - let availability = secret_backend_availability().map_err(|error| { - SecretBackendResolutionError::Invalid(format!("account secret backend: {error}")) - })?; - RadrootsNostrAccountsManager::resolve_local_backend( - account_secret_backend_selection(config), - availability, - ) - .map_err(|error| match error { - RadrootsSecretVaultError::BackendUnavailable { .. } => { - SecretBackendResolutionError::Unavailable(format!("account secret backend: {error}")) - } - RadrootsSecretVaultError::HostVaultPolicyUnsupported { .. } => { - SecretBackendResolutionError::Invalid(format!("account secret backend: {error}")) - } - }) -} - -fn secret_backend_resolution_error(error: SecretBackendResolutionError) -> RuntimeError { - match error { - SecretBackendResolutionError::Unavailable(reason) - | SecretBackendResolutionError::Invalid(reason) => RuntimeError::Config(reason), - } -} - -pub(crate) fn account_secret_vault( - config: &RuntimeConfig, -) -> Result<Arc<dyn RadrootsSecretVault>, RuntimeError> { - let resolved = resolve_secret_backend(config).map_err(secret_backend_resolution_error)?; - secret_vault_for_backend(config, resolved.backend, HOST_VAULT_SERVICE_NAME) -} - -fn secret_vault_for_backend( - config: &RuntimeConfig, - backend: RadrootsSecretBackend, - service_name: &str, -) -> Result<Arc<dyn RadrootsSecretVault>, RuntimeError> { - match backend { - RadrootsSecretBackend::HostVault(_) => { - Ok(Arc::new(RadrootsSecretVaultOsKeyring::new(service_name))) - } - RadrootsSecretBackend::EncryptedFile => Ok(Arc::new( - RadrootsProtectedFileSecretVault::new(config.account.secrets_dir.as_path()), - )), - RadrootsSecretBackend::ExternalCommand => Err(RuntimeError::Config( - "external_command account secret backend is not supported for CLI signer sessions" - .to_owned(), - )), - RadrootsSecretBackend::Memory => Err(RuntimeError::Config( - "memory account secret backend is not supported for persisted CLI signer sessions" - .to_owned(), - )), - } -} - -fn account_secret_backend_selection(config: &RuntimeConfig) -> RadrootsSecretBackendSelection { - RadrootsSecretBackendSelection { - primary: config.account.secret_backend, - } -} - -fn secret_backend_availability() -> Result<RadrootsSecretBackendAvailability, RuntimeError> { - Ok(RadrootsSecretBackendAvailability { - host_vault: host_vault_capabilities()?, - encrypted_file: true, - external_command: false, - memory: true, - }) -} - -fn host_vault_capabilities() -> Result<RadrootsHostVaultCapabilities, RuntimeError> { - if let Some(available) = host_vault_availability_override()? { - return Ok(match available { - true => RadrootsHostVaultCapabilities::desktop_keyring(), - false => RadrootsHostVaultCapabilities::unavailable(), - }); - } - - let keyring = RadrootsSecretVaultOsKeyring::new(HOST_VAULT_SERVICE_NAME); - match keyring.load_secret(HOST_VAULT_PROBE_SLOT) { - Ok(_) => Ok(RadrootsHostVaultCapabilities::desktop_keyring()), - Err(_) => Ok(RadrootsHostVaultCapabilities::unavailable()), - } -} - -fn host_vault_availability_override() -> Result<Option<bool>, RuntimeError> { - let Ok(value) = std::env::var(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV) else { - return Ok(None); - }; - - parse_bool_value(HOST_VAULT_AVAILABILITY_OVERRIDE_ENV, value.trim()).map(Some) -} - -fn parse_bool_value(key: &str, value: &str) -> Result<bool, RuntimeError> { - match value.trim().to_ascii_lowercase().as_str() { - "1" | "true" | "yes" | "on" => Ok(true), - "0" | "false" | "no" | "off" => Ok(false), - other => Err(RuntimeError::Config(format!( - "{key} must be a boolean value, got `{other}`" - ))), - } -} - -#[derive(Debug, Clone)] -enum SecretBackendResolutionError { - Unavailable(String), - Invalid(String), -} - -#[cfg(test)] -mod tests { - use radroots_protected_store::RadrootsProtectedFileSecretVault; - use radroots_secret_vault::RadrootsSecretVault; - use std::fs; - use tempfile::tempdir; - - #[test] - fn protected_file_vault_round_trips_secret() { - let temp = tempdir().expect("tempdir"); - let vault = RadrootsProtectedFileSecretVault::new(temp.path()); - - vault.store_secret("acct_demo", "deadbeef").expect("store"); - let loaded = vault.load_secret("acct_demo").expect("load"); - assert_eq!(loaded.as_deref(), Some("deadbeef")); - let raw = fs::read_to_string(temp.path().join("acct_demo.secret.json")).expect("raw file"); - assert!(!raw.contains("deadbeef")); - } - - #[test] - fn protected_file_vault_removes_secret() { - let temp = tempdir().expect("tempdir"); - let vault = RadrootsProtectedFileSecretVault::new(temp.path()); - - vault.store_secret("acct_demo", "deadbeef").expect("store"); - vault.remove_secret("acct_demo").expect("remove"); - assert!(vault.load_secret("acct_demo").expect("load").is_none()); - } -} diff --git a/src/runtime/config.rs b/src/runtime/config.rs @@ -1,4111 +0,0 @@ -use std::collections::BTreeMap; -use std::fs; -use std::io::IsTerminal; -use std::path::Path; -use std::path::PathBuf; - -use radroots_runtime::{parse_bool_value, parse_strict_env_file, parse_u64_value}; -use radroots_runtime_paths::RadrootsPathResolver; -use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; -use radroots_transport::RadrootsTransportMeshScopeId; -use radroots_transport_nostr::{Error as RelayTransportError, RelayUrl, RelayUrlPolicy}; -use serde::Deserialize; -use url::Url; - -use crate::cli::global::RuntimeInvocationArgs; -use crate::runtime::RuntimeError; -pub use crate::runtime::paths::PathsConfig; -use crate::runtime::paths::{ENV_CLI_PATHS_PROFILE, ENV_CLI_PATHS_REPO_LOCAL_ROOT, resolve_paths}; - -const DEFAULT_LOG_FILTER: &str = "info"; -pub(crate) const RADROOTS_RETICULUM_SCOPE_ID: &str = "local"; -pub(crate) const RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE: &str = - "Reticulum transport is not available in this release"; -const DEFAULT_ENV_PATH: &str = ".env"; -const DEFAULT_LOCAL_STATE_DIR: &str = "replica"; -const DEFAULT_LOCAL_DB_FILE: &str = "replica.sqlite"; -const DEFAULT_LOCAL_BACKUPS_DIR: &str = "backups"; -const DEFAULT_LOCAL_EXPORTS_DIR: &str = "exports"; -const DEFAULT_SHARED_ACCOUNTS_STORE_FILE: &str = "store.json"; -const DEFAULT_MYC_STATUS_TIMEOUT_MS: u64 = 2_000; -const DEFAULT_HYF_EXECUTABLE: &str = "hyfd"; -const DEFAULT_RPC_URL: &str = "http://127.0.0.1:7070"; -const CLI_HOST_VAULT_POLICY: &str = "desktop"; -const CLI_DEFAULT_SECRET_BACKEND: &str = "host_vault"; -const CLI_ALLOWED_SHARED_SECRET_BACKENDS: &[&str] = &["host_vault", "encrypted_file"]; -const CLI_USES_PROTECTED_STORE: bool = true; -const ENV_CLI_FILE_PATH: &str = "RADROOTS_CLI_ENV_FILE"; -const ENV_CLI_OUTPUT_FORMAT: &str = "RADROOTS_CLI_OUTPUT_FORMAT"; -const ENV_CLI_LOG_FILTER: &str = "RADROOTS_CLI_LOGGING_FILTER"; -const ENV_CLI_LOG_DIR: &str = "RADROOTS_CLI_LOGGING_OUTPUT_DIR"; -const ENV_CLI_LOG_STDOUT: &str = "RADROOTS_CLI_LOGGING_STDOUT"; -const ENV_CLI_ACCOUNT_SELECTOR: &str = "RADROOTS_CLI_ACCOUNT_SELECTOR"; -const ENV_CLI_ACCOUNT_SECRET_BACKEND: &str = "RADROOTS_CLI_ACCOUNT_SECRET_BACKEND"; -const ENV_CLI_IDENTITY_PATH: &str = "RADROOTS_CLI_IDENTITY_PATH"; -const ENV_CLI_SIGNER_BACKEND: &str = "RADROOTS_CLI_SIGNER_BACKEND"; -const ENV_CLI_TRANSPORT_PROFILE: &str = "RADROOTS_CLI_TRANSPORT_PROFILE"; -const ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS: &str = "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS"; -const ENV_CLI_TRANSPORT_RETICULUM_BEHAVIOR: &str = "RADROOTS_CLI_TRANSPORT_RETICULUM_BEHAVIOR"; -const ENV_CLI_TRANSPORT_RETICULUM_SCOPE: &str = "RADROOTS_CLI_TRANSPORT_RETICULUM_SCOPE"; -const ENV_CLI_TRANSPORT_RETICULUM_AGENT_ENDPOINT: &str = - "RADROOTS_CLI_TRANSPORT_RETICULUM_AGENT_ENDPOINT"; -const ENV_CLI_RADROOTSD_EXECUTION_URL: &str = "RADROOTS_CLI_RADROOTSD_EXECUTION_URL"; -const ENV_CLI_RADROOTSD_EXECUTION_TOKEN_FILE: &str = "RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_FILE"; -const ENV_CLI_RADROOTSD_EXECUTION_TOKEN_SECRET_ID: &str = - "RADROOTS_CLI_RADROOTSD_EXECUTION_TOKEN_SECRET_ID"; -const ENV_CLI_MYC_EXECUTABLE: &str = "RADROOTS_CLI_MYC_EXECUTABLE"; -const ENV_CLI_MYC_STATUS_TIMEOUT_MS: &str = "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS"; -const ENV_CLI_HYF_ENABLED: &str = "RADROOTS_CLI_HYF_ENABLED"; -const ENV_CLI_HYF_EXECUTABLE: &str = "RADROOTS_CLI_HYF_EXECUTABLE"; -const ENV_CLI_RHI_VALIDATOR_SET_ID: &str = "RADROOTS_CLI_RHI_VALIDATOR_SET_ID"; -const ENV_CLI_RHI_VALIDATOR_PUBKEY: &str = "RADROOTS_CLI_RHI_VALIDATOR_PUBKEY"; -const ENV_CLI_RHI_VALIDATOR_SET_ADDR: &str = "RADROOTS_CLI_RHI_VALIDATOR_SET_ADDR"; -const ENV_CLI_RHI_VALIDATOR_SET_EVENT_ID: &str = "RADROOTS_CLI_RHI_VALIDATOR_SET_EVENT_ID"; -const ENV_CLI_RHI_VALIDATOR_SET_VALID_FROM: &str = "RADROOTS_CLI_RHI_VALIDATOR_SET_VALID_FROM"; -const ENV_CLI_RHI_VALIDATOR_SET_VALID_UNTIL: &str = "RADROOTS_CLI_RHI_VALIDATOR_SET_VALID_UNTIL"; -const ENV_CLI_RHI_VALIDATOR_SET_PROTOCOL_CONTRACT_HASH: &str = - "RADROOTS_CLI_RHI_VALIDATOR_SET_PROTOCOL_CONTRACT_HASH"; -const ENV_CLI_RHI_VALIDATOR_SET_OPERATOR_NAME: &str = - "RADROOTS_CLI_RHI_VALIDATOR_SET_OPERATOR_NAME"; -const ENV_CLI_RHI_VALIDATOR_SET_OPERATOR_CONTACT: &str = - "RADROOTS_CLI_RHI_VALIDATOR_SET_OPERATOR_CONTACT"; -const ENV_CLI_RHI_REQUIRE_CRYPTOGRAPHIC_PROOF: &str = - "RADROOTS_CLI_RHI_REQUIRE_CRYPTOGRAPHIC_PROOF"; -const ENV_CLI_MESH_SCOPE: &str = "RADROOTS_CLI_MESH_SCOPE"; -const SUPPORTED_ENV_FILE_KEYS: &[&str] = &[ - ENV_CLI_OUTPUT_FORMAT, - ENV_CLI_LOG_FILTER, - ENV_CLI_LOG_DIR, - ENV_CLI_LOG_STDOUT, - ENV_CLI_PATHS_PROFILE, - ENV_CLI_PATHS_REPO_LOCAL_ROOT, - ENV_CLI_ACCOUNT_SELECTOR, - ENV_CLI_ACCOUNT_SECRET_BACKEND, - ENV_CLI_IDENTITY_PATH, - ENV_CLI_SIGNER_BACKEND, - ENV_CLI_TRANSPORT_PROFILE, - ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS, - ENV_CLI_TRANSPORT_RETICULUM_BEHAVIOR, - ENV_CLI_TRANSPORT_RETICULUM_SCOPE, - ENV_CLI_TRANSPORT_RETICULUM_AGENT_ENDPOINT, - ENV_CLI_RADROOTSD_EXECUTION_URL, - ENV_CLI_RADROOTSD_EXECUTION_TOKEN_FILE, - ENV_CLI_RADROOTSD_EXECUTION_TOKEN_SECRET_ID, - ENV_CLI_MYC_EXECUTABLE, - ENV_CLI_MYC_STATUS_TIMEOUT_MS, - ENV_CLI_HYF_ENABLED, - ENV_CLI_HYF_EXECUTABLE, - ENV_CLI_RHI_VALIDATOR_SET_ID, - ENV_CLI_RHI_VALIDATOR_PUBKEY, - ENV_CLI_RHI_VALIDATOR_SET_ADDR, - ENV_CLI_RHI_VALIDATOR_SET_EVENT_ID, - ENV_CLI_RHI_VALIDATOR_SET_VALID_FROM, - ENV_CLI_RHI_VALIDATOR_SET_VALID_UNTIL, - ENV_CLI_RHI_VALIDATOR_SET_PROTOCOL_CONTRACT_HASH, - ENV_CLI_RHI_VALIDATOR_SET_OPERATOR_NAME, - ENV_CLI_RHI_VALIDATOR_SET_OPERATOR_CONTACT, - ENV_CLI_RHI_REQUIRE_CRYPTOGRAPHIC_PROOF, - ENV_CLI_MESH_SCOPE, -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum OutputFormat { - Terminal, - Json, - Ndjson, -} - -impl OutputFormat { - pub fn as_str(self) -> &'static str { - match self { - Self::Terminal => "terminal", - Self::Json => "json", - Self::Ndjson => "ndjson", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Verbosity { - Quiet, - Normal, - Verbose, - Trace, -} - -impl Verbosity { - pub fn as_str(self) -> &'static str { - match self { - Self::Quiet => "quiet", - Self::Normal => "normal", - Self::Verbose => "verbose", - Self::Trace => "trace", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct OutputConfig { - pub format: OutputFormat, - pub verbosity: Verbosity, - pub dry_run: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct InteractionConfig { - pub input_enabled: bool, - pub assume_yes: bool, - pub stdin_tty: bool, - pub stdout_tty: bool, - pub prompts_allowed: bool, - pub confirmations_allowed: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LoggingConfig { - pub filter: String, - pub directory: Option<PathBuf>, - pub stdout: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct IdentityConfig { - pub path: PathBuf, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AccountConfig { - pub selector: Option<String>, - pub store_path: PathBuf, - pub secrets_dir: PathBuf, - pub secret_backend: RadrootsSecretBackend, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct AccountSecretContractConfig { - pub default_backend: String, - pub allowed_backends: Vec<String>, - pub host_vault_policy: Option<String>, - pub uses_protected_store: bool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum SignerBackend { - Local, - Myc, -} - -impl SignerBackend { - pub fn as_str(self) -> &'static str { - match self { - Self::Local => "local", - Self::Myc => "myc", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct SignerConfig { - pub backend: SignerBackend, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TransportProfileKind { - LocalOnly, - Nostr, - Reticulum, - MultiTarget, -} - -impl TransportProfileKind { - pub fn as_str(self) -> &'static str { - match self { - Self::LocalOnly => "local_only", - Self::Nostr => "nostr", - Self::Reticulum => "reticulum", - Self::MultiTarget => "multi_target", - } - } - - pub fn transport_family(self) -> &'static str { - match self { - Self::LocalOnly => "local", - Self::Nostr => "nostr", - Self::Reticulum => "reticulum", - Self::MultiTarget => "multi_target", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ReticulumBehavior { - RejectDeliveryAttempts, - DeferDeliveryPlans, -} - -impl ReticulumBehavior { - pub fn as_str(self) -> &'static str { - match self { - Self::RejectDeliveryAttempts => "reject_delivery_attempts", - Self::DeferDeliveryPlans => "defer_delivery_plans", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TransportConfigSource { - Environment, - UserConfig, - WorkspaceConfig, - Defaults, -} - -impl TransportConfigSource { - pub fn as_str(self) -> &'static str { - match self { - Self::Environment => "environment · transport profile", - Self::UserConfig => "user config · transport profile", - Self::WorkspaceConfig => "workspace config · transport profile", - Self::Defaults => "defaults · transport profile", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TransportConfig { - pub profile: TransportProfileKind, - pub source: TransportConfigSource, - pub nostr_relay_urls: Vec<String>, - pub reticulum_behavior: ReticulumBehavior, - pub reticulum_scope: String, - pub reticulum_agent_endpoint: Option<String>, - pub radrootsd_execution: RadrootsdExecutionConfig, -} - -impl TransportConfig { - pub fn local_only() -> Self { - Self { - profile: TransportProfileKind::LocalOnly, - source: TransportConfigSource::Defaults, - nostr_relay_urls: Vec::new(), - reticulum_behavior: ReticulumBehavior::RejectDeliveryAttempts, - reticulum_scope: RADROOTS_RETICULUM_SCOPE_ID.to_owned(), - reticulum_agent_endpoint: None, - radrootsd_execution: RadrootsdExecutionConfig::default(), - } - } - - pub fn from_nostr_relay_urls(nostr_relay_urls: Vec<String>) -> Self { - if nostr_relay_urls.is_empty() { - return Self::local_only(); - } - Self { - profile: TransportProfileKind::Nostr, - source: TransportConfigSource::Defaults, - nostr_relay_urls, - reticulum_behavior: ReticulumBehavior::RejectDeliveryAttempts, - reticulum_scope: RADROOTS_RETICULUM_SCOPE_ID.to_owned(), - reticulum_agent_endpoint: None, - radrootsd_execution: RadrootsdExecutionConfig::default(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RadrootsdExecutionConfig { - pub url: String, - pub token_file: Option<PathBuf>, - pub token_secret_id: Option<String>, -} - -impl Default for RadrootsdExecutionConfig { - fn default() -> Self { - Self { - url: DEFAULT_RPC_URL.to_owned(), - token_file: None, - token_secret_id: None, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LocalConfig { - pub root: PathBuf, - pub replica_store_path: PathBuf, - pub backups_dir: PathBuf, - pub exports_dir: PathBuf, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MycConfig { - pub executable: PathBuf, - pub status_timeout_ms: u64, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HyfConfig { - pub enabled: bool, - pub executable: PathBuf, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MeshScope { - Disabled, - Local, -} - -impl MeshScope { - pub fn as_str(self) -> &'static str { - match self { - Self::Disabled => "disabled", - Self::Local => "local", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct MeshConfig { - pub scope: MeshScope, - pub source: MeshConfigSource, -} - -impl MeshConfig { - pub fn disabled() -> Self { - Self { - scope: MeshScope::Disabled, - source: MeshConfigSource::Defaults, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MeshConfigSource { - Environment, - UserConfig, - WorkspaceConfig, - Defaults, -} - -impl MeshConfigSource { - pub fn as_str(self) -> &'static str { - match self { - Self::Environment => "environment · local first", - Self::UserConfig => "user config · local first", - Self::WorkspaceConfig => "workspace config · local first", - Self::Defaults => "defaults · local first", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CapabilityBindingTargetKind { - ManagedInstance, - ExplicitEndpoint, -} - -impl CapabilityBindingTargetKind { - pub fn as_str(self) -> &'static str { - match self { - Self::ManagedInstance => "managed_instance", - Self::ExplicitEndpoint => "explicit_endpoint", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CapabilityBindingSource { - UserConfig, - WorkspaceConfig, -} - -impl CapabilityBindingSource { - pub fn as_str(self) -> &'static str { - match self { - Self::UserConfig => "user config [[capability_binding]]", - Self::WorkspaceConfig => "workspace config [[capability_binding]]", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CapabilityBindingConfig { - pub capability_id: String, - pub provider_runtime_id: String, - pub binding_model: String, - pub target_kind: CapabilityBindingTargetKind, - pub target: String, - pub managed_account_ref: Option<String>, - pub signer_session_ref: Option<String>, - pub source: CapabilityBindingSource, -} - -#[cfg(test)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CapabilityBindingInspectionState { - Configured, - NotConfigured, - Disabled, -} - -#[cfg(test)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CapabilityBindingInspection { - pub capability_id: String, - pub provider_runtime_id: String, - pub binding_model: String, - pub state: CapabilityBindingInspectionState, - pub source: String, - pub target_kind: Option<String>, - pub target: Option<String>, - pub managed_account_ref: Option<String>, - pub signer_session_ref: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RpcConfig { - pub url: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RhiConfig { - pub validator_set: Option<RhiValidatorSetConfig>, - pub require_cryptographic_proof: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RhiValidatorSetConfig { - pub set_id: String, - pub validator_pubkey: String, - pub validator_set_addr: String, - pub validator_set_event_id: String, - pub valid_from: u64, - pub valid_until: u64, - pub protocol_contract_hash: String, - pub operator_name: String, - pub operator_contact: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct RuntimeConfig { - pub output: OutputConfig, - pub interaction: InteractionConfig, - pub paths: PathsConfig, - pub logging: LoggingConfig, - pub account: AccountConfig, - pub account_secret_contract: AccountSecretContractConfig, - pub identity: IdentityConfig, - pub signer: SignerConfig, - pub transport: TransportConfig, - pub local: LocalConfig, - pub myc: MycConfig, - pub hyf: HyfConfig, - pub mesh: MeshConfig, - pub rpc: RpcConfig, - pub rhi: RhiConfig, - pub capability_bindings: Vec<CapabilityBindingConfig>, -} - -#[derive(Debug, Default)] -pub(crate) struct EnvFileValues(BTreeMap<String, String>); - -impl EnvFileValues { - pub(crate) fn get(&self, key: &str) -> Option<&str> { - self.0.get(key).map(String::as_str) - } -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct CliConfigFile { - output: Option<OutputFileConfig>, - logging: Option<LoggingFileConfig>, - account: Option<AccountFileConfig>, - identity: Option<IdentityFileConfig>, - transport: Option<TransportFileConfig>, - signer: Option<SignerFileConfig>, - myc: Option<MycFileConfig>, - hyf: Option<HyfFileConfig>, - mesh: Option<MeshFileConfig>, - rpc: Option<RpcFileConfig>, - rhi: Option<RhiFileConfig>, - capability_binding: Option<Vec<CapabilityBindingFileConfig>>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct OutputFileConfig { - format: Option<String>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct LoggingFileConfig { - filter: Option<String>, - output_dir: Option<PathBuf>, - stdout: Option<bool>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct AccountFileConfig { - selector: Option<String>, - secret: Option<AccountSecretFileConfig>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct AccountSecretFileConfig { - backend: Option<String>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct IdentityFileConfig { - path: Option<PathBuf>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct TransportFileConfig { - profile: Option<String>, - nostr: Option<NostrTransportFileConfig>, - reticulum: Option<ReticulumFileConfig>, - radrootsd_execution: Option<RadrootsdExecutionFileConfig>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct NostrTransportFileConfig { - relay_urls: Option<Vec<String>>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct ReticulumFileConfig { - behavior: Option<String>, - scope: Option<String>, - agent_endpoint: Option<String>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct RadrootsdExecutionFileConfig { - url: Option<String>, - token_file: Option<PathBuf>, - token_secret_id: Option<String>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct RpcFileConfig { - url: Option<String>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct RhiFileConfig { - validator_set: Option<RhiValidatorSetFileConfig>, - require_cryptographic_proof: Option<bool>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct RhiValidatorSetFileConfig { - set_id: Option<String>, - validator_pubkey: Option<String>, - validator_set_addr: Option<String>, - validator_set_event_id: Option<String>, - valid_from: Option<u64>, - valid_until: Option<u64>, - protocol_contract_hash: Option<String>, - operator_name: Option<String>, - operator_contact: Option<String>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct MycFileConfig { - executable: Option<PathBuf>, - status_timeout_ms: Option<u64>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct SignerFileConfig { - backend: Option<String>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct HyfFileConfig { - enabled: Option<bool>, - executable: Option<PathBuf>, -} - -#[derive(Debug, Default, Deserialize)] -#[serde(default, deny_unknown_fields)] -struct MeshFileConfig { - scope: Option<String>, -} - -#[derive(Debug, Clone, Deserialize)] -#[serde(deny_unknown_fields)] -struct CapabilityBindingFileConfig { - capability: String, - provider: String, - target_kind: String, - target: String, - managed_account_ref: Option<String>, - signer_session_ref: Option<String>, -} - -#[derive(Debug, Clone, Copy)] -struct CapabilityBindingSpec { - capability_id: &'static str, - provider_runtime_id: &'static str, - binding_model: &'static str, -} - -pub(crate) const SIGNER_REMOTE_NIP46_CAPABILITY: &str = "signer.remote_nip46"; -pub(crate) const INFERENCE_HYF_STDIO_CAPABILITY: &str = "inference.hyf_stdio"; -const CAPABILITY_BINDING_SPECS: &[CapabilityBindingSpec] = &[ - CapabilityBindingSpec { - capability_id: SIGNER_REMOTE_NIP46_CAPABILITY, - provider_runtime_id: "myc", - binding_model: "session_authorized_remote_signer", - }, - CapabilityBindingSpec { - capability_id: INFERENCE_HYF_STDIO_CAPABILITY, - provider_runtime_id: "hyf", - binding_model: "stdio_service", - }, -]; - -pub(crate) trait Environment { - fn var(&self, key: &str) -> Option<String>; - fn current_dir(&self) -> Result<PathBuf, RuntimeError>; - fn path_resolver(&self) -> RadrootsPathResolver; - fn stdin_is_tty(&self) -> bool; - fn stdout_is_tty(&self) -> bool; -} - -pub struct SystemEnvironment; - -impl Environment for SystemEnvironment { - fn var(&self, key: &str) -> Option<String> { - std::env::var(key).ok() - } - - fn current_dir(&self) -> Result<PathBuf, RuntimeError> { - std::env::current_dir().map_err(|err| { - RuntimeError::Config(format!("failed to resolve current directory: {err}")) - }) - } - - fn path_resolver(&self) -> RadrootsPathResolver { - RadrootsPathResolver::current() - } - - fn stdin_is_tty(&self) -> bool { - std::io::stdin().is_terminal() - } - - fn stdout_is_tty(&self) -> bool { - std::io::stdout().is_terminal() - } -} - -impl RuntimeConfig { - pub fn from_system(args: &RuntimeInvocationArgs) -> Result<Self, RuntimeError> { - let system = SystemEnvironment; - let env_file_path = resolve_env_file_path(args, &system); - let env_file = load_env_file_values(env_file_path.as_deref())?; - Self::resolve_with_env_file(args, &system, &env_file) - } - - fn resolve_with_env_file( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - ) -> Result<Self, RuntimeError> { - let paths = resolve_paths(env, env_file)?; - let workspace_config = paths - .workspace_config_path - .as_deref() - .map(load_cli_config_file) - .transpose()? - .flatten(); - let app_config = load_cli_config_file(paths.app_config_path.as_path())?; - let account_secret_backend = resolve_account_secret_backend( - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )? - .unwrap_or(RadrootsSecretBackend::HostVault( - RadrootsHostVaultPolicy::desktop(), - )); - let output = OutputConfig { - format: resolve_output_format( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, - verbosity: resolve_verbosity(args)?, - dry_run: args.dry_run, - }; - let logging = LoggingConfig { - filter: resolve_logging_filter( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - ), - directory: resolve_logging_directory( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - paths.app_logs_root.as_path(), - ), - stdout: resolve_logging_stdout( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, - }; - validate_logging_output_contract(&output, &logging)?; - let transport = resolve_transport_config( - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?; - Ok(Self { - capability_bindings: resolve_capability_bindings( - app_config.as_ref(), - workspace_config.as_ref(), - )?, - output, - interaction: resolve_interaction_config(args, env), - paths: paths.clone(), - logging, - account: AccountConfig { - selector: args - .account - .clone() - .or_else(|| env_value(env, env_file, &[ENV_CLI_ACCOUNT_SELECTOR])) - .or_else(|| { - app_config - .as_ref() - .and_then(|config| config.account.as_ref()) - .and_then(|account| account.selector.clone()) - }) - .or_else(|| { - workspace_config - .as_ref() - .and_then(|config| config.account.as_ref()) - .and_then(|account| account.selector.clone()) - }), - store_path: paths - .shared_accounts_data_root - .join(DEFAULT_SHARED_ACCOUNTS_STORE_FILE), - secrets_dir: paths.shared_accounts_secrets_root.clone(), - secret_backend: account_secret_backend, - }, - account_secret_contract: AccountSecretContractConfig { - default_backend: CLI_DEFAULT_SECRET_BACKEND.to_owned(), - allowed_backends: CLI_ALLOWED_SHARED_SECRET_BACKENDS - .iter() - .map(|value| (*value).to_owned()) - .collect(), - host_vault_policy: Some(CLI_HOST_VAULT_POLICY.to_owned()), - uses_protected_store: CLI_USES_PROTECTED_STORE, - }, - identity: IdentityConfig { - path: args - .identity_path - .clone() - .or_else(|| { - env_value(env, env_file, &[ENV_CLI_IDENTITY_PATH]).map(PathBuf::from) - }) - .or_else(|| { - app_config - .as_ref() - .and_then(|config| config.identity.as_ref()) - .and_then(|identity| identity.path.clone()) - }) - .or_else(|| { - workspace_config - .as_ref() - .and_then(|config| config.identity.as_ref()) - .and_then(|identity| identity.path.clone()) - }) - .unwrap_or_else(|| paths.default_identity_path.clone()), - }, - signer: resolve_signer_config( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, - transport, - local: LocalConfig { - root: paths.app_data_root.join(DEFAULT_LOCAL_STATE_DIR), - replica_store_path: paths - .app_data_root - .join(DEFAULT_LOCAL_STATE_DIR) - .join(DEFAULT_LOCAL_DB_FILE), - backups_dir: paths - .app_data_root - .join(DEFAULT_LOCAL_STATE_DIR) - .join(DEFAULT_LOCAL_BACKUPS_DIR), - exports_dir: paths - .app_data_root - .join(DEFAULT_LOCAL_STATE_DIR) - .join(DEFAULT_LOCAL_EXPORTS_DIR), - }, - myc: resolve_myc_config( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, - hyf: HyfConfig { - enabled: resolve_hyf_enabled( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, - executable: resolve_hyf_executable( - args, - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - ), - }, - mesh: resolve_mesh_config( - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, - rpc: resolve_rpc_config( - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, - rhi: resolve_rhi_config( - env, - env_file, - app_config.as_ref(), - workspace_config.as_ref(), - )?, - }) - } - - #[cfg(test)] - pub fn inspect_capability_bindings(&self) -> Vec<CapabilityBindingInspection> { - CAPABILITY_BINDING_SPECS - .iter() - .map(|spec| { - if let Some(binding) = self - .capability_bindings - .iter() - .find(|binding| binding.capability_id == spec.capability_id) - { - return CapabilityBindingInspection { - capability_id: binding.capability_id.clone(), - provider_runtime_id: binding.provider_runtime_id.clone(), - binding_model: binding.binding_model.clone(), - state: CapabilityBindingInspectionState::Configured, - source: binding.source.as_str().to_owned(), - target_kind: Some(binding.target_kind.as_str().to_owned()), - target: Some(binding.target.clone()), - managed_account_ref: binding.managed_account_ref.clone(), - signer_session_ref: binding.signer_session_ref.clone(), - }; - } - - let (state, source) = match spec.capability_id { - SIGNER_REMOTE_NIP46_CAPABILITY - if matches!(self.signer.backend, SignerBackend::Local) => - { - ( - CapabilityBindingInspectionState::Disabled, - "independent local signer mode".to_owned(), - ) - } - INFERENCE_HYF_STDIO_CAPABILITY if !self.hyf.enabled => ( - CapabilityBindingInspectionState::Disabled, - "hyf disabled by config".to_owned(), - ), - _ => ( - CapabilityBindingInspectionState::NotConfigured, - "no explicit capability binding".to_owned(), - ), - }; - - CapabilityBindingInspection { - capability_id: spec.capability_id.to_owned(), - provider_runtime_id: spec.provider_runtime_id.to_owned(), - binding_model: spec.binding_model.to_owned(), - state, - source, - target_kind: None, - target: None, - managed_account_ref: None, - signer_session_ref: None, - } - }) - .collect() - } - - pub fn capability_binding(&self, capability_id: &str) -> Option<&CapabilityBindingConfig> { - self.capability_bindings - .iter() - .find(|binding| binding.capability_id == capability_id) - } -} - -fn load_cli_config_file(path: &Path) -> Result<Option<CliConfigFile>, RuntimeError> { - if !path.exists() { - return Ok(None); - } - - let raw = fs::read_to_string(path).map_err(|err| { - RuntimeError::Config(format!( - "failed to read config file {}: {err}", - path.display() - )) - })?; - - if raw.trim().is_empty() { - return Ok(Some(CliConfigFile::default())); - } - - toml::from_str::<CliConfigFile>(&raw) - .map(Some) - .map_err(|err| { - RuntimeError::Config(format!( - "failed to parse config file {}: {err}", - path.display() - )) - }) -} - -fn resolve_rpc_config( - _env: &dyn Environment, - _env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<RpcConfig, RuntimeError> { - let url = user_config - .and_then(|config| config.rpc.as_ref()) - .and_then(|rpc| rpc.url.clone()) - .or_else(|| { - workspace_config - .and_then(|config| config.rpc.as_ref()) - .and_then(|rpc| rpc.url.clone()) - }) - .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()); - - Ok(RpcConfig { - url: validate_rpc_url(url.as_str())?, - }) -} - -fn resolve_transport_config( - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<TransportConfig, RuntimeError> { - if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_TRANSPORT_PROFILE]) { - let profile = parse_transport_profile_kind(key.as_str(), value.as_str())?; - let reticulum_behavior = env_value(env, env_file, &[ENV_CLI_TRANSPORT_RETICULUM_BEHAVIOR]) - .map(|value| { - parse_reticulum_behavior(ENV_CLI_TRANSPORT_RETICULUM_BEHAVIOR, value.as_str()) - }) - .transpose()? - .unwrap_or(ReticulumBehavior::RejectDeliveryAttempts); - let reticulum_scope = env_value(env, env_file, &[ENV_CLI_TRANSPORT_RETICULUM_SCOPE]) - .map(|value| parse_reticulum_scope(ENV_CLI_TRANSPORT_RETICULUM_SCOPE, value.as_str())) - .transpose()? - .unwrap_or_else(|| RADROOTS_RETICULUM_SCOPE_ID.to_owned()); - let reticulum_agent_endpoint = - env_value(env, env_file, &[ENV_CLI_TRANSPORT_RETICULUM_AGENT_ENDPOINT]) - .map(|value| { - parse_reticulum_agent_endpoint( - ENV_CLI_TRANSPORT_RETICULUM_AGENT_ENDPOINT, - value.as_str(), - ) - }) - .transpose()?; - return Ok(TransportConfig { - profile, - source: TransportConfigSource::Environment, - nostr_relay_urls: env_value(env, env_file, &[ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS]) - .map(|value| { - parse_relay_env_value(value.as_str(), ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS) - }) - .transpose()? - .unwrap_or_default(), - reticulum_behavior, - reticulum_scope, - reticulum_agent_endpoint, - radrootsd_execution: resolve_radrootsd_execution_env(env, env_file)?, - }); - } - - if let Some(transport) = user_config.and_then(|config| config.transport.as_ref()) { - return resolve_transport_file_config( - transport, - TransportConfigSource::UserConfig, - "user config [transport]", - ); - } - - if let Some(transport) = workspace_config.and_then(|config| config.transport.as_ref()) { - return resolve_transport_file_config( - transport, - TransportConfigSource::WorkspaceConfig, - "workspace config [transport]", - ); - } - - Ok(TransportConfig { - profile: TransportProfileKind::LocalOnly, - source: TransportConfigSource::Defaults, - nostr_relay_urls: Vec::new(), - reticulum_behavior: ReticulumBehavior::RejectDeliveryAttempts, - reticulum_scope: RADROOTS_RETICULUM_SCOPE_ID.to_owned(), - reticulum_agent_endpoint: None, - radrootsd_execution: RadrootsdExecutionConfig::default(), - }) -} - -fn resolve_transport_file_config( - transport: &TransportFileConfig, - source: TransportConfigSource, - source_label: &str, -) -> Result<TransportConfig, RuntimeError> { - let Some(profile_value) = transport.profile.as_deref() else { - if transport.nostr.is_some() - || transport.reticulum.is_some() - || transport.radrootsd_execution.is_some() - { - return Err(RuntimeError::Config(format!( - "{source_label}.profile must be set when profile-specific transport tables are present" - ))); - } - return Ok(TransportConfig { - profile: TransportProfileKind::LocalOnly, - source, - nostr_relay_urls: Vec::new(), - reticulum_behavior: ReticulumBehavior::RejectDeliveryAttempts, - reticulum_scope: RADROOTS_RETICULUM_SCOPE_ID.to_owned(), - reticulum_agent_endpoint: None, - radrootsd_execution: RadrootsdExecutionConfig::default(), - }); - }; - let profile = - parse_transport_profile_kind(format!("{source_label}.profile").as_str(), profile_value)?; - let nostr_relay_urls = transport - .nostr - .as_ref() - .and_then(|nostr| nostr.relay_urls.clone()) - .map(|urls| normalize_relay_urls(urls, format!("{source_label}.nostr.relay_urls").as_str())) - .transpose()? - .unwrap_or_default(); - let reticulum_behavior = transport - .reticulum - .as_ref() - .and_then(|reticulum| reticulum.behavior.as_deref()) - .map(|value| { - parse_reticulum_behavior(format!("{source_label}.reticulum.behavior").as_str(), value) - }) - .transpose()? - .unwrap_or(ReticulumBehavior::RejectDeliveryAttempts); - let reticulum_scope = transport - .reticulum - .as_ref() - .and_then(|reticulum| reticulum.scope.as_deref()) - .map(|value| { - parse_reticulum_scope(format!("{source_label}.reticulum.scope").as_str(), value) - }) - .transpose()? - .unwrap_or_else(|| RADROOTS_RETICULUM_SCOPE_ID.to_owned()); - let reticulum_agent_endpoint = transport - .reticulum - .as_ref() - .and_then(|reticulum| reticulum.agent_endpoint.as_deref()) - .map(|value| { - parse_reticulum_agent_endpoint( - format!("{source_label}.reticulum.agent_endpoint").as_str(), - value, - ) - }) - .transpose()?; - Ok(TransportConfig { - profile, - source, - nostr_relay_urls, - reticulum_behavior, - reticulum_scope, - reticulum_agent_endpoint, - radrootsd_execution: resolve_radrootsd_execution_file( - transport.radrootsd_execution.as_ref(), - source_label, - )?, - }) -} - -fn resolve_radrootsd_execution_env( - env: &dyn Environment, - env_file: &EnvFileValues, -) -> Result<RadrootsdExecutionConfig, RuntimeError> { - let url = env_value(env, env_file, &[ENV_CLI_RADROOTSD_EXECUTION_URL]) - .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()); - let token_file = - env_value(env, env_file, &[ENV_CLI_RADROOTSD_EXECUTION_TOKEN_FILE]).map(PathBuf::from); - let token_secret_id = env_value( - env, - env_file, - &[ENV_CLI_RADROOTSD_EXECUTION_TOKEN_SECRET_ID], - ); - validate_radrootsd_execution_token_source( - token_file.as_ref(), - token_secret_id.as_deref(), - "environment radrootsd execution", - )?; - Ok(RadrootsdExecutionConfig { - url: validate_rpc_url(url.as_str())?, - token_file, - token_secret_id, - }) -} - -fn resolve_radrootsd_execution_file( - radrootsd_execution: Option<&RadrootsdExecutionFileConfig>, - source_label: &str, -) -> Result<RadrootsdExecutionConfig, RuntimeError> { - let url = radrootsd_execution - .and_then(|execution| execution.url.clone()) - .unwrap_or_else(|| DEFAULT_RPC_URL.to_owned()); - let token_file = radrootsd_execution.and_then(|execution| execution.token_file.clone()); - let token_secret_id = - radrootsd_execution.and_then(|execution| execution.token_secret_id.clone()); - validate_radrootsd_execution_token_source( - token_file.as_ref(), - token_secret_id.as_deref(), - format!("{source_label}.radrootsd_execution").as_str(), - )?; - Ok(RadrootsdExecutionConfig { - url: validate_rpc_url(url.as_str()).map_err(|error| match error { - RuntimeError::Config(message) => RuntimeError::Config(format!( - "{source_label}.radrootsd_execution.url is invalid: {message}" - )), - other => other, - })?, - token_file, - token_secret_id, - }) -} - -fn validate_radrootsd_execution_token_source( - token_file: Option<&PathBuf>, - token_secret_id: Option<&str>, - source_label: &str, -) -> Result<(), RuntimeError> { - if token_file.is_some_and(|path| path.as_os_str().is_empty()) { - return Err(RuntimeError::Config(format!( - "{source_label}.token_file must not be empty" - ))); - } - if token_secret_id.is_some_and(|secret_id| secret_id.trim().is_empty()) { - return Err(RuntimeError::Config(format!( - "{source_label}.token_secret_id must not be empty" - ))); - } - if token_file.is_some() && token_secret_id.is_some() { - return Err(RuntimeError::Config(format!( - "{source_label} cannot set both token_file and token_secret_id" - ))); - } - Ok(()) -} - -fn resolve_rhi_config( - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<RhiConfig, RuntimeError> { - let validator_set = - resolve_rhi_validator_set_config(env, env_file, user_config, workspace_config)?; - let require_cryptographic_proof = - if let Some(value) = env_value(env, env_file, &[ENV_CLI_RHI_REQUIRE_CRYPTOGRAPHIC_PROOF]) { - parse_bool_env(ENV_CLI_RHI_REQUIRE_CRYPTOGRAPHIC_PROOF, value.as_str())? - } else if let Some(value) = user_config - .and_then(|config| config.rhi.as_ref()) - .and_then(|rhi| rhi.require_cryptographic_proof) - { - value - } else { - workspace_config - .and_then(|config| config.rhi.as_ref()) - .and_then(|rhi| rhi.require_cryptographic_proof) - .unwrap_or_default() - }; - - Ok(RhiConfig { - validator_set, - require_cryptographic_proof, - }) -} - -fn resolve_rhi_validator_set_config( - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<Option<RhiValidatorSetConfig>, RuntimeError> { - let env_keys = [ - ENV_CLI_RHI_VALIDATOR_SET_ID, - ENV_CLI_RHI_VALIDATOR_PUBKEY, - ENV_CLI_RHI_VALIDATOR_SET_ADDR, - ENV_CLI_RHI_VALIDATOR_SET_EVENT_ID, - ENV_CLI_RHI_VALIDATOR_SET_VALID_FROM, - ENV_CLI_RHI_VALIDATOR_SET_VALID_UNTIL, - ENV_CLI_RHI_VALIDATOR_SET_PROTOCOL_CONTRACT_HASH, - ENV_CLI_RHI_VALIDATOR_SET_OPERATOR_NAME, - ENV_CLI_RHI_VALIDATOR_SET_OPERATOR_CONTACT, - ]; - if env_keys - .iter() - .any(|key| env_value(env, env_file, &[*key]).is_some()) - { - return Ok(Some(validate_rhi_validator_set_config( - RhiValidatorSetConfig { - set_id: required_env_value(env, env_file, ENV_CLI_RHI_VALIDATOR_SET_ID)?, - validator_pubkey: required_env_value(env, env_file, ENV_CLI_RHI_VALIDATOR_PUBKEY)?, - validator_set_addr: required_env_value( - env, - env_file, - ENV_CLI_RHI_VALIDATOR_SET_ADDR, - )?, - validator_set_event_id: required_env_value( - env, - env_file, - ENV_CLI_RHI_VALIDATOR_SET_EVENT_ID, - )?, - valid_from: parse_u64_value( - ENV_CLI_RHI_VALIDATOR_SET_VALID_FROM, - required_env_value(env, env_file, ENV_CLI_RHI_VALIDATOR_SET_VALID_FROM)? - .as_str(), - ) - .map_err(|err| RuntimeError::Config(err.to_string()))?, - valid_until: parse_u64_value( - ENV_CLI_RHI_VALIDATOR_SET_VALID_UNTIL, - required_env_value(env, env_file, ENV_CLI_RHI_VALIDATOR_SET_VALID_UNTIL)? - .as_str(), - ) - .map_err(|err| RuntimeError::Config(err.to_string()))?, - protocol_contract_hash: required_env_value( - env, - env_file, - ENV_CLI_RHI_VALIDATOR_SET_PROTOCOL_CONTRACT_HASH, - )?, - operator_name: required_env_value( - env, - env_file, - ENV_CLI_RHI_VALIDATOR_SET_OPERATOR_NAME, - )?, - operator_contact: env_value( - env, - env_file, - &[ENV_CLI_RHI_VALIDATOR_SET_OPERATOR_CONTACT], - ), - }, - "environment RHI validator_set", - )?)); - } - if let Some(config) = user_config - .and_then(|config| config.rhi.as_ref()) - .and_then(|rhi| rhi.validator_set.as_ref()) - { - return Ok(Some(rhi_validator_set_from_file_config( - config, - "user config [rhi.validator_set]", - )?)); - } - if let Some(config) = workspace_config - .and_then(|config| config.rhi.as_ref()) - .and_then(|rhi| rhi.validator_set.as_ref()) - { - return Ok(Some(rhi_validator_set_from_file_config( - config, - "workspace config [rhi.validator_set]", - )?)); - } - Ok(None) -} - -fn required_env_value( - env: &dyn Environment, - env_file: &EnvFileValues, - key: &'static str, -) -> Result<String, RuntimeError> { - env_value(env, env_file, &[key]).ok_or_else(|| { - RuntimeError::Config(format!( - "{key} is required when any RHI validator set environment key is set" - )) - }) -} - -fn rhi_validator_set_from_file_config( - config: &RhiValidatorSetFileConfig, - source: &'static str, -) -> Result<RhiValidatorSetConfig, RuntimeError> { - validate_rhi_validator_set_config( - RhiValidatorSetConfig { - set_id: required_file_value(config.set_id.clone(), source, "set_id")?, - validator_pubkey: required_file_value( - config.validator_pubkey.clone(), - source, - "validator_pubkey", - )?, - validator_set_addr: required_file_value( - config.validator_set_addr.clone(), - source, - "validator_set_addr", - )?, - validator_set_event_id: required_file_value( - config.validator_set_event_id.clone(), - source, - "validator_set_event_id", - )?, - valid_from: config - .valid_from - .ok_or_else(|| missing_file_value(source, "valid_from"))?, - valid_until: config - .valid_until - .ok_or_else(|| missing_file_value(source, "valid_until"))?, - protocol_contract_hash: required_file_value( - config.protocol_contract_hash.clone(), - source, - "protocol_contract_hash", - )?, - operator_name: required_file_value( - config.operator_name.clone(), - source, - "operator_name", - )?, - operator_contact: config.operator_contact.clone(), - }, - source, - ) -} - -fn required_file_value( - value: Option<String>, - source: &'static str, - field: &'static str, -) -> Result<String, RuntimeError> { - value.ok_or_else(|| missing_file_value(source, field)) -} - -fn missing_file_value(source: &'static str, field: &'static str) -> RuntimeError { - RuntimeError::Config(format!("{source}.{field} is required")) -} - -fn validate_rhi_validator_set_config( - mut config: RhiValidatorSetConfig, - source: &'static str, -) -> Result<RhiValidatorSetConfig, RuntimeError> { - config.set_id = validate_uuidv7(config.set_id.as_str(), source, "set_id")?; - config.validator_pubkey = validate_pubkey( - config.validator_pubkey.as_str(), - format!("{source}.validator_pubkey").as_str(), - )?; - config.validator_set_event_id = validate_event_id( - config.validator_set_event_id.as_str(), - format!("{source}.validator_set_event_id").as_str(), - )?; - config.protocol_contract_hash = validate_hash32( - config.protocol_contract_hash.as_str(), - format!("{source}.protocol_contract_hash").as_str(), - )?; - config.validator_set_addr = validate_validator_set_address( - config.validator_set_addr.as_str(), - config.set_id.as_str(), - format!("{source}.validator_set_addr").as_str(), - )?; - if config.valid_from >= config.valid_until { - return Err(RuntimeError::Config(format!( - "{source}.valid_from must be less than {source}.valid_until" - ))); - } - config.operator_name = validate_required_text( - config.operator_name.as_str(), - format!("{source}.operator_name").as_str(), - )?; - if let Some(operator_contact) = config.operator_contact.as_ref() { - validate_required_text( - operator_contact.as_str(), - format!("{source}.operator_contact").as_str(), - )?; - } - Ok(config) -} - -fn validate_pubkey(value: &str, source: &str) -> Result<String, RuntimeError> { - let trimmed = value.trim(); - if trimmed.len() != 64 || !trimmed.chars().all(|char| char.is_ascii_hexdigit()) { - return Err(RuntimeError::Config(format!( - "{source} must contain 64-character hex Nostr public keys" - ))); - } - Ok(trimmed.to_ascii_lowercase()) -} - -fn validate_event_id(value: &str, source: &str) -> Result<String, RuntimeError> { - validate_pubkey(value, source) -} - -fn validate_hash32(value: &str, source: &str) -> Result<String, RuntimeError> { - let trimmed = value.trim(); - let Some(hex) = trimmed.strip_prefix("0x") else { - return Err(RuntimeError::Config(format!( - "{source} must be a 0x-prefixed 32-byte lowercase hex digest" - ))); - }; - if hex.len() != 64 || !hex.chars().all(|char| char.is_ascii_hexdigit()) { - return Err(RuntimeError::Config(format!( - "{source} must be a 0x-prefixed 32-byte lowercase hex digest" - ))); - } - Ok(format!("0x{}", hex.to_ascii_lowercase())) -} - -fn validate_uuidv7(value: &str, source: &str, field: &str) -> Result<String, RuntimeError> { - let trimmed = value.trim().to_ascii_lowercase(); - let bytes = trimmed.as_bytes(); - if bytes.len() != 36 - || bytes[8] != b'-' - || bytes[13] != b'-' - || bytes[18] != b'-' - || bytes[23] != b'-' - || bytes[14] != b'7' - || !matches!(bytes[19], b'8'..=b'9' | b'a'..=b'b') - { - return Err(RuntimeError::Config(format!( - "{source}.{field} must be a UUIDv7" - ))); - } - for (index, byte) in bytes.iter().enumerate() { - if matches!(index, 8 | 13 | 18 | 23) { - continue; - } - if !byte.is_ascii_digit() && !(b'a'..=b'f').contains(byte) { - return Err(RuntimeError::Config(format!( - "{source}.{field} must be a UUIDv7" - ))); - } - } - Ok(trimmed) -} - -fn validate_validator_set_address( - value: &str, - expected_set_id: &str, - source: &str, -) -> Result<String, RuntimeError> { - let trimmed = value.trim(); - let mut parts = trimmed.split(':'); - let Some(kind) = parts.next() else { - return Err(RuntimeError::Config(format!( - "{source} must be a 30381 address" - ))); - }; - let Some(pubkey) = parts.next() else { - return Err(RuntimeError::Config(format!( - "{source} must be a 30381 address" - ))); - }; - let Some(set_id) = parts.next() else { - return Err(RuntimeError::Config(format!( - "{source} must be a 30381 address" - ))); - }; - if parts.next().is_some() || kind != "30381" || set_id != expected_set_id { - return Err(RuntimeError::Config(format!( - "{source} must be a 30381 address" - ))); - } - let pubkey = validate_pubkey(pubkey, source)?; - Ok(format!("30381:{pubkey}:{set_id}")) -} - -fn validate_required_text(value: &str, source: &str) -> Result<String, RuntimeError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(RuntimeError::Config(format!("{source} must not be empty"))); - } - Ok(trimmed.to_owned()) -} - -fn resolve_capability_bindings( - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<Vec<CapabilityBindingConfig>, RuntimeError> { - let workspace = resolve_file_capability_bindings( - workspace_config.and_then(|config| config.capability_binding.as_deref()), - CapabilityBindingSource::WorkspaceConfig, - )?; - let user = resolve_file_capability_bindings( - user_config.and_then(|config| config.capability_binding.as_deref()), - CapabilityBindingSource::UserConfig, - )?; - - let mut merged = BTreeMap::new(); - for binding in workspace.into_iter().chain(user) { - merged.insert(binding.capability_id.clone(), binding); - } - - Ok(CAPABILITY_BINDING_SPECS - .iter() - .filter_map(|spec| merged.remove(spec.capability_id)) - .collect()) -} - -fn resolve_file_capability_bindings( - bindings: Option<&[CapabilityBindingFileConfig]>, - source: CapabilityBindingSource, -) -> Result<Vec<CapabilityBindingConfig>, RuntimeError> { - let Some(bindings) = bindings else { - return Ok(Vec::new()); - }; - - let mut seen = BTreeMap::new(); - let mut resolved = Vec::with_capacity(bindings.len()); - - for binding in bindings { - let capability = binding.capability.trim(); - let provider = binding.provider.trim(); - let Some(spec) = capability_binding_spec(capability) else { - return Err(RuntimeError::Config(format!( - "unknown capability_binding capability `{capability}`" - ))); - }; - if provider != spec.provider_runtime_id { - return Err(RuntimeError::Config(format!( - "capability_binding `{capability}` must use provider `{}`, got `{provider}`", - spec.provider_runtime_id - ))); - } - if seen.insert(spec.capability_id.to_owned(), ()).is_some() { - return Err(RuntimeError::Config(format!( - "capability_binding `{capability}` is duplicated in one config file" - ))); - } - - let target = binding.target.trim(); - if target.is_empty() { - return Err(RuntimeError::Config(format!( - "capability_binding `{capability}` target must not be empty" - ))); - } - - let managed_account_ref = normalize_binding_ref( - binding - .managed_account_ref - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()), - ); - let signer_session_ref = normalize_binding_ref( - binding - .signer_session_ref - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()), - ); - if spec.capability_id != SIGNER_REMOTE_NIP46_CAPABILITY - && (managed_account_ref.is_some() || signer_session_ref.is_some()) - { - return Err(RuntimeError::Config(format!( - "capability_binding `{capability}` may not set managed_account_ref or signer_session_ref" - ))); - } - - resolved.push(CapabilityBindingConfig { - capability_id: spec.capability_id.to_owned(), - provider_runtime_id: spec.provider_runtime_id.to_owned(), - binding_model: spec.binding_model.to_owned(), - target_kind: parse_capability_binding_target_kind( - binding.target_kind.as_str(), - spec.capability_id, - )?, - target: target.to_owned(), - managed_account_ref, - signer_session_ref, - source, - }); - } - - Ok(resolved) -} - -fn capability_binding_spec(capability_id: &str) -> Option<CapabilityBindingSpec> { - CAPABILITY_BINDING_SPECS - .iter() - .copied() - .find(|spec| spec.capability_id == capability_id) -} - -fn parse_capability_binding_target_kind( - value: &str, - capability_id: &str, -) -> Result<CapabilityBindingTargetKind, RuntimeError> { - match value.trim().to_ascii_lowercase().as_str() { - "managed_instance" => Ok(CapabilityBindingTargetKind::ManagedInstance), - "explicit_endpoint" => Ok(CapabilityBindingTargetKind::ExplicitEndpoint), - other => Err(RuntimeError::Config(format!( - "capability_binding `{capability_id}` target_kind must be `managed_instance` or `explicit_endpoint`, got `{other}`" - ))), - } -} - -fn normalize_binding_ref(value: Option<&str>) -> Option<String> { - value.map(ToOwned::to_owned) -} - -fn resolve_signer_config( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<SignerConfig, RuntimeError> { - let backend = if let Some(value) = args.signer.clone() { - parse_signer_mode("internal invocation signer mode", value)? - } else if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_SIGNER_BACKEND]) { - parse_signer_mode(key.as_str(), value)? - } else if let Some(value) = user_config - .and_then(|config| config.signer.as_ref()) - .and_then(|signer| signer.backend.clone()) - { - parse_signer_mode("user config [signer].backend", value)? - } else if let Some(value) = workspace_config - .and_then(|config| config.signer.as_ref()) - .and_then(|signer| signer.backend.clone()) - { - parse_signer_mode("workspace config [signer].backend", value)? - } else { - SignerBackend::Local - }; - - Ok(SignerConfig { backend }) -} - -fn resolve_myc_config( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<MycConfig, RuntimeError> { - let executable = args - .myc_executable - .clone() - .or_else(|| env_value(env, env_file, &[ENV_CLI_MYC_EXECUTABLE]).map(PathBuf::from)) - .or_else(|| { - user_config - .and_then(|config| config.myc.as_ref()) - .and_then(|myc| myc.executable.clone()) - }) - .or_else(|| { - workspace_config - .and_then(|config| config.myc.as_ref()) - .and_then(|myc| myc.executable.clone()) - }) - .unwrap_or_else(|| PathBuf::from("myc")); - - Ok(MycConfig { - executable, - status_timeout_ms: resolve_myc_status_timeout_ms( - args, - env, - env_file, - user_config, - workspace_config, - )?, - }) -} - -fn resolve_myc_status_timeout_ms( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<u64, RuntimeError> { - if let Some(value) = args.myc_status_timeout_ms { - return validate_myc_status_timeout_ms("--myc-status-timeout-ms", value); - } - - if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_MYC_STATUS_TIMEOUT_MS]) { - let parsed = parse_u64_value(key.as_str(), value.as_str()) - .map_err(|err| RuntimeError::Config(err.to_string()))?; - return validate_myc_status_timeout_ms(key.as_str(), parsed); - } - - if let Some(value) = user_config - .and_then(|config| config.myc.as_ref()) - .and_then(|myc| myc.status_timeout_ms) - { - return validate_myc_status_timeout_ms("user config [myc].status_timeout_ms", value); - } - - if let Some(value) = workspace_config - .and_then(|config| config.myc.as_ref()) - .and_then(|myc| myc.status_timeout_ms) - { - return validate_myc_status_timeout_ms("workspace config [myc].status_timeout_ms", value); - } - - Ok(DEFAULT_MYC_STATUS_TIMEOUT_MS) -} - -fn validate_myc_status_timeout_ms(source: &str, value: u64) -> Result<u64, RuntimeError> { - if value == 0 { - return Err(RuntimeError::Config(format!( - "{source} must be greater than zero" - ))); - } - Ok(value) -} - -fn resolve_hyf_enabled( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<bool, RuntimeError> { - match (args.hyf_enabled, args.no_hyf_enabled) { - (true, true) => { - return Err(RuntimeError::Config( - "flags --hyf-enabled and --no-hyf-enabled cannot be used together".to_owned(), - )); - } - (true, false) => return Ok(true), - (false, true) => return Ok(false), - (false, false) => {} - } - - if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_HYF_ENABLED]) { - return parse_bool_env(key.as_str(), value.as_str()); - } - - if let Some(enabled) = user_config - .and_then(|config| config.hyf.as_ref()) - .and_then(|hyf| hyf.enabled) - { - return Ok(enabled); - } - - if let Some(enabled) = workspace_config - .and_then(|config| config.hyf.as_ref()) - .and_then(|hyf| hyf.enabled) - { - return Ok(enabled); - } - - Ok(false) -} - -fn resolve_hyf_executable( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> PathBuf { - args.hyf_executable - .clone() - .or_else(|| env_value(env, env_file, &[ENV_CLI_HYF_EXECUTABLE]).map(PathBuf::from)) - .or_else(|| { - user_config - .and_then(|config| config.hyf.as_ref()) - .and_then(|hyf| hyf.executable.clone()) - }) - .or_else(|| { - workspace_config - .and_then(|config| config.hyf.as_ref()) - .and_then(|hyf| hyf.executable.clone()) - }) - .unwrap_or_else(|| PathBuf::from(DEFAULT_HYF_EXECUTABLE)) -} - -fn resolve_mesh_config( - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<MeshConfig, RuntimeError> { - if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_MESH_SCOPE]) { - return Ok(MeshConfig { - scope: parse_mesh_scope(key.as_str(), value.as_str())?, - source: MeshConfigSource::Environment, - }); - } - if let Some(value) = user_config - .and_then(|config| config.mesh.as_ref()) - .and_then(|mesh| mesh.scope.as_deref()) - { - return Ok(MeshConfig { - scope: parse_mesh_scope("user config [mesh].scope", value)?, - source: MeshConfigSource::UserConfig, - }); - } - if let Some(value) = workspace_config - .and_then(|config| config.mesh.as_ref()) - .and_then(|mesh| mesh.scope.as_deref()) - { - return Ok(MeshConfig { - scope: parse_mesh_scope("workspace config [mesh].scope", value)?, - source: MeshConfigSource::WorkspaceConfig, - }); - } - Ok(MeshConfig { - scope: MeshScope::Disabled, - source: MeshConfigSource::Defaults, - }) -} - -fn parse_mesh_scope(source: &str, value: &str) -> Result<MeshScope, RuntimeError> { - match value.trim().to_ascii_lowercase().as_str() { - "disabled" => Ok(MeshScope::Disabled), - "local" => Ok(MeshScope::Local), - other => Err(RuntimeError::Config(format!( - "{source} must be `disabled` or `local`, got `{other}`" - ))), - } -} - -fn validate_rpc_url(value: &str) -> Result<String, RuntimeError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(RuntimeError::Config("rpc url must not be empty".to_owned())); - } - let parsed = Url::parse(trimmed) - .map_err(|err| RuntimeError::Config(format!("rpc url `{trimmed}` is invalid: {err}")))?; - if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() { - return Err(RuntimeError::Config(format!( - "rpc url must use http or https, got `{trimmed}`" - ))); - } - Ok(trimmed.to_owned()) -} - -fn parse_relay_env_value(value: &str, key: &str) -> Result<Vec<String>, RuntimeError> { - let entries = value - .split(',') - .map(str::trim) - .map(ToOwned::to_owned) - .collect::<Vec<_>>(); - - if entries.is_empty() { - return Err(RuntimeError::Config(format!( - "{key} must contain at least one websocket relay url" - ))); - } - - normalize_relay_urls(entries, key) -} - -fn normalize_relay_urls(values: Vec<String>, source: &str) -> Result<Vec<String>, RuntimeError> { - let mut normalized = Vec::new(); - for value in values { - let relay = validate_relay_url(value.as_str(), source)?; - if !normalized.iter().any(|existing| existing == &relay) { - normalized.push(relay); - } - } - Ok(normalized) -} - -fn validate_relay_url(value: &str, source: &str) -> Result<String, RuntimeError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(RuntimeError::Config(format!( - "{source} contains an empty relay url" - ))); - } - let relay = if trimmed.starts_with("ws://") { - radroots_transport::Target::nostr_relay(trimmed) - .map(|target| target.uri().as_str().to_owned()) - .map_err(|error| RelayTransportError::InvalidRelayUrl { - url: trimmed.to_owned(), - reason: error.to_string(), - }) - } else { - RelayUrl::parse(trimmed, nostr_relay_url_policy_for_url(trimmed)) - .map(|relay| relay.as_str().to_owned()) - }; - relay.map_err(|error| match error { - RelayTransportError::RelaySchemeDenied { .. } - | RelayTransportError::RelayDestinationDenied { .. } => { - RuntimeError::Config(format!("{source} must use websocket relay urls allowed by the active Nostr policy, got `{trimmed}`")) - } - other => RuntimeError::Config(format!( - "{source} contains invalid relay url `{trimmed}`: {other}" - )), - }) -} - -pub(crate) fn nostr_relay_url_policy_for_url(value: &str) -> RelayUrlPolicy { - if value.trim_start().starts_with("ws://") { - RelayUrlPolicy::Local - } else { - RelayUrlPolicy::Public - } -} - -fn resolve_env_file_path(args: &RuntimeInvocationArgs, env: &dyn Environment) -> Option<PathBuf> { - args.env_file - .clone() - .or_else(|| env.var(ENV_CLI_FILE_PATH).map(PathBuf::from)) - .or_else(|| { - let default_path = PathBuf::from(DEFAULT_ENV_PATH); - default_path.exists().then_some(default_path) - }) -} - -fn resolve_output_format( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<OutputFormat, RuntimeError> { - if args.output_format.is_some() && (args.json || args.ndjson) { - return Err(RuntimeError::Config( - "flags --output, --json, and --ndjson cannot be used together".to_owned(), - )); - } - - match (args.output_format, args.json, args.ndjson) { - (_, true, true) => { - return Err(RuntimeError::Config( - "flags --json and --ndjson cannot be used together".to_owned(), - )); - } - (Some(format), false, false) => return Ok(format.as_output_format()), - (None, true, false) => return Ok(OutputFormat::Json), - (None, false, true) => return Ok(OutputFormat::Ndjson), - (None, false, false) => {} - (Some(_), true, false) | (Some(_), false, true) => unreachable!(), - } - if let Some(value) = env_value(env, env_file, &[ENV_CLI_OUTPUT_FORMAT]) { - return parse_output_format(value.as_str()); - } - - if let Some(value) = user_config - .and_then(|config| config.output.as_ref()) - .and_then(|output| output.format.as_deref()) - { - return parse_output_format(value); - } - - match workspace_config - .and_then(|config| config.output.as_ref()) - .and_then(|output| output.format.as_deref()) - { - Some(value) => parse_output_format(value), - None => Ok(OutputFormat::Terminal), - } -} - -fn resolve_verbosity(args: &RuntimeInvocationArgs) -> Result<Verbosity, RuntimeError> { - let selected = [args.quiet, args.verbose, args.trace] - .into_iter() - .filter(|selected| *selected) - .count(); - if selected > 1 { - return Err(RuntimeError::Config( - "flags --quiet, --verbose, and --trace are mutually exclusive".to_owned(), - )); - } - - if args.quiet { - Ok(Verbosity::Quiet) - } else if args.trace { - Ok(Verbosity::Trace) - } else if args.verbose { - Ok(Verbosity::Verbose) - } else { - Ok(Verbosity::Normal) - } -} - -fn resolve_interaction_config( - args: &RuntimeInvocationArgs, - env: &dyn Environment, -) -> InteractionConfig { - let stdin_tty = env.stdin_is_tty(); - let stdout_tty = env.stdout_is_tty(); - let input_enabled = !args.no_input; - let prompts_allowed = input_enabled && stdin_tty && stdout_tty; - let confirmations_allowed = prompts_allowed && !args.yes; - InteractionConfig { - input_enabled, - assume_yes: args.yes, - stdin_tty, - stdout_tty, - prompts_allowed, - confirmations_allowed, - } -} - -fn resolve_logging_filter( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> String { - args.log_filter - .clone() - .or_else(|| env_value(env, env_file, &[ENV_CLI_LOG_FILTER])) - .or_else(|| { - user_config - .and_then(|config| config.logging.as_ref()) - .and_then(|logging| logging.filter.clone()) - }) - .or_else(|| { - workspace_config - .and_then(|config| config.logging.as_ref()) - .and_then(|logging| logging.filter.clone()) - }) - .unwrap_or_else(|| DEFAULT_LOG_FILTER.to_owned()) -} - -fn resolve_logging_directory( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, - default_logs_root: &Path, -) -> Option<PathBuf> { - args.log_dir - .clone() - .or_else(|| env_value(env, env_file, &[ENV_CLI_LOG_DIR]).map(PathBuf::from)) - .or_else(|| { - user_config - .and_then(|config| config.logging.as_ref()) - .and_then(|logging| logging.output_dir.clone()) - }) - .or_else(|| { - workspace_config - .and_then(|config| config.logging.as_ref()) - .and_then(|logging| logging.output_dir.clone()) - }) - .or_else(|| Some(default_logs_root.to_path_buf())) -} - -fn resolve_logging_stdout( - args: &RuntimeInvocationArgs, - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<bool, RuntimeError> { - resolve_bool_pair( - args.log_stdout, - args.no_log_stdout, - &[ENV_CLI_LOG_STDOUT], - user_config - .and_then(|config| config.logging.as_ref()) - .and_then(|logging| logging.stdout), - workspace_config - .and_then(|config| config.logging.as_ref()) - .and_then(|logging| logging.stdout), - false, - env, - env_file, - "--log-stdout", - "--no-log-stdout", - ) -} - -fn validate_logging_output_contract( - output: &OutputConfig, - logging: &LoggingConfig, -) -> Result<(), RuntimeError> { - if logging.stdout { - return Err(RuntimeError::Config(format!( - "stdout logging cannot be used with {} output; unset {ENV_CLI_LOG_STDOUT} or use --no-log-stdout", - output.format.as_str() - ))); - } - - Ok(()) -} - -#[expect( - clippy::too_many_arguments, - reason = "configuration precedence keeps each source explicit" -)] -fn resolve_bool_pair( - positive_flag: bool, - negative_flag: bool, - env_keys: &[&str], - user_value: Option<bool>, - workspace_value: Option<bool>, - default: bool, - env: &dyn Environment, - env_file: &EnvFileValues, - positive_label: &str, - negative_label: &str, -) -> Result<bool, RuntimeError> { - match (positive_flag, negative_flag) { - (true, true) => Err(RuntimeError::Config(format!( - "flags {positive_label} and {negative_label} cannot be used together" - ))), - (true, false) => Ok(true), - (false, true) => Ok(false), - (false, false) => match env_value_entry(env, env_file, env_keys) { - Some((key, value)) => parse_bool_env(key.as_str(), value.as_str()), - None => Ok(user_value.or(workspace_value).unwrap_or(default)), - }, - } -} - -fn env_value(env: &dyn Environment, env_file: &EnvFileValues, keys: &[&str]) -> Option<String> { - env_value_entry(env, env_file, keys).map(|(_, value)| value) -} - -fn env_value_entry( - env: &dyn Environment, - env_file: &EnvFileValues, - keys: &[&str], -) -> Option<(String, String)> { - keys.iter() - .find_map(|key| env.var(key).map(|value| ((*key).to_owned(), value))) - .or_else(|| { - keys.iter().find_map(|key| { - env_file - .0 - .get(*key) - .cloned() - .map(|value| ((*key).to_owned(), value)) - }) - }) -} - -fn load_env_file_values(path: Option<&Path>) -> Result<EnvFileValues, RuntimeError> { - let Some(path) = path else { - return Ok(EnvFileValues::default()); - }; - let raw = fs::read_to_string(path).map_err(|err| { - RuntimeError::Config(format!("failed to read env file {}: {err}", path.display())) - })?; - parse_env_file_values(&raw, path) -} - -fn parse_env_file_values(raw: &str, path: &Path) -> Result<EnvFileValues, RuntimeError> { - parse_strict_env_file(raw, path, SUPPORTED_ENV_FILE_KEYS) - .map(|values| EnvFileValues(values.into_inner())) - .map_err(|err| RuntimeError::Config(err.to_string())) -} - -fn parse_output_format(value: &str) -> Result<OutputFormat, RuntimeError> { - match value.trim().to_ascii_lowercase().as_str() { - "terminal" => Ok(OutputFormat::Terminal), - "json" => Ok(OutputFormat::Json), - "ndjson" => Ok(OutputFormat::Ndjson), - other => Err(RuntimeError::Config(format!( - "{ENV_CLI_OUTPUT_FORMAT} must be `terminal`, `json`, or `ndjson`, got `{other}`" - ))), - } -} - -fn parse_signer_mode(source: &str, value: String) -> Result<SignerBackend, RuntimeError> { - match value.trim().to_ascii_lowercase().as_str() { - "local" => Ok(SignerBackend::Local), - "myc" => Ok(SignerBackend::Myc), - other => Err(RuntimeError::Config(format!( - "{source} must be `local` or `myc`, got `{other}`" - ))), - } -} - -fn parse_transport_profile_kind( - source: &str, - value: &str, -) -> Result<TransportProfileKind, RuntimeError> { - match value.trim().to_ascii_lowercase().as_str() { - "local_only" => Ok(TransportProfileKind::LocalOnly), - "nostr" => Ok(TransportProfileKind::Nostr), - "reticulum" => Ok(TransportProfileKind::Reticulum), - "multi_target" => Ok(TransportProfileKind::MultiTarget), - other => Err(RuntimeError::Config(format!( - "{source} must be `local_only`, `nostr`, `reticulum`, or `multi_target`, got `{other}`" - ))), - } -} - -fn parse_reticulum_behavior(source: &str, value: &str) -> Result<ReticulumBehavior, RuntimeError> { - match value.trim().to_ascii_lowercase().as_str() { - "reject_delivery_attempts" => Ok(ReticulumBehavior::RejectDeliveryAttempts), - "defer_delivery_plans" => Ok(ReticulumBehavior::DeferDeliveryPlans), - other => Err(RuntimeError::Config(format!( - "{source} must be `reject_delivery_attempts` or `defer_delivery_plans`, got `{other}`" - ))), - } -} - -fn parse_reticulum_scope(source: &str, value: &str) -> Result<String, RuntimeError> { - RadrootsTransportMeshScopeId::parse(value) - .map(|scope| scope.as_str().to_owned()) - .map_err(|error| RuntimeError::Config(format!("{source} is invalid: {error}"))) -} - -fn parse_reticulum_agent_endpoint(source: &str, value: &str) -> Result<String, RuntimeError> { - if value.is_empty() - || value != value.trim() - || value - .chars() - .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace()) - || value.find(':').is_none() - { - return Err(RuntimeError::Config(format!( - "{source} must be a non-empty URI-like endpoint without whitespace" - ))); - } - Ok(value.to_owned()) -} - -fn resolve_account_secret_backend( - env: &dyn Environment, - env_file: &EnvFileValues, - user_config: Option<&CliConfigFile>, - workspace_config: Option<&CliConfigFile>, -) -> Result<Option<RadrootsSecretBackend>, RuntimeError> { - if let Some((key, value)) = env_value_entry(env, env_file, &[ENV_CLI_ACCOUNT_SECRET_BACKEND]) { - return parse_account_secret_backend(key.as_str(), value.as_str()).map(Some); - } - - if let Some(value) = user_config - .and_then(|config| config.account.as_ref()) - .and_then(|account| account.secret.as_ref()) - .and_then(|secret| secret.backend.as_deref()) - { - return parse_account_secret_backend("user config [account.secret].backend", value) - .map(Some); - } - - workspace_config - .and_then(|config| config.account.as_ref()) - .and_then(|account| account.secret.as_ref()) - .and_then(|secret| secret.backend.as_deref()) - .map(|value| { - parse_account_secret_backend("workspace config [account.secret].backend", value) - }) - .transpose() -} - -fn parse_account_secret_backend( - key: &str, - value: &str, -) -> Result<RadrootsSecretBackend, RuntimeError> { - match value.trim().to_ascii_lowercase().as_str() { - "host_vault" => Ok(RadrootsSecretBackend::HostVault( - RadrootsHostVaultPolicy::desktop(), - )), - "encrypted_file" => Ok(RadrootsSecretBackend::EncryptedFile), - other => Err(RuntimeError::Config(format!( - "{key} must be `host_vault` or `encrypted_file`, got `{other}`" - ))), - } -} - -fn parse_bool_env(key: &str, value: &str) -> Result<bool, RuntimeError> { - parse_bool_value(key, value).map_err(|err| RuntimeError::Config(err.to_string())) -} - -#[cfg(test)] -mod tests { - use super::{ - AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig, - CapabilityBindingSource, CapabilityBindingTargetKind, DEFAULT_HYF_EXECUTABLE, - DEFAULT_LOG_FILTER, DEFAULT_MYC_STATUS_TIMEOUT_MS, DEFAULT_RPC_URL, EnvFileValues, - Environment, HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, InteractionConfig, OutputConfig, - OutputFormat, PathsConfig, RuntimeConfig, SignerBackend, TransportConfigSource, - TransportProfileKind, Verbosity, parse_env_file_values, - }; - use crate::cli::global::{RuntimeInvocationArgs, RuntimeOutputFormatArg}; - use radroots_runtime_paths::{RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform}; - use radroots_secret_vault::{RadrootsHostVaultPolicy, RadrootsSecretBackend}; - use std::collections::BTreeMap; - use std::fs; - use std::path::{Path, PathBuf}; - use tempfile::tempdir; - - struct MapEnvironment { - values: BTreeMap<String, String>, - current_dir: PathBuf, - path_resolver: RadrootsPathResolver, - stdin_tty: bool, - stdout_tty: bool, - } - - impl MapEnvironment { - fn new(values: BTreeMap<String, String>) -> Self { - Self { - values, - current_dir: PathBuf::from("/workspaces/radroots-cli"), - path_resolver: RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(PathBuf::from("/home/tester")), - ..RadrootsHostEnvironment::default() - }, - ), - stdin_tty: false, - stdout_tty: false, - } - } - - fn with_tty(mut self, stdin_tty: bool, stdout_tty: bool) -> Self { - self.stdin_tty = stdin_tty; - self.stdout_tty = stdout_tty; - self - } - } - - impl Environment for MapEnvironment { - fn var(&self, key: &str) -> Option<String> { - self.values.get(key).cloned() - } - - fn current_dir(&self) -> Result<PathBuf, crate::runtime::RuntimeError> { - Ok(self.current_dir.clone()) - } - - fn path_resolver(&self) -> RadrootsPathResolver { - self.path_resolver.clone() - } - - fn stdin_is_tty(&self) -> bool { - self.stdin_tty - } - - fn stdout_is_tty(&self) -> bool { - self.stdout_tty - } - } - - fn repo_local_env( - workspace_root: PathBuf, - repo_local_root: PathBuf, - user_home: PathBuf, - mut values: BTreeMap<String, String>, - ) -> MapEnvironment { - values.insert( - "RADROOTS_CLI_PATHS_PROFILE".to_owned(), - "repo_local".to_owned(), - ); - values.insert( - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), - repo_local_root.display().to_string(), - ); - - MapEnvironment { - values, - current_dir: workspace_root, - path_resolver: RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(user_home), - ..RadrootsHostEnvironment::default() - }, - ), - stdin_tty: false, - stdout_tty: false, - } - } - - fn runtime_args() -> RuntimeInvocationArgs { - RuntimeInvocationArgs::default() - } - - #[test] - fn flags_override_environment_values() { - let args = RuntimeInvocationArgs { - output_format: Some(RuntimeOutputFormatArg::Terminal), - verbose: true, - dry_run: true, - log_filter: Some("debug".to_owned()), - log_stdout: false, - identity_path: Some(PathBuf::from("custom-identity.json")), - signer: Some("local".to_owned()), - myc_executable: Some(PathBuf::from("bin/myc-cli")), - myc_status_timeout_ms: Some(2500), - hyf_enabled: true, - hyf_executable: Some(PathBuf::from("bin/hyfd-cli")), - ..runtime_args() - }; - let env = MapEnvironment::new(BTreeMap::from([ - ( - "RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), - "terminal".to_owned(), - ), - ("RADROOTS_CLI_LOGGING_FILTER".to_owned(), "trace".to_owned()), - ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "false".to_owned()), - ( - "RADROOTS_CLI_IDENTITY_PATH".to_owned(), - "env-identity.json".to_owned(), - ), - ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()), - ( - "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), - "nostr".to_owned(), - ), - ( - "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS".to_owned(), - "wss://relay.one,wss://relay.two".to_owned(), - ), - ( - "RADROOTS_CLI_MYC_EXECUTABLE".to_owned(), - "env-myc".to_owned(), - ), - ( - "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS".to_owned(), - "9000".to_owned(), - ), - ("RADROOTS_CLI_HYF_ENABLED".to_owned(), "false".to_owned()), - ( - "RADROOTS_CLI_HYF_EXECUTABLE".to_owned(), - "env-hyfd".to_owned(), - ), - ])); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve runtime config"); - assert_eq!( - resolved.output, - OutputConfig { - format: OutputFormat::Terminal, - verbosity: Verbosity::Verbose, - dry_run: true, - } - ); - assert_eq!( - resolved.interaction, - InteractionConfig { - input_enabled: true, - assume_yes: false, - stdin_tty: false, - stdout_tty: false, - prompts_allowed: false, - confirmations_allowed: false, - } - ); - assert_eq!( - resolved.paths, - PathsConfig { - profile: "interactive_user".to_owned(), - profile_source: "default".to_owned(), - allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned(),], - root_source: "host_defaults".to_owned(), - repo_local_root: None, - repo_local_root_source: None, - subordinate_path_override_source: "runtime_config".to_owned(), - app_namespace: "apps/cli".to_owned(), - shared_accounts_namespace: "shared/accounts".to_owned(), - shared_identities_namespace: "shared/identities".to_owned(), - app_config_path: PathBuf::from( - "/home/tester/.radroots/config/apps/cli/config.toml" - ), - workspace_config_path: None, - app_data_root: PathBuf::from("/home/tester/.radroots/data/apps/cli"), - shared_cache_root: PathBuf::from("/home/tester/.radroots/cache"), - app_logs_root: PathBuf::from("/home/tester/.radroots/logs/apps/cli"), - shared_accounts_data_root: PathBuf::from( - "/home/tester/.radroots/data/shared/accounts" - ), - shared_accounts_secrets_root: PathBuf::from( - "/home/tester/.radroots/secrets/shared/accounts" - ), - default_identity_path: PathBuf::from( - "/home/tester/.radroots/secrets/shared/identities/default.json" - ), - } - ); - assert_eq!(resolved.logging.filter, "debug"); - assert!(!resolved.logging.stdout); - assert_eq!( - resolved.identity.path, - PathBuf::from("custom-identity.json") - ); - assert_eq!( - resolved.account, - AccountConfig { - selector: None, - store_path: PathBuf::from("/home/tester/.radroots/data/shared/accounts/store.json"), - secrets_dir: PathBuf::from("/home/tester/.radroots/secrets/shared/accounts"), - secret_backend: RadrootsSecretBackend::HostVault( - RadrootsHostVaultPolicy::desktop(), - ), - } - ); - assert_eq!( - resolved.account_secret_contract, - AccountSecretContractConfig { - default_backend: "host_vault".to_owned(), - allowed_backends: vec!["host_vault".to_owned(), "encrypted_file".to_owned(),], - host_vault_policy: Some("desktop".to_owned()), - uses_protected_store: true, - } - ); - assert_eq!(resolved.signer.backend, SignerBackend::Local); - assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); - assert_eq!( - resolved.transport.source, - TransportConfigSource::Environment - ); - assert_eq!( - resolved.transport.nostr_relay_urls, - vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] - ); - assert_eq!( - resolved.transport.source, - TransportConfigSource::Environment - ); - assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc-cli")); - assert_eq!(resolved.myc.status_timeout_ms, 2500); - assert_eq!( - resolved.hyf, - HyfConfig { - enabled: true, - executable: PathBuf::from("bin/hyfd-cli"), - } - ); - } - - #[test] - fn environment_values_fill_missing_flags() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([ - ("RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), "json".to_owned()), - ( - "RADROOTS_CLI_LOGGING_FILTER".to_owned(), - "debug,cli=trace".to_owned(), - ), - ( - "RADROOTS_CLI_LOGGING_OUTPUT_DIR".to_owned(), - "logs/runtime".to_owned(), - ), - ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "false".to_owned()), - ( - "RADROOTS_CLI_ACCOUNT_SELECTOR".to_owned(), - "acct_demo".to_owned(), - ), - ( - "RADROOTS_CLI_IDENTITY_PATH".to_owned(), - "state/identity.json".to_owned(), - ), - ("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned()), - ( - "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), - "nostr".to_owned(), - ), - ( - "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS".to_owned(), - "wss://relay.one,wss://relay.two".to_owned(), - ), - ( - "RADROOTS_CLI_MYC_EXECUTABLE".to_owned(), - "bin/myc".to_owned(), - ), - ( - "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS".to_owned(), - "3500".to_owned(), - ), - ("RADROOTS_CLI_HYF_ENABLED".to_owned(), "true".to_owned()), - ( - "RADROOTS_CLI_HYF_EXECUTABLE".to_owned(), - "bin/hyfd".to_owned(), - ), - ])); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve runtime config"); - assert_eq!( - resolved.output, - OutputConfig { - format: OutputFormat::Json, - verbosity: Verbosity::Normal, - dry_run: false, - } - ); - assert_eq!( - resolved.interaction, - InteractionConfig { - input_enabled: true, - assume_yes: false, - stdin_tty: false, - stdout_tty: false, - prompts_allowed: false, - confirmations_allowed: false, - } - ); - assert_eq!(resolved.logging.filter, "debug,cli=trace"); - assert_eq!( - resolved.logging.directory, - Some(PathBuf::from("logs/runtime")) - ); - assert!(!resolved.logging.stdout); - assert_eq!(resolved.account.selector.as_deref(), Some("acct_demo")); - assert_eq!( - resolved.account.secret_backend, - RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()) - ); - assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); - assert_eq!(resolved.signer.backend, SignerBackend::Myc); - assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); - assert_eq!( - resolved.transport.source, - TransportConfigSource::Environment - ); - assert_eq!( - resolved.transport.nostr_relay_urls, - vec!["wss://relay.one".to_owned(), "wss://relay.two".to_owned()] - ); - assert_eq!( - resolved.transport.source, - TransportConfigSource::Environment - ); - assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); - assert_eq!(resolved.myc.status_timeout_ms, 3500); - assert_eq!( - resolved.hyf, - HyfConfig { - enabled: true, - executable: PathBuf::from("bin/hyfd"), - } - ); - } - - #[test] - fn old_process_environment_names_have_no_effect() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([ - ("RADROOTS_OUTPUT".to_owned(), "json".to_owned()), - ("RADROOTS_LOG_FILTER".to_owned(), "trace".to_owned()), - ("RADROOTS_LOG_DIR".to_owned(), "logs/old".to_owned()), - ("RADROOTS_LOG_STDOUT".to_owned(), "true".to_owned()), - ("RADROOTS_ACCOUNT".to_owned(), "old_account".to_owned()), - ( - "RADROOTS_ACCOUNT_SECRET_BACKEND".to_owned(), - "encrypted_file".to_owned(), - ), - ( - "RADROOTS_IDENTITY_PATH".to_owned(), - "old-identity.json".to_owned(), - ), - ("RADROOTS_SIGNER".to_owned(), "myc".to_owned()), - ("RADROOTS_PUBLISH_MODE".to_owned(), "legacy".to_owned()), - ( - "RADROOTS_RELAYS".to_owned(), - "wss://old-relay.example".to_owned(), - ), - ("RADROOTS_MYC_EXECUTABLE".to_owned(), "old-myc".to_owned()), - ( - "RADROOTS_MYC_STATUS_TIMEOUT_MS".to_owned(), - "9999".to_owned(), - ), - ("RADROOTS_HYF_ENABLED".to_owned(), "true".to_owned()), - ("RADROOTS_HYF_EXECUTABLE".to_owned(), "old-hyfd".to_owned()), - ( - "RADROOTS_RPC_URL".to_owned(), - "http://127.0.0.1:9".to_owned(), - ), - ( - "RADROOTS_RPC_BEARER_TOKEN".to_owned(), - "old-token".to_owned(), - ), - ( - "RADROOTS_TRUSTED_RHI_WORKER_PUBKEYS".to_owned(), - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef".to_owned(), - ), - ])); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve runtime config"); - - assert_eq!(resolved.output.format, OutputFormat::Terminal); - assert_eq!(resolved.logging.filter, DEFAULT_LOG_FILTER); - assert!(!resolved.logging.stdout); - assert_eq!(resolved.account.selector, None); - assert_eq!( - resolved.account.secret_backend, - RadrootsSecretBackend::HostVault(RadrootsHostVaultPolicy::desktop()) - ); - assert_eq!(resolved.signer.backend, SignerBackend::Local); - assert_eq!(resolved.transport.profile, TransportProfileKind::LocalOnly); - assert_eq!(resolved.transport.nostr_relay_urls, Vec::<String>::new()); - assert_eq!(resolved.myc.executable, PathBuf::from("myc")); - assert_eq!( - resolved.myc.status_timeout_ms, - DEFAULT_MYC_STATUS_TIMEOUT_MS - ); - assert!(!resolved.hyf.enabled); - assert_eq!( - resolved.hyf.executable, - PathBuf::from(DEFAULT_HYF_EXECUTABLE) - ); - assert_eq!(resolved.rpc.url, DEFAULT_RPC_URL); - assert_eq!(resolved.rhi.validator_set, None); - assert!(!resolved.rhi.require_cryptographic_proof); - } - - #[test] - fn toml_output_logging_account_and_identity_config_resolve() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let app_config_dir = repo_local_root.join("config/apps/cli"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&app_config_dir).expect("app config dir"); - fs::write( - app_config_dir.join("config.toml"), - r#" -[output] -format = "json" - -[logging] -filter = "debug,cli=trace" -output_dir = "logs/from-toml" -stdout = false - -[account] -selector = "acct_from_toml" - -[account.secret] -backend = "encrypted_file" - -[identity] -path = "identity/from-toml.json" -"#, - ) - .expect("write user config"); - - let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); - let resolved = - RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve toml config"); - - assert_eq!(resolved.output.format, OutputFormat::Json); - assert_eq!(resolved.logging.filter, "debug,cli=trace"); - assert_eq!( - resolved.logging.directory, - Some(PathBuf::from("logs/from-toml")) - ); - assert!(!resolved.logging.stdout); - assert_eq!(resolved.account.selector.as_deref(), Some("acct_from_toml")); - assert_eq!( - resolved.account.secret_backend, - RadrootsSecretBackend::EncryptedFile - ); - assert_eq!( - resolved.identity.path, - PathBuf::from("identity/from-toml.json") - ); - } - - #[test] - fn conflicting_boolean_flags_fail() { - let args = RuntimeInvocationArgs { - log_stdout: true, - no_log_stdout: true, - ..runtime_args() - }; - let env = MapEnvironment::new(BTreeMap::new()); - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("conflicting flags"); - assert!(error.to_string().contains("cannot be used together")); - - let hyf_args = RuntimeInvocationArgs { - hyf_enabled: true, - no_hyf_enabled: true, - ..runtime_args() - }; - let error = - RuntimeConfig::resolve_with_env_file(&hyf_args, &env, &EnvFileValues::default()) - .expect_err("conflicting hyf flags"); - assert!(error.to_string().contains("--hyf-enabled")); - } - - #[test] - fn conflicting_output_and_verbosity_flags_fail() { - let env = MapEnvironment::new(BTreeMap::new()); - - let conflicting_output = RuntimeInvocationArgs { - json: true, - ndjson: true, - ..runtime_args() - }; - let error = RuntimeConfig::resolve_with_env_file( - &conflicting_output, - &env, - &EnvFileValues::default(), - ) - .expect_err("conflicting output flags"); - assert!(error.to_string().contains("--json and --ndjson")); - - let conflicting_verbosity = RuntimeInvocationArgs { - quiet: true, - trace: true, - ..runtime_args() - }; - let error = RuntimeConfig::resolve_with_env_file( - &conflicting_verbosity, - &env, - &EnvFileValues::default(), - ) - .expect_err("conflicting verbosity flags"); - assert!( - error - .to_string() - .contains("--quiet, --verbose, and --trace") - ); - - let conflicting_aliases = RuntimeInvocationArgs { - output_format: Some(RuntimeOutputFormatArg::Json), - json: true, - ..runtime_args() - }; - let error = RuntimeConfig::resolve_with_env_file( - &conflicting_aliases, - &env, - &EnvFileValues::default(), - ) - .expect_err("conflicting output aliases"); - assert!(error.to_string().contains("--output, --json, and --ndjson")); - } - - #[test] - fn output_rejects_stdout_logging_flags() { - let env = MapEnvironment::new(BTreeMap::new()); - - let terminal_args = RuntimeInvocationArgs { - output_format: Some(RuntimeOutputFormatArg::Terminal), - log_stdout: true, - ..runtime_args() - }; - let error = - RuntimeConfig::resolve_with_env_file(&terminal_args, &env, &EnvFileValues::default()) - .expect_err("terminal stdout logging should fail"); - let message = error.to_string(); - assert!(message.contains("stdout logging")); - assert!(message.contains("terminal output")); - assert!(message.contains("--no-log-stdout")); - - let json_args = RuntimeInvocationArgs { - json: true, - log_stdout: true, - ..runtime_args() - }; - let error = - RuntimeConfig::resolve_with_env_file(&json_args, &env, &EnvFileValues::default()) - .expect_err("json stdout logging should fail"); - let message = error.to_string(); - assert!(message.contains("stdout logging")); - assert!(message.contains("json output")); - assert!(message.contains("--no-log-stdout")); - - let ndjson_args = RuntimeInvocationArgs { - ndjson: true, - log_stdout: true, - ..runtime_args() - }; - let error = - RuntimeConfig::resolve_with_env_file(&ndjson_args, &env, &EnvFileValues::default()) - .expect_err("ndjson stdout logging should fail"); - let message = error.to_string(); - assert!(message.contains("stdout logging")); - assert!(message.contains("ndjson output")); - } - - #[test] - fn output_rejects_stdout_logging_environment() { - let terminal_args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_LOGGING_STDOUT".to_owned(), - "true".to_owned(), - )])); - let error = - RuntimeConfig::resolve_with_env_file(&terminal_args, &env, &EnvFileValues::default()) - .expect_err("terminal stdout logging from env should fail"); - assert!(error.to_string().contains("terminal output")); - - let json_args = RuntimeInvocationArgs { - json: true, - ..runtime_args() - }; - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_LOGGING_STDOUT".to_owned(), - "true".to_owned(), - )])); - let error = - RuntimeConfig::resolve_with_env_file(&json_args, &env, &EnvFileValues::default()) - .expect_err("json stdout logging from env should fail"); - let message = error.to_string(); - assert!(message.contains("RADROOTS_CLI_LOGGING_STDOUT")); - assert!(message.contains("RADROOTS_CLI_LOGGING_STDOUT")); - - let ndjson_env_args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([ - ("RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), "ndjson".to_owned()), - ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "true".to_owned()), - ])); - let error = - RuntimeConfig::resolve_with_env_file(&ndjson_env_args, &env, &EnvFileValues::default()) - .expect_err("ndjson stdout logging from env should fail"); - assert!(error.to_string().contains("ndjson output")); - } - - #[test] - fn no_log_stdout_overrides_environment_for_output() { - let args = RuntimeInvocationArgs { - json: true, - no_log_stdout: true, - ..runtime_args() - }; - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_LOGGING_STDOUT".to_owned(), - "true".to_owned(), - )])); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve output with stdout logging disabled"); - assert_eq!(resolved.output.format, OutputFormat::Json); - assert!(!resolved.logging.stdout); - } - - #[test] - fn invalid_environment_value_fails() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_LOGGING_STDOUT".to_owned(), - "maybe".to_owned(), - )])); - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("invalid bool"); - assert!(error.to_string().contains("RADROOTS_CLI_LOGGING_STDOUT")); - - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS".to_owned(), - "slow".to_owned(), - )])); - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("invalid myc timeout"); - assert!( - error - .to_string() - .contains("RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS") - ); - - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), - "relay".to_owned(), - )])); - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("invalid transport profile"); - assert!(error.to_string().contains("RADROOTS_CLI_TRANSPORT_PROFILE")); - assert!(error.to_string().contains("local_only")); - assert!(error.to_string().contains("reticulum")); - - let args = RuntimeInvocationArgs { - myc_status_timeout_ms: Some(0), - ..runtime_args() - }; - let env = MapEnvironment::new(BTreeMap::new()); - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("zero myc timeout"); - assert!(error.to_string().contains("greater than zero")); - } - - #[test] - fn env_file_values_fill_missing_flags() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::new()); - let env_file = parse_env_file_values( - r#" -RADROOTS_CLI_OUTPUT_FORMAT=json -RADROOTS_CLI_LOGGING_FILTER="debug,radroots_cli=trace" -RADROOTS_CLI_LOGGING_OUTPUT_DIR=/tmp/radroots-cli-logs -RADROOTS_CLI_LOGGING_STDOUT=false -RADROOTS_CLI_ACCOUNT_SELECTOR=acct_env_file -RADROOTS_CLI_IDENTITY_PATH=state/identity.json -RADROOTS_CLI_SIGNER_BACKEND=myc -RADROOTS_CLI_TRANSPORT_PROFILE=nostr -RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS=wss://relay.env-file -RADROOTS_CLI_MYC_EXECUTABLE=bin/myc -RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS=4500 -RADROOTS_CLI_HYF_ENABLED=true -RADROOTS_CLI_HYF_EXECUTABLE=bin/hyfd -"#, - Path::new(".env.test"), - ) - .expect("parse env file"); - - let resolved = - RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); - assert_eq!(resolved.output.format, OutputFormat::Json); - assert_eq!(resolved.logging.filter, "debug,radroots_cli=trace"); - assert_eq!( - resolved.logging.directory, - Some(PathBuf::from("/tmp/radroots-cli-logs")) - ); - assert!(!resolved.logging.stdout); - assert_eq!(resolved.account.selector.as_deref(), Some("acct_env_file")); - assert_eq!(resolved.identity.path, PathBuf::from("state/identity.json")); - assert_eq!(resolved.signer.backend, SignerBackend::Myc); - assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); - assert_eq!( - resolved.transport.source, - TransportConfigSource::Environment - ); - assert_eq!( - resolved.transport.nostr_relay_urls, - vec!["wss://relay.env-file".to_owned()] - ); - assert_eq!( - resolved.transport.source, - TransportConfigSource::Environment - ); - assert_eq!(resolved.myc.executable, PathBuf::from("bin/myc")); - assert_eq!(resolved.myc.status_timeout_ms, 4500); - assert_eq!( - resolved.hyf, - HyfConfig { - enabled: true, - executable: PathBuf::from("bin/hyfd"), - } - ); - } - - #[test] - fn explicit_output_flag_overrides_environment_output() { - let args = RuntimeInvocationArgs { - output_format: Some(RuntimeOutputFormatArg::Ndjson), - ..runtime_args() - }; - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), - "json".to_owned(), - )])); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve runtime config"); - assert_eq!(resolved.output.format, OutputFormat::Ndjson); - } - - #[test] - fn interaction_config_reflects_tty_and_flags() { - let args = RuntimeInvocationArgs { - no_input: true, - yes: true, - ..runtime_args() - }; - let env = MapEnvironment::new(BTreeMap::new()).with_tty(true, true); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve runtime config"); - assert_eq!( - resolved.interaction, - InteractionConfig { - input_enabled: false, - assume_yes: true, - stdin_tty: true, - stdout_tty: true, - prompts_allowed: false, - confirmations_allowed: false, - } - ); - - let interactive_args = runtime_args(); - let interactive = RuntimeConfig::resolve_with_env_file( - &interactive_args, - &env, - &EnvFileValues::default(), - ) - .expect("resolve interactive runtime config"); - assert_eq!( - interactive.interaction, - InteractionConfig { - input_enabled: true, - assume_yes: false, - stdin_tty: true, - stdout_tty: true, - prompts_allowed: true, - confirmations_allowed: true, - } - ); - } - - #[test] - fn process_environment_overrides_env_file_values() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([ - ("RADROOTS_CLI_LOGGING_FILTER".to_owned(), "info".to_owned()), - ("RADROOTS_CLI_LOGGING_STDOUT".to_owned(), "false".to_owned()), - ])); - let env_file = parse_env_file_values( - r#" -RADROOTS_CLI_LOGGING_FILTER=debug -RADROOTS_CLI_LOGGING_STDOUT=true -"#, - Path::new(".env.test"), - ) - .expect("parse env file"); - - let resolved = - RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); - assert_eq!(resolved.output.format, OutputFormat::Terminal); - assert_eq!(resolved.logging.filter, "info"); - assert!(!resolved.logging.stdout); - } - - #[test] - fn user_transport_config_overrides_workspace_transport_config() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let app_config_dir = repo_local_root.join("config/apps/cli"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - fs::create_dir_all(&app_config_dir).expect("app config dir"); - fs::write( - repo_local_root.join("config.toml"), - "[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = [\"wss://relay.workspace\"]\n", - ) - .expect("write workspace config"); - fs::write( - app_config_dir.join("config.toml"), - "[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = [\"wss://relay.user\", \"wss://relay.workspace\"]\n", - ) - .expect("write user config"); - - let env = MapEnvironment { - values: BTreeMap::from([ - ( - "RADROOTS_CLI_PATHS_PROFILE".to_owned(), - "repo_local".to_owned(), - ), - ( - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), - repo_local_root.display().to_string(), - ), - ]), - current_dir: workspace_root, - path_resolver: RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(user_home), - ..RadrootsHostEnvironment::default() - }, - ), - stdin_tty: false, - stdout_tty: false, - }; - let args = runtime_args(); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve config"); - assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); - assert_eq!(resolved.transport.source, TransportConfigSource::UserConfig); - assert_eq!( - resolved.transport.nostr_relay_urls, - vec![ - "wss://relay.user".to_owned(), - "wss://relay.workspace".to_owned() - ] - ); - assert_eq!(resolved.transport.source, TransportConfigSource::UserConfig); - } - - #[test] - fn multi_target_transport_config_uses_nostr_targets_and_reticulum_behavior() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let app_config_dir = repo_local_root.join("config/apps/cli"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - fs::create_dir_all(&app_config_dir).expect("app config dir"); - fs::write( - app_config_dir.join("config.toml"), - "[transport]\nprofile = \"multi_target\"\n\n[transport.nostr]\nrelay_urls = [\"wss://relay.user\", \"wss://relay.backup\"]\n\n[transport.reticulum]\nbehavior = \"defer_delivery_plans\"\nscope = \"farmers_market\"\nagent_endpoint = \"reticulum-agent:local\"\n", - ) - .expect("write user config"); - - let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); - let resolved = - RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve multi-target transport profile"); - - assert_eq!( - resolved.transport.profile, - TransportProfileKind::MultiTarget - ); - assert_eq!(resolved.transport.source, TransportConfigSource::UserConfig); - assert_eq!( - resolved.transport.nostr_relay_urls, - vec![ - "wss://relay.user".to_owned(), - "wss://relay.backup".to_owned() - ] - ); - assert_eq!( - resolved.transport.reticulum_behavior, - super::ReticulumBehavior::DeferDeliveryPlans - ); - assert_eq!(resolved.transport.reticulum_scope, "farmers_market"); - assert_eq!( - resolved.transport.reticulum_agent_endpoint.as_deref(), - Some("reticulum-agent:local") - ); - } - - #[test] - fn transport_profile_precedence_tracks_source() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let app_config_dir = repo_local_root.join("config/apps/cli"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - fs::create_dir_all(&app_config_dir).expect("app config dir"); - fs::write( - repo_local_root.join("config.toml"), - "[transport]\nprofile = \"reticulum\"\n", - ) - .expect("write workspace config"); - fs::write( - app_config_dir.join("config.toml"), - "[transport]\nprofile = \"nostr\"\n\n[transport.nostr]\nrelay_urls = [\"wss://relay.user\"]\n", - ) - .expect("write user config"); - - let env = repo_local_env( - workspace_root.clone(), - repo_local_root.clone(), - user_home.clone(), - BTreeMap::from([( - "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), - "reticulum".to_owned(), - )]), - ); - let resolved = - RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve environment transport profile"); - assert_eq!(resolved.transport.profile, TransportProfileKind::Reticulum); - assert_eq!( - resolved.transport.source, - TransportConfigSource::Environment - ); - let env = repo_local_env( - workspace_root.clone(), - repo_local_root.clone(), - user_home.clone(), - BTreeMap::new(), - ); - let resolved = - RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve user transport profile"); - assert_eq!(resolved.transport.profile, TransportProfileKind::Nostr); - assert_eq!(resolved.transport.source, TransportConfigSource::UserConfig); - fs::remove_file(app_config_dir.join("config.toml")).expect("remove user config"); - let env = repo_local_env( - workspace_root.clone(), - repo_local_root.clone(), - user_home.clone(), - BTreeMap::new(), - ); - let resolved = - RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve workspace transport profile"); - assert_eq!(resolved.transport.profile, TransportProfileKind::Reticulum); - assert_eq!( - resolved.transport.source, - TransportConfigSource::WorkspaceConfig - ); - fs::remove_file(repo_local_root.join("config.toml")).expect("remove workspace config"); - let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); - let resolved = - RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect("resolve default transport profile"); - assert_eq!(resolved.transport.profile, TransportProfileKind::LocalOnly); - assert_eq!(resolved.transport.source, TransportConfigSource::Defaults); - } - - #[test] - fn user_hyf_config_overrides_workspace_hyf_config() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let app_config_dir = repo_local_root.join("config/apps/cli"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - fs::create_dir_all(&app_config_dir).expect("app config dir"); - fs::write( - repo_local_root.join("config.toml"), - "[hyf]\nenabled = false\nexecutable = \"workspace-hyfd\"\n", - ) - .expect("write workspace config"); - fs::write( - app_config_dir.join("config.toml"), - "[hyf]\nenabled = true\nexecutable = \"user-hyfd\"\n", - ) - .expect("write user config"); - - let env = MapEnvironment { - values: BTreeMap::from([ - ( - "RADROOTS_CLI_PATHS_PROFILE".to_owned(), - "repo_local".to_owned(), - ), - ( - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), - repo_local_root.display().to_string(), - ), - ]), - current_dir: workspace_root, - path_resolver: RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(user_home), - ..RadrootsHostEnvironment::default() - }, - ), - stdin_tty: false, - stdout_tty: false, - }; - let args = runtime_args(); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve config"); - assert_eq!( - resolved.hyf, - HyfConfig { - enabled: true, - executable: PathBuf::from("user-hyfd"), - } - ); - } - - #[test] - fn user_myc_config_overrides_workspace_myc_config() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let app_config_dir = repo_local_root.join("config/apps/cli"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - fs::create_dir_all(&app_config_dir).expect("app config dir"); - fs::write( - repo_local_root.join("config.toml"), - "[myc]\nexecutable = \"workspace-myc\"\nstatus_timeout_ms = 9000\n", - ) - .expect("write workspace config"); - fs::write( - app_config_dir.join("config.toml"), - "[myc]\nexecutable = \"user-myc\"\nstatus_timeout_ms = 3000\n", - ) - .expect("write user config"); - - let env = MapEnvironment { - values: BTreeMap::from([ - ( - "RADROOTS_CLI_PATHS_PROFILE".to_owned(), - "repo_local".to_owned(), - ), - ( - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), - repo_local_root.display().to_string(), - ), - ]), - current_dir: workspace_root, - path_resolver: RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(user_home), - ..RadrootsHostEnvironment::default() - }, - ), - stdin_tty: false, - stdout_tty: false, - }; - let args = runtime_args(); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve config"); - assert_eq!(resolved.myc.executable, PathBuf::from("user-myc")); - assert_eq!(resolved.myc.status_timeout_ms, 3000); - } - - #[test] - fn user_signer_config_overrides_workspace_signer_config() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let app_config_dir = repo_local_root.join("config/apps/cli"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&app_config_dir).expect("app config dir"); - fs::write( - repo_local_root.join("config.toml"), - "[signer]\nbackend = \"myc\"\n", - ) - .expect("write workspace config"); - fs::write( - app_config_dir.join("config.toml"), - "[signer]\nbackend = \"local\"\n", - ) - .expect("write user config"); - - let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); - let args = runtime_args(); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve config"); - assert_eq!(resolved.signer.backend, SignerBackend::Local); - } - - #[test] - fn environment_signer_overrides_user_signer_config() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let app_config_dir = repo_local_root.join("config/apps/cli"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&app_config_dir).expect("app config dir"); - fs::write( - app_config_dir.join("config.toml"), - "[signer]\nbackend = \"local\"\n", - ) - .expect("write user config"); - - let env = repo_local_env( - workspace_root, - repo_local_root, - user_home, - BTreeMap::from([("RADROOTS_CLI_SIGNER_BACKEND".to_owned(), "myc".to_owned())]), - ); - let args = runtime_args(); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve config"); - assert_eq!(resolved.signer.backend, SignerBackend::Myc); - } - - #[test] - fn invalid_signer_config_reports_config_source() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - fs::write( - repo_local_root.join("config.toml"), - "[signer]\nbackend = \"remote\"\n", - ) - .expect("write workspace config"); - - let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); - let args = runtime_args(); - - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("invalid signer mode"); - let message = error.to_string(); - assert!(message.contains("workspace config [signer].backend")); - assert!(!message.contains("--signer")); - } - - #[test] - fn invalid_transport_config_reports_config_source() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - fs::write( - repo_local_root.join("config.toml"), - "[transport]\nprofile = \"relay\"\n", - ) - .expect("write workspace config"); - - let env = repo_local_env(workspace_root, repo_local_root, user_home, BTreeMap::new()); - let error = - RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect_err("invalid transport profile"); - let message = error.to_string(); - assert!(message.contains("workspace config [transport].profile")); - assert!(message.contains("local_only")); - assert!(message.contains("reticulum")); - } - - #[test] - fn user_capability_binding_overrides_workspace_binding() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let app_config_dir = repo_local_root.join("config/apps/cli"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - fs::create_dir_all(&app_config_dir).expect("app config dir"); - fs::write( - repo_local_root.join("config.toml"), - r#" -[[capability_binding]] -capability = "inference.hyf_stdio" -provider = "hyf" -target_kind = "managed_instance" -target = "workspace-hyf" -"#, - ) - .expect("write workspace config"); - fs::write( - app_config_dir.join("config.toml"), - r#" -[[capability_binding]] -capability = "inference.hyf_stdio" -provider = "hyf" -target_kind = "explicit_endpoint" -target = "bin/user-hyfd" -"#, - ) - .expect("write user config"); - - let env = MapEnvironment { - values: BTreeMap::from([ - ( - "RADROOTS_CLI_PATHS_PROFILE".to_owned(), - "repo_local".to_owned(), - ), - ( - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), - repo_local_root.display().to_string(), - ), - ]), - current_dir: workspace_root, - path_resolver: RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(user_home), - ..RadrootsHostEnvironment::default() - }, - ), - stdin_tty: false, - stdout_tty: false, - }; - let args = runtime_args(); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve config"); - assert_eq!(resolved.capability_bindings.len(), 1); - assert_eq!( - resolved.capability_bindings[0], - CapabilityBindingConfig { - capability_id: INFERENCE_HYF_STDIO_CAPABILITY.to_owned(), - provider_runtime_id: "hyf".to_owned(), - binding_model: "stdio_service".to_owned(), - target_kind: CapabilityBindingTargetKind::ExplicitEndpoint, - target: "bin/user-hyfd".to_owned(), - managed_account_ref: None, - signer_session_ref: None, - source: CapabilityBindingSource::UserConfig, - } - ); - } - - #[test] - fn daemon_and_workflow_capability_bindings_are_rejected() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - fs::create_dir_all(user_home.join(".radroots/config/apps/cli")).expect("app config dir"); - fs::write( - repo_local_root.join("config.toml"), - r#" -[[capability_binding]] -capability = "write_plane.trade_jsonrpc" - provider = "legacy_provider" -target_kind = "explicit_endpoint" -target = "https://rpc.workspace.test/jsonrpc" -"#, - ) - .expect("write workspace config"); - - let env = MapEnvironment { - values: BTreeMap::from([ - ( - "RADROOTS_CLI_PATHS_PROFILE".to_owned(), - "repo_local".to_owned(), - ), - ( - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), - repo_local_root.display().to_string(), - ), - ]), - current_dir: workspace_root, - path_resolver: RadrootsPathResolver::new( - RadrootsPlatform::Linux, - RadrootsHostEnvironment { - home_dir: Some(user_home), - ..RadrootsHostEnvironment::default() - }, - ), - stdin_tty: false, - stdout_tty: false, - }; - let args = runtime_args(); - - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("rejected daemon capability binding"); - assert!( - error - .to_string() - .contains("unknown capability_binding capability `write_plane.trade_jsonrpc`") - ); - - fs::write( - repo_local_root.join("config.toml"), - r#" -[[capability_binding]] -capability = "workflow.trade" -provider = "rhi" -target_kind = "managed_instance" -target = "workflow-default" -"#, - ) - .expect("write workflow config"); - - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("rejected workflow capability binding"); - assert!( - error - .to_string() - .contains("unknown capability_binding capability `workflow.trade`") - ); - } - - #[test] - fn invalid_relay_url_fails() { - for relay in [ - "https://not-a-websocket.example.com", - "wss://", - "wss://user@relay.example", - "wss://relay.example:abc", - " ", - ] { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([ - ( - "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), - "nostr".to_owned(), - ), - ( - "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS".to_owned(), - relay.to_owned(), - ), - ])); - let error = - RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("invalid relay url"); - assert!( - error.to_string().contains("relay url") - || error.to_string().contains("websocket relay urls"), - "unexpected error for {relay}: {error}" - ); - } - } - - #[test] - fn relay_env_value_rejects_empty_entries() { - let env = MapEnvironment::new(BTreeMap::from([ - ( - "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), - "nostr".to_owned(), - ), - ( - super::ENV_CLI_TRANSPORT_NOSTR_RELAY_URLS.to_owned(), - "wss://relay.example,,wss://relay-two.example".to_owned(), - ), - ])); - let error = - RuntimeConfig::resolve_with_env_file(&runtime_args(), &env, &EnvFileValues::default()) - .expect_err("empty relay entry"); - - assert!(error.to_string().contains("empty relay url")); - } - - #[test] - fn valid_ipv6_relay_url_resolves() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([ - ( - "RADROOTS_CLI_TRANSPORT_PROFILE".to_owned(), - "nostr".to_owned(), - ), - ( - "RADROOTS_CLI_TRANSPORT_NOSTR_RELAY_URLS".to_owned(), - " ws://[::1]:443/relay ".to_owned(), - ), - ])); - let config = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("valid relay url"); - - assert_eq!( - config.transport.nostr_relay_urls, - vec!["ws://[::1]:443/relay"] - ); - } - - #[test] - fn state_roots_are_resolved_from_home_and_workspace() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::new()); - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve runtime config"); - - assert_eq!( - resolved.paths.app_config_path, - PathBuf::from("/home/tester/.radroots/config/apps/cli/config.toml") - ); - assert_eq!(resolved.paths.profile_source, "default"); - assert_eq!(resolved.paths.root_source, "host_defaults"); - assert_eq!(resolved.paths.repo_local_root, None); - assert_eq!(resolved.paths.repo_local_root_source, None); - assert_eq!( - resolved.paths.subordinate_path_override_source, - "runtime_config" - ); - assert_eq!(resolved.paths.app_namespace, "apps/cli"); - assert_eq!(resolved.paths.shared_accounts_namespace, "shared/accounts"); - assert_eq!( - resolved.paths.shared_identities_namespace, - "shared/identities" - ); - assert_eq!(resolved.paths.workspace_config_path, None); - assert_eq!( - resolved.paths.app_data_root, - PathBuf::from("/home/tester/.radroots/data/apps/cli") - ); - assert_eq!( - resolved.paths.allowed_profiles, - vec!["interactive_user".to_owned(), "repo_local".to_owned(),] - ); - } - - #[test] - fn windows_roots_use_native_user_directories() { - let args = runtime_args(); - let env = MapEnvironment { - values: BTreeMap::new(), - current_dir: PathBuf::from(r"C:\workspaces\radroots-cli"), - path_resolver: RadrootsPathResolver::new( - RadrootsPlatform::Windows, - RadrootsHostEnvironment { - appdata_dir: Some(PathBuf::from(r"C:\Users\tester\AppData\Roaming")), - localappdata_dir: Some(PathBuf::from(r"C:\Users\tester\AppData\Local")), - ..RadrootsHostEnvironment::default() - }, - ), - stdin_tty: false, - stdout_tty: false, - }; - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve runtime config"); - - assert_eq!( - resolved.paths.app_config_path, - PathBuf::from(r"C:\Users\tester\AppData\Roaming") - .join("Radroots") - .join("config") - .join("apps") - .join("cli") - .join("config.toml") - ); - assert_eq!( - resolved.paths.app_data_root, - PathBuf::from(r"C:\Users\tester\AppData\Local") - .join("Radroots") - .join("data") - .join("apps") - .join("cli") - ); - assert_eq!( - resolved.paths.shared_accounts_data_root, - PathBuf::from(r"C:\Users\tester\AppData\Local") - .join("Radroots") - .join("data") - .join("shared") - .join("accounts") - ); - assert_eq!( - resolved.paths.default_identity_path, - PathBuf::from(r"C:\Users\tester\AppData\Roaming") - .join("Radroots") - .join("secrets") - .join("shared") - .join("identities") - .join("default.json") - ); - } - - #[test] - fn repo_local_profile_uses_explicit_repo_local_root() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([ - ( - "RADROOTS_CLI_PATHS_PROFILE".to_owned(), - "repo_local".to_owned(), - ), - ( - "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned(), - ".local/radroots/dev".to_owned(), - ), - ])); - - let resolved = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve runtime config"); - - assert_eq!(resolved.paths.profile, "repo_local"); - assert_eq!( - resolved.paths.profile_source, - "process_env:RADROOTS_CLI_PATHS_PROFILE" - ); - assert_eq!(resolved.paths.root_source, "repo_local_root"); - assert_eq!( - resolved.paths.repo_local_root, - Some(PathBuf::from( - "/workspaces/radroots-cli/.local/radroots/dev" - )) - ); - assert_eq!( - resolved.paths.repo_local_root_source, - Some("process_env:RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT".to_owned()) - ); - assert_eq!( - resolved.paths.app_config_path, - PathBuf::from( - "/workspaces/radroots-cli/.local/radroots/dev/config/apps/cli/config.toml" - ) - ); - assert_eq!( - resolved.paths.workspace_config_path, - Some(PathBuf::from( - "/workspaces/radroots-cli/.local/radroots/dev/config.toml" - )) - ); - assert_eq!( - resolved.paths.app_data_root, - PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/apps/cli") - ); - assert_eq!( - resolved.paths.app_logs_root, - PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/logs/apps/cli") - ); - assert_eq!( - resolved.paths.shared_accounts_data_root, - PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/shared/accounts") - ); - assert_eq!( - resolved.paths.default_identity_path, - PathBuf::from( - "/workspaces/radroots-cli/.local/radroots/dev/secrets/shared/identities/default.json" - ) - ); - } - - #[test] - fn repo_local_profile_requires_explicit_root() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_PATHS_PROFILE".to_owned(), - "repo_local".to_owned(), - )])); - - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("repo_local should require an explicit root"); - assert!( - error - .to_string() - .contains("RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT") - ); - } - - #[test] - fn env_file_can_select_repo_local_profile() { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::new()); - let env_file = parse_env_file_values( - r#" -RADROOTS_CLI_PATHS_PROFILE=repo_local -RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT=.local/radroots/dev -"#, - Path::new(".env.test"), - ) - .expect("parse env file"); - - let resolved = - RuntimeConfig::resolve_with_env_file(&args, &env, &env_file).expect("resolve config"); - assert_eq!(resolved.paths.profile, "repo_local"); - assert_eq!( - resolved.paths.app_data_root, - PathBuf::from("/workspaces/radroots-cli/.local/radroots/dev/data/apps/cli") - ); - assert_eq!( - resolved.paths.workspace_config_path, - Some(PathBuf::from( - "/workspaces/radroots-cli/.local/radroots/dev/config.toml" - )) - ); - } - - #[test] - fn unknown_env_file_variable_fails() { - let error = parse_env_file_values( - "RADROOTS_CLI_LOGGING_FILTRE=debug\n", - Path::new(".env.test"), - ) - .expect_err("unknown env variable"); - assert!( - error - .to_string() - .contains("unknown environment variable `RADROOTS_CLI_LOGGING_FILTRE`") - ); - } - - #[test] - fn old_env_file_variable_fails() { - let error = parse_env_file_values("RADROOTS_OUTPUT=json\n", Path::new(".env.test")) - .expect_err("old env variable should fail"); - assert!( - error - .to_string() - .contains("unknown environment variable `RADROOTS_OUTPUT`") - ); - } - - #[test] - fn duplicate_env_file_variable_fails() { - let error = parse_env_file_values( - "RADROOTS_CLI_OUTPUT_FORMAT=json\nRADROOTS_CLI_OUTPUT_FORMAT=terminal\n", - Path::new(".env.test"), - ) - .expect_err("duplicate env variable should fail"); - assert!( - error - .to_string() - .contains("duplicate environment variable `RADROOTS_CLI_OUTPUT_FORMAT`") - ); - } - - #[test] - fn old_toml_groups_and_fields_fail() { - let temp = tempdir().expect("tempdir"); - let workspace_root = temp.path().join("workspace"); - let repo_local_root = workspace_root.join("infra/local/runtime/radroots"); - let user_home = temp.path().join("home"); - fs::create_dir_all(&repo_local_root).expect("workspace config dir"); - - for (raw, expected) in [ - ( - "[relay]\nurls = [\"wss://relay.old\"]\n", - "unknown field `relay`", - ), - ("[signer]\nmode = \"local\"\n", "unknown field `mode`"), - ( - "[relays]\nurls = [\"wss://relay.example\"]\nextra = true\n", - "unknown field `relays`", - ), - ] { - fs::write(repo_local_root.join("config.toml"), raw).expect("write config"); - let env = repo_local_env( - workspace_root.clone(), - repo_local_root.clone(), - user_home.clone(), - BTreeMap::new(), - ); - let error = RuntimeConfig::resolve_with_env_file( - &runtime_args(), - &env, - &EnvFileValues::default(), - ) - .expect_err("old toml shape should fail"); - assert!( - error.to_string().contains(expected), - "expected {expected}, got {error}" - ); - } - } - - #[test] - fn runtime_config_output_format_accepts_current_contract_values_and_rejects_human() { - for (value, expected) in [ - ("terminal", OutputFormat::Terminal), - ("json", OutputFormat::Json), - ("ndjson", OutputFormat::Ndjson), - ] { - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), - value.to_owned(), - )])); - - let resolved = - RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect("resolve runtime config"); - assert_eq!(resolved.output.format, expected); - } - - let args = runtime_args(); - let env = MapEnvironment::new(BTreeMap::from([( - "RADROOTS_CLI_OUTPUT_FORMAT".to_owned(), - "human".to_owned(), - )])); - let error = RuntimeConfig::resolve_with_env_file(&args, &env, &EnvFileValues::default()) - .expect_err("human output format must stay removed"); - let message = error.to_string(); - assert!(message.contains("RADROOTS_CLI_OUTPUT_FORMAT")); - assert!(message.contains("terminal")); - assert!(message.contains("json")); - assert!(message.contains("ndjson")); - assert!(message.contains("human")); - } -} diff --git a/src/runtime/farm.rs b/src/runtime/farm.rs @@ -1,2051 +0,0 @@ -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use radroots_event::contract::AuthorRole; -use radroots_event::envelope::kind::{KIND_FARM, KIND_PROFILE}; -use radroots_event::farm::{Farm, FarmPublicLocation}; -use radroots_event::id::AddressableCoordinate; -use radroots_event::listing::operational::OperationalListingPublicLocation; -use radroots_event::profile::{AuthoredProfile, Nip05Identifier}; -use radroots_event_codec::d_tag::is_d_tag_base64url; -use radroots_event_codec::profile::authored::authored_profile_to_wire_parts; -use radroots_sdk::farm::{self as sdk_farm, Plan as FarmPlan}; -use radroots_signing::{Actor, actor::ActorSource}; -use serde_json::json; - -use crate::cli::global::{ - FarmCreateArgs, FarmFieldArg, FarmPrivateLocationKeyArgs, FarmPrivateLocationSetArgs, - FarmPublishArgs, FarmRebindArgs, FarmScopeArg, FarmScopedArgs, FarmUpdateArgs, -}; -use crate::runtime::RuntimeError; -use crate::runtime::account::{self, AccountRecordView}; -use crate::runtime::config::{RuntimeConfig, SignerBackend, TransportProfileKind}; -use crate::runtime::farm_config::{ - self, FarmConfigDocument, FarmConfigScope, FarmConfigSelection, FarmListingDefaults, - FarmMissingField, FarmProfileDraft, FarmPublicationStatus, ResolvedFarmConfig, - SUPPORTED_FARM_CONFIG_VERSION, -}; -use crate::runtime::runtime_store::append_local_work; -use crate::runtime::sdk::{CliSdkAdapterError, validate_configured_signer_for_actor}; -use crate::runtime::signer::ActorWriteBindingError; -use crate::view::runtime::{ - FarmConfigDocumentView, FarmConfigSummaryView, FarmGetView, FarmListingDefaultsView, - FarmPrivateLocationView, FarmProfileDraftView, FarmPublicationView, FarmPublishComponentView, - FarmPublishEventView, FarmPublishView, FarmRebindView, FarmSelectionView, FarmSetView, - FarmSetupView, FarmStatusView, -}; - -const FARM_CONFIG_SOURCE: &str = "farm config · local first"; -const FARM_SELLER_ACTOR_SOURCE: &str = "farm_config"; -const SDK_FARM_WRITE_SOURCE: &str = "SDK farm publish · configured signer"; -const SDK_PROFILE_NOT_SUBMITTED_METHOD: &str = "sdk.farm.profile.not_submitted"; -const SDK_FARM_PUBLISH_METHOD: &str = "sdk.farm.publish.v1"; -const SDK_FARM_PRIVATE_LOCATION_SOURCE: &str = "SDK private farm location · local private store"; -const SDK_PROFILE_NOT_SUBMITTED_REASON: &str = - "profile publish is not part of SDK farm.publish.v1; profile draft was not submitted"; - -static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); - -pub fn init(config: &RuntimeConfig, args: &FarmCreateArgs) -> Result<FarmSetupView, RuntimeError> { - let scope = scope_from_arg(args.scope); - let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; - let Some(selected_account) = selected_account_for_draft(config)? else { - return Ok(missing_selected_account_setup_view()); - }; - let existing = farm_config::load(config, Some(resolved_scope))?; - let document = init_document(resolved_scope, &selected_account, existing.as_ref(), args)?; - save_draft_view( - "saved", - resolved_scope, - &selected_account, - &document, - Some("The farm draft is local until you publish it.".to_owned()), - farm_setup_actions(config, &document, Some(&selected_account)), - config, - ) -} - -pub fn init_preflight( - config: &RuntimeConfig, - args: &FarmCreateArgs, -) -> Result<FarmSetupView, RuntimeError> { - let scope = scope_from_arg(args.scope); - let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; - let Some(selected_account) = selected_account_for_draft(config)? else { - return Ok(missing_selected_account_setup_view()); - }; - let existing = farm_config::load(config, Some(resolved_scope))?; - let document = init_document(resolved_scope, &selected_account, existing.as_ref(), args)?; - let path = farm_config::config_path(&config.paths, resolved_scope)?; - Ok(FarmSetupView { - state: "dry_run".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - config: Some(summary_view( - resolved_scope, - path.display().to_string(), - &document, - Some( - selected_account - .record - .public_identity() - .public_key() - .to_hex() - .as_str(), - ), - )), - reason: Some("dry run requested; farm draft was not written".to_owned()), - actions: farm_setup_actions(config, &document, Some(&selected_account)), - }) -} - -pub fn rebind( - config: &RuntimeConfig, - args: &FarmRebindArgs, -) -> Result<FarmRebindView, RuntimeError> { - rebind_inner(config, args, false) -} - -pub fn rebind_preflight( - config: &RuntimeConfig, - args: &FarmRebindArgs, -) -> Result<FarmRebindView, RuntimeError> { - rebind_inner(config, args, true) -} - -fn rebind_inner( - config: &RuntimeConfig, - args: &FarmRebindArgs, - dry_run: bool, -) -> Result<FarmRebindView, RuntimeError> { - let scope = scope_from_arg(args.scope); - let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; - let path = farm_config::config_path(&config.paths, resolved_scope)?; - let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { - return Ok(FarmRebindView { - state: "unconfigured".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - scope: resolved_scope.as_str().to_owned(), - path: path.display().to_string(), - config_present: false, - dry_run, - seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), - from_seller_account_id: None, - from_seller_pubkey: None, - to_seller_account_id: None, - to_seller_pubkey: None, - seller_pubkey_changed: None, - publication_state_action: None, - config: None, - reason: Some(format!("no farm config found at {}", path.display())), - actions: vec!["radroots farm create".to_owned()], - }); - }; - - let from_account = configured_account(config, &resolved.document.selection.account)?; - let from_seller_pubkey = from_account - .as_ref() - .map(|account| account.record.public_identity().public_key().to_hex()); - let target_account = account::resolve_account_selector(config, args.selector.as_str()) - .map_err(|error| farm_rebind_selector_error(args.selector.as_str(), error))?; - let to_seller_pubkey = target_account - .record - .public_identity() - .public_key() - .to_hex(); - let seller_pubkey_changed = from_seller_pubkey - .as_deref() - .is_none_or(|pubkey| !pubkey.eq_ignore_ascii_case(to_seller_pubkey.as_str())); - let publication_state_action = if seller_pubkey_changed { - "cleared" - } else { - "preserved" - }; - let mut document = resolved.document.clone(); - document.selection.account = target_account.record.id().to_string(); - if seller_pubkey_changed { - document.publication = FarmPublicationStatus::default(); - } - let written_path = if dry_run { - resolved.path.clone() - } else { - let written_path = farm_config::write(&config.paths, resolved.scope, &document)?; - append_farm_local_work( - config, - resolved.scope, - written_path.display().to_string(), - &document, - Some(to_seller_pubkey.as_str()), - )?; - written_path - }; - let state = if dry_run { "dry_run" } else { "rebound" }; - - Ok(FarmRebindView { - state: state.to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - scope: resolved.scope.as_str().to_owned(), - path: written_path.display().to_string(), - config_present: true, - dry_run, - seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), - from_seller_account_id: Some(resolved.document.selection.account.clone()), - from_seller_pubkey, - to_seller_account_id: Some(target_account.record.id().to_string()), - to_seller_pubkey: Some(to_seller_pubkey.clone()), - seller_pubkey_changed: Some(seller_pubkey_changed), - publication_state_action: Some(publication_state_action.to_owned()), - config: Some(summary_view( - resolved.scope, - written_path.display().to_string(), - &document, - Some(to_seller_pubkey.as_str()), - )), - reason: Some(if dry_run { - "dry run requested; farm seller binding was not written".to_owned() - } else { - "farm seller binding updated".to_owned() - }), - actions: if dry_run { - vec![farm_rebind_live_action(args.selector.as_str())] - } else { - vec!["radroots farm get".to_owned()] - }, - }) -} - -fn farm_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { - match error { - RuntimeError::Account(account::AccountRuntimeFailure::Unresolved(issue)) => { - account::AccountRuntimeFailure::unresolved_with_detail( - issue.message().to_owned(), - json!({ - "seller_actor_source": FARM_SELLER_ACTOR_SOURCE, - "selector": selector, - "actions": account_recovery_actions(), - }), - ) - .into() - } - other => other, - } -} - -pub fn set(config: &RuntimeConfig, args: &FarmUpdateArgs) -> Result<FarmSetView, RuntimeError> { - let scope = scope_from_arg(args.scope); - let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; - let path = farm_config::config_path(&config.paths, resolved_scope)?; - let Some(mut resolved) = farm_config::load(config, Some(resolved_scope))? else { - return Ok(FarmSetView { - state: "unconfigured".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - field: terminal_field_name(args.field).to_owned(), - value: terminal_field_value(args.field, args.value.join(" ").trim()).to_owned(), - config: None, - reason: Some(format!("no farm draft found at {}", path.display())), - actions: vec!["radroots farm create".to_owned()], - }); - }; - - let raw_value = args.value.join(" "); - let field_value = required_text(raw_value.as_str(), "farm set value")?; - apply_field_update(&mut resolved.document, args.field, field_value.as_str())?; - let written_path = farm_config::write(&config.paths, resolved.scope, &resolved.document)?; - let configured_account = configured_account(config, &resolved.document.selection.account)?; - let account_public_key_hex = configured_account - .as_ref() - .map(|account| account.record.public_identity().public_key().to_hex()); - let account_pubkey = account_public_key_hex.as_deref(); - append_farm_local_work( - config, - resolved.scope, - written_path.display().to_string(), - &resolved.document, - account_pubkey, - )?; - let reason = if configured_account.is_none() { - Some(missing_farm_bound_seller_reason( - resolved.document.selection.account.as_str(), - )) - } else { - None - }; - - Ok(FarmSetView { - state: "updated".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - field: terminal_field_name(args.field).to_owned(), - value: terminal_field_value(args.field, field_value.as_str()).to_owned(), - config: Some(summary_view( - resolved.scope, - written_path.display().to_string(), - &resolved.document, - account_pubkey, - )), - reason, - actions: farm_update_actions(config, &resolved.document, configured_account.as_ref()), - }) -} - -pub fn set_preflight( - config: &RuntimeConfig, - args: &FarmUpdateArgs, -) -> Result<FarmSetView, RuntimeError> { - let scope = scope_from_arg(args.scope); - let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; - let path = farm_config::config_path(&config.paths, resolved_scope)?; - let Some(mut resolved) = farm_config::load(config, Some(resolved_scope))? else { - return Ok(FarmSetView { - state: "unconfigured".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - field: terminal_field_name(args.field).to_owned(), - value: terminal_field_value(args.field, args.value.join(" ").trim()).to_owned(), - config: None, - reason: Some(format!("no farm draft found at {}", path.display())), - actions: vec!["radroots farm create".to_owned()], - }); - }; - - let raw_value = args.value.join(" "); - let field_value = required_text(raw_value.as_str(), "farm set value")?; - apply_field_update(&mut resolved.document, args.field, field_value.as_str())?; - let configured_account = configured_account(config, &resolved.document.selection.account)?; - let account_public_key_hex = configured_account - .as_ref() - .map(|account| account.record.public_identity().public_key().to_hex()); - let account_pubkey = account_public_key_hex.as_deref(); - let reason = if configured_account.is_none() { - Some(format!( - "dry run requested; farm draft was not written; {}", - missing_farm_bound_seller_reason(resolved.document.selection.account.as_str()) - )) - } else { - Some("dry run requested; farm draft was not written".to_owned()) - }; - - Ok(FarmSetView { - state: "dry_run".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - field: terminal_field_name(args.field).to_owned(), - value: terminal_field_value(args.field, field_value.as_str()).to_owned(), - config: Some(summary_view( - resolved.scope, - path.display().to_string(), - &resolved.document, - account_pubkey, - )), - reason, - actions: farm_update_actions(config, &resolved.document, configured_account.as_ref()), - }) -} - -pub fn private_location_set( - config: &RuntimeConfig, - args: &FarmPrivateLocationSetArgs, -) -> Result<FarmPrivateLocationView, CliSdkAdapterError> { - match private_location_target(config, args.farm_d_tag.as_deref())? { - Some(_) => Err(RuntimeError::Config( - "private farm location writes require a host-owned private artifact adapter".to_owned(), - ) - .into()), - None => Ok(private_location_unconfigured_view()), - } -} - -pub fn private_location_get( - config: &RuntimeConfig, - args: &FarmPrivateLocationKeyArgs, -) -> Result<FarmPrivateLocationView, CliSdkAdapterError> { - match private_location_target(config, args.farm_d_tag.as_deref())? { - Some(_) => Err(RuntimeError::Config( - "private farm location reads require a host-owned private artifact adapter".to_owned(), - ) - .into()), - None => Ok(private_location_unconfigured_view()), - } -} - -pub fn private_location_clear( - config: &RuntimeConfig, - args: &FarmPrivateLocationKeyArgs, -) -> Result<FarmPrivateLocationView, CliSdkAdapterError> { - match private_location_target(config, args.farm_d_tag.as_deref())? { - Some(_) => Err(RuntimeError::Config( - "private farm location deletion requires a host-owned private artifact adapter" - .to_owned(), - ) - .into()), - None => Ok(private_location_unconfigured_view()), - } -} - -pub fn status( - config: &RuntimeConfig, - args: &FarmScopedArgs, -) -> Result<FarmStatusView, RuntimeError> { - let scope = scope_from_arg(args.scope); - let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; - let path = farm_config::config_path(&config.paths, resolved_scope)?; - let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { - return Ok(FarmStatusView { - state: "unconfigured".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - scope: resolved_scope.as_str().to_owned(), - path: path.display().to_string(), - config_present: false, - config_valid: false, - account_state: "not_checked".to_owned(), - listing_defaults_state: "missing".to_owned(), - transport_profile: config.transport.profile.as_str().to_owned(), - publish_state: "not_checked".to_owned(), - publish_executable: false, - publish_reason: None, - config: None, - missing: vec!["Farm draft".to_owned()], - reason: Some(format!("no farm config found at {}", path.display())), - actions: vec!["radroots farm create".to_owned()], - }); - }; - - let account = configured_account(config, &resolved.document.selection.account)?; - let draft_missing = farm_config::missing_fields(&resolved.document); - let account_state = if account.is_some() { - "ready" - } else { - "missing" - }; - let listing_defaults_state = if missing_blocks_listing_defaults(draft_missing.as_slice()) { - "missing" - } else { - "ready" - }; - let publish = account - .as_ref() - .filter(|_| draft_missing.is_empty()) - .map(|account| farm_publish_readiness(config, account)) - .unwrap_or_else(FarmPublishReadiness::not_checked); - let state = if account.is_some() && draft_missing.is_empty() && publish.executable { - "ready" - } else { - "unconfigured" - }; - let reason = if account.is_none() { - Some(format!( - "farm config account `{}` is not present in the local account store", - resolved.document.selection.account - )) - } else if !draft_missing.is_empty() { - Some("farm draft is missing required fields".to_owned()) - } else { - publish.reason.clone() - }; - let mut actions = Vec::new(); - if account.is_none() { - actions.extend(farm_bound_seller_recovery_actions("<selector>")); - } else if draft_missing.is_empty() { - actions.extend(publish.actions.clone()); - } else { - actions.extend(missing_field_actions(draft_missing.as_slice())); - } - let account_public_key_hex = account - .as_ref() - .map(|account| account.record.public_identity().public_key().to_hex()); - let account_pubkey = account_public_key_hex.as_deref(); - - Ok(FarmStatusView { - state: state.to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - scope: resolved.scope.as_str().to_owned(), - path: resolved.path.display().to_string(), - config_present: true, - config_valid: true, - account_state: account_state.to_owned(), - listing_defaults_state: listing_defaults_state.to_owned(), - transport_profile: config.transport.profile.as_str().to_owned(), - publish_state: publish.state.to_owned(), - publish_executable: publish.executable, - publish_reason: publish.reason, - config: Some(summary_view( - resolved.scope, - resolved.path.display().to_string(), - &resolved.document, - account_pubkey, - )), - missing: if account.is_none() { - vec!["Farm-bound seller account".to_owned()] - } else { - let mut missing = missing_field_labels(draft_missing.as_slice()); - missing.extend(publish.missing); - missing - }, - reason, - actions, - }) -} - -pub fn get(config: &RuntimeConfig, args: &FarmScopedArgs) -> Result<FarmGetView, RuntimeError> { - let scope = scope_from_arg(args.scope); - let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; - let path = farm_config::config_path(&config.paths, resolved_scope)?; - let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { - return Ok(FarmGetView { - state: "unconfigured".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - scope: resolved_scope.as_str().to_owned(), - path: path.display().to_string(), - config_present: false, - document: None, - reason: Some(format!("no farm config found at {}", path.display())), - actions: vec!["radroots farm create".to_owned()], - }); - }; - - Ok(FarmGetView { - state: "ready".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - scope: resolved.scope.as_str().to_owned(), - path: resolved.path.display().to_string(), - config_present: true, - document: Some(document_view(&resolved.document)), - reason: None, - actions: Vec::new(), - }) -} - -#[derive(Debug, Clone)] -struct FarmPublishReadiness { - state: &'static str, - executable: bool, - reason: Option<String>, - missing: Vec<String>, - actions: Vec<String>, -} - -impl FarmPublishReadiness { - fn not_checked() -> Self { - Self { - state: "not_checked", - executable: false, - reason: None, - missing: Vec::new(), - actions: Vec::new(), - } - } -} - -fn farm_publish_readiness( - config: &RuntimeConfig, - account: &AccountRecordView, -) -> FarmPublishReadiness { - transport_farm_publish_readiness(config, account) -} - -fn transport_farm_publish_readiness( - config: &RuntimeConfig, - account: &AccountRecordView, -) -> FarmPublishReadiness { - if matches!( - config.transport.profile, - TransportProfileKind::Nostr | TransportProfileKind::MultiTarget - ) && config.transport.nostr_relay_urls.is_empty() - { - return FarmPublishReadiness { - state: "unconfigured", - executable: false, - reason: Some("farm publish requires a configured Nostr transport profile".to_owned()), - missing: vec!["Configured Nostr transport profile".to_owned()], - actions: vec![ - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com" - .to_owned(), - ], - }; - } - - if matches!(config.signer.backend, SignerBackend::Myc) { - if let Err(error) = validate_configured_signer_for_actor( - config, - Some(account.record.id().to_hex().as_str()), - account - .record - .public_identity() - .public_key() - .to_hex() - .as_str(), - "farm seller", - ) { - return FarmPublishReadiness { - state: "unconfigured", - executable: false, - reason: Some(error.to_string()), - missing: vec!["Remote signer binding".to_owned()], - actions: vec!["radroots signer status".to_owned()], - }; - } - return FarmPublishReadiness { - state: "ready", - executable: true, - reason: None, - missing: Vec::new(), - actions: Vec::new(), - }; - } - - if !account.write_capable { - return FarmPublishReadiness { - state: "unconfigured", - executable: false, - reason: Some( - account::AccountRuntimeFailure::watch_only(&account.record.id()).to_string(), - ), - missing: vec!["Write-capable farm-bound seller account".to_owned()], - actions: vec!["radroots account create".to_owned()], - }; - } - - FarmPublishReadiness { - state: "ready", - executable: true, - reason: None, - missing: Vec::new(), - actions: vec!["radroots farm publish".to_owned()], - } -} - -pub fn publish( - config: &RuntimeConfig, - args: &FarmPublishArgs, -) -> Result<FarmPublishView, CliSdkAdapterError> { - let scope = scope_from_arg(args.scope); - let resolved_scope = farm_config::resolve_scope(&config.paths, scope)?; - let path = farm_config::config_path(&config.paths, resolved_scope)?; - let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { - return Ok(missing_publish_view( - config, - resolved_scope, - path.display().to_string(), - args, - format!("no farm config found at {}", path.display()), - vec!["Farm draft".to_owned()], - vec!["radroots farm create".to_owned()], - config.output.dry_run, - false, - String::new(), - String::new(), - String::new(), - )); - }; - - let Some(account) = configured_account(config, &resolved.document.selection.account)? else { - return Ok(missing_publish_view( - config, - resolved.scope, - resolved.path.display().to_string(), - args, - format!( - "farm config account `{}` is not present in the local account store", - resolved.document.selection.account - ), - vec!["Farm-bound seller account".to_owned()], - farm_bound_seller_recovery_actions("<selector>"), - config.output.dry_run, - true, - resolved.document.selection.account.clone(), - String::new(), - resolved.document.selection.farm_d_tag.clone(), - )); - }; - let draft_missing = farm_config::missing_fields(&resolved.document); - if !draft_missing.is_empty() { - return Ok(missing_publish_view( - config, - resolved.scope, - resolved.path.display().to_string(), - args, - "farm draft is missing required fields".to_owned(), - missing_field_labels(draft_missing.as_slice()), - missing_field_actions(draft_missing.as_slice()), - config.output.dry_run, - true, - resolved.document.selection.account.clone(), - account.record.public_identity().public_key().to_hex(), - resolved.document.selection.farm_d_tag.clone(), - )); - } - let account_pubkey = account.record.public_identity().public_key().to_hex(); - let previews = build_publish_previews(&resolved.document, account_pubkey.as_str())?; - let profile_idempotency_key = component_idempotency_key(args, "profile")?; - let farm_idempotency_key = component_idempotency_key(args, "farm")?; - - publish_via_sdk( - config, - args, - resolved, - account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - ) -} - -fn publish_via_sdk( - config: &RuntimeConfig, - args: &FarmPublishArgs, - mut resolved: ResolvedFarmConfig, - account_pubkey: String, - previews: FarmPublishPreviews, - profile_idempotency_key: Option<String>, - farm_idempotency_key: Option<String>, -) -> Result<FarmPublishView, CliSdkAdapterError> { - let input = sdk_farm_publish_input(&resolved, account_pubkey.as_str())?; - if config.output.dry_run { - if let Err(error) = validate_configured_signer_for_actor( - config, - Some(resolved.document.selection.account.as_str()), - account_pubkey.as_str(), - "farm seller", - ) { - let binding_error = ActorWriteBindingError::from_runtime(error); - return match binding_error { - ActorWriteBindingError::Account(failure) => Err(RuntimeError::from(failure).into()), - error => Ok(binding_error_publish_view( - config, - args, - &resolved, - &account_pubkey, - previews, - profile_idempotency_key, - farm_idempotency_key, - error, - )), - }; - } - - let plan = sdk_farm::prepare(sdk_farm::PrepareRequest::new( - input.actor, - input.farm, - now_unix(), - )) - .map_err(|error| RuntimeError::Config(format!("invalid SDK farm plan: {error}")))?; - return Ok(sdk_prepared_publish_view( - config, - args, - &resolved, - account_pubkey.as_str(), - previews, - profile_idempotency_key, - farm_idempotency_key, - plan, - )); - } - - Err(RuntimeError::Config( - "farm commit is unavailable until the shared sync engine is configured".to_owned(), - ) - .into()) -} - -#[derive(Debug, Clone)] -struct SdkFarmPublishInput { - actor: Actor, - farm: Farm, -} - -#[derive(Debug, Clone)] -struct PrivateFarmLocationTarget { - actor: Actor, - farm_addr: AddressableCoordinate, - farm_d_tag: String, - seller_account_id: String, - seller_pubkey: String, -} - -#[derive(Debug, Clone)] -struct FarmPublishPreviews { - profile: FarmPublishEventDraft, -} - -#[derive(Debug, Clone)] -struct FarmPublishEventDraft { - event: FarmPublishEventView, -} - -#[expect( - clippy::too_many_arguments, - reason = "publish view construction mirrors the V1 output contract fields" -)] -fn missing_publish_view( - config: &RuntimeConfig, - scope: FarmConfigScope, - path: String, - args: &FarmPublishArgs, - reason: String, - missing: Vec<String>, - actions: Vec<String>, - dry_run: bool, - config_present: bool, - seller_account_id: String, - seller_pubkey: String, - farm_d_tag: String, -) -> FarmPublishView { - FarmPublishView { - state: "unconfigured".to_owned(), - source: farm_write_source(config).to_owned(), - scope: scope.as_str().to_owned(), - path, - config_present, - dry_run, - seller_account_id, - seller_pubkey, - seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), - farm_d_tag, - profile: not_submitted_component( - profile_publish_rpc_method(config), - KIND_PROFILE, - args, - None, - None, - ), - farm: not_submitted_component(farm_publish_rpc_method(config), KIND_FARM, args, None, None), - local_replica: Vec::new(), - missing, - reason: Some(reason), - actions, - } -} - -#[expect( - clippy::too_many_arguments, - reason = "publish view construction mirrors the V1 output contract fields" -)] -fn base_publish_view( - state: &str, - config: &RuntimeConfig, - _args: &FarmPublishArgs, - resolved: &ResolvedFarmConfig, - account_pubkey: &str, - profile: FarmPublishComponentView, - farm: FarmPublishComponentView, - reason: Option<String>, - actions: Vec<String>, -) -> FarmPublishView { - FarmPublishView { - state: state.to_owned(), - source: farm_write_source(config).to_owned(), - scope: resolved.scope.as_str().to_owned(), - path: resolved.path.display().to_string(), - config_present: true, - dry_run: config.output.dry_run, - seller_account_id: resolved.document.selection.account.clone(), - seller_pubkey: account_pubkey.to_owned(), - seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), - farm_d_tag: resolved.document.selection.farm_d_tag.clone(), - profile, - farm, - local_replica: Vec::new(), - missing: Vec::new(), - reason, - actions, - } -} - -fn build_publish_previews( - document: &FarmConfigDocument, - account_pubkey: &str, -) -> Result<FarmPublishPreviews, RuntimeError> { - require_verified_publish_media(&document.profile, &document.farm)?; - let profile = authored_profile_from_draft(&document.profile)?; - let profile_parts = authored_profile_to_wire_parts(&profile) - .map_err(|error| RuntimeError::Config(format!("invalid farm profile: {error}")))?; - - Ok(FarmPublishPreviews { - profile: FarmPublishEventDraft { - event: FarmPublishEventView { - kind: profile_parts.kind, - author: account_pubkey.to_owned(), - content: profile_parts.content.clone(), - tags: profile_parts.tags.clone(), - event_id: None, - event_addr: None, - }, - }, - }) -} - -fn authored_profile_from_draft(draft: &FarmProfileDraft) -> Result<AuthoredProfile, RuntimeError> { - let mut profile = AuthoredProfile::new(draft.name.clone()) - .map_err(|error| RuntimeError::Config(format!("invalid farm profile: {error}")))?; - if let Some(display_name) = draft.display_name.as_deref().and_then(non_empty) { - profile = profile.with_display_name(display_name); - } - if let Some(about) = draft.about.as_deref().and_then(non_empty) { - profile = profile.with_about(about); - } - if let Some(nip05) = draft.nip05.as_deref().and_then(non_empty) { - let identifier = Nip05Identifier::parse(nip05.as_str()).map_err(|error| { - RuntimeError::Config(format!("invalid farm profile NIP-05 identifier: {error}")) - })?; - profile = profile.with_nip05(identifier); - } - if let Some(bot) = draft.bot.as_deref().and_then(non_empty) { - let bot = match bot.as_str() { - "true" => true, - "false" => false, - _ => { - return Err(RuntimeError::Config( - "farm profile bot must be `true` or `false`".to_owned(), - )); - } - }; - profile = profile.with_bot(bot); - } - Ok(profile) -} - -fn require_verified_publish_media( - profile: &FarmProfileDraft, - farm: &Farm, -) -> Result<(), RuntimeError> { - let contains_unverified_media = [ - profile.picture.as_deref(), - profile.banner.as_deref(), - farm.picture.as_deref(), - farm.banner.as_deref(), - ] - .into_iter() - .flatten() - .any(|value| !value.trim().is_empty()); - if contains_unverified_media { - return Err(RuntimeError::Config( - "farm publish cannot use raw picture or banner URLs; publish media only after byte-verifying the Blossom descriptor and retaining proof of successful BUD-02 upload completion" - .to_owned(), - )); - } - Ok(()) -} - -fn component_idempotency_key( - args: &FarmPublishArgs, - component: &str, -) -> Result<Option<String>, RuntimeError> { - args.idempotency_key - .as_deref() - .map(|value| { - required_text(value, "idempotency_key") - .and_then(|key| derive_component_idempotency_key(key.as_str(), component)) - }) - .transpose() -} - -fn derive_component_idempotency_key(key: &str, component: &str) -> Result<String, RuntimeError> { - if !is_uuid_v7_idempotency_key(key) { - return Err(RuntimeError::Config( - "idempotency_key must be a UUIDv7".to_owned(), - )); - } - let mut hash = 0xcbf2_9ce4_8422_2325_u64; - for byte in key - .bytes() - .chain(std::iter::once(b':')) - .chain(component.bytes()) - { - hash ^= u64::from(byte); - hash = hash.wrapping_mul(0x0000_0100_0000_01b3); - } - Ok(format!("{}{:012x}", &key[..24], hash & 0xffff_ffff_ffff)) -} - -fn is_uuid_v7_idempotency_key(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() == 36 - && bytes[8] == b'-' - && bytes[13] == b'-' - && bytes[18] == b'-' - && bytes[23] == b'-' - && bytes[14] == b'7' - && matches!(bytes[19], b'8' | b'9' | b'a' | b'b') - && bytes - .iter() - .enumerate() - .all(|(index, byte)| matches!(index, 8 | 13 | 18 | 23) || byte.is_ascii_hexdigit()) -} - -fn preview_component( - rpc_method: &str, - event_kind: u32, - idempotency_key: Option<String>, - args: &FarmPublishArgs, - event: Option<FarmPublishEventView>, -) -> FarmPublishComponentView { - FarmPublishComponentView { - state: if event.is_some() { - "not_submitted".to_owned() - } else { - "unconfigured".to_owned() - }, - rpc_method: rpc_method.to_owned(), - event_kind, - deduplicated: false, - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - job_id: None, - job_status: None, - signer_mode: None, - event_id: None, - event_addr: event.as_ref().and_then(|event| event.event_addr.clone()), - idempotency_key: idempotency_key.clone(), - reason: Some("not submitted".to_owned()), - job: None, - event: args.print_event.then_some(event).flatten(), - } -} - -fn not_submitted_component( - rpc_method: &str, - event_kind: u32, - args: &FarmPublishArgs, - idempotency_key: Option<String>, - event: Option<FarmPublishEventView>, -) -> FarmPublishComponentView { - preview_component(rpc_method, event_kind, idempotency_key, args, event) -} - -#[expect( - clippy::too_many_arguments, - reason = "publish view construction mirrors the V1 output contract fields" -)] -fn binding_error_publish_view( - config: &RuntimeConfig, - args: &FarmPublishArgs, - resolved: &ResolvedFarmConfig, - account_pubkey: &str, - previews: FarmPublishPreviews, - profile_idempotency_key: Option<String>, - farm_idempotency_key: Option<String>, - error: ActorWriteBindingError, -) -> FarmPublishView { - let reason = error.reason(); - let state = "unconfigured".to_owned(); - let actions = vec!["run radroots signer status".to_owned()]; - base_publish_view( - state.as_str(), - config, - args, - resolved, - account_pubkey, - FarmPublishComponentView { - state: state.clone(), - reason: Some(reason.clone()), - ..profile_not_submitted_component( - profile_idempotency_key, - args, - Some(previews.profile.event), - ) - }, - FarmPublishComponentView { - state: state.clone(), - reason: Some(reason.clone()), - ..preview_component( - farm_publish_rpc_method(config), - KIND_FARM, - farm_idempotency_key, - args, - None, - ) - }, - Some(reason), - actions, - ) -} - -fn sdk_farm_publish_input( - resolved: &ResolvedFarmConfig, - account_pubkey: &str, -) -> Result<SdkFarmPublishInput, RuntimeError> { - let actor = Actor::from_public_key_hex( - account_pubkey, - ActorSource::ExplicitPublicKey, - [AuthorRole::Farmer], - ) - .map_err(|error| RuntimeError::Config(format!("invalid farm SDK actor: {error}")))?; - Ok(SdkFarmPublishInput { - actor, - farm: resolved.document.farm.clone(), - }) -} - -#[expect( - clippy::too_many_arguments, - reason = "publish view construction mirrors the V1 output contract fields" -)] -fn sdk_prepared_publish_view( - config: &RuntimeConfig, - args: &FarmPublishArgs, - resolved: &ResolvedFarmConfig, - account_pubkey: &str, - previews: FarmPublishPreviews, - profile_idempotency_key: Option<String>, - farm_idempotency_key: Option<String>, - plan: FarmPlan, -) -> FarmPublishView { - base_publish_view( - "dry_run", - config, - args, - resolved, - account_pubkey, - profile_not_submitted_component( - profile_idempotency_key, - args, - Some(previews.profile.event), - ), - FarmPublishComponentView { - state: "not_submitted".to_owned(), - reason: Some("dry run requested; SDK enqueue and transport push skipped".to_owned()), - signer_mode: Some(config.signer.backend.as_str().to_owned()), - event_id: Some(plan.draft().expected_event_id().to_string()), - event_addr: Some(plan.coordinate().as_str().to_owned()), - event: args.print_event.then_some(sdk_plan_event_view(&plan)), - ..preview_component( - farm_publish_rpc_method(config), - KIND_FARM, - farm_idempotency_key, - args, - None, - ) - }, - Some("dry run requested; SDK enqueue and transport push skipped".to_owned()), - vec!["radroots farm publish".to_owned()], - ) -} - -fn sdk_plan_event_view(plan: &FarmPlan) -> FarmPublishEventView { - FarmPublishEventView { - kind: plan.draft().kind_u32(), - author: plan.draft().expected_pubkey().to_hex(), - content: plan.draft().content().to_owned(), - tags: plan.draft().tags_as_vec(), - event_id: Some(plan.draft().expected_event_id().to_string()), - event_addr: Some(plan.coordinate().as_str().to_owned()), - } -} - -fn profile_not_submitted_component( - idempotency_key: Option<String>, - args: &FarmPublishArgs, - event: Option<FarmPublishEventView>, -) -> FarmPublishComponentView { - FarmPublishComponentView { - reason: Some(SDK_PROFILE_NOT_SUBMITTED_REASON.to_owned()), - ..preview_component( - SDK_PROFILE_NOT_SUBMITTED_METHOD, - KIND_PROFILE, - idempotency_key, - args, - event, - ) - } -} - -fn farm_write_source(config: &RuntimeConfig) -> &'static str { - let _ = config; - SDK_FARM_WRITE_SOURCE -} - -fn profile_publish_rpc_method(config: &RuntimeConfig) -> &'static str { - let _ = config; - SDK_PROFILE_NOT_SUBMITTED_METHOD -} - -fn farm_publish_rpc_method(config: &RuntimeConfig) -> &'static str { - let _ = config; - SDK_FARM_PUBLISH_METHOD -} - -fn selected_account_for_draft( - config: &RuntimeConfig, -) -> Result<Option<AccountRecordView>, RuntimeError> { - account::resolve_account(config) -} - -fn private_location_target( - config: &RuntimeConfig, - farm_d_tag: Option<&str>, -) -> Result<Option<PrivateFarmLocationTarget>, RuntimeError> { - let resolved_scope = farm_config::resolve_scope(&config.paths, None)?; - let Some(resolved) = farm_config::load(config, Some(resolved_scope))? else { - return Ok(None); - }; - let Some(account) = configured_account(config, &resolved.document.selection.account)? else { - return Ok(None); - }; - let seller_pubkey = account.record.public_identity().public_key().to_hex(); - let seller_account_id = account.record.id().to_string(); - let farm_d_tag = farm_d_tag - .map(str::to_owned) - .unwrap_or_else(|| resolved.document.selection.farm_d_tag.clone()); - let actor = Actor::from_public_key_hex( - seller_pubkey.as_str(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Farmer], - ) - .map_err(|error| { - RuntimeError::Config(format!("invalid farm private location actor: {error}")) - })?; - let farm_addr = - AddressableCoordinate::parse(format!("{KIND_FARM}:{seller_pubkey}:{farm_d_tag}")) - .map_err(|error| RuntimeError::Config(format!("invalid farm address: {error}")))?; - Ok(Some(PrivateFarmLocationTarget { - actor, - farm_addr, - farm_d_tag, - seller_account_id, - seller_pubkey, - })) -} - -fn missing_selected_account_setup_view() -> FarmSetupView { - FarmSetupView { - state: "unconfigured".to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - config: None, - reason: Some("choose or create an account before setting up your farm".to_owned()), - actions: vec!["radroots account create".to_owned()], - } -} - -fn private_location_unconfigured_view() -> FarmPrivateLocationView { - FarmPrivateLocationView { - state: "unconfigured".to_owned(), - source: SDK_FARM_PRIVATE_LOCATION_SOURCE.to_owned(), - farm_addr: None, - farm_d_tag: None, - seller_account_id: None, - seller_pubkey: None, - label: None, - exact_location: None, - public_locality: None, - geonames_feature_id: None, - geonames_country_id: None, - geonames_database_path: None, - cleared: None, - candidates: Vec::new(), - reason: Some("create and bind a farm before storing private location".to_owned()), - actions: vec![ - "radroots account create".to_owned(), - "radroots farm create".to_owned(), - ], - } -} - -fn init_document( - scope: FarmConfigScope, - account: &AccountRecordView, - existing: Option<&ResolvedFarmConfig>, - args: &FarmCreateArgs, -) -> Result<FarmConfigDocument, RuntimeError> { - let existing_document = existing.map(|resolved| &resolved.document); - if let Some(document) = existing_document - && document.selection.account != account.record.id().to_string() - { - let message = format!( - "account mismatch: farm config is bound to seller account `{}`; select account `{}` before updating this farm config", - document.selection.account, - account.record.id() - ); - return Err(account::AccountRuntimeFailure::mismatch_with_detail( - message, - json!({ - "seller_actor_source": FARM_SELLER_ACTOR_SOURCE, - "farm_bound_seller_account_id": document.selection.account, - "attempted_seller_account_id": account.record.id().to_string(), - "actions": farm_rebind_recovery_actions(account.record.id().to_hex().as_str()), - }), - ) - .into()); - } - let farm_d_tag = match args.farm_d_tag.as_deref() { - Some(value) => required_d_tag(value, "farm_d_tag")?, - None => existing_document - .map(|document| document.farm.d_tag.clone()) - .unwrap_or_else(generate_d_tag), - }; - let existing_name = existing_name(existing_document); - let existing_location = existing_location_primary(existing_document); - let existing_city = existing_city(existing_document); - let existing_region = existing_region(existing_document); - let existing_country = existing_country(existing_document); - let existing_geohash = existing_geohash(existing_document); - let existing_delivery = existing_delivery_method(existing_document); - let name = optional_arg_or_existing(args.name.as_ref(), existing_name.as_ref()) - .or_else(|| draft_name_from_account(account)) - .unwrap_or_default(); - let display_name = optional_arg_or_existing( - args.display_name.as_ref(), - existing_document.and_then(|document| document.profile.display_name.as_ref()), - ) - .or_else(|| non_empty(name.as_str())); - let about = optional_arg_or_existing( - args.about.as_ref(), - existing_document.and_then(|document| document.profile.about.as_ref()), - ); - let website = optional_arg_or_existing( - args.website.as_ref(), - existing_document.and_then(|document| document.profile.website.as_ref()), - ); - let picture = optional_arg_or_existing( - args.picture.as_ref(), - existing_document.and_then(|document| document.profile.picture.as_ref()), - ); - let banner = optional_arg_or_existing( - args.banner.as_ref(), - existing_document.and_then(|document| document.profile.banner.as_ref()), - ); - let location_primary = - optional_arg_or_existing(args.location.as_ref(), existing_location.as_ref()) - .unwrap_or_default(); - let city = optional_arg_or_existing(args.city.as_ref(), existing_city.as_ref()); - let region = optional_arg_or_existing(args.region.as_ref(), existing_region.as_ref()); - let country = optional_arg_or_existing(args.country.as_ref(), existing_country.as_ref()); - let geohash = optional_arg_or_existing(args.geohash.as_ref(), existing_geohash.as_ref()) - .unwrap_or_default(); - let delivery_method = - optional_arg_or_existing(args.delivery_method.as_ref(), existing_delivery.as_ref()) - .unwrap_or_default(); - let publication = publication_for_document(existing_document, account, farm_d_tag.as_str()); - - Ok(FarmConfigDocument { - version: SUPPORTED_FARM_CONFIG_VERSION, - selection: FarmConfigSelection { - scope, - account: account.record.id().to_string(), - farm_d_tag: farm_d_tag.clone(), - }, - profile: FarmProfileDraft { - name: name.clone(), - display_name, - nip05: None, - about: about.clone(), - website: website.clone(), - picture: picture.clone(), - banner: banner.clone(), - lud06: None, - lud16: None, - bot: None, - }, - farm: Farm { - d_tag: farm_d_tag, - name, - about, - website, - picture, - banner, - location: Some(FarmPublicLocation { - primary: location_primary.clone(), - city: city.clone(), - region: region.clone(), - country: country.clone(), - geohash: geohash.clone(), - }), - tags: None, - }, - listing_defaults: FarmListingDefaults { - delivery_method, - location: OperationalListingPublicLocation { - primary: location_primary, - city, - region, - country, - geohash, - }, - }, - publication, - }) -} - -fn save_draft_view( - state: &str, - scope: FarmConfigScope, - account: &AccountRecordView, - document: &FarmConfigDocument, - reason: Option<String>, - actions: Vec<String>, - config: &RuntimeConfig, -) -> Result<FarmSetupView, RuntimeError> { - let written_path = farm_config::write(&config.paths, scope, document)?; - append_farm_local_work( - config, - scope, - written_path.display().to_string(), - document, - Some( - account - .record - .public_identity() - .public_key() - .to_hex() - .as_str(), - ), - )?; - Ok(FarmSetupView { - state: state.to_owned(), - source: FARM_CONFIG_SOURCE.to_owned(), - config: Some(summary_view( - scope, - written_path.display().to_string(), - document, - Some( - account - .record - .public_identity() - .public_key() - .to_hex() - .as_str(), - ), - )), - reason, - actions, - }) -} - -fn append_farm_local_work( - config: &RuntimeConfig, - scope: FarmConfigScope, - path: String, - document: &FarmConfigDocument, - owner_pubkey: Option<&str>, -) -> Result<(), RuntimeError> { - let payload = json!({ - "record_kind": "farm_config_v1", - "scope": scope.as_str(), - "path": path, - "document": document, - }); - let subject = format!("farm:{}", document.selection.farm_d_tag); - append_local_work( - config, - subject.as_str(), - Some(document.selection.account.clone()), - owner_pubkey.map(str::to_owned), - Some(document.selection.farm_d_tag.clone()), - None, - payload, - )?; - Ok(()) -} - -fn farm_update_actions( - config: &RuntimeConfig, - document: &FarmConfigDocument, - account: Option<&AccountRecordView>, -) -> Vec<String> { - farm_setup_actions(config, document, account) -} - -fn farm_setup_actions( - config: &RuntimeConfig, - document: &FarmConfigDocument, - account: Option<&AccountRecordView>, -) -> Vec<String> { - let mut actions = vec!["radroots farm get".to_owned()]; - if account.is_none() { - actions.extend(farm_bound_seller_recovery_actions("<selector>")); - return actions; - } - if farm_config::missing_fields(document).is_empty() - && account - .map(|account| farm_publish_readiness(config, account).executable) - .unwrap_or(false) - { - actions.push("radroots farm publish".to_owned()); - } - actions -} - -fn missing_farm_bound_seller_reason(account_id: &str) -> String { - format!("farm-bound seller account `{account_id}` is not present in the local account store") -} - -pub(crate) fn farm_bound_seller_recovery_actions(selector: &str) -> Vec<String> { - let mut actions = vec!["radroots account import <path>".to_owned()]; - actions.extend(farm_rebind_recovery_actions(selector)); - actions -} - -pub(crate) fn farm_rebind_recovery_actions(selector: &str) -> Vec<String> { - vec![ - farm_rebind_dry_run_action(selector), - farm_rebind_live_action(selector), - ] -} - -pub(crate) fn farm_rebind_dry_run_action(selector: &str) -> String { - format!("radroots --dry-run account select {selector}") -} - -pub(crate) fn farm_rebind_live_action(selector: &str) -> String { - format!("radroots account select {selector}") -} - -fn account_recovery_actions() -> Vec<String> { - vec![ - "radroots account import <path>".to_owned(), - "radroots account create".to_owned(), - ] -} - -fn missing_blocks_listing_defaults(missing: &[FarmMissingField]) -> bool { - missing.iter().any(|field| { - matches!( - field, - FarmMissingField::Location - | FarmMissingField::City - | FarmMissingField::Delivery - | FarmMissingField::Geohash - ) - }) -} - -fn missing_field_labels(missing: &[FarmMissingField]) -> Vec<String> { - missing - .iter() - .map(|field| field.label().to_owned()) - .collect() -} - -fn missing_field_actions(missing: &[FarmMissingField]) -> Vec<String> { - let mut actions = Vec::new(); - for field in missing { - match field { - FarmMissingField::Name => { - push_action(&mut actions, "radroots farm set name \"La Huerta Farm\""); - } - FarmMissingField::Location => { - push_action( - &mut actions, - "radroots farm set location \"San Francisco, CA\"", - ); - } - FarmMissingField::City => { - push_action(&mut actions, "radroots farm set city \"San Francisco\""); - } - FarmMissingField::Delivery => { - push_action(&mut actions, "radroots farm set delivery pickup"); - } - FarmMissingField::Country => { - push_action(&mut actions, "radroots farm set country US"); - } - FarmMissingField::Geohash => { - push_action(&mut actions, "radroots farm set geohash 9q8yy"); - } - } - } - actions -} - -fn push_action(actions: &mut Vec<String>, action: &str) { - if !actions.iter().any(|existing| existing == action) { - actions.push(action.to_owned()); - } -} - -fn terminal_field_name(field: FarmFieldArg) -> &'static str { - match field { - FarmFieldArg::Name => "Name", - FarmFieldArg::DisplayName => "Display name", - FarmFieldArg::About => "About", - FarmFieldArg::Website => "Website", - FarmFieldArg::Picture => "Picture", - FarmFieldArg::Banner => "Banner", - FarmFieldArg::Location => "Location", - FarmFieldArg::City => "City", - FarmFieldArg::Region => "Region", - FarmFieldArg::Country => "Country", - FarmFieldArg::Geohash => "Geohash", - FarmFieldArg::Delivery => "Delivery", - } -} - -fn terminal_field_value(field: FarmFieldArg, value: &str) -> String { - match field { - FarmFieldArg::Delivery => display_delivery_method(value), - _ => value.to_owned(), - } -} - -fn apply_field_update( - document: &mut FarmConfigDocument, - field: FarmFieldArg, - value: &str, -) -> Result<(), RuntimeError> { - let value = required_text(value, "farm set value")?; - match field { - FarmFieldArg::Name => { - document.profile.name = value.clone(); - document.farm.name = value; - } - FarmFieldArg::DisplayName => { - document.profile.display_name = Some(value); - } - FarmFieldArg::About => { - document.profile.about = Some(value.clone()); - document.farm.about = Some(value); - } - FarmFieldArg::Website => { - document.profile.website = Some(value.clone()); - document.farm.website = Some(value); - } - FarmFieldArg::Picture => { - document.profile.picture = Some(value.clone()); - document.farm.picture = Some(value); - } - FarmFieldArg::Banner => { - document.profile.banner = Some(value.clone()); - document.farm.banner = Some(value); - } - FarmFieldArg::Location => { - document.listing_defaults.location.primary = value.clone(); - ensure_farm_location(document).primary = value; - } - FarmFieldArg::City => { - document.listing_defaults.location.city = Some(value.clone()); - ensure_farm_location(document).city = Some(value); - } - FarmFieldArg::Region => { - document.listing_defaults.location.region = Some(value.clone()); - ensure_farm_location(document).region = Some(value); - } - FarmFieldArg::Country => { - document.listing_defaults.location.country = Some(value.clone()); - ensure_farm_location(document).country = Some(value); - } - FarmFieldArg::Geohash => { - document.listing_defaults.location.geohash = value.clone(); - ensure_farm_location(document).geohash = value; - } - FarmFieldArg::Delivery => { - document.listing_defaults.delivery_method = value; - } - } - Ok(()) -} - -fn ensure_farm_location(document: &mut FarmConfigDocument) -> &mut FarmPublicLocation { - let primary = document.listing_defaults.location.primary.clone(); - let city = document.listing_defaults.location.city.clone(); - let region = document.listing_defaults.location.region.clone(); - let country = document.listing_defaults.location.country.clone(); - let geohash = document.listing_defaults.location.geohash.clone(); - document.farm.location.get_or_insert(FarmPublicLocation { - primary, - city, - region, - country, - geohash, - }) -} - -fn publication_for_document( - existing_document: Option<&FarmConfigDocument>, - account: &AccountRecordView, - farm_d_tag: &str, -) -> FarmPublicationStatus { - existing_document - .filter(|document| { - document.farm.d_tag == farm_d_tag - && document.selection.account == account.record.id().to_hex() - }) - .map(|document| document.publication.clone()) - .unwrap_or_default() -} - -fn configured_account( - config: &RuntimeConfig, - account_id: &str, -) -> Result<Option<AccountRecordView>, RuntimeError> { - let snapshot = account::snapshot(config)?; - Ok(snapshot - .accounts - .into_iter() - .find(|account| account.record.id().to_hex() == account_id)) -} - -fn summary_view( - scope: FarmConfigScope, - path: String, - document: &FarmConfigDocument, - account_pubkey: Option<&str>, -) -> FarmConfigSummaryView { - FarmConfigSummaryView { - scope: scope.as_str().to_owned(), - path, - seller_account_id: document.selection.account.clone(), - seller_pubkey: account_pubkey.map(str::to_owned), - seller_actor_source: FARM_SELLER_ACTOR_SOURCE.to_owned(), - farm_d_tag: document.selection.farm_d_tag.clone(), - name: resolved_name(document).unwrap_or_default(), - location_primary: resolved_location_primary(document), - delivery_method: resolved_delivery_method(document).unwrap_or_default(), - publication: publication_view(&document.publication), - } -} - -fn document_view(document: &FarmConfigDocument) -> FarmConfigDocumentView { - FarmConfigDocumentView { - selection: FarmSelectionView { - scope: document.selection.scope.as_str().to_owned(), - seller_account_id: document.selection.account.clone(), - farm_d_tag: document.selection.farm_d_tag.clone(), - }, - profile: FarmProfileDraftView { - name: document.profile.name.clone(), - display_name: document.profile.display_name.clone(), - nip05: document.profile.nip05.clone(), - about: document.profile.about.clone(), - website: document.profile.website.clone(), - picture: document.profile.picture.clone(), - banner: document.profile.banner.clone(), - lud06: document.profile.lud06.clone(), - lud16: document.profile.lud16.clone(), - bot: document.profile.bot.clone(), - }, - farm: document.farm.clone(), - listing_defaults: FarmListingDefaultsView { - delivery_method: document.listing_defaults.delivery_method.clone(), - location: document.listing_defaults.location.clone(), - }, - publication: publication_view(&document.publication), - } -} - -fn publication_view(publication: &FarmPublicationStatus) -> FarmPublicationView { - FarmPublicationView { - profile_state: publish_state( - publication.profile_event_id.as_deref(), - publication.profile_published_at, - ) - .to_owned(), - farm_state: publish_state( - publication.farm_event_id.as_deref(), - publication.farm_published_at, - ) - .to_owned(), - profile_event_id: publication.profile_event_id.clone(), - farm_event_id: publication.farm_event_id.clone(), - profile_published_at: publication.profile_published_at, - farm_published_at: publication.farm_published_at, - } -} - -fn publish_state(event_id: Option<&str>, published_at: Option<u64>) -> &'static str { - if event_id.is_some_and(|value| !value.trim().is_empty()) || published_at.is_some() { - "published" - } else { - "not_published" - } -} - -fn scope_from_arg(scope: Option<FarmScopeArg>) -> Option<FarmConfigScope> { - scope.map(|scope| match scope { - FarmScopeArg::User => FarmConfigScope::User, - FarmScopeArg::Workspace => FarmConfigScope::Workspace, - }) -} - -fn required_d_tag(value: &str, field: &str) -> Result<String, RuntimeError> { - let value = required_text(value, field)?; - if !is_d_tag_base64url(value.as_str()) { - return Err(RuntimeError::Config(format!( - "{field} must be a 22-character base64url identifier" - ))); - } - Ok(value) -} - -fn required_text(value: &str, field: &str) -> Result<String, RuntimeError> { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err(RuntimeError::Config(format!("{field} must not be empty"))); - } - Ok(trimmed.to_owned()) -} - -fn optional_arg_or_existing(arg: Option<&String>, existing: Option<&String>) -> Option<String> { - arg.and_then(|value| non_empty(value.as_str())) - .or_else(|| existing.and_then(|value| non_empty(value.as_str()))) -} - -fn draft_name_from_account(account: &AccountRecordView) -> Option<String> { - account - .record - .label() - .and_then(non_empty) - .or_else(|| non_empty(account.record.id().to_hex().as_str())) -} - -fn existing_name(existing_document: Option<&FarmConfigDocument>) -> Option<String> { - existing_document.and_then(resolved_name) -} - -fn existing_location_primary(existing_document: Option<&FarmConfigDocument>) -> Option<String> { - existing_document.and_then(resolved_location_primary) -} - -fn existing_city(existing_document: Option<&FarmConfigDocument>) -> Option<String> { - existing_document - .and_then(|document| { - document - .farm - .location - .as_ref() - .and_then(|location| location.city.as_ref()) - }) - .and_then(|value| non_empty(value.as_str())) - .or_else(|| { - existing_document - .and_then(|document| document.listing_defaults.location.city.as_ref()) - .and_then(|value| non_empty(value.as_str())) - }) -} - -fn existing_region(existing_document: Option<&FarmConfigDocument>) -> Option<String> { - existing_document - .and_then(|document| { - document - .farm - .location - .as_ref() - .and_then(|location| location.region.as_ref()) - }) - .and_then(|value| non_empty(value.as_str())) - .or_else(|| { - existing_document - .and_then(|document| document.listing_defaults.location.region.as_ref()) - .and_then(|value| non_empty(value.as_str())) - }) -} - -fn existing_country(existing_document: Option<&FarmConfigDocument>) -> Option<String> { - existing_document - .and_then(|document| { - document - .farm - .location - .as_ref() - .and_then(|location| location.country.as_ref()) - }) - .and_then(|value| non_empty(value.as_str())) - .or_else(|| { - existing_document - .and_then(|document| document.listing_defaults.location.country.as_ref()) - .and_then(|value| non_empty(value.as_str())) - }) -} - -fn existing_geohash(existing_document: Option<&FarmConfigDocument>) -> Option<String> { - existing_document - .and_then(|document| { - document - .farm - .location - .as_ref() - .and_then(|location| non_empty(location.geohash.as_str())) - }) - .or_else(|| { - existing_document - .and_then(|document| non_empty(document.listing_defaults.location.geohash.as_str())) - }) -} - -fn existing_delivery_method(existing_document: Option<&FarmConfigDocument>) -> Option<String> { - existing_document - .and_then(|document| non_empty(document.listing_defaults.delivery_method.as_str())) -} - -fn resolved_name(document: &FarmConfigDocument) -> Option<String> { - non_empty(document.profile.name.as_str()).or_else(|| non_empty(document.farm.name.as_str())) -} - -fn resolved_location_primary(document: &FarmConfigDocument) -> Option<String> { - non_empty(document.listing_defaults.location.primary.as_str()).or_else(|| { - document - .farm - .location - .as_ref() - .and_then(|location| non_empty(location.primary.as_str())) - }) -} - -fn resolved_delivery_method(document: &FarmConfigDocument) -> Option<String> { - non_empty(document.listing_defaults.delivery_method.as_str()) -} - -fn display_delivery_method(value: &str) -> String { - value - .split('_') - .filter(|segment| !segment.is_empty()) - .map(capitalize_ascii_word) - .collect::<Vec<_>>() - .join(" ") -} - -fn capitalize_ascii_word(word: &str) -> String { - let mut chars = word.chars(); - let Some(first) = chars.next() else { - return String::new(); - }; - let mut rendered = String::new(); - rendered.push(first.to_ascii_uppercase()); - rendered.push_str(chars.as_str()); - rendered -} - -fn non_empty(value: &str) -> Option<String> { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .unwrap_or_default() -} - -fn generate_d_tag() -> String { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - let counter = D_TAG_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; - encode_base64url_no_pad((nanos ^ counter).to_be_bytes()) -} - -fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut output = String::with_capacity(22); - let mut index = 0usize; - while index + 3 <= bytes.len() { - let block = ((bytes[index] as u32) << 16) - | ((bytes[index + 1] as u32) << 8) - | (bytes[index + 2] as u32); - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); - output.push(ALPHABET[(block & 0x3f) as usize] as char); - index += 3; - } - let remaining = bytes.len() - index; - if remaining == 1 { - let block = (bytes[index] as u32) << 16; - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - } else if remaining == 2 { - let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8); - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); - } - output -} - -#[cfg(test)] -mod tests { - use super::{authored_profile_from_draft, generate_d_tag, require_verified_publish_media}; - use crate::runtime::RuntimeError; - use crate::runtime::farm_config::FarmProfileDraft; - use radroots_event::farm::Farm; - use radroots_event_codec::d_tag::is_d_tag_base64url; - use radroots_event_codec::profile::authored::authored_profile_to_wire_parts; - use serde_json::json; - - #[test] - fn generated_farm_d_tag_is_valid_base64url() { - assert!(is_d_tag_base64url(&generate_d_tag())); - } - - #[test] - fn farm_profile_draft_uses_strict_tagless_profile_authoring() { - let draft = FarmProfileDraft { - name: "moss street farm".to_owned(), - display_name: Some("Moss Street Farm".to_owned()), - nip05: Some("farm@example.com".to_owned()), - about: Some("Victoria produce".to_owned()), - website: Some("https://example.com".to_owned()), - bot: Some("true".to_owned()), - ..FarmProfileDraft::default() - }; - - let authored = authored_profile_from_draft(&draft).expect("strict authored Profile"); - let wire = authored_profile_to_wire_parts(&authored).expect("Profile wire parts"); - - assert!(wire.tags.is_empty()); - assert_eq!( - serde_json::from_str::<serde_json::Value>(&wire.content).expect("Profile metadata"), - json!({ - "name": "moss street farm", - "display_name": "Moss Street Farm", - "about": "Victoria produce", - "nip05": "farm@example.com", - "bot": true, - }) - ); - } - - #[test] - fn farm_profile_draft_rejects_non_boolean_legacy_bot_values() { - let draft = FarmProfileDraft { - name: "moss street farm".to_owned(), - bot: Some("yes".to_owned()), - ..FarmProfileDraft::default() - }; - - let RuntimeError::Config(message) = - authored_profile_from_draft(&draft).expect_err("invalid bot must fail closed") - else { - panic!("expected config error"); - }; - assert_eq!(message, "farm profile bot must be `true` or `false`"); - } - - #[test] - fn farm_publish_rejects_raw_media_without_blossom_proofs() { - let mut draft = FarmProfileDraft { - name: "moss street farm".to_owned(), - picture: Some("https://media.example/picture.jpg".to_owned()), - ..FarmProfileDraft::default() - }; - let mut farm = sample_farm(); - - assert_blossom_proof_error(require_verified_publish_media(&draft, &farm)); - - draft.picture = None; - farm.banner = Some("https://media.example/banner.jpg".to_owned()); - assert_blossom_proof_error(require_verified_publish_media(&draft, &farm)); - } - - fn sample_farm() -> Farm { - Farm { - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), - name: "Moss Street Farm".to_owned(), - about: None, - website: None, - picture: None, - banner: None, - location: None, - tags: None, - } - } - - fn assert_blossom_proof_error(result: Result<(), RuntimeError>) { - let RuntimeError::Config(message) = result.expect_err("raw media must fail closed") else { - panic!("expected config error"); - }; - assert!(message.contains("Blossom descriptor")); - assert!(message.contains("BUD-02 upload completion")); - } -} diff --git a/src/runtime/farm_config.rs b/src/runtime/farm_config.rs @@ -1,623 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; - -use radroots_event::farm::Farm; -use radroots_event::listing::operational::{ - OperationalListingDeliveryMethod, OperationalListingPublicLocation, -}; -use radroots_event_codec::d_tag::is_d_tag_base64url; -use serde::{Deserialize, Serialize}; - -use crate::runtime::RuntimeError; -use crate::runtime::config::{PathsConfig, RuntimeConfig}; - -const FARM_CONFIG_FILE_NAME: &str = "farm.toml"; -pub const SUPPORTED_FARM_CONFIG_VERSION: u32 = 1; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum FarmConfigScope { - User, - Workspace, -} - -impl FarmConfigScope { - pub fn as_str(self) -> &'static str { - match self { - Self::User => "user", - Self::Workspace => "workspace", - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FarmConfigDocument { - pub version: u32, - pub selection: FarmConfigSelection, - pub profile: FarmProfileDraft, - pub farm: Farm, - pub listing_defaults: FarmListingDefaults, - #[serde(default)] - pub publication: FarmPublicationStatus, -} - -/// CLI-owned local draft for a future kind-0 Profile replacement. -/// -/// String media references are deliberately retained only as local draft data. -/// They are not proof of Blossom byte verification or BUD-02 upload completion -/// and therefore cannot cross the Profile publication boundary. -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FarmProfileDraft { - pub name: String, - pub display_name: Option<String>, - pub nip05: Option<String>, - pub about: Option<String>, - pub website: Option<String>, - pub picture: Option<String>, - pub banner: Option<String>, - pub lud06: Option<String>, - pub lud16: Option<String>, - pub bot: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FarmConfigSelection { - pub scope: FarmConfigScope, - pub account: String, - pub farm_d_tag: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FarmListingDefaults { - pub delivery_method: String, - pub location: OperationalListingPublicLocation, -} - -impl FarmListingDefaults { - pub fn delivery_method_model(&self) -> Result<OperationalListingDeliveryMethod, RuntimeError> { - parse_delivery_method(self.delivery_method.as_str()) - } -} - -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FarmPublicationStatus { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile_event_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub farm_event_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub profile_published_at: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub farm_published_at: Option<u64>, -} - -#[derive(Debug, Clone)] -pub struct ResolvedFarmConfig { - pub scope: FarmConfigScope, - pub path: PathBuf, - pub document: FarmConfigDocument, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum FarmMissingField { - Name, - Location, - City, - Delivery, - Country, - Geohash, -} - -impl FarmMissingField { - pub fn label(self) -> &'static str { - match self { - Self::Name => "Farm name", - Self::Location => "Location", - Self::City => "City", - Self::Delivery => "Delivery method", - Self::Country => "Country", - Self::Geohash => "Geohash", - } - } -} - -pub fn resolve_scope( - paths: &PathsConfig, - explicit_scope: Option<FarmConfigScope>, -) -> Result<FarmConfigScope, RuntimeError> { - if let Some(scope) = explicit_scope { - return Ok(scope); - } - match paths.profile.as_str() { - "repo_local" => Ok(FarmConfigScope::Workspace), - "interactive_user" => Ok(FarmConfigScope::User), - other => Err(RuntimeError::Config(format!( - "unsupported farm config path profile `{other}`" - ))), - } -} - -pub fn user_config_path(paths: &PathsConfig) -> Result<PathBuf, RuntimeError> { - let Some(parent) = paths.app_config_path.parent() else { - return Err(RuntimeError::Config(format!( - "app config path {} has no parent directory", - paths.app_config_path.display() - ))); - }; - Ok(parent.join(FARM_CONFIG_FILE_NAME)) -} - -pub fn workspace_config_path(paths: &PathsConfig) -> Result<PathBuf, RuntimeError> { - let Some(path) = paths.workspace_config_path.as_ref() else { - return Err(RuntimeError::Config(format!( - "workspace farm config requires repo_local path profile, got `{}`", - paths.profile - ))); - }; - let Some(parent) = path.parent() else { - return Err(RuntimeError::Config(format!( - "workspace config path {} has no parent directory", - path.display() - ))); - }; - Ok(parent.join("config/apps/cli").join(FARM_CONFIG_FILE_NAME)) -} - -pub fn config_path(paths: &PathsConfig, scope: FarmConfigScope) -> Result<PathBuf, RuntimeError> { - match scope { - FarmConfigScope::User => user_config_path(paths), - FarmConfigScope::Workspace => workspace_config_path(paths), - } -} - -pub fn load( - config: &RuntimeConfig, - explicit_scope: Option<FarmConfigScope>, -) -> Result<Option<ResolvedFarmConfig>, RuntimeError> { - load_from_paths(&config.paths, explicit_scope) -} - -pub fn load_from_paths( - paths: &PathsConfig, - explicit_scope: Option<FarmConfigScope>, -) -> Result<Option<ResolvedFarmConfig>, RuntimeError> { - let scope = resolve_scope(paths, explicit_scope)?; - let path = config_path(paths, scope)?; - load_from_path(path.as_path(), scope) -} - -pub fn load_from_path( - path: &Path, - scope: FarmConfigScope, -) -> Result<Option<ResolvedFarmConfig>, RuntimeError> { - if !path.exists() { - return Ok(None); - } - let contents = fs::read_to_string(path)?; - let document: FarmConfigDocument = toml::from_str(contents.as_str()).map_err(|error| { - RuntimeError::Config(format!("parse farm config {}: {error}", path.display())) - })?; - validate(&document, scope)?; - Ok(Some(ResolvedFarmConfig { - scope, - path: path.to_path_buf(), - document, - })) -} - -pub fn write( - paths: &PathsConfig, - scope: FarmConfigScope, - document: &FarmConfigDocument, -) -> Result<PathBuf, RuntimeError> { - validate(document, scope)?; - let path = config_path(paths, scope)?; - let Some(parent) = path.parent() else { - return Err(RuntimeError::Config(format!( - "farm config path {} has no parent directory", - path.display() - ))); - }; - fs::create_dir_all(parent)?; - let encoded = toml::to_string_pretty(document).map_err(|error| { - RuntimeError::Config(format!("encode farm config {}: {error}", path.display())) - })?; - fs::write(&path, encoded)?; - Ok(path) -} - -pub fn validate( - document: &FarmConfigDocument, - resolved_scope: FarmConfigScope, -) -> Result<(), RuntimeError> { - if document.version != SUPPORTED_FARM_CONFIG_VERSION { - return Err(RuntimeError::Config(format!( - "farm config version must be {}, got {}", - SUPPORTED_FARM_CONFIG_VERSION, document.version - ))); - } - if document.selection.scope != resolved_scope { - return Err(RuntimeError::Config(format!( - "farm config scope `{}` does not match resolved `{}` scope", - document.selection.scope.as_str(), - resolved_scope.as_str() - ))); - } - if trimmed(document.selection.account.as_str()).is_empty() { - return Err(RuntimeError::Config( - "farm config selection.account must not be empty".to_owned(), - )); - } - if trimmed(document.selection.farm_d_tag.as_str()).is_empty() { - return Err(RuntimeError::Config( - "farm config selection.farm_d_tag must not be empty".to_owned(), - )); - } - if !is_d_tag_base64url(trimmed(document.selection.farm_d_tag.as_str())) { - return Err(RuntimeError::Config( - "farm config selection.farm_d_tag must be a 22-character base64url identifier" - .to_owned(), - )); - } - if trimmed(document.farm.d_tag.as_str()).is_empty() { - return Err(RuntimeError::Config( - "farm config farm.d_tag must not be empty".to_owned(), - )); - } - if !is_d_tag_base64url(trimmed(document.farm.d_tag.as_str())) { - return Err(RuntimeError::Config( - "farm config farm.d_tag must be a 22-character base64url identifier".to_owned(), - )); - } - if trimmed(document.selection.farm_d_tag.as_str()) != trimmed(document.farm.d_tag.as_str()) { - return Err(RuntimeError::Config( - "farm config selection.farm_d_tag must match farm.d_tag".to_owned(), - )); - } - if !trimmed(document.listing_defaults.delivery_method.as_str()).is_empty() { - let _ = document.listing_defaults.delivery_method_model()?; - } - Ok(()) -} - -pub fn missing_fields(document: &FarmConfigDocument) -> Vec<FarmMissingField> { - let mut missing = Vec::new(); - - if farm_name(document).is_none() { - missing.push(FarmMissingField::Name); - } - - let location_present = location_primary(document).is_some(); - if !location_present { - missing.push(FarmMissingField::Location); - } - if location_present && location_city(document).is_none() { - missing.push(FarmMissingField::City); - } - - if trimmed(document.listing_defaults.delivery_method.as_str()).is_empty() { - missing.push(FarmMissingField::Delivery); - } - - if location_present && location_country(document).is_none() { - missing.push(FarmMissingField::Country); - } - if location_present && location_geohash(document).is_none() { - missing.push(FarmMissingField::Geohash); - } - - missing -} - -fn farm_name(document: &FarmConfigDocument) -> Option<&str> { - non_empty_ref(document.profile.name.as_str()) - .or_else(|| non_empty_ref(document.farm.name.as_str())) -} - -fn location_primary(document: &FarmConfigDocument) -> Option<&str> { - non_empty_ref(document.listing_defaults.location.primary.as_str()).or_else(|| { - document - .farm - .location - .as_ref() - .and_then(|location| non_empty_ref(location.primary.as_str())) - }) -} - -fn location_country(document: &FarmConfigDocument) -> Option<&str> { - document - .listing_defaults - .location - .country - .as_deref() - .and_then(non_empty_ref) - .or_else(|| { - document - .farm - .location - .as_ref() - .and_then(|location| location.country.as_deref()) - .and_then(non_empty_ref) - }) -} - -fn location_city(document: &FarmConfigDocument) -> Option<&str> { - document - .listing_defaults - .location - .city - .as_deref() - .and_then(non_empty_ref) - .or_else(|| { - document - .farm - .location - .as_ref() - .and_then(|location| location.city.as_deref()) - .and_then(non_empty_ref) - }) -} - -fn location_geohash(document: &FarmConfigDocument) -> Option<&str> { - non_empty_ref(document.listing_defaults.location.geohash.as_str()).or_else(|| { - document - .farm - .location - .as_ref() - .and_then(|location| non_empty_ref(location.geohash.as_str())) - }) -} - -fn parse_delivery_method(value: &str) -> Result<OperationalListingDeliveryMethod, RuntimeError> { - let method = trimmed(value); - if method.is_empty() { - return Err(RuntimeError::Config( - "farm config listing_defaults.delivery_method must not be empty".to_owned(), - )); - } - Ok(match method { - "pickup" => OperationalListingDeliveryMethod::Pickup, - "local_delivery" => OperationalListingDeliveryMethod::LocalDelivery, - "shipping" => OperationalListingDeliveryMethod::Shipping, - other => OperationalListingDeliveryMethod::Other { - method: other.to_owned(), - }, - }) -} - -fn trimmed(value: &str) -> &str { - value.trim() -} - -fn non_empty_ref(value: &str) -> Option<&str> { - let trimmed = trimmed(value); - if trimmed.is_empty() { - None - } else { - Some(trimmed) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use std::path::PathBuf; - - use radroots_event::farm::FarmPublicLocation; - use tempfile::tempdir; - - fn sample_paths(profile: &str, root: &Path) -> PathsConfig { - let repo_local_root = root.join("infra/local/runtime/radroots"); - PathsConfig { - profile: profile.to_owned(), - profile_source: "test".to_owned(), - allowed_profiles: vec!["interactive_user".to_owned(), "repo_local".to_owned()], - root_source: "test".to_owned(), - repo_local_root: Some(repo_local_root.clone()), - repo_local_root_source: Some("test".to_owned()), - subordinate_path_override_source: "test".to_owned(), - app_namespace: "apps/cli".to_owned(), - shared_accounts_namespace: "shared/accounts".to_owned(), - shared_identities_namespace: "shared/identities".to_owned(), - app_config_path: root.join("home/.radroots/config/apps/cli/config.toml"), - workspace_config_path: (profile == "repo_local") - .then(|| repo_local_root.join("config.toml")), - app_data_root: root.join("home/.radroots/data/apps/cli"), - shared_cache_root: root.join("home/.radroots/cache"), - app_logs_root: root.join("home/.radroots/logs/apps/cli"), - shared_accounts_data_root: root.join("home/.radroots/data/shared/accounts"), - shared_accounts_secrets_root: root.join("home/.radroots/secrets/shared/accounts"), - default_identity_path: root - .join("home/.radroots/secrets/shared/identities/default.json"), - } - } - - fn sample_document(scope: FarmConfigScope) -> FarmConfigDocument { - FarmConfigDocument { - version: SUPPORTED_FARM_CONFIG_VERSION, - selection: FarmConfigSelection { - scope, - account: "seller".to_owned(), - farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), - }, - profile: FarmProfileDraft { - name: "La Huerta".to_owned(), - display_name: Some("La Huerta".to_owned()), - nip05: None, - about: Some("Small mixed vegetable farm.".to_owned()), - website: Some("https://example.invalid/la-huerta".to_owned()), - picture: None, - banner: None, - lud06: None, - lud16: None, - bot: None, - }, - farm: Farm { - d_tag: "AAAAAAAAAAAAAAAAAAAAAA".to_owned(), - name: "La Huerta".to_owned(), - about: Some("Small mixed vegetable farm.".to_owned()), - website: Some("https://example.invalid/la-huerta".to_owned()), - picture: None, - banner: None, - location: Some(FarmPublicLocation { - primary: "San Francisco, CA".to_owned(), - city: Some("San Francisco".to_owned()), - region: Some("CA".to_owned()), - country: Some("US".to_owned()), - geohash: "9q8yy".to_owned(), - }), - tags: None, - }, - listing_defaults: FarmListingDefaults { - delivery_method: "pickup".to_owned(), - location: OperationalListingPublicLocation { - primary: "San Francisco, CA".to_owned(), - city: Some("San Francisco".to_owned()), - region: Some("CA".to_owned()), - country: Some("US".to_owned()), - geohash: "9q8yy".to_owned(), - }, - }, - publication: FarmPublicationStatus::default(), - } - } - - #[test] - fn resolve_scope_defaults_from_runtime_profile() { - let dir = tempdir().expect("tempdir"); - let interactive_paths = sample_paths("interactive_user", dir.path()); - let repo_local_paths = sample_paths("repo_local", dir.path()); - - assert_eq!( - resolve_scope(&interactive_paths, None).expect("interactive scope"), - FarmConfigScope::User - ); - assert_eq!( - resolve_scope(&repo_local_paths, None).expect("repo_local scope"), - FarmConfigScope::Workspace - ); - } - - #[test] - fn explicit_scope_override_selects_requested_document() { - let dir = tempdir().expect("tempdir"); - let paths = sample_paths("repo_local", dir.path()); - let document = sample_document(FarmConfigScope::User); - let path = write(&paths, FarmConfigScope::User, &document).expect("write user farm config"); - - let resolved = - load_from_paths(&paths, Some(FarmConfigScope::User)).expect("load user farm config"); - let resolved = resolved.expect("resolved farm config"); - - assert_eq!(resolved.scope, FarmConfigScope::User); - assert_eq!(resolved.path, path); - assert_eq!(resolved.document.selection.account, "seller"); - assert_eq!(resolved.document.selection.scope, FarmConfigScope::User); - } - - #[test] - fn write_and_load_workspace_config_round_trip() { - let dir = tempdir().expect("tempdir"); - let paths = sample_paths("repo_local", dir.path()); - let document = sample_document(FarmConfigScope::Workspace); - let expected_path = PathBuf::from(dir.path()) - .join("infra/local/runtime/radroots/config/apps/cli/farm.toml"); - - let written_path = - write(&paths, FarmConfigScope::Workspace, &document).expect("write workspace config"); - let resolved = load_from_paths(&paths, None).expect("load workspace config"); - let resolved = resolved.expect("resolved farm config"); - - assert_eq!(written_path, expected_path); - assert_eq!(resolved.path, expected_path); - assert_eq!(resolved.scope, FarmConfigScope::Workspace); - assert_eq!( - resolved.document.selection.scope, - FarmConfigScope::Workspace - ); - assert_eq!( - resolved.document.selection.farm_d_tag, - "AAAAAAAAAAAAAAAAAAAAAA" - ); - assert_eq!(resolved.document.farm.d_tag, "AAAAAAAAAAAAAAAAAAAAAA"); - assert_eq!( - resolved.document.profile.website.as_deref(), - Some("https://example.invalid/la-huerta") - ); - assert_eq!( - resolved.document.listing_defaults.location.primary, - "San Francisco, CA" - ); - } - - #[test] - fn workspace_config_write_requires_repo_local_profile() { - let dir = tempdir().expect("tempdir"); - let paths = sample_paths("interactive_user", dir.path()); - let document = sample_document(FarmConfigScope::Workspace); - let repo_local_root = dir.path().join("infra/local/runtime/radroots"); - - let error = write(&paths, FarmConfigScope::Workspace, &document) - .expect_err("interactive workspace farm config should fail"); - - match error { - RuntimeError::Config(message) => { - assert!(message.contains("requires repo_local path profile")); - assert!(message.contains("interactive_user")); - } - other => panic!("expected config error, got {other:?}"), - } - assert!(!repo_local_root.exists()); - } - - #[test] - fn load_rejects_scope_mismatch() { - let dir = tempdir().expect("tempdir"); - let paths = sample_paths("repo_local", dir.path()); - let path = workspace_config_path(&paths).expect("workspace farm path"); - let Some(parent) = path.parent() else { - panic!("workspace farm path should have parent"); - }; - fs::create_dir_all(parent).expect("create workspace farm config dir"); - let contents = toml::to_string_pretty(&sample_document(FarmConfigScope::User)) - .expect("encode mismatched farm config"); - fs::write(&path, contents).expect("write mismatched farm config"); - - let error = load_from_paths(&paths, None).expect_err("scope mismatch should fail"); - match error { - RuntimeError::Config(message) => { - assert!(message.contains("does not match resolved `workspace` scope")); - } - other => panic!("expected config error, got {other:?}"), - } - } - - #[test] - fn load_rejects_unsupported_version() { - let dir = tempdir().expect("tempdir"); - let paths = sample_paths("interactive_user", dir.path()); - let path = user_config_path(&paths).expect("user farm path"); - let Some(parent) = path.parent() else { - panic!("user farm path should have parent"); - }; - fs::create_dir_all(parent).expect("create user farm config dir"); - let mut document = sample_document(FarmConfigScope::User); - document.version = 2; - let contents = toml::to_string_pretty(&document).expect("encode version mismatch"); - fs::write(&path, contents).expect("write version mismatch config"); - - let error = load_from_paths(&paths, None).expect_err("version mismatch should fail"); - match error { - RuntimeError::Config(message) => { - assert!(message.contains("farm config version must be 1, got 2")); - } - other => panic!("expected config error, got {other:?}"), - } - } -} diff --git a/src/runtime/find.rs b/src/runtime/find.rs @@ -1,235 +0,0 @@ -use radroots_replica_store::ReplicaSql; -use radroots_sql_core::SqlxSqliteExecutor; - -use crate::cli::global::FindQueryArgs; -use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; -use crate::runtime::hyf::{self, HyfQueryRewriteRequest, HyfRequestContext}; -use crate::runtime::sync::{ - RelayIngestScope, freshness_for_scope_from_executor, freshness_requires_refresh, - market_refresh, missing_freshness, -}; -use crate::view::runtime::{ - FindHyfView, FindPriceView, FindQuantityView, FindResultHyfView, FindResultProvenanceView, - FindResultView, FindView, MarketReadinessView, -}; - -const FIND_SOURCE: &str = "local replica · local first"; -const FIND_HYF_SOURCE: &str = "hyf query_rewrite · local first"; -const FIND_HYF_QUERY_REWRITE_REQUEST_ID: &str = "cli-find-query-rewrite"; - -#[derive(Debug, Clone)] -struct AppliedQueryRewrite { - rewritten_query: String, - query_terms: Vec<String>, -} - -impl AppliedQueryRewrite { - fn to_find_view(&self) -> FindHyfView { - FindHyfView { - state: "query_rewrite_applied".to_owned(), - source: FIND_HYF_SOURCE.to_owned(), - rewritten_query: self.rewritten_query.clone(), - query_terms: self.query_terms.clone(), - } - } - - fn to_result_view(&self) -> FindResultHyfView { - FindResultHyfView { - state: "query_rewrite_applied".to_owned(), - rewritten_query: self.rewritten_query.clone(), - query_terms: self.query_terms.clone(), - } - } -} - -pub fn search(config: &RuntimeConfig, args: &FindQueryArgs) -> Result<FindView, RuntimeError> { - let query = args.query.join(" "); - if !config.local.replica_store_path.exists() { - return Ok(FindView { - state: "unconfigured".to_owned(), - source: FIND_SOURCE.to_owned(), - query, - count: 0, - relay_count: config.transport.nostr_relay_urls.len(), - replica_store: config.local.replica_store_path.display().to_string(), - freshness: missing_freshness(), - results: Vec::new(), - hyf: None, - reason: Some("local replica database is not initialized".to_owned()), - actions: vec!["radroots store inspect".to_owned()], - }); - } - - refresh_market_if_needed(config)?; - let db = ReplicaSql::new(SqlxSqliteExecutor::open(&config.local.replica_store_path)?); - let freshness = - freshness_for_scope_from_executor(config, db.executor(), RelayIngestScope::MarketPull)?; - let applied_query_rewrite = attempt_query_rewrite(config, query.as_str(), &args.query); - let effective_query_terms = applied_query_rewrite - .as_ref() - .map(|rewrite| rewrite.query_terms.clone()) - .unwrap_or_else(|| normalize_query_terms(args.query.clone())); - let rows = db.trade_product_search(effective_query_terms.as_slice())?; - let relay_count = config.transport.nostr_relay_urls.len(); - let result_provenance = FindResultProvenanceView { - origin: "local_replica.trade_product".to_owned(), - freshness: freshness.display.clone(), - relay_count, - }; - let results = rows - .into_iter() - .map(|row| { - let listing_addr = row.listing_addr.and_then(non_empty); - let primary_bin_id = row.primary_bin_id.and_then(non_empty); - let verified_primary_bin_id = row.verified_primary_bin_id.and_then(non_empty); - let available_amount = row.qty_avail; - let price_amount = row.price_amt; - let price_currency = row.price_currency; - let price_per_amount = row.price_qty_amt; - let readiness = MarketReadinessView::from_market_projection( - listing_addr.as_deref(), - primary_bin_id.as_deref(), - verified_primary_bin_id.as_deref(), - Some(row.title.as_str()), - Some(row.category.as_str()), - available_amount, - price_amount, - price_currency.as_str(), - price_per_amount, - ); - FindResultView { - id: row.id, - product_key: row.key, - readiness, - listing_addr, - primary_bin_id, - title: row.title, - category: row.category, - summary: non_empty(row.summary), - location_primary: row.location_primary.and_then(non_empty), - available: FindQuantityView { - total_amount: row.qty_amt, - total_unit: row.qty_unit, - label: row.qty_label.and_then(non_empty), - available_amount, - }, - price: FindPriceView { - amount: price_amount, - currency: price_currency, - per_amount: price_per_amount, - per_unit: row.price_qty_unit, - }, - provenance: result_provenance.clone(), - hyf: applied_query_rewrite - .as_ref() - .map(AppliedQueryRewrite::to_result_view), - } - }) - .collect::<Vec<_>>(); - - let (state, reason, actions) = if results.is_empty() { - let actions = if freshness.state == "never" { - vec!["radroots sync status".to_owned()] - } else { - Vec::new() - }; - ( - "empty".to_owned(), - Some(format!("no local market results matched `{query}`")), - actions, - ) - } else { - ("ready".to_owned(), None, Vec::new()) - }; - - Ok(FindView { - state, - source: FIND_SOURCE.to_owned(), - query, - count: results.len(), - relay_count, - replica_store: config.local.replica_store_path.display().to_string(), - freshness, - results, - hyf: applied_query_rewrite.map(|rewrite| rewrite.to_find_view()), - reason, - actions, - }) -} - -fn refresh_market_if_needed(config: &RuntimeConfig) -> Result<(), RuntimeError> { - if config.output.dry_run || config.transport.nostr_relay_urls.is_empty() { - return Ok(()); - } - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - let freshness = - freshness_for_scope_from_executor(config, &executor, RelayIngestScope::MarketPull)?; - if freshness_requires_refresh(&freshness) { - let _ = market_refresh(config)?; - } - Ok(()) -} - -fn attempt_query_rewrite( - config: &RuntimeConfig, - query: &str, - original_terms: &[String], -) -> Option<AppliedQueryRewrite> { - if query.trim().is_empty() { - return None; - } - - let client = hyf::resolve_runtime_client(config).ok()?; - let response = client - .query_rewrite( - FIND_HYF_QUERY_REWRITE_REQUEST_ID, - Some(FIND_HYF_QUERY_REWRITE_REQUEST_ID), - &HyfRequestContext::deterministic_cli(), - &HyfQueryRewriteRequest::new(query), - ) - .ok()?; - - let rewritten_terms = normalize_query_terms(response.output.query_terms.clone()); - if rewritten_terms.is_empty() { - return None; - } - - if rewritten_terms == normalize_query_terms(original_terms.iter().cloned()) { - return None; - } - - let rewritten_query = { - let rewritten_text = response.output.rewritten_text.trim(); - if rewritten_text.is_empty() { - rewritten_terms.join(" ") - } else { - rewritten_text.to_owned() - } - }; - - Some(AppliedQueryRewrite { - rewritten_query, - query_terms: rewritten_terms, - }) -} - -fn non_empty(value: String) -> Option<String> { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -fn normalize_query_terms<I>(terms: I) -> Vec<String> -where - I: IntoIterator<Item = String>, -{ - terms - .into_iter() - .map(|term| term.trim().to_lowercase()) - .filter(|term| !term.is_empty()) - .collect() -} diff --git a/src/runtime/hyf.rs b/src/runtime/hyf.rs @@ -1,1118 +0,0 @@ -use std::collections::BTreeMap; -use std::io::{Read, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, ExitStatus, Output, Stdio}; -use std::thread; -use std::time::{Duration, Instant}; - -use serde::{Deserialize, Serialize}; -use serde_json::Value; - -use crate::runtime::config::{ - CapabilityBindingTargetKind, HyfConfig, INFERENCE_HYF_STDIO_CAPABILITY, RuntimeConfig, -}; - -const HYF_CONTROL_TIMEOUT: Duration = Duration::from_secs(2); -const HYF_BUSINESS_TIMEOUT: Duration = Duration::from_secs(4); -const HYF_TIMEOUT_POLL_INTERVAL: Duration = Duration::from_millis(10); -const HYF_STATUS_REQUEST_ID: &str = "cli-doctor-hyf-status"; -const HYF_CAPABILITIES_REQUEST_ID: &str = "cli-runtime-hyf-capabilities"; -const HYF_SOURCE: &str = "hyf status control request · local first"; -const HYF_PROTOCOL_VERSION: u64 = 1; -const HYF_CONSUMER: &str = "radroots-cli"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HyfStatusView { - pub executable: String, - pub state: String, - pub source: String, - pub reason: Option<String>, - pub protocol_version: Option<u64>, - pub deterministic_available: Option<bool>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HyfClient { - executable: PathBuf, - timeouts: HyfClientTimeouts, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct HyfClientTimeouts { - control: Duration, - business: Duration, -} - -impl Default for HyfClientTimeouts { - fn default() -> Self { - Self { - control: HYF_CONTROL_TIMEOUT, - business: HYF_BUSINESS_TIMEOUT, - } - } -} - -impl HyfClient { - pub fn new(executable: PathBuf) -> Self { - Self { - executable, - timeouts: HyfClientTimeouts::default(), - } - } - - #[cfg(test)] - fn with_timeouts(executable: PathBuf, control: Duration, business: Duration) -> Self { - Self { - executable, - timeouts: HyfClientTimeouts { control, business }, - } - } - - pub fn executable(&self) -> &Path { - self.executable.as_path() - } - - pub fn status(&self) -> Result<HyfSuccess<HyfStatusOutput>, HyfClientError> { - self.call( - HYF_STATUS_REQUEST_ID, - Some(HYF_STATUS_REQUEST_ID), - "sys.status", - None, - &HyfEmptyInput::default(), - self.timeouts.control, - ) - } - - pub fn capabilities(&self) -> Result<HyfSuccess<HyfCapabilitiesOutput>, HyfClientError> { - self.call( - HYF_CAPABILITIES_REQUEST_ID, - None, - "sys.capabilities", - None, - &HyfEmptyInput::default(), - self.timeouts.control, - ) - } - - pub fn query_rewrite( - &self, - request_id: &str, - trace_id: Option<&str>, - context: &HyfRequestContext, - request: &HyfQueryRewriteRequest, - ) -> Result<HyfSuccess<HyfQueryRewriteOutput>, HyfClientError> { - self.call( - request_id, - trace_id, - "query_rewrite", - Some(context), - request, - self.timeouts.business, - ) - } - - pub fn semantic_rank( - &self, - request_id: &str, - trace_id: Option<&str>, - context: &HyfRequestContext, - request: &HyfSemanticRankRequest, - ) -> Result<HyfSuccess<HyfSemanticRankOutput>, HyfClientError> { - self.call( - request_id, - trace_id, - "semantic_rank", - Some(context), - request, - self.timeouts.business, - ) - } - - pub fn explain_result( - &self, - request_id: &str, - trace_id: Option<&str>, - context: &HyfRequestContext, - request: &HyfExplainResultRequest, - ) -> Result<HyfSuccess<HyfExplainResultOutput>, HyfClientError> { - self.call( - request_id, - trace_id, - "explain_result", - Some(context), - request, - self.timeouts.business, - ) - } - - fn call<TRequest, TResponse>( - &self, - request_id: &str, - trace_id: Option<&str>, - capability: &str, - context: Option<&HyfRequestContext>, - input: &TRequest, - timeout: Duration, - ) -> Result<HyfSuccess<TResponse>, HyfClientError> - where - TRequest: Serialize, - TResponse: for<'de> Deserialize<'de>, - { - let request = serialize_request(request_id, trace_id, capability, context, input) - .map_err(HyfClientError::SerializeRequest)?; - - let output = self.run_request(request.as_str(), timeout)?; - let stdout = String::from_utf8(output.stdout).map_err(HyfClientError::InvalidUtf8)?; - let response: HyfWireResponse<TResponse> = - serde_json::from_str(stdout.as_str()).map_err(HyfClientError::InvalidJson)?; - - if !response.ok { - return Err(HyfClientError::RemoteError { - code: response.error.as_ref().and_then(|error| error.code.clone()), - message: response - .error - .as_ref() - .and_then(|error| error.message.clone()), - }); - } - - let Some(output) = response.output else { - return Err(HyfClientError::InvalidResponse( - "hyf response omitted output for a successful request".to_owned(), - )); - }; - - Ok(HyfSuccess { - version: response.version, - request_id: response.request_id, - trace_id: response.trace_id, - output, - meta: response.meta, - }) - } - - fn run_request(&self, request: &str, timeout: Duration) -> Result<Output, HyfClientError> { - let mut child = Command::new(&self.executable) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(|error| match error.kind() { - std::io::ErrorKind::NotFound => HyfClientError::NotFound, - _ => HyfClientError::Start(error), - })?; - - if let Some(mut stdin) = child.stdin.take() { - writeln!(stdin, "{request}").map_err(HyfClientError::Write)?; - } - - let output = collect_output_with_timeout(child, timeout)?; - if !output.status.success() { - return Err(HyfClientError::NonZeroExit { - status: output.status.code(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(), - }); - } - Ok(output) - } -} - -fn serialize_request<TRequest: Serialize>( - request_id: &str, - trace_id: Option<&str>, - capability: &str, - context: Option<&HyfRequestContext>, - input: &TRequest, -) -> Result<String, serde_json::Error> { - serde_json::to_string(&HyfRequestEnvelope { - version: HYF_PROTOCOL_VERSION, - request_id, - trace_id, - capability, - context, - input, - }) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HyfSuccess<T> { - pub version: u64, - pub request_id: String, - pub trace_id: Option<String>, - pub output: T, - pub meta: Option<Value>, -} - -#[derive(Debug, thiserror::Error)] -pub enum HyfClientError { - #[error("hyf executable was not found")] - NotFound, - #[error("failed to start hyf request: {0}")] - Start(std::io::Error), - #[error("failed to write hyf request stdin: {0}")] - Write(std::io::Error), - #[error("failed to wait on hyf request: {0}")] - Wait(std::io::Error), - #[error("failed to read hyf request output: {0}")] - Read(std::io::Error), - #[error("hyf request timed out after {0}ms")] - Timeout(u128), - #[error("hyf request exited unsuccessfully")] - NonZeroExit { status: Option<i32>, stderr: String }, - #[error("failed to serialize hyf request: {0}")] - SerializeRequest(serde_json::Error), - #[error("hyf response was not valid UTF-8: {0}")] - InvalidUtf8(std::string::FromUtf8Error), - #[error("hyf response was not valid JSON: {0}")] - InvalidJson(serde_json::Error), - #[error("{0}")] - InvalidResponse(String), - #[error("hyf request returned a remote error")] - RemoteError { - code: Option<String>, - message: Option<String>, - }, -} - -#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] -pub struct HyfRequestContext { - #[serde(skip_serializing_if = "Option::is_none")] - pub consumer: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub execution_mode_preference: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub scope: Option<HyfRequestScope>, - #[serde(skip_serializing_if = "Option::is_none")] - pub return_provenance: Option<bool>, -} - -impl HyfRequestContext { - pub fn deterministic_cli() -> Self { - Self { - consumer: Some(HYF_CONSUMER.to_owned()), - execution_mode_preference: Some("deterministic".to_owned()), - scope: None, - return_provenance: None, - } - } - - pub fn with_return_provenance(mut self, return_provenance: bool) -> Self { - self.return_provenance = Some(return_provenance); - self - } - - pub fn with_listing_scope(mut self, listing_ids: Vec<String>) -> Self { - self.scope = if listing_ids.is_empty() { - None - } else { - Some(HyfRequestScope { listing_ids }) - }; - self - } -} - -#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] -pub struct HyfRequestScope { - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub listing_ids: Vec<String>, -} - -#[derive(Debug, Clone, Serialize, PartialEq, Eq)] -pub struct HyfQueryRewriteRequest { - pub query: String, -} - -impl HyfQueryRewriteRequest { - pub fn new(query: impl Into<String>) -> Self { - Self { - query: query.into(), - } - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] -pub struct HyfSemanticCandidate { - pub id: String, - pub title: String, - pub farm: String, - pub delivery: String, - pub distance_km: f64, - pub freshness_minutes: i64, -} - -#[derive(Debug, Clone, Serialize, PartialEq)] -pub struct HyfSemanticRankRequest { - pub query: String, - pub candidates: Vec<HyfSemanticCandidate>, -} - -impl HyfSemanticRankRequest { - pub fn new(query: impl Into<String>, candidates: Vec<HyfSemanticCandidate>) -> Self { - Self { - query: query.into(), - candidates, - } - } -} - -#[derive(Debug, Clone, Serialize, PartialEq)] -pub struct HyfExplainResultRequest { - pub query: String, - pub candidate: HyfSemanticCandidate, -} - -impl HyfExplainResultRequest { - pub fn new(query: impl Into<String>, candidate: HyfSemanticCandidate) -> Self { - Self { - query: query.into(), - candidate, - } - } -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfBuildIdentity { - pub protocol_version: u64, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfExecutionModes { - pub deterministic: bool, - #[serde(default)] - pub assisted: Option<bool>, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfStatusOutput { - pub build_identity: HyfBuildIdentity, - pub enabled_execution_modes: HyfExecutionModes, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfRequestContextContract { - pub accepted_features: Vec<String>, - pub effective_features: Vec<String>, - pub unsupported_field_behavior: String, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfBusinessCapability { - pub id: String, - pub kind: String, - pub deterministic_execution: String, - pub implementation_status: String, - pub callable: bool, - pub implemented: bool, - pub assisted_execution: String, - pub assisted_backend_available: bool, - #[serde(default)] - pub disabled_reason: Option<String>, -} - -#[derive(Debug, Clone, Deserialize, PartialEq)] -pub struct HyfCapabilitiesOutput { - pub control_routes: Vec<String>, - pub business_capabilities: Vec<HyfBusinessCapability>, - pub assisted_backend_capabilities: Vec<Value>, - pub request_context_contract: HyfRequestContextContract, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfExtractedFilters { - pub local_intent: bool, - pub fulfillment: String, - pub time_window: String, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfQueryRewriteOutput { - pub original_text: String, - pub normalized_text: String, - pub rewritten_text: String, - pub query_terms: Vec<String>, - pub normalization_signals: Vec<String>, - pub ranking_hints: Vec<String>, - pub extracted_filters: HyfExtractedFilters, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfScoredCandidate { - pub id: String, - pub heuristic_score: i64, - pub matched_terms: Vec<String>, - pub reasons: Vec<String>, - pub delivery_alignment: String, - pub distance_band: String, - pub freshness_band: String, - pub scope_match: bool, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfSemanticRankOutput { - pub ranked_ids: Vec<String>, - pub reasons: BTreeMap<String, Vec<String>>, - pub scored_candidates: Vec<HyfScoredCandidate>, - pub ranking_hints: Vec<String>, - pub extracted_filters: HyfExtractedFilters, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfSignalAssessment { - pub delivery_alignment: String, - pub distance_band: String, - pub freshness_band: String, - pub scope_match: bool, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct HyfExplainResultOutput { - pub result_id: String, - pub explanation_kind: String, - pub summary: String, - pub score: i64, - pub reasons: Vec<String>, - pub matched_terms: Vec<String>, - pub ranking_hints: Vec<String>, - pub extracted_filters: HyfExtractedFilters, - pub signal_assessment: HyfSignalAssessment, -} - -#[derive(Debug, Clone, Serialize, Default)] -struct HyfEmptyInput {} - -#[derive(Debug, Serialize)] -struct HyfRequestEnvelope<'a, T> { - version: u64, - request_id: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - trace_id: Option<&'a str>, - capability: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - context: Option<&'a HyfRequestContext>, - input: &'a T, -} - -#[derive(Debug, Deserialize)] -#[serde(bound(deserialize = "T: Deserialize<'de>"))] -struct HyfWireResponse<T> { - version: u64, - request_id: String, - #[serde(default)] - trace_id: Option<String>, - ok: bool, - #[serde(default)] - output: Option<T>, - #[serde(default)] - meta: Option<Value>, - #[serde(default)] - error: Option<HyfWireError>, -} - -#[derive(Debug, Clone, Deserialize)] -struct HyfWireError { - #[serde(default)] - code: Option<String>, - #[serde(default)] - message: Option<String>, -} - -pub fn resolve_runtime_client(config: &RuntimeConfig) -> Result<HyfClient, HyfStatusView> { - if !config.hyf.enabled { - return Err(disabled_status(config.hyf.executable.display().to_string())); - } - - let Some(binding) = config.capability_binding(INFERENCE_HYF_STDIO_CAPABILITY) else { - return resolve_client(&config.hyf); - }; - - match binding.target_kind { - CapabilityBindingTargetKind::ExplicitEndpoint => resolve_client(&HyfConfig { - enabled: true, - executable: binding.target.clone().into(), - }), - CapabilityBindingTargetKind::ManagedInstance => Err(unavailable_status( - config.hyf.executable.display().to_string(), - format!( - "configured hyf binding target `{}` uses unsupported target_kind `managed_instance`; use `explicit_endpoint` for `inference.hyf_stdio`", - binding.target - ), - None, - None, - )), - } -} - -pub fn resolve_runtime_status(config: &RuntimeConfig) -> HyfStatusView { - match resolve_runtime_client(config) { - Ok(client) => resolve_status_for_client(&client), - Err(view) => view, - } -} - -pub fn resolve_status(config: &HyfConfig) -> HyfStatusView { - match resolve_client(config) { - Ok(client) => resolve_status_for_client(&client), - Err(view) => view, - } -} - -fn resolve_client(config: &HyfConfig) -> Result<HyfClient, HyfStatusView> { - let executable = config.executable.display().to_string(); - if !config.enabled { - return Err(disabled_status(executable)); - } - - if config.executable.as_os_str().is_empty() { - return Err(unavailable_status( - executable, - "hyf executable path is not configured".to_owned(), - None, - None, - )); - } - - Ok(HyfClient::new(config.executable.clone())) -} - -fn resolve_status_for_client(client: &HyfClient) -> HyfStatusView { - let executable = client.executable().display().to_string(); - let response = match client.status() { - Ok(response) => response, - Err(HyfClientError::NotFound) => { - return unavailable_status( - executable, - format!( - "hyf executable was not found at {}", - client.executable().display() - ), - None, - None, - ); - } - Err(HyfClientError::Start(error)) => { - return unavailable_status( - executable, - format!( - "failed to start hyf control request at {}: {error}", - client.executable().display() - ), - None, - None, - ); - } - Err(HyfClientError::Write(error)) => { - return unavailable_status( - executable, - format!("failed to write hyf control request stdin: {error}"), - None, - None, - ); - } - Err(HyfClientError::Timeout(timeout_ms)) => { - return unavailable_status( - executable, - format!("hyf status control request timed out after {timeout_ms}ms"), - None, - None, - ); - } - Err(HyfClientError::Wait(error)) | Err(HyfClientError::Read(error)) => { - return unavailable_status( - executable, - format!("failed to capture hyf status control output: {error}"), - None, - None, - ); - } - Err(HyfClientError::NonZeroExit { status, stderr }) => { - return unavailable_status( - executable, - format_nonzero_exit("hyf status control request", status, stderr.as_str()), - None, - None, - ); - } - Err(HyfClientError::InvalidUtf8(error)) => { - return unavailable_status( - executable, - format!("hyf status output was not valid UTF-8: {error}"), - None, - None, - ); - } - Err(HyfClientError::InvalidJson(error)) => { - return unavailable_status( - executable, - format!("hyf status output was not valid JSON: {error}"), - None, - None, - ); - } - Err(HyfClientError::RemoteError { code, .. }) => { - let reason = code - .map(|code| format!("hyf status control request returned error code {code}")) - .unwrap_or_else(|| { - "hyf status control request returned an invalid error response".to_owned() - }); - return unavailable_status(executable, reason, None, None); - } - Err(HyfClientError::SerializeRequest(_) | HyfClientError::InvalidResponse(_)) => { - return unavailable_status( - executable, - "hyf status control request returned an invalid error response".to_owned(), - None, - None, - ); - } - }; - - let protocol_version = Some(response.output.build_identity.protocol_version); - let deterministic_available = Some(response.output.enabled_execution_modes.deterministic); - - if response.version != HYF_PROTOCOL_VERSION { - return unavailable_status( - executable, - format!( - "hyf status response version {:?} is incompatible with cli expected {}", - Some(response.version), - HYF_PROTOCOL_VERSION - ), - protocol_version, - deterministic_available, - ); - } - - if response.request_id != HYF_STATUS_REQUEST_ID { - return unavailable_status( - executable, - "hyf status response did not preserve the control request id".to_owned(), - protocol_version, - deterministic_available, - ); - } - - if protocol_version != Some(HYF_PROTOCOL_VERSION) { - return unavailable_status( - executable, - format!( - "hyf protocol version {:?} is incompatible with cli expected {}", - protocol_version, HYF_PROTOCOL_VERSION - ), - protocol_version, - deterministic_available, - ); - } - - if deterministic_available != Some(true) { - return unavailable_status( - executable, - "hyf deterministic execution is unavailable".to_owned(), - protocol_version, - deterministic_available, - ); - } - - HyfStatusView { - executable, - state: "ready".to_owned(), - source: HYF_SOURCE.to_owned(), - reason: Some("healthy · protocol 1 · deterministic available".to_owned()), - protocol_version, - deterministic_available, - } -} - -fn collect_output_with_timeout( - mut child: Child, - timeout: Duration, -) -> Result<Output, HyfClientError> { - let started_at = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(status)) => return collect_output(child, status), - Ok(None) => { - if started_at.elapsed() >= timeout { - let _ = child.kill(); - let _ = child.wait(); - return Err(HyfClientError::Timeout(timeout.as_millis())); - } - thread::sleep(HYF_TIMEOUT_POLL_INTERVAL); - } - Err(error) => { - let _ = child.kill(); - let _ = child.wait(); - return Err(HyfClientError::Wait(error)); - } - } - } -} - -fn collect_output(mut child: Child, status: ExitStatus) -> Result<Output, HyfClientError> { - let mut stdout = Vec::new(); - let mut stderr = Vec::new(); - - if let Some(mut pipe) = child.stdout.take() { - pipe.read_to_end(&mut stdout) - .map_err(HyfClientError::Read)?; - } - if let Some(mut pipe) = child.stderr.take() { - pipe.read_to_end(&mut stderr) - .map_err(HyfClientError::Read)?; - } - - Ok(Output { - status, - stdout, - stderr, - }) -} - -fn disabled_status(executable: String) -> HyfStatusView { - HyfStatusView { - executable, - state: "disabled".to_owned(), - source: HYF_SOURCE.to_owned(), - reason: Some("disabled by config".to_owned()), - protocol_version: None, - deterministic_available: None, - } -} - -fn unavailable_status( - executable: String, - reason: String, - protocol_version: Option<u64>, - deterministic_available: Option<bool>, -) -> HyfStatusView { - HyfStatusView { - executable, - state: "unavailable".to_owned(), - source: HYF_SOURCE.to_owned(), - reason: Some(reason), - protocol_version, - deterministic_available, - } -} - -fn format_nonzero_exit(request_label: &str, status: Option<i32>, stderr: &str) -> String { - match status { - Some(code) if stderr.is_empty() => { - format!("{request_label} exited with status code {code}") - } - Some(code) => { - format!("{request_label} exited with status code {code}: {stderr}") - } - None if stderr.is_empty() => format!("{request_label} terminated by signal"), - None => format!("{request_label} terminated by signal: {stderr}"), - } -} - -#[cfg(test)] -mod tests { - use super::{ - HYF_PROTOCOL_VERSION, HyfClient, HyfClientError, HyfEmptyInput, HyfExplainResultRequest, - HyfQueryRewriteRequest, HyfRequestContext, HyfSemanticCandidate, HyfSemanticRankRequest, - resolve_status, - }; - use crate::runtime::config::HyfConfig; - use serde::Serialize; - use serde_json::Value; - use std::fs; - use std::os::unix::fs::PermissionsExt; - use std::path::{Path, PathBuf}; - use std::sync::{Mutex, MutexGuard, OnceLock}; - use std::time::Duration; - use tempfile::tempdir; - - fn hyf_test_lock() -> &'static Mutex<()> { - static LOCK: OnceLock<Mutex<()>> = OnceLock::new(); - LOCK.get_or_init(|| Mutex::new(())) - } - - fn lock_hyf_tests() -> MutexGuard<'static, ()> { - hyf_test_lock() - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - } - - #[test] - fn disabled_hyf_reports_disabled_state_without_spawning() { - let _guard = lock_hyf_tests(); - let view = resolve_status(&HyfConfig { - enabled: false, - executable: "hyfd".into(), - }); - assert_eq!(view.state, "disabled"); - assert_eq!(view.reason.as_deref(), Some("disabled by config")); - } - - #[test] - fn healthy_hyf_status_reports_ready() { - let _guard = lock_hyf_tests(); - let dir = tempdir().expect("tempdir"); - let executable = write_response_script( - dir.path(), - format!( - "{{\"version\":{HYF_PROTOCOL_VERSION},\"request_id\":\"cli-doctor-hyf-status\",\"trace_id\":\"cli-doctor-hyf-status\",\"ok\":true,\"output\":{{\"build_identity\":{{\"protocol_version\":{HYF_PROTOCOL_VERSION}}},\"enabled_execution_modes\":{{\"deterministic\":true}}}}}}" - ) - .as_str(), - ); - - let view = resolve_status(&HyfConfig { - enabled: true, - executable, - }); - assert_eq!(view.state, "ready", "reason: {:?}", view.reason); - assert_eq!(view.protocol_version, Some(HYF_PROTOCOL_VERSION)); - assert_eq!(view.deterministic_available, Some(true)); - } - - #[test] - fn incompatible_hyf_status_reports_unavailable() { - let _guard = lock_hyf_tests(); - let dir = tempdir().expect("tempdir"); - let executable = write_response_script( - dir.path(), - "{\"version\":1,\"request_id\":\"cli-doctor-hyf-status\",\"trace_id\":\"cli-doctor-hyf-status\",\"ok\":true,\"output\":{\"build_identity\":{\"protocol_version\":2},\"enabled_execution_modes\":{\"deterministic\":true}}}", - ); - - let view = resolve_status(&HyfConfig { - enabled: true, - executable, - }); - assert_eq!(view.state, "unavailable", "reason: {:?}", view.reason); - assert!( - view.reason - .as_deref() - .is_some_and(|reason| reason.contains("incompatible")) - ); - } - - #[test] - fn capabilities_request_uses_typed_client() { - let _guard = lock_hyf_tests(); - let dir = tempdir().expect("tempdir"); - let executable = write_response_script( - dir.path(), - "{\"version\":1,\"request_id\":\"cli-runtime-hyf-capabilities\",\"ok\":true,\"output\":{\"control_routes\":[\"sys.status\",\"sys.capabilities\"],\"business_capabilities\":[{\"id\":\"query_rewrite\",\"kind\":\"business\",\"deterministic_execution\":\"enabled\",\"implementation_status\":\"implemented\",\"callable\":true,\"implemented\":true,\"assisted_execution\":\"unavailable\",\"assisted_backend_available\":false}],\"assisted_backend_capabilities\":[],\"request_context_contract\":{\"accepted_features\":[\"consumer\",\"execution_mode_preference\"],\"effective_features\":[\"execution_mode_preference\"],\"unsupported_field_behavior\":\"reject\"}}}", - ); - - let request = request_json( - "cli-runtime-hyf-capabilities", - None, - "sys.capabilities", - None, - &HyfEmptyInput::default(), - ); - let response = - HyfClient::with_timeouts(executable, Duration::from_secs(5), Duration::from_secs(5)) - .capabilities() - .expect("capabilities"); - - assert_eq!(request["capability"], "sys.capabilities"); - assert_eq!(request["input"], serde_json::json!({})); - assert!(request.get("context").is_none()); - assert_eq!( - response.output.control_routes, - vec!["sys.status", "sys.capabilities"] - ); - assert_eq!(response.output.business_capabilities[0].id, "query_rewrite"); - } - - #[test] - fn query_rewrite_request_round_trips_typed_output() { - let _guard = lock_hyf_tests(); - let dir = tempdir().expect("tempdir"); - let executable = write_response_script( - dir.path(), - "{\"version\":1,\"request_id\":\"rewrite-test-1\",\"trace_id\":\"trace-rewrite-test-1\",\"ok\":true,\"output\":{\"original_text\":\"apples near me with weekend pickup\",\"normalized_text\":\"apples near me with weekend pickup\",\"rewritten_text\":\"apples\",\"query_terms\":[\"apples\"],\"normalization_signals\":[\"local_intent_detected\"],\"ranking_hints\":[\"prefer_local_results\"],\"extracted_filters\":{\"local_intent\":true,\"fulfillment\":\"pickup\",\"time_window\":\"weekend\"}},\"meta\":{\"execution_mode\":\"deterministic\",\"backend\":\"heuristic\"}}", - ); - let context = HyfRequestContext::deterministic_cli().with_return_provenance(true); - let request = request_json( - "rewrite-test-1", - Some("trace-rewrite-test-1"), - "query_rewrite", - Some(&context), - &HyfQueryRewriteRequest::new("apples near me with weekend pickup"), - ); - let client = HyfClient::new(executable); - let response = client - .query_rewrite( - "rewrite-test-1", - Some("trace-rewrite-test-1"), - &context, - &HyfQueryRewriteRequest::new("apples near me with weekend pickup"), - ) - .expect("query rewrite"); - assert_eq!(request["capability"], "query_rewrite"); - assert_eq!( - request["context"]["execution_mode_preference"], - "deterministic" - ); - assert_eq!(request["context"]["consumer"], "radroots-cli"); - assert_eq!(request["context"]["return_provenance"], true); - assert_eq!( - request["input"]["query"], - "apples near me with weekend pickup" - ); - assert_eq!(response.output.rewritten_text, "apples"); - assert_eq!(response.output.query_terms, vec!["apples"]); - assert_eq!( - response.meta, - Some(serde_json::json!({"execution_mode":"deterministic","backend":"heuristic"})) - ); - } - - #[test] - fn business_requests_use_a_longer_timeout_than_control_requests() { - let _guard = lock_hyf_tests(); - let dir = tempdir().expect("tempdir"); - let executable = write_script( - dir.path(), - "#!/bin/sh\nread -r request || exit 64\ncase \"$request\" in\n *'\"capability\":\"sys.status\"'*)\n sleep 1\n cat <<'JSON'\n{\"version\":1,\"request_id\":\"cli-doctor-hyf-status\",\"trace_id\":\"cli-doctor-hyf-status\",\"ok\":true,\"output\":{\"build_identity\":{\"protocol_version\":1},\"enabled_execution_modes\":{\"deterministic\":true}}}\nJSON\n ;;\n *'\"capability\":\"query_rewrite\"'*)\n sleep 1\n cat <<'JSON'\n{\"version\":1,\"request_id\":\"rewrite-timeout-test\",\"trace_id\":\"rewrite-timeout-test\",\"ok\":true,\"output\":{\"original_text\":\"henhouse\",\"normalized_text\":\"henhouse\",\"rewritten_text\":\"eggs\",\"query_terms\":[\"eggs\"],\"normalization_signals\":[\"query_rewrite\"],\"ranking_hints\":[\"local_first\"],\"extracted_filters\":{\"local_intent\":false,\"fulfillment\":\"any\",\"time_window\":\"any\"}}}\nJSON\n ;;\n *)\n exit 65\n ;;\nesac\n", - ); - let client = HyfClient::with_timeouts( - executable, - Duration::from_millis(100), - Duration::from_secs(5), - ); - assert!(client.timeouts.business > client.timeouts.control); - - let status = client.status().expect_err("status should time out"); - assert!(matches!(status, HyfClientError::Timeout(100))); - - let rewrite = client - .query_rewrite( - "rewrite-timeout-test", - Some("rewrite-timeout-test"), - &HyfRequestContext::deterministic_cli(), - &HyfQueryRewriteRequest::new("henhouse"), - ) - .expect("query rewrite should use longer timeout"); - assert_eq!(rewrite.output.rewritten_text, "eggs"); - } - - #[test] - fn semantic_rank_request_round_trips_typed_output() { - let _guard = lock_hyf_tests(); - let dir = tempdir().expect("tempdir"); - let executable = write_response_script( - dir.path(), - "{\"version\":1,\"request_id\":\"rank-test-1\",\"ok\":true,\"output\":{\"ranked_ids\":[\"listing_local_1\",\"listing_regional_1\"],\"reasons\":{\"listing_local_1\":[\"apples match\",\"pickup match\"],\"listing_regional_1\":[\"delivery mismatch\"]},\"scored_candidates\":[{\"id\":\"listing_local_1\",\"heuristic_score\":14,\"matched_terms\":[\"apples\"],\"reasons\":[\"apples match\",\"pickup match\"],\"delivery_alignment\":\"match\",\"distance_band\":\"closer\",\"freshness_band\":\"fresher\",\"scope_match\":true}],\"ranking_hints\":[\"prefer_local_results\"],\"extracted_filters\":{\"local_intent\":true,\"fulfillment\":\"pickup\",\"time_window\":\"weekend\"}},\"meta\":{\"execution_mode\":\"deterministic\",\"backend\":\"heuristic\"}}", - ); - let context = HyfRequestContext::deterministic_cli() - .with_listing_scope(vec!["listing_local_1".to_owned()]); - let request = request_json( - "rank-test-1", - None, - "semantic_rank", - Some(&context), - &HyfSemanticRankRequest::new( - "apples near me with weekend pickup", - vec![sample_candidate("listing_local_1")], - ), - ); - let client = HyfClient::new(executable); - let response = client - .semantic_rank( - "rank-test-1", - None, - &context, - &HyfSemanticRankRequest::new( - "apples near me with weekend pickup", - vec![sample_candidate("listing_local_1")], - ), - ) - .expect("semantic rank"); - - assert_eq!(request["capability"], "semantic_rank"); - assert_eq!( - request["context"]["scope"]["listing_ids"], - serde_json::json!(["listing_local_1"]) - ); - assert_eq!(request["input"]["candidates"][0]["id"], "listing_local_1"); - assert_eq!(response.output.ranked_ids[0], "listing_local_1"); - assert_eq!(response.output.scored_candidates[0].heuristic_score, 14); - } - - #[test] - fn explain_result_request_round_trips_typed_output() { - let _guard = lock_hyf_tests(); - let dir = tempdir().expect("tempdir"); - let executable = write_response_script( - dir.path(), - "{\"version\":1,\"request_id\":\"explain-test-1\",\"trace_id\":\"trace-explain-test-1\",\"ok\":true,\"output\":{\"result_id\":\"listing_local_1\",\"explanation_kind\":\"deterministic\",\"summary\":\"Result listing_local_1 was ranked using deterministic heuristic signals: apples match and pickup match.\",\"score\":14,\"reasons\":[\"apples match\",\"pickup match\"],\"matched_terms\":[\"apples\"],\"ranking_hints\":[\"prefer_local_results\"],\"extracted_filters\":{\"local_intent\":true,\"fulfillment\":\"pickup\",\"time_window\":\"weekend\"},\"signal_assessment\":{\"delivery_alignment\":\"match\",\"distance_band\":\"closer\",\"freshness_band\":\"fresher\",\"scope_match\":true}},\"meta\":{\"execution_mode\":\"deterministic\",\"backend\":\"heuristic\"}}", - ); - let context = HyfRequestContext::deterministic_cli().with_return_provenance(true); - let request = request_json( - "explain-test-1", - Some("trace-explain-test-1"), - "explain_result", - Some(&context), - &HyfExplainResultRequest::new( - "apples near me with weekend pickup", - sample_candidate("listing_local_1"), - ), - ); - let client = HyfClient::new(executable); - let response = client - .explain_result( - "explain-test-1", - Some("trace-explain-test-1"), - &context, - &HyfExplainResultRequest::new( - "apples near me with weekend pickup", - sample_candidate("listing_local_1"), - ), - ) - .expect("explain result"); - - assert_eq!(request["capability"], "explain_result"); - assert_eq!(request["context"]["return_provenance"], true); - assert_eq!(request["input"]["candidate"]["id"], "listing_local_1"); - assert_eq!(response.output.result_id, "listing_local_1"); - assert_eq!( - response.output.signal_assessment.delivery_alignment, - "match" - ); - } - - fn sample_candidate(id: &str) -> HyfSemanticCandidate { - HyfSemanticCandidate { - id: id.to_owned(), - title: "Organic apples".to_owned(), - farm: "Local Orchard".to_owned(), - delivery: "pickup".to_owned(), - distance_km: 4.1, - freshness_minutes: 3, - } - } - - fn request_json<T: Serialize>( - request_id: &str, - trace_id: Option<&str>, - capability: &str, - context: Option<&HyfRequestContext>, - input: &T, - ) -> Value { - let raw = super::serialize_request(request_id, trace_id, capability, context, input) - .expect("serialize request"); - serde_json::from_str(raw.as_str()).expect("request json") - } - - fn write_response_script(dir: &Path, response: &str) -> PathBuf { - write_script( - dir, - format!("#!/bin/sh\nread -r _request || exit 64\ncat <<'JSON'\n{response}\nJSON\n") - .as_str(), - ) - } - fn write_script(dir: &Path, script: &str) -> PathBuf { - let path = dir.join("fake-hyfd"); - fs::write(&path, script).expect("write fake hyfd"); - let mut permissions = fs::metadata(&path).expect("metadata").permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("chmod fake hyfd"); - path - } -} diff --git a/src/runtime/listing.rs b/src/runtime/listing.rs @@ -1,3067 +0,0 @@ -use std::collections::HashMap; -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use radroots_core::pricing::{Discount, DiscountScope, DiscountThreshold, DiscountValue}; -use radroots_core::{Currency, Decimal, Money, Percent, Quantity, QuantityPrice, Unit}; -use radroots_event::contract::AuthorRole; -use radroots_event::envelope::kind::KIND_CLASSIFIED_LISTING; -use radroots_event::farm::FarmRef; -use radroots_event::id::{DTag, InventoryBinId}; -use radroots_event::listing::operational::{ - OperationalListing, OperationalListingAvailability, OperationalListingBin, - OperationalListingDeliveryMethod, OperationalListingProduct, OperationalListingPublicLocation, - OperationalListingStatus, -}; -use radroots_event::trade::validation::OperationalListingValidationError; -use radroots_event_codec::d_tag::is_d_tag_base64url; -use radroots_event_codec::operational_listing::encode::to_wire_parts_with_kind; -use radroots_identity::PublicKey; -use radroots_replica_store::ReplicaSql; -use radroots_runtime_store::{RuntimeStoreRecord, RuntimeStoreRecordFamily, SourceRuntime}; -use radroots_sdk::listing::{self as sdk_listing, Plan as ListingPlan}; -use radroots_signing::{Actor, actor::ActorSource}; -use radroots_sql_core::SqlxSqliteExecutor; -use radroots_trade::operational_listing::{ - RadrootsOperationalListingEditDocumentV1, validate_operational_listing_model, -}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -use crate::cli::global::{ - ListingAppRecordExportArgs, ListingCreateArgs, ListingFileArgs, ListingMutationArgs, - ListingRebindArgs, RecordLookupArgs, -}; -use crate::runtime::RuntimeError; -use crate::runtime::account; -use crate::runtime::config::RuntimeConfig; -use crate::runtime::farm_config; -use crate::runtime::runtime_store::{ - append_local_work, get_shared_record, list_shared_records_before, list_shared_records_latest, - shared_runtime_store_db_path, -}; -use crate::runtime::sdk::{CliSdkAdapterError, validate_configured_signer_for_actor}; -use crate::runtime::sync::{ - RelayIngestScope, freshness_for_scope_from_executor, market_refresh, missing_freshness, -}; -use crate::view::runtime::{ - FindPriceView, FindQuantityView, FindResultProvenanceView, ListingAppRecordExportView, - ListingAppRecordListView, ListingAppRecordSummaryView, ListingGetView, ListingListView, - ListingMutationEventView, ListingMutationView, ListingNewView, ListingRebindView, - ListingSummaryView, ListingValidateView, ListingValidationIssueView, MarketReadinessView, -}; - -const DRAFT_KIND: &str = "listing_draft_v1"; -const LISTING_SOURCE: &str = "local draft · local first"; -const LISTING_READ_SOURCE: &str = "local replica · local first"; -const LISTING_APP_RECORD_SOURCE: &str = "shared runtime store · app"; -const SDK_LISTING_WRITE_SOURCE: &str = "SDK listing publish · configured signer"; -const LISTING_DRAFTS_DIR: &str = "listings/drafts"; -const LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG: &str = "farm_config"; -const LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT: &str = "resolved_account"; -const LISTING_SELLER_ACTOR_SOURCE_REBIND: &str = "listing_rebind"; -const CANONICAL_OWNER_PUBKEY_REQUIRED_REASON: &str = "canonical hex pubkey required before export"; -const APP_RECORD_LIST_LIMIT: u32 = 500; - -static D_TAG_COUNTER: AtomicU64 = AtomicU64::new(0); - -fn protocol_d_tag(value: &str, field: &str) -> Result<DTag, RuntimeError> { - value - .parse() - .map_err(|error| RuntimeError::Config(format!("{field} is not a valid d tag: {error}"))) -} - -fn protocol_inventory_bin_id(value: &str, field: &str) -> Result<InventoryBinId, RuntimeError> { - value.parse().map_err(|error| { - RuntimeError::Config(format!("{field} is not a valid inventory bin id: {error}")) - }) -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftDocument { - version: u32, - kind: String, - listing: ListingDraftMeta, - seller_actor: ListingDraftSellerActor, - product: ListingDraftProduct, - primary_bin: ListingDraftPrimaryBin, - inventory: ListingDraftInventory, - availability: ListingDraftAvailability, - delivery: ListingDraftDelivery, - location: ListingDraftLocation, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - discounts: Vec<ListingDraftDiscount>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftMeta { - d_tag: String, - farm_d_tag: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftSellerActor { - account_id: String, - pubkey: String, - source: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftProduct { - key: String, - title: String, - category: String, - summary: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftPrimaryBin { - bin_id: String, - quantity_amount: String, - quantity_unit: String, - price_amount: String, - price_currency: String, - price_per_amount: String, - price_per_unit: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - label: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftInventory { - available: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftAvailability { - #[serde(default, skip_serializing_if = "String::is_empty")] - kind: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - status: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - start: Option<u64>, - #[serde(default, skip_serializing_if = "Option::is_none")] - end: Option<u64>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftDelivery { - method: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftLocation { - primary: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - city: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - region: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - country: Option<String>, - geohash: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct ListingDraftDiscount { - id: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - label: String, - kind: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - value: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - amount: String, - #[serde(default, skip_serializing_if = "String::is_empty")] - currency: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - bin_id: Option<String>, - #[serde(default, skip_serializing_if = "Option::is_none")] - min_bin_count: Option<u32>, -} - -#[derive(Debug, Clone)] -struct ListingValidationContext { - farm_setup_action: String, -} - -#[derive(Debug, Clone)] -struct ListingAuthoringDefaults { - farm_config_present: bool, - farm_defaults_ready: bool, - farm_next_action: Option<String>, - farm_reason: Option<String>, - farm_name: Option<String>, - seller_account_id: String, - seller_pubkey: String, - seller_actor_source: String, - selected_farm_d_tag: Option<String>, - delivery_method: Option<String>, - location: Option<ListingDraftLocation>, -} - -#[derive(Debug, Clone)] -struct CanonicalListingDraft { - listing_id: String, - seller_account_id: String, - seller_pubkey: String, - seller_actor_source: String, - farm_d_tag: String, - listing: OperationalListing, -} - -#[derive(Debug, Clone)] -struct SdkListingPublishInput { - canonical: CanonicalListingDraft, - actor: Actor, - document: RadrootsOperationalListingEditDocumentV1, -} - -#[derive(Debug, Clone)] -struct LoadedListingDraft { - file: PathBuf, - updated_at_unix: u64, - contents: String, - document: ListingDraftDocument, -} - -#[derive(Debug, Clone)] -enum ListingDraftValidationError { - Issue(ListingValidationIssueView), - MissingSellerAccount(ListingValidationIssueView), -} - -impl ListingDraftValidationError { - fn into_issue(self) -> ListingValidationIssueView { - match self { - Self::Issue(issue) | Self::MissingSellerAccount(issue) => issue, - } - } -} - -impl From<ListingValidationIssueView> for ListingDraftValidationError { - fn from(issue: ListingValidationIssueView) -> Self { - Self::Issue(issue) - } -} - -#[derive(Debug, Clone, Copy)] -pub enum ListingMutationOperation { - Publish, - Update, - Pause, - Withdraw, -} - -impl ListingMutationOperation { - fn as_str(self) -> &'static str { - match self { - Self::Publish => "publish", - Self::Update => "update", - Self::Pause => "pause", - Self::Withdraw => "withdraw", - } - } - - fn listing_status(self) -> Option<&'static str> { - match self { - Self::Pause => Some("paused"), - Self::Withdraw => Some("withdrawn"), - Self::Publish | Self::Update => None, - } - } -} - -pub fn scaffold( - config: &RuntimeConfig, - args: &ListingCreateArgs, -) -> Result<ListingNewView, RuntimeError> { - let (draft, defaults) = build_listing_draft(config, args)?; - let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; - write_listing_draft(&output_path, &draft, false)?; - append_listing_local_work(config, output_path.as_path(), &draft)?; - - let mut actions = vec![format!( - "radroots listing publish {}", - output_path.display() - )]; - if let Some(action) = &defaults.farm_next_action { - actions.push(action.clone()); - } - - Ok(ListingNewView { - state: "draft created".to_owned(), - source: LISTING_SOURCE.to_owned(), - file: output_path.display().to_string(), - listing_id: draft.listing.d_tag, - key: non_empty(draft.product.key.clone()), - seller_account_id: Some(defaults.seller_account_id), - seller_pubkey: Some(defaults.seller_pubkey), - seller_actor_source: Some(defaults.seller_actor_source), - farm_d_tag: defaults.selected_farm_d_tag, - delivery_method: non_empty(draft.delivery.method.clone()), - location_primary: non_empty(draft.location.primary.clone()), - reason: defaults.farm_reason, - actions, - }) -} - -pub fn scaffold_preflight( - config: &RuntimeConfig, - args: &ListingCreateArgs, -) -> Result<ListingNewView, RuntimeError> { - let (draft, defaults) = build_listing_draft(config, args)?; - let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; - validate_listing_output_target(&output_path)?; - - let mut actions = vec![format!( - "radroots listing publish {}", - output_path.display() - )]; - if let Some(action) = &defaults.farm_next_action { - actions.push(action.clone()); - } - - Ok(ListingNewView { - state: "dry_run".to_owned(), - source: LISTING_SOURCE.to_owned(), - file: output_path.display().to_string(), - listing_id: draft.listing.d_tag, - key: non_empty(draft.product.key.clone()), - seller_account_id: Some(defaults.seller_account_id), - seller_pubkey: Some(defaults.seller_pubkey), - seller_actor_source: Some(defaults.seller_actor_source), - farm_d_tag: defaults.selected_farm_d_tag, - delivery_method: non_empty(draft.delivery.method.clone()), - location_primary: non_empty(draft.location.primary.clone()), - reason: Some("dry run requested; listing draft was not written".to_owned()), - actions, - }) -} - -fn build_listing_draft( - config: &RuntimeConfig, - args: &ListingCreateArgs, -) -> Result<(ListingDraftDocument, ListingAuthoringDefaults), RuntimeError> { - let defaults = authoring_defaults(config)?; - let quantity_unit = args.quantity_unit.clone().unwrap_or_else(|| "g".to_owned()); - let draft = ListingDraftDocument { - version: 1, - kind: DRAFT_KIND.to_owned(), - listing: ListingDraftMeta { - d_tag: generate_d_tag(), - farm_d_tag: defaults.selected_farm_d_tag.clone().unwrap_or_default(), - }, - seller_actor: ListingDraftSellerActor { - account_id: defaults.seller_account_id.clone(), - pubkey: defaults.seller_pubkey.clone(), - source: defaults.seller_actor_source.clone(), - }, - product: ListingDraftProduct { - key: args.key.clone().unwrap_or_default(), - title: args.title.clone().unwrap_or_default(), - category: args.category.clone().unwrap_or_default(), - summary: args.summary.clone().unwrap_or_default(), - }, - primary_bin: ListingDraftPrimaryBin { - bin_id: args.bin_id.clone().unwrap_or_else(|| "bin-1".to_owned()), - quantity_amount: args - .quantity_amount - .clone() - .unwrap_or_else(|| "1000".to_owned()), - quantity_unit: quantity_unit.clone(), - price_amount: args - .price_amount - .clone() - .unwrap_or_else(|| "0.01".to_owned()), - price_currency: args - .price_currency - .clone() - .unwrap_or_else(|| "USD".to_owned()), - price_per_amount: args - .price_per_amount - .clone() - .unwrap_or_else(|| "1".to_owned()), - price_per_unit: args - .price_per_unit - .clone() - .unwrap_or_else(|| quantity_unit.clone()), - label: args.label.clone().unwrap_or_default(), - }, - inventory: ListingDraftInventory { - available: args.available.clone().unwrap_or_else(|| "1".to_owned()), - }, - availability: ListingDraftAvailability { - kind: "status".to_owned(), - status: "active".to_owned(), - start: None, - end: None, - }, - delivery: ListingDraftDelivery { - method: defaults.delivery_method.clone().unwrap_or_default(), - }, - location: defaults.location.clone().unwrap_or(ListingDraftLocation { - primary: String::new(), - city: None, - region: None, - country: None, - geohash: String::new(), - }), - discounts: listing_discount_drafts_from_args(args), - }; - Ok((draft, defaults)) -} - -fn listing_discount_drafts_from_args(args: &ListingCreateArgs) -> Vec<ListingDraftDiscount> { - let has_discount = args.discount_id.is_some() - || args.discount_label.is_some() - || args.discount_kind.is_some() - || args.discount_value.is_some() - || args.discount_amount.is_some() - || args.discount_currency.is_some(); - if !has_discount { - return Vec::new(); - } - let kind = args.discount_kind.clone().unwrap_or_else(|| { - if args.discount_amount.is_some() { - "amount".to_owned() - } else { - "percent".to_owned() - } - }); - vec![ListingDraftDiscount { - id: args - .discount_id - .clone() - .unwrap_or_else(|| "discount_1".to_owned()), - label: args.discount_label.clone().unwrap_or_default(), - kind, - value: args.discount_value.clone().unwrap_or_default(), - amount: args.discount_amount.clone().unwrap_or_default(), - currency: args.discount_currency.clone().unwrap_or_default(), - bin_id: None, - min_bin_count: None, - }] -} - -fn listing_output_path( - config: &RuntimeConfig, - explicit: Option<&std::path::PathBuf>, - listing_id: &str, -) -> Result<std::path::PathBuf, RuntimeError> { - match explicit { - Some(path) => Ok(path.clone()), - None => Ok(drafts_dir(config).join(format!("{listing_id}.toml"))), - } -} - -fn write_listing_draft( - output_path: &Path, - draft: &ListingDraftDocument, - overwrite: bool, -) -> Result<(), RuntimeError> { - if !overwrite { - validate_listing_output_target(output_path)?; - } - if let Some(parent) = output_path.parent() { - fs::create_dir_all(parent)?; - } - fs::write(output_path, scaffold_contents(draft)?)?; - Ok(()) -} - -fn append_listing_local_work( - config: &RuntimeConfig, - path: &Path, - draft: &ListingDraftDocument, -) -> Result<(), RuntimeError> { - let listing_id = draft.listing.d_tag.trim(); - let seller_pubkey = draft.seller_actor.pubkey.trim(); - let listing_addr = if seller_pubkey.is_empty() || listing_id.is_empty() { - None - } else { - Some(listing_addr(seller_pubkey, listing_id)) - }; - let payload = json!({ - "record_kind": DRAFT_KIND, - "path": path.display().to_string(), - "document": draft, - }); - let subject = format!("listing:{}", draft.listing.d_tag); - append_local_work( - config, - subject.as_str(), - non_empty(draft.seller_actor.account_id.clone()), - non_empty(draft.seller_actor.pubkey.clone()), - non_empty(draft.listing.farm_d_tag.clone()), - listing_addr, - payload, - )?; - Ok(()) -} - -fn validate_listing_output_target(output_path: &Path) -> Result<(), RuntimeError> { - if output_path.exists() { - return Err(RuntimeError::Config(format!( - "listing draft output {} must not already exist", - output_path.display() - ))); - } - if let Some(parent) = output_path.parent() - && parent.exists() - && !parent.is_dir() - { - return Err(RuntimeError::Config(format!( - "listing draft parent {} is not a directory", - parent.display() - ))); - } - Ok(()) -} - -pub fn validate( - config: &RuntimeConfig, - args: &ListingFileArgs, -) -> Result<ListingValidateView, RuntimeError> { - let contents = fs::read_to_string(&args.file)?; - let context = validation_context(config)?; - - let parsed = match toml::from_str::<ListingDraftDocument>(&contents) { - Ok(parsed) => parsed, - Err(error) => { - return Ok(ListingValidateView { - state: "invalid".to_owned(), - source: LISTING_SOURCE.to_owned(), - file: args.file.display().to_string(), - valid: false, - listing_id: None, - seller_account_id: None, - seller_pubkey: None, - seller_actor_source: None, - farm_d_tag: None, - issues: vec![ListingValidationIssueView { - field: "toml".to_owned(), - message: error.to_string(), - line: error - .span() - .map(|span| line_for_offset(&contents, span.start + 1)), - }], - actions: vec![format!("edit {}", args.file.display())], - }); - } - }; - - match canonicalize_draft(&parsed, &contents, &context) { - Ok(canonical) => { - match to_wire_parts_with_kind(&canonical.listing, KIND_CLASSIFIED_LISTING) { - Ok(_) => {} - Err(error) => { - return Ok(invalid_validation_view( - args.file.as_path(), - &parsed, - &context, - ListingValidationIssueView { - field: "listing".to_owned(), - message: format!("invalid listing contract: {error}"), - line: None, - }, - )); - } - } - if let Some(issue) = listing_bound_account_issue(config, &canonical, &contents)? { - return Ok(invalid_validation_view( - args.file.as_path(), - &parsed, - &context, - issue, - )); - } - match validate_operational_listing_draft(&canonical) { - Ok(_) => Ok(ListingValidateView { - state: "valid".to_owned(), - source: LISTING_SOURCE.to_owned(), - file: args.file.display().to_string(), - valid: true, - listing_id: Some(canonical.listing_id), - seller_account_id: Some(canonical.seller_account_id), - seller_pubkey: Some(canonical.seller_pubkey), - seller_actor_source: Some(canonical.seller_actor_source), - farm_d_tag: Some(canonical.farm_d_tag), - issues: Vec::new(), - actions: vec![format!("radroots listing publish {}", args.file.display())], - }), - Err(error) => Ok(invalid_validation_view( - args.file.as_path(), - &parsed, - &context, - issue_from_trade_validation(error, &contents), - )), - } - } - Err(error) => Ok(invalid_validation_view( - args.file.as_path(), - &parsed, - &context, - error.into_issue(), - )), - } -} - -pub fn list(config: &RuntimeConfig) -> Result<ListingListView, RuntimeError> { - let dir = drafts_dir(config); - if !dir.exists() { - return Ok(ListingListView { - state: "empty".to_owned(), - source: LISTING_SOURCE.to_owned(), - count: 0, - draft_dir: dir.display().to_string(), - listings: Vec::new(), - actions: vec!["radroots listing create".to_owned()], - }); - } - - let context = validation_context(config).map_err(|error| error.to_string()); - let mut listings = Vec::new(); - for entry in fs::read_dir(&dir)? { - let entry = entry?; - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("toml") { - continue; - } - match load_listing_draft(path.as_path()) { - Ok(loaded) => listings.push(summary_from_loaded(config, &loaded, context.as_ref())), - Err(issue) => listings.push(summary_for_invalid_file(path.as_path(), issue)), - } - } - - listings.sort_by(|left, right| { - right - .updated_at_unix - .cmp(&left.updated_at_unix) - .then_with(|| left.id.cmp(&right.id)) - }); - - let state = if listings.is_empty() { - "empty" - } else if listings.iter().any(|listing| listing.state == "error") { - "degraded" - } else { - "ready" - }; - let actions = if listings.is_empty() { - vec!["radroots listing create".to_owned()] - } else { - Vec::new() - }; - - Ok(ListingListView { - state: state.to_owned(), - source: LISTING_SOURCE.to_owned(), - count: listings.len(), - draft_dir: dir.display().to_string(), - listings, - actions, - }) -} - -pub fn app_record_list(config: &RuntimeConfig) -> Result<ListingAppRecordListView, RuntimeError> { - let database_path = shared_runtime_store_db_path(config)?; - let mut entries = current_app_record_entries(app_local_records(config)?); - let has_more = entries.len() > APP_RECORD_LIST_LIMIT as usize; - if has_more { - entries.truncate(APP_RECORD_LIST_LIMIT as usize); - } - let next_cursor = if has_more { - entries - .last() - .map(|entry| (entry.record.change_seq, entry.record.seq)) - } else { - None - }; - let records = entries - .iter() - .map(|entry| app_record_summary(&entry.record, entry.superseded_count)) - .collect::<Vec<_>>(); - let state = if records.is_empty() { "empty" } else { "ready" }; - let actions = if records.is_empty() { - vec!["create or save a farm listing in radroots_studio_app".to_owned()] - } else { - Vec::new() - }; - - Ok(ListingAppRecordListView { - state: state.to_owned(), - source: LISTING_APP_RECORD_SOURCE.to_owned(), - count: records.len(), - limit: APP_RECORD_LIST_LIMIT, - has_more, - next_before_change_seq: next_cursor.map(|(change_seq, _)| change_seq), - next_before_seq: next_cursor.map(|(_, seq)| seq), - runtime_store_db: database_path.display().to_string(), - records, - actions, - }) -} - -pub fn app_record_export( - config: &RuntimeConfig, - args: &ListingAppRecordExportArgs, -) -> Result<ListingAppRecordExportView, RuntimeError> { - let Some(record) = get_shared_record(config, args.record_id.as_str())? else { - return Ok(ListingAppRecordExportView { - state: "missing".to_owned(), - source: LISTING_APP_RECORD_SOURCE.to_owned(), - record_id: args.record_id.clone(), - dry_run: config.output.dry_run, - file: args - .output - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(), - valid: false, - listing_id: None, - listing_addr: None, - seller_account_id: None, - seller_pubkey: None, - seller_actor_source: None, - farm_d_tag: None, - issues: Vec::new(), - reason: Some(format!( - "app-authored local record `{}` was not found", - args.record_id - )), - actions: vec!["radroots listing app list".to_owned()], - }); - }; - - if let Some(current_record) = current_app_record_for(config, &record)? - && current_record.record_id != record.record_id - { - let (listing_id, title, farm_d_tag) = app_listing_display_parts(&record); - let current_action = format!("radroots listing app export {}", current_record.record_id); - return Ok(ListingAppRecordExportView { - state: "stale".to_owned(), - source: LISTING_APP_RECORD_SOURCE.to_owned(), - record_id: args.record_id.clone(), - dry_run: config.output.dry_run, - file: args - .output - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(), - valid: false, - listing_id, - listing_addr: record.listing_addr.clone(), - seller_account_id: record.owner_account_id.clone(), - seller_pubkey: record.owner_pubkey.clone(), - seller_actor_source: None, - farm_d_tag: farm_d_tag.or(record.farm_id.clone()), - issues: vec![ListingValidationIssueView { - field: "record_id".to_owned(), - message: format!( - "app-authored local record `{}` was superseded by `{}`", - record.record_id, current_record.record_id - ), - line: None, - }], - reason: Some(format!( - "app-authored local record `{}` was superseded by current record `{}`{}", - record.record_id, - current_record.record_id, - title - .as_deref() - .map(|value| format!(" for `{value}`")) - .unwrap_or_default() - )), - actions: vec![current_action, "radroots listing app list".to_owned()], - }); - } - - let draft = match app_listing_draft_from_record(&record) { - Ok(draft) => draft, - Err(reason) => { - return Ok(ListingAppRecordExportView { - state: "unsupported".to_owned(), - source: LISTING_APP_RECORD_SOURCE.to_owned(), - record_id: args.record_id.clone(), - dry_run: config.output.dry_run, - file: args - .output - .as_ref() - .map(|path| path.display().to_string()) - .unwrap_or_default(), - valid: false, - listing_id: None, - listing_addr: record.listing_addr.clone(), - seller_account_id: record.owner_account_id.clone(), - seller_pubkey: record.owner_pubkey.clone(), - seller_actor_source: None, - farm_d_tag: record.farm_id.clone(), - issues: vec![ListingValidationIssueView { - field: "local_work_json".to_owned(), - message: reason.clone(), - line: None, - }], - reason: Some(reason), - actions: vec!["radroots listing app list".to_owned()], - }); - } - }; - let output_path = listing_output_path(config, args.output.as_ref(), &draft.listing.d_tag)?; - validate_listing_output_target(output_path.as_path())?; - let contents = scaffold_contents(&draft)?; - let context = validation_context(config)?; - let issues = app_listing_export_issues(config, &draft, contents.as_str(), &context)?; - let listing_addr_value = app_record_listing_addr(&draft); - - if !issues.is_empty() { - return Ok(ListingAppRecordExportView { - state: "invalid".to_owned(), - source: LISTING_APP_RECORD_SOURCE.to_owned(), - record_id: args.record_id.clone(), - dry_run: config.output.dry_run, - file: output_path.display().to_string(), - valid: false, - listing_id: non_empty(draft.listing.d_tag.clone()), - listing_addr: listing_addr_value, - seller_account_id: non_empty(draft.seller_actor.account_id.clone()), - seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()), - seller_actor_source: non_empty(draft.seller_actor.source.clone()), - farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()), - issues, - reason: Some(format!( - "app-authored local record `{}` does not validate as a CLI listing draft", - args.record_id - )), - actions: vec!["radroots listing app list".to_owned()], - }); - } - - if !config.output.dry_run { - write_listing_draft(output_path.as_path(), &draft, false)?; - } - - Ok(ListingAppRecordExportView { - state: if config.output.dry_run { - "dry_run" - } else { - "exported" - } - .to_owned(), - source: LISTING_APP_RECORD_SOURCE.to_owned(), - record_id: args.record_id.clone(), - dry_run: config.output.dry_run, - file: output_path.display().to_string(), - valid: true, - listing_id: Some(draft.listing.d_tag.clone()), - listing_addr: app_record_listing_addr(&draft), - seller_account_id: Some(draft.seller_actor.account_id.clone()), - seller_pubkey: Some(draft.seller_actor.pubkey.clone()), - seller_actor_source: Some(draft.seller_actor.source.clone()), - farm_d_tag: Some(draft.listing.farm_d_tag.clone()), - issues: Vec::new(), - reason: Some(if config.output.dry_run { - "dry run requested; listing draft was not written".to_owned() - } else { - "app-authored listing record exported as a CLI listing draft".to_owned() - }), - actions: vec![ - format!("radroots listing publish {}", output_path.display()), - format!("radroots listing publish {}", output_path.display()), - ], - }) -} - -pub fn rebind( - config: &RuntimeConfig, - args: &ListingRebindArgs, -) -> Result<ListingRebindView, RuntimeError> { - rebind_inner(config, args, false) -} - -pub fn rebind_preflight( - config: &RuntimeConfig, - args: &ListingRebindArgs, -) -> Result<ListingRebindView, RuntimeError> { - rebind_inner(config, args, true) -} - -fn rebind_inner( - config: &RuntimeConfig, - args: &ListingRebindArgs, - dry_run: bool, -) -> Result<ListingRebindView, RuntimeError> { - let contents = fs::read_to_string(&args.file)?; - let mut draft = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { - RuntimeError::Config(format!( - "invalid listing draft {}: {error}", - args.file.display() - )) - })?; - let listing_id = draft.listing.d_tag.trim().to_owned(); - if !is_d_tag_base64url(&listing_id) { - return Err(RuntimeError::Config(format!( - "invalid listing draft {}: listing d_tag must be a 22-character base64url identifier", - args.file.display() - ))); - } - - let target_account = account::resolve_account_selector(config, args.selector.as_str()) - .map_err(|error| listing_rebind_selector_error(args.selector.as_str(), error))?; - let from_seller_account_id = non_empty(draft.seller_actor.account_id.clone()); - let from_seller_pubkey = non_empty(draft.seller_actor.pubkey.clone()); - let from_seller_actor_source = non_empty(draft.seller_actor.source.clone()); - let from_farm_d_tag = non_empty(draft.listing.farm_d_tag.clone()); - let target_account_id = target_account.record.id().to_string(); - let target_pubkey = target_account - .record - .public_identity() - .public_key() - .to_hex(); - let target_farm_d_tag = resolve_rebind_farm_d_tag( - config, - args, - from_seller_account_id.as_deref(), - from_farm_d_tag.as_deref(), - target_account_id.as_str(), - )?; - let from_listing_addr = from_seller_pubkey - .as_ref() - .map(|pubkey| listing_addr(pubkey, listing_id.as_str())); - let to_listing_addr = listing_addr(target_pubkey.as_str(), listing_id.as_str()); - let seller_pubkey_changed = from_seller_pubkey - .as_deref() - .map(|pubkey| !pubkey.eq_ignore_ascii_case(target_pubkey.as_str())); - let listing_addr_changed = from_listing_addr - .as_deref() - .map(|addr| addr != to_listing_addr.as_str()); - let farm_d_tag_changed = from_farm_d_tag - .as_deref() - .map(|d_tag| d_tag != target_farm_d_tag.as_str()); - - draft.seller_actor.account_id = target_account_id.clone(); - draft.seller_actor.pubkey = target_pubkey.clone(); - draft.seller_actor.source = LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned(); - draft.listing.farm_d_tag = target_farm_d_tag.clone(); - - if !dry_run { - write_listing_draft(args.file.as_path(), &draft, true)?; - append_listing_local_work(config, args.file.as_path(), &draft)?; - } - - Ok(ListingRebindView { - state: if dry_run { "dry_run" } else { "rebound" }.to_owned(), - source: LISTING_SOURCE.to_owned(), - file: args.file.display().to_string(), - listing_id, - dry_run, - from_seller_account_id, - from_seller_pubkey, - from_seller_actor_source, - to_seller_account_id: target_account_id, - to_seller_pubkey: target_pubkey, - to_seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_REBIND.to_owned(), - seller_pubkey_changed, - from_listing_addr, - to_listing_addr, - listing_addr_changed, - from_farm_d_tag, - to_farm_d_tag: target_farm_d_tag, - farm_d_tag_changed, - reason: Some(if dry_run { - "dry run requested; listing seller actor binding was not written".to_owned() - } else { - "listing seller actor binding updated".to_owned() - }), - actions: if dry_run { - vec![format!("radroots account select {}", args.selector)] - } else { - vec![format!("radroots listing publish {}", args.file.display())] - }, - }) -} - -fn resolve_rebind_farm_d_tag( - config: &RuntimeConfig, - args: &ListingRebindArgs, - from_seller_account_id: Option<&str>, - from_farm_d_tag: Option<&str>, - target_account_id: &str, -) -> Result<String, RuntimeError> { - if let Some(explicit) = args - .farm_d_tag - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - if !is_d_tag_base64url(explicit) { - return Err(RuntimeError::Config( - "listing rebind --farm-d-tag must be a 22-character base64url identifier" - .to_owned(), - )); - } - return Ok(explicit.to_owned()); - } - if from_seller_account_id == Some(target_account_id) - && let Some(existing) = from_farm_d_tag - { - return Ok(existing.to_owned()); - } - if let Some(resolved) = farm_config::load(config, None)? - && resolved.document.selection.account == target_account_id - { - return Ok(resolved.document.selection.farm_d_tag); - } - Err(RuntimeError::Config(format!( - "listing rebind requires --farm-d-tag when target account `{target_account_id}` is not bound by the selected farm config" - ))) -} - -fn listing_rebind_selector_error(selector: &str, error: RuntimeError) -> RuntimeError { - match error { - RuntimeError::Account(account::AccountRuntimeFailure::Unresolved(issue)) => { - account::AccountRuntimeFailure::unresolved_with_detail( - issue.message().to_owned(), - json!({ - "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_REBIND, - "selector": selector, - "actions": [ - "radroots account import <path>", - "radroots account create", - ], - }), - ) - .into() - } - other => other, - } -} - -fn listing_addr(seller_pubkey: &str, listing_id: &str) -> String { - format!("{KIND_CLASSIFIED_LISTING}:{seller_pubkey}:{listing_id}") -} - -fn load_listing_draft(path: &Path) -> Result<LoadedListingDraft, ListingValidationIssueView> { - let contents = fs::read_to_string(path).map_err(|error| ListingValidationIssueView { - field: "file".to_owned(), - message: format!("read listing draft {}: {error}", path.display()), - line: None, - })?; - let document = toml::from_str::<ListingDraftDocument>(contents.as_str()).map_err(|error| { - ListingValidationIssueView { - field: "toml".to_owned(), - message: error.to_string(), - line: error - .span() - .map(|span| line_for_offset(contents.as_str(), span.start + 1)), - } - })?; - Ok(LoadedListingDraft { - file: path.to_path_buf(), - updated_at_unix: modified_unix(path).unwrap_or_default(), - contents, - document, - }) -} - -fn summary_from_loaded( - config: &RuntimeConfig, - loaded: &LoadedListingDraft, - context: Result<&ListingValidationContext, &String>, -) -> ListingSummaryView { - let mut seller_account_id = non_empty(loaded.document.seller_actor.account_id.clone()); - let mut seller_pubkey = non_empty(loaded.document.seller_actor.pubkey.clone()); - let mut seller_actor_source = non_empty(loaded.document.seller_actor.source.clone()); - let mut farm_d_tag = non_empty(loaded.document.listing.farm_d_tag.clone()); - let mut issues = Vec::new(); - let mut state = "draft"; - - match context { - Ok(context) => { - match canonicalize_draft(&loaded.document, loaded.contents.as_str(), context) { - Ok(canonical) => { - seller_account_id = Some(canonical.seller_account_id.clone()); - seller_pubkey = Some(canonical.seller_pubkey.clone()); - seller_actor_source = Some(canonical.seller_actor_source.clone()); - farm_d_tag = Some(canonical.farm_d_tag.clone()); - issues = listing_ready_issues(&canonical, loaded.contents.as_str()); - if let Ok(Some(issue)) = - listing_bound_account_issue(config, &canonical, loaded.contents.as_str()) - { - issues.push(issue); - } - if issues.is_empty() { - state = "ready"; - } - } - Err(error) => issues.push(error.into_issue()), - } - } - Err(reason) => issues.push(ListingValidationIssueView { - field: "context".to_owned(), - message: reason.to_string(), - line: None, - }), - } - - ListingSummaryView { - id: non_empty(loaded.document.listing.d_tag.clone()) - .unwrap_or_else(|| file_stem(loaded.file.as_path())), - state: state.to_owned(), - file: loaded.file.display().to_string(), - product_key: non_empty(loaded.document.product.key.clone()), - title: non_empty(loaded.document.product.title.clone()), - category: non_empty(loaded.document.product.category.clone()), - seller_account_id, - seller_pubkey, - seller_actor_source, - farm_d_tag, - location_primary: non_empty(loaded.document.location.primary.clone()), - updated_at_unix: loaded.updated_at_unix, - issues, - } -} - -fn listing_ready_issues( - canonical: &CanonicalListingDraft, - contents: &str, -) -> Vec<ListingValidationIssueView> { - match to_wire_parts_with_kind(&canonical.listing, KIND_CLASSIFIED_LISTING) { - Ok(_) => {} - Err(error) => { - return vec![ListingValidationIssueView { - field: "listing".to_owned(), - message: format!("invalid listing contract: {error}"), - line: None, - }]; - } - } - match validate_operational_listing_draft(canonical) { - Ok(_) => Vec::new(), - Err(error) => vec![issue_from_trade_validation(error, contents)], - } -} - -fn summary_for_invalid_file(path: &Path, issue: ListingValidationIssueView) -> ListingSummaryView { - ListingSummaryView { - id: file_stem(path), - state: "error".to_owned(), - file: path.display().to_string(), - product_key: None, - title: None, - category: None, - seller_account_id: None, - seller_pubkey: None, - seller_actor_source: None, - farm_d_tag: None, - location_primary: None, - updated_at_unix: modified_unix(path).unwrap_or_default(), - issues: vec![issue], - } -} - -#[derive(Debug, Clone)] -struct AppRecordListEntry { - record: RuntimeStoreRecord, - superseded_count: usize, -} - -fn app_local_records(config: &RuntimeConfig) -> Result<Vec<RuntimeStoreRecord>, RuntimeError> { - let mut app_records = Vec::new(); - let mut before_cursor = None::<(i64, i64)>; - loop { - let shared_records = if let Some((before_change_seq, before_seq)) = before_cursor { - list_shared_records_before( - config, - before_change_seq, - before_seq, - APP_RECORD_LIST_LIMIT, - )? - } else { - list_shared_records_latest(config, APP_RECORD_LIST_LIMIT)? - }; - let Some(next_cursor) = shared_records - .last() - .map(|record| (record.change_seq, record.seq)) - else { - break; - }; - let has_more = shared_records.len() == APP_RECORD_LIST_LIMIT as usize; - app_records.extend( - shared_records - .into_iter() - .filter(is_supported_app_local_record), - ); - if !has_more { - break; - } - before_cursor = Some(next_cursor); - } - Ok(app_records) -} - -fn is_supported_app_local_record(record: &RuntimeStoreRecord) -> bool { - record.source_runtime == SourceRuntime::App - && record.family == RuntimeStoreRecordFamily::LocalWork - && matches!( - local_record_kind(record).as_deref(), - Some("farm_config_v1" | DRAFT_KIND) - ) -} - -fn current_app_record_entries(mut records: Vec<RuntimeStoreRecord>) -> Vec<AppRecordListEntry> { - records.sort_by(|left, right| { - right - .change_seq - .cmp(&left.change_seq) - .then_with(|| right.seq.cmp(&left.seq)) - .then_with(|| left.record_id.cmp(&right.record_id)) - }); - - let mut entries: Vec<AppRecordListEntry> = Vec::new(); - let mut seen = HashMap::<String, usize>::new(); - for record in records { - let key = app_record_current_key(&record); - if let Some(index) = seen.get(&key).copied() { - entries[index].superseded_count += 1; - } else { - seen.insert(key, entries.len()); - entries.push(AppRecordListEntry { - record, - superseded_count: 0, - }); - } - } - entries -} - -fn current_app_record_for( - config: &RuntimeConfig, - record: &RuntimeStoreRecord, -) -> Result<Option<RuntimeStoreRecord>, RuntimeError> { - let key = app_record_current_key(record); - Ok(app_local_records(config)? - .into_iter() - .filter(|candidate| app_record_current_key(candidate) == key) - .max_by(|left, right| { - left.change_seq - .cmp(&right.change_seq) - .then_with(|| left.seq.cmp(&right.seq)) - })) -} - -fn app_record_summary( - record: &RuntimeStoreRecord, - superseded_count: usize, -) -> ListingAppRecordSummaryView { - let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); - let (listing_id, title, exportable, reason) = match record_kind.as_str() { - DRAFT_KIND => { - if let Some(reason) = app_record_exportability_reason(record) { - let (listing_id, title, _) = app_listing_display_parts(record); - (listing_id, title, false, Some(reason)) - } else { - match app_listing_draft_from_record(record) { - Ok(draft) => ( - non_empty(draft.listing.d_tag), - non_empty(draft.product.title), - true, - None, - ), - Err(reason) => { - let (listing_id, title, _) = app_listing_display_parts(record); - (listing_id, title, false, Some(reason)) - } - } - } - } - "farm_config_v1" => ( - None, - record - .local_work_json - .as_ref() - .and_then(|payload| payload["document"]["farm"]["name"].as_str()) - .map(str::to_owned), - false, - Some("farm records provide defaults; export selects listing records".to_owned()), - ), - _ => ( - None, - None, - false, - Some(format!("unsupported app record kind `{record_kind}`")), - ), - }; - let actions = if exportable { - vec![format!("radroots listing app export {}", record.record_id)] - } else { - Vec::new() - }; - - ListingAppRecordSummaryView { - record_id: record.record_id.clone(), - seq: record.seq, - change_seq: record.change_seq, - superseded_count, - record_kind, - status: record.status.as_str().to_owned(), - source_runtime: record.source_runtime.as_str().to_owned(), - owner_account_id: record.owner_account_id.clone(), - owner_pubkey: record.owner_pubkey.clone(), - farm_id: record.farm_id.clone(), - listing_addr: record.listing_addr.clone(), - listing_id, - title, - exportable, - reason, - actions, - } -} - -fn app_record_current_key(record: &RuntimeStoreRecord) -> String { - match local_record_kind(record).as_deref() { - Some(DRAFT_KIND) => { - if let Some(listing_addr) = record - .listing_addr - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return format!("listing_addr:{listing_addr}"); - } - let (listing_id, _, _) = app_listing_display_parts(record); - if let (Some(owner_pubkey), Some(listing_id)) = ( - app_record_canonical_owner_pubkey(record), - listing_id.filter(|value| is_d_tag_base64url(value)), - ) { - return format!("listing_owner:{owner_pubkey}:{listing_id}"); - } - } - Some("farm_config_v1") => { - if let Some(farm_id) = record - .farm_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return format!("farm:{farm_id}"); - } - if let Some(farm_id) = record - .local_work_json - .as_ref() - .and_then(|payload| payload["document"]["farm"]["d_tag"].as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return format!("farm:{farm_id}"); - } - } - _ => {} - } - format!("record:{}", record.record_id) -} - -fn canonical_hex_pubkey(value: &str) -> Option<String> { - let trimmed = value.trim(); - if trimmed.len() == 64 && trimmed.chars().all(|char| char.is_ascii_hexdigit()) { - Some(trimmed.to_ascii_lowercase()) - } else { - None - } -} - -fn app_record_canonical_owner_pubkey(record: &RuntimeStoreRecord) -> Option<String> { - record - .owner_pubkey - .as_deref() - .and_then(canonical_hex_pubkey) -} - -fn app_listing_display_parts( - record: &RuntimeStoreRecord, -) -> (Option<String>, Option<String>, Option<String>) { - let document = record - .local_work_json - .as_ref() - .and_then(|payload| payload.get("document")); - let listing_id = document - .and_then(|document| document["listing"]["d_tag"].as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); - let title = document - .and_then(|document| document["product"]["title"].as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); - let farm_d_tag = document - .and_then(|document| document["listing"]["farm_d_tag"].as_str()) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned); - (listing_id, title, farm_d_tag) -} - -fn app_record_exportability_reason(record: &RuntimeStoreRecord) -> Option<String> { - if local_record_kind(record).as_deref() == Some(DRAFT_KIND) - && app_record_canonical_owner_pubkey(record).is_none() - { - return Some(CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned()); - } - let exportability = record - .local_work_json - .as_ref() - .and_then(|payload| payload.get("exportability"))?; - let state = exportability - .get("state") - .and_then(Value::as_str) - .unwrap_or_default(); - if state.is_empty() || state == "exportable" { - return None; - } - let reason = exportability - .get("reason") - .and_then(Value::as_str) - .unwrap_or_default(); - Some(match (state, reason) { - ("identity_unresolved", "canonical_hex_pubkey_required") => { - CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned() - } - ("identity_unresolved", _) => "app record identity is unresolved".to_owned(), - (_, "") => format!("app record exportability state `{state}` is not exportable"), - (_, reason) => format!("app record exportability state `{state}`: {reason}"), - }) -} - -fn app_listing_draft_from_record( - record: &RuntimeStoreRecord, -) -> Result<ListingDraftDocument, String> { - if record.source_runtime != SourceRuntime::App { - return Err(format!( - "record source_runtime `{}` is not app", - record.source_runtime.as_str() - )); - } - if record.family != RuntimeStoreRecordFamily::LocalWork { - return Err(format!( - "record family `{}` is not local_work", - record.family.as_str() - )); - } - let payload = record - .local_work_json - .as_ref() - .ok_or_else(|| "record has no local_work_json payload".to_owned())?; - let record_kind = local_record_kind(record).unwrap_or_else(|| "unknown".to_owned()); - if record_kind != DRAFT_KIND { - return Err(format!("record kind `{record_kind}` is not {DRAFT_KIND}")); - } - if let Some(reason) = app_record_exportability_reason(record) { - return Err(reason); - } - let owner_pubkey = app_record_canonical_owner_pubkey(record) - .ok_or_else(|| CANONICAL_OWNER_PUBKEY_REQUIRED_REASON.to_owned())?; - let document = payload - .get("document") - .cloned() - .ok_or_else(|| "record local_work_json.document is missing".to_owned())?; - let mut draft = serde_json::from_value::<ListingDraftDocument>(document) - .map_err(|error| format!("record listing document is invalid: {error}"))?; - if let Some(account_id) = record - .owner_account_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - draft.seller_actor.account_id = account_id.to_owned(); - } - draft.seller_actor.pubkey = owner_pubkey; - if draft.listing.farm_d_tag.trim().is_empty() - && let Some(farm_id) = record.farm_id.as_ref() - { - draft.listing.farm_d_tag = farm_id.clone(); - } - normalize_app_listing_availability(&mut draft)?; - normalize_app_listing_units(&mut draft); - Ok(draft) -} - -fn normalize_app_listing_availability(draft: &mut ListingDraftDocument) -> Result<(), String> { - let kind = draft.availability.kind.trim(); - if kind.is_empty() || kind == "local" { - draft.availability.kind = "status".to_owned(); - } else if !matches!(kind, "status" | "window") { - return Err(format!( - "unsupported app listing availability kind `{kind}`" - )); - } - if draft.availability.kind == "window" { - return Ok(()); - } - - let status = draft.availability.status.trim(); - draft.availability.status = match status { - "" | "active" | "draft" | "published" => "active".to_owned(), - "archived" | "paused" | "sold" => { - return Err(format!( - "app listing status `{status}` is not exportable as a publishable CLI draft" - )); - } - other => return Err(format!("unsupported app listing status `{other}`")), - }; - Ok(()) -} - -fn normalize_app_listing_units(draft: &mut ListingDraftDocument) { - let quantity_unit = draft.primary_bin.quantity_unit.trim().to_owned(); - let price_per_unit = draft.primary_bin.price_per_unit.trim().to_owned(); - let quantity_unit_supported = quantity_unit.parse::<Unit>().is_ok(); - let price_per_unit_supported = price_per_unit.parse::<Unit>().is_ok(); - if quantity_unit_supported && price_per_unit_supported { - return; - } - - if draft.primary_bin.label.trim().is_empty() { - draft.primary_bin.label = if !quantity_unit_supported && !quantity_unit.is_empty() { - quantity_unit.clone() - } else { - price_per_unit.clone() - }; - } - if !quantity_unit_supported { - draft.primary_bin.quantity_unit = "each".to_owned(); - } - if !price_per_unit_supported { - draft.primary_bin.price_per_unit = "each".to_owned(); - } -} - -fn app_listing_export_issues( - config: &RuntimeConfig, - draft: &ListingDraftDocument, - contents: &str, - context: &ListingValidationContext, -) -> Result<Vec<ListingValidationIssueView>, RuntimeError> { - let canonical = match canonicalize_draft(draft, contents, context) { - Ok(canonical) => canonical, - Err(error) => return Ok(vec![error.into_issue()]), - }; - let mut issues = listing_ready_issues(&canonical, contents); - if let Some(issue) = listing_bound_account_issue(config, &canonical, contents)? { - issues.push(issue); - } - Ok(issues) -} - -fn app_record_listing_addr(draft: &ListingDraftDocument) -> Option<String> { - let seller_pubkey = draft.seller_actor.pubkey.trim(); - let listing_id = draft.listing.d_tag.trim(); - if seller_pubkey.is_empty() || listing_id.is_empty() { - None - } else { - Some(listing_addr(seller_pubkey, listing_id)) - } -} - -fn local_record_kind(record: &RuntimeStoreRecord) -> Option<String> { - record - .local_work_json - .as_ref() - .and_then(|payload| payload.get("record_kind")) - .and_then(Value::as_str) - .map(str::to_owned) -} - -pub fn get( - config: &RuntimeConfig, - args: &RecordLookupArgs, -) -> Result<ListingGetView, RuntimeError> { - refresh_market_listing_if_needed(config)?; - let freshness = if config.local.replica_store_path.exists() { - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - freshness_for_scope_from_executor(config, &executor, RelayIngestScope::MarketPull)? - } else { - missing_freshness() - }; - let provenance = FindResultProvenanceView { - origin: "local_replica.trade_product".to_owned(), - freshness: freshness.display.clone(), - relay_count: config.transport.nostr_relay_urls.len(), - }; - - if !config.local.replica_store_path.exists() { - return Ok(ListingGetView { - state: "unconfigured".to_owned(), - source: LISTING_READ_SOURCE.to_owned(), - lookup: args.key.clone(), - readiness: MarketReadinessView::unavailable("local_replica_not_initialized"), - listing_id: None, - product_key: None, - listing_addr: None, - primary_bin_id: None, - title: None, - category: None, - description: None, - location_primary: None, - available: None, - price: None, - provenance, - reason: Some("local replica database is not initialized".to_owned()), - actions: vec!["radroots store inspect".to_owned()], - }); - } - - let db = ReplicaSql::new(SqlxSqliteExecutor::open(&config.local.replica_store_path)?); - let rows = db.trade_product_lookup(args.key.as_str())?; - let Some(row) = rows.into_iter().next() else { - return Ok(ListingGetView { - state: "missing".to_owned(), - source: LISTING_READ_SOURCE.to_owned(), - lookup: args.key.clone(), - readiness: MarketReadinessView::unavailable("market_listing_missing"), - listing_id: None, - product_key: None, - listing_addr: None, - primary_bin_id: None, - title: None, - category: None, - description: None, - location_primary: None, - available: None, - price: None, - provenance, - reason: Some(format!( - "listing `{}` is not available in the local replica", - args.key - )), - actions: vec![ - "radroots sync pull".to_owned(), - format!("radroots market search {}", args.key), - ], - }); - }; - - let listing_addr = row.listing_addr.and_then(non_empty); - let primary_bin_id = row.primary_bin_id.and_then(non_empty); - let verified_primary_bin_id = row.verified_primary_bin_id.and_then(non_empty); - let available_amount = row.qty_avail; - let price_amount = row.price_amt; - let price_currency = row.price_currency; - let price_per_amount = row.price_qty_amt; - let readiness = MarketReadinessView::from_market_projection( - listing_addr.as_deref(), - primary_bin_id.as_deref(), - verified_primary_bin_id.as_deref(), - Some(row.title.as_str()), - Some(row.category.as_str()), - available_amount, - price_amount, - price_currency.as_str(), - price_per_amount, - ); - - Ok(ListingGetView { - state: "ready".to_owned(), - source: LISTING_READ_SOURCE.to_owned(), - lookup: args.key.clone(), - readiness, - listing_id: Some(row.id), - product_key: Some(row.key), - listing_addr, - primary_bin_id, - title: Some(row.title), - category: Some(row.category), - description: non_empty(row.summary), - location_primary: row.location_primary.and_then(non_empty), - available: Some(FindQuantityView { - total_amount: row.qty_amt, - total_unit: row.qty_unit, - label: row.qty_label.and_then(non_empty), - available_amount, - }), - price: Some(FindPriceView { - amount: price_amount, - currency: price_currency, - per_amount: price_per_amount, - per_unit: row.price_qty_unit, - }), - provenance, - reason: None, - actions: Vec::new(), - }) -} - -fn refresh_market_listing_if_needed(config: &RuntimeConfig) -> Result<(), RuntimeError> { - if !config.local.replica_store_path.exists() - || config.output.dry_run - || config.transport.nostr_relay_urls.is_empty() - { - return Ok(()); - } - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - let freshness = - freshness_for_scope_from_executor(config, &executor, RelayIngestScope::MarketPull)?; - if crate::runtime::sync::freshness_requires_refresh(&freshness) { - let _ = market_refresh(config)?; - } - Ok(()) -} - -pub fn publish_via_sdk( - config: &RuntimeConfig, - args: &ListingMutationArgs, -) -> Result<ListingMutationView, CliSdkAdapterError> { - let input = sdk_listing_publish_input(config, args)?; - validate_configured_listing_signer(config, &input.canonical)?; - let plan = sdk_listing::prepare(sdk_listing::PrepareRequest::publish( - input.actor, - input.document, - sdk_created_at_unix()?, - )) - .map_err(|error| RuntimeError::Config(format!("invalid SDK listing plan: {error}")))?; - if !config.output.dry_run { - return Err(RuntimeError::Config( - "listing commit is unavailable until the shared sync engine is configured".to_owned(), - ) - .into()); - } - Ok(sdk_prepared_publish_view( - config, - args, - ListingMutationOperation::Publish, - &input.canonical, - plan, - )) -} - -fn sdk_listing_publish_input( - config: &RuntimeConfig, - args: &ListingMutationArgs, -) -> Result<SdkListingPublishInput, RuntimeError> { - let contents = fs::read_to_string(&args.file)?; - let parsed = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { - RuntimeError::Config(format!( - "invalid listing draft {}: {error}", - args.file.display() - )) - })?; - let context = mutation_validation_context(config)?; - let canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| { - let issue = match error { - ListingDraftValidationError::MissingSellerAccount(issue) => { - return account::AccountRuntimeFailure::unresolved_with_detail( - format!("{} ({})", issue.message, issue.field), - json!({ - "seller_actor_source": "listing_draft", - "listing_file": args.file.display().to_string(), - "actions": listing_bound_account_recovery_actions(args.file.as_path()), - }), - ) - .into(); - } - ListingDraftValidationError::Issue(issue) => issue, - }; - RuntimeError::Config(format!( - "invalid listing draft {}: {} ({})", - args.file.display(), - issue.message, - issue.field - )) - })?; - ensure_listing_bound_account(config, &canonical, args.file.as_path())?; - let actor = Actor::from_public_key_hex( - canonical.seller_pubkey.as_str(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Seller], - ) - .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?; - let document = RadrootsOperationalListingEditDocumentV1::new(canonical.listing.clone()); - Ok(SdkListingPublishInput { - canonical, - actor, - document, - }) -} - -fn sdk_prepared_publish_view( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, - canonical: &CanonicalListingDraft, - plan: ListingPlan, -) -> ListingMutationView { - let listing_addr = plan.address().as_str().to_owned(); - let event = sdk_plan_event_view(&plan); - ListingMutationView { - state: "dry_run".to_owned(), - operation: operation.as_str().to_owned(), - source: SDK_LISTING_WRITE_SOURCE.to_owned(), - file: args.file.display().to_string(), - listing_id: canonical.listing_id.clone(), - listing_addr: listing_addr.clone(), - seller_account_id: canonical.seller_account_id.clone(), - seller_pubkey: canonical.seller_pubkey.clone(), - seller_actor_source: canonical.seller_actor_source.clone(), - event_kind: KIND_CLASSIFIED_LISTING, - dry_run: true, - deduplicated: false, - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - job_id: None, - job_status: None, - signer_mode: Some(config.signer.backend.as_str().to_owned()), - event_id: Some(plan.draft().expected_event_id().to_string()), - event_addr: Some(listing_addr), - idempotency_key: args.idempotency_key.clone(), - local_replica: None, - reason: Some("dry run requested; SDK enqueue and transport push skipped".to_owned()), - job: None, - event: args.print_event.then_some(event), - actions: vec![format!("radroots listing publish {}", args.file.display())], - } -} - -fn sdk_plan_event_view(plan: &ListingPlan) -> ListingMutationEventView { - ListingMutationEventView { - kind: plan.draft().kind_u32(), - author: plan.draft().expected_pubkey().to_hex(), - created_at: Some(plan.draft().created_at_u64()), - content: plan.draft().content().to_owned(), - tags: plan.draft().tags_as_vec(), - event_id: Some(plan.draft().expected_event_id().to_string()), - signature: None, - event_addr: plan.address().as_str().to_owned(), - } -} - -pub fn update( - config: &RuntimeConfig, - args: &ListingMutationArgs, -) -> Result<ListingMutationView, CliSdkAdapterError> { - mutate(config, args, ListingMutationOperation::Update) -} - -pub fn pause( - config: &RuntimeConfig, - args: &ListingMutationArgs, -) -> Result<ListingMutationView, CliSdkAdapterError> { - mutate(config, args, ListingMutationOperation::Pause) -} - -pub fn withdraw( - config: &RuntimeConfig, - args: &ListingMutationArgs, -) -> Result<ListingMutationView, CliSdkAdapterError> { - mutate(config, args, ListingMutationOperation::Withdraw) -} - -fn mutate( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, -) -> Result<ListingMutationView, CliSdkAdapterError> { - let contents = fs::read_to_string(&args.file).map_err(RuntimeError::from)?; - let parsed = toml::from_str::<ListingDraftDocument>(&contents).map_err(|error| { - RuntimeError::Config(format!( - "invalid listing draft {}: {error}", - args.file.display() - )) - })?; - let context = mutation_validation_context(config)?; - let mut canonical = canonicalize_draft(&parsed, &contents, &context).map_err(|error| { - let issue = match error { - ListingDraftValidationError::MissingSellerAccount(issue) => { - return account::AccountRuntimeFailure::unresolved_with_detail( - format!("{} ({})", issue.message, issue.field), - json!({ - "seller_actor_source": "listing_draft", - "listing_file": args.file.display().to_string(), - "actions": listing_bound_account_recovery_actions(args.file.as_path()), - }), - ) - .into(); - } - ListingDraftValidationError::Issue(issue) => issue, - }; - RuntimeError::Config(format!( - "invalid listing draft {}: {} ({})", - args.file.display(), - issue.message, - issue.field - )) - })?; - ensure_listing_bound_account(config, &canonical, args.file.as_path())?; - - if let Some(status) = operation.listing_status() { - canonical.listing.availability = Some(OperationalListingAvailability::Status { - status: OperationalListingStatus::Other { - value: status.to_owned(), - }, - }); - } - - if config.output.dry_run { - validate_configured_listing_signer(config, &canonical)?; - } - - mutate_via_sdk_from_canonical(config, args, operation, canonical) -} - -fn mutate_via_sdk_from_canonical( - config: &RuntimeConfig, - args: &ListingMutationArgs, - operation: ListingMutationOperation, - canonical: CanonicalListingDraft, -) -> Result<ListingMutationView, CliSdkAdapterError> { - let actor = Actor::from_public_key_hex( - canonical.seller_pubkey.as_str(), - ActorSource::ExplicitPublicKey, - [AuthorRole::Seller], - ) - .map_err(|error| RuntimeError::Config(format!("invalid listing SDK actor: {error}")))?; - let document = RadrootsOperationalListingEditDocumentV1::new(canonical.listing.clone()); - let request = if matches!(operation, ListingMutationOperation::Publish) { - sdk_listing::PrepareRequest::publish(actor, document, sdk_created_at_unix()?) - } else { - sdk_listing::PrepareRequest::update(actor, document, sdk_created_at_unix()?) - }; - let plan = sdk_listing::prepare(request) - .map_err(|error| RuntimeError::Config(format!("invalid SDK listing plan: {error}")))?; - if !config.output.dry_run { - return Err(RuntimeError::Config( - "listing commit is unavailable until the shared sync engine is configured".to_owned(), - ) - .into()); - } - Ok(sdk_prepared_publish_view( - config, args, operation, &canonical, plan, - )) -} - -fn sdk_created_at_unix() -> Result<u64, RuntimeError> { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_secs()) - .map_err(|error| RuntimeError::Config(format!("system clock error: {error}"))) -} - -fn scaffold_contents(draft: &ListingDraftDocument) -> Result<String, RuntimeError> { - let toml = toml::to_string_pretty(draft).map_err(|error| { - RuntimeError::Config(format!("failed to render listing draft: {error}")) - })?; - Ok(format!( - "# radroots listing draft v1\n# this scaffold applies selected farm defaults and provided product inputs when available\n# review any remaining empty fields, then run `radroots listing publish <file>`\n\n{toml}" - )) -} - -fn validation_context(config: &RuntimeConfig) -> Result<ListingValidationContext, RuntimeError> { - Ok(ListingValidationContext { - farm_setup_action: farm_setup_action(config)?, - }) -} - -fn mutation_validation_context( - config: &RuntimeConfig, -) -> Result<ListingValidationContext, RuntimeError> { - validation_context(config) -} - -fn canonicalize_draft( - draft: &ListingDraftDocument, - contents: &str, - _context: &ListingValidationContext, -) -> Result<CanonicalListingDraft, ListingDraftValidationError> { - if draft.version != 1 { - return Err(issue_for_field( - contents, - "version", - format!("unsupported listing draft version `{}`", draft.version), - ) - .into()); - } - if draft.kind.trim() != DRAFT_KIND { - return Err(issue_for_field( - contents, - "kind", - format!("unsupported listing draft kind `{}`", draft.kind), - ) - .into()); - } - - let listing_id = draft.listing.d_tag.trim().to_owned(); - if !is_d_tag_base64url(&listing_id) { - return Err(issue_for_field( - contents, - "listing.d_tag", - "listing d_tag must be a 22-character base64url identifier", - ) - .into()); - } - - let seller_account_id = - if let Some(account_id) = non_empty(draft.seller_actor.account_id.clone()) { - account_id - } else { - return Err(ListingDraftValidationError::MissingSellerAccount( - issue_for_field( - contents, - "seller_actor.account_id", - "missing listing seller_actor account_id", - ), - )); - }; - - let seller_pubkey = if let Some(pubkey) = non_empty(draft.seller_actor.pubkey.clone()) { - pubkey - } else { - return Err(ListingDraftValidationError::MissingSellerAccount( - issue_for_field( - contents, - "seller_actor.pubkey", - "missing listing seller_actor pubkey", - ), - )); - }; - - let seller_actor_source = if let Some(source) = non_empty(draft.seller_actor.source.clone()) { - source - } else { - return Err(ListingDraftValidationError::MissingSellerAccount( - issue_for_field( - contents, - "seller_actor.source", - "missing listing seller_actor source", - ), - )); - }; - if !matches!( - seller_actor_source.as_str(), - LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG - | LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT - | LISTING_SELLER_ACTOR_SOURCE_REBIND - ) { - return Err(issue_for_field( - contents, - "seller_actor.source", - format!("unsupported listing seller_actor source `{seller_actor_source}`"), - ) - .into()); - } - - let farm_d_tag = if let Some(d_tag) = non_empty(draft.listing.farm_d_tag.clone()) { - d_tag - } else { - return Err( - issue_for_field(contents, "listing.farm_d_tag", "missing listing farm_d_tag").into(), - ); - }; - if !is_d_tag_base64url(&farm_d_tag) { - return Err(issue_for_field( - contents, - "listing.farm_d_tag", - "farm_d_tag must be a 22-character base64url identifier", - ) - .into()); - } - - let quantity_amount = parse_decimal_field( - draft.primary_bin.quantity_amount.as_str(), - contents, - "primary_bin.quantity_amount", - )?; - let quantity_unit = parse_unit_field( - draft.primary_bin.quantity_unit.as_str(), - contents, - "primary_bin.quantity_unit", - )?; - let quantity = Quantity::try_new(quantity_amount, quantity_unit) - .map_err(|error| { - issue_for_field( - contents, - "primary_bin.quantity_amount", - format!("invalid primary_bin quantity: {error}"), - ) - })? - .with_optional_label(non_empty(draft.primary_bin.label.clone())) - .to_canonical() - .map_err(|error| { - issue_for_field( - contents, - "primary_bin.quantity_unit", - format!("invalid primary_bin quantity unit conversion: {error}"), - ) - })?; - - let price_amount = parse_decimal_field( - draft.primary_bin.price_amount.as_str(), - contents, - "primary_bin.price_amount", - )?; - let price_currency = parse_currency_field( - draft.primary_bin.price_currency.as_str(), - contents, - "primary_bin.price_currency", - )?; - let price_per_amount = parse_decimal_field( - draft.primary_bin.price_per_amount.as_str(), - contents, - "primary_bin.price_per_amount", - )?; - let price_per_unit = parse_unit_field( - draft.primary_bin.price_per_unit.as_str(), - contents, - "primary_bin.price_per_unit", - )?; - let price_money = Money::try_new(price_amount, price_currency).map_err(|error| { - issue_for_field(contents, "primary_bin.price_amount", error.to_string()) - })?; - let price_quantity = Quantity::try_new(price_per_amount, price_per_unit).map_err(|error| { - issue_for_field(contents, "primary_bin.price_per_amount", error.to_string()) - })?; - let price = QuantityPrice::try_new(price_money, price_quantity) - .map_err(|error| issue_for_field(contents, "primary_bin.price_amount", error.to_string()))? - .try_to_canonical_unit_price() - .map_err(|error| { - issue_for_field( - contents, - "primary_bin.price_per_unit", - format!("invalid primary_bin price definition: {error:?}"), - ) - })?; - - let inventory_available = parse_decimal_field( - draft.inventory.available.as_str(), - contents, - "inventory.available", - )?; - let availability = build_availability(draft, contents)?; - let delivery_method = build_delivery_method(draft, contents)?; - let location = build_location(draft); - let discounts = build_listing_discounts( - draft, - contents, - draft.primary_bin.bin_id.trim(), - price_currency, - )?; - let primary_bin_id = - protocol_inventory_bin_id(draft.primary_bin.bin_id.trim(), "primary_bin.bin_id").map_err( - |error| { - issue_for_field( - contents, - "primary_bin.bin_id", - format!("invalid primary_bin bin id: {error}"), - ) - }, - )?; - - let listing = OperationalListing { - d_tag: protocol_d_tag(listing_id.as_str(), "listing d_tag").map_err(|error| { - issue_for_field( - contents, - "listing.d_tag", - format!("invalid listing d_tag: {error}"), - ) - })?, - published_at: None, - farm: FarmRef { - pubkey: seller_pubkey.clone(), - d_tag: farm_d_tag.clone(), - }, - product: OperationalListingProduct { - key: draft.product.key.trim().to_owned(), - title: draft.product.title.trim().to_owned(), - category: draft.product.category.trim().to_owned(), - summary: non_empty(draft.product.summary.clone()), - process: None, - lot: None, - location: None, - profile: None, - year: None, - }, - primary_bin_id: primary_bin_id.clone(), - bins: vec![OperationalListingBin { - bin_id: primary_bin_id, - quantity, - price_per_canonical_unit: price, - display_amount: None, - display_unit: None, - display_label: non_empty(draft.primary_bin.label.clone()), - display_price: None, - display_price_unit: None, - }], - resource_area: None, - plot: None, - discounts, - inventory_available: Some(inventory_available), - availability: Some(availability), - delivery_method: Some(delivery_method), - location: Some(location), - images: None, - }; - - Ok(CanonicalListingDraft { - listing_id, - seller_account_id, - seller_pubkey, - seller_actor_source, - farm_d_tag, - listing, - }) -} - -fn build_availability( - draft: &ListingDraftDocument, - contents: &str, -) -> Result<OperationalListingAvailability, ListingValidationIssueView> { - let kind = if draft.availability.kind.trim().is_empty() { - if draft.availability.start.is_some() || draft.availability.end.is_some() { - "window" - } else { - "status" - } - } else { - draft.availability.kind.trim() - }; - - match kind { - "status" => { - let status = draft.availability.status.trim(); - if status.is_empty() { - return Err(issue_for_field( - contents, - "availability.status", - "missing availability status", - )); - } - Ok(OperationalListingAvailability::Status { - status: match status { - "active" => OperationalListingStatus::Active, - "sold" => OperationalListingStatus::Sold, - other => OperationalListingStatus::Other { - value: other.to_owned(), - }, - }, - }) - } - "window" => Ok(OperationalListingAvailability::Window { - start: draft.availability.start, - end: draft.availability.end, - }), - _ => Err(issue_for_field( - contents, - "availability.kind", - format!("unsupported availability kind `{kind}`"), - )), - } -} - -fn build_delivery_method( - draft: &ListingDraftDocument, - contents: &str, -) -> Result<OperationalListingDeliveryMethod, ListingValidationIssueView> { - let method = draft.delivery.method.trim(); - if method.is_empty() { - return Err(issue_for_field( - contents, - "delivery.method", - "missing delivery method", - )); - } - - Ok(match method { - "pickup" => OperationalListingDeliveryMethod::Pickup, - "local_delivery" => OperationalListingDeliveryMethod::LocalDelivery, - "shipping" => OperationalListingDeliveryMethod::Shipping, - other => OperationalListingDeliveryMethod::Other { - method: other.to_owned(), - }, - }) -} - -fn build_location(draft: &ListingDraftDocument) -> OperationalListingPublicLocation { - OperationalListingPublicLocation { - primary: draft.location.primary.trim().to_owned(), - city: draft.location.city.clone().and_then(non_empty), - region: draft.location.region.clone().and_then(non_empty), - country: draft.location.country.clone().and_then(non_empty), - geohash: draft.location.geohash.trim().to_owned(), - } -} - -fn build_listing_discounts( - draft: &ListingDraftDocument, - contents: &str, - primary_bin_id: &str, - price_currency: Currency, -) -> Result<Option<Vec<Discount>>, ListingValidationIssueView> { - let mut discounts = Vec::new(); - for (index, discount) in draft.discounts.iter().enumerate() { - let field_prefix = format!("discounts.{index}"); - if discount.id.trim().is_empty() { - return Err(issue_for_field( - contents, - field_prefix.as_str(), - "discount id must not be empty", - )); - } - let bin_id = discount - .bin_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(primary_bin_id) - .to_owned(); - let min = discount.min_bin_count.unwrap_or(1); - if min == 0 { - return Err(issue_for_field( - contents, - field_prefix.as_str(), - "discount min_bin_count must be greater than zero", - )); - } - let value = match discount.kind.trim() { - "percent" => { - let raw = discount.value.trim(); - if raw.is_empty() { - return Err(issue_for_field( - contents, - field_prefix.as_str(), - "percent discount requires value", - )); - } - let percent = raw.parse::<Percent>().map_err(|error| { - issue_for_field( - contents, - field_prefix.as_str(), - format!("percent discount value is invalid: {error}"), - ) - })?; - DiscountValue::Percent(percent) - } - "amount" => { - let raw_amount = discount.amount.trim(); - if raw_amount.is_empty() { - return Err(issue_for_field( - contents, - field_prefix.as_str(), - "amount discount requires amount", - )); - } - let amount = parse_decimal_field(raw_amount, contents, field_prefix.as_str())?; - let currency = if discount.currency.trim().is_empty() { - price_currency - } else { - parse_currency_field( - discount.currency.as_str(), - contents, - field_prefix.as_str(), - )? - }; - DiscountValue::MoneyPerBin(Money::try_new(amount, currency).map_err(|error| { - issue_for_field(contents, field_prefix.as_str(), error.to_string()) - })?) - } - other => { - return Err(issue_for_field( - contents, - field_prefix.as_str(), - format!("unsupported discount kind `{other}`"), - )); - } - }; - let discount = Discount::try_new( - DiscountScope::Bin, - DiscountThreshold::BinCount { bin_id, min }, - value, - ) - .map_err(|error| issue_for_field(contents, field_prefix.as_str(), error.to_string()))?; - discounts.push(discount); - } - Ok((!discounts.is_empty()).then_some(discounts)) -} - -fn listing_bound_account_issue( - config: &RuntimeConfig, - canonical: &CanonicalListingDraft, - contents: &str, -) -> Result<Option<ListingValidationIssueView>, RuntimeError> { - let Some(account) = configured_account(config, &canonical.seller_account_id)? else { - return Ok(Some(issue_for_field( - contents, - "seller_actor.account_id", - format!( - "listing seller_actor account_id `{}` is not present in the local account store", - canonical.seller_account_id - ), - ))); - }; - let account_pubkey = account.record.public_identity().public_key().to_hex(); - if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { - return Ok(Some(issue_for_field( - contents, - "seller_actor.pubkey", - format!( - "listing seller_actor pubkey `{}` does not match account `{}` pubkey `{account_pubkey}`", - canonical.seller_pubkey, canonical.seller_account_id - ), - ))); - } - Ok(None) -} - -fn ensure_listing_bound_account( - config: &RuntimeConfig, - canonical: &CanonicalListingDraft, - file: &Path, -) -> Result<(), RuntimeError> { - validate_invocation_account_matches_bound(config, canonical, file)?; - let Some(account) = configured_account(config, &canonical.seller_account_id)? else { - return Err(account::AccountRuntimeFailure::unresolved_with_detail( - format!( - "listing-bound seller account `{}` is not present in the local account store", - canonical.seller_account_id - ), - json!({ - "seller_actor_source": canonical.seller_actor_source, - "listing_seller_account_id": canonical.seller_account_id, - "listing_file": file.display().to_string(), - "actions": listing_bound_account_recovery_actions(file), - }), - ) - .into()); - }; - let account_pubkey = account.record.public_identity().public_key().to_hex(); - if !account_pubkey.eq_ignore_ascii_case(canonical.seller_pubkey.as_str()) { - return Err(account::AccountRuntimeFailure::mismatch_with_detail( - format!( - "account mismatch: listing-bound seller account `{}` pubkey `{account_pubkey}` cannot sign listing seller_pubkey `{}`", - canonical.seller_account_id, canonical.seller_pubkey - ), - json!({ - "seller_actor_source": canonical.seller_actor_source, - "listing_seller_account_id": canonical.seller_account_id, - "listing_seller_pubkey": canonical.seller_pubkey, - "account_pubkey": account_pubkey, - "listing_file": file.display().to_string(), - "actions": listing_bound_account_recovery_actions(file), - }), - ) - .into()); - } - Ok(()) -} - -fn validate_invocation_account_matches_bound( - config: &RuntimeConfig, - canonical: &CanonicalListingDraft, - file: &Path, -) -> Result<(), RuntimeError> { - let Some(selector) = config - .account - .selector - .as_deref() - .map(str::trim) - .filter(|selector| !selector.is_empty()) - else { - return Ok(()); - }; - let attempted = account::resolve_account_selector(config, selector)?; - if attempted.record.id().to_string() == canonical.seller_account_id { - return Ok(()); - } - Err(account::AccountRuntimeFailure::mismatch_with_detail( - format!( - "account mismatch: listing draft is bound to seller account `{}`; invocation selected `{}`", - canonical.seller_account_id, attempted.record.id() - ), - json!({ - "seller_actor_source": canonical.seller_actor_source, - "listing_seller_account_id": canonical.seller_account_id, - "attempted_seller_account_id": attempted.record.id().to_string(), - "listing_file": file.display().to_string(), - "actions": listing_bound_account_recovery_actions(file), - }), - ) - .into()) -} - -fn listing_bound_account_recovery_actions(file: &Path) -> Vec<String> { - vec![ - "radroots account import <path>".to_owned(), - format!("radroots listing rebind {} <selector>", file.display()), - ] -} - -fn invalid_validation_view( - file: &Path, - draft: &ListingDraftDocument, - context: &ListingValidationContext, - issue: ListingValidationIssueView, -) -> ListingValidateView { - let mut actions = vec![format!("edit {}", file.display())]; - if draft.seller_actor.account_id.trim().is_empty() { - actions.push("radroots account create".to_owned()); - } else { - actions.push(format!( - "radroots listing rebind {} <selector>", - file.display() - )); - } - if draft.listing.farm_d_tag.trim().is_empty() { - actions.push(context.farm_setup_action.clone()); - } - - ListingValidateView { - state: "invalid".to_owned(), - source: LISTING_SOURCE.to_owned(), - file: file.display().to_string(), - valid: false, - listing_id: non_empty(draft.listing.d_tag.clone()), - seller_account_id: non_empty(draft.seller_actor.account_id.clone()), - seller_pubkey: non_empty(draft.seller_actor.pubkey.clone()), - seller_actor_source: non_empty(draft.seller_actor.source.clone()), - farm_d_tag: non_empty(draft.listing.farm_d_tag.clone()), - issues: vec![issue], - actions, - } -} - -fn validate_configured_listing_signer( - config: &RuntimeConfig, - canonical: &CanonicalListingDraft, -) -> Result<(), RuntimeError> { - validate_configured_signer_for_actor( - config, - Some(canonical.seller_account_id.as_str()), - canonical.seller_pubkey.as_str(), - "listing seller", - ) -} - -fn validate_operational_listing_draft( - canonical: &CanonicalListingDraft, -) -> Result<(), OperationalListingValidationError> { - let seller_pubkey = PublicKey::from_hex(canonical.seller_pubkey.as_str()) - .map_err(|_| OperationalListingValidationError::InvalidSeller)?; - validate_operational_listing_model(canonical.listing.clone(), &seller_pubkey).map(|_| ()) -} - -fn issue_from_trade_validation( - error: OperationalListingValidationError, - contents: &str, -) -> ListingValidationIssueView { - match error { - OperationalListingValidationError::InvalidSeller => issue_for_field( - contents, - "seller_actor.pubkey", - "listing author does not match the farm pubkey", - ), - OperationalListingValidationError::MissingTitle => { - issue_for_field(contents, "product.title", "missing listing title") - } - OperationalListingValidationError::MissingDescription => { - issue_for_field(contents, "product.summary", "missing listing description") - } - OperationalListingValidationError::MissingProductType => { - issue_for_field(contents, "product.category", "missing listing product type") - } - OperationalListingValidationError::MissingBins - | OperationalListingValidationError::MissingPrimaryBin - | OperationalListingValidationError::InvalidBin => { - issue_for_field(contents, "primary_bin.bin_id", error.to_string()) - } - OperationalListingValidationError::MissingPrice - | OperationalListingValidationError::InvalidPrice => issue_for_field( - contents, - "primary_bin.price_amount", - "invalid listing price", - ), - OperationalListingValidationError::MissingInventory - | OperationalListingValidationError::InvalidInventory => { - issue_for_field(contents, "inventory.available", error.to_string()) - } - OperationalListingValidationError::MissingAvailability => issue_for_field( - contents, - "availability.status", - "missing listing availability", - ), - OperationalListingValidationError::MissingLocation - | OperationalListingValidationError::MissingLocationLocality => { - issue_for_field(contents, "location.primary", error.to_string()) - } - OperationalListingValidationError::MissingLocationGeohash - | OperationalListingValidationError::InvalidLocationGeohash => { - issue_for_field(contents, "location.geohash", error.to_string()) - } - OperationalListingValidationError::MissingDeliveryMethod => issue_for_field( - contents, - "delivery.method", - "missing listing delivery method", - ), - other => issue_for_field(contents, "listing", other.to_string()), - } -} - -fn authoring_defaults(config: &RuntimeConfig) -> Result<ListingAuthoringDefaults, RuntimeError> { - let account_resolution = account::resolve_account_resolution(config)?; - let Some(selected_account) = account_resolution.resolved_account.clone() else { - return Err(account::AccountRuntimeFailure::unresolved_with_detail( - "no resolved account is available for listing seller actor", - json!({ - "seller_actor_source": LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT, - "actions": [ - "radroots account create", - "radroots account import <path>", - ], - }), - ) - .into()); - }; - let mut defaults = ListingAuthoringDefaults { - farm_config_present: false, - farm_defaults_ready: false, - farm_next_action: Some(farm_setup_action(config)?), - farm_reason: Some( - "selected farm draft not found; delivery, location, and farm defaults were left blank" - .to_owned(), - ), - farm_name: None, - seller_account_id: selected_account.record.id().to_string(), - seller_pubkey: selected_account - .record - .public_identity() - .public_key() - .to_hex(), - seller_actor_source: LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), - selected_farm_d_tag: None, - delivery_method: None, - location: None, - }; - - let Some(resolved) = farm_config::load(config, None)? else { - return Ok(defaults); - }; - let Some(account) = configured_account(config, &resolved.document.selection.account)? else { - let account_id = resolved.document.selection.account.clone(); - return Err(account::AccountRuntimeFailure::unresolved_with_detail( - format!( - "farm-bound seller account `{account_id}` is not present in the local account store" - ), - json!({ - "seller_actor_source": "farm_config", - "farm_bound_seller_account_id": account_id, - "actions": crate::runtime::farm::farm_bound_seller_recovery_actions("<selector>"), - }), - ) - .into()); - }; - - defaults.farm_config_present = true; - defaults.farm_name = resolved - .document - .profile - .display_name - .clone() - .and_then(non_empty) - .or_else(|| non_empty(resolved.document.profile.name.clone())) - .or_else(|| non_empty(resolved.document.farm.name.clone())); - defaults.seller_account_id = resolved.document.selection.account.clone(); - defaults.seller_pubkey = account.record.public_identity().public_key().to_hex(); - defaults.seller_actor_source = LISTING_SELLER_ACTOR_SOURCE_FARM_CONFIG.to_owned(); - defaults.selected_farm_d_tag = Some(resolved.document.selection.farm_d_tag.clone()); - let draft_missing = farm_config::missing_fields(&resolved.document); - defaults.farm_defaults_ready = !draft_missing.iter().any(|field| { - matches!( - field, - farm_config::FarmMissingField::Location - | farm_config::FarmMissingField::City - | farm_config::FarmMissingField::Delivery - | farm_config::FarmMissingField::Geohash - ) - }); - if defaults.farm_defaults_ready { - defaults.delivery_method = Some(resolved.document.listing_defaults.delivery_method.clone()); - defaults.location = Some(draft_location_from_model( - &resolved.document.listing_defaults.location, - )); - defaults.farm_next_action = None; - defaults.farm_reason = None; - } else { - defaults.farm_next_action = Some("radroots farm get".to_owned()); - defaults.farm_reason = Some( - "selected farm draft is missing delivery or location defaults; those fields were left blank" - .to_owned(), - ); - } - Ok(defaults) -} - -fn draft_location_from_model(location: &OperationalListingPublicLocation) -> ListingDraftLocation { - ListingDraftLocation { - primary: location.primary.clone(), - city: location.city.clone(), - region: location.region.clone(), - country: location.country.clone(), - geohash: location.geohash.clone(), - } -} - -fn farm_setup_action(_config: &RuntimeConfig) -> Result<String, RuntimeError> { - Ok("radroots farm create".to_owned()) -} - -fn drafts_dir(config: &RuntimeConfig) -> PathBuf { - config.paths.app_data_root.join(LISTING_DRAFTS_DIR) -} - -fn file_stem(path: &Path) -> String { - path.file_stem() - .and_then(|value| value.to_str()) - .unwrap_or("unknown") - .to_owned() -} - -fn modified_unix(path: &Path) -> Option<u64> { - let modified = fs::metadata(path).ok()?.modified().ok()?; - modified - .duration_since(UNIX_EPOCH) - .ok() - .map(|value| value.as_secs()) -} - -fn configured_account( - config: &RuntimeConfig, - account_id: &str, -) -> Result<Option<account::AccountRecordView>, RuntimeError> { - let snapshot = account::snapshot(config)?; - Ok(snapshot - .accounts - .into_iter() - .find(|account| account.record.id().to_hex() == account_id)) -} - -fn parse_decimal_field( - value: &str, - contents: &str, - field: &str, -) -> Result<Decimal, ListingValidationIssueView> { - value.trim().parse::<Decimal>().map_err(|_| { - issue_for_field( - contents, - field, - format!("`{field}` must be a valid decimal value"), - ) - }) -} - -fn parse_unit_field( - value: &str, - contents: &str, - field: &str, -) -> Result<Unit, ListingValidationIssueView> { - value.parse::<Unit>().map_err(|_| { - issue_for_field( - contents, - field, - format!("`{field}` must be a valid unit code"), - ) - }) -} - -fn parse_currency_field( - value: &str, - contents: &str, - field: &str, -) -> Result<Currency, ListingValidationIssueView> { - let upper = value.trim().to_ascii_uppercase(); - Currency::from_str_upper(&upper).map_err(|_| { - issue_for_field( - contents, - field, - format!("`{field}` must be a valid ISO currency code"), - ) - }) -} - -fn issue_for_field( - contents: &str, - field: &str, - message: impl Into<String>, -) -> ListingValidationIssueView { - ListingValidationIssueView { - field: field.to_owned(), - message: message.into(), - line: line_for_field(contents, field), - } -} - -fn line_for_field(contents: &str, field: &str) -> Option<usize> { - let needles: &[&str] = match field { - "version" => &["version ="], - "kind" => &["kind ="], - "listing.d_tag" => &["d_tag ="], - "listing.farm_d_tag" => &["farm_d_tag ="], - "seller_actor.account_id" => &["[seller_actor]", "account_id ="], - "seller_actor.pubkey" => &["[seller_actor]", "pubkey ="], - "seller_actor.source" => &["[seller_actor]", "source ="], - "product.key" => &["key ="], - "product.title" => &["title ="], - "product.category" => &["category ="], - "product.summary" => &["summary ="], - "primary_bin.bin_id" => &["bin_id ="], - "primary_bin.quantity_amount" => &["quantity_amount ="], - "primary_bin.quantity_unit" => &["quantity_unit ="], - "primary_bin.price_amount" => &["price_amount ="], - "primary_bin.price_currency" => &["price_currency ="], - "primary_bin.price_per_amount" => &["price_per_amount ="], - "primary_bin.price_per_unit" => &["price_per_unit ="], - "inventory.available" => &["available ="], - "availability.kind" => &["[availability]", "kind ="], - "availability.status" => &["status ="], - "delivery.method" => &["method ="], - "location.primary" => &["primary ="], - field if field.starts_with("discounts.") => &["[[discounts]]"], - _ => &[], - }; - for needle in needles { - if let Some(line) = contents.lines().position(|line| line.contains(needle)) { - return Some(line + 1); - } - } - None -} - -fn line_for_offset(contents: &str, offset: usize) -> usize { - let mut seen = 0usize; - for (index, line) in contents.lines().enumerate() { - seen += line.len() + 1; - if seen >= offset { - return index + 1; - } - } - contents.lines().count().max(1) -} - -fn non_empty(value: String) -> Option<String> { - let trimmed = value.trim(); - if trimmed.is_empty() { - None - } else { - Some(trimmed.to_owned()) - } -} - -fn generate_d_tag() -> String { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - let counter = D_TAG_COUNTER.fetch_add(1, Ordering::Relaxed) as u128; - let mixed = nanos ^ counter; - encode_base64url_no_pad(mixed.to_be_bytes()) -} - -fn encode_base64url_no_pad(bytes: [u8; 16]) -> String { - const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut output = String::with_capacity(22); - let mut index = 0usize; - while index + 3 <= bytes.len() { - let block = ((bytes[index] as u32) << 16) - | ((bytes[index + 1] as u32) << 8) - | (bytes[index + 2] as u32); - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); - output.push(ALPHABET[(block & 0x3f) as usize] as char); - index += 3; - } - let remaining = bytes.len() - index; - if remaining == 1 { - let block = (bytes[index] as u32) << 16; - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - } else if remaining == 2 { - let block = ((bytes[index] as u32) << 16) | ((bytes[index + 1] as u32) << 8); - output.push(ALPHABET[((block >> 18) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 12) & 0x3f) as usize] as char); - output.push(ALPHABET[((block >> 6) & 0x3f) as usize] as char); - } - output -} - -#[cfg(test)] -mod tests { - use super::{DRAFT_KIND, ListingDraftDocument, encode_base64url_no_pad, generate_d_tag}; - use radroots_event_codec::d_tag::is_d_tag_base64url; - - #[test] - fn generated_listing_d_tag_is_valid_base64url() { - let d_tag = generate_d_tag(); - assert!(is_d_tag_base64url(&d_tag)); - } - - #[test] - fn base64url_encoder_produces_twenty_two_characters_for_sixteen_bytes() { - let encoded = encode_base64url_no_pad([0u8; 16]); - assert_eq!(encoded.len(), 22); - assert!(is_d_tag_base64url(&encoded)); - } - - #[test] - fn listing_draft_kind_constant_is_stable() { - let document = ListingDraftDocument { - version: 1, - kind: DRAFT_KIND.to_owned(), - listing: super::ListingDraftMeta { - d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(), - }, - seller_actor: super::ListingDraftSellerActor { - account_id: "acct_seller".to_owned(), - pubkey: "a".repeat(64), - source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), - }, - product: super::ListingDraftProduct { - key: "sku".to_owned(), - title: "Widget".to_owned(), - category: "produce".to_owned(), - summary: "Fresh".to_owned(), - }, - primary_bin: super::ListingDraftPrimaryBin { - bin_id: "bin-1".to_owned(), - quantity_amount: "1".to_owned(), - quantity_unit: "kg".to_owned(), - price_amount: "12.50".to_owned(), - price_currency: "USD".to_owned(), - price_per_amount: "1".to_owned(), - price_per_unit: "kg".to_owned(), - label: "kg".to_owned(), - }, - inventory: super::ListingDraftInventory { - available: "2".to_owned(), - }, - availability: super::ListingDraftAvailability { - kind: "status".to_owned(), - status: "active".to_owned(), - start: None, - end: None, - }, - delivery: super::ListingDraftDelivery { - method: "pickup".to_owned(), - }, - location: super::ListingDraftLocation { - primary: "Asheville".to_owned(), - city: None, - region: None, - country: None, - geohash: "dnqwy".to_owned(), - }, - discounts: Vec::new(), - }; - let rendered = toml::to_string_pretty(&document).expect("render draft"); - assert!(rendered.contains("kind = \"listing_draft_v1\"")); - } - - #[test] - fn listing_draft_canonicalization_preserves_discounts_and_validates_semantics() { - let seller_pubkey = "a".repeat(64); - let document = ListingDraftDocument { - version: 1, - kind: DRAFT_KIND.to_owned(), - listing: super::ListingDraftMeta { - d_tag: "AAAAAAAAAAAAAAAAAAAAAg".to_owned(), - farm_d_tag: "AAAAAAAAAAAAAAAAAAAAAw".to_owned(), - }, - seller_actor: super::ListingDraftSellerActor { - account_id: "acct_seller".to_owned(), - pubkey: seller_pubkey.clone(), - source: super::LISTING_SELLER_ACTOR_SOURCE_RESOLVED_ACCOUNT.to_owned(), - }, - product: super::ListingDraftProduct { - key: "sku".to_owned(), - title: "Widget".to_owned(), - category: "produce".to_owned(), - summary: "Fresh".to_owned(), - }, - primary_bin: super::ListingDraftPrimaryBin { - bin_id: "bin-1".to_owned(), - quantity_amount: "1".to_owned(), - quantity_unit: "each".to_owned(), - price_amount: "10".to_owned(), - price_currency: "USD".to_owned(), - price_per_amount: "1".to_owned(), - price_per_unit: "each".to_owned(), - label: "each".to_owned(), - }, - inventory: super::ListingDraftInventory { - available: "2".to_owned(), - }, - availability: super::ListingDraftAvailability { - kind: "status".to_owned(), - status: "active".to_owned(), - start: None, - end: None, - }, - delivery: super::ListingDraftDelivery { - method: "pickup".to_owned(), - }, - location: super::ListingDraftLocation { - primary: "Farm stand".to_owned(), - city: Some("Asheville".to_owned()), - region: None, - country: None, - geohash: "dnqwy".to_owned(), - }, - discounts: vec![super::ListingDraftDiscount { - id: "discount_farmstand".to_owned(), - label: "farmstand pickup".to_owned(), - kind: "percent".to_owned(), - value: "10".to_owned(), - amount: String::new(), - currency: String::new(), - bin_id: None, - min_bin_count: None, - }], - }; - let contents = toml::to_string_pretty(&document).expect("render draft"); - let context = super::ListingValidationContext { - farm_setup_action: "radroots farm create".to_owned(), - }; - - let canonical = - super::canonicalize_draft(&document, contents.as_str(), &context).expect("canonical"); - - assert!(contents.contains("[[discounts]]")); - assert_eq!( - canonical - .listing - .discounts - .as_ref() - .expect("discounts") - .len(), - 1 - ); - super::validate_operational_listing_draft(&canonical) - .expect("canonical listing passes operational validation"); - - let mut missing_description = canonical; - missing_description.listing.product.summary = Some(" ".to_owned()); - assert_eq!( - super::validate_operational_listing_draft(&missing_description), - Err(super::OperationalListingValidationError::MissingDescription) - ); - } -} diff --git a/src/runtime/logging.rs b/src/runtime/logging.rs @@ -1,57 +0,0 @@ -use std::path::PathBuf; - -use radroots_log::{LogFileLayout, LoggingOptions}; - -use crate::runtime::config::LoggingConfig; - -const CLI_LOG_FILE_NAME: &str = "radroots-cli.log"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct LoggingState { - pub initialized: bool, - pub current_file: Option<PathBuf>, -} - -pub fn initialize_logging(config: &LoggingConfig) -> Result<LoggingState, radroots_log::Error> { - let options = to_radroots_logging_options(config); - let state = LoggingState { - initialized: true, - current_file: options.resolved_current_log_file_path(), - }; - radroots_log::init_logging(options)?; - Ok(state) -} - -pub fn to_radroots_logging_options(config: &LoggingConfig) -> LoggingOptions { - LoggingOptions { - dir: config.directory.clone(), - file_name: CLI_LOG_FILE_NAME.to_owned(), - stdout: config.stdout, - default_level: Some(config.filter.clone()), - file_layout: LogFileLayout::StableFileName, - ..LoggingOptions::default() - } -} - -#[cfg(test)] -mod tests { - use super::{LogFileLayout, to_radroots_logging_options}; - use crate::runtime::config::LoggingConfig; - use std::path::PathBuf; - - #[test] - fn logging_options_use_cli_file_name() { - let options = to_radroots_logging_options(&LoggingConfig { - filter: "info".to_owned(), - directory: Some(PathBuf::from("logs")), - stdout: false, - }); - assert_eq!(options.file_name, "radroots-cli.log"); - assert_eq!(options.default_level.as_deref(), Some("info")); - assert_eq!(options.dir, Some(PathBuf::from("logs"))); - assert!(!options.stdout); - assert_eq!(options.file_layout, LogFileLayout::StableFileName); - assert!(options.rotation.max_file_bytes > 0); - assert!(options.rotation.retained_files > 0); - } -} diff --git a/src/runtime/mesh.rs b/src/runtime/mesh.rs @@ -1,96 +0,0 @@ -use radroots_mesh::{ - RADROOTS_MESH_UNAVAILABLE_MESSAGE, RadrootsMeshAdmissionInput, RadrootsMeshPayloadPolicy, - RadrootsMeshPrivacyClass, RadrootsMeshScope, -}; -use serde_json::Value as JsonValue; -use toml::{Value, map::Map}; - -use crate::ops::OperationData; -use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; -use crate::runtime::transport::update_app_config_table; -use crate::view::runtime::{MeshPolicyCheckView, MeshScopeView, MeshStatusView}; - -const MESH_SOURCE: &str = "mesh config"; - -pub fn scope(config: &RuntimeConfig) -> MeshScopeView { - scope_view(config.mesh.scope.as_str(), "ready") -} - -pub fn set_scope( - config: &RuntimeConfig, - input: &OperationData, -) -> Result<MeshScopeView, RuntimeError> { - let scope = input - .get("scope") - .and_then(JsonValue::as_str) - .unwrap_or("disabled"); - match scope { - "disabled" | "local" => {} - other => { - return Err(RuntimeError::Config(format!( - "mesh scope `{other}` is not supported" - ))); - } - } - let mut mesh = Map::new(); - mesh.insert("scope".to_owned(), Value::String(scope.to_owned())); - update_app_config_table(config, "mesh", Value::Table(mesh))?; - Ok(scope_view(scope, "configured")) -} - -pub fn status(config: &RuntimeConfig) -> MeshStatusView { - let scope = config.mesh.scope.as_str(); - MeshStatusView { - state: "ready".to_owned(), - source: MESH_SOURCE.to_owned(), - scope: scope.to_owned(), - transport: "reticulum".to_owned(), - configured: scope != "disabled", - implementation: "unavailable".to_owned(), - usable_for_delivery: false, - message: RADROOTS_MESH_UNAVAILABLE_MESSAGE.to_owned(), - } -} - -pub fn policy_check(config: &RuntimeConfig) -> MeshPolicyCheckView { - let policy = RadrootsMeshPayloadPolicy::reticulum_unavailable(); - let privacy_class = RadrootsMeshPrivacyClass::PublicEvent; - let input = RadrootsMeshAdmissionInput::new(RadrootsMeshScope::Local, privacy_class, 1, 1); - let decision = policy.evaluate(&input); - let deny_reason = decision - .deny_reason() - .map(|reason| reason.label()) - .unwrap_or("none"); - - MeshPolicyCheckView { - state: "ready".to_owned(), - source: MESH_SOURCE.to_owned(), - scope: config.mesh.scope.as_str().to_owned(), - policy: policy.policy_id().to_owned(), - transport: "reticulum".to_owned(), - usable_for_delivery: decision.usable_for_delivery(), - decision: decision.label().to_owned(), - deny_reason: deny_reason.to_owned(), - privacy_class: privacy_class.label().to_owned(), - payload_bytes: input.payload_bytes, - frame_bytes: input.frame_bytes, - max_payload_bytes: policy.max_payload_bytes, - max_frame_bytes: policy.max_frame_bytes, - compression: policy.compression.label().to_owned(), - custom_scopes_enabled: policy.custom_scopes_enabled, - message: decision.message().to_owned(), - } -} - -fn scope_view(scope: &str, state: &str) -> MeshScopeView { - MeshScopeView { - state: state.to_owned(), - source: MESH_SOURCE.to_owned(), - scope: scope.to_owned(), - implementation: "unavailable".to_owned(), - message: "Mesh delivery is disabled unless a Reticulum scope is explicitly configured" - .to_owned(), - actions: vec!["radroots diagnostics inspect".to_owned()], - } -} diff --git a/src/runtime/mod.rs b/src/runtime/mod.rs @@ -1,61 +0,0 @@ -pub mod account; -pub mod config; -pub mod farm; -pub mod farm_config; -pub mod find; -pub mod hyf; -pub mod listing; -pub mod logging; -pub mod mesh; -pub mod paths; -pub mod provider; -pub mod runtime_store; -pub mod sdk; -pub mod signer; -pub mod signing; -pub mod store; -pub mod sync; -pub mod trade; -pub mod transport; - -use std::process::ExitCode; - -#[derive(Debug, thiserror::Error)] -pub enum RuntimeError { - #[error("{0}")] - Config(String), - #[error("{0}")] - Account(#[from] account::AccountRuntimeFailure), - #[error("failed to initialize logging: {0}")] - Logging(#[from] radroots_log::Error), - #[error("accounts error: {0}")] - Accounts(#[from] radroots_nostr_accounts::prelude::RadrootsNostrAccountsError), - #[error("replica sql error: {0}")] - Sql(#[from] radroots_replica_store::SqlError), - #[error("replica sync error: {0}")] - ReplicaSync(#[from] radroots_replica_sync::RadrootsReplicaEventsError), - #[error("runtime store error: {0}")] - RuntimeStore(#[from] radroots_runtime_store::RuntimeStoreError), - #[error("network error: {0}")] - Network(String), - #[error("failed to serialize json output: {0}")] - Json(#[from] serde_json::Error), - #[error("failed to write output: {0}")] - Io(#[from] std::io::Error), -} - -impl RuntimeError { - pub fn exit_code(&self) -> ExitCode { - match self { - Self::Config(_) | Self::Account(_) => ExitCode::from(2), - Self::Logging(_) - | Self::Accounts(_) - | Self::Sql(_) - | Self::ReplicaSync(_) - | Self::RuntimeStore(_) - | Self::Network(_) - | Self::Json(_) - | Self::Io(_) => ExitCode::from(1), - } - } -} diff --git a/src/runtime/paths.rs b/src/runtime/paths.rs @@ -1,250 +0,0 @@ -use std::path::{Path, PathBuf}; - -use radroots_runtime_paths::{ - DEFAULT_CONFIG_FILE_NAME, RadrootsPathOverrides, RadrootsPathProfile, RadrootsRuntimeNamespace, - default_shared_identity_path, -}; - -use crate::runtime::{ - RuntimeError, - config::{EnvFileValues, Environment}, -}; - -const CLI_DEFAULT_PROFILE: &str = "interactive_user"; -const CLI_REPO_LOCAL_PROFILE: &str = "repo_local"; -const CLI_APP_NAMESPACE_VALUE: &str = "cli"; -const SHARED_ACCOUNTS_NAMESPACE_VALUE: &str = "accounts"; -const SHARED_IDENTITIES_NAMESPACE_VALUE: &str = "identities"; - -pub(crate) const CLI_ALLOWED_PROFILES: &[&str] = &[CLI_DEFAULT_PROFILE, CLI_REPO_LOCAL_PROFILE]; -pub(crate) const ENV_CLI_PATHS_PROFILE: &str = "RADROOTS_CLI_PATHS_PROFILE"; -pub(crate) const ENV_CLI_PATHS_REPO_LOCAL_ROOT: &str = "RADROOTS_CLI_PATHS_REPO_LOCAL_ROOT"; - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PathsConfig { - pub profile: String, - pub profile_source: String, - pub allowed_profiles: Vec<String>, - pub root_source: String, - pub repo_local_root: Option<PathBuf>, - pub repo_local_root_source: Option<String>, - pub subordinate_path_override_source: String, - pub app_namespace: String, - pub shared_accounts_namespace: String, - pub shared_identities_namespace: String, - pub app_config_path: PathBuf, - pub workspace_config_path: Option<PathBuf>, - pub app_data_root: PathBuf, - pub shared_cache_root: PathBuf, - pub app_logs_root: PathBuf, - pub shared_accounts_data_root: PathBuf, - pub shared_accounts_secrets_root: PathBuf, - pub default_identity_path: PathBuf, -} - -pub(crate) fn resolve_paths( - env: &dyn Environment, - env_file: &EnvFileValues, -) -> Result<PathsConfig, RuntimeError> { - let current_dir = env.current_dir()?; - let resolver = env.path_resolver(); - let (profile, profile_label, profile_source) = resolve_cli_path_profile(env, env_file)?; - let override_selection = - resolve_cli_path_overrides(current_dir.as_path(), env, env_file, profile)?; - let workspace_config_path = resolve_workspace_config_path(profile, &override_selection)?; - let resolved = resolver - .resolve(profile, &override_selection.overrides) - .map_err(|err| RuntimeError::Config(format!("resolve Radroots path roots: {err}")))?; - let app_namespace = RadrootsRuntimeNamespace::app(CLI_APP_NAMESPACE_VALUE) - .map_err(|err| RuntimeError::Config(format!("resolve cli namespace: {err}")))?; - let shared_accounts_namespace = - RadrootsRuntimeNamespace::shared(SHARED_ACCOUNTS_NAMESPACE_VALUE).map_err(|err| { - RuntimeError::Config(format!("resolve shared accounts namespace: {err}")) - })?; - let shared_identity_namespace = - RadrootsRuntimeNamespace::shared(SHARED_IDENTITIES_NAMESPACE_VALUE).map_err(|err| { - RuntimeError::Config(format!("resolve shared identities namespace: {err}")) - })?; - let app_paths = resolved.namespaced(&app_namespace); - let shared_accounts_paths = resolved.namespaced(&shared_accounts_namespace); - let default_identity_path = - default_shared_identity_path(&resolver, profile, &override_selection.overrides) - .map_err(|err| RuntimeError::Config(format!("resolve shared identity path: {err}")))?; - - Ok(PathsConfig { - profile: profile_label.to_owned(), - profile_source, - allowed_profiles: CLI_ALLOWED_PROFILES - .iter() - .map(|value| (*value).to_owned()) - .collect(), - root_source: path_root_source(profile).to_owned(), - repo_local_root: override_selection.repo_local_root, - repo_local_root_source: override_selection.repo_local_root_source, - subordinate_path_override_source: "runtime_config".to_owned(), - app_namespace: app_namespace.relative_path().display().to_string(), - shared_accounts_namespace: shared_accounts_namespace - .relative_path() - .display() - .to_string(), - shared_identities_namespace: shared_identity_namespace - .relative_path() - .display() - .to_string(), - app_config_path: app_paths.config.join(DEFAULT_CONFIG_FILE_NAME), - workspace_config_path, - app_data_root: app_paths.data, - shared_cache_root: resolved.cache, - app_logs_root: app_paths.logs, - shared_accounts_data_root: shared_accounts_paths.data, - shared_accounts_secrets_root: shared_accounts_paths.secrets, - default_identity_path, - }) -} - -fn resolve_cli_path_profile( - env: &dyn Environment, - env_file: &EnvFileValues, -) -> Result<(RadrootsPathProfile, &'static str, String), RuntimeError> { - match path_env_value_entry(env, env_file, &[ENV_CLI_PATHS_PROFILE]) { - Some(entry) => parse_cli_path_profile(entry.key.as_str(), entry.value.as_str()) - .map(|(profile, label)| (profile, label, entry.source_label())), - None => Ok(( - RadrootsPathProfile::InteractiveUser, - CLI_DEFAULT_PROFILE, - "default".to_owned(), - )), - } -} - -fn parse_cli_path_profile( - key: &str, - value: &str, -) -> Result<(RadrootsPathProfile, &'static str), RuntimeError> { - match value.trim().to_ascii_lowercase().as_str() { - CLI_DEFAULT_PROFILE => Ok((RadrootsPathProfile::InteractiveUser, CLI_DEFAULT_PROFILE)), - CLI_REPO_LOCAL_PROFILE => Ok((RadrootsPathProfile::RepoLocal, CLI_REPO_LOCAL_PROFILE)), - other => Err(RuntimeError::Config(format!( - "{key} must be `interactive_user` or `repo_local`, got `{other}`" - ))), - } -} - -struct CliPathOverrideSelection { - overrides: RadrootsPathOverrides, - repo_local_root: Option<PathBuf>, - repo_local_root_source: Option<String>, -} - -fn resolve_cli_path_overrides( - current_dir: &Path, - env: &dyn Environment, - env_file: &EnvFileValues, - profile: RadrootsPathProfile, -) -> Result<CliPathOverrideSelection, RuntimeError> { - match profile { - RadrootsPathProfile::InteractiveUser => Ok(CliPathOverrideSelection { - overrides: RadrootsPathOverrides::default(), - repo_local_root: None, - repo_local_root_source: None, - }), - RadrootsPathProfile::RepoLocal => { - let Some(entry) = path_env_value_entry(env, env_file, &[ENV_CLI_PATHS_REPO_LOCAL_ROOT]) - else { - return Err(RuntimeError::Config(format!( - "{ENV_CLI_PATHS_REPO_LOCAL_ROOT} must be set when {ENV_CLI_PATHS_PROFILE}=repo_local" - ))); - }; - if entry.value.trim().is_empty() { - return Err(RuntimeError::Config(format!( - "{} must not be empty when {ENV_CLI_PATHS_PROFILE}=repo_local", - entry.key - ))); - } - let repo_local_root = normalize_explicit_path_root(current_dir, entry.value.as_str()); - Ok(CliPathOverrideSelection { - overrides: RadrootsPathOverrides::repo_local(repo_local_root.as_path()), - repo_local_root: Some(repo_local_root), - repo_local_root_source: Some(entry.source_label()), - }) - } - _ => Err(RuntimeError::Config( - "cli only supports interactive_user and repo_local path profiles".to_owned(), - )), - } -} - -fn path_root_source(profile: RadrootsPathProfile) -> &'static str { - match profile { - RadrootsPathProfile::InteractiveUser => "host_defaults", - RadrootsPathProfile::RepoLocal => "repo_local_root", - RadrootsPathProfile::ServiceHost => "service_host_defaults", - RadrootsPathProfile::MobileNative => "mobile_native_defaults", - } -} - -fn normalize_explicit_path_root(current_dir: &Path, value: &str) -> PathBuf { - let root = PathBuf::from(value.trim()); - if root.is_absolute() { - root - } else { - current_dir.join(root) - } -} - -fn resolve_workspace_config_path( - profile: RadrootsPathProfile, - override_selection: &CliPathOverrideSelection, -) -> Result<Option<PathBuf>, RuntimeError> { - match profile { - RadrootsPathProfile::InteractiveUser => Ok(None), - RadrootsPathProfile::RepoLocal => override_selection - .repo_local_root - .as_ref() - .map(|root| Some(root.join(DEFAULT_CONFIG_FILE_NAME))) - .ok_or_else(|| { - RuntimeError::Config(format!( - "{ENV_CLI_PATHS_REPO_LOCAL_ROOT} must be resolved when {ENV_CLI_PATHS_PROFILE}=repo_local" - )) - }), - _ => Err(RuntimeError::Config( - "cli only supports interactive_user and repo_local path profiles".to_owned(), - )), - } -} - -struct PathEnvValueEntry { - key: String, - value: String, - source_kind: &'static str, -} - -impl PathEnvValueEntry { - fn source_label(&self) -> String { - format!("{}:{}", self.source_kind, self.key) - } -} - -fn path_env_value_entry( - env: &dyn Environment, - env_file: &EnvFileValues, - keys: &[&str], -) -> Option<PathEnvValueEntry> { - keys.iter() - .find_map(|key| { - env.var(key).map(|value| PathEnvValueEntry { - key: (*key).to_owned(), - value, - source_kind: "process_env", - }) - }) - .or_else(|| { - keys.iter().find_map(|key| { - env_file.get(key).map(|value| PathEnvValueEntry { - key: (*key).to_owned(), - value: value.to_owned(), - source_kind: "env_file", - }) - }) - }) -} diff --git a/src/runtime/provider.rs b/src/runtime/provider.rs @@ -1,458 +0,0 @@ -use crate::runtime::config::RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE; -#[cfg(test)] -use crate::runtime::config::{ - CapabilityBindingInspection, CapabilityBindingInspectionState, INFERENCE_HYF_STDIO_CAPABILITY, -}; -use crate::runtime::config::{RuntimeConfig, TransportProfileKind}; -#[cfg(test)] -use crate::runtime::hyf; -use crate::view::runtime::PublishRuntimeView; - -#[cfg(test)] -const WRITE_PLANE_TARGET_DETAIL: &str = - "write-plane targets are resolved by mode-specific publish commands"; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ProviderProvenance { - #[cfg(test)] - ExplicitBinding, - #[cfg(test)] - ManagedDefault, - #[cfg(test)] - DirectConfig, - #[cfg(test)] - Disabled, - TransportProfile, - #[cfg(test)] - Unavailable, -} - -impl ProviderProvenance { - pub fn as_str(self) -> &'static str { - match self { - #[cfg(test)] - Self::ExplicitBinding => "explicit_binding", - #[cfg(test)] - Self::ManagedDefault => "managed_default", - #[cfg(test)] - Self::DirectConfig => "direct_config", - #[cfg(test)] - Self::Disabled => "disabled", - Self::TransportProfile => "transport_profile", - #[cfg(test)] - Self::Unavailable => "unavailable", - } - } -} - -#[cfg(test)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedProviderView { - pub capability_id: String, - pub provider_runtime_id: String, - pub binding_model: String, - pub state: String, - pub provenance: String, - pub source: String, - pub target_kind: Option<String>, - pub target: Option<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct WritePlaneProviderView { - pub provider_runtime_id: String, - pub binding_model: String, - pub state: String, - pub provenance: String, - pub source: String, - pub target_kind: Option<String>, - pub target: Option<String>, - pub detail: String, -} - -#[cfg(test)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ResolvedWritePlaneTarget { - pub url: String, -} - -#[cfg(test)] -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct HyfProviderView { - pub provider_runtime_id: String, - pub binding_model: String, - pub state: String, - pub provenance: String, - pub source: String, - pub target_kind: Option<String>, - pub target: Option<String>, - pub executable: String, - pub reason: Option<String>, - pub protocol_version: Option<u64>, - pub deterministic_available: Option<bool>, -} - -pub fn resolve_write_plane_provider( - config: &RuntimeConfig, - publish: &PublishRuntimeView, -) -> WritePlaneProviderView { - let (provider_runtime_id, binding_model, detail) = match config.transport.profile { - TransportProfileKind::Nostr | TransportProfileKind::MultiTarget => ( - config.transport.profile.as_str(), - "nostr_transport", - "configured transport profile publishes through Nostr; readiness is reported under publish", - ), - TransportProfileKind::LocalOnly => ( - "local_only", - "local_transport", - "local_only transport profile does not provide network publish", - ), - TransportProfileKind::Reticulum => ( - "reticulum", - "reticulum_transport", - RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, - ), - }; - WritePlaneProviderView { - provider_runtime_id: provider_runtime_id.to_owned(), - binding_model: binding_model.to_owned(), - state: publish.state.clone(), - provenance: ProviderProvenance::TransportProfile.as_str().to_owned(), - source: publish.source.clone(), - target_kind: None, - target: None, - detail: publish.reason.clone().unwrap_or_else(|| detail.to_owned()), - } -} - -#[cfg(test)] -pub fn resolve_actor_write_plane_target( - config: &RuntimeConfig, -) -> Result<ResolvedWritePlaneTarget, String> { - let _ = config; - Err(WRITE_PLANE_TARGET_DETAIL.to_owned()) -} - -#[cfg(test)] -pub fn resolve_hyf_provider(config: &RuntimeConfig) -> HyfProviderView { - let binding = inspect_binding(config, INFERENCE_HYF_STDIO_CAPABILITY); - let status = hyf::resolve_runtime_status(config); - let binding_configured = binding.state == CapabilityBindingInspectionState::Configured; - let provenance = if binding_configured { - binding_provenance(&binding) - } else if status.state == "disabled" { - ProviderProvenance::Disabled - } else { - ProviderProvenance::DirectConfig - } - .as_str() - .to_owned(); - let target_kind = hyf_target_kind(config, &binding); - let target = hyf_target(config, &binding); - let executable = hyf_executable(config, &binding, &status); - let source = if binding_configured { - binding.source.clone() - } else { - status.source.clone() - }; - - HyfProviderView { - provider_runtime_id: binding.provider_runtime_id, - binding_model: binding.binding_model, - state: status.state, - provenance, - source, - target_kind, - target, - executable, - reason: status.reason, - protocol_version: status.protocol_version, - deterministic_available: status.deterministic_available, - } -} - -#[cfg(test)] -pub fn resolve_capability_providers(config: &RuntimeConfig) -> Vec<ResolvedProviderView> { - let hyf = resolve_hyf_provider(config); - - vec![ResolvedProviderView { - capability_id: INFERENCE_HYF_STDIO_CAPABILITY.to_owned(), - provider_runtime_id: hyf.provider_runtime_id, - binding_model: hyf.binding_model, - state: hyf.state, - provenance: hyf.provenance, - source: hyf.source, - target_kind: hyf.target_kind, - target: hyf.target, - }] -} - -#[cfg(test)] -fn inspect_binding(config: &RuntimeConfig, capability_id: &str) -> CapabilityBindingInspection { - config - .inspect_capability_bindings() - .into_iter() - .find(|binding| binding.capability_id == capability_id) - .expect("provider capability binding inspection must exist") -} - -#[cfg(test)] -fn binding_provenance(binding: &CapabilityBindingInspection) -> ProviderProvenance { - match binding.state { - CapabilityBindingInspectionState::Configured => match binding.target_kind.as_deref() { - Some("managed_instance") => ProviderProvenance::ManagedDefault, - _ => ProviderProvenance::ExplicitBinding, - }, - CapabilityBindingInspectionState::Disabled => ProviderProvenance::Disabled, - CapabilityBindingInspectionState::NotConfigured => ProviderProvenance::Unavailable, - } -} - -#[cfg(test)] -fn hyf_target_kind( - config: &RuntimeConfig, - binding: &CapabilityBindingInspection, -) -> Option<String> { - if binding.state == CapabilityBindingInspectionState::Configured { - return binding.target_kind.clone(); - } - if config.hyf.enabled { - return Some("direct_config".to_owned()); - } - None -} - -#[cfg(test)] -fn hyf_target(config: &RuntimeConfig, binding: &CapabilityBindingInspection) -> Option<String> { - if binding.state == CapabilityBindingInspectionState::Configured { - return binding.target.clone(); - } - if config.hyf.enabled { - return Some(config.hyf.executable.display().to_string()); - } - None -} - -#[cfg(test)] -fn hyf_executable( - config: &RuntimeConfig, - binding: &CapabilityBindingInspection, - status: &hyf::HyfStatusView, -) -> String { - if binding.state == CapabilityBindingInspectionState::Configured - && binding.target_kind.as_deref() == Some("explicit_endpoint") - { - return binding - .target - .clone() - .unwrap_or_else(|| status.executable.clone()); - } - if !config.hyf.enabled { - return status.executable.clone(); - } - status.executable.clone() -} - -#[cfg(test)] -mod tests { - use radroots_secret_vault::RadrootsSecretBackend; - use std::path::PathBuf; - - use super::{ - ProviderProvenance, resolve_actor_write_plane_target, resolve_capability_providers, - resolve_hyf_provider, resolve_write_plane_provider, - }; - use crate::runtime::config::{ - AccountConfig, AccountSecretContractConfig, CapabilityBindingConfig, - CapabilityBindingSource, CapabilityBindingTargetKind, HyfConfig, IdentityConfig, - InteractionConfig, LocalConfig, LoggingConfig, MycConfig, OutputConfig, OutputFormat, - PathsConfig, RpcConfig, RuntimeConfig, SignerBackend, SignerConfig, Verbosity, - }; - use crate::view::runtime::{ - PublishProviderRuntimeView, PublishRelayRuntimeView, PublishRuntimeView, - }; - - fn sample_config(bindings: Vec<CapabilityBindingConfig>, hyf_enabled: bool) -> RuntimeConfig { - RuntimeConfig { - output: OutputConfig { - format: OutputFormat::Terminal, - verbosity: Verbosity::Normal, - dry_run: false, - }, - interaction: InteractionConfig { - input_enabled: true, - assume_yes: false, - stdin_tty: true, - stdout_tty: true, - prompts_allowed: true, - confirmations_allowed: true, - }, - paths: PathsConfig { - profile: "interactive_user".into(), - profile_source: "default".into(), - allowed_profiles: vec!["interactive_user".into()], - root_source: "host_defaults".into(), - repo_local_root: None, - repo_local_root_source: None, - subordinate_path_override_source: "runtime_config".into(), - app_namespace: "apps/cli".into(), - shared_accounts_namespace: "shared/accounts".into(), - shared_identities_namespace: "shared/identities".into(), - app_config_path: PathBuf::from("/tmp/config/apps/cli/config.toml"), - workspace_config_path: None, - app_data_root: PathBuf::from("/tmp/data"), - shared_cache_root: PathBuf::from("/tmp/cache"), - app_logs_root: PathBuf::from("/tmp/logs"), - shared_accounts_data_root: PathBuf::from("/tmp/shared/accounts"), - shared_accounts_secrets_root: PathBuf::from("/tmp/shared/accounts-secrets"), - default_identity_path: PathBuf::from("/tmp/default-identity.json"), - }, - logging: LoggingConfig { - filter: "info".into(), - directory: None, - stdout: true, - }, - account: AccountConfig { - selector: None, - store_path: PathBuf::from("/tmp/store.json"), - secrets_dir: PathBuf::from("/tmp/secrets"), - secret_backend: RadrootsSecretBackend::EncryptedFile, - }, - account_secret_contract: AccountSecretContractConfig { - default_backend: "host_vault".into(), - allowed_backends: vec!["host_vault".into(), "encrypted_file".into()], - host_vault_policy: Some("desktop".into()), - uses_protected_store: true, - }, - identity: IdentityConfig { - path: PathBuf::from("/tmp/default-identity.json"), - }, - signer: SignerConfig { - backend: SignerBackend::Local, - }, - transport: crate::runtime::config::TransportConfig::local_only(), - local: LocalConfig { - root: PathBuf::from("/tmp/local"), - replica_store_path: PathBuf::from("/tmp/local/replica.sqlite"), - backups_dir: PathBuf::from("/tmp/local/backups"), - exports_dir: PathBuf::from("/tmp/local/exports"), - }, - myc: MycConfig { - executable: PathBuf::from("myc"), - status_timeout_ms: 2_000, - }, - hyf: HyfConfig { - enabled: hyf_enabled, - executable: PathBuf::from("hyfd"), - }, - mesh: crate::runtime::config::MeshConfig::disabled(), - rpc: RpcConfig { - url: "http://127.0.0.1:7070".into(), - }, - rhi: crate::runtime::config::RhiConfig { - validator_set: None, - require_cryptographic_proof: false, - }, - capability_bindings: bindings, - } - } - - fn publish_view( - config: &RuntimeConfig, - state: &str, - reason: Option<&str>, - ) -> PublishRuntimeView { - PublishRuntimeView { - transport: config.transport.profile.as_str().to_owned(), - source: config.transport.source.as_str().to_owned(), - transport_family: config.transport.profile.transport_family().to_owned(), - state: state.to_owned(), - executable: state == "ready", - reason: reason.map(str::to_owned), - signed_write_required: true, - relay: PublishRelayRuntimeView { - ready: !config.transport.nostr_relay_urls.is_empty(), - count: config.transport.nostr_relay_urls.len(), - source: config.transport.source.as_str().to_owned(), - }, - provider: PublishProviderRuntimeView { - provider_runtime_id: config.transport.profile.as_str().to_owned(), - state: state.to_owned(), - source: config.transport.source.as_str().to_owned(), - reason: reason.map(str::to_owned), - }, - } - } - - #[test] - fn write_plane_provider_tracks_nostr_transport_publish() { - let mut config = sample_config(Vec::new(), false); - config.transport = crate::runtime::config::TransportConfig::from_nostr_relay_urls(vec![ - "wss://relay.example.com".to_owned(), - ]); - let publish = publish_view( - &config, - "ready", - Some("Nostr relay transport is configured for delivery"), - ); - let view = resolve_write_plane_provider(&config, &publish); - assert_eq!(view.provider_runtime_id, "nostr"); - assert_eq!(view.binding_model, "nostr_transport"); - assert_eq!(view.state, "ready"); - assert_eq!( - view.provenance, - ProviderProvenance::TransportProfile.as_str() - ); - assert!(view.target.is_none()); - assert!(view.detail.contains("configured for delivery")); - } - - #[test] - fn actor_write_plane_target_fails_closed() { - let error = resolve_actor_write_plane_target(&sample_config(Vec::new(), false)) - .expect_err("write plane target"); - assert_eq!( - error, - "write-plane targets are resolved by mode-specific publish commands" - ); - } - - #[test] - fn hyf_uses_direct_config_when_enabled_without_binding() { - let view = resolve_hyf_provider(&sample_config(Vec::new(), true)); - assert_eq!(view.provenance, ProviderProvenance::DirectConfig.as_str()); - assert_eq!(view.target_kind.as_deref(), Some("direct_config")); - assert_eq!(view.target.as_deref(), Some("hyfd")); - } - - #[test] - fn hyf_binding_remains_visible_when_runtime_is_disabled() { - let binding = CapabilityBindingConfig { - capability_id: "inference.hyf_stdio".into(), - provider_runtime_id: "hyf".into(), - binding_model: "stdio_service".into(), - source: CapabilityBindingSource::UserConfig, - target_kind: CapabilityBindingTargetKind::ExplicitEndpoint, - target: "bin/hyfd-user".into(), - managed_account_ref: None, - signer_session_ref: None, - }; - let view = resolve_hyf_provider(&sample_config(vec![binding], false)); - assert_eq!(view.state, "disabled"); - assert_eq!( - view.provenance, - ProviderProvenance::ExplicitBinding.as_str() - ); - assert_eq!(view.source, "user config [[capability_binding]]"); - assert_eq!(view.target_kind.as_deref(), Some("explicit_endpoint")); - assert_eq!(view.target.as_deref(), Some("bin/hyfd-user")); - assert_eq!(view.executable, "bin/hyfd-user"); - } - - #[test] - fn capability_provider_list_only_covers_active_hyf_provider() { - let providers = resolve_capability_providers(&sample_config(Vec::new(), false)); - assert_eq!(providers.len(), 1); - assert_eq!(providers[0].capability_id, "inference.hyf_stdio"); - } -} diff --git a/src/runtime/runtime_store.rs b/src/runtime/runtime_store.rs @@ -1,193 +0,0 @@ -use std::fs; -use std::path::PathBuf; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use radroots_runtime_paths::{ - default_shared_runtime_store_database_path_from_shared_accounts_data_root, - default_shared_runtime_store_root_from_shared_accounts_data_root, -}; -use radroots_runtime_store::{ - PublishOutboxStatus, RuntimeStore, RuntimeStoreRecord, RuntimeStoreRecordFamily, - RuntimeStoreRecordInput, RuntimeStoreRecordStatus, SourceRuntime, -}; -use radroots_sql_core::SqlxSqliteExecutor; -use serde_json::Value; - -use crate::runtime::RuntimeError; -use crate::runtime::config::{PathsConfig, RuntimeConfig}; - -static RECORD_COUNTER: AtomicU64 = AtomicU64::new(0); - -pub fn append_local_work( - config: &RuntimeConfig, - subject: &str, - owner_account_id: Option<String>, - owner_pubkey: Option<String>, - farm_id: Option<String>, - listing_addr: Option<String>, - payload: Value, -) -> Result<RuntimeStoreRecord, RuntimeError> { - let timestamp = current_time_ms()?; - let sequence = RECORD_COUNTER.fetch_add(1, Ordering::Relaxed); - let input = RuntimeStoreRecordInput { - record_id: format!("cli:local_work:{subject}:{timestamp}:{sequence}"), - family: RuntimeStoreRecordFamily::LocalWork, - status: RuntimeStoreRecordStatus::LocalSaved, - source_runtime: SourceRuntime::Cli, - created_at_ms: timestamp, - inserted_at_ms: timestamp, - owner_account_id, - owner_pubkey, - farm_id, - listing_addr, - local_work_json: Some(payload), - event_id: None, - event_kind: None, - event_pubkey: None, - event_created_at: None, - event_tags_json: None, - event_content: None, - event_sig: None, - raw_event_json: None, - outbox_status: PublishOutboxStatus::None, - relay_set_fingerprint: None, - relay_delivery_json: None, - }; - let store = open_store(config)?; - Ok(store.append_record(&input)?) -} - -pub fn shared_runtime_store_db_path(config: &RuntimeConfig) -> Result<PathBuf, RuntimeError> { - shared_runtime_store_db_path_from_paths(&config.paths) -} - -fn shared_runtime_store_db_path_from_paths(paths: &PathsConfig) -> Result<PathBuf, RuntimeError> { - default_shared_runtime_store_database_path_from_shared_accounts_data_root( - &paths.shared_accounts_data_root, - ) - .map_err(|err| { - RuntimeError::Config(format!("resolve shared runtime-store database path: {err}")) - }) -} - -pub fn list_shared_records_latest( - config: &RuntimeConfig, - limit: u32, -) -> Result<Vec<RuntimeStoreRecord>, RuntimeError> { - let database_path = shared_runtime_store_db_path(config)?; - if !database_path.exists() { - return Ok(Vec::new()); - } - let executor = SqlxSqliteExecutor::open(database_path)?; - let store = RuntimeStore::new(executor); - Ok(store.list_records_changed_latest(limit)?) -} - -pub fn list_shared_records_before( - config: &RuntimeConfig, - before_change_seq: i64, - before_seq: i64, - limit: u32, -) -> Result<Vec<RuntimeStoreRecord>, RuntimeError> { - let database_path = shared_runtime_store_db_path(config)?; - if !database_path.exists() { - return Ok(Vec::new()); - } - let executor = SqlxSqliteExecutor::open(database_path)?; - let store = RuntimeStore::new(executor); - Ok(store.list_records_changed_before(before_change_seq, before_seq, limit)?) -} - -pub fn get_shared_record( - config: &RuntimeConfig, - record_id: &str, -) -> Result<Option<RuntimeStoreRecord>, RuntimeError> { - let database_path = shared_runtime_store_db_path(config)?; - if !database_path.exists() { - return Ok(None); - } - let executor = SqlxSqliteExecutor::open(database_path)?; - let store = RuntimeStore::new(executor); - Ok(store.get_record(record_id)?) -} - -fn open_store(config: &RuntimeConfig) -> Result<RuntimeStore<SqlxSqliteExecutor>, RuntimeError> { - let root = shared_runtime_store_root_from_paths(&config.paths)?; - fs::create_dir_all(&root)?; - let executor = - SqlxSqliteExecutor::open(shared_runtime_store_db_path_from_paths(&config.paths)?)?; - let store = RuntimeStore::new(executor); - store.migrate_up()?; - Ok(store) -} - -fn shared_runtime_store_root_from_paths(paths: &PathsConfig) -> Result<PathBuf, RuntimeError> { - default_shared_runtime_store_root_from_shared_accounts_data_root( - &paths.shared_accounts_data_root, - ) - .map_err(|err| RuntimeError::Config(format!("resolve shared runtime-store root: {err}"))) -} - -fn current_time_ms() -> Result<i64, RuntimeError> { - let duration = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| { - RuntimeError::Config(format!("system clock is before unix epoch: {error}")) - })?; - i64::try_from(duration.as_millis()) - .map_err(|_| RuntimeError::Config("current timestamp exceeds i64 milliseconds".to_owned())) -} - -#[cfg(test)] -mod tests { - use std::path::PathBuf; - - use super::{shared_runtime_store_db_path_from_paths, shared_runtime_store_root_from_paths}; - use crate::runtime::config::PathsConfig; - - #[test] - fn shared_runtime_store_paths_use_shared_runtime_contract() { - let paths = paths_config("/repo/infra/local/runtime/radroots/data/shared/accounts"); - - assert_eq!( - shared_runtime_store_root_from_paths(&paths).expect("shared runtime-store root"), - PathBuf::from("/repo/infra/local/runtime/radroots/data/shared/runtime_store") - ); - assert_eq!( - shared_runtime_store_db_path_from_paths(&paths).expect("shared runtime-store database"), - PathBuf::from( - "/repo/infra/local/runtime/radroots/data/shared/runtime_store/runtime_store.sqlite" - ) - ); - } - - fn paths_config(shared_accounts_data_root: &str) -> PathsConfig { - PathsConfig { - profile: "repo_local".to_owned(), - profile_source: "test".to_owned(), - allowed_profiles: vec!["repo_local".to_owned()], - root_source: "repo_local_root".to_owned(), - repo_local_root: Some(PathBuf::from("/repo/infra/local/runtime/radroots")), - repo_local_root_source: Some("test".to_owned()), - subordinate_path_override_source: "runtime_config".to_owned(), - app_namespace: "apps/cli".to_owned(), - shared_accounts_namespace: "shared/accounts".to_owned(), - shared_identities_namespace: "shared/identities".to_owned(), - app_config_path: PathBuf::from( - "/repo/infra/local/runtime/radroots/config/apps/cli/config.toml", - ), - workspace_config_path: None, - app_data_root: PathBuf::from("/repo/infra/local/runtime/radroots/data/apps/cli"), - shared_cache_root: PathBuf::from("/repo/infra/local/runtime/radroots/cache"), - app_logs_root: PathBuf::from("/repo/infra/local/runtime/radroots/logs/apps/cli"), - shared_accounts_data_root: PathBuf::from(shared_accounts_data_root), - shared_accounts_secrets_root: PathBuf::from( - "/repo/infra/local/runtime/radroots/secrets/shared/accounts", - ), - default_identity_path: PathBuf::from( - "/repo/infra/local/runtime/radroots/secrets/shared/identities/default.json", - ), - } - } -} diff --git a/src/runtime/sdk.rs b/src/runtime/sdk.rs @@ -1,390 +0,0 @@ -//! CLI-owned composition of the final SDK capability graph. - -use std::{ - fs, - future::Future, - path::PathBuf, - sync::Arc, - time::{SystemTime, UNIX_EPOCH}, -}; - -use radroots_sdk::{Client, ClientBuilder, Error as SdkError}; -use radroots_signing::{ - SignReceipt, SignRequest, Signer, SignerStatus, signer::BoxFuture as SigningFuture, -}; -use radroots_storage::{event::SourceGeneration, memory::MemoryStorage}; -use radroots_storage_sqlite::{OpenMode, OpenOptions, Paths, SqliteStorage}; -use radroots_sync::policy::{Clock, DeadlinePolicy, IdSource, OperationKind, SyncId, SyncStorage}; -use radroots_transport::{ - BoxFuture, DeliveryReceipt, DeliveryRequest, Error as TransportError, EventSink, EventSource, - FetchPage, FetchRequest, SinkStatus, SourceStatus, TransportId, - capability::{Availability, Maturity, SinkCapabilities, SourceCapabilities}, -}; -use radroots_transport_nostr::{Config as NostrConfig, NostrTransport, RelayUrlPolicy}; -use tokio::runtime::{Builder as TokioRuntimeBuilder, Runtime}; - -use crate::runtime::{ - RuntimeError, - config::{RuntimeConfig, TransportProfileKind}, - signing, -}; - -const SDK_STORAGE_DIR_NAME: &str = "sdk"; -const SYNC_TIMEOUT_MS: u64 = 30_000; -const LOCAL_TRANSPORT_MESSAGE: &str = - "local-only transport is configured without network fetch or delivery"; - -pub(crate) use signing::{MYC_NIP46_SESSION_SECRET_SERVICE, myc_managed_account_ref_matches}; - -#[derive(Debug, thiserror::Error)] -pub enum CliSdkAdapterError { - #[error("{0}")] - Runtime(#[from] RuntimeError), - #[error("{0}")] - Sdk(#[from] SdkError), - #[error("{0}")] - Sync(#[from] radroots_sync::Error), - #[error("{0}")] - Storage(#[from] radroots_storage_sqlite::Error), - #[error("{0}")] - Transport(#[from] radroots_transport_nostr::Error), - #[error("{0}")] - Io(#[from] std::io::Error), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CliSdkConfig { - pub storage_root: PathBuf, -} - -impl CliSdkConfig { - pub fn from_runtime_config(config: &RuntimeConfig) -> Self { - Self { - storage_root: sdk_storage_root(config), - } - } - - fn sqlite_options(&self) -> Result<OpenOptions, CliSdkAdapterError> { - fs::create_dir_all(&self.storage_root)?; - let paths = Paths::from_directory(&self.storage_root)?; - let mut options = OpenOptions::new(paths, OpenMode::Create); - if !self.storage_root.join("runtime.sqlite").exists() { - options = options.with_source_generation(new_source_generation()?, now_unix_ms()?)?; - } - Ok(options) - } -} - -pub struct CliSdkSession { - runtime: Runtime, - sdk: Client, - config: CliSdkConfig, -} - -impl CliSdkSession { - pub fn connect(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { - Self::connect_inner(config, None, false) - } - - pub fn connect_storage_status(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { - Self::connect(config) - } - - pub fn connect_memory(config: &RuntimeConfig) -> Result<Self, CliSdkAdapterError> { - Self::connect_inner(config, None, true) - } - - pub fn connect_for_actor( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: &str, - actor_label: &str, - ) -> Result<Self, CliSdkAdapterError> { - let runtime = sdk_runtime()?; - let provider = runtime.block_on(signing::provider_for_actor( - config, - actor_account_id, - actor_pubkey, - actor_label, - ))?; - Self::compose(config, runtime, Some(provider), false) - } - - pub fn connect_memory_for_actor( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: &str, - actor_label: &str, - ) -> Result<Self, CliSdkAdapterError> { - let runtime = sdk_runtime()?; - let provider = runtime.block_on(signing::provider_for_actor( - config, - actor_account_id, - actor_pubkey, - actor_label, - ))?; - Self::compose(config, runtime, Some(provider), true) - } - - fn connect_inner( - config: &RuntimeConfig, - signer: Option<radroots_sdk::signing::Provider>, - memory: bool, - ) -> Result<Self, CliSdkAdapterError> { - Self::compose(config, sdk_runtime()?, signer, memory) - } - - fn compose( - config: &RuntimeConfig, - runtime: Runtime, - signer: Option<radroots_sdk::signing::Provider>, - memory: bool, - ) -> Result<Self, CliSdkAdapterError> { - let sdk_config = CliSdkConfig::from_runtime_config(config); - if memory { - let storage = Arc::new(MemoryStorage::new(new_source_generation()?)); - Self::compose_with_storage(config, runtime, sdk_config, signer, storage) - } else { - let storage = - Arc::new(runtime.block_on(SqliteStorage::open(sdk_config.sqlite_options()?))?); - Self::compose_with_storage(config, runtime, sdk_config, signer, storage) - } - } - - fn compose_with_storage<T>( - config: &RuntimeConfig, - runtime: Runtime, - sdk_config: CliSdkConfig, - signer: Option<radroots_sdk::signing::Provider>, - storage: Arc<T>, - ) -> Result<Self, CliSdkAdapterError> - where - T: radroots_storage::Storage + SyncStorage + 'static, - { - let (source, sink) = transport_capabilities(config)?; - let signer_capability = signer - .as_ref() - .map(|provider| Arc::new(SharedProvider(provider.clone())) as Arc<dyn Signer>); - let mut engine = radroots_sync::Engine::builder( - storage.clone(), - Arc::new(SystemClock), - Arc::new(RandomIds), - DeadlinePolicy::new(SYNC_TIMEOUT_MS, SYNC_TIMEOUT_MS, SYNC_TIMEOUT_MS)?, - ) - .source(Arc::clone(&source)) - .sink(Arc::clone(&sink)); - if let Some(capability) = signer_capability.as_ref() { - engine = engine.signer(Arc::clone(capability)); - } - let engine = engine.build()?; - - let mut builder = ClientBuilder::new() - .storage(storage) - .source(source) - .sink(sink) - .sync_engine(engine); - if let Some(provider) = signer { - builder = builder.signing(provider); - } - let sdk = builder.build()?; - Ok(Self { - runtime, - sdk, - config: sdk_config, - }) - } - - pub fn sdk(&self) -> &Client { - &self.sdk - } - - pub fn config(&self) -> &CliSdkConfig { - &self.config - } - - pub fn block_on<F: Future>(&self, future: F) -> F::Output { - self.runtime.block_on(future) - } -} - -pub fn validate_configured_signer_for_actor( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: &str, - actor_label: &str, -) -> Result<(), RuntimeError> { - signing::validate_for_actor(config, actor_account_id, actor_pubkey, actor_label) -} - -pub fn sdk_storage_root(config: &RuntimeConfig) -> PathBuf { - config.local.root.join(SDK_STORAGE_DIR_NAME) -} - -pub(crate) fn sdk_runtime() -> Result<Runtime, RuntimeError> { - TokioRuntimeBuilder::new_multi_thread() - .enable_all() - .build() - .map_err(|error| { - RuntimeError::Config(format!("failed to initialize SDK async runtime: {error}")) - }) -} - -pub fn sdk_nostr_relay_url_policy(config: &RuntimeConfig) -> RelayUrlPolicy { - if config - .transport - .nostr_relay_urls - .iter() - .any(|relay_url| relay_url.starts_with("ws://")) - { - RelayUrlPolicy::Local - } else { - RelayUrlPolicy::Public - } -} - -pub(crate) fn sync_targets( - config: &RuntimeConfig, -) -> Result<radroots_transport::TargetSet, TransportError> { - let targets = config - .transport - .nostr_relay_urls - .iter() - .map(|relay| radroots_transport::Target::nostr_relay(relay)) - .collect::<Result<Vec<_>, _>>()?; - radroots_transport::TargetSet::new(targets) -} - -fn transport_capabilities( - config: &RuntimeConfig, -) -> Result<(Arc<dyn EventSource>, Arc<dyn EventSink>), CliSdkAdapterError> { - if matches!( - config.transport.profile, - TransportProfileKind::Nostr | TransportProfileKind::MultiTarget - ) && !config.transport.nostr_relay_urls.is_empty() - { - let transport = Arc::new(NostrTransport::new(NostrConfig::new( - sdk_nostr_relay_url_policy(config), - &config.transport.nostr_relay_urls, - )?)); - let source: Arc<dyn EventSource> = transport.clone(); - let sink: Arc<dyn EventSink> = transport; - Ok((source, sink)) - } else { - let transport = Arc::new(UnavailableTransport); - let source: Arc<dyn EventSource> = transport.clone(); - let sink: Arc<dyn EventSink> = transport; - Ok((source, sink)) - } -} - -fn now_unix_ms() -> Result<u64, RuntimeError> { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| RuntimeError::Config(format!("system clock error: {error}")))? - .as_millis() - .try_into() - .map_err(|_| RuntimeError::Config("system clock is outside SDK range".to_owned())) -} - -fn new_source_generation() -> Result<SourceGeneration, RuntimeError> { - let mut bytes = [0_u8; 32]; - getrandom::getrandom(&mut bytes).map_err(|error| { - RuntimeError::Config(format!("failed to generate SDK source identity: {error}")) - })?; - SourceGeneration::new(bytes) - .map_err(|error| RuntimeError::Config(format!("invalid SDK source identity: {error}"))) -} - -struct SystemClock; - -impl Clock for SystemClock { - fn now_unix_ms(&self) -> Result<u64, radroots_sync::Error> { - now_unix_ms().map_err(|_| radroots_sync::Error::ClockUnavailable) - } -} - -struct RandomIds; - -impl IdSource for RandomIds { - fn next_id(&self, _operation: OperationKind) -> Result<SyncId, radroots_sync::Error> { - let mut bytes = [0_u8; 16]; - getrandom::getrandom(&mut bytes).map_err(|_| radroots_sync::Error::InvalidSyncId)?; - SyncId::new(bytes) - } -} - -#[derive(Clone)] -struct SharedProvider(radroots_sdk::signing::Provider); - -impl Signer for SharedProvider { - fn status(&self) -> SigningFuture<'_, Result<SignerStatus, radroots_signing::Error>> { - Box::pin(self.0.status()) - } - - fn sign( - &self, - request: SignRequest, - ) -> SigningFuture<'_, Result<SignReceipt, radroots_signing::Error>> { - Box::pin(self.0.sign(request)) - } -} - -struct UnavailableTransport; - -impl EventSource for UnavailableTransport { - fn status(&self) -> BoxFuture<'_, Result<SourceStatus, TransportError>> { - Box::pin(async { - Ok(SourceStatus::new( - TransportId::LOCAL, - false, - Maturity::Stable, - Availability::Unavailable, - SourceCapabilities::NONE, - LOCAL_TRANSPORT_MESSAGE, - )) - }) - } - - fn fetch(&self, _request: FetchRequest) -> BoxFuture<'_, Result<FetchPage, TransportError>> { - Box::pin(async { Err(TransportError::UnsupportedOperation) }) - } -} - -impl EventSink for UnavailableTransport { - fn status(&self) -> BoxFuture<'_, Result<SinkStatus, TransportError>> { - Box::pin(async { - Ok(SinkStatus::new( - TransportId::LOCAL, - false, - Maturity::Stable, - Availability::Unavailable, - SinkCapabilities::NONE, - LOCAL_TRANSPORT_MESSAGE, - )) - }) - } - - fn deliver( - &self, - _request: DeliveryRequest, - ) -> BoxFuture<'_, Result<DeliveryReceipt, TransportError>> { - Box::pin(async { Err(TransportError::UnsupportedOperation) }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn random_host_policies_produce_valid_values() { - assert!(SystemClock.now_unix_ms().expect("clock") > 0); - assert_ne!( - RandomIds - .next_id(OperationKind::Pull) - .expect("sync id") - .as_bytes(), - &[0; 16] - ); - } -} diff --git a/src/runtime/signer.rs b/src/runtime/signer.rs @@ -1,622 +0,0 @@ -use crate::runtime::RuntimeError; -use crate::runtime::account::AccountRuntimeFailure; -use crate::runtime::account::{SHARED_ACCOUNT_STORE_SOURCE, empty_account_resolution_view}; -use crate::runtime::config::{ - CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, -}; -use crate::runtime::sdk::{MYC_NIP46_SESSION_SECRET_SERVICE, myc_managed_account_ref_matches}; -use crate::view::runtime::{ - IdentityPublicView, LocalSignerStatusView, MycStatusView, SignerBindingStatusView, - SignerStatusView, SignerWriteKindReadinessView, -}; -use radroots_event::envelope::kind::{ - KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, - KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, -}; -use radroots_identity::account::Status as AccountStatus; -use url::Url; - -const SIGNER_BINDING_PROVIDER_RUNTIME_ID: &str = "myc"; -const SIGNER_BINDING_MODEL: &str = "session_authorized_remote_signer"; - -#[derive(Debug, Clone, Copy)] -struct CliWriteKind { - command: &'static str, - event_kind: u32, -} - -#[derive(Debug, Clone)] -pub enum ActorWriteBindingError { - Unconfigured(String), - Account(AccountRuntimeFailure), -} - -impl ActorWriteBindingError { - pub fn from_runtime(error: RuntimeError) -> Self { - match error { - RuntimeError::Account(failure) => Self::Account(failure), - other => Self::Unconfigured(other.to_string()), - } - } - - pub fn reason(self) -> String { - match self { - Self::Unconfigured(reason) => reason, - Self::Account(failure) => failure.to_string(), - } - } -} - -pub fn resolve_signer_status(config: &RuntimeConfig) -> SignerStatusView { - match config.signer.backend { - SignerBackend::Local => resolve_local_signer_status(config), - SignerBackend::Myc => resolve_myc_signer_status(config), - } -} - -fn resolve_local_signer_status(config: &RuntimeConfig) -> SignerStatusView { - let (account_resolution, resolved_account_id) = - match crate::runtime::account::resolve_account_resolution(config) { - Ok(resolution) => ( - crate::runtime::account::account_resolution_view(&resolution), - resolution - .resolved_account - .as_ref() - .map(|account| account.record.id().to_string()), - ), - Err(error) => { - let reason = error.to_string(); - return SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "error".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: None, - account_resolution: empty_account_resolution_view(), - reason: Some(reason.clone()), - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness(false, Some(reason)), - local: None, - myc: None, - }; - } - }; - let secret_backend = crate::runtime::account::secret_backend_status(config); - if secret_backend.state == "unavailable" { - let reason = secret_backend.reason.clone(); - return SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "unavailable".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: resolved_account_id.clone(), - account_resolution: account_resolution.clone(), - reason: reason.clone(), - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness(false, reason), - local: None, - myc: None, - }; - } - - if secret_backend.state == "error" { - let reason = secret_backend.reason.clone(); - return SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "error".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: resolved_account_id.clone(), - account_resolution: account_resolution.clone(), - reason: reason.clone(), - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness(false, reason), - local: None, - myc: None, - }; - } - - let backend = secret_backend - .active_backend - .unwrap_or_else(|| "unknown".to_owned()); - - match crate::runtime::account::resolved_account_signing_status(config) { - Ok(AccountStatus::Ready { account }) => SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "ready".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: Some(account.id().to_string()), - account_resolution: account_resolution.clone(), - reason: None, - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness(true, None), - local: Some(LocalSignerStatusView { - account_id: account.id().to_string(), - public_identity: IdentityPublicView::from_public_identity( - account.public_identity(), - ), - availability: "secret_backed".to_owned(), - secret_backed: true, - backend: backend.clone(), - }), - myc: None, - }, - Ok(AccountStatus::PublicOnly { account }) => { - let reason = AccountRuntimeFailure::watch_only(&account.id()).to_string(); - SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "unconfigured".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: Some(account.id().to_string()), - account_resolution: account_resolution.clone(), - reason: Some(reason.clone()), - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness(false, Some(reason)), - local: Some(LocalSignerStatusView { - account_id: account.id().to_string(), - public_identity: IdentityPublicView::from_public_identity( - account.public_identity(), - ), - availability: "public_only".to_owned(), - secret_backed: false, - backend: backend.clone(), - }), - myc: None, - } - } - Ok(AccountStatus::NotConfigured) => SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "unconfigured".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: None, - account_resolution: account_resolution.clone(), - reason: crate::runtime::account::unresolved_account_reason(config).ok(), - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness( - false, - crate::runtime::account::unresolved_account_reason(config).ok(), - ), - local: None, - myc: None, - }, - Ok(_) => SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "error".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: resolved_account_id, - account_resolution, - reason: Some("account status is not supported by this CLI version".to_owned()), - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness( - false, - Some("account status is not supported by this CLI version".to_owned()), - ), - local: None, - myc: None, - }, - Err(error) => { - let reason = error.to_string(); - SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: "error".to_owned(), - source: SHARED_ACCOUNT_STORE_SOURCE.to_owned(), - signer_account_id: resolved_account_id, - account_resolution, - reason: Some(reason.clone()), - binding: disabled_binding_status(), - write_kinds: local_write_kind_readiness(false, Some(reason)), - local: None, - myc: None, - } - } - } -} - -fn resolve_myc_signer_status(config: &RuntimeConfig) -> SignerStatusView { - let (account_resolution, actor_account_id, actor_pubkey) = - match crate::runtime::account::resolve_account_resolution(config) { - Ok(resolution) => { - let actor_account_id = resolution - .resolved_account - .as_ref() - .map(|account| account.record.id().to_string()); - let actor_pubkey = resolution - .resolved_account - .as_ref() - .map(|account| account.record.public_identity().public_key().to_hex()); - ( - crate::runtime::account::account_resolution_view(&resolution), - actor_account_id, - actor_pubkey, - ) - } - Err(_) => (empty_account_resolution_view(), None, None), - }; - let readiness = - myc_binding_readiness(config, actor_account_id.as_deref(), actor_pubkey.as_deref()); - SignerStatusView { - mode: config.signer.backend.as_str().to_owned(), - state: if readiness.ready { - "ready" - } else { - "unconfigured" - } - .to_owned(), - source: readiness.source.clone(), - signer_account_id: None, - account_resolution, - reason: readiness.reason.clone(), - binding: readiness.binding, - write_kinds: myc_write_kind_readiness(readiness.ready, readiness.reason.clone()), - local: None, - myc: Some(MycStatusView { - executable: config.myc.executable.display().to_string(), - state: if readiness.ready { - "ready" - } else { - "unconfigured" - } - .to_owned(), - source: readiness.source, - service_status: None, - ready: readiness.ready, - reason: readiness.reason, - reasons: readiness.reasons, - remote_session_count: usize::from(readiness.signer_session_ref.is_some()), - local_signer: None, - remote_sessions: Vec::new(), - custody: None, - }), - } -} - -fn disabled_binding_status() -> SignerBindingStatusView { - SignerBindingStatusView { - capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(), - provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(), - binding_model: SIGNER_BINDING_MODEL.to_owned(), - state: "disabled".to_owned(), - source: "independent local signer mode".to_owned(), - target_kind: None, - target: None, - managed_account_ref: None, - signer_session_ref: None, - resolved_session_ref: None, - matched_session_count: None, - reason: Some( - "remote myc signer binding is disabled while cli signer mode is `local`".to_owned(), - ), - } -} - -fn cli_write_kinds() -> [CliWriteKind; 9] { - [ - CliWriteKind { - command: "farm.publish", - event_kind: KIND_FARM, - }, - CliWriteKind { - command: "listing.publish", - event_kind: KIND_CLASSIFIED_LISTING, - }, - CliWriteKind { - command: "listing.update", - event_kind: KIND_CLASSIFIED_LISTING, - }, - CliWriteKind { - command: "listing.withdraw", - event_kind: KIND_CLASSIFIED_LISTING, - }, - CliWriteKind { - command: "trade.proposal.submit", - event_kind: KIND_TRADE_PROPOSAL, - }, - CliWriteKind { - command: "trade.candidate.decide", - event_kind: KIND_TRADE_DECISION, - }, - CliWriteKind { - command: "trade.revision.propose", - event_kind: KIND_TRADE_REVISION_PROPOSAL, - }, - CliWriteKind { - command: "trade.revision.candidate.decide", - event_kind: KIND_TRADE_REVISION_DECISION, - }, - CliWriteKind { - command: "trade.cancellation.submit", - event_kind: KIND_TRADE_CANCELLATION, - }, - ] -} - -fn local_write_kind_readiness( - ready: bool, - reason: Option<String>, -) -> Vec<SignerWriteKindReadinessView> { - cli_write_kinds() - .iter() - .map(|kind| SignerWriteKindReadinessView { - command: kind.command.to_owned(), - event_kind: kind.event_kind, - permission: "local_account_secret".to_owned(), - ready, - reason: if ready { None } else { reason.clone() }, - }) - .collect() -} - -#[derive(Debug, Clone)] -struct MycBindingReadiness { - binding: SignerBindingStatusView, - ready: bool, - source: String, - reason: Option<String>, - reasons: Vec<String>, - signer_session_ref: Option<String>, -} - -fn myc_binding_readiness( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: Option<&str>, -) -> MycBindingReadiness { - let Some(binding) = config.capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) else { - let reason = "signer.remote_nip46 binding is missing".to_owned(); - return MycBindingReadiness { - binding: missing_myc_binding_status(reason.clone()), - ready: false, - source: "no explicit capability binding".to_owned(), - reason: Some(reason.clone()), - reasons: vec![reason], - signer_session_ref: None, - }; - }; - - let mut reasons = Vec::new(); - if binding.target_kind != CapabilityBindingTargetKind::ExplicitEndpoint { - reasons.push(format!( - "signer.remote_nip46 binding target_kind `{}` is not supported for CLI Myc signing; use `explicit_endpoint`", - binding.target_kind.as_str() - )); - } - if let Err(reason) = validate_myc_target(binding.target.as_str()) { - reasons.push(reason); - } - if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() { - let managed_account_matches = actor_pubkey - .map(|actor_pubkey| { - myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey) - }) - .unwrap_or_else(|| { - actor_account_id.is_some_and(|account_id| managed_account_ref == account_id) - }); - if !managed_account_matches { - let reason = if actor_account_id.is_none() && actor_pubkey.is_none() { - format!( - "signer.remote_nip46 managed_account_ref `{managed_account_ref}` cannot be evaluated because no actor account or pubkey resolved" - ) - } else { - format!( - "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey" - ) - }; - reasons.push(reason); - } - } - let signer_session_ref = binding.signer_session_ref.clone(); - if let Some(session_ref) = signer_session_ref.as_deref() { - match crate::runtime::account::load_secret_backend_secret( - config, - session_ref, - MYC_NIP46_SESSION_SECRET_SERVICE, - ) { - Ok(Some(secret)) if secret.trim().is_empty() => { - reasons.push(format!( - "signer.remote_nip46 signer_session_ref `{session_ref}` resolved to an empty client secret" - )); - } - Ok(Some(_)) => {} - Ok(None) => { - reasons.push(format!( - "signer.remote_nip46 signer_session_ref `{session_ref}` was not found in the account secret backend" - )); - } - Err(error) => reasons.push(error.to_string()), - } - } else { - reasons.push("signer.remote_nip46 signer_session_ref is missing".to_owned()); - } - - let ready = reasons.is_empty(); - let reason = reasons.first().cloned(); - let source = binding.source.as_str().to_owned(); - MycBindingReadiness { - binding: SignerBindingStatusView { - capability_id: binding.capability_id.clone(), - provider_runtime_id: binding.provider_runtime_id.clone(), - binding_model: binding.binding_model.clone(), - state: if ready { "ready" } else { "unconfigured" }.to_owned(), - source: source.clone(), - target_kind: Some(binding.target_kind.as_str().to_owned()), - target: Some(binding.target.clone()), - managed_account_ref: binding.managed_account_ref.clone(), - signer_session_ref: binding.signer_session_ref.clone(), - resolved_session_ref: binding.signer_session_ref.clone().filter(|_| ready), - matched_session_count: Some(usize::from(ready)), - reason: reason.clone(), - }, - ready, - source, - reason, - reasons, - signer_session_ref, - } -} - -fn missing_myc_binding_status(reason: String) -> SignerBindingStatusView { - SignerBindingStatusView { - capability_id: SIGNER_REMOTE_NIP46_CAPABILITY.to_owned(), - provider_runtime_id: SIGNER_BINDING_PROVIDER_RUNTIME_ID.to_owned(), - binding_model: SIGNER_BINDING_MODEL.to_owned(), - state: "unconfigured".to_owned(), - source: "no explicit capability binding".to_owned(), - target_kind: None, - target: None, - managed_account_ref: None, - signer_session_ref: None, - resolved_session_ref: None, - matched_session_count: Some(0), - reason: Some(reason), - } -} - -fn validate_myc_target(value: &str) -> Result<(), String> { - let trimmed = value.trim(); - if trimmed.starts_with("nostrconnect://") { - return Err( - "signer.remote_nip46 target must be a bunker URI or discovery URL; raw nostrconnect client URIs are signer-side only" - .to_owned(), - ); - } - let bunker_uri = if trimmed.starts_with("bunker://") { - trimmed.to_owned() - } else { - let url = Url::parse(trimmed) - .map_err(|error| format!("signer.remote_nip46 target is invalid: {error}"))?; - url.query_pairs() - .find(|(key, _)| key == "uri") - .map(|(_, uri)| uri.into_owned()) - .ok_or_else(|| { - "signer.remote_nip46 discovery target is missing `uri` query parameter".to_owned() - })? - }; - match radroots_nostr_connect::uri::Uri::parse(bunker_uri.as_str()) - .map_err(|error| format!("signer.remote_nip46 target is invalid: {error}"))? - { - radroots_nostr_connect::uri::Uri::Bunker(_) => Ok(()), - radroots_nostr_connect::uri::Uri::Client(_) => Err( - "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only" - .to_owned(), - ), - } -} - -fn myc_write_kind_readiness( - ready: bool, - reason: Option<String>, -) -> Vec<SignerWriteKindReadinessView> { - cli_write_kinds() - .iter() - .map(|kind| { - let permission = sign_event_permission_for_kind(kind.event_kind); - SignerWriteKindReadinessView { - command: kind.command.to_owned(), - event_kind: kind.event_kind, - permission, - ready, - reason: if ready { None } else { reason.clone() }, - } - }) - .collect() -} - -fn sign_event_permission_for_kind(event_kind: u32) -> String { - format!("sign_event:{event_kind}") -} - -#[cfg(test)] -mod tests { - use super::{ - KIND_CLASSIFIED_LISTING, KIND_FARM, KIND_TRADE_CANCELLATION, KIND_TRADE_DECISION, - KIND_TRADE_PROPOSAL, KIND_TRADE_REVISION_DECISION, KIND_TRADE_REVISION_PROPOSAL, - cli_write_kinds, myc_managed_account_ref_matches, myc_write_kind_readiness, - sign_event_permission_for_kind, - }; - - #[test] - fn write_kind_readiness_matches_active_signed_mutations() { - let commands: Vec<&str> = cli_write_kinds() - .iter() - .map(|write_kind| write_kind.command) - .collect(); - - assert_eq!( - commands, - [ - "farm.publish", - "listing.publish", - "listing.update", - "listing.withdraw", - "trade.proposal.submit", - "trade.candidate.decide", - "trade.revision.propose", - "trade.revision.candidate.decide", - "trade.cancellation.submit", - ] - ); - assert!(!commands.contains(&"signer.status")); - assert!(!commands.contains(&"sync.push")); - } - - #[test] - fn trade_readiness_uses_canonical_mutation_kinds() { - for (command, event_kind) in [ - ("trade.proposal.submit", KIND_TRADE_PROPOSAL), - ("trade.candidate.decide", KIND_TRADE_DECISION), - ("trade.revision.propose", KIND_TRADE_REVISION_PROPOSAL), - ( - "trade.revision.candidate.decide", - KIND_TRADE_REVISION_DECISION, - ), - ("trade.cancellation.submit", KIND_TRADE_CANCELLATION), - ] { - let write_kind = cli_write_kinds() - .into_iter() - .find(|kind| kind.command == command) - .expect("trade readiness"); - - assert_eq!(write_kind.event_kind, event_kind); - } - } - - #[test] - fn myc_write_readiness_requires_exact_permissions() { - let readiness = myc_write_kind_readiness(true, None); - assert!(readiness.iter().all(|kind| kind.command != "sync.push")); - - for (command, event_kind) in [ - ("farm.publish", KIND_FARM), - ("listing.publish", KIND_CLASSIFIED_LISTING), - ("trade.proposal.submit", KIND_TRADE_PROPOSAL), - ] { - let entry = readiness - .iter() - .find(|kind| kind.command == command) - .expect("product write readiness"); - - assert_eq!(entry.permission, sign_event_permission_for_kind(event_kind)); - assert!(entry.ready, "{command} should be ready"); - assert_eq!(entry.reason, None); - } - } - - #[test] - fn myc_managed_account_ref_matches_actor_account_id_or_pubkey() { - let actor_account_id = Some("acct_farmer_market"); - let actor_pubkey = "02d67b520cb0b835a5ca6ddf78bf3bbfe636d31a523050efc01bf8cb0c680da09e"; - - assert!(myc_managed_account_ref_matches( - "acct_farmer_market", - actor_account_id, - actor_pubkey, - )); - assert!(myc_managed_account_ref_matches( - actor_pubkey, - actor_account_id, - actor_pubkey, - )); - assert!(!myc_managed_account_ref_matches( - "acct_other", - actor_account_id, - actor_pubkey, - )); - } -} diff --git a/src/runtime/signing.rs b/src/runtime/signing.rs @@ -1,619 +0,0 @@ -//! CLI-owned composition of canonical local and NIP-46 signer adapters. - -use std::{ - sync::Arc, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -use nostr_sdk::prelude::{ - Client as RelayClient, Filter, JsonUtil, Kind, RelayPoolNotification, Timestamp, -}; -use radroots_event::{SignedEvent, wire::Nip01EventWire}; -use radroots_nostr_connect::{ - Client as NostrConnectClient, Error as NostrConnectError, Request, Response, - client::{ - CancellationToken, ClientEvent, Completion, Progress, Receive, Target, Transport, - TransportFuture, - }, - message::{RequestId, UnsignedEvent}, - uri::{BunkerUri, Uri}, -}; -use radroots_sdk::signing::Provider; -use radroots_signing::{ - Error as SigningError, SignReceipt, SignRequest, Signer, SignerStatus, - capability::{CancellationSupport, SignerCapability, SignerKind}, - error::Kind as SigningErrorKind, - signer::BoxFuture, - status::{AuthChallenge, SignProgress, SignProgressStage, SignerAvailability}, -}; -use tokio::{sync::broadcast, time::Instant}; -use url::Url; -use zeroize::Zeroizing; - -use crate::runtime::{ - RuntimeError, account, - config::{ - CapabilityBindingTargetKind, RuntimeConfig, SIGNER_REMOTE_NIP46_CAPABILITY, SignerBackend, - }, -}; - -pub(crate) const MYC_NIP46_SESSION_SECRET_SERVICE: &str = "org.radroots.cli.myc-nip46-session"; - -pub(crate) fn validate_for_actor( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: &str, - actor_label: &str, -) -> Result<(), RuntimeError> { - match config.signer.backend { - SignerBackend::Local => { - let signing = local_signer(config, actor_account_id)?; - let signer_pubkey = signing.signer.public_key().to_hex(); - if !signer_pubkey.eq_ignore_ascii_case(actor_pubkey) { - return Err(account::AccountRuntimeFailure::mismatch(format!( - "{actor_label} public key `{actor_pubkey}` does not match local signer account `{}` public key `{signer_pubkey}`", - signing.account.record.id() - )) - .into()); - } - Ok(()) - } - SignerBackend::Myc => remote_input(config, actor_account_id, actor_pubkey).map(|_| ()), - } -} - -pub(crate) async fn provider_for_actor( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: &str, - actor_label: &str, -) -> Result<Provider, RuntimeError> { - match config.signer.backend { - SignerBackend::Local => { - let signing = local_signer(config, actor_account_id)?; - let signer_pubkey = signing.signer.public_key().to_hex(); - if !signer_pubkey.eq_ignore_ascii_case(actor_pubkey) { - return Err(account::AccountRuntimeFailure::mismatch(format!( - "{actor_label} public key `{actor_pubkey}` does not match local signer account `{}` public key `{signer_pubkey}`", - signing.account.record.id() - )) - .into()); - } - Ok(Provider::local(signing.signer)) - } - SignerBackend::Myc => { - let input = remote_input(config, actor_account_id, actor_pubkey)?; - let signer = Nip46Signer::connect(input).await?; - Ok(Provider::nip46(Arc::new(signer))) - } - } -} - -fn local_signer( - config: &RuntimeConfig, - actor_account_id: Option<&str>, -) -> Result<account::AccountLocalSigner, RuntimeError> { - match actor_account_id { - Some(account_id) => account::resolve_local_signing_identity_for_account(config, account_id), - None => account::resolve_local_signing_identity(config), - } -} - -struct RemoteInput { - session_secret: Zeroizing<String>, - bunker: BunkerUri, - request_timeout: Duration, -} - -fn remote_input( - config: &RuntimeConfig, - actor_account_id: Option<&str>, - actor_pubkey: &str, -) -> Result<RemoteInput, RuntimeError> { - let binding = config - .capability_binding(SIGNER_REMOTE_NIP46_CAPABILITY) - .ok_or_else(|| RuntimeError::Config("signer.remote_nip46 binding is missing".to_owned()))?; - if binding.target_kind != CapabilityBindingTargetKind::ExplicitEndpoint { - return Err(RuntimeError::Config(format!( - "signer.remote_nip46 binding target_kind `{}` is not supported for CLI Myc signing; use `explicit_endpoint`", - binding.target_kind.as_str() - ))); - } - if let Some(managed_account_ref) = binding.managed_account_ref.as_deref() - && !myc_managed_account_ref_matches(managed_account_ref, actor_account_id, actor_pubkey) - { - return Err(RuntimeError::Config(format!( - "signer.remote_nip46 managed_account_ref `{managed_account_ref}` does not match actor account or pubkey" - ))); - } - let signer_session_ref = binding.signer_session_ref.as_deref().ok_or_else(|| { - RuntimeError::Config("signer.remote_nip46 signer_session_ref is missing".to_owned()) - })?; - let secret = account::load_secret_backend_secret( - config, - signer_session_ref, - MYC_NIP46_SESSION_SECRET_SERVICE, - )? - .ok_or_else(|| { - RuntimeError::Config(format!( - "signer.remote_nip46 signer_session_ref `{signer_session_ref}` was not found in the account secret backend" - )) - })?; - if config.myc.status_timeout_ms == 0 { - return Err(RuntimeError::Config( - "RADROOTS_CLI_MYC_STATUS_TIMEOUT_MS must be greater than zero".to_owned(), - )); - } - let bunker = parse_target(binding.target.as_str())?; - if bunker.remote_signer_public_key().to_hex() != actor_pubkey { - return Err(account::AccountRuntimeFailure::mismatch(format!( - "remote signer public key `{}` does not match actor public key `{actor_pubkey}`", - bunker.remote_signer_public_key().to_hex() - )) - .into()); - } - Ok(RemoteInput { - session_secret: Zeroizing::new(secret), - bunker, - request_timeout: Duration::from_millis(config.myc.status_timeout_ms), - }) -} - -pub(crate) fn myc_managed_account_ref_matches( - managed_account_ref: &str, - actor_account_id: Option<&str>, - actor_pubkey: &str, -) -> bool { - actor_account_id.is_some_and(|account_id| managed_account_ref == account_id) - || managed_account_ref == actor_pubkey -} - -fn parse_target(value: &str) -> Result<BunkerUri, RuntimeError> { - let trimmed = value.trim(); - if trimmed.starts_with("nostrconnect://") { - return Err(RuntimeError::Config( - "signer.remote_nip46 target must be a bunker URI or discovery URL; raw nostrconnect client URIs are signer-side only" - .to_owned(), - )); - } - let bunker_uri = if trimmed.starts_with("bunker://") { - trimmed.to_owned() - } else { - Url::parse(trimmed) - .map_err(|error| { - RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}")) - })? - .query_pairs() - .find(|(key, _)| key == "uri") - .map(|(_, uri)| uri.into_owned()) - .ok_or_else(|| { - RuntimeError::Config( - "signer.remote_nip46 discovery target is missing `uri` query parameter" - .to_owned(), - ) - })? - }; - match Uri::parse(bunker_uri.as_str()) - .map_err(|error| RuntimeError::Config(format!("signer.remote_nip46 target is invalid: {error}")))? - { - Uri::Bunker(bunker) => Ok(bunker), - Uri::Client(_) => Err(RuntimeError::Config( - "signer.remote_nip46 target must resolve to a bunker URI; raw nostrconnect client URIs are signer-side only" - .to_owned(), - )), - } -} - -struct Nip46Signer { - client: NostrConnectClient, - target: Target, - request_timeout: Duration, -} - -impl Nip46Signer { - async fn connect(input: RemoteInput) -> Result<Self, RuntimeError> { - let target = Target::try_new( - input.bunker.remote_signer_public_key(), - input.bunker.relays().to_vec(), - ) - .map_err(|error| RuntimeError::Config(error.to_string()))?; - let client = NostrConnectClient::from_secret(input.session_secret.trim(), target.clone()) - .map_err(|_| { - RuntimeError::Config("NIP-46 client session secret is invalid".to_owned()) - })?; - Ok(Self { - client, - target, - request_timeout: input.request_timeout, - }) - } -} - -impl Signer for Nip46Signer { - fn status(&self) -> BoxFuture<'_, Result<SignerStatus, SigningError>> { - Box::pin(async { - Ok(SignerStatus::new( - SignerAvailability::Ready, - vec![SignerCapability::new( - SignerKind::Remote, - CancellationSupport::BeforeAndAfterPublication, - true, - true, - )], - None, - )) - }) - } - - fn sign(&self, request: SignRequest) -> BoxFuture<'_, Result<SignReceipt, SigningError>> { - Box::pin(async move { - let now = - unix_time().map_err(|_| SigningError::new(SigningErrorKind::InternalError))?; - let remaining = request.policy().deadline_unix().saturating_sub(now); - if remaining == 0 { - return Err(SigningError::new(SigningErrorKind::DeadlineExceeded)); - } - request.report_progress(&SignProgress::stage(SignProgressStage::Validating)?); - let unsigned_json = serde_json::json!({ - "pubkey": request.draft().expected_pubkey().to_hex(), - "created_at": request.draft().created_at_u64(), - "kind": request.draft().kind_u32(), - "tags": request.draft().tags_as_vec(), - "content": request.draft().content(), - }) - .to_string(); - let unsigned = UnsignedEvent::from_json(unsigned_json.as_str()) - .map_err(|error| signing_source(SigningErrorKind::InvalidArgument, error))?; - let mut transport = RelayTransport::connect( - &self.client, - &self.target, - self.request_timeout.min(Duration::from_secs(remaining)), - ) - .await - .map_err(|error| signing_source(SigningErrorKind::SignerUnavailable, error))?; - let request_id = random_request_id() - .map_err(|error| signing_source(SigningErrorKind::InternalError, error))?; - request.report_progress(&SignProgress::stage(SignProgressStage::RequestPublished)?); - let cancellation = CancellationToken::new(); - let completion = self - .client - .execute( - request_id, - Request::SignEvent(unsigned), - &mut transport, - &cancellation, - |progress| report_remote_progress(&request, progress), - ) - .await - .map_err(normalize_nip46_error)?; - finish_remote_signing(&request, completion) - }) - } -} - -fn report_remote_progress( - request: &SignRequest, - progress: Progress, -) -> Result<(), NostrConnectError> { - match progress { - Progress::AuthChallenge { url } => { - let now = unix_time().map_err(|error| NostrConnectError::Transport { - reason: error.to_string(), - })?; - let challenge = AuthChallenge::new(url, now, Some(request.policy().deadline_unix())) - .map_err(|_| NostrConnectError::InvalidClientState { - reason: "remote authentication challenge is invalid", - })?; - request.report_progress(&SignProgress::authentication(challenge)); - } - } - Ok(()) -} - -fn finish_remote_signing( - request: &SignRequest, - completion: Completion, -) -> Result<SignReceipt, SigningError> { - let Completion::Response(response) = completion else { - return Err(SigningError::new(SigningErrorKind::SignerCancelled)); - }; - let Response::SignedEvent(event) = *response else { - return Err(SigningError::new(SigningErrorKind::SignerOutputInvalid)); - }; - request.report_progress(&SignProgress::stage(SignProgressStage::VerifyingOutput)?); - let raw_json = event.as_json(); - let wire = Nip01EventWire::parse_json(raw_json.as_str()) - .map_err(|error| signing_source(SigningErrorKind::SignerOutputInvalid, error))?; - let signed = SignedEvent::from_wire_verified_id(wire, raw_json) - .map_err(|error| signing_source(SigningErrorKind::SignerOutputInvalid, error))?; - let completed_at = - unix_time().map_err(|error| signing_source(SigningErrorKind::InternalError, error))?; - let receipt = SignReceipt::from_signed_event(request, signed, completed_at)?; - request.report_progress(&SignProgress::stage(SignProgressStage::Complete)?); - Ok(receipt) -} - -fn normalize_nip46_error(error: NostrConnectError) -> SigningError { - let kind = match error { - NostrConnectError::RequestTimedOut => SigningErrorKind::SignerTimeout, - NostrConnectError::WrongRequestId - | NostrConnectError::WrongResponseSigner - | NostrConnectError::ReplayedResponse - | NostrConnectError::InvalidResponseEnvelope { .. } - | NostrConnectError::InvalidResponsePayload { .. } - | NostrConnectError::InvalidClientEvent => SigningErrorKind::SignerOutputInvalid, - NostrConnectError::Transport { .. } => SigningErrorKind::SignerUnavailable, - _ => SigningErrorKind::InternalError, - }; - signing_source(kind, error) -} - -fn signing_source<E>(kind: SigningErrorKind, source: E) -> SigningError -where - E: std::error::Error + Send + Sync + 'static, -{ - SigningError::with_source(kind, source) -} - -fn random_request_id() -> Result<RequestId, getrandom::Error> { - let mut bytes = [0_u8; 16]; - getrandom::getrandom(&mut bytes)?; - let value = bytes - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::<String>(); - Ok(RequestId::parse(value).expect("hex request ID is bounded and valid")) -} - -fn unix_time() -> Result<u64, std::time::SystemTimeError> { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|value| value.as_secs()) -} - -struct RelayTransport { - client: RelayClient, - notifications: broadcast::Receiver<RelayPoolNotification>, - request_timeout: Duration, - deadline: Option<Instant>, -} - -impl RelayTransport { - async fn connect( - protocol_client: &NostrConnectClient, - target: &Target, - request_timeout: Duration, - ) -> Result<Self, NostrConnectError> { - let client = RelayClient::default(); - client.automatic_authentication(false); - for relay in target.relays() { - client - .add_relay(relay.to_string()) - .await - .map_err(transport_error)?; - } - let connected = client.try_connect(request_timeout).await; - if connected.success.is_empty() { - return Err(NostrConnectError::Transport { - reason: "no NIP-46 relay accepted the connection".to_owned(), - }); - } - let client_public_key = protocol_client.public_key().map_err(|error| error)?; - let client_public_key = nostr_sdk::prelude::PublicKey::from_slice( - client_public_key.as_bytes(), - ) - .map_err(|_| NostrConnectError::InvalidClientState { - reason: "client public key is invalid", - })?; - let filter = Filter::new() - .kind(Kind::Custom(radroots_nostr_connect::message::RPC_KIND)) - .pubkey(client_public_key) - .since(Timestamp::now()); - let notifications = client.notifications(); - let subscribed = client - .subscribe(filter, None) - .await - .map_err(transport_error)?; - if subscribed.success.is_empty() { - return Err(NostrConnectError::Transport { - reason: "no NIP-46 relay accepted the response subscription".to_owned(), - }); - } - Ok(Self { - client, - notifications, - request_timeout, - deadline: None, - }) - } -} - -impl Transport for RelayTransport { - fn publish<'a>(&'a mut self, event: ClientEvent) -> TransportFuture<'a, ()> { - Box::pin(async move { - self.deadline = Some(Instant::now() + self.request_timeout); - let event = nostr_sdk::prelude::Event::from_json(event.as_json()) - .map_err(|_| NostrConnectError::InvalidClientEvent)?; - let output = self - .client - .send_event(&event) - .await - .map_err(transport_error)?; - if output.success.is_empty() { - return Err(NostrConnectError::Transport { - reason: "no NIP-46 relay accepted the request".to_owned(), - }); - } - Ok(()) - }) - } - - fn receive<'a>( - &'a mut self, - cancellation: &'a CancellationToken, - ) -> TransportFuture<'a, Receive> { - Box::pin(async move { - loop { - if cancellation.is_cancelled() { - return Ok(Receive::Cancelled); - } - let Some(deadline) = self.deadline else { - return Err(NostrConnectError::InvalidClientState { - reason: "request deadline is not initialized", - }); - }; - let now = Instant::now(); - if now >= deadline { - return Ok(Receive::TimedOut); - } - let poll = (deadline - now).min(Duration::from_millis(50)); - match tokio::time::timeout(poll, self.notifications.recv()).await { - Err(_) => continue, - Ok(Err(broadcast::error::RecvError::Lagged(_))) => continue, - Ok(Err(broadcast::error::RecvError::Closed)) => { - return Err(NostrConnectError::Transport { - reason: "NIP-46 relay notification stream closed".to_owned(), - }); - } - Ok(Ok(RelayPoolNotification::Event { event, .. })) => { - return ClientEvent::from_json(event.as_json().as_str()) - .map(Receive::event); - } - Ok(Ok(_)) => continue, - } - } - }) - } -} - -fn transport_error(error: impl std::fmt::Display) -> NostrConnectError { - NostrConnectError::Transport { - reason: error.to_string(), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::{Arc, Mutex}; - - use radroots_event::{EventDraft, contract::AuthorRole, envelope::kind::KIND_GEOCHAT}; - use radroots_protocol::runtime::v1::OperationId; - use radroots_signing::{ - Actor, - actor::ActorSource, - request::{CancellationPolicy, ProgressObserver, SignPolicy}, - }; - - struct RecordingObserver(Mutex<Vec<SignProgressStage>>); - - impl ProgressObserver for RecordingObserver { - fn on_progress(&self, progress: &SignProgress) { - self.0 - .lock() - .expect("progress lock") - .push(progress.stage_value()); - } - } - - fn local_request() -> (radroots_nostr::signing::LocalSigner, SignRequest) { - let signer = radroots_nostr::signing::LocalSigner::generate().expect("local signer"); - let public_key = signer.public_key(); - let actor = Actor::new( - public_key, - ActorSource::ExplicitPublicKey, - [AuthorRole::Any], - ) - .expect("actor"); - let now = unix_time().expect("time"); - let draft = EventDraft::new( - "radroots.social.geochat.v1", - KIND_GEOCHAT, - now, - Vec::new(), - "CLI NIP-46 fixture", - public_key.to_hex(), - ) - .expect("draft"); - let request = SignRequest::new( - OperationId::SyncPush, - actor, - draft, - SignPolicy::new(now + 120, CancellationPolicy::PreservePublishedRequest) - .expect("policy"), - ) - .expect("request"); - (signer, request) - } - - #[tokio::test] - async fn remote_happy_response_becomes_an_exact_verified_receipt() { - let (local, request) = local_request(); - let signed = local.sign(request.clone()).await.expect("local fixture"); - let wire = radroots_nostr_connect::message::SignedEvent::from_json( - signed.signed_event().raw_json(), - ) - .expect("NIP-46 signed event"); - - let receipt = - finish_remote_signing(&request, Completion::response(Response::SignedEvent(wire))) - .expect("remote receipt"); - - assert_eq!(receipt.operation_id(), OperationId::SyncPush); - assert_eq!( - receipt.signed_event().id_str(), - request.draft().expected_event_id_hex() - ); - } - - #[test] - fn remote_auth_challenge_is_presented_through_the_canonical_observer() { - let (_, request) = local_request(); - let observer = Arc::new(RecordingObserver(Mutex::new(Vec::new()))); - let request = request.with_progress_observer(observer.clone()); - - report_remote_progress( - &request, - Progress::AuthChallenge { - url: "https://signer.example/approve".to_owned(), - }, - ) - .expect("auth progress"); - - assert_eq!( - observer.0.lock().expect("progress lock").as_slice(), - &[SignProgressStage::AwaitingAuthentication] - ); - } - - #[test] - fn timeout_and_wrong_response_errors_map_to_stable_signing_kinds() { - assert_eq!( - normalize_nip46_error(NostrConnectError::RequestTimedOut).kind(), - SigningErrorKind::SignerTimeout - ); - assert_eq!( - normalize_nip46_error(NostrConnectError::WrongResponseSigner).kind(), - SigningErrorKind::SignerOutputInvalid - ); - } - - #[test] - fn exact_account_or_pubkey_binding_is_required() { - assert!(myc_managed_account_ref_matches( - "account-a", - Some("account-a"), - "aa" - )); - assert!(myc_managed_account_ref_matches( - "aa", - Some("account-a"), - "aa" - )); - assert!(!myc_managed_account_ref_matches( - "account-b", - Some("account-a"), - "aa" - )); - } -} diff --git a/src/runtime/store.rs b/src/runtime/store.rs @@ -1,640 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use radroots_replica_store::export::{ReplicaStoreExportManifestRs, export_manifest}; -use radroots_replica_store::migrations; -use radroots_replica_sync::radroots_replica_sync_status; -use radroots_sdk::storage::{IntegrityStatus, Status as StorageStatus}; -use radroots_sql_core::SqlxSqliteExecutor; -use radroots_storage::{ - backup::{BackupFormatVersion, BackupId, BackupPlan, BackupSecretPolicy, RestorePlan}, - status::{IntegrityHealth, ShutdownState, StorageBackend, StorageOpenMode, WriterPolicy}, -}; -use serde::Serialize; -use serde_json::{Value, json}; - -use crate::cli::global::LocalExportFormatArg; -use crate::runtime::RuntimeError; -use crate::runtime::config::RuntimeConfig; -use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession, sdk_storage_root}; -use crate::runtime::sync::ensure_sync_run_table; -use crate::view::runtime::{ - LocalBackupView, LocalDerivedProjectionStatusView, LocalExportView, LocalInitView, - LocalReplicaCountsView, LocalReplicaSyncView, LocalRestoreView, LocalStatusView, - SdkIntegrityView, SdkStorageStatusView, -}; - -const DERIVED_PROJECTION_SOURCE: &str = "local derived projection cache"; -const SDK_CANONICAL_SOURCE: &str = "SDK canonical event store and outbox"; -const SDK_CANONICAL_STORE: &str = "sdk"; -const SDK_BACKUP_KIND: &str = "sdk_canonical"; -const SDK_BACKUP_MANIFEST_FILE: &str = "manifest.json"; -const SDK_RUNTIME_FILE: &str = "runtime.sqlite"; -const SDK_PRIVATE_FILE: &str = "private.sqlite"; - -pub fn init(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> { - let existed = config.local.replica_store_path.exists(); - ensure_local_roots(config)?; - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - migrations::run_all_up(&executor)?; - ensure_sync_run_table(&executor)?; - let manifest = export_manifest(&executor)?; - - Ok(LocalInitView { - state: if existed { - "ready".to_owned() - } else { - "initialized".to_owned() - }, - source: DERIVED_PROJECTION_SOURCE.to_owned(), - local_root: config.local.root.display().to_string(), - replica_store: "ready".to_owned(), - path: config.local.replica_store_path.display().to_string(), - replica_store_version: manifest.replica_store_version, - backup_format_version: manifest.backup_format_version, - }) -} - -pub fn init_preflight(config: &RuntimeConfig) -> Result<LocalInitView, RuntimeError> { - validate_local_roots(config)?; - if config.local.replica_store_path.exists() { - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - ensure_sync_run_table(&executor)?; - let manifest = export_manifest(&executor)?; - return Ok(LocalInitView { - state: "ready".to_owned(), - source: DERIVED_PROJECTION_SOURCE.to_owned(), - local_root: config.local.root.display().to_string(), - replica_store: "ready".to_owned(), - path: config.local.replica_store_path.display().to_string(), - replica_store_version: manifest.replica_store_version, - backup_format_version: manifest.backup_format_version, - }); - } - - Ok(LocalInitView { - state: "dry_run".to_owned(), - source: DERIVED_PROJECTION_SOURCE.to_owned(), - local_root: config.local.root.display().to_string(), - replica_store: "missing".to_owned(), - path: config.local.replica_store_path.display().to_string(), - replica_store_version: String::new(), - backup_format_version: String::new(), - }) -} - -pub fn status(config: &RuntimeConfig) -> Result<LocalStatusView, CliSdkAdapterError> { - let sdk_root = sdk_storage_root(config); - let sdk_existed_before_open = sdk_storage_files_exist(sdk_root.as_path()); - let derived_projection = derived_projection_status(config)?; - let session = CliSdkSession::connect(config)?; - let receipt = session.block_on(session.sdk().storage_status())?; - let integrity = session.block_on(session.sdk().storage_integrity())?; - Ok(sdk_status_view( - config, - sdk_root, - sdk_existed_before_open, - receipt, - integrity, - derived_projection, - )) -} - -fn derived_projection_status( - config: &RuntimeConfig, -) -> Result<LocalDerivedProjectionStatusView, RuntimeError> { - if !config.local.replica_store_path.exists() { - return Ok(LocalDerivedProjectionStatusView { - state: "unconfigured".to_owned(), - source: DERIVED_PROJECTION_SOURCE.to_owned(), - replica_store: "missing".to_owned(), - path: config.local.replica_store_path.display().to_string(), - replica_store_version: String::new(), - backup_format_version: String::new(), - schema_hash: String::new(), - counts: LocalReplicaCountsView { - farms: 0, - listings: 0, - profiles: 0, - relays: 0, - event_states: 0, - }, - sync: LocalReplicaSyncView { - expected_count: 0, - pending_count: 0, - }, - reason: Some("local replica database is not initialized".to_owned()), - actions: vec!["radroots store inspect".to_owned()], - }); - } - - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - ensure_sync_run_table(&executor)?; - let manifest = export_manifest(&executor)?; - let sync = radroots_replica_sync_status(&executor)?; - - Ok(LocalDerivedProjectionStatusView { - state: "ready".to_owned(), - source: DERIVED_PROJECTION_SOURCE.to_owned(), - replica_store: "ready".to_owned(), - path: config.local.replica_store_path.display().to_string(), - replica_store_version: manifest.replica_store_version.clone(), - backup_format_version: manifest.backup_format_version.clone(), - schema_hash: manifest.schema_hash.clone(), - counts: manifest_counts(&manifest), - sync: LocalReplicaSyncView { - expected_count: sync.expected_count, - pending_count: sync.pending_count, - }, - reason: None, - actions: Vec::new(), - }) -} - -pub fn backup( - config: &RuntimeConfig, - output: &Path, -) -> Result<LocalBackupView, CliSdkAdapterError> { - ensure_safe_sdk_backup_destination(config, output)?; - let session = CliSdkSession::connect(config)?; - let plan = sdk_backup_plan()?; - let operation = session - .block_on(session.sdk().storage_operations()?.begin_backup(plan)) - .map_err(|error| RuntimeError::Config(format!("SDK backup planning failed: {error}")))?; - Ok(LocalBackupView { - state: format!("{:?}", operation.stage()).to_lowercase(), - source: SDK_CANONICAL_SOURCE.to_owned(), - backup_kind: SDK_BACKUP_KIND.to_owned(), - canonical_store: SDK_CANONICAL_STORE.to_owned(), - destination: output.display().to_string(), - file: output.join(SDK_BACKUP_MANIFEST_FILE).display().to_string(), - event_store_file: None, - outbox_file: None, - manifest_file: None, - size_bytes: 0, - manifest: json!({ - "backup_id": hex_bytes(operation.plan().backup_id().as_bytes()), - "format_version": operation.plan().format_version().get(), - "secret_policy": format!("{:?}", operation.plan().secret_policy()).to_lowercase(), - "requested_at_unix_ms": operation.plan().requested_at_unix_ms(), - "revision": operation.revision().get(), - }), - reason: Some( - "backup is durably planned; the host capture worker must complete staged capture, verification, and finalization" - .to_owned(), - ), - actions: vec!["radroots store status".to_owned()], - }) -} - -pub fn backup_preflight( - config: &RuntimeConfig, - output: &Path, -) -> Result<LocalBackupView, CliSdkAdapterError> { - ensure_safe_sdk_backup_destination(config, output)?; - let session = CliSdkSession::connect(config)?; - let status = session.block_on(session.sdk().storage_status())?; - let integrity = session.block_on(session.sdk().storage_integrity())?; - let manifest = sdk_backup_manifest_preview(output, &status, &integrity); - Ok(LocalBackupView { - state: "dry_run".to_owned(), - source: SDK_CANONICAL_SOURCE.to_owned(), - backup_kind: SDK_BACKUP_KIND.to_owned(), - canonical_store: SDK_CANONICAL_STORE.to_owned(), - destination: output.display().to_string(), - file: output.join(SDK_BACKUP_MANIFEST_FILE).display().to_string(), - event_store_file: Some(output.join(SDK_RUNTIME_FILE).display().to_string()), - outbox_file: Some(output.join(SDK_RUNTIME_FILE).display().to_string()), - manifest_file: Some(output.join(SDK_BACKUP_MANIFEST_FILE).display().to_string()), - size_bytes: 0, - manifest, - reason: Some( - "dry run requested; SDK canonical backup directory was not written".to_owned(), - ), - actions: vec!["radroots store backup".to_owned()], - }) -} - -pub fn restore( - config: &RuntimeConfig, - source: &Path, - destination: Option<&Path>, - overwrite: bool, - dry_run: bool, -) -> Result<LocalRestoreView, CliSdkAdapterError> { - let destination = destination - .map(Path::to_path_buf) - .unwrap_or_else(|| sdk_storage_root(config)); - ensure_safe_sdk_restore_destination(config, &destination)?; - let manifest_path = source.join(SDK_BACKUP_MANIFEST_FILE); - let manifest = serde_json::from_slice::<radroots_storage::backup::BackupManifest>(&fs::read( - &manifest_path, - )?) - .map_err(|error| RuntimeError::Config(format!("invalid backup manifest: {error}")))?; - let plan = RestorePlan::new(manifest.clone(), manifest.secret_policy(), unix_ms()?) - .map_err(|error| RuntimeError::Config(format!("invalid restore plan: {error}")))?; - let session = CliSdkSession::connect(config)?; - let operation = session - .block_on(session.sdk().storage_operations()?.begin_restore(plan)) - .map_err(|error| RuntimeError::Config(format!("SDK restore planning failed: {error}")))?; - if !dry_run { - return Err(RuntimeError::Config( - "restore is planned but requires the host staging and atomic replacement worker" - .to_owned(), - ) - .into()); - } - Ok(LocalRestoreView { - state: "dry_run".to_owned(), - source: SDK_CANONICAL_SOURCE.to_owned(), - restore_kind: SDK_BACKUP_KIND.to_owned(), - canonical_store: SDK_CANONICAL_STORE.to_owned(), - backup_source: source.display().to_string(), - destination: destination.display().to_string(), - event_store_file: source.join(SDK_RUNTIME_FILE).display().to_string(), - outbox_file: source.join(SDK_RUNTIME_FILE).display().to_string(), - manifest_file: manifest_path.display().to_string(), - destination_event_store_file: Some( - destination.join(SDK_RUNTIME_FILE).display().to_string(), - ), - destination_outbox_file: Some(destination.join(SDK_RUNTIME_FILE).display().to_string()), - restored_event_store_file: None, - restored_outbox_file: None, - manifest: json_value(&manifest)?, - verification: json!({ - "stage": format!("{:?}", operation.stage()).to_lowercase(), - "revision": operation.revision().get(), - }), - overwrite, - dry_run, - reason: Some("dry run requested; restore was validated and not staged".to_owned()), - actions: vec!["radroots store restore <backup-dir>".to_owned()], - }) -} - -pub fn export( - config: &RuntimeConfig, - format: LocalExportFormatArg, - output: &Path, -) -> Result<LocalExportView, RuntimeError> { - if !config.local.replica_store_path.exists() { - return Ok(LocalExportView { - state: "unconfigured".to_owned(), - source: DERIVED_PROJECTION_SOURCE.to_owned(), - format: format.as_str().to_owned(), - file: output.display().to_string(), - records: 0, - export_version: String::new(), - schema_hash: String::new(), - reason: Some("local replica database is not initialized".to_owned()), - actions: vec!["radroots store inspect".to_owned()], - }); - } - - ensure_safe_output_path(config, output)?; - create_parent_dir(output)?; - - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - let manifest = export_manifest(&executor)?; - let sync = radroots_replica_sync_status(&executor)?; - let records = match format { - LocalExportFormatArg::Json => { - let export = json!({ - "kind": "local_export_manifest_v1", - "source": DERIVED_PROJECTION_SOURCE, - "replica_store_version": manifest.replica_store_version, - "backup_format_version": manifest.backup_format_version, - "export_version": manifest.export_version, - "schema_hash": manifest.schema_hash, - "sync": { - "expected_count": sync.expected_count, - "pending_count": sync.pending_count, - }, - "table_counts": manifest.table_counts, - }); - fs::write(output, serde_json::to_string_pretty(&export)?)?; - 1 - } - LocalExportFormatArg::Ndjson => { - let mut lines = Vec::new(); - lines.push( - json!({ - "kind": "local_export_manifest", - "source": DERIVED_PROJECTION_SOURCE, - "replica_store_version": manifest.replica_store_version, - "backup_format_version": manifest.backup_format_version, - "export_version": manifest.export_version, - "schema_hash": manifest.schema_hash, - }) - .to_string(), - ); - lines.push( - json!({ - "kind": "local_sync_status", - "expected_count": sync.expected_count, - "pending_count": sync.pending_count, - }) - .to_string(), - ); - for table in &manifest.table_counts { - lines.push( - json!({ - "kind": "local_table_count", - "table": table.name, - "row_count": table.row_count, - }) - .to_string(), - ); - } - fs::write(output, format!("{}\n", lines.join("\n")))?; - lines.len() - } - }; - - Ok(LocalExportView { - state: "exported".to_owned(), - source: DERIVED_PROJECTION_SOURCE.to_owned(), - format: format.as_str().to_owned(), - file: output.display().to_string(), - records, - export_version: manifest.export_version, - schema_hash: manifest.schema_hash, - reason: None, - actions: Vec::new(), - }) -} - -fn ensure_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> { - fs::create_dir_all(&config.local.root)?; - fs::create_dir_all(&config.local.backups_dir)?; - fs::create_dir_all(&config.local.exports_dir)?; - Ok(()) -} - -fn validate_local_roots(config: &RuntimeConfig) -> Result<(), RuntimeError> { - validate_directory_target(&config.local.root)?; - validate_directory_target(&config.local.backups_dir)?; - validate_directory_target(&config.local.exports_dir)?; - Ok(()) -} - -fn validate_directory_target(path: &Path) -> Result<(), RuntimeError> { - let mut candidate = path.to_path_buf(); - loop { - if candidate.exists() { - if candidate.is_dir() { - return Ok(()); - } - return Err(RuntimeError::Config(format!( - "path {} is not a directory", - candidate.display() - ))); - } - if !candidate.pop() { - return Err(RuntimeError::Config(format!( - "path {} has no existing parent directory", - path.display() - ))); - } - } -} - -fn sdk_storage_files_exist(sdk_root: &Path) -> bool { - sdk_root.join(SDK_RUNTIME_FILE).exists() && sdk_root.join(SDK_PRIVATE_FILE).exists() -} - -fn sdk_status_view( - config: &RuntimeConfig, - sdk_root: PathBuf, - sdk_existed_before_open: bool, - status: StorageStatus, - integrity: IntegrityStatus, - derived_projection: LocalDerivedProjectionStatusView, -) -> LocalStatusView { - let state = if integrity.health() == IntegrityHealth::Healthy { - "ready" - } else { - "needs_attention" - }; - LocalStatusView { - state: state.to_owned(), - source: SDK_CANONICAL_SOURCE.to_owned(), - local_root: config.local.root.display().to_string(), - canonical_store: SDK_CANONICAL_STORE.to_owned(), - sdk_storage: storage_backend_label(status.backend()).to_owned(), - sdk_root: sdk_root.display().to_string(), - sdk_existed_before_open, - storage: SdkStorageStatusView { - backend: storage_backend_label(status.backend()).to_owned(), - open_mode: storage_open_mode_label(status.open_mode()).to_owned(), - writer_policy: writer_policy_label(status.writer_policy()).to_owned(), - shutdown: shutdown_state_label(status.shutdown()).to_owned(), - wal_enabled: status.wal_enabled(), - busy_timeout_ms: status.busy_timeout_ms(), - }, - integrity: SdkIntegrityView { - health: integrity_health_label(integrity.health()).to_owned(), - checked_at_unix_ms: integrity.checked_at_unix_ms(), - verified_members: integrity.verified_members(), - failed_members: integrity.failed_members(), - }, - derived_projection, - reason: (state != "ready") - .then(|| "SDK canonical store integrity requires attention".to_owned()), - actions: if state == "ready" { - Vec::new() - } else { - vec!["radroots store inspect".to_owned()] - }, - } -} - -fn storage_backend_label(value: StorageBackend) -> &'static str { - match value { - StorageBackend::Memory => "memory", - StorageBackend::Sqlite => "sqlite", - } -} - -fn storage_open_mode_label(value: StorageOpenMode) -> &'static str { - match value { - StorageOpenMode::ReadOnly => "read_only", - StorageOpenMode::ReadWriteExisting => "read_write_existing", - StorageOpenMode::Create => "create", - } -} - -fn writer_policy_label(value: WriterPolicy) -> &'static str { - match value { - WriterPolicy::NoWriter => "no_writer", - WriterPolicy::AdvisoryProcessLock => "advisory_process_lock", - } -} - -fn shutdown_state_label(value: ShutdownState) -> &'static str { - match value { - ShutdownState::Open => "open", - ShutdownState::Closing => "closing", - ShutdownState::Closed => "closed", - } -} - -fn integrity_health_label(value: IntegrityHealth) -> &'static str { - match value { - IntegrityHealth::Healthy => "healthy", - IntegrityHealth::Degraded => "degraded", - IntegrityHealth::Corrupt => "corrupt", - IntegrityHealth::Unknown => "unknown", - } -} - -fn sdk_backup_manifest_preview( - output: &Path, - status: &StorageStatus, - integrity: &IntegrityStatus, -) -> Value { - json!({ - "manifest_kind": "sdk_canonical_backup_preview", - "destination": output.display().to_string(), - "backup_paths": { - "runtime_path": output.join(SDK_RUNTIME_FILE).display().to_string(), - "private_path": output.join(SDK_PRIVATE_FILE).display().to_string(), - }, - "source_status": status, - "integrity": integrity, - }) -} - -fn ensure_safe_sdk_backup_destination( - config: &RuntimeConfig, - output: &Path, -) -> Result<(), RuntimeError> { - let sdk_root = sdk_storage_root(config); - let sdk_runtime_path = sdk_root.join(SDK_RUNTIME_FILE); - let sdk_private_path = sdk_root.join(SDK_PRIVATE_FILE); - let forbidden_paths = [ - sdk_root.as_path(), - config.local.replica_store_path.as_path(), - sdk_runtime_path.as_path(), - sdk_private_path.as_path(), - ]; - if forbidden_paths.contains(&output) { - return Err(RuntimeError::Config(format!( - "backup destination {} would overwrite canonical or derived projection store data", - output.display() - ))); - } - if output.starts_with(sdk_root.as_path()) { - return Err(RuntimeError::Config(format!( - "backup destination {} must not be inside the SDK canonical store directory", - output.display() - ))); - } - Ok(()) -} - -fn ensure_safe_sdk_restore_destination( - config: &RuntimeConfig, - destination: &Path, -) -> Result<(), RuntimeError> { - let sdk_root = sdk_storage_root(config); - let sdk_runtime_path = sdk_root.join(SDK_RUNTIME_FILE); - let sdk_private_path = sdk_root.join(SDK_PRIVATE_FILE); - let forbidden_paths = [ - config.local.root.as_path(), - config.local.replica_store_path.as_path(), - sdk_runtime_path.as_path(), - sdk_private_path.as_path(), - ]; - if forbidden_paths.contains(&destination) { - return Err(RuntimeError::Config(format!( - "restore destination {} would overwrite canonical runtime roots or store files", - destination.display() - ))); - } - if config.local.replica_store_path.starts_with(destination) - || config.local.backups_dir.starts_with(destination) - || config.local.exports_dir.starts_with(destination) - { - return Err(RuntimeError::Config(format!( - "restore destination {} must not contain CLI runtime state directories", - destination.display() - ))); - } - Ok(()) -} - -fn json_value(value: impl Serialize) -> Result<Value, RuntimeError> { - serde_json::to_value(value).map_err(RuntimeError::from) -} - -fn sdk_backup_plan() -> Result<BackupPlan, RuntimeError> { - let mut bytes = [0_u8; 16]; - getrandom::getrandom(&mut bytes) - .map_err(|error| RuntimeError::Config(format!("failed to generate backup ID: {error}")))?; - let backup_id = BackupId::new(bytes) - .map_err(|error| RuntimeError::Config(format!("invalid backup ID: {error}")))?; - BackupPlan::new( - backup_id, - BackupFormatVersion::V1, - BackupSecretPolicy::ExcludeProtectedStorage, - unix_ms()?, - ) - .map_err(|error| RuntimeError::Config(format!("invalid backup plan: {error}"))) -} - -fn unix_ms() -> Result<u64, RuntimeError> { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|error| RuntimeError::Config(format!("system clock error: {error}")))? - .as_millis() - .try_into() - .map_err(|_| RuntimeError::Config("system clock is outside SDK range".to_owned())) -} - -fn hex_bytes(bytes: &[u8]) -> String { - const HEX: &[u8; 16] = b"0123456789abcdef"; - let mut encoded = String::with_capacity(bytes.len() * 2); - for byte in bytes { - encoded.push(char::from(HEX[usize::from(byte >> 4)])); - encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); - } - encoded -} - -fn create_parent_dir(path: &Path) -> Result<(), RuntimeError> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - Ok(()) -} - -fn ensure_safe_output_path(config: &RuntimeConfig, output: &Path) -> Result<(), RuntimeError> { - if output == config.local.replica_store_path.as_path() { - return Err(RuntimeError::Config(format!( - "output path {} would overwrite the local replica database", - output.display() - ))); - } - Ok(()) -} - -fn manifest_counts(manifest: &ReplicaStoreExportManifestRs) -> LocalReplicaCountsView { - LocalReplicaCountsView { - farms: table_row_count(manifest, "farm"), - listings: table_row_count(manifest, "trade_product"), - profiles: table_row_count(manifest, "nostr_profile"), - relays: table_row_count(manifest, "nostr"), - event_states: table_row_count(manifest, "nostr_event_state"), - } -} - -fn table_row_count(manifest: &ReplicaStoreExportManifestRs, name: &str) -> u64 { - manifest - .table_counts - .iter() - .find(|table| table.name == name) - .map(|table| table.row_count) - .unwrap_or(0) -} diff --git a/src/runtime/sync.rs b/src/runtime/sync.rs @@ -1,655 +0,0 @@ -//! CLI scheduling and presentation over the canonical shared sync engine. - -use std::{ - thread, - time::{Duration, SystemTime, UNIX_EPOCH}, -}; - -use crate::{ - cli::global::SyncWatchArgs, - runtime::{ - RuntimeError, - config::{RuntimeConfig, TransportProfileKind}, - sdk::{CliSdkAdapterError, CliSdkSession, sync_targets}, - }, - view::runtime::{ - SyncActionView, SyncFreshnessView, SyncQueueView, SyncStatusView, - SyncTransportStatusInspectView, SyncTransportTargetView, SyncWatchFrameView, SyncWatchView, - TransportOperationCapabilitiesView, TransportTargetFailureView, - }, -}; -use radroots_replica_store::ReplicaSql; -#[cfg(test)] -use radroots_replica_store::migrations; -use radroots_sql_core::{SqlExecutor, SqlxSqliteExecutor}; -use radroots_storage::outbox::LeaseOwner; -use radroots_sync::{ - PullRequest, SyncStatus, - ingest::RegistryPolicy, - policy::SyncId, - pull::PullTermination, - push::{DeliveryRunReceipt, DeliveryRunRequest}, -}; -use radroots_transport::{ - SinkStatus, SourceStatus, Target, - capability::{Availability, Maturity}, - outcome::FetchTargetState, -}; - -const SDK_SYNC_SOURCE: &str = "canonical SDK sync engine"; -const MARKET_FRESHNESS_STALE_AFTER_SECONDS: u64 = 15 * 60; -const SYNC_PULL_FRESHNESS_STALE_AFTER_SECONDS: u64 = 30 * 60; -const PULL_PAGE_LIMIT: u16 = 1_000; -const PULL_MAX_PAGES: u16 = 5; -const DELIVERY_LIMIT: u16 = 1_000; -const DELIVERY_LEASE_MS: u64 = 30_000; - -pub fn status(config: &RuntimeConfig) -> Result<SyncStatusView, CliSdkAdapterError> { - let session = CliSdkSession::connect(config)?; - let receipt = canonical_status(&session)?; - Ok(status_view(config, &receipt)) -} - -pub fn pull(config: &RuntimeConfig) -> Result<SyncActionView, RuntimeError> { - pull_for_scope(config, RelayIngestScope::SyncPull) -} - -pub fn market_refresh(config: &RuntimeConfig) -> Result<SyncActionView, RuntimeError> { - pull_for_scope(config, RelayIngestScope::MarketPull) -} - -fn pull_for_scope( - config: &RuntimeConfig, - scope: RelayIngestScope, -) -> Result<SyncActionView, RuntimeError> { - let session = CliSdkSession::connect(config).map_err(adapter_runtime_error)?; - let before = canonical_status(&session).map_err(adapter_runtime_error)?; - if !nostr_targets_configured(config) { - return Ok(empty_action( - config, - &before, - "pull", - "unconfigured", - Some("sync pull requires at least one configured Nostr relay".to_owned()), - )); - } - if config.output.dry_run { - let mut view = empty_action( - config, - &before, - "pull", - "dry_run", - Some("dry run requested; canonical sync pull skipped".to_owned()), - ); - view.target_transport_endpoints = config.transport.nostr_relay_urls.clone(); - view.fetched_count = Some(0); - view.ingested_count = Some(0); - view.skipped_count = Some(0); - view.unsupported_count = Some(0); - view.failed_count = Some(0); - view.reason_code = Some("dry_run".to_owned()); - return Ok(view); - } - - let targets = sync_targets(config).map_err(|error| RuntimeError::Config(error.to_string()))?; - let request = PullRequest::new(targets.clone(), PULL_PAGE_LIMIT, PULL_MAX_PAGES) - .map_err(|error| RuntimeError::Config(error.to_string()))?; - let operations = session - .sdk() - .sync() - .map_err(CliSdkAdapterError::from) - .map_err(adapter_runtime_error)? - .ok_or_else(|| { - RuntimeError::Config("canonical sync engine is not configured".to_owned()) - })?; - let receipt = session - .block_on(operations.pull(request, &RegistryPolicy::verified())) - .map_err(|error| adapter_runtime_error(error.into()))?; - let after = canonical_status(&session).map_err(adapter_runtime_error)?; - let ingested = receipt - .ingest_outcomes() - .iter() - .filter(|item| item.is_ok()) - .count(); - let ingest_failed = receipt.ingest_outcomes().len().saturating_sub(ingested); - let mut failed_targets = Vec::new(); - for outcome in receipt.target_outcomes() { - if matches!(outcome.state(), FetchTargetState::Complete) { - continue; - } - let target = targets - .targets() - .iter() - .find(|target| target.fingerprint() == outcome.target()); - failed_targets.push(TransportTargetFailureView { - transport_kind: target - .map_or("nostr", |target| target.kind().as_str()) - .to_owned(), - endpoint_uri: target.map_or_else( - || outcome.target().as_str().to_owned(), - |target| target.uri().as_str().to_owned(), - ), - target_scope: target - .and_then(|target| target.scope()) - .map(|value| value.as_str().to_owned()), - target_label: target - .and_then(|target| target.label()) - .map(|value| value.as_str().to_owned()), - transport_outcome_kind: Some(fetch_state_label(outcome.state()).to_owned()), - reason: outcome - .message() - .unwrap_or(fetch_state_label(outcome.state())) - .to_owned(), - }); - } - let failed_count = ingest_failed + failed_targets.len(); - let state = if receipt.termination() == PullTermination::SourceFailed { - "unavailable" - } else if failed_count > 0 || receipt.termination() != PullTermination::Complete { - "partial" - } else { - "ready" - }; - let reason = (state != "ready").then(|| { - format!( - "canonical pull terminated as {} with {failed_count} failed outcome(s)", - pull_termination_label(receipt.termination()) - ) - }); - let mut view = empty_action(config, &after, "pull", state, reason); - view.target_transport_endpoints = config.transport.nostr_relay_urls.clone(); - view.attempted_transport_endpoints = config.transport.nostr_relay_urls.clone(); - view.accepted_transport_endpoints = config - .transport - .nostr_relay_urls - .iter() - .filter(|endpoint| { - !failed_targets - .iter() - .any(|failure| &failure.endpoint_uri == *endpoint) - }) - .cloned() - .collect(); - view.failed_transport_targets = failed_targets; - view.fetched_count = Some(receipt.events_observed()); - view.ingested_count = Some(ingested); - view.skipped_count = Some(0); - view.unsupported_count = Some(0); - view.failed_count = Some(failed_count); - view.reason_code = - (state != "ready").then(|| pull_termination_label(receipt.termination()).to_owned()); - view.actions = vec![scope.ready_action().to_owned()]; - Ok(view) -} - -pub fn push(config: &RuntimeConfig) -> Result<SyncActionView, CliSdkAdapterError> { - let session = CliSdkSession::connect(config)?; - let before = canonical_status(&session)?; - if config.output.dry_run { - return Ok(empty_action( - config, - &before, - "push", - "dry_run", - Some("dry run requested; canonical outbox delivery skipped".to_owned()), - )); - } - let receipt = deliver_pending(&session)?; - let after = canonical_status(&session)?; - Ok(delivery_action(config, &after, &receipt)) -} - -pub fn watch(config: &RuntimeConfig, args: &SyncWatchArgs) -> Result<SyncWatchView, RuntimeError> { - if args.frames == 0 { - return Err(RuntimeError::Config( - "`sync watch --frames` must be greater than 0".to_owned(), - )); - } - let mut frames = Vec::with_capacity(args.frames); - let mut final_view = None; - for index in 0..args.frames { - let view = status(config).map_err(adapter_runtime_error)?; - frames.push(SyncWatchFrameView { - sequence: index + 1, - observed_at: unix_now(), - state: view.state.clone(), - configured_transport_target_count: view.configured_transport_target_count, - freshness: view.freshness.clone(), - queue: view.queue.clone(), - }); - final_view = Some(view); - if index + 1 < args.frames { - thread::sleep(Duration::from_millis(args.interval_ms)); - } - } - let view = final_view - .ok_or_else(|| RuntimeError::Config("sync watch produced no frames".to_owned()))?; - Ok(SyncWatchView { - state: view.state, - source: view.source, - interval_ms: args.interval_ms, - frames, - reason: view.reason, - actions: view.actions, - }) -} - -pub(crate) fn canonical_status(session: &CliSdkSession) -> Result<SyncStatus, CliSdkAdapterError> { - let operations = session.sdk().sync()?.ok_or_else(|| { - RuntimeError::Config("canonical sync engine is not configured".to_owned()) - })?; - Ok(session.block_on(operations.status(&[]))?) -} - -pub(crate) fn deliver_pending( - session: &CliSdkSession, -) -> Result<DeliveryRunReceipt, CliSdkAdapterError> { - let mut seed = [0_u8; 16]; - getrandom::getrandom(&mut seed).map_err(|error| { - RuntimeError::Config(format!( - "failed to generate delivery lease identity: {error}" - )) - })?; - let owner = LeaseOwner::parse(format!("radroots-cli-{}", std::process::id())) - .map_err(|error| RuntimeError::Config(error.to_string()))?; - let request = - DeliveryRunRequest::new(owner, SyncId::new(seed)?, DELIVERY_LEASE_MS, DELIVERY_LIMIT)?; - let operations = session.sdk().sync()?.ok_or_else(|| { - RuntimeError::Config("canonical sync engine is not configured".to_owned()) - })?; - Ok(session.block_on(operations.deliver_pending(request))?) -} - -fn status_view(config: &RuntimeConfig, status: &SyncStatus) -> SyncStatusView { - let configured_targets = configured_targets(config); - let state = sync_health_label(status); - SyncStatusView { - state: state.to_owned(), - source: SDK_SYNC_SOURCE.to_owned(), - local_root: config.local.root.display().to_string(), - replica_store: "canonical".to_owned(), - configured_transport_target_count: configured_targets.len(), - configured_transport_targets: configured_targets, - transport_statuses: transport_status_views(status), - publish_policy: "canonical satisfaction policy".to_owned(), - freshness: missing_freshness(), - queue: queue_view(status), - reason: (!nostr_targets_configured(config)) - .then(|| "no Nostr relay targets are configured".to_owned()), - actions: if !nostr_targets_configured(config) { - vec!["radroots transport config update --kind nostr --nostr-relay wss://relay.example.com".to_owned()] - } else { - vec![ - "radroots sync pull".to_owned(), - "radroots sync push".to_owned(), - ] - }, - } -} - -fn empty_action( - config: &RuntimeConfig, - status: &SyncStatus, - direction: &str, - state: &str, - reason: Option<String>, -) -> SyncActionView { - let snapshot = status_view(config, status); - SyncActionView { - direction: direction.to_owned(), - state: state.to_owned(), - source: SDK_SYNC_SOURCE.to_owned(), - local_root: snapshot.local_root, - replica_store: snapshot.replica_store, - configured_transport_target_count: snapshot.configured_transport_target_count, - configured_transport_targets: snapshot.configured_transport_targets, - transport_statuses: snapshot.transport_statuses, - publish_policy: snapshot.publish_policy, - freshness: snapshot.freshness, - queue: snapshot.queue, - target_transport_endpoints: Vec::new(), - attempted_transport_endpoints: Vec::new(), - accepted_transport_endpoints: Vec::new(), - failed_transport_targets: Vec::new(), - fetched_count: None, - ingested_count: None, - publishable_count: None, - published_count: None, - skipped_count: None, - unsupported_count: None, - failed_count: None, - publish_plan: None, - reason_code: None, - reason, - actions: snapshot.actions, - } -} - -fn delivery_action( - config: &RuntimeConfig, - status: &SyncStatus, - receipt: &DeliveryRunReceipt, -) -> SyncActionView { - let attempted = receipt.outcomes().len(); - let succeeded = receipt.succeeded(); - let failed = receipt.failed(); - let state = if attempted == 0 { - "ready" - } else if failed == 0 { - "published" - } else if succeeded > 0 { - "partial" - } else { - "unavailable" - }; - let mut view = empty_action( - config, - status, - "push", - state, - (failed > 0).then(|| format!("{failed} canonical delivery outcome(s) failed")), - ); - view.target_transport_endpoints = config.transport.nostr_relay_urls.clone(); - view.attempted_transport_endpoints = if attempted > 0 { - config.transport.nostr_relay_urls.clone() - } else { - Vec::new() - }; - view.accepted_transport_endpoints = if succeeded > 0 { - config.transport.nostr_relay_urls.clone() - } else { - Vec::new() - }; - view.publishable_count = Some(attempted); - view.published_count = Some(succeeded); - view.failed_count = Some(failed); - view.reason_code = (failed > 0).then(|| "delivery_partial_failure".to_owned()); - view.actions = vec!["radroots sync status".to_owned()]; - view -} - -fn queue_view(status: &SyncStatus) -> SyncQueueView { - let outbox = status.outbox(); - let total = outbox.total().and_then(|value| usize::try_from(value).ok()); - SyncQueueView { - expected_count: total.unwrap_or_default(), - pending_count: usize::try_from(outbox.pending + outbox.leased + outbox.retryable) - .unwrap_or(usize::MAX), - total_count: total, - retryable_count: usize::try_from(outbox.retryable).ok(), - terminal_count: usize::try_from(outbox.satisfied + outbox.exhausted).ok(), - failed_terminal_count: usize::try_from(outbox.exhausted).ok(), - deferred_until_implemented_count: Some(0), - ready_signed_count: usize::try_from(outbox.pending + outbox.retryable).ok(), - publishing_count: usize::try_from(outbox.leased).ok(), - last_attempt_at_ms: None, - last_error: None, - } -} - -fn configured_targets(config: &RuntimeConfig) -> Vec<SyncTransportTargetView> { - config - .transport - .nostr_relay_urls - .iter() - .filter_map(|endpoint| Target::nostr_relay(endpoint).ok()) - .map(|target| SyncTransportTargetView { - transport_kind: target.kind().as_str().to_owned(), - endpoint_uri: target.uri().as_str().to_owned(), - endpoint_fingerprint: target.fingerprint().as_str().to_owned(), - target_scope: target.scope().map(|scope| scope.as_str().to_owned()), - target_label: target.label().map(|label| label.as_str().to_owned()), - }) - .collect() -} - -fn transport_status_views(status: &SyncStatus) -> Vec<SyncTransportStatusInspectView> { - let mut views = Vec::new(); - if let Some(source) = status.source().status() { - views.push(source_status_view(source)); - } - if let Some(sink) = status.sink().status() - && !views - .iter() - .any(|view| view.transport == sink.transport_id().as_str()) - { - views.push(sink_status_view(sink)); - } - views -} - -fn source_status_view(status: &SourceStatus) -> SyncTransportStatusInspectView { - SyncTransportStatusInspectView { - transport: status.transport_id().as_str().to_owned(), - profile_id: None, - endpoint_uri: None, - configured: status.is_configured(), - implementation: "real".to_owned(), - maturity: maturity_label(status.maturity()).to_owned(), - availability: availability_label(status.availability()).to_owned(), - usable_for_delivery: false, - capabilities: TransportOperationCapabilitiesView { - deliver: false, - fetch: status.capabilities().can_fetch(), - }, - message: status.message().to_owned(), - } -} - -fn sink_status_view(status: &SinkStatus) -> SyncTransportStatusInspectView { - SyncTransportStatusInspectView { - transport: status.transport_id().as_str().to_owned(), - profile_id: None, - endpoint_uri: None, - configured: status.is_configured(), - implementation: "real".to_owned(), - maturity: maturity_label(status.maturity()).to_owned(), - availability: availability_label(status.availability()).to_owned(), - usable_for_delivery: status.capabilities().can_deliver() - && status.availability() != Availability::Unavailable, - capabilities: TransportOperationCapabilitiesView { - deliver: status.capabilities().can_deliver(), - fetch: false, - }, - message: status.message().to_owned(), - } -} - -fn sync_health_label(status: &SyncStatus) -> &'static str { - match status.health() { - radroots_protocol::runtime::v1::SyncHealth::Healthy => "ready", - radroots_protocol::runtime::v1::SyncHealth::Degraded => "degraded", - radroots_protocol::runtime::v1::SyncHealth::Unavailable => "unavailable", - } -} - -fn maturity_label(value: Maturity) -> &'static str { - match value { - Maturity::Experimental => "experimental", - Maturity::Preview => "preview", - Maturity::Stable => "stable", - } -} - -fn availability_label(value: Availability) -> &'static str { - match value { - Availability::Available => "available", - Availability::Degraded => "degraded", - Availability::Unavailable => "unavailable", - } -} - -fn fetch_state_label(value: FetchTargetState) -> &'static str { - match value { - FetchTargetState::Complete => "complete", - FetchTargetState::Partial => "partial", - FetchTargetState::Unavailable => "unavailable", - FetchTargetState::FailedRetryable => "failed_retryable", - FetchTargetState::FailedTerminal => "failed_terminal", - FetchTargetState::Cancelled => "cancelled", - } -} - -fn pull_termination_label(value: PullTermination) -> &'static str { - match value { - PullTermination::Complete => "complete", - PullTermination::PageLimit => "page_limit", - PullTermination::Deadline => "deadline", - PullTermination::Cancelled => "cancelled", - PullTermination::SourceFailed => "source_failed", - } -} - -fn nostr_targets_configured(config: &RuntimeConfig) -> bool { - matches!( - config.transport.profile, - TransportProfileKind::Nostr | TransportProfileKind::MultiTarget - ) && !config.transport.nostr_relay_urls.is_empty() -} - -fn adapter_runtime_error(error: CliSdkAdapterError) -> RuntimeError { - RuntimeError::Network(error.to_string()) -} - -pub(crate) fn missing_freshness() -> SyncFreshnessView { - SyncFreshnessView { - state: "never".to_owned(), - display: "never synced".to_owned(), - age_seconds: None, - last_event_at: None, - run: None, - } -} - -#[cfg(test)] -pub(crate) fn freshness_for_scope( - config: &RuntimeConfig, - scope: RelayIngestScope, -) -> Result<SyncFreshnessView, RuntimeError> { - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - migrations::run_all_up(&executor)?; - freshness_for_scope_from_executor(config, &executor, scope) -} - -#[cfg(test)] -pub(crate) fn relay_provenance_relays_for_scope( - _config: &RuntimeConfig, - _scope: RelayIngestScope, -) -> Result<Vec<String>, RuntimeError> { - Ok(Vec::new()) -} - -pub(crate) fn freshness_for_scope_from_executor( - _config: &RuntimeConfig, - executor: &SqlxSqliteExecutor, - scope: RelayIngestScope, -) -> Result<SyncFreshnessView, RuntimeError> { - let last_event_at = ReplicaSql::new(executor).nostr_event_last_created_at()?; - let age_seconds = last_event_at.map(|last| unix_now().saturating_sub(last)); - let state = match age_seconds { - None => "never", - Some(age) if age > scope.stale_after_seconds() => "stale", - Some(_) => "fresh", - }; - Ok(SyncFreshnessView { - state: state.to_owned(), - display: match age_seconds { - Some(age) => format!("{} {state} {}s ago", scope.display(), age), - None => format!("{} never synced", scope.display()), - }, - age_seconds, - last_event_at, - run: None, - }) -} - -pub(crate) fn freshness_requires_refresh(freshness: &SyncFreshnessView) -> bool { - matches!( - freshness.state.as_str(), - "never" | "stale" | "relay_set_changed" | "refresh_failed" - ) -} - -pub(crate) fn ensure_sync_run_table(executor: &SqlxSqliteExecutor) -> Result<(), RuntimeError> { - executor.exec( - "CREATE TABLE IF NOT EXISTS radroots_cli_sync_run ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scope TEXT NOT NULL, - relay_set_fingerprint TEXT NOT NULL, - target_transport_endpoints_json TEXT NOT NULL, - attempted_transport_endpoints_json TEXT NOT NULL, - failed_transport_targets_json TEXT NOT NULL, - started_at INTEGER NOT NULL, - completed_at INTEGER, - state TEXT NOT NULL, - fetched_count INTEGER NOT NULL, - ingested_count INTEGER NOT NULL, - skipped_count INTEGER NOT NULL, - unsupported_count INTEGER NOT NULL, - failed_count INTEGER NOT NULL, - failure_reason TEXT - ); - CREATE INDEX IF NOT EXISTS idx_radroots_cli_sync_run_scope_started - ON radroots_cli_sync_run(scope, started_at DESC);", - "[]", - )?; - Ok(()) -} - -#[derive(Debug, Clone, Copy)] -pub(crate) enum RelayIngestScope { - SyncPull, - MarketPull, -} - -impl RelayIngestScope { - fn display(self) -> &'static str { - match self { - Self::SyncPull => "sync pull", - Self::MarketPull => "market refresh", - } - } - - fn stale_after_seconds(self) -> u64 { - match self { - Self::SyncPull => SYNC_PULL_FRESHNESS_STALE_AFTER_SECONDS, - Self::MarketPull => MARKET_FRESHNESS_STALE_AFTER_SECONDS, - } - } - - fn ready_action(self) -> &'static str { - match self { - Self::SyncPull => "radroots market search eggs", - Self::MarketPull => "radroots market search eggs", - } - } -} - -fn unix_now() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_or(0, |duration| duration.as_secs()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn receipt_labels_cover_partial_failure_and_cancellation() { - assert_eq!( - pull_termination_label(PullTermination::Cancelled), - "cancelled" - ); - assert_eq!( - fetch_state_label(FetchTargetState::FailedRetryable), - "failed_retryable" - ); - assert_eq!(fetch_state_label(FetchTargetState::Partial), "partial"); - } - - #[test] - fn missing_freshness_requires_a_pull() { - assert!(freshness_requires_refresh(&missing_freshness())); - } -} diff --git a/src/runtime/trade.rs b/src/runtime/trade.rs @@ -1,985 +0,0 @@ -use std::fs; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::{SystemTime, UNIX_EPOCH}; - -use radroots_core::unit::convert_unit_decimal; -use radroots_core::{Currency, Decimal, Unit}; -use radroots_event::contract::AuthorRole; -use radroots_event::id::{ - CandidateId, ClassifiedListingAddress, DTag, InventoryBinId, MutationId, TradeId, -}; -use radroots_event::trade::{ - FulfillmentProfileV1, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, RADROOTS_TRADE_SCHEMA_VERSION, - TradeCancellationProfileV1, TradeCandidateLineV1, TradeCandidateTermsV1, - TradeEconomicsProfileV1, TradeMutationBodyV1, TradeMutationEnvelopeV1, - canonical_trade_mutation_content, -}; -use radroots_identity::PublicKey; -use radroots_replica_schema::nostr_event_head::{ - INostrEventHeadFindOne, INostrEventHeadFindOneArgs, NostrEventHeadQueryBindValues, -}; -use radroots_replica_schema::trade_product::{ITradeProductFieldsFilter, ITradeProductFindMany}; -use radroots_replica_store::{ReplicaSql, nostr_event_head, trade_product}; -use radroots_sdk::trade::{self as sdk_trade, Plan as TradePlan}; -use radroots_signing::{Actor, actor::ActorSource}; -use radroots_sql_core::SqlxSqliteExecutor; -use serde::Serialize; - -use crate::runtime::RuntimeError; -use crate::runtime::account; -use crate::runtime::config::RuntimeConfig; -use crate::runtime::sdk::{CliSdkAdapterError, validate_configured_signer_for_actor}; - -const TRADE_CANDIDATE_DRAFTS_DIR: &str = "trades/candidates"; -const TRADE_CANDIDATE_DRAFT_SOURCE: &str = "SDK trade proposal candidate draft"; -const DEFAULT_PROPOSAL_TTL_SECONDS: u64 = 7 * 24 * 60 * 60; -const DEFAULT_FULFILLMENT_START_OFFSET_SECONDS: u64 = 24 * 60 * 60; -const DEFAULT_FULFILLMENT_WINDOW_SECONDS: u64 = 2 * 60 * 60; - -static TRADE_COUNTER: AtomicU64 = AtomicU64::new(0); - -#[derive(Debug, Clone)] -pub struct TradeEnvelopeFileRuntimeArgs { - pub file: PathBuf, - pub idempotency_key: Option<String>, - pub acknowledge_private_terms: bool, - pub operation_kind: Option<String>, -} - -#[derive(Debug, Clone)] -pub struct TradeIdRuntimeArgs { - pub trade_id: String, -} - -#[derive(Debug, Clone)] -pub struct TradePageRuntimeArgs { - pub limit: Option<u32>, - pub cursor: Option<String>, -} - -#[derive(Debug, Clone)] -pub struct TradeEvidenceInspectRuntimeArgs { - pub trade_id: String, - pub limit: Option<u32>, - pub cursor: Option<String>, -} - -#[derive(Debug, Clone)] -pub struct TradePrivateArtifactSealRuntimeArgs { - pub trade_id: String, - pub artifact_id: String, - pub schema_id: String, - pub input: PathBuf, - pub kind: String, - pub candidate_id: Option<String>, - pub retention_class: Option<String>, - pub expires_at_ms: Option<i64>, -} - -#[derive(Debug, Clone)] -pub struct TradePrivateArtifactOpenRuntimeArgs { - pub artifact_id: String, - pub output: PathBuf, -} - -#[derive(Debug, Clone)] -pub struct TradePrivateArtifactDeleteRuntimeArgs { - pub artifact_id: String, -} - -#[derive(Debug, Clone)] -pub struct TradeCandidateDraftCreateArgs { - pub listing: Option<String>, - pub listing_addr: Option<String>, - pub bin_id: String, - pub quantity: u32, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TradeCandidateDraftView { - pub state: String, - pub source: String, - pub trade_id: String, - pub candidate_id: Option<String>, - pub mutation_id: Option<String>, - pub file: String, - pub listing_addr: String, - pub listing_event_id: String, - pub listing_snapshot_sha256: String, - pub buyer_pubkey: String, - pub seller_pubkey: String, - pub farm_id: String, - pub ready_for_submit: bool, - pub economics: TradeCandidateDraftEconomicsView, - pub issues: Vec<TradeCandidateDraftIssue>, - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TradeCandidateDraftEconomicsView { - pub currency_code: String, - pub currency_exponent: u32, - pub subtotal_mantissa: String, - pub discount_total_mantissa: String, - pub adjustment_total_mantissa: String, - pub total_mantissa: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TradeCandidateDraftIssue { - pub code: String, - pub field: String, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TradePrivateArtifactOpenView { - pub state: String, - pub artifact_id: String, - pub trade_id: Option<String>, - pub candidate_id: Option<String>, - pub artifact_kind: Option<String>, - pub schema_id: Option<String>, - pub retention_class: Option<String>, - pub output: String, - pub bytes_written: usize, - pub created_at_ms: Option<i64>, - pub expires_at_ms: Option<i64>, - pub deleted_at_ms: Option<i64>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TradePreparedView { - pub state: String, - pub operation: String, - pub trade_id: String, - pub mutation_id: String, - pub mutation_kind: String, - pub event_id: String, - pub event_kind: u32, - pub author: String, - pub required_actions: Vec<String>, - pub idempotency_key: Option<String>, - pub reason: Option<String>, - pub actions: Vec<String>, -} - -pub fn submit_proposal( - config: &RuntimeConfig, - args: &TradeEnvelopeFileRuntimeArgs, -) -> Result<TradePreparedView, CliSdkAdapterError> { - prepare_trade_command(config, args, "trade.submit_proposal.v1") -} - -pub fn propose_revision( - config: &RuntimeConfig, - args: &TradeEnvelopeFileRuntimeArgs, -) -> Result<TradePreparedView, CliSdkAdapterError> { - prepare_trade_command(config, args, "trade.propose_revision.v1") -} - -pub fn decide_candidate( - config: &RuntimeConfig, - args: &TradeEnvelopeFileRuntimeArgs, -) -> Result<TradePreparedView, CliSdkAdapterError> { - prepare_trade_command(config, args, "trade.decide_candidate.v1") -} - -pub fn cancel_trade( - config: &RuntimeConfig, - args: &TradeEnvelopeFileRuntimeArgs, -) -> Result<TradePreparedView, CliSdkAdapterError> { - prepare_trade_command(config, args, "trade.cancel.v1") -} - -pub fn resume_operation( - config: &RuntimeConfig, - args: &TradeEnvelopeFileRuntimeArgs, -) -> Result<TradePreparedView, CliSdkAdapterError> { - let operation_kind = args.operation_kind.as_deref().ok_or_else(|| { - RuntimeError::Config("trade operation resume requires `--operation-kind`".to_owned()) - })?; - prepare_trade_command(config, args, resume_operation_kind(operation_kind)?) -} - -fn prepare_trade_command( - config: &RuntimeConfig, - args: &TradeEnvelopeFileRuntimeArgs, - operation: &str, -) -> Result<TradePreparedView, CliSdkAdapterError> { - let envelope = load_trade_envelope(args.file.as_path())?; - let actor = actor_for_envelope(config, &envelope, operation)?; - let idempotency_key = args - .idempotency_key - .as_deref() - .and_then(non_empty_ref) - .map(radroots_storage::journal::IdempotencyKey::parse) - .transpose() - .map_err(|error| RuntimeError::Config(format!("invalid idempotency key: {error}")))?; - let plan = sdk_trade::prepare(sdk_trade::PrepareRequest::new(actor, envelope)) - .map_err(|error| RuntimeError::Config(format!("invalid SDK trade plan: {error}")))?; - if matches!(operation, "trade.decide_candidate.v1") - && plan.workflow().private_terms().is_some() - && !args.acknowledge_private_terms - { - return Err(RuntimeError::Config( - "trade decision requires explicit private-terms acknowledgement".to_owned(), - ) - .into()); - } - Ok(trade_prepared_view(operation, args, &plan, idempotency_key)) -} - -fn trade_prepared_view( - operation: &str, - args: &TradeEnvelopeFileRuntimeArgs, - plan: &TradePlan, - idempotency_key: Option<radroots_storage::journal::IdempotencyKey>, -) -> TradePreparedView { - TradePreparedView { - state: "prepared".to_owned(), - operation: operation.to_owned(), - trade_id: plan.workflow().trade_id().to_string(), - mutation_id: plan.workflow().mutation_id().to_string(), - mutation_kind: format!("{:?}", plan.workflow().kind()), - event_id: plan.draft().expected_event_id().to_string(), - event_kind: plan.draft().kind_u32(), - author: plan.draft().expected_pubkey().to_hex(), - required_actions: plan - .workflow() - .required_actions() - .iter() - .map(|action| format!("{action:?}")) - .collect(), - idempotency_key: idempotency_key.map(|value| value.as_str().to_owned()), - reason: Some( - "trade plan validated; durable enqueue requires the configured shared sync engine" - .to_owned(), - ), - actions: vec![format!( - "radroots trade operation resume {}", - args.file.display() - )], - } -} - -pub fn get_trade( - _config: &RuntimeConfig, - args: &TradeIdRuntimeArgs, -) -> Result<serde_json::Value, CliSdkAdapterError> { - let _ = trade_id(args.trade_id.as_str(), "trade_id")?; - Err(RuntimeError::Config( - "trade queries require the configured SDK storage and projection adapter".to_owned(), - ) - .into()) -} - -pub fn list_trades( - _config: &RuntimeConfig, - args: &TradePageRuntimeArgs, -) -> Result<serde_json::Value, CliSdkAdapterError> { - if matches!(args.limit, Some(0)) { - return Err(RuntimeError::Config("trade list limit must be positive".to_owned()).into()); - } - let _ = args.cursor.as_deref().and_then(non_empty_ref); - Err(RuntimeError::Config( - "trade queries require the configured SDK storage and projection adapter".to_owned(), - ) - .into()) -} - -pub fn refresh_evidence( - _config: &RuntimeConfig, - args: &TradeIdRuntimeArgs, -) -> Result<serde_json::Value, CliSdkAdapterError> { - let _ = trade_id(args.trade_id.as_str(), "trade_id")?; - Err(RuntimeError::Config( - "trade projection refresh requires the configured shared sync engine".to_owned(), - ) - .into()) -} - -pub fn inspect_evidence( - _config: &RuntimeConfig, - args: &TradeEvidenceInspectRuntimeArgs, -) -> Result<serde_json::Value, CliSdkAdapterError> { - let _ = trade_id(args.trade_id.as_str(), "trade_id")?; - if matches!(args.limit, Some(0)) { - return Err( - RuntimeError::Config("trade evidence limit must be positive".to_owned()).into(), - ); - } - let _ = args.cursor.as_deref().and_then(non_empty_ref); - Err(RuntimeError::Config( - "trade evidence queries require the configured SDK storage adapter".to_owned(), - ) - .into()) -} - -pub fn seal_private_artifact( - _config: &RuntimeConfig, - args: &TradePrivateArtifactSealRuntimeArgs, -) -> Result<serde_json::Value, CliSdkAdapterError> { - let _ = trade_id(args.trade_id.as_str(), "trade_id")?; - let _ = private_artifact_kind(args.kind.as_str())?; - let _ = fs::metadata(args.input.as_path()).map_err(RuntimeError::from)?; - Err(RuntimeError::Config( - "private trade artifact sealing requires a host-owned secret adapter".to_owned(), - ) - .into()) -} - -pub fn open_private_artifact( - _config: &RuntimeConfig, - args: &TradePrivateArtifactOpenRuntimeArgs, -) -> Result<TradePrivateArtifactOpenView, CliSdkAdapterError> { - Err(RuntimeError::Config(format!( - "private trade artifact `{}` requires a host-owned secret adapter", - args.artifact_id - )) - .into()) -} - -pub fn delete_private_artifact( - _config: &RuntimeConfig, - args: &TradePrivateArtifactDeleteRuntimeArgs, -) -> Result<serde_json::Value, CliSdkAdapterError> { - Err(RuntimeError::Config(format!( - "private trade artifact `{}` requires a host-owned secret adapter", - args.artifact_id - )) - .into()) -} - -pub fn scaffold_proposal_draft( - config: &RuntimeConfig, - args: &TradeCandidateDraftCreateArgs, -) -> Result<TradeCandidateDraftView, RuntimeError> { - scaffold_proposal_draft_inner(config, args, false) -} - -pub fn scaffold_proposal_draft_preflight( - config: &RuntimeConfig, - args: &TradeCandidateDraftCreateArgs, -) -> Result<TradeCandidateDraftView, RuntimeError> { - scaffold_proposal_draft_inner(config, args, true) -} - -fn scaffold_proposal_draft_inner( - config: &RuntimeConfig, - args: &TradeCandidateDraftCreateArgs, - dry_run: bool, -) -> Result<TradeCandidateDraftView, RuntimeError> { - if args.quantity == 0 { - return Err(RuntimeError::Config( - "basket item quantity must be greater than zero".to_owned(), - )); - } - let buyer = account::resolve_account(config)?.ok_or_else(|| { - RuntimeError::Config("trade proposal draft requires a selected buyer account".to_owned()) - })?; - let product = resolve_product(config, args)?; - let parsed_listing = parse_listing_addr(product.listing_addr.as_str())?; - let listing_state = - resolve_active_listing_state(config, product.listing_addr.as_str(), &parsed_listing)?; - let farm_id = resolve_farm_id(config, parsed_listing.seller_pubkey.as_str())?; - let buyer_public_key_hex = buyer.record.public_identity().public_key().to_hex(); - let buyer_pubkey = pubkey(buyer_public_key_hex.as_str(), "buyer_pubkey")?; - let seller_pubkey = pubkey(parsed_listing.seller_pubkey.as_str(), "seller_pubkey")?; - let candidate = candidate_terms( - &product, - &listing_state, - buyer_pubkey.clone(), - seller_pubkey.clone(), - farm_id.clone(), - args.quantity, - )?; - let envelope = TradeMutationEnvelopeV1 { - mutation_id: None, - contract_id: RADROOTS_TRADE_PROPOSAL_CONTRACT_ID.to_owned(), - schema_version: RADROOTS_TRADE_SCHEMA_VERSION, - trade_id: next_trade_id()?, - root_mutation_id: None, - buyer_pubkey, - seller_pubkey: seller_pubkey.clone(), - farm_id: farm_id.clone(), - parent_mutation_ids: Vec::new(), - author_pubkey: pubkey(buyer_public_key_hex.as_str(), "author_pubkey")?, - counterparty_pubkey: seller_pubkey, - authored_at_unix_s: now_unix(), - body: TradeMutationBodyV1::Proposal { candidate }, - }; - let canonical = canonical_trade_mutation_content(envelope) - .map_err(|error| RuntimeError::Config(format!("build trade proposal envelope: {error}")))?; - let file = candidate_draft_file(config, canonical.envelope.trade_id.to_string().as_str()); - if !dry_run { - if let Some(parent) = file.parent() { - fs::create_dir_all(parent)?; - } - let contents = serde_json::to_string_pretty(&canonical.envelope)?; - fs::write(file.as_path(), contents)?; - } - let economics = proposal_economics_view(&canonical.envelope); - let candidate_id = proposal_candidate_id(&canonical.envelope); - Ok(TradeCandidateDraftView { - state: if dry_run { "dry_run" } else { "drafted" }.to_owned(), - source: TRADE_CANDIDATE_DRAFT_SOURCE.to_owned(), - trade_id: canonical.envelope.trade_id.to_string(), - candidate_id, - mutation_id: canonical - .envelope - .mutation_id - .as_ref() - .map(ToString::to_string), - file: file.display().to_string(), - listing_addr: product.listing_addr, - listing_event_id: listing_state.last_event_id, - listing_snapshot_sha256: listing_state.content_hash, - buyer_pubkey: buyer_public_key_hex, - seller_pubkey: parsed_listing.seller_pubkey, - farm_id: farm_id.to_string(), - ready_for_submit: true, - economics, - issues: Vec::new(), - actions: if dry_run { - Vec::new() - } else { - vec![format!("radroots trade proposal submit {}", file.display())] - }, - }) -} - -fn load_trade_envelope(path: &Path) -> Result<TradeMutationEnvelopeV1, RuntimeError> { - let contents = fs::read_to_string(path)?; - serde_json::from_str(contents.as_str()).map_err(|error| { - RuntimeError::Config(format!( - "invalid trade envelope {}: {error}", - path.display() - )) - }) -} - -fn actor_for_envelope( - config: &RuntimeConfig, - envelope: &TradeMutationEnvelopeV1, - operation: &str, -) -> Result<Actor, CliSdkAdapterError> { - let account = account::resolve_account(config)?.ok_or_else(|| { - RuntimeError::Config(format!("{operation} requires a selected signer account")) - })?; - let author_pubkey = envelope.author_pubkey.to_hex(); - let account_pubkey = account.record.public_identity().public_key().to_hex(); - if !account_pubkey.eq_ignore_ascii_case(author_pubkey.as_str()) { - return Err(RuntimeError::Config(format!( - "{operation} envelope author `{author_pubkey}` does not match selected account `{}` public key `{account_pubkey}`", - account.record.id() - )) - .into()); - } - let role = if envelope - .buyer_pubkey - .to_hex() - .eq_ignore_ascii_case(author_pubkey.as_str()) - { - AuthorRole::Buyer - } else if envelope - .seller_pubkey - .to_hex() - .eq_ignore_ascii_case(author_pubkey.as_str()) - { - AuthorRole::Seller - } else { - return Err(RuntimeError::Config(format!( - "{operation} envelope author must be the buyer or seller" - )) - .into()); - }; - let actor = Actor::from_public_key_hex( - author_pubkey.as_str(), - ActorSource::ExplicitPublicKey, - [role], - ) - .map_err(|error| RuntimeError::Config(format!("invalid trade SDK actor: {error}")))?; - validate_configured_signer_for_actor( - config, - Some(account.record.id().to_hex().as_str()), - author_pubkey.as_str(), - operation, - )?; - Ok(actor) -} - -fn resume_operation_kind(value: &str) -> Result<&'static str, RuntimeError> { - match value { - "trade.submit_proposal.v1" => Ok("trade.submit_proposal.v1"), - "trade.propose_revision.v1" => Ok("trade.propose_revision.v1"), - "trade.decide_candidate.v1" => Ok("trade.decide_candidate.v1"), - "trade.cancel.v1" => Ok("trade.cancel.v1"), - other => Err(RuntimeError::Config(format!( - "unsupported trade operation kind `{other}`" - ))), - } -} - -fn private_artifact_kind(value: &str) -> Result<&'static str, RuntimeError> { - match value { - "binding_terms" => Ok("binding_terms"), - "message" => Ok("message"), - "contact_bundle" => Ok("contact_bundle"), - "delivery_instruction" => Ok("delivery_instruction"), - other => Err(RuntimeError::Config(format!( - "unsupported private artifact kind `{other}`" - ))), - } -} - -#[derive(Debug, Clone)] -struct ProductFacts { - key: String, - qty_amt_exact: String, - qty_unit: String, - price_amt_exact: String, - price_currency: String, - price_qty_amt_exact: String, - price_qty_unit: String, - listing_addr: String, - primary_bin_id: String, -} - -#[derive(Debug, Clone)] -struct ParsedListingAddress { - kind: u32, - seller_pubkey: String, - listing_id: String, -} - -#[derive(Debug, Clone)] -struct ActiveListingState { - last_event_id: String, - content_hash: String, -} - -fn resolve_product( - config: &RuntimeConfig, - args: &TradeCandidateDraftCreateArgs, -) -> Result<ProductFacts, RuntimeError> { - if !config.local.replica_store_path.exists() { - return Err(RuntimeError::Config( - "trade proposal draft requires local market data; run `radroots store inspect` and `radroots market pull`".to_owned(), - )); - } - if let Some(listing_addr) = args.listing_addr.as_deref().and_then(non_empty_ref) { - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - let rows = trade_product::find_many( - &executor, - &ITradeProductFindMany { - filter: Some(trade_product_listing_addr_filter(listing_addr)), - }, - ) - .map_err(|error| RuntimeError::Config(format!("resolve listing product state: {error:?}")))? - .results; - let product = match rows.len() { - 0 => { - return Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` is not available in the local replica" - ))); - } - 1 => rows.into_iter().next().expect("one row"), - count => { - return Err(RuntimeError::Config(format!( - "listing address `{listing_addr}` matched {count} local listing rows" - ))); - } - }; - let facts = ProductFacts { - key: product.key, - qty_amt_exact: required(product.qty_amt_exact, "qty_amt_exact")?, - qty_unit: product.qty_unit, - price_amt_exact: required(product.price_amt_exact, "price_amt_exact")?, - price_currency: product.price_currency, - price_qty_amt_exact: required(product.price_qty_amt_exact, "price_qty_amt_exact")?, - price_qty_unit: product.price_qty_unit, - listing_addr: required(product.listing_addr, "listing_addr")?, - primary_bin_id: verified_primary_bin( - product.primary_bin_id, - product.verified_primary_bin_id, - )?, - }; - validate_product_bin(&facts, args.bin_id.as_str())?; - return Ok(facts); - } - let listing = args - .listing - .as_deref() - .and_then(non_empty_ref) - .ok_or_else(|| { - RuntimeError::Config( - "trade proposal draft requires `listing` or `listing_addr`".to_owned(), - ) - })?; - let db = ReplicaSql::new(SqlxSqliteExecutor::open(&config.local.replica_store_path)?); - let rows = db.trade_product_lookup(listing)?; - let product = match rows.len() { - 0 => { - return Err(RuntimeError::Config(format!( - "listing `{listing}` is not available in the local replica" - ))); - } - 1 => rows.into_iter().next().expect("one row"), - count => { - return Err(RuntimeError::Config(format!( - "listing `{listing}` matched {count} local listings" - ))); - } - }; - let facts = ProductFacts { - key: product.key, - qty_amt_exact: required(product.qty_amt_exact, "qty_amt_exact")?, - qty_unit: product.qty_unit, - price_amt_exact: required(product.price_amt_exact, "price_amt_exact")?, - price_currency: product.price_currency, - price_qty_amt_exact: required(product.price_qty_amt_exact, "price_qty_amt_exact")?, - price_qty_unit: product.price_qty_unit, - listing_addr: required(product.listing_addr, "listing_addr")?, - primary_bin_id: verified_primary_bin( - product.primary_bin_id, - product.verified_primary_bin_id, - )?, - }; - validate_product_bin(&facts, args.bin_id.as_str())?; - Ok(facts) -} - -fn candidate_terms( - product: &ProductFacts, - listing_state: &ActiveListingState, - buyer_pubkey: PublicKey, - seller_pubkey: PublicKey, - farm_id: DTag, - bin_count: u32, -) -> Result<TradeCandidateTermsV1, RuntimeError> { - let currency = product - .price_currency - .parse::<Currency>() - .map_err(|error| { - RuntimeError::Config(format!("listing price_currency is invalid: {error}")) - })?; - let quantity_amount = exact_positive_decimal(product.qty_amt_exact.as_str(), "qty_amt_exact")? - .checked_mul(Decimal::from(bin_count)) - .map_err(|error| RuntimeError::Config(format!("trade quantity overflow: {error}")))?; - let quantity_unit = product - .qty_unit - .parse::<Unit>() - .map_err(|error| RuntimeError::Config(format!("listing qty_unit is invalid: {error}")))?; - let price_amount = exact_positive_decimal(product.price_amt_exact.as_str(), "price_amt_exact")?; - let price_quantity_amount = - exact_positive_decimal(product.price_qty_amt_exact.as_str(), "price_qty_amt_exact")?; - let price_unit = product.price_qty_unit.parse::<Unit>().map_err(|error| { - RuntimeError::Config(format!("listing price_qty_unit is invalid: {error}")) - })?; - let quantity_unit_in_price_units = - convert_unit_decimal(Decimal::ONE, quantity_unit, price_unit).map_err(|error| { - RuntimeError::Config(format!( - "listing quantity and price units are incompatible: {error}" - )) - })?; - let unit_price_amount = price_amount - .checked_div(price_quantity_amount) - .and_then(|value| value.checked_mul(quantity_unit_in_price_units)) - .map_err(|error| RuntimeError::Config(format!("trade unit price is invalid: {error}")))?; - let subtotal = unit_price_amount - .checked_mul(quantity_amount) - .map_err(|error| RuntimeError::Config(format!("trade subtotal overflow: {error}")))?; - let quantity_scale = u8::try_from(quantity_amount.scale()) - .map_err(|_| RuntimeError::Config("trade quantity scale exceeds u8".to_owned()))?; - let currency_exponent = currency.minor_unit_exponent(); - let subtotal_mantissa = decimal_mantissa_at_scale(subtotal, currency_exponent); - let line = TradeCandidateLineV1 { - line_id: DTag::parse("line-1") - .map_err(|error| RuntimeError::Config(format!("invalid line id: {error}")))?, - listing_addr: ClassifiedListingAddress::parse(product.listing_addr.as_str()) - .map_err(|error| RuntimeError::Config(format!("invalid listing address: {error}")))?, - listing_event_id: listing_state - .last_event_id - .parse() - .map_err(|error| RuntimeError::Config(format!("invalid listing event id: {error}")))?, - listing_snapshot_sha256: listing_state.content_hash.clone(), - product_id: product.key.clone(), - option_id: None, - bin_id: inventory_bin_id(product.primary_bin_id.as_str(), "bin_id")?, - quantity_mantissa: decimal_mantissa_at_scale(quantity_amount, u32::from(quantity_scale)), - quantity_scale, - unit_code: product.qty_unit.clone(), - unit_profile: "radroots-core-unit".to_owned(), - unit_price_mantissa: decimal_mantissa_at_scale(unit_price_amount, currency_exponent), - currency_code: product.price_currency.clone(), - line_subtotal_mantissa: subtotal_mantissa.clone(), - replaces_line_id: None, - }; - let now = now_unix(); - Ok(TradeCandidateTermsV1 { - candidate_id: None, - schema_version: RADROOTS_TRADE_SCHEMA_VERSION, - base_candidate_id: None, - supersession_intent: None, - buyer_pubkey, - seller_pubkey, - farm_id, - lines: vec![line], - line_tombstones: Vec::new(), - economics: TradeEconomicsProfileV1 { - profile_id: "mvp-fixed".to_owned(), - currency_code: product.price_currency.clone(), - currency_exponent: u8::try_from(currency_exponent) - .map_err(|_| RuntimeError::Config("currency exponent exceeds u8".to_owned()))?, - rounding_profile: "half-even".to_owned(), - subtotal_mantissa: subtotal_mantissa.clone(), - discount_total_mantissa: "0".to_owned(), - adjustment_total_mantissa: "0".to_owned(), - total_mantissa: subtotal_mantissa, - adjustments: Vec::new(), - }, - fulfillment: FulfillmentProfileV1 { - profile_id: "market-pickup".to_owned(), - method: "pickup".to_owned(), - starts_at_unix_s: now + DEFAULT_FULFILLMENT_START_OFFSET_SECONDS, - ends_at_unix_s: now - + DEFAULT_FULFILLMENT_START_OFFSET_SECONDS - + DEFAULT_FULFILLMENT_WINDOW_SECONDS, - timezone: "UTC".to_owned(), - utc_offset_seconds: 0, - fold: 0, - location_class: "seller_public_listing".to_owned(), - requires_private_terms: false, - }, - cancellation: TradeCancellationProfileV1 { - profile_id: "buyer-pre-agreement".to_owned(), - buyer_pre_agreement: true, - post_agreement_cutoff_unix_s: None, - }, - private_terms: None, - proposal_expires_at_unix_s: now + DEFAULT_PROPOSAL_TTL_SECONDS, - }) -} - -fn resolve_active_listing_state( - config: &RuntimeConfig, - listing_addr: &str, - parsed: &ParsedListingAddress, -) -> Result<ActiveListingState, RuntimeError> { - let executor = SqlxSqliteExecutor::open(&config.local.replica_store_path)?; - let key = format!( - "{}:{}:{}", - parsed.kind, parsed.seller_pubkey, parsed.listing_id - ); - let state = nostr_event_head::find_one( - &executor, - &INostrEventHeadFindOne::On(INostrEventHeadFindOneArgs { - on: NostrEventHeadQueryBindValues::Key { key }, - }), - ) - .map_err(|error| RuntimeError::Config(format!("resolve listing event state: {error:?}")))? - .result - .ok_or_else(|| { - RuntimeError::Config(format!( - "listing address `{listing_addr}` is missing latest listing event state; run `radroots market pull`" - )) - })?; - MutationId::parse(state.content_hash.as_str()).map_err(|error| { - RuntimeError::Config(format!( - "listing content hash is not a 32-byte hex digest: {error}" - )) - })?; - state - .last_event_id - .parse::<radroots_event::EventId>() - .map_err(|error| { - RuntimeError::Config(format!("listing latest event id is invalid: {error}")) - })?; - Ok(ActiveListingState { - last_event_id: state.last_event_id, - content_hash: state.content_hash, - }) -} - -fn resolve_farm_id(config: &RuntimeConfig, seller_pubkey: &str) -> Result<DTag, RuntimeError> { - let db = ReplicaSql::new(SqlxSqliteExecutor::open(&config.local.replica_store_path)?); - let d_tag = db.farm_unique_d_tag_by_pubkey(seller_pubkey)?.ok_or_else(|| { - RuntimeError::Config(format!( - "seller `{seller_pubkey}` must have exactly one farm profile in the local replica before creating a trade proposal draft" - )) - })?; - DTag::parse(d_tag.as_str()) - .map_err(|error| RuntimeError::Config(format!("farm d tag is invalid: {error}"))) -} - -fn parse_listing_addr(raw: &str) -> Result<ParsedListingAddress, RuntimeError> { - let parsed = ClassifiedListingAddress::parse(raw) - .map_err(|error| RuntimeError::Config(format!("listing address is invalid: {error}")))?; - let (kind, rest) = parsed - .as_str() - .split_once(':') - .ok_or_else(|| RuntimeError::Config("listing address has invalid format".to_owned()))?; - let (seller_pubkey, listing_id) = rest - .split_once(':') - .ok_or_else(|| RuntimeError::Config("listing address has invalid format".to_owned()))?; - let kind = kind - .parse::<u32>() - .map_err(|_| RuntimeError::Config("listing address kind is invalid".to_owned()))?; - Ok(ParsedListingAddress { - kind, - seller_pubkey: seller_pubkey.to_owned(), - listing_id: listing_id.to_owned(), - }) -} - -fn proposal_economics_view(envelope: &TradeMutationEnvelopeV1) -> TradeCandidateDraftEconomicsView { - let TradeMutationBodyV1::Proposal { candidate } = &envelope.body else { - unreachable!("proposal draft envelope is a proposal") - }; - TradeCandidateDraftEconomicsView { - currency_code: candidate.economics.currency_code.clone(), - currency_exponent: u32::from(candidate.economics.currency_exponent), - subtotal_mantissa: candidate.economics.subtotal_mantissa.clone(), - discount_total_mantissa: candidate.economics.discount_total_mantissa.clone(), - adjustment_total_mantissa: candidate.economics.adjustment_total_mantissa.clone(), - total_mantissa: candidate.economics.total_mantissa.clone(), - } -} - -fn proposal_candidate_id(envelope: &TradeMutationEnvelopeV1) -> Option<String> { - let TradeMutationBodyV1::Proposal { candidate } = &envelope.body else { - return None; - }; - candidate.candidate_id.as_ref().map(ToString::to_string) -} - -fn trade_product_listing_addr_filter(listing_addr: &str) -> ITradeProductFieldsFilter { - ITradeProductFieldsFilter { - id: None, - created_at: None, - updated_at: None, - key: None, - category: None, - title: None, - summary: None, - process: None, - lot: None, - profile: None, - year: None, - qty_amt: None, - qty_amt_exact: None, - qty_unit: None, - qty_label: None, - qty_avail: None, - price_amt: None, - price_amt_exact: None, - price_currency: None, - price_qty_amt: None, - price_qty_amt_exact: None, - price_qty_unit: None, - listing_addr: Some(listing_addr.to_owned()), - primary_bin_id: None, - verified_primary_bin_id: None, - notes: None, - } -} - -fn required(value: Option<String>, field: &str) -> Result<String, RuntimeError> { - value - .and_then(|value| non_empty_ref(value.as_str()).map(str::to_owned)) - .ok_or_else(|| RuntimeError::Config(format!("listing {field} is missing"))) -} - -fn verified_primary_bin( - primary_bin_id: Option<String>, - verified_primary_bin_id: Option<String>, -) -> Result<String, RuntimeError> { - let primary = required(primary_bin_id, "primary_bin_id")?; - let verified = required(verified_primary_bin_id, "verified_primary_bin_id")?; - if primary != verified { - return Err(RuntimeError::Config(format!( - "listing primary bin `{primary}` does not match verified primary bin `{verified}`" - ))); - } - Ok(primary) -} - -fn validate_product_bin(product: &ProductFacts, bin_id: &str) -> Result<(), RuntimeError> { - if product.primary_bin_id != bin_id { - return Err(RuntimeError::Config(format!( - "basket bin `{bin_id}` does not match listing primary bin `{}`", - product.primary_bin_id - ))); - } - Ok(()) -} - -fn exact_positive_decimal(value: &str, field: &str) -> Result<Decimal, RuntimeError> { - let parsed = value - .trim() - .parse::<Decimal>() - .map_err(|error| RuntimeError::Config(format!("listing {field} is invalid: {error}")))?; - if parsed.is_zero() || parsed.is_sign_negative() { - return Err(RuntimeError::Config(format!( - "listing {field} must be greater than zero" - ))); - } - Ok(parsed) -} - -fn decimal_mantissa_at_scale(mut value: Decimal, scale: u32) -> String { - value.rescale(scale); - value.to_string().replace('.', "") -} - -fn candidate_draft_file(config: &RuntimeConfig, trade_id: &str) -> PathBuf { - config - .paths - .app_data_root - .join(TRADE_CANDIDATE_DRAFTS_DIR) - .join(format!("{trade_id}.json")) -} - -fn next_trade_id() -> Result<TradeId, RuntimeError> { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|duration| duration.as_nanos()) - .unwrap_or_default(); - let counter = u128::from(TRADE_COUNTER.fetch_add(1, Ordering::Relaxed)); - let value = format!("{:032x}", nanos ^ counter); - trade_id(value.as_str(), "trade_id") -} - -fn now_unix() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|value| value.as_secs()) - .unwrap_or_default() -} - -fn trade_id(value: &str, field: &str) -> Result<TradeId, RuntimeError> { - TradeId::parse(value) - .map_err(|error| RuntimeError::Config(format!("{field} is invalid: {error}"))) -} - -fn trade_candidate_id(value: &str, field: &str) -> Result<CandidateId, RuntimeError> { - CandidateId::parse(value) - .map_err(|error| RuntimeError::Config(format!("{field} is invalid: {error}"))) -} - -fn inventory_bin_id(value: &str, field: &str) -> Result<InventoryBinId, RuntimeError> { - InventoryBinId::parse(value) - .map_err(|error| RuntimeError::Config(format!("{field} is invalid: {error}"))) -} - -fn pubkey(value: &str, field: &str) -> Result<PublicKey, RuntimeError> { - PublicKey::from_hex(value) - .map_err(|error| RuntimeError::Config(format!("{field} is invalid: {error}"))) -} - -fn non_empty_ref(value: &str) -> Option<&str> { - let value = value.trim(); - if value.is_empty() { None } else { Some(value) } -} diff --git a/src/runtime/transport.rs b/src/runtime/transport.rs @@ -1,532 +0,0 @@ -use radroots_transport::{ - RadrootsTransportCapabilityAvailability, RadrootsTransportCapabilityMaturity, - RadrootsTransportImplementationState, RadrootsTransportKind, RadrootsTransportStatus, -}; -use serde_json::Value as JsonValue; -use std::fs; -use toml::{Value, map::Map}; - -use crate::ops::OperationData; -use crate::runtime::RuntimeError; -use crate::runtime::config::{RuntimeConfig, TransportProfileKind}; -use crate::runtime::sdk::{CliSdkAdapterError, CliSdkSession}; -use crate::view::runtime::{ - TransportDeliveryInspectView, TransportDeliveryRetryView, TransportOperationCapabilitiesView, - TransportProfileSummaryView, TransportProfileView, TransportRuntimeStatusView, - TransportStatusInspectView, -}; - -const TRANSPORT_SOURCE: &str = "transport profile config"; -const RADROOTS_RETICULUM_ENDPOINT_URI: &str = "reticulum:local"; -const RADROOTS_RETICULUM_SCOPE_ID: &str = "local"; -const RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE: &str = - "Reticulum transport is not available in this release"; -pub fn profile(config: &RuntimeConfig) -> TransportProfileView { - active_profile_view(config) -} - -pub fn set_profile( - config: &RuntimeConfig, - input: &OperationData, -) -> Result<TransportProfileView, RuntimeError> { - let kind = string_input(input, "kind").unwrap_or("local_only"); - let mut transport = Map::new(); - transport.insert("profile".to_owned(), Value::String(kind.to_owned())); - match kind { - "local_only" => {} - "nostr" => { - let relays = string_array_input(input, "nostr_relays"); - if relays.is_empty() { - return Err(RuntimeError::Config( - "transport profile `nostr` requires at least one --nostr-relay".to_owned(), - )); - } - let mut nostr = Map::new(); - nostr.insert( - "relay_urls".to_owned(), - Value::Array(relays.into_iter().map(Value::String).collect()), - ); - transport.insert("nostr".to_owned(), Value::Table(nostr)); - } - "reticulum" => { - let behavior = - string_input(input, "reticulum_behavior").unwrap_or("reject_delivery_attempts"); - let scope = - string_input(input, "reticulum_scope").unwrap_or(RADROOTS_RETICULUM_SCOPE_ID); - let mut reticulum = Map::new(); - reticulum.insert("behavior".to_owned(), Value::String(behavior.to_owned())); - reticulum.insert("scope".to_owned(), Value::String(scope.to_owned())); - if let Some(agent_endpoint) = string_input(input, "reticulum_agent_endpoint") { - reticulum.insert( - "agent_endpoint".to_owned(), - Value::String(agent_endpoint.to_owned()), - ); - } - transport.insert("reticulum".to_owned(), Value::Table(reticulum)); - } - "multi_target" => { - let relays = string_array_input(input, "nostr_relays"); - if relays.is_empty() { - return Err(RuntimeError::Config( - "transport profile `multi_target` requires at least one --nostr-relay" - .to_owned(), - )); - } - let behavior = - string_input(input, "reticulum_behavior").unwrap_or("reject_delivery_attempts"); - let scope = - string_input(input, "reticulum_scope").unwrap_or(RADROOTS_RETICULUM_SCOPE_ID); - let mut nostr = Map::new(); - nostr.insert( - "relay_urls".to_owned(), - Value::Array(relays.into_iter().map(Value::String).collect()), - ); - let mut reticulum = Map::new(); - reticulum.insert("behavior".to_owned(), Value::String(behavior.to_owned())); - reticulum.insert("scope".to_owned(), Value::String(scope.to_owned())); - if let Some(agent_endpoint) = string_input(input, "reticulum_agent_endpoint") { - reticulum.insert( - "agent_endpoint".to_owned(), - Value::String(agent_endpoint.to_owned()), - ); - } - transport.insert("nostr".to_owned(), Value::Table(nostr)); - transport.insert("reticulum".to_owned(), Value::Table(reticulum)); - } - other => { - return Err(RuntimeError::Config(format!( - "transport profile kind `{other}` is not supported" - ))); - } - } - update_app_config_table(config, "transport", Value::Table(transport))?; - Ok(profile_view_from_parts( - kind, - string_array_input(input, "nostr_relays"), - string_input(input, "reticulum_behavior").map(str::to_owned), - reticulum_scope_for_profile(kind, input), - string_input(input, "reticulum_agent_endpoint").map(str::to_owned), - "configured", - )) -} - -pub fn status(config: &RuntimeConfig) -> TransportStatusInspectView { - let profile = active_profile_view(config); - - TransportStatusInspectView { - state: "ready".to_owned(), - source: TRANSPORT_SOURCE.to_owned(), - active_profile: profile.summary(), - transports: profile.transport_statuses, - } -} - -pub fn outbox_status( - config: &RuntimeConfig, -) -> Result<TransportDeliveryInspectView, CliSdkAdapterError> { - let profile = active_profile_view(config); - let session = if profile.profile_delivery_usable { - CliSdkSession::connect(config)? - } else { - CliSdkSession::connect_storage_status(config)? - }; - let receipt = crate::runtime::sync::canonical_status(&session)?; - let outbox = receipt.outbox(); - let state = if profile.configured_state == "configured" { - "ready".to_owned() - } else { - profile.configured_state.clone() - }; - Ok(TransportDeliveryInspectView { - state, - source: "SDK transport outbox".to_owned(), - transport_profile: profile.profile_id, - total_count: i64::try_from(outbox.total().unwrap_or_default()).unwrap_or(i64::MAX), - pending_count: i64::try_from(outbox.pending).unwrap_or(i64::MAX), - retryable_count: i64::try_from(outbox.retryable).unwrap_or(i64::MAX), - terminal_count: i64::try_from(outbox.satisfied + outbox.exhausted).unwrap_or(i64::MAX), - deferred_until_implemented_count: 0, - ready_signed_count: i64::try_from(outbox.pending + outbox.retryable).unwrap_or(i64::MAX), - publishing_count: i64::try_from(outbox.leased).unwrap_or(i64::MAX), - last_attempt_at_ms: None, - last_error: None, - actions: vec!["radroots transport outbox push".to_owned()], - }) -} - -pub fn outbox_push( - config: &RuntimeConfig, -) -> Result<TransportDeliveryRetryView, CliSdkAdapterError> { - if config.output.dry_run { - let status = outbox_status(config)?; - return Ok(TransportDeliveryRetryView { - state: "dry_run".to_owned(), - source: "SDK transport outbox".to_owned(), - attempted_events: 0, - published_events: 0, - retryable_events: 0, - terminal_events: 0, - target_count: usize::try_from(status.ready_signed_count).unwrap_or_default(), - reason: Some("dry run requested; transport outbox push skipped".to_owned()), - actions: vec!["radroots transport outbox status".to_owned()], - }); - } - let session = CliSdkSession::connect(config)?; - let receipt = crate::runtime::sync::deliver_pending(&session)?; - let attempted_events = receipt.outcomes().len(); - let published_events = receipt.succeeded(); - let failed_count = receipt.failed(); - let state = if attempted_events == 0 { - "ready" - } else if failed_count == 0 { - "published" - } else if published_events > 0 { - "partial" - } else { - "unavailable" - }; - Ok(TransportDeliveryRetryView { - state: state.to_owned(), - source: "SDK transport outbox".to_owned(), - attempted_events, - published_events, - retryable_events: failed_count, - terminal_events: 0, - target_count: config.transport.nostr_relay_urls.len(), - reason: if attempted_events == 0 { - Some("canonical outbox had no ready delivery plans".to_owned()) - } else if failed_count > 0 { - Some(format!( - "{failed_count} canonical delivery outcome(s) failed" - )) - } else { - None - }, - actions: vec!["radroots transport outbox status".to_owned()], - }) -} - -fn active_profile_view(config: &RuntimeConfig) -> TransportProfileView { - match config.transport.profile { - TransportProfileKind::LocalOnly => profile_view_from_parts( - "local_only", - Vec::new(), - None, - None, - None, - "configured", - ), - TransportProfileKind::Nostr => profile_view_from_parts( - "nostr", - config.transport.nostr_relay_urls.clone(), - None, - None, - None, - if config.transport.nostr_relay_urls.is_empty() { - "unconfigured" - } else { - "configured" - }, - ), - TransportProfileKind::Reticulum => profile_view_from_parts( - "reticulum", - Vec::new(), - Some(config.transport.reticulum_behavior.as_str().to_owned()), - Some(config.transport.reticulum_scope.clone()), - config.transport.reticulum_agent_endpoint.clone(), - "configured", - ), - TransportProfileKind::MultiTarget => profile_view_from_parts( - "multi_target", - config.transport.nostr_relay_urls.clone(), - Some(config.transport.reticulum_behavior.as_str().to_owned()), - Some(config.transport.reticulum_scope.clone()), - config.transport.reticulum_agent_endpoint.clone(), - if config.transport.nostr_relay_urls.is_empty() { - "unconfigured" - } else { - "configured" - }, - ) - .with_message( - "Multi-target transport publishes through configured Nostr relays and reports Reticulum availability" - .to_owned(), - ), - } -} - -fn profile_view_from_parts( - profile_id: &str, - nostr_relays: Vec<String>, - reticulum_behavior: Option<String>, - reticulum_scope: Option<String>, - reticulum_agent_endpoint: Option<String>, - configured_state: &str, -) -> TransportProfileView { - let transport_statuses = transport_statuses_from_parts(profile_id, nostr_relays.as_slice()); - let profile_delivery_usable = transport_statuses - .iter() - .any(|status| status.usable_for_delivery); - let message = match profile_id { - "nostr" if profile_delivery_usable => "Nostr relay transport is configured for delivery", - "nostr" => "Nostr transport requires configured Nostr relay targets", - "reticulum" => RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, - "multi_target" if profile_delivery_usable => { - "Multi-target transport publishes through configured Nostr relays and reports Reticulum availability" - } - "multi_target" => "Multi-target transport requires configured Nostr relay targets", - _ => "Local-only profile does not deliver to network transports", - }; - TransportProfileView { - state: configured_state.to_owned(), - source: TRANSPORT_SOURCE.to_owned(), - profile_id: profile_id.to_owned(), - profile_kind: profile_id.to_owned(), - configured_state: configured_state.to_owned(), - profile_delivery_usable, - message: message.to_owned(), - nostr_relays, - reticulum_behavior, - reticulum_scope, - reticulum_agent_endpoint, - transport_statuses, - actions: profile_actions(profile_id, profile_delivery_usable), - } -} - -fn transport_statuses_from_parts( - profile_id: &str, - nostr_relays: &[String], -) -> Vec<TransportRuntimeStatusView> { - match profile_id { - "nostr" => vec![transport_runtime_status_view(nostr_transport_status( - profile_id, - !nostr_relays.is_empty(), - ))], - "reticulum" => { - vec![transport_runtime_status_view(reticulum_transport_status( - profile_id, - ))] - } - "multi_target" => vec![ - transport_runtime_status_view(nostr_transport_status( - profile_id, - !nostr_relays.is_empty(), - )), - transport_runtime_status_view(reticulum_transport_status(profile_id)), - ], - _ => vec![transport_runtime_status_view(local_transport_status( - profile_id, - ))], - } -} - -fn local_transport_status(profile_id: &str) -> RadrootsTransportStatus { - RadrootsTransportStatus::new( - RadrootsTransportKind::Local, - true, - RadrootsTransportImplementationState::Real, - false, - "Local-only profile writes only to local state", - ) - .with_profile_id(profile_id) -} - -fn nostr_transport_status(profile_id: &str, targets_configured: bool) -> RadrootsTransportStatus { - RadrootsTransportStatus::new( - RadrootsTransportKind::Nostr, - targets_configured, - RadrootsTransportImplementationState::Real, - targets_configured, - if targets_configured { - "Nostr relay transport is configured for delivery" - } else { - "Nostr transport requires configured Nostr relay targets" - }, - ) - .with_profile_id(profile_id) -} - -fn reticulum_transport_status(profile_id: &str) -> RadrootsTransportStatus { - RadrootsTransportStatus::new( - RadrootsTransportKind::Reticulum, - true, - RadrootsTransportImplementationState::Real, - false, - RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE, - ) - .with_profile_id(profile_id) - .with_endpoint_uri(RADROOTS_RETICULUM_ENDPOINT_URI) - .with_maturity(RadrootsTransportCapabilityMaturity::Preview) - .with_availability(RadrootsTransportCapabilityAvailability::Unavailable) -} - -fn transport_runtime_status_view(status: RadrootsTransportStatus) -> TransportRuntimeStatusView { - TransportRuntimeStatusView { - transport: status.kind.canonical_label(), - profile_id: status.profile_id, - endpoint_uri: status.endpoint_uri, - configured: status.configured, - implementation: transport_implementation_label(status.implementation).to_owned(), - maturity: transport_maturity_label(status.maturity).to_owned(), - availability: transport_availability_label(status.availability).to_owned(), - usable_for_delivery: status.usable_for_delivery, - capabilities: TransportOperationCapabilitiesView { - deliver: status.capabilities.deliver, - fetch: status.capabilities.fetch, - }, - message: status.message, - } -} - -fn transport_implementation_label(state: RadrootsTransportImplementationState) -> &'static str { - match state { - RadrootsTransportImplementationState::Real => "real", - RadrootsTransportImplementationState::Mock => "mock", - } -} - -fn transport_maturity_label(maturity: RadrootsTransportCapabilityMaturity) -> &'static str { - match maturity { - RadrootsTransportCapabilityMaturity::Experimental => "experimental", - RadrootsTransportCapabilityMaturity::Preview => "preview", - RadrootsTransportCapabilityMaturity::Stable => "stable", - } -} - -fn transport_availability_label( - availability: RadrootsTransportCapabilityAvailability, -) -> &'static str { - match availability { - RadrootsTransportCapabilityAvailability::Available => "available", - RadrootsTransportCapabilityAvailability::Degraded => "degraded", - RadrootsTransportCapabilityAvailability::Unavailable => "unavailable", - } -} - -fn reticulum_scope_for_profile(profile_id: &str, input: &OperationData) -> Option<String> { - match profile_id { - "reticulum" | "multi_target" => Some( - string_input(input, "reticulum_scope") - .unwrap_or(RADROOTS_RETICULUM_SCOPE_ID) - .to_owned(), - ), - _ => None, - } -} - -fn profile_actions(profile_id: &str, profile_delivery_usable: bool) -> Vec<String> { - if profile_delivery_usable { - return Vec::new(); - } - match profile_id { - "reticulum" => vec![ - "radroots transport status inspect".to_owned(), - "radroots transport config inspect".to_owned(), - ], - "nostr" => { - vec![ - "radroots transport config update --kind nostr --nostr-relay wss://relay.example.com" - .to_owned(), - ] - } - "multi_target" => { - vec![ - "radroots transport config update --kind multi-target --nostr-relay wss://relay.example.com" - .to_owned(), - ] - } - _ => vec!["radroots transport config inspect".to_owned()], - } -} - -trait TransportProfileViewMessage { - fn with_message(self, message: String) -> Self; - fn summary(&self) -> TransportProfileSummaryView; -} - -impl TransportProfileViewMessage for TransportProfileView { - fn with_message(mut self, message: String) -> Self { - self.message = message; - self - } - - fn summary(&self) -> TransportProfileSummaryView { - TransportProfileSummaryView { - profile_id: self.profile_id.clone(), - profile_kind: self.profile_kind.clone(), - configured_state: self.configured_state.clone(), - profile_delivery_usable: self.profile_delivery_usable, - message: self.message.clone(), - } - } -} - -pub(crate) fn update_app_config_table( - config: &RuntimeConfig, - key: &str, - value: Value, -) -> Result<(), RuntimeError> { - let path = &config.paths.app_config_path; - let mut document = if path.exists() { - let raw = fs::read_to_string(path)?; - toml::from_str::<Value>(&raw) - .map_err(|error| RuntimeError::Config(format!("failed to parse app config: {error}")))? - } else { - Value::Table(Map::new()) - }; - let Some(table) = document.as_table_mut() else { - return Err(RuntimeError::Config( - "app config root must be a TOML table".to_owned(), - )); - }; - table.remove("publish"); - table.remove("relays"); - table.insert(key.to_owned(), value); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - let rendered = toml::to_string_pretty(&document) - .map_err(|error| RuntimeError::Config(format!("failed to render app config: {error}")))?; - fs::write(path, rendered)?; - Ok(()) -} - -fn string_input<'a>(input: &'a OperationData, key: &str) -> Option<&'a str> { - input.get(key).and_then(JsonValue::as_str) -} - -fn string_array_input(input: &OperationData, key: &str) -> Vec<String> { - input - .get(key) - .and_then(JsonValue::as_array) - .into_iter() - .flatten() - .filter_map(JsonValue::as_str) - .map(str::to_owned) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn reticulum_profile_remains_explicitly_unavailable() { - let status = reticulum_transport_status("reticulum"); - assert!(!status.usable_for_delivery); - assert_eq!( - status.endpoint_uri.as_deref(), - Some(RADROOTS_RETICULUM_ENDPOINT_URI) - ); - assert_eq!(status.message, RADROOTS_RETICULUM_UNAVAILABLE_MESSAGE); - } - - #[test] - fn experimental_maturity_has_a_stable_terminal_label() { - assert_eq!( - transport_maturity_label(RadrootsTransportCapabilityMaturity::Experimental), - "experimental" - ); - } -} diff --git a/src/view/mod.rs b/src/view/mod.rs @@ -1 +0,0 @@ -pub mod runtime; diff --git a/src/view/runtime.rs b/src/view/runtime.rs @@ -1,2800 +0,0 @@ -use std::process::ExitCode; - -use radroots_event::farm::Farm; -use radroots_event::id::ClassifiedListingAddress; -use radroots_event::listing::operational::OperationalListingPublicLocation; -use radroots_identity::account::Record as AccountRecord; -use serde::Serialize; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CommandDisposition { - Success, - NotFound, - ValidationFailed, - Unconfigured, - ExternalUnavailable, - Unsupported, - InternalError, -} - -impl CommandDisposition { - pub fn exit_code(self) -> ExitCode { - match self { - Self::Success => ExitCode::SUCCESS, - Self::NotFound => ExitCode::from(4), - Self::ValidationFailed => ExitCode::from(10), - Self::Unconfigured => ExitCode::from(3), - Self::ExternalUnavailable => ExitCode::from(4), - Self::Unsupported => ExitCode::from(5), - Self::InternalError => ExitCode::from(1), - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ConfigShowView { - pub source: String, - pub output: OutputRuntimeView, - pub interaction: InteractionRuntimeView, - pub config_files: ConfigFilesRuntimeView, - pub paths: PathsRuntimeView, - pub logging: LoggingRuntimeView, - pub account: AccountRuntimeView, - pub signer: SignerRuntimeView, - pub publish: PublishRuntimeView, - pub relay: RelayRuntimeView, - pub local: LocalRuntimeView, - pub myc: MycRuntimeView, - pub write_plane: WritePlaneRuntimeView, - pub workflow: WorkflowRuntimeView, - pub hyf_provider: HyfProviderRuntimeView, - pub hyf: HyfRuntimeView, - pub rpc: RpcRuntimeView, - pub capability_bindings: Vec<CapabilityBindingRuntimeView>, - pub resolved_providers: Vec<ResolvedProviderRuntimeView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RuntimeActionView { - pub action: String, - pub runtime_id: String, - pub instance_id: String, - pub instance_source: String, - pub runtime_group: String, - pub state: String, - pub source: String, - pub detail: String, - pub mutates_bindings: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_step: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RuntimeManagedConfigView { - pub runtime_id: String, - pub instance_id: String, - pub instance_source: String, - pub runtime_group: String, - pub state: String, - pub source: String, - pub detail: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub config_format: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub config_path: Option<String>, - pub config_present: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub requires_bootstrap_secret: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub requires_config_bootstrap: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub requires_signer_provider: Option<bool>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RuntimeLogsView { - pub runtime_id: String, - pub instance_id: String, - pub instance_source: String, - pub runtime_group: String, - pub state: String, - pub source: String, - pub detail: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub stdout_log_path: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub stderr_log_path: Option<String>, - pub stdout_log_present: bool, - pub stderr_log_present: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RuntimeStatusView { - pub runtime_id: String, - pub instance_id: String, - pub instance_source: String, - pub runtime_group: String, - pub management_posture: String, - pub state: String, - pub source: String, - pub detail: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub management_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub service_manager_integration: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub uses_absolute_binary_paths: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub preferred_cli_binding: Option<bool>, - pub install_state: String, - pub health_state: String, - pub health_source: String, - pub registry_path: String, - pub lifecycle_actions: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_paths: Option<RuntimeInstancePathsView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub instance_record: Option<RuntimeInstanceRecordView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RuntimeInstancePathsView { - pub install_dir: String, - pub state_dir: String, - pub logs_dir: String, - pub run_dir: String, - pub secrets_dir: String, - pub pid_file_path: String, - pub stdout_log_path: String, - pub stderr_log_path: String, - pub metadata_path: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RuntimeInstanceRecordView { - pub management_mode: String, - pub install_state: String, - pub binary_path: String, - pub config_path: String, - pub logs_path: String, - pub run_path: String, - pub installed_version: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub health_endpoint: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub secret_material_ref: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_started_at: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_stopped_at: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub notes: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct OutputRuntimeView { - pub format: String, - pub verbosity: String, - pub dry_run: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct InteractionRuntimeView { - pub input_enabled: bool, - pub assume_yes: bool, - pub stdin_tty: bool, - pub stdout_tty: bool, - pub prompts_allowed: bool, - pub confirmations_allowed: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ConfigFilesRuntimeView { - pub user_present: bool, - pub workspace_present: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LoggingRuntimeView { - pub initialized: bool, - pub filter: String, - pub stdout: bool, - pub directory: Option<String>, - pub current_file: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct PathsRuntimeView { - pub profile: String, - pub profile_source: String, - pub allowed_profiles: Vec<String>, - pub root_source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub repo_local_root: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub repo_local_root_source: Option<String>, - pub subordinate_path_override_source: String, - pub app_namespace: String, - pub shared_accounts_namespace: String, - pub shared_identities_namespace: String, - pub app_config_path: String, - pub workspace_config_enabled: bool, - pub workspace_config_path: Option<String>, - pub app_data_root: String, - pub shared_cache_root: String, - pub app_logs_root: String, - pub shared_accounts_data_root: String, - pub shared_accounts_secrets_root: String, - pub default_identity_path: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountRuntimeView { - #[serde(skip_serializing_if = "Option::is_none")] - pub selector: Option<String>, - pub store_path: String, - pub secrets_dir: String, - pub identity_path: String, - pub secret_backend: AccountSecretRuntimeView, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountSecretRuntimeView { - pub contract_default_backend: String, - pub allowed_backends: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub host_vault_policy: Option<String>, - pub uses_protected_store: bool, - pub configured_primary: String, - pub state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub active_backend: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SignerRuntimeView { - pub mode: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RelayRuntimeView { - pub count: usize, - pub urls: Vec<String>, - pub publish_policy: String, - pub source: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct PublishRuntimeView { - pub transport: String, - pub source: String, - pub transport_family: String, - pub state: String, - pub executable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - pub signed_write_required: bool, - pub relay: PublishRelayRuntimeView, - pub provider: PublishProviderRuntimeView, -} - -#[derive(Debug, Clone, Serialize)] -pub struct PublishRelayRuntimeView { - pub ready: bool, - pub count: usize, - pub source: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct PublishProviderRuntimeView { - pub provider_runtime_id: String, - pub state: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalRuntimeView { - pub root: String, - pub replica_store_path: String, - pub backups_dir: String, - pub exports_dir: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycRuntimeView { - pub executable: String, - pub status_timeout_ms: u64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct WorkflowRuntimeView { - pub provider_runtime_id: String, - pub binding_model: String, - pub state: String, - pub provenance: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_kind: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option<String>, - pub hyf_helper_state: String, - pub hyf_helper_detail: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct HyfRuntimeView { - pub enabled: bool, - pub executable: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct HyfProviderRuntimeView { - pub provider_runtime_id: String, - pub binding_model: String, - pub state: String, - pub provenance: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_kind: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option<String>, - pub executable: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub protocol_version: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub deterministic_available: Option<bool>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct WritePlaneRuntimeView { - pub provider_runtime_id: String, - pub binding_model: String, - pub state: String, - pub provenance: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_kind: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option<String>, - pub detail: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RpcRuntimeView { - pub url: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ResolvedProviderRuntimeView { - pub capability_id: String, - pub provider_runtime_id: String, - pub binding_model: String, - pub state: String, - pub provenance: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_kind: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct CapabilityBindingRuntimeView { - pub capability_id: String, - pub provider_runtime_id: String, - pub binding_model: String, - pub state: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_kind: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub managed_account_ref: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_ref: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct DoctorView { - pub ok: bool, - pub state: String, - pub account_resolution: AccountResolutionView, - pub checks: Vec<DoctorCheckView>, - pub source: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct DoctorCheckView { - pub name: String, - pub status: String, - pub detail: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct IdentityPublicView { - pub id: String, - pub public_key_hex: String, - pub public_key_npub: String, -} - -impl IdentityPublicView { - pub fn from_public_identity(identity: &radroots_identity::PublicIdentity) -> Self { - Self { - id: identity.id().to_string(), - public_key_hex: identity.public_key().to_hex(), - public_key_npub: radroots_nostr::key::public_key_to_npub(identity.public_key()) - .unwrap_or_else(|_| identity.public_key().to_hex()), - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountSummaryView { - pub id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option<String>, - pub signer: String, - pub custody: String, - pub write_capable: bool, - pub is_default: bool, -} - -impl AccountSummaryView { - pub fn from_account_runtime( - record: &AccountRecord, - signer: &str, - custody: &str, - write_capable: bool, - is_default: bool, - ) -> Self { - Self { - id: record.id().to_string(), - display_name: record.label().map(ToOwned::to_owned), - signer: signer.to_owned(), - custody: custody.to_owned(), - write_capable, - is_default, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountResolutionView { - pub status: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub resolved_account: Option<AccountSummaryView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_account: Option<AccountSummaryView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountWhoamiView { - pub state: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - pub account_resolution: AccountResolutionView, - #[serde(skip_serializing_if = "Option::is_none")] - pub public_identity: Option<IdentityPublicView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl AccountWhoamiView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountNewView { - pub state: String, - pub source: String, - pub account: AccountSummaryView, - pub public_identity: IdentityPublicView, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountImportView { - pub state: String, - pub source: String, - pub account: AccountSummaryView, - pub public_identity: IdentityPublicView, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountUseView { - pub state: String, - pub source: String, - pub default_account_id: String, - pub account: AccountSummaryView, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountClearDefaultView { - pub state: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub cleared_account: Option<AccountSummaryView>, - pub remaining_account_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountRemoveView { - pub state: String, - pub source: String, - pub removed_account: AccountSummaryView, - pub default_cleared: bool, - pub remaining_account_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct AccountListView { - pub source: String, - pub count: usize, - pub accounts: Vec<AccountSummaryView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalInitView { - pub state: String, - pub source: String, - pub local_root: String, - pub replica_store: String, - pub path: String, - pub replica_store_version: String, - pub backup_format_version: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalStatusView { - pub state: String, - pub source: String, - pub local_root: String, - pub canonical_store: String, - pub sdk_storage: String, - pub sdk_root: String, - pub sdk_existed_before_open: bool, - pub storage: SdkStorageStatusView, - pub integrity: SdkIntegrityView, - pub derived_projection: LocalDerivedProjectionStatusView, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl LocalStatusView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "needs_attention" => CommandDisposition::ValidationFailed, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SdkStorageStatusView { - pub backend: String, - pub open_mode: String, - pub writer_policy: String, - pub shutdown: String, - pub wal_enabled: bool, - pub busy_timeout_ms: u32, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SdkIntegrityView { - pub health: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub checked_at_unix_ms: Option<u64>, - pub verified_members: u32, - pub failed_members: u32, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalDerivedProjectionStatusView { - pub state: String, - pub source: String, - pub replica_store: String, - pub path: String, - pub replica_store_version: String, - pub backup_format_version: String, - pub schema_hash: String, - pub counts: LocalReplicaCountsView, - pub sync: LocalReplicaSyncView, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalReplicaCountsView { - pub farms: u64, - pub listings: u64, - pub profiles: u64, - pub relays: u64, - pub event_states: u64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalReplicaSyncView { - pub expected_count: usize, - pub pending_count: usize, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SetupView { - pub state: String, - pub source: String, - pub role: String, - pub account_resolution: AccountResolutionView, - pub local_state: String, - pub local_root: String, - pub relay_state: String, - pub relay_count: usize, - pub farm_state: String, - #[serde(default)] - pub ready: Vec<String>, - #[serde(default)] - pub needs_attention: Vec<String>, - #[serde(default)] - pub next: Vec<String>, -} - -impl SetupView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct StatusView { - pub state: String, - pub source: String, - pub account_resolution: AccountResolutionView, - pub local_state: String, - pub local_root: String, - pub relay_state: String, - pub relay_count: usize, - pub farm_state: String, - #[serde(default)] - pub ready: Vec<String>, - #[serde(default)] - pub needs_attention: Vec<String>, - #[serde(default)] - pub next: Vec<String>, -} - -impl StatusView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmSetupView { - pub state: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub config: Option<FarmConfigSummaryView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl FarmSetupView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmSetView { - pub state: String, - pub source: String, - pub field: String, - pub value: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub config: Option<FarmConfigSummaryView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl FarmSetView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPrivateLocationView { - pub state: String, - pub source: String, - pub farm_addr: Option<String>, - pub farm_d_tag: Option<String>, - pub seller_account_id: Option<String>, - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub label: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub exact_location: Option<FarmPrivateExactLocationView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub public_locality: Option<FarmPrivatePublicLocalityView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub geonames_feature_id: Option<i64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub geonames_country_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub geonames_database_path: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub cleared: Option<bool>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub candidates: Vec<FarmPrivateLocationCandidateView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPrivateExactLocationView { - pub lat: f64, - pub lng: f64, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPrivatePublicLocalityView { - pub primary: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub city: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub region: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub country: Option<String>, - pub geohash5: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPrivateLocationCandidateView { - pub geonames_feature_id: i64, - pub geonames_country_id: String, - pub name: String, - pub display_name: String, - pub exact_location: FarmPrivateExactLocationView, - #[serde(skip_serializing_if = "Option::is_none")] - pub region: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub country: Option<String>, -} - -impl FarmPrivateLocationView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "missing" => CommandDisposition::NotFound, - "no_match" => CommandDisposition::NotFound, - "ambiguous" => CommandDisposition::ValidationFailed, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmRebindView { - pub state: String, - pub source: String, - pub scope: String, - pub path: String, - pub config_present: bool, - pub dry_run: bool, - pub seller_actor_source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_seller_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub to_seller_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub to_seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey_changed: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub publication_state_action: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub config: Option<FarmConfigSummaryView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl FarmRebindView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmStatusView { - pub state: String, - pub source: String, - pub scope: String, - pub path: String, - pub config_present: bool, - pub config_valid: bool, - pub account_state: String, - pub listing_defaults_state: String, - pub transport_profile: String, - pub publish_state: String, - pub publish_executable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub publish_reason: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub config: Option<FarmConfigSummaryView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub missing: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl FarmStatusView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmGetView { - pub state: String, - pub source: String, - pub scope: String, - pub path: String, - pub config_present: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub document: Option<FarmConfigDocumentView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl FarmGetView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" | "missing" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPublishView { - pub state: String, - pub source: String, - pub scope: String, - pub path: String, - pub config_present: bool, - pub dry_run: bool, - pub seller_account_id: String, - pub seller_pubkey: String, - pub seller_actor_source: String, - pub farm_d_tag: String, - pub profile: FarmPublishComponentView, - pub farm: FarmPublishComponentView, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub local_replica: Vec<FarmPublishLocalReplicaView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub missing: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl FarmPublishView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "partial" | "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPublishComponentView { - pub state: String, - pub rpc_method: String, - pub event_kind: u32, - pub deduplicated: bool, - #[serde(default)] - pub target_transport_endpoints: Vec<String>, - #[serde(default)] - pub attempted_transport_endpoints: Vec<String>, - #[serde(default)] - pub accepted_transport_endpoints: Vec<String>, - #[serde(default)] - pub failed_transport_targets: Vec<TransportTargetFailureView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job_status: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job: Option<FarmPublishJobView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event: Option<FarmPublishEventView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPublishLocalReplicaView { - pub component: String, - pub state: String, - pub store_state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub ingest_outcome: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPublishJobView { - pub rpc_method: String, - pub state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub job_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPublishEventView { - pub kind: u32, - pub author: String, - pub content: String, - pub tags: Vec<Vec<String>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_addr: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmConfigSummaryView { - pub scope: String, - pub path: String, - pub seller_account_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - pub seller_actor_source: String, - pub farm_d_tag: String, - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub location_primary: Option<String>, - pub delivery_method: String, - pub publication: FarmPublicationView, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmConfigDocumentView { - pub selection: FarmSelectionView, - pub profile: FarmProfileDraftView, - pub farm: Farm, - pub listing_defaults: FarmListingDefaultsView, - pub publication: FarmPublicationView, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmProfileDraftView { - pub name: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub display_name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub nip05: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub about: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub website: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub picture: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub banner: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub lud06: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub lud16: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub bot: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmSelectionView { - pub scope: String, - pub seller_account_id: String, - pub farm_d_tag: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmListingDefaultsView { - pub delivery_method: String, - pub location: OperationalListingPublicLocation, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FarmPublicationView { - pub profile_state: String, - pub farm_state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile_published_at: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_published_at: Option<u64>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FindView { - pub state: String, - pub source: String, - pub query: String, - pub count: usize, - pub relay_count: usize, - pub replica_store: String, - pub freshness: SyncFreshnessView, - pub results: Vec<FindResultView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub hyf: Option<FindHyfView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl FindView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct FindHyfView { - pub state: String, - pub source: String, - pub rewritten_query: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub query_terms: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MarketReadinessView { - pub protocol_valid: bool, - pub marketplace_eligible: bool, - pub checkout_enabled: bool, - pub primary_bin_verified: bool, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub reason_codes: Vec<String>, -} - -impl MarketReadinessView { - pub fn unavailable(reason_code: impl Into<String>) -> Self { - Self { - protocol_valid: false, - marketplace_eligible: false, - checkout_enabled: false, - primary_bin_verified: false, - reason_codes: vec![reason_code.into()], - } - } - - #[expect( - clippy::too_many_arguments, - reason = "market candidate construction maps projection fields explicitly" - )] - pub fn from_market_projection( - listing_addr: Option<&str>, - primary_bin_id: Option<&str>, - verified_primary_bin_id: Option<&str>, - title: Option<&str>, - category: Option<&str>, - available_amount: Option<i64>, - price_amount: f64, - price_currency: &str, - price_per_amount: f64, - ) -> Self { - let protocol_valid = listing_addr - .is_some_and(|listing_addr| ClassifiedListingAddress::parse(listing_addr).is_ok()); - let marketplace_eligible = protocol_valid - && title.is_some_and(|title| !title.trim().is_empty()) - && category.is_some_and(|category| !category.trim().is_empty()); - let inventory_available = available_amount.is_some_and(|amount| amount > 0); - let primary_bin_available = - primary_bin_id.is_some_and(|primary_bin_id| !primary_bin_id.trim().is_empty()); - let primary_bin_verified = protocol_valid - && primary_bin_available - && primary_bin_id.is_some_and(|primary_bin_id| { - verified_primary_bin_id.is_some_and(|verified_primary_bin_id| { - verified_primary_bin_id.trim() == primary_bin_id.trim() - }) - }); - let price_available = price_amount.is_finite() - && price_amount > 0.0 - && !price_currency.trim().is_empty() - && price_per_amount.is_finite() - && price_per_amount > 0.0; - let checkout_enabled = - marketplace_eligible && inventory_available && primary_bin_verified && price_available; - let mut reason_codes = Vec::new(); - if !protocol_valid { - reason_codes.push("listing_protocol_invalid".to_owned()); - } - if protocol_valid && !marketplace_eligible { - reason_codes.push("listing_marketplace_ineligible".to_owned()); - } - if marketplace_eligible && !checkout_enabled { - reason_codes.push("listing_checkout_disabled".to_owned()); - if !inventory_available { - reason_codes.push("listing_inventory_unavailable".to_owned()); - } - if !primary_bin_available { - reason_codes.push("listing_primary_bin_missing".to_owned()); - } else if !primary_bin_verified { - reason_codes.push("listing_primary_bin_invalid".to_owned()); - } - if !price_available { - reason_codes.push("listing_price_unavailable".to_owned()); - } - } - Self { - protocol_valid, - marketplace_eligible, - checkout_enabled, - primary_bin_verified, - reason_codes, - } - } -} - -#[cfg(test)] -mod market_readiness_tests { - use super::MarketReadinessView; - - const LISTING_ADDR: &str = "30402:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAg"; - - #[test] - fn market_readiness_separates_protocol_marketplace_and_order_request_state() { - let enabled = MarketReadinessView::from_market_projection( - Some(LISTING_ADDR), - Some("bin-1"), - Some("bin-1"), - Some("Eggs"), - Some("eggs"), - Some(1), - 6.0, - "USD", - 1.0, - ); - assert!(enabled.protocol_valid); - assert!(enabled.marketplace_eligible); - assert!(enabled.checkout_enabled); - assert!(enabled.primary_bin_verified); - assert!(enabled.reason_codes.is_empty()); - - let invalid = MarketReadinessView::from_market_projection( - None, - Some("bin-1"), - Some("bin-1"), - Some("Eggs"), - Some("eggs"), - Some(1), - 6.0, - "USD", - 1.0, - ); - assert!(!invalid.protocol_valid); - assert!(!invalid.marketplace_eligible); - assert!(!invalid.checkout_enabled); - assert!(!invalid.primary_bin_verified); - assert_eq!(invalid.reason_codes, vec!["listing_protocol_invalid"]); - - let ineligible = MarketReadinessView::from_market_projection( - Some(LISTING_ADDR), - Some("bin-1"), - Some("bin-1"), - Some(" "), - Some("eggs"), - Some(1), - 6.0, - "USD", - 1.0, - ); - assert!(ineligible.protocol_valid); - assert!(!ineligible.marketplace_eligible); - assert!(!ineligible.checkout_enabled); - assert!(ineligible.primary_bin_verified); - assert_eq!( - ineligible.reason_codes, - vec!["listing_marketplace_ineligible"] - ); - - let order_request_disabled = MarketReadinessView::from_market_projection( - Some(LISTING_ADDR), - Some("bin-1"), - Some("bin-1"), - Some("Eggs"), - Some("eggs"), - Some(0), - 6.0, - "USD", - 1.0, - ); - assert!(order_request_disabled.protocol_valid); - assert!(order_request_disabled.marketplace_eligible); - assert!(!order_request_disabled.checkout_enabled); - assert!(order_request_disabled.primary_bin_verified); - assert_eq!( - order_request_disabled.reason_codes, - vec!["listing_checkout_disabled", "listing_inventory_unavailable"] - ); - - let primary_bin_missing = MarketReadinessView::from_market_projection( - Some(LISTING_ADDR), - None, - Some("bin-1"), - Some("Eggs"), - Some("eggs"), - Some(1), - 6.0, - "USD", - 1.0, - ); - assert!(primary_bin_missing.protocol_valid); - assert!(primary_bin_missing.marketplace_eligible); - assert!(!primary_bin_missing.checkout_enabled); - assert!(!primary_bin_missing.primary_bin_verified); - assert_eq!( - primary_bin_missing.reason_codes, - vec!["listing_checkout_disabled", "listing_primary_bin_missing"] - ); - - let primary_bin_blank = MarketReadinessView::from_market_projection( - Some(LISTING_ADDR), - Some(" "), - Some("bin-1"), - Some("Eggs"), - Some("eggs"), - Some(1), - 6.0, - "USD", - 1.0, - ); - assert_eq!( - primary_bin_blank.reason_codes, - vec!["listing_checkout_disabled", "listing_primary_bin_missing"] - ); - - let primary_bin_invalid = MarketReadinessView::from_market_projection( - Some(LISTING_ADDR), - Some("missing-bin"), - Some("bin-1"), - Some("Eggs"), - Some("eggs"), - Some(1), - 6.0, - "USD", - 1.0, - ); - assert!(primary_bin_invalid.protocol_valid); - assert!(primary_bin_invalid.marketplace_eligible); - assert!(!primary_bin_invalid.checkout_enabled); - assert!(!primary_bin_invalid.primary_bin_verified); - assert_eq!( - primary_bin_invalid.reason_codes, - vec!["listing_checkout_disabled", "listing_primary_bin_invalid"] - ); - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct JobListView { - pub state: String, - pub source: String, - pub rpc_url: String, - pub count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - pub jobs: Vec<JobSummaryView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct JobGetView { - pub state: String, - pub source: String, - pub rpc_url: String, - pub lookup: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job: Option<JobDetailView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct JobWatchView { - pub state: String, - pub source: String, - pub rpc_url: String, - pub job_id: String, - pub interval_ms: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - pub frames: Vec<JobWatchFrameView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct JobSummaryView { - pub id: String, - pub command: String, - pub state: String, - pub terminal: bool, - pub signer: String, - pub requested_at_unix: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub completed_at_unix: Option<u64>, - pub recovered_after_restart: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct JobDetailView { - pub id: String, - pub command: String, - pub state: String, - pub terminal: bool, - pub signer: String, - pub requested_at_unix: u64, - #[serde(skip_serializing_if = "Option::is_none")] - pub completed_at_unix: Option<u64>, - pub recovered_after_restart: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_addr: Option<String>, - pub delivery_policy: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub delivery_quorum: Option<usize>, - pub relay_count: usize, - pub acknowledged_relay_count: usize, - pub required_acknowledged_relay_count: usize, - pub attempt_count: usize, - pub relay_outcome_summary: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attempt_summaries: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct JobWatchFrameView { - pub sequence: usize, - pub observed_at_unix: u64, - pub state: String, - pub terminal: bool, - pub signer: String, - pub summary: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingNewView { - pub state: String, - pub source: String, - pub file: String, - pub listing_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_actor_source: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_d_tag: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub delivery_method: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub location_primary: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl ListingNewView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingValidateView { - pub state: String, - pub source: String, - pub file: String, - pub valid: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_actor_source: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_d_tag: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<ListingValidationIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingValidationIssueView { - pub field: String, - pub message: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub line: Option<usize>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingListView { - pub state: String, - pub source: String, - pub count: usize, - pub draft_dir: String, - pub listings: Vec<ListingSummaryView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl ListingListView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingAppRecordListView { - pub state: String, - pub source: String, - pub count: usize, - pub limit: u32, - pub has_more: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_before_change_seq: Option<i64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub next_before_seq: Option<i64>, - pub runtime_store_db: String, - pub records: Vec<ListingAppRecordSummaryView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl ListingAppRecordListView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingAppRecordSummaryView { - pub record_id: String, - pub seq: i64, - pub change_seq: i64, - pub superseded_count: usize, - pub record_kind: String, - pub status: String, - pub source_runtime: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub owner_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub owner_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - pub exportable: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingAppRecordExportView { - pub state: String, - pub source: String, - pub record_id: String, - pub dry_run: bool, - pub file: String, - pub valid: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_actor_source: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_d_tag: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<ListingValidationIssueView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl ListingAppRecordExportView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "missing" => CommandDisposition::NotFound, - "invalid" | "stale" | "unsupported" => CommandDisposition::ValidationFailed, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingSummaryView { - pub id: String, - pub state: String, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub product_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub category: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_actor_source: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_d_tag: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub location_primary: Option<String>, - pub updated_at_unix: u64, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<ListingValidationIssueView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SellAddView { - pub state: String, - pub source: String, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub product_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub offer: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub price: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub stock: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_name: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub delivery_method: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub location_primary: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl SellAddView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SellShowView { - pub state: String, - pub source: String, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub product_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub category: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub offer: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub price: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub stock: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub delivery_method: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub location_primary: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl SellShowView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SellCheckView { - pub state: String, - pub source: String, - pub file: String, - pub valid: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub product_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_ref: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub issues: Vec<ListingValidationIssueView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl SellCheckView { - pub fn disposition(&self) -> CommandDisposition { - CommandDisposition::Success - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SellMutationView { - pub state: String, - pub operation: String, - pub source: String, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub product_key: Option<String>, - pub listing_addr: String, - #[serde(default)] - pub dry_run: bool, - #[serde(default)] - pub deduplicated: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub transport_profile: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job_status: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl SellMutationView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SellDraftMutationView { - pub state: String, - pub operation: String, - pub source: String, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub product_key: Option<String>, - pub changed_label: String, - pub changed_value: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl SellDraftMutationView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingGetView { - pub state: String, - pub source: String, - pub lookup: String, - #[serde(flatten)] - pub readiness: MarketReadinessView, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub product_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub primary_bin_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub title: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub category: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub location_primary: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub available: Option<FindQuantityView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub price: Option<FindPriceView>, - pub provenance: FindResultProvenanceView, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl ListingGetView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingMutationView { - pub state: String, - pub operation: String, - pub source: String, - pub file: String, - pub listing_id: String, - pub listing_addr: String, - pub seller_account_id: String, - pub seller_pubkey: String, - pub seller_actor_source: String, - pub event_kind: u32, - #[serde(default)] - pub dry_run: bool, - #[serde(default)] - pub deduplicated: bool, - #[serde(default)] - pub target_transport_endpoints: Vec<String>, - #[serde(default)] - pub attempted_transport_endpoints: Vec<String>, - #[serde(default)] - pub accepted_transport_endpoints: Vec<String>, - #[serde(default)] - pub failed_transport_targets: Vec<TransportTargetFailureView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job_status: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub local_replica: Option<ListingMutationLocalReplicaView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job: Option<ListingMutationJobView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event: Option<ListingMutationEventView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl ListingMutationView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingRebindView { - pub state: String, - pub source: String, - pub file: String, - pub listing_id: String, - pub dry_run: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_seller_account_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_seller_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_seller_actor_source: Option<String>, - pub to_seller_account_id: String, - pub to_seller_pubkey: String, - pub to_seller_actor_source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub seller_pubkey_changed: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_listing_addr: Option<String>, - pub to_listing_addr: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr_changed: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub from_farm_d_tag: Option<String>, - pub to_farm_d_tag: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub farm_d_tag_changed: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl ListingRebindView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingMutationLocalReplicaView { - pub state: String, - pub store_state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub ingest_outcome: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingMutationJobView { - pub rpc_method: String, - pub state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub job_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub idempotency_key: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub relay_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub acknowledged_relay_count: Option<usize>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct ListingMutationEventView { - pub kind: u32, - pub author: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub created_at: Option<u64>, - pub content: String, - pub tags: Vec<Vec<String>>, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signature: Option<String>, - pub event_addr: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FindResultView { - pub id: String, - pub product_key: String, - #[serde(flatten)] - pub readiness: MarketReadinessView, - #[serde(skip_serializing_if = "Option::is_none")] - pub listing_addr: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub primary_bin_id: Option<String>, - pub title: String, - pub category: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub summary: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub location_primary: Option<String>, - pub available: FindQuantityView, - pub price: FindPriceView, - pub provenance: FindResultProvenanceView, - #[serde(skip_serializing_if = "Option::is_none")] - pub hyf: Option<FindResultHyfView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FindResultHyfView { - pub state: String, - pub rewritten_query: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub query_terms: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FindQuantityView { - pub total_amount: f64, - pub total_unit: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub label: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub available_amount: Option<i64>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FindPriceView { - pub amount: f64, - pub currency: String, - pub per_amount: f64, - pub per_unit: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct FindResultProvenanceView { - pub origin: String, - pub freshness: String, - pub relay_count: usize, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncFreshnessView { - pub state: String, - pub display: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub age_seconds: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_event_at: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub run: Option<SyncRunFreshnessView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncRunFreshnessView { - pub scope: String, - pub relay_set_fingerprint: String, - pub relay_set_current: bool, - pub last_state: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_attempted_at: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_successful_at: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_completed_at: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub stale_after_seconds: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub fetched_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub ingested_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub skipped_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub unsupported_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub failed_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub failure_reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncQueueView { - pub expected_count: usize, - pub pending_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub total_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub retryable_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub terminal_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub failed_terminal_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub deferred_until_implemented_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub ready_signed_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub publishing_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_attempt_at_ms: Option<i64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_error: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalBackupView { - pub state: String, - pub source: String, - pub backup_kind: String, - pub canonical_store: String, - pub destination: String, - pub file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub event_store_file: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub outbox_file: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub manifest_file: Option<String>, - pub size_bytes: u64, - pub manifest: serde_json::Value, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl LocalBackupView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalRestoreView { - pub state: String, - pub source: String, - pub restore_kind: String, - pub canonical_store: String, - pub backup_source: String, - pub destination: String, - pub event_store_file: String, - pub outbox_file: String, - pub manifest_file: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub destination_event_store_file: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub destination_outbox_file: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub restored_event_store_file: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub restored_outbox_file: Option<String>, - pub manifest: serde_json::Value, - pub verification: serde_json::Value, - pub overwrite: bool, - pub dry_run: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl LocalRestoreView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalExportView { - pub state: String, - pub source: String, - pub format: String, - pub file: String, - pub records: usize, - pub export_version: String, - pub schema_hash: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl LocalExportView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct TransportProfileView { - pub state: String, - pub source: String, - pub profile_id: String, - pub profile_kind: String, - pub configured_state: String, - pub profile_delivery_usable: bool, - pub message: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub nostr_relays: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reticulum_behavior: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reticulum_scope: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reticulum_agent_endpoint: Option<String>, - pub transport_statuses: Vec<TransportRuntimeStatusView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TransportStatusInspectView { - pub state: String, - pub source: String, - pub active_profile: TransportProfileSummaryView, - pub transports: Vec<TransportRuntimeStatusView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TransportProfileSummaryView { - pub profile_id: String, - pub profile_kind: String, - pub configured_state: String, - pub profile_delivery_usable: bool, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TransportRuntimeStatusView { - pub transport: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub endpoint_uri: Option<String>, - pub configured: bool, - pub implementation: String, - pub maturity: String, - pub availability: String, - pub usable_for_delivery: bool, - pub capabilities: TransportOperationCapabilitiesView, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TransportOperationCapabilitiesView { - pub deliver: bool, - pub fetch: bool, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TransportDeliveryInspectView { - pub state: String, - pub source: String, - pub transport_profile: String, - pub total_count: i64, - pub pending_count: i64, - pub retryable_count: i64, - pub terminal_count: i64, - pub deferred_until_implemented_count: i64, - pub ready_signed_count: i64, - pub publishing_count: i64, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_attempt_at_ms: Option<i64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub last_error: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TransportDeliveryRetryView { - pub state: String, - pub source: String, - pub attempted_events: usize, - pub published_events: usize, - pub retryable_events: usize, - pub terminal_events: usize, - pub target_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MeshScopeView { - pub state: String, - pub source: String, - pub scope: String, - pub implementation: String, - pub message: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MeshStatusView { - pub state: String, - pub source: String, - pub scope: String, - pub transport: String, - pub configured: bool, - pub implementation: String, - pub usable_for_delivery: bool, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MeshPolicyCheckView { - pub state: String, - pub source: String, - pub scope: String, - pub policy: String, - pub transport: String, - pub usable_for_delivery: bool, - pub decision: String, - pub deny_reason: String, - pub privacy_class: String, - pub payload_bytes: u64, - pub frame_bytes: u64, - pub max_payload_bytes: u64, - pub max_frame_bytes: u64, - pub compression: String, - pub custom_scopes_enabled: bool, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RpcStatusView { - pub state: String, - pub source: String, - pub url: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub auth_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub default_signer_mode: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub supported_signer_modes: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub daemon_execution_enabled: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub daemon_execution_ready: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub relay_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub available_nip46_signer_sessions: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub job_status_retention: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub retained_jobs: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub accepted_jobs: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub published_jobs: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub failed_jobs: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub recovered_failed_jobs: Option<usize>, - pub session_surface_enabled: bool, - pub methods_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RpcSessionsView { - pub state: String, - pub source: String, - pub url: String, - pub count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - pub sessions: Vec<RpcSessionView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct RpcSessionView { - pub session_id: String, - pub role: String, - pub client_pubkey: String, - pub signer_pubkey: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_pubkey: Option<String>, - pub relay_count: usize, - pub permissions_count: usize, - pub auth_required: bool, - pub authorized: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub expires_in_secs: Option<u64>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncStatusView { - pub state: String, - pub source: String, - pub local_root: String, - pub replica_store: String, - pub configured_transport_target_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub configured_transport_targets: Vec<SyncTransportTargetView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub transport_statuses: Vec<SyncTransportStatusInspectView>, - pub publish_policy: String, - pub freshness: SyncFreshnessView, - pub queue: SyncQueueView, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl SyncStatusView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncActionView { - pub direction: String, - pub state: String, - pub source: String, - pub local_root: String, - pub replica_store: String, - pub configured_transport_target_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub configured_transport_targets: Vec<SyncTransportTargetView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub transport_statuses: Vec<SyncTransportStatusInspectView>, - pub publish_policy: String, - pub freshness: SyncFreshnessView, - pub queue: SyncQueueView, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub target_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub attempted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub accepted_transport_endpoints: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub failed_transport_targets: Vec<TransportTargetFailureView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub fetched_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub ingested_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub publishable_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub published_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub skipped_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub unsupported_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub failed_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub publish_plan: Option<SyncPublishPlanView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason_code: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncTransportTargetView { - pub transport_kind: String, - pub endpoint_uri: String, - pub endpoint_fingerprint: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_scope: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_label: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncTransportStatusInspectView { - pub transport: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub profile_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub endpoint_uri: Option<String>, - pub configured: bool, - pub implementation: String, - pub maturity: String, - pub availability: String, - pub usable_for_delivery: bool, - pub capabilities: TransportOperationCapabilitiesView, - pub message: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct TransportTargetFailureView { - pub transport_kind: String, - pub endpoint_uri: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_scope: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_label: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub transport_outcome_kind: Option<String>, - pub reason: String, -} - -impl SyncActionView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "partial" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncPublishPlanView { - pub selected_author: String, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub event_kinds: Vec<SyncPublishPlanKindView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub authors: Vec<SyncPublishPlanAuthorView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncPublishPlanKindView { - pub kind: u32, - pub pending_count: usize, - pub publishable_count: usize, - pub skipped_count: usize, - pub unsupported_count: usize, - pub failed_count: usize, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncPublishPlanAuthorView { - pub author: String, - pub eligibility: String, - pub pending_count: usize, - pub publishable_count: usize, - pub skipped_count: usize, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncWatchView { - pub state: String, - pub source: String, - pub interval_ms: u64, - pub frames: Vec<SyncWatchFrameView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub actions: Vec<String>, -} - -impl SyncWatchView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct SyncWatchFrameView { - pub sequence: usize, - pub observed_at: u64, - pub state: String, - pub configured_transport_target_count: usize, - pub freshness: SyncFreshnessView, - pub queue: SyncQueueView, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SignerStatusView { - pub mode: String, - pub state: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_account_id: Option<String>, - pub account_resolution: AccountResolutionView, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - pub binding: SignerBindingStatusView, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub write_kinds: Vec<SignerWriteKindReadinessView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub local: Option<LocalSignerStatusView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub myc: Option<MycStatusView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SignerWriteKindReadinessView { - pub command: String, - pub event_kind: u32, - pub permission: String, - pub ready: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SignerSessionActionView { - pub action: String, - pub state: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub mode: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub remote_signer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub client_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub user_pubkey: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub relays: Vec<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub permissions: Vec<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub auth_required: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub authorized: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub auth_url: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub expires_in_secs: Option<u64>, - #[serde(skip_serializing_if = "Option::is_none")] - pub pubkey: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub replayed: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub required: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub closed: Option<bool>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -impl SignerStatusView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "degraded" => CommandDisposition::ExternalUnavailable, - "unavailable" => CommandDisposition::ExternalUnavailable, - "error" => CommandDisposition::InternalError, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct LocalSignerStatusView { - pub account_id: String, - pub public_identity: IdentityPublicView, - pub availability: String, - pub secret_backed: bool, - pub backend: String, -} - -#[derive(Debug, Clone, Serialize)] -pub struct SignerBindingStatusView { - pub capability_id: String, - pub provider_runtime_id: String, - pub binding_model: String, - pub state: String, - pub source: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub target_kind: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub target: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub managed_account_ref: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub signer_session_ref: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub resolved_session_ref: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub matched_session_count: Option<usize>, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycStatusView { - pub executable: String, - pub state: String, - pub source: String, - pub service_status: Option<String>, - pub ready: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub reason: Option<String>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub reasons: Vec<String>, - pub remote_session_count: usize, - #[serde(skip_serializing_if = "Option::is_none")] - pub local_signer: Option<LocalSignerStatusView>, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub remote_sessions: Vec<MycRemoteSessionView>, - #[serde(skip_serializing_if = "Option::is_none")] - pub custody: Option<MycCustodyView>, -} - -impl MycStatusView { - pub fn disposition(&self) -> CommandDisposition { - match self.state.as_str() { - "unconfigured" => CommandDisposition::Unconfigured, - "degraded" => CommandDisposition::ExternalUnavailable, - "unavailable" => CommandDisposition::ExternalUnavailable, - _ => CommandDisposition::Success, - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycRemoteSessionView { - pub connection_id: String, - pub signer_identity: IdentityPublicView, - pub user_identity: IdentityPublicView, - pub relay_count: usize, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub permissions: Vec<String>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycCustodyView { - pub signer: MycCustodyIdentityView, - pub user: MycCustodyIdentityView, - #[serde(skip_serializing_if = "Option::is_none")] - pub discovery_app: Option<MycCustodyIdentityView>, -} - -#[derive(Debug, Clone, Serialize)] -pub struct MycCustodyIdentityView { - pub resolved: bool, - pub selected_account_id: Option<String>, - pub selected_account_state: Option<String>, - pub identity_id: Option<String>, - pub public_key_hex: Option<String>, - pub error: Option<String>, -} diff --git a/tests/target_cli.rs b/tests/target_cli.rs @@ -1,144 +1,43 @@ use assert_cmd::Command; -use radroots_runtime_contract_v1::RUNTIME_OPERATION_DESCRIPTORS_V1; use serde_json::Value; -const UUID_V7: &str = "01890f78-9abc-7def-8abc-123456789abc"; - fn radroots() -> Command { Command::cargo_bin("radroots").expect("radroots binary") } -fn json_output(args: &[&str]) -> Value { - let output = radroots() - .args(args) - .assert() - .success() - .get_output() - .clone(); - serde_json::from_slice(&output.stdout).expect("json stdout") -} - -fn json_failure(args: &[&str]) -> Value { - let output = radroots() - .args(args) - .assert() - .failure() - .get_output() - .clone(); - serde_json::from_slice(&output.stdout).expect("json failure stdout") -} - -#[test] -fn generated_v1_read_operation_runs_from_binary() { - let value = json_output(&["--format", "json", "profile", "inspect"]); - - assert_eq!(value["operation_id"], "profile.inspect"); - assert_eq!(value["kind"], "profile.inspect"); - assert_eq!(value["status"], "ok"); -} - -#[test] -fn binary_rejects_retired_command_surfaces() { - for args in [ - &["workspace", "get"][..], - &["config", "get"], - &["mesh", "status"], - &["trade", "submit"], - &["listing", "archive"], - &["validation", "receipt", "list"], - ] { - radroots().args(args).assert().failure(); - } -} - #[test] -fn reads_reject_idempotency_keys() { - let value = json_failure(&[ - "--format", - "json", - "--idempotency-key", - UUID_V7, - "profile", - "inspect", - ]); - - assert_eq!(value["operation_id"], "profile.inspect"); - assert_eq!(value["status"], "error"); - assert_eq!(value["reason_code"], "invalid_input"); - assert!( - value["errors"][0]["message"] - .as_str() - .expect("error message") - .contains("forbids idempotency") - ); +fn help_exposes_the_resource_command_tree() { + let output = radroots().arg("--help").output().expect("help output"); + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 help"); + assert!(stdout.contains("health")); + assert!(stdout.contains("profile")); } #[test] -fn mutations_require_uuid_v7_idempotency_keys() { - let missing = json_failure(&["--format", "json", "--yes", "profile", "reset"]); - assert_eq!(missing["operation_id"], "profile.reset"); - assert!( - missing["errors"][0]["message"] - .as_str() - .expect("missing idempotency message") - .contains("requires UUIDv7 idempotency") - ); - - let invalid = json_failure(&[ - "--format", - "json", - "--yes", - "--idempotency-key", - "profile-reset", - "profile", - "reset", - ]); - assert_eq!(invalid["operation_id"], "profile.reset"); - assert!( - invalid["errors"][0]["message"] - .as_str() - .expect("invalid idempotency message") - .contains("invalid UUIDv7 idempotency") - ); -} - -#[test] -fn approval_required_operations_accept_explicit_yes_confirmation() { - let value = json_output(&[ - "--format", - "json", - "--dry-run", - "--yes", - "--idempotency-key", - UUID_V7, - "profile", - "reset", - ]); - - assert_eq!(value["operation_id"], "profile.reset"); +fn health_uses_the_registry_backed_facade() { + let output = radroots() + .args(["health", "inspect", "--format", "json"]) + .output() + .expect("health output"); + assert!(output.status.success()); + let value: Value = serde_json::from_slice(&output.stdout).expect("JSON envelope"); + assert_eq!(value["schema"], "radroots.cli.output.v1"); + assert_eq!(value["operation_id"], "health.inspect"); assert_eq!(value["status"], "ok"); - assert_eq!(value["dry_run"], true); - assert_eq!(value["idempotency_key"], UUID_V7); + assert_eq!(value["data"]["ready"], true); + assert_eq!(value["data"]["profile"]["artifact_graph"], "registry"); } #[test] -fn binary_v1_catalog_has_no_retired_operation_ids() { - let operation_ids = RUNTIME_OPERATION_DESCRIPTORS_V1 - .iter() - .map(|descriptor| descriptor.operation_id.as_str()) - .collect::<Vec<_>>(); - - for retired in [ - vec!["workspace", "get"], - vec!["config", "get"], - vec!["mesh", "status"], - vec!["trade", "submit"], - vec!["listing", "archive"], - vec!["validation", "receipt", "list"], - ] - .into_iter() - .map(|parts| parts.join(".")) - { - assert!(!operation_ids.contains(&retired.as_str()), "{retired}"); - } +fn retired_runtime_operations_fail_closed() { + let output = radroots() + .args(["account", "list", "--format", "json"]) + .output() + .expect("account output"); + assert_eq!(output.status.code(), Some(2)); + let value: Value = serde_json::from_slice(&output.stdout).expect("JSON envelope"); + assert_eq!(value["operation_id"], "account.list"); + assert_eq!(value["status"], "error"); + assert_eq!(value["error"]["code"], "unsupported_operation"); }