diff --git a/cli-rs/.gitignore b/cli-rs/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/cli-rs/.gitignore @@ -0,0 +1 @@ +/target diff --git a/cli-rs/Cargo.lock b/cli-rs/Cargo.lock new file mode 100644 index 0000000..ac4aeac --- /dev/null +++ b/cli-rs/Cargo.lock @@ -0,0 +1,5567 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aes" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" +dependencies = [ + "cipher", + "cpubits", + "cpufeatures 0.3.1", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "apple-native-keyring-store" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b350bfd03649e07aa05c0a81b3e15934374e585c98204a57e20b9d49f49bb9a" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-polyfill" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" +dependencies = [ + "critical-section", +] + +[[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" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base-x" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + +[[package]] +name = "base45" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240e56f4d3c453c36faacb695c535a4d5f8c7d23dac175014f32eb0a71012a03" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "bon" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60eafe0d77c3a2fc292c1d1346c3041b33c0a108085a2afabf672b70f69dbbc9" +dependencies = [ + "bon-macros", +] + +[[package]] +name = "bon-macros" +version = "3.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0f9631d8aaaee112c41985d675ef269e02acbd4f33122836af4f0c5f699ff6" +dependencies = [ + "darling", + "ident_case", + "prettyplease", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "borsh" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" +dependencies = [ + "bytes", + "cfg_aliases", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "serde_core", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + +[[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" +dependencies = [ + "serde", +] + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher", +] + +[[package]] +name = "cbor4ii" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0948fe1a10668d439d5fd476904223ab08a6783945d451fb996728e952ebcc7" + +[[package]] +name = "cc" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[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", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "cid" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a304f95f84d169a6f31c4d0a30d784643aaa0bbc9c1e449a2c23e963ec4971" +dependencies = [ + "multibase", + "multihash", + "serde", + "serde_bytes", + "unsigned-varint", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cordyceps" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b9ab7e0ca1d179628fa0172b2b97203c7fa0cd81be2448bd446fb9559ca9261" +dependencies = [ + "loom", + "tracing", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.13.2", + "crossterm_winapi", + "derive_more", + "document-features", + "mio", + "parking_lot", + "rustix", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-encoding-macro" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6a127ecbb3c4632e1525380e04c0c3fcf8dcb44d32a79ea290d8a36906edcd8" +dependencies = [ + "data-encoding", + "data-encoding-macro-internal", +] + +[[package]] +name = "data-encoding-macro-internal" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" +dependencies = [ + "data-encoding", + "syn 3.0.6", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "diatomic-waker" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "signature", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "embedded-io" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" + +[[package]] +name = "embedded-io" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "zlib-rs", +] + +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-buffered" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4421cb78ee172b6b06080093479d3c50f058e7c81b7d577bbb8d118d551d4cd5" +dependencies = [ + "cordyceps", + "diatomic-waker", + "futures-core", + "pin-project-lite", + "spin 0.10.1", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generator" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54ade96dc9003043bce7c035c85a9df5a858bfb2039c5a2e6fdf00f324f6c551" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +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", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[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 = "globset" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "gloo-storage" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e82ce7d41c6d821640a43726f6ff33c924795cc151b62e5a84d0c988c151c980" +dependencies = [ + "gloo-utils", + "js-sys", + "serde", + "serde_json", + "thiserror", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "gloo-utils" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4202275d95a142fa209a1e35e91c250a710c5600731372cd3464a39ed01573d6" +dependencies = [ + "js-sys", + "serde", + "serde_json", + "wasm-bindgen", + "web-sys", +] + +[[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 = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hash32" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[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" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heapless" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" +dependencies = [ + "atomic-polyfill", + "hash32", + "rustc_version", + "serde", + "spin 0.9.9", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hickory-net" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" +dependencies = [ + "async-trait", + "cfg-if", + "data-encoding", + "futures-channel", + "futures-io", + "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni", + "rand 0.10.3", + "thiserror", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "prefix-trie", + "rand 0.10.3", + "ring", + "thiserror", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "hickory-resolver" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" +dependencies = [ + "cfg-if", + "futures-util", + "hickory-net", + "hickory-proto", + "ipconfig", + "ipnet", + "jni", + "moka", + "ndk-context", + "once_cell", + "parking_lot", + "rand 0.10.3", + "resolv-conf", + "smallvec", + "system-configuration", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "hkdf" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" +dependencies = [ + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +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 = "ignore" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + +[[package]] +name = "instability" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipconfig" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222" +dependencies = [ + "socket2", + "widestring", + "windows-registry", + "windows-result", + "windows-sys 0.61.2", +] + +[[package]] +name = "ipld-core" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "090f624976d72f0b0bb71b86d58dc16c15e069193067cb3a3a09d655246cbbda" +dependencies = [ + "cid", + "serde", + "serde_bytes", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jacquard" +version = "0.13.0" +source = "git+https://tangled.org/okami.mom/jacquard?branch=main#87ec0751475d5eed85a5bb4c28fd97c3bc4cce11" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "gloo-storage", + "http", + "httpdate", + "jacquard-api", + "jacquard-common", + "jacquard-identity", + "jacquard-oauth", + "jose-jwk", + "miette", + "regex", + "regex-lite", + "reqwest", + "serde", + "serde_html_form", + "serde_ipld_dagcbor", + "serde_json", + "smol_str", + "thiserror", + "tokio", + "trait-variant", + "webpage", +] + +[[package]] +name = "jacquard-api" +version = "0.13.0" +source = "git+https://tangled.org/okami.mom/jacquard?branch=main#87ec0751475d5eed85a5bb4c28fd97c3bc4cce11" +dependencies = [ + "jacquard-common", + "jacquard-derive", + "jacquard-lexicon", + "miette", + "serde", + "thiserror", +] + +[[package]] +name = "jacquard-common" +version = "0.13.0" +source = "git+https://tangled.org/okami.mom/jacquard?branch=main#87ec0751475d5eed85a5bb4c28fd97c3bc4cce11" +dependencies = [ + "base64", + "bon", + "bytes", + "chrono", + "ciborium", + "ciborium-io", + "cid", + "fluent-uri", + "futures", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.3", + "hashbrown 0.17.1", + "http", + "ipld-core", + "k256", + "maitake-sync", + "miette", + "multibase", + "multihash", + "n0-future", + "oxilangtag", + "p256", + "phf 0.14.0", + "postcard", + "rand 0.9.5", + "regex", + "regex-automata", + "regex-lite", + "reqwest", + "rustls", + "rustversion", + "serde", + "serde_bytes", + "serde_html_form", + "serde_ipld_dagcbor", + "serde_json", + "signature", + "smol_str", + "spin 0.12.3", + "thiserror", + "tokio", + "tokio-tungstenite-wasm-jacquard", + "tokio-util", + "trait-variant", + "unicode-segmentation", +] + +[[package]] +name = "jacquard-derive" +version = "0.13.0" +source = "git+https://tangled.org/okami.mom/jacquard?branch=main#87ec0751475d5eed85a5bb4c28fd97c3bc4cce11" +dependencies = [ + "heck", + "jacquard-lexicon", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "jacquard-identity" +version = "0.13.0" +source = "git+https://tangled.org/okami.mom/jacquard?branch=main#87ec0751475d5eed85a5bb4c28fd97c3bc4cce11" +dependencies = [ + "bon", + "bytes", + "hickory-resolver", + "http", + "jacquard-common", + "jacquard-lexicon", + "miette", + "mini-moka-wasm", + "n0-future", + "percent-encoding", + "reqwest", + "serde", + "serde_html_form", + "serde_json", + "thiserror", + "tokio", + "trait-variant", +] + +[[package]] +name = "jacquard-lexicon" +version = "0.13.0" +source = "git+https://tangled.org/okami.mom/jacquard?branch=main#87ec0751475d5eed85a5bb4c28fd97c3bc4cce11" +dependencies = [ + "cid", + "dashmap", + "heck", + "inventory", + "jacquard-common", + "miette", + "multihash", + "prettyplease", + "proc-macro2", + "quote", + "serde", + "serde_ipld_dagcbor", + "serde_json", + "serde_path_to_error", + "serde_repr", + "serde_with", + "sha2 0.11.0", + "syn 3.0.6", + "thiserror", + "unicode-segmentation", +] + +[[package]] +name = "jacquard-oauth" +version = "0.13.0" +source = "git+https://tangled.org/okami.mom/jacquard?branch=main#87ec0751475d5eed85a5bb4c28fd97c3bc4cce11" +dependencies = [ + "base64", + "bytes", + "chrono", + "dashmap", + "ed25519-dalek", + "elliptic-curve", + "http", + "jacquard-common", + "jacquard-identity", + "jose-jwa", + "jose-jwk", + "k256", + "miette", + "mini-moka-wasm", + "p256", + "p384", + "rand 0.8.8", + "reqwest", + "serde", + "serde_html_form", + "serde_json", + "sha2 0.10.9", + "smallvec", + "smol_str", + "thiserror", + "tokio", + "trait-variant", + "webbrowser", +] + +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jose-b64" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec69375368709666b21c76965ce67549f2d2db7605f1f8707d17c9656801b56" +dependencies = [ + "base64ct", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "jose-jwa" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ab78e053fe886a351d67cf0d194c000f9d0dcb92906eb34d853d7e758a4b3a7" +dependencies = [ + "serde", +] + +[[package]] +name = "jose-jwk" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "280fa263807fe0782ecb6f2baadc28dffc04e00558a58e33bfdb801d11fd58e7" +dependencies = [ + "jose-b64", + "jose-jwa", + "p256", + "p384", + "serde", + "zeroize", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "ecdsa", + "elliptic-curve", + "sha2 0.10.9", +] + +[[package]] +name = "kasuari" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde5057d6143cc94e861d90f591b9303d6716c6b9602309150bd068853c10899" +dependencies = [ + "hashbrown 0.16.1", + "portable-atomic", + "thiserror", +] + +[[package]] +name = "keyring" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2270074a3d26bcac93c1dc5d2845eb4c089e8d761ccf6e0ea266a16004640627" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + +[[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 = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "line-clipping" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e" +dependencies = [ + "bitflags 2.13.2", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "lru" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maitake-sync" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d77c365d697828821727b9bc09e6bc3c518b8c63804e79e1be5a5ae091a7c5f" +dependencies = [ + "cordyceps", + "loom", + "mutex-traits", + "mycelium-bitfield", + "pin-project", + "portable-atomic", + "tracing", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + +[[package]] +name = "match-lookup" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "549e39695cc0b640f3cb378053832db3d2133422d49e8dcae5c866a2aaf1f730" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "cfg-if", + "miette-derive", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mini-moka-wasm" +version = "0.10.99" +source = "git+https://tangled.org/okami.mom/jacquard?branch=main#87ec0751475d5eed85a5bb4c28fd97c3bc4cce11" +dependencies = [ + "crossbeam-channel", + "crossbeam-utils", + "dashmap", + "smallvec", + "tagptr", + "triomphe", +] + +[[package]] +name = "mio" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moka" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9" +dependencies = [ + "crossbeam-channel", + "crossbeam-epoch", + "crossbeam-utils", + "equivalent", + "parking_lot", + "portable-atomic", + "smallvec", + "tagptr", + "uuid", +] + +[[package]] +name = "multibase" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e0e4a371cbf1dfd666b658ba137763edb23c45beb43cfe369b5593cd6b437b6" +dependencies = [ + "base-x", + "base256emoji", + "base45", + "data-encoding", + "data-encoding-macro", +] + +[[package]] +name = "multihash" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c63b00ad74d57e8c9aa870b5fccebf2fd64a308a5aee9f1bb88e4aea19447" +dependencies = [ + "serde", + "unsigned-varint", +] + +[[package]] +name = "mutex-traits" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3929f2b5633d29cf7b6624992e5f3c1e9334f1193423e12d17be4faf678cde3f" + +[[package]] +name = "mycelium-bitfield" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24e0cc5e2c585acbd15c5ce911dff71e1f4d5313f43345873311c4f5efd741cc" + +[[package]] +name = "n0-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2ab99dfb861450e68853d34ae665243a88b8c493d01ba957321a1e9b2312bbe" +dependencies = [ + "cfg_aliases", + "derive_more", + "futures-buffered", + "futures-lite", + "futures-util", + "js-sys", + "pin-project", + "send_wrapper", + "tokio", + "tokio-util", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[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" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "oxilangtag" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3b4eb570abd4a1dcb062c31fd37b832264d9dc7292c3e69acfe926c87b063f" +dependencies = [ + "serde", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2 0.10.9", +] + +[[package]] +name = "palette" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64" +dependencies = [ + "approx", + "libm", + "palette_derive", + "palette_math", +] + +[[package]] +name = "palette_derive" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be" +dependencies = [ + "by_address", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "palette_math" +version = "0.7.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12" +dependencies = [ + "libm", +] + +[[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 = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared 0.14.0", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.8", +] + +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "embedded-io 0.4.0", + "embedded-io 0.6.1", + "heapless", + "serde", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +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 = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.6", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "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 = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "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 = "ratatui" +version = "0.30.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-widgets", + "serde", +] + +[[package]] +name = "ratatui-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c" +dependencies = [ + "bitflags 2.13.2", + "compact_str", + "hashbrown 0.17.1", + "itertools", + "kasuari", + "lru", + "palette", + "serde", + "strum", + "thiserror", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.2", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1" +dependencies = [ + "bitflags 2.13.2", + "hashbrown 0.17.1", + "indoc", + "instability", + "itertools", + "line-clipping", + "ratatui-core", + "serde", + "strum", + "time", + "unicode-segmentation", + "unicode-width 0.2.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + +[[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.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + +[[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 = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.2", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "secret-service" +version = "5.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5107b24b91445dd2aa449a258a1807b63240942157292354dc5bfdbeb8bc6db8" +dependencies = [ + "aes", + "cbc", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hybrid-array", + "num", + "once_cell", + "serde", + "sha2 0.11.0", + "zbus", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "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 = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_html_form" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f0346d7a342ab90f405cfc08f25d15075f944f42fcabbc5eac923829fa6d228" +dependencies = [ + "form_urlencoded", + "indexmap", + "itoa", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_ipld_dagcbor" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2918680761336241e07049131fdc59f63c5691a8aaf592a167c0706b473c5fe5" +dependencies = [ + "cbor4ii", + "ipld-core", + "scopeguard", + "serde", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" +dependencies = [ + "base64", + "bs58", + "chrono", + "hex", + "jiff", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" + +[[package]] +name = "smol_str" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" +dependencies = [ + "borsh", + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + +[[package]] +name = "spin" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0134f9043ed38b087ac4f7d4af44c79e2c9e5094421fe3164f435ce585953b10" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tagptr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "libc", + "num-conv", + "num_threads", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", +] + +[[package]] +name = "tokio-tungstenite-wasm-jacquard" +version = "0.6.99" +source = "git+https://tangled.org/okami.mom/jacquard?branch=main#87ec0751475d5eed85a5bb4c28fd97c3bc4cce11" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "httparse", + "js-sys", + "rustls", + "thiserror", + "tokio", + "tokio-tungstenite", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.15+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trait-variant" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b19a4867a870f6edc4c283f2b455804b1879c0baf0e642f26b03ed8ee262d9d3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "triomphe" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40688ea6389c8171614b25491f71d4a27946e0c7ce2da1c6de27e25abf1a0ae" + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.2.2", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unsigned-varint" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb066959b24b5196ae73cb057f45598450d2c5f71460e98c49b738086eff9c06" + +[[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", + "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" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "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" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "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.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +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 = "webbrowser" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" +dependencies = [ + "jni", + "log", + "ndk-context", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "url", + "web-sys", +] + +[[package]] +name = "webpage" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70862efc041d46e6bbaa82bb9c34ae0596d090e86cbd14bd9e93b36ee6802eac" +dependencies = [ + "html5ever", + "markup5ever_rcdom", + "serde_json", + "url", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-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-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[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", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wisp-core" +version = "2.0.0" +dependencies = [ + "data-encoding", + "flate2", + "ignore", + "jacquard-common", + "jacquard-lexicon", + "mime_guess", + "percent-encoding", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "url", + "wisp-lexicons", +] + +[[package]] +name = "wisp-lexicons" +version = "2.0.0" +dependencies = [ + "jacquard-common", + "jacquard-derive", + "jacquard-lexicon", + "miette", + "serde", + "thiserror", +] + +[[package]] +name = "wisp-ui" +version = "2.0.0" +dependencies = [ + "crossterm", + "ratatui", + "unicode-width 0.2.2", +] + +[[package]] +name = "wispctl" +version = "2.0.0" +dependencies = [ + "anyhow", + "base64", + "bytes", + "chrono", + "clap", + "flate2", + "futures-util", + "http", + "http-body-util", + "hyper", + "hyper-util", + "jacquard", + "jacquard-api", + "jacquard-common", + "jacquard-lexicon", + "keyring", + "mime_guess", + "reqwest", + "rusqlite", + "rustls", + "serde", + "serde_html_form", + "serde_json", + "tempfile", + "tokio", + "webbrowser", + "wisp-core", + "wisp-lexicons", + "wisp-ui", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74801d001b9e7729adb4f1825b67b398185fed424749aa3d8bacf70417137d9a" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + +[[package]] +name = "zerocopy" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" +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" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "serde", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.6", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.6", + "winnow", +] diff --git a/cli-rs/Cargo.toml b/cli-rs/Cargo.toml new file mode 100644 index 0000000..2a95cff --- /dev/null +++ b/cli-rs/Cargo.toml @@ -0,0 +1,67 @@ +[workspace] +resolver = "3" +members = ["crates/*"] + +[workspace.package] +version = "2.0.0" +edition = "2024" +license = "MIT" +rust-version = "1.90" + +[workspace.dependencies] +ratatui = { version = "0.30.2", default-features = false, features = ["crossterm_0_29"] } +crossterm = "0.29.0" +unicode-width = "0.2" +# jacquard from ana's fork: upstream main plus pending scope fixes (see DESIGN.md). +jacquard = { git = "https://tangled.org/okami.mom/jacquard", branch = "main", default-features = false, features = ["api", "dns", "loopback", "websocket", "reqwest-rustls-ring", "reqwest-json", "reqwest-http2", "reqwest-stream"] } +jacquard-common = { git = "https://tangled.org/okami.mom/jacquard", branch = "main" } +# For com.atproto.sync.subscribeRepos types (the facade `streaming` feature drags in app.bsky). +jacquard-api = { git = "https://tangled.org/okami.mom/jacquard", branch = "main", default-features = false, features = ["minimal", "std", "streaming"] } +jacquard-derive = { git = "https://tangled.org/okami.mom/jacquard", branch = "main" } +jacquard-lexicon = { git = "https://tangled.org/okami.mom/jacquard", branch = "main", default-features = false } +miette = "7.6" +anyhow = "1" +clap = { version = "4.6.7", features = ["derive", "env", "wrap_help"] } +tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "net", "signal", "time", "sync", "io-util"] } +serde = { version = "1", default-features = false, features = ["derive", "alloc"] } +serde_json = "1" +thiserror = { version = "2", default-features = false } + +flate2 = { version = "1.1.10", default-features = false, features = ["zlib-rs"] } +sha2 = { version = "0.11.0", default-features = false } +data-encoding = "2.11" +mime_guess = "2.0.5" +ignore = "0.4.33" + +# ring as the one rustls provider; installed at startup by wispctl::tls. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +reqwest = { version = "0.13", default-features = false, features = ["rustls-no-provider", "stream", "json", "query"] } +base64 = "0.23.1" +tempfile = "3.27.0" +futures-util = "0.3.32" + +http = "1" +# Already in the tree via jacquard; used for local wall-clock times in serve. +chrono = { version = "0.4", default-features = false, features = ["clock"] } + +serde_html_form = "0.4" + +webbrowser = "1" + +hyper = { version = "1", default-features = false, features = ["server", "http1"] } +hyper-util = { version = "0.1", default-features = false, features = ["tokio"] } +http-body-util = "0.1" +bytes = "1" +percent-encoding = "2" + +url = "2.5.8" +rusqlite = { version = "0.40.2", features = ["bundled"] } +keyring = { version = "4.2.0", default-features = false, features = ["v1"] } + + +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = true diff --git a/cli-rs/DESIGN.md b/cli-rs/DESIGN.md new file mode 100644 index 0000000..7e99117 --- /dev/null +++ b/cli-rs/DESIGN.md @@ -0,0 +1,88 @@ +# wispctl (Rust) + +A from-scratch Rust port of the TypeScript `cli/` (wispctl 1.3.x). The TS +source is the contract: same commands, flags, defaults, prompts, wording, +on-disk state and network behaviour, unless this file says otherwise. + +## Layout + +| crate | what | effects | +|---|---|---| +| `wisp-lexicons` | `place.wisp.*` types generated by `jacquard-codegen` (`scripts/lexgen.sh`). Never hand-edit. | none | +| `wisp-core` | Pure logic: CID/gzip/MIME rules, ignore rules, tree building, subfs split + expansion, path validation, `_redirects`, generated pages, OAuth scope maths, constants. | none (callbacks for fetches) | +| `wisp-ui` | All terminal output: prompts, spinners, progress, panels, printed lines. ratatui inline viewport. | terminal | +| `wispctl` | clap CLI, auth + account store, XRPC, commands. | everything | + +## Conventions + +- **Functional core, imperative shell.** Put decisions in `wisp-core` as pure + functions over data (`fn plan(...) -> Plan`), keep I/O in `wispctl` thin. + Prefer immutable values, iterators and small enums over mutable state and + flags. Traits only where an effect needs a fake in tests. +- **Never `println!`/`eprintln!`.** Use `wisp_ui`: + - results (listings, `--json`, final URLs) → `wisp_ui::out` / `out_text` + (stdout, so `--json | jq` works); + - status → `note`, `success`, `failure`, `warning`, `info`, `blank`; + - work in flight → `spinner(..)` then `.succeed(..)`/`.fail(..)`; + counted work → `progress(label, total, Direction::Up|Down)`; + - long-lived dashboards → `panel(..)`; + - prompts → `crate::prompts::*` (handles cancel → exit 0, and the no-TTY + case with a hint about which flag to pass). + - colour mapping from the TS code: `pc.bold`→`s::bold`, `pc.dim`→`s::muted`, + `pc.cyan` headline→`s::accent`, `pc.cyan` URL→`s::link`, `pc.green`→`s::ok`, + `pc.yellow`→`s::warn`, `pc.red`→`s::danger`. +- Errors: `anyhow::Result` in `wispctl`; typed errors (`thiserror`-free enums + are fine) in `wisp-core`. `main` prints `Error: {err:#}` and exits 1. +- Keep the TS wording for user-facing messages; agents and scripts grep them. +- Tests next to the code (`#[cfg(test)]`). Port the TS tests + (`cli/**/*.test.ts`) and add fixtures generated from the TS implementation + wherever byte-compatibility matters (CIDs, gzip, JSON shapes). +- Latest crate versions; add deps to `[workspace.dependencies]` in + `cli-rs/Cargo.toml` and reference them with `.workspace = true`. Binary size + matters: `default-features = false`, rustls only, no second async runtime, + no second HTTP stack if jacquard's reqwest already covers it. + +## jacquard + +We build against ana's fork, `https://tangled.org/okami.mom/jacquard` `main` +(upstream `main`, 0.13.0 unreleased, plus fixes pending upstream: repo scope +`collection` params, permission-set rpc audiences per spec, and always writing +`aud` on rpc scopes). `Cargo.lock` pins the commit; `cargo update -p jacquard` +moves it. `scripts/lexgen.sh` runs the fork's `jacquard-codegen` from a local +checkout (`JACQUARD_DIR`, default `../../jacquard`); unlike 0.12 it also accepts +the permission-set lexicons (`aud: "*"`). + +## Compatibility decisions + +- State DB stays at `~/.config/wispctl/state.sqlite`, same `kv` table, same + keys (`account:`, `handle:`, `dir:`, `default_account`, `store_version`, + `dir_oauth_port:`), same JSON account shape, 0600/0700 permissions. Existing + installs keep their accounts, directory links and default account. +- Keychain service `wispctl`. App passwords stay at `app-password:` + (shared with the TS CLI). jacquard OAuth sessions use their own namespace + (`oauth-v2:...` keychain accounts, `oauth_session_v2:` / `oauth_state_v2:` kv + fallback keys) and never read or overwrite the TS CLI's `` / + `oauth_session:` entries; `logout` removes both old and new entries. +- Blob bytes must match what the TS CLI uploads (gzip level 9, Node zlib + header) so unchanged files are reused across both CLIs. + +## Deliberate differences from the TypeScript CLI + +- Records are written through the generated types (`Fs`, `SubfsRecord`, + `Settings`) and jacquard's typed record calls, not hand-built JSON. +- Subfs record keys carry a per-deploy TID (`{site}-{tid}-subfs-1`, + `{site}-{tid}-chunk-0`). Reusing the previous deploy's keys overwrote records + that still referenced reused blobs; the PDS deletes unreferenced blobs + immediately, so the next write failed with `BlobNotFound` whenever the chunk + layout changed. Old records are deleted after the new manifest lands. +- When a directory is too large for one subfs record, splitting descends into + an oversized subdirectory instead of chunking the parent's entries. The TS + planner put a 600-file nested folder into one 180 KB chunk with a + 600-entry directory, which the lexicon's 500-entry limit rejects. +- Uploads use a replenishing worker pool instead of fixed batches; file order + is sorted and deterministic. +- New gzip blobs always use OS byte 3; reuse matches the OS bytes Node/Bun + write on any platform, so blobs from either CLI are reused. +- Directory listings sort case-insensitively instead of with ICU collation. +- Status goes to stderr and results to stdout, so `--json` output is clean. + diff --git a/cli-rs/crates/wisp-core/Cargo.toml b/cli-rs/crates/wisp-core/Cargo.toml new file mode 100644 index 0000000..009e419 --- /dev/null +++ b/cli-rs/crates/wisp-core/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "wisp-core" +description = "Pure wisp.place logic shared by wispctl: site trees, manifests, subfs, paths, redirects, scopes" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +wisp-lexicons = { path = "../wisp-lexicons" } +jacquard-common.workspace = true +serde.workspace = true +serde_json.workspace = true +flate2.workspace = true +sha2.workspace = true +data-encoding.workspace = true +mime_guess.workspace = true +ignore.workspace = true +url.workspace = true +percent-encoding.workspace = true + +jacquard-lexicon.workspace = true + +[dev-dependencies] +tempfile.workspace = true + diff --git a/cli-rs/crates/wisp-core/src/blob.rs b/cli-rs/crates/wisp-core/src/blob.rs new file mode 100644 index 0000000..cdbd2bc --- /dev/null +++ b/cli-rs/crates/wisp-core/src/blob.rs @@ -0,0 +1,196 @@ +//! Byte-compatible blob preparation for the TypeScript CLI. +use sha2::{Digest, Sha256}; +use std::io::{self, Write}; + +pub fn compute_cid(bytes: &[u8]) -> String { + let mut cid = vec![1, 0x55, 0x12, 0x20]; + cid.extend_from_slice(&Sha256::digest(bytes)); + format!( + "b{}", + data_encoding::BASE32_NOPAD + .encode(&cid) + .to_ascii_lowercase() + ) +} + +pub fn gzip(bytes: &[u8]) -> io::Result> { + let mut encoder = flate2::GzBuilder::new() + .mtime(0) + .operating_system(3) + .write(Vec::new(), flate2::Compression::best()); + encoder.write_all(bytes)?; + encoder.finish() +} + +/// gzip OS is platform-specific in Node/Bun; the payload and checksum are not. +pub fn gzip_cid_variants(bytes: &[u8]) -> impl Iterator + '_ { + gzip_variants(bytes).map(|variant| compute_cid(&variant)) +} + +pub fn gzip_variants(bytes: &[u8]) -> impl Iterator> + '_ { + [3, 19, 10, 0, 255].into_iter().map(move |os| { + let mut variant = bytes.to_vec(); + if variant.starts_with(&[0x1f, 0x8b, 8]) && variant.len() >= 10 { + variant[9] = os; + } + variant + }) +} + +pub fn mime_for(path: &str) -> String { + match std::path::Path::new(path) + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .as_deref() + { + Some("js" | "mjs") => "text/javascript".into(), + Some("cjs") => "application/node".into(), + Some("map") => "application/json".into(), + Some("xml") => "application/xml".into(), + Some("woff") => "font/woff".into(), + Some("woff2") => "font/woff2".into(), + Some("ttf") => "font/ttf".into(), + Some("otf") => "font/otf".into(), + Some("ico") => "image/vnd.microsoft.icon".into(), + Some("wav") => "audio/wav".into(), + Some("aiff" | "aif" | "aifc") => "audio/x-aiff".into(), + _ => mime_guess::from_path(path) + .first_or_octet_stream() + .essence_str() + .to_owned(), + } +} + +pub fn should_compress(mime: &str, path: &str) -> bool { + if path == "_redirects" || path.ends_with("/_redirects") { + return false; + } + [ + "text/html", + "text/css", + "text/javascript", + "application/javascript", + "application/json", + "image/svg+xml", + "text/xml", + "application/xml", + "text/plain", + "application/x-javascript", + "audio/wav", + "audio/wave", + "audio/x-wav", + "audio/aiff", + "audio/x-aiff", + ] + .iter() + .any(|prefix| mime.starts_with(prefix)) +} + +pub fn is_text_mime(mime: &str) -> bool { + let mime = mime + .split(';') + .next() + .unwrap_or(mime) + .trim() + .to_ascii_lowercase(); + // Non-text UTF-8 entries from mime-db, the database behind mime-types. + mime.starts_with("text/") + || matches!( + mime.as_str(), + "application/3gpdash-qoe-report+xml" + | "application/beep+xml" + | "application/cda+xml" + | "application/elm+json" + | "application/emergencycalldata.cap+xml" + | "application/fhir+json" + | "application/fhir+xml" + | "application/hl7v2+xml" + | "application/im-iscomposing+xml" + | "application/javascript" + | "application/json" + | "application/manifest+json" + | "application/msc-ivr+xml" + | "application/msc-mixer+xml" + | "application/pidf+xml" + | "application/pidf-diff+xml" + | "application/poc-settings+xml" + | "application/vnd.omads-email+xml" + | "application/vnd.omads-file+xml" + | "application/vnd.omads-folder+xml" + | "application/vnd.syncml+xml" + | "application/vnd.syncml.dm+wbxml" + | "application/vnd.syncml.dm+xml" + | "application/vnd.syncml.dmddf+xml" + | "application/vnd.syncml.dmtnds+xml" + | "application/vnd.wap.wbxml" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + /// The fixture inputs, rebuilt here so only their expected CIDs are checked in. + fn fixture_input(name: &str) -> Vec { + match name { + "empty" => Vec::new(), + "text" => b"hello, wisp!\n".to_vec(), + // xorshift32 over JavaScript's int32 arithmetic, as in generate-blobs.ts. + "random" => { + let mut seed: i32 = 123_456_789; + (0..1024 * 1024) + .map(|_| { + seed ^= seed.wrapping_shl(13); + seed ^= ((seed as u32) >> 17) as i32; + seed ^= seed.wrapping_shl(5); + (seed & 255) as u8 + }) + .collect() + } + other => panic!("unknown fixture {other}"), + } + } + + #[test] + fn typescript_fixtures() { + let meta: serde_json::Value = + serde_json::from_str(include_str!("../tests/fixtures/blobs.json")).unwrap(); + for fixture in meta.as_array().unwrap() { + let name = fixture["name"].as_str().unwrap(); + let raw = fixture_input(name); + assert_eq!(compute_cid(&raw), fixture["cid"], "{name}"); + // The TS gzip differs only in the OS header byte, so one of the + // variants hashes to exactly the bytes it produced. + let actual = gzip(&raw).unwrap(); + assert_eq!(actual[9], 3, "{name}"); + assert!( + gzip_cid_variants(&actual).any(|cid| cid == fixture["gzipCid"]), + "{name}" + ); + } + } + + #[test] + fn mime_typescript_fixture() { + let fixtures: serde_json::Value = + serde_json::from_str(include_str!("../tests/fixtures/mime.json")).unwrap(); + for fixture in fixtures.as_array().unwrap() { + let path = format!("asset.{}", fixture["extension"].as_str().unwrap()); + let mime = mime_for(&path); + assert_eq!(mime, fixture["mime"], "{path}"); + assert_eq!( + is_text_mime(&mime), + fixture["text"].as_bool().unwrap(), + "{path}" + ); + } + } + #[test] + fn types_and_redirects() { + assert_eq!(mime_for("foo.js"), "text/javascript"); + assert!(!should_compress("text/plain", "nested/_redirects")); + assert!(should_compress("audio/wav", "a.wav")); + assert!(is_text_mime("text/html; charset=utf-8")); + assert!(!is_text_mime("image/svg+xml")); + } +} diff --git a/cli-rs/crates/wisp-core/src/constants.rs b/cli-rs/crates/wisp-core/src/constants.rs new file mode 100644 index 0000000..e8e4e61 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/constants.rs @@ -0,0 +1,67 @@ +//! Limits and defaults shared with the hosting service +//! (`packages/@wispplace/constants`). Keep in sync with the TypeScript source. + +pub const MAX_SITE_SIZE: u64 = 300 * 1024 * 1024; +pub const MAX_SITE_SIZE_SUPPORTER: u64 = 700 * 1024 * 1024; +pub const MAX_FILE_SIZE: u64 = 200 * 1024 * 1024; +pub const MAX_FILE_COUNT: usize = 1000; +pub const MAX_BLOB_SIZE: u64 = MAX_FILE_SIZE; + +pub const MAX_PRIVATE_SITE_SIZE: u64 = 100 * 1024 * 1024; +pub const MAX_PRIVATE_SITE_FILE_COUNT: usize = 500; + +pub const GZIP_COMPRESSION_LEVEL: u32 = 9; + +/// Hosting service DID used as the XRPC proxy target unless `--service` says otherwise. +pub const DEFAULT_WISP_SERVICE_DID: &str = "did:web:wisp.place"; +/// Service id fragment in `atproto-proxy: #wisp_xrpc`. +pub const WISP_PROXY_SERVICE_ID: &str = "wisp_xrpc"; + +pub const FS_COLLECTION: &str = "place.wisp.fs"; +pub const SUBFS_COLLECTION: &str = "place.wisp.subfs"; +pub const SETTINGS_COLLECTION: &str = "place.wisp.settings"; + +pub const DEFAULT_IGNORE_PATTERNS: &[&str] = &[ + ".git", + ".git/**", + ".github", + ".github/**", + ".gitlab", + ".gitlab/**", + ".DS_Store", + ".wisp.metadata.json", + ".wisp-metadata.json", + ".env", + ".env.*", + "node_modules", + "node_modules/**", + "Thumbs.db", + "desktop.ini", + "._*", + ".Spotlight-V100", + ".Spotlight-V100/**", + ".Trashes", + ".Trashes/**", + ".fseventsd", + ".fseventsd/**", + ".cache", + ".cache/**", + ".temp", + ".temp/**", + ".tmp", + ".tmp/**", + "__pycache__", + "__pycache__/**", + "*.pyc", + ".venv", + ".venv/**", + "venv", + "venv/**", + "env", + "env/**", + "*.swp", + "*.swo", + ".tangled", + ".tangled/**", + ".wispignore", +]; diff --git a/cli-rs/crates/wisp-core/src/convert.rs b/cli-rs/crates/wisp-core/src/convert.rs new file mode 100644 index 0000000..e61e485 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/convert.rs @@ -0,0 +1,148 @@ +//! Moving trees between the `place.wisp.fs` and `place.wisp.subfs` shapes. +//! They are identical apart from their `$type`s and subfs nodes, which only +//! the fs lexicon lets mark as `flat` (`flat` absent means flat). +use std::collections::BTreeMap; + +use jacquard_common::{deps::smol_str::SmolStr, types::value::Data}; +use wisp_lexicons::place_wisp::{fs, subfs}; + +/// Directory extras without a root `$type`, which names the source lexicon +/// and would be wrong in the other one (records re-add their own). +fn without_type(extra: &Option>) -> Option> { + extra + .as_ref() + .map(|extra| extra.iter().filter(|(key, _)| key.as_str() != "$type")) + .map(|entries| { + entries + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .filter(|extra: &BTreeMap<_, _>| !extra.is_empty()) +} + +pub fn fs_to_subfs(dir: &fs::Directory) -> subfs::Directory { + subfs::Directory { + entries: dir + .entries + .iter() + .map(|entry| subfs::Entry { + name: entry.name.clone(), + node: node_to_subfs(&entry.node), + extra_data: entry.extra_data.clone(), + }) + .collect(), + r#type: dir.r#type.clone(), + extra_data: without_type(&dir.extra_data), + } +} + +fn node_to_subfs(node: &fs::EntryNode) -> subfs::EntryNode { + match node { + fs::EntryNode::File(file) => subfs::EntryNode::File(Box::new(subfs::File { + base64: file.base64, + blob: file.blob.clone(), + encoding: file + .encoding + .as_ref() + .map(|encoding| subfs::FileEncoding::from_value(encoding.as_str().into())), + mime_type: file.mime_type.clone(), + r#type: file.r#type.clone(), + extra_data: file.extra_data.clone(), + })), + fs::EntryNode::Directory(dir) => subfs::EntryNode::Directory(Box::new(fs_to_subfs(dir))), + fs::EntryNode::Subfs(node) => subfs::EntryNode::Subfs(Box::new(subfs::Subfs { + subject: node.subject.clone(), + r#type: node.r#type.clone(), + extra_data: node.extra_data.clone(), + })), + fs::EntryNode::Unknown(data) => subfs::EntryNode::Unknown(data.clone()), + } +} + +pub fn subfs_to_fs(dir: &subfs::Directory) -> fs::Directory { + fs::Directory { + entries: dir + .entries + .iter() + .map(|entry| fs::Entry { + name: entry.name.clone(), + node: node_to_fs(&entry.node), + extra_data: entry.extra_data.clone(), + }) + .collect(), + r#type: dir.r#type.clone(), + extra_data: without_type(&dir.extra_data), + } +} + +fn node_to_fs(node: &subfs::EntryNode) -> fs::EntryNode { + match node { + subfs::EntryNode::File(file) => fs::EntryNode::File(Box::new(fs::File { + base64: file.base64, + blob: file.blob.clone(), + encoding: file + .encoding + .as_ref() + .map(|encoding| fs::FileEncoding::from_value(encoding.as_str().into())), + mime_type: file.mime_type.clone(), + r#type: file.r#type.clone(), + extra_data: file.extra_data.clone(), + })), + subfs::EntryNode::Directory(dir) => fs::EntryNode::Directory(Box::new(subfs_to_fs(dir))), + subfs::EntryNode::Subfs(node) => fs::EntryNode::Subfs(Box::new(fs::Subfs { + flat: None, + subject: node.subject.clone(), + r#type: node.r#type.clone(), + extra_data: node.extra_data.clone(), + })), + subfs::EntryNode::Unknown(data) => fs::EntryNode::Unknown(data.clone()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn round_trips_and_rewrites_types() { + let fs_tree: fs::Directory = serde_json::from_value(json!({ + "$type": "place.wisp.fs#directory", + "type": "directory", + "entries": [ + {"name": "index.html", "node": { + "$type": "place.wisp.fs#file", "type": "file", "encoding": "gzip", + "mimeType": "text/html", "base64": false, + "blob": {"$type": "blob", "ref": {"$link": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"}, "mimeType": "application/octet-stream", "size": 1} + }}, + {"name": "docs", "node": { + "$type": "place.wisp.fs#subfs", "type": "subfs", "flat": true, + "subject": "at://did:plc:untyra7qun43gbecoft5cglc/place.wisp.subfs/x" + }} + ] + })) + .unwrap(); + let subfs_tree = fs_to_subfs(&fs_tree); + let value = serde_json::to_value(&subfs_tree).unwrap(); + assert_eq!( + value["entries"][0]["node"]["$type"], + "place.wisp.subfs#file" + ); + assert_eq!( + value["entries"][1]["node"]["$type"], + "place.wisp.subfs#subfs" + ); + assert!(value["entries"][1]["node"].get("flat").is_none()); + + let back = subfs_to_fs(&subfs_tree); + let back_value = serde_json::to_value(&back).unwrap(); + assert_eq!( + back_value["entries"][0], + serde_json::to_value(&fs_tree).unwrap()["entries"][0] + ); + assert_eq!( + back_value["entries"][1]["node"]["$type"], + "place.wisp.fs#subfs" + ); + } +} diff --git a/cli-rs/crates/wisp-core/src/ignore.rs b/cli-rs/crates/wisp-core/src/ignore.rs new file mode 100644 index 0000000..b84e9f8 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/ignore.rs @@ -0,0 +1,120 @@ +//! Deterministic scans using only wisp ignore rules, not ambient git settings. +use crate::constants::DEFAULT_IGNORE_PATTERNS; +use ignore::gitignore::{Gitignore, GitignoreBuilder}; +use std::{ + io, + path::{Path, PathBuf}, +}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FileInfo { + pub path: PathBuf, + pub relative_path: String, + pub size: u64, +} + +pub fn matcher(root: &Path, custom: &str) -> Result { + let mut builder = GitignoreBuilder::new(root); + builder.case_insensitive(true)?; + for line in DEFAULT_IGNORE_PATTERNS + .iter() + .copied() + .chain(custom.lines().map(str::trim)) + { + if !line.is_empty() && !line.starts_with('#') { + builder.add_line(None, line)?; + } + } + builder.build() +} + +pub fn collect_files(root: &Path) -> io::Result> { + let custom = match std::fs::read_to_string(root.join(".wispignore")) { + Ok(content) => content, + Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(), + Err(err) => return Err(err), + }; + let matcher = matcher(root, &custom).map_err(io::Error::other)?; + let mut result = Vec::new(); + scan(root, root, &matcher, &mut result)?; + result.sort_by(|a, b| a.relative_path.cmp(&b.relative_path)); + Ok(result) +} + +fn scan( + root: &Path, + dir: &Path, + matcher: &Gitignore, + result: &mut Vec, +) -> io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let kind = entry.file_type()?; + if matcher + .matched_path_or_any_parents(&path, kind.is_dir()) + .is_ignore() + { + continue; + } + if kind.is_dir() { + scan(root, &path, matcher, result)?; + } else if kind.is_file() { + let relative = path.strip_prefix(root).map_err(io::Error::other)?; + result.push(FileInfo { + relative_path: relative + .components() + .map(|p| p.as_os_str().to_string_lossy()) + .collect::>() + .join("/"), + size: entry.metadata()?.len(), + path, + }); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn sorted_scan_prunes_directories_and_symlinks() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("nested")).unwrap(); + std::fs::create_dir(root.path().join("node_modules")).unwrap(); + for path in [ + "z.html", + "a.html", + "nested/x.txt", + "nested/keep.txt", + "node_modules/a.js", + ] { + std::fs::write(root.path().join(path), b"hello").unwrap(); + } + std::fs::write(root.path().join(".wispignore"), "*.txt\n!keep.txt\n").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(root.path().join("a.html"), root.path().join("link.html")) + .unwrap(); + let files = collect_files(root.path()).unwrap(); + assert_eq!( + files + .iter() + .map(|file| file.relative_path.as_str()) + .collect::>(), + ["a.html", "nested/keep.txt", "z.html"] + ); + assert!(files.iter().all(|file| file.size == 5)); + } + #[test] + fn defaults_and_negations() { + let root = Path::new("/site"); + let ig = matcher(root, "*.txt\n!keep.txt\nassets/\n").unwrap(); + assert!(ig.matched(root.join(".env"), false).is_ignore()); + assert!(ig.matched(root.join(".ENV"), false).is_ignore()); + assert!(ig.matched(root.join("a.txt"), false).is_ignore()); + assert!(!ig.matched(root.join("keep.txt"), false).is_ignore()); + assert!(ig.matched(root.join("assets"), true).is_ignore()); + assert!(!ig.matched(root.join("assets"), false).is_ignore()); + } +} diff --git a/cli-rs/crates/wisp-core/src/lib.rs b/cli-rs/crates/wisp-core/src/lib.rs new file mode 100644 index 0000000..289dbc6 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/lib.rs @@ -0,0 +1,15 @@ +//! Pure wisp.place logic, free of I/O so it can be tested exhaustively. +//! Network and filesystem effects live in `wispctl`; functions here take +//! data (or small fetch callbacks) and return data. + +pub mod blob; +pub mod constants; +pub mod convert; +pub mod ignore; +pub mod pages; +pub mod path; +pub mod redirects; +pub mod scopes; +pub mod split; +pub mod subfs; +pub mod tree; diff --git a/cli-rs/crates/wisp-core/src/pages.rs b/cli-rs/crates/wisp-core/src/pages.rs new file mode 100644 index 0000000..89c68d9 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/pages.rs @@ -0,0 +1,498 @@ +//! HTML pages shared by the host and local preview server. +use crate::redirects::encode_component; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DirectoryEntry { + pub name: String, + pub is_directory: bool, +} + +fn escape_html(value: &str) -> String { + value + .chars() + .map(|character| match character { + '&' => "&".into(), + '<' => "<".into(), + '>' => ">".into(), + '"' => """.into(), + '\'' => "'".into(), + c => c.to_string(), + }) + .collect() +} + +pub fn generate_404_page() -> String { + NOT_FOUND.to_owned() +} +pub fn generate_site_updating_page() -> String { + SITE_UPDATING.to_owned() +} + +pub fn generate_directory_listing(path: &str, entries: &[DirectoryEntry]) -> String { + // Deliberately avoid ICU data in the CLI; localeCompare ordering differs + // for case ties, punctuation, and non-ASCII names. + let mut sorted: Vec<_> = entries.iter().collect(); + sorted.sort_by_cached_key(|entry| { + ( + !entry.is_directory, + entry.name.to_lowercase(), + entry.name.clone(), + ) + }); + let escaped_path = escape_html(path); + let parent = if path.is_empty() { + "" + } else { + "
  • ../
  • " + }; + let entry_html = sorted + .iter() + .map(|entry| { + let suffix = if entry.is_directory { "/" } else { "" }; + let href = escape_html(&format!("{}{suffix}", encode_component(&entry.name, true))); + let display_name = escape_html(&format!("{}{suffix}", entry.name)); + let class = if entry.is_directory { "folder" } else { "file" }; + format!("
  • {display_name}
  • ") + }) + .collect::>() + .join("\n "); + format!( + r###" + + + + + Index of /{escaped_path} + + + +

    Index of /{escaped_path}

    +
      + {parent} + {entry_html} +
    + + +"### + ) +} + +/// Pure response data; the HTTP shell chooses its own response type. +pub struct PageResponse { + pub body: String, + pub status: u16, + pub headers: [(&'static str, &'static str); 3], +} +pub fn site_updating_response() -> PageResponse { + PageResponse { + body: generate_site_updating_page(), + status: 503, + headers: [ + ("Content-Type", "text/html; charset=utf-8"), + ("Cache-Control", "no-store, no-cache, must-revalidate"), + ("Retry-After", "3"), + ], + } +} + +const NOT_FOUND: &str = r###" + + + + + 404 - Not Found + + + +
    +

    404

    +

    Page not found

    +

    The page you're looking for doesn't exist.

    + ← Back to home +
    + + +"###; + +const SITE_UPDATING: &str = r###" + + + + + Site Updating + + + + +
    +

    Site Updating

    +

    This site is undergoing an update right now. Check back in a moment...

    +
    +
    + +"###; + +#[cfg(test)] +mod tests { + use super::*; + use sha2::{Digest, Sha256}; + fn hash(html: &str) -> String { + data_encoding::HEXLOWER.encode(&Sha256::digest(html.as_bytes())) + } + #[test] + fn typescript_byte_identical_pages() { + let fixture: serde_json::Value = serde_json::from_str( + r###"{ + "notFound": "f5203182cb4c7688985bb3af76b2c8eb09076c411116e9eac9e208f0802bffd6", + "updating": "322a9e3f3bdf55e7e625dc5350479f329996fc0aedcec1d453034dfc7194ac8e", + "cases": [ + { + "path": "", + "entries": [], + "hash": "de254241b198359987130a6239345ba362931cc3c3527acdb2277c045b435c8c" + }, + { + "path": "nested", + "entries": [ + { + "name": "javascript:alert(1) #?&<>\"'", + "isDirectory": true + } + ], + "hash": "b4f84ad4808afeeb0733f8fdc6687b86daf31ca1c12c7d40bfe20f976a6204f0" + }, + { + "path": "current/\"&", + "entries": [ + { + "name": "\">", + "isDirectory": false + } + ], + "hash": "27d645aa72495dd0d2e96a729ac5874c41a9bc640c6dd70ef479030364e2781f" + }, + { + "path": "${parent}/${entryHtml}", + "entries": [ + { + "name": "literal", + "isDirectory": false + } + ], + "hash": "2afe613665dbceca06341d9fff41a06bf3c66e2f5dc0c99d288e5cf86e602ff6" + } + ] +}"###, + ) + .unwrap(); + assert_eq!(hash(&generate_404_page()), fixture["notFound"]); + assert_eq!(hash(&generate_site_updating_page()), fixture["updating"]); + for case in fixture["cases"].as_array().unwrap() { + let entries: Vec<_> = case["entries"] + .as_array() + .unwrap() + .iter() + .map(|entry| DirectoryEntry { + name: entry["name"].as_str().unwrap().into(), + is_directory: entry["isDirectory"].as_bool().unwrap(), + }) + .collect(); + assert_eq!( + hash(&generate_directory_listing( + case["path"].as_str().unwrap(), + &entries + )), + case["hash"] + ); + } + } + #[test] + fn escapes_paths_names_and_attributes() { + let html = generate_directory_listing( + "current/\"&", + &[DirectoryEntry { + name: "\">".into(), + is_directory: false, + }], + ); + for forbidden in [ + "", + "", + " onerror=\"alert(1)\"", + ] { + assert!(!html.contains(forbidden)); + } + for expected in [ + "Index of /current/<script>alert(1)</script>"&", + ""><img src=x onerror="alert(1)">", + "href=\"%22%3E%3Cimg%20src%3Dx%20onerror%3D%22alert%281%29%22%3E\"", + ] { + assert!(html.contains(expected)); + } + } + #[test] + fn encoded_segments_and_parent_navigation() { + let html = generate_directory_listing( + "nested", + &[DirectoryEntry { + name: "javascript:alert(1) #?&<>\"'".into(), + is_directory: true, + }], + ); + assert!(html.contains("
  • ../
  • ")); + assert!(html.contains( + "href=\"javascript%3Aalert%281%29%20%23%3F%26%3C%3E%22%27/\" class=\"folder\"" + )); + assert!(!html.contains("href=\"javascript:")); + assert!(html.contains("javascript:alert(1) #?&<>"'/")); + } + #[test] + fn updating_response_headers() { + let response = site_updating_response(); + assert_eq!(response.status, 503); + assert_eq!(response.body, generate_site_updating_page()); + assert_eq!(response.headers[2], ("Retry-After", "3")); + } +} diff --git a/cli-rs/crates/wisp-core/src/path.rs b/cli-rs/crates/wisp-core/src/path.rs new file mode 100644 index 0000000..94725b1 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/path.rs @@ -0,0 +1,296 @@ +//! Fail-closed path validation shared by storage, pull, and local serving. +use std::fmt; + +fn js_length(value: &str) -> usize { + value.encode_utf16().count() +} + +fn drive_prefix(segment: &str) -> bool { + let bytes = segment.as_bytes(); + bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' +} + +pub fn normalize_site_path(path: &str, allow_trailing_slash: bool) -> Option<&str> { + if js_length(path) > 4096 + || path.starts_with('/') + || path.contains('\\') + || path.chars().any(char::is_control) + || path.as_bytes().windows(3).any(|part| { + part[0] == b'%' + && matches!( + (part[1].to_ascii_lowercase(), part[2].to_ascii_lowercase()), + (b'0' | b'1', b'0'..=b'9' | b'a'..=b'f') + | (b'7', b'f') + | (b'2', b'e' | b'f') + | (b'5', b'c') + ) + }) + || path.split('/').any(drive_prefix) + { + return None; + } + if path.is_empty() { + return Some(path); + } + let canonical = if let Some(path) = path.strip_suffix('/') { + if !allow_trailing_slash || path.is_empty() || path.ends_with('/') { + return None; + } + path + } else { + path + }; + let segments: Vec<_> = canonical.split('/').collect(); + (segments.len() <= 128 + && segments.iter().all(|part| { + !part.is_empty() && *part != "." && *part != ".." && js_length(part) <= 255 + })) + .then_some(canonical) +} + +/// Legacy lossy sanitizer; do not use at trust boundaries. +pub fn sanitize_path(path: &str) -> String { + path.replace('\\', "/") + .split('/') + .map(|part| if drive_prefix(part) { &part[2..] } else { part }) + .filter(|part| { + !part.is_empty() && *part != "." && *part != ".." && !part.chars().any(char::is_control) + }) + .collect::>() + .join("/") +} + +pub fn normalize_path(path: &str) -> &str { + path.split_once('/').map_or(path, |(_, rest)| rest) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PathError; + +impl fmt::Display for PathError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Site contains an invalid file path") + } +} +impl std::error::Error for PathError {} + +fn safe_local_path(path: &str) -> bool { + !path + .split('/') + .any(|part| part.contains(':') || part.ends_with(['.', ' '])) +} + +pub fn require_site_file_path(path: &str) -> Result<&str, PathError> { + let canonical = normalize_site_path(path, false) + .filter(|path| !path.is_empty()) + .ok_or(PathError)?; + if !safe_local_path(canonical) + || canonical.split('/').any(|part| { + let stem = part.split('.').next().unwrap_or("").to_ascii_lowercase(); + matches!(stem.as_str(), "con" | "prn" | "aux" | "nul") + || (stem.len() == 4 + && (stem.starts_with("com") || stem.starts_with("lpt")) + && matches!(stem.as_bytes()[3], b'1'..=b'9')) + }) + { + return Err(PathError); + } + Ok(canonical) +} + +/// Input is already percent-decoded, just as in the TypeScript routing helper. +pub fn normalize_serve_request_path(pathname: &str) -> Option { + let raw = pathname.strip_prefix('/')?; + let path = normalize_site_path(raw, true)?; + if !safe_local_path(path) { + return None; + } + Some(if path.is_empty() { + "/".into() + } else { + format!("/{path}{}", if raw.ends_with('/') { "/" } else { "" }) + }) +} + +pub fn normalize_configured_site_path(path: &str) -> Option<&str> { + let candidate = path.strip_prefix('/').unwrap_or(path); + let path = normalize_site_path(candidate, false)?; + (!path.is_empty() && safe_local_path(path)).then_some(path) +} + +pub fn normalize_rewrite_path(path: &str) -> Option { + normalize_serve_request_path(path) + .or_else(|| normalize_configured_site_path(path).map(|path| format!("/{path}"))) +} + +/// Strict decodeURIComponent equivalent followed by request-path validation. +pub fn decode_serve_request_path(path: &str) -> Option { + let mut decoded = Vec::with_capacity(path.len()); + let mut bytes = path.bytes(); + while let Some(byte) = bytes.next() { + decoded.push(if byte == b'%' { + let high = (bytes.next()? as char).to_digit(16)?; + let low = (bytes.next()? as char).to_digit(16)?; + (high * 16 + low) as u8 + } else { + byte + }); + } + normalize_serve_request_path(std::str::from_utf8(&decoded).ok()?) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn canonical_paths_and_trailing_slashes() { + for path in [ + "", + "assets/images/logo.svg", + "did:plc:example/site/index.html", + "café/☃.html", + ] { + assert_eq!(normalize_site_path(path, false), Some(path)); + } + assert_eq!(normalize_site_path("assets/", false), None); + assert_eq!(normalize_site_path("assets/", true), Some("assets")); + for path in ["/", "assets//", "assets//file", "./", "../"] { + assert_eq!(normalize_site_path(path, true), None); + } + } + + #[test] + fn rejects_all_ts_attack_paths() { + for path in [ + "/absolute/path", + "//network/share", + "../secret.txt", + "nested/../secret.txt", + "nested/./file.txt", + "nested//file.txt", + "..\\secret.txt", + "nested\\file.txt", + "C:/Windows/system.ini", + "C:\\Windows\\system.ini", + "assets/C:/Windows/system.ini", + "file\0name.txt", + "file\u{1f}name.txt", + "file\u{7f}name.txt", + "file\u{85}name.txt", + "%00name.txt", + "%2e%2e%2fsecret.txt", + "%2E%2E%5Csecret.txt", + "%1fname", + "%7Fname", + ] { + assert_eq!(normalize_site_path(path, false), None, "{path:?}"); + } + } + + #[test] + fn exact_utf16_limits() { + assert!(normalize_site_path(&"a".repeat(255), false).is_some()); + assert!(normalize_site_path(&"a".repeat(256), false).is_none()); + assert!(normalize_site_path(&"😀".repeat(127), false).is_some()); + assert!(normalize_site_path(&"😀".repeat(128), false).is_none()); + assert!(normalize_site_path(&vec!["a"; 128].join("/"), false).is_some()); + assert!(normalize_site_path(&vec!["a"; 129].join("/"), false).is_none()); + let path = vec!["a".repeat(255); 16].join("/"); + assert_eq!(path.len(), 4095); + assert!(normalize_site_path(&path, false).is_some()); + assert!(normalize_site_path(&format!("{path}/a"), false).is_none()); + } + + #[test] + fn legacy_sanitizers() { + for (input, expected) in [ + ("..\\outside\\file.txt", "outside/file.txt"), + ("C:\\sites\\index.html", "sites/index.html"), + ("assets/C:/Windows/index.html", "assets/Windows/index.html"), + ("nested\\..\\safe.txt", "nested/safe.txt"), + ("/./a/../b\0/c", "a/c"), + ] { + assert_eq!(sanitize_path(input), expected); + } + assert_eq!( + normalize_path("base/nested/index.html"), + "nested/index.html" + ); + assert_eq!(normalize_path("index.html"), "index.html"); + } + + #[test] + fn pull_rejects_windows_unsafe_names() { + for path in [ + "", + "a:b", + "file.", + "file ", + "con", + "CON.txt", + "x/nul.html", + "Lpt9", + "COM1.tar.gz", + "prn", + "aux", + ] { + assert!(require_site_file_path(path).is_err(), "{path}"); + } + for path in [ + "index.html", + "com0", + "com10", + "lpt0.txt", + "console.txt", + "a b/c.txt", + ] { + assert_eq!(require_site_file_path(path), Ok(path)); + } + } + + #[test] + fn serving_paths_and_strict_decoding() { + for path in ["/", "/assets/", "/index.html", "/con"] { + assert_eq!(normalize_serve_request_path(path), Some(path.into())); + } + for path in [ + "index.html", + "//", + "/a//", + "/a:b", + "/a.", + "/a ", + "/%2e", + "/../x", + ] { + assert_eq!(normalize_serve_request_path(path), None); + } + for path in [ + "/%", + "/%ff", + "/%c0%af", + "/%ed%a0%80", + "/%00", + "/%252e%252e", + "/%2e%2e/x", + "/%5c", + ] { + assert_eq!(decode_serve_request_path(path), None, "{path}"); + } + assert_eq!( + decode_serve_request_path("/caf%C3%A9+file"), + Some("/café+file".into()) + ); + assert_eq!( + normalize_configured_site_path("/index.html"), + Some("index.html") + ); + assert_eq!(normalize_configured_site_path("/"), None); + assert_eq!(normalize_configured_site_path("assets/"), None); + assert_eq!( + normalize_rewrite_path("index.html"), + Some("/index.html".into()) + ); + } +} diff --git a/cli-rs/crates/wisp-core/src/redirects.rs b/cli-rs/crates/wisp-core/src/redirects.rs new file mode 100644 index 0000000..a091aa3 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/redirects.rs @@ -0,0 +1,860 @@ +//! Bounded, token-based Netlify-style `_redirects` parsing and matching. +use std::collections::HashSet; + +pub const MAX_REDIRECT_FILE_BYTES: usize = 1_000_000; +/// Ordered records preserve JavaScript's query-string insertion order. +pub type StringRecord = Vec<(String, String)>; + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Conditions { + pub country: Option>, + pub language: Option>, + pub role: Option>, + pub cookie: Option>, +} +#[derive(Clone, Debug)] +pub struct RedirectRule { + pub from: String, + pub to: String, + pub status: u16, + pub force: bool, + pub conditions: Conditions, + pub query_params: StringRecord, + compiled: CompiledPath, +} +#[derive(Clone, Debug, Default)] +pub struct MatchRedirectContext { + pub query_params: StringRecord, + pub headers: StringRecord, + pub cookies: StringRecord, +} +#[derive(Debug)] +pub struct RedirectMatch<'a> { + pub rule: &'a RedirectRule, + pub target_path: String, + pub status: u16, +} +#[derive(Clone, Debug)] +struct Segment { + prefix: String, + param: Option, + suffix: String, +} +#[derive(Clone, Debug)] +struct CompiledPath { + segments: Vec, + params: Vec, + splat: Option<(String, bool)>, +} +fn js_whitespace(c: char) -> bool { + matches!(c, '\u{0009}'..='\u{000d}' | ' ' | '\u{00a0}' | '\u{1680}' | '\u{2000}'..='\u{200a}' | '\u{2028}' | '\u{2029}' | '\u{202f}' | '\u{205f}' | '\u{3000}' | '\u{feff}') +} +fn js_trim(value: &str) -> &str { + value.trim_matches(js_whitespace) +} +fn js_len(value: &str) -> usize { + value.encode_utf16().count() +} +fn get<'a>(record: &'a StringRecord, key: &str) -> Option<&'a str> { + record + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) +} +fn set(record: &mut StringRecord, key: String, value: String, bounded: bool) { + if let Some((_, old)) = record.iter_mut().find(|(k, _)| *k == key) { + *old = value; + } else if !bounded || (!key.is_empty() && record.len() < 32) { + // Object.entries enumerates canonical array-index keys before strings. + let index_key = |key: &str| { + key.parse::() + .ok() + .filter(|n| *n != u32::MAX && n.to_string() == key) + }; + let position = index_key(&key) + .map(|index| { + record + .iter() + .position(|(k, _)| index_key(k).is_none_or(|n| n > index)) + .unwrap_or(record.len()) + }) + .unwrap_or(record.len()); + record.insert(position, (key, value)); + } +} +fn safe_decode(value: &str) -> String { + let mut bytes = Vec::with_capacity(value.len()); + let mut input = value.as_bytes().iter().copied(); + while let Some(byte) = input.next() { + if byte == b'%' { + let Some(high) = input.next().and_then(|v| (v as char).to_digit(16)) else { + return value.into(); + }; + let Some(low) = input.next().and_then(|v| (v as char).to_digit(16)) else { + return value.into(); + }; + bytes.push((high * 16 + low) as u8); + } else { + bytes.push(byte); + } + } + String::from_utf8(bytes).unwrap_or_else(|_| value.into()) +} +pub(crate) fn encode_component(value: &str, strict: bool) -> String { + value + .bytes() + .map(|b| { + if b.is_ascii_alphanumeric() + || b"-_.~".contains(&b) + || (!strict && b"!'()*".contains(&b)) + { + (b as char).to_string() + } else { + format!("%{b:02X}") + } + }) + .collect() +} +pub fn parse_query_string(url: &str) -> StringRecord { + let mut result = Vec::new(); + if let Some((_, query)) = url.split_once('?') { + for pair in query.split('&').filter(|v| !v.is_empty()) { + let (key, value) = pair.split_once('=').unwrap_or((pair, "")); + if !key.is_empty() { + set(&mut result, safe_decode(key), safe_decode(value), true); + } + } + } + result +} +pub fn parse_cookies(header: Option<&str>) -> StringRecord { + let mut result = Vec::new(); + for part in header.unwrap_or("").split(';') { + if let Some((key, value)) = part.split_once('=') + && !key.is_empty() + { + set( + &mut result, + js_trim(key).into(), + js_trim(value).into(), + false, + ); + } + } + result +} +pub fn parse_redirects_file_bytes(data: &[u8]) -> Option> { + parse_redirects_file_bytes_with(data, |bytes| { + String::from_utf8_lossy(bytes) + .trim_start_matches('\u{feff}') + .into() + }) +} +pub fn parse_redirects_file_bytes_with( + data: &[u8], + decode: impl FnOnce(&[u8]) -> String, +) -> Option> { + (data.len() <= MAX_REDIRECT_FILE_BYTES).then(|| parse_redirects_file(&decode(data))) +} +pub fn parse_redirects_file(content: &str) -> Vec { + let mut units = 0; + let end = content.char_indices().find_map(|(i, c)| { + units += c.len_utf16(); + (units > MAX_REDIRECT_FILE_BYTES).then_some(i) + }); + let limited = &content[..end.unwrap_or(content.len())]; + let limited = if end.is_some() && !limited.ends_with('\n') { + limited.rsplit_once('\n').map_or("", |(head, _)| head) + } else { + limited + }; + limited + .split('\n') + .filter(|line| js_len(line) <= 8192) + .map(js_trim) + .filter(|line| !line.is_empty() && !line.starts_with('#')) + .filter_map(parse_line) + .take(1000) + .collect() +} +fn parse_line(line: &str) -> Option { + let mut parts = line.split(js_whitespace).filter(|v| !v.is_empty()); + let from = parts.next()?; + let mut query_params = Vec::new(); + if let Some((_, query)) = from.split_once('?') { + for pair in query.split('&') { + if let Some((key, value)) = pair.split_once('=') + && !key.is_empty() + && !value.is_empty() + { + set( + &mut query_params, + safe_decode(key), + safe_decode(value), + true, + ); + } + } + } + let to = loop { + let part = parts.next()?; + if part.starts_with('/') || part.starts_with("http://") || part.starts_with("https://") { + break part; + } + let Some((key, value)) = part.split_once('=') else { + break part; + }; + if !key.is_empty() && !value.is_empty() { + set(&mut query_params, key.into(), value.into(), true); + } + }; + let mut status = 301; + let mut force = false; + let mut conditions = Conditions::default(); + for part in parts { + let digits = part.strip_suffix('!').unwrap_or(part); + if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) { + force |= part.ends_with('!'); + status = digits.parse().ok()?; + } else if let Some((key, value)) = part.split_once('=') + && !value.is_empty() + { + let key = key.to_lowercase(); + let values = value + .split(',') + .map(|v| { + if key == "role" { + js_trim(v).into() + } else { + js_trim(v).to_lowercase() + } + }) + .collect(); + match key.as_str() { + "country" => conditions.country = Some(values), + "language" => conditions.language = Some(values), + "role" => conditions.role = Some(values), + "cookie" => conditions.cookie = Some(values), + _ => {} + } + } + } + Some(RedirectRule { + from: from.into(), + to: to.into(), + status, + force, + conditions, + query_params, + compiled: compile_path(from)?, + }) +} +fn parse_segment(segment: &str, params: &mut Vec) -> Option { + let mut placeholder = None; + let bytes = segment.as_bytes(); + for (i, byte) in bytes.iter().enumerate() { + if *byte != b':' + || !bytes + .get(i + 1) + .is_some_and(|b| b.is_ascii_alphabetic() || *b == b'_') + { + continue; + } + if placeholder.is_some() { + return None; + } + let end = (i + 1..bytes.len()) + .find(|&j| !bytes[j].is_ascii_alphanumeric() && bytes[j] != b'_') + .unwrap_or(bytes.len()); + placeholder = Some((i, end)); + } + if let Some((start, end)) = placeholder { + if params.len() >= 32 { + return None; + } + let name = segment[start + 1..end].to_owned(); + params.push(name.clone()); + Some(Segment { + prefix: segment[..start].into(), + param: Some(name), + suffix: segment[end..].into(), + }) + } else { + Some(Segment { + prefix: segment.into(), + param: None, + suffix: String::new(), + }) + } +} +fn parse_segments(path: &str, params: &mut Vec) -> Option> { + let pieces: Vec<_> = path.split('/').collect(); + if pieces.len() > 128 { + return None; + } + pieces + .into_iter() + .map(|p| parse_segment(p, params)) + .collect() +} +fn compile_path(from: &str) -> Option { + let path = from.split('?').next()?; + if path.is_empty() || js_len(path) > 2048 { + return None; + } + let mut params = Vec::new(); + let (segments, splat) = if let Some(star) = path.find('*') { + if star != path.len() - 1 { + return None; + } + let before = &path[..star]; + let (segments, prefix, separator) = if let Some((head, tail)) = before.rsplit_once('/') { + (parse_segments(head, &mut params)?, tail, true) + } else { + (Vec::new(), before, false) + }; + if parse_segment(prefix, &mut params)?.param.is_some() || params.len() >= 32 { + return None; + } + params.push("splat".into()); + (segments, Some((prefix.into(), separator))) + } else { + (parse_segments(path, &mut params)?, None) + }; + Some(CompiledPath { + segments, + params, + splat, + }) +} +fn match_segments(pattern: &[Segment], request: &[&str], complete: bool) -> Option> { + if request.len() < pattern.len() || (complete && request.len() != pattern.len()) { + return None; + } + pattern + .iter() + .zip(request) + .try_fold(Vec::new(), |mut captures, (segment, value)| { + if segment.param.is_none() { + if *value != segment.prefix { + return None; + } + } else { + let capture = value + .strip_prefix(&segment.prefix)? + .strip_suffix(&segment.suffix)?; + if capture.is_empty() || capture.contains('?') { + return None; + } + captures.push(capture.into()); + } + Some(captures) + }) +} +fn match_path(compiled: &CompiledPath, path: &str, request: &[&str]) -> Option> { + if let Some((prefix, separator)) = &compiled.splat { + let mut captures = match_segments(&compiled.segments, request, false)?; + let start = request[..compiled.segments.len()].join("/").len(); + let remaining = path.get(start..)?; + let remaining = if *separator { + remaining.strip_prefix('/')? + } else { + remaining + }; + captures.push(remaining.strip_prefix(prefix)?.into()); + Some(captures) + } else { + match_segments(&compiled.segments, request, true).or_else(|| { + (request.last() == Some(&"")) + .then(|| match_segments(&compiled.segments, &request[..request.len() - 1], true)) + .flatten() + }) + } +} +fn matches_conditions(conditions: &Conditions, context: &MatchRedirectContext) -> bool { + if conditions.role.is_some() { + return false; + } + if let Some(countries) = &conditions.country { + let country = get(&context.headers, "cf-ipcountry") + .filter(|v| !v.is_empty()) + .or_else(|| get(&context.headers, "x-country")); + if !country.is_some_and(|v| countries.contains(&v.to_lowercase())) { + return false; + } + } + if let Some(languages) = &conditions.language { + let Some(header) = get(&context.headers, "accept-language").filter(|v| !v.is_empty()) + else { + return false; + }; + if !header + .split(',') + .map(|v| js_trim(v.split(';').next().unwrap_or("")).to_lowercase()) + .filter(|v| !v.is_empty()) + .any(|v| { + languages + .iter() + .any(|lang| v == *lang || v.starts_with(&format!("{lang}-"))) + }) + { + return false; + } + } + if let Some(cookies) = &conditions.cookie + && !cookies + .iter() + .any(|key| get(&context.cookies, key).is_some()) + { + return false; + } + true +} +pub fn match_redirect_rule<'a>( + request_path: &str, + rules: &'a [RedirectRule], + context: Option<&MatchRedirectContext>, +) -> Option> { + match_redirect_rule_with_visited(request_path, rules, context, &mut HashSet::new()) +} +pub fn match_redirect_rule_with_visited<'a>( + request_path: &str, + rules: &'a [RedirectRule], + context: Option<&MatchRedirectContext>, + visited: &mut HashSet, +) -> Option> { + let path = if request_path.starts_with('/') { + request_path.to_owned() + } else { + format!("/{request_path}") + }; + if js_len(&path) > 8192 || !visited.insert(path.clone()) || visited.len() > 10 { + return None; + } + let empty = MatchRedirectContext::default(); + let context = context.unwrap_or(&empty); + let segments: Vec<_> = path.split('/').collect(); + for rule in rules { + if !rule.query_params.iter().all(|(key, value)| { + get(&context.query_params, key) + .is_some_and(|v| value.is_empty() || value.starts_with(':') || v == value) + }) || !matches_conditions(&rule.conditions, context) + { + continue; + } + let Some(captures) = match_path(&rule.compiled, &path, &segments) else { + continue; + }; + let mut target = rule.to.clone(); + for (param, value) in rule.compiled.params.iter().zip(captures) { + let encoded = encode_component(&value, false); + let encoded = if param == "splat" { + encoded.replace("%2F", "/") + } else { + encoded + }; + target = target.replacen(&format!(":{param}"), &encoded, 1); + } + for (key, placeholder) in &rule.query_params { + if let Some(param) = placeholder.strip_prefix(':').filter(|v| !v.is_empty()) + && let Some(value) = get(&context.query_params, key).filter(|v| !v.is_empty()) + { + target = target.replacen(&format!(":{param}"), &encode_component(value, false), 1); + } + } + if [200, 301, 302].contains(&rule.status) + && !target.contains('?') + && !context.query_params.is_empty() + { + let query = context + .query_params + .iter() + .map(|(k, v)| { + format!( + "{}={}", + encode_component(k, false), + encode_component(v, false) + ) + }) + .collect::>() + .join("&"); + target.push('?'); + target.push_str(&query); + } + return Some(RedirectMatch { + rule, + target_path: target, + status: rule.status, + }); + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + fn context( + query: &[(&str, &str)], + headers: &[(&str, &str)], + cookies: &[(&str, &str)], + ) -> MatchRedirectContext { + let pairs = |p: &[(&str, &str)]| { + p.iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + }; + MatchRedirectContext { + query_params: pairs(query), + headers: pairs(headers), + cookies: pairs(cookies), + } + } + fn target( + source: &str, + path: &str, + context: Option<&MatchRedirectContext>, + ) -> Option<(String, u16)> { + match_redirect_rule(path, &parse_redirects_file(source), context) + .map(|m| (m.target_path, m.status)) + } + #[test] + fn parse_simple_statuses_force_and_placeholders() { + let rules = parse_redirects_file( + "\n# comment\n/old-path /new-path\n/home / 301\n/temp /target 302\n/rewrite /content 200\n/not-found /404 404\n/force /target 301!\n/news/* /blog/:splat\n/blog/:year/:month/:day /posts/:year-:month-:day", + ); + assert_eq!(rules.len(), 8); + assert_eq!(rules[0].from, "/old-path"); + assert_eq!(rules[0].to, "/new-path"); + assert_eq!( + rules.iter().map(|r| r.status).collect::>(), + vec![301, 301, 302, 200, 404, 301, 301, 301] + ); + assert!(!rules[0].force); + assert!(rules[5].force); + assert_eq!(rules[6].from, "/news/*"); + assert_eq!(rules[7].to, "/posts/:year-:month-:day"); + } + #[test] + fn parse_conditions() { + let rules = parse_redirects_file( + "/ /anz 302 Country=AU,nz\n/products /en/products 301 Language=EN\n/* /legacy/:splat 200 Cookie=is_legacy,my_cookie\n/ /role Role=Admin,user", + ); + assert_eq!(rules[0].conditions.country.as_ref().unwrap(), &["au", "nz"]); + assert_eq!(rules[1].conditions.language.as_ref().unwrap(), &["en"]); + assert_eq!( + rules[2].conditions.cookie.as_ref().unwrap(), + &["is_legacy", "my_cookie"] + ); + assert_eq!( + rules[3].conditions.role.as_ref().unwrap(), + &["Admin", "user"] + ); + assert_eq!(target("/ /role Role=Admin", "/", None), None); + } + #[test] + fn exact_trailing_splat_and_named_paths() { + for (source, path, expected, status) in [ + ("/old-path /new-path", "/old-path", "/new-path", 301), + ("/old-path /new-path", "/old-path/", "/new-path", 301), + ("/old-path /new-path", "old-path", "/new-path", 301), + ( + "/news/* /blog/:splat", + "/news/2024/01/15/my-post", + "/blog/2024/01/15/my-post", + 301, + ), + ( + "/blog/:year/:month/:day /posts/:year-:month-:day", + "/blog/2024/01/15", + "/posts/2024-01-15", + 301, + ), + ("/* /index.html 200", "/about", "/index.html", 200), + ( + "/* /index.html 200", + "/users/123/profile", + "/index.html", + 200, + ), + ("/* /index.html 200", "/", "/index.html", 200), + ( + "/files/pre:id.txt /:id", + "/files/prehello.txt", + "/hello", + 301, + ), + ("/pre* /:splat", "/prefix/part", "/fix/part", 301), + ] { + assert_eq!( + target(source, path, None), + Some((expected.into(), status)), + "{source}: {path}" + ); + } + assert_eq!(target("/x/:id /:id", "/x/", None), None); + assert_eq!(target("/x/:id /:id", "/x/a?b", None), None); + assert_eq!(target("/x/ /ok", "/x", None), None); + } + #[test] + fn first_rule_and_specific_before_general() { + assert_eq!( + target("/path /first\n/path /second", "/path", None) + .unwrap() + .0, + "/first" + ); + let rules = "/jobs/customer-ninja /careers/support\n/jobs/* /careers/:splat"; + assert_eq!( + target(rules, "/jobs/customer-ninja", None).unwrap().0, + "/careers/support" + ); + assert_eq!( + target(rules, "/jobs/developer", None).unwrap().0, + "/careers/developer" + ); + } + #[test] + fn query_preservation_matching_and_encoding() { + let ctx = context(&[("foo", "bar"), ("baz", "qux")], &[], &[]); + for status in [200, 301, 302] { + assert_eq!( + target(&format!("/old /new {status}"), "/old", Some(&ctx)) + .unwrap() + .0, + "/new?foo=bar&baz=qux" + ); + } + assert_eq!( + target("/old /new 404", "/old", Some(&ctx)).unwrap().0, + "/new" + ); + let ctx = context(&[("id", "my-post")], &[], &[]); + assert_eq!( + target("/store id=:id /blog/:id 301", "/store", Some(&ctx)) + .unwrap() + .0, + "/blog/my-post?id=my-post" + ); + assert_eq!( + target( + "/store id=:id /blog/:id 301", + "/store", + Some(&MatchRedirectContext::default()) + ), + None + ); + let ctx = context(&[("resource", "acct:ana@example.com")], &[], &[]); + assert_eq!( + target( + "/.well-known/webfinger?resource=:resource https://webfinger.example.com/?resource=:resource 200", + "/.well-known/webfinger", + Some(&ctx) + ), + Some(( + "https://webfinger.example.com/?resource=acct%3Aana%40example.com".into(), + 200 + )) + ); + assert_eq!( + target("/:id /:id", "/a%20é", None).unwrap().0, + "/a%2520%C3%A9" + ); + assert_eq!( + target("/* /:splat", "/a b/é", None).unwrap().0, + "/a%20b/%C3%A9" + ); + assert_eq!(target("/x?key=literal /target", "/x", Some(&ctx)), None); + } + #[test] + fn country_language_cookie_context() { + let ctx = context(&[], &[("cf-ipcountry", "AU")], &[]); + assert_eq!( + target("/ /aus 302 Country=au", "/", Some(&ctx)).unwrap().0, + "/aus" + ); + assert_eq!( + target( + "/ /aus 302 Country=au", + "/", + Some(&context(&[], &[("cf-ipcountry", "US")], &[])) + ), + None + ); + assert_eq!( + target( + "/ /aus Country=au", + "/", + Some(&context(&[], &[("x-country", "AU")], &[])) + ) + .unwrap() + .0, + "/aus" + ); + let ctx = context(&[], &[("accept-language", "en-US,en;q=0.9")], &[]); + assert_eq!( + target( + "/products /en/products 301 Language=en", + "/products", + Some(&ctx) + ) + .unwrap() + .0, + "/en/products" + ); + assert_eq!( + target("/products /en/products Language=en", "/products", None), + None + ); + let ctx = context(&[], &[], &[("is_legacy", "true")]); + assert_eq!( + target( + "/* /legacy/:splat 200 Cookie=is_legacy", + "/some-path", + Some(&ctx) + ) + .unwrap() + .0, + "/legacy/some-path" + ); + assert_eq!( + target("/* /legacy/:splat Cookie=is_legacy", "/some-path", None), + None + ); + } + #[test] + fn byte_limit_checked_before_decoding() { + let large = vec![0; MAX_REDIRECT_FILE_BYTES + 1024]; + assert!(parse_redirects_file_bytes_with(&large, |_| panic!("must not decode")).is_none()); + let exact = vec![0; MAX_REDIRECT_FILE_BYTES]; + let rules = parse_redirects_file_bytes_with(&exact, |bytes| { + assert_eq!(bytes.len(), MAX_REDIRECT_FILE_BYTES); + "/old /new 301".into() + }) + .unwrap(); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].status, 301); + assert_eq!( + parse_redirects_file_bytes(b"\xef\xbb\xbf/old /new") + .unwrap() + .len(), + 1 + ); + } + #[test] + fn rejects_ambiguous_patterns_and_bounds_work() { + assert_eq!( + parse_redirects_file( + "/safe/* /target/:splat\n/nested/*/suffix /target\n/*/*/*/*/*/*/*/*/x /target" + ) + .len(), + 1 + ); + let adjacent = (0..32).map(|i| format!(":part{i}")).collect::(); + assert!(parse_redirects_file(&format!("/{adjacent}/expected /target")).is_empty()); + let bounded = (0..32) + .map(|i| format!(":part{i}")) + .collect::>() + .join("/"); + let path = format!("/{}/actual", vec!["x".repeat(180); 32].join("/")); + let start = std::time::Instant::now(); + assert_eq!( + target(&format!("/{bounded}/expected /target"), &path, None), + None + ); + assert!(start.elapsed() < std::time::Duration::from_millis(250)); + assert!(parse_redirects_file("/:id* /target").is_empty()); + assert!(parse_redirects_file(&format!("/{} /target", "x".repeat(2048))).is_empty()); + assert!(parse_redirects_file(&format!("/{} /target", vec!["x"; 128].join("/"))).is_empty()); + assert_eq!(parse_redirects_file(&"/x /y\n".repeat(1001)).len(), 1000); + assert_eq!(target("/* /ok", &"x".repeat(8192), None), None); + } + #[test] + fn truncated_file_drops_partial_final_rule() { + let retained = "/kept /target 301\n"; + let final_rule = "/partial /must-not-apply 302"; + let comment_len = MAX_REDIRECT_FILE_BYTES - retained.len() - final_rule.len() - 2; + let content = format!( + "{retained}#{}\n{final_rule} trailing bytes", + "x".repeat(comment_len) + ); + let rules = parse_redirects_file(&content); + assert_eq!(rules.len(), 1); + assert_eq!(rules[0].from, "/kept"); + } + #[test] + fn total_query_decoding_inline_and_cookie_parsing() { + let params = + parse_query_string("/search?malformed=%E0%A4%A&value=a=b=c&flag&empty=&=ignored"); + assert_eq!(get(¶ms, "malformed"), Some("%E0%A4%A")); + assert_eq!(get(¶ms, "value"), Some("a=b=c")); + assert_eq!(get(¶ms, "flag"), Some("")); + assert_eq!(get(¶ms, "empty"), Some("")); + assert_eq!(get(¶ms, ""), None); + let ctx = MatchRedirectContext { + query_params: params, + ..Default::default() + }; + assert_eq!( + target( + "/search?malformed=%E0%A4%A&value=a=b=c /target 404", + "/search", + Some(&ctx) + ) + .unwrap() + .0, + "/target" + ); + assert_eq!( + parse_cookies(Some("x=a=b; flag; y= hello ;x=last")), + vec![("x".into(), "last".into()), ("y".into(), "hello".into())] + ); + assert!(parse_cookies(None).is_empty()); + assert_eq!( + get(&parse_query_string("?x=a+b&%C3%A9=%F0%9F%99%82"), "x"), + Some("a+b") + ); + } + #[test] + fn query_cap_and_javascript_integer_order() { + let query = (0..40) + .map(|i| format!("k{i}=v")) + .collect::>() + .join("&"); + assert_eq!(parse_query_string(&format!("?{query}")).len(), 32); + let ctx = MatchRedirectContext { + query_params: parse_query_string("?z=a&2=b&1=c&01=d"), + ..Default::default() + }; + assert_eq!( + target("/ /target", "/", Some(&ctx)).unwrap().0, + "/target?1=c&2=b&z=a&01=d" + ); + } + #[test] + fn visited_path_and_chain_limit() { + let rules = parse_redirects_file("/* /target"); + let mut visited = HashSet::new(); + assert!(match_redirect_rule_with_visited("/a", &rules, None, &mut visited).is_some()); + assert!(match_redirect_rule_with_visited("a", &rules, None, &mut visited).is_none()); + for i in 0..9 { + assert!( + match_redirect_rule_with_visited(&format!("/{i}"), &rules, None, &mut visited) + .is_some() + ); + } + assert!(match_redirect_rule_with_visited("/last", &rules, None, &mut visited).is_none()); + } + #[test] + fn javascript_whitespace_and_utf16_limits() { + assert_eq!( + parse_redirects_file("\u{feff}/x\u{feff}/y\u{feff}301").len(), + 1 + ); + assert!(parse_redirects_file("/x\u{0085}/y").is_empty()); + assert_eq!( + parse_redirects_file(&format!("/{} /ok", "🙂".repeat(1023))).len(), + 1 + ); + assert!(parse_redirects_file(&format!("/{} /ok", "🙂".repeat(1024))).is_empty()); + } +} diff --git a/cli-rs/crates/wisp-core/src/scopes.fixture.json b/cli-rs/crates/wisp-core/src/scopes.fixture.json new file mode 100644 index 0000000..a7ed580 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/scopes.fixture.json @@ -0,0 +1 @@ +{"preferred":"atproto blob:*/* include:place.wisp.authSites include:place.wisp.authHosting","legacy":"atproto blob:*/* repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings repo:place.wisp.domain rpc:place.wisp.v2.domain.addSite?aud=* rpc:place.wisp.v2.domain.claim?aud=* rpc:place.wisp.v2.domain.claimSubdomain?aud=* rpc:place.wisp.v2.domain.delete?aud=* rpc:place.wisp.v2.domain.getList?aud=* rpc:place.wisp.v2.domain.getStatus?aud=* rpc:place.wisp.v2.domain.verify?aud=* rpc:place.wisp.v2.privateSite.create?aud=* rpc:place.wisp.v2.privateSite.createShare?aud=* rpc:place.wisp.v2.privateSite.delete?aud=* rpc:place.wisp.v2.privateSite.list?aud=* rpc:place.wisp.v2.privateSite.listShares?aud=* rpc:place.wisp.v2.privateSite.revokeShare?aud=* rpc:place.wisp.v2.site.delete?aud=* rpc:place.wisp.v2.site.getDomains?aud=* rpc:place.wisp.v2.site.getList?aud=*","metadata":"atproto blob:*/* include:place.wisp.authSites include:place.wisp.authHosting repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings repo:place.wisp.domain rpc:place.wisp.v2.domain.addSite?aud=* rpc:place.wisp.v2.domain.claim?aud=* rpc:place.wisp.v2.domain.claimSubdomain?aud=* rpc:place.wisp.v2.domain.delete?aud=* rpc:place.wisp.v2.domain.getList?aud=* rpc:place.wisp.v2.domain.getStatus?aud=* rpc:place.wisp.v2.domain.verify?aud=* rpc:place.wisp.v2.privateSite.create?aud=* rpc:place.wisp.v2.privateSite.createShare?aud=* rpc:place.wisp.v2.privateSite.delete?aud=* rpc:place.wisp.v2.privateSite.list?aud=* rpc:place.wisp.v2.privateSite.listShares?aud=* rpc:place.wisp.v2.privateSite.revokeShare?aud=* rpc:place.wisp.v2.site.delete?aud=* rpc:place.wisp.v2.site.getDomains?aud=* rpc:place.wisp.v2.site.getList?aud=*"} \ No newline at end of file diff --git a/cli-rs/crates/wisp-core/src/scopes.rs b/cli-rs/crates/wisp-core/src/scopes.rs new file mode 100644 index 0000000..01cb248 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/scopes.rs @@ -0,0 +1,602 @@ +//! OAuth permission-set expansion and semantic grant checks. + +pub const WISP_SITE_COLLECTIONS: &[&str] = &[ + "place.wisp.fs", + "place.wisp.subfs", + "place.wisp.settings", + "place.wisp.domain", +]; +pub const WISP_WEBHOOK_COLLECTIONS: &[&str] = &["place.wisp.v2.wh"]; +pub const WISP_HOSTING_LXMS: &[&str] = &[ + "place.wisp.v2.domain.addSite", + "place.wisp.v2.domain.claim", + "place.wisp.v2.domain.claimSubdomain", + "place.wisp.v2.domain.delete", + "place.wisp.v2.domain.getList", + "place.wisp.v2.domain.getStatus", + "place.wisp.v2.domain.verify", + "place.wisp.v2.privateSite.create", + "place.wisp.v2.privateSite.createShare", + "place.wisp.v2.privateSite.delete", + "place.wisp.v2.privateSite.list", + "place.wisp.v2.privateSite.listShares", + "place.wisp.v2.privateSite.revokeShare", + "place.wisp.v2.site.delete", + "place.wisp.v2.site.getDomains", + "place.wisp.v2.site.getList", +]; +pub const WISP_SECRET_LXMS: &[&str] = &[ + "place.wisp.v2.secret.create", + "place.wisp.v2.secret.delete", + "place.wisp.v2.secret.list", + "place.wisp.v2.secret.rotate", +]; +pub const REPO_ACTIONS: &[&str] = &["create", "update", "delete"]; +pub const WISP_PERMISSION_SET_SITES: &str = "place.wisp.authSites"; +pub const WISP_PERMISSION_SET_WEBHOOKS: &str = "place.wisp.authWebhooks"; +pub const WISP_PERMISSION_SET_HOSTING: &str = "place.wisp.authHosting"; +pub const WISP_PERMISSION_SET_FULL: &str = "place.wisp.authFullAccess"; +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PermissionSet { + pub nsid: &'static str, + pub title: &'static str, + pub detail: &'static str, +} + +pub const WISP_PERMISSION_SETS: &[PermissionSet] = &[ + PermissionSet { + nsid: WISP_PERMISSION_SET_SITES, + title: "Manage your wisp.place sites", + detail: "Create, update, and delete your site files and settings.", + }, + PermissionSet { + nsid: WISP_PERMISSION_SET_WEBHOOKS, + title: "Manage your wisp.place webhooks", + detail: "Create, update, and delete your webhook subscriptions.", + }, + PermissionSet { + nsid: WISP_PERMISSION_SET_HOSTING, + title: "Use wisp.place hosting features", + detail: "Manage your sites, custom domains, and private site links.", + }, + PermissionSet { + nsid: WISP_PERMISSION_SET_FULL, + title: "Full wisp.place access", + detail: "Full control of your sites, domains, and webhooks.", + }, +]; + +pub const WISP_CLI_PERMISSION_SETS: &[&str] = + &[WISP_PERMISSION_SET_SITES, WISP_PERMISSION_SET_HOSTING]; +pub const WISP_APP_PERMISSION_SETS: &[&str] = + &[WISP_PERMISSION_SET_SITES, WISP_PERMISSION_SET_WEBHOOKS]; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum WispCapability { + Repo { collection: String, action: String }, + Rpc { lxm: String, aud: String }, + Blob { mime: String }, +} + +pub fn describe_capability(capability: &WispCapability) -> String { + match capability { + WispCapability::Repo { collection, action } => format!("{action} {collection} records"), + WispCapability::Rpc { lxm, .. } => format!("call {lxm}"), + WispCapability::Blob { mime } => format!("upload {mime} blobs"), + } +} + +fn repo_capabilities(collections: &[&str]) -> Vec { + collections + .iter() + .flat_map(|collection| { + REPO_ACTIONS.iter().map(move |action| WispCapability::Repo { + collection: (*collection).into(), + action: (*action).into(), + }) + }) + .collect() +} + +fn rpc_capabilities(lxms: &[&str]) -> Vec { + lxms.iter() + .map(|lxm| WispCapability::Rpc { + lxm: (*lxm).into(), + aud: "*".into(), + }) + .collect() +} + +pub fn permission_set_capabilities(nsid: &str) -> Result, String> { + match nsid { + WISP_PERMISSION_SET_SITES => Ok(repo_capabilities(WISP_SITE_COLLECTIONS)), + WISP_PERMISSION_SET_WEBHOOKS => Ok(repo_capabilities(WISP_WEBHOOK_COLLECTIONS)), + WISP_PERMISSION_SET_HOSTING => Ok(rpc_capabilities(WISP_HOSTING_LXMS)), + WISP_PERMISSION_SET_FULL => Ok([ + repo_capabilities(WISP_SITE_COLLECTIONS), + repo_capabilities(WISP_WEBHOOK_COLLECTIONS), + rpc_capabilities(WISP_HOSTING_LXMS), + rpc_capabilities(WISP_SECRET_LXMS), + ] + .concat()), + _ => Err(format!("Unknown wisp permission set: {nsid}")), + } +} + +pub fn include_scopes(nsids: &[&str]) -> Vec { + nsids.iter().map(|nsid| format!("include:{nsid}")).collect() +} + +pub fn expand_permission_sets(nsids: &[&str]) -> Vec { + let mut scopes = Vec::new(); + for capability in nsids + .iter() + .flat_map(|nsid| permission_set_capabilities(nsid).unwrap_or_default()) + { + let scope = match capability { + WispCapability::Repo { collection, .. } => format!("repo:{collection}"), + WispCapability::Rpc { lxm, aud } => format!("rpc:{lxm}?aud={aud}"), + WispCapability::Blob { .. } => continue, + }; + if !scopes.contains(&scope) { + scopes.push(scope); + } + } + scopes +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WispScopes { + pub preferred: String, + pub legacy: String, + pub metadata: String, +} + +pub fn build_wisp_scopes(nsids: &[&str]) -> WispScopes { + build_wisp_scopes_with_extra(nsids, &["atproto", "blob:*/*"]) +} + +pub fn build_wisp_scopes_with_extra(nsids: &[&str], extra: &[&str]) -> WispScopes { + let preferred: Vec<_> = extra + .iter() + .map(|s| (*s).into()) + .chain(include_scopes(nsids)) + .collect(); + let legacy: Vec<_> = extra + .iter() + .map(|s| (*s).into()) + .chain(expand_permission_sets(nsids)) + .collect(); + let mut metadata = Vec::new(); + for scope in preferred.iter().chain(&legacy) { + if !metadata.contains(scope) { + metadata.push(scope.clone()); + } + } + WispScopes { + preferred: preferred.join(" "), + legacy: legacy.join(" "), + metadata: metadata.join(" "), + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ParsedScope { + pub prefix: String, + pub positional: Option, + pub params: Vec<(String, String)>, +} + +fn decode_positional(raw: &str) -> String { + // decodeURIComponent rejects malformed escapes instead of partially decoding. + let bytes = raw.as_bytes(); + if bytes.iter().enumerate().any(|(i, b)| { + *b == b'%' + && (i + 2 >= bytes.len() + || !bytes[i + 1].is_ascii_hexdigit() + || !bytes[i + 2].is_ascii_hexdigit()) + }) { + return raw.into(); + } + percent_encoding::percent_decode_str(raw) + .decode_utf8() + .map_or_else(|_| raw.into(), |s| s.into_owned()) +} + +pub fn parse_scope_value(value: &str) -> ParsedScope { + let query = value.find('?'); + let colon = value.find(':'); + let end = query.into_iter().chain(colon).min().unwrap_or(value.len()); + let positional = colon + .filter(|c| query.is_none_or(|q| *c < q)) + .map(|c| decode_positional(&value[c + 1..query.unwrap_or(value.len())])); + let params = query + .map(|q| { + url::form_urlencoded::parse(&value.as_bytes()[q + 1..]) + .into_owned() + .collect() + }) + .unwrap_or_default(); + ParsedScope { + prefix: value[..end].into(), + positional, + params, + } +} + +impl ParsedScope { + fn values(&self, key: &str) -> Vec<&str> { + self.params + .iter() + .filter(|(k, _)| k == key) + .map(|(_, v)| v.as_str()) + .collect() + } + fn targets(&self, key: &str) -> Vec<&str> { + let values = self.values(key); + if values.is_empty() { + self.positional.as_deref().into_iter().collect() + } else { + values + } + } + fn grants(&self, capability: &WispCapability) -> bool { + match capability { + WispCapability::Repo { collection, action } if self.prefix == "repo" => { + let targets = self.targets("collection"); + let actions = self.values("action"); + (targets.contains(&"*") || targets.contains(&collection.as_str())) + && (actions.is_empty() || actions.contains(&action.as_str())) + } + WispCapability::Rpc { lxm, aud } if self.prefix == "rpc" => { + let targets = self.targets("lxm"); + let granted_aud = self.values("aud").first().copied(); + (targets.contains(&"*") || targets.contains(&lxm.as_str())) + && (granted_aud == Some("*") + || (aud != "*" && granted_aud == Some(aud.as_str()))) + } + WispCapability::Blob { mime } if self.prefix == "blob" => { + let wildcard = format!("{}/*", mime.split('/').next().unwrap_or(mime)); + self.targets("accept") + .iter() + .any(|accept| *accept == "*/*" || *accept == mime || *accept == wildcard) + } + _ => false, + } + } +} + +pub fn missing_capabilities( + granted_scope: Option<&str>, + required: &[WispCapability], +) -> Vec { + let granted: Vec<_> = granted_scope + .unwrap_or_default() + .split_whitespace() + .map(parse_scope_value) + .collect(); + required + .iter() + .filter(|capability| !granted.iter().any(|scope| scope.grants(capability))) + .cloned() + .collect() +} + +pub fn wisp_cli_required_capabilities() -> Vec { + let rpc = WISP_HOSTING_LXMS + .iter() + .copied() + .filter(|lxm| *lxm != "place.wisp.v2.site.getDomains") + .collect::>(); + [ + repo_capabilities(&["place.wisp.fs", "place.wisp.subfs", "place.wisp.settings"]), + rpc_capabilities(&rpc), + vec![WispCapability::Blob { mime: "*/*".into() }], + ] + .concat() +} + +pub fn wisp_app_required_capabilities() -> Vec { + [ + repo_capabilities(WISP_SITE_COLLECTIONS), + repo_capabilities(WISP_WEBHOOK_COLLECTIONS), + vec![WispCapability::Blob { mime: "*/*".into() }], + ] + .concat() +} + +#[cfg(test)] +mod tests { + use super::*; + fn repo(action: &str) -> WispCapability { + WispCapability::Repo { + collection: "place.wisp.fs".into(), + action: action.into(), + } + } + fn rpc(aud: &str) -> WispCapability { + WispCapability::Rpc { + lxm: "place.wisp.v2.site.getList".into(), + aud: aud.into(), + } + } + fn blob(mime: &str) -> WispCapability { + WispCapability::Blob { mime: mime.into() } + } + #[test] + fn matches_typescript_fixture_byte_for_byte() { + let fixture: serde_json::Value = + serde_json::from_str(include_str!("scopes.fixture.json")).unwrap(); + let scopes = build_wisp_scopes(WISP_CLI_PERMISSION_SETS); + assert_eq!(scopes.preferred, fixture["preferred"].as_str().unwrap()); + assert_eq!(scopes.legacy, fixture["legacy"].as_str().unwrap()); + assert_eq!(scopes.metadata, fixture["metadata"].as_str().unwrap()); + } + #[test] + fn sets_stay_in_authority_and_use_any_audience() { + for nsid in [ + WISP_PERMISSION_SET_SITES, + WISP_PERMISSION_SET_WEBHOOKS, + WISP_PERMISSION_SET_HOSTING, + WISP_PERMISSION_SET_FULL, + ] { + for cap in permission_set_capabilities(nsid).unwrap() { + match cap { + WispCapability::Repo { collection, .. } => { + assert!(collection.starts_with("place.wisp.")) + } + WispCapability::Rpc { lxm, aud } => { + assert!(lxm.starts_with("place.wisp.")); + assert_eq!(aud, "*"); + } + _ => panic!("sets cannot contain blob permissions"), + } + } + } + } + #[test] + fn full_access_contains_every_other_set() { + let full = permission_set_capabilities(WISP_PERMISSION_SET_FULL).unwrap(); + for nsid in [ + WISP_PERMISSION_SET_SITES, + WISP_PERMISSION_SET_WEBHOOKS, + WISP_PERMISSION_SET_HOSTING, + ] { + assert!( + permission_set_capabilities(nsid) + .unwrap() + .iter() + .all(|c| full.contains(c)) + ); + } + } + #[test] + fn scope_strings_declare_both_strategies() { + let scopes = build_wisp_scopes(WISP_CLI_PERMISSION_SETS); + assert_eq!( + scopes.preferred, + "atproto blob:*/* include:place.wisp.authSites include:place.wisp.authHosting" + ); + assert!(scopes.legacy.contains("repo:place.wisp.fs")); + assert!(!scopes.legacy.contains("include:")); + for scope in scopes + .preferred + .split_whitespace() + .chain(scopes.legacy.split_whitespace()) + { + assert!(scopes.metadata.split_whitespace().any(|s| s == scope)); + } + assert!( + missing_capabilities(Some(&scopes.legacy), &wisp_cli_required_capabilities()) + .is_empty() + ); + } + #[test] + fn metadata_deduplicates_extra_scopes_without_changing_the_request() { + let scopes = build_wisp_scopes_with_extra(&[], &["atproto", "atproto"]); + assert_eq!(scopes.preferred, "atproto atproto"); + assert_eq!(scopes.legacy, "atproto atproto"); + assert_eq!(scopes.metadata, "atproto"); + } + #[test] + fn scope_expansion_deduplicates_and_ignores_unknown_sets() { + assert_eq!( + expand_permission_sets(&[ + WISP_PERMISSION_SET_SITES, + WISP_PERMISSION_SET_SITES, + "unknown" + ]), + expand_permission_sets(&[WISP_PERMISSION_SET_SITES]) + ); + assert!(permission_set_capabilities("unknown").is_err()); + assert!( + !expand_permission_sets(WISP_APP_PERMISSION_SETS) + .iter() + .any(|s| s == "atproto" || s.starts_with("blob:")) + ); + } + #[test] + fn parser_handles_bare_positional_and_grouped_forms() { + assert_eq!( + parse_scope_value("atproto"), + ParsedScope { + prefix: "atproto".into(), + positional: None, + params: vec![] + } + ); + assert_eq!( + parse_scope_value("repo:place.wisp.fs") + .positional + .as_deref(), + Some("place.wisp.fs") + ); + let grouped = parse_scope_value("rpc?lxm=one&lxm=two&aud=*"); + assert_eq!(grouped.values("lxm"), vec!["one", "two"]); + assert_eq!(grouped.values("aud"), vec!["*"]); + } + #[test] + fn parser_percent_decoding_matches_javascript() { + assert_eq!( + parse_scope_value("blob:image%2Fpng").positional.as_deref(), + Some("image/png") + ); + assert_eq!( + parse_scope_value("blob:a+b").positional.as_deref(), + Some("a+b") + ); + assert_eq!( + parse_scope_value("blob:%FF%2F").positional.as_deref(), + Some("%FF%2F") + ); + assert_eq!( + parse_scope_value("blob:a%2Fb%zz").positional.as_deref(), + Some("a%2Fb%zz") + ); + assert_eq!(parse_scope_value("rpc?aud=a+b").values("aud"), vec!["a b"]); + assert_eq!(parse_scope_value("rpc?aud=did:web:test").prefix, "rpc"); + } + #[test] + fn grouped_server_grants_cover_both_clients() { + let grouped_repo = format!( + "repo?{}", + WISP_SITE_COLLECTIONS + .iter() + .map(|s| format!("collection={s}")) + .collect::>() + .join("&") + ); + let grouped_rpc = format!( + "rpc?{}&aud=*", + WISP_HOSTING_LXMS + .iter() + .map(|s| format!("lxm={s}")) + .collect::>() + .join("&") + ); + assert!( + missing_capabilities( + Some(&format!("atproto blob:*/* {grouped_repo} {grouped_rpc}")), + &wisp_cli_required_capabilities() + ) + .is_empty() + ); + assert!( + missing_capabilities( + Some(&format!( + "atproto blob:*/* {grouped_repo} repo:place.wisp.v2.wh" + )), + &wisp_app_required_capabilities() + ) + .is_empty() + ); + } + #[test] + fn ignored_includes_are_not_grants() { + let required = wisp_cli_required_capabilities(); + assert_eq!( + missing_capabilities( + Some(&build_wisp_scopes(WISP_CLI_PERMISSION_SETS).preferred), + &required + ) + .len(), + required.len() - 1 + ); + assert_eq!(missing_capabilities(None, &required), required); + } + #[test] + fn repo_actions_and_wildcards() { + assert_eq!( + missing_capabilities( + Some("repo:place.wisp.fs?action=create&action=update"), + &[repo("delete")] + ), + vec![repo("delete")] + ); + assert!( + missing_capabilities( + Some("repo:place.wisp.fs?action=create&action=update"), + &[repo("update")] + ) + .is_empty() + ); + assert!(missing_capabilities(Some("repo:*"), &[repo("delete")]).is_empty()); + assert!(!missing_capabilities(Some("repo:place.wisp.*"), &[repo("delete")]).is_empty()); + } + #[test] + fn rpc_audience_must_cover_the_requirement() { + let pinned = "rpc:place.wisp.v2.site.getList?aud=did:web:wisp.place%23wisp_xrpc"; + assert_eq!( + missing_capabilities(Some(pinned), &[rpc("*")]), + vec![rpc("*")] + ); + assert!( + missing_capabilities(Some(pinned), &[rpc("did:web:wisp.place#wisp_xrpc")]).is_empty() + ); + assert!(!missing_capabilities(Some("rpc:*"), &[rpc("*")]).is_empty()); + assert!(missing_capabilities(Some("rpc:*?aud=*"), &[rpc("*")]).is_empty()); + } + #[test] + fn blob_mime_matching() { + assert!(missing_capabilities(Some("blob:image/*"), &[blob("image/png")]).is_empty()); + assert_eq!( + missing_capabilities(Some("blob:image/*"), &[blob("text/html")]), + vec![blob("text/html")] + ); + assert!( + missing_capabilities( + Some("blob?accept=image/png&accept=text/html"), + &[blob("text/html")] + ) + .is_empty() + ); + assert!(!missing_capabilities(Some("blob:image/*"), &[blob("*/*")]).is_empty()); + } + #[test] + fn grouped_params_override_positional_shorthand() { + assert!( + !missing_capabilities(Some("repo:*?collection=other"), &[repo("delete")]).is_empty() + ); + } + #[test] + fn old_sessions_remain_valid() { + let cli = format!( + "atproto repo:place.wisp.fs repo:place.wisp.subfs repo:place.wisp.settings blob:*/* {}", + WISP_HOSTING_LXMS + .iter() + .filter(|s| **s != "place.wisp.v2.site.getDomains") + .map(|s| format!("rpc:{s}?aud=*")) + .collect::>() + .join(" ") + ); + assert!(missing_capabilities(Some(&cli), &wisp_cli_required_capabilities()).is_empty()); + let app = "atproto repo:place.wisp.fs repo:place.wisp.domain repo:place.wisp.subfs repo:place.wisp.settings repo:place.wisp.v2.wh blob:*/*"; + assert!(missing_capabilities(Some(app), &wisp_app_required_capabilities()).is_empty()); + } + #[test] + fn required_capabilities_are_no_broader_than_the_sets() { + let grants = [ + permission_set_capabilities(WISP_PERMISSION_SET_SITES).unwrap(), + permission_set_capabilities(WISP_PERMISSION_SET_HOSTING).unwrap(), + vec![blob("*/*")], + ] + .concat(); + assert!( + wisp_cli_required_capabilities() + .iter() + .all(|c| grants.contains(c)) + ); + } + #[test] + fn descriptions() { + assert_eq!( + describe_capability(&repo("delete")), + "delete place.wisp.fs records" + ); + assert_eq!( + describe_capability(&rpc("*")), + "call place.wisp.v2.site.getList" + ); + assert_eq!(describe_capability(&blob("*/*")), "upload */* blobs"); + } +} diff --git a/cli-rs/crates/wisp-core/src/split.rs b/cli-rs/crates/wisp-core/src/split.rs new file mode 100644 index 0000000..1b5470c --- /dev/null +++ b/cli-rs/crates/wisp-core/src/split.rs @@ -0,0 +1,463 @@ +//! Pure subfs write planning, using the TypeScript CLI's thresholds and keys. +use crate::convert::fs_to_subfs; +use crate::tree::{count_files, directory, root_type}; +use jacquard_common::types::string::{AtUri, Datetime}; +use wisp_lexicons::place_wisp::{ + fs::{Directory, Entry, EntryNode, Fs, Subfs}, + subfs::{self, SubfsRecord}, +}; + +pub const MAX_MANIFEST_SIZE: usize = 140 * 1024; +pub const FILE_COUNT_THRESHOLD: usize = 250; +pub const TARGET_FILE_COUNT: usize = 200; +pub const MAX_SUBFS_SIZE: usize = 75 * 1024; + +#[derive(Debug, Clone)] +pub struct SubfsWrite { + pub rkey: String, + pub root: subfs::Directory, + pub file_count: usize, +} +#[derive(Debug, Clone)] +pub struct SplitPlan { + pub directory: Directory, + pub records: Vec, + pub messages: Vec, +} + +#[derive(Debug, Clone)] +pub enum SplitMessage { + DirectoryTooLarge { + size: usize, + }, + CreatedChunks { + count: usize, + }, + UploadingChunk { + index: usize, + count: usize, + files: usize, + size: usize, + }, + CreatingParent { + count: usize, + }, + CreatedParent { + count: usize, + }, + CannotSplit { + files: usize, + size: usize, + }, +} + +#[derive(Debug)] +pub enum SplitError { + InvalidUri(String), +} +impl std::fmt::Display for SplitError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidUri(uri) => write!(f, "Invalid subfs URI: {uri}"), + } + } +} +impl std::error::Error for SplitError {} + +pub fn manifest(site: &str, root: &Directory, created_at: &Datetime) -> Fs { + Fs { + created_at: created_at.clone(), + file_count: Some(count_files(root) as i64), + root: Directory { + extra_data: root_type("place.wisp.fs#directory"), + ..root.clone() + }, + site: site.into(), + extra_data: None, + } +} + +/// The `place.wisp.subfs` record for one planned write. +pub fn subfs_record(write: &SubfsWrite, created_at: &Datetime) -> SubfsRecord { + SubfsRecord { + created_at: created_at.clone(), + file_count: Some(write.file_count as i64), + root: subfs::Directory { + extra_data: root_type("place.wisp.subfs#directory"), + ..write.root.clone() + }, + extra_data: None, + } +} + +// JS counts UTF-16 code units, not UTF-8 bytes, in JSON.stringify(...).length. +fn json_size(value: &impl serde::Serialize) -> usize { + serde_json::to_string(&serde_json::to_value(value).expect("lexicon serialization")) + .expect("JSON serialization") + .encode_utf16() + .count() +} + +/// Size of a directory as the TypeScript CLI measures it: its directory +/// objects always carry `"$type":"place.wisp.fs#directory",`, which ours only +/// get from a union tag or as a record root. +fn directory_size(dir: &Directory) -> usize { + json_size(dir) + r#""$type":"place.wisp.fs#directory","#.len() +} + +/// Plan the subfs records that bring `root` under the manifest limits. +/// +/// `generation` (unique per deploy, e.g. a TID) is part of every new record +/// key. Reusing the previous deploy's keys would overwrite records that still +/// reference reused blobs, and the PDS deletes a blob as soon as nothing +/// references it, so a later write in the same deploy then fails with +/// `BlobNotFound`. With fresh keys the old records stay until deploy removes +/// them after the new manifest is in place. +pub fn split_plan( + root: &Directory, + did: &str, + site: &str, + generation: &str, + created_at: &Datetime, +) -> Result { + let mut plan = SplitPlan { + directory: root.clone(), + records: Vec::new(), + messages: Vec::new(), + }; + if count_files(root) < FILE_COUNT_THRESHOLD + && json_size(&manifest(site, root, created_at)) <= MAX_MANIFEST_SIZE + { + return Ok(plan); + } + let mut chunk_counter = 0; + for iteration in 1..=100 { + if count_files(&plan.directory) <= TARGET_FILE_COUNT + && json_size(&manifest(site, &plan.directory, created_at)) <= MAX_MANIFEST_SIZE + { + break; + } + let mut dirs = large_directories(&plan.directory, ""); + dirs.sort_by_key(|(_, dir)| std::cmp::Reverse(directory_size(dir))); + if let Some((path, largest)) = dirs.into_iter().next().map(splittable) { + let largest = &largest; + let rkey = format!("{site}-{generation}-subfs-{iteration}"); + let uri = if directory_size(largest) > MAX_SUBFS_SIZE { + let chunks = split_chunks(largest, MAX_SUBFS_SIZE); + plan.messages.push(SplitMessage::DirectoryTooLarge { + size: directory_size(largest), + }); + plan.messages.push(SplitMessage::CreatedChunks { + count: chunks.len(), + }); + let mut entries = Vec::new(); + for (index, chunk) in chunks.iter().enumerate() { + plan.messages.push(SplitMessage::UploadingChunk { + index: index + 1, + count: chunks.len(), + files: count_files(chunk), + size: directory_size(chunk), + }); + let key = format!("{site}-{generation}-chunk-{chunk_counter}"); + chunk_counter += 1; + let uri = add_record(&mut plan, did, key, chunk); + entries.push(reference(&format!("chunk{index}"), &uri, true)?); + } + plan.messages.push(SplitMessage::CreatingParent { + count: chunks.len(), + }); + let uri = add_record(&mut plan, did, rkey, &directory(entries)); + plan.messages.push(SplitMessage::CreatedParent { + count: chunks.len(), + }); + uri + } else { + add_record(&mut plan, did, rkey, largest) + }; + plan.directory = + replace_directory(&plan.directory, &path.split('/').collect::>(), &uri)?; + } else { + let files: Vec<_> = plan + .directory + .entries + .iter() + .filter(|entry| matches!(entry.node, EntryNode::File(_))) + .take(100) + .cloned() + .collect(); + if files.is_empty() { + plan.messages.push(SplitMessage::CannotSplit { + files: count_files(&plan.directory), + size: json_size(&manifest(site, &plan.directory, created_at)), + }); + break; + } + let key = format!("{site}-{generation}-subfs-{iteration}"); + let uri = add_record(&mut plan, did, key, &directory(files.clone())); + let mut entries: Vec<_> = plan + .directory + .entries + .iter() + .filter(|entry| !files.iter().any(|file| file.name == entry.name)) + .cloned() + .collect(); + entries.push(reference(&format!("__subfs_{iteration}"), &uri, true)?); + plan.directory = directory(entries); + } + } + Ok(plan) +} + +/// The directory to split out: `dir`, or the first of its subdirectories (by +/// size, recursively) that is too big for one record on its own. Chunking only +/// divides a directory's own entries, so a too-big child would otherwise land +/// whole in a single chunk, over the record size and the lexicon's 500-entry +/// limit. (The TypeScript CLI has this bug; see the nested-icons test.) +fn splittable((path, dir): (String, Directory)) -> (String, Directory) { + let oversized_child = dir + .entries + .iter() + .filter_map(|entry| match &entry.node { + EntryNode::Directory(child) => Some((entry.name.as_str(), child)), + _ => None, + }) + .max_by_key(|(_, child)| directory_size(child)) + .filter(|(_, child)| directory_size(child) > MAX_SUBFS_SIZE); + match oversized_child { + Some((name, child)) => splittable((format!("{path}/{name}"), (**child).clone())), + None => (path, dir), + } +} + +/// Queue `root` as a subfs record and return the URI it will have. +fn add_record(plan: &mut SplitPlan, did: &str, rkey: String, root: &Directory) -> String { + let uri = format!("at://{did}/place.wisp.subfs/{rkey}"); + plan.records.push(SubfsWrite { + rkey, + root: fs_to_subfs(root), + file_count: count_files(root), + }); + uri +} + +fn reference(name: &str, uri: &str, flat: bool) -> Result { + Ok(Entry { + name: name.to_owned().into(), + extra_data: None, + node: EntryNode::Subfs(Box::new(Subfs { + r#type: "subfs".into(), + subject: AtUri::new(uri.to_owned().into()) + .map_err(|_| SplitError::InvalidUri(uri.into()))?, + flat: Some(flat), + extra_data: None, + })), + }) +} + +fn large_directories(root: &Directory, prefix: &str) -> Vec<(String, Directory)> { + root.entries + .iter() + .flat_map(|entry| { + if let EntryNode::Directory(dir) = &entry.node { + let path = if prefix.is_empty() { + entry.name.to_string() + } else { + format!("{prefix}/{}", entry.name) + }; + let mut result = vec![(path.clone(), (**dir).clone())]; + result.extend(large_directories(dir, &path)); + result + } else { + Vec::new() + } + }) + .collect() +} + +fn replace_directory(root: &Directory, path: &[&str], uri: &str) -> Result { + let entries = root + .entries + .iter() + .map(|entry| { + if entry.name.as_str() != path[0] { + return Ok(entry.clone()); + } + if let EntryNode::Directory(child) = &entry.node { + if path.len() == 1 { + reference(entry.name.as_str(), uri, false) + } else { + Ok(Entry { + name: entry.name.clone(), + extra_data: None, + node: EntryNode::Directory(Box::new(replace_directory( + child, + &path[1..], + uri, + )?)), + }) + } + } else { + Ok(entry.clone()) + } + }) + .collect::, SplitError>>()?; + Ok(directory(entries)) +} + +pub fn split_chunks(root: &Directory, max_size: usize) -> Vec { + let mut chunks = Vec::new(); + let mut entries = Vec::new(); + let mut size = 100; + for entry in &root.entries { + let entry_size = json_size(entry); + if !entries.is_empty() && size + entry_size > max_size { + chunks.push(directory(std::mem::take(&mut entries))); + size = 100; + } + entries.push(entry.clone()); + size += entry_size; + } + if !entries.is_empty() { + chunks.push(directory(entries)); + } + chunks +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tree::{UploadResult, build_tree, extract_subfs_uris}; + use serde_json::json; + + fn created() -> Datetime { + "2026-01-01T00:00:00.000Z".parse().unwrap() + } + + fn files(count: usize, prefix: &str) -> Directory { + let upload = UploadResult { + blob: serde_json::from_value(json!({ + "$type": "blob", + "ref": { "$link": crate::blob::compute_cid(b"a") }, + "mimeType": "application/octet-stream", + "size": 1 + })) + .unwrap(), + encoding: None, + mime_type: "text/plain".into(), + base64: false, + }; + build_tree( + &(0..count) + .map(|i| (format!("{prefix}file-{i:04}.txt"), upload.clone())) + .collect::>(), + ) + } + #[test] + fn chunk_limits_preserve_order_and_files() { + let root = files(300, ""); + let chunks = split_chunks(&root, 8000); + assert_eq!(chunks.iter().map(count_files).sum::(), 300); + assert!(chunks.iter().all(|chunk| json_size(chunk) <= 8000)); + assert_eq!( + chunks + .into_iter() + .flat_map(|chunk| chunk.entries) + .collect::>(), + root.entries + ); + } + #[test] + fn utf16_json_estimates() { + let value = json!({"name":"🦋"}); + assert_eq!( + json_size(&value), + "{\"name\":\"🦋\"}".encode_utf16().count() + ); + assert!(json_size(&value) < serde_json::to_string(&value).unwrap().len()); + } + #[test] + fn small_tree_is_unchanged() { + let root = files(10, "a/b/"); + let plan = split_plan(&root, "did:plc:test", "site", "g", &created()).unwrap(); + assert_eq!(plan.directory, root); + assert!(plan.records.is_empty()); + } + #[test] + fn root_chunks_and_boundary() { + let unsplit = split_plan(&files(249, ""), "did:plc:test", "site", "g", &created()).unwrap(); + assert!(unsplit.records.is_empty()); + let plan = split_plan(&files(350, ""), "did:plc:test", "site", "g", &created()).unwrap(); + assert_eq!(plan.records.len(), 2); + assert_eq!(count_files(&plan.directory), 150); + assert_eq!(plan.records[0].rkey, "site-g-subfs-1"); + assert!( + extract_subfs_uris(&plan.directory) + .iter() + .all(|node| node.flat) + ); + } + #[test] + fn records_only_carry_their_own_lexicon_types() { + let root = files(600, "assets/icons/"); + let plan = split_plan(&root, "did:plc:test", "site", "g", &created()).unwrap(); + for write in &plan.records { + let json = serde_json::to_string(&subfs_record(write, &created())).unwrap(); + assert!(!json.contains("place.wisp.fs#"), "{json:.200}"); + assert!(json.starts_with(r#"{"$type":"place.wisp.subfs""#)); + } + let manifest = serde_json::to_string(&manifest("site", &root, &created())).unwrap(); + // One `$type` per object: the manifest, the root, each directory node and file. + let objects = 1 + 1 + 2 + 600 * 2; + assert_eq!(manifest.matches(r#""$type""#).count(), objects); + } + + #[test] + fn oversized_subdirectories_are_split_themselves() { + let plan = split_plan( + &files(600, "assets/icons/"), + "did:plc:test", + "site", + "g", + &created(), + ) + .unwrap(); + assert_eq!(count_files(&plan.directory), 0); + assert_eq!( + plan.records.iter().map(|r| r.file_count).sum::(), + 600 + ); + for write in &plan.records { + assert!(write.root.entries.len() <= 500); + // The chunk budget ignores commas between entries, like the old CLI, + // so chunks can run a little over it. + let as_fs = crate::convert::subfs_to_fs(&write.root); + assert!( + directory_size(&as_fs) <= MAX_SUBFS_SIZE * 21 / 20, + "{}", + write.rkey + ); + } + } + + #[test] + fn nested_directory_chunks() { + let plan = split_plan( + &files(600, "assets/"), + "did:plc:test", + "site", + "g", + &created(), + ) + .unwrap(); + assert!(plan.records.len() >= 3); + assert_eq!(count_files(&plan.directory), 0); + assert!(!extract_subfs_uris(&plan.directory)[0].flat); + assert_eq!(plan.records.last().unwrap().rkey, "site-g-subfs-1"); + let parent = serde_json::to_value(&plan.records.last().unwrap().root).unwrap(); + assert!(parent["entries"][0]["node"].get("flat").is_none()); + assert_eq!( + parent["entries"][0]["node"]["$type"], + "place.wisp.subfs#subfs" + ); + } +} diff --git a/cli-rs/crates/wisp-core/src/subfs.rs b/cli-rs/crates/wisp-core/src/subfs.rs new file mode 100644 index 0000000..82b4c86 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/subfs.rs @@ -0,0 +1,1260 @@ +//! Bounded SubFS expansion with per-file source repository provenance. +use jacquard_common::types::blob::BlobRef; +use jacquard_lexicon::schema::LexiconSchema; +use serde_json::{Value, json}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::future::{Future, poll_fn}; +use std::task::Poll; +use wisp_lexicons::place_wisp::{fs, subfs}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SubfsSubject { + pub uri: String, + pub repo: String, + pub collection: String, + pub rkey: String, +} + +pub fn parse_subfs_subject(uri: &str) -> Result { + let parts: Vec<_> = uri + .strip_prefix("at://") + .ok_or(ExpansionError::InvalidSubject)? + .split('/') + .collect(); + if parts.len() != 3 || parts[1] != "place.wisp.subfs" { + return Err(ExpansionError::InvalidSubject); + } + let repo = parts[0]; + let rkey = parts[2]; + let (method, identifier) = repo + .strip_prefix("did:") + .and_then(|did| did.split_once(':')) + .ok_or(ExpansionError::InvalidSubject)?; + let mut identifier_bytes = identifier.bytes(); + let mut valid_identifier = true; + let mut final_literal = false; + while let Some(byte) = identifier_bytes.next() { + if byte == b'%' { + final_literal = false; + valid_identifier &= (0..2).all(|_| { + identifier_bytes + .next() + .is_some_and(|b| b.is_ascii_digit() || (b'A'..=b'F').contains(&b)) + }); + } else { + valid_identifier &= byte.is_ascii_alphanumeric() || b"._:-".contains(&byte); + final_literal = byte.is_ascii_alphanumeric() || b"._-".contains(&byte); + } + } + if repo.len() > 2048 + || method.is_empty() + || !method.bytes().all(|b| b.is_ascii_lowercase()) + || !valid_identifier + || !final_literal + || rkey.is_empty() + || rkey.len() > 512 + || matches!(rkey, "." | "..") + || !rkey + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_~.:-".contains(&b)) + { + return Err(ExpansionError::InvalidSubject); + } + Ok(SubfsSubject { + uri: uri.into(), + repo: repo.into(), + collection: parts[1].into(), + rkey: rkey.into(), + }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExpansionError { + Cycle, + DuplicatePath, + FetchFailed, + InvalidLimits, + InvalidRecord, + InvalidSubject, + MaxDepth, + MaxEntries, + MaxFiles, + MaxRecords, + MaxRawBytes, + MissingRecord, +} + +impl ExpansionError { + pub fn code(self) -> &'static str { + match self { + Self::Cycle => "CYCLE", + Self::DuplicatePath => "DUPLICATE_PATH", + Self::FetchFailed => "FETCH_FAILED", + Self::InvalidLimits => "INVALID_LIMITS", + Self::InvalidRecord => "INVALID_RECORD", + Self::InvalidSubject => "INVALID_SUBJECT", + Self::MaxDepth => "MAX_DEPTH", + Self::MaxEntries => "MAX_ENTRIES", + Self::MaxFiles => "MAX_FILES", + Self::MaxRecords => "MAX_RECORDS", + Self::MaxRawBytes => "MAX_RAW_BYTES", + Self::MissingRecord => "MISSING_RECORD", + } + } +} +impl fmt::Display for ExpansionError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::Cycle => "SubFS expansion contains a cycle", + Self::DuplicatePath => "SubFS expansion contains duplicate output paths", + Self::FetchFailed => "SubFS record fetch failed", + Self::InvalidLimits => "SubFS expansion limits are invalid", + Self::InvalidRecord => "SubFS record is invalid", + Self::InvalidSubject => "SubFS subject is invalid", + Self::MaxDepth => "SubFS expansion exceeded its nesting limit", + Self::MaxEntries => "SubFS expansion exceeded its entry limit", + Self::MaxFiles => "SubFS expansion exceeded its file limit", + Self::MaxRecords => "SubFS expansion exceeded its record limit", + Self::MaxRawBytes => "SubFS expansion exceeded its raw JSON byte limit", + Self::MissingRecord => "SubFS record is missing", + }) + } +} +impl std::error::Error for ExpansionError {} + +#[derive(Clone, Copy, Debug)] +pub struct ExpansionLimits { + pub max_concurrent_fetches: usize, + pub max_depth: usize, + pub max_entries: usize, + pub max_files: usize, + pub max_records: usize, + pub max_raw_json_bytes: usize, +} +impl Default for ExpansionLimits { + fn default() -> Self { + Self { + max_concurrent_fetches: 4, + max_depth: 10, + max_entries: 5_000, + max_files: 1_000, + max_records: 100, + max_raw_json_bytes: 8 * 1024 * 1024, + } + } +} +impl ExpansionLimits { + fn validate(self) -> Result { + if self.max_concurrent_fetches == 0 + || self.max_records == 0 + || self.max_raw_json_bytes == 0 + || [ + self.max_concurrent_fetches, + self.max_depth, + self.max_entries, + self.max_files, + self.max_records, + self.max_raw_json_bytes, + ] + .iter() + .any(|&value| value as u128 > 9_007_199_254_740_991) + { + return Err(ExpansionError::InvalidLimits); + } + Ok(self) + } +} + +#[derive(Debug)] +pub struct ExpandedSubfs { + pub root: Value, + pub owner_did_by_file_path: BTreeMap, +} + +fn entries(node: &Value) -> Result<&Vec, ExpansionError> { + node.get("entries") + .and_then(Value::as_array) + .ok_or(ExpansionError::InvalidRecord) +} + +fn validate_typed(raw: &Value) -> Result +where + T: serde::de::DeserializeOwned + LexiconSchema, +{ + let typed: T = + serde_json::from_value(raw.clone()).map_err(|_| ExpansionError::InvalidRecord)?; + typed + .validate() + .map_err(|_| ExpansionError::InvalidRecord)?; + Ok(typed) +} + +/// Open unions fall back to unknown variants on malformed known nodes, so each +/// known node needs explicit deserialization plus deferred CID/lexicon checks. +fn validate_directory(node: &Value, namespace: &str, nesting: usize) -> Result<(), ExpansionError> { + if nesting > 128 || node["type"] != "directory" || entries(node)?.len() > 500 { + return Err(ExpansionError::InvalidRecord); + } + if namespace == "place.wisp.fs" { + validate_typed::>(node)?; + } else { + validate_typed::>(node)?; + } + for entry in entries(node)? { + if namespace == "place.wisp.fs" { + validate_typed::>(entry)?; + } else { + validate_typed::>(entry)?; + } + let name = entry["name"] + .as_str() + .ok_or(ExpansionError::InvalidRecord)?; + if name.len() > 255 { + return Err(ExpansionError::InvalidRecord); + } + let child = &entry["node"]; + let tag = child["$type"] + .as_str() + .ok_or(ExpansionError::InvalidRecord)?; + let kind = tag + .strip_prefix(namespace) + .and_then(|suffix| suffix.strip_prefix('#')); + match kind { + Some("directory") => validate_directory(child, namespace, nesting + 1)?, + Some("file") => { + let blob: BlobRef = serde_json::from_value(child["blob"].clone()) + .map_err(|_| ExpansionError::InvalidRecord)?; + if !blob.blob().cid().is_valid() + || child["type"] != "file" + || blob.blob().size > 1_000_000_000 + || child.get("encoding").is_some_and(|value| value != "gzip") + || child.get("base64").is_some_and(|value| !value.is_boolean()) + || child + .get("mimeType") + .is_some_and(|value| !value.is_string()) + { + return Err(ExpansionError::InvalidRecord); + } + if namespace == "place.wisp.fs" { + validate_typed::>(child)?; + } else { + validate_typed::>(child)?; + } + } + Some("subfs") => { + if child["type"] != "subfs" { + return Err(ExpansionError::InvalidRecord); + } + if namespace == "place.wisp.fs" { + validate_typed::>(child)?; + } else { + validate_typed::>(child)?; + } + } + _ => {} + } + } + Ok(()) +} + +fn validate_record_shape(raw: &Value, namespace: &str) -> Result<(), ExpansionError> { + if raw["$type"] != namespace + || raw + .get("fileCount") + .is_some_and(|count| count.as_u64().is_none_or(|count| count > 1000)) + { + return Err(ExpansionError::InvalidRecord); + } + validate_directory(&raw["root"], namespace, 0) +} + +/// Validate a raw root manifest recursively before trusting its open-union nodes. +pub fn validate_fs_record(raw: &Value) -> Result<(), ExpansionError> { + validate_record_shape(raw, "place.wisp.fs")?; + validate_typed::>(raw)?; + Ok(()) +} + +fn validate_record(raw: Value) -> Result { + validate_record_shape(&raw, "place.wisp.subfs")?; + validate_typed::>(&raw)?; + Ok(raw) +} + +fn convert_entries(children: &[Value], nesting: usize) -> Result, ExpansionError> { + if nesting > 128 { + return Err(ExpansionError::InvalidRecord); + } + children + .iter() + .map(|entry| { + let node = &entry["node"]; + let converted = match node["type"].as_str() { + Some("directory") if node.get("entries").is_some() => { + make_directory(convert_entries(entries(node)?, nesting + 1)?) + } + Some("file") if node.get("blob").is_some() => { + let mut file = json!({ + "$type": "place.wisp.fs#file", + "type": "file", + "blob": node["blob"], + }); + for key in ["encoding", "mimeType", "base64"] { + if let Some(value) = node.get(key) { + file[key] = value.clone(); + } + } + file + } + Some("subfs") if node.get("subject").is_some() => json!({ + "$type": "place.wisp.fs#subfs", + "type": "subfs", + "subject": node["subject"], + }), + _ => node.clone(), + }; + Ok(json!({ + "$type": "place.wisp.fs#entry", + "name": entry["name"], + "node": converted, + })) + }) + .collect() +} + +fn make_directory(entries: Vec) -> Value { + json!({"$type":"place.wisp.fs#directory", "type":"directory", "entries":entries}) +} + +/// Cleanup callers intentionally extract subjects without validation or fetching. +pub fn extract_subfs_uris(directory: &Value, current_path: &str) -> Vec<(String, String)> { + let mut output = Vec::new(); + let mut pending: Vec<_> = entries(directory) + .into_iter() + .flatten() + .rev() + .map(|entry| (entry, current_path.to_owned())) + .collect(); + while let Some((entry, prefix)) = pending.pop() { + let path = join_path(&prefix, entry["name"].as_str().unwrap_or("")); + let node = &entry["node"]; + if node["type"] == "subfs" { + if let Some(subject) = node["subject"].as_str() { + output.push((subject.into(), path)); + } + } else if node["type"] == "directory" + && let Ok(children) = entries(node) + { + pending.extend(children.iter().rev().map(|entry| (entry, path.clone()))); + } + } + output +} + +fn join_path(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.into() + } else { + format!("{prefix}/{name}") + } +} + +#[derive(Clone, Copy)] +struct Branch<'a> { + depth: usize, + subjects: &'a BTreeSet, + prefix: &'a str, + owner: &'a str, + nesting: usize, +} + +struct Expansion { + fetch: F, + limits: ExpansionLimits, + records: BTreeMap, + owners: BTreeMap, + paths: BTreeSet, + files: usize, + raw_bytes: usize, +} + +impl Expansion +where + F: FnMut(SubfsSubject) -> Fut, + Fut: Future, ExpansionError>>, +{ + fn count(&mut self, entry: &Value, path: String, owner: &str) -> Result<(), ExpansionError> { + if !self.paths.insert(path.clone()) { + return Err(ExpansionError::DuplicatePath); + } + if self.paths.len() > self.limits.max_entries { + return Err(ExpansionError::MaxEntries); + } + if entry["node"]["type"] == "file" && entry["node"].get("blob").is_some() { + self.files += 1; + if self.files > self.limits.max_files { + return Err(ExpansionError::MaxFiles); + } + self.owners.insert(path, owner.into()); + } + Ok(()) + } + + fn accept_record( + &mut self, + subject: &SubfsSubject, + raw: Option, + ) -> Result<(), ExpansionError> { + let raw = raw + .filter(|value| !value.is_null()) + .ok_or(ExpansionError::MissingRecord)?; + let bytes = serde_json::to_vec(&raw) + .map_err(|_| ExpansionError::InvalidRecord)? + .len(); + self.raw_bytes = self + .raw_bytes + .checked_add(bytes) + .ok_or(ExpansionError::MaxRawBytes)?; + if self.raw_bytes > self.limits.max_raw_json_bytes { + return Err(ExpansionError::MaxRawBytes); + } + self.records + .insert(subject.uri.clone(), validate_record(raw)?); + Ok(()) + } + + async fn prefetch(&mut self, subjects: Vec) -> Result<(), ExpansionError> { + for chunk in subjects.chunks(self.limits.max_concurrent_fetches) { + if self.records.len() + chunk.len() > self.limits.max_records { + return Err(ExpansionError::MaxRecords); + } + let mut pending: Vec<_> = chunk + .iter() + .map(|subject| Some(Box::pin((self.fetch)(subject.clone())))) + .collect(); + poll_fn(|cx| { + let mut complete = true; + for (subject, future) in chunk.iter().zip(&mut pending) { + if let Some(work) = future { + match work.as_mut().poll(cx) { + Poll::Ready(Err(error)) => return Poll::Ready(Err(error)), + Poll::Ready(Ok(raw)) => { + *future = None; + if let Err(error) = self.accept_record(subject, raw) { + return Poll::Ready(Err(error)); + } + } + Poll::Pending => complete = false, + } + } + } + if complete { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + }) + .await?; + } + Ok(()) + } + + async fn expand_entries( + &mut self, + children: Vec, + context: Branch<'_>, + ) -> Result, ExpansionError> { + let Branch { + depth, + subjects: branch, + prefix, + owner, + nesting, + } = context; + if nesting > 128 { + return Err(ExpansionError::InvalidRecord); + } + let mut output = Vec::new(); + for batch in children.chunks(32) { + // Cooperative yielding lets an outer timeout cancel even fetch-free trees. + let mut yielded = false; + poll_fn(|cx| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }) + .await; + let mut subjects = Vec::new(); + let mut seen = BTreeSet::new(); + for entry in batch { + let node = &entry["node"]; + if node["type"] == "subfs" && node.get("subject").is_some() { + if depth >= self.limits.max_depth { + return Err(ExpansionError::MaxDepth); + } + let subject = parse_subfs_subject( + node["subject"] + .as_str() + .ok_or(ExpansionError::InvalidSubject)?, + )?; + if branch.contains(&subject.uri) { + return Err(ExpansionError::Cycle); + } + if !self.records.contains_key(&subject.uri) && seen.insert(subject.uri.clone()) + { + subjects.push(subject); + } + } + } + self.prefetch(subjects).await?; + for mut entry in batch.iter().cloned() { + let name = entry["name"] + .as_str() + .ok_or(ExpansionError::InvalidRecord)?; + let path = join_path(prefix, name); + let node = &entry["node"]; + if node["type"] == "subfs" && node.get("subject").is_some() { + let subject = parse_subfs_subject( + node["subject"] + .as_str() + .ok_or(ExpansionError::InvalidSubject)?, + )?; + let record = &self.records[&subject.uri]; + let nested = convert_entries(entries(&record["root"])?, 0)?; + let mut next_branch = branch.clone(); + next_branch.insert(subject.uri); + let mounted = node["flat"] == false; + let replacement = Box::pin(self.expand_entries( + nested, + Branch { + depth: depth + 1, + subjects: &next_branch, + prefix: if mounted { &path } else { prefix }, + owner: &subject.repo, + nesting: nesting + 1, + }, + )) + .await?; + if mounted { + entry["node"] = make_directory(replacement); + self.count(&entry, path, owner)?; + output.push(entry); + } else { + output.extend(replacement); + } + } else if node["type"] == "directory" && node.get("entries").is_some() { + let children = entries(node)?.clone(); + let expanded = Box::pin(self.expand_entries( + children, + Branch { + prefix: &path, + nesting: nesting + 1, + ..context + }, + )) + .await?; + entry["node"]["entries"] = Value::Array(expanded); + self.count(&entry, path, owner)?; + output.push(entry); + } else { + self.count(&entry, path, owner)?; + output.push(entry); + } + } + } + Ok(output) + } +} + +/// Fetches raw record values, never XRPC wrappers. Dropping this future cancels +/// all pending callbacks; callers own deadline and transfer-budget enforcement. +pub async fn expand_subfs( + mut root: Value, + root_owner_did: &str, + limits: ExpansionLimits, + fetch: F, +) -> Result +where + F: FnMut(SubfsSubject) -> Fut, + Fut: Future, ExpansionError>>, +{ + let mut state = Expansion { + fetch, + limits: limits.validate()?, + records: BTreeMap::new(), + owners: BTreeMap::new(), + paths: BTreeSet::new(), + files: 0, + raw_bytes: 0, + }; + let children = entries(&root)?.clone(); + root["entries"] = Value::Array( + state + .expand_entries( + children, + Branch { + depth: 0, + subjects: &BTreeSet::new(), + prefix: "", + owner: root_owner_did, + nesting: 0, + }, + ) + .await?, + ); + Ok(ExpandedSubfs { + root, + owner_did_by_file_path: state.owners, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::Cell; + use std::pin::pin; + use std::rc::Rc; + use std::sync::Arc; + use std::task::{Context, Wake, Waker}; + + struct ThreadWake(std::thread::Thread); + impl Wake for ThreadWake { + fn wake(self: Arc) { + self.0.unpark(); + } + } + fn block_on(future: impl Future) -> T { + let waker = Waker::from(Arc::new(ThreadWake(std::thread::current()))); + let mut cx = Context::from_waker(&waker); + let mut future = pin!(future); + loop { + if let Poll::Ready(result) = future.as_mut().poll(&mut cx) { + return result; + } + std::thread::park(); + } + } + fn subject(owner: &str, key: &str) -> String { + format!("at://did:plc:{owner}/place.wisp.subfs/{key}") + } + fn file(name: &str, lexicon: &str) -> Value { + json!({"name":name,"node":{"$type":format!("place.wisp.{lexicon}#file"),"type":"file","blob":{ + "$type":"blob","ref":{"$link":"bafkreie56uer6qjm7mqckb52xtd3mecy77t5axnumzdcnhjzpcwiin6zue"},"mimeType":"text/plain","size":1 + }}}) + } + fn mount(name: &str, subject: &str, flat: Option, lexicon: &str) -> Value { + let mut node = + json!({"$type":format!("place.wisp.{lexicon}#subfs"),"type":"subfs","subject":subject}); + if let Some(flat) = flat { + node["flat"] = flat.into(); + } + json!({"name":name,"node":node}) + } + fn root(children: Vec) -> Value { + json!({"type":"directory","entries":children}) + } + fn record(children: Vec) -> Value { + json!({"$type":"place.wisp.subfs","createdAt":"2026-01-01T00:00:00.000Z","root":root(children)}) + } + fn expand( + root: Value, + records: BTreeMap, + limits: ExpansionLimits, + ) -> Result { + block_on(expand_subfs(root, "did:plc:root", limits, move |subject| { + std::future::ready(Ok(records.get(&subject.uri).cloned())) + })) + } + fn names(root: &Value) -> Vec<&str> { + entries(root) + .unwrap() + .iter() + .map(|entry| entry["name"].as_str().unwrap()) + .collect() + } + + #[test] + fn only_canonical_did_subjects() { + let uri = subject("owner", "record-key_1"); + assert_eq!( + parse_subfs_subject(&uri).unwrap(), + SubfsSubject { + uri: uri.clone(), + repo: "did:plc:owner".into(), + collection: "place.wisp.subfs".into(), + rkey: "record-key_1".into() + } + ); + for uri in [ + "at://example.com/place.wisp.subfs/record", + "at://did:plc:x/place.wisp.fs/record", + "at://did:plc:x/place.wisp.subfs/record?query=1", + "at://did:plc:x/place.wisp.subfs/record/extra", + "at://did:plc:x/place.wisp.subfs/%2F", + "at://did:plc:x/place.wisp.subfs/.", + "at://did:plc:x/place.wisp.subfs/..", + "at://did:plc:x/place.wisp.subfs/record#fragment", + "at://did:PLC:x/place.wisp.subfs/r", + "at://did:plc:/place.wisp.subfs/r", + "at://did:plc:x:/place.wisp.subfs/r", + "at://did:plc:x%2a/place.wisp.subfs/r", + "at://did:plc:x%2A/place.wisp.subfs/r", + "at://did:plc:x%XXz/place.wisp.subfs/r", + "at://did:plc:x/place.wisp.subfs/r\n", + ] { + assert_eq!( + parse_subfs_subject(uri), + Err(ExpansionError::InvalidSubject), + "{uri}" + ); + } + assert!(parse_subfs_subject("at://did:web:host%3A443/place.wisp.subfs/r").is_ok()); + assert!(parse_subfs_subject(&subject("owner", &"a".repeat(512))).is_ok()); + assert!(parse_subfs_subject(&subject("owner", &"a".repeat(513))).is_err()); + assert!(parse_subfs_subject(&subject(&"a".repeat(2040), "r")).is_ok()); + assert!(parse_subfs_subject(&subject(&"a".repeat(2041), "r")).is_err()); + } + + #[test] + fn flat_splice_order_provenance_and_metadata() { + let uri = subject("first", "flat"); + let mut child = file("one.txt", "subfs"); + child["node"]["encoding"] = "gzip".into(); + child["node"]["mimeType"] = "text/plain".into(); + child["node"]["base64"] = true.into(); + let expanded = expand( + root(vec![ + file("before.txt", "fs"), + mount("mount", &uri, None, "fs"), + file("after.txt", "fs"), + ]), + BTreeMap::from([(uri, record(vec![child, file("two.txt", "subfs")]))]), + ExpansionLimits::default(), + ) + .unwrap(); + assert_eq!( + names(&expanded.root), + ["before.txt", "one.txt", "two.txt", "after.txt"] + ); + assert_eq!( + expanded.owner_did_by_file_path, + BTreeMap::from([ + ("before.txt".into(), "did:plc:root".into()), + ("one.txt".into(), "did:plc:first".into()), + ("two.txt".into(), "did:plc:first".into()), + ("after.txt".into(), "did:plc:root".into()) + ]) + ); + let node = &expanded.root["entries"][1]["node"]; + assert_eq!(node["$type"], "place.wisp.fs#file"); + assert_eq!(node["encoding"], "gzip"); + assert_eq!(node["mimeType"], "text/plain"); + assert_eq!(node["base64"], true); + } + + #[test] + fn mounted_nested_cross_repository_tree() { + let first = subject("first", "first"); + let second = subject("second", "second"); + // Nested flat fields are ignored even if supplied as extension data. + let nested = mount("nested", &second, Some(false), "subfs"); + let expanded = expand( + root(vec![ + file("before.txt", "fs"), + mount("assets", &first, Some(false), "fs"), + file("after.txt", "fs"), + ]), + BTreeMap::from([ + (first, record(vec![nested])), + (second, record(vec![file("logo.txt", "subfs")])), + ]), + ExpansionLimits::default(), + ) + .unwrap(); + assert_eq!(names(&expanded.root), ["before.txt", "assets", "after.txt"]); + let mounted = &expanded.root["entries"][1]["node"]; + assert_eq!(mounted["type"], "directory"); + assert_eq!(names(mounted), ["logo.txt"]); + assert_eq!( + expanded.owner_did_by_file_path["assets/logo.txt"], + "did:plc:second" + ); + } + + #[test] + fn duplicate_subjects_share_one_fetch_not_one_expansion() { + let uri = subject("owner", "duplicate"); + let count = Cell::new(0); + let expanded = block_on(expand_subfs( + root(vec![ + mount("flat", &uri, None, "fs"), + mount("folder", &uri, Some(false), "fs"), + ]), + "did:plc:root", + ExpansionLimits::default(), + |_| { + count.set(count.get() + 1); + std::future::ready(Ok(Some(record(vec![file("child.txt", "subfs")])))) + }, + )) + .unwrap(); + assert_eq!(count.get(), 1); + assert_eq!( + expanded.owner_did_by_file_path["child.txt"], + "did:plc:owner" + ); + assert_eq!( + expanded.owner_did_by_file_path["folder/child.txt"], + "did:plc:owner" + ); + } + + #[test] + fn cycles_and_collisions_fail_closed() { + let first = subject("owner", "cycle-a"); + let second = subject("owner", "cycle-b"); + let records = BTreeMap::from([ + ( + first.clone(), + record(vec![mount("b", &second, None, "subfs")]), + ), + (second, record(vec![mount("a", &first, None, "subfs")])), + ]); + assert_eq!( + expand( + root(vec![mount("a", &first, None, "fs")]), + records, + ExpansionLimits::default() + ) + .unwrap_err(), + ExpansionError::Cycle + ); + assert_eq!( + expand( + root(vec![file("same.txt", "fs"), mount("a", &first, None, "fs")]), + BTreeMap::from([(first, record(vec![file("same.txt", "subfs")]))]), + ExpansionLimits::default() + ) + .unwrap_err(), + ExpansionError::DuplicatePath + ); + assert_eq!( + expand( + root(vec![ + json!({"name":"same","node":root(vec![])}), + file("same", "fs") + ]), + BTreeMap::new(), + ExpansionLimits::default() + ) + .unwrap_err(), + ExpansionError::DuplicatePath + ); + } + + #[test] + fn depth_record_entry_file_and_raw_budgets() { + let first = subject("owner", "budget-a"); + let second = subject("owner", "budget-b"); + let records = BTreeMap::from([ + ( + first.clone(), + record(vec![mount("second", &second, None, "subfs")]), + ), + ( + second.clone(), + record(vec![file("one.txt", "subfs"), file("two.txt", "subfs")]), + ), + ]); + for (limits, error) in [ + ( + ExpansionLimits { + max_depth: 1, + ..ExpansionLimits::default() + }, + ExpansionError::MaxDepth, + ), + ( + ExpansionLimits { + max_records: 1, + ..ExpansionLimits::default() + }, + ExpansionError::MaxRecords, + ), + ( + ExpansionLimits { + max_entries: 1, + ..ExpansionLimits::default() + }, + ExpansionError::MaxEntries, + ), + ( + ExpansionLimits { + max_files: 1, + ..ExpansionLimits::default() + }, + ExpansionError::MaxFiles, + ), + ( + ExpansionLimits { + max_raw_json_bytes: 1, + ..ExpansionLimits::default() + }, + ExpansionError::MaxRawBytes, + ), + ] { + assert_eq!( + expand( + root(vec![mount("first", &first, None, "fs")]), + records.clone(), + limits + ) + .unwrap_err(), + error + ); + } + let bytes = serde_json::to_vec(&records[&first]).unwrap().len(); + assert_eq!( + expand( + root(vec![mount("first", &first, None, "fs")]), + records, + ExpansionLimits { + max_raw_json_bytes: bytes, + ..ExpansionLimits::default() + } + ) + .unwrap_err(), + ExpansionError::MaxRawBytes + ); + assert!( + expand( + root(vec![]), + BTreeMap::new(), + ExpansionLimits { + max_depth: 0, + max_entries: 0, + max_files: 0, + ..ExpansionLimits::default() + } + ) + .is_ok() + ); + assert_eq!( + expand( + root(vec![mount("a", &first, None, "fs")]), + BTreeMap::new(), + ExpansionLimits { + max_depth: 0, + ..ExpansionLimits::default() + } + ) + .unwrap_err(), + ExpansionError::MaxDepth + ); + } + + #[test] + fn invalid_limits_missing_invalid_subject_and_fetch_errors() { + for limits in [ + ExpansionLimits { + max_concurrent_fetches: 0, + ..ExpansionLimits::default() + }, + ExpansionLimits { + max_records: 0, + ..ExpansionLimits::default() + }, + ExpansionLimits { + max_raw_json_bytes: 0, + ..ExpansionLimits::default() + }, + ExpansionLimits { + max_files: usize::MAX, + ..ExpansionLimits::default() + }, + ] { + assert_eq!( + expand(root(vec![]), BTreeMap::new(), limits).unwrap_err(), + ExpansionError::InvalidLimits + ); + } + let uri = subject("owner", "missing"); + assert_eq!( + expand( + root(vec![mount( + "a", + "at://not-a-did/place.wisp.subfs/r", + None, + "fs" + )]), + BTreeMap::new(), + ExpansionLimits::default() + ) + .unwrap_err(), + ExpansionError::InvalidSubject + ); + assert_eq!( + expand( + root(vec![mount("a", &uri, None, "fs")]), + BTreeMap::new(), + ExpansionLimits::default() + ) + .unwrap_err(), + ExpansionError::MissingRecord + ); + let error = block_on(expand_subfs( + root(vec![mount("a", &uri, None, "fs")]), + "root", + ExpansionLimits::default(), + |_| std::future::ready(Err(ExpansionError::FetchFailed)), + )) + .unwrap_err(); + assert_eq!(error.to_string(), "SubFS record fetch failed"); + assert_eq!(error.code(), "FETCH_FAILED"); + } + + #[test] + fn directory_depth_does_not_spend_subfs_budget_but_is_stack_bounded() { + let directory = json!({"name": "assets", "node": root(vec![file("index.html", "fs")])}); + assert!( + expand( + root(vec![directory]), + BTreeMap::new(), + ExpansionLimits { + max_depth: 0, + ..ExpansionLimits::default() + } + ) + .is_ok() + ); + let mut nested = root(vec![]); + for _ in 0..130 { + nested = root(vec![json!({"name": "nested", "node": nested})]); + } + assert_eq!( + expand(nested, BTreeMap::new(), ExpansionLimits::default()).unwrap_err(), + ExpansionError::InvalidRecord + ); + } + + #[test] + fn raw_records_are_recursively_validated() { + let uri = subject("owner", "malformed"); + let mut malformed = vec![json!({"$type":"place.wisp.subfs","root":{"type":"directory"}})]; + for (path, value) in [ + ("/createdAt", json!("invalid")), + ("/fileCount", json!(-1)), + ("/fileCount", json!(1001)), + ("/root/type", json!("file")), + ("/root/entries/0/node/type", json!("directory")), + ("/root/entries/0/node/blob/size", json!(1_000_000_001)), + ("/root/entries/0/node/blob/size", json!(-1)), + ("/root/entries/0/node/encoding", json!("br")), + ("/root/entries/0/node/base64", json!("true")), + ("/root/entries/0/node/blob/ref/$link", json!("not-a-cid")), + ("/root/entries/0/name", json!("x".repeat(256))), + ] { + let mut raw = record(vec![file("test", "subfs")]); + // Optional encoding/base64 keys need to exist before pointer mutation. + raw["root"]["entries"][0]["node"]["encoding"] = "gzip".into(); + raw["root"]["entries"][0]["node"]["base64"] = false.into(); + raw["fileCount"] = 1.into(); + *raw.pointer_mut(path).unwrap() = value; + malformed.push(raw); + } + malformed.push(record(vec![file("test", "subfs"); 501])); + for raw in malformed { + assert_eq!( + expand( + root(vec![mount("a", &uri, None, "fs")]), + BTreeMap::from([(uri.clone(), raw.clone())]), + ExpansionLimits::default() + ) + .unwrap_err(), + ExpansionError::InvalidRecord, + "{raw}" + ); + } + } + + #[test] + fn root_fs_validation_rejects_malformed_known_unions() { + let uri = subject("owner", "child"); + let manifest = json!({"$type":"place.wisp.fs", "site":"example", + "createdAt":"2026-01-01T00:00:00.000Z", "fileCount":1, + "root":root(vec![file("index.html", "fs"), mount("child", &uri, Some(false), "fs")])}); + assert!(validate_fs_record(&manifest).is_ok()); + for (path, value) in [ + ("/$type", json!("place.wisp.subfs")), + ("/site", json!(1)), + ("/createdAt", json!("invalid")), + ("/fileCount", json!(1001)), + ("/root/type", json!("file")), + ("/root/entries", json!(false)), + ("/root/entries/0/name", json!("x".repeat(256))), + ("/root/entries/0/node/type", json!("directory")), + ("/root/entries/0/node/blob/ref/$link", json!("not-a-cid")), + ("/root/entries/0/node/blob/size", json!(1_000_000_001)), + ("/root/entries/1/node/flat", json!("false")), + ("/root/entries/1/node/subject", json!("not-an-at-uri")), + ("/root/entries/1/node/type", json!("file")), + ] { + let mut raw = manifest.clone(); + *raw.pointer_mut(path).unwrap() = value; + assert_eq!( + validate_fs_record(&raw), + Err(ExpansionError::InvalidRecord), + "{raw}" + ); + } + for (key, value) in [ + ("encoding", json!("br")), + ("base64", json!("true")), + ("mimeType", json!(5)), + ] { + let mut raw = manifest.clone(); + raw["root"]["entries"][0]["node"][key] = value; + assert_eq!(validate_fs_record(&raw), Err(ExpansionError::InvalidRecord)); + } + let mut raw = manifest.clone(); + raw["root"] = root(vec![json!({"name":"folder","node":{ + "$type":"place.wisp.fs#directory", "type":"directory", "entries":[file("child", "fs")] + }})]); + assert!(validate_fs_record(&raw).is_ok()); + raw["root"]["entries"][0]["node"]["entries"][0]["node"]["blob"]["size"] = (-1).into(); + assert_eq!(validate_fs_record(&raw), Err(ExpansionError::InvalidRecord)); + raw["root"] = root(vec![ + json!({"name":"future","node":{"$type":"place.wisp.future","value":42}}), + ]); + assert!(validate_fs_record(&raw).is_ok()); + } + + #[test] + fn concurrent_fetches_reach_but_never_exceed_cap() { + let active = Rc::new(Cell::new(0)); + let max_active = Rc::new(Cell::new(0)); + let children = (0..6) + .map(|i| { + mount( + &format!("mount{i}"), + &subject("owner", &format!("parallel{i}")), + None, + "fs", + ) + }) + .collect(); + let expanded = block_on(expand_subfs( + root(children), + "root", + ExpansionLimits { + max_concurrent_fetches: 2, + ..ExpansionLimits::default() + }, + |subject| { + active.set(active.get() + 1); + max_active.set(max_active.get().max(active.get())); + let active = active.clone(); + async move { + let mut yielded = false; + poll_fn(|cx| { + if yielded { + Poll::Ready(()) + } else { + yielded = true; + cx.waker().wake_by_ref(); + Poll::Pending + } + }) + .await; + active.set(active.get() - 1); + Ok(Some(record(vec![file(&subject.rkey, "subfs")]))) + } + }, + )) + .unwrap(); + assert_eq!(max_active.get(), 2); + assert_eq!(active.get(), 0); + assert_eq!(expanded.owner_did_by_file_path.len(), 6); + } + + #[test] + fn missing_record_fails_without_waiting_for_stalled_sibling() { + let children = vec![ + mount("first", &subject("owner", "first"), None, "fs"), + mount("stalled", &subject("owner", "stalled"), None, "fs"), + ]; + let pending = expand_subfs( + root(children), + "root", + ExpansionLimits::default(), + |subject| { + poll_fn(move |_| { + if subject.rkey == "first" { + Poll::Ready(Ok(None)) + } else { + Poll::Pending + } + }) + }, + ); + let mut pending = pin!(pending); + let waker = Waker::from(Arc::new(ThreadWake(std::thread::current()))); + let mut context = Context::from_waker(&waker); + assert!(pending.as_mut().poll(&mut context).is_pending()); + assert!(matches!( + pending.as_mut().poll(&mut context), + Poll::Ready(Err(ExpansionError::MissingRecord)) + )); + } + + #[test] + fn pending_tree_yields_and_can_be_cancelled_before_fetch() { + let fetches = Cell::new(0); + let children = (0..64).map(|i| file(&format!("file{i}"), "fs")).collect(); + let pending = expand_subfs(root(children), "root", ExpansionLimits::default(), |_| { + fetches.set(fetches.get() + 1); + std::future::ready(Ok(None)) + }); + let mut pending = Box::pin(pending); + let waker = Waker::from(Arc::new(ThreadWake(std::thread::current()))); + assert!( + pending + .as_mut() + .poll(&mut Context::from_waker(&waker)) + .is_pending() + ); + drop(pending); + assert_eq!(fetches.get(), 0); + } + + #[test] + fn unknown_union_preserved_and_cleanup_order_is_depth_first() { + let unknown = json!({"name":"future","node":{"$type":"place.wisp.future","value":42}}); + let expanded = expand( + root(vec![unknown.clone()]), + BTreeMap::new(), + ExpansionLimits::default(), + ) + .unwrap(); + assert_eq!(expanded.root["entries"][0], unknown); + assert!(expanded.owner_did_by_file_path.is_empty()); + let directory = root(vec![ + json!({"name":"a","node":root(vec![mount("x","unvalidated-x",None,"fs")])}), + json!({"name":"b","node":root(vec![mount("y","unvalidated-y",None,"fs")])}), + mount("z", "unvalidated-z", None, "fs"), + ]); + assert_eq!( + extract_subfs_uris(&directory, "base"), + vec![ + ("unvalidated-x".into(), "base/a/x".into()), + ("unvalidated-y".into(), "base/b/y".into()), + ("unvalidated-z".into(), "base/z".into()) + ] + ); + } +} diff --git a/cli-rs/crates/wisp-core/src/tree.rs b/cli-rs/crates/wisp-core/src/tree.rs new file mode 100644 index 0000000..49969b7 --- /dev/null +++ b/cli-rs/crates/wisp-core/src/tree.rs @@ -0,0 +1,219 @@ +//! Typed trees assembled from path-based upload results without retaining bytes. +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::{blob::BlobRef, string::AtprotoStr, value::Data}; +use std::collections::BTreeMap; +use wisp_lexicons::place_wisp::fs::{Directory, Entry, EntryNode, File}; + +#[derive(Debug, Clone)] +pub struct UploadResult { + pub blob: BlobRef, + pub encoding: Option, + pub mime_type: String, + pub base64: bool, +} + +/// A directory node. Its `$type` comes from the enclosing union tag, or from +/// [`root_type`] when it is a record's root. +pub fn directory(entries: Vec) -> Directory { + Directory { + entries, + r#type: "directory".into(), + extra_data: None, + } +} + +/// `extra_data` giving a record's root directory an explicit `$type` field, +/// as the TypeScript CLI writes it. Nested directories must not carry one: +/// their union tag already serializes `$type`. +pub fn root_type(def: &str) -> Option> { + Some(BTreeMap::from([( + "$type".into(), + Data::String(AtprotoStr::new(def.into())), + )])) +} + +pub fn build_tree(files: &[(String, UploadResult)]) -> Directory { + let valid: Vec<_> = files + .iter() + .filter(|(path, _)| !path.is_empty() && path != ".git" && !path.starts_with(".git/")) + .collect(); + let mut root = directory(Vec::new()); + for (path, upload) in valid.iter().filter(|(path, _)| !path.contains('/')) { + root.entries.push(file_entry(path, upload)); + } + for (path, upload) in valid.iter().filter(|(path, _)| path.contains('/')) { + insert(&mut root, &path.split('/').collect::>(), upload); + } + root +} + +fn file_entry(name: &str, upload: &UploadResult) -> Entry { + Entry { + name: name.to_owned().into(), + extra_data: None, + node: EntryNode::File(Box::new(File { + r#type: "file".into(), + blob: upload.blob.clone(), + encoding: upload.encoding.as_ref().map(|encoding| { + wisp_lexicons::place_wisp::fs::FileEncoding::from_value(encoding.clone().into()) + }), + mime_type: Some(upload.mime_type.clone().into()), + base64: Some(upload.base64), + extra_data: None, + })), + } +} + +fn insert(dir: &mut Directory, parts: &[&str], upload: &UploadResult) { + if parts.len() == 1 { + dir.entries.push(file_entry(parts[0], upload)); + return; + } + let index = dir + .entries + .iter() + .position(|entry| entry.name.as_str() == parts[0]) + .unwrap_or_else(|| { + dir.entries.push(Entry { + name: parts[0].to_owned().into(), + extra_data: None, + node: EntryNode::Directory(Box::new(directory(Vec::new()))), + }); + dir.entries.len() - 1 + }); + if let EntryNode::Directory(child) = &mut dir.entries[index].node { + insert(child, &parts[1..], upload); + } +} + +pub fn count_files(dir: &Directory) -> usize { + dir.entries + .iter() + .map(|entry| match &entry.node { + EntryNode::File(_) => 1, + EntryNode::Directory(child) => count_files(child), + _ => 0, + }) + .sum() +} + +#[derive(Debug, Clone)] +pub struct ExistingBlob { + pub blob: BlobRef, + pub cid: String, +} + +pub fn extract_blob_map(dir: &Directory) -> BTreeMap { + let mut result = BTreeMap::new(); + walk_blobs(dir, "", &mut result); + result +} + +fn walk_blobs(dir: &Directory, prefix: &str, result: &mut BTreeMap) { + for entry in &dir.entries { + let path = joined(prefix, entry.name.as_str()); + match &entry.node { + EntryNode::File(file) => { + result.insert( + path, + ExistingBlob { + blob: file.blob.clone(), + cid: file.blob.blob().cid().to_string(), + }, + ); + } + EntryNode::Directory(child) => walk_blobs(child, &path, result), + _ => {} + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubfsReference { + pub path: String, + pub uri: String, + pub flat: bool, +} + +pub fn extract_subfs_uris(dir: &Directory) -> Vec { + fn walk(dir: &Directory, prefix: &str, result: &mut Vec) { + for entry in &dir.entries { + let path = joined(prefix, entry.name.as_str()); + match &entry.node { + EntryNode::Subfs(node) => result.push(SubfsReference { + path, + uri: node.subject.to_string(), + flat: node.flat.unwrap_or(true), + }), + EntryNode::Directory(child) => walk(child, &path, result), + _ => {} + } + } + } + let mut result = Vec::new(); + walk(dir, "", &mut result); + result +} + +fn joined(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.into() + } else { + format!("{prefix}/{name}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn upload() -> UploadResult { + UploadResult { + blob: serde_json::from_value(serde_json::json!({ + "$type": "blob", + "ref": { "$link": crate::blob::compute_cid(b"hello") }, + "mimeType": "application/octet-stream", + "size": 5 + })) + .unwrap(), + encoding: Some("gzip".into()), + mime_type: "text/html".into(), + base64: false, + } + } + #[test] + fn invalid_and_git_paths_do_not_create_files() { + let tree = build_tree(&[ + ("".into(), upload()), + (".git/config".into(), upload()), + ("ok.txt".into(), upload()), + ]); + assert_eq!(count_files(&tree), 1); + assert_eq!( + extract_blob_map(&tree).keys().collect::>(), + ["ok.txt"] + ); + } + #[test] + fn nested_paths_and_metadata() { + let tree = build_tree(&[ + ("a/b/index.html".into(), upload()), + ("index.html".into(), upload()), + ("a/other.html".into(), upload()), + ]); + assert_eq!(count_files(&tree), 3); + assert_eq!(tree.entries[0].name.as_str(), "index.html"); + let map = extract_blob_map(&tree); + assert!(map.contains_key("a/b/index.html")); + let root = Directory { + extra_data: root_type("place.wisp.fs#directory"), + ..tree + }; + let json = serde_json::to_value(root).unwrap(); + let expected: serde_json::Value = + serde_json::from_str(include_str!("../tests/fixtures/tree.json")).unwrap(); + assert_eq!(json, expected); + assert_eq!(json["$type"], "place.wisp.fs#directory"); + assert_eq!(json["entries"][0]["node"]["base64"], false); + assert_eq!(json["entries"][0]["node"]["encoding"], "gzip"); + } +} diff --git a/cli-rs/crates/wisp-core/tests/fixtures/blobs.json b/cli-rs/crates/wisp-core/tests/fixtures/blobs.json new file mode 100644 index 0000000..6a37f89 --- /dev/null +++ b/cli-rs/crates/wisp-core/tests/fixtures/blobs.json @@ -0,0 +1,17 @@ +[ + { + "name": "empty", + "cid": "bafkreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku", + "gzipCid": "bafkreihrwzishcsy7yuqxlwgyxrs6o63dfb52k6vqlyck2jdd55hk7dyg4" + }, + { + "name": "text", + "cid": "bafkreihwp6xdngcltqklgaareza7cgffpusdvm6zstdrew7ut2vy3eurqm", + "gzipCid": "bafkreid2dzal5pnj3nocnr64rlxcsvqpl2wn5ybkjzauzab7kla5tenecq" + }, + { + "name": "random", + "cid": "bafkreif5k2o5wk3xvzluakxkynqwku4pkft4y4t56tdtxa3eme5h3rfqfa", + "gzipCid": "bafkreicpucxtweooj2elepmfowfgd42iwjz2dl6cmz5on7x2tt6wep4sre" + } +] \ No newline at end of file diff --git a/cli-rs/crates/wisp-core/tests/fixtures/generate-blobs.ts b/cli-rs/crates/wisp-core/tests/fixtures/generate-blobs.ts new file mode 100644 index 0000000..9d62aff --- /dev/null +++ b/cli-rs/crates/wisp-core/tests/fixtures/generate-blobs.ts @@ -0,0 +1,100 @@ +// Run from the repository root: bun cli-rs/crates/wisp-core/tests/fixtures/generate-blobs.ts +import { writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { computeCID } from '../../../../../packages/@wispplace/atproto-utils/src/blob' +import { compressFile } from '../../../../../packages/@wispplace/atproto-utils/src/compression' +import { + type FileUploadResult, + processUploadedFiles, + updateFileBlobs, +} from '../../../../../packages/@wispplace/fs-utils/src/tree' + +const random = Buffer.alloc(1024 * 1024) +let seed = 123456789 +for (let i = 0; i < random.length; i++) { + seed ^= seed << 13 + seed ^= seed >>> 17 + seed ^= seed << 5 + random[i] = seed & 255 +} +const root = dirname(import.meta.path) +// Resolve the CLI's dependency versions without introducing a fixture package. +const { lookup, charsets } = createRequire(join(root, '../../../../../cli/package.json'))('mime-types') as { + lookup(extension: string): string | false + charsets: { lookup(mime: string): string | false } +} +const fixtures: [string, Buffer][] = [ + ['empty', Buffer.alloc(0)], + ['text', Buffer.from('hello, wisp!\n')], + ['random', random], +] +// Only the CIDs are kept; blob.rs rebuilds the same inputs. +const metadata = fixtures.map(([name, input]) => ({ + name, + cid: computeCID(input), + gzipCid: computeCID(compressFile(input)), +})) +writeFileSync(join(root, 'blobs.json'), JSON.stringify(metadata, null, 2)) + +const paths = ['a/b/index.html', 'index.html', 'a/other.html'] +const blob = { + $type: 'blob', + ref: { $link: computeCID(Buffer.from('hello')) }, + mimeType: 'application/octet-stream', + size: 5, +} +const uploaded = paths.map((name) => ({ name, content: Buffer.alloc(0), mimeType: 'text/html', size: 0 })) +const results = paths.map(() => ({ + hash: '', + blobRef: blob as unknown as FileUploadResult['blobRef'], + encoding: 'gzip' as const, + mimeType: 'text/html', + base64: false, +})) +const { directory } = processUploadedFiles(uploaded, { skipNormalization: true }) +writeFileSync( + join(root, 'tree.json'), + JSON.stringify(updateFileBlobs(directory, results, paths, '', new Set(paths), { skipNormalization: true }), null, 2), +) +const extensions = [ + 'html', + 'css', + 'js', + 'JS', + 'mjs', + 'cjs', + 'json', + 'map', + 'xml', + 'svg', + 'txt', + 'csv', + 'md', + 'wasm', + 'ico', + 'jpg', + 'jpeg', + 'png', + 'webp', + 'avif', + 'woff', + 'woff2', + 'ttf', + 'otf', + 'wav', + 'aiff', + 'pdf', + 'webmanifest', +] +writeFileSync( + join(root, 'mime.json'), + JSON.stringify( + extensions.map((extension) => { + const mime = lookup(extension) || 'application/octet-stream' + return { extension, mime, text: charsets.lookup(mime) === 'UTF-8' } + }), + null, + 2, + ), +) diff --git a/cli-rs/crates/wisp-core/tests/fixtures/mime.json b/cli-rs/crates/wisp-core/tests/fixtures/mime.json new file mode 100644 index 0000000..3ec7c36 --- /dev/null +++ b/cli-rs/crates/wisp-core/tests/fixtures/mime.json @@ -0,0 +1,142 @@ +[ + { + "extension": "html", + "mime": "text/html", + "text": true + }, + { + "extension": "css", + "mime": "text/css", + "text": true + }, + { + "extension": "js", + "mime": "text/javascript", + "text": true + }, + { + "extension": "JS", + "mime": "text/javascript", + "text": true + }, + { + "extension": "mjs", + "mime": "text/javascript", + "text": true + }, + { + "extension": "cjs", + "mime": "application/node", + "text": false + }, + { + "extension": "json", + "mime": "application/json", + "text": true + }, + { + "extension": "map", + "mime": "application/json", + "text": true + }, + { + "extension": "xml", + "mime": "application/xml", + "text": false + }, + { + "extension": "svg", + "mime": "image/svg+xml", + "text": false + }, + { + "extension": "txt", + "mime": "text/plain", + "text": true + }, + { + "extension": "csv", + "mime": "text/csv", + "text": true + }, + { + "extension": "md", + "mime": "text/markdown", + "text": true + }, + { + "extension": "wasm", + "mime": "application/wasm", + "text": false + }, + { + "extension": "ico", + "mime": "image/vnd.microsoft.icon", + "text": false + }, + { + "extension": "jpg", + "mime": "image/jpeg", + "text": false + }, + { + "extension": "jpeg", + "mime": "image/jpeg", + "text": false + }, + { + "extension": "png", + "mime": "image/png", + "text": false + }, + { + "extension": "webp", + "mime": "image/webp", + "text": false + }, + { + "extension": "avif", + "mime": "image/avif", + "text": false + }, + { + "extension": "woff", + "mime": "font/woff", + "text": false + }, + { + "extension": "woff2", + "mime": "font/woff2", + "text": false + }, + { + "extension": "ttf", + "mime": "font/ttf", + "text": false + }, + { + "extension": "otf", + "mime": "font/otf", + "text": false + }, + { + "extension": "wav", + "mime": "audio/wav", + "text": false + }, + { + "extension": "aiff", + "mime": "audio/x-aiff", + "text": false + }, + { + "extension": "pdf", + "mime": "application/pdf", + "text": false + }, + { + "extension": "webmanifest", + "mime": "application/manifest+json", + "text": true + } +] \ No newline at end of file diff --git a/cli-rs/crates/wisp-core/tests/fixtures/tree.json b/cli-rs/crates/wisp-core/tests/fixtures/tree.json new file mode 100644 index 0000000..5183848 --- /dev/null +++ b/cli-rs/crates/wisp-core/tests/fixtures/tree.json @@ -0,0 +1,78 @@ +{ + "$type": "place.wisp.fs#directory", + "type": "directory", + "entries": [ + { + "name": "index.html", + "node": { + "$type": "place.wisp.fs#file", + "type": "file", + "blob": { + "$type": "blob", + "ref": { + "$link": "bafkreibm6jg3ux5qumhcn2b3flc3tyu6dmlb4xa7u5bf44yegnrjhc4yeq" + }, + "mimeType": "application/octet-stream", + "size": 5 + }, + "encoding": "gzip", + "mimeType": "text/html", + "base64": false + } + }, + { + "name": "a", + "node": { + "$type": "place.wisp.fs#directory", + "type": "directory", + "entries": [ + { + "name": "b", + "node": { + "$type": "place.wisp.fs#directory", + "type": "directory", + "entries": [ + { + "name": "index.html", + "node": { + "$type": "place.wisp.fs#file", + "type": "file", + "blob": { + "$type": "blob", + "ref": { + "$link": "bafkreibm6jg3ux5qumhcn2b3flc3tyu6dmlb4xa7u5bf44yegnrjhc4yeq" + }, + "mimeType": "application/octet-stream", + "size": 5 + }, + "encoding": "gzip", + "mimeType": "text/html", + "base64": false + } + } + ] + } + }, + { + "name": "other.html", + "node": { + "$type": "place.wisp.fs#file", + "type": "file", + "blob": { + "$type": "blob", + "ref": { + "$link": "bafkreibm6jg3ux5qumhcn2b3flc3tyu6dmlb4xa7u5bf44yegnrjhc4yeq" + }, + "mimeType": "application/octet-stream", + "size": 5 + }, + "encoding": "gzip", + "mimeType": "text/html", + "base64": false + } + } + ] + } + } + ] +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/Cargo.toml b/cli-rs/crates/wisp-lexicons/Cargo.toml new file mode 100644 index 0000000..c3dab9e --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "wisp-lexicons" +description = "Rust bindings for the place.wisp.* lexicons, generated by jacquard-codegen" +version.workspace = true +edition.workspace = true +license.workspace = true + +[features] +default = ["place_wisp"] +place_wisp = [] + +[dependencies] +jacquard-common.workspace = true +jacquard-derive.workspace = true +jacquard-lexicon.workspace = true +miette.workspace = true +serde.workspace = true +thiserror.workspace = true + +[lints.rust] +non_snake_case = "allow" +unused_imports = "allow" + +# Generated code; lints belong upstream in jacquard-codegen. +[lints.clippy] +all = "allow" diff --git a/cli-rs/crates/wisp-lexicons/README.md b/cli-rs/crates/wisp-lexicons/README.md new file mode 100644 index 0000000..2a4757e --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/README.md @@ -0,0 +1,8 @@ +# wisp-lexicons + +Generated from `../../../lexicons/*.json` by the jacquard fork's +`jacquard-codegen`. Do not edit `src/` by hand; regenerate with: + +```sh +./cli-rs/scripts/lexgen.sh # JACQUARD_DIR defaults to ../jacquard next to the monorepo +``` diff --git a/cli-rs/crates/wisp-lexicons/src/builder_types.rs b/cli-rs/crates/wisp-lexicons/src/builder_types.rs new file mode 100644 index 0000000..cd45c2e --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/builder_types.rs @@ -0,0 +1,45 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +/// Marker type indicating a builder field has been set +pub struct Set(pub T); +impl Set { + /// Extract the inner value + #[inline] + pub fn into_inner(self) -> T { + self.0 + } +} + +/// Marker type indicating a builder field has not been set +pub struct Unset; +/// Trait indicating a builder field is set (has a value) + +#[jacquard_common::deps::codegen::rustversion::attr( + since(1.78.0), + diagnostic::on_unimplemented( + message = "the field `{Self}` was not set, but this method requires it to be set", + label = "the field `{Self}` was not set" + ) +)] +pub trait IsSet: private::Sealed {} +/// Trait indicating a builder field is unset (no value yet) + +#[jacquard_common::deps::codegen::rustversion::attr( + since(1.78.0), + diagnostic::on_unimplemented( + message = "the field `{Self}` was already set, but this method requires it to be unset", + label = "the field `{Self}` was already set" + ) +)] +pub trait IsUnset: private::Sealed {} +impl IsSet for Set {} +impl IsUnset for Unset {} +mod private { + /// Sealed trait to prevent external implementations + pub trait Sealed {} + impl Sealed for super::Set {} + impl Sealed for super::Unset {} +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/lib.rs b/cli-rs/crates/wisp-lexicons/src/lib.rs new file mode 100644 index 0000000..f42076b --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/lib.rs @@ -0,0 +1,48 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +//! Generated AT Protocol Lexicon bindings. +//! +//! This crate contains Rust bindings generated from Lexicon schemas. Most +//! application code uses these types through the `jacquard::api` re-export, +//! alongside `jacquard::Client` or one of the higher-level session helpers. +//! +//! ## Finding things +//! +//! Lexicon NSIDs map to Rust modules by replacing dots with module separators +//! and using snake_case for Rust identifiers. For example, +//! `app.bsky.feed.getTimeline` is generated under +//! `app_bsky::feed::get_timeline`. +//! +//! Top-level namespace modules are feature-gated. Enable the feature matching +//! the top-level namespace module, such as `app_bsky` or `com_atproto`, to use +//! those bindings. +//! +//! ## String backing +//! +//! Many generated data types are generic over a string backing type, +//! usually written as `S: BosStr = DefaultStr`. The generic is part of the +//! public API: use the default owned backing when you do not need to choose, +//! or select another backing such as `String`, `&str`, or `CowStr<'_>` when +//! that is useful for your code. +//! +//! Common examples omit the generic because the default is inferred in the +//! usual request/response paths. +//! +//! ## XRPC endpoints +//! +//! Endpoint modules contain request parameter/input structs, output structs, +//! error enums, and marker types implementing +//! `jacquard_common::xrpc::{XrpcRequest, XrpcResp, XrpcEndpoint}`. High-level +//! client code normally constructs the request struct or marker type and sends +//! it with `jacquard::Client`, then uses response helpers such as +//! `into_output()` for default-backed output. +//! +extern crate alloc; +pub mod builder_types; + + +#[cfg(feature = "place_wisp")] +pub mod place_wisp; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp.rs new file mode 100644 index 0000000..c0e4098 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp.rs @@ -0,0 +1,14 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +//! Generated bindings for the `place.wisp` Lexicon namespace/module. +pub mod auth_full_access; +pub mod auth_hosting; +pub mod auth_sites; +pub mod auth_webhooks; +pub mod fs; +pub mod settings; +pub mod subfs; +pub mod v2; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_full_access.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_full_access.rs new file mode 100644 index 0000000..4d97284 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_full_access.rs @@ -0,0 +1,8 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.authFullAccess +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_hosting.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_hosting.rs new file mode 100644 index 0000000..0555210 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_hosting.rs @@ -0,0 +1,8 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.authHosting +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_sites.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_sites.rs new file mode 100644 index 0000000..d854036 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_sites.rs @@ -0,0 +1,8 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.authSites +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_webhooks.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_webhooks.rs new file mode 100644 index 0000000..62f81aa --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/auth_webhooks.rs @@ -0,0 +1,8 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.authWebhooks +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +use jacquard_common::{BosStr, DefaultStr, FromStaticStr}; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/fs.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/fs.rs new file mode 100644 index 0000000..84e850a --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/fs.rs @@ -0,0 +1,1560 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.fs +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::blob::BlobRef; +use jacquard_common::types::collection::{Collection, RecordError}; +use jacquard_common::types::string::{AtUri, Cid, Datetime}; +use jacquard_common::types::uri::{RecordUri, UriError}; +use jacquard_common::types::value::Data; +use jacquard_common::xrpc::XrpcResp; +use jacquard_derive::{IntoStatic, lexicon, open_union}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::fs; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Directory { + pub entries: Vec>, + pub r#type: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_directory_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Entry { + pub name: S, + pub node: EntryNode, + #[serde( + flatten, + default, + deserialize_with = "deserialize_entry_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[open_union] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(tag = "$type", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub enum EntryNode { + #[serde(rename = "place.wisp.fs#file")] + File(Box>), + #[serde(rename = "place.wisp.fs#directory")] + Directory(Box>), + #[serde(rename = "place.wisp.fs#subfs")] + Subfs(Box>), +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct File { + /// True if blob content is base64-encoded (used to bypass PDS content sniffing) + #[serde(skip_serializing_if = "Option::is_none")] + pub base64: Option, + /// Content blob ref + pub blob: BlobRef, + /// Content encoding (e.g., gzip for compressed files) + #[serde(skip_serializing_if = "Option::is_none")] + pub encoding: Option>, + /// Original MIME type before compression + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + pub r#type: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_file_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/// Content encoding (e.g., gzip for compressed files) + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FileEncoding { + Gzip, + Other(S), +} + +impl FileEncoding { + pub fn as_str(&self) -> &str { + match self { + Self::Gzip => "gzip", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "gzip" => Self::Gzip, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for FileEncoding { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for FileEncoding { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for FileEncoding { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for FileEncoding { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for FileEncoding { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for FileEncoding +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = FileEncoding; + fn into_static(self) -> Self::Output { + match self { + FileEncoding::Gzip => FileEncoding::Gzip, + FileEncoding::Other(v) => FileEncoding::Other(v.into_static()), + } + } +} + +/// Virtual filesystem manifest for a Wisp site + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + rename = "place.wisp.fs", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct Fs { + pub created_at: Datetime, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_count: Option, + pub root: fs::Directory, + pub site: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_fs_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/// Typed wrapper for GetRecord response with this collection's record type. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct FsGetRecordOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option>, + pub uri: AtUri, + pub value: Fs, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Subfs { + /// If true (default), the subfs record's root entries are merged (flattened) into the parent directory, replacing the subfs entry. If false, the subfs entries are placed in a subdirectory with the subfs entry's name. Flat merging is useful for splitting large directories across multiple records while maintaining a flat structure. + #[serde(skip_serializing_if = "Option::is_none")] + pub flat: Option, + /// AT-URI pointing to a place.wisp.subfs record containing this subtree. + pub subject: AtUri, + pub r#type: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_subfs_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +impl Fs { + pub fn uri(uri: S) -> Result, UriError> { + RecordUri::try_from_uri(AtUri::new(uri)?) + } +} + +impl LexiconSchema for Directory { + fn nsid() -> &'static str { + "place.wisp.fs" + } + fn def_name() -> &'static str { + "directory" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_fs() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.entries; + #[allow(unused_comparisons)] + if value.len() > 500usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("entries"), + max: 500usize, + actual: value.len(), + }); + } + } + Ok(()) + } +} + +impl LexiconSchema for Entry { + fn nsid() -> &'static str { + "place.wisp.fs" + } + fn def_name() -> &'static str { + "entry" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_fs() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.name; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 255usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("name"), + max: 255usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +impl LexiconSchema for File { + fn nsid() -> &'static str { + "place.wisp.fs" + } + fn def_name() -> &'static str { + "file" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_fs() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.blob; + { + let size = value.blob().size; + if size > 1000000000usize { + return Err(ConstraintError::BlobTooLarge { + path: ValidationPath::from_field("blob"), + max: 1000000000usize, + actual: size, + }); + } + } + } + { + let value = &self.blob; + { + let mime = value.blob().mime_type.as_str(); + let accepted: &[&str] = &["*/*"]; + let matched = accepted + .iter() + .any(|pattern| { + if *pattern == "*/*" { + true + } else if pattern.ends_with("/*") { + let prefix = &pattern[..pattern.len() - 2]; + mime.starts_with(prefix) + && mime.as_bytes().get(prefix.len()) == Some(&b'/') + } else { + mime == *pattern + } + }); + if !matched { + return Err(ConstraintError::BlobMimeTypeNotAccepted { + path: ValidationPath::from_field("blob"), + accepted: vec!["*/*".to_string()], + actual: mime.to_string(), + }); + } + } + } + Ok(()) + } +} + +/// Marker type for deserializing records from this collection. + +#[derive(Debug, Serialize, Deserialize)] +pub struct FsRecord; +impl XrpcResp for FsRecord { + const NSID: &'static str = "place.wisp.fs"; + const ENCODING: &'static str = "application/json"; + type Output = FsGetRecordOutput; + type Err = RecordError; +} + +impl From> for Fs { + fn from(output: FsGetRecordOutput) -> Self { + output.value + } +} + +impl Collection for Fs { + const NSID: &'static str = "place.wisp.fs"; + type Record = FsRecord; +} + +impl Collection for FsRecord { + const NSID: &'static str = "place.wisp.fs"; + type Record = FsRecord; +} + +impl LexiconSchema for Fs { + fn nsid() -> &'static str { + "place.wisp.fs" + } + fn def_name() -> &'static str { + "main" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_fs() + } + fn validate(&self) -> Result<(), ConstraintError> { + if let Some(ref value) = self.file_count { + if *value > 1000i64 { + return Err(ConstraintError::Maximum { + path: ValidationPath::from_field("file_count"), + max: 1000i64, + actual: *value, + }); + } + } + if let Some(ref value) = self.file_count { + if *value < 0i64 { + return Err(ConstraintError::Minimum { + path: ValidationPath::from_field("file_count"), + min: 0i64, + actual: *value, + }); + } + } + Ok(()) + } +} + +impl LexiconSchema for Subfs { + fn nsid() -> &'static str { + "place.wisp.fs" + } + fn def_name() -> &'static str { + "subfs" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_fs() + } + fn validate(&self) -> Result<(), ConstraintError> { + Ok(()) + } +} + +fn deserialize_directory_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod directory_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Entries; + type Type; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Entries = Unset; + type Type = Unset; + } + ///State transition - sets the `entries` field to Set + pub struct SetEntries(PhantomData St>); + impl sealed::Sealed for SetEntries {} + impl State for SetEntries { + type Entries = Set; + type Type = St::Type; + } + ///State transition - sets the `type` field to Set + pub struct SetType(PhantomData St>); + impl sealed::Sealed for SetType {} + impl State for SetType { + type Entries = St::Entries; + type Type = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `entries` field + pub struct entries(()); + ///Marker type for the `type` field + pub struct r#type(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct DirectoryBuilder { + _state: PhantomData St>, + _fields: (Option>>, Option), + _type: PhantomData S>, +} + +impl Directory { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> DirectoryBuilder { + DirectoryBuilder::new() + } +} + +impl Directory { + /// Create a new builder for this type + pub fn builder() -> DirectoryBuilder { + DirectoryBuilder::builder() + } +} + +impl DirectoryBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + DirectoryBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl DirectoryBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + DirectoryBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl DirectoryBuilder +where + St: directory_state::State, + St::Entries: directory_state::IsUnset, +{ + /// Set the `entries` field (required) + pub fn entries( + mut self, + value: impl Into>>, + ) -> DirectoryBuilder, S> { + self._fields.0 = Option::Some(value.into()); + DirectoryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DirectoryBuilder +where + St: directory_state::State, + St::Type: directory_state::IsUnset, +{ + /// Set the `type` field (required) + pub fn r#type( + mut self, + value: impl Into, + ) -> DirectoryBuilder, S> { + self._fields.1 = Option::Some(value.into()); + DirectoryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DirectoryBuilder +where + St: directory_state::State, + St::Entries: directory_state::IsSet, + St::Type: directory_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Directory { + Directory { + entries: self._fields.0.unwrap(), + r#type: self._fields.1.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> Directory { + Directory { + entries: self._fields.0.unwrap(), + r#type: self._fields.1.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_place_wisp_fs() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.fs"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("directory"), + LexUserType::Object(LexObject { + required: Some( + vec![SmolStr::new_static("type"), SmolStr::new_static("entries")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("entries"), + LexObjectProperty::Array(LexArray { + items: LexArrayItem::Ref(LexRef { + r#ref: CowStr::new_static("#entry"), + ..Default::default() + }), + max_length: Some(500usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("type"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("entry"), + LexUserType::Object(LexObject { + required: Some( + vec![SmolStr::new_static("name"), SmolStr::new_static("node")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("name"), + LexObjectProperty::String(LexString { + max_length: Some(255usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("node"), + LexObjectProperty::Union(LexRefUnion { + refs: vec![ + CowStr::new_static("#file"), + CowStr::new_static("#directory"), + CowStr::new_static("#subfs") + ], + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("file"), + LexUserType::Object(LexObject { + required: Some( + vec![SmolStr::new_static("type"), SmolStr::new_static("blob")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("base64"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("blob"), + LexObjectProperty::Blob(LexBlob { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("encoding"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Content encoding (e.g., gzip for compressed files)", + ), + ), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("mimeType"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static("Original MIME type before compression"), + ), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("type"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("main"), + LexUserType::Record(LexRecord { + description: Some( + CowStr::new_static("Virtual filesystem manifest for a Wisp site"), + ), + record: LexRecordRecord::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("site"), SmolStr::new_static("root"), + SmolStr::new_static("createdAt") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("fileCount"), + LexObjectProperty::Integer(LexInteger { + minimum: Some(0i64), + maximum: Some(1000i64), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("root"), + LexObjectProperty::Ref(LexRef { + r#ref: CowStr::new_static("#directory"), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("site"), + LexObjectProperty::String(LexString { + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("subfs"), + LexUserType::Object(LexObject { + required: Some( + vec![SmolStr::new_static("type"), SmolStr::new_static("subject")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("flat"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("subject"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "AT-URI pointing to a place.wisp.subfs record containing this subtree.", + ), + ), + format: Some(LexStringFormat::AtUri), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("type"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map + }, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} + +fn deserialize_entry_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod entry_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Name; + type Node; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Name = Unset; + type Node = Unset; + } + ///State transition - sets the `name` field to Set + pub struct SetName(PhantomData St>); + impl sealed::Sealed for SetName {} + impl State for SetName { + type Name = Set; + type Node = St::Node; + } + ///State transition - sets the `node` field to Set + pub struct SetNode(PhantomData St>); + impl sealed::Sealed for SetNode {} + impl State for SetNode { + type Name = St::Name; + type Node = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `name` field + pub struct name(()); + ///Marker type for the `node` field + pub struct node(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct EntryBuilder { + _state: PhantomData St>, + _fields: (Option, Option>), + _type: PhantomData S>, +} + +impl Entry { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> EntryBuilder { + EntryBuilder::new() + } +} + +impl Entry { + /// Create a new builder for this type + pub fn builder() -> EntryBuilder { + EntryBuilder::builder() + } +} + +impl EntryBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + EntryBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl EntryBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + EntryBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl EntryBuilder +where + St: entry_state::State, + St::Name: entry_state::IsUnset, +{ + /// Set the `name` field (required) + pub fn name( + mut self, + value: impl Into, + ) -> EntryBuilder, S> { + self._fields.0 = Option::Some(value.into()); + EntryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl EntryBuilder +where + St: entry_state::State, + St::Node: entry_state::IsUnset, +{ + /// Set the `node` field (required) + pub fn node( + mut self, + value: impl Into>, + ) -> EntryBuilder, S> { + self._fields.1 = Option::Some(value.into()); + EntryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl EntryBuilder +where + St: entry_state::State, + St::Name: entry_state::IsSet, + St::Node: entry_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Entry { + Entry { + name: self._fields.0.unwrap(), + node: self._fields.1.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Entry { + Entry { + name: self._fields.0.unwrap(), + node: self._fields.1.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn deserialize_file_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod file_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Blob; + type Type; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Blob = Unset; + type Type = Unset; + } + ///State transition - sets the `blob` field to Set + pub struct SetBlob(PhantomData St>); + impl sealed::Sealed for SetBlob {} + impl State for SetBlob { + type Blob = Set; + type Type = St::Type; + } + ///State transition - sets the `type` field to Set + pub struct SetType(PhantomData St>); + impl sealed::Sealed for SetType {} + impl State for SetType { + type Blob = St::Blob; + type Type = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `blob` field + pub struct blob(()); + ///Marker type for the `type` field + pub struct r#type(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct FileBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option>, + Option>, + Option, + Option, + ), + _type: PhantomData S>, +} + +impl File { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> FileBuilder { + FileBuilder::new() + } +} + +impl File { + /// Create a new builder for this type + pub fn builder() -> FileBuilder { + FileBuilder::builder() + } +} + +impl FileBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + FileBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl FileBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + FileBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl FileBuilder { + /// Set the `base64` field (optional) + pub fn base64(mut self, value: impl Into>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `base64` field to an Option value (optional) + pub fn maybe_base64(mut self, value: Option) -> Self { + self._fields.0 = value; + self + } +} + +impl FileBuilder +where + St: file_state::State, + St::Blob: file_state::IsUnset, +{ + /// Set the `blob` field (required) + pub fn blob( + mut self, + value: impl Into>, + ) -> FileBuilder, S> { + self._fields.1 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder { + /// Set the `encoding` field (optional) + pub fn encoding(mut self, value: impl Into>>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `encoding` field to an Option value (optional) + pub fn maybe_encoding(mut self, value: Option>) -> Self { + self._fields.2 = value; + self + } +} + +impl FileBuilder { + /// Set the `mimeType` field (optional) + pub fn mime_type(mut self, value: impl Into>) -> Self { + self._fields.3 = value.into(); + self + } + /// Set the `mimeType` field to an Option value (optional) + pub fn maybe_mime_type(mut self, value: Option) -> Self { + self._fields.3 = value; + self + } +} + +impl FileBuilder +where + St: file_state::State, + St::Type: file_state::IsUnset, +{ + /// Set the `type` field (required) + pub fn r#type( + mut self, + value: impl Into, + ) -> FileBuilder, S> { + self._fields.4 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder +where + St: file_state::State, + St::Blob: file_state::IsSet, + St::Type: file_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> File { + File { + base64: self._fields.0, + blob: self._fields.1.unwrap(), + encoding: self._fields.2, + mime_type: self._fields.3, + r#type: self._fields.4.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> File { + File { + base64: self._fields.0, + blob: self._fields.1.unwrap(), + encoding: self._fields.2, + mime_type: self._fields.3, + r#type: self._fields.4.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn deserialize_fs_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let mut data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + if let Some(extra_data) = &mut data { + extra_data.remove("$type"); + if extra_data.is_empty() { + data = None; + } + } + Ok(data) +} + +pub mod fs_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + type Root; + type Site; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + type Root = Unset; + type Site = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + type Root = St::Root; + type Site = St::Site; + } + ///State transition - sets the `root` field to Set + pub struct SetRoot(PhantomData St>); + impl sealed::Sealed for SetRoot {} + impl State for SetRoot { + type CreatedAt = St::CreatedAt; + type Root = Set; + type Site = St::Site; + } + ///State transition - sets the `site` field to Set + pub struct SetSite(PhantomData St>); + impl sealed::Sealed for SetSite {} + impl State for SetSite { + type CreatedAt = St::CreatedAt; + type Root = St::Root; + type Site = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `root` field + pub struct root(()); + ///Marker type for the `site` field + pub struct site(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct FsBuilder { + _state: PhantomData St>, + _fields: (Option, Option, Option>, Option), + _type: PhantomData S>, +} + +impl Fs { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> FsBuilder { + FsBuilder::new() + } +} + +impl Fs { + /// Create a new builder for this type + pub fn builder() -> FsBuilder { + FsBuilder::builder() + } +} + +impl FsBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + FsBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl FsBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + FsBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl FsBuilder +where + St: fs_state::State, + St::CreatedAt: fs_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> FsBuilder, S> { + self._fields.0 = Option::Some(value.into()); + FsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FsBuilder { + /// Set the `fileCount` field (optional) + pub fn file_count(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `fileCount` field to an Option value (optional) + pub fn maybe_file_count(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl FsBuilder +where + St: fs_state::State, + St::Root: fs_state::IsUnset, +{ + /// Set the `root` field (required) + pub fn root( + mut self, + value: impl Into>, + ) -> FsBuilder, S> { + self._fields.2 = Option::Some(value.into()); + FsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FsBuilder +where + St: fs_state::State, + St::Site: fs_state::IsUnset, +{ + /// Set the `site` field (required) + pub fn site(mut self, value: impl Into) -> FsBuilder, S> { + self._fields.3 = Option::Some(value.into()); + FsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FsBuilder +where + St: fs_state::State, + St::CreatedAt: fs_state::IsSet, + St::Root: fs_state::IsSet, + St::Site: fs_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Fs { + Fs { + created_at: self._fields.0.unwrap(), + file_count: self._fields.1, + root: self._fields.2.unwrap(), + site: self._fields.3.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Fs { + Fs { + created_at: self._fields.0.unwrap(), + file_count: self._fields.1, + root: self._fields.2.unwrap(), + site: self._fields.3.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn deserialize_subfs_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod subfs_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Subject; + type Type; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Subject = Unset; + type Type = Unset; + } + ///State transition - sets the `subject` field to Set + pub struct SetSubject(PhantomData St>); + impl sealed::Sealed for SetSubject {} + impl State for SetSubject { + type Subject = Set; + type Type = St::Type; + } + ///State transition - sets the `type` field to Set + pub struct SetType(PhantomData St>); + impl sealed::Sealed for SetType {} + impl State for SetType { + type Subject = St::Subject; + type Type = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `subject` field + pub struct subject(()); + ///Marker type for the `type` field + pub struct r#type(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct SubfsBuilder { + _state: PhantomData St>, + _fields: (Option, Option>, Option), + _type: PhantomData S>, +} + +impl Subfs { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SubfsBuilder { + SubfsBuilder::new() + } +} + +impl Subfs { + /// Create a new builder for this type + pub fn builder() -> SubfsBuilder { + SubfsBuilder::builder() + } +} + +impl SubfsBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SubfsBuilder { + _state: PhantomData, + _fields: (None, None, None), + _type: PhantomData, + } + } +} + +impl SubfsBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SubfsBuilder { + _state: PhantomData, + _fields: (None, None, None), + _type: PhantomData, + } + } +} + +impl SubfsBuilder { + /// Set the `flat` field (optional) + pub fn flat(mut self, value: impl Into>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `flat` field to an Option value (optional) + pub fn maybe_flat(mut self, value: Option) -> Self { + self._fields.0 = value; + self + } +} + +impl SubfsBuilder +where + St: subfs_state::State, + St::Subject: subfs_state::IsUnset, +{ + /// Set the `subject` field (required) + pub fn subject( + mut self, + value: impl Into>, + ) -> SubfsBuilder, S> { + self._fields.1 = Option::Some(value.into()); + SubfsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SubfsBuilder +where + St: subfs_state::State, + St::Type: subfs_state::IsUnset, +{ + /// Set the `type` field (required) + pub fn r#type( + mut self, + value: impl Into, + ) -> SubfsBuilder, S> { + self._fields.2 = Option::Some(value.into()); + SubfsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SubfsBuilder +where + St: subfs_state::State, + St::Subject: subfs_state::IsSet, + St::Type: subfs_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Subfs { + Subfs { + flat: self._fields.0, + subject: self._fields.1.unwrap(), + r#type: self._fields.2.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Subfs { + Subfs { + flat: self._fields.0, + subject: self._fields.1.unwrap(), + r#type: self._fields.2.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/settings.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/settings.rs new file mode 100644 index 0000000..4594264 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/settings.rs @@ -0,0 +1,637 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.settings +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::collection::{Collection, RecordError}; +use jacquard_common::types::string::{AtUri, Cid}; +use jacquard_common::types::uri::{RecordUri, UriError}; +use jacquard_common::types::value::Data; +use jacquard_common::xrpc::XrpcResp; +use jacquard_derive::{IntoStatic, lexicon}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::settings; +/// Custom HTTP header configuration + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct CustomHeader { + /// HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options') + pub name: S, + /// Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + /// HTTP header value + pub value: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_custom_header_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/// Configuration settings for a static site hosted on wisp.place + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + rename = "place.wisp.settings", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct Settings { + /// Enable clean URL routing. When enabled, '/about' will attempt to serve '/about.html' or '/about/index.html' automatically. Defaults to `false`. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default = "_default_settings_clean_urls")] + pub clean_urls: Option, + /// Custom 404 error page file path. Incompatible with directoryListing and spaMode. + #[serde(skip_serializing_if = "Option::is_none")] + pub custom404: Option, + /// Enable directory listing mode for paths that resolve to directories without an index file. Incompatible with spaMode. Defaults to `false`. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(default = "_default_settings_directory_listing")] + pub directory_listing: Option, + /// Custom HTTP headers to set on responses + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option>>, + /// Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified. + #[serde(skip_serializing_if = "Option::is_none")] + pub index_files: Option>, + /// File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404. + #[serde(skip_serializing_if = "Option::is_none")] + pub spa_mode: Option, + #[serde( + flatten, + default, + deserialize_with = "deserialize_settings_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/// Typed wrapper for GetRecord response with this collection's record type. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct SettingsGetRecordOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option>, + pub uri: AtUri, + pub value: Settings, +} + +impl Settings { + pub fn uri(uri: S) -> Result, UriError> { + RecordUri::try_from_uri(AtUri::new(uri)?) + } +} + +impl LexiconSchema for CustomHeader { + fn nsid() -> &'static str { + "place.wisp.settings" + } + fn def_name() -> &'static str { + "customHeader" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_settings() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.name; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 100usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("name"), + max: 100usize, + actual: ::len(value.as_ref()), + }); + } + } + if let Some(ref value) = self.path { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 500usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("path"), + max: 500usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.value; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 1000usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("value"), + max: 1000usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +/// Marker type for deserializing records from this collection. + +#[derive(Debug, Serialize, Deserialize)] +pub struct SettingsRecord; +impl XrpcResp for SettingsRecord { + const NSID: &'static str = "place.wisp.settings"; + const ENCODING: &'static str = "application/json"; + type Output = SettingsGetRecordOutput; + type Err = RecordError; +} + +impl From> for Settings { + fn from(output: SettingsGetRecordOutput) -> Self { + output.value + } +} + +impl Collection for Settings { + const NSID: &'static str = "place.wisp.settings"; + type Record = SettingsRecord; +} + +impl Collection for SettingsRecord { + const NSID: &'static str = "place.wisp.settings"; + type Record = SettingsRecord; +} + +impl LexiconSchema for Settings { + fn nsid() -> &'static str { + "place.wisp.settings" + } + fn def_name() -> &'static str { + "main" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_settings() + } + fn validate(&self) -> Result<(), ConstraintError> { + if let Some(ref value) = self.custom404 { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 500usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("custom404"), + max: 500usize, + actual: ::len(value.as_ref()), + }); + } + } + if let Some(ref value) = self.headers { + #[allow(unused_comparisons)] + if value.len() > 50usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("headers"), + max: 50usize, + actual: value.len(), + }); + } + } + if let Some(ref value) = self.index_files { + #[allow(unused_comparisons)] + if value.len() > 10usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("index_files"), + max: 10usize, + actual: value.len(), + }); + } + } + if let Some(values) = &self.index_files { + for value in values { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 255usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("index_files"), + max: 255usize, + actual: ::len(value.as_ref()), + }); + } + } + } + if let Some(ref value) = self.spa_mode { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 500usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("spa_mode"), + max: 500usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +fn deserialize_custom_header_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +fn lexicon_doc_place_wisp_settings() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.settings"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("customHeader"), + LexUserType::Object(LexObject { + description: Some( + CowStr::new_static("Custom HTTP header configuration"), + ), + required: Some( + vec![SmolStr::new_static("name"), SmolStr::new_static("value")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("name"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "HTTP header name (e.g., 'Cache-Control', 'X-Frame-Options')", + ), + ), + max_length: Some(100usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("path"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Optional glob pattern to apply this header to specific paths (e.g., '*.html', '/assets/*'). If not specified, applies to all paths.", + ), + ), + max_length: Some(500usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("value"), + LexObjectProperty::String(LexString { + description: Some(CowStr::new_static("HTTP header value")), + max_length: Some(1000usize), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("main"), + LexUserType::Record(LexRecord { + description: Some( + CowStr::new_static( + "Configuration settings for a static site hosted on wisp.place", + ), + ), + key: Some(CowStr::new_static("any")), + record: LexRecordRecord::Object(LexObject { + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("cleanUrls"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("custom404"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Custom 404 error page file path. Incompatible with directoryListing and spaMode.", + ), + ), + max_length: Some(500usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("directoryListing"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("headers"), + LexObjectProperty::Array(LexArray { + description: Some( + CowStr::new_static( + "Custom HTTP headers to set on responses", + ), + ), + items: LexArrayItem::Ref(LexRef { + r#ref: CowStr::new_static("#customHeader"), + ..Default::default() + }), + max_length: Some(50usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("indexFiles"), + LexObjectProperty::Array(LexArray { + description: Some( + CowStr::new_static( + "Ordered list of files to try when serving a directory. Defaults to ['index.html'] if not specified.", + ), + ), + items: LexArrayItem::String(LexString { + max_length: Some(255usize), + ..Default::default() + }), + max_length: Some(10usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("spaMode"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "File to serve for all routes (e.g., 'index.html'). When set, enables SPA mode where all non-file requests are routed to this file. Incompatible with directoryListing and custom404.", + ), + ), + max_length: Some(500usize), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} + +fn deserialize_settings_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let mut data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + if let Some(extra_data) = &mut data { + extra_data.remove("$type"); + if extra_data.is_empty() { + data = None; + } + } + Ok(data) +} + +fn _default_settings_clean_urls() -> Option { + Some(false) +} + +fn _default_settings_directory_listing() -> Option { + Some(false) +} + +impl Default for Settings { + fn default() -> Self { + Self { + clean_urls: Some(false), + custom404: None, + directory_listing: Some(false), + headers: None, + index_files: None, + spa_mode: None, + extra_data: Default::default(), + } + } +} + +pub mod settings_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed {} + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty {} + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members {} +} + +/// Builder for constructing an instance of this type. +pub struct SettingsBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option, + Option, + Option>>, + Option>, + Option, + ), + _type: PhantomData S>, +} + +impl Settings { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SettingsBuilder { + SettingsBuilder::new() + } +} + +impl Settings { + /// Create a new builder for this type + pub fn builder() -> SettingsBuilder { + SettingsBuilder::builder() + } +} + +impl SettingsBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SettingsBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl SettingsBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SettingsBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl SettingsBuilder { + /// Set the `cleanUrls` field (optional) + pub fn clean_urls(mut self, value: impl Into>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `cleanUrls` field to an Option value (optional) + pub fn maybe_clean_urls(mut self, value: Option) -> Self { + self._fields.0 = value; + self + } +} + +impl SettingsBuilder { + /// Set the `custom404` field (optional) + pub fn custom404(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `custom404` field to an Option value (optional) + pub fn maybe_custom404(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl SettingsBuilder { + /// Set the `directoryListing` field (optional) + pub fn directory_listing(mut self, value: impl Into>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `directoryListing` field to an Option value (optional) + pub fn maybe_directory_listing(mut self, value: Option) -> Self { + self._fields.2 = value; + self + } +} + +impl SettingsBuilder { + /// Set the `headers` field (optional) + pub fn headers( + mut self, + value: impl Into>>>, + ) -> Self { + self._fields.3 = value.into(); + self + } + /// Set the `headers` field to an Option value (optional) + pub fn maybe_headers( + mut self, + value: Option>>, + ) -> Self { + self._fields.3 = value; + self + } +} + +impl SettingsBuilder { + /// Set the `indexFiles` field (optional) + pub fn index_files(mut self, value: impl Into>>) -> Self { + self._fields.4 = value.into(); + self + } + /// Set the `indexFiles` field to an Option value (optional) + pub fn maybe_index_files(mut self, value: Option>) -> Self { + self._fields.4 = value; + self + } +} + +impl SettingsBuilder { + /// Set the `spaMode` field (optional) + pub fn spa_mode(mut self, value: impl Into>) -> Self { + self._fields.5 = value.into(); + self + } + /// Set the `spaMode` field to an Option value (optional) + pub fn maybe_spa_mode(mut self, value: Option) -> Self { + self._fields.5 = value; + self + } +} + +impl SettingsBuilder +where + St: settings_state::State, +{ + /// Build the final struct. + pub fn build(self) -> Settings { + Settings { + clean_urls: self._fields.0.or_else(|| Some(false)), + custom404: self._fields.1, + directory_listing: self._fields.2.or_else(|| Some(false)), + headers: self._fields.3, + index_files: self._fields.4, + spa_mode: self._fields.5, + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Settings { + Settings { + clean_urls: self._fields.0.or_else(|| Some(false)), + custom404: self._fields.1, + directory_listing: self._fields.2.or_else(|| Some(false)), + headers: self._fields.3, + index_files: self._fields.4, + spa_mode: self._fields.5, + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/subfs.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/subfs.rs new file mode 100644 index 0000000..ca778f1 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/subfs.rs @@ -0,0 +1,1501 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.subfs +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::blob::BlobRef; +use jacquard_common::types::collection::{Collection, RecordError}; +use jacquard_common::types::string::{AtUri, Cid, Datetime}; +use jacquard_common::types::uri::{RecordUri, UriError}; +use jacquard_common::types::value::Data; +use jacquard_common::xrpc::XrpcResp; +use jacquard_derive::{IntoStatic, lexicon, open_union}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::subfs; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Directory { + pub entries: Vec>, + pub r#type: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_directory_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Entry { + pub name: S, + pub node: EntryNode, + #[serde( + flatten, + default, + deserialize_with = "deserialize_entry_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[open_union] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(tag = "$type", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub enum EntryNode { + #[serde(rename = "place.wisp.subfs#file")] + File(Box>), + #[serde(rename = "place.wisp.subfs#directory")] + Directory(Box>), + #[serde(rename = "place.wisp.subfs#subfs")] + Subfs(Box>), +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct File { + /// True if blob content is base64-encoded (used to bypass PDS content sniffing) + #[serde(skip_serializing_if = "Option::is_none")] + pub base64: Option, + /// Content blob ref + pub blob: BlobRef, + /// Content encoding (e.g., gzip for compressed files) + #[serde(skip_serializing_if = "Option::is_none")] + pub encoding: Option>, + /// Original MIME type before compression + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_type: Option, + pub r#type: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_file_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/// Content encoding (e.g., gzip for compressed files) + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum FileEncoding { + Gzip, + Other(S), +} + +impl FileEncoding { + pub fn as_str(&self) -> &str { + match self { + Self::Gzip => "gzip", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "gzip" => Self::Gzip, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for FileEncoding { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for FileEncoding { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for FileEncoding { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for FileEncoding { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for FileEncoding { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for FileEncoding +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = FileEncoding; + fn into_static(self) -> Self::Output { + match self { + FileEncoding::Gzip => FileEncoding::Gzip, + FileEncoding::Other(v) => FileEncoding::Other(v.into_static()), + } + } +} + +/// Virtual filesystem subtree referenced by place.wisp.fs records. When a subfs entry is expanded, its root entries are merged (flattened) into the parent directory, allowing large directories to be split across multiple records while maintaining a flat structure. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + rename = "place.wisp.subfs", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct SubfsRecord { + pub created_at: Datetime, + #[serde(skip_serializing_if = "Option::is_none")] + pub file_count: Option, + pub root: subfs::Directory, + #[serde( + flatten, + default, + deserialize_with = "deserialize_subfs_record_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/// Typed wrapper for GetRecord response with this collection's record type. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct SubfsRecordGetRecordOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option>, + pub uri: AtUri, + pub value: SubfsRecord, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Subfs { + /// AT-URI pointing to another place.wisp.subfs record for nested subtrees. When expanded, the referenced record's root entries are merged (flattened) into the parent directory, allowing recursive splitting of large directory structures. + pub subject: AtUri, + pub r#type: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_subfs_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +impl SubfsRecord { + pub fn uri(uri: S) -> Result, UriError> { + RecordUri::try_from_uri(AtUri::new(uri)?) + } +} + +impl LexiconSchema for Directory { + fn nsid() -> &'static str { + "place.wisp.subfs" + } + fn def_name() -> &'static str { + "directory" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_subfs() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.entries; + #[allow(unused_comparisons)] + if value.len() > 500usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("entries"), + max: 500usize, + actual: value.len(), + }); + } + } + Ok(()) + } +} + +impl LexiconSchema for Entry { + fn nsid() -> &'static str { + "place.wisp.subfs" + } + fn def_name() -> &'static str { + "entry" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_subfs() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.name; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 255usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("name"), + max: 255usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +impl LexiconSchema for File { + fn nsid() -> &'static str { + "place.wisp.subfs" + } + fn def_name() -> &'static str { + "file" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_subfs() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.blob; + { + let size = value.blob().size; + if size > 1000000000usize { + return Err(ConstraintError::BlobTooLarge { + path: ValidationPath::from_field("blob"), + max: 1000000000usize, + actual: size, + }); + } + } + } + { + let value = &self.blob; + { + let mime = value.blob().mime_type.as_str(); + let accepted: &[&str] = &["*/*"]; + let matched = accepted + .iter() + .any(|pattern| { + if *pattern == "*/*" { + true + } else if pattern.ends_with("/*") { + let prefix = &pattern[..pattern.len() - 2]; + mime.starts_with(prefix) + && mime.as_bytes().get(prefix.len()) == Some(&b'/') + } else { + mime == *pattern + } + }); + if !matched { + return Err(ConstraintError::BlobMimeTypeNotAccepted { + path: ValidationPath::from_field("blob"), + accepted: vec!["*/*".to_string()], + actual: mime.to_string(), + }); + } + } + } + Ok(()) + } +} + +/// Marker type for deserializing records from this collection. + +#[derive(Debug, Serialize, Deserialize)] +pub struct SubfsRecordRecord; +impl XrpcResp for SubfsRecordRecord { + const NSID: &'static str = "place.wisp.subfs"; + const ENCODING: &'static str = "application/json"; + type Output = SubfsRecordGetRecordOutput; + type Err = RecordError; +} + +impl From> for SubfsRecord { + fn from(output: SubfsRecordGetRecordOutput) -> Self { + output.value + } +} + +impl Collection for SubfsRecord { + const NSID: &'static str = "place.wisp.subfs"; + type Record = SubfsRecordRecord; +} + +impl Collection for SubfsRecordRecord { + const NSID: &'static str = "place.wisp.subfs"; + type Record = SubfsRecordRecord; +} + +impl LexiconSchema for SubfsRecord { + fn nsid() -> &'static str { + "place.wisp.subfs" + } + fn def_name() -> &'static str { + "main" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_subfs() + } + fn validate(&self) -> Result<(), ConstraintError> { + if let Some(ref value) = self.file_count { + if *value > 1000i64 { + return Err(ConstraintError::Maximum { + path: ValidationPath::from_field("file_count"), + max: 1000i64, + actual: *value, + }); + } + } + if let Some(ref value) = self.file_count { + if *value < 0i64 { + return Err(ConstraintError::Minimum { + path: ValidationPath::from_field("file_count"), + min: 0i64, + actual: *value, + }); + } + } + Ok(()) + } +} + +impl LexiconSchema for Subfs { + fn nsid() -> &'static str { + "place.wisp.subfs" + } + fn def_name() -> &'static str { + "subfs" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_subfs() + } + fn validate(&self) -> Result<(), ConstraintError> { + Ok(()) + } +} + +fn deserialize_directory_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod directory_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Entries; + type Type; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Entries = Unset; + type Type = Unset; + } + ///State transition - sets the `entries` field to Set + pub struct SetEntries(PhantomData St>); + impl sealed::Sealed for SetEntries {} + impl State for SetEntries { + type Entries = Set; + type Type = St::Type; + } + ///State transition - sets the `type` field to Set + pub struct SetType(PhantomData St>); + impl sealed::Sealed for SetType {} + impl State for SetType { + type Entries = St::Entries; + type Type = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `entries` field + pub struct entries(()); + ///Marker type for the `type` field + pub struct r#type(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct DirectoryBuilder { + _state: PhantomData St>, + _fields: (Option>>, Option), + _type: PhantomData S>, +} + +impl Directory { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> DirectoryBuilder { + DirectoryBuilder::new() + } +} + +impl Directory { + /// Create a new builder for this type + pub fn builder() -> DirectoryBuilder { + DirectoryBuilder::builder() + } +} + +impl DirectoryBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + DirectoryBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl DirectoryBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + DirectoryBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl DirectoryBuilder +where + St: directory_state::State, + St::Entries: directory_state::IsUnset, +{ + /// Set the `entries` field (required) + pub fn entries( + mut self, + value: impl Into>>, + ) -> DirectoryBuilder, S> { + self._fields.0 = Option::Some(value.into()); + DirectoryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DirectoryBuilder +where + St: directory_state::State, + St::Type: directory_state::IsUnset, +{ + /// Set the `type` field (required) + pub fn r#type( + mut self, + value: impl Into, + ) -> DirectoryBuilder, S> { + self._fields.1 = Option::Some(value.into()); + DirectoryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DirectoryBuilder +where + St: directory_state::State, + St::Entries: directory_state::IsSet, + St::Type: directory_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Directory { + Directory { + entries: self._fields.0.unwrap(), + r#type: self._fields.1.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> Directory { + Directory { + entries: self._fields.0.unwrap(), + r#type: self._fields.1.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_place_wisp_subfs() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.subfs"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("directory"), + LexUserType::Object(LexObject { + required: Some( + vec![SmolStr::new_static("type"), SmolStr::new_static("entries")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("entries"), + LexObjectProperty::Array(LexArray { + items: LexArrayItem::Ref(LexRef { + r#ref: CowStr::new_static("#entry"), + ..Default::default() + }), + max_length: Some(500usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("type"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("entry"), + LexUserType::Object(LexObject { + required: Some( + vec![SmolStr::new_static("name"), SmolStr::new_static("node")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("name"), + LexObjectProperty::String(LexString { + max_length: Some(255usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("node"), + LexObjectProperty::Union(LexRefUnion { + refs: vec![ + CowStr::new_static("#file"), + CowStr::new_static("#directory"), + CowStr::new_static("#subfs") + ], + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("file"), + LexUserType::Object(LexObject { + required: Some( + vec![SmolStr::new_static("type"), SmolStr::new_static("blob")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("base64"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("blob"), + LexObjectProperty::Blob(LexBlob { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("encoding"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Content encoding (e.g., gzip for compressed files)", + ), + ), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("mimeType"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static("Original MIME type before compression"), + ), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("type"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("main"), + LexUserType::Record(LexRecord { + description: Some( + CowStr::new_static( + "Virtual filesystem subtree referenced by place.wisp.fs records. When a subfs entry is expanded, its root entries are merged (flattened) into the parent directory, allowing large directories to be split across multiple records while maintaining a flat structure.", + ), + ), + record: LexRecordRecord::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("root"), + SmolStr::new_static("createdAt") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("fileCount"), + LexObjectProperty::Integer(LexInteger { + minimum: Some(0i64), + maximum: Some(1000i64), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("root"), + LexObjectProperty::Ref(LexRef { + r#ref: CowStr::new_static("#directory"), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("subfs"), + LexUserType::Object(LexObject { + required: Some( + vec![SmolStr::new_static("type"), SmolStr::new_static("subject")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("subject"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "AT-URI pointing to another place.wisp.subfs record for nested subtrees. When expanded, the referenced record's root entries are merged (flattened) into the parent directory, allowing recursive splitting of large directory structures.", + ), + ), + format: Some(LexStringFormat::AtUri), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("type"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map + }, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} + +fn deserialize_entry_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod entry_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Name; + type Node; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Name = Unset; + type Node = Unset; + } + ///State transition - sets the `name` field to Set + pub struct SetName(PhantomData St>); + impl sealed::Sealed for SetName {} + impl State for SetName { + type Name = Set; + type Node = St::Node; + } + ///State transition - sets the `node` field to Set + pub struct SetNode(PhantomData St>); + impl sealed::Sealed for SetNode {} + impl State for SetNode { + type Name = St::Name; + type Node = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `name` field + pub struct name(()); + ///Marker type for the `node` field + pub struct node(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct EntryBuilder { + _state: PhantomData St>, + _fields: (Option, Option>), + _type: PhantomData S>, +} + +impl Entry { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> EntryBuilder { + EntryBuilder::new() + } +} + +impl Entry { + /// Create a new builder for this type + pub fn builder() -> EntryBuilder { + EntryBuilder::builder() + } +} + +impl EntryBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + EntryBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl EntryBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + EntryBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl EntryBuilder +where + St: entry_state::State, + St::Name: entry_state::IsUnset, +{ + /// Set the `name` field (required) + pub fn name( + mut self, + value: impl Into, + ) -> EntryBuilder, S> { + self._fields.0 = Option::Some(value.into()); + EntryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl EntryBuilder +where + St: entry_state::State, + St::Node: entry_state::IsUnset, +{ + /// Set the `node` field (required) + pub fn node( + mut self, + value: impl Into>, + ) -> EntryBuilder, S> { + self._fields.1 = Option::Some(value.into()); + EntryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl EntryBuilder +where + St: entry_state::State, + St::Name: entry_state::IsSet, + St::Node: entry_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Entry { + Entry { + name: self._fields.0.unwrap(), + node: self._fields.1.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Entry { + Entry { + name: self._fields.0.unwrap(), + node: self._fields.1.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn deserialize_file_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod file_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Blob; + type Type; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Blob = Unset; + type Type = Unset; + } + ///State transition - sets the `blob` field to Set + pub struct SetBlob(PhantomData St>); + impl sealed::Sealed for SetBlob {} + impl State for SetBlob { + type Blob = Set; + type Type = St::Type; + } + ///State transition - sets the `type` field to Set + pub struct SetType(PhantomData St>); + impl sealed::Sealed for SetType {} + impl State for SetType { + type Blob = St::Blob; + type Type = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `blob` field + pub struct blob(()); + ///Marker type for the `type` field + pub struct r#type(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct FileBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option>, + Option>, + Option, + Option, + ), + _type: PhantomData S>, +} + +impl File { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> FileBuilder { + FileBuilder::new() + } +} + +impl File { + /// Create a new builder for this type + pub fn builder() -> FileBuilder { + FileBuilder::builder() + } +} + +impl FileBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + FileBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl FileBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + FileBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl FileBuilder { + /// Set the `base64` field (optional) + pub fn base64(mut self, value: impl Into>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `base64` field to an Option value (optional) + pub fn maybe_base64(mut self, value: Option) -> Self { + self._fields.0 = value; + self + } +} + +impl FileBuilder +where + St: file_state::State, + St::Blob: file_state::IsUnset, +{ + /// Set the `blob` field (required) + pub fn blob( + mut self, + value: impl Into>, + ) -> FileBuilder, S> { + self._fields.1 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder { + /// Set the `encoding` field (optional) + pub fn encoding(mut self, value: impl Into>>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `encoding` field to an Option value (optional) + pub fn maybe_encoding(mut self, value: Option>) -> Self { + self._fields.2 = value; + self + } +} + +impl FileBuilder { + /// Set the `mimeType` field (optional) + pub fn mime_type(mut self, value: impl Into>) -> Self { + self._fields.3 = value.into(); + self + } + /// Set the `mimeType` field to an Option value (optional) + pub fn maybe_mime_type(mut self, value: Option) -> Self { + self._fields.3 = value; + self + } +} + +impl FileBuilder +where + St: file_state::State, + St::Type: file_state::IsUnset, +{ + /// Set the `type` field (required) + pub fn r#type( + mut self, + value: impl Into, + ) -> FileBuilder, S> { + self._fields.4 = Option::Some(value.into()); + FileBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl FileBuilder +where + St: file_state::State, + St::Blob: file_state::IsSet, + St::Type: file_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> File { + File { + base64: self._fields.0, + blob: self._fields.1.unwrap(), + encoding: self._fields.2, + mime_type: self._fields.3, + r#type: self._fields.4.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> File { + File { + base64: self._fields.0, + blob: self._fields.1.unwrap(), + encoding: self._fields.2, + mime_type: self._fields.3, + r#type: self._fields.4.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn deserialize_subfs_record_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let mut data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + if let Some(extra_data) = &mut data { + extra_data.remove("$type"); + if extra_data.is_empty() { + data = None; + } + } + Ok(data) +} + +pub mod subfs_record_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + type Root; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + type Root = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + type Root = St::Root; + } + ///State transition - sets the `root` field to Set + pub struct SetRoot(PhantomData St>); + impl sealed::Sealed for SetRoot {} + impl State for SetRoot { + type CreatedAt = St::CreatedAt; + type Root = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `root` field + pub struct root(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct SubfsRecordBuilder { + _state: PhantomData St>, + _fields: (Option, Option, Option>), + _type: PhantomData S>, +} + +impl SubfsRecord { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SubfsRecordBuilder { + SubfsRecordBuilder::new() + } +} + +impl SubfsRecord { + /// Create a new builder for this type + pub fn builder() -> SubfsRecordBuilder { + SubfsRecordBuilder::builder() + } +} + +impl SubfsRecordBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SubfsRecordBuilder { + _state: PhantomData, + _fields: (None, None, None), + _type: PhantomData, + } + } +} + +impl SubfsRecordBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SubfsRecordBuilder { + _state: PhantomData, + _fields: (None, None, None), + _type: PhantomData, + } + } +} + +impl SubfsRecordBuilder +where + St: subfs_record_state::State, + St::CreatedAt: subfs_record_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> SubfsRecordBuilder, S> { + self._fields.0 = Option::Some(value.into()); + SubfsRecordBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SubfsRecordBuilder { + /// Set the `fileCount` field (optional) + pub fn file_count(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `fileCount` field to an Option value (optional) + pub fn maybe_file_count(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl SubfsRecordBuilder +where + St: subfs_record_state::State, + St::Root: subfs_record_state::IsUnset, +{ + /// Set the `root` field (required) + pub fn root( + mut self, + value: impl Into>, + ) -> SubfsRecordBuilder, S> { + self._fields.2 = Option::Some(value.into()); + SubfsRecordBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SubfsRecordBuilder +where + St: subfs_record_state::State, + St::CreatedAt: subfs_record_state::IsSet, + St::Root: subfs_record_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> SubfsRecord { + SubfsRecord { + created_at: self._fields.0.unwrap(), + file_count: self._fields.1, + root: self._fields.2.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> SubfsRecord { + SubfsRecord { + created_at: self._fields.0.unwrap(), + file_count: self._fields.1, + root: self._fields.2.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn deserialize_subfs_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod subfs_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Subject; + type Type; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Subject = Unset; + type Type = Unset; + } + ///State transition - sets the `subject` field to Set + pub struct SetSubject(PhantomData St>); + impl sealed::Sealed for SetSubject {} + impl State for SetSubject { + type Subject = Set; + type Type = St::Type; + } + ///State transition - sets the `type` field to Set + pub struct SetType(PhantomData St>); + impl sealed::Sealed for SetType {} + impl State for SetType { + type Subject = St::Subject; + type Type = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `subject` field + pub struct subject(()); + ///Marker type for the `type` field + pub struct r#type(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct SubfsBuilder { + _state: PhantomData St>, + _fields: (Option>, Option), + _type: PhantomData S>, +} + +impl Subfs { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SubfsBuilder { + SubfsBuilder::new() + } +} + +impl Subfs { + /// Create a new builder for this type + pub fn builder() -> SubfsBuilder { + SubfsBuilder::builder() + } +} + +impl SubfsBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SubfsBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl SubfsBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SubfsBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl SubfsBuilder +where + St: subfs_state::State, + St::Subject: subfs_state::IsUnset, +{ + /// Set the `subject` field (required) + pub fn subject( + mut self, + value: impl Into>, + ) -> SubfsBuilder, S> { + self._fields.0 = Option::Some(value.into()); + SubfsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SubfsBuilder +where + St: subfs_state::State, + St::Type: subfs_state::IsUnset, +{ + /// Set the `type` field (required) + pub fn r#type( + mut self, + value: impl Into, + ) -> SubfsBuilder, S> { + self._fields.1 = Option::Some(value.into()); + SubfsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SubfsBuilder +where + St: subfs_state::State, + St::Subject: subfs_state::IsSet, + St::Type: subfs_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Subfs { + Subfs { + subject: self._fields.0.unwrap(), + r#type: self._fields.1.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Subfs { + Subfs { + subject: self._fields.0.unwrap(), + r#type: self._fields.1.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2.rs new file mode 100644 index 0000000..35ed662 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2.rs @@ -0,0 +1,12 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +//! Generated bindings for the `place.wisp.v2` Lexicon namespace/module. +pub mod domain; +pub mod domains; +pub mod privateSite; +pub mod secret; +pub mod site; +pub mod wh; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain.rs new file mode 100644 index 0000000..f4176ad --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain.rs @@ -0,0 +1,13 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +//! Generated bindings for the `place.wisp.v2.domain` Lexicon namespace/module. +pub mod add_site; +pub mod claim; +pub mod claim_subdomain; +pub mod delete; +pub mod get_list; +pub mod get_status; +pub mod verify; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/add_site.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/add_site.rs new file mode 100644 index 0000000..3a11b4d --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/add_site.rs @@ -0,0 +1,457 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.domain.addSite +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct AddSite { + /// Fully-qualified domain to map. + pub domain: S, + /// Owned place.wisp.fs record key to map this domain to. + pub site_rkey: RecordKey>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct AddSiteOutput { + pub domain: S, + pub kind: AddSiteOutputKind, + pub mapped: bool, + pub site_rkey: RecordKey>, + pub status: AddSiteOutputStatus, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AddSiteOutputKind { + Wisp, + Custom, + Other(S), +} + +impl AddSiteOutputKind { + pub fn as_str(&self) -> &str { + match self { + Self::Wisp => "wisp", + Self::Custom => "custom", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "wisp" => Self::Wisp, + "custom" => Self::Custom, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for AddSiteOutputKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for AddSiteOutputKind { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for AddSiteOutputKind { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for AddSiteOutputKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for AddSiteOutputKind { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for AddSiteOutputKind +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = AddSiteOutputKind; + fn into_static(self) -> Self::Output { + match self { + AddSiteOutputKind::Wisp => AddSiteOutputKind::Wisp, + AddSiteOutputKind::Custom => AddSiteOutputKind::Custom, + AddSiteOutputKind::Other(v) => AddSiteOutputKind::Other(v.into_static()), + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum AddSiteOutputStatus { + PendingVerification, + Verified, + Other(S), +} + +impl AddSiteOutputStatus { + pub fn as_str(&self) -> &str { + match self { + Self::PendingVerification => "pendingVerification", + Self::Verified => "verified", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "pendingVerification" => Self::PendingVerification, + "verified" => Self::Verified, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for AddSiteOutputStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for AddSiteOutputStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for AddSiteOutputStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for AddSiteOutputStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for AddSiteOutputStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for AddSiteOutputStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = AddSiteOutputStatus; + fn into_static(self) -> Self::Output { + match self { + AddSiteOutputStatus::PendingVerification => { + AddSiteOutputStatus::PendingVerification + } + AddSiteOutputStatus::Verified => AddSiteOutputStatus::Verified, + AddSiteOutputStatus::Other(v) => AddSiteOutputStatus::Other(v.into_static()), + } + } +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum AddSiteError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidDomain")] + InvalidDomain(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for AddSiteError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidDomain(msg) => { + write!(f, "InvalidDomain")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.domain.addSite` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `AddSiteOutput` for this endpoint.*/ +pub struct AddSiteResponse; +impl jacquard_common::xrpc::XrpcResp for AddSiteResponse { + const NSID: &'static str = "place.wisp.v2.domain.addSite"; + const ENCODING: &'static str = "application/json"; + type Output = AddSiteOutput; + type Err = AddSiteError; +} + +impl jacquard_common::xrpc::XrpcRequest for AddSite { + const NSID: &'static str = "place.wisp.v2.domain.addSite"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = AddSiteResponse; +} + +/** Endpoint marker for the `place.wisp.v2.domain.addSite` procedure. + +Path: `/xrpc/place.wisp.v2.domain.addSite`. The request payload type is `AddSite`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct AddSiteRequest; +impl jacquard_common::xrpc::XrpcEndpoint for AddSiteRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.domain.addSite"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = AddSite; + type Response = AddSiteResponse; +} + +pub mod add_site_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Domain; + type SiteRkey; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Domain = Unset; + type SiteRkey = Unset; + } + ///State transition - sets the `domain` field to Set + pub struct SetDomain(PhantomData St>); + impl sealed::Sealed for SetDomain {} + impl State for SetDomain { + type Domain = Set; + type SiteRkey = St::SiteRkey; + } + ///State transition - sets the `site_rkey` field to Set + pub struct SetSiteRkey(PhantomData St>); + impl sealed::Sealed for SetSiteRkey {} + impl State for SetSiteRkey { + type Domain = St::Domain; + type SiteRkey = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `domain` field + pub struct domain(()); + ///Marker type for the `site_rkey` field + pub struct site_rkey(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct AddSiteBuilder { + _state: PhantomData St>, + _fields: (Option, Option>>), + _type: PhantomData S>, +} + +impl AddSite { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> AddSiteBuilder { + AddSiteBuilder::new() + } +} + +impl AddSite { + /// Create a new builder for this type + pub fn builder() -> AddSiteBuilder { + AddSiteBuilder::builder() + } +} + +impl AddSiteBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + AddSiteBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl AddSiteBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + AddSiteBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl AddSiteBuilder +where + St: add_site_state::State, + St::Domain: add_site_state::IsUnset, +{ + /// Set the `domain` field (required) + pub fn domain( + mut self, + value: impl Into, + ) -> AddSiteBuilder, S> { + self._fields.0 = Option::Some(value.into()); + AddSiteBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl AddSiteBuilder +where + St: add_site_state::State, + St::SiteRkey: add_site_state::IsUnset, +{ + /// Set the `siteRkey` field (required) + pub fn site_rkey( + mut self, + value: impl Into>>, + ) -> AddSiteBuilder, S> { + self._fields.1 = Option::Some(value.into()); + AddSiteBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl AddSiteBuilder +where + St: add_site_state::State, + St::Domain: add_site_state::IsSet, + St::SiteRkey: add_site_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> AddSite { + AddSite { + domain: self._fields.0.unwrap(), + site_rkey: self._fields.1.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> AddSite { + AddSite { + domain: self._fields.0.unwrap(), + site_rkey: self._fields.1.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/claim.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/claim.rs new file mode 100644 index 0000000..31f37d1 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/claim.rs @@ -0,0 +1,334 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.domain.claim +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Did, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Claim { + /// Custom domain FQDN to claim (for example, example.com). + pub domain: S, + /// Optional place.wisp.fs rkey to map immediately after claim. + #[serde(skip_serializing_if = "Option::is_none")] + pub site_rkey: Option>>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct ClaimOutput { + /// Identifier used to construct DNS challenge targets for custom domains. + #[serde(skip_serializing_if = "Option::is_none")] + pub challenge_id: Option, + /// Advisory CNAME target (custom domains). + #[serde(skip_serializing_if = "Option::is_none")] + pub cname_target: Option, + pub domain: S, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub site_rkey: Option>>, + pub status: ClaimOutputStatus, + /// TXT hostname to set for ownership proof (custom domains). + #[serde(skip_serializing_if = "Option::is_none")] + pub txt_name: Option, + /// TXT value to set for ownership proof (custom domains). + #[serde(skip_serializing_if = "Option::is_none")] + pub txt_value: Option>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ClaimOutputKind { + Custom, + Other(S), +} + +impl ClaimOutputKind { + pub fn as_str(&self) -> &str { + match self { + Self::Custom => "custom", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "custom" => Self::Custom, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for ClaimOutputKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for ClaimOutputKind { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for ClaimOutputKind { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for ClaimOutputKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for ClaimOutputKind { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for ClaimOutputKind +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = ClaimOutputKind; + fn into_static(self) -> Self::Output { + match self { + ClaimOutputKind::Custom => ClaimOutputKind::Custom, + ClaimOutputKind::Other(v) => ClaimOutputKind::Other(v.into_static()), + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ClaimOutputStatus { + AlreadyClaimed, + PendingVerification, + Verified, + Other(S), +} + +impl ClaimOutputStatus { + pub fn as_str(&self) -> &str { + match self { + Self::AlreadyClaimed => "alreadyClaimed", + Self::PendingVerification => "pendingVerification", + Self::Verified => "verified", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "alreadyClaimed" => Self::AlreadyClaimed, + "pendingVerification" => Self::PendingVerification, + "verified" => Self::Verified, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for ClaimOutputStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for ClaimOutputStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for ClaimOutputStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for ClaimOutputStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for ClaimOutputStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for ClaimOutputStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = ClaimOutputStatus; + fn into_static(self) -> Self::Output { + match self { + ClaimOutputStatus::AlreadyClaimed => ClaimOutputStatus::AlreadyClaimed, + ClaimOutputStatus::PendingVerification => { + ClaimOutputStatus::PendingVerification + } + ClaimOutputStatus::Verified => ClaimOutputStatus::Verified, + ClaimOutputStatus::Other(v) => ClaimOutputStatus::Other(v.into_static()), + } + } +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum ClaimError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidDomain")] + InvalidDomain(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "AlreadyClaimed")] + AlreadyClaimed(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "DomainLimitReached")] + DomainLimitReached( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "RateLimitExceeded")] + RateLimitExceeded(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for ClaimError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidDomain(msg) => { + write!(f, "InvalidDomain")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::AlreadyClaimed(msg) => { + write!(f, "AlreadyClaimed")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::DomainLimitReached(msg) => { + write!(f, "DomainLimitReached")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::RateLimitExceeded(msg) => { + write!(f, "RateLimitExceeded")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.domain.claim` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `ClaimOutput` for this endpoint.*/ +pub struct ClaimResponse; +impl jacquard_common::xrpc::XrpcResp for ClaimResponse { + const NSID: &'static str = "place.wisp.v2.domain.claim"; + const ENCODING: &'static str = "application/json"; + type Output = ClaimOutput; + type Err = ClaimError; +} + +impl jacquard_common::xrpc::XrpcRequest for Claim { + const NSID: &'static str = "place.wisp.v2.domain.claim"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = ClaimResponse; +} + +/** Endpoint marker for the `place.wisp.v2.domain.claim` procedure. + +Path: `/xrpc/place.wisp.v2.domain.claim`. The request payload type is `Claim`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct ClaimRequest; +impl jacquard_common::xrpc::XrpcEndpoint for ClaimRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.domain.claim"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = Claim; + type Response = ClaimResponse; +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/claim_subdomain.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/claim_subdomain.rs new file mode 100644 index 0000000..40a3198 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/claim_subdomain.rs @@ -0,0 +1,323 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.domain.claimSubdomain +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct ClaimSubdomain { + /// Subdomain label only (for example, alice). + pub handle: S, + /// Optional place.wisp.fs rkey to map immediately after claim. + #[serde(skip_serializing_if = "Option::is_none")] + pub site_rkey: Option>>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct ClaimSubdomainOutput { + pub domain: S, + pub kind: ClaimSubdomainOutputKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub site_rkey: Option>>, + pub status: ClaimSubdomainOutputStatus, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ClaimSubdomainOutputKind { + Wisp, + Other(S), +} + +impl ClaimSubdomainOutputKind { + pub fn as_str(&self) -> &str { + match self { + Self::Wisp => "wisp", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "wisp" => Self::Wisp, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for ClaimSubdomainOutputKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for ClaimSubdomainOutputKind { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for ClaimSubdomainOutputKind { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> +for ClaimSubdomainOutputKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for ClaimSubdomainOutputKind { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for ClaimSubdomainOutputKind +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = ClaimSubdomainOutputKind; + fn into_static(self) -> Self::Output { + match self { + ClaimSubdomainOutputKind::Wisp => ClaimSubdomainOutputKind::Wisp, + ClaimSubdomainOutputKind::Other(v) => { + ClaimSubdomainOutputKind::Other(v.into_static()) + } + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ClaimSubdomainOutputStatus { + Verified, + AlreadyClaimed, + Other(S), +} + +impl ClaimSubdomainOutputStatus { + pub fn as_str(&self) -> &str { + match self { + Self::Verified => "verified", + Self::AlreadyClaimed => "alreadyClaimed", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "verified" => Self::Verified, + "alreadyClaimed" => Self::AlreadyClaimed, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for ClaimSubdomainOutputStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for ClaimSubdomainOutputStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for ClaimSubdomainOutputStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> +for ClaimSubdomainOutputStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for ClaimSubdomainOutputStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for ClaimSubdomainOutputStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = ClaimSubdomainOutputStatus; + fn into_static(self) -> Self::Output { + match self { + ClaimSubdomainOutputStatus::Verified => ClaimSubdomainOutputStatus::Verified, + ClaimSubdomainOutputStatus::AlreadyClaimed => { + ClaimSubdomainOutputStatus::AlreadyClaimed + } + ClaimSubdomainOutputStatus::Other(v) => { + ClaimSubdomainOutputStatus::Other(v.into_static()) + } + } + } +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum ClaimSubdomainError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidDomain")] + InvalidDomain(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "AlreadyClaimed")] + AlreadyClaimed(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "DomainLimitReached")] + DomainLimitReached( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "RateLimitExceeded")] + RateLimitExceeded(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for ClaimSubdomainError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidDomain(msg) => { + write!(f, "InvalidDomain")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::AlreadyClaimed(msg) => { + write!(f, "AlreadyClaimed")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::DomainLimitReached(msg) => { + write!(f, "DomainLimitReached")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::RateLimitExceeded(msg) => { + write!(f, "RateLimitExceeded")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.domain.claimSubdomain` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `ClaimSubdomainOutput` for this endpoint.*/ +pub struct ClaimSubdomainResponse; +impl jacquard_common::xrpc::XrpcResp for ClaimSubdomainResponse { + const NSID: &'static str = "place.wisp.v2.domain.claimSubdomain"; + const ENCODING: &'static str = "application/json"; + type Output = ClaimSubdomainOutput; + type Err = ClaimSubdomainError; +} + +impl jacquard_common::xrpc::XrpcRequest for ClaimSubdomain { + const NSID: &'static str = "place.wisp.v2.domain.claimSubdomain"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = ClaimSubdomainResponse; +} + +/** Endpoint marker for the `place.wisp.v2.domain.claimSubdomain` procedure. + +Path: `/xrpc/place.wisp.v2.domain.claimSubdomain`. The request payload type is `ClaimSubdomain`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct ClaimSubdomainRequest; +impl jacquard_common::xrpc::XrpcEndpoint for ClaimSubdomainRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.domain.claimSubdomain"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = ClaimSubdomain; + type Response = ClaimSubdomainResponse; +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/delete.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/delete.rs new file mode 100644 index 0000000..7eb4522 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/delete.rs @@ -0,0 +1,135 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.domain.delete +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Delete { + /// Fully-qualified domain to delete (wisp subdomain or custom domain). + pub domain: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct DeleteOutput { + pub deleted: bool, + pub domain: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum DeleteError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidDomain")] + InvalidDomain(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for DeleteError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidDomain(msg) => { + write!(f, "InvalidDomain")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.domain.delete` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `DeleteOutput` for this endpoint.*/ +pub struct DeleteResponse; +impl jacquard_common::xrpc::XrpcResp for DeleteResponse { + const NSID: &'static str = "place.wisp.v2.domain.delete"; + const ENCODING: &'static str = "application/json"; + type Output = DeleteOutput; + type Err = DeleteError; +} + +impl jacquard_common::xrpc::XrpcRequest for Delete { + const NSID: &'static str = "place.wisp.v2.domain.delete"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = DeleteResponse; +} + +/** Endpoint marker for the `place.wisp.v2.domain.delete` procedure. + +Path: `/xrpc/place.wisp.v2.domain.delete`. The request payload type is `Delete`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct DeleteRequest; +impl jacquard_common::xrpc::XrpcEndpoint for DeleteRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.domain.delete"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = Delete; + type Response = DeleteResponse; +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/get_list.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/get_list.rs new file mode 100644 index 0000000..f35964e --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/get_list.rs @@ -0,0 +1,710 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.domain.getList +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Datetime, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::v2::domain::get_list; +/// Summary of a claimed domain for list views. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct DomainSummary { + pub domain: S, + pub kind: DomainSummaryKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_checked_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub site_rkey: Option>>, + pub status: DomainSummaryStatus, + pub verified: bool, + #[serde( + flatten, + default, + deserialize_with = "deserialize_domain_summary_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DomainSummaryKind { + Wisp, + Custom, + Other(S), +} + +impl DomainSummaryKind { + pub fn as_str(&self) -> &str { + match self { + Self::Wisp => "wisp", + Self::Custom => "custom", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "wisp" => Self::Wisp, + "custom" => Self::Custom, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for DomainSummaryKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for DomainSummaryKind { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for DomainSummaryKind { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for DomainSummaryKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for DomainSummaryKind { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for DomainSummaryKind +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = DomainSummaryKind; + fn into_static(self) -> Self::Output { + match self { + DomainSummaryKind::Wisp => DomainSummaryKind::Wisp, + DomainSummaryKind::Custom => DomainSummaryKind::Custom, + DomainSummaryKind::Other(v) => DomainSummaryKind::Other(v.into_static()), + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum DomainSummaryStatus { + PendingVerification, + Verified, + Other(S), +} + +impl DomainSummaryStatus { + pub fn as_str(&self) -> &str { + match self { + Self::PendingVerification => "pendingVerification", + Self::Verified => "verified", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "pendingVerification" => Self::PendingVerification, + "verified" => Self::Verified, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for DomainSummaryStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for DomainSummaryStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for DomainSummaryStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for DomainSummaryStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for DomainSummaryStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for DomainSummaryStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = DomainSummaryStatus; + fn into_static(self) -> Self::Output { + match self { + DomainSummaryStatus::PendingVerification => { + DomainSummaryStatus::PendingVerification + } + DomainSummaryStatus::Verified => DomainSummaryStatus::Verified, + DomainSummaryStatus::Other(v) => DomainSummaryStatus::Other(v.into_static()), + } + } +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct GetListOutput { + /// Domains owned by the caller DID. + pub domains: Vec>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum GetListError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for GetListError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +impl LexiconSchema for DomainSummary { + fn nsid() -> &'static str { + "place.wisp.v2.domain.getList" + } + fn def_name() -> &'static str { + "domainSummary" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_domain_getList() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 253usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("domain"), + max: 253usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) < 3usize { + return Err(ConstraintError::MinLength { + path: ValidationPath::from_field("domain"), + min: 3usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +/** Request marker for the `place.wisp.v2.domain.getList` query. + +This endpoint has no request parameters or input body; send this marker with `jacquard::Client`.*/ + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Copy)] +pub struct GetList; +/** Response marker for the `place.wisp.v2.domain.getList` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `GetListOutput` for this endpoint.*/ +pub struct GetListResponse; +impl jacquard_common::xrpc::XrpcResp for GetListResponse { + const NSID: &'static str = "place.wisp.v2.domain.getList"; + const ENCODING: &'static str = "application/json"; + type Output = GetListOutput; + type Err = GetListError; +} + +impl jacquard_common::xrpc::XrpcRequest for GetList { + const NSID: &'static str = "place.wisp.v2.domain.getList"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = GetListResponse; + fn encode_body( + &self, + _buffer: &mut Vec, + ) -> Result<(), jacquard_common::xrpc::EncodeError> { + Ok(()) + } +} + +/** Endpoint marker for the `place.wisp.v2.domain.getList` query. + +Path: `/xrpc/place.wisp.v2.domain.getList`. The request payload type is `GetList`; use this marker with lower-level `XrpcEndpoint` APIs when you need endpoint metadata.*/ +pub struct GetListRequest; +impl jacquard_common::xrpc::XrpcEndpoint for GetListRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.domain.getList"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = GetList; + type Response = GetListResponse; +} + +fn deserialize_domain_summary_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod domain_summary_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Domain; + type Kind; + type Status; + type Verified; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Domain = Unset; + type Kind = Unset; + type Status = Unset; + type Verified = Unset; + } + ///State transition - sets the `domain` field to Set + pub struct SetDomain(PhantomData St>); + impl sealed::Sealed for SetDomain {} + impl State for SetDomain { + type Domain = Set; + type Kind = St::Kind; + type Status = St::Status; + type Verified = St::Verified; + } + ///State transition - sets the `kind` field to Set + pub struct SetKind(PhantomData St>); + impl sealed::Sealed for SetKind {} + impl State for SetKind { + type Domain = St::Domain; + type Kind = Set; + type Status = St::Status; + type Verified = St::Verified; + } + ///State transition - sets the `status` field to Set + pub struct SetStatus(PhantomData St>); + impl sealed::Sealed for SetStatus {} + impl State for SetStatus { + type Domain = St::Domain; + type Kind = St::Kind; + type Status = Set; + type Verified = St::Verified; + } + ///State transition - sets the `verified` field to Set + pub struct SetVerified(PhantomData St>); + impl sealed::Sealed for SetVerified {} + impl State for SetVerified { + type Domain = St::Domain; + type Kind = St::Kind; + type Status = St::Status; + type Verified = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `domain` field + pub struct domain(()); + ///Marker type for the `kind` field + pub struct kind(()); + ///Marker type for the `status` field + pub struct status(()); + ///Marker type for the `verified` field + pub struct verified(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct DomainSummaryBuilder< + St: domain_summary_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: ( + Option, + Option>, + Option, + Option>>, + Option>, + Option, + ), + _type: PhantomData S>, +} + +impl DomainSummary { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> DomainSummaryBuilder { + DomainSummaryBuilder::new() + } +} + +impl DomainSummary { + /// Create a new builder for this type + pub fn builder() -> DomainSummaryBuilder { + DomainSummaryBuilder::builder() + } +} + +impl DomainSummaryBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + DomainSummaryBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl DomainSummaryBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + DomainSummaryBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl DomainSummaryBuilder +where + St: domain_summary_state::State, + St::Domain: domain_summary_state::IsUnset, +{ + /// Set the `domain` field (required) + pub fn domain( + mut self, + value: impl Into, + ) -> DomainSummaryBuilder, S> { + self._fields.0 = Option::Some(value.into()); + DomainSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DomainSummaryBuilder +where + St: domain_summary_state::State, + St::Kind: domain_summary_state::IsUnset, +{ + /// Set the `kind` field (required) + pub fn kind( + mut self, + value: impl Into>, + ) -> DomainSummaryBuilder, S> { + self._fields.1 = Option::Some(value.into()); + DomainSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DomainSummaryBuilder { + /// Set the `lastCheckedAt` field (optional) + pub fn last_checked_at(mut self, value: impl Into>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `lastCheckedAt` field to an Option value (optional) + pub fn maybe_last_checked_at(mut self, value: Option) -> Self { + self._fields.2 = value; + self + } +} + +impl DomainSummaryBuilder { + /// Set the `siteRkey` field (optional) + pub fn site_rkey(mut self, value: impl Into>>>) -> Self { + self._fields.3 = value.into(); + self + } + /// Set the `siteRkey` field to an Option value (optional) + pub fn maybe_site_rkey(mut self, value: Option>>) -> Self { + self._fields.3 = value; + self + } +} + +impl DomainSummaryBuilder +where + St: domain_summary_state::State, + St::Status: domain_summary_state::IsUnset, +{ + /// Set the `status` field (required) + pub fn status( + mut self, + value: impl Into>, + ) -> DomainSummaryBuilder, S> { + self._fields.4 = Option::Some(value.into()); + DomainSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DomainSummaryBuilder +where + St: domain_summary_state::State, + St::Verified: domain_summary_state::IsUnset, +{ + /// Set the `verified` field (required) + pub fn verified( + mut self, + value: impl Into, + ) -> DomainSummaryBuilder, S> { + self._fields.5 = Option::Some(value.into()); + DomainSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DomainSummaryBuilder +where + St: domain_summary_state::State, + St::Domain: domain_summary_state::IsSet, + St::Kind: domain_summary_state::IsSet, + St::Status: domain_summary_state::IsSet, + St::Verified: domain_summary_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> DomainSummary { + DomainSummary { + domain: self._fields.0.unwrap(), + kind: self._fields.1.unwrap(), + last_checked_at: self._fields.2, + site_rkey: self._fields.3, + status: self._fields.4.unwrap(), + verified: self._fields.5.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> DomainSummary { + DomainSummary { + domain: self._fields.0.unwrap(), + kind: self._fields.1.unwrap(), + last_checked_at: self._fields.2, + site_rkey: self._fields.3, + status: self._fields.4.unwrap(), + verified: self._fields.5.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_place_wisp_v2_domain_getList() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.v2.domain.getList"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("domainSummary"), + LexUserType::Object(LexObject { + description: Some( + CowStr::new_static("Summary of a claimed domain for list views."), + ), + required: Some( + vec![ + SmolStr::new_static("domain"), SmolStr::new_static("kind"), + SmolStr::new_static("status"), + SmolStr::new_static("verified") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("domain"), + LexObjectProperty::String(LexString { + min_length: Some(3usize), + max_length: Some(253usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("kind"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("lastCheckedAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("siteRkey"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::RecordKey), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("status"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("verified"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("main"), + LexUserType::XrpcQuery(LexXrpcQuery { + parameters: None, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/get_status.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/get_status.rs new file mode 100644 index 0000000..c0bb9fe --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/get_status.rs @@ -0,0 +1,349 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.domain.getStatus +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Datetime, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::IntoStatic; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct GetStatus { + pub domain: S, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct GetStatusOutput { + pub domain: S, + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_checked_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub site_rkey: Option>>, + pub status: GetStatusOutputStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub verified: Option, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum GetStatusOutputKind { + Wisp, + Custom, + Other(S), +} + +impl GetStatusOutputKind { + pub fn as_str(&self) -> &str { + match self { + Self::Wisp => "wisp", + Self::Custom => "custom", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "wisp" => Self::Wisp, + "custom" => Self::Custom, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for GetStatusOutputKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for GetStatusOutputKind { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for GetStatusOutputKind { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for GetStatusOutputKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for GetStatusOutputKind { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for GetStatusOutputKind +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = GetStatusOutputKind; + fn into_static(self) -> Self::Output { + match self { + GetStatusOutputKind::Wisp => GetStatusOutputKind::Wisp, + GetStatusOutputKind::Custom => GetStatusOutputKind::Custom, + GetStatusOutputKind::Other(v) => GetStatusOutputKind::Other(v.into_static()), + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum GetStatusOutputStatus { + Unclaimed, + PendingVerification, + Verified, + AlreadyClaimed, + Other(S), +} + +impl GetStatusOutputStatus { + pub fn as_str(&self) -> &str { + match self { + Self::Unclaimed => "unclaimed", + Self::PendingVerification => "pendingVerification", + Self::Verified => "verified", + Self::AlreadyClaimed => "alreadyClaimed", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "unclaimed" => Self::Unclaimed, + "pendingVerification" => Self::PendingVerification, + "verified" => Self::Verified, + "alreadyClaimed" => Self::AlreadyClaimed, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for GetStatusOutputStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for GetStatusOutputStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for GetStatusOutputStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for GetStatusOutputStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for GetStatusOutputStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for GetStatusOutputStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = GetStatusOutputStatus; + fn into_static(self) -> Self::Output { + match self { + GetStatusOutputStatus::Unclaimed => GetStatusOutputStatus::Unclaimed, + GetStatusOutputStatus::PendingVerification => { + GetStatusOutputStatus::PendingVerification + } + GetStatusOutputStatus::Verified => GetStatusOutputStatus::Verified, + GetStatusOutputStatus::AlreadyClaimed => { + GetStatusOutputStatus::AlreadyClaimed + } + GetStatusOutputStatus::Other(v) => { + GetStatusOutputStatus::Other(v.into_static()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.domain.getStatus` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `GetStatusOutput` for this endpoint.*/ +pub struct GetStatusResponse; +impl jacquard_common::xrpc::XrpcResp for GetStatusResponse { + const NSID: &'static str = "place.wisp.v2.domain.getStatus"; + const ENCODING: &'static str = "application/json"; + type Output = GetStatusOutput; + type Err = jacquard_common::xrpc::GenericError; +} + +impl jacquard_common::xrpc::XrpcRequest for GetStatus { + const NSID: &'static str = "place.wisp.v2.domain.getStatus"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = GetStatusResponse; +} + +/** Endpoint marker for the `place.wisp.v2.domain.getStatus` query. + +Path: `/xrpc/place.wisp.v2.domain.getStatus`. The request payload type is `GetStatus`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct GetStatusRequest; +impl jacquard_common::xrpc::XrpcEndpoint for GetStatusRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.domain.getStatus"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = GetStatus; + type Response = GetStatusResponse; +} + +pub mod get_status_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Domain; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Domain = Unset; + } + ///State transition - sets the `domain` field to Set + pub struct SetDomain(PhantomData St>); + impl sealed::Sealed for SetDomain {} + impl State for SetDomain { + type Domain = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `domain` field + pub struct domain(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct GetStatusBuilder { + _state: PhantomData St>, + _fields: (Option,), + _type: PhantomData S>, +} + +impl GetStatus { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> GetStatusBuilder { + GetStatusBuilder::new() + } +} + +impl GetStatus { + /// Create a new builder for this type + pub fn builder() -> GetStatusBuilder { + GetStatusBuilder::builder() + } +} + +impl GetStatusBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + GetStatusBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl GetStatusBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + GetStatusBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl GetStatusBuilder +where + St: get_status_state::State, + St::Domain: get_status_state::IsUnset, +{ + /// Set the `domain` field (required) + pub fn domain( + mut self, + value: impl Into, + ) -> GetStatusBuilder, S> { + self._fields.0 = Option::Some(value.into()); + GetStatusBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl GetStatusBuilder +where + St: get_status_state::State, + St::Domain: get_status_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> GetStatus { + GetStatus { + domain: self._fields.0.unwrap(), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/verify.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/verify.rs new file mode 100644 index 0000000..2ac083a --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domain/verify.rs @@ -0,0 +1,303 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.domain.verify +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Verify { + /// Custom domain FQDN to verify (for example, example.com). + pub domain: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct VerifyOutput { + /// The CNAME target observed during verification, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub cname_found: Option, + pub domain: S, + /// Human-readable reason verification did not pass (when not verified). + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub kind: VerifyOutputKind, + pub status: VerifyOutputStatus, + /// The TXT value observed during verification, if any. + #[serde(skip_serializing_if = "Option::is_none")] + pub txt_found: Option, + pub verified: bool, + /// Non-fatal advisory (for example, CNAME could not be confirmed due to flattening). + #[serde(skip_serializing_if = "Option::is_none")] + pub warning: Option, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum VerifyOutputKind { + Custom, + Other(S), +} + +impl VerifyOutputKind { + pub fn as_str(&self) -> &str { + match self { + Self::Custom => "custom", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "custom" => Self::Custom, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for VerifyOutputKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for VerifyOutputKind { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for VerifyOutputKind { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for VerifyOutputKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for VerifyOutputKind { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for VerifyOutputKind +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = VerifyOutputKind; + fn into_static(self) -> Self::Output { + match self { + VerifyOutputKind::Custom => VerifyOutputKind::Custom, + VerifyOutputKind::Other(v) => VerifyOutputKind::Other(v.into_static()), + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum VerifyOutputStatus { + PendingVerification, + Verified, + Other(S), +} + +impl VerifyOutputStatus { + pub fn as_str(&self) -> &str { + match self { + Self::PendingVerification => "pendingVerification", + Self::Verified => "verified", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "pendingVerification" => Self::PendingVerification, + "verified" => Self::Verified, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for VerifyOutputStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for VerifyOutputStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for VerifyOutputStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for VerifyOutputStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for VerifyOutputStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for VerifyOutputStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = VerifyOutputStatus; + fn into_static(self) -> Self::Output { + match self { + VerifyOutputStatus::PendingVerification => { + VerifyOutputStatus::PendingVerification + } + VerifyOutputStatus::Verified => VerifyOutputStatus::Verified, + VerifyOutputStatus::Other(v) => VerifyOutputStatus::Other(v.into_static()), + } + } +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum VerifyError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidDomain")] + InvalidDomain(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for VerifyError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidDomain(msg) => { + write!(f, "InvalidDomain")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.domain.verify` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `VerifyOutput` for this endpoint.*/ +pub struct VerifyResponse; +impl jacquard_common::xrpc::XrpcResp for VerifyResponse { + const NSID: &'static str = "place.wisp.v2.domain.verify"; + const ENCODING: &'static str = "application/json"; + type Output = VerifyOutput; + type Err = VerifyError; +} + +impl jacquard_common::xrpc::XrpcRequest for Verify { + const NSID: &'static str = "place.wisp.v2.domain.verify"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = VerifyResponse; +} + +/** Endpoint marker for the `place.wisp.v2.domain.verify` procedure. + +Path: `/xrpc/place.wisp.v2.domain.verify`. The request payload type is `Verify`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct VerifyRequest; +impl jacquard_common::xrpc::XrpcEndpoint for VerifyRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.domain.verify"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = Verify; + type Response = VerifyResponse; +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domains.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domains.rs new file mode 100644 index 0000000..a10de4d --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/domains.rs @@ -0,0 +1,1180 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.domains +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::collection::{Collection, RecordError}; +use jacquard_common::types::string::{AtUri, Cid, Datetime, RecordKey, Rkey}; +use jacquard_common::types::uri::{RecordUri, UriError}; +use jacquard_common::types::value::Data; +use jacquard_common::xrpc::XrpcResp; +use jacquard_derive::{IntoStatic, lexicon, open_union}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::v2::domains; +/// Registration metadata for a custom domain. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct CustomRegistration { + /// Challenge identifier used to derive DNS setup instructions. + pub challenge_id: S, + pub kind: S, + pub verification: domains::Verification, + #[serde( + flatten, + default, + deserialize_with = "deserialize_custom_registration_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/// Domain registration metadata for wisp.place subdomains and custom domains. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + rename = "place.wisp.v2.domains", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct Domains { + pub created_at: Datetime, + /// Lowercase FQDN for this registration (for example, alice.wisp.place or example.com). + pub domain: S, + pub registration: DomainsRegistration, + /// Optional place.wisp.fs record key currently mapped to this domain. + #[serde(skip_serializing_if = "Option::is_none")] + pub site_rkey: Option>>, + pub updated_at: Datetime, + #[serde( + flatten, + default, + deserialize_with = "deserialize_domains_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[open_union] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(tag = "$type", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub enum DomainsRegistration { + #[serde(rename = "place.wisp.v2.domains#wispRegistration")] + WispRegistration(Box>), + #[serde(rename = "place.wisp.v2.domains#customRegistration")] + CustomRegistration(Box>), +} + +/// Typed wrapper for GetRecord response with this collection's record type. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct DomainsGetRecordOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option>, + pub uri: AtUri, + pub value: Domains, +} + +/// Latest verification state for a custom domain. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Verification { + #[serde(skip_serializing_if = "Option::is_none")] + pub last_checked_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_error: Option, + pub method: VerificationMethod, + pub status: VerificationStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub verified_at: Option, + #[serde( + flatten, + default, + deserialize_with = "deserialize_verification_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum VerificationMethod { + TxtDidV1, + Other(S), +} + +impl VerificationMethod { + pub fn as_str(&self) -> &str { + match self { + Self::TxtDidV1 => "txt-did-v1", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "txt-did-v1" => Self::TxtDidV1, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for VerificationMethod { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for VerificationMethod { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for VerificationMethod { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for VerificationMethod { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for VerificationMethod { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for VerificationMethod +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = VerificationMethod; + fn into_static(self) -> Self::Output { + match self { + VerificationMethod::TxtDidV1 => VerificationMethod::TxtDidV1, + VerificationMethod::Other(v) => VerificationMethod::Other(v.into_static()), + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum VerificationStatus { + Pending, + Verified, + Failed, + Other(S), +} + +impl VerificationStatus { + pub fn as_str(&self) -> &str { + match self { + Self::Pending => "pending", + Self::Verified => "verified", + Self::Failed => "failed", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "pending" => Self::Pending, + "verified" => Self::Verified, + "failed" => Self::Failed, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for VerificationStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for VerificationStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for VerificationStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for VerificationStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for VerificationStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for VerificationStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = VerificationStatus; + fn into_static(self) -> Self::Output { + match self { + VerificationStatus::Pending => VerificationStatus::Pending, + VerificationStatus::Verified => VerificationStatus::Verified, + VerificationStatus::Failed => VerificationStatus::Failed, + VerificationStatus::Other(v) => VerificationStatus::Other(v.into_static()), + } + } +} + +/// Registration for a first-party subdomain under the wisp.place base host. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct WispRegistration { + /// Subdomain label only (for example, alice). + pub handle: S, + pub kind: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_wisp_registration_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +impl Domains { + pub fn uri(uri: S) -> Result, UriError> { + RecordUri::try_from_uri(AtUri::new(uri)?) + } +} + +impl LexiconSchema for CustomRegistration { + fn nsid() -> &'static str { + "place.wisp.v2.domains" + } + fn def_name() -> &'static str { + "customRegistration" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_domains() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.challenge_id; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 64usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("challenge_id"), + max: 64usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.challenge_id; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) < 8usize { + return Err(ConstraintError::MinLength { + path: ValidationPath::from_field("challenge_id"), + min: 8usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +/// Marker type for deserializing records from this collection. + +#[derive(Debug, Serialize, Deserialize)] +pub struct DomainsRecord; +impl XrpcResp for DomainsRecord { + const NSID: &'static str = "place.wisp.v2.domains"; + const ENCODING: &'static str = "application/json"; + type Output = DomainsGetRecordOutput; + type Err = RecordError; +} + +impl From> for Domains { + fn from(output: DomainsGetRecordOutput) -> Self { + output.value + } +} + +impl Collection for Domains { + const NSID: &'static str = "place.wisp.v2.domains"; + type Record = DomainsRecord; +} + +impl Collection for DomainsRecord { + const NSID: &'static str = "place.wisp.v2.domains"; + type Record = DomainsRecord; +} + +impl LexiconSchema for Domains { + fn nsid() -> &'static str { + "place.wisp.v2.domains" + } + fn def_name() -> &'static str { + "main" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_domains() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 253usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("domain"), + max: 253usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) < 3usize { + return Err(ConstraintError::MinLength { + path: ValidationPath::from_field("domain"), + min: 3usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +impl LexiconSchema for Verification { + fn nsid() -> &'static str { + "place.wisp.v2.domains" + } + fn def_name() -> &'static str { + "verification" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_domains() + } + fn validate(&self) -> Result<(), ConstraintError> { + if let Some(ref value) = self.last_error { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 1000usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("last_error"), + max: 1000usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +impl LexiconSchema for WispRegistration { + fn nsid() -> &'static str { + "place.wisp.v2.domains" + } + fn def_name() -> &'static str { + "wispRegistration" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_domains() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.handle; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 63usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("handle"), + max: 63usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.handle; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) < 3usize { + return Err(ConstraintError::MinLength { + path: ValidationPath::from_field("handle"), + min: 3usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +fn deserialize_custom_registration_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod custom_registration_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type ChallengeId; + type Kind; + type Verification; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type ChallengeId = Unset; + type Kind = Unset; + type Verification = Unset; + } + ///State transition - sets the `challenge_id` field to Set + pub struct SetChallengeId(PhantomData St>); + impl sealed::Sealed for SetChallengeId {} + impl State for SetChallengeId { + type ChallengeId = Set; + type Kind = St::Kind; + type Verification = St::Verification; + } + ///State transition - sets the `kind` field to Set + pub struct SetKind(PhantomData St>); + impl sealed::Sealed for SetKind {} + impl State for SetKind { + type ChallengeId = St::ChallengeId; + type Kind = Set; + type Verification = St::Verification; + } + ///State transition - sets the `verification` field to Set + pub struct SetVerification(PhantomData St>); + impl sealed::Sealed for SetVerification {} + impl State for SetVerification { + type ChallengeId = St::ChallengeId; + type Kind = St::Kind; + type Verification = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `challenge_id` field + pub struct challenge_id(()); + ///Marker type for the `kind` field + pub struct kind(()); + ///Marker type for the `verification` field + pub struct verification(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct CustomRegistrationBuilder< + St: custom_registration_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: (Option, Option, Option>), + _type: PhantomData S>, +} + +impl CustomRegistration { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> CustomRegistrationBuilder< + custom_registration_state::Empty, + DefaultStr, + > { + CustomRegistrationBuilder::new() + } +} + +impl CustomRegistration { + /// Create a new builder for this type + pub fn builder() -> CustomRegistrationBuilder { + CustomRegistrationBuilder::builder() + } +} + +impl CustomRegistrationBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + CustomRegistrationBuilder { + _state: PhantomData, + _fields: (None, None, None), + _type: PhantomData, + } + } +} + +impl CustomRegistrationBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + CustomRegistrationBuilder { + _state: PhantomData, + _fields: (None, None, None), + _type: PhantomData, + } + } +} + +impl CustomRegistrationBuilder +where + St: custom_registration_state::State, + St::ChallengeId: custom_registration_state::IsUnset, +{ + /// Set the `challengeId` field (required) + pub fn challenge_id( + mut self, + value: impl Into, + ) -> CustomRegistrationBuilder, S> { + self._fields.0 = Option::Some(value.into()); + CustomRegistrationBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl CustomRegistrationBuilder +where + St: custom_registration_state::State, + St::Kind: custom_registration_state::IsUnset, +{ + /// Set the `kind` field (required) + pub fn kind( + mut self, + value: impl Into, + ) -> CustomRegistrationBuilder, S> { + self._fields.1 = Option::Some(value.into()); + CustomRegistrationBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl CustomRegistrationBuilder +where + St: custom_registration_state::State, + St::Verification: custom_registration_state::IsUnset, +{ + /// Set the `verification` field (required) + pub fn verification( + mut self, + value: impl Into>, + ) -> CustomRegistrationBuilder, S> { + self._fields.2 = Option::Some(value.into()); + CustomRegistrationBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl CustomRegistrationBuilder +where + St: custom_registration_state::State, + St::ChallengeId: custom_registration_state::IsSet, + St::Kind: custom_registration_state::IsSet, + St::Verification: custom_registration_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> CustomRegistration { + CustomRegistration { + challenge_id: self._fields.0.unwrap(), + kind: self._fields.1.unwrap(), + verification: self._fields.2.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> CustomRegistration { + CustomRegistration { + challenge_id: self._fields.0.unwrap(), + kind: self._fields.1.unwrap(), + verification: self._fields.2.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_place_wisp_v2_domains() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.v2.domains"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("customRegistration"), + LexUserType::Object(LexObject { + description: Some( + CowStr::new_static("Registration metadata for a custom domain."), + ), + required: Some( + vec![ + SmolStr::new_static("kind"), + SmolStr::new_static("challengeId"), + SmolStr::new_static("verification") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("challengeId"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Challenge identifier used to derive DNS setup instructions.", + ), + ), + min_length: Some(8usize), + max_length: Some(64usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("kind"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("verification"), + LexObjectProperty::Ref(LexRef { + r#ref: CowStr::new_static("#verification"), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("main"), + LexUserType::Record(LexRecord { + description: Some( + CowStr::new_static( + "Domain registration metadata for wisp.place subdomains and custom domains.", + ), + ), + key: Some(CowStr::new_static("any")), + record: LexRecordRecord::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("domain"), + SmolStr::new_static("registration"), + SmolStr::new_static("createdAt"), + SmolStr::new_static("updatedAt") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("domain"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Lowercase FQDN for this registration (for example, alice.wisp.place or example.com).", + ), + ), + min_length: Some(3usize), + max_length: Some(253usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("registration"), + LexObjectProperty::Union(LexRefUnion { + refs: vec![ + CowStr::new_static("#wispRegistration"), + CowStr::new_static("#customRegistration") + ], + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("siteRkey"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Optional place.wisp.fs record key currently mapped to this domain.", + ), + ), + format: Some(LexStringFormat::RecordKey), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("updatedAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("verification"), + LexUserType::Object(LexObject { + description: Some( + CowStr::new_static( + "Latest verification state for a custom domain.", + ), + ), + required: Some( + vec![ + SmolStr::new_static("status"), SmolStr::new_static("method") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("lastCheckedAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("lastError"), + LexObjectProperty::String(LexString { + max_length: Some(1000usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("method"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("status"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("verifiedAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("wispRegistration"), + LexUserType::Object(LexObject { + description: Some( + CowStr::new_static( + "Registration for a first-party subdomain under the wisp.place base host.", + ), + ), + required: Some( + vec![SmolStr::new_static("kind"), SmolStr::new_static("handle")], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("handle"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Subdomain label only (for example, alice).", + ), + ), + min_length: Some(3usize), + max_length: Some(63usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("kind"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map + }, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} + +fn deserialize_domains_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let mut data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + if let Some(extra_data) = &mut data { + extra_data.remove("$type"); + if extra_data.is_empty() { + data = None; + } + } + Ok(data) +} + +pub mod domains_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + type Domain; + type Registration; + type UpdatedAt; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + type Domain = Unset; + type Registration = Unset; + type UpdatedAt = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + type Domain = St::Domain; + type Registration = St::Registration; + type UpdatedAt = St::UpdatedAt; + } + ///State transition - sets the `domain` field to Set + pub struct SetDomain(PhantomData St>); + impl sealed::Sealed for SetDomain {} + impl State for SetDomain { + type CreatedAt = St::CreatedAt; + type Domain = Set; + type Registration = St::Registration; + type UpdatedAt = St::UpdatedAt; + } + ///State transition - sets the `registration` field to Set + pub struct SetRegistration(PhantomData St>); + impl sealed::Sealed for SetRegistration {} + impl State for SetRegistration { + type CreatedAt = St::CreatedAt; + type Domain = St::Domain; + type Registration = Set; + type UpdatedAt = St::UpdatedAt; + } + ///State transition - sets the `updated_at` field to Set + pub struct SetUpdatedAt(PhantomData St>); + impl sealed::Sealed for SetUpdatedAt {} + impl State for SetUpdatedAt { + type CreatedAt = St::CreatedAt; + type Domain = St::Domain; + type Registration = St::Registration; + type UpdatedAt = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `domain` field + pub struct domain(()); + ///Marker type for the `registration` field + pub struct registration(()); + ///Marker type for the `updated_at` field + pub struct updated_at(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct DomainsBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option, + Option>, + Option>>, + Option, + ), + _type: PhantomData S>, +} + +impl Domains { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> DomainsBuilder { + DomainsBuilder::new() + } +} + +impl Domains { + /// Create a new builder for this type + pub fn builder() -> DomainsBuilder { + DomainsBuilder::builder() + } +} + +impl DomainsBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + DomainsBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl DomainsBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + DomainsBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl DomainsBuilder +where + St: domains_state::State, + St::CreatedAt: domains_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> DomainsBuilder, S> { + self._fields.0 = Option::Some(value.into()); + DomainsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DomainsBuilder +where + St: domains_state::State, + St::Domain: domains_state::IsUnset, +{ + /// Set the `domain` field (required) + pub fn domain( + mut self, + value: impl Into, + ) -> DomainsBuilder, S> { + self._fields.1 = Option::Some(value.into()); + DomainsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DomainsBuilder +where + St: domains_state::State, + St::Registration: domains_state::IsUnset, +{ + /// Set the `registration` field (required) + pub fn registration( + mut self, + value: impl Into>, + ) -> DomainsBuilder, S> { + self._fields.2 = Option::Some(value.into()); + DomainsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DomainsBuilder { + /// Set the `siteRkey` field (optional) + pub fn site_rkey(mut self, value: impl Into>>>) -> Self { + self._fields.3 = value.into(); + self + } + /// Set the `siteRkey` field to an Option value (optional) + pub fn maybe_site_rkey(mut self, value: Option>>) -> Self { + self._fields.3 = value; + self + } +} + +impl DomainsBuilder +where + St: domains_state::State, + St::UpdatedAt: domains_state::IsUnset, +{ + /// Set the `updatedAt` field (required) + pub fn updated_at( + mut self, + value: impl Into, + ) -> DomainsBuilder, S> { + self._fields.4 = Option::Some(value.into()); + DomainsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DomainsBuilder +where + St: domains_state::State, + St::CreatedAt: domains_state::IsSet, + St::Domain: domains_state::IsSet, + St::Registration: domains_state::IsSet, + St::UpdatedAt: domains_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Domains { + Domains { + created_at: self._fields.0.unwrap(), + domain: self._fields.1.unwrap(), + registration: self._fields.2.unwrap(), + site_rkey: self._fields.3, + updated_at: self._fields.4.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Domains { + Domains { + created_at: self._fields.0.unwrap(), + domain: self._fields.1.unwrap(), + registration: self._fields.2.unwrap(), + site_rkey: self._fields.3, + updated_at: self._fields.4.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn deserialize_verification_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +fn deserialize_wisp_registration_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite.rs new file mode 100644 index 0000000..1e353f9 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite.rs @@ -0,0 +1,12 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +//! Generated bindings for the `place.wisp.v2.privateSite` Lexicon namespace/module. +pub mod create; +pub mod create_share; +pub mod delete; +pub mod list; +pub mod list_shares; +pub mod revoke_share; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/create.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/create.rs new file mode 100644 index 0000000..e4cd305 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/create.rs @@ -0,0 +1,163 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.privateSite.create +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::bytes::Bytes; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Datetime, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct Create { + pub body: Bytes, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct CreateOutput { + pub created_at: Datetime, + /// Absent when the site never expires. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + pub file_count: i64, + /// Display name. Not an identifier. + pub name: S, + /// Stable identifier for this private site. Record-key syntax so it can become a permissioned-space key in v2. + pub site_id: RecordKey>, + pub total_bytes: i64, + /// Owner-facing URL. Requires an authenticated session. + pub url: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum CreateError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "PayloadTooLarge")] + PayloadTooLarge(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for CreateError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::PayloadTooLarge(msg) => { + write!(f, "PayloadTooLarge")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.privateSite.create` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `CreateOutput` for this endpoint.*/ +pub struct CreateResponse; +impl jacquard_common::xrpc::XrpcResp for CreateResponse { + const NSID: &'static str = "place.wisp.v2.privateSite.create"; + const ENCODING: &'static str = "application/json"; + type Output = CreateOutput; + type Err = CreateError; +} + +impl jacquard_common::xrpc::XrpcRequest for Create { + const NSID: &'static str = "place.wisp.v2.privateSite.create"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "multipart/form-data", + ); + type Response = CreateResponse; + fn encode_body( + &self, + buffer: &mut Vec, + ) -> Result<(), jacquard_common::xrpc::EncodeError> + where + Self: Serialize, + { + Ok(buffer.extend_from_slice(self.body.as_ref())) + } + fn decode_body<'de>( + body: &'de [u8], + ) -> Result + where + Self: Deserialize<'de>, + { + Ok(Self { + body: jacquard_common::deps::bytes::Bytes::copy_from_slice(body), + }) + } +} + +/** Endpoint marker for the `place.wisp.v2.privateSite.create` procedure. + +Path: `/xrpc/place.wisp.v2.privateSite.create`. The request payload type is `Create`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct CreateRequest; +impl jacquard_common::xrpc::XrpcEndpoint for CreateRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.privateSite.create"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "multipart/form-data", + ); + type Request = Create; + type Response = CreateResponse; +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/create_share.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/create_share.rs new file mode 100644 index 0000000..43d4d1f --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/create_share.rs @@ -0,0 +1,318 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.privateSite.createShare +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Did, Datetime, RecordKey, Rkey, UriValue}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct CreateShare { + /// Restrict this link to a single account. The recipient must be signed in as this DID; the link alone grants nothing. Omit for a bearer link that anyone holding the URL can open, including people without an atproto account. + #[serde(skip_serializing_if = "Option::is_none")] + pub audience_did: Option>, + /// Minutes until this link expires. Omit for the configured default; 0 for no expiry of its own. + #[serde(skip_serializing_if = "Option::is_none")] + pub expiry_minutes: Option, + /// Optional human label for this link. + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + pub site_id: RecordKey>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct CreateShareOutput { + /// Set when this link is restricted to a single account. + #[serde(skip_serializing_if = "Option::is_none")] + pub audience_did: Option>, + pub created_at: Datetime, + /// The same credential on the site's own origin. Equivalent to `url`; useful when a link should not route through wisp.place. + #[serde(skip_serializing_if = "Option::is_none")] + pub direct_url: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + pub share_id: S, + pub site_id: RecordKey>, + /// Short, human-friendly share link (wisp.place/p/). Contains the credential and is returned exactly once. + pub url: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum CreateShareError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for CreateShareError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.privateSite.createShare` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `CreateShareOutput` for this endpoint.*/ +pub struct CreateShareResponse; +impl jacquard_common::xrpc::XrpcResp for CreateShareResponse { + const NSID: &'static str = "place.wisp.v2.privateSite.createShare"; + const ENCODING: &'static str = "application/json"; + type Output = CreateShareOutput; + type Err = CreateShareError; +} + +impl jacquard_common::xrpc::XrpcRequest for CreateShare { + const NSID: &'static str = "place.wisp.v2.privateSite.createShare"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = CreateShareResponse; +} + +/** Endpoint marker for the `place.wisp.v2.privateSite.createShare` procedure. + +Path: `/xrpc/place.wisp.v2.privateSite.createShare`. The request payload type is `CreateShare`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct CreateShareRequest; +impl jacquard_common::xrpc::XrpcEndpoint for CreateShareRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.privateSite.createShare"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = CreateShare; + type Response = CreateShareResponse; +} + +pub mod create_share_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type SiteId; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type SiteId = Unset; + } + ///State transition - sets the `site_id` field to Set + pub struct SetSiteId(PhantomData St>); + impl sealed::Sealed for SetSiteId {} + impl State for SetSiteId { + type SiteId = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `site_id` field + pub struct site_id(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct CreateShareBuilder { + _state: PhantomData St>, + _fields: (Option>, Option, Option, Option>>), + _type: PhantomData S>, +} + +impl CreateShare { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> CreateShareBuilder { + CreateShareBuilder::new() + } +} + +impl CreateShare { + /// Create a new builder for this type + pub fn builder() -> CreateShareBuilder { + CreateShareBuilder::builder() + } +} + +impl CreateShareBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + CreateShareBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl CreateShareBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + CreateShareBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl CreateShareBuilder { + /// Set the `audienceDid` field (optional) + pub fn audience_did(mut self, value: impl Into>>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `audienceDid` field to an Option value (optional) + pub fn maybe_audience_did(mut self, value: Option>) -> Self { + self._fields.0 = value; + self + } +} + +impl CreateShareBuilder { + /// Set the `expiryMinutes` field (optional) + pub fn expiry_minutes(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `expiryMinutes` field to an Option value (optional) + pub fn maybe_expiry_minutes(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl CreateShareBuilder { + /// Set the `label` field (optional) + pub fn label(mut self, value: impl Into>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `label` field to an Option value (optional) + pub fn maybe_label(mut self, value: Option) -> Self { + self._fields.2 = value; + self + } +} + +impl CreateShareBuilder +where + St: create_share_state::State, + St::SiteId: create_share_state::IsUnset, +{ + /// Set the `siteId` field (required) + pub fn site_id( + mut self, + value: impl Into>>, + ) -> CreateShareBuilder, S> { + self._fields.3 = Option::Some(value.into()); + CreateShareBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl CreateShareBuilder +where + St: create_share_state::State, + St::SiteId: create_share_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> CreateShare { + CreateShare { + audience_did: self._fields.0, + expiry_minutes: self._fields.1, + label: self._fields.2, + site_id: self._fields.3.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> CreateShare { + CreateShare { + audience_did: self._fields.0, + expiry_minutes: self._fields.1, + label: self._fields.2, + site_id: self._fields.3.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/delete.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/delete.rs new file mode 100644 index 0000000..123e2be --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/delete.rs @@ -0,0 +1,250 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.privateSite.delete +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Delete { + pub site_id: RecordKey>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct DeleteOutput { + pub deleted: bool, + pub site_id: RecordKey>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum DeleteError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for DeleteError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.privateSite.delete` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `DeleteOutput` for this endpoint.*/ +pub struct DeleteResponse; +impl jacquard_common::xrpc::XrpcResp for DeleteResponse { + const NSID: &'static str = "place.wisp.v2.privateSite.delete"; + const ENCODING: &'static str = "application/json"; + type Output = DeleteOutput; + type Err = DeleteError; +} + +impl jacquard_common::xrpc::XrpcRequest for Delete { + const NSID: &'static str = "place.wisp.v2.privateSite.delete"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = DeleteResponse; +} + +/** Endpoint marker for the `place.wisp.v2.privateSite.delete` procedure. + +Path: `/xrpc/place.wisp.v2.privateSite.delete`. The request payload type is `Delete`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct DeleteRequest; +impl jacquard_common::xrpc::XrpcEndpoint for DeleteRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.privateSite.delete"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = Delete; + type Response = DeleteResponse; +} + +pub mod delete_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type SiteId; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type SiteId = Unset; + } + ///State transition - sets the `site_id` field to Set + pub struct SetSiteId(PhantomData St>); + impl sealed::Sealed for SetSiteId {} + impl State for SetSiteId { + type SiteId = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `site_id` field + pub struct site_id(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct DeleteBuilder { + _state: PhantomData St>, + _fields: (Option>>,), + _type: PhantomData S>, +} + +impl Delete { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> DeleteBuilder { + DeleteBuilder::new() + } +} + +impl Delete { + /// Create a new builder for this type + pub fn builder() -> DeleteBuilder { + DeleteBuilder::builder() + } +} + +impl DeleteBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + DeleteBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl DeleteBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + DeleteBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl DeleteBuilder +where + St: delete_state::State, + St::SiteId: delete_state::IsUnset, +{ + /// Set the `siteId` field (required) + pub fn site_id( + mut self, + value: impl Into>>, + ) -> DeleteBuilder, S> { + self._fields.0 = Option::Some(value.into()); + DeleteBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DeleteBuilder +where + St: delete_state::State, + St::SiteId: delete_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Delete { + Delete { + site_id: self._fields.0.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Delete { + Delete { + site_id: self._fields.0.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/list.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/list.rs new file mode 100644 index 0000000..0e186ec --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/list.rs @@ -0,0 +1,650 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.privateSite.list +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Datetime, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::v2::privateSite::list; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct ListOutput { + pub sites: Vec>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum ListError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for ListError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct PrivateSiteSummary { + pub created_at: Datetime, + pub expired: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + pub file_count: i64, + pub name: S, + /// Number of share links that currently grant access. + pub share_count: i64, + pub site_id: RecordKey>, + pub total_bytes: i64, + #[serde( + flatten, + default, + deserialize_with = "deserialize_private_site_summary_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/** Request marker for the `place.wisp.v2.privateSite.list` query. + +This endpoint has no request parameters or input body; send this marker with `jacquard::Client`.*/ + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Copy)] +pub struct List; +/** Response marker for the `place.wisp.v2.privateSite.list` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `ListOutput` for this endpoint.*/ +pub struct ListResponse; +impl jacquard_common::xrpc::XrpcResp for ListResponse { + const NSID: &'static str = "place.wisp.v2.privateSite.list"; + const ENCODING: &'static str = "application/json"; + type Output = ListOutput; + type Err = ListError; +} + +impl jacquard_common::xrpc::XrpcRequest for List { + const NSID: &'static str = "place.wisp.v2.privateSite.list"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = ListResponse; + fn encode_body( + &self, + _buffer: &mut Vec, + ) -> Result<(), jacquard_common::xrpc::EncodeError> { + Ok(()) + } +} + +/** Endpoint marker for the `place.wisp.v2.privateSite.list` query. + +Path: `/xrpc/place.wisp.v2.privateSite.list`. The request payload type is `List`; use this marker with lower-level `XrpcEndpoint` APIs when you need endpoint metadata.*/ +pub struct ListRequest; +impl jacquard_common::xrpc::XrpcEndpoint for ListRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.privateSite.list"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = List; + type Response = ListResponse; +} + +impl LexiconSchema for PrivateSiteSummary { + fn nsid() -> &'static str { + "place.wisp.v2.privateSite.list" + } + fn def_name() -> &'static str { + "privateSiteSummary" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_privateSite_list() + } + fn validate(&self) -> Result<(), ConstraintError> { + Ok(()) + } +} + +fn deserialize_private_site_summary_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod private_site_summary_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + type Expired; + type FileCount; + type Name; + type ShareCount; + type SiteId; + type TotalBytes; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + type Expired = Unset; + type FileCount = Unset; + type Name = Unset; + type ShareCount = Unset; + type SiteId = Unset; + type TotalBytes = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + type Expired = St::Expired; + type FileCount = St::FileCount; + type Name = St::Name; + type ShareCount = St::ShareCount; + type SiteId = St::SiteId; + type TotalBytes = St::TotalBytes; + } + ///State transition - sets the `expired` field to Set + pub struct SetExpired(PhantomData St>); + impl sealed::Sealed for SetExpired {} + impl State for SetExpired { + type CreatedAt = St::CreatedAt; + type Expired = Set; + type FileCount = St::FileCount; + type Name = St::Name; + type ShareCount = St::ShareCount; + type SiteId = St::SiteId; + type TotalBytes = St::TotalBytes; + } + ///State transition - sets the `file_count` field to Set + pub struct SetFileCount(PhantomData St>); + impl sealed::Sealed for SetFileCount {} + impl State for SetFileCount { + type CreatedAt = St::CreatedAt; + type Expired = St::Expired; + type FileCount = Set; + type Name = St::Name; + type ShareCount = St::ShareCount; + type SiteId = St::SiteId; + type TotalBytes = St::TotalBytes; + } + ///State transition - sets the `name` field to Set + pub struct SetName(PhantomData St>); + impl sealed::Sealed for SetName {} + impl State for SetName { + type CreatedAt = St::CreatedAt; + type Expired = St::Expired; + type FileCount = St::FileCount; + type Name = Set; + type ShareCount = St::ShareCount; + type SiteId = St::SiteId; + type TotalBytes = St::TotalBytes; + } + ///State transition - sets the `share_count` field to Set + pub struct SetShareCount(PhantomData St>); + impl sealed::Sealed for SetShareCount {} + impl State for SetShareCount { + type CreatedAt = St::CreatedAt; + type Expired = St::Expired; + type FileCount = St::FileCount; + type Name = St::Name; + type ShareCount = Set; + type SiteId = St::SiteId; + type TotalBytes = St::TotalBytes; + } + ///State transition - sets the `site_id` field to Set + pub struct SetSiteId(PhantomData St>); + impl sealed::Sealed for SetSiteId {} + impl State for SetSiteId { + type CreatedAt = St::CreatedAt; + type Expired = St::Expired; + type FileCount = St::FileCount; + type Name = St::Name; + type ShareCount = St::ShareCount; + type SiteId = Set; + type TotalBytes = St::TotalBytes; + } + ///State transition - sets the `total_bytes` field to Set + pub struct SetTotalBytes(PhantomData St>); + impl sealed::Sealed for SetTotalBytes {} + impl State for SetTotalBytes { + type CreatedAt = St::CreatedAt; + type Expired = St::Expired; + type FileCount = St::FileCount; + type Name = St::Name; + type ShareCount = St::ShareCount; + type SiteId = St::SiteId; + type TotalBytes = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `expired` field + pub struct expired(()); + ///Marker type for the `file_count` field + pub struct file_count(()); + ///Marker type for the `name` field + pub struct name(()); + ///Marker type for the `share_count` field + pub struct share_count(()); + ///Marker type for the `site_id` field + pub struct site_id(()); + ///Marker type for the `total_bytes` field + pub struct total_bytes(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct PrivateSiteSummaryBuilder< + St: private_site_summary_state::State, + S: BosStr = DefaultStr, +> { + _state: PhantomData St>, + _fields: ( + Option, + Option, + Option, + Option, + Option, + Option, + Option>>, + Option, + ), + _type: PhantomData S>, +} + +impl PrivateSiteSummary { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> PrivateSiteSummaryBuilder< + private_site_summary_state::Empty, + DefaultStr, + > { + PrivateSiteSummaryBuilder::new() + } +} + +impl PrivateSiteSummary { + /// Create a new builder for this type + pub fn builder() -> PrivateSiteSummaryBuilder { + PrivateSiteSummaryBuilder::builder() + } +} + +impl PrivateSiteSummaryBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + PrivateSiteSummaryBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl PrivateSiteSummaryBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + PrivateSiteSummaryBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl PrivateSiteSummaryBuilder +where + St: private_site_summary_state::State, + St::CreatedAt: private_site_summary_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> PrivateSiteSummaryBuilder, S> { + self._fields.0 = Option::Some(value.into()); + PrivateSiteSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl PrivateSiteSummaryBuilder +where + St: private_site_summary_state::State, + St::Expired: private_site_summary_state::IsUnset, +{ + /// Set the `expired` field (required) + pub fn expired( + mut self, + value: impl Into, + ) -> PrivateSiteSummaryBuilder, S> { + self._fields.1 = Option::Some(value.into()); + PrivateSiteSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl PrivateSiteSummaryBuilder { + /// Set the `expiresAt` field (optional) + pub fn expires_at(mut self, value: impl Into>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `expiresAt` field to an Option value (optional) + pub fn maybe_expires_at(mut self, value: Option) -> Self { + self._fields.2 = value; + self + } +} + +impl PrivateSiteSummaryBuilder +where + St: private_site_summary_state::State, + St::FileCount: private_site_summary_state::IsUnset, +{ + /// Set the `fileCount` field (required) + pub fn file_count( + mut self, + value: impl Into, + ) -> PrivateSiteSummaryBuilder, S> { + self._fields.3 = Option::Some(value.into()); + PrivateSiteSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl PrivateSiteSummaryBuilder +where + St: private_site_summary_state::State, + St::Name: private_site_summary_state::IsUnset, +{ + /// Set the `name` field (required) + pub fn name( + mut self, + value: impl Into, + ) -> PrivateSiteSummaryBuilder, S> { + self._fields.4 = Option::Some(value.into()); + PrivateSiteSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl PrivateSiteSummaryBuilder +where + St: private_site_summary_state::State, + St::ShareCount: private_site_summary_state::IsUnset, +{ + /// Set the `shareCount` field (required) + pub fn share_count( + mut self, + value: impl Into, + ) -> PrivateSiteSummaryBuilder, S> { + self._fields.5 = Option::Some(value.into()); + PrivateSiteSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl PrivateSiteSummaryBuilder +where + St: private_site_summary_state::State, + St::SiteId: private_site_summary_state::IsUnset, +{ + /// Set the `siteId` field (required) + pub fn site_id( + mut self, + value: impl Into>>, + ) -> PrivateSiteSummaryBuilder, S> { + self._fields.6 = Option::Some(value.into()); + PrivateSiteSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl PrivateSiteSummaryBuilder +where + St: private_site_summary_state::State, + St::TotalBytes: private_site_summary_state::IsUnset, +{ + /// Set the `totalBytes` field (required) + pub fn total_bytes( + mut self, + value: impl Into, + ) -> PrivateSiteSummaryBuilder, S> { + self._fields.7 = Option::Some(value.into()); + PrivateSiteSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl PrivateSiteSummaryBuilder +where + St: private_site_summary_state::State, + St::CreatedAt: private_site_summary_state::IsSet, + St::Expired: private_site_summary_state::IsSet, + St::FileCount: private_site_summary_state::IsSet, + St::Name: private_site_summary_state::IsSet, + St::ShareCount: private_site_summary_state::IsSet, + St::SiteId: private_site_summary_state::IsSet, + St::TotalBytes: private_site_summary_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> PrivateSiteSummary { + PrivateSiteSummary { + created_at: self._fields.0.unwrap(), + expired: self._fields.1.unwrap(), + expires_at: self._fields.2, + file_count: self._fields.3.unwrap(), + name: self._fields.4.unwrap(), + share_count: self._fields.5.unwrap(), + site_id: self._fields.6.unwrap(), + total_bytes: self._fields.7.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> PrivateSiteSummary { + PrivateSiteSummary { + created_at: self._fields.0.unwrap(), + expired: self._fields.1.unwrap(), + expires_at: self._fields.2, + file_count: self._fields.3.unwrap(), + name: self._fields.4.unwrap(), + share_count: self._fields.5.unwrap(), + site_id: self._fields.6.unwrap(), + total_bytes: self._fields.7.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_place_wisp_v2_privateSite_list() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.v2.privateSite.list"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("main"), + LexUserType::XrpcQuery(LexXrpcQuery { + parameters: None, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("privateSiteSummary"), + LexUserType::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("siteId"), SmolStr::new_static("name"), + SmolStr::new_static("fileCount"), + SmolStr::new_static("totalBytes"), + SmolStr::new_static("createdAt"), + SmolStr::new_static("shareCount"), + SmolStr::new_static("expired") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("expired"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("expiresAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("fileCount"), + LexObjectProperty::Integer(LexInteger { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("name"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("shareCount"), + LexObjectProperty::Integer(LexInteger { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("siteId"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::RecordKey), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("totalBytes"), + LexObjectProperty::Integer(LexInteger { + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/list_shares.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/list_shares.rs new file mode 100644 index 0000000..0e6579b --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/list_shares.rs @@ -0,0 +1,804 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.privateSite.listShares +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Did, Datetime, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::v2::privateSite::list_shares; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct ListShares { + pub site_id: RecordKey>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct ListSharesOutput { + pub shares: Vec>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum ListSharesError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for ListSharesError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Share { + /// Set when this link is restricted to a single account. + #[serde(skip_serializing_if = "Option::is_none")] + pub audience_did: Option>, + pub created_at: Datetime, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revoked_at: Option, + pub share_id: S, + pub status: ShareStatus, + /// Non-secret leading fragment, for identification only. + pub token_prefix: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_share_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ShareStatus { + Active, + Expired, + Revoked, + Other(S), +} + +impl ShareStatus { + pub fn as_str(&self) -> &str { + match self { + Self::Active => "active", + Self::Expired => "expired", + Self::Revoked => "revoked", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "active" => Self::Active, + "expired" => Self::Expired, + "revoked" => Self::Revoked, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for ShareStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for ShareStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for ShareStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for ShareStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for ShareStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for ShareStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = ShareStatus; + fn into_static(self) -> Self::Output { + match self { + ShareStatus::Active => ShareStatus::Active, + ShareStatus::Expired => ShareStatus::Expired, + ShareStatus::Revoked => ShareStatus::Revoked, + ShareStatus::Other(v) => ShareStatus::Other(v.into_static()), + } + } +} + +/** Response marker for the `place.wisp.v2.privateSite.listShares` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `ListSharesOutput` for this endpoint.*/ +pub struct ListSharesResponse; +impl jacquard_common::xrpc::XrpcResp for ListSharesResponse { + const NSID: &'static str = "place.wisp.v2.privateSite.listShares"; + const ENCODING: &'static str = "application/json"; + type Output = ListSharesOutput; + type Err = ListSharesError; +} + +impl jacquard_common::xrpc::XrpcRequest for ListShares { + const NSID: &'static str = "place.wisp.v2.privateSite.listShares"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = ListSharesResponse; +} + +/** Endpoint marker for the `place.wisp.v2.privateSite.listShares` query. + +Path: `/xrpc/place.wisp.v2.privateSite.listShares`. The request payload type is `ListShares`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct ListSharesRequest; +impl jacquard_common::xrpc::XrpcEndpoint for ListSharesRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.privateSite.listShares"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = ListShares; + type Response = ListSharesResponse; +} + +impl LexiconSchema for Share { + fn nsid() -> &'static str { + "place.wisp.v2.privateSite.listShares" + } + fn def_name() -> &'static str { + "share" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_privateSite_listShares() + } + fn validate(&self) -> Result<(), ConstraintError> { + Ok(()) + } +} + +pub mod list_shares_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type SiteId; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type SiteId = Unset; + } + ///State transition - sets the `site_id` field to Set + pub struct SetSiteId(PhantomData St>); + impl sealed::Sealed for SetSiteId {} + impl State for SetSiteId { + type SiteId = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `site_id` field + pub struct site_id(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct ListSharesBuilder { + _state: PhantomData St>, + _fields: (Option>>,), + _type: PhantomData S>, +} + +impl ListShares { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> ListSharesBuilder { + ListSharesBuilder::new() + } +} + +impl ListShares { + /// Create a new builder for this type + pub fn builder() -> ListSharesBuilder { + ListSharesBuilder::builder() + } +} + +impl ListSharesBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + ListSharesBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl ListSharesBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + ListSharesBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl ListSharesBuilder +where + St: list_shares_state::State, + St::SiteId: list_shares_state::IsUnset, +{ + /// Set the `siteId` field (required) + pub fn site_id( + mut self, + value: impl Into>>, + ) -> ListSharesBuilder, S> { + self._fields.0 = Option::Some(value.into()); + ListSharesBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl ListSharesBuilder +where + St: list_shares_state::State, + St::SiteId: list_shares_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> ListShares { + ListShares { + site_id: self._fields.0.unwrap(), + } + } +} + +fn deserialize_share_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod share_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + type ShareId; + type Status; + type TokenPrefix; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + type ShareId = Unset; + type Status = Unset; + type TokenPrefix = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + type ShareId = St::ShareId; + type Status = St::Status; + type TokenPrefix = St::TokenPrefix; + } + ///State transition - sets the `share_id` field to Set + pub struct SetShareId(PhantomData St>); + impl sealed::Sealed for SetShareId {} + impl State for SetShareId { + type CreatedAt = St::CreatedAt; + type ShareId = Set; + type Status = St::Status; + type TokenPrefix = St::TokenPrefix; + } + ///State transition - sets the `status` field to Set + pub struct SetStatus(PhantomData St>); + impl sealed::Sealed for SetStatus {} + impl State for SetStatus { + type CreatedAt = St::CreatedAt; + type ShareId = St::ShareId; + type Status = Set; + type TokenPrefix = St::TokenPrefix; + } + ///State transition - sets the `token_prefix` field to Set + pub struct SetTokenPrefix(PhantomData St>); + impl sealed::Sealed for SetTokenPrefix {} + impl State for SetTokenPrefix { + type CreatedAt = St::CreatedAt; + type ShareId = St::ShareId; + type Status = St::Status; + type TokenPrefix = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `share_id` field + pub struct share_id(()); + ///Marker type for the `status` field + pub struct status(()); + ///Marker type for the `token_prefix` field + pub struct token_prefix(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct ShareBuilder { + _state: PhantomData St>, + _fields: ( + Option>, + Option, + Option, + Option, + Option, + Option, + Option, + Option>, + Option, + ), + _type: PhantomData S>, +} + +impl Share { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> ShareBuilder { + ShareBuilder::new() + } +} + +impl Share { + /// Create a new builder for this type + pub fn builder() -> ShareBuilder { + ShareBuilder::builder() + } +} + +impl ShareBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + ShareBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl ShareBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + ShareBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl ShareBuilder { + /// Set the `audienceDid` field (optional) + pub fn audience_did(mut self, value: impl Into>>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `audienceDid` field to an Option value (optional) + pub fn maybe_audience_did(mut self, value: Option>) -> Self { + self._fields.0 = value; + self + } +} + +impl ShareBuilder +where + St: share_state::State, + St::CreatedAt: share_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> ShareBuilder, S> { + self._fields.1 = Option::Some(value.into()); + ShareBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl ShareBuilder { + /// Set the `expiresAt` field (optional) + pub fn expires_at(mut self, value: impl Into>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `expiresAt` field to an Option value (optional) + pub fn maybe_expires_at(mut self, value: Option) -> Self { + self._fields.2 = value; + self + } +} + +impl ShareBuilder { + /// Set the `label` field (optional) + pub fn label(mut self, value: impl Into>) -> Self { + self._fields.3 = value.into(); + self + } + /// Set the `label` field to an Option value (optional) + pub fn maybe_label(mut self, value: Option) -> Self { + self._fields.3 = value; + self + } +} + +impl ShareBuilder { + /// Set the `lastUsedAt` field (optional) + pub fn last_used_at(mut self, value: impl Into>) -> Self { + self._fields.4 = value.into(); + self + } + /// Set the `lastUsedAt` field to an Option value (optional) + pub fn maybe_last_used_at(mut self, value: Option) -> Self { + self._fields.4 = value; + self + } +} + +impl ShareBuilder { + /// Set the `revokedAt` field (optional) + pub fn revoked_at(mut self, value: impl Into>) -> Self { + self._fields.5 = value.into(); + self + } + /// Set the `revokedAt` field to an Option value (optional) + pub fn maybe_revoked_at(mut self, value: Option) -> Self { + self._fields.5 = value; + self + } +} + +impl ShareBuilder +where + St: share_state::State, + St::ShareId: share_state::IsUnset, +{ + /// Set the `shareId` field (required) + pub fn share_id( + mut self, + value: impl Into, + ) -> ShareBuilder, S> { + self._fields.6 = Option::Some(value.into()); + ShareBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl ShareBuilder +where + St: share_state::State, + St::Status: share_state::IsUnset, +{ + /// Set the `status` field (required) + pub fn status( + mut self, + value: impl Into>, + ) -> ShareBuilder, S> { + self._fields.7 = Option::Some(value.into()); + ShareBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl ShareBuilder +where + St: share_state::State, + St::TokenPrefix: share_state::IsUnset, +{ + /// Set the `tokenPrefix` field (required) + pub fn token_prefix( + mut self, + value: impl Into, + ) -> ShareBuilder, S> { + self._fields.8 = Option::Some(value.into()); + ShareBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl ShareBuilder +where + St: share_state::State, + St::CreatedAt: share_state::IsSet, + St::ShareId: share_state::IsSet, + St::Status: share_state::IsSet, + St::TokenPrefix: share_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Share { + Share { + audience_did: self._fields.0, + created_at: self._fields.1.unwrap(), + expires_at: self._fields.2, + label: self._fields.3, + last_used_at: self._fields.4, + revoked_at: self._fields.5, + share_id: self._fields.6.unwrap(), + status: self._fields.7.unwrap(), + token_prefix: self._fields.8.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Share { + Share { + audience_did: self._fields.0, + created_at: self._fields.1.unwrap(), + expires_at: self._fields.2, + label: self._fields.3, + last_used_at: self._fields.4, + revoked_at: self._fields.5, + share_id: self._fields.6.unwrap(), + status: self._fields.7.unwrap(), + token_prefix: self._fields.8.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_place_wisp_v2_privateSite_listShares() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.v2.privateSite.listShares"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("main"), + LexUserType::XrpcQuery(LexXrpcQuery { + parameters: Some( + LexXrpcQueryParameter::Params(LexXrpcParameters { + required: Some(vec![SmolStr::new_static("siteId")]), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("siteId"), + LexXrpcParametersProperty::String(LexString { + format: Some(LexStringFormat::RecordKey), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("share"), + LexUserType::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("shareId"), + SmolStr::new_static("tokenPrefix"), + SmolStr::new_static("createdAt"), + SmolStr::new_static("status") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("audienceDid"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Set when this link is restricted to a single account.", + ), + ), + format: Some(LexStringFormat::Did), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("expiresAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("label"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("lastUsedAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("revokedAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("shareId"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("status"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("tokenPrefix"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Non-secret leading fragment, for identification only.", + ), + ), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/revoke_share.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/revoke_share.rs new file mode 100644 index 0000000..9aaa242 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/privateSite/revoke_share.rs @@ -0,0 +1,288 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.privateSite.revokeShare +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct RevokeShare { + pub share_id: S, + pub site_id: RecordKey>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct RevokeShareOutput { + pub revoked: bool, + pub share_id: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum RevokeShareError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for RevokeShareError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.privateSite.revokeShare` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `RevokeShareOutput` for this endpoint.*/ +pub struct RevokeShareResponse; +impl jacquard_common::xrpc::XrpcResp for RevokeShareResponse { + const NSID: &'static str = "place.wisp.v2.privateSite.revokeShare"; + const ENCODING: &'static str = "application/json"; + type Output = RevokeShareOutput; + type Err = RevokeShareError; +} + +impl jacquard_common::xrpc::XrpcRequest for RevokeShare { + const NSID: &'static str = "place.wisp.v2.privateSite.revokeShare"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = RevokeShareResponse; +} + +/** Endpoint marker for the `place.wisp.v2.privateSite.revokeShare` procedure. + +Path: `/xrpc/place.wisp.v2.privateSite.revokeShare`. The request payload type is `RevokeShare`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct RevokeShareRequest; +impl jacquard_common::xrpc::XrpcEndpoint for RevokeShareRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.privateSite.revokeShare"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = RevokeShare; + type Response = RevokeShareResponse; +} + +pub mod revoke_share_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type ShareId; + type SiteId; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type ShareId = Unset; + type SiteId = Unset; + } + ///State transition - sets the `share_id` field to Set + pub struct SetShareId(PhantomData St>); + impl sealed::Sealed for SetShareId {} + impl State for SetShareId { + type ShareId = Set; + type SiteId = St::SiteId; + } + ///State transition - sets the `site_id` field to Set + pub struct SetSiteId(PhantomData St>); + impl sealed::Sealed for SetSiteId {} + impl State for SetSiteId { + type ShareId = St::ShareId; + type SiteId = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `share_id` field + pub struct share_id(()); + ///Marker type for the `site_id` field + pub struct site_id(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct RevokeShareBuilder { + _state: PhantomData St>, + _fields: (Option, Option>>), + _type: PhantomData S>, +} + +impl RevokeShare { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> RevokeShareBuilder { + RevokeShareBuilder::new() + } +} + +impl RevokeShare { + /// Create a new builder for this type + pub fn builder() -> RevokeShareBuilder { + RevokeShareBuilder::builder() + } +} + +impl RevokeShareBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + RevokeShareBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl RevokeShareBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + RevokeShareBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl RevokeShareBuilder +where + St: revoke_share_state::State, + St::ShareId: revoke_share_state::IsUnset, +{ + /// Set the `shareId` field (required) + pub fn share_id( + mut self, + value: impl Into, + ) -> RevokeShareBuilder, S> { + self._fields.0 = Option::Some(value.into()); + RevokeShareBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl RevokeShareBuilder +where + St: revoke_share_state::State, + St::SiteId: revoke_share_state::IsUnset, +{ + /// Set the `siteId` field (required) + pub fn site_id( + mut self, + value: impl Into>>, + ) -> RevokeShareBuilder, S> { + self._fields.1 = Option::Some(value.into()); + RevokeShareBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl RevokeShareBuilder +where + St: revoke_share_state::State, + St::ShareId: revoke_share_state::IsSet, + St::SiteId: revoke_share_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> RevokeShare { + RevokeShare { + share_id: self._fields.0.unwrap(), + site_id: self._fields.1.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> RevokeShare { + RevokeShare { + share_id: self._fields.0.unwrap(), + site_id: self._fields.1.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret.rs new file mode 100644 index 0000000..2fb7626 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret.rs @@ -0,0 +1,10 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +//! Generated bindings for the `place.wisp.v2.secret` Lexicon namespace/module. +pub mod create; +pub mod delete; +pub mod list; +pub mod rotate; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/create.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/create.rs new file mode 100644 index 0000000..01ba5fe --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/create.rs @@ -0,0 +1,271 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.secret.create +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Datetime, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Create { + /// Unique 1–64 character secret ID scoped to the caller DID. Use ASCII letters, digits, dots, underscores, and hyphens only. + pub name: RecordKey>, + /// Optional caller-supplied signing token: 32–256 printable ASCII characters, no whitespace. Omit it to have the server generate one. Use this to register a secret the receiving system already generated. + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct CreateOutput { + pub created_at: Datetime, + pub name: S, + /// The signing token. Only returned at creation time — store it now. + pub token: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum CreateError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "AlreadyExists")] + AlreadyExists(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for CreateError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::AlreadyExists(msg) => { + write!(f, "AlreadyExists")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.secret.create` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `CreateOutput` for this endpoint.*/ +pub struct CreateResponse; +impl jacquard_common::xrpc::XrpcResp for CreateResponse { + const NSID: &'static str = "place.wisp.v2.secret.create"; + const ENCODING: &'static str = "application/json"; + type Output = CreateOutput; + type Err = CreateError; +} + +impl jacquard_common::xrpc::XrpcRequest for Create { + const NSID: &'static str = "place.wisp.v2.secret.create"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = CreateResponse; +} + +/** Endpoint marker for the `place.wisp.v2.secret.create` procedure. + +Path: `/xrpc/place.wisp.v2.secret.create`. The request payload type is `Create`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct CreateRequest; +impl jacquard_common::xrpc::XrpcEndpoint for CreateRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.secret.create"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = Create; + type Response = CreateResponse; +} + +pub mod create_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Name; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Name = Unset; + } + ///State transition - sets the `name` field to Set + pub struct SetName(PhantomData St>); + impl sealed::Sealed for SetName {} + impl State for SetName { + type Name = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `name` field + pub struct name(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct CreateBuilder { + _state: PhantomData St>, + _fields: (Option>>, Option), + _type: PhantomData S>, +} + +impl Create { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> CreateBuilder { + CreateBuilder::new() + } +} + +impl Create { + /// Create a new builder for this type + pub fn builder() -> CreateBuilder { + CreateBuilder::builder() + } +} + +impl CreateBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + CreateBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl CreateBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + CreateBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl CreateBuilder +where + St: create_state::State, + St::Name: create_state::IsUnset, +{ + /// Set the `name` field (required) + pub fn name( + mut self, + value: impl Into>>, + ) -> CreateBuilder, S> { + self._fields.0 = Option::Some(value.into()); + CreateBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl CreateBuilder { + /// Set the `token` field (optional) + pub fn token(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `token` field to an Option value (optional) + pub fn maybe_token(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl CreateBuilder +where + St: create_state::State, + St::Name: create_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Create { + Create { + name: self._fields.0.unwrap(), + token: self._fields.1, + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Create { + Create { + name: self._fields.0.unwrap(), + token: self._fields.1, + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/delete.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/delete.rs new file mode 100644 index 0000000..848fb66 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/delete.rs @@ -0,0 +1,253 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.secret.delete +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Delete { + /// 1–64 character server-managed secret ID. Use ASCII letters, digits, dots, underscores, and hyphens only. + pub name: RecordKey>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum DeleteError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for DeleteError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.secret.delete` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `()` for this endpoint.*/ +pub struct DeleteResponse; +impl jacquard_common::xrpc::XrpcResp for DeleteResponse { + const NSID: &'static str = "place.wisp.v2.secret.delete"; + const ENCODING: &'static str = "application/json"; + type Output = (); + type Err = DeleteError; + fn decode_output<'de, S>( + body: &'de [u8], + ) -> Result, jacquard_common::error::DecodeError> + where + S: BosStr + Deserialize<'de>, + { + if body.is_empty() { + Ok(()) + } else { + Err(jacquard_common::error::DecodeError::UnexpectedBody) + } + } +} + +impl jacquard_common::xrpc::XrpcRequest for Delete { + const NSID: &'static str = "place.wisp.v2.secret.delete"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = DeleteResponse; +} + +/** Endpoint marker for the `place.wisp.v2.secret.delete` procedure. + +Path: `/xrpc/place.wisp.v2.secret.delete`. The request payload type is `Delete`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct DeleteRequest; +impl jacquard_common::xrpc::XrpcEndpoint for DeleteRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.secret.delete"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = Delete; + type Response = DeleteResponse; +} + +pub mod delete_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Name; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Name = Unset; + } + ///State transition - sets the `name` field to Set + pub struct SetName(PhantomData St>); + impl sealed::Sealed for SetName {} + impl State for SetName { + type Name = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `name` field + pub struct name(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct DeleteBuilder { + _state: PhantomData St>, + _fields: (Option>>,), + _type: PhantomData S>, +} + +impl Delete { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> DeleteBuilder { + DeleteBuilder::new() + } +} + +impl Delete { + /// Create a new builder for this type + pub fn builder() -> DeleteBuilder { + DeleteBuilder::builder() + } +} + +impl DeleteBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + DeleteBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl DeleteBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + DeleteBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl DeleteBuilder +where + St: delete_state::State, + St::Name: delete_state::IsUnset, +{ + /// Set the `name` field (required) + pub fn name( + mut self, + value: impl Into>>, + ) -> DeleteBuilder, S> { + self._fields.0 = Option::Some(value.into()); + DeleteBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DeleteBuilder +where + St: delete_state::State, + St::Name: delete_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Delete { + Delete { + name: self._fields.0.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Delete { + Delete { + name: self._fields.0.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/list.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/list.rs new file mode 100644 index 0000000..6d0ec07 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/list.rs @@ -0,0 +1,393 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.secret.list +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::Datetime; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::v2::secret::list; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct ListOutput { + pub secrets: Vec>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum ListError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for ListError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct SecretMeta { + pub created_at: Datetime, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_rotated_at: Option, + pub name: S, + #[serde( + flatten, + default, + deserialize_with = "deserialize_secret_meta_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/** Request marker for the `place.wisp.v2.secret.list` query. + +This endpoint has no request parameters or input body; send this marker with `jacquard::Client`.*/ + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Copy)] +pub struct List; +/** Response marker for the `place.wisp.v2.secret.list` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `ListOutput` for this endpoint.*/ +pub struct ListResponse; +impl jacquard_common::xrpc::XrpcResp for ListResponse { + const NSID: &'static str = "place.wisp.v2.secret.list"; + const ENCODING: &'static str = "application/json"; + type Output = ListOutput; + type Err = ListError; +} + +impl jacquard_common::xrpc::XrpcRequest for List { + const NSID: &'static str = "place.wisp.v2.secret.list"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = ListResponse; + fn encode_body( + &self, + _buffer: &mut Vec, + ) -> Result<(), jacquard_common::xrpc::EncodeError> { + Ok(()) + } +} + +/** Endpoint marker for the `place.wisp.v2.secret.list` query. + +Path: `/xrpc/place.wisp.v2.secret.list`. The request payload type is `List`; use this marker with lower-level `XrpcEndpoint` APIs when you need endpoint metadata.*/ +pub struct ListRequest; +impl jacquard_common::xrpc::XrpcEndpoint for ListRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.secret.list"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = List; + type Response = ListResponse; +} + +impl LexiconSchema for SecretMeta { + fn nsid() -> &'static str { + "place.wisp.v2.secret.list" + } + fn def_name() -> &'static str { + "secretMeta" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_secret_list() + } + fn validate(&self) -> Result<(), ConstraintError> { + Ok(()) + } +} + +fn deserialize_secret_meta_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod secret_meta_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + type Name; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + type Name = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + type Name = St::Name; + } + ///State transition - sets the `name` field to Set + pub struct SetName(PhantomData St>); + impl sealed::Sealed for SetName {} + impl State for SetName { + type CreatedAt = St::CreatedAt; + type Name = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `name` field + pub struct name(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct SecretMetaBuilder { + _state: PhantomData St>, + _fields: (Option, Option, Option), + _type: PhantomData S>, +} + +impl SecretMeta { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SecretMetaBuilder { + SecretMetaBuilder::new() + } +} + +impl SecretMeta { + /// Create a new builder for this type + pub fn builder() -> SecretMetaBuilder { + SecretMetaBuilder::builder() + } +} + +impl SecretMetaBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SecretMetaBuilder { + _state: PhantomData, + _fields: (None, None, None), + _type: PhantomData, + } + } +} + +impl SecretMetaBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SecretMetaBuilder { + _state: PhantomData, + _fields: (None, None, None), + _type: PhantomData, + } + } +} + +impl SecretMetaBuilder +where + St: secret_meta_state::State, + St::CreatedAt: secret_meta_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> SecretMetaBuilder, S> { + self._fields.0 = Option::Some(value.into()); + SecretMetaBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SecretMetaBuilder { + /// Set the `lastRotatedAt` field (optional) + pub fn last_rotated_at(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `lastRotatedAt` field to an Option value (optional) + pub fn maybe_last_rotated_at(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl SecretMetaBuilder +where + St: secret_meta_state::State, + St::Name: secret_meta_state::IsUnset, +{ + /// Set the `name` field (required) + pub fn name( + mut self, + value: impl Into, + ) -> SecretMetaBuilder, S> { + self._fields.2 = Option::Some(value.into()); + SecretMetaBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SecretMetaBuilder +where + St: secret_meta_state::State, + St::CreatedAt: secret_meta_state::IsSet, + St::Name: secret_meta_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> SecretMeta { + SecretMeta { + created_at: self._fields.0.unwrap(), + last_rotated_at: self._fields.1, + name: self._fields.2.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> SecretMeta { + SecretMeta { + created_at: self._fields.0.unwrap(), + last_rotated_at: self._fields.1, + name: self._fields.2.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_place_wisp_v2_secret_list() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.v2.secret.list"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("main"), + LexUserType::XrpcQuery(LexXrpcQuery { + parameters: None, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("secretMeta"), + LexUserType::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("name"), SmolStr::new_static("createdAt") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("lastRotatedAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("name"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map + }, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/rotate.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/rotate.rs new file mode 100644 index 0000000..ddaf562 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/secret/rotate.rs @@ -0,0 +1,271 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.secret.rotate +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Datetime, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use serde::{Serialize, Deserialize}; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Rotate { + /// 1–64 character server-managed secret ID. Use ASCII letters, digits, dots, underscores, and hyphens only. + pub name: RecordKey>, + /// Optional caller-supplied signing token: 32–256 printable ASCII characters, no whitespace. Omit it to have the server generate one. + #[serde(skip_serializing_if = "Option::is_none")] + pub token: Option, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct RotateOutput { + pub name: S, + pub rotated_at: Datetime, + /// The new signing token. Only returned here — store it now. + pub token: S, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum RotateError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for RotateError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.secret.rotate` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `RotateOutput` for this endpoint.*/ +pub struct RotateResponse; +impl jacquard_common::xrpc::XrpcResp for RotateResponse { + const NSID: &'static str = "place.wisp.v2.secret.rotate"; + const ENCODING: &'static str = "application/json"; + type Output = RotateOutput; + type Err = RotateError; +} + +impl jacquard_common::xrpc::XrpcRequest for Rotate { + const NSID: &'static str = "place.wisp.v2.secret.rotate"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = RotateResponse; +} + +/** Endpoint marker for the `place.wisp.v2.secret.rotate` procedure. + +Path: `/xrpc/place.wisp.v2.secret.rotate`. The request payload type is `Rotate`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct RotateRequest; +impl jacquard_common::xrpc::XrpcEndpoint for RotateRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.secret.rotate"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = Rotate; + type Response = RotateResponse; +} + +pub mod rotate_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Name; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Name = Unset; + } + ///State transition - sets the `name` field to Set + pub struct SetName(PhantomData St>); + impl sealed::Sealed for SetName {} + impl State for SetName { + type Name = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `name` field + pub struct name(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct RotateBuilder { + _state: PhantomData St>, + _fields: (Option>>, Option), + _type: PhantomData S>, +} + +impl Rotate { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> RotateBuilder { + RotateBuilder::new() + } +} + +impl Rotate { + /// Create a new builder for this type + pub fn builder() -> RotateBuilder { + RotateBuilder::builder() + } +} + +impl RotateBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + RotateBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl RotateBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + RotateBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl RotateBuilder +where + St: rotate_state::State, + St::Name: rotate_state::IsUnset, +{ + /// Set the `name` field (required) + pub fn name( + mut self, + value: impl Into>>, + ) -> RotateBuilder, S> { + self._fields.0 = Option::Some(value.into()); + RotateBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl RotateBuilder { + /// Set the `token` field (optional) + pub fn token(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `token` field to an Option value (optional) + pub fn maybe_token(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl RotateBuilder +where + St: rotate_state::State, + St::Name: rotate_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Rotate { + Rotate { + name: self._fields.0.unwrap(), + token: self._fields.1, + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Rotate { + Rotate { + name: self._fields.0.unwrap(), + token: self._fields.1, + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site.rs new file mode 100644 index 0000000..0224064 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site.rs @@ -0,0 +1,9 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +//! Generated bindings for the `place.wisp.v2.site` Lexicon namespace/module. +pub mod delete; +pub mod get_domains; +pub mod get_list; \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site/delete.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site/delete.rs new file mode 100644 index 0000000..9541893 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site/delete.rs @@ -0,0 +1,570 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.site.delete +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::v2::site::delete; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct Delete { + /// Owned place.wisp.fs record key to delete from wisp metadata. + pub site_rkey: RecordKey>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct DeleteOutput { + pub deleted: bool, + pub site_rkey: RecordKey>, + /// Domains that were detached from this site before deletion. + pub unmapped_domains: Vec>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum DeleteError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + #[serde(rename = "InvalidRequest")] + InvalidRequest(#[serde(skip_serializing_if = "Option::is_none")] Option), + #[serde(rename = "NotFound")] + NotFound(#[serde(skip_serializing_if = "Option::is_none")] Option), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for DeleteError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::InvalidRequest(msg) => { + write!(f, "InvalidRequest")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::NotFound(msg) => { + write!(f, "NotFound")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct UnmappedDomain { + pub domain: S, + pub kind: UnmappedDomainKind, + pub status: UnmappedDomainStatus, + #[serde( + flatten, + default, + deserialize_with = "deserialize_unmapped_domain_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum UnmappedDomainKind { + Wisp, + Custom, + Other(S), +} + +impl UnmappedDomainKind { + pub fn as_str(&self) -> &str { + match self { + Self::Wisp => "wisp", + Self::Custom => "custom", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "wisp" => Self::Wisp, + "custom" => Self::Custom, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for UnmappedDomainKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for UnmappedDomainKind { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for UnmappedDomainKind { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for UnmappedDomainKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for UnmappedDomainKind { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for UnmappedDomainKind +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = UnmappedDomainKind; + fn into_static(self) -> Self::Output { + match self { + UnmappedDomainKind::Wisp => UnmappedDomainKind::Wisp, + UnmappedDomainKind::Custom => UnmappedDomainKind::Custom, + UnmappedDomainKind::Other(v) => UnmappedDomainKind::Other(v.into_static()), + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum UnmappedDomainStatus { + PendingVerification, + Verified, + Other(S), +} + +impl UnmappedDomainStatus { + pub fn as_str(&self) -> &str { + match self { + Self::PendingVerification => "pendingVerification", + Self::Verified => "verified", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "pendingVerification" => Self::PendingVerification, + "verified" => Self::Verified, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for UnmappedDomainStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for UnmappedDomainStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for UnmappedDomainStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for UnmappedDomainStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for UnmappedDomainStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for UnmappedDomainStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = UnmappedDomainStatus; + fn into_static(self) -> Self::Output { + match self { + UnmappedDomainStatus::PendingVerification => { + UnmappedDomainStatus::PendingVerification + } + UnmappedDomainStatus::Verified => UnmappedDomainStatus::Verified, + UnmappedDomainStatus::Other(v) => { + UnmappedDomainStatus::Other(v.into_static()) + } + } + } +} + +/** Response marker for the `place.wisp.v2.site.delete` procedure. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `DeleteOutput` for this endpoint.*/ +pub struct DeleteResponse; +impl jacquard_common::xrpc::XrpcResp for DeleteResponse { + const NSID: &'static str = "place.wisp.v2.site.delete"; + const ENCODING: &'static str = "application/json"; + type Output = DeleteOutput; + type Err = DeleteError; +} + +impl jacquard_common::xrpc::XrpcRequest for Delete { + const NSID: &'static str = "place.wisp.v2.site.delete"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Response = DeleteResponse; +} + +/** Endpoint marker for the `place.wisp.v2.site.delete` procedure. + +Path: `/xrpc/place.wisp.v2.site.delete`. The request payload type is `Delete`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct DeleteRequest; +impl jacquard_common::xrpc::XrpcEndpoint for DeleteRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.site.delete"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Procedure( + "application/json", + ); + type Request = Delete; + type Response = DeleteResponse; +} + +impl LexiconSchema for UnmappedDomain { + fn nsid() -> &'static str { + "place.wisp.v2.site.delete" + } + fn def_name() -> &'static str { + "unmappedDomain" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_site_delete() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 253usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("domain"), + max: 253usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) < 3usize { + return Err(ConstraintError::MinLength { + path: ValidationPath::from_field("domain"), + min: 3usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +pub mod delete_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type SiteRkey; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type SiteRkey = Unset; + } + ///State transition - sets the `site_rkey` field to Set + pub struct SetSiteRkey(PhantomData St>); + impl sealed::Sealed for SetSiteRkey {} + impl State for SetSiteRkey { + type SiteRkey = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `site_rkey` field + pub struct site_rkey(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct DeleteBuilder { + _state: PhantomData St>, + _fields: (Option>>,), + _type: PhantomData S>, +} + +impl Delete { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> DeleteBuilder { + DeleteBuilder::new() + } +} + +impl Delete { + /// Create a new builder for this type + pub fn builder() -> DeleteBuilder { + DeleteBuilder::builder() + } +} + +impl DeleteBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + DeleteBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl DeleteBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + DeleteBuilder { + _state: PhantomData, + _fields: (None,), + _type: PhantomData, + } + } +} + +impl DeleteBuilder +where + St: delete_state::State, + St::SiteRkey: delete_state::IsUnset, +{ + /// Set the `siteRkey` field (required) + pub fn site_rkey( + mut self, + value: impl Into>>, + ) -> DeleteBuilder, S> { + self._fields.0 = Option::Some(value.into()); + DeleteBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl DeleteBuilder +where + St: delete_state::State, + St::SiteRkey: delete_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Delete { + Delete { + site_rkey: self._fields.0.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Delete { + Delete { + site_rkey: self._fields.0.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn deserialize_unmapped_domain_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +fn lexicon_doc_place_wisp_v2_site_delete() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.v2.site.delete"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("main"), + LexUserType::XrpcProcedure(LexXrpcProcedure { + input: Some(LexXrpcBody { + encoding: CowStr::new_static("application/json"), + schema: Some( + LexXrpcBodySchema::Object(LexObject { + required: Some(vec![SmolStr::new_static("siteRkey")]), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("siteRkey"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Owned place.wisp.fs record key to delete from wisp metadata.", + ), + ), + format: Some(LexStringFormat::RecordKey), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ), + ..Default::default() + }), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("unmappedDomain"), + LexUserType::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("domain"), SmolStr::new_static("kind"), + SmolStr::new_static("status") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("domain"), + LexObjectProperty::String(LexString { + min_length: Some(3usize), + max_length: Some(253usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("kind"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("status"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map + }, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site/get_domains.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site/get_domains.rs new file mode 100644 index 0000000..f9eea0b --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site/get_domains.rs @@ -0,0 +1,758 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.site.getDomains +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Did, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::IntoStatic; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::v2::site::get_domains; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct GetDomains { + pub did: Did, + pub rkey: RecordKey>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct GetDomainsOutput { + pub domains: Vec>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct SiteDomain { + pub domain: S, + pub kind: SiteDomainKind, + pub status: SiteDomainStatus, + pub verified: bool, + #[serde( + flatten, + default, + deserialize_with = "deserialize_site_domain_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SiteDomainKind { + Wisp, + Custom, + Other(S), +} + +impl SiteDomainKind { + pub fn as_str(&self) -> &str { + match self { + Self::Wisp => "wisp", + Self::Custom => "custom", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "wisp" => Self::Wisp, + "custom" => Self::Custom, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for SiteDomainKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for SiteDomainKind { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for SiteDomainKind { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for SiteDomainKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for SiteDomainKind { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for SiteDomainKind +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = SiteDomainKind; + fn into_static(self) -> Self::Output { + match self { + SiteDomainKind::Wisp => SiteDomainKind::Wisp, + SiteDomainKind::Custom => SiteDomainKind::Custom, + SiteDomainKind::Other(v) => SiteDomainKind::Other(v.into_static()), + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SiteDomainStatus { + PendingVerification, + Verified, + Other(S), +} + +impl SiteDomainStatus { + pub fn as_str(&self) -> &str { + match self { + Self::PendingVerification => "pendingVerification", + Self::Verified => "verified", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "pendingVerification" => Self::PendingVerification, + "verified" => Self::Verified, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for SiteDomainStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for SiteDomainStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for SiteDomainStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for SiteDomainStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for SiteDomainStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for SiteDomainStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = SiteDomainStatus; + fn into_static(self) -> Self::Output { + match self { + SiteDomainStatus::PendingVerification => { + SiteDomainStatus::PendingVerification + } + SiteDomainStatus::Verified => SiteDomainStatus::Verified, + SiteDomainStatus::Other(v) => SiteDomainStatus::Other(v.into_static()), + } + } +} + +/** Response marker for the `place.wisp.v2.site.getDomains` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `GetDomainsOutput` for this endpoint.*/ +pub struct GetDomainsResponse; +impl jacquard_common::xrpc::XrpcResp for GetDomainsResponse { + const NSID: &'static str = "place.wisp.v2.site.getDomains"; + const ENCODING: &'static str = "application/json"; + type Output = GetDomainsOutput; + type Err = jacquard_common::xrpc::GenericError; +} + +impl jacquard_common::xrpc::XrpcRequest for GetDomains { + const NSID: &'static str = "place.wisp.v2.site.getDomains"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = GetDomainsResponse; +} + +/** Endpoint marker for the `place.wisp.v2.site.getDomains` query. + +Path: `/xrpc/place.wisp.v2.site.getDomains`. The request payload type is `GetDomains`; send that request with `jacquard::Client` or use this marker through lower-level `XrpcEndpoint` APIs.*/ +pub struct GetDomainsRequest; +impl jacquard_common::xrpc::XrpcEndpoint for GetDomainsRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.site.getDomains"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = GetDomains; + type Response = GetDomainsResponse; +} + +impl LexiconSchema for SiteDomain { + fn nsid() -> &'static str { + "place.wisp.v2.site.getDomains" + } + fn def_name() -> &'static str { + "siteDomain" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_site_getDomains() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 253usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("domain"), + max: 253usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) < 3usize { + return Err(ConstraintError::MinLength { + path: ValidationPath::from_field("domain"), + min: 3usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +pub mod get_domains_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Did; + type Rkey; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Did = Unset; + type Rkey = Unset; + } + ///State transition - sets the `did` field to Set + pub struct SetDid(PhantomData St>); + impl sealed::Sealed for SetDid {} + impl State for SetDid { + type Did = Set; + type Rkey = St::Rkey; + } + ///State transition - sets the `rkey` field to Set + pub struct SetRkey(PhantomData St>); + impl sealed::Sealed for SetRkey {} + impl State for SetRkey { + type Did = St::Did; + type Rkey = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `did` field + pub struct did(()); + ///Marker type for the `rkey` field + pub struct rkey(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct GetDomainsBuilder { + _state: PhantomData St>, + _fields: (Option>, Option>>), + _type: PhantomData S>, +} + +impl GetDomains { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> GetDomainsBuilder { + GetDomainsBuilder::new() + } +} + +impl GetDomains { + /// Create a new builder for this type + pub fn builder() -> GetDomainsBuilder { + GetDomainsBuilder::builder() + } +} + +impl GetDomainsBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + GetDomainsBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl GetDomainsBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + GetDomainsBuilder { + _state: PhantomData, + _fields: (None, None), + _type: PhantomData, + } + } +} + +impl GetDomainsBuilder +where + St: get_domains_state::State, + St::Did: get_domains_state::IsUnset, +{ + /// Set the `did` field (required) + pub fn did( + mut self, + value: impl Into>, + ) -> GetDomainsBuilder, S> { + self._fields.0 = Option::Some(value.into()); + GetDomainsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl GetDomainsBuilder +where + St: get_domains_state::State, + St::Rkey: get_domains_state::IsUnset, +{ + /// Set the `rkey` field (required) + pub fn rkey( + mut self, + value: impl Into>>, + ) -> GetDomainsBuilder, S> { + self._fields.1 = Option::Some(value.into()); + GetDomainsBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl GetDomainsBuilder +where + St: get_domains_state::State, + St::Did: get_domains_state::IsSet, + St::Rkey: get_domains_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> GetDomains { + GetDomains { + did: self._fields.0.unwrap(), + rkey: self._fields.1.unwrap(), + } + } +} + +fn deserialize_site_domain_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod site_domain_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Domain; + type Kind; + type Status; + type Verified; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Domain = Unset; + type Kind = Unset; + type Status = Unset; + type Verified = Unset; + } + ///State transition - sets the `domain` field to Set + pub struct SetDomain(PhantomData St>); + impl sealed::Sealed for SetDomain {} + impl State for SetDomain { + type Domain = Set; + type Kind = St::Kind; + type Status = St::Status; + type Verified = St::Verified; + } + ///State transition - sets the `kind` field to Set + pub struct SetKind(PhantomData St>); + impl sealed::Sealed for SetKind {} + impl State for SetKind { + type Domain = St::Domain; + type Kind = Set; + type Status = St::Status; + type Verified = St::Verified; + } + ///State transition - sets the `status` field to Set + pub struct SetStatus(PhantomData St>); + impl sealed::Sealed for SetStatus {} + impl State for SetStatus { + type Domain = St::Domain; + type Kind = St::Kind; + type Status = Set; + type Verified = St::Verified; + } + ///State transition - sets the `verified` field to Set + pub struct SetVerified(PhantomData St>); + impl sealed::Sealed for SetVerified {} + impl State for SetVerified { + type Domain = St::Domain; + type Kind = St::Kind; + type Status = St::Status; + type Verified = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `domain` field + pub struct domain(()); + ///Marker type for the `kind` field + pub struct kind(()); + ///Marker type for the `status` field + pub struct status(()); + ///Marker type for the `verified` field + pub struct verified(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct SiteDomainBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option>, + Option>, + Option, + ), + _type: PhantomData S>, +} + +impl SiteDomain { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SiteDomainBuilder { + SiteDomainBuilder::new() + } +} + +impl SiteDomain { + /// Create a new builder for this type + pub fn builder() -> SiteDomainBuilder { + SiteDomainBuilder::builder() + } +} + +impl SiteDomainBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SiteDomainBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SiteDomainBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Domain: site_domain_state::IsUnset, +{ + /// Set the `domain` field (required) + pub fn domain( + mut self, + value: impl Into, + ) -> SiteDomainBuilder, S> { + self._fields.0 = Option::Some(value.into()); + SiteDomainBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Kind: site_domain_state::IsUnset, +{ + /// Set the `kind` field (required) + pub fn kind( + mut self, + value: impl Into>, + ) -> SiteDomainBuilder, S> { + self._fields.1 = Option::Some(value.into()); + SiteDomainBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Status: site_domain_state::IsUnset, +{ + /// Set the `status` field (required) + pub fn status( + mut self, + value: impl Into>, + ) -> SiteDomainBuilder, S> { + self._fields.2 = Option::Some(value.into()); + SiteDomainBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Verified: site_domain_state::IsUnset, +{ + /// Set the `verified` field (required) + pub fn verified( + mut self, + value: impl Into, + ) -> SiteDomainBuilder, S> { + self._fields.3 = Option::Some(value.into()); + SiteDomainBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Domain: site_domain_state::IsSet, + St::Kind: site_domain_state::IsSet, + St::Status: site_domain_state::IsSet, + St::Verified: site_domain_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> SiteDomain { + SiteDomain { + domain: self._fields.0.unwrap(), + kind: self._fields.1.unwrap(), + status: self._fields.2.unwrap(), + verified: self._fields.3.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> SiteDomain { + SiteDomain { + domain: self._fields.0.unwrap(), + kind: self._fields.1.unwrap(), + status: self._fields.2.unwrap(), + verified: self._fields.3.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_place_wisp_v2_site_getDomains() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.v2.site.getDomains"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("main"), + LexUserType::XrpcQuery(LexXrpcQuery { + parameters: Some( + LexXrpcQueryParameter::Params(LexXrpcParameters { + required: Some( + vec![ + SmolStr::new_static("did"), SmolStr::new_static("rkey") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("did"), + LexXrpcParametersProperty::String(LexString { + format: Some(LexStringFormat::Did), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("rkey"), + LexXrpcParametersProperty::String(LexString { + format: Some(LexStringFormat::RecordKey), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("siteDomain"), + LexUserType::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("domain"), SmolStr::new_static("kind"), + SmolStr::new_static("status"), + SmolStr::new_static("verified") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("domain"), + LexObjectProperty::String(LexString { + min_length: Some(3usize), + max_length: Some(253usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("kind"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("status"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("verified"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site/get_list.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site/get_list.rs new file mode 100644 index 0000000..522a8a0 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/site/get_list.rs @@ -0,0 +1,960 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.site.getList +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::string::{Datetime, RecordKey, Rkey}; +use jacquard_common::types::value::Data; +use jacquard_derive::{IntoStatic, open_union}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::v2::site::get_list; + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct GetListOutput { + pub sites: Vec>, + #[serde(flatten, default, skip_serializing_if = "Option::is_none")] + pub extra_data: Option>>, +} + + +#[derive( + Serialize, + Deserialize, + Debug, + Clone, + PartialEq, + Eq, + thiserror::Error, + miette::Diagnostic +)] + +#[serde(tag = "error", content = "message")] +pub enum GetListError { + #[serde(rename = "AuthenticationRequired")] + AuthenticationRequired( + #[serde(skip_serializing_if = "Option::is_none")] + Option, + ), + /// Catch-all for unknown error codes. + #[serde(untagged)] + Other { + error: SmolStr, + #[serde(skip_serializing_if = "Option::is_none")] + message: Option, + }, +} + +impl core::fmt::Display for GetListError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::AuthenticationRequired(msg) => { + write!(f, "AuthenticationRequired")?; + if let Some(msg) = msg { + write!(f, ": {}", msg)?; + } + Ok(()) + } + Self::Other { error, message } => { + write!(f, "{}", error)?; + if let Some(msg) = message { + write!(f, ": {}", msg)?; + } + Ok(()) + } + } + } +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct SiteDomain { + pub domain: S, + pub kind: SiteDomainKind, + pub status: SiteDomainStatus, + pub verified: bool, + #[serde( + flatten, + default, + deserialize_with = "deserialize_site_domain_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SiteDomainKind { + Wisp, + Custom, + Other(S), +} + +impl SiteDomainKind { + pub fn as_str(&self) -> &str { + match self { + Self::Wisp => "wisp", + Self::Custom => "custom", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "wisp" => Self::Wisp, + "custom" => Self::Custom, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for SiteDomainKind { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for SiteDomainKind { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for SiteDomainKind { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for SiteDomainKind { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for SiteDomainKind { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for SiteDomainKind +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = SiteDomainKind; + fn into_static(self) -> Self::Output { + match self { + SiteDomainKind::Wisp => SiteDomainKind::Wisp, + SiteDomainKind::Custom => SiteDomainKind::Custom, + SiteDomainKind::Other(v) => SiteDomainKind::Other(v.into_static()), + } + } +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum SiteDomainStatus { + PendingVerification, + Verified, + Other(S), +} + +impl SiteDomainStatus { + pub fn as_str(&self) -> &str { + match self { + Self::PendingVerification => "pendingVerification", + Self::Verified => "verified", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "pendingVerification" => Self::PendingVerification, + "verified" => Self::Verified, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for SiteDomainStatus { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for SiteDomainStatus { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for SiteDomainStatus { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for SiteDomainStatus { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for SiteDomainStatus { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for SiteDomainStatus +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = SiteDomainStatus; + fn into_static(self) -> Self::Output { + match self { + SiteDomainStatus::PendingVerification => { + SiteDomainStatus::PendingVerification + } + SiteDomainStatus::Verified => SiteDomainStatus::Verified, + SiteDomainStatus::Other(v) => SiteDomainStatus::Other(v.into_static()), + } + } +} + + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct SiteSummary { + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, + pub domains: Vec>, + pub site_rkey: RecordKey>, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde( + flatten, + default, + deserialize_with = "deserialize_site_summary_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/** Request marker for the `place.wisp.v2.site.getList` query. + +This endpoint has no request parameters or input body; send this marker with `jacquard::Client`.*/ + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Copy)] +pub struct GetList; +/** Response marker for the `place.wisp.v2.site.getList` query. + +Implements `jacquard_common::xrpc::XrpcResp`; successful bodies decode as `Self::Output`, which is `GetListOutput` for this endpoint.*/ +pub struct GetListResponse; +impl jacquard_common::xrpc::XrpcResp for GetListResponse { + const NSID: &'static str = "place.wisp.v2.site.getList"; + const ENCODING: &'static str = "application/json"; + type Output = GetListOutput; + type Err = GetListError; +} + +impl jacquard_common::xrpc::XrpcRequest for GetList { + const NSID: &'static str = "place.wisp.v2.site.getList"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Response = GetListResponse; + fn encode_body( + &self, + _buffer: &mut Vec, + ) -> Result<(), jacquard_common::xrpc::EncodeError> { + Ok(()) + } +} + +/** Endpoint marker for the `place.wisp.v2.site.getList` query. + +Path: `/xrpc/place.wisp.v2.site.getList`. The request payload type is `GetList`; use this marker with lower-level `XrpcEndpoint` APIs when you need endpoint metadata.*/ +pub struct GetListRequest; +impl jacquard_common::xrpc::XrpcEndpoint for GetListRequest { + const PATH: &'static str = "/xrpc/place.wisp.v2.site.getList"; + const METHOD: jacquard_common::xrpc::XrpcMethod = jacquard_common::xrpc::XrpcMethod::Query; + type Request = GetList; + type Response = GetListResponse; +} + +impl LexiconSchema for SiteDomain { + fn nsid() -> &'static str { + "place.wisp.v2.site.getList" + } + fn def_name() -> &'static str { + "siteDomain" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_site_getList() + } + fn validate(&self) -> Result<(), ConstraintError> { + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 253usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("domain"), + max: 253usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.domain; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) < 3usize { + return Err(ConstraintError::MinLength { + path: ValidationPath::from_field("domain"), + min: 3usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +impl LexiconSchema for SiteSummary { + fn nsid() -> &'static str { + "place.wisp.v2.site.getList" + } + fn def_name() -> &'static str { + "siteSummary" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_site_getList() + } + fn validate(&self) -> Result<(), ConstraintError> { + if let Some(ref value) = self.display_name { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 200usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("display_name"), + max: 200usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +fn deserialize_site_domain_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod site_domain_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Domain; + type Kind; + type Status; + type Verified; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Domain = Unset; + type Kind = Unset; + type Status = Unset; + type Verified = Unset; + } + ///State transition - sets the `domain` field to Set + pub struct SetDomain(PhantomData St>); + impl sealed::Sealed for SetDomain {} + impl State for SetDomain { + type Domain = Set; + type Kind = St::Kind; + type Status = St::Status; + type Verified = St::Verified; + } + ///State transition - sets the `kind` field to Set + pub struct SetKind(PhantomData St>); + impl sealed::Sealed for SetKind {} + impl State for SetKind { + type Domain = St::Domain; + type Kind = Set; + type Status = St::Status; + type Verified = St::Verified; + } + ///State transition - sets the `status` field to Set + pub struct SetStatus(PhantomData St>); + impl sealed::Sealed for SetStatus {} + impl State for SetStatus { + type Domain = St::Domain; + type Kind = St::Kind; + type Status = Set; + type Verified = St::Verified; + } + ///State transition - sets the `verified` field to Set + pub struct SetVerified(PhantomData St>); + impl sealed::Sealed for SetVerified {} + impl State for SetVerified { + type Domain = St::Domain; + type Kind = St::Kind; + type Status = St::Status; + type Verified = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `domain` field + pub struct domain(()); + ///Marker type for the `kind` field + pub struct kind(()); + ///Marker type for the `status` field + pub struct status(()); + ///Marker type for the `verified` field + pub struct verified(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct SiteDomainBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option>, + Option>, + Option, + ), + _type: PhantomData S>, +} + +impl SiteDomain { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SiteDomainBuilder { + SiteDomainBuilder::new() + } +} + +impl SiteDomain { + /// Create a new builder for this type + pub fn builder() -> SiteDomainBuilder { + SiteDomainBuilder::builder() + } +} + +impl SiteDomainBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SiteDomainBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SiteDomainBuilder { + _state: PhantomData, + _fields: (None, None, None, None), + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Domain: site_domain_state::IsUnset, +{ + /// Set the `domain` field (required) + pub fn domain( + mut self, + value: impl Into, + ) -> SiteDomainBuilder, S> { + self._fields.0 = Option::Some(value.into()); + SiteDomainBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Kind: site_domain_state::IsUnset, +{ + /// Set the `kind` field (required) + pub fn kind( + mut self, + value: impl Into>, + ) -> SiteDomainBuilder, S> { + self._fields.1 = Option::Some(value.into()); + SiteDomainBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Status: site_domain_state::IsUnset, +{ + /// Set the `status` field (required) + pub fn status( + mut self, + value: impl Into>, + ) -> SiteDomainBuilder, S> { + self._fields.2 = Option::Some(value.into()); + SiteDomainBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Verified: site_domain_state::IsUnset, +{ + /// Set the `verified` field (required) + pub fn verified( + mut self, + value: impl Into, + ) -> SiteDomainBuilder, S> { + self._fields.3 = Option::Some(value.into()); + SiteDomainBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteDomainBuilder +where + St: site_domain_state::State, + St::Domain: site_domain_state::IsSet, + St::Kind: site_domain_state::IsSet, + St::Status: site_domain_state::IsSet, + St::Verified: site_domain_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> SiteDomain { + SiteDomain { + domain: self._fields.0.unwrap(), + kind: self._fields.1.unwrap(), + status: self._fields.2.unwrap(), + verified: self._fields.3.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> SiteDomain { + SiteDomain { + domain: self._fields.0.unwrap(), + kind: self._fields.1.unwrap(), + status: self._fields.2.unwrap(), + verified: self._fields.3.unwrap(), + extra_data: Some(extra_data), + } + } +} + +fn lexicon_doc_place_wisp_v2_site_getList() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.v2.site.getList"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("main"), + LexUserType::XrpcQuery(LexXrpcQuery { + parameters: None, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("siteDomain"), + LexUserType::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("domain"), SmolStr::new_static("kind"), + SmolStr::new_static("status"), + SmolStr::new_static("verified") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("domain"), + LexObjectProperty::String(LexString { + min_length: Some(3usize), + max_length: Some(253usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("kind"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("status"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("verified"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("siteSummary"), + LexUserType::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("siteRkey"), + SmolStr::new_static("domains") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("displayName"), + LexObjectProperty::String(LexString { + max_length: Some(200usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("domains"), + LexObjectProperty::Array(LexArray { + items: LexArrayItem::Ref(LexRef { + r#ref: CowStr::new_static("#siteDomain"), + ..Default::default() + }), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("siteRkey"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::RecordKey), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("updatedAt"), + LexObjectProperty::String(LexString { + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} + +fn deserialize_site_summary_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +pub mod site_summary_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type Domains; + type SiteRkey; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type Domains = Unset; + type SiteRkey = Unset; + } + ///State transition - sets the `domains` field to Set + pub struct SetDomains(PhantomData St>); + impl sealed::Sealed for SetDomains {} + impl State for SetDomains { + type Domains = Set; + type SiteRkey = St::SiteRkey; + } + ///State transition - sets the `site_rkey` field to Set + pub struct SetSiteRkey(PhantomData St>); + impl sealed::Sealed for SetSiteRkey {} + impl State for SetSiteRkey { + type Domains = St::Domains; + type SiteRkey = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `domains` field + pub struct domains(()); + ///Marker type for the `site_rkey` field + pub struct site_rkey(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct SiteSummaryBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option, + Option>>, + Option>>, + Option, + ), + _type: PhantomData S>, +} + +impl SiteSummary { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> SiteSummaryBuilder { + SiteSummaryBuilder::new() + } +} + +impl SiteSummary { + /// Create a new builder for this type + pub fn builder() -> SiteSummaryBuilder { + SiteSummaryBuilder::builder() + } +} + +impl SiteSummaryBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + SiteSummaryBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl SiteSummaryBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + SiteSummaryBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl SiteSummaryBuilder { + /// Set the `createdAt` field (optional) + pub fn created_at(mut self, value: impl Into>) -> Self { + self._fields.0 = value.into(); + self + } + /// Set the `createdAt` field to an Option value (optional) + pub fn maybe_created_at(mut self, value: Option) -> Self { + self._fields.0 = value; + self + } +} + +impl SiteSummaryBuilder { + /// Set the `displayName` field (optional) + pub fn display_name(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `displayName` field to an Option value (optional) + pub fn maybe_display_name(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl SiteSummaryBuilder +where + St: site_summary_state::State, + St::Domains: site_summary_state::IsUnset, +{ + /// Set the `domains` field (required) + pub fn domains( + mut self, + value: impl Into>>, + ) -> SiteSummaryBuilder, S> { + self._fields.2 = Option::Some(value.into()); + SiteSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteSummaryBuilder +where + St: site_summary_state::State, + St::SiteRkey: site_summary_state::IsUnset, +{ + /// Set the `siteRkey` field (required) + pub fn site_rkey( + mut self, + value: impl Into>>, + ) -> SiteSummaryBuilder, S> { + self._fields.3 = Option::Some(value.into()); + SiteSummaryBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl SiteSummaryBuilder { + /// Set the `updatedAt` field (optional) + pub fn updated_at(mut self, value: impl Into>) -> Self { + self._fields.4 = value.into(); + self + } + /// Set the `updatedAt` field to an Option value (optional) + pub fn maybe_updated_at(mut self, value: Option) -> Self { + self._fields.4 = value; + self + } +} + +impl SiteSummaryBuilder +where + St: site_summary_state::State, + St::Domains: site_summary_state::IsSet, + St::SiteRkey: site_summary_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> SiteSummary { + SiteSummary { + created_at: self._fields.0, + display_name: self._fields.1, + domains: self._fields.2.unwrap(), + site_rkey: self._fields.3.unwrap(), + updated_at: self._fields.4, + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data( + self, + extra_data: BTreeMap>, + ) -> SiteSummary { + SiteSummary { + created_at: self._fields.0, + display_name: self._fields.1, + domains: self._fields.2.unwrap(), + site_rkey: self._fields.3.unwrap(), + updated_at: self._fields.4, + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/wh.rs b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/wh.rs new file mode 100644 index 0000000..aab2f11 --- /dev/null +++ b/cli-rs/crates/wisp-lexicons/src/place_wisp/v2/wh.rs @@ -0,0 +1,733 @@ +// @generated by jacquard-lexicon. DO NOT EDIT. +// +// Lexicon: place.wisp.v2.wh +// +// This file was automatically generated from Lexicon schemas. +// Any manual changes will be overwritten on the next regeneration. + +#[allow(unused_imports)] +use alloc::collections::BTreeMap; + +#[allow(unused_imports)] +use core::marker::PhantomData; +use jacquard_common::{CowStr, BosStr, DefaultStr, FromStaticStr}; + +#[allow(unused_imports)] +use jacquard_common::deps::codegen::unicode_segmentation::UnicodeSegmentation; +use jacquard_common::deps::smol_str::SmolStr; +use jacquard_common::types::collection::{Collection, RecordError}; +use jacquard_common::types::string::{Cid, Datetime, RecordKey, Rkey, UriValue}; +use jacquard_common::types::uri::{RecordUri, UriError}; +use jacquard_common::types::value::Data; +use jacquard_common::xrpc::XrpcResp; +use jacquard_derive::{IntoStatic, lexicon}; +use jacquard_lexicon::lexicon::LexiconDoc; +use jacquard_lexicon::schema::LexiconSchema; + +#[allow(unused_imports)] +use jacquard_lexicon::validation::{ConstraintError, ValidationPath}; +use serde::{Serialize, Deserialize}; +use crate::place_wisp::v2::wh; +/// Watch by AT-URI. at://did watches all collections for a DID. at://did/collection watches all records of that collection for a DID. at://did/collection/record watches a specific record. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic, Default)] +#[serde(rename_all = "camelCase", bound(deserialize = "S: Deserialize<'de> + BosStr"))] +pub struct AtUri { + pub aturi: S, + /// If true, also watch for records in any repo that reference this DID and collection. + #[serde(skip_serializing_if = "Option::is_none")] + pub backlinks: Option, + /// If true, fire only for records in other repos that reference this scope. Events from the scope DID's own repo never fire, including records that reference itself. Implies backlinks. + #[serde(skip_serializing_if = "Option::is_none")] + pub backlinks_only: Option, + #[serde( + flatten, + default, + deserialize_with = "deserialize_at_uri_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + +/// Webhook configuration for AT Protocol record events. Fires an HTTP POST to a URL when a matching record event is observed on the firehose. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde( + rename_all = "camelCase", + rename = "place.wisp.v2.wh", + tag = "$type", + bound(deserialize = "S: Deserialize<'de> + BosStr") +)] +pub struct Wh { + /// Timestamp of when the webhook was created. + pub created_at: Datetime, + /// Whether the webhook is active. Defaults to true if omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Which record events to trigger on. Defaults to all events if omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub events: Option>>, + /// What to watch. An AT-URI scopes to a specific DID, collection, or record. + pub scope: wh::AtUri, + /// Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records. + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, + /// 1–64 character server-managed signing secret ID. Use ASCII letters, digits, dots, underscores, and hyphens only. + #[serde(skip_serializing_if = "Option::is_none")] + pub secret_id: Option>>, + /// HTTPS endpoint to POST the webhook payload to. + pub url: UriValue, + #[serde( + flatten, + default, + deserialize_with = "deserialize_wh_extra_data", + skip_serializing_if = "Option::is_none" + )] + pub extra_data: Option>>, +} + + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum WhEvents { + Create, + Update, + Delete, + Other(S), +} + +impl WhEvents { + pub fn as_str(&self) -> &str { + match self { + Self::Create => "create", + Self::Update => "update", + Self::Delete => "delete", + Self::Other(s) => s.as_ref(), + } + } + /// Construct from a string-like value, matching known values. + pub fn from_value(s: S) -> Self { + match s.as_ref() { + "create" => Self::Create, + "update" => Self::Update, + "delete" => Self::Delete, + _ => Self::Other(s), + } + } +} + +impl core::fmt::Display for WhEvents { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl AsRef for WhEvents { + fn as_ref(&self) -> &str { + self.as_str() + } +} + +impl Serialize for WhEvents { + fn serialize(&self, serializer: Ser) -> Result + where + Ser: serde::Serializer, + { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de, S: Deserialize<'de> + BosStr> Deserialize<'de> for WhEvents { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = S::deserialize(deserializer)?; + Ok(Self::from_value(s)) + } +} + +impl Default for WhEvents { + fn default() -> Self { + Self::Other(Default::default()) + } +} + +impl jacquard_common::IntoStatic for WhEvents +where + S: BosStr + jacquard_common::IntoStatic, + S::Output: BosStr, +{ + type Output = WhEvents; + fn into_static(self) -> Self::Output { + match self { + WhEvents::Create => WhEvents::Create, + WhEvents::Update => WhEvents::Update, + WhEvents::Delete => WhEvents::Delete, + WhEvents::Other(v) => WhEvents::Other(v.into_static()), + } + } +} + +/// Typed wrapper for GetRecord response with this collection's record type. + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, IntoStatic)] +#[serde(rename_all = "camelCase")] +pub struct WhGetRecordOutput { + #[serde(skip_serializing_if = "Option::is_none")] + pub cid: Option>, + pub uri: jacquard_common::types::string::AtUri, + pub value: Wh, +} + +impl Wh { + pub fn uri(uri: S) -> Result, UriError> { + RecordUri::try_from_uri(jacquard_common::types::string::AtUri::new(uri)?) + } +} + +impl LexiconSchema for AtUri { + fn nsid() -> &'static str { + "place.wisp.v2.wh" + } + fn def_name() -> &'static str { + "atUri" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_wh() + } + fn validate(&self) -> Result<(), ConstraintError> { + Ok(()) + } +} + +/// Marker type for deserializing records from this collection. + +#[derive(Debug, Serialize, Deserialize)] +pub struct WhRecord; +impl XrpcResp for WhRecord { + const NSID: &'static str = "place.wisp.v2.wh"; + const ENCODING: &'static str = "application/json"; + type Output = WhGetRecordOutput; + type Err = RecordError; +} + +impl From> for Wh { + fn from(output: WhGetRecordOutput) -> Self { + output.value + } +} + +impl Collection for Wh { + const NSID: &'static str = "place.wisp.v2.wh"; + type Record = WhRecord; +} + +impl Collection for WhRecord { + const NSID: &'static str = "place.wisp.v2.wh"; + type Record = WhRecord; +} + +impl LexiconSchema for Wh { + fn nsid() -> &'static str { + "place.wisp.v2.wh" + } + fn def_name() -> &'static str { + "main" + } + fn lexicon_doc() -> LexiconDoc<'static> { + lexicon_doc_place_wisp_v2_wh() + } + fn validate(&self) -> Result<(), ConstraintError> { + if let Some(ref value) = self.events { + #[allow(unused_comparisons)] + if value.len() > 3usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("events"), + max: 3usize, + actual: value.len(), + }); + } + } + if let Some(ref value) = self.secret { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 256usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("secret"), + max: 256usize, + actual: ::len(value.as_ref()), + }); + } + } + if let Some(ref value) = self.secret { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) < 1usize { + return Err(ConstraintError::MinLength { + path: ValidationPath::from_field("secret"), + min: 1usize, + actual: ::len(value.as_ref()), + }); + } + } + if let Some(ref value) = self.secret_id { + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 64usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("secret_id"), + max: 64usize, + actual: ::len(value.as_ref()), + }); + } + } + { + let value = &self.url; + #[allow(unused_comparisons)] + if ::len(value.as_ref()) > 2048usize { + return Err(ConstraintError::MaxLength { + path: ValidationPath::from_field("url"), + max: 2048usize, + actual: ::len(value.as_ref()), + }); + } + } + Ok(()) + } +} + +fn deserialize_at_uri_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + Ok(data.filter(|extra_data| !extra_data.is_empty())) +} + +fn lexicon_doc_place_wisp_v2_wh() -> LexiconDoc<'static> { + #[allow(unused_imports)] + use jacquard_common::{CowStr, deps::smol_str::SmolStr, types::blob::MimeType}; + use jacquard_lexicon::lexicon::*; + use alloc::collections::BTreeMap; + LexiconDoc { + lexicon: Lexicon::Lexicon1, + id: CowStr::new_static("place.wisp.v2.wh"), + defs: { + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("atUri"), + LexUserType::Object(LexObject { + description: Some( + CowStr::new_static( + "Watch by AT-URI. at://did watches all collections for a DID. at://did/collection watches all records of that collection for a DID. at://did/collection/record watches a specific record.", + ), + ), + required: Some(vec![SmolStr::new_static("aturi")]), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("aturi"), + LexObjectProperty::String(LexString { ..Default::default() }), + ); + map.insert( + SmolStr::new_static("backlinks"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("backlinksOnly"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("main"), + LexUserType::Record(LexRecord { + description: Some( + CowStr::new_static( + "Webhook configuration for AT Protocol record events. Fires an HTTP POST to a URL when a matching record event is observed on the firehose.", + ), + ), + key: Some(CowStr::new_static("any")), + record: LexRecordRecord::Object(LexObject { + required: Some( + vec![ + SmolStr::new_static("scope"), SmolStr::new_static("url"), + SmolStr::new_static("createdAt") + ], + ), + properties: { + #[allow(unused_mut)] + let mut map = BTreeMap::new(); + map.insert( + SmolStr::new_static("createdAt"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Timestamp of when the webhook was created.", + ), + ), + format: Some(LexStringFormat::Datetime), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("enabled"), + LexObjectProperty::Boolean(LexBoolean { + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("events"), + LexObjectProperty::Array(LexArray { + description: Some( + CowStr::new_static( + "Which record events to trigger on. Defaults to all events if omitted.", + ), + ), + items: LexArrayItem::String(LexString { + ..Default::default() + }), + max_length: Some(3usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("scope"), + LexObjectProperty::Ref(LexRef { + r#ref: CowStr::new_static("#atUri"), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("secret"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "Optional raw secret used to sign the webhook payload with HMAC-SHA256. Prefer secretId to avoid embedding plaintext values in PDS records.", + ), + ), + min_length: Some(1usize), + max_length: Some(256usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("secretId"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "1–64 character server-managed signing secret ID. Use ASCII letters, digits, dots, underscores, and hyphens only.", + ), + ), + format: Some(LexStringFormat::RecordKey), + max_length: Some(64usize), + ..Default::default() + }), + ); + map.insert( + SmolStr::new_static("url"), + LexObjectProperty::String(LexString { + description: Some( + CowStr::new_static( + "HTTPS endpoint to POST the webhook payload to.", + ), + ), + format: Some(LexStringFormat::Uri), + max_length: Some(2048usize), + ..Default::default() + }), + ); + map + }, + ..Default::default() + }), + ..Default::default() + }), + ); + map + }, + ..Default::default() + } +} + +fn deserialize_wh_extra_data<'de, S, D>( + deserializer: D, +) -> Result>>, D::Error> +where + S: BosStr + serde::Deserialize<'de>, + D: serde::Deserializer<'de>, +{ + let mut data = >, + > as serde::Deserialize<'de>>::deserialize(deserializer)?; + if let Some(extra_data) = &mut data { + extra_data.remove("$type"); + if extra_data.is_empty() { + data = None; + } + } + Ok(data) +} + +pub mod wh_state { + + pub use crate::builder_types::{Set, Unset, IsSet, IsUnset}; + #[allow(unused)] + use ::core::marker::PhantomData; + mod sealed { + pub trait Sealed {} + } + /// State trait tracking which required fields have been set + pub trait State: sealed::Sealed { + type CreatedAt; + type Scope; + type Url; + } + /// Empty state - all required fields are unset + pub struct Empty(()); + impl sealed::Sealed for Empty {} + impl State for Empty { + type CreatedAt = Unset; + type Scope = Unset; + type Url = Unset; + } + ///State transition - sets the `created_at` field to Set + pub struct SetCreatedAt(PhantomData St>); + impl sealed::Sealed for SetCreatedAt {} + impl State for SetCreatedAt { + type CreatedAt = Set; + type Scope = St::Scope; + type Url = St::Url; + } + ///State transition - sets the `scope` field to Set + pub struct SetScope(PhantomData St>); + impl sealed::Sealed for SetScope {} + impl State for SetScope { + type CreatedAt = St::CreatedAt; + type Scope = Set; + type Url = St::Url; + } + ///State transition - sets the `url` field to Set + pub struct SetUrl(PhantomData St>); + impl sealed::Sealed for SetUrl {} + impl State for SetUrl { + type CreatedAt = St::CreatedAt; + type Scope = St::Scope; + type Url = Set; + } + /// Marker types for field names + #[allow(non_camel_case_types)] + pub mod members { + ///Marker type for the `created_at` field + pub struct created_at(()); + ///Marker type for the `scope` field + pub struct scope(()); + ///Marker type for the `url` field + pub struct url(()); + } +} + +/// Builder for constructing an instance of this type. +pub struct WhBuilder { + _state: PhantomData St>, + _fields: ( + Option, + Option, + Option>>, + Option>, + Option, + Option>>, + Option>, + ), + _type: PhantomData S>, +} + +impl Wh { + /// Create a new builder for this type, using the default string type (DefaultStr = SmolStr) if needed + pub fn new() -> WhBuilder { + WhBuilder::new() + } +} + +impl Wh { + /// Create a new builder for this type + pub fn builder() -> WhBuilder { + WhBuilder::builder() + } +} + +impl WhBuilder { + /// Create a new builder with all fields unset, using the default string type, if needed + pub fn new() -> Self { + WhBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl WhBuilder { + /// Create a new builder with all fields unset + pub fn builder() -> Self { + WhBuilder { + _state: PhantomData, + _fields: (None, None, None, None, None, None, None), + _type: PhantomData, + } + } +} + +impl WhBuilder +where + St: wh_state::State, + St::CreatedAt: wh_state::IsUnset, +{ + /// Set the `createdAt` field (required) + pub fn created_at( + mut self, + value: impl Into, + ) -> WhBuilder, S> { + self._fields.0 = Option::Some(value.into()); + WhBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl WhBuilder { + /// Set the `enabled` field (optional) + pub fn enabled(mut self, value: impl Into>) -> Self { + self._fields.1 = value.into(); + self + } + /// Set the `enabled` field to an Option value (optional) + pub fn maybe_enabled(mut self, value: Option) -> Self { + self._fields.1 = value; + self + } +} + +impl WhBuilder { + /// Set the `events` field (optional) + pub fn events(mut self, value: impl Into>>>) -> Self { + self._fields.2 = value.into(); + self + } + /// Set the `events` field to an Option value (optional) + pub fn maybe_events(mut self, value: Option>>) -> Self { + self._fields.2 = value; + self + } +} + +impl WhBuilder +where + St: wh_state::State, + St::Scope: wh_state::IsUnset, +{ + /// Set the `scope` field (required) + pub fn scope( + mut self, + value: impl Into>, + ) -> WhBuilder, S> { + self._fields.3 = Option::Some(value.into()); + WhBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl WhBuilder { + /// Set the `secret` field (optional) + pub fn secret(mut self, value: impl Into>) -> Self { + self._fields.4 = value.into(); + self + } + /// Set the `secret` field to an Option value (optional) + pub fn maybe_secret(mut self, value: Option) -> Self { + self._fields.4 = value; + self + } +} + +impl WhBuilder { + /// Set the `secretId` field (optional) + pub fn secret_id(mut self, value: impl Into>>>) -> Self { + self._fields.5 = value.into(); + self + } + /// Set the `secretId` field to an Option value (optional) + pub fn maybe_secret_id(mut self, value: Option>>) -> Self { + self._fields.5 = value; + self + } +} + +impl WhBuilder +where + St: wh_state::State, + St::Url: wh_state::IsUnset, +{ + /// Set the `url` field (required) + pub fn url( + mut self, + value: impl Into>, + ) -> WhBuilder, S> { + self._fields.6 = Option::Some(value.into()); + WhBuilder { + _state: PhantomData, + _fields: self._fields, + _type: PhantomData, + } + } +} + +impl WhBuilder +where + St: wh_state::State, + St::CreatedAt: wh_state::IsSet, + St::Scope: wh_state::IsSet, + St::Url: wh_state::IsSet, +{ + /// Build the final struct. + pub fn build(self) -> Wh { + Wh { + created_at: self._fields.0.unwrap(), + enabled: self._fields.1, + events: self._fields.2, + scope: self._fields.3.unwrap(), + secret: self._fields.4, + secret_id: self._fields.5, + url: self._fields.6.unwrap(), + extra_data: Default::default(), + } + } + /// Build the final struct with custom extra_data. + pub fn build_with_data(self, extra_data: BTreeMap>) -> Wh { + Wh { + created_at: self._fields.0.unwrap(), + enabled: self._fields.1, + events: self._fields.2, + scope: self._fields.3.unwrap(), + secret: self._fields.4, + secret_id: self._fields.5, + url: self._fields.6.unwrap(), + extra_data: Some(extra_data), + } + } +} \ No newline at end of file diff --git a/cli-rs/crates/wisp-ui/Cargo.toml b/cli-rs/crates/wisp-ui/Cargo.toml new file mode 100644 index 0000000..530fca5 --- /dev/null +++ b/cli-rs/crates/wisp-ui/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "wisp-ui" +description = "Terminal rendering for wispctl: prompts, spinners, progress and live panels" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +ratatui.workspace = true +crossterm.workspace = true +unicode-width.workspace = true diff --git a/cli-rs/crates/wisp-ui/examples/demo.rs b/cli-rs/crates/wisp-ui/examples/demo.rs new file mode 100644 index 0000000..17701e7 --- /dev/null +++ b/cli-rs/crates/wisp-ui/examples/demo.rs @@ -0,0 +1,137 @@ +//! Visual check for every live element: `cargo run -p wisp-ui --example demo -- `. + +use std::thread::sleep; +use std::time::Duration; + +use wisp_ui::{Choice, Direction, Line, PanelStatus, TextPrompt, s}; + +fn pause(ms: u64) { + sleep(Duration::from_millis(ms)); +} + +fn deploy() { + wisp_ui::intro("deploy"); + let handle = + wisp_ui::text(TextPrompt::new("AT Protocol handle").placeholder("alice.bsky.social")); + let site = wisp_ui::text( + TextPrompt::new("Site name") + .placeholder("my-website") + .validate(|v| { + if v.is_empty() { + Err("Site name is required".into()) + } else { + Ok(()) + } + }), + ); + let _ = (handle, site); + let spinner = wisp_ui::spinner("Authenticating..."); + pause(600); + spinner.succeed("Authenticated as did:plc:untyra7qun43gbecoft5cglc".to_owned()); + wisp_ui::note(Line::from(vec![ + s::accent("Deploying "), + s::accent_bold("my-blog"), + s::accent(" from ./dist"), + ])); + let scan = wisp_ui::spinner("Scanning directory..."); + pause(300); + scan.succeed("Found 412 files (3.4 MB)".to_owned()); + let progress = wisp_ui::progress("Uploading", 412, Direction::Up); + let names = [ + "index.html", + "assets/app.4f3a9c.js", + "assets/vendor.81be2d.js", + "img/hero.avif", + "fonts/inter-var.woff2", + "blog/2026/09/a-very-long-post-title-that-goes-on/index.html", + ]; + let items: Vec<_> = names + .iter() + .map(|n| progress.start_item(*n, "84.1 KB")) + .collect(); + progress.advance(212); + progress.set_note("32 uploaded · 180 reused"); + pause(900); + for item in items { + progress.finish_item(item); + } + progress.advance(200); + progress.succeed("Processed 412 files (32 uploaded, 380 reused)"); + wisp_ui::warning("Site may not be cached by the hosting service."); + wisp_ui::out(Line::from(vec![s::muted( + " URI: at://did:plc:untyra7qun43gbecoft5cglc/place.wisp.fs/my-blog", + )])); + wisp_ui::out(Line::from(vec![ + s::accent(" URL: "), + s::link("https://sites.wisp.place/alice.test/my-blog"), + ])); + wisp_ui::outro("Deployed successfully!"); +} + +fn menus() { + wisp_ui::intro("domain"); + let choices = vec![ + Choice::new("claim", "Claim custom domain").hint("example.com"), + Choice::new("claim-subdomain", "Claim wisp subdomain").hint("alice.wisp.place"), + Choice::new("status", "Get domain status"), + Choice::new("add-site", "Map domain to site"), + Choice::new("verify", "Verify domain"), + Choice::new("delete", "Delete domain"), + ]; + let _ = wisp_ui::select("Choose domain action", choices); + let _ = wisp_ui::confirm("Delete site \"my-blog\" and unmap its domains?"); + let failing = wisp_ui::spinner("Deleting site my-blog..."); + pause(400); + failing.fail("Site my-blog not found".to_owned()); + wisp_ui::error("XRPC request failed: RecordNotFound"); +} + +fn serve() { + wisp_ui::success("Pulled my-blog to .wisp-serve"); + let status = |state: &str| { + vec![ + Line::from(vec![ + s::bold("serving my-blog"), + s::muted(" → "), + s::link("http://127.0.0.1:8080"), + ]), + Line::from(vec![s::muted(format!( + "firehose {state} · synced 12:04:11 · 142 files" + ))]), + ] + }; + let panel = wisp_ui::panel(PanelStatus::Live, status("connected")); + for (code, path) in [ + (200, "/"), + (200, "/assets/app.js"), + (304, "/favicon.ico"), + (404, "/nope"), + ] { + pause(250); + let code_span = match code { + 200..=299 => s::ok(code.to_string()), + 300..=399 => s::info(code.to_string()), + _ => s::warn(code.to_string()), + }; + wisp_ui::note(Line::from(vec![ + s::muted(" 12:04:13 "), + code_span, + s::muted(" GET "), + s::plain(path), + s::muted(" 1.2ms"), + ])); + } + panel.set(PanelStatus::Busy, status("re-pulling")); + pause(700); + panel.set(PanelStatus::Live, status("connected")); + pause(1500); +} + +fn main() { + let _guard = wisp_ui::init(wisp_ui::Options::default()); + match std::env::args().nth(1).as_deref() { + Some("menus") => menus(), + Some("serve") => serve(), + _ => deploy(), + } +} diff --git a/cli-rs/crates/wisp-ui/src/ansi.rs b/cli-rs/crates/wisp-ui/src/ansi.rs new file mode 100644 index 0000000..df01927 --- /dev/null +++ b/cli-rs/crates/wisp-ui/src/ansi.rs @@ -0,0 +1,62 @@ +//! Writing styled lines straight to a stream, for output that is not part of +//! the live region (no renderer running, or stdout redirected). + +use std::fmt::Write as _; + +use crossterm::style::{Attribute, Color as CtColor, Stylize}; +use ratatui::style::{Color, Modifier}; +use ratatui::text::Line; + +fn to_crossterm(color: Color) -> Option { + Some(match color { + Color::Reset => return None, + Color::Black => CtColor::Black, + Color::Red => CtColor::DarkRed, + Color::Green => CtColor::DarkGreen, + Color::Yellow => CtColor::DarkYellow, + Color::Blue => CtColor::DarkBlue, + Color::Magenta => CtColor::DarkMagenta, + Color::Cyan => CtColor::DarkCyan, + Color::Gray => CtColor::Grey, + Color::DarkGray => CtColor::DarkGrey, + Color::LightRed => CtColor::Red, + Color::LightGreen => CtColor::Green, + Color::LightYellow => CtColor::Yellow, + Color::LightBlue => CtColor::Blue, + Color::LightMagenta => CtColor::Magenta, + Color::LightCyan => CtColor::Cyan, + Color::White => CtColor::White, + Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b }, + Color::Indexed(i) => CtColor::AnsiValue(i), + }) +} + +/// Render a line with ANSI escapes when `styled`, as plain text otherwise. +pub(crate) fn render(line: &Line<'_>, styled: bool) -> String { + if !styled { + return line.to_string(); + } + let mut out = String::new(); + for span in &line.spans { + let style = line.style.patch(span.style); + let mut content = span.content.as_ref().stylize(); + if let Some(fg) = style.fg.and_then(to_crossterm) { + content = content.with(fg); + } + let attributes = [ + (Modifier::BOLD, Attribute::Bold), + (Modifier::DIM, Attribute::Dim), + (Modifier::ITALIC, Attribute::Italic), + (Modifier::UNDERLINED, Attribute::Underlined), + (Modifier::REVERSED, Attribute::Reverse), + (Modifier::CROSSED_OUT, Attribute::CrossedOut), + ]; + for (modifier, attribute) in attributes { + if style.add_modifier.contains(modifier) { + content = content.attribute(attribute); + } + } + let _ = write!(out, "{content}"); + } + out +} diff --git a/cli-rs/crates/wisp-ui/src/lib.rs b/cli-rs/crates/wisp-ui/src/lib.rs new file mode 100644 index 0000000..d2244c8 --- /dev/null +++ b/cli-rs/crates/wisp-ui/src/lib.rs @@ -0,0 +1,424 @@ +//! Terminal output for wispctl. +//! +//! Everything the CLI shows goes through this crate so the live region (spinners, +//! progress, prompts, panels) and ordinary printed lines never fight over the +//! terminal. Call [`init`] once at startup, keep the returned [`Guard`] alive for +//! the whole run, and never write to stdout/stderr directly while it is. +//! +//! Two modes, picked once by [`init`]: +//! - **Rich**: stderr is a terminal. Live elements render inline below the +//! scrollback with ratatui, and finished work collapses into a single line. +//! - **Plain**: CI, pipes, `--quiet`, `WISPCTL_NO_PROGRESS=1`. No animation; +//! finished work prints one `✓`/`✗` line, in-flight updates are dropped. +//! +//! Status goes to stderr. Only command *results* (`--json`, listings) go to +//! stdout via [`out`], so `wispctl list sites --json | jq` stays clean. + +mod ansi; +mod live; +mod prompt; +mod runtime; +mod theme; + +use std::sync::OnceLock; +use std::sync::atomic::{AtomicU64, Ordering}; + +pub use ratatui::text::{Line, Span}; +pub use theme::{ColorSupport, Palette, palette, s}; + +pub use live::{Direction, PanelStatus}; +pub use prompt::{Choice, PromptError, TextPrompt}; + +use live::Node; +use runtime::Runtime; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Rich, + Plain, +} + +#[derive(Clone, Copy, Debug, Default)] +pub struct Options { + /// `--quiet`: never animate, even on a terminal. + pub quiet: bool, +} + +static RUNTIME: OnceLock = OnceLock::new(); +static NEXT_ID: AtomicU64 = AtomicU64::new(1); + +fn rt() -> &'static Runtime { + RUNTIME.get_or_init(|| Runtime::start(Options::default())) +} + +fn next_id() -> u64 { + NEXT_ID.fetch_add(1, Ordering::Relaxed) +} + +/// Pick the output mode and start the renderer. Hold the guard until exit; +/// dropping it tears down the live region and restores the terminal. +pub fn init(options: Options) -> Guard { + let _ = RUNTIME.set(Runtime::start(options)); + Guard(()) +} + +#[must_use = "dropping the guard tears the terminal UI down"] +pub struct Guard(()); + +impl Drop for Guard { + fn drop(&mut self) { + shutdown(); + } +} + +/// Clear the live region and restore the terminal. Safe to call repeatedly, +/// and from a signal handler path before `std::process::exit`. +pub fn shutdown() { + if let Some(rt) = RUNTIME.get() { + rt.shutdown(); + } +} + +/// Tear down the UI, then exit. Use instead of `std::process::exit`, which +/// skips destructors and would leave the cursor hidden. +pub fn exit(code: i32) -> ! { + shutdown(); + std::process::exit(code) +} + +pub fn mode() -> Mode { + rt().mode() +} + +/// Whether interactive prompts can be shown (stdin and stderr are terminals). +pub fn can_prompt() -> bool { + rt().can_prompt() +} + +// --------------------------------------------------------------------------- +// Printing +// --------------------------------------------------------------------------- + +/// A command result on stdout (listings, `--json`, URLs a script may capture). +pub fn out(line: impl Into>) { + rt().print(runtime::Target::Stdout, vec![line.into()]); +} + +/// Raw text on stdout, e.g. pretty JSON. Printed without styling. +pub fn out_text(text: &str) { + let lines = text.lines().map(|l| Line::raw(l.to_owned())).collect(); + rt().print(runtime::Target::Stdout, lines); +} + +/// A status line on stderr. +pub fn note(line: impl Into>) { + rt().print(runtime::Target::Stderr, vec![line.into()]); +} + +/// An empty status line, for breathing room between sections. +pub fn blank() { + note(Line::default()); +} + +/// Session header: `✦ wisp.place deploy`. +pub fn intro(command: &str) { + blank(); + note(Line::from(vec![ + s::accent("✦ "), + s::bold("wisp.place"), + s::plain(" "), + s::muted(command.to_owned()), + ])); + blank(); +} + +/// Closing line after an interactive flow finished well. +pub fn outro(message: &str) { + blank(); + note(Line::from(vec![s::accent("✦ "), s::ok(message.to_owned())])); + blank(); +} + +/// The user backed out of a prompt. Printed in place of the flow's result. +pub fn cancelled(message: &str) { + note(Line::from(vec![ + s::muted("■ "), + s::muted(message.to_owned()), + ])); +} + +pub fn success(message: impl Into) { + note(glyph_line(s::ok("✓"), message.into())); +} + +pub fn failure(message: impl Into) { + note(glyph_line(s::danger("✗"), message.into())); +} + +pub fn warning(message: impl Into) { + note(Line::from(vec![s::warn("▲ "), s::warn(message.into())])); +} + +pub fn info(message: impl Into) { + note(Line::from(vec![s::muted("· "), s::muted(message.into())])); +} + +/// A fatal error, printed as the last thing before a non-zero exit. +pub fn error(message: impl Into) { + blank(); + note(Line::from(vec![ + s::danger("✗ "), + s::danger("Error: "), + s::plain(message.into()), + ])); + blank(); +} + +fn glyph_line(glyph: Span<'static>, message: String) -> Line<'static> { + Line::from(vec![glyph, s::plain(" "), s::plain(message)]) +} + +// --------------------------------------------------------------------------- +// Prompts +// --------------------------------------------------------------------------- + +/// Ask for a line of text. Blocks the calling thread until answered. +pub fn text(prompt: TextPrompt) -> Result { + rt().prompt(prompt::Prompt::Text(prompt.into_state())) + .map(prompt::Answer::into_text) +} + +/// Pick one of `choices`. Blocks the calling thread until answered. +pub fn select(message: &str, choices: Vec>) -> Result { + let labels = choices.iter().map(Choice::display).collect(); + let index = rt() + .prompt(prompt::Prompt::Select(prompt::SelectState::new( + message, labels, + )))? + .into_index(); + Ok(choices[index].value.clone()) +} + +/// Yes or no. Blocks the calling thread until answered. +pub fn confirm(message: &str) -> Result { + rt().prompt(prompt::Prompt::Confirm(prompt::ConfirmState::new( + message, true, + ))) + .map(prompt::Answer::into_bool) +} + +// --------------------------------------------------------------------------- +// Live elements +// --------------------------------------------------------------------------- + +/// A single line of in-flight work: `⠹ Authenticating...`. +/// +/// Finish it with [`Spinner::succeed`] or [`Spinner::fail`]; dropping it +/// unfinished removes it without a trace. +pub struct Spinner { + id: u64, + text: String, + done: bool, +} + +pub fn spinner(text: impl Into) -> Spinner { + let text = text.into(); + let id = next_id(); + rt().upsert(id, Node::spinner(&text)); + Spinner { + id, + text, + done: false, + } +} + +impl Spinner { + pub fn set_text(&mut self, text: impl Into) { + self.text = text.into(); + rt().upsert(self.id, Node::spinner(&self.text)); + } + + pub fn text(&self) -> &str { + &self.text + } + + pub fn succeed(mut self, message: impl Into>) { + let message = message.into().unwrap_or_else(|| self.text.clone()); + self.finish(vec![glyph_line(s::ok("✓"), message)]); + } + + pub fn fail(mut self, message: impl Into>) { + let message = message.into().unwrap_or_else(|| self.text.clone()); + self.finish(vec![glyph_line(s::danger("✗"), message)]); + } + + pub fn warn(mut self, message: impl Into>) { + let message = message.into().unwrap_or_else(|| self.text.clone()); + self.finish(vec![Line::from(vec![s::warn("▲ "), s::warn(message)])]); + } + + fn finish(&mut self, lines: Vec>) { + self.done = true; + rt().remove(self.id, lines); + } +} + +impl Drop for Spinner { + fn drop(&mut self) { + if !self.done { + rt().remove(self.id, Vec::new()); + } + } +} + +/// Counted work with a bar, the items currently in flight and a free-form +/// note line. Built for uploads and downloads: +/// +/// ```text +/// ⠹ Uploading ━━━━━━━━━━━━━━━╺━━━━━━━━━━━━ 212/412 +/// ↑ assets/app.4f3a.js 84.1 KB +/// ↑ img/hero.avif 1.2 MB +/// 32 uploaded · 180 reused +/// ``` +pub struct Progress { + id: u64, + done: bool, +} + +/// Handle for one in-flight item row of a [`Progress`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ItemId(u64); + +pub fn progress(label: impl Into, total: u64, direction: Direction) -> Progress { + let id = next_id(); + rt().upsert(id, Node::progress(label.into(), total, direction)); + Progress { id, done: false } +} + +impl Progress { + fn update(&self, f: impl FnOnce(&mut live::ProgressState) + Send + 'static) { + rt().update(self.id, move |node| { + if let Node::Progress(state) = node { + f(state); + } + }); + } + + pub fn set_label(&self, label: impl Into) { + let label = label.into(); + self.update(move |p| p.label = label); + } + + pub fn set_total(&self, total: u64) { + self.update(move |p| p.total = total); + } + + /// Advance the counter by `n` finished items. + pub fn advance(&self, n: u64) { + self.update(move |p| p.done += n); + } + + /// The muted line under the bar, e.g. `32 uploaded · 180 reused`. + pub fn set_note(&self, note: impl Into) { + let note = note.into(); + self.update(move |p| p.note = note); + } + + /// Show an item as in flight. `detail` is right-aligned (usually a size). + pub fn start_item(&self, name: impl Into, detail: impl Into) -> ItemId { + let item = next_id(); + let (name, detail) = (name.into(), detail.into()); + self.update(move |p| { + p.items.push(live::Item { + id: item, + name, + detail, + }) + }); + ItemId(item) + } + + pub fn finish_item(&self, item: ItemId) { + self.update(move |p| p.items.retain(|i| i.id != item.0)); + } + + pub fn succeed(mut self, message: impl Into) { + self.finish(vec![glyph_line(s::ok("✓"), message.into())]); + } + + pub fn fail(mut self, message: impl Into) { + self.finish(vec![glyph_line(s::danger("✗"), message.into())]); + } + + fn finish(&mut self, lines: Vec>) { + self.done = true; + rt().remove(self.id, lines); + } +} + +impl Drop for Progress { + fn drop(&mut self) { + if !self.done { + rt().remove(self.id, Vec::new()); + } + } +} + +/// A long-lived status block, e.g. the `serve` dashboard. The first line gets +/// a status glyph (breathing dot, spinner or cross); the rest render as given. +/// In plain mode panels are invisible, so print anything important as well. +pub struct Panel { + id: u64, +} + +pub fn panel(status: PanelStatus, lines: Vec>) -> Panel { + let id = next_id(); + rt().upsert(id, Node::Panel { status, lines }); + Panel { id } +} + +impl Panel { + pub fn set(&self, status: PanelStatus, lines: Vec>) { + rt().upsert(self.id, Node::Panel { status, lines }); + } +} + +impl Drop for Panel { + fn drop(&mut self) { + rt().remove(self.id, Vec::new()); + } +} + +// --------------------------------------------------------------------------- +// Formatting helpers shared by commands +// --------------------------------------------------------------------------- + +/// `1536` -> `1.5 KB`. Binary units, at most two decimals, trailing zeros +/// dropped (`1 KB`, `1.25 MB`), matching the old CLI's output. +pub fn format_bytes(bytes: u64) -> String { + const UNITS: [&str; 5] = ["B", "KB", "MB", "GB", "TB"]; + if bytes == 0 { + return "0 B".into(); + } + let exp = ((bytes as f64).ln() / 1024f64.ln()).floor() as usize; + let exp = exp.min(UNITS.len() - 1); + let value = bytes as f64 / 1024f64.powi(exp as i32); + let rounded = format!("{value:.2}"); + let trimmed = rounded.trim_end_matches('0').trim_end_matches('.'); + format!("{trimmed} {}", UNITS[exp]) +} + +#[cfg(test)] +mod tests { + use super::format_bytes; + + #[test] + fn formats_bytes_like_the_old_cli() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(512), "512 B"); + assert_eq!(format_bytes(1024), "1 KB"); + assert_eq!(format_bytes(1536), "1.5 KB"); + assert_eq!(format_bytes(300 * 1024 * 1024), "300 MB"); + assert_eq!(format_bytes(1_234_567), "1.18 MB"); + } +} diff --git a/cli-rs/crates/wisp-ui/src/live.rs b/cli-rs/crates/wisp-ui/src/live.rs new file mode 100644 index 0000000..f7cc05e --- /dev/null +++ b/cli-rs/crates/wisp-ui/src/live.rs @@ -0,0 +1,299 @@ +//! Live-region elements and how they render. Rendering is a pure function of +//! the element, the available width and the animation clock. + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; + +use crate::prompt::Prompt; +use crate::theme::{self, ColorSupport, accent_style, danger_style, muted_style, s}; + +/// One animation step, in milliseconds. +pub(crate) const TICK_MS: u64 = 80; + +const SPINNER_FRAMES: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + +/// In-flight rows a progress element shows before summarising the rest. +const MAX_VISIBLE_ITEMS: usize = 4; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PanelStatus { + /// Healthy and waiting for events: a slowly breathing dot. + Live, + /// Doing something right now: a spinner. + Busy, + /// Something is wrong: a red cross. + Error, + /// Nothing happening: a hollow dot. + Idle, +} + +/// Which way in-flight items are moving; picks the row glyph. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Direction { + Up, + Down, +} + +impl Direction { + fn glyph(self) -> &'static str { + match self { + Self::Up => "↑ ", + Self::Down => "↓ ", + } + } +} + +pub(crate) struct Item { + pub id: u64, + pub name: String, + pub detail: String, +} + +pub(crate) struct ProgressState { + pub label: String, + pub total: u64, + pub done: u64, + pub note: String, + pub items: Vec, + pub direction: Direction, +} + +pub(crate) enum Node { + Spinner(String), + Progress(ProgressState), + Panel { + status: PanelStatus, + lines: Vec>, + }, + Prompt(Prompt), +} + +impl Node { + pub(crate) fn spinner(text: &str) -> Self { + Self::Spinner(text.to_owned()) + } + + pub(crate) fn progress(label: String, total: u64, direction: Direction) -> Self { + Self::Progress(ProgressState { + label, + total, + done: 0, + note: String::new(), + items: Vec::new(), + direction, + }) + } + + pub(crate) fn animated(&self) -> bool { + match self { + Self::Spinner(_) | Self::Progress(_) => true, + Self::Panel { status, .. } => matches!(status, PanelStatus::Live | PanelStatus::Busy), + Self::Prompt(_) => false, + } + } + + pub(crate) fn render(&self, width: u16, tick: u64) -> Vec> { + match self { + Self::Spinner(text) => { + vec![Line::from(vec![ + spinner_frame(tick), + Span::raw(" "), + Span::raw(text.clone()), + ])] + } + Self::Progress(state) => render_progress(state, width, tick), + Self::Panel { status, lines } => render_panel(*status, lines, tick), + Self::Prompt(prompt) => prompt.render(), + } + } +} + +fn spinner_frame(tick: u64) -> Span<'static> { + s::accent(SPINNER_FRAMES[(tick as usize) % SPINNER_FRAMES.len()]) +} + +/// A dot that fades between the accent and muted colours, one breath every +/// ~2.5s. Terminals without truecolor get a steady accent dot instead of an +/// abrupt two-colour blink. +fn breathing_dot(tick: u64) -> Span<'static> { + let palette = theme::palette(); + let color = match (theme::support(), palette.accent, palette.muted) { + (ColorSupport::TrueColor, Color::Rgb(ar, ag, ab), Color::Rgb(mr, mg, mb)) => { + let period = 32.0; + let phase = (tick as f64 % period) / period * std::f64::consts::TAU; + let t = (phase.cos() + 1.0) / 2.0; + let mix = |a: u8, m: u8| (m as f64 + (a as f64 - m as f64) * t).round() as u8; + Color::Rgb(mix(ar, mr), mix(ag, mg), mix(ab, mb)) + } + _ => palette.accent, + }; + let style = if theme::support() == ColorSupport::None { + Style::new() + } else { + Style::new().fg(color) + }; + Span::styled("●", style) +} + +fn render_panel(status: PanelStatus, lines: &[Line<'static>], tick: u64) -> Vec> { + let glyph = match status { + PanelStatus::Live => breathing_dot(tick), + PanelStatus::Busy => spinner_frame(tick), + PanelStatus::Error => Span::styled("✗", danger_style()), + PanelStatus::Idle => Span::styled("○", muted_style()), + }; + lines + .iter() + .enumerate() + .map(|(i, line)| { + let lead = if i == 0 { + vec![glyph.clone(), Span::raw(" ")] + } else { + vec![Span::raw(" ")] + }; + Line::from( + lead.into_iter() + .chain(line.spans.iter().cloned()) + .collect::>(), + ) + }) + .collect() +} + +/// `━━━━━━╸━━━━━`: filled cells in the accent colour, a half-cell head, the +/// rest muted. +fn bar(fraction: f64, cells: usize) -> Vec> { + let exact = fraction.clamp(0.0, 1.0) * cells as f64; + let full = exact.floor() as usize; + let half = full < cells && exact - full as f64 >= 0.5; + let rest = cells - full - usize::from(half); + let mut spans = vec![Span::styled("━".repeat(full), accent_style())]; + if half { + spans.push(Span::styled("╸", accent_style())); + } + spans.push(Span::styled( + "━".repeat(rest), + muted_style().add_modifier(Modifier::DIM), + )); + spans +} + +/// Shorten `text` to `max` display columns, keeping its end (the file name is +/// the informative part of a path). +pub(crate) fn truncate_start(text: &str, max: usize) -> String { + if text.width() <= max { + return text.to_owned(); + } + if max == 0 { + return String::new(); + } + let mut kept = Vec::new(); + let mut used = 1; // the ellipsis + for c in text.chars().rev() { + let w = c.width().unwrap_or(0); + if used + w > max { + break; + } + used += w; + kept.push(c); + } + std::iter::once('…').chain(kept.into_iter().rev()).collect() +} + +fn render_progress(state: &ProgressState, width: u16, tick: u64) -> Vec> { + let width = width as usize; + let counter = format!("{}/{}", state.done, state.total); + // "⠹ " + label + " " + bar + " " + counter + let fixed = 2 + state.label.width() + 2 + 2 + counter.width(); + let cells = width.saturating_sub(fixed).clamp(0, 32); + let fraction = if state.total == 0 { + 0.0 + } else { + state.done as f64 / state.total as f64 + }; + + let mut head = vec![ + spinner_frame(tick), + Span::raw(" "), + s::bold(state.label.clone()), + ]; + if cells >= 8 { + head.push(Span::raw(" ")); + head.extend(bar(fraction, cells)); + } + head.push(Span::raw(" ")); + head.push(Span::raw(counter)); + let mut lines = vec![Line::from(head)]; + + let name_room = width.saturating_sub(4); + for item in state.items.iter().take(MAX_VISIBLE_ITEMS) { + let detail_width = item.detail.width(); + let name_width = name_room.saturating_sub(detail_width + 2).min(48); + let name = truncate_start(&item.name, name_width); + let gap = name_width.saturating_sub(name.width()) + 2; + lines.push(Line::from(vec![ + Span::raw(" "), + s::accent(state.direction.glyph()), + Span::raw(name), + Span::raw(" ".repeat(gap)), + s::muted(item.detail.clone()), + ])); + } + let hidden = state.items.len().saturating_sub(MAX_VISIBLE_ITEMS); + let note = match (hidden, state.note.is_empty()) { + (0, true) => None, + (0, false) => Some(state.note.clone()), + (n, true) => Some(format!("+{n} more in flight")), + (n, false) => Some(format!("+{n} more in flight · {}", state.note)), + }; + if let Some(note) = note { + lines.push(Line::from(vec![Span::raw(" "), s::muted(note)])); + } + lines +} + +#[cfg(test)] +mod tests { + use super::*; + + fn text(lines: &[Line<'_>]) -> Vec { + lines.iter().map(ToString::to_string).collect() + } + + #[test] + fn truncates_from_the_start() { + assert_eq!(truncate_start("assets/js/app.js", 10), "…js/app.js"); + assert_eq!(truncate_start("short", 10), "short"); + } + + #[test] + fn progress_shows_bar_items_and_note() { + let mut state = ProgressState { + label: "Uploading".into(), + total: 10, + done: 5, + note: "3 uploaded · 2 reused".into(), + items: Vec::new(), + direction: Direction::Up, + }; + for i in 0..6 { + state.items.push(Item { + id: i, + name: format!("file-{i}.js"), + detail: "1 KB".into(), + }); + } + let out = text(&render_progress(&state, 60, 0)); + assert!(out[0].contains("Uploading") && out[0].ends_with("5/10")); + assert_eq!(out.len(), 1 + MAX_VISIBLE_ITEMS + 1); + assert_eq!(out[5], " +2 more in flight · 3 uploaded · 2 reused"); + } + + #[test] + fn bar_fills_proportionally() { + let cells: String = bar(0.5, 10).iter().map(|s| s.content.as_ref()).collect(); + assert_eq!(cells, "━━━━━━━━━━"); + assert_eq!(bar(0.55, 10)[1].content, "╸"); + } +} diff --git a/cli-rs/crates/wisp-ui/src/prompt.rs b/cli-rs/crates/wisp-ui/src/prompt.rs new file mode 100644 index 0000000..4ef564e --- /dev/null +++ b/cli-rs/crates/wisp-ui/src/prompt.rs @@ -0,0 +1,645 @@ +//! Prompt state machines. Pure: a key goes in, a new state or an outcome comes +//! out, and rendering is a function of the state. The runtime owns the I/O. + +use std::fmt; + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthStr; + +use crate::theme::{accent_style, danger_style, muted_style, s}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PromptError { + /// The user pressed Esc or Ctrl-C. + Cancelled, + /// There is no terminal to ask on (CI, piped stdin). The caller should + /// tell the user which flag supplies the value instead. + NotInteractive, +} + +impl fmt::Display for PromptError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Cancelled => f.write_str("cancelled"), + Self::NotInteractive => f.write_str("no terminal to prompt on"), + } + } +} + +impl std::error::Error for PromptError {} + +pub struct Choice { + pub value: T, + pub label: String, + pub hint: Option, +} + +impl Choice { + pub fn new(value: T, label: impl Into) -> Self { + Self { + value, + label: label.into(), + hint: None, + } + } + + pub fn hint(mut self, hint: impl Into) -> Self { + self.hint = Some(hint.into()); + self + } + + pub(crate) fn display(&self) -> (String, Option) { + (self.label.clone(), self.hint.clone()) + } +} + +type Validator = Box Result<(), String> + Send>; + +/// Builder for [`crate::text`]. +pub struct TextPrompt { + message: String, + placeholder: Option, + default: Option, + validate: Option, +} + +impl TextPrompt { + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + placeholder: None, + default: None, + validate: None, + } + } + + pub fn placeholder(mut self, placeholder: impl Into) -> Self { + self.placeholder = Some(placeholder.into()); + self + } + + /// Submitted when the user presses enter on an empty input. + pub fn default(mut self, default: impl Into) -> Self { + self.default = Some(default.into()); + self + } + + /// Runs on submit; an `Err` message is shown under the input. + pub fn validate(mut self, f: impl Fn(&str) -> Result<(), String> + Send + 'static) -> Self { + self.validate = Some(Box::new(f)); + self + } + + pub(crate) fn into_state(self) -> TextState { + TextState { + message: self.message, + placeholder: self.placeholder, + default: self.default, + validate: self.validate, + input: Vec::new(), + cursor: 0, + error: None, + } + } +} + +pub(crate) struct TextState { + message: String, + placeholder: Option, + default: Option, + validate: Option, + input: Vec, + cursor: usize, + error: Option, +} + +pub(crate) struct SelectState { + message: String, + options: Vec<(String, Option)>, + selected: usize, +} + +pub(crate) struct ConfirmState { + message: String, + value: bool, +} + +pub(crate) enum Prompt { + Text(TextState), + Select(SelectState), + Confirm(ConfirmState), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Answer { + Text(String), + Index(usize), + Bool(bool), +} + +impl Answer { + pub(crate) fn into_text(self) -> String { + match self { + Self::Text(text) => text, + _ => unreachable!("text prompt answered with a non-text value"), + } + } + pub(crate) fn into_index(self) -> usize { + match self { + Self::Index(index) => index, + _ => unreachable!("select prompt answered with a non-index value"), + } + } + pub(crate) fn into_bool(self) -> bool { + match self { + Self::Bool(value) => value, + _ => unreachable!("confirm prompt answered with a non-bool value"), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum Outcome { + Pending, + Done(Answer), + Cancelled, +} + +/// Rows of options a select shows at once before it scrolls. +const SELECT_WINDOW: usize = 8; + +impl SelectState { + pub(crate) fn new(message: &str, options: Vec<(String, Option)>) -> Self { + Self { + message: message.to_owned(), + options, + selected: 0, + } + } +} + +impl ConfirmState { + pub(crate) fn new(message: &str, initial: bool) -> Self { + Self { + message: message.to_owned(), + value: initial, + } + } +} + +fn is_cancel(key: &KeyEvent) -> bool { + key.code == KeyCode::Esc + || (key.modifiers.contains(KeyModifiers::CONTROL) + && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d'))) +} + +impl TextState { + fn value(&self) -> String { + self.input.iter().collect() + } + + fn submit(&mut self) -> Outcome { + let typed = self.value(); + let value = match (&self.default, typed.is_empty()) { + (Some(default), true) => default.clone(), + _ => typed, + }; + match self.validate.as_ref().map(|f| f(&value)) { + Some(Err(message)) => { + self.error = Some(message); + Outcome::Pending + } + _ => Outcome::Done(Answer::Text(value)), + } + } + + fn delete_word_before_cursor(&mut self) { + let mut start = self.cursor; + while start > 0 && self.input[start - 1].is_whitespace() { + start -= 1; + } + while start > 0 && !self.input[start - 1].is_whitespace() { + start -= 1; + } + self.input.drain(start..self.cursor); + self.cursor = start; + } + + fn handle(&mut self, key: KeyEvent) -> Outcome { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + KeyCode::Enter => return self.submit(), + KeyCode::Char('a') if ctrl => self.cursor = 0, + KeyCode::Char('e') if ctrl => self.cursor = self.input.len(), + KeyCode::Char('u') if ctrl => { + self.input.drain(..self.cursor); + self.cursor = 0; + } + KeyCode::Char('w') if ctrl => self.delete_word_before_cursor(), + KeyCode::Char(c) if !ctrl => { + self.input.insert(self.cursor, c); + self.cursor += 1; + } + KeyCode::Backspace if self.cursor > 0 => { + self.cursor -= 1; + self.input.remove(self.cursor); + } + KeyCode::Delete if self.cursor < self.input.len() => { + self.input.remove(self.cursor); + } + KeyCode::Left => self.cursor = self.cursor.saturating_sub(1), + KeyCode::Right => self.cursor = (self.cursor + 1).min(self.input.len()), + KeyCode::Home => self.cursor = 0, + KeyCode::End => self.cursor = self.input.len(), + _ => return Outcome::Pending, + } + self.error = None; + Outcome::Pending + } + + fn paste(&mut self, text: &str) { + let chars: Vec = text.chars().filter(|c| !c.is_control()).collect(); + let n = chars.len(); + self.input.splice(self.cursor..self.cursor, chars); + self.cursor += n; + self.error = None; + } + + fn render(&self) -> Vec> { + let cursor_style = Style::new().add_modifier(Modifier::REVERSED); + let input_line = if self.input.is_empty() { + let placeholder = self.placeholder.clone().or_else(|| self.default.clone()); + match placeholder { + Some(p) => { + let mut chars = p.chars(); + let first = chars.next().map(String::from).unwrap_or_else(|| " ".into()); + Line::from(vec![ + Span::raw(" "), + Span::styled(first, cursor_style.patch(muted_style())), + s::muted(chars.collect::()), + ]) + } + None => Line::from(vec![Span::raw(" "), Span::styled(" ", cursor_style)]), + } + } else { + let before: String = self.input[..self.cursor].iter().collect(); + let at: String = self + .input + .get(self.cursor) + .map(|c| c.to_string()) + .unwrap_or_else(|| " ".into()); + let after: String = self + .input + .get(self.cursor + 1..) + .map(|r| r.iter().collect()) + .unwrap_or_default(); + Line::from(vec![ + Span::raw(" "), + Span::raw(before), + Span::styled(at, cursor_style), + Span::raw(after), + ]) + }; + let mut lines = vec![active_header(&self.message), input_line]; + if let Some(error) = &self.error { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(error.clone(), danger_style()), + ])); + } + lines + } +} + +impl SelectState { + fn handle(&mut self, key: KeyEvent) -> Outcome { + let last = self.options.len().saturating_sub(1); + match key.code { + KeyCode::Enter => return Outcome::Done(Answer::Index(self.selected)), + KeyCode::Up | KeyCode::Char('k') | KeyCode::BackTab => { + self.selected = if self.selected == 0 { + last + } else { + self.selected - 1 + }; + } + KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => { + self.selected = if self.selected >= last { + 0 + } else { + self.selected + 1 + }; + } + KeyCode::Home | KeyCode::PageUp => self.selected = 0, + KeyCode::End | KeyCode::PageDown => self.selected = last, + _ => {} + } + Outcome::Pending + } + + /// First visible option, keeping the selection inside the window. + fn window_start(&self) -> usize { + let len = self.options.len(); + if len <= SELECT_WINDOW { + return 0; + } + let half = SELECT_WINDOW / 2; + self.selected.saturating_sub(half).min(len - SELECT_WINDOW) + } + + fn render(&self) -> Vec> { + let start = self.window_start(); + let end = (start + SELECT_WINDOW).min(self.options.len()); + let label_width = self.options[start..end] + .iter() + .map(|(l, _)| l.width()) + .max() + .unwrap_or(0); + + let mut lines = vec![active_header(&self.message)]; + if start > 0 { + lines.push(Line::from(vec![ + Span::raw(" "), + s::muted(format!("↑ {start} more")), + ])); + } + for (index, (label, hint)) in self.options.iter().enumerate().take(end).skip(start) { + let focused = index == self.selected; + let pad = " ".repeat(label_width - label.width()); + let mut spans = if focused { + vec![ + Span::raw(" "), + s::accent("› "), + Span::styled(label.clone(), accent_style().add_modifier(Modifier::BOLD)), + ] + } else { + vec![Span::raw(" "), Span::raw(label.clone())] + }; + if let Some(hint) = hint { + spans.push(Span::raw(pad)); + spans.push(Span::raw(" ")); + spans.push(s::muted(hint.clone())); + } + lines.push(Line::from(spans)); + } + let below = self.options.len() - end; + if below > 0 { + lines.push(Line::from(vec![ + Span::raw(" "), + s::muted(format!("↓ {below} more")), + ])); + } + lines.push(Line::from(vec![ + Span::raw(" "), + s::muted("↑↓ move · enter select · esc cancel"), + ])); + lines + } +} + +impl ConfirmState { + fn handle(&mut self, key: KeyEvent) -> Outcome { + match key.code { + KeyCode::Enter => return Outcome::Done(Answer::Bool(self.value)), + KeyCode::Char('y') | KeyCode::Char('Y') => return Outcome::Done(Answer::Bool(true)), + KeyCode::Char('n') | KeyCode::Char('N') => return Outcome::Done(Answer::Bool(false)), + KeyCode::Left + | KeyCode::Right + | KeyCode::Up + | KeyCode::Down + | KeyCode::Tab + | KeyCode::BackTab => { + self.value = !self.value; + } + KeyCode::Char('h') | KeyCode::Char('k') => self.value = true, + KeyCode::Char('l') | KeyCode::Char('j') => self.value = false, + _ => {} + } + Outcome::Pending + } + + fn render(&self) -> Vec> { + let option = |label: &'static str, on: bool| -> Vec> { + if on { + vec![ + s::accent("● "), + Span::styled(label, Style::new().add_modifier(Modifier::BOLD)), + ] + } else { + vec![s::muted("○ "), s::muted(label)] + } + }; + let mut spans = vec![Span::raw(" ")]; + spans.extend(option("Yes", self.value)); + spans.push(Span::raw(" ")); + spans.extend(option("No", !self.value)); + vec![active_header(&self.message), Line::from(spans)] + } +} + +fn active_header(message: &str) -> Line<'static> { + Line::from(vec![s::accent("◆ "), s::bold(message.to_owned())]) +} + +impl Prompt { + pub(crate) fn handle_key(&mut self, key: KeyEvent) -> Outcome { + if key.kind == KeyEventKind::Release { + return Outcome::Pending; + } + if is_cancel(&key) { + return Outcome::Cancelled; + } + match self { + Self::Text(state) => state.handle(key), + Self::Select(state) => state.handle(key), + Self::Confirm(state) => state.handle(key), + } + } + + pub(crate) fn paste(&mut self, text: &str) { + if let Self::Text(state) = self { + state.paste(text); + } + } + + pub(crate) fn render(&self) -> Vec> { + match self { + Self::Text(state) => state.render(), + Self::Select(state) => state.render(), + Self::Confirm(state) => state.render(), + } + } + + fn message(&self) -> &str { + match self { + Self::Text(state) => &state.message, + Self::Select(state) => &state.message, + Self::Confirm(state) => &state.message, + } + } + + /// The single line a finished prompt collapses into. + pub(crate) fn summary(&self, answer: &Answer) -> Line<'static> { + let value = match (self, answer) { + (Self::Select(state), Answer::Index(i)) => state.options[*i].0.clone(), + (_, Answer::Text(text)) => text.clone(), + (_, Answer::Bool(true)) => "Yes".into(), + (_, Answer::Bool(false)) => "No".into(), + (_, Answer::Index(i)) => i.to_string(), + }; + Line::from(vec![ + s::muted("◇ "), + s::muted(self.message().to_owned()), + Span::raw(" "), + Span::raw(value), + ]) + } + + pub(crate) fn cancelled_summary(&self) -> Line<'static> { + Line::from(vec![ + s::muted("■ "), + Span::styled( + self.message().to_owned(), + muted_style().add_modifier(Modifier::CROSSED_OUT), + ), + ]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn ctrl(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) + } + + fn type_str(prompt: &mut Prompt, text: &str) { + for c in text.chars() { + assert_eq!(prompt.handle_key(key(KeyCode::Char(c))), Outcome::Pending); + } + } + + #[test] + fn text_edits_and_submits() { + let mut p = Prompt::Text(TextPrompt::new("Site").into_state()); + type_str(&mut p, "my blg"); + p.handle_key(key(KeyCode::Left)); + type_str(&mut p, "o"); + assert_eq!( + p.handle_key(key(KeyCode::Enter)), + Outcome::Done(Answer::Text("my blog".into())) + ); + } + + #[test] + fn text_uses_default_when_empty() { + let mut p = Prompt::Text(TextPrompt::new("Dir").default(".").into_state()); + assert_eq!( + p.handle_key(key(KeyCode::Enter)), + Outcome::Done(Answer::Text(".".into())) + ); + } + + #[test] + fn text_validation_blocks_until_fixed() { + let mut p = Prompt::Text( + TextPrompt::new("Handle") + .validate(|v| { + if v.contains('.') { + Ok(()) + } else { + Err("needs a dot".into()) + } + }) + .into_state(), + ); + type_str(&mut p, "alice"); + assert_eq!(p.handle_key(key(KeyCode::Enter)), Outcome::Pending); + assert!( + p.render() + .iter() + .any(|l| l.to_string().contains("needs a dot")) + ); + type_str(&mut p, ".test"); + assert!( + !p.render() + .iter() + .any(|l| l.to_string().contains("needs a dot")) + ); + assert_eq!( + p.handle_key(key(KeyCode::Enter)), + Outcome::Done(Answer::Text("alice.test".into())) + ); + } + + #[test] + fn text_ctrl_w_and_ctrl_u() { + let mut p = Prompt::Text(TextPrompt::new("x").into_state()); + type_str(&mut p, "one two "); + p.handle_key(ctrl('w')); + assert_eq!( + p.handle_key(key(KeyCode::Enter)), + Outcome::Done(Answer::Text("one ".into())) + ); + let mut p = Prompt::Text(TextPrompt::new("x").into_state()); + type_str(&mut p, "abc"); + p.handle_key(ctrl('u')); + p.paste("x\ny"); + assert_eq!( + p.handle_key(key(KeyCode::Enter)), + Outcome::Done(Answer::Text("xy".into())) + ); + } + + #[test] + fn escape_and_ctrl_c_cancel() { + let mut p = Prompt::Confirm(ConfirmState::new("ok?", true)); + assert_eq!(p.handle_key(key(KeyCode::Esc)), Outcome::Cancelled); + assert_eq!(p.handle_key(ctrl('c')), Outcome::Cancelled); + } + + #[test] + fn select_wraps_and_scrolls() { + let options = (0..20).map(|i| (format!("site-{i}"), None)).collect(); + let mut p = Prompt::Select(SelectState::new("pick", options)); + p.handle_key(key(KeyCode::Up)); + let rendered: Vec = p.render().iter().map(ToString::to_string).collect(); + assert!(rendered.iter().any(|l| l.contains("› site-19"))); + assert!(rendered.iter().any(|l| l.contains("↑ 12 more"))); + assert_eq!( + p.handle_key(key(KeyCode::Enter)), + Outcome::Done(Answer::Index(19)) + ); + } + + #[test] + fn confirm_shortcuts() { + let mut p = Prompt::Confirm(ConfirmState::new("ok?", true)); + assert_eq!( + p.handle_key(key(KeyCode::Char('n'))), + Outcome::Done(Answer::Bool(false)) + ); + let mut p = Prompt::Confirm(ConfirmState::new("ok?", true)); + p.handle_key(key(KeyCode::Right)); + assert_eq!( + p.handle_key(key(KeyCode::Enter)), + Outcome::Done(Answer::Bool(false)) + ); + } + + #[test] + fn summaries_collapse_to_one_line() { + let p = Prompt::Select(SelectState::new( + "List", + vec![("Domains".into(), None), ("Sites".into(), None)], + )); + assert_eq!(p.summary(&Answer::Index(1)).to_string(), "◇ List Sites"); + } +} diff --git a/cli-rs/crates/wisp-ui/src/runtime.rs b/cli-rs/crates/wisp-ui/src/runtime.rs new file mode 100644 index 0000000..6f1cca1 --- /dev/null +++ b/cli-rs/crates/wisp-ui/src/runtime.rs @@ -0,0 +1,471 @@ +//! The render thread. It owns the terminal: every print, live update and +//! prompt is a message to it, so output ordering is the order of calls and the +//! live region is never overwritten by a stray write. + +use std::collections::BTreeMap; +use std::io::{self, IsTerminal, Stderr, Write}; +use std::sync::Mutex; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, Sender}; +use std::thread::JoinHandle; +use std::time::{Duration, Instant}; + +use crossterm::event::{self, DisableBracketedPaste, EnableBracketedPaste, Event}; +use crossterm::{cursor, execute, terminal}; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::Position; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Paragraph, Widget}; +use ratatui::{Terminal, TerminalOptions, Viewport}; +use unicode_width::UnicodeWidthStr; + +use crate::live::{Node, TICK_MS}; +use crate::prompt::{Answer, Outcome, Prompt, PromptError}; +use crate::{Mode, Options, ansi, theme}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Target { + Stdout, + Stderr, +} + +type Reply = Sender>; + +enum Msg { + Print(Target, Vec>), + Upsert(u64, Node), + Update(u64, Box), + Remove(u64, Vec>), + Prompt(u64, Prompt, Reply), + Shutdown(Sender<()>), +} + +pub(crate) struct Runtime { + mode: Mode, + can_prompt: bool, + stdout_tty: bool, + stderr_tty: bool, + tx: Mutex>>, + thread: Mutex>>, +} + +fn env_flag(name: &str, value: &str) -> bool { + std::env::var(name).is_ok_and(|v| v == value) +} + +impl Runtime { + pub(crate) fn start(options: Options) -> Self { + let stdout_tty = io::stdout().is_terminal(); + let stderr_tty = io::stderr().is_terminal(); + let dumb = env_flag("TERM", "dumb"); + let quiet = options.quiet || env_flag("WISPCTL_NO_PROGRESS", "1") || env_flag("CI", "true"); + let interactive = stderr_tty && !dumb; + + if !stderr_tty && !stdout_tty { + theme::set_support(theme::ColorSupport::None); + } + + let mode = if interactive && !quiet { + Mode::Rich + } else { + Mode::Plain + }; + let (tx, thread) = if interactive { + let (tx, rx) = mpsc::channel(); + let thread = std::thread::Builder::new() + .name("wisp-ui".into()) + .spawn(move || Renderer::new(stdout_tty).run(rx)) + .ok(); + install_panic_hook(); + (Some(tx), thread) + } else { + (None, None) + }; + + Self { + mode, + can_prompt: interactive && io::stdin().is_terminal(), + stdout_tty, + stderr_tty, + tx: Mutex::new(tx), + thread: Mutex::new(thread), + } + } + + pub(crate) fn mode(&self) -> Mode { + self.mode + } + + pub(crate) fn can_prompt(&self) -> bool { + self.can_prompt && self.tx.lock().is_ok_and(|tx| tx.is_some()) + } + + /// Hand `msg` to the renderer, or give it back when none is running. + fn send(&self, msg: Msg) -> Option { + let guard = self.tx.lock().ok(); + match guard.as_ref().and_then(|tx| tx.as_ref()) { + Some(tx) => tx.send(msg).err().map(|e| e.0), + None => Some(msg), + } + } + + pub(crate) fn print(&self, target: Target, lines: Vec>) { + if let Some(Msg::Print(target, lines)) = self.send(Msg::Print(target, lines)) { + let tty = match target { + Target::Stdout => self.stdout_tty, + Target::Stderr => self.stderr_tty, + }; + write_direct(target, &lines, tty); + } + } + + pub(crate) fn upsert(&self, id: u64, node: Node) { + if self.mode == Mode::Rich { + self.send(Msg::Upsert(id, node)); + } + } + + pub(crate) fn update(&self, id: u64, f: impl FnOnce(&mut Node) + Send + 'static) { + if self.mode == Mode::Rich { + self.send(Msg::Update(id, Box::new(f))); + } + } + + pub(crate) fn remove(&self, id: u64, lines: Vec>) { + if self.mode == Mode::Rich { + self.send(Msg::Remove(id, lines)); + } else if !lines.is_empty() { + self.print(Target::Stderr, lines); + } + } + + pub(crate) fn prompt(&self, prompt: Prompt) -> Result { + if !self.can_prompt { + return Err(PromptError::NotInteractive); + } + let (reply, answer) = mpsc::channel(); + if self + .send(Msg::Prompt(crate::next_id(), prompt, reply)) + .is_some() + { + return Err(PromptError::NotInteractive); + } + answer.recv().unwrap_or(Err(PromptError::Cancelled)) + } + + pub(crate) fn shutdown(&self) { + let Some(tx) = self.tx.lock().ok().and_then(|mut tx| tx.take()) else { + return; + }; + let (done, wait) = mpsc::channel(); + if tx.send(Msg::Shutdown(done)).is_ok() { + let _ = wait.recv_timeout(Duration::from_secs(1)); + } + if let Some(thread) = self.thread.lock().ok().and_then(|mut t| t.take()) { + let _ = thread.join(); + } + } +} + +fn write_direct(target: Target, lines: &[Line<'static>], tty: bool) { + let styled = tty && theme::support() != theme::ColorSupport::None; + let text: String = lines + .iter() + .map(|l| ansi::render(l, styled) + "\n") + .collect(); + let _ = match target { + Target::Stdout => io::stdout().lock().write_all(text.as_bytes()), + Target::Stderr => io::stderr().lock().write_all(text.as_bytes()), + }; +} + +/// Put the terminal back if the process panics mid-render. +fn install_panic_hook() { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = terminal::disable_raw_mode(); + let _ = execute!(io::stderr(), DisableBracketedPaste, cursor::Show); + previous(info); + })); +} + +/// Hard-wrap a line to `width` columns so inserted scrollback text takes +/// exactly the rows we reserve for it. +fn wrap(line: &Line<'static>, width: usize) -> Vec> { + if width == 0 || line.width() <= width { + return vec![line.clone()]; + } + let mut rows = vec![Vec::new()]; + let mut used = 0; + for span in &line.spans { + let mut chunk = String::new(); + for c in span.content.chars() { + let w = UnicodeWidthStr::width(c.encode_utf8(&mut [0; 4]) as &str); + if used + w > width { + if !chunk.is_empty() { + rows.last_mut() + .unwrap() + .push(Span::styled(std::mem::take(&mut chunk), span.style)); + } + rows.push(Vec::new()); + used = 0; + } + chunk.push(c); + used += w; + } + if !chunk.is_empty() { + rows.last_mut() + .unwrap() + .push(Span::styled(chunk, span.style)); + } + } + rows.into_iter().map(Line::from).collect() +} + +struct ActivePrompt { + id: u64, + reply: Reply, +} + +struct Renderer { + term: Option>>, + height: u16, + nodes: BTreeMap, + prompt: Option, + raw: bool, + started: Instant, + stdout_tty: bool, +} + +impl Renderer { + fn new(stdout_tty: bool) -> Self { + Self { + term: None, + height: 0, + nodes: BTreeMap::new(), + prompt: None, + raw: false, + started: Instant::now(), + stdout_tty, + } + } + + fn tick(&self) -> u64 { + self.started.elapsed().as_millis() as u64 / TICK_MS + } + + fn run(mut self, rx: Receiver) { + loop { + let wait = if self.prompt.is_some() { + Duration::from_millis(15) + } else if self.nodes.values().any(Node::animated) { + Duration::from_millis(TICK_MS) + } else { + Duration::from_millis(500) + }; + match rx.recv_timeout(wait) { + Ok(msg) => { + if !self.handle(msg) { + return; + } + while let Ok(msg) = rx.try_recv() { + if !self.handle(msg) { + return; + } + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => { + self.teardown(); + return; + } + } + self.poll_input(); + self.draw(); + } + } + + /// Returns false once the renderer has shut down. + fn handle(&mut self, msg: Msg) -> bool { + match msg { + Msg::Print(target, lines) => self.print(target, lines), + Msg::Upsert(id, node) => { + self.nodes.insert(id, node); + } + Msg::Update(id, f) => { + if let Some(node) = self.nodes.get_mut(&id) { + f(node); + } + } + Msg::Remove(id, lines) => self.remove(id, lines), + Msg::Prompt(id, prompt, reply) => self.start_prompt(id, prompt, reply), + Msg::Shutdown(done) => { + self.teardown(); + let _ = done.send(()); + return false; + } + } + true + } + + fn width(&self) -> u16 { + terminal::size().map(|(w, _)| w).unwrap_or(80) + } + + fn render_nodes(&self) -> Vec> { + let (width, tick) = (self.width(), self.tick()); + self.nodes + .values() + .flat_map(|node| node.render(width, tick)) + .collect() + } + + /// Resize the inline viewport to `height` rows, recreating the terminal + /// at the top of the old region (ratatui fixes an inline viewport's height + /// at construction). + fn set_height(&mut self, height: u16) { + if height == self.height && (height == 0) == self.term.is_none() { + return; + } + if let Some(mut term) = self.term.take() { + let top = term.get_frame().area().y; + let _ = term.clear(); + let _ = term.set_cursor_position(Position::new(0, top)); + } + self.height = height; + if height > 0 { + let backend = CrosstermBackend::new(io::stderr()); + let options = TerminalOptions { + viewport: Viewport::Inline(height), + }; + self.term = Terminal::with_options(backend, options).ok(); + } else { + let _ = execute!(io::stderr(), cursor::Show); + } + } + + fn draw(&mut self) { + let lines = self.render_nodes(); + let rows = terminal::size().map(|(_, h)| h).unwrap_or(24); + let height = (lines.len() as u16).min(rows.saturating_sub(1)); + self.set_height(height); + if let Some(term) = self.term.as_mut() { + let _ = term.draw(|frame| { + let area = frame.area(); + Paragraph::new(lines).render(area, frame.buffer_mut()); + }); + } + } + + fn print(&mut self, target: Target, lines: Vec>) { + let through_viewport = match target { + Target::Stderr => true, + Target::Stdout => self.stdout_tty, + }; + match self.term.as_mut() { + Some(term) if through_viewport => { + let width = term.size().map(|s| s.width).unwrap_or(80) as usize; + let rows: Vec> = lines.iter().flat_map(|l| wrap(l, width)).collect(); + // insert_before draws at most a screenful per call. + for chunk in rows.chunks(200) { + let chunk = chunk.to_vec(); + let _ = term.insert_before(chunk.len() as u16, |buf| { + Paragraph::new(chunk).render(buf.area, buf); + }); + } + } + _ => { + let tty = match target { + Target::Stdout => self.stdout_tty, + Target::Stderr => true, + }; + if self.raw { + let _ = terminal::disable_raw_mode(); + } + write_direct(target, &lines, tty); + if self.raw { + let _ = terminal::enable_raw_mode(); + } + } + } + } + + fn remove(&mut self, id: u64, lines: Vec>) { + if self.nodes.remove(&id).is_none() && lines.is_empty() { + return; + } + self.draw(); + if !lines.is_empty() { + self.print(Target::Stderr, lines); + } + } + + fn start_prompt(&mut self, id: u64, prompt: Prompt, reply: Reply) { + if self.prompt.is_some() { + let _ = reply.send(Err(PromptError::NotInteractive)); + return; + } + if terminal::enable_raw_mode().is_err() { + let _ = reply.send(Err(PromptError::NotInteractive)); + return; + } + self.raw = true; + let _ = execute!(io::stderr(), EnableBracketedPaste); + self.nodes.insert(id, Node::Prompt(prompt)); + self.prompt = Some(ActivePrompt { id, reply }); + } + + fn finish_prompt(&mut self, outcome: Result) { + let Some(active) = self.prompt.take() else { + return; + }; + let summary = match (self.nodes.get(&active.id), &outcome) { + (Some(Node::Prompt(prompt)), Ok(answer)) => prompt.summary(answer), + (Some(Node::Prompt(prompt)), Err(_)) => prompt.cancelled_summary(), + _ => Line::default(), + }; + let _ = execute!(io::stderr(), DisableBracketedPaste); + let _ = terminal::disable_raw_mode(); + self.raw = false; + self.remove(active.id, vec![summary]); + let _ = active.reply.send(outcome); + } + + fn poll_input(&mut self) { + let Some(id) = self.prompt.as_ref().map(|p| p.id) else { + return; + }; + while event::poll(Duration::ZERO).unwrap_or(false) { + let Ok(event) = event::read() else { break }; + let Some(Node::Prompt(prompt)) = self.nodes.get_mut(&id) else { + return; + }; + let outcome = match event { + Event::Key(key) => prompt.handle_key(key), + Event::Paste(text) => { + prompt.paste(&text); + Outcome::Pending + } + _ => Outcome::Pending, + }; + match outcome { + Outcome::Pending => {} + Outcome::Done(answer) => return self.finish_prompt(Ok(answer)), + Outcome::Cancelled => return self.finish_prompt(Err(PromptError::Cancelled)), + } + } + } + + fn teardown(&mut self) { + if self.prompt.is_some() { + self.finish_prompt(Err(PromptError::Cancelled)); + } + self.nodes.clear(); + self.draw(); + self.set_height(0); + if self.raw { + let _ = terminal::disable_raw_mode(); + } + let _ = execute!(io::stderr(), cursor::Show); + let _ = io::stderr().flush(); + } +} diff --git a/cli-rs/crates/wisp-ui/src/theme.rs b/cli-rs/crates/wisp-ui/src/theme.rs new file mode 100644 index 0000000..1c74085 --- /dev/null +++ b/cli-rs/crates/wisp-ui/src/theme.rs @@ -0,0 +1,173 @@ +//! Colour palette and span helpers. +//! +//! Colours come from the wisp.place web palette, lifted to a lightness that +//! reads on both dark and light terminals. Terminals without truecolor get the +//! nearest ANSI colour so user themes still apply, and `NO_COLOR` turns every +//! colour off while keeping bold/dim emphasis. + +use std::sync::OnceLock; + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::Span; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColorSupport { + None, + Ansi, + TrueColor, +} + +#[derive(Clone, Copy, Debug)] +pub struct Palette { + pub accent: Color, + pub secondary: Color, + pub success: Color, + pub danger: Color, + pub warning: Color, + pub info: Color, + pub muted: Color, +} + +impl Palette { + pub const fn for_support(support: ColorSupport) -> Self { + match support { + ColorSupport::TrueColor => Self { + accent: Color::Rgb(0xea, 0x89, 0xa1), + secondary: Color::Rgb(0xa9, 0x98, 0xdd), + success: Color::Rgb(0x4c, 0xb8, 0x6a), + danger: Color::Rgb(0xec, 0x5b, 0x57), + warning: Color::Rgb(0xe3, 0xad, 0x4b), + info: Color::Rgb(0x4f, 0xa8, 0xe1), + muted: Color::Rgb(0x84, 0x85, 0x92), + }, + ColorSupport::Ansi => Self { + accent: Color::Magenta, + secondary: Color::LightBlue, + success: Color::Green, + danger: Color::Red, + warning: Color::Yellow, + info: Color::Cyan, + muted: Color::DarkGray, + }, + ColorSupport::None => Self { + accent: Color::Reset, + secondary: Color::Reset, + success: Color::Reset, + danger: Color::Reset, + warning: Color::Reset, + info: Color::Reset, + muted: Color::Reset, + }, + } + } +} + +fn env_is(name: &str, pred: impl Fn(&str) -> bool) -> bool { + std::env::var(name).is_ok_and(|v| pred(&v)) +} + +pub fn detect_color_support() -> ColorSupport { + if env_is("NO_COLOR", |v| !v.is_empty()) || env_is("TERM", |v| v == "dumb") { + return ColorSupport::None; + } + let truecolor = env_is("COLORTERM", |v| v == "truecolor" || v == "24bit") + || env_is("TERM_PROGRAM", |v| { + matches!(v, "iTerm.app" | "WezTerm" | "ghostty" | "vscode" | "Hyper") + }) + || env_is("WT_SESSION", |_| true); + if truecolor { + ColorSupport::TrueColor + } else { + ColorSupport::Ansi + } +} + +static SUPPORT: OnceLock = OnceLock::new(); + +pub(crate) fn set_support(support: ColorSupport) { + let _ = SUPPORT.set(support); +} + +pub fn support() -> ColorSupport { + *SUPPORT.get_or_init(detect_color_support) +} + +pub fn palette() -> Palette { + Palette::for_support(support()) +} + +fn fg(color: Color) -> Style { + if support() == ColorSupport::None { + Style::new() + } else { + Style::new().fg(color) + } +} + +pub fn accent_style() -> Style { + fg(palette().accent) +} +pub fn muted_style() -> Style { + match support() { + ColorSupport::None => Style::new().add_modifier(Modifier::DIM), + _ => fg(palette().muted), + } +} +pub fn success_style() -> Style { + fg(palette().success) +} +pub fn danger_style() -> Style { + fg(palette().danger) +} +pub fn warning_style() -> Style { + fg(palette().warning) +} +pub fn info_style() -> Style { + fg(palette().info) +} +pub fn secondary_style() -> Style { + fg(palette().secondary) +} + +/// Span constructors so callers can build lines without touching styles. +pub mod s { + use super::*; + use std::borrow::Cow; + + type Text = Cow<'static, str>; + + pub fn plain(text: impl Into) -> Span<'static> { + Span::raw(text.into()) + } + pub fn bold(text: impl Into) -> Span<'static> { + Span::styled(text.into(), Style::new().add_modifier(Modifier::BOLD)) + } + pub fn accent(text: impl Into) -> Span<'static> { + Span::styled(text.into(), accent_style()) + } + pub fn accent_bold(text: impl Into) -> Span<'static> { + Span::styled(text.into(), accent_style().add_modifier(Modifier::BOLD)) + } + pub fn secondary(text: impl Into) -> Span<'static> { + Span::styled(text.into(), secondary_style()) + } + pub fn muted(text: impl Into) -> Span<'static> { + Span::styled(text.into(), muted_style()) + } + pub fn ok(text: impl Into) -> Span<'static> { + Span::styled(text.into(), success_style()) + } + pub fn warn(text: impl Into) -> Span<'static> { + Span::styled(text.into(), warning_style()) + } + pub fn danger(text: impl Into) -> Span<'static> { + Span::styled(text.into(), danger_style()) + } + pub fn info(text: impl Into) -> Span<'static> { + Span::styled(text.into(), info_style()) + } + /// A URL or other value the user may want to copy. + pub fn link(text: impl Into) -> Span<'static> { + Span::styled(text.into(), info_style().add_modifier(Modifier::UNDERLINED)) + } +} diff --git a/cli-rs/crates/wispctl/Cargo.toml b/cli-rs/crates/wispctl/Cargo.toml new file mode 100644 index 0000000..3aa12cc --- /dev/null +++ b/cli-rs/crates/wispctl/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "wispctl" +description = "CLI for wisp.place - deploy static sites to the AT Protocol" +version.workspace = true +edition.workspace = true +license.workspace = true + +[[bin]] +name = "wispctl" +path = "src/main.rs" + +[dependencies] +wisp-core = { path = "../wisp-core" } +wisp-lexicons = { path = "../wisp-lexicons" } +wisp-ui = { path = "../wisp-ui" } +anyhow.workspace = true +clap.workspace = true +jacquard.workspace = true +jacquard-api.workspace = true +serde.workspace = true +serde_json.workspace = true +tokio.workspace = true +reqwest.workspace = true +rustls.workspace = true +base64.workspace = true +tempfile.workspace = true +futures-util.workspace = true +flate2.workspace = true +jacquard-lexicon.workspace = true +http.workspace = true +chrono.workspace = true +serde_html_form.workspace = true +webbrowser.workspace = true +hyper.workspace = true +hyper-util.workspace = true +http-body-util.workspace = true +bytes.workspace = true +mime_guess.workspace = true +jacquard-common.workspace = true +rusqlite.workspace = true +keyring.workspace = true diff --git a/cli-rs/crates/wispctl/src/auth/keychain.rs b/cli-rs/crates/wispctl/src/auth/keychain.rs new file mode 100644 index 0000000..8a45315 --- /dev/null +++ b/cli-rs/crates/wispctl/src/auth/keychain.rs @@ -0,0 +1,416 @@ +//! Native OS credentials. App passwords never fall back to plaintext SQLite. +use std::{ + collections::HashMap, + sync::{LazyLock, Mutex}, +}; + +pub const KEYCHAIN_SERVICE: &str = "wispctl"; +const PROBE_ACCOUNT: &str = "__wispctl_probe__"; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct KeychainProbeResult { + pub available: bool, + pub detail: Option, + pub module_available: bool, +} + +trait SecretEntry { + fn read(&self) -> Result, String>; + fn write(&self, value: &str) -> Result<(), String>; + fn remove(&self) -> Result<(), String>; +} + +impl SecretEntry for keyring::Entry { + fn read(&self) -> Result, String> { + match self.get_password() { + Ok(value) => Ok(Some(value)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(error) => Err(error.to_string()), + } + } + fn write(&self, value: &str) -> Result<(), String> { + self.set_password(value).map_err(|e| e.to_string()) + } + fn remove(&self) -> Result<(), String> { + self.delete_credential().map_err(|e| e.to_string()) + } +} + +struct Keychain { + entries: HashMap, + secrets: HashMap>, + probe: Option, +} + +impl Keychain { + fn new() -> Self { + Self { + entries: HashMap::new(), + secrets: HashMap::new(), + probe: None, + } + } + + fn entry( + &mut self, + account: &str, + create: impl FnOnce(&str) -> Result, + ) -> Result<&E, String> { + if !self.entries.contains_key(account) { + self.entries.insert(account.to_owned(), create(account)?); + } + Ok(&self.entries[account]) + } + + fn read( + &mut self, + account: &str, + create: impl FnOnce(&str) -> Result, + ) -> Option { + if let Some(value) = self.secrets.get(account) { + return value.clone(); + } + let value = self.entry(account, create).ok()?.read().ok().flatten(); + self.secrets.insert(account.to_owned(), value.clone()); + value + } + + fn write( + &mut self, + account: &str, + value: &str, + create: impl FnOnce(&str) -> Result, + ) -> bool { + if self + .entry(account, create) + .and_then(|entry| entry.write(value)) + .is_err() + { + return false; + } + self.secrets + .insert(account.to_owned(), Some(value.to_owned())); + true + } + + fn remove(&mut self, account: &str, create: impl FnOnce(&str) -> Result) { + if let Ok(entry) = self.entry(account, create) { + let _ = entry.remove(); + } + self.secrets.insert(account.to_owned(), None); + } + + fn probe( + &mut self, + module_available: bool, + create: impl FnOnce(&str) -> Result, + ) -> KeychainProbeResult { + if let Some(result) = &self.probe { + return result.clone(); + } + let result = if module_available { + // A missing sentinel is success; never write a probe credential. + match self + .entry(PROBE_ACCOUNT, create) + .and_then(SecretEntry::read) + { + Ok(_) => KeychainProbeResult { + available: true, + module_available, + detail: None, + }, + Err(detail) => KeychainProbeResult { + available: false, + module_available, + detail: Some(detail), + }, + } + } else { + KeychainProbeResult { + available: false, + module_available, + detail: None, + } + }; + self.probe = Some(result.clone()); + result + } +} + +static KEYCHAIN: LazyLock>> = + LazyLock::new(|| Mutex::new(Keychain::new())); + +fn create_entry(account: &str) -> Result { + keyring::Entry::new(KEYCHAIN_SERVICE, account).map_err(|e| { + keyring::Entry::store_status() + .as_ref() + .err() + .unwrap_or(&e) + .to_string() + }) +} + +fn with_keychain(operation: impl FnOnce(&mut Keychain) -> T) -> T { + let mut cache = KEYCHAIN.lock().unwrap_or_else(|e| e.into_inner()); + operation(&mut cache) +} + +fn keychain_disabled() -> bool { + std::env::var_os("WISPCTL_NO_KEYCHAIN").is_some_and(|value| value == "1") +} + +pub fn read_secret(account: &str) -> Option { + if keychain_disabled() { + return None; + } + with_keychain(|cache| cache.read(account, create_entry)) +} + +pub fn write_secret(account: &str, value: &str) -> bool { + if keychain_disabled() { + return false; + } + with_keychain(|cache| cache.write(account, value, create_entry)) +} + +pub fn remove_secret(account: &str) { + if keychain_disabled() { + return; + } + with_keychain(|cache| cache.remove(account, create_entry)); +} + +pub fn probe_keychain() -> KeychainProbeResult { + if keychain_disabled() { + return KeychainProbeResult { + available: false, + module_available: false, + detail: None, + }; + } + let supported = cfg!(all( + any(unix, windows), + not(any(target_os = "ios", target_os = "android")) + )); + with_keychain(|cache| cache.probe(supported, create_entry)) +} + +pub fn get_stored_app_password(did: &str) -> Option { + read_secret(&format!("app-password:{did}")) +} +pub fn set_stored_app_password(did: &str, password: &str) -> bool { + write_secret(&format!("app-password:{did}"), password) +} +pub fn delete_stored_app_password(did: &str) { + remove_secret(&format!("app-password:{did}")); +} + +/// Legacy TS sessions are only removed, never restored as jacquard sessions. +pub fn delete_stored_oauth_session(did: &str) { + remove_secret(did); +} + +pub fn describe_unavailable_keychain(result: &KeychainProbeResult) -> String { + describe_for_platform(result, std::env::consts::OS) +} + +fn describe_for_platform(result: &KeychainProbeResult, platform: &str) -> String { + let detail = result.detail.as_deref(); + let lower = detail.unwrap_or_default().to_lowercase(); + match platform { + "macos" if !result.module_available => { + "macOS Keychain support is unavailable in this build.".into() + } + "macos" if lower.contains("authorization") => { + "macOS Keychain access could not be authorized.".into() + } + "macos" => detail.map_or_else( + || "macOS Keychain access is unavailable.".into(), + |d| format!("macOS Keychain access failed: {d}"), + ), + "linux" if !result.module_available => { + "System keychain support is unavailable in this build.".into() + } + "linux" if lower.contains("secret service") => { + "System keychain is unavailable (no Secret Service daemon or equivalent).".into() + } + "linux" => detail.map_or_else( + || "System keychain is unavailable.".into(), + |d| format!("System keychain access failed: {d}"), + ), + "windows" if !result.module_available => { + "Windows Credential Manager support is unavailable in this build.".into() + } + "windows" => detail.map_or_else( + || "Windows Credential Manager is unavailable.".into(), + |d| format!("Windows Credential Manager access failed: {d}"), + ), + _ if !result.module_available => { + "Secure OS credential storage is unavailable in this build.".into() + } + _ => detail.map_or_else( + || "Secure OS credential storage is unavailable.".into(), + |d| format!("Secure OS credential storage failed: {d}"), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + cell::{Cell, RefCell}, + rc::Rc, + }; + + #[derive(Default)] + struct Calls { + reads: Cell, + writes: Cell, + removes: Cell, + value: RefCell>, + fail: Cell, + } + struct Fake(Rc); + impl SecretEntry for Fake { + fn read(&self) -> Result, String> { + self.0.reads.set(self.0.reads.get() + 1); + if self.0.fail.get() { + Err("Secret Service unavailable".into()) + } else { + Ok(self.0.value.borrow().clone()) + } + } + fn write(&self, value: &str) -> Result<(), String> { + self.0.writes.set(self.0.writes.get() + 1); + if self.0.fail.get() { + return Err("denied".into()); + } + *self.0.value.borrow_mut() = Some(value.into()); + Ok(()) + } + fn remove(&self) -> Result<(), String> { + self.0.removes.set(self.0.removes.get() + 1); + if self.0.fail.get() { + return Err("denied".into()); + } + *self.0.value.borrow_mut() = None; + Ok(()) + } + } + + #[test] + fn probe_is_read_only_and_cached() { + let calls = Rc::new(Calls::default()); + let mut cache = Keychain::new(); + assert!(cache.probe(true, |_| Ok(Fake(calls.clone()))).available); + assert!( + cache + .probe(true, |_| panic!("cached probe should not create an entry")) + .available + ); + assert_eq!(calls.reads.get(), 1); + assert_eq!(calls.writes.get(), 0); + assert_eq!(calls.removes.get(), 0); + } + + #[test] + fn caches_entries_and_both_missing_and_present_secrets() { + let calls = Rc::new(Calls::default()); + let mut cache = Keychain::new(); + assert_eq!(cache.read("did", |_| Ok(Fake(calls.clone()))), None); + assert_eq!(cache.read("did", |_| panic!("already cached")), None); + assert!(cache.write("did", "token", |_| panic!("reuse entry"))); + assert_eq!( + cache.read("did", |_| panic!("already cached")), + Some("token".into()) + ); + cache.remove("did", |_| panic!("reuse entry")); + assert_eq!(cache.read("did", |_| panic!("already cached")), None); + assert_eq!(calls.reads.get(), 1); + assert_eq!(calls.writes.get(), 1); + assert_eq!(calls.removes.get(), 1); + } + + #[test] + fn caches_errors_without_plaintext_fallback() { + let calls = Rc::new(Calls::default()); + calls.fail.set(true); + let mut cache = Keychain::new(); + assert!(!cache.probe(true, |_| Ok(Fake(calls.clone()))).available); + assert!(!cache.write("app-password:did", "secret", |_| Ok(Fake(calls.clone())))); + assert_eq!( + cache.read("app-password:did", |_| panic!("reuse entry")), + None + ); + assert_eq!( + cache.read("app-password:did", |_| panic!("cached read")), + None + ); + assert_eq!(calls.reads.get(), 2); + assert!(cache.secrets.values().all(Option::is_none)); + } + + #[test] + fn write_failure_preserves_cache_and_delete_failure_invalidates_it() { + let calls = Rc::new(Calls::default()); + let mut cache = Keychain::new(); + assert!(cache.write("did", "original", |_| Ok(Fake(calls.clone())))); + calls.fail.set(true); + assert!(!cache.write("did", "replacement", |_| panic!("reuse entry"))); + assert_eq!( + cache.read("did", |_| panic!("cached")), + Some("original".into()) + ); + cache.remove("did", |_| panic!("reuse entry")); + assert_eq!(cache.read("did", |_| panic!("cached")), None); + assert_eq!(calls.value.borrow().as_deref(), Some("original")); + } + + #[test] + fn missing_backend_probe_and_initialization_failure_are_cached() { + let mut cache: Keychain = Keychain::new(); + assert!( + !cache + .probe(false, |_| panic!("unsupported backend")) + .module_available + ); + assert!(!cache.probe(true, |_| panic!("cached probe")).available); + let mut cache: Keychain = Keychain::new(); + let result = cache.probe(true, |_| Err("initialization failed".into())); + assert!(result.module_available); + assert!(!result.available); + assert_eq!(result.detail.as_deref(), Some("initialization failed")); + assert_eq!(cache.probe(true, |_| panic!("cached failure")), result); + } + + #[test] + fn platform_error_wording() { + let unavailable = KeychainProbeResult { + available: false, + module_available: false, + detail: None, + }; + assert_eq!( + describe_for_platform(&unavailable, "macos"), + "macOS Keychain support is unavailable in this build." + ); + let failed = KeychainProbeResult { + available: false, + module_available: true, + detail: Some("Authorization refused".into()), + }; + assert_eq!( + describe_for_platform(&failed, "macos"), + "macOS Keychain access could not be authorized." + ); + let failed = KeychainProbeResult { + detail: Some("No Secret Service".into()), + ..failed + }; + assert_eq!( + describe_for_platform(&failed, "linux"), + "System keychain is unavailable (no Secret Service daemon or equivalent)." + ); + } +} diff --git a/cli-rs/crates/wispctl/src/auth/mod.rs b/cli-rs/crates/wispctl/src/auth/mod.rs new file mode 100644 index 0000000..aca882e --- /dev/null +++ b/cli-rs/crates/wispctl/src/auth/mod.rs @@ -0,0 +1,473 @@ +//! Authentication shared by deploy and the service commands. +pub mod keychain; +pub mod oauth; +pub mod resolver; +pub mod store; + +use anyhow::{Context, Result, bail}; +use jacquard::client::credential_session::{CredentialSession, SessionKey}; +use jacquard::{ + AuthorizationToken, BosStr, SmolStr, + client::{Agent, AgentKind, AgentSession, AtpSession, ClientResult, MemorySessionStore}, + deps::fluent_uri::Uri, + http_client::HttpClient, + identity::resolver::{DidDocResponse, IdentityResolver, ResolverOptions}, + types::string::{Did, Handle}, + xrpc::{CallOptions, XrpcClient, XrpcRequest, XrpcResponse}, +}; +use resolver::EnvResolver; +use serde::Serialize; +use std::{ + path::{Path, PathBuf}, + sync::{Arc, Mutex}, +}; +use store::{AccountStore, AccountUpdates, AuthMethod, OAuthScopeStrategy, StoredAccount}; + +#[derive(Clone, Debug, Default)] +pub struct AuthOptions { + pub app_password: Option, + pub db_path: Option, + pub force_reauth: bool, +} + +pub type WispAgent = Agent; +pub struct Authenticated { + pub agent: WispAgent, + pub did: String, + pub handle: Option, +} + +type PasswordSession = CredentialSession, EnvResolver>; +#[derive(Clone)] +pub enum WispSession { + OAuth(oauth::Session), + Credential(Arc), +} + +impl HttpClient for WispSession { + type Error = reqwest::Error; + async fn send_http( + &self, + request: http::Request>, + ) -> std::result::Result>, Self::Error> { + match self { + Self::OAuth(session) => session.send_http(request).await, + Self::Credential(session) => session.send_http(request).await, + } + } +} + +impl XrpcClient for WispSession { + async fn base_uri(&self) -> Uri { + match self { + Self::OAuth(session) => session.base_uri().await, + Self::Credential(session) => session.base_uri().await, + } + } + async fn set_base_uri(&self, uri: Uri) -> () { + match self { + Self::OAuth(session) => session.set_base_uri(uri).await, + Self::Credential(session) => session.set_base_uri(uri).await, + } + } + async fn opts(&self) -> CallOptions { + match self { + Self::OAuth(session) => session.opts().await, + Self::Credential(session) => session.opts().await, + } + } + async fn set_opts(&self, opts: CallOptions) -> () { + match self { + Self::OAuth(session) => session.set_opts(opts).await, + Self::Credential(session) => session.set_opts(opts).await, + } + } + async fn send(&self, request: R) -> jacquard::error::XrpcResult> + where + R: XrpcRequest + Send + Sync + Serialize, + R::Response: Send + Sync, + { + match self { + Self::OAuth(session) => session.send(request).await, + Self::Credential(session) => session.send(request).await, + } + } + async fn send_with_opts( + &self, + request: R, + opts: CallOptions, + ) -> jacquard::error::XrpcResult> + where + R: XrpcRequest + Send + Sync + Serialize, + R::Response: Send + Sync, + { + match self { + Self::OAuth(session) => session.send_with_opts(request, opts).await, + Self::Credential(session) => session.send_with_opts(request, opts).await, + } + } +} +impl AgentSession for WispSession { + fn session_kind(&self) -> AgentKind { + match self { + Self::OAuth(_) => AgentKind::OAuth, + Self::Credential(_) => AgentKind::AppPassword, + } + } + async fn session_info(&self) -> Option<(Did, Option)> { + match self { + Self::OAuth(session) => AgentSession::session_info(session).await, + Self::Credential(session) => AgentSession::session_info(session.as_ref()).await, + } + } + async fn endpoint(&self) -> Uri { + match self { + Self::OAuth(session) => AgentSession::endpoint(session).await, + Self::Credential(session) => AgentSession::endpoint(session.as_ref()).await, + } + } + async fn set_options(&self, opts: CallOptions) -> () { + match self { + Self::OAuth(session) => AgentSession::set_options(session, opts).await, + Self::Credential(session) => AgentSession::set_options(session.as_ref(), opts).await, + } + } + async fn refresh(&self) -> ClientResult> { + match self { + Self::OAuth(session) => AgentSession::refresh(session).await, + Self::Credential(session) => AgentSession::refresh(session.as_ref()).await, + } + } +} +impl IdentityResolver for WispSession { + fn options(&self) -> &ResolverOptions { + match self { + Self::OAuth(s) => s.options(), + Self::Credential(s) => s.options(), + } + } + async fn resolve_handle( + &self, + value: &Handle, + ) -> jacquard::identity::resolver::Result { + match self { + Self::OAuth(s) => s.resolve_handle(value).await, + Self::Credential(s) => s.resolve_handle(value).await, + } + } + async fn resolve_did_doc( + &self, + value: &Did, + ) -> jacquard::identity::resolver::Result { + match self { + Self::OAuth(s) => s.resolve_did_doc(value).await, + Self::Credential(s) => s.resolve_did_doc(value).await, + } + } +} + +fn open_store(db: Option<&Path>) -> Result { + match db { + Some(path) => AccountStore::open(path), + None => AccountStore::open_default(), + } +} + +pub async fn resolve_account_for_cwd(db: Option<&Path>) -> Result> { + open_store(db)?.resolve_account_for_dir(&std::env::current_dir()?.to_string_lossy()) +} + +async fn password_session( + identifier: &str, + password: &str, + pds: Option<&str>, + resolver: Arc, + status: &mut impl FnMut(&str), +) -> Result<(WispSession, String, String, Option)> { + let pds = match pds { + Some(pds) => pds.to_owned(), + None => { + status(&format!("Resolving PDS for {identifier}...")); + let (_, pds) = self::resolver::resolve_identity(identifier).await?; + status(&format!("Found PDS: {pds}")); + pds + } + }; + let session = CredentialSession::new(Arc::new(MemorySessionStore::default()), resolver); + let data = session + .login( + identifier, + password, + None, + None, + None, + Some(Uri::parse(pds.clone()).map_err(|(err, _)| err)?), + ) + .await?; + let did = data.did.to_string(); + let handle = Some(data.handle.to_string()); + status(&format!("Authenticated as {did}")); + Ok((WispSession::Credential(Arc::new(session)), did, pds, handle)) +} + +async fn finish( + db: &Arc>, + session: WispSession, + did: String, + handle: Option, + updates: AccountUpdates, + cwd: &str, +) -> Result { + { + let store = db + .lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))?; + store.upsert_account( + &did, + &AccountUpdates { + handle: handle.clone(), + handle_checked: handle.is_some(), + ..updates + }, + )?; + store.set_dir(cwd, &did)?; + } + // Do not hold a SQLite lock across network I/O. + let handle = if handle.is_some() { + handle + } else { + let found = resolver::verified_handle(&did).await?; + if let Some(handle) = &found { + db.lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))? + .upsert_account( + &did, + &AccountUpdates { + handle: Some(handle.clone()), + handle_checked: true, + ..Default::default() + }, + )?; + } + found + }; + Ok(Authenticated { + agent: Agent::new(session), + did, + handle, + }) +} + +pub async fn authenticate( + handle: Option<&str>, + opts: &AuthOptions, + mut on_status: impl FnMut(&str), +) -> Result { + let db = Arc::new(Mutex::new(open_store(opts.db_path.as_deref())?)); + let cwd = std::env::current_dir()?.to_string_lossy().into_owned(); + let normalized = handle.map(|h| { + if h.starts_with("did:") { + h.to_owned() + } else { + store::normalize_handle(h) + } + }); + let identifier = normalized.as_deref(); + let password = match opts.app_password.as_deref() { + Some(password) => { + if password.trim().is_empty() { + bail!("App password is required when using --password"); + } + if identifier.is_none() { + bail!("Handle required with app password authentication"); + } + Some(password.trim().to_owned()) + } + None if identifier.is_some() => std::env::var("WISPCTL_APP_PASSWORD") + .ok() + .filter(|p| !p.trim().is_empty()) + .map(|p| p.trim().to_owned()), + None => None, + }; + let resolver = Arc::new(resolver::resolver()?); + if let Some(password) = password { + let (session, did, pds, session_handle) = password_session( + identifier.unwrap(), + &password, + None, + resolver, + &mut on_status, + ) + .await?; + return finish( + &db, + session, + did, + session_handle, + AccountUpdates { + method: Some(AuthMethod::AppPassword), + pds_url: Some(pds), + ..Default::default() + }, + &cwd, + ) + .await; + } + let target_did = match identifier { + Some(value) => { + let lookup = open_store(opts.db_path.as_deref())?; + resolver::resolve_identifier_to_did(&lookup, value).await? + } + None => db + .lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))? + .resolve_account_for_dir(&cwd)? + .map(|account| account.did), + }; + let account = match &target_did { + Some(did) => db + .lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))? + .get_account(did)?, + None => None, + }; + let store = Arc::new(oauth::OAuthStore::new(db.clone())); + let port_key = format!("dir_oauth_port:{cwd}"); + let port = db + .lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))? + .get(&port_key)? + .and_then(|s| s.parse::().ok()) + .filter(|p| *p > 0) + .unwrap_or(4000); + if !opts.force_reauth + && let Some(did) = &target_did + { + let label = account + .as_ref() + .and_then(|a| a.handle.as_deref()) + .unwrap_or(did); + let methods = if account + .as_ref() + .is_some_and(|a| a.method == AuthMethod::AppPassword) + { + [AuthMethod::AppPassword, AuthMethod::OAuth] + } else { + [AuthMethod::OAuth, AuthMethod::AppPassword] + }; + for method in methods { + match method { + AuthMethod::OAuth => { + let strategy = account + .as_ref() + .and_then(|a| a.oauth_scope.clone()) + .unwrap_or(OAuthScopeStrategy::Sets); + if let Ok(Some(session)) = oauth::restore( + store.clone(), + resolver.clone(), + did, + port, + strategy.clone(), + &mut on_status, + ) + .await + { + on_status(&format!("Restored session for {label}")); + return finish( + &db, + WispSession::OAuth(session), + did.clone(), + account.as_ref().and_then(|a| a.handle.clone()), + AccountUpdates { + method: Some(AuthMethod::OAuth), + oauth_scope: Some(strategy), + ..Default::default() + }, + &cwd, + ) + .await; + } + } + AuthMethod::AppPassword => { + if let Some(password) = keychain::get_stored_app_password(did) { + let identifier = account + .as_ref() + .and_then(|a| a.handle.as_deref()) + .unwrap_or(did); + let pds = account.as_ref().and_then(|a| a.pds_url.as_deref()); + match password_session( + identifier, + &password, + pds, + resolver.clone(), + &mut |_| {}, + ) + .await + { + Ok((session, actual, pds, handle)) if &actual == did => { + on_status(&format!("Restored app password session for {label}")); + return finish( + &db, + session, + actual, + handle, + AccountUpdates { + method: Some(AuthMethod::AppPassword), + pds_url: Some(pds), + ..Default::default() + }, + &cwd, + ) + .await; + } + Ok((_, actual, _, _)) => wisp_ui::warning(format!( + "Stored app password authenticated as {actual}, expected {did}; ignoring it." + )), + Err(_) => wisp_ui::warning(format!( + "Stored app password for {label} was rejected." + )), + } + } + } + } + } + } + let identifier = identifier + .or(target_did.as_deref()) + .context("No stored account. Run `wispctl login ` first.")?; + let (session, strategy, port) = oauth::login( + store, + resolver, + identifier, + target_did.as_deref(), + port, + &mut on_status, + ) + .await?; + let (did, _) = session.session_info().await; + { + let store = db + .lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))?; + if port == 4000 { + store.delete(&port_key)?; + } else { + store.set(&port_key, &port.to_string(), None)?; + } + } + on_status(&format!("Authenticated as {did}")); + let handle = normalized.filter(|handle| !handle.starts_with("did:")); + finish( + &db, + WispSession::OAuth(session), + did.to_string(), + handle, + AccountUpdates { + method: Some(AuthMethod::OAuth), + oauth_scope: Some(strategy), + ..Default::default() + }, + &cwd, + ) + .await +} diff --git a/cli-rs/crates/wispctl/src/auth/oauth-success.html b/cli-rs/crates/wispctl/src/auth/oauth-success.html new file mode 100644 index 0000000..a0eab56 --- /dev/null +++ b/cli-rs/crates/wispctl/src/auth/oauth-success.html @@ -0,0 +1,58 @@ + + + + Wisp CLI - Authentication Successful + + + + +
    +

    Authentication Successful

    +

    You can close this window and return to the CLI.

    +
    + + + \ No newline at end of file diff --git a/cli-rs/crates/wispctl/src/auth/oauth.rs b/cli-rs/crates/wispctl/src/auth/oauth.rs new file mode 100644 index 0000000..84330b5 --- /dev/null +++ b/cli-rs/crates/wispctl/src/auth/oauth.rs @@ -0,0 +1,552 @@ +//! Jacquard OAuth sessions have a separate namespace from the TypeScript client. +use super::{ + keychain, + resolver::EnvResolver, + store::{AccountStore, OAuthScopeStrategy}, +}; +use anyhow::{Result, bail}; +use jacquard::{ + BosStr, SmolStr, + deps::fluent_uri::Uri, + oauth::{ + atproto::AtprotoClientMetadata, + authstore::ClientAuthStore, + client::{OAuthClient, OAuthSession}, + scopes::Scopes, + session::{AuthRequestData, ClientData, ClientSessionData}, + types::{AuthorizeOptions, CallbackParams}, + }, + session::{SessionKey, SessionStoreError}, + types::string::Did, +}; +use serde::{Serialize, de::DeserializeOwned}; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; +use tokio::{ + io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}, + net::TcpListener, +}; + +pub type Session = OAuthSession; +type Client = OAuthClient; + +pub struct OAuthStore { + pub db: Arc>, + warned: AtomicBool, + secrets: Arc, +} + +trait Secrets: Send + Sync { + fn read(&self, key: &str) -> Option; + fn write(&self, key: &str, value: &str) -> bool; + fn remove(&self, key: &str); +} +struct SystemSecrets; +impl Secrets for SystemSecrets { + fn read(&self, key: &str) -> Option { + keychain::read_secret(key) + } + fn write(&self, key: &str, value: &str) -> bool { + keychain::write_secret(key, value) + } + fn remove(&self, key: &str) { + keychain::remove_secret(key); + } +} + +fn store_error(err: anyhow::Error) -> SessionStoreError { + SessionStoreError::Other(err.into()) +} + +impl OAuthStore { + pub fn new(db: Arc>) -> Self { + Self { + db, + warned: AtomicBool::new(false), + secrets: Arc::new(SystemSecrets), + } + } + fn read(&self, key: &str) -> Result> { + let secret = if key.starts_with("oauth_session_v2:") { + self.secrets.read(&format!("oauth-v2:{key}")) + } else { + None + }; + let value = match secret { + Some(value) => Some(value), + None => self + .db + .lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))? + .get(key)?, + }; + value + .map(|value| serde_json::from_str(&value)) + .transpose() + .map_err(Into::into) + } + fn write(&self, key: &str, value: &T, ttl: f64, secret: bool) -> Result<()> { + let value = serde_json::to_string(value)?; + let db = self + .db + .lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))?; + if secret && self.secrets.write(&format!("oauth-v2:{key}"), &value) { + db.delete(key)?; + } else { + if secret && !self.warned.swap(true, Ordering::Relaxed) { + wisp_ui::warning( + "Could not save the session in the system credential store; keeping it in the local wispctl database instead.", + ); + } + // A refused keychain write must not leave a stale secret shadowing SQLite. + if secret { + self.secrets.remove(&format!("oauth-v2:{key}")); + } + db.set(key, &value, Some(ttl))?; + } + Ok(()) + } + fn remove(&self, key: &str) -> Result<()> { + if key.starts_with("oauth_session_v2:") { + self.secrets.remove(&format!("oauth-v2:{key}")); + } + self.db + .lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))? + .delete(key) + } + pub fn session_key(did: &str, id: &str) -> String { + format!("oauth_session_v2:{did}/{id}") + } + pub fn keys(&self) -> Result> { + self.db + .lock() + .map_err(|_| anyhow::anyhow!("Account database lock poisoned"))? + .entries("oauth_index_v2:")? + .into_iter() + .map(|(_, value)| serde_json::from_str(&value).map_err(Into::into)) + .collect() + } +} + +impl ClientAuthStore for OAuthStore { + async fn get_session( + &self, + did: &Did, + session_id: &str, + ) -> std::result::Result, SessionStoreError> { + self.read(&Self::session_key(did.as_str(), session_id)) + .map_err(store_error) + } + async fn upsert_session( + &self, + session: ClientSessionData, + ) -> std::result::Result<(), SessionStoreError> { + let key = Self::session_key(session.account_did.as_str(), session.session_id.as_str()); + self.write(&key, &session, 14.0 * 86400.0, true) + .map_err(store_error)?; + let index = SessionKey::new(session.account_did, session.session_id); + self.db + .lock() + .map_err(|_| store_error(anyhow::anyhow!("Account database lock poisoned")))? + .set( + &format!("oauth_index_v2:{}/{}", index.did, index.session_id), + &serde_json::to_string(&index).map_err(|err| store_error(err.into()))?, + None, + ) + .map_err(store_error) + } + async fn delete_session( + &self, + did: &Did, + session_id: &str, + ) -> std::result::Result<(), SessionStoreError> { + self.remove(&Self::session_key(did.as_str(), session_id)) + .map_err(store_error)?; + self.remove(&format!("oauth_index_v2:{did}/{session_id}")) + .map_err(store_error) + } + async fn get_auth_req_info( + &self, + state: &str, + ) -> std::result::Result, SessionStoreError> { + self.read(&format!("oauth_state_v2:{state}")) + .map_err(store_error) + } + async fn save_auth_req_info( + &self, + info: &AuthRequestData, + ) -> std::result::Result<(), SessionStoreError> { + self.write( + &format!("oauth_state_v2:{}", info.state), + info, + 600.0, + false, + ) + .map_err(store_error) + } + async fn delete_auth_req_info( + &self, + state: &str, + ) -> std::result::Result<(), SessionStoreError> { + self.remove(&format!("oauth_state_v2:{state}")) + .map_err(store_error) + } + async fn list_session_keys(&self) -> std::result::Result, SessionStoreError> { + self.keys().map_err(store_error) + } +} + +fn scope(strategy: OAuthScopeStrategy) -> String { + let scopes = wisp_core::scopes::build_wisp_scopes(wisp_core::scopes::WISP_CLI_PERMISSION_SETS); + match strategy { + OAuthScopeStrategy::Granular => scopes.legacy, + _ => scopes.preferred, + } +} + +fn client( + store: Arc, + resolver: Arc, + port: u16, + strategy: OAuthScopeStrategy, +) -> Result { + let config = metadata(port, strategy)?; + Ok(OAuthClient::new_with_shared( + store, + resolver, + ClientData { + config, + keyset: None, + }, + )) +} + +fn metadata(port: u16, strategy: OAuthScopeStrategy) -> Result> { + let scope = scope(strategy); + let redirect = format!("http://127.0.0.1:{port}/oauth/callback"); + let query = serde_html_form::to_string([ + ("redirect_uri", redirect.as_str()), + ("scope", scope.as_str()), + ])?; + let mut config = AtprotoClientMetadata::new_localhost( + Some(vec![Uri::parse(redirect).map_err(|(err, _)| err)?]), + Some(Scopes::new(SmolStr::new(scope))?), + ); + config.client_id = Uri::parse(format!("http://localhost?{query}")).map_err(|(err, _)| err)?; + config.client_name = Some("Wisp CLI".into()); + config.client_uri = Some(Uri::parse("https://wisp.place").unwrap().to_owned()); + Ok(config) +} + +pub async fn restore( + store: Arc, + resolver: Arc, + did: &str, + port: u16, + strategy: OAuthScopeStrategy, + status: &mut impl FnMut(&str), +) -> Result> { + let client = client(store.clone(), resolver, port, strategy.clone())?; + for key in store + .keys()? + .into_iter() + .filter(|key| key.did.as_str() == did) + { + let session = match client.restore(&key.did, key.session_id.as_str()).await { + Ok(session) => session, + Err(_) => continue, + }; + if let Some(data) = store.get_session(&key.did, key.session_id.as_str()).await? { + let missing = wisp_core::scopes::missing_capabilities( + Some(data.scopes.as_str()), + &wisp_core::scopes::wisp_cli_required_capabilities(), + ); + if missing.is_empty() { + return Ok(Some(session)); + } + status(&format!( + "Stored session is missing {} scope(s). Re-authenticating...", + missing.len() + )); + } + } + Ok(None) +} + +const SUCCESS_HTML: &str = include_str!("oauth-success.html"); + +async fn callback(listener: &TcpListener) -> Result { + loop { + let (mut stream, _) = listener.accept().await?; + let mut reader = BufReader::new((&mut stream).take(16 * 1024)); + let mut line = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(10), + reader.read_line(&mut line), + ) + .await??; + if !line.ends_with('\n') { + continue; + } + let mut parts = line.split_whitespace(); + let method = parts.next().unwrap_or(""); + let target = parts.next().unwrap_or(""); + let (path, query) = target.split_once('?').unwrap_or((target, "")); + if method != "GET" || path != "/oauth/callback" { + stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 9\r\nConnection: close\r\n\r\nNot found").await?; + continue; + } + let params = serde_html_form::from_str(query)?; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{SUCCESS_HTML}", + SUCCESS_HTML.len() + ); + stream.write_all(response.as_bytes()).await?; + return Ok(params); + } +} + +pub async fn login( + store: Arc, + resolver: Arc, + identifier: &str, + target_did: Option<&str>, + preferred_port: u16, + status: &mut impl FnMut(&str), +) -> Result<(Session, OAuthScopeStrategy, u16)> { + let listener = match TcpListener::bind(("127.0.0.1", preferred_port)).await { + Ok(listener) => listener, + Err(err) if err.kind() == std::io::ErrorKind::AddrInUse => { + TcpListener::bind(("127.0.0.1", 0)).await? + } + Err(err) => return Err(err.into()), + }; + let port = listener.local_addr()?.port(); + if port != preferred_port { + status(&format!( + "OAuth callback port {preferred_port} is unavailable. Using {port} for this login flow." + )); + } + status(&format!("Starting OAuth flow for {identifier}...")); + let mut strategy = OAuthScopeStrategy::Sets; + loop { + let client = client(store.clone(), resolver.clone(), port, strategy.clone())?; + let opts = AuthorizeOptions { + scopes: Scopes::new(SmolStr::new(scope(strategy.clone())))?, + ..Default::default() + }; + let url = match client.start_auth(identifier, opts).await { + Ok(url) => url, + Err(err) if strategy == OAuthScopeStrategy::Sets => { + wisp_ui::warning(format!( + "Authorization server rejected the wisp.place permission sets ({err}). Falling back to granular scopes." + )); + strategy = OAuthScopeStrategy::Granular; + continue; + } + Err(err) => return Err(err.into()), + }; + status("Opening browser for authentication..."); + status(&format!("If browser does not open, visit: {url}")); + if std::env::var("WISPCTL_NO_BROWSER").as_deref() != Ok("1") { + let _ = webbrowser::open(&url); + } + let params = tokio::time::timeout(std::time::Duration::from_secs(300), callback(&listener)) + .await + .map_err(|_| anyhow::anyhow!("OAuth callback timeout"))??; + let session = client.callback(params).await?; + let (did, id) = session.session_info().await; + if target_did.is_some_and(|target| target != did.as_str()) { + store.delete_session(&did, &id).await?; + bail!( + "Authenticated account {did} does not match the requested account {}", + target_did.unwrap() + ); + } + let data = store + .get_session(&did, &id) + .await? + .ok_or_else(|| anyhow::anyhow!("OAuth session was not persisted"))?; + let missing = wisp_core::scopes::missing_capabilities( + Some(data.scopes.as_str()), + &wisp_core::scopes::wisp_cli_required_capabilities(), + ); + if !missing.is_empty() && strategy == OAuthScopeStrategy::Sets { + wisp_ui::warning( + "Authorization server ignored the wisp.place permission sets. Retrying with granular scopes...", + ); + store.delete_session(&did, &id).await?; + strategy = OAuthScopeStrategy::Granular; + continue; + } + if let Some(first) = missing.first() { + wisp_ui::warning(format!( + "OAuth token is missing {} requested permission(s). First missing: {}", + missing.len(), + wisp_core::scopes::describe_capability(first) + )); + } + return Ok((session, strategy, port)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn store() -> OAuthStore { + OAuthStore::new(Arc::new(Mutex::new( + AccountStore::open(":memory:").unwrap(), + ))) + } + #[derive(Default)] + struct FakeSecrets { + values: Mutex>, + refuse: AtomicBool, + } + impl Secrets for FakeSecrets { + fn read(&self, key: &str) -> Option { + self.values.lock().unwrap().get(key).cloned() + } + fn write(&self, key: &str, value: &str) -> bool { + if self.refuse.load(Ordering::Relaxed) { + return false; + } + self.values.lock().unwrap().insert(key.into(), value.into()); + true + } + fn remove(&self, key: &str) { + self.values.lock().unwrap().remove(key); + } + } + fn fake_store() -> (OAuthStore, Arc) { + let secrets = Arc::new(FakeSecrets::default()); + let mut store = store(); + store.secrets = secrets.clone(); + (store, secrets) + } + #[test] + fn refused_keychain_write_keeps_latest_session_in_sqlite() { + let (store, secrets) = fake_store(); + let key = "oauth_session_v2:did:plc:test/id"; + store.write(key, &"old", 1209600.0, true).unwrap(); + secrets.refuse.store(true, Ordering::Relaxed); + store.write(key, &"new", 1209600.0, true).unwrap(); + assert_eq!(store.read::(key).unwrap().as_deref(), Some("new")); + assert!(secrets.values.lock().unwrap().is_empty()); + assert!(store.warned.load(Ordering::Relaxed)); + } + #[test] + fn successful_keychain_write_clears_older_sqlite_copy() { + let (store, secrets) = fake_store(); + let key = "oauth_session_v2:did:plc:test/id"; + store.write(key, &"local", 1209600.0, false).unwrap(); + store.write(key, &"secret", 1209600.0, true).unwrap(); + assert!(store.db.lock().unwrap().get(key).unwrap().is_none()); + assert_eq!( + store.read::(key).unwrap().as_deref(), + Some("secret") + ); + assert_eq!(secrets.values.lock().unwrap().len(), 1); + } + #[test] + fn deletion_clears_both_backends() { + let (store, secrets) = fake_store(); + let key = "oauth_session_v2:did:plc:test/id"; + store.write(key, &"secret", 1209600.0, true).unwrap(); + store + .db + .lock() + .unwrap() + .set(key, "\"local\"", None) + .unwrap(); + store.remove(key).unwrap(); + assert!(store.read::(key).unwrap().is_none()); + assert!(secrets.values.lock().unwrap().is_empty()); + } + #[test] + fn fallback_storage_expires_and_does_not_touch_legacy_entries() { + let (store, _) = fake_store(); + store + .db + .lock() + .unwrap() + .set("oauth_session:did:plc:test", "legacy", None) + .unwrap(); + store + .write("oauth_state_v2:test", &"pending", 600.0, false) + .unwrap(); + assert_eq!( + store + .read::("oauth_state_v2:test") + .unwrap() + .as_deref(), + Some("pending") + ); + store + .write("oauth_state_v2:test", &"expired", -1.0, false) + .unwrap(); + assert!( + store + .read::("oauth_state_v2:test") + .unwrap() + .is_none() + ); + assert_eq!( + store + .db + .lock() + .unwrap() + .get("oauth_session:did:plc:test") + .unwrap() + .as_deref(), + Some("legacy") + ); + } + #[test] + fn strategy_declares_only_its_own_scopes_in_client_id() { + for strategy in [OAuthScopeStrategy::Sets, OAuthScopeStrategy::Granular] { + let requested = scope(strategy.clone()); + let config = metadata(4000, strategy).unwrap(); + let id = config.client_id.as_str(); + assert!(id.starts_with("http://localhost?")); + assert!(id.contains("redirect_uri=")); + assert_eq!(config.scopes.as_str(), requested); + } + } + #[tokio::test] + async fn callback_ignores_other_paths_and_decodes_parameters() { + let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap(); + let address = listener.local_addr().unwrap(); + let client = tokio::spawn(async move { + use tokio::io::AsyncReadExt; + for target in [ + "/favicon.ico", + "/oauth/callback?code=test%2Bcode&state=test&iss=http%3A%2F%2Flocalhost%3A3300", + ] { + let mut stream = tokio::net::TcpStream::connect(address).await.unwrap(); + stream + .write_all( + format!("GET {target} HTTP/1.1\r\nHost: localhost\r\n\r\n").as_bytes(), + ) + .await + .unwrap(); + let mut body = String::new(); + stream.read_to_string(&mut body).await.unwrap(); + assert!(body.starts_with(if target == "/favicon.ico" { + "HTTP/1.1 404" + } else { + "HTTP/1.1 200" + })); + } + }); + let params = callback(&listener).await.unwrap(); + assert_eq!(params.code.as_str(), "test+code"); + assert_eq!(params.state.as_deref(), Some("test")); + assert_eq!(params.iss.as_deref(), Some("http://localhost:3300")); + client.await.unwrap(); + } +} diff --git a/cli-rs/crates/wispctl/src/auth/resolver.rs b/cli-rs/crates/wispctl/src/auth/resolver.rs new file mode 100644 index 0000000..3433cb8 --- /dev/null +++ b/cli-rs/crates/wispctl/src/auth/resolver.rs @@ -0,0 +1,254 @@ +//! Environment-aware identity resolution using Jacquard's HTTP stack. +use super::store::{AccountStore, AccountUpdates, normalize_handle}; +use anyhow::{Context, Result, bail}; +use jacquard::{ + BosStr, IntoStatic, + deps::fluent_uri::Uri, + http_client::HttpClient, + identity::{ + PublicResolver, + resolver::{DidDocResponse, IdentityError, IdentityResolver, PlcSource, ResolverOptions}, + }, + oauth::{dpop::DpopExt, resolver::OAuthResolver}, + types::string::{Did, Handle}, +}; + +#[derive(Clone)] +pub struct EnvResolver { + http: reqwest::Client, + inner: PublicResolver, + handle_url: Option, +} + +pub fn resolver() -> Result { + let http = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(20)) + .build()?; + let mut opts = ResolverOptions::default(); + if let Ok(url) = std::env::var("WISP_PLC_DIRECTORY_URL") { + opts.plc_source = PlcSource::PlcDirectory { + base: Uri::parse(format!("{}/", url.trim_end_matches('/'))).map_err(|(err, _)| err)?, + }; + } + let inner = PublicResolver::new(http.clone(), opts).with_system_dns(); + Ok(EnvResolver { + http, + inner, + handle_url: std::env::var("WISP_HANDLE_RESOLVER_URL").ok(), + }) +} + +impl HttpClient for EnvResolver { + type Error = reqwest::Error; + async fn send_http( + &self, + request: http::Request>, + ) -> std::result::Result>, Self::Error> { + self.http.send_http(request).await + } +} + +impl IdentityResolver for EnvResolver { + fn options(&self) -> &ResolverOptions { + self.inner.options() + } + async fn resolve_handle( + &self, + handle: &Handle, + ) -> jacquard::identity::resolver::Result { + if let Some(url) = &self.handle_url { + let response = self + .http + .get(url) + .query(&[("handle", handle.as_str())]) + .send() + .await + .map_err(|err| { + IdentityError::transport("handle resolver request failed".into(), err) + })? + .error_for_status() + .map_err(|err| { + IdentityError::transport("handle resolver rejected request".into(), err) + })?; + #[derive(serde::Deserialize)] + struct Answer { + did: Did, + } + return serde_json::from_slice::(&bounded_body(response).await?) + .map(|answer| answer.did) + .map_err(|err| { + IdentityError::transport("invalid handle resolver response".into(), err) + }); + } + self.inner.resolve_handle(handle).await + } + async fn resolve_did_doc( + &self, + did: &Did, + ) -> jacquard::identity::resolver::Result { + if std::env::var("WISP_ALLOW_LOCALHOST_FETCH").as_deref() == Ok("1") + && let Some(url) = local_web_url(did.as_str()) + { + let response = + self.http.get(url).send().await.map_err(|err| { + IdentityError::transport("local DID request failed".into(), err) + })?; + let status = response.status(); + let headers = response.headers().clone(); + let buffer = bounded_body(response).await?; + return Ok(DidDocResponse { + buffer, + status, + headers, + requested: Some(did.borrow().into_static()), + }); + } + self.inner.resolve_did_doc(did).await + } +} +impl OAuthResolver for EnvResolver {} +impl DpopExt for EnvResolver {} + +pub async fn resolve_identity(identifier: &str) -> Result<(String, String)> { + let resolver = resolver()?; + let did = if identifier.starts_with("did:") { + Did::new(identifier)?.into_static() + } else { + resolver + .resolve_handle(&Handle::new(normalize_handle(identifier))?) + .await? + }; + let doc = resolver.resolve_did_doc(&did).await?.into_owned()?; + let pds = doc + .pds_endpoint() + .context("DID document has no PDS endpoint")?; + let url = reqwest::Url::parse(pds.as_str())?; + if url.scheme() != "https" + && !(url.scheme() == "http" + && is_loopback(&url) + && std::env::var("WISP_ALLOW_LOCALHOST_FETCH").as_deref() == Ok("1")) + { + bail!("HTTP PDS requires a loopback host and WISP_ALLOW_LOCALHOST_FETCH=1"); + } + Ok((did.to_string(), pds.to_string())) +} + +pub async fn resolve_identifier_to_did( + store: &AccountStore, + identifier: &str, +) -> Result> { + let cached = store.cached_identifier(identifier)?; + if identifier.starts_with("did:") { + return Ok(cached); + } + let normalized = normalize_handle(identifier); + if let Some(did) = &cached + && store.alias_is_fresh(did, &normalized)? + { + return Ok(cached); + } + let did = match resolver()? + .resolve_handle(&Handle::new(normalized.as_str())?) + .await + { + Ok(did) => did.to_string(), + Err(_) => return Ok(cached), + }; + if let Some(previous) = &cached + && previous != &did + { + store.detach_handle(previous, &normalized)?; + } + if store.get_account(&did)?.is_some() { + store.upsert_account( + &did, + &AccountUpdates { + handle: Some(normalized.clone()), + handle_checked: true, + ..Default::default() + }, + )?; + } + store.set(&format!("handle:{normalized}"), &did, None)?; + Ok(Some(did)) +} + +pub async fn backfill_handle(store: &AccountStore, did: &str) -> Result> { + if let Some(handle) = store.get_account(did)?.and_then(|a| a.handle) { + return Ok(Some(handle)); + } + let handle = verified_handle(did).await?; + if let Some(handle) = &handle { + store.upsert_account( + did, + &AccountUpdates { + handle: Some(handle.clone()), + handle_checked: true, + ..Default::default() + }, + )?; + } + Ok(handle) +} + +pub async fn verified_handle(did: &str) -> Result> { + let resolver = resolver()?; + let doc = match resolver.resolve_did_doc(&Did::new(did)?).await { + Ok(response) => response.into_owned()?, + Err(_) => return Ok(None), + }; + for handle in doc.handles() { + if resolver + .resolve_handle(&handle) + .await + .is_ok_and(|resolved| resolved.as_str() == did) + { + return Ok(Some(handle.to_string())); + } + } + Ok(None) +} + +fn is_loopback(url: &reqwest::Url) -> bool { + url.host_str().is_some_and(|host| { + host == "localhost" + || host + .trim_matches(['[', ']']) + .parse::() + .is_ok_and(|ip| ip.is_loopback()) + }) +} +fn local_web_url(did: &str) -> Option { + let authority = did + .strip_prefix("did:web:")? + .replace("%3A", ":") + .replace("%3a", ":"); + let url = reqwest::Url::parse(&format!("http://{authority}/.well-known/did.json")).ok()?; + is_loopback(&url).then_some(url) +} + +async fn bounded_body( + mut response: reqwest::Response, +) -> jacquard::identity::resolver::Result { + const LIMIT: usize = 1024 * 1024; + let read = async { + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|err| IdentityError::transport("identity response failed".into(), err))? + { + if bytes.len() + chunk.len() > LIMIT { + return Err(IdentityError::transport( + "identity response too large".into(), + std::io::Error::other("identity response exceeds 1 MiB"), + )); + } + bytes.extend_from_slice(&chunk); + } + Ok(bytes.into()) + }; + tokio::time::timeout(std::time::Duration::from_secs(10), read) + .await + .map_err(|err| IdentityError::transport("identity response timed out".into(), err))? +} diff --git a/cli-rs/crates/wispctl/src/auth/store.rs b/cli-rs/crates/wispctl/src/auth/store.rs new file mode 100644 index 0000000..b676ba3 --- /dev/null +++ b/cli-rs/crates/wispctl/src/auth/store.rs @@ -0,0 +1,722 @@ +//! SQLite bookkeeping compatible with the TypeScript CLI's account store. +use anyhow::{Context, Result}; +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +use std::{ + fs, + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +pub const HANDLE_ALIAS_TTL_MS: f64 = 86_400_000.0; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum AuthMethod { + #[default] + #[serde(rename = "oauth")] + OAuth, + #[serde(rename = "app-password")] + AppPassword, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OAuthScopeStrategy { + Sets, + Granular, + Unknown(serde_json::Value), +} + +impl Serialize for OAuthScopeStrategy { + fn serialize( + &self, + serializer: S, + ) -> std::result::Result { + match self { + Self::Sets => serializer.serialize_str("sets"), + Self::Granular => serializer.serialize_str("granular"), + Self::Unknown(value) => value.serialize(serializer), + } + } +} + +impl<'de> Deserialize<'de> for OAuthScopeStrategy { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + let value = serde_json::Value::deserialize(deserializer)?; + Ok(match value.as_str() { + Some("sets") => Self::Sets, + Some("granular") => Self::Granular, + _ => Self::Unknown(value), + }) + } +} + +fn deserialize_scope<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + OAuthScopeStrategy::deserialize(deserializer).map(Some) +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoredAccount { + pub did: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub handle: Option, + pub method: AuthMethod, + #[serde(skip_serializing_if = "Option::is_none")] + pub pds_url: Option, + pub added_at: f64, + pub last_used_at: f64, + #[serde(skip_serializing_if = "Option::is_none")] + pub handle_checked_at: Option, + #[serde( + default, + deserialize_with = "deserialize_scope", + skip_serializing_if = "Option::is_none" + )] + pub oauth_scope: Option, +} + +#[derive(Debug, Clone, Default)] +pub struct AccountUpdates { + pub handle: Option, + pub method: Option, + pub pds_url: Option, + pub handle_checked: bool, + pub oauth_scope: Option, +} + +pub fn normalize_handle(handle: &str) -> String { + let normalized = handle.trim().to_lowercase(); + normalized + .strip_prefix('@') + .unwrap_or(&normalized) + .to_owned() +} + +pub fn now_ms() -> f64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as f64 +} + +pub fn default_db_path() -> Result { + let home = std::env::var_os("HOME") + .or_else(|| std::env::var_os("USERPROFILE")) + .context("Could not determine home directory")?; + Ok(PathBuf::from(home).join(".config/wispctl/state.sqlite")) +} + +fn parse_account(raw: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(raw).ok()?; + let fields = value.as_object()?; + // Optional fields may be absent, but explicit null is not a TS string/number. + for name in ["handle", "pdsUrl"] { + if fields.get(name).is_some_and(|v| !v.is_string()) { + return None; + } + } + if fields + .get("handleCheckedAt") + .is_some_and(|v| !v.is_number()) + { + return None; + } + let account: StoredAccount = serde_json::from_value(value).ok()?; + (account.did.starts_with("did:") + && account.added_at.is_finite() + && account.last_used_at.is_finite() + && account.handle_checked_at.is_none_or(f64::is_finite)) + .then_some(account) +} + +pub struct AccountStore { + connection: Connection, +} + +impl AccountStore { + pub fn open_default() -> Result { + Self::open(default_db_path()?) + } + + pub fn open(path: impl AsRef) -> Result { + let path = path.as_ref(); + if path != Path::new(":memory:") { + let parent = path + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .unwrap_or(Path::new(".")); + let mut builder = fs::DirBuilder::new(); + builder.recursive(true); + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + builder.mode(0o700); + } + builder.create(parent)?; + let mut options = fs::OpenOptions::new(); + options.create(true).append(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + options.open(path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o600))?; + } + } + let connection = Connection::open(path)?; + connection.execute_batch("PRAGMA journal_mode = WAL; + CREATE TABLE IF NOT EXISTS kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires_at INTEGER);")?; + let store = Self { connection }; + store.migrate_dir_mappings()?; + Ok(store) + } + + pub fn get(&self, key: &str) -> Result> { + let row: Option<(String, Option)> = self + .connection + .query_row( + "SELECT value, expires_at FROM kv WHERE key = ?", + [key], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + match row { + Some((_, Some(expires))) if expires <= now_ms() => { + self.delete(key)?; + Ok(None) + } + Some((value, _)) => Ok(Some(value)), + None => Ok(None), + } + } + + pub fn set(&self, key: &str, value: &str, ttl_seconds: Option) -> Result<()> { + self.connection.execute( + "INSERT OR REPLACE INTO kv (key, value, expires_at) VALUES (?, ?, ?)", + params![key, value, ttl_seconds.map(|ttl| now_ms() + ttl * 1000.0)], + )?; + Ok(()) + } + + pub fn delete(&self, key: &str) -> Result<()> { + self.connection + .execute("DELETE FROM kv WHERE key = ?", [key])?; + Ok(()) + } + + pub fn clear(&self) -> Result<()> { + self.connection.execute("DELETE FROM kv", [])?; + Ok(()) + } + + pub fn entries(&self, prefix: &str) -> Result> { + let escaped = prefix + .replace('\\', "\\\\") + .replace('%', "\\%") + .replace('_', "\\_") + + "%"; + let mut statement = self + .connection + .prepare("SELECT key, value, expires_at FROM kv WHERE key LIKE ? ESCAPE '\\'")?; + let rows = statement.query_map([escaped], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + })?; + let now = now_ms(); + let mut entries = Vec::new(); + for row in rows { + let (key, value, expires) = row?; + if expires.is_none_or(|expires| expires > now) { + entries.push((key, value)); + } + } + Ok(entries) + } + + pub fn get_account(&self, did: &str) -> Result> { + Ok(self + .get(&format!("account:{did}"))? + .and_then(|raw| parse_account(&raw))) + } + + pub fn write_account(&self, account: &StoredAccount) -> Result<()> { + self.set( + &format!("account:{}", account.did), + &serde_json::to_string(account)?, + None, + )?; + if let Some(handle) = account.handle.as_ref().filter(|h| !h.is_empty()) { + self.set(&format!("handle:{handle}"), &account.did, None)?; + } + Ok(()) + } + + pub fn list_accounts(&self) -> Result> { + let mut accounts: Vec<_> = self + .entries("account:")? + .into_iter() + .filter_map(|(_, raw)| parse_account(&raw)) + .collect(); + accounts.sort_by_cached_key(|account| { + let label = account.handle.as_ref().unwrap_or(&account.did); + (label.to_lowercase(), label.clone()) + }); + Ok(accounts) + } + + pub fn detach_handle(&self, did: &str, handle: &str) -> Result<()> { + let key = format!("handle:{handle}"); + if self.get(&key)?.as_deref() == Some(did) { + self.delete(&key)?; + } + if let Some(mut account) = self.get_account(did)? + && account.handle.as_deref() == Some(handle) + { + account.handle = None; + account.handle_checked_at = None; + self.set( + &format!("account:{did}"), + &serde_json::to_string(&account)?, + None, + )?; + } + Ok(()) + } + + pub fn upsert_account(&self, did: &str, updates: &AccountUpdates) -> Result { + let now = now_ms(); + let existing = self.get_account(did)?; + let old_handle = existing.as_ref().and_then(|a| a.handle.clone()); + let handle = updates + .handle + .as_ref() + .filter(|h| !h.is_empty()) + .map(|h| normalize_handle(h)) + .or_else(|| old_handle.clone()); + if let Some(old) = old_handle + .as_ref() + .filter(|old| Some(*old) != handle.as_ref()) + { + self.detach_handle(did, old)?; + } + if let Some(handle) = handle.as_ref().filter(|h| !h.is_empty()) + && let Some(owner) = self + .get(&format!("handle:{handle}"))? + .filter(|owner| owner != did) + { + self.detach_handle(&owner, handle)?; + } + let account = StoredAccount { + did: did.to_owned(), + handle_checked_at: if updates.handle_checked { + Some(now) + } else if handle != old_handle { + None + } else { + existing.as_ref().and_then(|a| a.handle_checked_at) + }, + handle, + method: updates + .method + .or_else(|| existing.as_ref().map(|a| a.method)) + .unwrap_or_default(), + pds_url: updates + .pds_url + .clone() + .or_else(|| existing.as_ref().and_then(|a| a.pds_url.clone())), + added_at: existing.as_ref().map_or(now, |a| a.added_at), + last_used_at: now, + oauth_scope: updates + .oauth_scope + .clone() + .or_else(|| existing.as_ref().and_then(|a| a.oauth_scope.clone())), + }; + self.write_account(&account)?; + Ok(account) + } + + pub fn delete_account(&self, did: &str) -> Result<()> { + if let Some(handle) = self.get_account(did)?.and_then(|a| a.handle) { + self.delete(&format!("handle:{handle}"))?; + } + for prefix in ["handle:", "dir:"] { + for (key, value) in self.entries(prefix)? { + if value == did { + self.delete(&key)?; + } + } + } + if self.get_default()?.as_deref() == Some(did) { + self.delete("default_account")?; + } + self.delete(&format!("account:{did}")) + } + + pub fn get_dir(&self, dir: &str) -> Result> { + self.get(&format!("dir:{dir}")) + } + pub fn set_dir(&self, dir: &str, did: &str) -> Result<()> { + self.set(&format!("dir:{dir}"), did, None) + } + pub fn delete_dir(&self, dir: &str) -> Result<()> { + self.delete(&format!("dir:{dir}")) + } + pub fn get_default(&self) -> Result> { + self.get("default_account") + } + pub fn set_default(&self, did: &str) -> Result<()> { + self.set("default_account", did, None) + } + + pub fn list_dirs_for_did(&self, did: &str) -> Result> { + Ok(self + .entries("dir:")? + .into_iter() + .filter(|(_, value)| value == did) + .map(|(key, _)| key[4..].to_owned()) + .collect()) + } + + pub fn resolve_account_for_dir(&self, dir: &str) -> Result> { + if let Some(did) = self.get_dir(dir)? { + return Ok(Some(match self.get_account(&did)? { + Some(account) => account, + None => self.upsert_account(&did, &AccountUpdates::default())?, + })); + } + if let Some(did) = self.get_default()? + && let Some(account) = self.get_account(&did)? + { + return Ok(Some(account)); + } + let mut accounts = self.list_accounts()?; + Ok(if accounts.len() == 1 { + accounts.pop() + } else { + None + }) + } + + pub fn cached_identifier(&self, identifier: &str) -> Result> { + if identifier.starts_with("did:") { + return Ok(Some(identifier.to_owned())); + } + self.get(&format!("handle:{}", normalize_handle(identifier))) + } + + pub fn alias_is_fresh(&self, did: &str, handle: &str) -> Result { + Ok(self.get_account(did)?.is_some_and(|a| { + a.handle.as_deref() == Some(handle) + && a.handle_checked_at + .is_some_and(|checked| now_ms() - checked < HANDLE_ALIAS_TTL_MS) + })) + } + + fn migrate_dir_mappings(&self) -> Result<()> { + if self.get("store_version")?.as_deref() == Some("1") { + return Ok(()); + } + for (_, did) in self.entries("dir:")? { + if did.starts_with("did:") && self.get_account(&did)?.is_none() { + self.upsert_account(&did, &AccountUpdates::default())?; + } + } + self.set("store_version", "1", None) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn memory() -> AccountStore { + AccountStore::open(":memory:").unwrap() + } + + #[test] + fn kv_expiry_and_literal_prefixes() { + let store = memory(); + store.set("a_%\\one", "yes", None).unwrap(); + store.set("aXXone", "no", None).unwrap(); + assert_eq!( + store.entries("a_%\\").unwrap(), + vec![("a_%\\one".into(), "yes".into())] + ); + store.set("expired", "old", Some(-1.0)).unwrap(); + assert!(store.entries("expired").unwrap().is_empty()); + assert!( + store + .connection + .query_row("SELECT value FROM kv WHERE key='expired'", [], |r| r + .get::<_, String>(0)) + .is_ok() + ); + assert_eq!(store.get("expired").unwrap(), None); + assert!( + store + .connection + .query_row("SELECT value FROM kv WHERE key='expired'", [], |r| r + .get::<_, String>(0)) + .is_err() + ); + store.set("zero", "old", Some(0.0)).unwrap(); + assert_eq!(store.get("zero").unwrap(), None); + } + + #[test] + fn unique_handle_alias_and_handle_changes() { + let store = memory(); + let update = AccountUpdates { + handle: Some("@ALICE.EXAMPLE".into()), + handle_checked: true, + ..Default::default() + }; + store.upsert_account("did:plc:old", &update).unwrap(); + let new = store.upsert_account("did:plc:new", &update).unwrap(); + assert!( + store + .get_account("did:plc:old") + .unwrap() + .unwrap() + .handle + .is_none() + ); + assert_eq!(new.handle.as_deref(), Some("alice.example")); + assert_eq!( + store + .cached_identifier(" @ALICE.EXAMPLE ") + .unwrap() + .as_deref(), + Some("did:plc:new") + ); + assert!( + store + .alias_is_fresh("did:plc:new", "alice.example") + .unwrap() + ); + let changed = store + .upsert_account( + "did:plc:new", + &AccountUpdates { + handle: Some("bob.example".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(changed.added_at, new.added_at); + assert!(changed.handle_checked_at.is_none()); + assert!(store.cached_identifier("alice.example").unwrap().is_none()); + } + + #[test] + fn malformed_rows_and_camel_case_shape() { + let store = memory(); + for raw in [ + r#"{"did":"did:plc:broken","method":"unknown"}"#, + r#"{"did":"did:plc:broken","method":"oauth","addedAt":1,"lastUsedAt":1,"handle":null}"#, + ] { + store.set("account:did:plc:broken", raw, None).unwrap(); + assert!(store.get_account("did:plc:broken").unwrap().is_none()); + assert!(store.list_accounts().unwrap().is_empty()); + } + let account = store + .upsert_account("did:plc:ok", &AccountUpdates::default()) + .unwrap(); + let value = serde_json::to_value(account).unwrap(); + assert!(value.get("addedAt").is_some()); + assert!(value.get("lastUsedAt").is_some()); + assert!(value.get("handle").is_none()); + assert_eq!(value["method"], "oauth"); + } + + #[test] + fn lists_accounts_in_case_insensitive_order() { + let store = memory(); + for (did, handle) in [ + ("did:plc:z", "z.example"), + ("did:plc:e", "é.example"), + ("did:plc:a", "a.example"), + ] { + store + .upsert_account( + did, + &AccountUpdates { + handle: Some(handle.into()), + ..Default::default() + }, + ) + .unwrap(); + } + let handles: Vec<_> = store + .list_accounts() + .unwrap() + .into_iter() + .map(|a| a.handle.unwrap()) + .collect(); + assert_eq!(handles, ["a.example", "z.example", "é.example"]); + } + + #[test] + fn accepts_unknown_scope_values_and_retains_bookkeeping() { + let store = memory(); + for scope in [ + serde_json::json!("future"), + serde_json::json!(null), + serde_json::json!({"future": true}), + serde_json::json!(42), + ] { + let raw = serde_json::json!({ + "did": "did:plc:scope", + "method": "app-password", + "addedAt": 12.5, + "lastUsedAt": 13, + "oauthScope": scope, + "pdsUrl": "https://pds.example" + }); + store + .set("account:did:plc:scope", &raw.to_string(), None) + .unwrap(); + let account = store.get_account("did:plc:scope").unwrap().unwrap(); + assert_eq!(serde_json::to_value(&account).unwrap()["oauthScope"], scope); + let updated = store + .upsert_account(&account.did, &AccountUpdates::default()) + .unwrap(); + assert_eq!(updated.method, AuthMethod::AppPassword); + assert_eq!(updated.added_at, 12.5); + assert_eq!(updated.pds_url.as_deref(), Some("https://pds.example")); + assert_eq!(serde_json::to_value(updated).unwrap()["oauthScope"], scope); + } + } + + #[test] + fn alias_ttl_and_stale_default_fallback() { + let store = memory(); + let mut account = store + .upsert_account( + "did:plc:a", + &AccountUpdates { + handle: Some("a.example".into()), + handle_checked: true, + ..Default::default() + }, + ) + .unwrap(); + account.handle_checked_at = Some(now_ms() - HANDLE_ALIAS_TTL_MS); + store.write_account(&account).unwrap(); + assert!(!store.alias_is_fresh(&account.did, "a.example").unwrap()); + assert_eq!( + store.cached_identifier("A.EXAMPLE").unwrap().as_deref(), + Some("did:plc:a") + ); + assert!(!store.alias_is_fresh(&account.did, "other.example").unwrap()); + store.set_default("did:plc:missing").unwrap(); + assert_eq!( + store + .resolve_account_for_dir("/unused") + .unwrap() + .unwrap() + .did, + account.did + ); + store.delete_dir("/unused").unwrap(); + store.clear().unwrap(); + assert!(store.entries("").unwrap().is_empty()); + } + + #[test] + fn resolution_precedence_and_cleanup() { + let store = memory(); + store + .upsert_account("did:plc:a", &AccountUpdates::default()) + .unwrap(); + assert_eq!( + store.resolve_account_for_dir("/x").unwrap().unwrap().did, + "did:plc:a" + ); + store + .upsert_account("did:plc:b", &AccountUpdates::default()) + .unwrap(); + assert!(store.resolve_account_for_dir("/x").unwrap().is_none()); + store.set_default("did:plc:a").unwrap(); + assert_eq!( + store.resolve_account_for_dir("/x").unwrap().unwrap().did, + "did:plc:a" + ); + store.set_dir("/x", "did:plc:b").unwrap(); + assert_eq!( + store.resolve_account_for_dir("/x").unwrap().unwrap().did, + "did:plc:b" + ); + store.set_dir("/legacy", "did:plc:legacy").unwrap(); + assert_eq!( + store + .resolve_account_for_dir("/legacy") + .unwrap() + .unwrap() + .method, + AuthMethod::OAuth + ); + store.set("handle:stale", "did:plc:a", None).unwrap(); + store.set_dir("/a", "did:plc:a").unwrap(); + store.delete_account("did:plc:a").unwrap(); + assert!(store.get_default().unwrap().is_none()); + assert!(store.get_dir("/a").unwrap().is_none()); + assert!(store.cached_identifier("stale").unwrap().is_none()); + store.delete_account("did:plc:missing").unwrap(); + } + + #[test] + fn migrates_legacy_mappings_and_secures_database() { + use std::sync::atomic::{AtomicUsize, Ordering}; + static COUNTER: AtomicUsize = AtomicUsize::new(0); + let parent = std::env::temp_dir().join(format!( + "wispctl-store-{}-{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + )); + let path = parent.join("config/state.sqlite"); + { + let store = AccountStore::open(&path).unwrap(); + store.delete("store_version").unwrap(); + store.set_dir("/legacy", "did:plc:old").unwrap(); + store.set_dir("/invalid", "not-a-did").unwrap(); + } + let store = AccountStore::open(&path).unwrap(); + assert!(store.get_account("did:plc:old").unwrap().is_some()); + assert!(store.get_account("not-a-did").unwrap().is_none()); + assert_eq!(store.get("store_version").unwrap().as_deref(), Some("1")); + let journal: String = store + .connection + .query_row("PRAGMA journal_mode", [], |r| r.get(0)) + .unwrap(); + assert_eq!(journal, "wal"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(path.parent().unwrap()) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + } + drop(store); + fs::remove_dir_all(parent).unwrap(); + } +} diff --git a/cli-rs/crates/wispctl/src/cli.rs b/cli-rs/crates/wispctl/src/cli.rs new file mode 100644 index 0000000..2a8e8e4 --- /dev/null +++ b/cli-rs/crates/wispctl/src/cli.rs @@ -0,0 +1,460 @@ +//! Command-line surface. Mirrors the TypeScript wispctl 1.x exactly: same +//! commands, aliases, flags, defaults and positional arguments. + +use std::path::PathBuf; + +use clap::{Args, Parser, Subcommand}; + +#[derive(Parser, Debug)] +#[command( + name = "wispctl", + version, + about = "CLI for wisp.place - deploy static sites to the AT Protocol", + subcommand_negates_reqs = true +)] +pub struct Cli { + /// Suppress progress output — useful for CI/agents (also set via WISPCTL_NO_PROGRESS=1) + #[arg(short, long, global = true)] + pub quiet: bool, + + #[command(subcommand)] + pub command: Option, + + /// `wispctl [handle] ...` with no subcommand deploys. + #[command(flatten)] + pub deploy: DeployArgs, +} + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Deploy a static site to wisp.place + Deploy(DeployArgs), + /// Download a site from wisp.place to a local directory + Pull(PullArgs), + /// Serve a site locally with live updates from firehose + Serve(ServeArgs), + /// List sites and domains from wisp XRPC routes + List(ListArgs), + /// Manage domains with wisp XRPC + #[command(alias = "domains")] + Domain(DomainArgs), + /// Manage sites with wisp XRPC + Site(SiteArgs), + /// Manage private sites (never published to your PDS) + Private(PrivateArgs), + /// Authenticate and store credentials for use from any directory + Login(LoginArgs), + /// Unlink the current directory, or forget an account everywhere + Logout(LogoutArgs), + /// Manage stored accounts + Accounts(AccountsArgs), +} + +#[derive(Args, Debug, Clone, Default)] +pub struct DbArg { + /// Account database path + #[arg(long = "db", value_name = "path")] + pub db: Option, +} + +/// Options shared by every command that calls the wisp XRPC service. +#[derive(Args, Debug, Clone, Default)] +pub struct XrpcOptions { + /// App password for headless authentication + #[arg(long, value_name = "password")] + pub password: Option, + #[command(flatten)] + pub db: DbArg, + /// Service DID to proxy through + #[arg(long, value_name = "did:...")] + pub service: Option, + /// Output raw JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Args, Debug, Clone, Default)] +pub struct DeployArgs { + /// Handle or DID to deploy as + pub handle: Option, + /// Directory to deploy + #[arg(short, long, value_name = "path")] + pub path: Option, + /// Site name (defaults to directory name) + #[arg(short, long, value_name = "name")] + pub site: Option, + /// Enable directory listing + #[arg(long)] + pub directory: bool, + /// Enable SPA mode (serve index.html for all routes) + #[arg(long)] + pub spa: bool, + /// Number of concurrent uploads (backs off to 2 on rate limit) + #[arg(short, long, value_name = "n", default_value_t = 3)] + pub concurrency: usize, + /// Force gzip compression for all files regardless of type + #[arg(long)] + pub force_gzip: bool, + /// App password for headless authentication + #[arg(long, value_name = "password")] + pub password: Option, + #[command(flatten)] + pub db: DbArg, + /// Skip confirmation prompts + #[arg(short, long)] + pub yes: bool, +} + +#[derive(Args, Debug, Clone)] +pub struct PullArgs { + pub handle: String, + /// Site name to pull + #[arg(short, long, value_name = "name")] + pub site: String, + /// Output directory + #[arg(short, long, value_name = "path", default_value = ".")] + pub path: PathBuf, +} + +#[derive(Args, Debug, Clone)] +pub struct ServeArgs { + pub handle: String, + /// Site name to serve + #[arg(short, long, value_name = "name")] + pub site: String, + /// Local directory to cache site + #[arg(short, long, value_name = "path", default_value = ".wisp-serve")] + pub path: PathBuf, + /// Port to serve on + #[arg(short = 'P', long, value_name = "port", default_value_t = 8080)] + pub port: u16, + /// Bind address (defaults to loopback; use 0.0.0.0 for public access) + #[arg(long, value_name = "host", default_value = "127.0.0.1")] + pub host: String, + /// Enable SPA mode (serve file for all unmatched routes, defaults to index.html) + #[arg(long, value_name = "file", num_args = 0..=1, default_missing_value = "index.html")] + pub spa: Option, + /// Enable directory listing + #[arg(long)] + pub directory_listing: bool, +} + +#[derive(Args, Debug)] +#[command(args_conflicts_with_subcommands = true)] +pub struct ListArgs { + #[command(subcommand)] + pub command: Option, + #[command(flatten)] + pub xrpc: XrpcOptions, +} + +#[derive(Subcommand, Debug)] +pub enum ListCommand { + /// List domains for an account + Domains(HandleXrpc), + /// List sites and their mapped domains for an account + Sites(HandleXrpc), +} + +#[derive(Args, Debug, Clone)] +pub struct HandleXrpc { + pub handle: Option, + #[command(flatten)] + pub xrpc: XrpcOptions, +} + +#[derive(Args, Debug)] +#[command(args_conflicts_with_subcommands = true)] +pub struct DomainArgs { + #[command(subcommand)] + pub command: Option, + #[command(flatten)] + pub xrpc: XrpcOptions, +} + +#[derive(Subcommand, Debug)] +pub enum DomainCommand { + /// Claim a custom domain + Claim { + handle: Option, + /// Custom domain + #[arg(short, long, value_name = "domain")] + domain: Option, + /// Optional site rkey to map + #[arg(short, long, value_name = "rkey")] + site: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, + /// Claim a wisp subdomain + ClaimSubdomain { + handle: Option, + /// Subdomain handle + #[arg(short = 'n', long, value_name = "name")] + subdomain: Option, + /// Optional site rkey to map + #[arg(short, long, value_name = "rkey")] + site: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, + /// Get domain verification/claim status + Status { + handle: Option, + /// Domain + #[arg(short, long, value_name = "domain")] + domain: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, + /// Map a claimed domain to a site rkey + AddSite { + handle: Option, + /// Domain + #[arg(short, long, value_name = "domain")] + domain: Option, + /// Site rkey + #[arg(short, long, value_name = "rkey")] + site: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, + /// Delete a claimed domain + Delete { + handle: Option, + /// Domain + #[arg(short, long, value_name = "domain")] + domain: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, + /// Run DNS verification for a claimed custom domain + Verify { + handle: Option, + /// Domain + #[arg(short, long, value_name = "domain")] + domain: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, +} + +#[derive(Args, Debug)] +#[command(args_conflicts_with_subcommands = true)] +pub struct SiteArgs { + #[command(subcommand)] + pub command: Option, + #[command(flatten)] + pub xrpc: XrpcOptions, +} + +#[derive(Subcommand, Debug)] +pub enum SiteCommand { + /// Delete a site from wisp metadata and unmap its domains + Delete { + handle: Option, + /// Site rkey + #[arg(short, long, value_name = "rkey")] + site: Option, + /// Skip delete confirmation + #[arg(short, long)] + yes: bool, + #[command(flatten)] + xrpc: XrpcOptions, + }, +} + +#[derive(Args, Debug)] +pub struct PrivateArgs { + #[command(subcommand)] + pub command: PrivateCommand, +} + +#[derive(Subcommand, Debug)] +pub enum PrivateCommand { + /// Upload a directory as a private site + Deploy { + handle: Option, + /// Directory to upload + #[arg(short, long, value_name = "dir", default_value = ".")] + path: PathBuf, + /// Display name for the private site + #[arg(short, long, value_name = "name")] + name: Option, + /// Minutes until the site expires. Omit for the server default, 0 to never expire + #[arg(short, long, value_name = "minutes")] + expiry: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, + /// List your private sites + List(HandleXrpc), + /// Delete a private site and all of its share links + Delete { + site_id: String, + handle: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, + /// Create a shareable link for a private site + Share { + site_id: String, + handle: Option, + /// Label to identify this link + #[arg(short, long, value_name = "label")] + label: Option, + /// Minutes until the link expires. Omit for the server default, 0 for none + #[arg(short, long, value_name = "minutes")] + expiry: Option, + /// Restrict the link to one account. They sign in to open it; the link alone grants nothing + #[arg(short, long, value_name = "did")] + to: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, + /// List share links for a private site + Shares { + site_id: String, + handle: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, + /// Revoke a share link immediately + Revoke { + site_id: String, + share_id: String, + handle: Option, + #[command(flatten)] + xrpc: XrpcOptions, + }, +} + +#[derive(Args, Debug)] +pub struct LoginArgs { + pub handle: String, + #[command(flatten)] + pub db: DbArg, + /// Log in with an app password instead of OAuth (or set WISPCTL_APP_PASSWORD) + #[arg(long, value_name = "password")] + pub password: Option, +} + +#[derive(Args, Debug)] +pub struct LogoutArgs { + pub handle: Option, + #[command(flatten)] + pub db: DbArg, + /// Forget every stored account and credential + #[arg(long)] + pub all: bool, +} + +#[derive(Args, Debug)] +#[command(args_conflicts_with_subcommands = true)] +pub struct AccountsArgs { + #[command(subcommand)] + pub command: Option, + /// `wispctl accounts` with no subcommand lists. + #[command(flatten)] + pub list: AccountsListArgs, +} + +#[derive(Args, Debug, Clone, Default)] +pub struct AccountsListArgs { + #[command(flatten)] + pub db: DbArg, + /// Output raw JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Subcommand, Debug)] +pub enum AccountsCommand { + /// List stored accounts + List(AccountsListArgs), + /// Set the account used in directories with no linked account + Use { + handle: String, + #[command(flatten)] + db: DbArg, + }, + /// Forget an account and remove its stored credentials + Remove { + handle: String, + #[command(flatten)] + db: DbArg, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + #[test] + fn cli_definition_is_consistent() { + Cli::command().debug_assert(); + } + + fn parse(args: &[&str]) -> Cli { + Cli::try_parse_from(std::iter::once("wispctl").chain(args.iter().copied())).unwrap() + } + + #[test] + fn bare_invocation_deploys() { + let cli = parse(&["alice.bsky.social", "--path", ".", "--site", "blog"]); + assert!(cli.command.is_none()); + assert_eq!(cli.deploy.handle.as_deref(), Some("alice.bsky.social")); + assert_eq!(cli.deploy.site.as_deref(), Some("blog")); + assert_eq!(cli.deploy.concurrency, 3); + } + + #[test] + fn quiet_is_global() { + let cli = parse(&["--quiet", "login", "alice.test"]); + assert!(cli.quiet && matches!(cli.command, Some(Command::Login(_)))); + let cli = parse(&["login", "alice.test", "-q"]); + assert!(cli.quiet && matches!(cli.command, Some(Command::Login(_)))); + let cli = parse(&["-q", "alice.test", "--site", "blog"]); + assert!(cli.quiet && cli.command.is_none()); + } + + #[test] + fn serve_spa_flag_takes_optional_file() { + let Some(Command::Serve(args)) = parse(&["serve", "a.test", "-s", "x", "--spa"]).command + else { + panic!("expected serve"); + }; + assert_eq!(args.spa.as_deref(), Some("index.html")); + assert_eq!(args.port, 8080); + let Some(Command::Serve(args)) = + parse(&["serve", "a.test", "-s", "x", "--spa", "app.html"]).command + else { + panic!("expected serve"); + }; + assert_eq!(args.spa.as_deref(), Some("app.html")); + } + + #[test] + fn domains_alias_and_bare_group() { + assert!(matches!( + parse(&["domains"]).command, + Some(Command::Domain(DomainArgs { command: None, .. })) + )); + assert!(matches!( + parse(&["domain", "claim", "a.test", "-d", "example.com"]).command, + Some(Command::Domain(DomainArgs { + command: Some(DomainCommand::Claim { .. }), + .. + })) + )); + } + + #[test] + fn accounts_defaults_to_list() { + let Some(Command::Accounts(args)) = parse(&["accounts", "--json"]).command else { + panic!("expected accounts"); + }; + assert!(args.command.is_none() && args.list.json); + } +} diff --git a/cli-rs/crates/wispctl/src/commands/accounts.rs b/cli-rs/crates/wispctl/src/commands/accounts.rs new file mode 100644 index 0000000..d35e731 --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/accounts.rs @@ -0,0 +1,136 @@ +//! Stored account listing and default selection. +use super::logout::{find_account, forget, open_store}; +use crate::{ + auth::{ + self, keychain, + store::{AccountStore, AuthMethod, StoredAccount}, + }, + cli::{AccountsArgs, AccountsCommand, AccountsListArgs}, +}; +use anyhow::{Result, bail}; +use serde::Serialize; +use wisp_ui::{Line, s}; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct AccountListing { + #[serde(flatten)] + account: StoredAccount, + has_credential: bool, + dirs: Vec, + is_default: bool, + is_current_dir: bool, +} + +fn has_credential(store: &AccountStore, did: &str) -> Result { + for key in [did.to_owned(), format!("app-password:{did}")] { + if keychain::read_secret(&key).is_some() { + return Ok(true); + } + } + for (key, _) in store.entries(&format!("oauth_index_v2:{did}/"))? { + let session = key.replacen("oauth_index_v2:", "oauth_session_v2:", 1); + if keychain::read_secret(&format!("oauth-v2:{session}")).is_some() + || store.get(&session)?.is_some() + { + return Ok(true); + } + } + Ok(store.get(&format!("oauth_session:{did}"))?.is_some()) +} + +async fn list(args: AccountsListArgs) -> Result<()> { + let store = open_store(args.db.db.as_deref())?; + let default = store.get_default()?; + let current = store.get_dir(&std::env::current_dir()?.to_string_lossy())?; + let mut listings = Vec::new(); + for mut account in store.list_accounts()? { + if account.handle.is_none() { + account.handle = auth::resolver::backfill_handle(&store, &account.did).await?; + } + listings.push(AccountListing { + has_credential: has_credential(&store, &account.did)?, + dirs: store.list_dirs_for_did(&account.did)?, + is_default: default.as_deref() == Some(&account.did), + is_current_dir: current.as_deref() == Some(&account.did), + account, + }); + } + if args.json { + wisp_ui::out_text(&serde_json::to_string_pretty(&listings)?); + return Ok(()); + } + if listings.is_empty() { + wisp_ui::out("No stored accounts. Run `wispctl login ` to add one."); + return Ok(()); + } + wisp_ui::out(""); + for listing in listings { + let mut tags = vec![s::muted( + if listing.account.method == AuthMethod::AppPassword { + "app password" + } else { + "oauth" + }, + )]; + for (show, tag) in [ + (!listing.has_credential, s::warn("no credential")), + (listing.is_default, s::ok("default")), + (listing.is_current_dir, s::accent("this directory")), + ] { + if show { + tags.push(s::muted(", ")); + tags.push(tag); + } + } + let mut line = vec![ + s::plain(" "), + s::bold( + listing + .account + .handle + .as_deref() + .unwrap_or(&listing.account.did) + .to_owned(), + ), + s::muted(" ("), + ]; + line.extend(tags); + line.push(s::muted(")")); + wisp_ui::out(Line::from(line)); + if listing.account.handle.is_some() { + wisp_ui::out(Line::from(s::muted(format!(" {}", listing.account.did)))); + } + if !listing.dirs.is_empty() { + let suffix = if listing.dirs.len() == 1 { "y" } else { "ies" }; + wisp_ui::out(Line::from(s::muted(format!( + " {} linked director{suffix}", + listing.dirs.len() + )))); + } + } + wisp_ui::out(""); + Ok(()) +} + +pub async fn run(args: AccountsArgs) -> Result<()> { + match args.command { + None => list(args.list).await, + Some(AccountsCommand::List(args)) => list(args).await, + Some(AccountsCommand::Use { handle, db }) => { + let store = open_store(db.db.as_deref())?; + let Some(account) = find_account(&store, &handle).await? else { + bail!("No stored account for {handle}. Run `wispctl login {handle}` first."); + }; + store.set_default(&account.did)?; + wisp_ui::out(format!( + "Default account set to {}", + account.handle.as_deref().unwrap_or(&account.did) + )); + Ok(()) + } + Some(AccountsCommand::Remove { handle, db }) => { + forget(&open_store(db.db.as_deref())?, &handle).await + } + } +} diff --git a/cli-rs/crates/wispctl/src/commands/deploy.rs b/cli-rs/crates/wispctl/src/commands/deploy.rs new file mode 100644 index 0000000..ba16e58 --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/deploy.rs @@ -0,0 +1,401 @@ +//! Deployment orchestrates repository effects around the pure tree/split planner. + +use crate::deploy::{repo, repo::SiteRepo, upload}; +use crate::{ + auth::{self, AuthOptions}, + cli::DeployArgs, + prompts, + xrpc::bind_auth_status, +}; +use anyhow::{Result, bail}; +use jacquard::types::{string::Datetime, tid::Tid}; +use std::collections::{BTreeMap, BTreeSet, VecDeque}; +use wisp_core::{ + blob::{is_text_mime, mime_for, should_compress}, + constants::{MAX_FILE_COUNT, MAX_FILE_SIZE, MAX_SITE_SIZE}, + convert::subfs_to_fs, + ignore::collect_files, + split::{manifest, split_plan, subfs_record}, + tree::{ExistingBlob, UploadResult, build_tree, extract_blob_map, extract_subfs_uris}, +}; +use wisp_lexicons::place_wisp::{fs::Fs, settings::Settings, subfs::SubfsRecord}; +use wisp_ui::{TextPrompt, s}; + +fn valid_site(site: &str) -> bool { + !site.is_empty() + && site.len() <= 512 + && site + .bytes() + .all(|c| c.is_ascii_alphanumeric() || b"._~:-".contains(&c)) +} + +pub async fn run(mut args: DeployArgs) -> Result<()> { + let known = if args.handle.is_none() && args.password.is_none() { + auth::resolve_account_for_cwd(args.db.db.as_deref()).await? + } else { + None + }; + let needs_handle = args.handle.is_none() && known.is_none(); + let prompted = needs_handle || args.path.is_none() || args.site.is_none(); + if prompted { + wisp_ui::intro("deploy"); + } + if needs_handle { + args.handle = Some(prompts::handle("Deploy cancelled")?); + } + let path = match args.path { + Some(path) => path, + None => prompts::required_text( + TextPrompt::new("Directory to deploy") + .placeholder(".") + .default("."), + "Deploy cancelled", + "Missing path: pass --path ", + )? + .into(), + }; + let site = match args.site { + Some(site) => site, + None => prompts::required_text( + TextPrompt::new("Site name") + .placeholder("my-website") + .validate(|value| { + if value.is_empty() { + Err("Site name is required".into()) + } else if !valid_site(value) { + Err("Site name must be 1-512 characters of [a-zA-Z0-9._~:-]".into()) + } else { + Ok(()) + } + }), + "Deploy cancelled", + "Missing site name: pass --site ", + )?, + } + .to_lowercase(); + if !valid_site(&site) { + bail!("Invalid site name: {site}. Must be 1-512 chars of [a-zA-Z0-9._~:-]"); + } + if args.concurrency == 0 { + bail!("Concurrency must be at least 1"); + } + let mut spinner = wisp_ui::spinner("Authenticating..."); + let authenticated = auth::authenticate( + args.handle.as_deref(), + &AuthOptions { + app_password: args.password, + db_path: args.db.db, + force_reauth: false, + }, + |message| bind_auth_status(&mut spinner, message), + ) + .await?; + spinner.succeed(format!("Authenticated as {}", authenticated.did)); + let agent = authenticated.agent; + let did = authenticated.did; + wisp_ui::blank(); + wisp_ui::note(wisp_ui::Line::from(vec![ + s::accent("Deploying "), + s::bold(site.clone()), + s::accent(format!(" from {}", path.display())), + ])); + wisp_ui::blank(); + let spinner = wisp_ui::spinner("Scanning directory..."); + let files = collect_files(&path)?; + if files.is_empty() { + spinner.fail("No files found to deploy".to_owned()); + bail!("No files found"); + } + let size: u64 = files.iter().map(|file| file.size).sum(); + spinner.succeed(format!( + "Found {} files ({})", + files.len(), + wisp_ui::format_bytes(size) + )); + if files.len() > MAX_FILE_COUNT { + wisp_ui::warning(format!( + "Warning: Site has {} files (limit: {MAX_FILE_COUNT})", + files.len() + )); + wisp_ui::warning("Site may not be cached by the hosting service."); + } + if size > MAX_SITE_SIZE { + wisp_ui::warning(format!( + "Warning: Site is {} (limit: {})", + wisp_ui::format_bytes(size), + wisp_ui::format_bytes(MAX_SITE_SIZE) + )); + wisp_ui::warning("Site may not be cached by the hosting service."); + } + for file in files.iter().filter(|file| file.size > MAX_FILE_SIZE) { + wisp_ui::warning(format!( + "{} exceeds max size ({} > {})", + file.relative_path, + wisp_ui::format_bytes(file.size), + wisp_ui::format_bytes(MAX_FILE_SIZE) + )); + wisp_ui::warning("This file may not be cached by the hosting service."); + } + let spinner = wisp_ui::spinner("Checking for existing site..."); + let existing = fetch_existing(&agent, &did, &site).await; + spinner.succeed( + if existing.is_some() { + "Found existing site, will reuse unchanged files" + } else { + "No existing site found, uploading all files" + } + .to_owned(), + ); + let empty = BTreeMap::new(); + let blobs = existing.as_ref().map(|site| &site.blobs).unwrap_or(&empty); + let mut uploads = upload::process( + &agent, + &files, + blobs, + args.concurrency, + false, + args.force_gzip, + ) + .await?; + let spinner = wisp_ui::spinner("Creating manifest..."); + let writes = match put_manifest(&agent, &did, &site, &uploads).await { + Ok(writes) => writes, + Err(error) if repo::http_status(&error) == Some(500) => { + wisp_ui::warning( + "Manifest put failed with 500, retrying with base64 encoding for text files...", + ); + let text_files: Vec<_> = files + .iter() + .filter(|file| { + let mime = mime_for(&file.relative_path); + should_compress(&mime, &file.relative_path) && is_text_mime(&mime) + }) + .cloned() + .collect(); + let replacements: BTreeMap<_, _> = upload::process( + &agent, + &text_files, + blobs, + args.concurrency, + true, + args.force_gzip, + ) + .await? + .into_iter() + .collect(); + for (path, upload) in &mut uploads { + if let Some(replacement) = replacements.get(path) { + *upload = replacement.clone(); + } + } + put_manifest(&agent, &did, &site, &uploads).await? + } + Err(error) => return Err(error), + }; + spinner.succeed("Created manifest record".to_owned()); + if let Some(existing) = existing { + for (owner, key) in existing.subfs { + if owner == did && !writes.contains(&key) { + // Best effort, like the old CLI: a leftover record is harmless. + let _ = agent.delete::(&key).await; + } + } + } + if args.directory || args.spa { + let spinner = wisp_ui::spinner("Creating settings..."); + let settings: Settings = Settings { + directory_listing: Some(args.directory), + clean_urls: Some(true), + spa_mode: args.spa.then(|| "index.html".into()), + custom404: None, + headers: None, + index_files: None, + extra_data: None, + }; + agent.put(&site, settings).await?; + spinner.succeed("Created settings record".to_owned()); + } + wisp_ui::blank(); + wisp_ui::out(labelled( + "URI", + s::muted(format!("at://{did}/place.wisp.fs/{site}")), + )); + if let Some(handle) = args + .handle + .or_else(|| known.and_then(|account| account.handle)) + { + wisp_ui::out(labelled( + "URL", + s::link(format!("https://sites.wisp.place/{handle}/{site}")), + )); + } + wisp_ui::out(labelled( + "URL", + s::link(format!("https://sites.wisp.place/{did}/{site}")), + )); + if prompted { + wisp_ui::outro("Deployed successfully!"); + } else { + wisp_ui::blank(); + wisp_ui::success("Deployed successfully!"); + } + Ok(()) +} + +async fn put_manifest( + repo: &impl SiteRepo, + did: &str, + site: &str, + uploads: &[(String, UploadResult)], +) -> Result> { + let root = build_tree(uploads); + // Millisecond precision, like JavaScript's toISOString in the old CLI. + let created_at: Datetime = chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S%.3fZ") + .to_string() + .parse()?; + let generation = Tid::now_0().to_string(); + let plan = split_plan(&root, did, site, &generation, &created_at)?; + let splitting = (!plan.records.is_empty()) + .then(|| wisp_ui::spinner("Splitting large site into subfs records...")); + let mut completed_messages = Vec::new(); + for message in plan.messages { + if matches!( + message, + wisp_core::split::SplitMessage::CreatedParent { .. } + ) { + completed_messages.push(message); + } else { + wisp_ui::info(split_message(message)); + } + } + let mut keys = BTreeSet::new(); + for record in plan.records { + repo.put(&record.rkey, subfs_record(&record, &created_at)) + .await?; + keys.insert(record.rkey); + } + for message in completed_messages { + wisp_ui::info(split_message(message)); + } + if let Some(spinner) = splitting { + spinner.succeed(format!("Created {} subfs records", keys.len())); + } + repo.put(site, manifest(site, &plan.directory, &created_at)) + .await?; + Ok(keys) +} + +fn split_message(message: wisp_core::split::SplitMessage) -> String { + use wisp_core::split::SplitMessage; + match message { + SplitMessage::DirectoryTooLarge { size } => format!( + " → Directory too large ({}), splitting into chunks...", + wisp_ui::format_bytes(size as u64) + ), + SplitMessage::CreatedChunks { count } => format!(" → Created {count} chunks"), + SplitMessage::UploadingChunk { + index, + count, + files, + size, + } => format!( + " → Uploading chunk {index}/{count} ({files} files, {})...", + wisp_ui::format_bytes(size as u64) + ), + SplitMessage::CreatingParent { count } => { + format!(" → Creating parent subfs with {count} chunk references...") + } + SplitMessage::CreatedParent { count } => { + format!(" ✓ Created parent subfs with {count} chunks") + } + SplitMessage::CannotSplit { files, size } => format!( + "Cannot split further — no files or directories available ({files} files, {:.1}KB)", + size as f64 / 1024.0 + ), + } +} + +struct ExistingSite { + blobs: BTreeMap, + subfs: BTreeSet<(String, String)>, +} + +async fn fetch_existing(repo: &impl SiteRepo, did: &str, site: &str) -> Option { + let root = repo.fetch::(did, site).await.ok()?.root; + let mut result = ExistingSite { + blobs: extract_blob_map(&root), + subfs: BTreeSet::new(), + }; + let mut queue = VecDeque::from([(String::new(), root, BTreeSet::new())]); + let mut visited = BTreeSet::new(); + while let Some((prefix, root, ancestors)) = queue.pop_front() { + if ancestors.len() >= 100 || visited.len() >= 10_000 { + continue; + } + for reference in extract_subfs_uris(&root) { + let uri = reference.uri; + if ancestors.contains(&uri) { + continue; + } + let Ok(subject) = wisp_core::subfs::parse_subfs_subject(&uri) else { + continue; + }; + let (owner, key) = (subject.repo, subject.rkey); + result.subfs.insert((owner.clone(), key.clone())); + let base = if reference.flat { + reference + .path + .rsplit_once('/') + .map(|(parent, _)| parent) + .unwrap_or("") + } else { + &reference.path + }; + let prefix = join(&prefix, base); + if !visited.insert((prefix.clone(), uri.clone())) { + continue; + } + let Ok(record) = repo.fetch::(&owner, &key).await else { + continue; + }; + let root = subfs_to_fs(&record.root); + for (path, blob) in extract_blob_map(&root) { + result.blobs.insert(join(&prefix, &path), blob); + } + let mut ancestors = ancestors.clone(); + ancestors.insert(uri); + queue.push_back((prefix, root, ancestors)); + } + } + Some(result) +} + +fn join(prefix: &str, path: &str) -> String { + if prefix.is_empty() { + path.into() + } else if path.is_empty() { + prefix.into() + } else { + format!("{prefix}/{path}") + } +} + +/// ` URL: `: a muted label so only the value itself is highlighted. +fn labelled(label: &str, value: wisp_ui::Span<'static>) -> wisp_ui::Line<'static> { + wisp_ui::Line::from(vec![s::muted(format!(" {label}: ")), value]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn rkeys_match_typescript() { + for key in ["a", "A._~:-9", &"x".repeat(512)] { + assert!(valid_site(key)); + } + for key in ["", "a/b", "a b", "é", &"x".repeat(513)] { + assert!(!valid_site(key)); + } + } +} diff --git a/cli-rs/crates/wispctl/src/commands/domain.rs b/cli-rs/crates/wispctl/src/commands/domain.rs new file mode 100644 index 0000000..d224170 --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/domain.rs @@ -0,0 +1,248 @@ +use crate::{ + cli::{DomainArgs, DomainCommand}, + prompts, + xrpc::{self, text}, +}; +use anyhow::Result; +use serde_json::{Value, json}; +use wisp_lexicons::place_wisp::v2::domain; +use wisp_ui::{Choice, Line, s}; + +pub async fn run(args: DomainArgs) -> Result<()> { + let command = match args.command { + Some(command) => command, + None => { + wisp_ui::intro("domain"); + let action = prompts::select( + "Choose domain action", + vec![ + Choice::new("claim", "Claim custom domain"), + Choice::new("claim-subdomain", "Claim wisp subdomain"), + Choice::new("status", "Get domain status"), + Choice::new("add-site", "Map domain to site"), + Choice::new("verify", "Verify domain"), + Choice::new("delete", "Delete domain"), + ], + "Domain command cancelled", + "Pass a domain subcommand", + )?; + match action { + "claim" => DomainCommand::Claim { + handle: None, + domain: None, + site: None, + xrpc: args.xrpc, + }, + "claim-subdomain" => DomainCommand::ClaimSubdomain { + handle: None, + subdomain: None, + site: None, + xrpc: args.xrpc, + }, + "status" => DomainCommand::Status { + handle: None, + domain: None, + xrpc: args.xrpc, + }, + "add-site" => DomainCommand::AddSite { + handle: None, + domain: None, + site: None, + xrpc: args.xrpc, + }, + "verify" => DomainCommand::Verify { + handle: None, + domain: None, + xrpc: args.xrpc, + }, + _ => DomainCommand::Delete { + handle: None, + domain: None, + xrpc: args.xrpc, + }, + } + } + }; + let (action, handle, value, site, opts) = match command { + DomainCommand::Claim { + handle, + domain, + site, + xrpc, + } => ("claim", handle, domain, site, xrpc), + DomainCommand::ClaimSubdomain { + handle, + subdomain, + site, + xrpc, + } => ("claim-subdomain", handle, subdomain, site, xrpc), + DomainCommand::Status { + handle, + domain, + xrpc, + } => ("status", handle, domain, None, xrpc), + DomainCommand::AddSite { + handle, + domain, + site, + xrpc, + } => ("add-site", handle, domain, site, xrpc), + DomainCommand::Verify { + handle, + domain, + xrpc, + } => ("verify", handle, domain, None, xrpc), + DomainCommand::Delete { + handle, + domain, + xrpc, + } => ("delete", handle, domain, None, xrpc), + }; + let cancel = match action { + "claim" | "claim-subdomain" => "Claim cancelled", + "status" => "Status check cancelled", + "add-site" => "Add-site cancelled", + "verify" => "Verify cancelled", + _ => "Delete cancelled", + }; + let value = match value { + Some(value) => value, + None if action == "claim-subdomain" => prompts::required_value( + "Subdomain handle", + "alice", + "Subdomain is required", + cancel, + "Pass --subdomain ", + )?, + None => prompts::required_value( + if action == "claim" { + "Custom domain" + } else { + "Domain" + }, + "example.com", + "Domain is required", + cancel, + "Pass --domain ", + )?, + }; + let site = if action == "add-site" && site.is_none() { + Some(prompts::required_value( + "Site rkey", + "mysite", + "Site rkey is required", + cancel, + "Pass --site ", + )?) + } else { + site + }; + let (agent, service, _) = xrpc::authenticate_for_xrpc(handle.as_deref(), &opts).await?; + let label = match action { + "claim" => format!("Claiming {value}..."), + "claim-subdomain" => format!("Claiming subdomain {value}..."), + "status" => format!("Fetching status for {value}..."), + "add-site" => format!("Mapping {value} -> {}...", site.as_deref().unwrap_or("")), + "verify" => format!("Verifying {value}..."), + _ => format!("Deleting {value}..."), + }; + let spinner = wisp_ui::spinner(label); + let mut input = json!({"domain": value}); + if let Some(site) = site { + input["siteRkey"] = json!(site); + } + let data = match action { + "claim" => xrpc::call::(&agent, &service, input).await?, + "claim-subdomain" => { + input["handle"] = input["domain"].take(); + input.as_object_mut().unwrap().remove("domain"); + xrpc::call::(&agent, &service, input).await? + } + "status" => xrpc::call::(&agent, &service, input).await?, + "add-site" => xrpc::call::(&agent, &service, input).await?, + "verify" => xrpc::call::(&agent, &service, input).await?, + _ => xrpc::call::(&agent, &service, input).await?, + }; + let domain = text(&data, "domain"); + if action == "verify" && data["verified"] == false { + let error = data["error"] + .as_str() + .filter(|s| !s.is_empty()) + .map(|s| format!(": {s}")) + .unwrap_or_default(); + spinner.fail(format!("{domain} not verified{error}")); + } else { + let completed = match action { + "claim" | "claim-subdomain" => format!("Claimed {domain}"), + "status" => format!("Fetched status for {domain}"), + "add-site" => format!("Mapped {domain}"), + "verify" => format!("Verified {domain}"), + _ => format!("Deleted {domain}"), + }; + spinner.succeed(completed); + } + if opts.json { + return xrpc::json(&data); + } + render(action, &data); + Ok(()) +} + +fn render(action: &str, data: &Value) { + let domain = text(data, "domain").to_owned(); + let suffix = match action { + "delete" => " deleted".to_owned(), + "add-site" => format!(" -> {} ({})", text(data, "siteRkey"), text(data, "status")), + "verify" => format!( + " {}", + if data["verified"] == true { + "verified" + } else { + text(data, "status") + } + ), + _ => format!(" -> {}", text(data, "status")), + }; + wisp_ui::out(Line::from(vec![ + s::bold(domain), + if action == "verify" { + if data["verified"] == true { + s::ok(suffix) + } else { + s::warn(suffix) + } + } else { + s::plain(suffix) + }, + ])); + if action == "claim" { + if let (Some(name), Some(value)) = (data["txtName"].as_str(), data["txtValue"].as_str()) { + wisp_ui::out(format!("TXT: {name} = {value}")); + } + field(data, "cnameTarget", "CNAME"); + } + if action == "status" { + field(data, "kind", "Kind"); + if let Some(verified) = data["verified"].as_bool() { + wisp_ui::out(format!("Verified: {verified}")); + } + } + if matches!(action, "claim" | "claim-subdomain" | "status") { + field(data, "siteRkey", "Mapped site"); + } + if action == "status" { + field(data, "lastCheckedAt", "Last checked"); + field(data, "lastError", "Last error"); + } + if action == "verify" + && let Some(warning) = data["warning"].as_str().filter(|s| !s.is_empty()) + { + wisp_ui::out(s::warn(format!("warning: {warning}"))); + } +} + +fn field(data: &Value, key: &str, label: &str) { + if let Some(value) = data[key].as_str().filter(|s| !s.is_empty()) { + wisp_ui::out(format!("{label}: {value}")); + } +} diff --git a/cli-rs/crates/wispctl/src/commands/list.rs b/cli-rs/crates/wispctl/src/commands/list.rs new file mode 100644 index 0000000..b93b0fc --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/list.rs @@ -0,0 +1,132 @@ +use crate::{ + cli::{ListArgs, ListCommand, XrpcOptions}, + prompts, + xrpc::{self, items, text}, +}; +use anyhow::Result; +use serde_json::Value; +use wisp_lexicons::place_wisp::v2::{domain, site}; +use wisp_ui::{Choice, Line, s}; + +pub async fn run(args: ListArgs) -> Result<()> { + match args.command { + Some(ListCommand::Domains(args)) => domains(args.handle.as_deref(), &args.xrpc).await, + Some(ListCommand::Sites(args)) => sites(args.handle.as_deref(), &args.xrpc).await, + None => { + wisp_ui::intro("list"); + let domains_selected = prompts::select( + "What do you want to list?", + vec![ + Choice::new(true, "Domains").hint("Claimed, pending, and mapped domains"), + Choice::new(false, "Sites").hint("Sites with mapped domains"), + ], + "List cancelled", + "Pass `list sites` or `list domains`", + )?; + if domains_selected { + domains(None, &args.xrpc).await + } else { + sites(None, &args.xrpc).await + } + } + } +} + +pub fn domain_line(domain: &Value, prefix: &str, mapped: bool) -> Line<'static> { + let status = text(domain, "status").to_owned(); + let mut spans = vec![ + s::plain(prefix.to_owned()), + s::bold(text(domain, "domain").to_owned()), + s::plain(format!(" [{}] ", text(domain, "kind"))), + if status == "verified" { + s::ok(status) + } else { + s::warn(status) + }, + ]; + if mapped && let Some(site) = domain["siteRkey"].as_str().filter(|s| !s.is_empty()) { + spans.push(s::plain(format!(" -> {site}"))); + } + Line::from(spans) +} + +pub async fn domains(handle: Option<&str>, opts: &XrpcOptions) -> Result<()> { + let (agent, service, _) = xrpc::authenticate_for_xrpc(handle, opts).await?; + let spinner = wisp_ui::spinner("Fetching domains..."); + let data = xrpc::send(&agent, &service, domain::get_list::GetList, None).await?; + spinner.succeed("Fetched domains".to_owned()); + if opts.json { + return xrpc::json(&data); + } + let domains = items(&data, "domains"); + if domains.is_empty() { + wisp_ui::out(s::muted("No domains found.")); + return Ok(()); + } + wisp_ui::out(s::bold(format!("Domains ({})", domains.len()))); + for domain in domains { + wisp_ui::out(domain_line(domain, "- ", true)); + } + Ok(()) +} + +pub async fn sites(handle: Option<&str>, opts: &XrpcOptions) -> Result<()> { + let (agent, service, _) = xrpc::authenticate_for_xrpc(handle, opts).await?; + let spinner = wisp_ui::spinner("Fetching sites..."); + let data = xrpc::send(&agent, &service, site::get_list::GetList, None).await?; + spinner.succeed("Fetched sites".to_owned()); + if opts.json { + return xrpc::json(&data); + } + let sites = items(&data, "sites"); + if sites.is_empty() { + wisp_ui::out(s::muted("No sites found.")); + return Ok(()); + } + wisp_ui::out(s::bold(format!("Sites ({})", sites.len()))); + for site in sites { + wisp_ui::out(Line::from(vec![s::plain("- "), s::bold(site_title(site))])); + let domains = items(site, "domains"); + if domains.is_empty() { + wisp_ui::out(s::muted(" (no mapped domains)")); + } + for domain in domains { + wisp_ui::out(domain_line(domain, " ", false)); + } + } + Ok(()) +} + +pub fn site_title(site: &Value) -> String { + match site["displayName"].as_str().filter(|s| !s.is_empty()) { + Some(name) => format!("{} ({name})", text(site, "siteRkey")), + None => text(site, "siteRkey").to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn listings_keep_typescript_wording() { + let domain = json!({"domain": "alice.wisp.place", "kind": "wisp", "status": "verified", "siteRkey": "blog"}); + assert_eq!( + domain_line(&domain, "- ", true).to_string(), + "- alice.wisp.place [wisp] verified -> blog" + ); + assert_eq!( + domain_line(&domain, " ", false).to_string(), + " alice.wisp.place [wisp] verified" + ); + assert_eq!( + site_title(&json!({"siteRkey": "blog", "displayName": "My blog"})), + "blog (My blog)" + ); + assert_eq!( + site_title(&json!({"siteRkey": "blog", "displayName": ""})), + "blog" + ); + } +} diff --git a/cli-rs/crates/wispctl/src/commands/login.rs b/cli-rs/crates/wispctl/src/commands/login.rs new file mode 100644 index 0000000..a50bdd6 --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/login.rs @@ -0,0 +1,51 @@ +//! Explicit OAuth or app-password login. +use crate::{ + auth::{self, AuthOptions, keychain}, + cli::LoginArgs, + xrpc, +}; +use anyhow::Result; +use wisp_ui::{Line, s}; + +pub async fn run(args: LoginArgs) -> Result<()> { + let password = args + .password + .or_else(|| std::env::var("WISPCTL_APP_PASSWORD").ok()); + let password = password.map(|password| password.trim().to_owned()); + if password.as_ref().is_some_and(String::is_empty) { + anyhow::bail!("App password is required"); + } + let mut spinner = wisp_ui::spinner("Authenticating..."); + let result = auth::authenticate( + Some(&args.handle), + &AuthOptions { + app_password: password.clone(), + db_path: args.db.db, + force_reauth: true, + }, + |message| xrpc::bind_auth_status(&mut spinner, message), + ) + .await?; + let label = if password.is_some() { + result.handle.as_deref().unwrap_or(&args.handle) + } else { + &result.did + }; + spinner.succeed(format!("Authenticated as {label}")); + if let Some(password) = password { + if keychain::set_stored_app_password(&result.did, &password) { + wisp_ui::out(Line::from(s::muted( + " App password saved to the system keychain", + ))); + } else { + wisp_ui::out(Line::from(s::warn(format!( + " App password not saved: {}", + keychain::describe_unavailable_keychain(&keychain::probe_keychain()) + )))); + wisp_ui::out(Line::from(s::muted( + " Use --password or WISPCTL_APP_PASSWORD for future commands.", + ))); + } + } + Ok(()) +} diff --git a/cli-rs/crates/wispctl/src/commands/logout.rs b/cli-rs/crates/wispctl/src/commands/logout.rs new file mode 100644 index 0000000..7b2f903 --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/logout.rs @@ -0,0 +1,77 @@ +//! Account unlinking and credential removal. +use crate::{ + auth::{ + self, keychain, + store::{AccountStore, StoredAccount}, + }, + cli::LogoutArgs, +}; +use anyhow::Result; +use std::path::Path; + +pub(super) fn open_store(path: Option<&Path>) -> Result { + path.map_or_else(AccountStore::open_default, AccountStore::open) +} + +pub(super) async fn find_account( + store: &AccountStore, + identifier: &str, +) -> Result> { + match auth::resolver::resolve_identifier_to_did(store, identifier).await? { + Some(did) => store.get_account(&did), + None => Ok(None), + } +} + +pub(super) fn remove_credentials(store: &AccountStore, did: &str) -> Result<()> { + keychain::delete_stored_oauth_session(did); + keychain::delete_stored_app_password(did); + for (key, _) in store.entries(&format!("oauth_index_v2:{did}/"))? { + let session = key.replacen("oauth_index_v2:", "oauth_session_v2:", 1); + keychain::remove_secret(&format!("oauth-v2:{session}")); + store.delete(&session)?; + store.delete(&key)?; + } + store.delete(&format!("oauth_session:{did}"))?; + Ok(()) +} + +pub(super) async fn forget(store: &AccountStore, identifier: &str) -> Result<()> { + if let Some(account) = find_account(store, identifier).await? { + remove_credentials(store, &account.did)?; + store.delete_account(&account.did)?; + wisp_ui::out(format!( + "Forgot {} and removed its stored credentials", + account.handle.as_deref().unwrap_or(&account.did) + )); + } else { + wisp_ui::out(format!("No stored account for {identifier}")); + } + Ok(()) +} + +pub async fn run(args: LogoutArgs) -> Result<()> { + let store = open_store(args.db.db.as_deref())?; + if args.all { + let mut dids: std::collections::BTreeSet<_> = + store.list_accounts()?.into_iter().map(|a| a.did).collect(); + dids.extend( + store + .entries("dir:")? + .into_iter() + .map(|(_, did)| did) + .filter(|did| did.starts_with("did:")), + ); + for did in dids { + remove_credentials(&store, &did)?; + } + store.clear()?; + wisp_ui::out("Cleared all stored accounts and credentials"); + } else if let Some(identifier) = args.handle { + forget(&store, &identifier).await?; + } else { + store.delete_dir(&std::env::current_dir()?.to_string_lossy())?; + wisp_ui::out("Unlinked the current directory (stored credentials kept)"); + } + Ok(()) +} diff --git a/cli-rs/crates/wispctl/src/commands/mod.rs b/cli-rs/crates/wispctl/src/commands/mod.rs new file mode 100644 index 0000000..6213e7c --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/mod.rs @@ -0,0 +1,30 @@ +pub mod accounts; +pub mod deploy; +pub mod domain; +pub mod list; +pub mod login; +pub mod logout; +pub mod private; +pub mod pull; +pub mod serve; +pub mod site; + +use anyhow::Result; + +use crate::cli::{Cli, Command}; + +pub async fn run(cli: Cli) -> Result<()> { + match cli.command { + None => deploy::run(cli.deploy).await, + Some(Command::Deploy(args)) => deploy::run(args).await, + Some(Command::Pull(args)) => pull::run(args).await, + Some(Command::Serve(args)) => serve::run(args).await, + Some(Command::List(args)) => list::run(args).await, + Some(Command::Domain(args)) => domain::run(args).await, + Some(Command::Site(args)) => site::run(args).await, + Some(Command::Private(args)) => private::run(args).await, + Some(Command::Login(args)) => login::run(args).await, + Some(Command::Logout(args)) => logout::run(args).await, + Some(Command::Accounts(args)) => accounts::run(args).await, + } +} diff --git a/cli-rs/crates/wispctl/src/commands/private.rs b/cli-rs/crates/wispctl/src/commands/private.rs new file mode 100644 index 0000000..d72e2e4 --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/private.rs @@ -0,0 +1,465 @@ +use crate::{ + auth::WispAgent, + cli::{PrivateArgs, PrivateCommand, XrpcOptions}, + xrpc::{self, items, text}, +}; +use anyhow::{Result, bail}; +use jacquard::types::string::Datetime; +use serde_json::{Value, json}; +use std::path::Path; +use wisp_lexicons::place_wisp::v2::privateSite as private; +use wisp_ui::{Line, Span, s}; + +use wisp_core::constants::{ + MAX_PRIVATE_SITE_FILE_COUNT as MAX_FILES, MAX_PRIVATE_SITE_SIZE as MAX_SIZE, +}; + +fn format_bytes(bytes: u64) -> String { + if bytes < 1024 { + format!("{bytes} B") + } else if bytes < 1024 * 1024 { + format!("{:.1} KB", bytes as f64 / 1024.0) + } else { + format!("{:.1} MB", bytes as f64 / 1024.0 / 1024.0) + } +} + +fn expiry(raw: Option<&str>) -> Result> { + let Some(raw) = raw else { + return Ok(None); + }; + let raw = raw.trim(); + let radix = [ + ("0x", 16), + ("0X", 16), + ("0o", 8), + ("0O", 8), + ("0b", 2), + ("0B", 2), + ] + .into_iter() + .find_map(|(prefix, radix)| raw.strip_prefix(prefix).map(|digits| (digits, radix))); + let value = if raw.is_empty() { + 0.0 + } else if let Some((digits, radix)) = radix { + u64::from_str_radix(digits, radix) + .map(|n| n as f64) + .unwrap_or(f64::NAN) + } else { + raw.parse::().unwrap_or(f64::NAN) + }; + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > i64::MAX as f64 { + bail!("--expiry must be a non-negative whole number of minutes (0 disables expiry)"); + } + Ok(Some(value as i64)) +} + +fn expiry_label(raw: Option<&str>) -> Span<'static> { + let Some(raw) = raw else { + return s::muted("never expires"); + }; + let Ok(when) = raw.parse::() else { + return s::danger("expired"); + }; + let mins = ((when.timestamp_millis() - Datetime::now().timestamp_millis()) as f64 / 60000.0) + .round() as i64; + if mins <= 0 { + s::danger("expired") + } else if mins < 60 { + s::warn(format!("expires in {mins}m")) + } else if mins < 1440 { + s::warn(format!( + "expires in {}h", + (mins as f64 / 60.0).round() as i64 + )) + } else { + s::muted(format!("expires {}", raw.get(..10).unwrap_or(raw))) + } +} + +fn size(data: &Value) -> String { + format_bytes(data["totalBytes"].as_u64().unwrap_or(0)) +} + +pub async fn run(args: PrivateArgs) -> Result<()> { + match args.command { + PrivateCommand::Deploy { + handle, + path, + name, + expiry, + xrpc, + } => { + let (agent, service, _) = xrpc::authenticate_for_xrpc(handle.as_deref(), &xrpc).await?; + deploy(&agent, &service, &path, name.as_deref(), expiry.as_deref()).await + } + PrivateCommand::List(args) => { + let (agent, service, _) = + xrpc::authenticate_for_xrpc(args.handle.as_deref(), &args.xrpc).await?; + let spinner = wisp_ui::spinner("Fetching private sites..."); + let data = xrpc::send(&agent, &service, private::list::List, None).await?; + spinner.succeed("Fetched private sites".to_owned()); + render_sites(&data); + Ok(()) + } + PrivateCommand::Delete { + site_id, + handle, + xrpc, + } => { + let (agent, service, _) = xrpc::authenticate_for_xrpc(handle.as_deref(), &xrpc).await?; + let spinner = wisp_ui::spinner("Deleting private site..."); + xrpc::call::(&agent, &service, json!({"siteId": site_id})) + .await?; + spinner.succeed(format!("Deleted private site {site_id}")); + Ok(()) + } + PrivateCommand::Share { + site_id, + handle, + label, + expiry: raw_expiry, + to, + xrpc, + } => { + let (agent, service, _) = xrpc::authenticate_for_xrpc(handle.as_deref(), &xrpc).await?; + let expiry_minutes = expiry(raw_expiry.as_deref())?; + let spinner = wisp_ui::spinner("Creating share link..."); + let data = xrpc::call::(&agent, &service, json!({ + "siteId": site_id, "label": label, "expiryMinutes": expiry_minutes, "audienceDid": to, + })).await?; + spinner.succeed("Share link created".to_owned()); + render_share(&data, &site_id); + Ok(()) + } + PrivateCommand::Shares { + site_id, + handle, + xrpc, + } => shares(handle.as_deref(), &site_id, &xrpc).await, + PrivateCommand::Revoke { + site_id, + share_id, + handle, + xrpc, + } => { + let (agent, service, _) = xrpc::authenticate_for_xrpc(handle.as_deref(), &xrpc).await?; + let spinner = wisp_ui::spinner("Revoking share link..."); + xrpc::call::( + &agent, + &service, + json!({"siteId": site_id, "shareId": share_id}), + ) + .await?; + spinner.succeed(format!("Revoked share {share_id}")); + Ok(()) + } + } +} + +fn normalize_directory(path: &Path) -> std::path::PathBuf { + path.components() + .fold(std::path::PathBuf::new(), |mut out, part| { + match part { + std::path::Component::CurDir => {} + std::path::Component::ParentDir => { + out.pop(); + } + _ => out.push(part.as_os_str()), + } + out + }) +} + +async fn deploy( + agent: &WispAgent, + service: &str, + path: &Path, + name: Option<&str>, + raw_expiry: Option<&str>, +) -> Result<()> { + let site_dir = if path.is_absolute() { + path.to_owned() + } else { + std::env::current_dir()?.join(path) + }; + let site_dir = normalize_directory(&site_dir); + if !site_dir.is_dir() { + bail!("Not a directory: {}", site_dir.display()); + } + let fallback = site_dir.file_name().and_then(|s| s.to_str()).unwrap_or(""); + let name = name.filter(|s| !s.is_empty()).unwrap_or(fallback).trim(); + let expiry_minutes = expiry(raw_expiry)?; + wisp_ui::out(s::accent(format!( + "\nCreating private site {name} from {}\n", + site_dir.display() + ))); + let spinner = wisp_ui::spinner("Scanning directory..."); + let files = wisp_core::ignore::collect_files(&site_dir)?; + if files.is_empty() { + spinner.fail("No files to upload".to_owned()); + bail!("No files found to upload"); + } + if files.len() > MAX_FILES { + spinner.fail("Too many files".to_owned()); + bail!( + "Private sites are limited to {MAX_FILES} files (found {})", + files.len() + ); + } + let total_bytes: u64 = files.iter().map(|file| file.size).sum(); + if total_bytes > MAX_SIZE { + spinner.fail("Site too large".to_owned()); + bail!( + "Private sites are limited to {} (found {})", + format_bytes(MAX_SIZE), + format_bytes(total_bytes) + ); + } + spinner.succeed(format!( + "Found {} files ({})", + files.len(), + format_bytes(total_bytes) + )); + let spinner = wisp_ui::spinner("Uploading privately..."); + let parts = files + .iter() + .map(|file| { + Ok(( + file.relative_path.clone(), + wisp_core::blob::mime_for(&file.relative_path), + std::fs::read(&file.path)?, + )) + }) + .collect::>>()?; + let (body, content_type) = multipart(name, expiry_minutes, &parts); + let data = xrpc::send( + agent, + service, + private::create::Create { body: body.into() }, + Some(&content_type), + ) + .await?; + spinner.succeed("Uploaded".to_owned()); + wisp_ui::out(""); + wisp_ui::out(Line::from(vec![ + s::bold("Private site: "), + s::link(text(&data, "url").to_owned()), + ])); + wisp_ui::out(format!("site id: {}", text(&data, "siteId"))); + wisp_ui::out(format!( + "files: {} size: {}", + data["fileCount"], + size(&data) + )); + wisp_ui::out(Line::from(vec![ + s::muted("expiry: "), + expiry_label(data["expiresAt"].as_str()), + ])); + wisp_ui::out(""); + wisp_ui::out(s::muted("Only you can open that URL while signed in.")); + wisp_ui::out(s::muted(format!( + "Create a shareable link with: wisp private share {}", + text(&data, "siteId") + ))); + Ok(()) +} + +fn quote_filename(name: &str) -> String { + name.replace('\r', "%0D") + .replace('\n', "%0A") + .replace('"', "%22") +} + +fn multipart( + name: &str, + expiry: Option, + files: &[(String, String, Vec)], +) -> (Vec, String) { + // A deterministic candidate is fine, but never allow a file to terminate a part. + let mut boundary = "----wispctl-private-upload".to_owned(); + while name.contains(&boundary) + || files.iter().any(|(_, _, bytes)| { + bytes + .windows(boundary.len()) + .any(|w| w == boundary.as_bytes()) + }) + { + boundary.push('x'); + } + let mut body = Vec::new(); + body.extend_from_slice( + format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"name\"\r\n\r\n{name}\r\n" + ) + .as_bytes(), + ); + if let Some(expiry) = expiry { + body.extend_from_slice( + format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"expiryMinutes\"\r\n\r\n{expiry}\r\n" + ) + .as_bytes(), + ); + } + for (path, mime, bytes) in files { + body.extend_from_slice( + format!( + "--{boundary}\r\n\ + Content-Disposition: form-data; name=\"files\"; filename=\"{}\"\r\n\ + Content-Type: {mime}\r\n\r\n", + quote_filename(path) + ) + .as_bytes(), + ); + body.extend_from_slice(bytes); + body.extend_from_slice(b"\r\n"); + } + body.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); + (body, format!("multipart/form-data; boundary={boundary}")) +} + +fn render_sites(data: &Value) { + let sites = items(data, "sites"); + if sites.is_empty() { + wisp_ui::out(s::muted("No private sites found.")); + return; + } + wisp_ui::out(s::bold(format!("\nPrivate sites ({})", sites.len()))); + for site in sites { + let name = text(site, "name"); + let header = if site["expired"] == true { + s::danger(format!("{name} (expired)")) + } else { + s::bold(name.to_owned()) + }; + wisp_ui::out(Line::from(vec![s::plain("- "), header])); + wisp_ui::out(format!(" id: {}", text(site, "siteId"))); + wisp_ui::out(Line::from(vec![ + s::plain(format!( + " files: {} size: {} ", + site["fileCount"], + size(site) + )), + expiry_label(site["expiresAt"].as_str()), + ])); + wisp_ui::out(format!(" active share links: {}", site["shareCount"])); + } +} + +fn render_share(data: &Value, site_id: &str) { + wisp_ui::out(""); + wisp_ui::out(s::bold("Shareable link:")); + wisp_ui::out(s::link(text(data, "url").to_owned())); + if let Some(direct) = data["directUrl"] + .as_str() + .filter(|direct| *direct != text(data, "url")) + { + wisp_ui::out(s::muted(format!("direct: {direct}"))); + } + wisp_ui::out(""); + wisp_ui::out(Line::from(vec![ + s::muted(format!("share id: {} ", text(data, "shareId"))), + expiry_label(data["expiresAt"].as_str()), + ])); + if let Some(audience) = data["audienceDid"].as_str() { + wisp_ui::out(s::muted(format!( + "only {audience} can open this; they will be asked to sign in" + ))); + } + wisp_ui::out(s::warn( + "This link is shown once and cannot be retrieved later. Store it now.", + )); + wisp_ui::out(s::muted(format!( + "Revoke it with: wisp private revoke {site_id} {}", + text(data, "shareId") + ))); +} + +async fn shares(handle: Option<&str>, site_id: &str, opts: &XrpcOptions) -> Result<()> { + let (agent, service, _) = xrpc::authenticate_for_xrpc(handle, opts).await?; + let spinner = wisp_ui::spinner("Fetching share links..."); + let data = xrpc::call::( + &agent, + &service, + json!({"siteId": site_id}), + ) + .await?; + spinner.succeed("Fetched share links".to_owned()); + let shares = items(&data, "shares"); + if shares.is_empty() { + wisp_ui::out(s::muted("No share links for this site.")); + return Ok(()); + } + wisp_ui::out(s::bold(format!("\nShare links ({})", shares.len()))); + for share in shares { + let status = text(share, "status").to_owned(); + let color = match status.as_str() { + "active" => s::ok(status), + "revoked" => s::danger(status), + _ => s::warn(status), + }; + let label = share["label"] + .as_str() + .filter(|s| !s.is_empty()) + .map(|s| format!(" ({s})")) + .unwrap_or_default(); + wisp_ui::out(Line::from(vec![ + s::plain(format!("- {}", text(share, "shareId"))), + s::muted(label), + s::plain(" "), + color, + ])); + wisp_ui::out(Line::from(vec![ + s::muted(format!(" token: {}... ", text(share, "tokenPrefix"))), + expiry_label(share["expiresAt"].as_str()), + ])); + if let Some(last) = share["lastUsedAt"].as_str() { + wisp_ui::out(format!(" last used: {last}")); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn directory_names_are_lexical_not_symlink_targets() { + assert_eq!( + normalize_directory(Path::new("/var/./tmp/../site")), + Path::new("/var/site") + ); + assert_eq!( + normalize_directory(Path::new("/../../site")), + Path::new("/site") + ); + } + + #[test] + fn expiry_matches_number_conversion() { + for raw in ["0", "", " ", "1.0", "1e2", "0xff", "0o77", "0b11"] { + assert!(expiry(Some(raw)).is_ok()); + } + for raw in ["-1", "1.5", "NaN", "Infinity", "x", "+0xff", "0o8"] { + assert!(expiry(Some(raw)).is_err()); + } + assert_eq!(expiry(None).unwrap(), None); + } + #[test] + fn multipart_is_binary_safe_and_escapes_headers() { + let file = ( + "dir/\"\r\n.txt".into(), + "text/plain".into(), + b"----wispctl-private-upload\0".to_vec(), + ); + let (body, content_type) = multipart("test", Some(0), &[file]); + assert!(content_type.ends_with("uploadx")); + let body = String::from_utf8(body).unwrap(); + assert!(body.contains("filename=\"dir/%22%0D%0A.txt\"")); + assert!(body.contains("name=\"expiryMinutes\"\r\n\r\n0\r\n")); + assert!(body.ends_with("----wispctl-private-uploadx--\r\n")); + } +} diff --git a/cli-rs/crates/wispctl/src/commands/pull.rs b/cli-rs/crates/wispctl/src/commands/pull.rs new file mode 100644 index 0000000..d01124b --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/pull.rs @@ -0,0 +1,803 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fs, + io::Read, + path::{Path, PathBuf}, + sync::Arc, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +use anyhow::{Context, Result, bail, ensure}; +use base64::{ + Engine, alphabet, + engine::general_purpose::{GeneralPurpose, NO_PAD}, +}; +use futures_util::{StreamExt, TryStreamExt}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use wisp_core::{ + constants::{MAX_BLOB_SIZE, MAX_SITE_SIZE_SUPPORTER}, + path::require_site_file_path, + subfs::{ExpansionError, ExpansionLimits, expand_subfs, validate_fs_record}, +}; +use wisp_ui::{Direction, Line, s}; + +use crate::cli::PullArgs; + +const RECORD_LIMIT: usize = 1024 * 1024; +const METADATA_FILE: &str = ".wisp-metadata.json"; + +#[derive(Debug)] +pub struct PullResult { + pub did: String, + pub pds: String, + pub file_count: usize, +} + +#[derive(Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Metadata { + record_cid: String, + file_cids: BTreeMap, + last_sync: u64, +} + +#[derive(Debug)] +struct SiteFile { + path: String, + cid: String, + owner_did: String, + gzip: bool, + base64: bool, +} + +pub async fn run(args: PullArgs) -> Result<()> { + pull_site(&args.handle, &args.site, &args.path).await?; + Ok(()) +} + +pub async fn resolve_identity(identifier: &str) -> Result<(String, String)> { + crate::xrpc::resolve_identity(identifier).await +} + +type EndpointCell = Arc>; + +#[derive(Clone)] +struct SourceEndpoints(Arc>>); + +impl SourceEndpoints { + fn new(did: &str, pds: &str) -> Self { + let root = Arc::new(tokio::sync::OnceCell::new_with(Some(pds.to_owned()))); + Self(Arc::new(tokio::sync::Mutex::new(BTreeMap::from([( + did.to_owned(), + root, + )])))) + } + + async fn get(&self, did: &str) -> Result { + let cell = self + .0 + .lock() + .await + .entry(did.to_owned()) + .or_default() + .clone(); + let endpoint = cell + .get_or_try_init(|| async { resolve_identity(did).await.map(|(_, pds)| pds) }) + .await?; + Ok(endpoint.clone()) + } +} + +fn client() -> Result { + Ok(reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::limited(3)) + .no_gzip() + .no_brotli() + .no_deflate() + .build()?) +} + +pub async fn fetch_record(pds: &str, did: &str, collection: &str, rkey: &str) -> Result { + let http = client()?; + tokio::time::timeout(Duration::from_secs(10), async { + let response = http + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + pds.trim_end_matches('/') + )) + .query(&[("repo", did), ("collection", collection), ("rkey", rkey)]) + .send() + .await?; + ensure!( + response.status().is_success(), + "Failed to fetch record: {}", + response.status().as_u16() + ); + let bytes = read_bounded_response(response, RECORD_LIMIT).await?; + let record: Value = serde_json::from_slice(&bytes)?; + ensure!( + record.is_object() + && record.get("value").is_some() + && record.get("cid").is_none_or(Value::is_string), + "PDS returned an invalid record response" + ); + Ok(record) + }) + .await + .context("PDS record request timed out")? +} + +fn append_bounded(bytes: &mut Vec, chunk: &[u8], limit: usize) -> Result<()> { + ensure!( + chunk.len() <= limit.saturating_sub(bytes.len()), + "Downloaded blob exceeds the {limit}-byte limit" + ); + bytes.extend_from_slice(chunk); + Ok(()) +} + +async fn read_bounded_response(mut response: reqwest::Response, limit: usize) -> Result> { + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + append_bounded(&mut bytes, &chunk, limit)?; + } + Ok(bytes) +} + +fn decompress_pulled_gzip(bytes: &[u8], limit: u64) -> Result> { + ensure!( + limit <= MAX_BLOB_SIZE, + "Pulled gzip output limit must be within the blob limit" + ); + ensure!( + bytes.starts_with(&[0x1f, 0x8b]), + "Blob is marked gzip but is not a gzip stream" + ); + let mut output = Vec::new(); + flate2::read::MultiGzDecoder::new(bytes) + .take(limit + 1) + .read_to_end(&mut output) + .context("Could not safely decompress gzip blob")?; + ensure!( + output.len() as u64 <= limit, + "Could not safely decompress gzip blob" + ); + Ok(output) +} + +fn reserve(total: &mut u64, bytes: u64, limit: u64) -> Result<()> { + ensure!( + bytes <= limit.saturating_sub(*total), + "Pulled site exceeds the {limit}-byte logical size limit" + ); + *total += bytes; + Ok(()) +} + +pub fn resolve_pull_file_path( + root: &Path, + site_path: &str, + create_parents: bool, +) -> Result { + let normalized = require_site_file_path(site_path)?; + let root_info = fs::symlink_metadata(root)?; + ensure!( + root_info.is_dir() && !root_info.file_type().is_symlink(), + "Pull root must be a directory, not a symbolic link" + ); + let root = fs::canonicalize(root)?; + let parts: Vec<_> = normalized.split('/').collect(); + let mut directory = root.clone(); + for segment in &parts[..parts.len() - 1] { + directory.push(segment); + match fs::symlink_metadata(&directory) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + if !create_parents { + continue; + } + fs::create_dir(&directory)?; + } + Err(error) => return Err(error.into()), + Ok(info) => ensure!( + info.is_dir() && !info.file_type().is_symlink(), + "Pull path contains an unsafe directory" + ), + } + } + let target = root.join(normalized); + ensure!( + target.starts_with(&root) && target != root, + "Resolved pull path escapes its root" + ); + if let Ok(info) = fs::symlink_metadata(&target) { + ensure!( + !info.file_type().is_symlink(), + "Pull path resolves to a symbolic link" + ); + } + Ok(target) +} + +fn collect_files(root: &Value, owners: &BTreeMap) -> Result> { + fn collect( + node: &Value, + parent: &str, + owners: &BTreeMap, + seen: &mut BTreeSet, + files: &mut Vec, + ) -> Result<()> { + for entry in node["entries"] + .as_array() + .context("Invalid directory entries")? + { + let name = entry["name"].as_str().context("Invalid entry name")?; + require_site_file_path(name)?; + ensure!( + !name.contains('/'), + "Site entry names must be single path segments" + ); + let path = if parent.is_empty() { + name.to_owned() + } else { + format!("{parent}/{name}") + }; + ensure!( + seen.insert(path.clone()), + "Site contains duplicate file paths" + ); + let node = &entry["node"]; + match node["type"].as_str() { + Some("directory") => collect(node, &path, owners, seen, files)?, + Some("file") => { + let cid = node["blob"]["ref"]["$link"] + .as_str() + .or_else(|| node["blob"]["ref"].as_str()) + .context("Invalid blob CID")?; + ensure!( + jacquard::types::string::Cid::Str(cid.to_owned()).is_valid(), + "Invalid blob CID" + ); + let owner_did = owners + .get(&path) + .context("Expanded file is missing its source repository")? + .clone(); + files.push(SiteFile { + path, + cid: cid.to_owned(), + owner_did, + gzip: node["encoding"] == "gzip", + base64: node["base64"] == true, + }); + } + Some("subfs") => bail!("Site contains an unexpanded node"), + _ => {} + } + } + Ok(()) + } + let mut files = Vec::new(); + collect(root, "", owners, &mut BTreeSet::new(), &mut files)?; + Ok(files) +} + +fn decode_pulled_base64(bytes: &[u8]) -> Result> { + // Node accepts URL-safe symbols, ignored junk and partial final quanta. + let mut cleaned: Vec = bytes + .iter() + .copied() + .take_while(|&byte| byte != b'=') + .filter_map(|byte| match byte { + b'-' => Some(b'+'), + b'_' => Some(b'/'), + byte if byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/') => Some(byte), + _ => None, + }) + .collect(); + if cleaned.len() % 4 == 1 { + cleaned.pop(); + } + let engine = GeneralPurpose::new( + &alphabet::STANDARD, + NO_PAD.with_decode_allow_trailing_bits(true), + ); + Ok(engine.decode(cleaned)?) +} + +async fn download_blob(http: &reqwest::Client, pds: &str, file: &SiteFile) -> Result> { + tokio::time::timeout(Duration::from_secs(300), async { + let response = http + .get(format!( + "{}/xrpc/com.atproto.sync.getBlob", + pds.trim_end_matches('/') + )) + .query(&[("did", &file.owner_did), ("cid", &file.cid)]) + .send() + .await?; + ensure!( + response.status().is_success(), + "Failed to download blob {}: {}", + file.cid, + response.status().as_u16() + ); + let mut content = read_bounded_response(response, MAX_BLOB_SIZE as usize).await?; + if file.base64 { + content = decode_pulled_base64(&content)?; + } + if file.gzip { + content = tokio::task::spawn_blocking(move || { + decompress_pulled_gzip(&content, MAX_BLOB_SIZE) + }) + .await??; + } + Ok(content) + }) + .await + .context("PDS blob request timed out")? +} + +fn load_metadata(root: &Path) -> Metadata { + fs::symlink_metadata(root.join(METADATA_FILE)) + .ok() + .filter(|info| info.is_file() && !info.file_type().is_symlink()) + .and_then(|_| fs::read(root.join(METADATA_FILE)).ok()) + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +fn timestamp() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn replace_directory(temp: &Path, output: &Path) -> Result<()> { + if output.exists() { + let backup = tempfile::Builder::new() + .prefix(".wisp-backup-") + .tempdir_in(output.parent().context("Output has no parent")?)?; + let original = backup.path().join("original"); + fs::rename(output, &original)?; + if let Err(error) = fs::rename(temp, output) { + if fs::rename(&original, output).is_err() { + let recovery = backup.keep(); + return Err(error).with_context(|| { + format!( + "Could not replace output; original remains at {}", + recovery.join("original").display() + ) + }); + } + return Err(error.into()); + } + } else { + fs::rename(temp, output)?; + } + Ok(()) +} + +pub async fn pull_site(identifier: &str, site: &str, output: &Path) -> Result { + wisp_ui::blank(); + wisp_ui::note(Line::from(vec![ + s::accent("Pulling "), + s::bold(site.to_owned()), + s::accent(format!(" from {identifier}")), + ])); + wisp_ui::blank(); + let resolving = wisp_ui::spinner("Resolving identity..."); + let (did, pds) = match resolve_identity(identifier).await { + Ok(identity) => identity, + Err(error) => { + resolving.fail(Some("Failed to resolve identity".into())); + return Err(error); + } + }; + resolving.succeed(Some(format!("Resolved to {did}"))); + wisp_ui::spinner("Getting PDS endpoint...").succeed(Some("Got PDS endpoint".into())); + let fetching = wisp_ui::spinner("Fetching site record..."); + let response = match fetch_record(&pds, &did, "place.wisp.fs", site).await { + Ok(record) => record, + Err(_) => { + fetching.fail(Some("Site not found".into())); + bail!("Site not found: {site}"); + } + }; + let value = &response["value"]; + let validation = validate_fs_record(value); + if validation.is_err() { + fetching.fail(Some("Site record is invalid".into())); + bail!("Site record is invalid"); + } + fetching.succeed(Some("Fetched site record".into())); + let expanding = wisp_ui::spinner("Expanding subfs nodes..."); + let sources = SourceEndpoints::new(&did, &pds); + let expanded = expand_subfs( + value["root"].clone(), + &did, + ExpansionLimits { + max_entries: 4_000, + ..ExpansionLimits::default() + }, + |subject| { + let sources = sources.clone(); + async move { + let source_pds = sources + .get(&subject.repo) + .await + .map_err(|_| ExpansionError::FetchFailed)?; + let response = fetch_record( + &source_pds, + &subject.repo, + &subject.collection, + &subject.rkey, + ) + .await + .map_err(|_| ExpansionError::FetchFailed)?; + Ok(Some(response["value"].clone())) + } + }, + ) + .await; + let expanded = match expanded { + Ok(expanded) => expanded, + Err(error) => { + expanding.fail(Some("Could not expand SubFS nodes".into())); + return Err(error.into()); + } + }; + expanding.succeed(Some("Expanded SubFS nodes".into())); + let output = std::path::absolute(output)?; + if let Ok(info) = fs::symlink_metadata(&output) { + ensure!( + info.is_dir() && !info.file_type().is_symlink(), + "Output path must be a directory, not a symbolic link" + ); + } + let metadata = load_metadata(&output); + for path in metadata.file_cids.keys() { + require_site_file_path(path)?; + } + let files = collect_files(&expanded.root, &expanded.owner_did_by_file_path)?; + let result = PullResult { + did: did.clone(), + pds: pds.clone(), + file_count: files.len(), + }; + let (unchanged, downloads): (Vec<_>, Vec<_>) = files.iter().partition(|file| { + metadata.file_cids.get(&file.path) == Some(&file.cid) + && resolve_pull_file_path(&output, &file.path, false) + .ok() + .is_some_and(|path| path.is_file()) + }); + wisp_ui::note(Line::from(s::muted(format!( + "Files to download: {}, unchanged: {}", + downloads.len(), + unchanged.len() + )))); + let mut total = 0; + if downloads.is_empty() && !unchanged.is_empty() { + for file in &unchanged { + reserve( + &mut total, + fs::metadata(resolve_pull_file_path(&output, &file.path, false)?)?.len(), + MAX_SITE_SIZE_SUPPORTER, + )?; + } + wisp_ui::success("Site is already up to date"); + return Ok(result); + } + let parent = output.parent().context("Output has no parent")?; + fs::create_dir_all(parent)?; + let temp = tempfile::Builder::new() + .prefix(".wisp-pull-") + .tempdir_in(parent)?; + let http = client()?; + let owners: BTreeSet<_> = downloads.iter().map(|file| &file.owner_did).collect(); + let mut endpoints = BTreeMap::new(); + for owner in owners { + endpoints.insert(owner.clone(), sources.get(owner).await?); + } + let progress = wisp_ui::progress("Downloading", downloads.len() as u64, Direction::Down); + let mut stream = futures_util::stream::iter(downloads.iter().map(|file| { + let endpoint = &endpoints[&file.owner_did]; + let http = &http; + let progress = &progress; + async move { + let item = progress.start_item(file.path.clone(), ""); + let content = download_blob(http, endpoint, file).await?; + progress.finish_item(item); + Ok::<_, anyhow::Error>((*file, content)) + } + })) + .buffer_unordered(3); + let mut new_cids = BTreeMap::new(); + while let Some((file, content)) = stream.try_next().await? { + reserve(&mut total, content.len() as u64, MAX_SITE_SIZE_SUPPORTER)?; + fs::write( + resolve_pull_file_path(temp.path(), &file.path, true)?, + content, + )?; + new_cids.insert(file.path.clone(), file.cid.clone()); + progress.advance(1); + } + drop(stream); + progress.succeed(format!("Downloaded {} files", downloads.len())); + if !unchanged.is_empty() { + let copying = wisp_ui::spinner(format!("Copying {} unchanged files...", unchanged.len())); + for file in unchanged { + let source = resolve_pull_file_path(&output, &file.path, false)?; + reserve( + &mut total, + fs::metadata(&source)?.len(), + MAX_SITE_SIZE_SUPPORTER, + )?; + fs::copy( + source, + resolve_pull_file_path(temp.path(), &file.path, true)?, + )?; + new_cids.insert(file.path.clone(), file.cid.clone()); + } + copying.succeed(Some(format!( + "Copied {} unchanged files", + new_cids.len() - downloads.len() + ))); + } + let metadata = Metadata { + record_cid: response["cid"].as_str().unwrap_or("").into(), + file_cids: new_cids, + last_sync: timestamp(), + }; + fs::write( + temp.path().join(METADATA_FILE), + serde_json::to_vec_pretty(&metadata)?, + )?; + replace_directory(temp.path(), &output)?; + wisp_ui::blank(); + wisp_ui::success(format!("Pulled {site} to {}", output.display())); + wisp_ui::blank(); + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + fn gzip(bytes: &[u8]) -> Vec { + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(bytes).unwrap(); + encoder.finish().unwrap() + } + + #[test] + fn node_base64_compatibility() { + for (input, expected) in [ + ("aGVsbG8=", &b"hello"[..]), + ("aG V!sbG8", &b"hello"[..]), + ("_w", &[255][..]), + ("ZgA", &[102, 0][..]), + ("Zh", &b"f"[..]), + ("a", &b""[..]), + ("aGVsbG8=ignored", &b"hello"[..]), + ] { + assert_eq!( + decode_pulled_base64(input.as_bytes()).unwrap(), + expected, + "{input}" + ); + } + } + + #[tokio::test] + async fn root_source_endpoint_is_reused() { + let sources = SourceEndpoints::new("did:plc:root", "http://localhost:3300"); + assert_eq!( + sources.get("did:plc:root").await.unwrap(), + "http://localhost:3300" + ); + assert_eq!(sources.0.lock().await.len(), 1); + } + + #[test] + fn collect_preserves_nested_owner_and_blob_encoding() { + let root = serde_json::json!({"entries":[{"name":"assets","node":{"type":"directory","entries":[{"name":"app.js","node":{"type":"file","blob":{"ref":{"$link":"bafkreid66a42la6gporhibmglielca4svkphlib7alhifi5ud7lhdxafoa"}},"encoding":"gzip","base64":true}}]}}]}); + let owners = BTreeMap::from([("assets/app.js".into(), "did:plc:child".into())]); + let files = collect_files(&root, &owners).unwrap(); + assert_eq!(files.len(), 1); + assert_eq!(files[0].path, "assets/app.js"); + assert_eq!(files[0].owner_did, "did:plc:child"); + assert_eq!( + files[0].cid, + "bafkreid66a42la6gporhibmglielca4svkphlib7alhifi5ud7lhdxafoa" + ); + assert!(files[0].gzip && files[0].base64); + } + + #[test] + fn collect_rejects_duplicates_and_missing_provenance() { + let entry = serde_json::json!({"name":"index.html","node":{"type":"file","blob":{"ref":{"$link":"bafkreid66a42la6gporhibmglielca4svkphlib7alhifi5ud7lhdxafoa"}}}}); + let owners = BTreeMap::from([("index.html".into(), "did:plc:owner".into())]); + let root = serde_json::json!({"entries":[entry.clone(),entry.clone()]}); + assert!( + collect_files(&root, &owners) + .unwrap_err() + .to_string() + .contains("duplicate") + ); + let root = serde_json::json!({"entries":[entry]}); + assert!( + collect_files(&root, &BTreeMap::new()) + .unwrap_err() + .to_string() + .contains("source repository") + ); + } + + #[test] + fn canonical_pull_paths() { + let root = tempfile::tempdir().unwrap(); + let target = resolve_pull_file_path(root.path(), "assets/app.js", true).unwrap(); + assert_eq!( + target, + fs::canonicalize(root.path()).unwrap().join("assets/app.js") + ); + for path in [ + "../outside.txt", + "..\\outside.txt", + "C:/outside.txt", + "C:\\outside.txt", + "nested//file.txt", + "index.html:stream", + "file.", + "file ", + "CON.txt", + ] { + assert!( + resolve_pull_file_path(root.path(), path, true).is_err(), + "{path}" + ); + } + } + + #[cfg(unix)] + #[test] + fn refuses_child_final_and_root_symlinks() { + use std::os::unix::fs::symlink; + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("assets")).unwrap(); + symlink(outside.path(), root.path().join("assets/linked")).unwrap(); + symlink( + outside.path().join("target.txt"), + root.path().join("file.txt"), + ) + .unwrap(); + symlink(root.path(), outside.path().join("root-link")).unwrap(); + assert!( + resolve_pull_file_path(root.path(), "assets/linked/escape.txt", true) + .unwrap_err() + .to_string() + .contains("unsafe directory") + ); + assert!( + resolve_pull_file_path(root.path(), "file.txt", true) + .unwrap_err() + .to_string() + .contains("symbolic link") + ); + assert!( + resolve_pull_file_path(&outside.path().join("root-link"), "inside.txt", true) + .unwrap_err() + .to_string() + .contains("Pull root") + ); + } + + #[test] + fn rejects_oversized_gzip() { + assert!( + decompress_pulled_gzip(&gzip(&vec![b'x'; 8192]), 1024) + .unwrap_err() + .to_string() + .contains("safely decompress") + ); + } + + #[test] + fn accepts_inclusive_gzip_limit() { + let original = vec![b'x'; 1024]; + assert_eq!( + decompress_pulled_gzip(&gzip(&original), 1024).unwrap(), + original + ); + } + + #[test] + fn rejects_false_gzip_header() { + assert!( + decompress_pulled_gzip(b"plain text", 100) + .unwrap_err() + .to_string() + .contains("not a gzip stream") + ); + } + + #[test] + fn inclusive_site_budget() { + let mut total = 0; + reserve(&mut total, 6, 10).unwrap(); + reserve(&mut total, 4, 10).unwrap(); + assert_eq!(total, 10); + assert!( + reserve(&mut total, 1, 10) + .unwrap_err() + .to_string() + .contains("logical size limit") + ); + assert_eq!(total, 10); + } + + #[test] + fn rejects_oversized_chunk_before_retention() { + let mut bytes = Vec::new(); + assert!( + append_bounded(&mut bytes, &vec![0; 1025], 1024) + .unwrap_err() + .to_string() + .contains("1024-byte limit") + ); + assert!(bytes.is_empty()); + } + + #[test] + fn inclusive_blob_limit() { + let mut bytes = Vec::new(); + append_bounded(&mut bytes, &vec![b'x'; 1024], 1024).unwrap(); + assert_eq!(bytes, vec![b'x'; 1024]); + } + + #[test] + fn atomic_swap_removes_old_tree() { + let parent = tempfile::tempdir().unwrap(); + let output = parent.path().join("site"); + fs::create_dir(&output).unwrap(); + fs::write(output.join("old"), b"old").unwrap(); + let temp = tempfile::tempdir_in(parent.path()).unwrap(); + fs::write(temp.path().join("new"), b"new").unwrap(); + replace_directory(temp.path(), &output).unwrap(); + assert_eq!(fs::read(output.join("new")).unwrap(), b"new"); + assert!(!output.join("old").exists()); + } + + #[test] + fn failed_swap_restores_original() { + let parent = tempfile::tempdir().unwrap(); + let output = parent.path().join("site"); + fs::create_dir(&output).unwrap(); + fs::write(output.join("old"), b"old").unwrap(); + assert!(replace_directory(&parent.path().join("missing"), &output).is_err()); + assert_eq!(fs::read(output.join("old")).unwrap(), b"old"); + } + + #[test] + fn metadata_roundtrip_and_corruption() { + let root = tempfile::tempdir().unwrap(); + assert!(load_metadata(root.path()).file_cids.is_empty()); + let metadata = Metadata { + record_cid: "record".into(), + file_cids: BTreeMap::from([("index.html".into(), "blob".into())]), + last_sync: 42, + }; + fs::write( + root.path().join(METADATA_FILE), + serde_json::to_vec_pretty(&metadata).unwrap(), + ) + .unwrap(); + let loaded = load_metadata(root.path()); + assert_eq!(loaded.record_cid, "record"); + assert_eq!(loaded.file_cids, metadata.file_cids); + fs::write(root.path().join(METADATA_FILE), b"{bad").unwrap(); + assert!(load_metadata(root.path()).file_cids.is_empty()); + } +} diff --git a/cli-rs/crates/wispctl/src/commands/serve.rs b/cli-rs/crates/wispctl/src/commands/serve.rs new file mode 100644 index 0000000..0d99631 --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/serve.rs @@ -0,0 +1,157 @@ +//! Pull a public site, serve its local cache, and refresh it from the PDS firehose. +use crate::{ + cli::ServeArgs, + commands::pull::{fetch_record, pull_site}, + firehose, + serve::{ + dashboard::{Dashboard, SyncStatus}, + http, + routing::{Settings, State}, + }, +}; +use anyhow::{Context, Result}; +use jacquard_lexicon::schema::LexiconSchema; +use std::{path::PathBuf, sync::Arc}; +use tokio::{ + net::TcpListener, + sync::{RwLock, mpsc}, +}; +use wisp_ui::{Line, s}; + +async fn fetch_settings(pds: &str, did: &str, site: &str) -> Option { + let record = tokio::time::timeout( + std::time::Duration::from_secs(10), + fetch_record(pds, did, "place.wisp.settings", site), + ) + .await + .ok()? + .ok()?; + decode_settings(record.get("value")?.clone()) +} + +fn decode_settings(value: serde_json::Value) -> Option { + if value.get("$type")?.as_str()? != "place.wisp.settings" { + return None; + } + let record: wisp_lexicons::place_wisp::settings::Settings = + serde_json::from_value(value).ok()?; + record.validate().ok()?; + for header in record.headers.iter().flatten() { + header.validate().ok()?; + } + Some(Settings { + directory_listing: record.directory_listing.unwrap_or(false), + clean_urls: record.clean_urls.unwrap_or(false), + spa_mode: record.spa_mode, + custom_404: record.custom404, + index_files: record.index_files, + }) +} + +async fn reload_updates( + args: ServeArgs, + root: PathBuf, + did: String, + pds: String, + state: Arc>, + mut updates: mpsc::Receiver, + dashboard: Arc, +) { + while let Some(mut update) = updates.recv().await { + // Collapse bursts while keeping pulls serialized and avoiding stale settings writes. + while let Ok(next) = updates.try_recv() { + update.site |= next.site; + update.settings |= next.settings; + } + if update.site { + wisp_ui::warning("Site updated, re-pulling..."); + dashboard.sync(SyncStatus::Pulling, None); + match pull_site(&args.handle, &args.site, &root).await { + Ok(pulled) => { + dashboard.sync(SyncStatus::Idle, Some(pulled.file_count)); + let rules = http::load_redirects(&root); + state.write().await.redirects = rules; + wisp_ui::success("Site reloaded"); + } + Err(error) => { + dashboard.sync(SyncStatus::Error(format!("{error:#}")), None); + wisp_ui::warning(format!("Failed to reload site: {error:#}")); + } + } + } + if update.settings { + wisp_ui::info("Settings updated..."); + dashboard.sync(SyncStatus::Settings, None); + let settings = fetch_settings(&pds, &did, &args.site).await; + state.write().await.settings = settings; + dashboard.sync(SyncStatus::Idle, None); + wisp_ui::success("Settings reloaded"); + } + } +} + +pub async fn run(args: ServeArgs) -> Result<()> { + wisp_ui::blank(); + wisp_ui::note(Line::from(vec![ + s::accent("Serving "), + s::accent_bold(args.site.clone()), + s::accent(format!(" from {}", args.handle)), + ])); + wisp_ui::blank(); + let pulled = pull_site(&args.handle, &args.site, &args.path).await?; + let root = args + .path + .canonicalize() + .context("Could not resolve cache directory")?; + let state = Arc::new(RwLock::new(State { + settings: fetch_settings(&pulled.pds, &pulled.did, &args.site).await, + redirects: http::load_redirects(&root), + spa_override: args.spa.clone(), + directory_listing_override: args.directory_listing.then_some(true), + })); + let listener = TcpListener::bind((args.host.as_str(), args.port)) + .await + .context("Could not start HTTP server")?; + let address = listener.local_addr()?; + wisp_ui::success(format!("Server running at http://{address}")); + wisp_ui::info("Watching for updates via firehose..."); + let dashboard = Arc::new(Dashboard::new( + format!("http://{address}"), + args.site.clone(), + pulled.file_count, + )); + let (sender, receiver) = mpsc::channel(32); + // All long-lived futures are scoped to the command: cancellation drops sockets and tasks. + tokio::select! { + result = http::run(listener, root.clone(), state.clone()) => result, + _ = firehose::watch(pulled.pds.clone(), pulled.did.clone(), args.site.clone(), sender, dashboard.clone()) => Ok(()), + _ = reload_updates(args.clone(), root, pulled.did, pulled.pds, state, receiver, dashboard.clone()) => Ok(()), + _ = tokio::signal::ctrl_c() => { + wisp_ui::info("Shutting down..."); + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use super::decode_settings; + use serde_json::json; + + #[test] + fn settings_require_correct_type_and_lexicon_constraints() { + assert!(decode_settings(json!({"$type":"place.wisp.fs"})).is_none()); + assert!( + decode_settings(json!({"$type":"place.wisp.settings", "spaMode":"x".repeat(501)})) + .is_none() + ); + assert!(decode_settings(json!({"$type":"place.wisp.settings", "headers":[{"name":"x".repeat(101), "value":"bad"}]})).is_none()); + let settings = decode_settings(json!({"$type":"place.wisp.settings", "directoryListing":true, "cleanUrls":true, "spaMode":"app.html"})).unwrap(); + assert!(settings.directory_listing); + assert!(settings.clean_urls); + assert_eq!(settings.spa_mode.as_deref(), Some("app.html")); + let defaults = decode_settings(json!({"$type":"place.wisp.settings"})).unwrap(); + assert!(!defaults.directory_listing); + assert!(!defaults.clean_urls); + } +} diff --git a/cli-rs/crates/wispctl/src/commands/site.rs b/cli-rs/crates/wispctl/src/commands/site.rs new file mode 100644 index 0000000..c0cd534 --- /dev/null +++ b/cli-rs/crates/wispctl/src/commands/site.rs @@ -0,0 +1,107 @@ +use crate::{ + cli::{SiteArgs, SiteCommand, XrpcOptions}, + commands::list, + prompts, + xrpc::{self, items, text}, +}; +use anyhow::{Result, bail}; +use serde_json::json; +use wisp_lexicons::place_wisp::v2::site; +use wisp_ui::{Choice, Line, s}; + +pub async fn run(args: SiteArgs) -> Result<()> { + match args.command { + Some(SiteCommand::Delete { + handle, + site, + yes, + xrpc, + }) => delete(handle.as_deref(), site, yes, &xrpc).await, + None => { + wisp_ui::intro("site"); + let listing = prompts::select( + "Choose site action", + vec![ + Choice::new(true, "List sites").hint("Show sites and mapped domains"), + Choice::new(false, "Delete site").hint("Remove site mapping metadata"), + ], + "Site command cancelled", + "Pass a site subcommand", + )?; + if listing { + list::sites(None, &args.xrpc).await + } else { + delete(None, None, false, &args.xrpc).await + } + } + } +} + +async fn delete( + handle: Option<&str>, + rkey: Option, + yes: bool, + opts: &XrpcOptions, +) -> Result<()> { + let (agent, service, _) = xrpc::authenticate_for_xrpc(handle, opts).await?; + let rkey = match rkey { + Some(rkey) => rkey, + None => { + let spinner = wisp_ui::spinner("Fetching sites..."); + let data = xrpc::send(&agent, &service, site::get_list::GetList, None).await?; + spinner.succeed("Fetched sites".to_owned()); + let sites = items(&data, "sites"); + if sites.is_empty() { + bail!("No sites found for this account"); + } + let choices = sites + .iter() + .map(|site| { + let choice = + Choice::new(text(site, "siteRkey").to_owned(), list::site_title(site)); + let count = items(site, "domains").len(); + if count > 0 { + choice.hint(format!("{count} mapped domain(s)")) + } else { + choice + } + }) + .collect(); + prompts::select( + "Select site to delete", + choices, + "Site deletion cancelled", + "Pass --site ", + )? + } + }; + if !yes + && !prompts::confirm( + &format!("Delete site \"{rkey}\" and unmap its domains?"), + "Site deletion cancelled", + "Pass --yes to confirm deletion", + )? + { + wisp_ui::cancelled("Site deletion cancelled"); + return Ok(()); + } + let spinner = wisp_ui::spinner(format!("Deleting site {rkey}...")); + let data = + xrpc::call::(&agent, &service, json!({"siteRkey": rkey})).await?; + spinner.succeed(format!("Deleted site {}", text(&data, "siteRkey"))); + if opts.json { + return xrpc::json(&data); + } + wisp_ui::out(Line::from(vec![ + s::bold(text(&data, "siteRkey").to_owned()), + s::plain(" deleted"), + ])); + let domains = items(&data, "unmappedDomains"); + if !domains.is_empty() { + wisp_ui::out("Unmapped domains:"); + for domain in domains { + wisp_ui::out(list::domain_line(domain, "- ", false)); + } + } + Ok(()) +} diff --git a/cli-rs/crates/wispctl/src/deploy/mod.rs b/cli-rs/crates/wispctl/src/deploy/mod.rs new file mode 100644 index 0000000..57f3ab2 --- /dev/null +++ b/cli-rs/crates/wispctl/src/deploy/mod.rs @@ -0,0 +1,3 @@ +//! Deploy's repository effects and upload pipeline; `commands::deploy` drives them. +pub mod repo; +pub mod upload; diff --git a/cli-rs/crates/wispctl/src/deploy/repo.rs b/cli-rs/crates/wispctl/src/deploy/repo.rs new file mode 100644 index 0000000..1439803 --- /dev/null +++ b/cli-rs/crates/wispctl/src/deploy/repo.rs @@ -0,0 +1,72 @@ +//! Repository effects for deployment, typed by the generated place.wisp.* records. +use anyhow::Result; +use jacquard::{ + client::{Agent, AgentSession, AgentSessionExt, CollectionErr, CollectionOutput}, + identity::resolver::IdentityResolver, + types::{ + blob::{BlobRef, MimeType}, + collection::Collection, + recordkey::{RecordKey, Rkey}, + string::AtUri, + }, +}; +use serde::{Serialize, de::DeserializeOwned}; + +/// What deploy needs from a repository. Records are addressed by collection +/// type; writes always go to the authenticated account's repo. +pub trait SiteRepo { + /// `at://{did}/{R::NSID}/{rkey}`, decoded as `R`. + async fn fetch(&self, did: &str, rkey: &str) -> Result + where + R: Collection + From>, + CollectionOutput: DeserializeOwned, + CollectionErr: Send + Sync + 'static; + async fn put(&self, rkey: &str, record: R) -> Result<()>; + async fn delete(&self, rkey: &str) -> Result<()>; + async fn upload(&self, bytes: bytes::Bytes) -> Result; +} + +fn record_key(rkey: &str) -> Result> { + Ok(RecordKey(Rkey::new_owned(rkey)?)) +} + +impl SiteRepo for Agent { + async fn fetch(&self, did: &str, rkey: &str) -> Result + where + R: Collection + From>, + CollectionOutput: DeserializeOwned, + CollectionErr: Send + Sync + 'static, + { + let uri: AtUri = AtUri::new_owned(format!("at://{did}/{}/{rkey}", R::NSID))?; + let output = self.get_record::(&uri).await?.into_output()?; + Ok(R::from(output)) + } + + async fn put(&self, rkey: &str, record: R) -> Result<()> { + self.put_record::(record_key(rkey)?, record).await?; + Ok(()) + } + + async fn delete(&self, rkey: &str) -> Result<()> { + self.delete_record::(record_key(rkey)?).await?; + Ok(()) + } + + async fn upload(&self, bytes: bytes::Bytes) -> Result { + // Every blob is uploaded as opaque bytes; the manifest carries the real type. + let blob = self + .upload_blob(bytes, MimeType::new("application/octet-stream")) + .await?; + Ok(BlobRef::Blob(blob)) + } +} + +/// HTTP status of the first transport error in `error`'s chain. +pub fn http_status(error: &anyhow::Error) -> Option { + error.chain().find_map(|cause| { + cause + .downcast_ref::() + .and_then(|err| err.status()) + .map(|s| s.as_u16()) + }) +} diff --git a/cli-rs/crates/wispctl/src/deploy/upload.rs b/cli-rs/crates/wispctl/src/deploy/upload.rs new file mode 100644 index 0000000..69011e4 --- /dev/null +++ b/cli-rs/crates/wispctl/src/deploy/upload.rs @@ -0,0 +1,156 @@ +//! Only active uploads own file buffers; work is replenished on every completion. +use super::repo::{SiteRepo, http_status}; +use anyhow::Result; +use base64::Engine; +use futures_util::{StreamExt, stream::FuturesUnordered}; +use std::{ + collections::BTreeMap, + sync::atomic::{AtomicUsize, Ordering}, + time::Duration, +}; +use wisp_core::{ + blob::{ + compute_cid, gzip, gzip_cid_variants, gzip_variants, is_text_mime, mime_for, + should_compress, + }, + ignore::FileInfo, + tree::{ExistingBlob, UploadResult}, +}; + +pub async fn process( + repo: &impl SiteRepo, + files: &[FileInfo], + existing: &BTreeMap, + concurrency: usize, + base64: bool, + force_gzip: bool, +) -> Result> { + let progress = wisp_ui::progress("Uploading", files.len() as u64, wisp_ui::Direction::Up); + let limit = AtomicUsize::new(concurrency.max(1)); + let mut pending = files.iter(); + let mut workers = FuturesUnordered::new(); + let mut results = Vec::new(); + let (mut uploaded, mut reused) = (0, 0); + loop { + while workers.len() < limit.load(Ordering::Relaxed) { + let Some(file) = pending.next() else { + break; + }; + let item = + progress.start_item(file.relative_path.clone(), wisp_ui::format_bytes(file.size)); + let limit = &limit; + workers.push(async move { + ( + item, + prepare(repo, file, existing, limit, base64, force_gzip).await, + ) + }); + } + let Some((item, result)) = workers.next().await else { + break; + }; + progress.finish_item(item); + let (path, upload, was_reused) = match result { + Ok(result) => result, + Err(error) => { + progress.fail("Failed to process files"); + return Err(error); + } + }; + if was_reused { + reused += 1; + } else { + uploaded += 1; + } + results.push((path, upload)); + progress.advance(1); + progress.set_note(format!("{uploaded} uploaded · {reused} reused")); + } + progress.succeed(format!( + "Processed {} files ({uploaded} uploaded, {reused} reused)", + files.len() + )); + results.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(results) +} + +async fn prepare( + repo: &impl SiteRepo, + file: &FileInfo, + existing: &BTreeMap, + limit: &AtomicUsize, + use_base64: bool, + force_gzip: bool, +) -> Result<(String, UploadResult, bool)> { + let content = tokio::fs::read(&file.path).await?; + let mime = mime_for(&file.relative_path); + let compressed = force_gzip || should_compress(&mime, &file.relative_path); + let base64 = compressed && !force_gzip && use_base64 && is_text_mime(&mime); + let bytes = if compressed { + tokio::task::spawn_blocking(move || gzip(&content)).await?? + } else { + content + }; + let previous = existing.get(&file.relative_path).filter(|blob| { + if !compressed { + return blob.cid == compute_cid(&bytes); + } + if base64 { + gzip_variants(&bytes).any(|variant| { + compute_cid( + base64::engine::general_purpose::STANDARD + .encode(variant) + .as_bytes(), + ) == blob.cid + }) + } else { + gzip_cid_variants(&bytes).any(|candidate| candidate == blob.cid) + } + }); + let bytes = if base64 { + base64::engine::general_purpose::STANDARD + .encode(bytes) + .into_bytes() + } else { + bytes + }; + let reused = previous.is_some(); + let blob = match previous { + Some(previous) => previous.blob.clone(), + None => upload_retry(repo, bytes.into(), limit).await?, + }; + Ok(( + file.relative_path.clone(), + UploadResult { + blob, + encoding: compressed.then(|| "gzip".into()), + mime_type: mime, + base64, + }, + reused, + )) +} + +async fn upload_retry( + repo: &impl SiteRepo, + bytes: bytes::Bytes, + limit: &AtomicUsize, +) -> Result { + for attempt in 0..3 { + match repo.upload(bytes.clone()).await { + Ok(blob) => return Ok(blob), + Err(error) if attempt == 2 => return Err(error), + Err(error) => { + let rate_limited = http_status(&error) == Some(429); + if rate_limited && limit.fetch_min(2, Ordering::Relaxed) > 2 { + wisp_ui::warning("Rate limited — reducing concurrency to 2"); + } + tokio::time::sleep(Duration::from_millis( + (1 << attempt) * if rate_limited { 2000 } else { 500 }, + )) + .await; + } + } + } + unreachable!("three attempts return or fail") +} diff --git a/cli-rs/crates/wispctl/src/firehose.rs b/cli-rs/crates/wispctl/src/firehose.rs new file mode 100644 index 0000000..26c007c --- /dev/null +++ b/cli-rs/crates/wispctl/src/firehose.rs @@ -0,0 +1,221 @@ +//! Public PDS subscribeRepos websocket using jacquard's transport and DAG-CBOR decoder. +use crate::serve::dashboard::{Dashboard, FirehoseStatus}; +use anyhow::{Context, Result, bail}; +use futures_util::StreamExt; +use jacquard_api::com_atproto::sync::subscribe_repos::SubscribeReposMessage; +use jacquard_common::{ + deps::fluent_uri::Uri, + websocket::{WebSocketClient, WsMessage, tungstenite_client::TungsteniteClient}, +}; +use std::sync::Arc; +use tokio::sync::mpsc; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Update { + pub site: bool, + pub settings: bool, +} + +fn matching_update(message: &SubscribeReposMessage, did: &str, site: &str) -> Update { + match message { + SubscribeReposMessage::Commit(commit) if commit.repo.as_str() == did => { + if commit.too_big { + return Update { + site: true, + settings: true, + }; + } + commit + .ops + .iter() + .filter(|op| matches!(op.action.as_str(), "create" | "update" | "delete")) + .fold(Update::default(), |mut update, op| { + if let Some((collection, key)) = op.path.split_once('/') + && key == site + { + update.site |= collection == "place.wisp.fs"; + update.settings |= collection == "place.wisp.settings"; + } + update + }) + } + SubscribeReposMessage::Sync(sync) if sync.did.as_str() == did => Update { + site: true, + settings: true, + }, + _ => Update::default(), + } +} + +fn websocket_url(pds: &str, cursor: Option) -> Result { + let base = pds.trim_end_matches('/'); + let url = if let Some(rest) = base.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = base.strip_prefix("http://") { + format!("ws://{rest}") + } else { + bail!("Invalid PDS endpoint"); + }; + Ok(format!( + "{url}/xrpc/com.atproto.sync.subscribeRepos{}", + cursor + .map(|seq| format!("?cursor={seq}")) + .unwrap_or_default() + )) +} + +async fn subscribe( + pds: &str, + did: &str, + site: &str, + cursor: &mut Option, + sender: &mpsc::Sender, + dashboard: &Dashboard, +) -> Result<()> { + let url = websocket_url(pds, *cursor)?; + let uri = Uri::parse(url.as_str()).context("Invalid firehose URL")?; + let connection = tokio::time::timeout( + std::time::Duration::from_secs(10), + TungsteniteClient::new().connect(uri), + ) + .await??; + dashboard.firehose(FirehoseStatus::Connected); + let (_sink, receiver) = connection.split(); + let mut stream = receiver.into_inner(); + while let Some(message) = stream.next().await { + let message = message?; + let WsMessage::Binary(bytes) = message else { + if message.is_close() { + break; + } + continue; + }; + let decoded = SubscribeReposMessage::::decode_framed(&bytes)?; + let update = matching_update(&decoded, did, site); + if update.site || update.settings { + sender + .send(update) + .await + .context("Update receiver closed")?; + } + // Only resume after accepted messages; dropped frames are replayed on reconnect. + let seq = match decoded { + SubscribeReposMessage::Commit(c) => Some(c.seq), + SubscribeReposMessage::Sync(s) => Some(s.seq), + SubscribeReposMessage::Identity(i) => Some(i.seq), + SubscribeReposMessage::Account(a) => Some(a.seq), + SubscribeReposMessage::Info(info) => { + if info.name.as_str() == "OutdatedCursor" { + *cursor = None; + } + None + } + _ => None, + }; + if let Some(seq) = seq { + *cursor = Some(seq); + } + } + bail!("Firehose connection closed") +} + +pub async fn watch( + pds: String, + did: String, + site: String, + sender: mpsc::Sender, + dashboard: Arc, +) { + let mut cursor = None; + loop { + if sender.is_closed() { + return; + } + if let Err(error) = subscribe(&pds, &did, &site, &mut cursor, &sender, &dashboard).await { + dashboard.firehose(FirehoseStatus::Error(format!("{error:#}"))); + wisp_ui::warning(format!("Firehose error: {error:#}")); + } + tokio::time::sleep(std::time::Duration::from_secs(3)).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn websocket_endpoint_and_cursor() { + assert_eq!( + websocket_url("https://pds.example/", Some(42)).unwrap(), + "wss://pds.example/xrpc/com.atproto.sync.subscribeRepos?cursor=42" + ); + assert_eq!( + websocket_url("http://localhost:3300", None).unwrap(), + "ws://localhost:3300/xrpc/com.atproto.sync.subscribeRepos" + ); + assert!(websocket_url("file:///etc", None).is_err()); + } + fn commit(did: &str, ops: serde_json::Value, too_big: bool) -> SubscribeReposMessage { + let commit: jacquard_api::com_atproto::sync::subscribe_repos::Commit = + serde_json::from_str(&serde_json::json!({ + "repo": did, "seq": 42, + "rev": "3jqfcqzm3fo2j", "time": "2026-01-01T00:00:00.000Z", + "commit": {"$link": "bafkreigh2akiscaildcg6ez6jptywevd2cjfqxptzvtn2spgvknm7qrkta"}, + "blocks": [], "blobs": [], "ops": ops, "rebase": false, "tooBig": too_big, + }).to_string()) + .unwrap(); + SubscribeReposMessage::Commit(Box::new(commit)) + } + + #[test] + fn filters_did_collection_rkey_and_actions() { + for action in ["create", "update", "delete"] { + let message = commit( + "did:plc:test", + serde_json::json!([ + {"action": action, "path": "place.wisp.fs/site"}, + {"action": action, "path": "place.wisp.settings/site"}, + ]), + false, + ); + assert_eq!( + matching_update(&message, "did:plc:test", "site"), + Update { + site: true, + settings: true + } + ); + assert_eq!( + matching_update(&message, "did:plc:other", "site"), + Update::default() + ); + assert_eq!( + matching_update(&message, "did:plc:test", "other"), + Update::default() + ); + } + let message = commit( + "did:plc:test", + serde_json::json!([ + {"action": "create", "path": "app.bsky.feed.post/site"}, + {"action": "future", "path": "place.wisp.fs/site"}, + {"action": "update", "path": "place.wisp.fs/site/extra"}, + ]), + false, + ); + assert_eq!( + matching_update(&message, "did:plc:test", "site"), + Update::default() + ); + assert_eq!( + matching_update( + &commit("did:plc:test", serde_json::json!([]), true), + "did:plc:test", + "site" + ), + Update { + site: true, + settings: true + } + ); + } +} diff --git a/cli-rs/crates/wispctl/src/main.rs b/cli-rs/crates/wispctl/src/main.rs new file mode 100644 index 0000000..d894799 --- /dev/null +++ b/cli-rs/crates/wispctl/src/main.rs @@ -0,0 +1,60 @@ +mod auth; +mod cli; +mod commands; +mod deploy; +mod firehose; +mod prompts; +mod serve; +mod tls; +mod xrpc; + +use std::future::pending; + +use clap::Parser; + +use crate::cli::{Cli, Command}; + +fn main() { + let cli = Cli::parse(); + tls::install_crypto_provider(); + let guard = wisp_ui::init(wisp_ui::Options { quiet: cli.quiet }); + + let runtime = match tokio::runtime::Runtime::new() { + Ok(runtime) => runtime, + Err(err) => { + wisp_ui::error(format!("could not start the async runtime: {err}")); + wisp_ui::exit(1); + } + }; + + // `serve` shuts down on Ctrl-C itself and exits 0 like the old CLI. + let handles_interrupt = matches!(cli.command, Some(Command::Serve(_))); + let interrupted = async move { + if handles_interrupt { + pending::<()>().await; + } + let _ = tokio::signal::ctrl_c().await; + }; + + let code = runtime.block_on(async { + tokio::select! { + result = commands::run(cli) => match result { + Ok(()) => 0, + Err(err) => { + wisp_ui::error(format!("{err:#}")); + 1 + } + }, + () = interrupted => { + wisp_ui::blank(); + wisp_ui::cancelled("Interrupted"); + 130 + } + } + }); + + drop(guard); + // Long-lived tasks (serve's server and firehose) must not keep us alive. + runtime.shutdown_background(); + std::process::exit(code); +} diff --git a/cli-rs/crates/wispctl/src/prompts.rs b/cli-rs/crates/wispctl/src/prompts.rs new file mode 100644 index 0000000..2cb464c --- /dev/null +++ b/cli-rs/crates/wispctl/src/prompts.rs @@ -0,0 +1,75 @@ +//! Prompt helpers with the CLI's cancellation behaviour: backing out of a +//! prompt prints the flow's cancel message and exits 0, like the old CLI. + +use anyhow::{Result, bail}; +use wisp_ui::{Choice, PromptError, TextPrompt}; + +fn resolve(result: Result, cancel_message: &str, flag_hint: &str) -> Result { + match result { + Ok(value) => Ok(value), + Err(PromptError::Cancelled) => { + wisp_ui::cancelled(cancel_message); + wisp_ui::exit(0) + } + Err(PromptError::NotInteractive) => bail!("{flag_hint} (no terminal to prompt on)"), + } +} + +/// A required text value. `flag_hint` explains how to pass it without a +/// terminal, e.g. "Missing domain: pass --domain ". +pub fn required_text(prompt: TextPrompt, cancel_message: &str, flag_hint: &str) -> Result { + resolve(wisp_ui::text(prompt), cancel_message, flag_hint) +} + +/// A value that must be non-empty; the usual `-d/--domain`-style prompt. +pub fn required_value( + message: &str, + placeholder: &str, + empty_error: &'static str, + cancel_message: &str, + flag_hint: &str, +) -> Result { + let prompt = TextPrompt::new(message) + .placeholder(placeholder) + .validate(move |v| { + if v.is_empty() { + Err(empty_error.into()) + } else { + Ok(()) + } + }); + required_text(prompt, cancel_message, flag_hint) +} + +pub fn select( + message: &str, + choices: Vec>, + cancel_message: &str, + flag_hint: &str, +) -> Result { + resolve(wisp_ui::select(message, choices), cancel_message, flag_hint) +} + +pub fn confirm(message: &str, cancel_message: &str, flag_hint: &str) -> Result { + resolve(wisp_ui::confirm(message), cancel_message, flag_hint) +} + +/// The handle prompt shared by every command that needs an account. +pub fn handle(cancel_message: &str) -> Result { + let prompt = TextPrompt::new("AT Protocol handle") + .placeholder("alice.bsky.social") + .validate(|v| { + if v.is_empty() { + Err("Handle is required".into()) + } else if !v.contains('.') { + Err("Handle must include a domain (e.g., alice.bsky.social)".into()) + } else { + Ok(()) + } + }); + required_text( + prompt, + cancel_message, + "Missing handle: pass it as an argument or run `wispctl login `", + ) +} diff --git a/cli-rs/crates/wispctl/src/serve/dashboard.rs b/cli-rs/crates/wispctl/src/serve/dashboard.rs new file mode 100644 index 0000000..cf610fa --- /dev/null +++ b/cli-rs/crates/wispctl/src/serve/dashboard.rs @@ -0,0 +1,102 @@ +use std::sync::Mutex; +use wisp_ui::{Line, Panel, PanelStatus, s}; + +#[derive(Clone)] +pub enum FirehoseStatus { + Connecting, + Connected, + Error(String), +} +#[derive(Clone)] +pub enum SyncStatus { + Idle, + Pulling, + Settings, + Error(String), +} +struct Status { + firehose: FirehoseStatus, + sync: SyncStatus, + files: usize, + synced: String, +} + +pub struct Dashboard { + panel: Panel, + url: String, + site: String, + status: Mutex, +} +impl Dashboard { + pub fn new(url: String, site: String, files: usize) -> Self { + let dashboard = Self { + panel: wisp_ui::panel(PanelStatus::Busy, vec![]), + url, + site, + status: Mutex::new(Status { + firehose: FirehoseStatus::Connecting, + sync: SyncStatus::Idle, + files, + synced: super::log::timestamp(), + }), + }; + dashboard.render(&dashboard.status.lock().expect("dashboard lock")); + dashboard + } + pub fn firehose(&self, firehose: FirehoseStatus) { + let mut status = self.status.lock().expect("dashboard lock"); + status.firehose = firehose; + self.render(&status); + } + pub fn sync(&self, sync: SyncStatus, files: Option) { + let mut status = self.status.lock().expect("dashboard lock"); + if matches!(sync, SyncStatus::Idle) { + status.synced = super::log::timestamp(); + } + status.sync = sync; + if let Some(files) = files { + status.files = files; + } + self.render(&status); + } + /// Two lines under a status glyph: + /// + /// ```text + /// ● serving my-blog → http://127.0.0.1:8080 + /// firehose connected · synced 12:04:11 · 142 files + /// ``` + fn render(&self, status: &Status) { + let firehose = match &status.firehose { + FirehoseStatus::Connecting => "firehose connecting".to_owned(), + FirehoseStatus::Connected => "firehose connected".to_owned(), + FirehoseStatus::Error(error) => format!("firehose reconnecting ({error})"), + }; + let sync = match &status.sync { + SyncStatus::Idle => format!("synced {}", status.synced), + SyncStatus::Pulling => "re-pulling site".to_owned(), + SyncStatus::Settings => "reloading settings".to_owned(), + SyncStatus::Error(error) => format!("sync failed ({error})"), + }; + let health = match (&status.firehose, &status.sync) { + (FirehoseStatus::Error(_), _) | (_, SyncStatus::Error(_)) => PanelStatus::Error, + (FirehoseStatus::Connecting, _) | (_, SyncStatus::Pulling | SyncStatus::Settings) => { + PanelStatus::Busy + } + _ => PanelStatus::Live, + }; + self.panel.set( + health, + vec![ + Line::from(vec![ + s::bold(format!("serving {}", self.site)), + s::muted(" → "), + s::link(self.url.clone()), + ]), + Line::from(s::muted(format!( + "{firehose} · {sync} · {} files", + status.files + ))), + ], + ); + } +} diff --git a/cli-rs/crates/wispctl/src/serve/http.rs b/cli-rs/crates/wispctl/src/serve/http.rs new file mode 100644 index 0000000..e907d76 --- /dev/null +++ b/cli-rs/crates/wispctl/src/serve/http.rs @@ -0,0 +1,403 @@ +//! Hyper HTTP edge. A body owns its stream permit until EOF, error, or cancellation. +use super::routing::{self, Body, Decision, FilesystemView, State}; +use bytes::Bytes; +use http_body_util::{BodyExt, Full}; +use hyper::{ + Request, Response, + body::{Body as HttpBody, Frame, Incoming, SizeHint}, +}; +use hyper_util::rt::{TokioIo, TokioTimer}; +use std::{ + convert::Infallible, + io, + path::{Path, PathBuf}, + pin::Pin, + sync::Arc, + task::{Context, Poll}, +}; +use tokio::{ + fs::File, + io::{AsyncRead, AsyncWrite, ReadBuf}, + net::TcpListener, + sync::{OwnedSemaphorePermit, RwLock, Semaphore}, + task::JoinSet, +}; +use wisp_core::{ + pages::DirectoryEntry, + redirects::{MAX_REDIRECT_FILE_BYTES, RedirectRule, parse_redirects_file_bytes}, +}; + +pub const MAX_ACTIVE_FILE_STREAMS: usize = 64; +type ResponseBody = http_body_util::combinators::UnsyncBoxBody; + +struct LocalView<'a>(&'a Path); +impl LocalView<'_> { + fn resolve(&self, path: &str) -> Option { + let root = self.0.canonicalize().ok()?; + let resolved = root.join(path).canonicalize().ok()?; + resolved.starts_with(root).then_some(resolved) + } +} +impl FilesystemView for LocalView<'_> { + fn is_file(&self, path: &str) -> bool { + self.resolve(path).is_some_and(|p| p.is_file()) + } + fn is_directory(&self, path: &str) -> bool { + self.resolve(path).is_some_and(|p| p.is_dir()) + } + fn entries(&self, path: &str) -> Vec { + self.resolve(path) + .and_then(|p| std::fs::read_dir(p).ok()) + .into_iter() + .flatten() + .filter_map(|e| e.ok()) + .filter_map(|entry| { + Some(DirectoryEntry { + name: entry.file_name().into_string().ok()?, + is_directory: entry.file_type().ok()?.is_dir(), + }) + }) + .collect() + } +} + +pub fn load_redirects(root: &Path) -> Vec { + use std::io::Read; + let Some(path) = LocalView(root).resolve("_redirects") else { + return vec![]; + }; + let Ok(file) = std::fs::File::open(path) else { + return vec![]; + }; + let Ok(info) = file.metadata() else { + return vec![]; + }; + if !info.is_file() || info.len() > MAX_REDIRECT_FILE_BYTES as u64 { + return vec![]; + } + let mut bytes = Vec::new(); + if file + .take(MAX_REDIRECT_FILE_BYTES as u64 + 1) + .read_to_end(&mut bytes) + .is_err() + { + return vec![]; + } + parse_redirects_file_bytes(&bytes).unwrap_or_default() +} + +struct FileBody { + file: File, + remaining: u64, + permit: Option, +} +impl HttpBody for FileBody { + type Data = Bytes; + type Error = io::Error; + fn poll_frame( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, io::Error>>> { + if self.remaining == 0 { + self.permit.take(); + return Poll::Ready(None); + } + let mut storage = [0u8; 64 * 1024]; + let len = self.remaining.min(storage.len() as u64) as usize; + let mut buffer = ReadBuf::new(&mut storage[..len]); + match Pin::new(&mut self.file).poll_read(cx, &mut buffer) { + Poll::Pending => Poll::Pending, + Poll::Ready(Err(error)) => { + self.remaining = 0; + self.permit.take(); + Poll::Ready(Some(Err(error))) + } + Poll::Ready(Ok(())) => { + let bytes = buffer.filled(); + if bytes.is_empty() { + self.remaining = 0; + self.permit.take(); + return Poll::Ready(Some(Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "file changed while streaming", + )))); + } + self.remaining -= bytes.len() as u64; + if self.remaining == 0 { + self.permit.take(); + } + Poll::Ready(Some(Ok(Frame::data(Bytes::copy_from_slice(bytes))))) + } + } + } + fn is_end_stream(&self) -> bool { + self.remaining == 0 + } + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact(self.remaining) + } +} + +fn full(bytes: Bytes) -> ResponseBody { + Full::new(bytes) + .map_err(|never: Infallible| match never {}) + .boxed_unsync() +} +fn text(text: &str, status: u16, head: bool) -> Response { + Response::builder() + .status(status) + .header("Content-Type", "text/plain") + .header("Content-Length", text.len()) + .body(full(if head { + Bytes::new() + } else { + Bytes::copy_from_slice(text.as_bytes()) + })) + .expect("static response") +} + +pub async fn respond( + decision: Decision, + root: &Path, + head: bool, + streams: Arc, +) -> Response { + let mut builder = Response::builder().status(decision.status); + for (name, value) in decision.headers { + builder = builder.header(name, value); + } + let body = match decision.body { + Body::Empty => full(Bytes::new()), + Body::Text(text, mime) => { + builder = builder + .header("Content-Type", mime) + .header("Content-Length", text.len()); + full(if head { + Bytes::new() + } else { + Bytes::from(text) + }) + } + Body::File(path) => { + let Some(resolved) = LocalView(root).resolve(&path) else { + return text("Not Found", 404, head); + }; + // Admission precedes opening: saturation must not exhaust file descriptors. + let permit = if head { + None + } else { + match streams.try_acquire_owned() { + Ok(permit) => Some(permit), + Err(_) => { + let mut response = text("Too many active file streams", 503, false); + response + .headers_mut() + .insert("Retry-After", "1".parse().expect("static header")); + return response; + } + } + }; + let Ok(file) = File::open(&resolved).await else { + return text("Not Found", 404, head); + }; + let Ok(info) = file.metadata().await else { + return text("Not Found", 404, head); + }; + if !info.is_file() { + return text("Not Found", 404, head); + } + builder = builder + .header( + "Content-Type", + mime_guess::from_path(&path) + .first_or_octet_stream() + .as_ref(), + ) + .header("Content-Length", info.len()) + .header("Cache-Control", "no-cache"); + if head { + full(Bytes::new()) + } else { + FileBody { + file, + remaining: info.len(), + permit, + } + .boxed_unsync() + } + } + }; + builder + .body(body) + .unwrap_or_else(|_| text("Invalid response header", 500, head)) +} + +async fn handle_request( + request: Request, + root: PathBuf, + state: Arc>, + streams: Arc, +) -> Result, Infallible> { + let started = std::time::Instant::now(); + let head = request.method() == hyper::Method::HEAD; + let snapshot = state.read().await.clone(); + let method = request.method().as_str().to_owned(); + let path = request.uri().path().to_owned(); + let query = request.uri().query().unwrap_or("").to_owned(); + let (log_method, log_path) = (method.clone(), path.clone()); + let decision_root = root.clone(); + let decision = tokio::task::spawn_blocking(move || { + routing::decide( + &method, + &path, + &query, + &snapshot, + &LocalView(&decision_root), + ) + }) + .await; + let response = match decision { + Ok(decision) => respond(decision, &root, head, streams).await, + Err(_) => text("Internal Server Error", 500, head), + }; + super::log::request( + response.status().as_u16(), + &log_method, + &log_path, + started.elapsed(), + ); + Ok(response) +} + +struct IdleSocket { + socket: tokio::net::TcpStream, + deadline: Pin>, + idle: std::time::Duration, +} +impl IdleSocket { + fn new(socket: tokio::net::TcpStream, idle: std::time::Duration) -> Self { + Self { + socket, + deadline: Box::pin(tokio::time::sleep(idle)), + idle, + } + } + fn timed_out(&mut self, cx: &mut Context<'_>) -> bool { + std::future::Future::poll(self.deadline.as_mut(), cx).is_ready() + } + fn progress(&mut self) { + self.deadline + .as_mut() + .reset(tokio::time::Instant::now() + self.idle); + } +} +impl AsyncRead for IdleSocket { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + if self.timed_out(cx) { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + let before = buffer.filled().len(); + let result = Pin::new(&mut self.socket).poll_read(cx, buffer); + if matches!(&result, Poll::Ready(Ok(()))) && buffer.filled().len() > before { + self.progress(); + } + result + } +} +impl AsyncWrite for IdleSocket { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + bytes: &[u8], + ) -> Poll> { + if self.timed_out(cx) { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + let result = Pin::new(&mut self.socket).poll_write(cx, bytes); + if matches!(&result, Poll::Ready(Ok(count)) if *count > 0) { + self.progress(); + } + result + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + if self.timed_out(cx) { + return Poll::Ready(Err(io::ErrorKind::TimedOut.into())); + } + Pin::new(&mut self.socket).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.socket).poll_shutdown(cx) + } +} + +async fn serve_connection( + socket: tokio::net::TcpStream, + root: PathBuf, + state: Arc>, + streams: Arc, +) { + let service = hyper::service::service_fn(move |request| { + handle_request(request, root.clone(), state.clone(), streams.clone()) + }); + let mut http = hyper::server::conn::http1::Builder::new(); + http.timer(TokioTimer::new()) + .header_read_timeout(std::time::Duration::from_secs(10)) + .max_buf_size(32 * 1024); + let socket = IdleSocket::new(socket, std::time::Duration::from_secs(30)); + let _ = http.serve_connection(TokioIo::new(socket), service).await; +} + +pub async fn run( + listener: TcpListener, + root: PathBuf, + state: Arc>, +) -> anyhow::Result<()> { + let streams = Arc::new(Semaphore::new(MAX_ACTIVE_FILE_STREAMS)); + let mut connections = JoinSet::new(); + loop { + tokio::select! { + accepted = listener.accept() => { + let (socket, _) = accepted?; + connections.spawn(serve_connection( + socket, root.clone(), state.clone(), streams.clone(), + )); + } + _ = connections.join_next(), if !connections.is_empty() => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::IdleSocket; + use std::time::Duration; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::{TcpListener, TcpStream}, + }; + + #[tokio::test] + async fn timeout_tracks_inactivity_not_connection_lifetime() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut client = TcpStream::connect(listener.local_addr().unwrap()) + .await + .unwrap(); + let (socket, _) = listener.accept().await.unwrap(); + let mut socket = IdleSocket::new(socket, Duration::from_millis(100)); + for _ in 0..5 { + tokio::time::sleep(Duration::from_millis(30)).await; + client.write_all(b"x").await.unwrap(); + assert_eq!(socket.read_u8().await.unwrap(), b'x'); + socket.write_all(b"y").await.unwrap(); + assert_eq!(client.read_u8().await.unwrap(), b'y'); + } + assert_eq!( + socket.read_u8().await.unwrap_err().kind(), + std::io::ErrorKind::TimedOut + ); + } +} diff --git a/cli-rs/crates/wispctl/src/serve/log.rs b/cli-rs/crates/wispctl/src/serve/log.rs new file mode 100644 index 0000000..b4fcd21 --- /dev/null +++ b/cli-rs/crates/wispctl/src/serve/log.rs @@ -0,0 +1,26 @@ +use std::time::Duration; + +use wisp_ui::{Line, s}; + +/// Local wall-clock time, `HH:MM:SS`. +pub fn timestamp() -> String { + chrono::Local::now().format("%H:%M:%S").to_string() +} + +/// One request line above the dashboard: ` 12:04:13 200 GET /about 1.2ms`. +pub fn request(status: u16, method: &str, path: &str, elapsed: Duration) { + let code = status.to_string(); + let code = match status { + 200..=299 => s::ok(code), + 300..=399 => s::info(code), + 400..=499 => s::warn(code), + _ => s::danger(code), + }; + wisp_ui::note(Line::from(vec![ + s::muted(format!(" {} ", timestamp())), + code, + s::muted(format!(" {method:<4} ")), + s::plain(path.to_owned()), + s::muted(format!(" {:.1}ms", elapsed.as_secs_f64() * 1000.0)), + ])); +} diff --git a/cli-rs/crates/wispctl/src/serve/mod.rs b/cli-rs/crates/wispctl/src/serve/mod.rs new file mode 100644 index 0000000..2020a38 --- /dev/null +++ b/cli-rs/crates/wispctl/src/serve/mod.rs @@ -0,0 +1,7 @@ +pub mod dashboard; +pub mod http; +mod log; +pub mod routing; + +#[cfg(test)] +mod tests; diff --git a/cli-rs/crates/wispctl/src/serve/routing.rs b/cli-rs/crates/wispctl/src/serve/routing.rs new file mode 100644 index 0000000..bac4cb3 --- /dev/null +++ b/cli-rs/crates/wispctl/src/serve/routing.rs @@ -0,0 +1,181 @@ +//! Request decisions over a read-only filesystem view; no network or terminal effects. +use wisp_core::{ + pages::{DirectoryEntry, generate_404_page, generate_directory_listing}, + redirects::{MatchRedirectContext, RedirectRule, match_redirect_rule, parse_query_string}, +}; + +#[derive(Clone, Debug)] +pub struct Settings { + pub directory_listing: bool, + pub clean_urls: bool, + pub spa_mode: Option, + pub custom_404: Option, + pub index_files: Option>, +} + +#[derive(Clone, Debug, Default)] +pub struct State { + pub settings: Option, + pub redirects: Vec, + pub spa_override: Option, + pub directory_listing_override: Option, +} + +pub trait FilesystemView { + fn is_file(&self, path: &str) -> bool; + fn is_directory(&self, path: &str) -> bool; + fn entries(&self, path: &str) -> Vec; +} + +#[derive(Debug, PartialEq, Eq)] +pub enum Body { + File(String), + Text(String, &'static str), + Empty, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Decision { + pub status: u16, + pub headers: Vec<(&'static str, String)>, + pub body: Body, +} + +impl Decision { + fn file(path: String, status: u16) -> Self { + Self { + status, + headers: vec![], + body: Body::File(path), + } + } + fn text(text: impl Into, status: u16, mime: &'static str) -> Self { + Self { + status, + headers: vec![], + body: Body::Text(text.into(), mime), + } + } +} + +fn configured_path(path: &str) -> Option { + wisp_core::path::normalize_configured_site_path(path).map(str::to_owned) +} + +fn join(parent: &str, child: &str) -> String { + if parent.is_empty() { + child.into() + } else { + format!("{}/{child}", parent.trim_end_matches('/')) + } +} + +pub fn decide( + method: &str, + encoded_path: &str, + query: &str, + state: &State, + fs: &impl FilesystemView, +) -> Decision { + if method != "GET" && method != "HEAD" { + let mut response = Decision::text("Method Not Allowed", 405, "text/plain"); + response.headers.push(("Allow", "GET, HEAD".into())); + return response; + } + let Some(mut path) = wisp_core::path::decode_serve_request_path(encoded_path) else { + return Decision::text("Invalid path", 400, "text/plain"); + }; + let context = MatchRedirectContext { + query_params: parse_query_string(&format!("?{}", query.trim_start_matches('?'))), + ..Default::default() + }; + if let Some(matched) = match_redirect_rule(&path, &state.redirects, Some(&context)) { + match matched.status { + 200 => { + let rewritten = wisp_core::path::normalize_rewrite_path(&matched.target_path); + let Some(rewritten) = rewritten else { + return Decision::text("Invalid rewrite path", 400, "text/plain"); + }; + path = rewritten; + } + 301 | 302 | 307 | 308 => { + return Decision { + status: matched.status, + headers: vec![("Location", matched.target_path)], + body: Body::Empty, + }; + } + 404 => { + if let Some(target) = + configured_path(&matched.target_path).filter(|p| fs.is_file(p)) + { + return Decision::file(target, 404); + } + } + _ => {} + } + } + let path = path.trim_start_matches('/'); + let settings = state.settings.as_ref(); + let directory_listing = state + .directory_listing_override + .unwrap_or_else(|| settings.is_some_and(|s| s.directory_listing)); + if fs.is_directory(path) { + let defaults = vec!["index.html".into(), "index.htm".into()]; + let indices = settings + .and_then(|s| s.index_files.as_ref()) + .unwrap_or(&defaults); + for index in indices { + if let Some(index) = configured_path(index) { + let candidate = join(path, &index); + if fs.is_file(&candidate) { + return Decision::file(candidate, 200); + } + } + } + if directory_listing { + let entries = fs + .entries(path) + .into_iter() + .filter(|e| !e.name.starts_with('.')) + .collect::>(); + return Decision::text( + generate_directory_listing(path.trim_end_matches('/'), &entries), + 200, + "text/html", + ); + } + } + if fs.is_file(path) { + return Decision::file(path.into(), 200); + } + if settings.is_none_or(|s| s.clean_urls) { + for candidate in [format!("{path}.html"), join(path, "index.html")] { + if fs.is_file(&candidate) { + return Decision::file(candidate, 200); + } + } + } + let spa = state + .spa_override + .as_ref() + .or_else(|| settings.and_then(|s| s.spa_mode.as_ref())); + if let Some(spa) = spa + .and_then(|p| configured_path(p)) + .filter(|p| fs.is_file(p)) + { + return Decision::file(spa, 200); + } + let custom = settings + .and_then(|s| s.custom_404.as_ref()) + .and_then(|p| configured_path(p)); + for candidate in custom + .into_iter() + .chain(["404.html".into(), "not_found.html".into()]) + { + if fs.is_file(&candidate) { + return Decision::file(candidate, 404); + } + } + Decision::text(generate_404_page(), 404, "text/html") +} diff --git a/cli-rs/crates/wispctl/src/serve/tests.rs b/cli-rs/crates/wispctl/src/serve/tests.rs new file mode 100644 index 0000000..3674f1f --- /dev/null +++ b/cli-rs/crates/wispctl/src/serve/tests.rs @@ -0,0 +1,326 @@ +use super::{ + http::{MAX_ACTIVE_FILE_STREAMS, load_redirects, respond}, + routing::{Body, Decision, FilesystemView, Settings, State, decide}, +}; +use http_body_util::BodyExt; +use std::collections::BTreeSet; +use std::sync::Arc; +use tokio::sync::Semaphore; +use wisp_core::{ + pages::DirectoryEntry, + redirects::{MAX_REDIRECT_FILE_BYTES, parse_redirects_file}, +}; + +struct View { + files: BTreeSet, + directories: BTreeSet, +} +impl View { + fn new(files: &[&str], dirs: &[&str]) -> Self { + Self { + files: files.iter().map(|p| (*p).into()).collect(), + directories: dirs.iter().map(|p| (*p).into()).collect(), + } + } +} +impl FilesystemView for View { + fn is_file(&self, path: &str) -> bool { + self.files.contains(path) + } + fn is_directory(&self, path: &str) -> bool { + self.directories.contains(path) + } + fn entries(&self, _: &str) -> Vec { + vec![ + DirectoryEntry { + name: ".txt".into(), + is_directory: false, + }, + DirectoryEntry { + name: ".secret".into(), + is_directory: false, + }, + ] + } +} +fn settings() -> Settings { + Settings { + directory_listing: false, + clean_urls: true, + spa_mode: None, + custom_404: None, + index_files: None, + } +} +fn get(path: &str, state: &State, fs: &View) -> Decision { + decide("GET", path, "", state, fs) +} + +#[test] +fn routing_precedence_and_cli_overrides() { + let fs = View::new( + &[ + "index.html", + "about.html", + "app.html", + "error.html", + "404.html", + ], + &[""], + ); + let mut state = State::default(); + assert_eq!(get("/", &state, &fs).body, Body::File("index.html".into())); + assert_eq!( + get("/about", &state, &fs).body, + Body::File("about.html".into()) + ); + assert_eq!( + get("/missing", &state, &fs).body, + Body::File("404.html".into()) + ); + assert_eq!(get("/missing", &state, &fs).status, 404); + let mut record = settings(); + record.spa_mode = Some("app.html".into()); + record.custom_404 = Some("error.html".into()); + state.settings = Some(record); + assert_eq!( + get("/missing", &state, &fs).body, + Body::File("app.html".into()) + ); + state.spa_override = Some("".into()); + assert_eq!( + get("/missing", &state, &fs).body, + Body::File("error.html".into()) + ); + state.spa_override = Some("index.html".into()); + assert_eq!( + get("/missing", &state, &fs).body, + Body::File("index.html".into()) + ); + state.settings.as_mut().unwrap().clean_urls = false; + assert_eq!( + get("/about", &state, &fs).body, + Body::File("index.html".into()) + ); +} + +#[test] +fn unsafe_request_and_configuration_paths_never_reach_filesystem() { + let fs = View::new(&["secret", "404.html"], &[""]); + let mut state = State::default(); + for path in [ + "/../secret", + "/assets/../secret", + "/assets%2f..%2fsecret", + "/safe%00name", + "/assets\\secret", + "/C:/Windows/system.ini", + "/assets/C:/stream", + "/assets/file.", + "/assets/file%20", + "/%zz", + "/%ff", + "/%", + ] { + assert_eq!(get(path, &state, &fs).status, 400, "{path}"); + } + let mut record = settings(); + record.spa_mode = Some("../secret".into()); + record.custom_404 = Some("../secret".into()); + record.index_files = Some(vec!["../secret".into()]); + state.settings = Some(record); + assert_eq!(get("/", &state, &fs).body, Body::File("404.html".into())); + state.redirects = parse_redirects_file("/old /../secret 200"); + assert_eq!(get("/old", &state, &fs).status, 400); +} + +#[test] +fn redirects_rewrites_and_custom_404() { + let fs = View::new(&["target.html", "error.html"], &[]); + let mut state = State { + redirects: parse_redirects_file( + "/old /new 301\n/rewrite /target.html 200\n/gone /error.html 404", + ), + ..Default::default() + }; + let response = get("/old", &state, &fs); + assert_eq!(response.status, 301); + assert_eq!(response.headers, vec![("Location", "/new".into())]); + assert_eq!( + get("/rewrite", &state, &fs).body, + Body::File("target.html".into()) + ); + assert_eq!( + get("/gone", &state, &fs).body, + Body::File("error.html".into()) + ); + assert_eq!(get("/gone", &state, &fs).status, 404); + state.redirects = parse_redirects_file("/find q=hello /target.html 301"); + assert_eq!( + decide("GET", "/find", "q=hello", &state, &fs).headers, + vec![("Location", "/target.html?q=hello".into())] + ); +} + +#[test] +fn listing_is_opt_in_and_index_takes_precedence() { + let fs = View::new(&[], &[""]); + let mut state = State { + directory_listing_override: Some(true), + ..Default::default() + }; + let response = get("/", &state, &fs); + assert_eq!(response.status, 200); + let Body::Text(html, _) = response.body else { + panic!("expected listing"); + }; + assert!(!html.contains(".secret")); + assert!(html.contains("<hello>")); + state.directory_listing_override = Some(false); + assert_eq!(get("/", &state, &fs).status, 404); + assert_eq!( + get("/", &state, &View::new(&["index.htm"], &[""])).body, + Body::File("index.htm".into()) + ); + let method = decide("POST", "/", "", &state, &fs); + assert_eq!(method.status, 405); + assert_eq!(method.headers, vec![("Allow", "GET, HEAD".into())]); +} + +#[tokio::test] +async fn streaming_headers_head_and_permit_release() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("hello.txt"), "hello").unwrap(); + let streams = Arc::new(Semaphore::new(MAX_ACTIVE_FILE_STREAMS)); + let decision = || Decision { + status: 200, + headers: vec![], + body: Body::File("hello.txt".into()), + }; + let mut responses = Vec::new(); + for _ in 0..MAX_ACTIVE_FILE_STREAMS { + responses.push(respond(decision(), temp.path(), false, streams.clone()).await); + } + let saturated = respond(decision(), temp.path(), false, streams.clone()).await; + assert_eq!(saturated.status(), 503); + assert_eq!(saturated.headers()["Retry-After"], "1"); + let head = respond(decision(), temp.path(), true, streams.clone()).await; + assert_eq!(head.status(), 200); + assert_eq!(head.headers()["Content-Length"], "5"); + assert_eq!(head.headers()["Content-Type"], "text/plain"); + assert_eq!(head.headers()["Cache-Control"], "no-cache"); + assert!( + head.into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty() + ); + let get = responses.pop().unwrap(); + assert_eq!(get.headers()["Content-Length"], "5"); + assert_eq!(get.into_body().collect().await.unwrap().to_bytes(), "hello"); + assert_eq!(streams.available_permits(), 1); + drop(responses); + assert_eq!(streams.available_permits(), MAX_ACTIVE_FILE_STREAMS); + let missing = Decision { + status: 200, + headers: vec![], + body: Body::File("missing.txt".into()), + }; + assert_eq!( + respond(missing, temp.path(), false, streams.clone()) + .await + .status(), + 404 + ); + assert_eq!(streams.available_permits(), MAX_ACTIVE_FILE_STREAMS); +} + +#[test] +fn redirects_are_bounded_before_decoding() { + let temp = tempfile::tempdir().unwrap(); + std::fs::write( + temp.path().join("_redirects"), + vec![b'a'; MAX_REDIRECT_FILE_BYTES + 1], + ) + .unwrap(); + assert!(load_redirects(temp.path()).is_empty()); + std::fs::write(temp.path().join("_redirects"), b"/old /new 302").unwrap(); + assert_eq!(load_redirects(temp.path()).len(), 1); +} + +#[tokio::test] +async fn live_hyper_server_serves_get_head_and_rejects_post() { + crate::tls::install_crypto_provider(); + let temp = tempfile::tempdir().unwrap(); + std::fs::write(temp.path().join("hello.txt"), "hello").unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}/hello.txt", listener.local_addr().unwrap()); + let server = tokio::spawn(super::http::run( + listener, + temp.path().to_owned(), + Arc::new(tokio::sync::RwLock::new(State::default())), + )); + let client = reqwest::Client::new(); + let get = client.get(&url).send().await.unwrap(); + assert_eq!(get.status(), 200); + assert_eq!(get.headers()["content-length"], "5"); + assert_eq!(get.text().await.unwrap(), "hello"); + let head = client.head(&url).send().await.unwrap(); + assert_eq!(head.status(), 200); + assert_eq!(head.headers()["content-length"], "5"); + assert_eq!(head.text().await.unwrap(), ""); + let post = client.post(&url).body("ignored").send().await.unwrap(); + assert_eq!(post.status(), 405); + assert_eq!(post.headers()["allow"], "GET, HEAD"); + server.abort(); + let _ = server.await; +} + +#[tokio::test] +async fn shortened_file_releases_permit_on_stream_error() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("changing.txt"); + std::fs::write(&path, vec![b'x'; 100]).unwrap(); + let streams = Arc::new(Semaphore::new(1)); + let decision = Decision { + status: 200, + headers: vec![], + body: Body::File("changing.txt".into()), + }; + let response = respond(decision, temp.path(), false, streams.clone()).await; + assert_eq!(streams.available_permits(), 0); + std::fs::write(&path, []).unwrap(); + assert!(response.into_body().collect().await.is_err()); + assert_eq!(streams.available_permits(), 1); +} + +#[cfg(unix)] +#[tokio::test] +async fn symlinks_cannot_read_outside_cache() { + let root = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + std::fs::write(outside.path().join("secret.txt"), "secret").unwrap(); + std::os::unix::fs::symlink( + outside.path().join("secret.txt"), + root.path().join("escape.txt"), + ) + .unwrap(); + std::os::unix::fs::symlink(outside.path(), root.path().join("escape-dir")).unwrap(); + let streams = Arc::new(Semaphore::new(1)); + for path in ["escape.txt", "escape-dir/secret.txt"] { + let decision = Decision { + status: 200, + headers: vec![], + body: Body::File(path.into()), + }; + assert_eq!( + respond(decision, root.path(), false, streams.clone()) + .await + .status(), + 404 + ); + } + assert_eq!(streams.available_permits(), 1); +} diff --git a/cli-rs/crates/wispctl/src/tls.rs b/cli-rs/crates/wispctl/src/tls.rs new file mode 100644 index 0000000..e24b4ef --- /dev/null +++ b/cli-rs/crates/wispctl/src/tls.rs @@ -0,0 +1,15 @@ +//! TLS setup. reqwest is built without a bundled crypto provider (so aws-lc +//! stays out of the binary), which means ring has to be installed as the +//! process default before the first HTTPS client is created. + +use std::sync::Once; + +/// Install ring as rustls' process-wide provider. Idempotent; call it before +/// building any HTTP client (main does, tests that make clients must too). +pub fn install_crypto_provider() { + static INSTALL: Once = Once::new(); + INSTALL.call_once(|| { + // Err only means a provider is already installed, which is fine. + let _ = rustls::crypto::ring::default_provider().install_default(); + }); +} diff --git a/cli-rs/crates/wispctl/src/xrpc.rs b/cli-rs/crates/wispctl/src/xrpc.rs new file mode 100644 index 0000000..2d21948 --- /dev/null +++ b/cli-rs/crates/wispctl/src/xrpc.rs @@ -0,0 +1,185 @@ +//! Authenticated, typed calls through the wisp service proxy. + +use crate::{ + auth::{self, AuthOptions, WispAgent}, + cli::XrpcOptions, + prompts, +}; +use anyhow::{Result, bail}; +use jacquard::xrpc::{CallOptions, XrpcClient, XrpcRequest, XrpcResp}; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; +use wisp_core::constants::{DEFAULT_WISP_SERVICE_DID, WISP_PROXY_SERVICE_ID}; + +pub async fn resolve_identity(identifier: &str) -> Result<(String, String)> { + auth::resolver::resolve_identity(identifier).await +} + +pub fn parse_service_did(input: Option<&str>) -> Result { + let value = input + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(DEFAULT_WISP_SERVICE_DID); + let valid = value.starts_with("did:") + && value.len() >= 8 + && !value.contains('#') + && !value.chars().any(char::is_whitespace) + && value[4..] + .split_once(':') + .is_some_and(|(method, id)| !method.is_empty() && !id.is_empty()); + if !valid { + bail!("Invalid --service value \"{value}\". Expected did:..."); + } + Ok(value.to_owned()) +} + +fn swallowed_handle(handle: Option<&str>, password: Option<&str>) -> bool { + let Some(password) = password.filter(|_| handle.is_none()) else { + return false; + }; + password.contains('.') + && password + .chars() + .all(|c| c.is_ascii_alphanumeric() || "._:-".contains(c)) +} + +fn spinner_text(message: &str) -> String { + let compact = message.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= 120 { + compact + } else { + format!("{}...", compact.chars().take(119).collect::()) + } +} + +pub fn bind_auth_status(spinner: &mut wisp_ui::Spinner, message: &str) { + if message.starts_with("If browser does not open, visit: ") { + spinner.set_text("Waiting for OAuth callback..."); + wisp_ui::note(message.to_owned()); + } else { + spinner.set_text(spinner_text(message)); + } +} + +pub async fn authenticate_for_xrpc( + handle: Option<&str>, + options: &XrpcOptions, +) -> Result<(WispAgent, String, String)> { + if swallowed_handle(handle, options.password.as_deref()) { + bail!( + "`--password` appears to have consumed the handle argument. Provide a password value and pass the handle separately." + ); + } + let known = if handle.is_none() && options.password.is_none() { + auth::resolve_account_for_cwd(options.db.db.as_deref()).await? + } else { + None + }; + let prompted = if handle.is_none() && known.is_none() { + Some(prompts::handle("Command cancelled")?) + } else { + None + }; + let identifier = handle.or(prompted.as_deref()); + let service = parse_service_did(options.service.as_deref())?; + let mut spinner = wisp_ui::spinner("Authenticating..."); + let authenticated = auth::authenticate( + identifier, + &AuthOptions { + app_password: options.password.clone(), + db_path: options.db.db.clone(), + force_reauth: false, + }, + |message| bind_auth_status(&mut spinner, message), + ) + .await?; + let label = identifier + .or_else(|| known.as_ref().and_then(|a| a.handle.as_deref())) + .unwrap_or(&authenticated.did); + spinner.succeed(format!("Authenticated as {label}")); + Ok((authenticated.agent, service, authenticated.did)) +} + +pub async fn call(agent: &WispAgent, service: &str, request: Value) -> Result +where + R: XrpcRequest + Serialize + DeserializeOwned + Send + Sync, + R::Response: Send + Sync, + ::Output: DeserializeOwned, +{ + send(agent, service, serde_json::from_value::(request)?, None).await +} + +pub async fn send( + agent: &WispAgent, + service: &str, + request: R, + content_type: Option<&str>, +) -> Result +where + R: XrpcRequest + Serialize + Send + Sync, + R::Response: Send + Sync, + ::Output: DeserializeOwned, +{ + let mut opts = CallOptions { + atproto_proxy: Some(format!("{service}#{WISP_PROXY_SERVICE_ID}").into()), + ..Default::default() + }; + if let Some(content_type) = content_type { + opts.extra_headers + .push((http::header::CONTENT_TYPE, content_type.parse()?)); + } + let response = agent.send_with_opts(request, opts).await?; + // Decode the generated output before returning the original JSON to renderers. + response + .parse::() + .map_err(|e| anyhow::anyhow!("{e}"))?; + Ok(serde_json::from_slice(response.buffer())?) +} + +pub fn json(data: &Value) -> Result<()> { + wisp_ui::out_text(&serde_json::to_string_pretty(data)?); + Ok(()) +} + +pub fn text<'a>(data: &'a Value, key: &str) -> &'a str { + data[key].as_str().unwrap_or("") +} +pub fn items<'a>(data: &'a Value, key: &str) -> &'a [Value] { + data[key].as_array().map(Vec::as_slice).unwrap_or(&[]) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn service_did_validation_matches_typescript() { + for valid in [ + None, + Some(""), + Some(" did:web:localhost%3A8000 "), + Some("did:plc:abc"), + ] { + assert!(parse_service_did(valid).is_ok()); + } + for invalid in [ + "https://wisp.place", + "did::abc", + "did:web:", + "did:web:x#proxy", + "did:web:x y", + ] { + assert!(parse_service_did(Some(invalid)).is_err()); + } + } + #[test] + fn auth_status_is_compact_and_unicode_safe() { + assert_eq!(spinner_text(" a\n b "), "a b"); + assert_eq!( + spinner_text(&"é".repeat(121)), + format!("{}...", "é".repeat(119)) + ); + assert!(swallowed_handle(None, Some("alice.test"))); + assert!(!swallowed_handle(Some("alice.test"), Some("alice.test"))); + assert!(!swallowed_handle(None, Some("aaaa-bbbb-cccc-dddd"))); + } +} diff --git a/cli-rs/scripts/lexgen.sh b/cli-rs/scripts/lexgen.sh new file mode 100755 index 0000000..20d3c1e --- /dev/null +++ b/cli-rs/scripts/lexgen.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env sh +# Regenerate crates/wisp-lexicons/src from the repo's lexicon JSON with the +# jacquard fork's codegen (see DESIGN.md), so generated code matches the +# runtime crates we build against. +set -eu +root="$(cd "$(dirname "$0")/.." && pwd)" +jacquard="${JACQUARD_DIR:-$root/../../jacquard}" +out="$root/crates/wisp-lexicons/src" +cargo build --quiet --release --manifest-path "$jacquard/Cargo.toml" -p jacquard-lexgen --bin jacquard-codegen +rm -rf "$out" +"$jacquard/target/release/jacquard-codegen" --input "$root/../lexicons" --output "$out" diff --git a/cli-rs/test/index.html b/cli-rs/test/index.html new file mode 100644 index 0000000..01dcbc2 --- /dev/null +++ b/cli-rs/test/index.html @@ -0,0 +1 @@ +

    WAOW BASED BASED BASED