From e7dcba39a6b4a64478a2e8f4a8935d044efb6898 Mon Sep 17 00:00:00 2001
From: rozodru
Date: Sun, 20 Jul 2025 11:53:26 -0400
Subject: [PATCH] initial commit
---
.gitignore | 18 +
Cargo.lock | 2240 ++++++++++++++++++++++++++++++++++++++++++++
Cargo.toml | 18 +
README.md | 160 ++++
src/api/client.rs | 284 ++++++
src/api/mod.rs | 5 +
src/api/types.rs | 241 +++++
src/config/mod.rs | 77 ++
src/main.rs | 178 ++++
src/ui/app.rs | 296 ++++++
src/ui/compose.rs | 94 ++
src/ui/detail.rs | 176 ++++
src/ui/mod.rs | 214 +++++
src/ui/timeline.rs | 189 ++++
14 files changed, 4190 insertions(+)
create mode 100644 .gitignore
create mode 100644 Cargo.lock
create mode 100644 Cargo.toml
create mode 100644 README.md
create mode 100644 src/api/client.rs
create mode 100644 src/api/mod.rs
create mode 100644 src/api/types.rs
create mode 100644 src/config/mod.rs
create mode 100644 src/main.rs
create mode 100644 src/ui/app.rs
create mode 100644 src/ui/compose.rs
create mode 100644 src/ui/detail.rs
create mode 100644 src/ui/mod.rs
create mode 100644 src/ui/timeline.rs
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5267bdd
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,18 @@
+# Rust
+/target/
+**/*.rs.bk
+
+# IDE
+.vscode/
+.idea/
+*.swp
+*.swo
+*~
+
+# OS
+.DS_Store
+Thumbs.db
+
+# Configuration (contains sensitive data)
+config.json
+*.log
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..00aa962
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2240 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "addr2line"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "anstream"
+version = "0.6.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "301af1932e46185686725e0fad2f8f2aa7da69dd70bf6ecc44d6b703844a3933"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd"
+
+[[package]]
+name = "anstyle-parse"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c8bdeb6047d8983be085bab0ba1472e6dc604e7041dbf6fcd5e71523014fae9"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "403f75924867bb1033c59fbf0797484329750cfbe3c4325cd33127941fabc882"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.98"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487"
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "backtrace"
+version = "0.3.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002"
+dependencies = [
+ "addr2line",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
+
+[[package]]
+name = "bumpalo"
+version = "3.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
+
+[[package]]
+name = "bytes"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
+
+[[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.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "deec109607ca693028562ed836a5f1c4b8bd77755c4e132fc5ce11b0b6211ae7"
+dependencies = [
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
+
+[[package]]
+name = "chrono"
+version = "0.4.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "clap"
+version = "4.5.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be92d32e80243a54711e5d7ce823c35c41c9d929dc4ab58e1276f625841aadf9"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.5.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "707eab41e9622f9139419d573eca0900137718000c517d47da73045f54331c3d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.5.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef4f52386a59ca4c860f7393bcf8abd8dfd91ecccc0f774635ff68e92eeef491"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "clap_lex"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
+
+[[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.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
+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.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[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.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
+dependencies = [
+ "libc",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[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 = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
+
+[[package]]
+name = "futures-task"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi",
+ "wasi 0.14.2+wasi-0.2.4",
+]
+
+[[package]]
+name = "gimli"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f"
+
+[[package]]
+name = "h2"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17da50a276f1e01e0ba6c029e47b7100754904ee8a278f886546e98575380785"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "http"
+version = "1.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-tls"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
+dependencies = [
+ "bytes",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "native-tls",
+ "tokio",
+ "tokio-native-tls",
+ "tower-service",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "system-configuration",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "windows-registry",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0c919e5debc312ad217002b8048a17b7d83f80703865bbfcfebb0458b0b27d8"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "log",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3"
+
+[[package]]
+name = "icu_properties"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "potential_utf",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632"
+
+[[package]]
+name = "icu_provider"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "stable_deref_trait",
+ "tinystr",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "686f825264d630750a544639377bae737628043f20d38bbc029e8f29ea968a7e"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "indoc"
+version = "2.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
+
+[[package]]
+name = "instability"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a"
+dependencies = [
+ "darling",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "io-uring"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013"
+dependencies = [
+ "bitflags",
+ "cfg-if",
+ "libc",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
+
+[[package]]
+name = "iri-string"
+version = "0.7.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2"
+dependencies = [
+ "memchr",
+ "serde",
+]
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf"
+
+[[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.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c"
+
+[[package]]
+name = "js-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.174"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1171693293099992e19cddea4e8b849964e9846f4acee11b3948bcc337be8776"
+
+[[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.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd945864f07fe9f5371a27ad7b52a172b4b499999f1d97574c9fa68373937e12"
+
+[[package]]
+name = "litemap"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956"
+
+[[package]]
+name = "lock_api"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13dc2df351e3202783a1fe0d44375f7295ffb4049267b0f3018346dc122a1d94"
+
+[[package]]
+name = "lru"
+version = "0.12.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
+dependencies = [
+ "hashbrown",
+]
+
+[[package]]
+name = "mastui"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "chrono",
+ "clap",
+ "crossterm",
+ "ratatui",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "textwrap",
+ "tokio",
+ "url",
+ "urlencoding",
+]
+
+[[package]]
+name = "memchr"
+version = "2.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+]
+
+[[package]]
+name = "mio"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi 0.11.1+wasi-snapshot-preview1",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "native-tls"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
+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 = "object"
+version = "0.36.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad"
+
+[[package]]
+name = "openssl"
+version = "0.10.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8"
+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.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-sys"
+version = "0.9.109"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571"
+dependencies = [
+ "cc",
+ "libc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.95"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "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.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de3a5d9f0aba1dbcec1cc47f0ff94a4b778fe55bca98a6dfa92e4e094e57b1c4"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "reqwest"
+version = "0.12.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531"
+dependencies = [
+ "base64",
+ "bytes",
+ "encoding_rs",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-tls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "mime",
+ "native-tls",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-native-tls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.16",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "989e6739f80c4ad5b13e0fd7fe89531180375b18520cc8c82080e4dc4035b84f"
+
+[[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.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.9.4",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1"
+dependencies = [
+ "once_cell",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d"
+
+[[package]]
+name = "ryu"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f"
+
+[[package]]
+name = "schannel"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d"
+dependencies = [
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.219"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.141"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3"
+dependencies = [
+ "itoa",
+ "memchr",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "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.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9203b8055f63a2a00e2f593bb0510367fe707d7ff1e5c872de2f537b339e5410"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "04dc19736151f35336d325007ac991178d504a119863a2fcb3758cdb5e52c50d"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "smawk"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+
+[[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 = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "system-configuration"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
+dependencies = [
+ "bitflags",
+ "core-foundation",
+ "system-configuration-sys",
+]
+
+[[package]]
+name = "system-configuration-sys"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1"
+dependencies = [
+ "fastrand",
+ "getrandom 0.3.3",
+ "once_cell",
+ "rustix 1.0.8",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "textwrap"
+version = "0.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
+dependencies = [
+ "smawk",
+ "unicode-linebreak",
+ "unicode-width 0.2.0",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tokio"
+version = "1.46.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "io-uring",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "slab",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-native-tls"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
+dependencies = [
+ "native-tls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e727b36a1a0e8b74c376ac2211e40c2c8af09fb4013c60d910495810f008e9b"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tower"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "iri-string",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
+
+[[package]]
+name = "unicode-linebreak"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[package]]
+name = "unicode-truncate"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf"
+dependencies = [
+ "itertools",
+ "unicode-segmentation",
+ "unicode-width 0.1.14",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-width"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32f8b686cadd1473f4bd0117a5d28d36b1ade384ea9b5069a1c40aefed7fda60"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+]
+
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasi"
+version = "0.14.2+wasi-0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3"
+dependencies = [
+ "wit-bindgen-rt",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6"
+dependencies = [
+ "bumpalo",
+ "log",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[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.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.60.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.59.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
+
+[[package]]
+name = "windows-registry"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e"
+dependencies = [
+ "windows-link",
+ "windows-result",
+ "windows-strings",
+]
+
+[[package]]
+name = "windows-result"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.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.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c66f69fcc9ce11da9966ddb31a40968cad001c5bedeb5c2b82ede4253ab48aef"
+dependencies = [
+ "windows_aarch64_gnullvm 0.53.0",
+ "windows_aarch64_msvc 0.53.0",
+ "windows_i686_gnu 0.53.0",
+ "windows_i686_gnullvm 0.53.0",
+ "windows_i686_msvc 0.53.0",
+ "windows_x86_64_gnu 0.53.0",
+ "windows_x86_64_gnullvm 0.53.0",
+ "windows_x86_64_msvc 0.53.0",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486"
+
+[[package]]
+name = "wit-bindgen-rt"
+version = "0.39.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "writeable"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb"
+
+[[package]]
+name = "yoke"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc"
+dependencies = [
+ "serde",
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4a05eb080e015ba39cc9e23bbe5e7fb04d5fb040350f99f34e338d5fdd294428"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..0169ba2
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "mastui"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+ratatui = "0.29"
+crossterm = "0.28"
+tokio = { version = "1.0", features = ["full"] }
+reqwest = { version = "0.12", features = ["json"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+url = "2.5"
+chrono = { version = "0.4", features = ["serde"] }
+anyhow = "1.0"
+clap = { version = "4.0", features = ["derive"] }
+urlencoding = "2.1"
+textwrap = "0.16"
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0da5c65
--- /dev/null
+++ b/README.md
@@ -0,0 +1,160 @@
+# Mastui - A TUI Client for Mastodon
+
+Mastui is a terminal user interface (TUI) client for Mastodon, built in Rust using the ratatui library. It provides a fast, keyboard-driven way to interact with Mastodon instances directly from your terminal.
+
+## Features
+
+- 🏠 **Timeline Viewing**: Browse Home, Local, and Federated timelines
+- ✏️ **Post Creation**: Compose new posts and replies
+- 💬 **Interactive Navigation**: Keyboard shortcuts for efficient navigation
+- ⭐ **Status Interactions**: Like, boost, and reply to posts
+- 🔐 **OAuth Authentication**: Secure authentication with Mastodon instances
+- 📱 **Multiple Views**: Timeline, compose, status detail, and notifications
+
+## Installation
+
+### Prerequisites
+
+- Rust 1.70+ (for compiling from source)
+
+### Building from Source
+
+```bash
+git clone https://github.com/yourusername/mastui.git
+cd mastui
+cargo build --release
+```
+
+The binary will be available at `target/release/mastui`.
+
+## Usage
+
+### Authentication
+
+Before using Mastui, you need to authenticate with a Mastodon instance:
+
+```bash
+mastui auth mastodon.social
+```
+
+Replace `mastodon.social` with your preferred Mastodon instance. This will:
+
+1. Register Mastui as an application on your instance
+2. Open a browser window for authorization
+3. Prompt you to enter the authorization code
+4. Save your credentials for future use
+
+### Starting the TUI
+
+Once authenticated, start the TUI interface:
+
+```bash
+mastui
+```
+
+Or explicitly run the timeline view:
+
+```bash
+mastui timeline
+```
+
+### Command Line Posting
+
+You can also post directly from the command line:
+
+```bash
+mastui post "Hello from the terminal! #mastui"
+```
+
+## Keyboard Shortcuts
+
+### Timeline View
+
+- `j` / `↓` - Move down to next status
+- `k` / `↑` - Move up to previous status
+- `Enter` - View status details
+- `r` - Reply to selected status
+- `n` - Compose new post
+- `t` - Switch between timelines (Home → Local → Federated)
+- `m` - View notifications
+- `Ctrl+f` - Favorite/unfavorite status
+- `Ctrl+b` - Boost/unboost status
+- `F5` - Refresh timeline
+- `q` - Quit application
+
+### Compose View
+
+- Type to compose your post
+- `Ctrl+Enter` - Send post
+- `Escape` - Cancel and return to timeline
+
+### Status Detail View
+
+- `r` - Reply to status
+- `Escape` / `q` - Return to timeline
+
+### General
+
+- `Ctrl+C` - Force quit from any view
+
+## Configuration
+
+Mastui stores its configuration in `~/.config/mastui/config.json`. This file contains:
+
+- Instance URL
+- Client credentials
+- Access token
+- User information
+
+The configuration file is created automatically during the authentication process.
+
+## Supported Mastodon Features
+
+- ✅ Timeline viewing (Home, Local, Federated)
+- ✅ Post creation and replies
+- ✅ Status interactions (favorite, boost)
+- ✅ Notification viewing
+- ✅ Media attachment display (URLs shown)
+- ✅ Account information display
+- ⚠️ Image viewing (planned)
+- ⚠️ Search functionality (planned)
+- ⚠️ Direct messages (planned)
+
+## Architecture
+
+Mastui is built with:
+
+- **Rust** - Systems programming language
+- **ratatui** - Terminal user interface library
+- **tokio** - Async runtime
+- **reqwest** - HTTP client for API requests
+- **serde** - Serialization/deserialization
+- **crossterm** - Cross-platform terminal manipulation
+
+The application follows a modular architecture:
+
+- `api/` - Mastodon API client and data types
+- `config/` - Configuration management
+- `ui/` - TUI components and rendering
+
+## Contributing
+
+Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
+
+### Development Setup
+
+1. Clone the repository
+2. Run `cargo build` to compile
+3. Run `cargo test` to run tests
+4. Use `cargo run -- auth ` to test authentication
+5. Use `cargo run` to test the TUI
+
+## License
+
+This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
+
+## Acknowledgments
+
+- The Mastodon team for creating an amazing decentralized social platform
+- The ratatui community for the excellent TUI framework
+- The Rust community for the robust ecosystem
\ No newline at end of file
diff --git a/src/api/client.rs b/src/api/client.rs
new file mode 100644
index 0000000..52a93fe
--- /dev/null
+++ b/src/api/client.rs
@@ -0,0 +1,284 @@
+use crate::api::types::*;
+use anyhow::{anyhow, Result};
+use reqwest::{Client, Response};
+use serde::{Deserialize, Serialize};
+use serde_json::Value;
+use std::collections::HashMap;
+use url::Url;
+
+#[derive(Debug, Clone)]
+pub struct MastodonClient {
+ client: Client,
+ instance_url: String,
+ access_token: Option,
+}
+
+impl MastodonClient {
+ pub fn new(instance_url: String) -> Self {
+ Self {
+ client: Client::new(),
+ instance_url: instance_url.trim_end_matches('/').to_string(),
+ access_token: None,
+ }
+ }
+
+ pub fn with_token(instance_url: String, access_token: String) -> Self {
+ Self {
+ client: Client::new(),
+ instance_url: instance_url.trim_end_matches('/').to_string(),
+ access_token: Some(access_token),
+ }
+ }
+
+ fn build_url(&self, endpoint: &str) -> String {
+ format!("{}{}", self.instance_url, endpoint)
+ }
+
+ async fn get(&self, endpoint: &str) -> Result {
+ let url = self.build_url(endpoint);
+ let mut req = self.client.get(&url);
+
+ if let Some(token) = &self.access_token {
+ req = req.header("Authorization", format!("Bearer {}", token));
+ }
+
+ let response = req.send().await?;
+
+ if !response.status().is_success() {
+ return Err(anyhow!("API request failed: {}", response.status()));
+ }
+
+ Ok(response)
+ }
+
+ async fn post(&self, endpoint: &str, data: Option<&Value>) -> Result {
+ let url = self.build_url(endpoint);
+ let mut req = self.client.post(&url);
+
+ if let Some(token) = &self.access_token {
+ req = req.header("Authorization", format!("Bearer {}", token));
+ }
+
+ if let Some(data) = data {
+ req = req.json(data);
+ }
+
+ let response = req.send().await?;
+
+ if !response.status().is_success() {
+ return Err(anyhow!("API request failed: {}", response.status()));
+ }
+
+ Ok(response)
+ }
+
+ pub async fn register_app(
+ &self,
+ app_name: &str,
+ redirect_uris: &str,
+ scopes: &str,
+ website: Option<&str>,
+ ) -> Result {
+ let mut data = HashMap::new();
+ data.insert("client_name", app_name);
+ data.insert("redirect_uris", redirect_uris);
+ data.insert("scopes", scopes);
+
+ if let Some(website) = website {
+ data.insert("website", website);
+ }
+
+ let response = self
+ .client
+ .post(&self.build_url("/api/v1/apps"))
+ .json(&data)
+ .send()
+ .await?;
+
+ if !response.status().is_success() {
+ return Err(anyhow!("Failed to register app: {}", response.status()));
+ }
+
+ let app: AppRegistration = response.json().await?;
+ Ok(app)
+ }
+
+ pub fn get_auth_url(&self, client_id: &str, redirect_uri: &str, scopes: &str) -> Result {
+ let mut url = Url::parse(&format!("{}/oauth/authorize", self.instance_url))?;
+ url.query_pairs_mut()
+ .append_pair("client_id", client_id)
+ .append_pair("redirect_uri", redirect_uri)
+ .append_pair("response_type", "code")
+ .append_pair("scope", scopes);
+
+ Ok(url.to_string())
+ }
+
+ pub async fn get_access_token(
+ &self,
+ client_id: &str,
+ client_secret: &str,
+ redirect_uri: &str,
+ code: &str,
+ ) -> Result {
+ let mut data = HashMap::new();
+ data.insert("client_id", client_id);
+ data.insert("client_secret", client_secret);
+ data.insert("redirect_uri", redirect_uri);
+ data.insert("grant_type", "authorization_code");
+ data.insert("code", code);
+
+ let response = self
+ .client
+ .post(&self.build_url("/oauth/token"))
+ .form(&data)
+ .send()
+ .await?;
+
+ if !response.status().is_success() {
+ return Err(anyhow!("Failed to get access token: {}", response.status()));
+ }
+
+ let token: AccessToken = response.json().await?;
+ Ok(token)
+ }
+
+ pub async fn verify_credentials(&self) -> Result {
+ let response = self.get("/api/v1/accounts/verify_credentials").await?;
+ let account: Account = response.json().await?;
+ Ok(account)
+ }
+
+ pub async fn get_home_timeline(&self, limit: Option) -> Result> {
+ let mut endpoint = "/api/v1/timelines/home".to_string();
+ if let Some(limit) = limit {
+ endpoint.push_str(&format!("?limit={}", limit));
+ }
+
+ let response = self.get(&endpoint).await?;
+ let statuses: Vec = response.json().await?;
+ Ok(statuses)
+ }
+
+ pub async fn get_public_timeline(&self, local: bool, limit: Option) -> Result> {
+ let mut endpoint = "/api/v1/timelines/public".to_string();
+ let mut params = vec![];
+
+ if local {
+ params.push("local=true".to_string());
+ }
+
+ if let Some(limit) = limit {
+ params.push(format!("limit={}", limit));
+ }
+
+ if !params.is_empty() {
+ endpoint.push('?');
+ endpoint.push_str(¶ms.join("&"));
+ }
+
+ let response = self.get(&endpoint).await?;
+ let statuses: Vec = response.json().await?;
+ Ok(statuses)
+ }
+
+ pub async fn get_status(&self, id: &str) -> Result {
+ let endpoint = format!("/api/v1/statuses/{}", id);
+ let response = self.get(&endpoint).await?;
+ let status: Status = response.json().await?;
+ Ok(status)
+ }
+
+ pub async fn get_status_context(&self, id: &str) -> Result {
+ let endpoint = format!("/api/v1/statuses/{}/context", id);
+ let response = self.get(&endpoint).await?;
+ let context: Context = response.json().await?;
+ Ok(context)
+ }
+
+ pub async fn post_status(
+ &self,
+ content: &str,
+ in_reply_to_id: Option<&str>,
+ visibility: Option,
+ ) -> Result {
+ let mut data = HashMap::new();
+ data.insert("status", content);
+
+ if let Some(reply_id) = in_reply_to_id {
+ data.insert("in_reply_to_id", reply_id);
+ }
+
+ if let Some(vis) = visibility {
+ let vis_str = match vis {
+ Visibility::Public => "public",
+ Visibility::Unlisted => "unlisted",
+ Visibility::Private => "private",
+ Visibility::Direct => "direct",
+ };
+ data.insert("visibility", vis_str);
+ }
+
+ let json_data = serde_json::to_value(data)?;
+ let response = self.post("/api/v1/statuses", Some(&json_data)).await?;
+ let status: Status = response.json().await?;
+ Ok(status)
+ }
+
+ pub async fn favourite_status(&self, id: &str) -> Result {
+ let endpoint = format!("/api/v1/statuses/{}/favourite", id);
+ let response = self.post(&endpoint, None).await?;
+ let status: Status = response.json().await?;
+ Ok(status)
+ }
+
+ pub async fn unfavourite_status(&self, id: &str) -> Result {
+ let endpoint = format!("/api/v1/statuses/{}/unfavourite", id);
+ let response = self.post(&endpoint, None).await?;
+ let status: Status = response.json().await?;
+ Ok(status)
+ }
+
+ pub async fn reblog_status(&self, id: &str) -> Result {
+ let endpoint = format!("/api/v1/statuses/{}/reblog", id);
+ let response = self.post(&endpoint, None).await?;
+ let status: Status = response.json().await?;
+ Ok(status)
+ }
+
+ pub async fn unreblog_status(&self, id: &str) -> Result {
+ let endpoint = format!("/api/v1/statuses/{}/unreblog", id);
+ let response = self.post(&endpoint, None).await?;
+ let status: Status = response.json().await?;
+ Ok(status)
+ }
+
+ pub async fn get_notifications(&self, limit: Option) -> Result> {
+ let mut endpoint = "/api/v1/notifications".to_string();
+ if let Some(limit) = limit {
+ endpoint.push_str(&format!("?limit={}", limit));
+ }
+
+ let response = self.get(&endpoint).await?;
+ let notifications: Vec = response.json().await?;
+ Ok(notifications)
+ }
+
+ pub async fn search(&self, query: &str, resolve: bool) -> Result {
+ let mut endpoint = format!("/api/v2/search?q={}", urlencoding::encode(query));
+ if resolve {
+ endpoint.push_str("&resolve=true");
+ }
+
+ let response = self.get(&endpoint).await?;
+ let results: SearchResults = response.json().await?;
+ Ok(results)
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct SearchResults {
+ pub accounts: Vec,
+ pub statuses: Vec,
+ pub hashtags: Vec,
+}
\ No newline at end of file
diff --git a/src/api/mod.rs b/src/api/mod.rs
new file mode 100644
index 0000000..c93eff2
--- /dev/null
+++ b/src/api/mod.rs
@@ -0,0 +1,5 @@
+pub mod client;
+pub mod types;
+
+pub use client::MastodonClient;
+pub use types::*;
\ No newline at end of file
diff --git a/src/api/types.rs b/src/api/types.rs
new file mode 100644
index 0000000..fde56cf
--- /dev/null
+++ b/src/api/types.rs
@@ -0,0 +1,241 @@
+use serde::{Deserialize, Serialize};
+use chrono::{DateTime, Utc};
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Account {
+ pub id: String,
+ pub username: String,
+ pub acct: String,
+ pub display_name: String,
+ pub locked: bool,
+ pub bot: bool,
+ pub discoverable: Option,
+ pub group: bool,
+ pub created_at: DateTime,
+ pub note: String,
+ pub url: String,
+ pub avatar: String,
+ pub avatar_static: String,
+ pub header: String,
+ pub header_static: String,
+ pub followers_count: u64,
+ pub following_count: u64,
+ pub statuses_count: u64,
+ pub last_status_at: Option,
+ pub source: Option,
+ pub emojis: Vec,
+ pub fields: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct AccountSource {
+ pub privacy: String,
+ pub sensitive: bool,
+ pub language: Option,
+ pub note: String,
+ pub fields: Vec,
+ pub follow_requests_count: u64,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Field {
+ pub name: String,
+ pub value: String,
+ pub verified_at: Option>,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Status {
+ pub id: String,
+ pub created_at: DateTime,
+ pub in_reply_to_id: Option,
+ pub in_reply_to_account_id: Option,
+ pub sensitive: bool,
+ pub spoiler_text: String,
+ pub visibility: Visibility,
+ pub language: Option,
+ pub uri: String,
+ pub url: Option,
+ pub replies_count: u64,
+ pub reblogs_count: u64,
+ pub favourites_count: u64,
+ pub edited_at: Option>,
+ pub favourited: Option,
+ pub reblogged: Option,
+ pub muted: Option,
+ pub bookmarked: Option,
+ pub content: String,
+ pub reblog: Option>,
+ pub account: Account,
+ pub media_attachments: Vec,
+ pub mentions: Vec,
+ pub tags: Vec,
+ pub emojis: Vec,
+ pub card: Option,
+ pub poll: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum Visibility {
+ Public,
+ Unlisted,
+ Private,
+ Direct,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct MediaAttachment {
+ pub id: String,
+ #[serde(rename = "type")]
+ pub media_type: MediaType,
+ pub url: String,
+ pub preview_url: String,
+ pub remote_url: Option,
+ pub text_url: Option,
+ pub meta: Option,
+ pub description: Option,
+ pub blurhash: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "lowercase")]
+pub enum MediaType {
+ Unknown,
+ Image,
+ Gifv,
+ Video,
+ Audio,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct MediaMeta {
+ pub original: Option,
+ pub small: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct MediaInfo {
+ pub width: Option,
+ pub height: Option,
+ pub size: Option,
+ pub aspect: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Mention {
+ pub id: String,
+ pub username: String,
+ pub url: String,
+ pub acct: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Tag {
+ pub name: String,
+ pub url: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Emoji {
+ pub shortcode: String,
+ pub url: String,
+ pub static_url: String,
+ pub visible_in_picker: bool,
+ pub category: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct PreviewCard {
+ pub url: String,
+ pub title: String,
+ pub description: String,
+ #[serde(rename = "type")]
+ pub card_type: String,
+ pub author_name: String,
+ pub author_url: String,
+ pub provider_name: String,
+ pub provider_url: String,
+ pub html: String,
+ pub width: u32,
+ pub height: u32,
+ pub image: Option,
+ pub embed_url: String,
+ pub blurhash: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Poll {
+ pub id: String,
+ pub expires_at: Option>,
+ pub expired: bool,
+ pub multiple: bool,
+ pub votes_count: u64,
+ pub voters_count: Option,
+ pub voted: Option,
+ pub own_votes: Option>,
+ pub options: Vec,
+ pub emojis: Vec,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct PollOption {
+ pub title: String,
+ pub votes_count: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Application {
+ pub name: String,
+ pub website: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct AppRegistration {
+ pub id: String,
+ pub name: String,
+ pub website: Option,
+ pub redirect_uri: String,
+ pub client_id: String,
+ pub client_secret: String,
+ pub vapid_key: String,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct AccessToken {
+ pub access_token: String,
+ pub token_type: String,
+ pub scope: String,
+ pub created_at: u64,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Notification {
+ pub id: String,
+ #[serde(rename = "type")]
+ pub notification_type: NotificationType,
+ pub created_at: DateTime,
+ pub account: Account,
+ pub status: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+#[serde(rename_all = "snake_case")]
+pub enum NotificationType {
+ Follow,
+ FollowRequest,
+ Mention,
+ Reblog,
+ Favourite,
+ Poll,
+ Status,
+ Update,
+ AdminSignUp,
+ AdminReport,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct Context {
+ pub ancestors: Vec,
+ pub descendants: Vec,
+}
\ No newline at end of file
diff --git a/src/config/mod.rs b/src/config/mod.rs
new file mode 100644
index 0000000..fb0050a
--- /dev/null
+++ b/src/config/mod.rs
@@ -0,0 +1,77 @@
+use anyhow::{anyhow, Result};
+use serde::{Deserialize, Serialize};
+use std::path::PathBuf;
+use tokio::fs;
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct Config {
+ pub instance_url: String,
+ pub client_id: String,
+ pub client_secret: String,
+ pub access_token: Option,
+ pub username: Option,
+}
+
+impl Config {
+ pub fn new(instance_url: String, client_id: String, client_secret: String) -> Self {
+ Self {
+ instance_url,
+ client_id,
+ client_secret,
+ access_token: None,
+ username: None,
+ }
+ }
+
+ pub fn with_token(
+ instance_url: String,
+ client_id: String,
+ client_secret: String,
+ access_token: String,
+ username: Option,
+ ) -> Self {
+ Self {
+ instance_url,
+ client_id,
+ client_secret,
+ access_token: Some(access_token),
+ username,
+ }
+ }
+
+ pub async fn load() -> Result {
+ let config_path = Self::config_path()?;
+
+ if !config_path.exists() {
+ return Err(anyhow!("Config file not found. Run 'mastui auth' to authenticate."));
+ }
+
+ let content = fs::read_to_string(&config_path).await?;
+ let config: Config = serde_json::from_str(&content)?;
+ Ok(config)
+ }
+
+ pub async fn save(&self) -> Result<()> {
+ let config_path = Self::config_path()?;
+
+ if let Some(parent) = config_path.parent() {
+ fs::create_dir_all(parent).await?;
+ }
+
+ let content = serde_json::to_string_pretty(self)?;
+ fs::write(&config_path, content).await?;
+ Ok(())
+ }
+
+ fn config_path() -> Result {
+ let home = std::env::var("HOME")
+ .or_else(|_| std::env::var("USERPROFILE"))
+ .map_err(|_| anyhow!("Unable to determine home directory"))?;
+
+ Ok(PathBuf::from(home).join(".config").join("mastui").join("config.json"))
+ }
+
+ pub fn is_authenticated(&self) -> bool {
+ self.access_token.is_some()
+ }
+}
\ No newline at end of file
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..11171d8
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,178 @@
+mod api;
+mod config;
+mod ui;
+
+use anyhow::{anyhow, Result};
+use api::MastodonClient;
+use clap::{Parser, Subcommand};
+use config::Config;
+use std::io::{self, Write};
+use ui::{app::App, run_tui};
+
+#[derive(Parser)]
+#[command(name = "mastui")]
+#[command(about = "A TUI client for Mastodon")]
+struct Cli {
+ #[command(subcommand)]
+ command: Option,
+}
+
+#[derive(Subcommand)]
+enum Commands {
+ Auth {
+ #[arg(help = "Mastodon instance URL (e.g., mastodon.social)")]
+ instance: String,
+ },
+ Timeline,
+ Post {
+ #[arg(help = "Content of the post")]
+ content: String,
+ },
+}
+
+#[tokio::main]
+async fn main() -> Result<()> {
+ let cli = Cli::parse();
+
+ match cli.command {
+ Some(Commands::Auth { instance }) => {
+ auth_flow(instance).await?;
+ }
+ Some(Commands::Timeline) => {
+ let config = Config::load().await?;
+ if !config.is_authenticated() {
+ eprintln!("Not authenticated. Run 'mastui auth ' first.");
+ return Ok(());
+ }
+
+ let client = MastodonClient::with_token(
+ config.instance_url,
+ config.access_token.unwrap(),
+ );
+
+ let app = App::new(client);
+ run_tui(app).await?;
+ }
+ Some(Commands::Post { content }) => {
+ let config = Config::load().await?;
+ if !config.is_authenticated() {
+ eprintln!("Not authenticated. Run 'mastui auth ' first.");
+ return Ok(());
+ }
+
+ let client = MastodonClient::with_token(
+ config.instance_url,
+ config.access_token.unwrap(),
+ );
+
+ match client.post_status(&content, None, None).await {
+ Ok(status) => println!("Posted: {}", status.url.unwrap_or(status.uri)),
+ Err(e) => eprintln!("Failed to post: {}", e),
+ }
+ }
+ None => {
+ // Default to timeline view
+ match Config::load().await {
+ Ok(config) => {
+ if !config.is_authenticated() {
+ eprintln!("Not authenticated. Run 'mastui auth ' first.");
+ return Ok(());
+ }
+
+ let client = MastodonClient::with_token(
+ config.instance_url,
+ config.access_token.unwrap(),
+ );
+
+ let app = App::new(client);
+ run_tui(app).await?;
+ }
+ Err(_) => {
+ eprintln!("No configuration found. Run 'mastui auth ' to get started.");
+ }
+ }
+ }
+ }
+
+ Ok(())
+}
+
+async fn auth_flow(instance: String) -> Result<()> {
+ let instance_url = if instance.starts_with("http") {
+ instance
+ } else {
+ format!("https://{}", instance)
+ };
+
+ println!("Authenticating with {}...", instance_url);
+
+ let client = MastodonClient::new(instance_url.clone());
+
+ println!("Registering application...");
+ let app_registration = client
+ .register_app(
+ "Mastui",
+ "urn:ietf:wg:oauth:2.0:oob",
+ "read write follow push",
+ Some("https://github.com/yourusername/mastui"),
+ )
+ .await?;
+
+ println!("Application registered successfully!");
+
+ let auth_url = client.get_auth_url(
+ &app_registration.client_id,
+ "urn:ietf:wg:oauth:2.0:oob",
+ "read write follow push",
+ )?;
+
+ println!("\nPlease visit the following URL to authorize the application:");
+ println!("{}", auth_url);
+ println!();
+
+ print!("Enter the authorization code: ");
+ io::stdout().flush()?;
+
+ let mut code = String::new();
+ io::stdin().read_line(&mut code)?;
+ let code = code.trim();
+
+ if code.is_empty() {
+ return Err(anyhow!("No authorization code provided"));
+ }
+
+ println!("Exchanging authorization code for access token...");
+ let access_token = client
+ .get_access_token(
+ &app_registration.client_id,
+ &app_registration.client_secret,
+ "urn:ietf:wg:oauth:2.0:oob",
+ code,
+ )
+ .await?;
+
+ let authenticated_client = MastodonClient::with_token(
+ instance_url.clone(),
+ access_token.access_token.clone(),
+ );
+
+ println!("Verifying credentials...");
+ let account = authenticated_client.verify_credentials().await?;
+
+ let config = Config::with_token(
+ instance_url,
+ app_registration.client_id,
+ app_registration.client_secret,
+ access_token.access_token,
+ Some(account.username.clone()),
+ );
+
+ config.save().await?;
+
+ println!("Authentication successful!");
+ println!("Logged in as: {} (@{})", account.display_name, account.acct);
+ println!();
+ println!("You can now run 'mastui' or 'mastui timeline' to start using the TUI.");
+
+ Ok(())
+}
diff --git a/src/ui/app.rs b/src/ui/app.rs
new file mode 100644
index 0000000..e08ac21
--- /dev/null
+++ b/src/ui/app.rs
@@ -0,0 +1,296 @@
+use crate::api::{MastodonClient, Status, Notification};
+use anyhow::Result;
+use crossterm::event::KeyEvent;
+use std::sync::Arc;
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum AppMode {
+ Timeline,
+ Compose,
+ StatusDetail,
+ Notifications,
+ Search,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub enum TimelineType {
+ Home,
+ Local,
+ Federated,
+}
+
+pub struct App {
+ pub client: Arc,
+ pub mode: AppMode,
+ pub timeline_type: TimelineType,
+ pub statuses: Vec,
+ pub notifications: Vec,
+ pub selected_status_index: usize,
+ pub status_scroll: usize,
+ pub compose_text: String,
+ pub reply_to_id: Option,
+ pub search_query: String,
+ pub running: bool,
+ pub loading: bool,
+ pub error_message: Option,
+}
+
+impl App {
+ pub fn new(client: MastodonClient) -> Self {
+ Self {
+ client: Arc::new(client),
+ mode: AppMode::Timeline,
+ timeline_type: TimelineType::Home,
+ statuses: Vec::new(),
+ notifications: Vec::new(),
+ selected_status_index: 0,
+ status_scroll: 0,
+ compose_text: String::new(),
+ reply_to_id: None,
+ search_query: String::new(),
+ running: true,
+ loading: false,
+ error_message: None,
+ }
+ }
+
+ pub async fn load_timeline(&mut self) -> Result<()> {
+ self.loading = true;
+ self.error_message = None;
+
+ let result = match self.timeline_type {
+ TimelineType::Home => self.client.get_home_timeline(Some(40)).await,
+ TimelineType::Local => self.client.get_public_timeline(true, Some(40)).await,
+ TimelineType::Federated => self.client.get_public_timeline(false, Some(40)).await,
+ };
+
+ match result {
+ Ok(new_statuses) => {
+ self.statuses = new_statuses;
+ self.selected_status_index = 0;
+ self.status_scroll = 0;
+ }
+ Err(e) => {
+ self.error_message = Some(format!("Failed to load timeline: {}", e));
+ }
+ }
+
+ self.loading = false;
+ Ok(())
+ }
+
+ pub async fn load_notifications(&mut self) -> Result<()> {
+ self.loading = true;
+ self.error_message = None;
+
+ match self.client.get_notifications(Some(40)).await {
+ Ok(new_notifications) => {
+ self.notifications = new_notifications;
+ }
+ Err(e) => {
+ self.error_message = Some(format!("Failed to load notifications: {}", e));
+ }
+ }
+
+ self.loading = false;
+ Ok(())
+ }
+
+ pub async fn post_status(&mut self) -> Result<()> {
+ if self.compose_text.trim().is_empty() {
+ return Ok(());
+ }
+
+ self.loading = true;
+ self.error_message = None;
+
+ let result = self
+ .client
+ .post_status(&self.compose_text, self.reply_to_id.as_deref(), None)
+ .await;
+
+ match result {
+ Ok(_) => {
+ self.compose_text.clear();
+ self.reply_to_id = None;
+ self.mode = AppMode::Timeline;
+ self.load_timeline().await?;
+ }
+ Err(e) => {
+ self.error_message = Some(format!("Failed to post status: {}", e));
+ }
+ }
+
+ self.loading = false;
+ Ok(())
+ }
+
+ pub async fn toggle_favourite(&mut self) -> Result<()> {
+ if let Some(status) = self.statuses.get(self.selected_status_index) {
+ let status_id = status.id.clone();
+ let is_favourited = status.favourited.unwrap_or(false);
+
+ let result = if is_favourited {
+ self.client.unfavourite_status(&status_id).await
+ } else {
+ self.client.favourite_status(&status_id).await
+ };
+
+ if let Ok(updated_status) = result {
+ if let Some(status) = self.statuses.get_mut(self.selected_status_index) {
+ *status = updated_status;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ pub async fn toggle_reblog(&mut self) -> Result<()> {
+ if let Some(status) = self.statuses.get(self.selected_status_index) {
+ let status_id = status.id.clone();
+ let is_reblogged = status.reblogged.unwrap_or(false);
+
+ let result = if is_reblogged {
+ self.client.unreblog_status(&status_id).await
+ } else {
+ self.client.reblog_status(&status_id).await
+ };
+
+ if let Ok(updated_status) = result {
+ if let Some(status) = self.statuses.get_mut(self.selected_status_index) {
+ *status = updated_status;
+ }
+ }
+ }
+ Ok(())
+ }
+
+ pub fn start_reply(&mut self) {
+ if let Some(status) = self.statuses.get(self.selected_status_index) {
+ self.reply_to_id = Some(status.id.clone());
+ self.compose_text = format!("@{} ", status.account.acct);
+ self.mode = AppMode::Compose;
+ }
+ }
+
+ pub fn next_status(&mut self) {
+ if self.selected_status_index < self.statuses.len().saturating_sub(1) {
+ self.selected_status_index += 1;
+ }
+ }
+
+ pub fn previous_status(&mut self) {
+ if self.selected_status_index > 0 {
+ self.selected_status_index -= 1;
+ }
+ }
+
+ pub fn next_timeline(&mut self) {
+ self.timeline_type = match self.timeline_type {
+ TimelineType::Home => TimelineType::Local,
+ TimelineType::Local => TimelineType::Federated,
+ TimelineType::Federated => TimelineType::Home,
+ };
+ }
+
+ pub fn quit(&mut self) {
+ self.running = false;
+ }
+
+ pub fn handle_key_event(&mut self, key: KeyEvent) {
+ match self.mode {
+ AppMode::Timeline => self.handle_timeline_key(key),
+ AppMode::Compose => self.handle_compose_key(key),
+ AppMode::StatusDetail => self.handle_detail_key(key),
+ AppMode::Notifications => self.handle_notifications_key(key),
+ AppMode::Search => self.handle_search_key(key),
+ }
+ }
+
+ fn handle_timeline_key(&mut self, key: KeyEvent) {
+ use crossterm::event::{KeyCode, KeyModifiers};
+
+ match (key.code, key.modifiers) {
+ (KeyCode::Char('q'), _) => self.quit(),
+ (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.quit(),
+ (KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.next_status(),
+ (KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.previous_status(),
+ (KeyCode::Char('r'), _) => self.start_reply(),
+ (KeyCode::Char('n'), _) => {
+ self.compose_text.clear();
+ self.reply_to_id = None;
+ self.mode = AppMode::Compose;
+ }
+ (KeyCode::Char('t'), _) => self.next_timeline(),
+ (KeyCode::Char('m'), _) => self.mode = AppMode::Notifications,
+ (KeyCode::Enter, _) => self.mode = AppMode::StatusDetail,
+ _ => {}
+ }
+ }
+
+ fn handle_compose_key(&mut self, key: KeyEvent) {
+ use crossterm::event::{KeyCode, KeyModifiers};
+
+ match (key.code, key.modifiers) {
+ (KeyCode::Esc, _) => {
+ self.mode = AppMode::Timeline;
+ if self.reply_to_id.is_none() {
+ self.compose_text.clear();
+ }
+ }
+ (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
+ self.mode = AppMode::Timeline;
+ if self.reply_to_id.is_none() {
+ self.compose_text.clear();
+ }
+ }
+ (KeyCode::Char(c), _) => {
+ self.compose_text.push(c);
+ }
+ (KeyCode::Backspace, _) => {
+ self.compose_text.pop();
+ }
+ (KeyCode::Enter, KeyModifiers::CONTROL) => {
+ // Post the status - will be handled by the main loop
+ }
+ _ => {}
+ }
+ }
+
+ fn handle_detail_key(&mut self, key: KeyEvent) {
+ use crossterm::event::{KeyCode, KeyModifiers};
+
+ match (key.code, key.modifiers) {
+ (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.mode = AppMode::Timeline,
+ (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline,
+ (KeyCode::Char('r'), _) => self.start_reply(),
+ _ => {}
+ }
+ }
+
+ fn handle_notifications_key(&mut self, key: KeyEvent) {
+ use crossterm::event::{KeyCode, KeyModifiers};
+
+ match (key.code, key.modifiers) {
+ (KeyCode::Esc, _) | (KeyCode::Char('q'), _) => self.mode = AppMode::Timeline,
+ (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline,
+ _ => {}
+ }
+ }
+
+ fn handle_search_key(&mut self, key: KeyEvent) {
+ use crossterm::event::{KeyCode, KeyModifiers};
+
+ match (key.code, key.modifiers) {
+ (KeyCode::Esc, _) => self.mode = AppMode::Timeline,
+ (KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline,
+ (KeyCode::Char(c), _) => {
+ self.search_query.push(c);
+ }
+ (KeyCode::Backspace, _) => {
+ self.search_query.pop();
+ }
+ _ => {}
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/ui/compose.rs b/src/ui/compose.rs
new file mode 100644
index 0000000..339c7ad
--- /dev/null
+++ b/src/ui/compose.rs
@@ -0,0 +1,94 @@
+use crate::ui::app::App;
+use ratatui::{
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Color, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Paragraph, Wrap},
+ Frame,
+};
+
+pub fn render_compose(f: &mut Frame, app: &App, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3), // Header
+ Constraint::Min(5), // Compose area
+ Constraint::Length(3), // Instructions
+ ].as_ref())
+ .split(area);
+
+ render_compose_header(f, app, chunks[0]);
+ render_compose_area(f, app, chunks[1]);
+ render_compose_instructions(f, chunks[2]);
+}
+
+fn render_compose_header(f: &mut Frame, app: &App, area: Rect) {
+ let title = if app.reply_to_id.is_some() {
+ "Compose Reply"
+ } else {
+ "Compose New Post"
+ };
+
+ let header = Paragraph::new(title)
+ .block(Block::default().borders(Borders::ALL).title("Mastui"))
+ .style(Style::default().fg(Color::Green));
+
+ f.render_widget(header, area);
+}
+
+fn render_compose_area(f: &mut Frame, app: &App, area: Rect) {
+ let char_count = app.compose_text.chars().count();
+ let char_limit = 500; // Mastodon's default character limit
+
+ let char_style = if char_count > char_limit {
+ Style::default().fg(Color::Red)
+ } else if char_count > char_limit - 50 {
+ Style::default().fg(Color::Yellow)
+ } else {
+ Style::default().fg(Color::Gray)
+ };
+
+ let title = format!("Content ({}/{})", char_count, char_limit);
+
+ let compose_text = if app.compose_text.is_empty() {
+ "Start typing your post...".to_string()
+ } else {
+ app.compose_text.clone()
+ };
+
+ let text_style = if app.compose_text.is_empty() {
+ Style::default().fg(Color::Gray)
+ } else {
+ Style::default().fg(Color::White)
+ };
+
+ let paragraph = Paragraph::new(compose_text)
+ .block(
+ Block::default()
+ .borders(Borders::ALL)
+ .title(Line::from(vec![
+ Span::styled(title, char_style),
+ ]))
+ )
+ .style(text_style)
+ .wrap(Wrap { trim: false });
+
+ f.render_widget(paragraph, area);
+}
+
+fn render_compose_instructions(f: &mut Frame, area: Rect) {
+ let instructions = vec![
+ Line::from(vec![
+ Span::styled("Ctrl+Enter", Style::default().fg(Color::Cyan)),
+ Span::raw(" to post • "),
+ Span::styled("Escape", Style::default().fg(Color::Cyan)),
+ Span::raw(" to cancel"),
+ ]),
+ ];
+
+ let paragraph = Paragraph::new(instructions)
+ .block(Block::default().borders(Borders::ALL).title("Instructions"))
+ .style(Style::default().fg(Color::Gray));
+
+ f.render_widget(paragraph, area);
+}
\ No newline at end of file
diff --git a/src/ui/detail.rs b/src/ui/detail.rs
new file mode 100644
index 0000000..9e16d7a
--- /dev/null
+++ b/src/ui/detail.rs
@@ -0,0 +1,176 @@
+use crate::ui::app::App;
+use ratatui::{
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span, Text},
+ widgets::{Block, Borders, Paragraph, Wrap},
+ Frame,
+};
+
+pub fn render_status_detail(f: &mut Frame, app: &App, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([
+ Constraint::Length(3), // Header
+ Constraint::Min(0), // Content
+ Constraint::Length(3), // Instructions
+ ].as_ref())
+ .split(area);
+
+ render_detail_header(f, chunks[0]);
+ render_detail_content(f, app, chunks[1]);
+ render_detail_instructions(f, chunks[2]);
+}
+
+fn render_detail_header(f: &mut Frame, area: Rect) {
+ let header = Paragraph::new("Status Detail")
+ .block(Block::default().borders(Borders::ALL).title("Mastui"))
+ .style(Style::default().fg(Color::Cyan));
+
+ f.render_widget(header, area);
+}
+
+fn render_detail_content(f: &mut Frame, app: &App, area: Rect) {
+ if let Some(status) = app.statuses.get(app.selected_status_index) {
+ let display_status = if let Some(reblog) = &status.reblog {
+ reblog.as_ref()
+ } else {
+ status
+ };
+
+ let mut lines = Vec::new();
+
+ if status.reblog.is_some() {
+ lines.push(Line::from(vec![
+ Span::styled("🔄 Reblogged by ", Style::default().fg(Color::Green)),
+ Span::styled(
+ &status.account.display_name,
+ Style::default().fg(Color::Green).add_modifier(Modifier::BOLD),
+ ),
+ ]));
+ lines.push(Line::from(""));
+ }
+
+ lines.push(Line::from(vec![
+ Span::styled(
+ &display_status.account.display_name,
+ Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(" "),
+ Span::styled(
+ format!("@{}", display_status.account.acct),
+ Style::default().fg(Color::Gray),
+ ),
+ ]));
+
+ lines.push(Line::from(Span::styled(
+ format_detailed_time(&display_status.created_at),
+ Style::default().fg(Color::Gray),
+ )));
+
+ lines.push(Line::from(""));
+
+ let content = strip_html(&display_status.content);
+ for line in content.lines() {
+ lines.push(Line::from(Span::raw(line)));
+ }
+
+ if !display_status.media_attachments.is_empty() {
+ lines.push(Line::from(""));
+ lines.push(Line::from(Span::styled(
+ "Media Attachments:",
+ Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
+ )));
+
+ for (i, attachment) in display_status.media_attachments.iter().enumerate() {
+ lines.push(Line::from(vec![
+ Span::styled(format!("{}. ", i + 1), Style::default().fg(Color::Gray)),
+ Span::raw(format!("{:?}: ", attachment.media_type)),
+ Span::styled(&attachment.url, Style::default().fg(Color::Blue)),
+ ]));
+
+ if let Some(description) = &attachment.description {
+ lines.push(Line::from(vec![
+ Span::raw(" Alt text: "),
+ Span::styled(description, Style::default().fg(Color::Gray)),
+ ]));
+ }
+ }
+ }
+
+ lines.push(Line::from(""));
+
+ let mut stats = Vec::new();
+ stats.push(format!("💬 {} replies", display_status.replies_count));
+ stats.push(format!("🔄 {} reblogs", display_status.reblogs_count));
+ stats.push(format!("⭐ {} favourites", display_status.favourites_count));
+
+ lines.push(Line::from(Span::styled(
+ stats.join(" • "),
+ Style::default().fg(Color::Gray),
+ )));
+
+ if display_status.favourited.unwrap_or(false) {
+ lines.push(Line::from(Span::styled(
+ "⭐ You favourited this post",
+ Style::default().fg(Color::Yellow),
+ )));
+ }
+
+ if display_status.reblogged.unwrap_or(false) {
+ lines.push(Line::from(Span::styled(
+ "🔄 You reblogged this post",
+ Style::default().fg(Color::Green),
+ )));
+ }
+
+ let paragraph = Paragraph::new(Text::from(lines))
+ .block(Block::default().borders(Borders::ALL))
+ .wrap(Wrap { trim: false });
+
+ f.render_widget(paragraph, area);
+ } else {
+ let paragraph = Paragraph::new("No status selected")
+ .block(Block::default().borders(Borders::ALL))
+ .style(Style::default().fg(Color::Yellow));
+
+ f.render_widget(paragraph, area);
+ }
+}
+
+fn render_detail_instructions(f: &mut Frame, area: Rect) {
+ let instructions = vec![
+ Line::from(vec![
+ Span::styled("r", Style::default().fg(Color::Cyan)),
+ Span::raw(" to reply • "),
+ Span::styled("Escape/q", Style::default().fg(Color::Cyan)),
+ Span::raw(" to go back"),
+ ]),
+ ];
+
+ let paragraph = Paragraph::new(instructions)
+ .block(Block::default().borders(Borders::ALL).title("Instructions"))
+ .style(Style::default().fg(Color::Gray));
+
+ f.render_widget(paragraph, area);
+}
+
+fn strip_html(html: &str) -> String {
+ html.replace("
", "\n")
+ .replace("
", "\n")
+ .replace("
", "\n")
+ .replace("
", "\n\n")
+ .replace("", "")
+ .chars()
+ .filter(|&c| c != '<' && c != '>')
+ .collect::()
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&")
+ .replace(""", "\"")
+ .replace("'", "'")
+}
+
+fn format_detailed_time(time: &chrono::DateTime) -> String {
+ time.format("%Y-%m-%d %H:%M:%S UTC").to_string()
+}
\ No newline at end of file
diff --git a/src/ui/mod.rs b/src/ui/mod.rs
new file mode 100644
index 0000000..6d4c970
--- /dev/null
+++ b/src/ui/mod.rs
@@ -0,0 +1,214 @@
+pub mod app;
+pub mod compose;
+pub mod detail;
+pub mod timeline;
+
+use crate::ui::app::{App, AppMode};
+use anyhow::Result;
+use crossterm::{
+ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
+ execute,
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
+};
+use ratatui::{
+ backend::{Backend, CrosstermBackend},
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Color, Style},
+ text::{Line, Span},
+ widgets::{Block, Borders, Paragraph},
+ Frame, Terminal,
+};
+use std::io;
+use std::time::Duration;
+
+pub async fn run_tui(mut app: App) -> Result<()> {
+ enable_raw_mode()?;
+ let mut stdout = io::stdout();
+ execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
+ let backend = CrosstermBackend::new(stdout);
+ let mut terminal = Terminal::new(backend)?;
+
+ app.load_timeline().await?;
+
+ let result = run_app(&mut terminal, &mut app).await;
+
+ disable_raw_mode()?;
+ execute!(
+ terminal.backend_mut(),
+ LeaveAlternateScreen,
+ DisableMouseCapture
+ )?;
+ terminal.show_cursor()?;
+
+ result
+}
+
+async fn run_app(terminal: &mut Terminal, app: &mut App) -> Result<()> {
+ loop {
+ terminal.draw(|f| ui(f, app))?;
+
+ if !app.running {
+ break;
+ }
+
+ let event_available = event::poll(Duration::from_millis(100))?;
+
+ if event_available {
+ let event = event::read()?;
+ match event {
+ Event::Key(key) => {
+ match (key.code, key.modifiers) {
+ (KeyCode::Enter, KeyModifiers::CONTROL) if app.mode == AppMode::Compose => {
+ app.post_status().await?;
+ }
+ (KeyCode::Char('f'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => {
+ app.toggle_favourite().await?;
+ }
+ (KeyCode::Char('b'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => {
+ app.toggle_reblog().await?;
+ }
+ (KeyCode::F(5), _) => {
+ if app.mode == AppMode::Timeline {
+ app.load_timeline().await?;
+ } else if app.mode == AppMode::Notifications {
+ app.load_notifications().await?;
+ }
+ }
+ _ => {
+ app.handle_key_event(key);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ }
+
+ Ok(())
+}
+
+fn ui(f: &mut Frame, app: &App) {
+ let size = f.area();
+
+ match app.mode {
+ AppMode::Timeline => {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Min(0), Constraint::Length(3)].as_ref())
+ .split(size);
+
+ timeline::render_timeline(f, app, chunks[0]);
+ render_help(f, chunks[1]);
+ }
+ AppMode::Compose => {
+ compose::render_compose(f, app, size);
+ }
+ AppMode::StatusDetail => {
+ detail::render_status_detail(f, app, size);
+ }
+ AppMode::Notifications => {
+ render_notifications(f, app, size);
+ }
+ AppMode::Search => {
+ render_search(f, app, size);
+ }
+ }
+}
+
+fn render_help(f: &mut Frame, area: Rect) {
+ let help_text = vec![
+ Line::from(vec![
+ Span::styled("j/k", Style::default().fg(Color::Cyan)),
+ Span::raw("/"),
+ Span::styled("↑↓", Style::default().fg(Color::Cyan)),
+ Span::raw(" navigate • "),
+ Span::styled("r", Style::default().fg(Color::Cyan)),
+ Span::raw(" reply • "),
+ Span::styled("n", Style::default().fg(Color::Cyan)),
+ Span::raw(" new post • "),
+ Span::styled("t", Style::default().fg(Color::Cyan)),
+ Span::raw(" timeline • "),
+ Span::styled("Enter", Style::default().fg(Color::Cyan)),
+ Span::raw(" detail • "),
+ Span::styled("Ctrl+f", Style::default().fg(Color::Cyan)),
+ Span::raw(" fav • "),
+ Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)),
+ Span::raw(" boost • "),
+ Span::styled("F5", Style::default().fg(Color::Cyan)),
+ Span::raw(" refresh • "),
+ Span::styled("q", Style::default().fg(Color::Cyan)),
+ Span::raw(" quit"),
+ ]),
+ ];
+
+ let paragraph = Paragraph::new(help_text)
+ .block(Block::default().borders(Borders::ALL).title("Help"))
+ .style(Style::default().fg(Color::Gray));
+
+ f.render_widget(paragraph, area);
+}
+
+fn render_notifications(f: &mut Frame, app: &App, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
+ .split(area);
+
+ let header = Paragraph::new("Notifications")
+ .block(Block::default().borders(Borders::ALL).title("Mastui"))
+ .style(Style::default().fg(Color::Magenta));
+
+ f.render_widget(header, chunks[0]);
+
+ let notifications = &app.notifications;
+
+ if notifications.is_empty() {
+ let paragraph = Paragraph::new("No notifications")
+ .block(Block::default().borders(Borders::ALL))
+ .style(Style::default().fg(Color::Yellow));
+
+ f.render_widget(paragraph, chunks[1]);
+ } else {
+ let content = notifications
+ .iter()
+ .map(|notif| {
+ format!(
+ "{:?}: {} ({})",
+ notif.notification_type,
+ notif.account.display_name,
+ format_time(¬if.created_at)
+ )
+ })
+ .collect::>()
+ .join("\n");
+
+ let paragraph = Paragraph::new(content)
+ .block(Block::default().borders(Borders::ALL))
+ .wrap(ratatui::widgets::Wrap { trim: true });
+
+ f.render_widget(paragraph, chunks[1]);
+ }
+}
+
+fn render_search(f: &mut Frame, _app: &App, _area: Rect) {
+ let paragraph = Paragraph::new("Search functionality coming soon!")
+ .block(Block::default().borders(Borders::ALL).title("Search"))
+ .style(Style::default().fg(Color::Yellow));
+
+ f.render_widget(paragraph, _area);
+}
+
+fn format_time(time: &chrono::DateTime) -> String {
+ let now = chrono::Utc::now();
+ let duration = now.signed_duration_since(*time);
+
+ if duration.num_days() > 0 {
+ format!("{}d", duration.num_days())
+ } else if duration.num_hours() > 0 {
+ format!("{}h", duration.num_hours())
+ } else if duration.num_minutes() > 0 {
+ format!("{}m", duration.num_minutes())
+ } else {
+ "now".to_string()
+ }
+}
\ No newline at end of file
diff --git a/src/ui/timeline.rs b/src/ui/timeline.rs
new file mode 100644
index 0000000..770fe19
--- /dev/null
+++ b/src/ui/timeline.rs
@@ -0,0 +1,189 @@
+use crate::api::Status;
+use crate::ui::app::{App, TimelineType};
+use ratatui::{
+ layout::{Constraint, Direction, Layout, Rect},
+ style::{Color, Modifier, Style},
+ text::{Line, Span, Text},
+ widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap},
+ Frame,
+};
+
+pub fn render_timeline(f: &mut Frame, app: &App, area: Rect) {
+ let chunks = Layout::default()
+ .direction(Direction::Vertical)
+ .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
+ .split(area);
+
+ render_header(f, app, chunks[0]);
+ render_status_list(f, app, chunks[1]);
+}
+
+fn render_header(f: &mut Frame, app: &App, area: Rect) {
+ let timeline_name = match app.timeline_type {
+ TimelineType::Home => "Home",
+ TimelineType::Local => "Local",
+ TimelineType::Federated => "Federated",
+ };
+
+ let header_text = if app.loading {
+ format!("{} Timeline (Loading...)", timeline_name)
+ } else {
+ format!("{} Timeline", timeline_name)
+ };
+
+ let header = Paragraph::new(header_text)
+ .block(Block::default().borders(Borders::ALL).title("Mastui"))
+ .style(Style::default().fg(Color::Cyan));
+
+ f.render_widget(header, area);
+}
+
+fn render_status_list(f: &mut Frame, app: &App, area: Rect) {
+ let statuses = &app.statuses;
+
+ if statuses.is_empty() && !app.loading {
+ let empty_msg = if let Some(error) = &app.error_message {
+ format!("Error: {}", error)
+ } else {
+ "No statuses to display".to_string()
+ };
+
+ let paragraph = Paragraph::new(empty_msg)
+ .block(Block::default().borders(Borders::ALL))
+ .wrap(Wrap { trim: true })
+ .style(Style::default().fg(Color::Yellow));
+
+ f.render_widget(paragraph, area);
+ return;
+ }
+
+ let items: Vec = statuses
+ .iter()
+ .enumerate()
+ .map(|(i, status)| format_status(status, i == app.selected_status_index))
+ .collect();
+
+ let mut list_state = ListState::default();
+ list_state.select(Some(app.selected_status_index));
+
+ let list = List::new(items)
+ .block(Block::default().borders(Borders::ALL))
+ .highlight_style(Style::default().bg(Color::DarkGray).add_modifier(Modifier::BOLD))
+ .highlight_symbol("▶ ");
+
+ f.render_stateful_widget(list, area, &mut list_state);
+}
+
+fn format_status(status: &Status, is_selected: bool) -> ListItem {
+ let display_status = if let Some(reblog) = &status.reblog {
+ reblog.as_ref()
+ } else {
+ status
+ };
+
+ let mut lines = Vec::new();
+
+ if status.reblog.is_some() {
+ lines.push(Line::from(vec![
+ Span::styled("🔄 ", Style::default().fg(Color::Green)),
+ Span::styled(
+ format!("{} reblogged", status.account.display_name),
+ Style::default().fg(Color::Green),
+ ),
+ ]));
+ }
+
+ let username_style = if is_selected {
+ Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(Color::Cyan)
+ };
+
+ lines.push(Line::from(vec![
+ Span::styled(
+ format!("@{}", display_status.account.acct),
+ username_style,
+ ),
+ Span::raw(" • "),
+ Span::styled(
+ format_time(&display_status.created_at),
+ Style::default().fg(Color::Gray),
+ ),
+ ]));
+
+ let content = strip_html(&display_status.content);
+ let content_lines: Vec = textwrap::wrap(&content, 70)
+ .into_iter()
+ .map(|cow| cow.into_owned())
+ .collect();
+
+ for line in content_lines.iter().take(3) {
+ lines.push(Line::from(Span::raw(line.clone())));
+ }
+
+ if content_lines.len() > 3 {
+ lines.push(Line::from(Span::styled("...", Style::default().fg(Color::Gray))));
+ }
+
+ let mut stats = Vec::new();
+
+ if display_status.replies_count > 0 {
+ stats.push(format!("💬 {}", display_status.replies_count));
+ }
+
+ if display_status.reblogs_count > 0 {
+ stats.push(format!("🔄 {}", display_status.reblogs_count));
+ }
+
+ if display_status.favourites_count > 0 {
+ stats.push(format!("⭐ {}", display_status.favourites_count));
+ }
+
+ if !stats.is_empty() {
+ lines.push(Line::from(Span::styled(
+ stats.join(" • "),
+ Style::default().fg(Color::Gray),
+ )));
+ }
+
+ if !display_status.media_attachments.is_empty() {
+ lines.push(Line::from(Span::styled(
+ format!("📎 {} attachment(s)", display_status.media_attachments.len()),
+ Style::default().fg(Color::Magenta),
+ )));
+ }
+
+ lines.push(Line::from(Span::raw("")));
+
+ ListItem::new(Text::from(lines))
+}
+
+fn strip_html(html: &str) -> String {
+ html.replace("
", "\n")
+ .replace("
", "\n")
+ .replace("
", "\n")
+ .replace("
", "\n")
+ .chars()
+ .filter(|&c| c != '<' && c != '>')
+ .collect::()
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("&", "&")
+ .replace(""", "\"")
+ .replace("'", "'")
+}
+
+fn format_time(time: &chrono::DateTime) -> String {
+ let now = chrono::Utc::now();
+ let duration = now.signed_duration_since(*time);
+
+ if duration.num_days() > 0 {
+ format!("{}d", duration.num_days())
+ } else if duration.num_hours() > 0 {
+ format!("{}h", duration.num_hours())
+ } else if duration.num_minutes() > 0 {
+ format!("{}m", duration.num_minutes())
+ } else {
+ "now".to_string()
+ }
+}
\ No newline at end of file