diff --git a/Cargo.lock b/Cargo.lock index 4977252..145e929 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,7 +84,7 @@ dependencies = [ "rustc-hash", "serde", "serde_derive", - "syn", + "syn 2.0.117", ] [[package]] @@ -117,7 +117,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -142,6 +142,15 @@ dependencies = [ "quick-xml", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -252,7 +261,7 @@ checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -276,6 +285,27 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -309,6 +339,12 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + [[package]] name = "byteorder" version = "1.5.0" @@ -321,6 +357,15 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.61" @@ -389,6 +434,20 @@ dependencies = [ "memchr", ] +[[package]] +name = "compact_str" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -404,6 +463,15 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[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 = "core-foundation" version = "0.9.4" @@ -469,6 +537,33 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crossterm" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" +dependencies = [ + "bitflags 2.11.1", + "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 = "crypto-common" version = "0.1.7" @@ -479,14 +574,55 @@ dependencies = [ "typenum", ] +[[package]] +name = "csscolorparser" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a7d3066da2de787b7f032c736763eb7ae5d355f81a68bab2675a96008b0bf" +dependencies = [ + "lab", + "phf", +] + +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", ] [[package]] @@ -500,7 +636,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", ] [[package]] @@ -509,11 +658,28 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "darling_core", + "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deltae" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" + [[package]] name = "der" version = "0.7.10" @@ -549,10 +715,10 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -562,7 +728,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.117", +] + +[[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.117", ] [[package]] @@ -586,6 +774,27 @@ dependencies = [ "chrono", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -594,7 +803,16 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", ] [[package]] @@ -605,7 +823,7 @@ dependencies = [ "async-trait", "chrono", "email_address", - "thiserror", + "thiserror 2.0.18", "uuid", ] @@ -675,6 +893,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -696,12 +923,51 @@ dependencies = [ "tracing", ] +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "finl_unicode" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9844ddc3a6e533d62bba727eb6c28b5d360921d5175e9ff0f1e621a5c590a4d5" + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "flume" version = "0.11.1" @@ -725,6 +991,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[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" @@ -807,7 +1079,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -922,7 +1194,18 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", ] [[package]] @@ -1250,6 +1533,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + [[package]] name = "infer" version = "0.19.0" @@ -1259,6 +1551,19 @@ dependencies = [ "cfb", ] +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling 0.23.0", + "indoc", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -1284,6 +1589,15 @@ dependencies = [ "either", ] +[[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" @@ -1302,7 +1616,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.18", "walkdir", "windows-link", ] @@ -1317,7 +1631,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.117", ] [[package]] @@ -1336,7 +1650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -1376,6 +1690,33 @@ dependencies = [ "simple_asn1", ] +[[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 2.0.18", +] + +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + +[[package]] +name = "lab" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + [[package]] name = "lazy_static" version = "1.5.0" @@ -1409,7 +1750,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", + "bitflags 2.11.1", "libc", "plain", "redox_syscall 0.7.4", @@ -1426,12 +1767,33 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "line-clipping" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f50e8f47623268b5407192d26876c4d7f89d686ca130fdc53bced4814cd29f8" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "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" @@ -1447,12 +1809,31 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" +dependencies = [ + "hashbrown 0.16.1", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "mac_address" +version = "1.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0aeb26bf5e836cc1c341c8106051b573f1766dfa05aa87f0b98be5e51b02303" +dependencies = [ + "nix", + "winapi", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1484,6 +1865,21 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memmem" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a64a92489e2744ce060c349162be1c5f33c6969234104dbd99ddb5feb08b8c15" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "metadata" version = "0.1.0" @@ -1510,6 +1906,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "mio" version = "1.2.0" @@ -1517,6 +1919,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -1527,6 +1930,29 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c96aba5aa877601bb3f6dd6a63a969e1f82e60646e81e71b14496995e9853c91" +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "cfg_aliases", + "libc", + "memoffset", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1568,6 +1994,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "num-integer" version = "0.1.46" @@ -1598,6 +2035,15 @@ dependencies = [ "libm", ] +[[package]] +name = "num_threads" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +dependencies = [ + "libc", +] + [[package]] name = "object_store" version = "0.11.2" @@ -1611,7 +2057,7 @@ dependencies = [ "futures", "humantime", "hyper", - "itertools", + "itertools 0.13.0", "md-5", "parking_lot", "percent-encoding", @@ -1640,6 +2086,21 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + [[package]] name = "parking" version = "2.2.1" @@ -1705,6 +2166,101 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -1744,6 +2300,12 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + [[package]] name = "poster-fetcher" version = "0.1.0" @@ -1816,7 +2378,7 @@ dependencies = [ "sqlite", "sqlx", "template-askama", - "thiserror", + "thiserror 2.0.18", "tokio", "tower", "tower-http", @@ -1832,7 +2394,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.117", ] [[package]] @@ -1869,7 +2431,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -1891,7 +2453,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -1991,13 +2553,98 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "ratatui" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1ce67fb8ba4446454d1c8dbaeda0557ff5e94d39d5e5ed7f10a65eb4c8266bc" +dependencies = [ + "instability", + "ratatui-core", + "ratatui-crossterm", + "ratatui-macros", + "ratatui-termwiz", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ef8dea09a92caaf73bff7adb70b76162e5937524058a7e5bff37869cbbec293" +dependencies = [ + "bitflags 2.11.1", + "compact_str", + "hashbrown 0.16.1", + "indoc", + "itertools 0.14.0", + "kasuari", + "lru", + "strum", + "thiserror 2.0.18", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "ratatui-crossterm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "577c9b9f652b4c121fb25c6a391dd06406d3b092ba68827e6d2f09550edc54b3" +dependencies = [ + "cfg-if", + "crossterm", + "instability", + "ratatui-core", +] + +[[package]] +name = "ratatui-macros" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f1342a13e83e4bb9d0b793d0ea762be633f9582048c892ae9041ef39c936f4" +dependencies = [ + "ratatui-core", + "ratatui-widgets", +] + +[[package]] +name = "ratatui-termwiz" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f76fe0bd0ed4295f0321b1676732e2454024c15a35d01904ddb315afd3d545c" +dependencies = [ + "ratatui-core", + "termwiz", +] + +[[package]] +name = "ratatui-widgets" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7dbfa023cd4e604c2553483820c5fe8aa9d71a42eea5aa77c6e7f35756612db" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.16.1", + "indoc", + "instability", + "itertools 0.14.0", + "line-clipping", + "ratatui-core", + "strum", + "time", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2006,7 +2653,30 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" dependencies = [ - "bitflags", + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", ] [[package]] @@ -2180,6 +2850,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.40" @@ -2298,7 +2981,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -2348,7 +3031,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2424,6 +3107,27 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[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" @@ -2468,10 +3172,16 @@ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" dependencies = [ "num-bigint", "num-traits", - "thiserror", + "thiserror 2.0.18", "time", ] +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -2505,7 +3215,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2591,7 +3301,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror", + "thiserror 2.0.18", "tokio", "tokio-stream", "tracing", @@ -2610,7 +3320,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.117", ] [[package]] @@ -2633,7 +3343,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.117", "tokio", "url", ] @@ -2646,7 +3356,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.11.1", "byteorder", "bytes", "crc", @@ -2675,7 +3385,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2689,7 +3399,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.11.1", "byteorder", "crc", "dotenvy", @@ -2713,7 +3423,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror", + "thiserror 2.0.18", "tracing", "uuid", "whoami", @@ -2738,7 +3448,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror", + "thiserror 2.0.18", "tracing", "url", "uuid", @@ -2750,6 +3460,12 @@ 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 = "stringprep" version = "0.1.5" @@ -2767,12 +3483,44 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.27.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "subtle" version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "2.0.117" @@ -2801,7 +3549,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2810,7 +3558,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -2825,6 +3573,19 @@ dependencies = [ "libc", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "template-askama" version = "0.1.0" @@ -2836,13 +3597,96 @@ dependencies = [ "serde", ] +[[package]] +name = "terminfo" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ea810f0692f9f51b382fff5893887bb4580f5fa246fde546e0b13e7fcee662" +dependencies = [ + "fnv", + "nom", + "phf", + "phf_codegen", +] + +[[package]] +name = "termios" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411c5bf740737c7918b8b1fe232dca4dc9f8e754b8ad5e20966814001ed0ac6b" +dependencies = [ + "libc", +] + +[[package]] +name = "termwiz" +version = "0.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" +dependencies = [ + "anyhow", + "base64", + "bitflags 2.11.1", + "fancy-regex", + "filedescriptor", + "finl_unicode", + "fixedbitset", + "hex", + "lazy_static", + "libc", + "log", + "memmem", + "nix", + "num-derive", + "num-traits", + "ordered-float", + "pest", + "pest_derive", + "phf", + "sha2", + "signal-hook", + "siphasher", + "terminfo", + "termios", + "thiserror 1.0.69", + "ucd-trie", + "unicode-segmentation", + "vtparse", + "wezterm-bidi", + "wezterm-blob-leases", + "wezterm-color-types", + "wezterm-dynamic", + "wezterm-input-types", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -2853,7 +3697,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -2873,7 +3717,9 @@ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", + "libc", "num-conv", + "num_threads", "powerfmt", "serde_core", "time-core", @@ -2946,7 +3792,7 @@ checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3005,7 +3851,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-core", "futures-util", @@ -3059,7 +3905,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3107,12 +3953,36 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "csv", + "directories", + "keyring", + "ratatui", + "reqwest 0.13.3", + "serde", + "serde_json", + "tempfile", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "typenum" version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "unicase" version = "2.9.0" @@ -3146,6 +4016,29 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-truncate" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" +dependencies = [ + "itertools 0.14.0", + "unicode-segmentation", + "unicode-width", +] + +[[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" @@ -3176,12 +4069,19 @@ 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.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ + "atomic", "getrandom 0.4.2", "js-sys", "serde_core", @@ -3206,6 +4106,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vtparse" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9b2acfb050df409c972a37d3b8e08cdea3bddb0c09db9d53137e504cfabed0" +dependencies = [ + "utf8parse", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -3297,7 +4206,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wasm-bindgen-shared", ] @@ -3351,7 +4260,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap", "semver", @@ -3404,6 +4313,78 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "wezterm-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0a6e355560527dd2d1cf7890652f4f09bb3433b6aadade4c9b5ed76de5f3ec" +dependencies = [ + "log", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-blob-leases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "692daff6d93d94e29e4114544ef6d5c942a7ed998b37abdc19b17136ea428eb7" +dependencies = [ + "getrandom 0.3.4", + "mac_address", + "sha2", + "thiserror 1.0.69", + "uuid", +] + +[[package]] +name = "wezterm-color-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7de81ef35c9010270d63772bebef2f2d6d1f2d20a983d27505ac850b8c4b4296" +dependencies = [ + "csscolorparser", + "deltae", + "lazy_static", + "wezterm-dynamic", +] + +[[package]] +name = "wezterm-dynamic" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f2ab60e120fd6eaa68d9567f3226e876684639d22a4219b313ff69ec0ccd5ac" +dependencies = [ + "log", + "ordered-float", + "strsim", + "thiserror 1.0.69", + "wezterm-dynamic-derive", +] + +[[package]] +name = "wezterm-dynamic-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c0cf2d539c645b448eaffec9ec494b8b19bd5077d9e58cb1ae7efece8d575b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "wezterm-input-types" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7012add459f951456ec9d6c7e6fc340b1ce15d6fc9629f8c42853412c029e57e" +dependencies = [ + "bitflags 1.3.2", + "euclid", + "lazy_static", + "serde", + "wezterm-dynamic", +] + [[package]] name = "whoami" version = "1.6.1" @@ -3414,6 +4395,22 @@ dependencies = [ "wasite", ] +[[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" @@ -3423,6 +4420,12 @@ 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" @@ -3444,7 +4447,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3455,7 +4458,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3686,7 +4689,7 @@ dependencies = [ "heck", "indexmap", "prettyplease", - "syn", + "syn 2.0.117", "wasm-metadata", "wit-bindgen-core", "wit-component", @@ -3702,7 +4705,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.117", "wit-bindgen-core", "wit-bindgen-rust", ] @@ -3714,7 +4717,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap", "log", "serde", @@ -3769,7 +4772,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3790,7 +4793,7 @@ checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] @@ -3810,7 +4813,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", "synstructure", ] @@ -3850,7 +4853,7 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.117", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1ea2631..37a8dc8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ "crates/adapters/template-askama", "crates/application", "crates/domain", - "crates/presentation", + "crates/presentation", "crates/tui", ] resolver = "2" diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml new file mode 100644 index 0000000..0e5edeb --- /dev/null +++ b/crates/tui/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "tui" +version = "0.1.0" +edition = "2024" + +[dependencies] +ratatui = "0.30.0" + +keyring = "3" +directories = "6" +csv = "1" + +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +anyhow = { workspace = true } +uuid = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs new file mode 100644 index 0000000..3a96f2f --- /dev/null +++ b/crates/tui/src/app.rs @@ -0,0 +1,1168 @@ +use uuid::Uuid; +use crate::client::{DiaryEntryDto, LogReviewRequest, ReviewHistoryResponse}; +use crate::config::Config; + +// ── Screens ─────────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub enum Screen { + Setup(SetupState), + Login(LoginState), + Main(MainState), +} + +#[derive(Debug, Default)] +pub struct SetupState { + pub api_url: String, + pub error: Option, +} + +#[derive(Debug, Default)] +pub struct LoginState { + pub email: String, + pub password: String, + pub focused: LoginField, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum LoginField { #[default] Email, Password } + +// ── Main (4 tabs) ───────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct MainState { + pub tab: Tab, + pub diary: DiaryState, + pub add_review: AddReviewState, + pub bulk_import: BulkImportState, + pub settings: SettingsState, +} + +impl MainState { + pub fn new(api_url: String) -> Self { + Self { + tab: Tab::Diary, + diary: DiaryState::default(), + add_review: AddReviewState::default(), + bulk_import: BulkImportState::default(), + settings: SettingsState { api_url, focused: SettingsField::default() }, + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum Tab { #[default] Diary, AddReview, BulkImport, Settings } + +// ── Diary ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, Default)] +pub struct DiaryState { + pub entries: Vec, + pub selected: usize, + pub offset: u32, + pub total: u64, + pub history: Option, + pub delete_pending: Option, +} + +// ── Add Review ──────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct AddReviewState { + pub external_id: String, + pub title: String, + pub year: String, + pub rating: u8, + pub watched_at: String, + pub comment: String, + pub focused: AddReviewField, +} + +impl Default for AddReviewState { + fn default() -> Self { + Self { + external_id: String::new(), + title: String::new(), + year: String::new(), + rating: 5, + watched_at: String::new(), + comment: String::new(), + focused: AddReviewField::ExternalId, + } + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum AddReviewField { + #[default] ExternalId, Title, Year, Rating, WatchedAt, Comment, Submit, +} + +// ── Bulk Import ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Default)] +pub struct BulkImportState { + pub file_path: String, + pub stage: BulkImportStage, + pub parsed: Vec, + pub valid_requests: Vec, + // None = succeeded, Some(msg) = failed + pub results: Vec>, +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub enum BulkImportStage { + #[default] EnterPath, + Preview, + Importing { done: usize }, + Done, +} + +#[derive(Debug, Clone)] +pub struct ParsedRow { + pub row: usize, + pub result: Result, +} + +// ── Settings ────────────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct SettingsState { + pub api_url: String, + pub focused: SettingsField, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum SettingsField { #[default] ApiUrl, Save, Logout } + +// ── Status bar ──────────────────────────────────────────────────────────────── + +#[derive(Debug, Clone)] +pub struct StatusMsg { + pub text: String, + pub is_error: bool, +} + +// ── Top-level App ───────────────────────────────────────────────────────────── + +#[derive(Debug)] +pub struct App { + pub screen: Screen, + pub token: Option, + pub loading: bool, + pub status: Option, + pub api_url: String, +} + +impl App { + pub fn new(config: Option, token: Option) -> Self { + let api_url = config.as_ref().map(|c| c.api_url.clone()).unwrap_or_default(); + let screen = match &config { + None => Screen::Setup(SetupState::default()), + Some(_) if token.is_none() => Screen::Login(LoginState::default()), + Some(c) => Screen::Main(MainState::new(c.api_url.clone())), + }; + Self { screen, token, loading: false, status: None, api_url } + } +} + +// ── Action & Command (stubs; logic added in Task 5) ─────────────────────────── + +#[derive(Debug)] +pub enum Action { + Quit, Escape, TabSelect(Tab), TabNext, TabPrev, + SetupSubmit, + InputChar(char), Backspace, FocusNext, FocusPrev, + LoginSubmit, + ScrollDown, ScrollUp, OpenHistory, LoadMore, + DeleteInit, DeleteConfirm, DeleteCancel, + RatingUp, RatingDown, ReviewSubmit, + BulkParseFile, BulkImportAll, BulkCancel, + SettingsSave, SettingsLogout, + // async results + AuthOk(String), AuthFail(String), + DiaryLoaded { entries: Vec, total: u64 }, + DiaryLoadFailed(String), + HistoryLoaded(ReviewHistoryResponse), + HistoryLoadFailed(String), + ReviewCreated, ReviewCreateFailed(String), + ReviewDeleted(Uuid), ReviewDeleteFailed(String), + BulkItemDone { index: usize, error: Option }, +} + +#[derive(Debug)] +pub enum Command { + Login { email: String, password: String }, + LoadDiary { offset: u32 }, + LoadHistory { movie_id: Uuid }, + CreateReview(LogReviewRequest), + DeleteReview(Uuid), + ImportNext(usize), + SaveConfig(String), + SaveToken(String), + ClearToken, +} + +pub fn parse_csv(content: &str) -> Vec { + let mut rdr = csv::Reader::from_reader(content.as_bytes()); + let mut rows = Vec::new(); + + for (i, result) in rdr.records().enumerate() { + let row_num = i + 2; // 1-indexed, header is row 1 + let record = match result { + Ok(r) => r, + Err(e) => { + rows.push(ParsedRow { row: row_num, result: Err(e.to_string()) }); + continue; + } + }; + + let title = record.get(0).unwrap_or("").trim().to_string(); + let year_str = record.get(1).unwrap_or("").trim().to_string(); + let external_id = record.get(2).unwrap_or("").trim().to_string(); + let rating_str = record.get(3).unwrap_or("").trim().to_string(); + let watched_at = record.get(4).unwrap_or("").trim().to_string(); + let comment = record.get(5).unwrap_or("").trim().to_string(); + + if title.is_empty() && external_id.is_empty() { + rows.push(ParsedRow { row: row_num, result: Err("title or external_id required".into()) }); + continue; + } + + let rating: u8 = match rating_str.trim().parse::() { + Ok(r) if r <= 5 => r, + Ok(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("rating must be 0-5, got {rating_str}")) }); continue; } + Err(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("invalid rating: {rating_str}")) }); continue; } + }; + + if watched_at.is_empty() { + rows.push(ParsedRow { row: row_num, result: Err("watched_at required".into()) }); + continue; + } + + let manual_release_year: Option = if year_str.is_empty() { + None + } else { + match year_str.parse() { + Ok(y) => Some(y), + Err(_) => { rows.push(ParsedRow { row: row_num, result: Err(format!("invalid year: {year_str}")) }); continue; } + } + }; + + rows.push(ParsedRow { + row: row_num, + result: Ok(LogReviewRequest { + external_metadata_id: if external_id.is_empty() { None } else { Some(external_id) }, + manual_title: if title.is_empty() { None } else { Some(title) }, + manual_release_year, + rating, + comment: if comment.is_empty() { None } else { Some(comment) }, + watched_at, + }), + }); + } + rows +} + +pub fn update(app: &mut App, action: Action) -> Vec { + match action { + // ── Global ─────────────────────────────────────────────────────────── + Action::Quit => vec![], + + Action::TabSelect(tab) => { + if let Screen::Main(m) = &mut app.screen { + m.tab = tab; + } + vec![] + } + + Action::TabNext => { + if let Screen::Main(m) = &mut app.screen { + m.tab = match m.tab { Tab::Diary => Tab::AddReview, Tab::AddReview => Tab::BulkImport, Tab::BulkImport => Tab::Settings, Tab::Settings => Tab::Diary }; + } + vec![] + } + + Action::TabPrev => { + if let Screen::Main(m) = &mut app.screen { + m.tab = match m.tab { Tab::Diary => Tab::Settings, Tab::AddReview => Tab::Diary, Tab::BulkImport => Tab::AddReview, Tab::Settings => Tab::BulkImport }; + } + vec![] + } + + Action::Escape => { + if let Screen::Main(m) = &mut app.screen { + match m.tab { + Tab::Diary => { + if m.diary.delete_pending.is_some() { m.diary.delete_pending = None; } + else { m.diary.history = None; } + } + Tab::BulkImport => { + if matches!(m.bulk_import.stage, BulkImportStage::Preview | BulkImportStage::Done) { + m.bulk_import.stage = BulkImportStage::EnterPath; + } + } + _ => {} + } + } + vec![] + } + + // ── Shared text input ──────────────────────────────────────────────── + Action::InputChar(c) => { + match &mut app.screen { + Screen::Setup(s) => s.api_url.push(c), + Screen::Login(s) => match s.focused { + LoginField::Email => s.email.push(c), + LoginField::Password => s.password.push(c), + }, + Screen::Main(m) => match m.tab { + Tab::AddReview => match m.add_review.focused { + AddReviewField::ExternalId => m.add_review.external_id.push(c), + AddReviewField::Title => m.add_review.title.push(c), + AddReviewField::Year => m.add_review.year.push(c), + AddReviewField::WatchedAt => m.add_review.watched_at.push(c), + AddReviewField::Comment => m.add_review.comment.push(c), + _ => {} + }, + Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => { + m.bulk_import.file_path.push(c); + } + Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { + m.settings.api_url.push(c); + } + _ => {} + }, + } + vec![] + } + + Action::Backspace => { + match &mut app.screen { + Screen::Setup(s) => { s.api_url.pop(); } + Screen::Login(s) => match s.focused { + LoginField::Email => { s.email.pop(); } + LoginField::Password => { s.password.pop(); } + }, + Screen::Main(m) => match m.tab { + Tab::AddReview => match m.add_review.focused { + AddReviewField::ExternalId => { m.add_review.external_id.pop(); } + AddReviewField::Title => { m.add_review.title.pop(); } + AddReviewField::Year => { m.add_review.year.pop(); } + AddReviewField::WatchedAt => { m.add_review.watched_at.pop(); } + AddReviewField::Comment => { m.add_review.comment.pop(); } + _ => {} + }, + Tab::BulkImport if matches!(m.bulk_import.stage, BulkImportStage::EnterPath) => { + m.bulk_import.file_path.pop(); + } + Tab::Settings if matches!(m.settings.focused, SettingsField::ApiUrl) => { + m.settings.api_url.pop(); + } + _ => {} + }, + } + vec![] + } + + Action::FocusNext => { + match &mut app.screen { + Screen::Login(s) => { + s.focused = if s.focused == LoginField::Email { LoginField::Password } else { LoginField::Email }; + } + Screen::Main(m) => match m.tab { + Tab::AddReview => { + m.add_review.focused = match m.add_review.focused { + AddReviewField::ExternalId => AddReviewField::Title, + AddReviewField::Title => AddReviewField::Year, + AddReviewField::Year => AddReviewField::Rating, + AddReviewField::Rating => AddReviewField::WatchedAt, + AddReviewField::WatchedAt => AddReviewField::Comment, + AddReviewField::Comment => AddReviewField::Submit, + AddReviewField::Submit => AddReviewField::ExternalId, + }; + } + Tab::Settings => { + m.settings.focused = match m.settings.focused { + SettingsField::ApiUrl => SettingsField::Save, + SettingsField::Save => SettingsField::Logout, + SettingsField::Logout => SettingsField::ApiUrl, + }; + } + _ => {} + }, + _ => {} + } + vec![] + } + + Action::FocusPrev => { + match &mut app.screen { + Screen::Login(s) => { + s.focused = if s.focused == LoginField::Password { LoginField::Email } else { LoginField::Password }; + } + Screen::Main(m) => match m.tab { + Tab::AddReview => { + m.add_review.focused = match m.add_review.focused { + AddReviewField::ExternalId => AddReviewField::Submit, + AddReviewField::Title => AddReviewField::ExternalId, + AddReviewField::Year => AddReviewField::Title, + AddReviewField::Rating => AddReviewField::Year, + AddReviewField::WatchedAt => AddReviewField::Rating, + AddReviewField::Comment => AddReviewField::WatchedAt, + AddReviewField::Submit => AddReviewField::Comment, + }; + } + Tab::Settings => { + m.settings.focused = match m.settings.focused { + SettingsField::ApiUrl => SettingsField::Logout, + SettingsField::Save => SettingsField::ApiUrl, + SettingsField::Logout => SettingsField::Save, + }; + } + _ => {} + }, + _ => {} + } + vec![] + } + + // ── Setup ───────────────────────────────────────────────────────────── + Action::SetupSubmit => { + if let Screen::Setup(s) = &mut app.screen { + let url = s.api_url.trim().to_string(); + if url.is_empty() { + s.error = Some("URL required".into()); + return vec![]; + } + app.api_url = url.clone(); + app.screen = Screen::Login(LoginState::default()); + return vec![Command::SaveConfig(url)]; + } + vec![] + } + + // ── Login ───────────────────────────────────────────────────────────── + Action::LoginSubmit => { + if let Screen::Login(s) = &app.screen { + if s.email.is_empty() || s.password.is_empty() { + app.status = Some(StatusMsg { text: "Email and password required".into(), is_error: true }); + return vec![]; + } + let email = s.email.clone(); + let password = s.password.clone(); + app.loading = true; + return vec![Command::Login { email, password }]; + } + vec![] + } + + Action::AuthOk(token) => { + app.loading = false; + app.status = None; + app.screen = Screen::Main(MainState::new(app.api_url.clone())); + let cmds = vec![Command::SaveToken(token.clone()), Command::LoadDiary { offset: 0 }]; + app.token = Some(token); + cmds + } + + Action::AuthFail(msg) => { + app.loading = false; + app.status = Some(StatusMsg { text: msg, is_error: true }); + vec![] + } + + // ── Diary ───────────────────────────────────────────────────────────── + Action::ScrollDown => { + if let Screen::Main(m) = &mut app.screen { + let len = m.diary.entries.len(); + if len > 0 && m.diary.selected < len - 1 { + m.diary.selected += 1; + m.diary.history = None; + } + } + vec![] + } + + Action::ScrollUp => { + if let Screen::Main(m) = &mut app.screen { + if m.diary.selected > 0 { + m.diary.selected -= 1; + m.diary.history = None; + } + } + vec![] + } + + Action::OpenHistory => { + if let Screen::Main(m) = &mut app.screen { + if let Some(entry) = m.diary.entries.get(m.diary.selected) { + let movie_id = entry.movie.id; + app.loading = true; + return vec![Command::LoadHistory { movie_id }]; + } + } + vec![] + } + + Action::LoadMore => { + if let Screen::Main(m) = &mut app.screen { + let next = m.diary.offset + 20; + if (next as u64) < m.diary.total { + m.diary.offset = next; + return vec![Command::LoadDiary { offset: next }]; + } + } + vec![] + } + + Action::DiaryLoaded { entries, total } => { + app.loading = false; + if let Screen::Main(m) = &mut app.screen { + m.diary.total = total; + m.diary.entries = entries; + m.diary.selected = 0; + } + vec![] + } + + Action::DiaryLoadFailed(msg) => { + app.loading = false; + if msg.contains("unauthorized") || msg.contains("Unauthorized") { + app.token = None; + app.screen = Screen::Login(LoginState::default()); + app.status = Some(StatusMsg { text: "Session expired. Please log in again.".into(), is_error: true }); + return vec![Command::ClearToken]; + } + app.status = Some(StatusMsg { text: msg, is_error: true }); + vec![] + } + + Action::HistoryLoaded(h) => { + app.loading = false; + if let Screen::Main(m) = &mut app.screen { + m.diary.history = Some(h); + } + vec![] + } + + Action::HistoryLoadFailed(msg) => { + app.loading = false; + app.status = Some(StatusMsg { text: msg, is_error: true }); + vec![] + } + + Action::DeleteInit => { + if let Screen::Main(m) = &mut app.screen { + if let Some(entry) = m.diary.entries.get(m.diary.selected) { + m.diary.delete_pending = Some(entry.review.id); + } + } + vec![] + } + + Action::DeleteConfirm => { + if let Screen::Main(m) = &mut app.screen { + if let Some(review_id) = m.diary.delete_pending.take() { + return vec![Command::DeleteReview(review_id)]; + } + } + vec![] + } + + Action::DeleteCancel => { + if let Screen::Main(m) = &mut app.screen { + m.diary.delete_pending = None; + } + vec![] + } + + Action::ReviewDeleted(id) => { + if let Screen::Main(m) = &mut app.screen { + m.diary.entries.retain(|e| e.review.id != id); + m.diary.total = m.diary.total.saturating_sub(1); + if m.diary.selected >= m.diary.entries.len() { + m.diary.selected = m.diary.entries.len().saturating_sub(1); + } + m.diary.history = None; + } + app.status = Some(StatusMsg { text: "Review deleted".into(), is_error: false }); + vec![] + } + + Action::ReviewDeleteFailed(msg) => { + app.status = Some(StatusMsg { text: msg, is_error: true }); + vec![] + } + + // ── Add Review ──────────────────────────────────────────────────────── + Action::RatingUp => { + if let Screen::Main(m) = &mut app.screen { + if m.add_review.rating < 5 { m.add_review.rating += 1; } + } + vec![] + } + + Action::RatingDown => { + if let Screen::Main(m) = &mut app.screen { + if m.add_review.rating > 0 { m.add_review.rating -= 1; } + } + vec![] + } + + Action::ReviewSubmit => { + if let Screen::Main(m) = &app.screen { + if m.tab == Tab::AddReview { + let f = &m.add_review; + let has_ext = !f.external_id.is_empty(); + let has_title = !f.title.is_empty(); + let has_watched = !f.watched_at.is_empty(); + let ext_id = if has_ext { Some(f.external_id.clone()) } else { None }; + let title = if has_title { Some(f.title.clone()) } else { None }; + let year: Option = f.year.parse().ok(); + let rating = f.rating; + let comment = if f.comment.is_empty() { None } else { Some(f.comment.clone()) }; + let watched_at = f.watched_at.clone(); + + if !has_ext && !has_title { + app.status = Some(StatusMsg { text: "Title or external ID required".into(), is_error: true }); + return vec![]; + } + if !has_watched { + app.status = Some(StatusMsg { text: "Watched-at date required".into(), is_error: true }); + return vec![]; + } + let req = LogReviewRequest { + external_metadata_id: ext_id, + manual_title: title, + manual_release_year: year, + rating, + comment, + watched_at, + }; + app.loading = true; + return vec![Command::CreateReview(req)]; + } + } + vec![] + } + + Action::ReviewCreated => { + app.loading = false; + app.status = Some(StatusMsg { text: "Review added!".into(), is_error: false }); + if let Screen::Main(m) = &mut app.screen { + m.add_review = AddReviewState::default(); + } + vec![] + } + + Action::ReviewCreateFailed(msg) => { + app.loading = false; + app.status = Some(StatusMsg { text: msg, is_error: true }); + vec![] + } + + // ── Bulk Import ─────────────────────────────────────────────────────── + Action::BulkParseFile => { + if let Screen::Main(m) = &mut app.screen { + if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::EnterPath { + let path = m.bulk_import.file_path.trim().to_string(); + match std::fs::read_to_string(&path) { + Ok(content) => { + m.bulk_import.parsed = parse_csv(&content); + m.bulk_import.stage = BulkImportStage::Preview; + } + Err(e) => { + app.status = Some(StatusMsg { text: format!("Cannot read file: {e}"), is_error: true }); + } + } + } + } + vec![] + } + + Action::BulkImportAll => { + if let Screen::Main(m) = &mut app.screen { + if m.tab == Tab::BulkImport && m.bulk_import.stage == BulkImportStage::Preview { + let valid: Vec = m.bulk_import.parsed.iter() + .filter_map(|r| r.result.as_ref().ok().cloned()) + .collect(); + if valid.is_empty() { + app.status = Some(StatusMsg { text: "No valid rows to import".into(), is_error: true }); + return vec![]; + } + m.bulk_import.results = vec![None; valid.len()]; + m.bulk_import.valid_requests = valid; + m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; + return vec![Command::ImportNext(0)]; + } + } + vec![] + } + + Action::BulkCancel => { + if let Screen::Main(m) = &mut app.screen { + m.bulk_import = BulkImportState::default(); + } + vec![] + } + + Action::BulkItemDone { index, error } => { + if let Screen::Main(m) = &mut app.screen { + if index >= m.bulk_import.results.len() { + app.status = Some(StatusMsg { text: format!("Import error: unexpected index {index}"), is_error: true }); + m.bulk_import.stage = BulkImportStage::Done; + return vec![]; + } + m.bulk_import.results[index] = error; + let done = index + 1; + let total = m.bulk_import.valid_requests.len(); + if done < total { + m.bulk_import.stage = BulkImportStage::Importing { done }; + return vec![Command::ImportNext(done)]; + } else { + let failed = m.bulk_import.results.iter().filter(|r| r.is_some()).count(); + m.bulk_import.stage = BulkImportStage::Done; + app.status = Some(StatusMsg { + text: format!("Import done: {} ok, {} failed", total - failed, failed), + is_error: failed > 0, + }); + } + } + vec![] + } + + // ── Settings ────────────────────────────────────────────────────────── + Action::SettingsSave => { + if let Screen::Main(m) = &app.screen { + let url = m.settings.api_url.trim().to_string(); + if url.is_empty() { + app.status = Some(StatusMsg { text: "URL required".into(), is_error: true }); + return vec![]; + } + app.status = Some(StatusMsg { text: "Settings saved".into(), is_error: false }); + app.api_url = url.clone(); + return vec![Command::SaveConfig(url)]; + } + vec![] + } + + Action::SettingsLogout => { + app.token = None; + app.screen = Screen::Login(LoginState::default()); + app.status = None; + vec![Command::ClearToken] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::client::{DiaryEntryDto, MovieDto, ReviewDto}; + use uuid::Uuid; + + fn setup_app() -> App { + App { + screen: Screen::Setup(SetupState { api_url: String::new(), error: None }), + token: None, + loading: false, + status: None, + api_url: String::new(), + } + } + + fn login_app() -> App { + App { + screen: Screen::Login(LoginState::default()), + token: None, + loading: false, + status: None, + api_url: String::new(), + } + } + + fn main_app() -> App { + App { + screen: Screen::Main(MainState::new("http://localhost:3000".into())), + token: Some("tok".into()), + loading: false, + status: None, + api_url: "http://localhost:3000".into(), + } + } + + fn diary_entry() -> DiaryEntryDto { + DiaryEntryDto { + movie: MovieDto { id: Uuid::new_v4(), title: "The Matrix".into(), release_year: 1999, director: None }, + review: ReviewDto { id: Uuid::new_v4(), rating: 5, comment: None, watched_at: "1999-03-31T00:00:00".into() }, + } + } + + // ── Setup screen ────────────────────────────────────────────────────────── + + #[test] + fn setup_input_char_appends_to_api_url() { + let mut app = setup_app(); + update(&mut app, Action::InputChar('h')); + update(&mut app, Action::InputChar('i')); + if let Screen::Setup(s) = &app.screen { + assert_eq!(s.api_url, "hi"); + } else { panic!("expected Setup"); } + } + + #[test] + fn setup_submit_with_empty_url_sets_error() { + let mut app = setup_app(); + let cmds = update(&mut app, Action::SetupSubmit); + assert!(cmds.is_empty()); + if let Screen::Setup(s) = &app.screen { + assert!(s.error.is_some()); + } else { panic!("expected Setup"); } + } + + #[test] + fn setup_submit_with_url_saves_config_and_transitions_to_login() { + let mut app = setup_app(); + update(&mut app, Action::InputChar('h')); + let cmds = update(&mut app, Action::SetupSubmit); + assert!(cmds.iter().any(|c| matches!(c, Command::SaveConfig(_)))); + assert!(matches!(app.screen, Screen::Login(_))); + } + + // ── Login screen ────────────────────────────────────────────────────────── + + #[test] + fn login_input_char_goes_to_email_by_default() { + let mut app = login_app(); + update(&mut app, Action::InputChar('a')); + if let Screen::Login(s) = &app.screen { + assert_eq!(s.email, "a"); + assert_eq!(s.password, ""); + } else { panic!(); } + } + + #[test] + fn login_focus_next_moves_to_password() { + let mut app = login_app(); + update(&mut app, Action::FocusNext); + if let Screen::Login(s) = &app.screen { + assert_eq!(s.focused, LoginField::Password); + } else { panic!(); } + } + + #[test] + fn login_input_after_focus_goes_to_password() { + let mut app = login_app(); + update(&mut app, Action::FocusNext); + update(&mut app, Action::InputChar('x')); + if let Screen::Login(s) = &app.screen { + assert_eq!(s.password, "x"); + } else { panic!(); } + } + + #[test] + fn login_submit_returns_login_command_and_sets_loading() { + let mut app = login_app(); + for c in "user@example.com".chars() { update(&mut app, Action::InputChar(c)); } + update(&mut app, Action::FocusNext); + for c in "pass123".chars() { update(&mut app, Action::InputChar(c)); } + let cmds = update(&mut app, Action::LoginSubmit); + assert!(cmds.iter().any(|c| matches!(c, Command::Login { .. }))); + assert!(app.loading); + } + + #[test] + fn login_submit_with_empty_fields_sets_error_status() { + let mut app = login_app(); + let cmds = update(&mut app, Action::LoginSubmit); + assert!(cmds.is_empty()); + assert!(app.status.as_ref().map_or(false, |s| s.is_error)); + } + + #[test] + fn auth_ok_sets_token_and_transitions_to_main() { + let mut app = login_app(); + let cmds = update(&mut app, Action::AuthOk("jwt-token".into())); + assert_eq!(app.token, Some("jwt-token".into())); + assert!(matches!(app.screen, Screen::Main(_))); + assert!(!app.loading); + assert!(cmds.iter().any(|c| matches!(c, Command::SaveToken(_)))); + assert!(cmds.iter().any(|c| matches!(c, Command::LoadDiary { .. }))); + } + + #[test] + fn auth_fail_sets_error_status_and_clears_loading() { + let mut app = login_app(); + app.loading = true; + update(&mut app, Action::AuthFail("bad creds".into())); + assert!(!app.loading); + assert!(app.status.as_ref().map_or(false, |s| s.is_error)); + } + + // ── Diary ───────────────────────────────────────────────────────────────── + + #[test] + fn diary_scroll_down_increments_selected() { + let mut app = main_app(); + update(&mut app, Action::DiaryLoaded { + entries: vec![diary_entry(), diary_entry(), diary_entry()], + total: 3, + }); + update(&mut app, Action::ScrollDown); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.diary.selected, 1); + } else { panic!(); } + } + + #[test] + fn diary_scroll_up_clamps_at_zero() { + let mut app = main_app(); + update(&mut app, Action::DiaryLoaded { entries: vec![diary_entry()], total: 1 }); + update(&mut app, Action::ScrollUp); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.diary.selected, 0); + } else { panic!(); } + } + + #[test] + fn diary_scroll_down_clamps_at_last_entry() { + let mut app = main_app(); + update(&mut app, Action::DiaryLoaded { entries: vec![diary_entry()], total: 1 }); + update(&mut app, Action::ScrollDown); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.diary.selected, 0); + } else { panic!(); } + } + + #[test] + fn delete_init_sets_delete_pending() { + let mut app = main_app(); + let entry = diary_entry(); + let review_id = entry.review.id; + update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); + update(&mut app, Action::DeleteInit); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.diary.delete_pending, Some(review_id)); + } else { panic!(); } + } + + #[test] + fn delete_confirm_returns_delete_command() { + let mut app = main_app(); + let entry = diary_entry(); + let review_id = entry.review.id; + update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); + update(&mut app, Action::DeleteInit); + let cmds = update(&mut app, Action::DeleteConfirm); + assert!(cmds.iter().any(|c| matches!(c, Command::DeleteReview(id) if *id == review_id))); + } + + #[test] + fn delete_cancel_clears_pending() { + let mut app = main_app(); + let entry = diary_entry(); + update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); + update(&mut app, Action::DeleteInit); + update(&mut app, Action::DeleteCancel); + if let Screen::Main(m) = &app.screen { + assert!(m.diary.delete_pending.is_none()); + } else { panic!(); } + } + + #[test] + fn review_deleted_removes_entry_from_list() { + let mut app = main_app(); + let entry = diary_entry(); + let review_id = entry.review.id; + update(&mut app, Action::DiaryLoaded { entries: vec![entry], total: 1 }); + update(&mut app, Action::ReviewDeleted(review_id)); + if let Screen::Main(m) = &app.screen { + assert!(m.diary.entries.is_empty()); + assert_eq!(m.diary.total, 0); + } else { panic!(); } + } + + // ── Add Review ──────────────────────────────────────────────────────────── + + #[test] + fn rating_up_increments_rating() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.rating = 3; } + update(&mut app, Action::RatingUp); + if let Screen::Main(m) = &app.screen { assert_eq!(m.add_review.rating, 4); } + } + + #[test] + fn rating_clamps_at_5() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { m.tab = Tab::AddReview; m.add_review.rating = 5; } + update(&mut app, Action::RatingUp); + if let Screen::Main(m) = &app.screen { assert_eq!(m.add_review.rating, 5); } + } + + #[test] + fn review_submit_returns_create_review_command() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::AddReview; + m.add_review.title = "The Matrix".into(); + m.add_review.watched_at = "1999-03-31T00:00:00".into(); + m.add_review.rating = 5; + } + let cmds = update(&mut app, Action::ReviewSubmit); + assert!(cmds.iter().any(|c| matches!(c, Command::CreateReview(_)))); + } + + #[test] + fn review_submit_with_missing_title_and_id_sets_error() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::AddReview; + m.add_review.watched_at = "1999-03-31T00:00:00".into(); + } + let cmds = update(&mut app, Action::ReviewSubmit); + assert!(cmds.is_empty()); + assert!(app.status.as_ref().map_or(false, |s| s.is_error)); + } + + // ── Bulk Import ─────────────────────────────────────────────────────────── + + #[test] + fn bulk_import_all_with_valid_rows_returns_import_next_command() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::BulkImport; + m.bulk_import.stage = BulkImportStage::Preview; + m.bulk_import.parsed = vec![ + ParsedRow { + row: 2, + result: Ok(LogReviewRequest { + external_metadata_id: None, + manual_title: Some("The Matrix".into()), + manual_release_year: None, + rating: 5, + comment: None, + watched_at: "1999-03-31T00:00:00".into(), + }), + }, + ]; + } + let cmds = update(&mut app, Action::BulkImportAll); + assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(0)))); + } + + #[test] + fn bulk_item_done_advances_stage_and_returns_next_command() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::BulkImport; + m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; + m.bulk_import.valid_requests = vec![ + LogReviewRequest { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into() }, + LogReviewRequest { external_metadata_id: None, manual_title: Some("B".into()), manual_release_year: None, rating: 4, comment: None, watched_at: "2024-01-02T00:00:00".into() }, + ]; + m.bulk_import.results = vec![None, None]; + } + let cmds = update(&mut app, Action::BulkItemDone { index: 0, error: None }); + assert!(cmds.iter().any(|c| matches!(c, Command::ImportNext(1)))); + } + + #[test] + fn bulk_item_done_last_item_transitions_to_done() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::BulkImport; + m.bulk_import.stage = BulkImportStage::Importing { done: 0 }; + m.bulk_import.valid_requests = vec![ + LogReviewRequest { external_metadata_id: None, manual_title: Some("A".into()), manual_release_year: None, rating: 5, comment: None, watched_at: "2024-01-01T00:00:00".into() }, + ]; + m.bulk_import.results = vec![None]; + } + let cmds = update(&mut app, Action::BulkItemDone { index: 0, error: None }); + assert!(cmds.is_empty()); + if let Screen::Main(m) = &app.screen { + assert!(matches!(m.bulk_import.stage, BulkImportStage::Done)); + } + assert!(app.status.is_some()); + } + + // ── Settings ────────────────────────────────────────────────────────────── + + #[test] + fn settings_save_returns_save_config_command() { + let mut app = main_app(); + if let Screen::Main(m) = &mut app.screen { + m.tab = Tab::Settings; + m.settings.api_url = "http://new-server:8080".into(); + } + let cmds = update(&mut app, Action::SettingsSave); + assert!(cmds.iter().any(|c| matches!(c, Command::SaveConfig(url) if url.contains("8080")))); + } + + #[test] + fn settings_logout_clears_token_and_goes_to_login() { + let mut app = main_app(); + let cmds = update(&mut app, Action::SettingsLogout); + assert!(app.token.is_none()); + assert!(matches!(app.screen, Screen::Login(_))); + assert!(cmds.iter().any(|c| matches!(c, Command::ClearToken))); + } + + #[test] + fn auth_ok_uses_app_api_url_for_main_state() { + let mut app = login_app(); + app.api_url = "http://test-server:9000".into(); + update(&mut app, Action::AuthOk("tok".into())); + if let Screen::Main(m) = &app.screen { + assert_eq!(m.settings.api_url, "http://test-server:9000"); + } else { panic!("expected Main"); } + } + + // ── parse_csv ───────────────────────────────────────────────────────────── + + #[test] + fn parse_csv_valid_row_with_title() { + let csv = "title,year,external_id,rating,watched_at,comment\nThe Matrix,1999,,5,1999-03-31T00:00:00,\n"; + let rows = parse_csv(csv); + assert_eq!(rows.len(), 1); + assert!(rows[0].result.is_ok()); + let req = rows[0].result.as_ref().unwrap(); + assert_eq!(req.manual_title.as_deref(), Some("The Matrix")); + assert_eq!(req.rating, 5); + } + + #[test] + fn parse_csv_row_missing_title_and_id_is_error() { + let csv = "title,year,external_id,rating,watched_at,comment\n,,,5,2024-01-01T00:00:00,\n"; + let rows = parse_csv(csv); + assert_eq!(rows.len(), 1); + assert!(rows[0].result.is_err()); + } + + #[test] + fn parse_csv_invalid_rating_is_error() { + let csv = "title,year,external_id,rating,watched_at,comment\nThe Matrix,,,9,2024-01-01T00:00:00,\n"; + let rows = parse_csv(csv); + assert!(rows[0].result.is_err()); + } + + #[test] + fn parse_csv_with_external_id_only() { + let csv = "title,year,external_id,rating,watched_at,comment\n,,tt0133093,5,1999-03-31T00:00:00,\n"; + let rows = parse_csv(csv); + assert!(rows[0].result.is_ok()); + let req = rows[0].result.as_ref().unwrap(); + assert_eq!(req.external_metadata_id.as_deref(), Some("tt0133093")); + assert!(req.manual_title.is_none()); + } + + #[test] + fn parse_csv_rating_zero_is_valid() { + let csv = "title,year,external_id,rating,watched_at,comment\nThe Matrix,,,0,2024-01-01T00:00:00,\n"; + let rows = parse_csv(csv); + assert_eq!(rows.len(), 1); + assert!(rows[0].result.is_ok()); + let req = rows[0].result.as_ref().unwrap(); + assert_eq!(req.rating, 0); + } +} diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs new file mode 100644 index 0000000..375c2f7 --- /dev/null +++ b/crates/tui/src/client.rs @@ -0,0 +1,231 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// ── DTOs (mirror backend dtos.rs exactly) ──────────────────────────────────── + +#[derive(Debug, Clone, Serialize)] +pub struct LoginRequest { + pub email: String, + pub password: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct LoginResponse { + pub token: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogReviewRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub external_metadata_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub manual_title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub manual_release_year: Option, + pub rating: u8, + #[serde(skip_serializing_if = "Option::is_none")] + pub comment: Option, + pub watched_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DiaryResponse { + pub items: Vec, + pub total_count: u64, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct DiaryEntryDto { + pub movie: MovieDto, + pub review: ReviewDto, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct MovieDto { + pub id: Uuid, + pub title: String, + pub release_year: u16, + pub director: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ReviewDto { + pub id: Uuid, + pub rating: u8, + pub comment: Option, + pub watched_at: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct ReviewHistoryResponse { + pub movie: MovieDto, + pub viewings: Vec, + pub trend: String, +} + +// ── Error ───────────────────────────────────────────────────────────────────── + +#[derive(Debug, thiserror::Error)] +pub enum ApiError { + #[error("network error: {0}")] + Network(#[from] reqwest::Error), + #[error("unauthorized")] + Unauthorized, + #[error("not found")] + NotFound, + #[error("forbidden")] + Forbidden, + #[error("validation error: {0}")] + Validation(String), + #[error("server error {status}: {body}")] + Unknown { status: u16, body: String }, +} + +async fn check_status(resp: reqwest::Response) -> Result { + let status = resp.status(); + if status.is_success() { + return Ok(resp); + } + let body = resp.text().await.map_err(ApiError::Network)?; + Err(match status.as_u16() { + 401 => ApiError::Unauthorized, + 403 => ApiError::Forbidden, + 404 => ApiError::NotFound, + 400 => ApiError::Validation(body), + code => ApiError::Unknown { status: code, body }, + }) +} + +// ── Client ──────────────────────────────────────────────────────────────────── + +pub struct ApiClient { + base_url: std::sync::RwLock, + http: reqwest::Client, +} + +impl ApiClient { + pub fn new(url: &str) -> Self { + Self { + base_url: std::sync::RwLock::new(url.to_string()), + http: reqwest::Client::new(), + } + } + + pub fn update_url(&self, url: &str) { + *self.base_url.write().unwrap() = url.to_string(); + } + + fn url(&self) -> String { + self.base_url.read().unwrap().clone() + } + + pub async fn login(&self, email: &str, password: &str) -> Result { + let resp = self + .http + .post(format!("{}/api/auth/login", self.url())) + .json(&LoginRequest { email: email.into(), password: password.into() }) + .send() + .await?; + Ok(check_status(resp).await?.json().await?) + } + + pub async fn get_diary( + &self, + token: &str, + offset: u32, + limit: u32, + ) -> Result { + let resp = self + .http + .get(format!("{}/api/diary", self.url())) + .query(&[("offset", offset), ("limit", limit)]) + .bearer_auth(token) + .send() + .await?; + Ok(check_status(resp).await?.json().await?) + } + + pub async fn get_movie_history( + &self, + token: &str, + movie_id: Uuid, + ) -> Result { + let resp = self + .http + .get(format!("{}/api/movies/{}/history", self.url(), movie_id)) + .bearer_auth(token) + .send() + .await?; + Ok(check_status(resp).await?.json().await?) + } + + pub async fn create_review( + &self, + token: &str, + req: &LogReviewRequest, + ) -> Result<(), ApiError> { + let resp = self + .http + .post(format!("{}/api/reviews", self.url())) + .bearer_auth(token) + .json(req) + .send() + .await?; + check_status(resp).await?; + Ok(()) + } + + pub async fn delete_review(&self, token: &str, review_id: Uuid) -> Result<(), ApiError> { + let resp = self + .http + .delete(format!("{}/api/reviews/{}", self.url(), review_id)) + .bearer_auth(token) + .send() + .await?; + check_status(resp).await?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apierror_unauthorized_display() { + let err = ApiError::Unauthorized; + assert!(matches!(err, ApiError::Unauthorized)); + assert_eq!(err.to_string(), "unauthorized"); + } + + #[test] + fn apierror_validation_display() { + let err = ApiError::Validation("rating must be 0-5".into()); + assert!(err.to_string().contains("validation error")); + } + + #[test] + fn log_review_request_skips_none_fields() { + let req = LogReviewRequest { + external_metadata_id: None, + manual_title: Some("The Matrix".into()), + manual_release_year: None, + rating: 5, + comment: None, + watched_at: "2024-01-15T20:00:00".into(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(!json.contains("external_metadata_id")); + assert!(!json.contains("manual_release_year")); + assert!(json.contains("\"manual_title\":\"The Matrix\"")); + assert!(json.contains("\"rating\":5")); + } + + #[test] + fn api_client_update_url() { + let client = ApiClient::new("http://localhost:3000"); + assert!(client.url().contains("3000")); + client.update_url("http://localhost:8080"); + assert!(client.url().contains("8080")); + } +} diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs new file mode 100644 index 0000000..8c01a02 --- /dev/null +++ b/crates/tui/src/config.rs @@ -0,0 +1,70 @@ +use anyhow::Result; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub api_url: String, +} + +const KEYRING_SERVICE: &str = "movie-tui"; +const KEYRING_USER: &str = "jwt-token"; + +fn config_path() -> Option { + ProjectDirs::from("com", "movies", "movie-tui") + .map(|dirs| dirs.config_dir().join("config.json")) +} + +impl Config { + pub fn load() -> Option { + let path = config_path()?; + let content = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&content).ok() + } + + pub fn save(&self) -> Result<()> { + let path = config_path().ok_or_else(|| anyhow::anyhow!("no config dir"))?; + std::fs::create_dir_all(path.parent().unwrap())?; + let content = serde_json::to_string_pretty(self)?; + std::fs::write(path, content)?; + Ok(()) + } + + pub fn load_token() -> Option { + keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) + .ok() + .and_then(|e| e.get_password().ok()) + } + + pub fn save_token(token: &str) -> Result<()> { + let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)?; + entry.set_password(token)?; + Ok(()) + } + + pub fn clear_token() -> Result<()> { + let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)?; + let _ = entry.delete_credential(); // ignore NotFound + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_roundtrip() { + let config = Config { api_url: "http://localhost:3000".into() }; + let json = serde_json::to_string(&config).unwrap(); + let decoded: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.api_url, "http://localhost:3000"); + } + + #[test] + fn load_returns_none_when_no_file() { + // Tests that load() doesn't panic — may return Some or None depending on system state + let _ = Config::load(); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs new file mode 100644 index 0000000..fcf5f50 --- /dev/null +++ b/crates/tui/src/lib.rs @@ -0,0 +1,4 @@ +pub mod app; +pub mod client; +pub mod config; +pub mod ui; diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs new file mode 100644 index 0000000..fa691d8 --- /dev/null +++ b/crates/tui/src/main.rs @@ -0,0 +1,304 @@ +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::mpsc; + +use ratatui::crossterm::event::{self, Event, KeyCode, KeyModifiers}; + +use tui::app::{ + self, Action, App, BulkImportStage, Command, Screen, SettingsField, Tab, +}; +use tui::client::ApiClient; +use tui::config::Config; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let mut config = Config::load(); + + // env var override + if let Ok(url) = std::env::var("MOVIES_API_URL") { + match &mut config { + Some(c) => c.api_url = url, + None => config = Some(Config { api_url: url }), + } + } + + let initial_url = config.as_ref().map(|c| c.api_url.as_str()).unwrap_or("http://localhost:3000"); + let client = Arc::new(ApiClient::new(initial_url)); + let saved_token = Config::load_token(); + let mut app = App::new(config, saved_token.clone()); + + let (tx, mut rx) = mpsc::channel::(64); + let mut terminal = ratatui::init(); + + // If we start directly in Main (saved token), trigger an initial diary load + if matches!(app.screen, Screen::Main(_)) { + if let Some(token) = &saved_token { + let c = client.clone(); + let t = token.clone(); + let tx2 = tx.clone(); + tokio::spawn(async move { + let action = match c.get_diary(&t, 0, 20).await { + Ok(r) => Action::DiaryLoaded { entries: r.items, total: r.total_count }, + Err(e) => Action::DiaryLoadFailed(e.to_string()), + }; + let _ = tx2.send(action).await; + }); + } + } + + let result = async { + loop { + terminal.draw(|f| tui::ui::render(f, &app))?; + + // Poll keyboard — non-blocking with short timeout + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + if key.kind != ratatui::crossterm::event::KeyEventKind::Press { + continue; + } + if let Some(action) = key_to_action(&app, key) { + if matches!(action, Action::Quit) { + break; + } + let cmds = app::update(&mut app, action); + for cmd in cmds { + handle_command(cmd, &app, &client, &tx); + } + } + } + } + + // Drain async results + while let Ok(action) = rx.try_recv() { + let cmds = app::update(&mut app, action); + for cmd in cmds { + handle_command(cmd, &app, &client, &tx); + } + } + } + Ok::<(), anyhow::Error>(()) + }.await; + + ratatui::restore(); + result +} + +// ── Command executor ────────────────────────────────────────────────────────── + +fn handle_command(cmd: Command, app: &App, client: &Arc, tx: &mpsc::Sender) { + match cmd { + Command::SaveConfig(url) => { + let config = Config { api_url: url.clone() }; + if let Err(e) = config.save() { + let tx2 = tx.clone(); + let msg = format!("Failed to save config: {e}"); + tokio::spawn(async move { let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; }); + } + client.update_url(&url); + } + + Command::SaveToken(token) => { + if let Err(e) = Config::save_token(&token) { + let tx2 = tx.clone(); + let msg = format!("Token not saved to keychain: {e}"); + tokio::spawn(async move { let _ = tx2.send(Action::DiaryLoadFailed(msg)).await; }); + } + } + + Command::ClearToken => { + let _ = Config::clear_token(); // ignore NotFound errors on logout + } + + Command::Login { email, password } => { + let c = client.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let action = match c.login(&email, &password).await { + Ok(r) => Action::AuthOk(r.token), + Err(e) => Action::AuthFail(e.to_string()), + }; + let _ = tx.send(action).await; + }); + } + + Command::LoadDiary { offset } => { + let token = match &app.token { + Some(t) => t.clone(), + None => return, + }; + let c = client.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let action = match c.get_diary(&token, offset, 20).await { + Ok(r) => Action::DiaryLoaded { entries: r.items, total: r.total_count }, + Err(e) => Action::DiaryLoadFailed(e.to_string()), + }; + let _ = tx.send(action).await; + }); + } + + Command::LoadHistory { movie_id } => { + let token = match &app.token { + Some(t) => t.clone(), + None => return, + }; + let c = client.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let action = match c.get_movie_history(&token, movie_id).await { + Ok(r) => Action::HistoryLoaded(r), + Err(e) => Action::HistoryLoadFailed(e.to_string()), + }; + let _ = tx.send(action).await; + }); + } + + Command::CreateReview(req) => { + let token = match &app.token { + Some(t) => t.clone(), + None => return, + }; + let c = client.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let action = match c.create_review(&token, &req).await { + Ok(()) => Action::ReviewCreated, + Err(e) => Action::ReviewCreateFailed(e.to_string()), + }; + let _ = tx.send(action).await; + }); + } + + Command::DeleteReview(id) => { + let token = match &app.token { + Some(t) => t.clone(), + None => return, + }; + let c = client.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let action = match c.delete_review(&token, id).await { + Ok(()) => Action::ReviewDeleted(id), + Err(e) => Action::ReviewDeleteFailed(e.to_string()), + }; + let _ = tx.send(action).await; + }); + } + + Command::ImportNext(index) => { + let token = match &app.token { + Some(t) => t.clone(), + None => return, + }; + let req = match &app.screen { + Screen::Main(m) => match m.bulk_import.valid_requests.get(index) { + Some(r) => r.clone(), + None => return, + }, + _ => return, + }; + let c = client.clone(); + let tx = tx.clone(); + tokio::spawn(async move { + let error = c.create_review(&token, &req).await.err().map(|e| e.to_string()); + let _ = tx.send(Action::BulkItemDone { index, error }).await; + }); + } + } +} + +// ── Key → Action ────────────────────────────────────────────────────────────── + +fn key_to_action(app: &App, key: ratatui::crossterm::event::KeyEvent) -> Option { + // Ctrl+C always quits + if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') { + return Some(Action::Quit); + } + + match &app.screen { + Screen::Setup(_) => match key.code { + KeyCode::Char(c) => Some(Action::InputChar(c)), + KeyCode::Backspace => Some(Action::Backspace), + KeyCode::Enter => Some(Action::SetupSubmit), + KeyCode::Esc => Some(Action::Escape), + _ => None, + }, + + Screen::Login(_) => match key.code { + KeyCode::Char(c) => Some(Action::InputChar(c)), + KeyCode::Backspace => Some(Action::Backspace), + KeyCode::Tab => Some(Action::FocusNext), + KeyCode::BackTab => Some(Action::FocusPrev), + KeyCode::Enter => Some(Action::LoginSubmit), + _ => None, + }, + + Screen::Main(m) => match m.tab { + Tab::Diary => match key.code { + KeyCode::Up | KeyCode::Char('k') => Some(Action::ScrollUp), + KeyCode::Down | KeyCode::Char('j') => Some(Action::ScrollDown), + KeyCode::Enter => Some(Action::OpenHistory), + KeyCode::Char('d') => Some(Action::DeleteInit), + KeyCode::Char('y') if m.diary.delete_pending.is_some() => Some(Action::DeleteConfirm), + KeyCode::Char('n') if m.diary.delete_pending.is_some() => Some(Action::DeleteCancel), + KeyCode::Esc => Some(Action::Escape), + KeyCode::Char('q') => Some(Action::Quit), + KeyCode::Tab => Some(Action::TabNext), + KeyCode::BackTab => Some(Action::TabPrev), + KeyCode::Char('>') | KeyCode::Char('m') => Some(Action::LoadMore), + KeyCode::Char('1') => Some(Action::TabSelect(Tab::Diary)), + KeyCode::Char('2') => Some(Action::TabSelect(Tab::AddReview)), + KeyCode::Char('3') => Some(Action::TabSelect(Tab::BulkImport)), + KeyCode::Char('4') => Some(Action::TabSelect(Tab::Settings)), + _ => None, + }, + + Tab::AddReview => match key.code { + KeyCode::Char(c) => Some(Action::InputChar(c)), + KeyCode::Backspace => Some(Action::Backspace), + KeyCode::Tab => Some(Action::FocusNext), + KeyCode::BackTab => Some(Action::FocusPrev), + KeyCode::Left => Some(Action::RatingDown), + KeyCode::Right => Some(Action::RatingUp), + KeyCode::Enter => Some(Action::ReviewSubmit), + KeyCode::Esc => Some(Action::Escape), + _ => None, + }, + + Tab::BulkImport => { + let in_path = m.bulk_import.stage == BulkImportStage::EnterPath; + match key.code { + KeyCode::Char(c) if in_path => Some(Action::InputChar(c)), + KeyCode::Backspace if in_path => Some(Action::Backspace), + KeyCode::Enter => match m.bulk_import.stage { + BulkImportStage::EnterPath => Some(Action::BulkParseFile), + BulkImportStage::Preview => Some(Action::BulkImportAll), + _ => None, + }, + KeyCode::Esc => Some(Action::BulkCancel), + KeyCode::Tab if !in_path => Some(Action::TabNext), + KeyCode::BackTab if !in_path => Some(Action::TabPrev), + KeyCode::Char('q') if !in_path => Some(Action::Quit), + _ => None, + } + } + + Tab::Settings => { + let on_url = m.settings.focused == SettingsField::ApiUrl; + match key.code { + KeyCode::Char(c) if on_url => Some(Action::InputChar(c)), + KeyCode::Backspace if on_url => Some(Action::Backspace), + KeyCode::Tab => Some(Action::FocusNext), + KeyCode::BackTab => Some(Action::FocusPrev), + KeyCode::Enter => match m.settings.focused { + SettingsField::Save | SettingsField::ApiUrl => Some(Action::SettingsSave), + SettingsField::Logout => Some(Action::SettingsLogout), + }, + KeyCode::Esc => Some(Action::Escape), + KeyCode::Char('q') => Some(Action::Quit), + _ => None, + } + } + }, + } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs new file mode 100644 index 0000000..d98d04e --- /dev/null +++ b/crates/tui/src/ui.rs @@ -0,0 +1,712 @@ +use crate::app::{ + AddReviewField, AddReviewState, App, BulkImportStage, BulkImportState, DiaryState, LoginField, + LoginState, Screen, SettingsField, SettingsState, SetupState, StatusMsg, Tab, +}; +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph, Wrap}, +}; + +const APP_TITLE: &str = "Movies diary manager"; + +pub fn render(frame: &mut Frame, app: &App) { + match &app.screen { + Screen::Setup(s) => draw_setup(frame, frame.area(), s), + Screen::Login(s) => draw_login(frame, frame.area(), s), + Screen::Main(m) => { + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Fill(1), + Constraint::Length(1), + ]) + .split(frame.area()); + + draw_tab_bar(frame, rows[0], m.tab); + + match m.tab { + Tab::Diary => draw_diary(frame, rows[1], &m.diary), + Tab::AddReview => draw_add_review(frame, rows[1], &m.add_review), + Tab::BulkImport => draw_bulk_import(frame, rows[1], &m.bulk_import), + Tab::Settings => draw_settings(frame, rows[1], &m.settings), + } + + draw_status_bar(frame, rows[2], app.status.as_ref(), app.loading); + } + } +} + +// ── Setup ───────────────────────────────────────────────────────────────────── + +fn draw_setup(frame: &mut Frame, area: Rect, state: &SetupState) { + let popup = centered_rect(60, 14, area); + let block = Block::default() + .title(format!(" {APP_TITLE} — Setup ")) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + frame.render_widget(block, popup); + + let inner = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .margin(1) + .split(popup); + + frame.render_widget( + Paragraph::new("Enter the API server URL to continue.").alignment(Alignment::Center), + inner[1], + ); + + let url_display = format!("{}_", state.api_url); + let url_widget = Paragraph::new(url_display).block( + Block::default() + .title("API URL") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)), + ); + frame.render_widget(url_widget, inner[2]); + + if let Some(err) = &state.error { + frame.render_widget( + Paragraph::new(Span::styled(err.as_str(), Style::default().fg(Color::Red))), + inner[3], + ); + } + + frame.render_widget( + Paragraph::new("Enter to save and continue").alignment(Alignment::Center), + inner[4], + ); +} + +// ── Login ───────────────────────────────────────────────────────────────────── + +fn draw_login(frame: &mut Frame, area: Rect, state: &LoginState) { + let popup = centered_rect(60, 16, area); + let block = Block::default() + .title(format!(" {APP_TITLE} — Login ")) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)); + frame.render_widget(block, popup); + + let rows = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(3), + Constraint::Fill(1), + ]) + .margin(1) + .split(popup); + + let email_style = if state.focused == LoginField::Email { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let pass_style = if state.focused == LoginField::Password { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + + let email_display = if state.focused == LoginField::Email { + format!("{}_", state.email) + } else { + state.email.clone() + }; + let pass_display = if state.focused == LoginField::Password { + format!("{}_", "*".repeat(state.password.len())) + } else { + "*".repeat(state.password.len()) + }; + frame.render_widget( + Paragraph::new(email_display).block( + Block::default() + .title("Email") + .borders(Borders::ALL) + .border_style(email_style), + ), + rows[1], + ); + frame.render_widget( + Paragraph::new(pass_display).block( + Block::default() + .title("Password") + .borders(Borders::ALL) + .border_style(pass_style), + ), + rows[3], + ); + frame.render_widget( + Paragraph::new("Tab: next field Enter: login").alignment(Alignment::Center), + rows[4], + ); +} + +// ── Tab bar ─────────────────────────────────────────────────────────────────── + +fn draw_tab_bar(frame: &mut Frame, area: Rect, active: Tab) { + let tabs = [ + (Tab::Diary, "1: Diary"), + (Tab::AddReview, "2: Add Review"), + (Tab::BulkImport, "3: Bulk Import"), + (Tab::Settings, "4: Settings"), + ]; + + let spans: Vec = tabs + .iter() + .enumerate() + .flat_map(|(i, (tab, label))| { + let style = if *tab == active { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD | Modifier::UNDERLINED) + } else { + Style::default().fg(Color::DarkGray) + }; + let sep = if i < tabs.len() - 1 { " │ " } else { "" }; + vec![Span::styled(format!(" {label} "), style), Span::raw(sep)] + }) + .collect(); + + let tab_line = Paragraph::new(Line::from(spans)) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)), + ) + .alignment(Alignment::Left); + + frame.render_widget(tab_line, area); +} + +// ── Diary ───────────────────────────────────────────────────────────────────── + +fn draw_diary(frame: &mut Frame, area: Rect, state: &DiaryState) { + let cols = + Layout::horizontal([Constraint::Percentage(60), Constraint::Percentage(40)]).split(area); + + // Left: entry list + let items: Vec = state + .entries + .iter() + .enumerate() + .map(|(i, e)| { + let stars_str = stars(e.review.rating); + let watched = &e.review.watched_at[..10.min(e.review.watched_at.len())]; + let title = truncate(&e.movie.title, 24); + let line = format!("{watched} {title:<24} {stars_str}"); + let style = if i == state.selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(line).style(style) + }) + .collect(); + + let can_load_more = (state.offset as u64 + state.entries.len() as u64) < state.total; + let list_title = if can_load_more { + format!(" Diary ({} entries) [m: load more] ", state.total) + } else { + format!(" Diary ({} entries) ", state.total) + }; + let mut list_state = ListState::default(); + list_state.select(Some(state.selected)); + let list = List::new(items).block(Block::default().title(list_title).borders(Borders::ALL)); + frame.render_stateful_widget(list, cols[0], &mut list_state); + + // Delete confirmation overlay + if state.delete_pending.is_some() { + let confirm = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + "Delete this review?", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(" y: confirm n/Esc: cancel"), + ]) + .block( + Block::default() + .title(" Confirm Delete ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Red)), + ) + .alignment(Alignment::Center); + let overlay = centered_rect(40, 8, cols[0]); + frame.render_widget(ratatui::widgets::Clear, overlay); + frame.render_widget(confirm, overlay); + } + + // Right: history panel + let history_block = Block::default() + .title(" Movie History ") + .borders(Borders::ALL); + match &state.history { + None => { + let hint = Paragraph::new(vec![ + Line::from(""), + Line::from("Select an entry and"), + Line::from("press Enter to view"), + Line::from("movie history."), + ]) + .block(history_block) + .alignment(Alignment::Center); + frame.render_widget(hint, cols[1]); + } + Some(h) => { + let mut lines = vec![ + Line::from(Span::styled( + format!("{} ({})", h.movie.title, h.movie.release_year), + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from("─".repeat(cols[1].width.saturating_sub(2) as usize)), + ]; + for v in &h.viewings { + let watched = &v.watched_at[..10.min(v.watched_at.len())]; + lines.push(Line::from(format!("{watched} {}", stars(v.rating)))); + if let Some(c) = &v.comment { + lines.push(Line::from(Span::styled( + format!(" {}", truncate(c, 30)), + Style::default().fg(Color::DarkGray), + ))); + } + } + lines.push(Line::from("")); + lines.push(Line::from(format!("Trend: {}", h.trend))); + + frame.render_widget( + Paragraph::new(lines) + .block(history_block) + .wrap(Wrap { trim: true }), + cols[1], + ); + } + } +} + +// ── Add Review ──────────────────────────────────────────────────────────────── + +fn draw_add_review(frame: &mut Frame, area: Rect, state: &AddReviewState) { + let block = Block::default().title(" Add Review ").borders(Borders::ALL); + let inner = block.inner(area); + frame.render_widget(block, area); + + // rows[0]=ExternalId [1]=Title [2]=Year [3]=Rating [4]=WatchedAt [5]=Comment [6]=Submit [7]=hint + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(3), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .split(inner); + + let fs = |f: AddReviewField| { + if state.focused == f { + Style::default().fg(Color::Yellow) + } else { + Style::default() + } + }; + let ft = |s: &str, f: AddReviewField| { + if state.focused == f { + format!("{s}_") + } else { + s.to_string() + } + }; + + frame.render_widget( + Paragraph::new(ft(&state.external_id, AddReviewField::ExternalId)).block( + Block::default() + .title("External ID (TMDB/OMDB)") + .borders(Borders::ALL) + .border_style(fs(AddReviewField::ExternalId)), + ), + rows[0], + ); + frame.render_widget( + Paragraph::new(ft(&state.title, AddReviewField::Title)).block( + Block::default() + .title("Title") + .borders(Borders::ALL) + .border_style(fs(AddReviewField::Title)), + ), + rows[1], + ); + frame.render_widget( + Paragraph::new(ft(&state.year, AddReviewField::Year)).block( + Block::default() + .title("Year") + .borders(Borders::ALL) + .border_style(fs(AddReviewField::Year)), + ), + rows[2], + ); + frame.render_widget( + Paragraph::new(format!( + "{} \u{2190} \u{2192} to adjust", + stars(state.rating) + )) + .block( + Block::default() + .title("Rating (0-5)") + .borders(Borders::ALL) + .border_style(fs(AddReviewField::Rating)), + ), + rows[3], + ); + frame.render_widget( + Paragraph::new(ft(&state.watched_at, AddReviewField::WatchedAt)).block( + Block::default() + .title("Watched at (YYYY-MM-DDTHH:MM:SS)") + .borders(Borders::ALL) + .border_style(fs(AddReviewField::WatchedAt)), + ), + rows[4], + ); + frame.render_widget( + Paragraph::new(ft(&state.comment, AddReviewField::Comment)).block( + Block::default() + .title("Comment (optional)") + .borders(Borders::ALL) + .border_style(fs(AddReviewField::Comment)), + ), + rows[5], + ); + + let submit_style = if state.focused == AddReviewField::Submit { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + frame.render_widget( + Paragraph::new("[ Submit ]") + .style(submit_style) + .alignment(Alignment::Center), + rows[6], + ); + frame.render_widget( + Paragraph::new("Tab: next field \u{2190}\u{2192}: rating Enter: submit") + .style(Style::default().fg(Color::DarkGray)), + rows[7], + ); +} + +// ── Bulk Import ─────────────────────────────────────────────────────────────── + +fn draw_bulk_import(frame: &mut Frame, area: Rect, state: &BulkImportState) { + let block = Block::default() + .title(" Bulk Import ") + .borders(Borders::ALL); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).split(inner); + + // File path field (always visible) + let path_style = if state.stage == BulkImportStage::EnterPath { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + let path_display = if state.stage == BulkImportStage::EnterPath { + format!("{}_", state.file_path) + } else { + state.file_path.clone() + }; + frame.render_widget( + Paragraph::new(path_display).block( + Block::default() + .title("File path (CSV)") + .borders(Borders::ALL) + .border_style(path_style), + ), + rows[0], + ); + + match &state.stage { + BulkImportStage::EnterPath => { + frame.render_widget( + Paragraph::new("Enter to parse the file.").alignment(Alignment::Center), + rows[1], + ); + } + + BulkImportStage::Preview => { + let valid = state.parsed.iter().filter(|r| r.result.is_ok()).count(); + let errors = state.parsed.iter().filter(|r| r.result.is_err()).count(); + let summary = format!("{valid} reviews ready, {errors} errors"); + + let mut lines: Vec = vec![ + Line::from(Span::styled(summary, Style::default().fg(Color::Green))), + Line::from(""), + ]; + for row in &state.parsed { + let (icon, text) = match &row.result { + Ok(r) => ( + "\u{2713}", + format!( + "Row {}: {} \u{2014} rating {}", + row.row, + r.manual_title + .as_deref() + .or(r.external_metadata_id.as_deref()) + .unwrap_or("?"), + r.rating + ), + ), + Err(e) => ("\u{2717}", format!("Row {}: {}", row.row, e)), + }; + let style = if row.result.is_ok() { + Style::default() + } else { + Style::default().fg(Color::Red) + }; + lines.push(Line::from(vec![ + Span::styled(format!("{icon} "), style), + Span::raw(text), + ])); + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Enter: import all Esc: back", + Style::default().fg(Color::DarkGray), + ))); + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), rows[1]); + } + + BulkImportStage::Importing { done } => { + let total = state.valid_requests.len(); + let ratio = if total > 0 { + (*done as f64 / total as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + + let gauge_area = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(3), + Constraint::Fill(1), + ]) + .split(rows[1]); + frame.render_widget( + Paragraph::new(format!("Importing... {done} / {total}")) + .alignment(Alignment::Center), + gauge_area[0], + ); + frame.render_widget( + Gauge::default() + .gauge_style(Style::default().fg(Color::Green)) + .ratio(ratio), + gauge_area[1], + ); + + let results: Vec = state + .results + .iter() + .enumerate() + .take(*done) + .map(|(i, r)| { + let title = state + .valid_requests + .get(i) + .and_then(|r| { + r.manual_title + .as_deref() + .or(r.external_metadata_id.as_deref()) + }) + .unwrap_or("?"); + match r { + None => Line::from(Span::styled( + format!("\u{2713} {title}"), + Style::default().fg(Color::Green), + )), + Some(e) => Line::from(Span::styled( + format!("\u{2717} {title}: {e}"), + Style::default().fg(Color::Red), + )), + } + }) + .collect(); + frame.render_widget( + Paragraph::new(results).wrap(Wrap { trim: true }), + gauge_area[2], + ); + } + + BulkImportStage::Done => { + let failed = state.results.iter().filter(|r| r.is_some()).count(); + let total = state.results.len(); + let summary = format!("Done! {} succeeded, {} failed.", total - failed, failed); + let style = if failed > 0 { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Green) + }; + + let mut lines = vec![Line::from(Span::styled(summary, style)), Line::from("")]; + for (i, r) in state.results.iter().enumerate() { + if let Some(err) = r { + let title = state + .valid_requests + .get(i) + .and_then(|r| { + r.manual_title + .as_deref() + .or(r.external_metadata_id.as_deref()) + }) + .unwrap_or("?"); + lines.push(Line::from(Span::styled( + format!("\u{2717} {title}: {err}"), + Style::default().fg(Color::Red), + ))); + } + } + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Esc: start over", + Style::default().fg(Color::DarkGray), + ))); + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: true }), rows[1]); + } + } +} + +// ── Settings ────────────────────────────────────────────────────────────────── + +fn draw_settings(frame: &mut Frame, area: Rect, state: &SettingsState) { + let block = Block::default().title(" Settings ").borders(Borders::ALL); + let inner = block.inner(area); + frame.render_widget(block, area); + + let rows = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Fill(1), + ]) + .split(inner); + + let url_style = if state.focused == SettingsField::ApiUrl { + Style::default().fg(Color::Yellow) + } else { + Style::default() + }; + let url_display = if state.focused == SettingsField::ApiUrl { + format!("{}_", state.api_url) + } else { + state.api_url.clone() + }; + frame.render_widget( + Paragraph::new(url_display).block( + Block::default() + .title("API URL") + .borders(Borders::ALL) + .border_style(url_style), + ), + rows[0], + ); + + let save_style = if state.focused == SettingsField::Save { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + let logout_style = if state.focused == SettingsField::Logout { + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::DarkGray) + }; + + let buttons = Line::from(vec![ + Span::styled("[ Save ]", save_style), + Span::raw(" "), + Span::styled("[ Logout ]", logout_style), + ]); + frame.render_widget( + Paragraph::new(buttons).alignment(Alignment::Center), + rows[1], + ); + frame.render_widget( + Paragraph::new("Tab: next Enter: activate").style(Style::default().fg(Color::DarkGray)), + rows[2], + ); +} + +// ── Status bar ──────────────────────────────────────────────────────────────── + +fn draw_status_bar(frame: &mut Frame, area: Rect, status: Option<&StatusMsg>, loading: bool) { + let (text, color) = if loading { + ("Loading...", Color::Yellow) + } else { + match status { + None => ("q: quit Tab: next tab", Color::DarkGray), + Some(s) if s.is_error => (s.text.as_str(), Color::Red), + Some(s) => (s.text.as_str(), Color::Green), + } + }; + frame.render_widget(Paragraph::new(text).style(Style::default().fg(color)), area); +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn stars(rating: u8) -> String { + format!( + "{}{}", + "\u{2605}".repeat(rating as usize), + "\u{2606}".repeat(5usize.saturating_sub(rating as usize)) + ) +} + +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + s.to_string() + } else { + format!( + "{}\u{2026}", + &s[..s + .char_indices() + .nth(max - 1) + .map(|(i, _)| i) + .unwrap_or(s.len())] + ) + } +} + +fn centered_rect(width_pct: u16, height: u16, area: Rect) -> Rect { + let v_chunks = Layout::vertical([ + Constraint::Fill(1), + Constraint::Length(height.min(area.height)), + Constraint::Fill(1), + ]) + .split(area); + + let h_chunks = Layout::horizontal([ + Constraint::Percentage((100 - width_pct) / 2), + Constraint::Percentage(width_pct), + Constraint::Percentage((100 - width_pct) / 2), + ]) + .split(v_chunks[1]); + + h_chunks[1] +}