From 92c3d3be5dd038f4c8a9e518fa16328c82c45828 Mon Sep 17 00:00:00 2001 From: andwati Date: Wed, 25 Mar 2026 15:20:16 +0300 Subject: [PATCH] yolo --- Cargo.lock | 1629 +++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 24 +- README.md | 142 +++++ src/app.rs | 258 ++++++++ src/config.rs | 79 +++ src/ftp.rs | 604 ++++++++++++++++++ src/main.rs | 130 +++- src/ui.rs | 419 +++++++++++++ 8 files changed, 3282 insertions(+), 3 deletions(-) create mode 100644 README.md create mode 100644 src/app.rs create mode 100644 src/config.rs create mode 100644 src/ftp.rs create mode 100644 src/ui.rs diff --git a/Cargo.lock b/Cargo.lock index b539a4a..acff21b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,1635 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "compact_str" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b79c4069c6cad78e2e0cdfcbd26275770669fb39fd308a752dc110e83b9af32" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[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", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "ftsync" version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "crossterm", + "dirs", + "glob", + "ratatui", + "serde", + "suppaftp", + "tokio", + "toml", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "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 = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy-regex" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" +dependencies = [ + "lazy-regex-proc_macros", + "once_cell", + "regex", +] + +[[package]] +name = "lazy-regex-proc_macros" +version = "3.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libredox" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "951c002c75e16ea2c65b8c7e4d3d51d5530d8dfa7d060b4776828c88cfb18ecf" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.112" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d55af3b3e226502be1526dfdba67ab0e9c96fc293004e79576b2b9edb0dbdb" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[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]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "suppaftp" +version = "6.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d869e942cc5f349ad91645925a9e6b570f62c4c170ad1c7b92b867bd16bd54" +dependencies = [ + "chrono", + "futures-lite", + "lazy-regex", + "log", + "native-tls", + "thiserror 2.0.18", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[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 1.1.4", + "windows-sys 0.61.2", +] + +[[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 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", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da36089a805484bcccfffe0739803392c8298778a2d2f09febf76fac5ad9025b" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[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-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 0433a7d..12c6e73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,28 @@ [package] name = "ftsync" version = "0.1.0" -edition = "2024" +edition = "2021" +description = "FTP sync TUI — local directory is authoritative" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +ratatui = "0.29" +crossterm = "0.28" +suppaftp = { version = "6.0", features = ["native-tls"] } +tokio = { version = "1", features = ["full"] } +serde = { version = "1", features = ["derive"] } +toml = "0.8" +chrono = "0.4" +dirs = "5" +glob = "0.3" +anyhow = "1" + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 +strip = true + +[profile.dev] +opt-level = 1 \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4ce7bb --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# ftsync + +A terminal-based FTP sync tool built with Ratatui. Local directory is always +authoritative — new and modified files are uploaded to the server. + +--- + +## Requirements + +- Rust 1.80+ (install via [rustup](https://rustup.rs)) +- OpenSSL development headers (for FTPS/TLS support) + +```bash +# Debian / Ubuntu +sudo apt install libssl-dev pkg-config + +# Arch +sudo pacman -S openssl + +# Fedora +sudo dnf install openssl-devel +``` + +--- + +## Build + +```bash +cargo build --release +# Binary: ./target/release/ftsync +``` + +Optionally install system-wide: +```bash +cargo install --path . +``` + +--- + +## Running + +### Option A — environment variables (credentials never written to disk) + +```bash +FTP_HOST=ftp.example.com \ +FTP_USER=ian \ +FTP_PASS=secret \ +FTP_LOCAL=/home/ian/dist \ +FTP_REMOTE=/public_html \ +./target/release/ftsync +``` + +Full list of env vars: + +| Variable | Description | Default | +|-------------|------------------------------------|---------| +| `FTP_HOST` | FTP server hostname or IP | — | +| `FTP_PORT` | FTP port | 21 | +| `FTP_USER` | FTP username | — | +| `FTP_PASS` | FTP password | — | +| `FTP_LOCAL` | Local directory to sync from | — | +| `FTP_REMOTE`| Remote directory to sync to | — | +| `FTP_TLS` | Enable FTPS/TLS (`1`/`true`/`yes`) | false | + +### Option B — interactive config screen + +Run `ftsync` with no env vars. A config form will appear. Fill in your +credentials and press **Enter** — they are saved to `~/.ftpsync.conf`. + +--- + +## Keybindings + +### Config screen + +| Key | Action | +|------------------|-------------------------| +| `Tab` / `↓` | Next field | +| `Shift+Tab` / `↑`| Previous field | +| `Enter` | Save and go to sync | +| `q` / `Esc` | Quit | + +### Sync screen + +| Key | Action | +|-----|-----------------------------| +| `s` | Start sync | +| `d` | Dry run (preview only) | +| `c` | Back to config | +| `q` | Quit (or abort active sync) | + +### Summary screen + +| Key | Action | +|-----|-----------------| +| `s` | Sync again | +| `c` | Back to config | +| `q` | Quit | + +--- + +## Config file + +Stored at `~/.ftpsync.conf` (TOML). Environment variables always override +values in this file. + +```toml +host = "ftp.example.com" +port = 21 +user = "ian" +password = "secret" +local_dir = "/home/ian/dist" +remote_dir = "/public_html" +tls = false +excludes = [".git", "node_modules", "__pycache__", ".DS_Store", "Thumbs.db"] +``` + +--- + +## How sync works + +1. Connects to the FTP server (plain or FTPS). +2. Recursively lists all files on the remote, recording their sizes. +3. Walks the local directory tree, skipping excluded patterns. +4. For each local file: if the remote file exists with the same byte size, + it is **skipped**. Otherwise it is **uploaded**. +5. Remote directories are created automatically as needed. +6. The sync task runs on a Tokio async thread; the Ratatui UI stays + responsive throughout via an `mpsc` channel. + +--- + +## Project structure + +``` +src/ +├── main.rs Terminal setup, Tokio event loop, keyboard handling +├── app.rs App state machine (screens, fields, sync state) +├── ftp.rs FTP/FTPS connection and sync engine (suppaftp) +├── ui.rs All Ratatui widget rendering (3 screens) +└── config.rs Config loading/saving, env var overlay +``` diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3f1fcd4 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,258 @@ +use crate::config::Config; +use crate::ftp::{SyncEvent, SyncStats}; +use chrono::Local; +use tokio::sync::mpsc; + +#[derive(Debug, Clone, PartialEq)] +pub enum Screen { + Config, + Sync, + Summary, +} + +/// Which field is focused on the config screen. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ConfigField { + Host, + Port, + User, + Password, + LocalDir, + RemoteDir, + Excludes, + Tls, +} + +impl ConfigField { + pub const ALL: &'static [ConfigField] = &[ + ConfigField::Host, + ConfigField::Port, + ConfigField::User, + ConfigField::Password, + ConfigField::LocalDir, + ConfigField::RemoteDir, + ConfigField::Excludes, + ConfigField::Tls, + ]; + + pub fn next(self) -> Self { + let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0); + Self::ALL[(idx + 1) % Self::ALL.len()] + } + + pub fn prev(self) -> Self { + let idx = Self::ALL.iter().position(|f| *f == self).unwrap_or(0); + Self::ALL[(idx + Self::ALL.len() - 1) % Self::ALL.len()] + } +} + +#[derive(Debug)] +pub struct LogEntry { + pub time: String, + pub level: LogLevel, + pub message: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum LogLevel { + Info, + Skip, + Upload, + Error, + Success, +} + +#[derive(Debug)] +pub struct App { + pub screen: Screen, + pub config: Config, + + // Config screen state + pub focused_field: ConfigField, + pub dry_run: bool, + + // Sync screen state + pub status_line: String, + pub current_file: String, + pub current_file_size: u64, + pub total_local: usize, + pub files_done: usize, + pub log: Vec, + pub log_scroll: usize, + + // Channel for receiving sync events + pub event_rx: Option>, + + // Summary + pub stats: Option, + pub sync_active: bool, +} + +impl App { + pub fn new() -> Self { + let config = Config::load(); + Self { + screen: if config.is_complete() { Screen::Sync } else { Screen::Config }, + config, + focused_field: ConfigField::Host, + dry_run: false, + status_line: "Ready".into(), + current_file: String::new(), + current_file_size: 0, + total_local: 0, + files_done: 0, + log: Vec::new(), + log_scroll: 0, + event_rx: None, + stats: None, + sync_active: false, + } + } + + pub fn push_log(&mut self, level: LogLevel, message: String) { + let time = Local::now().format("%H:%M:%S").to_string(); + self.log.push(LogEntry { time, level, message }); + // Auto-scroll to bottom (len >= 1 since we just pushed) + self.log_scroll = self.log.len() - 1; + } + + /// Drain pending sync events from the channel, update state accordingly. + pub fn poll_events(&mut self) { + let Some(rx) = self.event_rx.as_mut() else { return }; + let mut events = Vec::new(); + while let Ok(e) = rx.try_recv() { + events.push(e); + } + for event in events { + self.apply_event(event); + } + } + + fn apply_event(&mut self, event: SyncEvent) { + match event { + SyncEvent::Connecting => { + self.status_line = "Connecting…".into(); + self.push_log(LogLevel::Info, format!("Connecting to {}:{}", self.config.host, self.config.port)); + } + SyncEvent::Connected => { + self.status_line = "Connected".into(); + self.push_log(LogLevel::Success, "Connected successfully".into()); + } + SyncEvent::Scanning => { + self.status_line = "Scanning remote…".into(); + self.push_log(LogLevel::Info, "Scanning remote directory…".into()); + } + SyncEvent::RemoteScanned(n) => { + self.push_log(LogLevel::Info, format!("Remote: {} file(s) found", n)); + } + SyncEvent::LocalScanned(n) => { + self.total_local = n; + self.push_log(LogLevel::Info, format!("Local: {} file(s) to consider", n)); + self.status_line = "Syncing…".into(); + } + SyncEvent::Uploading { path, size } => { + self.current_file = path.clone(); + self.current_file_size = size; + self.status_line = "Uploading…".into(); + self.push_log(LogLevel::Upload, format!("↑ {} ({} bytes)", path, size)); + } + SyncEvent::Uploaded { path: _, size: _ } => { + self.files_done += 1; + } + SyncEvent::Skipped(path) => { + self.files_done += 1; + self.push_log(LogLevel::Skip, format!("– {} (unchanged)", path)); + } + SyncEvent::DryRun(path) => { + self.files_done += 1; + self.push_log(LogLevel::Upload, format!("[dry] {}", path)); + } + SyncEvent::Error { path, message } => { + self.files_done += 1; + self.push_log(LogLevel::Error, format!("✗ {} — {}", path, message)); + } + SyncEvent::Done(stats) => { + self.sync_active = false; + self.current_file = String::new(); + self.stats = Some(stats); + self.screen = Screen::Summary; + } + SyncEvent::FatalError(msg) => { + self.sync_active = false; + self.status_line = format!("Error: {}", msg); + self.push_log(LogLevel::Error, msg); + } + } + } + + /// Reset state for a fresh sync run. + pub fn reset_sync(&mut self) { + self.log.clear(); + self.log_scroll = 0; + self.status_line = "Ready".into(); + self.current_file.clear(); + self.current_file_size = 0; + self.total_local = 0; + self.files_done = 0; + self.stats = None; + self.sync_active = true; + } + + pub fn progress_ratio(&self) -> f64 { + if self.total_local == 0 { + 0.0 + } else { + self.files_done as f64 / self.total_local as f64 + } + } + + // ── Config field helpers ────────────────────────────────────────────────── + + pub fn config_field_value(&self, field: ConfigField) -> String { + match field { + ConfigField::Host => self.config.host.clone(), + ConfigField::Port => self.config.port.to_string(), + ConfigField::User => self.config.user.clone(), + ConfigField::Password => "*".repeat(self.config.password.len()), + ConfigField::LocalDir => self.config.local_dir.clone(), + ConfigField::RemoteDir => self.config.remote_dir.clone(), + ConfigField::Excludes => self.config.excludes.join(", "), + ConfigField::Tls => if self.config.tls { "yes".into() } else { "no".into() }, + } + } + + /// Push a character into the currently focused config field. + pub fn config_field_push(&mut self, c: char) { + match self.focused_field { + ConfigField::Host => self.config.host.push(c), + ConfigField::Port => self.config.port = format!("{}{}", self.config.port, c).parse().unwrap_or(self.config.port), + ConfigField::User => self.config.user.push(c), + ConfigField::Password => self.config.password.push(c), + ConfigField::LocalDir => self.config.local_dir.push(c), + ConfigField::RemoteDir => self.config.remote_dir.push(c), + ConfigField::Excludes => {} // handled separately + ConfigField::Tls => { + if c == ' ' || c == 'y' || c == 'n' { + self.config.tls = !self.config.tls; + } + } + } + } + + pub fn config_field_pop(&mut self) { + match self.focused_field { + ConfigField::Host => { self.config.host.pop(); } + ConfigField::Port => { + let mut s = self.config.port.to_string(); + s.pop(); + self.config.port = s.parse().unwrap_or(21); + } + ConfigField::User => { self.config.user.pop(); } + ConfigField::Password => { self.config.password.pop(); } + ConfigField::LocalDir => { self.config.local_dir.pop(); } + ConfigField::RemoteDir => { self.config.remote_dir.pop(); } + ConfigField::Excludes => {} + ConfigField::Tls => {} + } + } +} \ No newline at end of file diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1f0aab3 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,79 @@ +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Config { + pub host: String, + pub port: u16, + pub user: String, + pub password: String, + pub local_dir: String, + pub remote_dir: String, + pub tls: bool, + pub excludes: Vec, +} + +impl Config { + pub fn default_excludes() -> Vec { + vec![ + ".git".into(), + ".svn".into(), + "__pycache__".into(), + ".DS_Store".into(), + "Thumbs.db".into(), + "node_modules".into(), + ] + } + + /// Load from ~/.ftpsync.conf (TOML), then overlay env vars on top. + pub fn load() -> Self { + let mut cfg = Self::from_file().unwrap_or_default(); + cfg.overlay_env(); + if cfg.port == 0 { + cfg.port = 21; + } + if cfg.excludes.is_empty() { + cfg.excludes = Self::default_excludes(); + } + cfg + } + + fn config_path() -> Option { + dirs::home_dir().map(|h| h.join(".ftpsync.conf")) + } + + fn from_file() -> Option { + let path = Self::config_path()?; + let content = std::fs::read_to_string(path).ok()?; + toml::from_str(&content).ok() + } + + pub fn save(&self) -> std::io::Result<()> { + if let Some(path) = Self::config_path() { + let content = toml::to_string_pretty(self) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + std::fs::write(path, content)?; + } + Ok(()) + } + + fn overlay_env(&mut self) { + if let Ok(v) = std::env::var("FTP_HOST") { self.host = v; } + if let Ok(v) = std::env::var("FTP_PORT") { self.port = v.parse().unwrap_or(21); } + if let Ok(v) = std::env::var("FTP_USER") { self.user = v; } + if let Ok(v) = std::env::var("FTP_PASS") { self.password = v; } + if let Ok(v) = std::env::var("FTP_LOCAL") { self.local_dir = v; } + if let Ok(v) = std::env::var("FTP_REMOTE") { self.remote_dir = v; } + if let Ok(v) = std::env::var("FTP_TLS") { + self.tls = matches!(v.to_lowercase().as_str(), "1" | "true" | "yes"); + } + } + + pub fn is_complete(&self) -> bool { + !self.host.is_empty() + && !self.user.is_empty() + && !self.password.is_empty() + && !self.local_dir.is_empty() + && !self.remote_dir.is_empty() + } +} diff --git a/src/ftp.rs b/src/ftp.rs new file mode 100644 index 0000000..367509f --- /dev/null +++ b/src/ftp.rs @@ -0,0 +1,604 @@ +use crate::config::Config; +use glob::Pattern; +use std::collections::{HashMap, HashSet, VecDeque}; +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use suppaftp::{FtpStream, NativeTlsFtpStream}; +use tokio::sync::mpsc; + +/// Number of parallel FTP connections used for uploads. +const WORKER_COUNT: usize = 4; + +#[derive(Debug, Clone)] +pub enum SyncEvent { + Connecting, + Connected, + Scanning, + RemoteScanned(usize), + LocalScanned(usize), + Uploading { path: String, size: u64 }, + Uploaded { path: String, size: u64 }, + Skipped(String), + DryRun(String), + Error { path: String, message: String }, + Done(SyncStats), + FatalError(String), +} + +#[derive(Debug, Clone, Default)] +pub struct SyncStats { + pub uploaded: usize, + pub skipped: usize, + pub errors: usize, + pub bytes: u64, + pub duration_secs: u64, +} + +// ─── Upload work item ───────────────────────────────────────────────────────── + +struct UploadTask { + local_path: PathBuf, + rel: String, + local_size: u64, + remote_file_path: String, +} + +// ─── Exclusion ──────────────────────────────────────────────────────────────── + +fn is_excluded(rel_path: &str, patterns: &[Pattern]) -> bool { + let path = Path::new(rel_path); + for component in path.components() { + let s = component.as_os_str().to_string_lossy(); + for pat in patterns { + if pat.matches(&s) { + return true; + } + } + } + false +} + +fn compile_patterns(excludes: &[String]) -> Vec { + excludes + .iter() + .filter_map(|e| Pattern::new(e).ok()) + .collect() +} + +// ─── Remote helpers (plain FTP) ─────────────────────────────────────────────── + +fn mkdirs_plain(ftp: &mut FtpStream, remote_path: &str) { + let mut current = String::new(); + for part in remote_path.split('/').filter(|p| !p.is_empty()) { + if current.is_empty() { + current = part.to_string(); + } else { + current = format!("{}/{}", current, part); + } + let _ = ftp.mkdir(¤t); // ignore error (already exists) + } +} + +fn list_remote_plain(ftp: &mut FtpStream, remote_dir: &str) -> Vec<(String, u64)> { + let mut result = Vec::new(); + list_recursive_plain(ftp, remote_dir, "", &mut result); + result +} + +fn list_recursive_plain( + ftp: &mut FtpStream, + base: &str, + prefix: &str, + out: &mut Vec<(String, u64)>, +) { + if ftp.cwd(base).is_err() { + return; + } + let lines = match ftp.list(None) { + Ok(l) => l, + Err(_) => return, + }; + for line in lines { + let parts: Vec<&str> = line.splitn(9, ' ').collect(); + if parts.len() < 9 { + continue; + } + let name = parts[8].trim(); + if name == "." || name == ".." { + continue; + } + let rel = if prefix.is_empty() { + name.to_string() + } else { + format!("{}/{}", prefix, name) + }; + if line.starts_with('d') { + list_recursive_plain(ftp, &format!("{}/{}", base, name), &rel, out); + let _ = ftp.cwd(base); + } else { + let size: u64 = parts[4].parse().unwrap_or(0); + out.push((rel, size)); + } + } +} + +// ─── Local file walker ──────────────────────────────────────────────────────── + +fn walk_local(local_root: &Path, patterns: &[Pattern]) -> Vec<(PathBuf, String)> { + let mut files = Vec::new(); + walk_dir(local_root, local_root, patterns, &mut files); + files +} + +fn walk_dir( + root: &Path, + current: &Path, + patterns: &[Pattern], + out: &mut Vec<(PathBuf, String)>, +) { + let entries = match std::fs::read_dir(current) { + Ok(e) => e, + Err(_) => return, + }; + for entry in entries.flatten() { + let Ok(ft) = entry.file_type() else { continue }; + let path = entry.path(); + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + + if is_excluded(&rel, patterns) { + continue; + } + + if ft.is_dir() { + walk_dir(root, &path, patterns, out); + } else if ft.is_file() { + out.push((path, rel)); + } + } +} + +// ─── Sync entry point ───────────────────────────────────────────────────────── + +pub async fn run_sync(config: Config, dry_run: bool, tx: mpsc::Sender) { + let start = std::time::Instant::now(); + + let _ = tx.send(SyncEvent::Connecting).await; + + let patterns = compile_patterns(&config.excludes); + let local_root = PathBuf::from(&config.local_dir); + + if config.tls { + run_tls(config, dry_run, patterns, local_root, start, tx).await; + } else { + run_plain(config, dry_run, patterns, local_root, start, tx).await; + } +} + +// ─── Plain FTP ──────────────────────────────────────────────────────────────── + +async fn run_plain( + config: Config, + dry_run: bool, + patterns: Vec, + local_root: PathBuf, + start: std::time::Instant, + tx: mpsc::Sender, +) { + let addr = format!("{}:{}", config.host, config.port); + + // ── Phase 1: scan with a single connection ──────────────────────────────── + + let mut ftp = match FtpStream::connect(&addr) { + Ok(f) => f, + Err(e) => { + let _ = tx.send(SyncEvent::FatalError(format!("Connect failed: {}", e))).await; + return; + } + }; + + if let Err(e) = ftp.login(&config.user, &config.password) { + let _ = tx.send(SyncEvent::FatalError(format!("Login failed: {}", e))).await; + return; + } + + ftp.set_mode(suppaftp::Mode::Passive); + let _ = tx.send(SyncEvent::Connected).await; + + let _ = tx.send(SyncEvent::Scanning).await; + let remote_files = list_remote_plain(&mut ftp, &config.remote_dir); + let _ = tx.send(SyncEvent::RemoteScanned(remote_files.len())).await; + + let local_files = walk_local(&local_root, &patterns); + let _ = tx.send(SyncEvent::LocalScanned(local_files.len())).await; + + let remote_map: HashMap = remote_files.into_iter().collect(); + + let mut stats = SyncStats::default(); + let mut upload_tasks: Vec = Vec::new(); + + for (local_path, rel) in local_files { + let local_size = local_path.metadata().map(|m| m.len()).unwrap_or(0); + let remote_size = remote_map.get(&rel).copied(); + + if remote_size == Some(local_size) { + let _ = tx.send(SyncEvent::Skipped(rel)).await; + stats.skipped += 1; + continue; + } + + if dry_run { + let _ = tx.send(SyncEvent::DryRun(rel)).await; + stats.uploaded += 1; + continue; + } + + let remote_file_path = format!("{}/{}", config.remote_dir.trim_end_matches('/'), rel); + upload_tasks.push(UploadTask { local_path, rel, local_size, remote_file_path }); + } + + if upload_tasks.is_empty() { + let _ = ftp.quit(); + stats.duration_secs = start.elapsed().as_secs(); + let _ = tx.send(SyncEvent::Done(stats)).await; + return; + } + + // Pre-create all remote directories on the scan connection so workers + // don't need to think about directory creation at all. + let _ = ftp.cwd("/"); + let mut created_dirs: HashSet = HashSet::new(); + for task in &upload_tasks { + let dir = task.remote_file_path.rsplitn(2, '/').last().unwrap_or("").to_string(); + if created_dirs.insert(dir.clone()) { + mkdirs_plain(&mut ftp, &dir); + } + } + let _ = ftp.quit(); + + // ── Phase 2: parallel uploads ───────────────────────────────────────────── + + let queue: Arc>> = + Arc::new(Mutex::new(upload_tasks.into_iter().collect())); + + let config = Arc::new(config); + let mut handles = Vec::with_capacity(WORKER_COUNT); + + for _ in 0..WORKER_COUNT { + let queue = Arc::clone(&queue); + let config = Arc::clone(&config); + let tx = tx.clone(); + let addr = addr.clone(); + + handles.push(tokio::task::spawn_blocking(move || -> WorkerResult { + let mut ftp = match FtpStream::connect(&addr) { + Ok(f) => f, + Err(e) => { + drain_queue_as_errors(&queue, &tx, format!("Connect failed: {}", e)); + return WorkerResult::default(); + } + }; + if let Err(e) = ftp.login(&config.user, &config.password) { + drain_queue_as_errors(&queue, &tx, format!("Login failed: {}", e)); + let _ = ftp.quit(); + return WorkerResult::default(); + } + ftp.set_mode(suppaftp::Mode::Passive); + + let mut result = WorkerResult::default(); + loop { + let task = match queue.lock().unwrap().pop_front() { + Some(t) => t, + None => break, + }; + upload_one_plain(&mut ftp, task, &tx, &mut result); + } + let _ = ftp.quit(); + result + })); + } + + for handle in handles { + if let Ok(r) = handle.await { + stats.uploaded += r.uploaded; + stats.errors += r.errors; + stats.bytes += r.bytes; + } + } + + stats.duration_secs = start.elapsed().as_secs(); + let _ = tx.send(SyncEvent::Done(stats)).await; +} + +fn upload_one_plain( + ftp: &mut FtpStream, + task: UploadTask, + tx: &mpsc::Sender, + result: &mut WorkerResult, +) { + let _ = tx.blocking_send(SyncEvent::Uploading { path: task.rel.clone(), size: task.local_size }); + + match std::fs::File::open(&task.local_path) { + Ok(file) => { + let mut reader = BufReader::new(file); + match ftp.put_file(&task.remote_file_path, &mut reader) { + Ok(_) => { + let _ = tx.blocking_send(SyncEvent::Uploaded { + path: task.rel, + size: task.local_size, + }); + result.uploaded += 1; + result.bytes += task.local_size; + } + Err(e) => { + let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: e.to_string() }); + result.errors += 1; + } + } + } + Err(e) => { + let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: e.to_string() }); + result.errors += 1; + } + } +} + +// ─── TLS FTP ────────────────────────────────────────────────────────────────── + +async fn run_tls( + config: Config, + dry_run: bool, + patterns: Vec, + local_root: PathBuf, + start: std::time::Instant, + tx: mpsc::Sender, +) { + use suppaftp::{NativeTlsConnector, native_tls::TlsConnector}; + + let addr = format!("{}:{}", config.host, config.port); + + // ── Phase 1: scan with a single TLS connection ──────────────────────────── + + let connector = match TlsConnector::new() { + Ok(c) => NativeTlsConnector::from(c), + Err(e) => { + let _ = tx.send(SyncEvent::FatalError(format!("TLS init failed: {}", e))).await; + return; + } + }; + let stream = match NativeTlsFtpStream::connect(&addr) { + Ok(f) => f, + Err(e) => { + let _ = tx.send(SyncEvent::FatalError(format!("Connect failed: {}", e))).await; + return; + } + }; + let mut ftp = match stream.into_secure(connector, &config.host) { + Ok(f) => f, + Err(e) => { + let _ = tx.send(SyncEvent::FatalError(format!("TLS handshake failed: {}", e))).await; + return; + } + }; + + if let Err(e) = ftp.login(&config.user, &config.password) { + let _ = tx.send(SyncEvent::FatalError(format!("Login failed: {}", e))).await; + return; + } + + ftp.set_mode(suppaftp::Mode::Passive); + let _ = tx.send(SyncEvent::Connected).await; + let _ = tx.send(SyncEvent::Scanning).await; + + let mut remote_files: Vec<(String, u64)> = Vec::new(); + tls_list_recursive(&mut ftp, &config.remote_dir, "", &mut remote_files); + let _ = tx.send(SyncEvent::RemoteScanned(remote_files.len())).await; + + let local_files = walk_local(&local_root, &patterns); + let _ = tx.send(SyncEvent::LocalScanned(local_files.len())).await; + + let remote_map: HashMap = remote_files.into_iter().collect(); + + let mut stats = SyncStats::default(); + let mut upload_tasks: Vec = Vec::new(); + + for (local_path, rel) in local_files { + let local_size = local_path.metadata().map(|m| m.len()).unwrap_or(0); + let remote_size = remote_map.get(&rel).copied(); + + if remote_size == Some(local_size) { + let _ = tx.send(SyncEvent::Skipped(rel)).await; + stats.skipped += 1; + continue; + } + + if dry_run { + let _ = tx.send(SyncEvent::DryRun(rel)).await; + stats.uploaded += 1; + continue; + } + + let remote_file_path = format!("{}/{}", config.remote_dir.trim_end_matches('/'), rel); + upload_tasks.push(UploadTask { local_path, rel, local_size, remote_file_path }); + } + + if upload_tasks.is_empty() { + let _ = ftp.quit(); + stats.duration_secs = start.elapsed().as_secs(); + let _ = tx.send(SyncEvent::Done(stats)).await; + return; + } + + // Pre-create all remote directories on the scan connection. + let _ = ftp.cwd("/"); + let mut created_dirs: HashSet = HashSet::new(); + for task in &upload_tasks { + let dir = task.remote_file_path.rsplitn(2, '/').last().unwrap_or("").to_string(); + if created_dirs.insert(dir.clone()) { + tls_mkdirs(&mut ftp, &dir); + } + } + let _ = ftp.quit(); + + // ── Phase 2: parallel TLS uploads ──────────────────────────────────────── + + let queue: Arc>> = + Arc::new(Mutex::new(upload_tasks.into_iter().collect())); + + let config = Arc::new(config); + let mut handles = Vec::with_capacity(WORKER_COUNT); + + for _ in 0..WORKER_COUNT { + let queue = Arc::clone(&queue); + let config = Arc::clone(&config); + let tx = tx.clone(); + let addr = addr.clone(); + + handles.push(tokio::task::spawn_blocking(move || -> WorkerResult { + use suppaftp::{NativeTlsConnector, native_tls::TlsConnector}; + + let connector = match TlsConnector::new() { + Ok(c) => NativeTlsConnector::from(c), + Err(e) => { + drain_queue_as_errors(&queue, &tx, format!("TLS init failed: {}", e)); + return WorkerResult::default(); + } + }; + let stream = match NativeTlsFtpStream::connect(&addr) { + Ok(f) => f, + Err(e) => { + drain_queue_as_errors(&queue, &tx, format!("Connect failed: {}", e)); + return WorkerResult::default(); + } + }; + let mut ftp = match stream.into_secure(connector, &config.host) { + Ok(f) => f, + Err(e) => { + drain_queue_as_errors(&queue, &tx, format!("TLS handshake failed: {}", e)); + return WorkerResult::default(); + } + }; + if let Err(e) = ftp.login(&config.user, &config.password) { + drain_queue_as_errors(&queue, &tx, format!("Login failed: {}", e)); + let _ = ftp.quit(); + return WorkerResult::default(); + } + ftp.set_mode(suppaftp::Mode::Passive); + + let mut result = WorkerResult::default(); + loop { + let task = match queue.lock().unwrap().pop_front() { + Some(t) => t, + None => break, + }; + upload_one_tls(&mut ftp, task, &tx, &mut result); + } + let _ = ftp.quit(); + result + })); + } + + for handle in handles { + if let Ok(r) = handle.await { + stats.uploaded += r.uploaded; + stats.errors += r.errors; + stats.bytes += r.bytes; + } + } + + stats.duration_secs = start.elapsed().as_secs(); + let _ = tx.send(SyncEvent::Done(stats)).await; +} + +fn upload_one_tls( + ftp: &mut NativeTlsFtpStream, + task: UploadTask, + tx: &mpsc::Sender, + result: &mut WorkerResult, +) { + let _ = tx.blocking_send(SyncEvent::Uploading { path: task.rel.clone(), size: task.local_size }); + + match std::fs::File::open(&task.local_path) { + Ok(file) => { + let mut reader = BufReader::new(file); + match ftp.put_file(&task.remote_file_path, &mut reader) { + Ok(_) => { + let _ = tx.blocking_send(SyncEvent::Uploaded { + path: task.rel, + size: task.local_size, + }); + result.uploaded += 1; + result.bytes += task.local_size; + } + Err(e) => { + let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: e.to_string() }); + result.errors += 1; + } + } + } + Err(e) => { + let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: e.to_string() }); + result.errors += 1; + } + } +} + +// ─── Shared worker helpers ──────────────────────────────────────────────────── + +#[derive(Default)] +struct WorkerResult { + uploaded: usize, + errors: usize, + bytes: u64, +} + +/// Drain any remaining items from the shared queue and report each as an error. +/// Used when a worker fails to connect so no items are silently lost. +fn drain_queue_as_errors( + queue: &Mutex>, + tx: &mpsc::Sender, + message: String, +) { + let items: Vec<_> = queue.lock().unwrap().drain(..).collect(); + for task in items { + let _ = tx.blocking_send(SyncEvent::Error { path: task.rel, message: message.clone() }); + } +} + +// ─── TLS helpers ────────────────────────────────────────────────────────────── + +fn tls_mkdirs(ftp: &mut NativeTlsFtpStream, remote_path: &str) { + let mut current = String::new(); + for part in remote_path.split('/').filter(|p| !p.is_empty()) { + current = if current.is_empty() { part.to_string() } else { format!("{}/{}", current, part) }; + let _ = ftp.mkdir(¤t); + } +} + +fn tls_list_recursive( + ftp: &mut NativeTlsFtpStream, + base: &str, + prefix: &str, + out: &mut Vec<(String, u64)>, +) { + if ftp.cwd(base).is_err() { return; } + let lines = match ftp.list(None) { Ok(l) => l, Err(_) => return }; + for line in lines { + let parts: Vec<&str> = line.splitn(9, ' ').collect(); + if parts.len() < 9 { continue; } + let name = parts[8].trim(); + if name == "." || name == ".." { continue; } + let rel = if prefix.is_empty() { name.to_string() } else { format!("{}/{}", prefix, name) }; + if line.starts_with('d') { + tls_list_recursive(ftp, &format!("{}/{}", base, name), &rel, out); + let _ = ftp.cwd(base); + } else { + let size: u64 = parts[4].parse().unwrap_or(0); + out.push((rel, size)); + } + } +} diff --git a/src/main.rs b/src/main.rs index e7a11a9..766029f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,129 @@ -fn main() { - println!("Hello, world!"); +mod app; +mod config; +mod ftp; +mod ui; + +use app::{App, Screen}; +use crossterm::{ + event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::{io, time::Duration}; +use tokio::sync::mpsc; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let res = run_app(&mut terminal).await; + + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?; + terminal.show_cursor()?; + + if let Err(e) = res { + eprintln!("Error: {e}"); + } + Ok(()) +} + +async fn run_app(terminal: &mut Terminal>) -> anyhow::Result<()> { + let mut app = App::new(); + + loop { + terminal.draw(|f| ui::draw(f, &app))?; + app.poll_events(); + + if event::poll(Duration::from_millis(100))? { + if let Event::Key(key) = event::read()? { + match app.screen { + Screen::Config => { + if handle_config_input(&mut app, key.code, key.modifiers) { + return Ok(()); + } + } + Screen::Sync => { + if handle_sync_input(&mut app, key.code).await { + return Ok(()); + } + } + Screen::Summary => { + if handle_summary_input(&mut app, key.code).await { + return Ok(()); + } + } + } + } + } + } +} + +fn handle_config_input(app: &mut App, code: KeyCode, mods: KeyModifiers) -> bool { + match code { + KeyCode::Char('q') | KeyCode::Esc => return true, + KeyCode::Tab => app.focused_field = app.focused_field.next(), + KeyCode::BackTab => app.focused_field = app.focused_field.prev(), + KeyCode::Enter => { + let _ = app.config.save(); + app.screen = Screen::Sync; + } + KeyCode::Backspace => app.config_field_pop(), + KeyCode::Char(c) => { + if mods.is_empty() || mods == KeyModifiers::SHIFT { + app.config_field_push(c); + } + } + KeyCode::Up => app.focused_field = app.focused_field.prev(), + KeyCode::Down => app.focused_field = app.focused_field.next(), + _ => {} + } + false +} + +async fn handle_sync_input(app: &mut App, code: KeyCode) -> bool { + match code { + KeyCode::Char('q') | KeyCode::Esc => { + if !app.sync_active { + return true; + } + app.event_rx = None; + app.sync_active = false; + app.status_line = "Aborted".into(); + } + KeyCode::Char('s') if !app.sync_active => start_sync(app, false).await, + KeyCode::Char('d') if !app.sync_active => start_sync(app, true).await, + KeyCode::Char('c') if !app.sync_active => app.screen = Screen::Config, + _ => {} + } + false +} + +async fn handle_summary_input(app: &mut App, code: KeyCode) -> bool { + match code { + KeyCode::Char('q') | KeyCode::Esc => return true, + KeyCode::Char('s') => { + app.screen = Screen::Sync; + start_sync(app, false).await; + } + KeyCode::Char('c') => app.screen = Screen::Config, + _ => {} + } + false +} + +async fn start_sync(app: &mut App, dry_run: bool) { + app.reset_sync(); + app.screen = Screen::Sync; + let (tx, rx) = mpsc::channel(256); + app.event_rx = Some(rx); + let config = app.config.clone(); + tokio::spawn(async move { + ftp::run_sync(config, dry_run, tx).await; + }); } diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..30eeb10 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,419 @@ +use crate::app::{App, ConfigField, LogLevel, Screen}; +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Gauge, List, ListItem, Paragraph}, + Frame, +}; + +const BRAND: Color = Color::Cyan; +const ACCENT: Color = Color::Yellow; +const MUTED: Color = Color::DarkGray; +const OK: Color = Color::Green; +const ERR: Color = Color::Red; +const SKIP_COL: Color = Color::Blue; + +pub fn draw(f: &mut Frame, app: &App) { + match app.screen { + Screen::Config => draw_config(f, app), + Screen::Sync => draw_sync(f, app), + Screen::Summary => draw_summary(f, app), + } +} + +// ─── Config screen ──────────────────────────────────────────────────────────── + +fn draw_config(f: &mut Frame, app: &App) { + let area = f.area(); + + let outer = Block::default() + .title(Span::styled( + " ftsync — Configuration ", + Style::default().fg(BRAND).add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL) + .border_style(Style::default().fg(BRAND)); + + let inner = outer.inner(area); + f.render_widget(outer, area); + + // Vertically centre the form + let fields = ConfigField::ALL; + let form_height = fields.len() as u16 * 3 + 4; // 3 lines per field + footer + let top_pad = inner.height.saturating_sub(form_height) / 2; + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(top_pad), + Constraint::Length(form_height), + Constraint::Min(0), + ]) + .split(inner); + + let form_area = rows[1]; + + // Split form into label col + input col + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(4), // left margin + Constraint::Length(14), // labels + Constraint::Length(2), // gap + Constraint::Min(30), // inputs + Constraint::Length(4), // right margin + ]) + .split(form_area); + + let label_area = cols[1]; + let input_area = cols[3]; + + let mut label_constraints = Vec::new(); + let mut input_constraints = Vec::new(); + for _ in fields { + label_constraints.push(Constraint::Length(3)); + input_constraints.push(Constraint::Length(3)); + } + label_constraints.push(Constraint::Length(4)); // footer + input_constraints.push(Constraint::Length(4)); + + let label_rows = Layout::default() + .direction(Direction::Vertical) + .constraints(label_constraints) + .split(label_area); + + let input_rows = Layout::default() + .direction(Direction::Vertical) + .constraints(input_constraints) + .split(input_area); + + for (i, &field) in fields.iter().enumerate() { + let focused = app.focused_field == field; + let label = config_field_label(field); + let value = app.config_field_value(field); + + let label_style = if focused { + Style::default().fg(ACCENT).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(MUTED) + }; + + let input_border = if focused { + Style::default().fg(ACCENT) + } else { + Style::default().fg(Color::White) + }; + + let label_widget = Paragraph::new(label) + .style(label_style) + .alignment(Alignment::Right); + f.render_widget(label_widget, centre_vertically(label_rows[i], 1)); + + let input_block = Block::default() + .borders(Borders::ALL) + .border_style(input_border); + + let display = if field == ConfigField::Password { + "•".repeat(app.config.password.len()) + } else { + value.clone() + }; + + let cursor_display = if focused { + format!("{}█", display) + } else { + display + }; + + let input_widget = Paragraph::new(cursor_display) + .block(input_block) + .style(Style::default().fg(Color::White)); + f.render_widget(input_widget, input_rows[i]); + } + + // Footer hints + let footer_area = input_rows[fields.len()]; + let hints = Line::from(vec![ + Span::styled("Tab", Style::default().fg(ACCENT)), + Span::raw(" next "), + Span::styled("Shift+Tab", Style::default().fg(ACCENT)), + Span::raw(" prev "), + Span::styled("Enter", Style::default().fg(OK)), + Span::raw(" go to sync "), + Span::styled("q", Style::default().fg(ERR)), + Span::raw(" quit"), + ]); + f.render_widget(Paragraph::new(hints), footer_area); +} + +fn centre_vertically(area: Rect, height: u16) -> Rect { + let pad = area.height.saturating_sub(height) / 2; + Rect { + y: area.y + pad, + height, + ..area + } +} + +// ─── Sync screen ────────────────────────────────────────────────────────────── + +fn draw_sync(f: &mut Frame, app: &App) { + let area = f.area(); + + // Outer block + let title_text = if app.dry_run { " ftsync — Dry Run " } else { " ftsync — Sync " }; + let outer = Block::default() + .title(Span::styled(title_text, Style::default().fg(BRAND).add_modifier(Modifier::BOLD))) + .borders(Borders::ALL) + .border_style(Style::default().fg(BRAND)); + let inner = outer.inner(area); + f.render_widget(outer, area); + + // Split: left panel | right log + let h_split = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(inner); + + draw_sync_left(f, app, h_split[0]); + draw_sync_log(f, app, h_split[1]); +} + +fn draw_sync_left(f: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .title(Span::styled(" Status ", Style::default().fg(MUTED))) + .borders(Borders::RIGHT) + .border_style(Style::default().fg(MUTED)); + let inner = block.inner(area); + f.render_widget(block, area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // padding + Constraint::Length(1), // local dir + Constraint::Length(1), // remote + Constraint::Length(1), // status + Constraint::Length(1), // padding + Constraint::Length(1), // current file + Constraint::Length(1), // padding + Constraint::Length(3), // progress bar + Constraint::Min(0), // spacer + Constraint::Length(1), // footer + ]) + .split(inner); + + let kv = |key: &str, val: &str| -> Line<'static> { + Line::from(vec![ + Span::styled(format!("{:<12}", key), Style::default().fg(MUTED)), + Span::styled(val.to_string(), Style::default().fg(Color::White)), + ]) + }; + + f.render_widget(Paragraph::new(kv("Local", &app.config.local_dir)), rows[1]); + f.render_widget( + Paragraph::new(kv("Remote", &format!("{}:{}{}", app.config.host, app.config.port, app.config.remote_dir))), + rows[2], + ); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!("{:<12}", "Status"), Style::default().fg(MUTED)), + Span::styled(app.status_line.clone(), Style::default().fg(ACCENT)), + ])), + rows[3], + ); + + // Current file (truncated) + let max_w = rows[5].width.saturating_sub(2) as usize; + let file_display = truncate_left(&app.current_file, max_w); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled("↑ ", Style::default().fg(OK)), + Span::styled(file_display, Style::default().fg(Color::White)), + ])), + rows[5], + ); + + // Progress bar + let ratio = app.progress_ratio(); + let label = if app.total_local > 0 { + format!("{}/{} files", app.files_done, app.total_local) + } else { + String::new() + }; + let gauge = Gauge::default() + .block(Block::default().borders(Borders::NONE)) + .gauge_style(Style::default().fg(BRAND).bg(Color::Black)) + .ratio(ratio) + .label(label); + f.render_widget(gauge, rows[7]); + + // Footer hints + let hints = if app.sync_active { + Line::from(vec![Span::styled("q", Style::default().fg(ERR)), Span::raw(" abort")]) + } else { + Line::from(vec![ + Span::styled("s", Style::default().fg(ACCENT)), + Span::raw(" sync "), + Span::styled("d", Style::default().fg(ACCENT)), + Span::raw(" dry-run "), + Span::styled("c", Style::default().fg(MUTED)), + Span::raw(" config "), + Span::styled("q", Style::default().fg(ERR)), + Span::raw(" quit"), + ]) + }; + f.render_widget(Paragraph::new(hints), rows[9]); +} + +fn draw_sync_log(f: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .title(Span::styled(" Log ", Style::default().fg(MUTED))) + .borders(Borders::NONE); + let inner = block.inner(area); + f.render_widget(block, area); + + let visible = inner.height as usize; + let total = app.log.len(); + let start = if total > visible { + total - visible + } else { + 0 + }; + + let items: Vec = app.log[start..] + .iter() + .map(|e| { + let (icon, col) = match e.level { + LogLevel::Info => ("·", MUTED), + LogLevel::Skip => ("–", SKIP_COL), + LogLevel::Upload => ("↑", OK), + LogLevel::Error => ("✗", ERR), + LogLevel::Success => ("✓", OK), + }; + let line = Line::from(vec![ + Span::styled(format!("{} ", e.time), Style::default().fg(MUTED)), + Span::styled(format!("{} ", icon), Style::default().fg(col)), + Span::styled(e.message.clone(), Style::default().fg(Color::White)), + ]); + ListItem::new(line) + }) + .collect(); + + f.render_widget(List::new(items), inner); +} + +// ─── Summary screen ─────────────────────────────────────────────────────────── + +fn draw_summary(f: &mut Frame, app: &App) { + let area = f.area(); + + let outer = Block::default() + .title(Span::styled(" ftsync — Complete ", Style::default().fg(OK).add_modifier(Modifier::BOLD))) + .borders(Borders::ALL) + .border_style(Style::default().fg(OK)); + let inner = outer.inner(area); + f.render_widget(outer, area); + + let stats = match &app.stats { + Some(s) => s, + None => return, + }; + + let box_w = 44u16; + let box_h = 10u16; + let x = inner.x + inner.width.saturating_sub(box_w) / 2; + let y = inner.y + inner.height.saturating_sub(box_h) / 2; + let box_area = Rect { x, y, width: box_w, height: box_h }; + + f.render_widget(Clear, box_area); + + let block = Block::default().borders(Borders::ALL).border_style(Style::default().fg(OK)); + let content_area = block.inner(box_area); + f.render_widget(block, box_area); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), + Constraint::Length(1), // uploaded + Constraint::Length(1), // skipped + Constraint::Length(1), // errors + Constraint::Length(1), // bytes + Constraint::Length(1), // duration + Constraint::Length(1), + Constraint::Length(1), // footer + ]) + .split(content_area); + + let stat_line = |icon: &str, icon_col: Color, label: &str, val: String| -> Paragraph<'static> { + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {} ", icon), Style::default().fg(icon_col)), + Span::styled(format!("{:<14}", label), Style::default().fg(MUTED)), + Span::styled(val, Style::default().fg(Color::White).add_modifier(Modifier::BOLD)), + ])) + }; + + f.render_widget(stat_line("✓", OK, "Uploaded", format!("{} file(s)", stats.uploaded)), rows[1]); + f.render_widget(stat_line("–", SKIP_COL, "Skipped", format!("{} file(s)", stats.skipped)), rows[2]); + f.render_widget(stat_line("✗", ERR, "Errors", stats.errors.to_string()), rows[3]); + f.render_widget(stat_line("⇅", BRAND, "Transferred", format_bytes(stats.bytes)), rows[4]); + f.render_widget(stat_line("⏱", MUTED, "Duration", format_duration(stats.duration_secs)), rows[5]); + + let footer = Line::from(vec![ + Span::styled(" s", Style::default().fg(ACCENT)), + Span::raw(" sync again "), + Span::styled("c", Style::default().fg(MUTED)), + Span::raw(" config "), + Span::styled("q", Style::default().fg(ERR)), + Span::raw(" quit"), + ]); + f.render_widget(Paragraph::new(footer), rows[7]); +} + +// ─── Utilities ──────────────────────────────────────────────────────────────── + +fn config_field_label(field: ConfigField) -> &'static str { + match field { + ConfigField::Host => "Host", + ConfigField::Port => "Port", + ConfigField::User => "User", + ConfigField::Password => "Password", + ConfigField::LocalDir => "Local Dir", + ConfigField::RemoteDir => "Remote Dir", + ConfigField::Excludes => "Excludes", + ConfigField::Tls => "TLS", + } +} + +fn format_bytes(b: u64) -> String { + if b >= 1_073_741_824 { + format!("{:.2} GB", b as f64 / 1_073_741_824.0) + } else if b >= 1_048_576 { + format!("{:.2} MB", b as f64 / 1_048_576.0) + } else if b >= 1_024 { + format!("{:.1} KB", b as f64 / 1_024.0) + } else { + format!("{} B", b) + } +} + +fn format_duration(secs: u64) -> String { + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + if h > 0 { + format!("{:02}:{:02}:{:02}", h, m, s) + } else { + format!("{:02}:{:02}", m, s) + } +} + +fn truncate_left(s: &str, max: usize) -> String { + if s.len() <= max { + s.to_string() + } else { + format!("…{}", &s[s.len() - max.saturating_sub(1)..]) + } +} \ No newline at end of file