diff --git a/Cargo.lock b/Cargo.lock index a9c802c..2802ed1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,15 +94,6 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" -[[package]] -name = "arc-swap" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" -dependencies = [ - "rustversion", -] - [[package]] name = "async-trait" version = "0.1.89" @@ -126,100 +117,18 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" -dependencies = [ - "async-trait", - "axum-core", - "bytes", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.8.1", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper 1.0.2", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" -dependencies = [ - "async-trait", - "bytes", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper 1.0.2", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bumpalo" version = "3.19.1" @@ -355,48 +264,19 @@ dependencies = [ "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" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - [[package]] name = "crossterm" version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.10.0", + "bitflags", "crossterm_winapi", "mio", "parking_lot", @@ -412,7 +292,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "crossterm_winapi", "document-features", "parking_lot", @@ -429,16 +309,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - [[package]] name = "darling" version = "0.23.0" @@ -473,25 +343,6 @@ dependencies = [ "syn", ] -[[package]] -name = "deranged" -version = "0.5.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" -dependencies = [ - "powerfmt", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "directories" version = "5.0.1" @@ -560,15 +411,6 @@ 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" @@ -609,12 +451,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" -[[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" @@ -630,21 +466,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "futures" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" -dependencies = [ - "futures-channel", - "futures-core", - "futures-executor", - "futures-io", - "futures-sink", - "futures-task", - "futures-util", -] - [[package]] name = "futures-channel" version = "0.3.31" @@ -652,7 +473,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", - "futures-sink", ] [[package]] @@ -661,40 +481,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" -[[package]] -name = "futures-executor" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" -dependencies = [ - "futures-core", - "futures-task", - "futures-util", -] - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[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" @@ -707,28 +493,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ - "futures-channel", "futures-core", - "futures-io", - "futures-macro", - "futures-sink", "futures-task", - "memchr", "pin-project-lite", "pin-utils", "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - [[package]] name = "getopts" version = "0.2.24" @@ -765,25 +536,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "hashbrown" version = "0.14.5" @@ -831,17 +583,6 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.0" @@ -852,17 +593,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -870,7 +600,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http", ] [[package]] @@ -881,8 +611,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] @@ -892,20 +622,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hu" version = "0.1.14" dependencies = [ "anyhow", "async-trait", - "axum", - "base64 0.22.1", "chrono", "clap", "comfy-table", @@ -914,15 +636,11 @@ dependencies = [ "dirs", "hex", "libc", - "oauth2", - "octocrab", - "open", "owo-colors", "pulldown-cmark", - "rand 0.8.5", "ratatui", "regex", - "reqwest 0.12.28", + "reqwest", "rusqlite", "serde", "serde_json", @@ -933,30 +651,6 @@ dependencies = [ "urlencoding", ] -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.8.1" @@ -967,10 +661,9 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -979,50 +672,21 @@ dependencies = [ "want", ] -[[package]] -name = "hyper-rustls" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" -dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "rustls 0.21.12", - "tokio", - "tokio-rustls 0.24.1", -] - [[package]] name = "hyper-rustls" version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http 1.4.0", - "hyper 1.8.1", + "http", + "hyper", "hyper-util", - "log", - "rustls 0.23.36", - "rustls-native-certs", + "rustls", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", - "tower-service", - "webpki-roots 1.0.6", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper 1.8.1", - "hyper-util", - "pin-project-lite", - "tokio", + "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -1031,18 +695,18 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.8.1", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2", "tokio", "tower-service", "tracing", @@ -1228,25 +892,6 @@ dependencies = [ "serde", ] -[[package]] -name = "is-docker" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" -dependencies = [ - "once_cell", -] - -[[package]] -name = "is-wsl" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" -dependencies = [ - "is-docker", - "once_cell", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1278,21 +923,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "libc" version = "0.2.180" @@ -1305,7 +935,7 @@ version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ - "bitflags 2.10.0", + "bitflags", "libc", ] @@ -1374,24 +1004,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "matchit" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" - [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - [[package]] name = "mio" version = "1.1.1" @@ -1404,31 +1022,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "num-bigint" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1438,66 +1031,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "oauth2" -version = "4.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" -dependencies = [ - "base64 0.13.1", - "chrono", - "getrandom 0.2.17", - "http 0.2.12", - "rand 0.8.5", - "reqwest 0.11.27", - "serde", - "serde_json", - "serde_path_to_error", - "sha2", - "thiserror 1.0.69", - "url", -] - -[[package]] -name = "octocrab" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86996964f8b721067b6ed238aa0ccee56ecad6ee5e714468aa567992d05d2b91" -dependencies = [ - "arc-swap", - "async-trait", - "base64 0.22.1", - "bytes", - "cfg-if", - "chrono", - "either", - "futures", - "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "http-body-util", - "hyper 1.8.1", - "hyper-rustls 0.27.7", - "hyper-timeout", - "hyper-util", - "jsonwebtoken", - "once_cell", - "percent-encoding", - "pin-project", - "secrecy", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "snafu", - "tokio", - "tower", - "tower-http", - "tracing", - "url", - "web-time", -] - [[package]] name = "once_cell" version = "1.21.3" @@ -1510,23 +1043,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "open" -version = "5.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" -dependencies = [ - "is-wsl", - "libc", - "pathdiff", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "option-ext" version = "0.2.0" @@ -1560,29 +1076,13 @@ dependencies = [ "redox_syscall", "smallvec", "windows-link", -] - -[[package]] -name = "paste" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" - -[[package]] -name = "pathdiff" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +] [[package]] -name = "pem" -version = "3.0.6" +name = "paste" +version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "percent-encoding" @@ -1590,26 +1090,6 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" -[[package]] -name = "pin-project" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1637,12 +1117,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1667,7 +1141,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" dependencies = [ - "bitflags 2.10.0", + "bitflags", "getopts", "memchr", "pulldown-cmark-escape", @@ -1692,8 +1166,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.36", - "socket2 0.6.2", + "rustls", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -1709,10 +1183,10 @@ dependencies = [ "bytes", "getrandom 0.3.4", "lru-slab", - "rand 0.9.2", + "rand", "ring", "rustc-hash", - "rustls 0.23.36", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -1730,7 +1204,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2", "tracing", "windows-sys 0.60.2", ] @@ -1750,35 +1224,14 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" -[[package]] -name = "rand" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - [[package]] name = "rand" version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", + "rand_chacha", + "rand_core", ] [[package]] @@ -1788,16 +1241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", + "rand_core", ] [[package]] @@ -1815,7 +1259,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.10.0", + "bitflags", "cassowary", "compact_str", "crossterm 0.28.1", @@ -1836,7 +1280,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags", ] [[package]] @@ -1890,75 +1334,34 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-rustls 0.24.2", - "ipnet", - "js-sys", - "log", - "mime", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls 0.21.12", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", - "tokio", - "tokio-rustls 0.24.1", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "webpki-roots 0.25.4", - "winreg", -] - [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", - "hyper 1.8.1", - "hyper-rustls 0.27.7", + "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.36", + "rustls", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -1966,7 +1369,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots 1.0.6", + "webpki-roots", ] [[package]] @@ -1989,7 +1392,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" dependencies = [ - "bitflags 2.10.0", + "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -2009,7 +1412,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.4.15", @@ -2022,61 +1425,27 @@ version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ - "bitflags 2.10.0", + "bitflags", "errno", "libc", "linux-raw-sys 0.11.0", "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.21.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" -dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", -] - [[package]] name = "rustls" version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ - "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki", "subtle", "zeroize", ] -[[package]] -name = "rustls-native-certs" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - [[package]] name = "rustls-pki-types" version = "1.14.0" @@ -2087,16 +1456,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-webpki" -version = "0.101.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" -dependencies = [ - "ring", - "untrusted", -] - [[package]] name = "rustls-webpki" version = "0.103.9" @@ -2120,63 +1479,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", -] - -[[package]] -name = "secrecy" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a" -dependencies = [ - "zeroize", -] - -[[package]] -name = "security-framework" -version = "3.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "serde" version = "1.0.228" @@ -2220,17 +1528,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_spanned" version = "0.6.9" @@ -2252,17 +1549,6 @@ dependencies = [ "serde", ] -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "shlex" version = "1.3.0" @@ -2300,18 +1586,6 @@ dependencies = [ "libc", ] -[[package]] -name = "simple_asn1" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -2324,37 +1598,6 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" -[[package]] -name = "snafu" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" -dependencies = [ - "snafu-derive", -] - -[[package]] -name = "snafu-derive" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[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 = "socket2" version = "0.6.2" @@ -2422,12 +1665,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2448,27 +1685,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "tempfile" version = "3.24.0" @@ -2532,37 +1748,6 @@ dependencies = [ "syn", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.2" @@ -2599,7 +1784,7 @@ dependencies = [ "mio", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2615,36 +1800,13 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-rustls" -version = "0.24.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" -dependencies = [ - "rustls 0.21.12", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.36", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", + "rustls", "tokio", ] @@ -2698,12 +1860,10 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", - "tokio-util", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2712,17 +1872,16 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags 2.10.0", + "bitflags", "bytes", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http", + "http-body", "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2743,23 +1902,10 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", - "tracing-attributes", "tracing-core", ] -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "tracing-core" version = "0.1.36" @@ -2775,12 +1921,6 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "typenum" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" - [[package]] name = "unicase" version = "2.9.0" @@ -2838,7 +1978,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -2971,16 +2110,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" dependencies = [ "js-sys", - "serde", "wasm-bindgen", ] -[[package]] -name = "webpki-roots" -version = "0.25.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" - [[package]] name = "webpki-roots" version = "1.0.6" @@ -3302,16 +2434,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index 9091dcf..27bd815 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "hu" version = "0.1.14" edition = "2021" rust-version = "1.80" -description = "Dev workflow CLI for Claude Code integration - Jira, GitHub, Slack, PagerDuty, Sentry, NewRelic, AWS" +description = "Dev workflow CLI for Claude Code integration - data, context, docs, shell, cron, MCP, setup" license = "BUSL-1.1" repository = "https://github.com/aladac/hu" homepage = "https://github.com/aladac/hu" @@ -20,7 +20,6 @@ clap = { version = "4.5", features = ["derive", "color", "wrap_help", "env"] } anyhow = "1.0" directories = "5" tokio = { version = "1", features = ["rt-multi-thread", "macros", "process", "io-util", "io-std"] } -octocrab = "0.44" serde = { version = "1", features = ["derive"] } serde_json = "1" toml = "0.8" @@ -30,11 +29,6 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus regex = "1" async-trait = "0.1" urlencoding = "2" -oauth2 = "4.4" -axum = "0.7" -open = "5" -base64 = "0.22" -rand = "0.8" comfy-table = "7.2.2" hex = "0.4.3" dirs = "6.0.0" @@ -59,7 +53,7 @@ codegen-units = 1 [package.metadata.deb] maintainer = "Adam Ladachowski " copyright = "2025, Adam Ladachowski" -extended-description = "Dev workflow CLI for Claude Code integration with Jira, GitHub, Slack, PagerDuty, Sentry, NewRelic, and AWS." +extended-description = "Dev workflow CLI for Claude Code integration: data, context, docs, cron, shell, MCP server, NewRelic, and host setup." section = "utility" priority = "optional" assets = [ diff --git a/src/cli.rs b/src/cli.rs index a0b1b41..8419f88 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -4,19 +4,12 @@ use crate::context::ContextCommand; use crate::cron::CronCommand; use crate::data::DataCommand; use crate::docs::DocsCommand; -use crate::eks::EksCommand; -use crate::gh::GhCommand; use crate::install::InstallCommand; -use crate::jira::JiraCommand; use crate::mcp::McpCommand; use crate::newrelic::NewRelicCommand; -use crate::pagerduty::PagerDutyCommand; -use crate::pipeline::PipelineCommand; use crate::read::ReadArgs; -use crate::sentry::SentryCommand; use crate::setup::SetupCommand; use crate::shell::ShellCommand; -use crate::slack::SlackCommands; use crate::utils::UtilsCommand; #[derive(Parser)] @@ -30,37 +23,6 @@ pub struct Cli { #[derive(Subcommand)] pub enum Command { - /// Jira operations (tickets, sprint, search) - Jira { - #[command(subcommand)] - cmd: Option, - }, - - /// GitHub operations (prs, runs, failures) - Gh { - #[command(subcommand)] - cmd: Option, - }, - - /// Slack operations (messages, channels) - Slack { - #[command(subcommand)] - cmd: Option, - }, - - /// PagerDuty (oncall, alerts) - #[command(name = "pagerduty", alias = "pd")] - PagerDuty { - #[command(subcommand)] - cmd: Option, - }, - - /// Sentry (issues, errors) - Sentry { - #[command(subcommand)] - cmd: Option, - }, - /// NewRelic (incidents, queries) #[command(name = "newrelic", alias = "nr")] NewRelic { @@ -68,18 +30,6 @@ pub enum Command { cmd: Option, }, - /// EKS pod access (list, exec, logs) - Eks { - #[command(subcommand)] - cmd: Option, - }, - - /// CodePipeline status (read-only) - Pipeline { - #[command(subcommand)] - cmd: Option, - }, - /// Utility commands (fetch-html, grep) Utils { #[command(subcommand)] diff --git a/src/eks/cli.rs b/src/eks/cli.rs deleted file mode 100644 index 235af8c..0000000 --- a/src/eks/cli.rs +++ /dev/null @@ -1,289 +0,0 @@ -//! EKS CLI commands - -use clap::Subcommand; - -#[derive(Debug, Subcommand)] -pub enum EksCommand { - /// List pods in the cluster - List { - /// Namespace to list pods from - #[arg(short, long)] - namespace: Option, - - /// List pods from all namespaces - #[arg(short = 'A', long)] - all_namespaces: bool, - - /// Kubeconfig context to use - #[arg(short, long)] - context: Option, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// Execute a command in a pod (interactive shell by default) - Exec { - /// Pod name - pod: String, - - /// Namespace - #[arg(short, long)] - namespace: Option, - - /// Container name (if pod has multiple containers) - #[arg(short, long)] - container: Option, - - /// Kubeconfig context to use - #[arg(long)] - context: Option, - - /// Command to run (default: /bin/sh) - #[arg(last = true)] - command: Vec, - }, - - /// Tail logs from a pod - Logs { - /// Pod name - pod: String, - - /// Namespace - #[arg(short, long)] - namespace: Option, - - /// Container name (if pod has multiple containers) - #[arg(short, long)] - container: Option, - - /// Follow log output - #[arg(short, long)] - follow: bool, - - /// Show logs from previous container instance - #[arg(long)] - previous: bool, - - /// Number of lines to show from the end - #[arg(long)] - tail: Option, - - /// Kubeconfig context to use - #[arg(long)] - context: Option, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::{CommandFactory, Parser}; - - #[derive(Parser)] - struct TestCli { - #[command(subcommand)] - cmd: EksCommand, - } - - #[test] - fn parses_list_basic() { - let cli = TestCli::try_parse_from(["test", "list"]).unwrap(); - match cli.cmd { - EksCommand::List { - namespace, - all_namespaces, - context, - json, - } => { - assert!(namespace.is_none()); - assert!(!all_namespaces); - assert!(context.is_none()); - assert!(!json); - } - _ => panic!("Expected List command"), - } - } - - #[test] - fn parses_list_with_namespace() { - let cli = TestCli::try_parse_from(["test", "list", "-n", "kube-system"]).unwrap(); - match cli.cmd { - EksCommand::List { namespace, .. } => { - assert_eq!(namespace, Some("kube-system".to_string())); - } - _ => panic!("Expected List command"), - } - } - - #[test] - fn parses_list_all_namespaces() { - let cli = TestCli::try_parse_from(["test", "list", "-A"]).unwrap(); - match cli.cmd { - EksCommand::List { all_namespaces, .. } => { - assert!(all_namespaces); - } - _ => panic!("Expected List command"), - } - } - - #[test] - fn parses_list_with_context() { - let cli = TestCli::try_parse_from(["test", "list", "-c", "prod"]).unwrap(); - match cli.cmd { - EksCommand::List { context, .. } => { - assert_eq!(context, Some("prod".to_string())); - } - _ => panic!("Expected List command"), - } - } - - #[test] - fn parses_list_json() { - let cli = TestCli::try_parse_from(["test", "list", "--json"]).unwrap(); - match cli.cmd { - EksCommand::List { json, .. } => { - assert!(json); - } - _ => panic!("Expected List command"), - } - } - - #[test] - fn parses_exec_basic() { - let cli = TestCli::try_parse_from(["test", "exec", "my-pod"]).unwrap(); - match cli.cmd { - EksCommand::Exec { - pod, - namespace, - container, - command, - .. - } => { - assert_eq!(pod, "my-pod"); - assert!(namespace.is_none()); - assert!(container.is_none()); - assert!(command.is_empty()); - } - _ => panic!("Expected Exec command"), - } - } - - #[test] - fn parses_exec_with_namespace() { - let cli = TestCli::try_parse_from(["test", "exec", "my-pod", "-n", "prod"]).unwrap(); - match cli.cmd { - EksCommand::Exec { namespace, .. } => { - assert_eq!(namespace, Some("prod".to_string())); - } - _ => panic!("Expected Exec command"), - } - } - - #[test] - fn parses_exec_with_container() { - let cli = TestCli::try_parse_from(["test", "exec", "my-pod", "-c", "app"]).unwrap(); - match cli.cmd { - EksCommand::Exec { container, .. } => { - assert_eq!(container, Some("app".to_string())); - } - _ => panic!("Expected Exec command"), - } - } - - #[test] - fn parses_exec_with_command() { - let cli = - TestCli::try_parse_from(["test", "exec", "my-pod", "--", "bash", "-c", "ls"]).unwrap(); - match cli.cmd { - EksCommand::Exec { command, .. } => { - assert_eq!(command, vec!["bash", "-c", "ls"]); - } - _ => panic!("Expected Exec command"), - } - } - - #[test] - fn parses_logs_basic() { - let cli = TestCli::try_parse_from(["test", "logs", "my-pod"]).unwrap(); - match cli.cmd { - EksCommand::Logs { - pod, - follow, - previous, - tail, - .. - } => { - assert_eq!(pod, "my-pod"); - assert!(!follow); - assert!(!previous); - assert!(tail.is_none()); - } - _ => panic!("Expected Logs command"), - } - } - - #[test] - fn parses_logs_follow() { - let cli = TestCli::try_parse_from(["test", "logs", "my-pod", "-f"]).unwrap(); - match cli.cmd { - EksCommand::Logs { follow, .. } => { - assert!(follow); - } - _ => panic!("Expected Logs command"), - } - } - - #[test] - fn parses_logs_previous() { - let cli = TestCli::try_parse_from(["test", "logs", "my-pod", "--previous"]).unwrap(); - match cli.cmd { - EksCommand::Logs { previous, .. } => { - assert!(previous); - } - _ => panic!("Expected Logs command"), - } - } - - #[test] - fn parses_logs_tail() { - let cli = TestCli::try_parse_from(["test", "logs", "my-pod", "--tail", "100"]).unwrap(); - match cli.cmd { - EksCommand::Logs { tail, .. } => { - assert_eq!(tail, Some(100)); - } - _ => panic!("Expected Logs command"), - } - } - - #[test] - fn parses_logs_with_container() { - let cli = TestCli::try_parse_from(["test", "logs", "my-pod", "-c", "sidecar"]).unwrap(); - match cli.cmd { - EksCommand::Logs { container, .. } => { - assert_eq!(container, Some("sidecar".to_string())); - } - _ => panic!("Expected Logs command"), - } - } - - #[test] - fn command_debug() { - let cmd = EksCommand::List { - namespace: None, - all_namespaces: false, - context: None, - json: false, - }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("List")); - } - - #[test] - fn command_has_help() { - let mut cmd = TestCli::command(); - let help = cmd.render_help(); - assert!(!help.to_string().is_empty()); - } -} diff --git a/src/eks/display.rs b/src/eks/display.rs deleted file mode 100644 index 7b75bc1..0000000 --- a/src/eks/display.rs +++ /dev/null @@ -1,168 +0,0 @@ -//! EKS output formatting - -use anyhow::{Context, Result}; -use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, ContentArrangement, Table}; - -use super::types::{OutputFormat, Pod}; - -/// Get color for pod status -fn status_color(status: &str) -> Color { - match status { - "Running" => Color::Green, - "Pending" => Color::Yellow, - "Succeeded" => Color::Cyan, - "Failed" => Color::Red, - "Unknown" => Color::DarkGrey, - _ => Color::White, - } -} - -/// Output pods list -pub fn output_pods(pods: &[Pod], format: OutputFormat, show_namespace: bool) -> Result<()> { - match format { - OutputFormat::Table => { - if pods.is_empty() { - println!("No pods found."); - return Ok(()); - } - - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - - if show_namespace { - table.set_header(vec![ - "NAMESPACE", - "NAME", - "READY", - "STATUS", - "RESTARTS", - "AGE", - ]); - } else { - table.set_header(vec!["NAME", "READY", "STATUS", "RESTARTS", "AGE"]); - } - - for pod in pods { - if show_namespace { - table.add_row(vec![ - Cell::new(&pod.namespace), - Cell::new(&pod.name).fg(Color::Cyan), - Cell::new(&pod.ready), - Cell::new(&pod.status).fg(status_color(&pod.status)), - Cell::new(pod.restarts.to_string()), - Cell::new(&pod.age), - ]); - } else { - table.add_row(vec![ - Cell::new(&pod.name).fg(Color::Cyan), - Cell::new(&pod.ready), - Cell::new(&pod.status).fg(status_color(&pod.status)), - Cell::new(pod.restarts.to_string()), - Cell::new(&pod.age), - ]); - } - } - - println!("{table}"); - println!("\n{} pods", pods.len()); - } - OutputFormat::Json => { - let json = serde_json::to_string_pretty(pods).context("Failed to serialize pods")?; - println!("{json}"); - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn status_color_running() { - assert_eq!(status_color("Running"), Color::Green); - } - - #[test] - fn status_color_pending() { - assert_eq!(status_color("Pending"), Color::Yellow); - } - - #[test] - fn status_color_succeeded() { - assert_eq!(status_color("Succeeded"), Color::Cyan); - } - - #[test] - fn status_color_failed() { - assert_eq!(status_color("Failed"), Color::Red); - } - - #[test] - fn status_color_unknown() { - assert_eq!(status_color("Unknown"), Color::DarkGrey); - } - - #[test] - fn status_color_other() { - assert_eq!(status_color("CrashLoopBackOff"), Color::White); - } - - #[test] - fn output_pods_empty() { - let result = output_pods(&[], OutputFormat::Table, false); - assert!(result.is_ok()); - } - - #[test] - fn output_pods_table() { - let pods = vec![Pod { - name: "test-pod".to_string(), - namespace: "default".to_string(), - status: "Running".to_string(), - ready: "1/1".to_string(), - restarts: 0, - age: "1d".to_string(), - node: None, - }]; - let result = output_pods(&pods, OutputFormat::Table, false); - assert!(result.is_ok()); - } - - #[test] - fn output_pods_table_with_namespace() { - let pods = vec![Pod { - name: "test-pod".to_string(), - namespace: "kube-system".to_string(), - status: "Running".to_string(), - ready: "1/1".to_string(), - restarts: 0, - age: "1d".to_string(), - node: None, - }]; - let result = output_pods(&pods, OutputFormat::Table, true); - assert!(result.is_ok()); - } - - #[test] - fn output_pods_json() { - let pods = vec![Pod { - name: "test-pod".to_string(), - namespace: "default".to_string(), - status: "Running".to_string(), - ready: "1/1".to_string(), - restarts: 0, - age: "1d".to_string(), - node: None, - }]; - let result = output_pods(&pods, OutputFormat::Json, false); - assert!(result.is_ok()); - } - - #[test] - fn output_pods_json_empty() { - let result = output_pods(&[], OutputFormat::Json, false); - assert!(result.is_ok()); - } -} diff --git a/src/eks/kubectl/mod.rs b/src/eks/kubectl/mod.rs deleted file mode 100644 index df4659a..0000000 --- a/src/eks/kubectl/mod.rs +++ /dev/null @@ -1,269 +0,0 @@ -//! kubectl wrapper functions - -use anyhow::{Context, Result}; -use std::process::{Command, Stdio}; - -use super::types::{KubectlConfig, Pod, PodList}; - -#[cfg(test)] -mod tests; - -/// Build kubectl base command with context/namespace -fn build_kubectl_cmd(config: &KubectlConfig) -> Command { - let mut cmd = Command::new("kubectl"); - - if let Some(ctx) = &config.context { - cmd.arg("--context").arg(ctx); - } - - if let Some(ns) = &config.namespace { - cmd.arg("-n").arg(ns); - } - - cmd -} - -/// List pods using kubectl -pub fn list_pods(config: &KubectlConfig, all_namespaces: bool) -> Result> { - let mut cmd = build_kubectl_cmd(config); - cmd.arg("get").arg("pods").arg("-o").arg("json"); - - if all_namespaces { - cmd.arg("--all-namespaces"); - } - - let output = cmd - .output() - .context("Failed to execute kubectl. Is kubectl installed and configured?")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("kubectl failed: {}", stderr.trim()); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - parse_pod_list(&stdout) -} - -/// Parse kubectl JSON output into Pod list -pub fn parse_pod_list(json: &str) -> Result> { - let pod_list: PodList = serde_json::from_str(json).context("Failed to parse kubectl output")?; - - Ok(pod_list.items.iter().map(|item| item.to_pod()).collect()) -} - -/// Execute into a pod (interactive) -pub fn exec_pod( - config: &KubectlConfig, - pod: &str, - container: Option<&str>, - command: &[String], -) -> Result<()> { - let mut cmd = build_kubectl_cmd(config); - cmd.arg("exec").arg("-it").arg(pod); - - if let Some(c) = container { - cmd.arg("-c").arg(c); - } - - cmd.arg("--"); - - if command.is_empty() { - cmd.arg("/bin/sh"); - } else { - for arg in command { - cmd.arg(arg); - } - } - - // Run interactively - cmd.stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - - let status = cmd.status().context("Failed to execute kubectl exec")?; - - if !status.success() { - anyhow::bail!("kubectl exec exited with status: {}", status); - } - - Ok(()) -} - -/// Tail logs from a pod -#[allow(clippy::too_many_arguments)] -pub fn tail_logs( - config: &KubectlConfig, - pod: &str, - container: Option<&str>, - follow: bool, - previous: bool, - tail_lines: Option, -) -> Result<()> { - let mut cmd = build_kubectl_cmd(config); - cmd.arg("logs").arg(pod); - - if let Some(c) = container { - cmd.arg("-c").arg(c); - } - - if follow { - cmd.arg("-f"); - } - - if previous { - cmd.arg("--previous"); - } - - if let Some(n) = tail_lines { - cmd.arg("--tail").arg(n.to_string()); - } - - // Stream output - cmd.stdin(Stdio::inherit()) - .stdout(Stdio::inherit()) - .stderr(Stdio::inherit()); - - let status = cmd.status().context("Failed to execute kubectl logs")?; - - if !status.success() { - anyhow::bail!("kubectl logs exited with status: {}", status); - } - - Ok(()) -} - -/// Get list of containers in a pod -#[allow(dead_code)] -pub fn get_containers(config: &KubectlConfig, pod: &str) -> Result> { - let mut cmd = build_kubectl_cmd(config); - cmd.arg("get") - .arg("pod") - .arg(pod) - .arg("-o") - .arg("jsonpath={.spec.containers[*].name}"); - - let output = cmd.output().context("Failed to execute kubectl")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("kubectl failed: {}", stderr.trim()); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - Ok(stdout.split_whitespace().map(|s| s.to_string()).collect()) -} - -/// Build kubectl command args (for testing) -#[cfg(test)] -pub fn build_list_args(config: &KubectlConfig, all_namespaces: bool) -> Vec { - let mut args = Vec::new(); - - if let Some(ctx) = &config.context { - args.push("--context".to_string()); - args.push(ctx.clone()); - } - - if let Some(ns) = &config.namespace { - args.push("-n".to_string()); - args.push(ns.clone()); - } - - args.push("get".to_string()); - args.push("pods".to_string()); - args.push("-o".to_string()); - args.push("json".to_string()); - - if all_namespaces { - args.push("--all-namespaces".to_string()); - } - - args -} - -/// Build kubectl exec args (for testing) -#[cfg(test)] -pub fn build_exec_args( - config: &KubectlConfig, - pod: &str, - container: Option<&str>, - command: &[String], -) -> Vec { - let mut args = Vec::new(); - - if let Some(ctx) = &config.context { - args.push("--context".to_string()); - args.push(ctx.clone()); - } - - if let Some(ns) = &config.namespace { - args.push("-n".to_string()); - args.push(ns.clone()); - } - - args.push("exec".to_string()); - args.push("-it".to_string()); - args.push(pod.to_string()); - - if let Some(c) = container { - args.push("-c".to_string()); - args.push(c.to_string()); - } - - args.push("--".to_string()); - - if command.is_empty() { - args.push("/bin/sh".to_string()); - } else { - args.extend(command.iter().cloned()); - } - - args -} - -/// Build kubectl logs args (for testing) -#[cfg(test)] -#[allow(clippy::too_many_arguments)] -pub fn build_logs_args( - config: &KubectlConfig, - pod: &str, - container: Option<&str>, - follow: bool, - previous: bool, - tail_lines: Option, -) -> Vec { - let mut args = Vec::new(); - - if let Some(ctx) = &config.context { - args.push("--context".to_string()); - args.push(ctx.clone()); - } - - if let Some(ns) = &config.namespace { - args.push("-n".to_string()); - args.push(ns.clone()); - } - - args.push("logs".to_string()); - args.push(pod.to_string()); - - if let Some(c) = container { - args.push("-c".to_string()); - args.push(c.to_string()); - } - - if follow { - args.push("-f".to_string()); - } - - if previous { - args.push("--previous".to_string()); - } - - if let Some(n) = tail_lines { - args.push("--tail".to_string()); - args.push(n.to_string()); - } - - args -} diff --git a/src/eks/kubectl/tests.rs b/src/eks/kubectl/tests.rs deleted file mode 100644 index 3ecdcc3..0000000 --- a/src/eks/kubectl/tests.rs +++ /dev/null @@ -1,431 +0,0 @@ -use super::*; - -#[test] -fn build_list_args_basic() { - let config = KubectlConfig::default(); - let args = build_list_args(&config, false); - assert_eq!(args, vec!["get", "pods", "-o", "json"]); -} - -#[test] -fn build_list_args_with_context() { - let config = KubectlConfig { - context: Some("prod".to_string()), - namespace: None, - }; - let args = build_list_args(&config, false); - assert_eq!(args, vec!["--context", "prod", "get", "pods", "-o", "json"]); -} - -#[test] -fn build_list_args_with_namespace() { - let config = KubectlConfig { - context: None, - namespace: Some("kube-system".to_string()), - }; - let args = build_list_args(&config, false); - assert_eq!(args, vec!["-n", "kube-system", "get", "pods", "-o", "json"]); -} - -#[test] -fn build_list_args_all_namespaces() { - let config = KubectlConfig::default(); - let args = build_list_args(&config, true); - assert_eq!(args, vec!["get", "pods", "-o", "json", "--all-namespaces"]); -} - -#[test] -fn build_list_args_full() { - let config = KubectlConfig { - context: Some("prod".to_string()), - namespace: Some("default".to_string()), - }; - let args = build_list_args(&config, true); - assert_eq!( - args, - vec![ - "--context", - "prod", - "-n", - "default", - "get", - "pods", - "-o", - "json", - "--all-namespaces" - ] - ); -} - -#[test] -fn build_exec_args_basic() { - let config = KubectlConfig::default(); - let args = build_exec_args(&config, "my-pod", None, &[]); - assert_eq!(args, vec!["exec", "-it", "my-pod", "--", "/bin/sh"]); -} - -#[test] -fn build_exec_args_with_container() { - let config = KubectlConfig::default(); - let args = build_exec_args(&config, "my-pod", Some("app"), &[]); - assert_eq!( - args, - vec!["exec", "-it", "my-pod", "-c", "app", "--", "/bin/sh"] - ); -} - -#[test] -fn build_exec_args_with_command() { - let config = KubectlConfig::default(); - let cmd = vec!["bash".to_string(), "-c".to_string(), "ls -la".to_string()]; - let args = build_exec_args(&config, "my-pod", None, &cmd); - assert_eq!( - args, - vec!["exec", "-it", "my-pod", "--", "bash", "-c", "ls -la"] - ); -} - -#[test] -fn build_exec_args_full() { - let config = KubectlConfig { - context: Some("prod".to_string()), - namespace: Some("app".to_string()), - }; - let args = build_exec_args(&config, "my-pod", Some("main"), &[]); - assert_eq!( - args, - vec![ - "--context", - "prod", - "-n", - "app", - "exec", - "-it", - "my-pod", - "-c", - "main", - "--", - "/bin/sh" - ] - ); -} - -#[test] -fn build_logs_args_basic() { - let config = KubectlConfig::default(); - let args = build_logs_args(&config, "my-pod", None, false, false, None); - assert_eq!(args, vec!["logs", "my-pod"]); -} - -#[test] -fn build_logs_args_follow() { - let config = KubectlConfig::default(); - let args = build_logs_args(&config, "my-pod", None, true, false, None); - assert_eq!(args, vec!["logs", "my-pod", "-f"]); -} - -#[test] -fn build_logs_args_previous() { - let config = KubectlConfig::default(); - let args = build_logs_args(&config, "my-pod", None, false, true, None); - assert_eq!(args, vec!["logs", "my-pod", "--previous"]); -} - -#[test] -fn build_logs_args_tail() { - let config = KubectlConfig::default(); - let args = build_logs_args(&config, "my-pod", None, false, false, Some(100)); - assert_eq!(args, vec!["logs", "my-pod", "--tail", "100"]); -} - -#[test] -fn build_logs_args_full() { - let config = KubectlConfig { - context: Some("prod".to_string()), - namespace: Some("app".to_string()), - }; - let args = build_logs_args(&config, "my-pod", Some("main"), true, true, Some(50)); - assert_eq!( - args, - vec![ - "--context", - "prod", - "-n", - "app", - "logs", - "my-pod", - "-c", - "main", - "-f", - "--previous", - "--tail", - "50" - ] - ); -} - -#[test] -fn parse_pod_list_empty() { - let json = r#"{"items": []}"#; - let pods = parse_pod_list(json).unwrap(); - assert!(pods.is_empty()); -} - -#[test] -fn parse_pod_list_single() { - let json = r#"{ - "items": [{ - "metadata": {"name": "test", "namespace": "default"}, - "status": {"phase": "Running", "containerStatuses": []} - }] - }"#; - let pods = parse_pod_list(json).unwrap(); - assert_eq!(pods.len(), 1); - assert_eq!(pods[0].name, "test"); -} - -#[test] -fn parse_pod_list_invalid_json() { - let result = parse_pod_list("not json"); - assert!(result.is_err()); -} - -#[test] -fn parse_pod_list_multiple_pods() { - let json = r#"{ - "items": [ - { - "metadata": {"name": "pod1", "namespace": "default"}, - "status": {"phase": "Running", "containerStatuses": []} - }, - { - "metadata": {"name": "pod2", "namespace": "kube-system"}, - "status": {"phase": "Pending", "containerStatuses": []} - } - ] - }"#; - let pods = parse_pod_list(json).unwrap(); - assert_eq!(pods.len(), 2); - assert_eq!(pods[0].name, "pod1"); - assert_eq!(pods[1].name, "pod2"); - assert_eq!(pods[1].namespace, "kube-system"); -} - -#[test] -fn parse_pod_list_with_full_metadata() { - let json = r#"{ - "items": [{ - "metadata": { - "name": "full-pod", - "namespace": "production", - "creationTimestamp": "2026-01-15T10:30:00Z" - }, - "spec": { - "nodeName": "worker-node-1", - "containers": [{"name": "main"}] - }, - "status": { - "phase": "Running", - "containerStatuses": [ - {"name": "main", "ready": true, "restartCount": 5} - ] - } - }] - }"#; - let pods = parse_pod_list(json).unwrap(); - assert_eq!(pods.len(), 1); - assert_eq!(pods[0].name, "full-pod"); - assert_eq!(pods[0].namespace, "production"); - assert_eq!(pods[0].node, Some("worker-node-1".to_string())); - assert_eq!(pods[0].restarts, 5); - assert_eq!(pods[0].ready, "1/1"); -} - -#[test] -fn build_logs_args_with_container_only() { - let config = KubectlConfig::default(); - let args = build_logs_args(&config, "my-pod", Some("sidecar"), false, false, None); - assert_eq!(args, vec!["logs", "my-pod", "-c", "sidecar"]); -} - -#[test] -fn build_exec_args_with_context_only() { - let config = KubectlConfig { - context: Some("staging".to_string()), - namespace: None, - }; - let args = build_exec_args(&config, "test-pod", None, &[]); - assert_eq!( - args, - vec![ - "--context", - "staging", - "exec", - "-it", - "test-pod", - "--", - "/bin/sh" - ] - ); -} - -#[test] -fn build_exec_args_with_namespace_only() { - let config = KubectlConfig { - context: None, - namespace: Some("monitoring".to_string()), - }; - let args = build_exec_args(&config, "test-pod", None, &[]); - assert_eq!( - args, - vec![ - "-n", - "monitoring", - "exec", - "-it", - "test-pod", - "--", - "/bin/sh" - ] - ); -} - -#[test] -fn build_logs_args_with_context_only() { - let config = KubectlConfig { - context: Some("dev".to_string()), - namespace: None, - }; - let args = build_logs_args(&config, "app-pod", None, false, false, None); - assert_eq!(args, vec!["--context", "dev", "logs", "app-pod"]); -} - -#[test] -fn build_logs_args_with_namespace_only() { - let config = KubectlConfig { - context: None, - namespace: Some("logging".to_string()), - }; - let args = build_logs_args(&config, "app-pod", None, false, false, None); - assert_eq!(args, vec!["-n", "logging", "logs", "app-pod"]); -} - -#[test] -fn build_logs_args_follow_and_tail() { - let config = KubectlConfig::default(); - let args = build_logs_args(&config, "my-pod", None, true, false, Some(200)); - assert_eq!(args, vec!["logs", "my-pod", "-f", "--tail", "200"]); -} - -#[test] -fn build_logs_args_previous_and_tail() { - let config = KubectlConfig::default(); - let args = build_logs_args(&config, "my-pod", None, false, true, Some(50)); - assert_eq!(args, vec!["logs", "my-pod", "--previous", "--tail", "50"]); -} - -#[test] -fn build_exec_args_with_multi_word_command() { - let config = KubectlConfig::default(); - let cmd = vec![ - "python".to_string(), - "-c".to_string(), - "print('hello')".to_string(), - ]; - let args = build_exec_args(&config, "py-pod", None, &cmd); - assert_eq!( - args, - vec![ - "exec", - "-it", - "py-pod", - "--", - "python", - "-c", - "print('hello')" - ] - ); -} - -#[test] -fn build_exec_args_full_with_command() { - let config = KubectlConfig { - context: Some("prod".to_string()), - namespace: Some("api".to_string()), - }; - let cmd = vec!["cat".to_string(), "/etc/hosts".to_string()]; - let args = build_exec_args(&config, "api-pod", Some("nginx"), &cmd); - assert_eq!( - args, - vec![ - "--context", - "prod", - "-n", - "api", - "exec", - "-it", - "api-pod", - "-c", - "nginx", - "--", - "cat", - "/etc/hosts" - ] - ); -} - -#[test] -fn parse_pod_list_mixed_container_states() { - let json = r#"{ - "items": [{ - "metadata": {"name": "mixed", "namespace": "default"}, - "status": { - "phase": "Running", - "containerStatuses": [ - {"name": "a", "ready": true, "restartCount": 0}, - {"name": "b", "ready": false, "restartCount": 2}, - {"name": "c", "ready": true, "restartCount": 1} - ] - } - }] - }"#; - let pods = parse_pod_list(json).unwrap(); - assert_eq!(pods[0].ready, "2/3"); - assert_eq!(pods[0].restarts, 3); -} - -#[test] -fn parse_pod_list_failed_status() { - let json = r#"{ - "items": [{ - "metadata": {"name": "failed", "namespace": "default"}, - "status": {"phase": "Failed", "containerStatuses": []} - }] - }"#; - let pods = parse_pod_list(json).unwrap(); - assert_eq!(pods[0].status, "Failed"); -} - -#[test] -fn parse_pod_list_succeeded_status() { - let json = r#"{ - "items": [{ - "metadata": {"name": "job-pod", "namespace": "batch"}, - "status": {"phase": "Succeeded", "containerStatuses": []} - }] - }"#; - let pods = parse_pod_list(json).unwrap(); - assert_eq!(pods[0].status, "Succeeded"); -} - -#[test] -fn parse_pod_list_unknown_status() { - let json = r#"{ - "items": [{ - "metadata": {"name": "mystery", "namespace": "default"}, - "status": {"phase": "Unknown", "containerStatuses": []} - }] - }"#; - let pods = parse_pod_list(json).unwrap(); - assert_eq!(pods[0].status, "Unknown"); -} diff --git a/src/eks/mod.rs b/src/eks/mod.rs deleted file mode 100644 index a7feaac..0000000 --- a/src/eks/mod.rs +++ /dev/null @@ -1,252 +0,0 @@ -//! EKS pod management -//! -//! List pods, exec into pods, and tail logs. - -mod cli; -mod display; -mod kubectl; -mod types; - -use anyhow::Result; - -pub use cli::EksCommand; -use types::{KubectlConfig, OutputFormat}; - -/// Run an EKS command -pub async fn run(cmd: EksCommand) -> Result<()> { - match cmd { - EksCommand::List { - namespace, - all_namespaces, - context, - json, - } => cmd_list(namespace, all_namespaces, context, json), - EksCommand::Exec { - pod, - namespace, - container, - context, - command, - } => cmd_exec(&pod, namespace, container, context, command), - EksCommand::Logs { - pod, - namespace, - container, - follow, - previous, - tail, - context, - } => cmd_logs(&pod, namespace, container, follow, previous, tail, context), - } -} - -/// List pods -fn cmd_list( - namespace: Option, - all_namespaces: bool, - context: Option, - json: bool, -) -> Result<()> { - let config = KubectlConfig { - context, - namespace: namespace.clone(), - }; - - let pods = kubectl::list_pods(&config, all_namespaces)?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - // Show namespace column if listing all namespaces or no specific namespace - let show_namespace = all_namespaces || namespace.is_none(); - display::output_pods(&pods, format, show_namespace)?; - - Ok(()) -} - -/// Exec into a pod -fn cmd_exec( - pod: &str, - namespace: Option, - container: Option, - context: Option, - command: Vec, -) -> Result<()> { - let config = KubectlConfig { context, namespace }; - - kubectl::exec_pod(&config, pod, container.as_deref(), &command) -} - -/// Tail logs from a pod -#[allow(clippy::too_many_arguments)] -fn cmd_logs( - pod: &str, - namespace: Option, - container: Option, - follow: bool, - previous: bool, - tail: Option, - context: Option, -) -> Result<()> { - let config = KubectlConfig { context, namespace }; - - kubectl::tail_logs(&config, pod, container.as_deref(), follow, previous, tail) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn kubectl_config_from_options() { - let config = KubectlConfig { - context: Some("prod".to_string()), - namespace: Some("default".to_string()), - }; - assert_eq!(config.context, Some("prod".to_string())); - assert_eq!(config.namespace, Some("default".to_string())); - } - - #[test] - fn kubectl_config_none_options() { - let config = KubectlConfig { - context: None, - namespace: None, - }; - assert!(config.context.is_none()); - assert!(config.namespace.is_none()); - } - - #[test] - fn output_format_table() { - let format = OutputFormat::Table; - assert_eq!(format, OutputFormat::Table); - } - - #[test] - fn output_format_json() { - let format = OutputFormat::Json; - assert_eq!(format, OutputFormat::Json); - } - - #[test] - fn output_format_from_bool_false() { - let json = false; - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - assert_eq!(format, OutputFormat::Table); - } - - #[test] - fn output_format_from_bool_true() { - let json = true; - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - assert_eq!(format, OutputFormat::Json); - } - - // Test show_namespace logic - matches cmd_list behavior - #[test] - fn show_namespace_all_namespaces() { - let all_namespaces = true; - let namespace: Option = None; - let show_namespace = all_namespaces || namespace.is_none(); - assert!(show_namespace); - } - - #[test] - fn show_namespace_specific_namespace() { - let all_namespaces = false; - let namespace = Some("kube-system".to_string()); - let show_namespace = all_namespaces || namespace.is_none(); - assert!(!show_namespace); - } - - #[test] - fn show_namespace_no_namespace() { - let all_namespaces = false; - let namespace: Option = None; - let show_namespace = all_namespaces || namespace.is_none(); - assert!(show_namespace); - } - - #[test] - fn show_namespace_both_set() { - // When both all_namespaces and specific namespace set, - // show_namespace should be true (all_namespaces takes precedence) - let all_namespaces = true; - let namespace = Some("default".to_string()); - let show_namespace = all_namespaces || namespace.is_none(); - assert!(show_namespace); - } - - // Test EksCommand variants exist and can be constructed - #[test] - fn eks_command_list_variant() { - let cmd = EksCommand::List { - namespace: None, - all_namespaces: false, - context: None, - json: false, - }; - // Just verify it constructs - match cmd { - EksCommand::List { .. } => {} - _ => panic!("Expected List variant"), - } - } - - #[test] - fn eks_command_exec_variant() { - let cmd = EksCommand::Exec { - pod: "my-pod".to_string(), - namespace: None, - container: None, - context: None, - command: vec![], - }; - match cmd { - EksCommand::Exec { pod, .. } => { - assert_eq!(pod, "my-pod"); - } - _ => panic!("Expected Exec variant"), - } - } - - #[test] - fn eks_command_logs_variant() { - let cmd = EksCommand::Logs { - pod: "log-pod".to_string(), - namespace: Some("prod".to_string()), - container: None, - follow: true, - previous: false, - tail: Some(100), - context: None, - }; - match cmd { - EksCommand::Logs { - pod, - namespace, - follow, - tail, - .. - } => { - assert_eq!(pod, "log-pod"); - assert_eq!(namespace, Some("prod".to_string())); - assert!(follow); - assert_eq!(tail, Some(100)); - } - _ => panic!("Expected Logs variant"), - } - } -} diff --git a/src/eks/types/mod.rs b/src/eks/types/mod.rs deleted file mode 100644 index 91b812e..0000000 --- a/src/eks/types/mod.rs +++ /dev/null @@ -1,176 +0,0 @@ -//! EKS data types - -use serde::{Deserialize, Serialize}; - -pub use crate::util::OutputFormat; - -#[cfg(test)] -mod tests; - -/// Kubernetes pod -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Pod { - /// Pod name - pub name: String, - /// Namespace - pub namespace: String, - /// Pod status (Running, Pending, etc.) - pub status: String, - /// Ready containers (e.g., "1/1") - pub ready: String, - /// Restart count - pub restarts: u32, - /// Age (e.g., "2d", "5h") - pub age: String, - /// Node name - #[serde(default)] - pub node: Option, -} - -/// Kubectl configuration -#[derive(Debug, Clone, Default)] -pub struct KubectlConfig { - /// Kubeconfig context to use - pub context: Option, - /// Namespace to use - pub namespace: Option, -} - -/// Kubectl JSON output for pods -#[derive(Debug, Deserialize)] -pub struct PodList { - /// List of items - pub items: Vec, -} - -/// Single pod item from kubectl JSON -#[derive(Debug, Deserialize)] -pub struct PodItem { - /// Metadata - pub metadata: PodMetadata, - /// Spec - #[serde(default)] - pub spec: Option, - /// Status - pub status: PodStatus, -} - -/// Pod metadata -#[derive(Debug, Deserialize)] -pub struct PodMetadata { - /// Pod name - pub name: String, - /// Namespace - pub namespace: String, - /// Creation timestamp - #[serde(rename = "creationTimestamp")] - pub creation_timestamp: Option, -} - -/// Pod spec -#[derive(Debug, Deserialize, Default)] -pub struct PodSpec { - /// Node name - #[serde(rename = "nodeName")] - pub node_name: Option, - /// Containers - #[serde(default)] - #[allow(dead_code)] - pub containers: Vec, -} - -/// Container spec -#[derive(Debug, Deserialize)] -#[allow(dead_code)] -pub struct Container { - /// Container name - pub name: String, -} - -/// Pod status -#[derive(Debug, Deserialize)] -pub struct PodStatus { - /// Phase (Running, Pending, Succeeded, Failed, Unknown) - pub phase: String, - /// Container statuses - #[serde(rename = "containerStatuses", default)] - pub container_statuses: Vec, -} - -/// Container status -#[derive(Debug, Deserialize)] -pub struct ContainerStatus { - /// Container name - #[allow(dead_code)] - pub name: String, - /// Ready state - pub ready: bool, - /// Restart count - #[serde(rename = "restartCount")] - pub restart_count: u32, -} - -impl PodItem { - /// Convert to simplified Pod struct - pub fn to_pod(&self) -> Pod { - let ready = self.ready_string(); - let restarts = self.total_restarts(); - let age = self.age_string(); - let node = self.spec.as_ref().and_then(|s| s.node_name.clone()); - - Pod { - name: self.metadata.name.clone(), - namespace: self.metadata.namespace.clone(), - status: self.status.phase.clone(), - ready, - restarts, - age, - node, - } - } - - /// Get ready string (e.g., "1/2") - fn ready_string(&self) -> String { - let total = self.status.container_statuses.len(); - let ready = self - .status - .container_statuses - .iter() - .filter(|c| c.ready) - .count(); - format!("{}/{}", ready, total) - } - - /// Get total restart count - fn total_restarts(&self) -> u32 { - self.status - .container_statuses - .iter() - .map(|c| c.restart_count) - .sum() - } - - /// Get age string from creation timestamp - fn age_string(&self) -> String { - let Some(ts) = &self.metadata.creation_timestamp else { - return "-".to_string(); - }; - - let Ok(created) = chrono::DateTime::parse_from_rfc3339(ts) else { - return "-".to_string(); - }; - - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(created); - - 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 { - format!("{}s", duration.num_seconds()) - } - } -} diff --git a/src/eks/types/tests.rs b/src/eks/types/tests.rs deleted file mode 100644 index 4702c5d..0000000 --- a/src/eks/types/tests.rs +++ /dev/null @@ -1,553 +0,0 @@ -use super::*; - -#[test] -fn pod_debug() { - let pod = Pod { - name: "test-pod".to_string(), - namespace: "default".to_string(), - status: "Running".to_string(), - ready: "1/1".to_string(), - restarts: 0, - age: "1d".to_string(), - node: Some("node-1".to_string()), - }; - let debug = format!("{:?}", pod); - assert!(debug.contains("test-pod")); -} - -#[test] -fn pod_clone() { - let pod = Pod { - name: "test-pod".to_string(), - namespace: "default".to_string(), - status: "Running".to_string(), - ready: "1/1".to_string(), - restarts: 0, - age: "1d".to_string(), - node: None, - }; - let cloned = pod.clone(); - assert_eq!(cloned.name, pod.name); -} - -#[test] -fn kubectl_config_default() { - let config = KubectlConfig::default(); - assert!(config.context.is_none()); - assert!(config.namespace.is_none()); -} - -#[test] -fn parse_pod_list() { - let json = r#"{ - "items": [ - { - "metadata": { - "name": "my-pod", - "namespace": "default", - "creationTimestamp": "2026-01-01T00:00:00Z" - }, - "status": { - "phase": "Running", - "containerStatuses": [ - {"name": "main", "ready": true, "restartCount": 2} - ] - } - } - ] - }"#; - - let pod_list: PodList = serde_json::from_str(json).unwrap(); - assert_eq!(pod_list.items.len(), 1); - - let pod = pod_list.items[0].to_pod(); - assert_eq!(pod.name, "my-pod"); - assert_eq!(pod.namespace, "default"); - assert_eq!(pod.status, "Running"); - assert_eq!(pod.ready, "1/1"); - assert_eq!(pod.restarts, 2); -} - -#[test] -fn parse_pod_list_multiple_containers() { - let json = r#"{ - "items": [ - { - "metadata": { - "name": "multi-container", - "namespace": "prod" - }, - "status": { - "phase": "Running", - "containerStatuses": [ - {"name": "app", "ready": true, "restartCount": 1}, - {"name": "sidecar", "ready": false, "restartCount": 3} - ] - } - } - ] - }"#; - - let pod_list: PodList = serde_json::from_str(json).unwrap(); - let pod = pod_list.items[0].to_pod(); - assert_eq!(pod.ready, "1/2"); - assert_eq!(pod.restarts, 4); -} - -#[test] -fn parse_pod_list_with_node() { - let json = r#"{ - "items": [ - { - "metadata": { - "name": "my-pod", - "namespace": "default" - }, - "spec": { - "nodeName": "node-abc123" - }, - "status": { - "phase": "Running", - "containerStatuses": [] - } - } - ] - }"#; - - let pod_list: PodList = serde_json::from_str(json).unwrap(); - let pod = pod_list.items[0].to_pod(); - assert_eq!(pod.node, Some("node-abc123".to_string())); -} - -#[test] -fn parse_pod_list_no_node() { - let json = r#"{ - "items": [ - { - "metadata": { - "name": "pending-pod", - "namespace": "default" - }, - "status": { - "phase": "Pending", - "containerStatuses": [] - } - } - ] - }"#; - - let pod_list: PodList = serde_json::from_str(json).unwrap(); - let pod = pod_list.items[0].to_pod(); - assert!(pod.node.is_none()); -} - -#[test] -fn age_string_no_timestamp() { - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: None, - }, - spec: None, - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![], - }, - }; - let pod = item.to_pod(); - assert_eq!(pod.age, "-"); -} - -#[test] -fn age_string_invalid_timestamp() { - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: Some("not-a-date".to_string()), - }, - spec: None, - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![], - }, - }; - let pod = item.to_pod(); - assert_eq!(pod.age, "-"); -} - -#[test] -fn pod_serialize() { - let pod = Pod { - name: "test".to_string(), - namespace: "default".to_string(), - status: "Running".to_string(), - ready: "1/1".to_string(), - restarts: 0, - age: "1h".to_string(), - node: None, - }; - let json = serde_json::to_string(&pod).unwrap(); - assert!(json.contains("test")); -} - -#[test] -fn pod_deserialize() { - let json = r#"{ - "name": "test-pod", - "namespace": "default", - "status": "Running", - "ready": "1/1", - "restarts": 5, - "age": "2d", - "node": "worker-1" - }"#; - let pod: Pod = serde_json::from_str(json).unwrap(); - assert_eq!(pod.name, "test-pod"); - assert_eq!(pod.restarts, 5); - assert_eq!(pod.node, Some("worker-1".to_string())); -} - -#[test] -fn pod_deserialize_no_node() { - let json = r#"{ - "name": "test-pod", - "namespace": "default", - "status": "Running", - "ready": "1/1", - "restarts": 0, - "age": "1h" - }"#; - let pod: Pod = serde_json::from_str(json).unwrap(); - assert!(pod.node.is_none()); -} - -#[test] -fn kubectl_config_debug() { - let config = KubectlConfig { - context: Some("test".to_string()), - namespace: Some("default".to_string()), - }; - let debug = format!("{:?}", config); - assert!(debug.contains("test")); -} - -#[test] -fn kubectl_config_clone() { - let config = KubectlConfig { - context: Some("prod".to_string()), - namespace: None, - }; - let cloned = config.clone(); - assert_eq!(cloned.context, Some("prod".to_string())); -} - -#[test] -fn pod_list_debug() { - let pod_list = PodList { items: vec![] }; - let debug = format!("{:?}", pod_list); - assert!(debug.contains("PodList")); -} - -#[test] -fn pod_item_debug() { - let item = PodItem { - metadata: PodMetadata { - name: "debug-test".to_string(), - namespace: "default".to_string(), - creation_timestamp: None, - }, - spec: None, - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![], - }, - }; - let debug = format!("{:?}", item); - assert!(debug.contains("debug-test")); -} - -#[test] -fn pod_metadata_debug() { - let meta = PodMetadata { - name: "test".to_string(), - namespace: "ns".to_string(), - creation_timestamp: Some("2026-01-01T00:00:00Z".to_string()), - }; - let debug = format!("{:?}", meta); - assert!(debug.contains("test")); -} - -#[test] -fn pod_spec_debug() { - let spec = PodSpec { - node_name: Some("node-1".to_string()), - containers: vec![Container { - name: "main".to_string(), - }], - }; - let debug = format!("{:?}", spec); - assert!(debug.contains("node-1")); -} - -#[test] -fn pod_spec_default() { - let spec = PodSpec::default(); - assert!(spec.node_name.is_none()); - assert!(spec.containers.is_empty()); -} - -#[test] -fn container_debug() { - let container = Container { - name: "sidecar".to_string(), - }; - let debug = format!("{:?}", container); - assert!(debug.contains("sidecar")); -} - -#[test] -fn pod_status_debug() { - let status = PodStatus { - phase: "Pending".to_string(), - container_statuses: vec![], - }; - let debug = format!("{:?}", status); - assert!(debug.contains("Pending")); -} - -#[test] -fn container_status_debug() { - let status = ContainerStatus { - name: "app".to_string(), - ready: true, - restart_count: 3, - }; - let debug = format!("{:?}", status); - assert!(debug.contains("app")); - assert!(debug.contains("true")); -} - -#[test] -fn ready_string_all_ready() { - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: None, - }, - spec: None, - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![ - ContainerStatus { - name: "a".to_string(), - ready: true, - restart_count: 0, - }, - ContainerStatus { - name: "b".to_string(), - ready: true, - restart_count: 0, - }, - ], - }, - }; - let pod = item.to_pod(); - assert_eq!(pod.ready, "2/2"); -} - -#[test] -fn ready_string_none_ready() { - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: None, - }, - spec: None, - status: PodStatus { - phase: "Pending".to_string(), - container_statuses: vec![ - ContainerStatus { - name: "a".to_string(), - ready: false, - restart_count: 0, - }, - ContainerStatus { - name: "b".to_string(), - ready: false, - restart_count: 0, - }, - ], - }, - }; - let pod = item.to_pod(); - assert_eq!(pod.ready, "0/2"); -} - -#[test] -fn total_restarts_sum() { - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: None, - }, - spec: None, - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![ - ContainerStatus { - name: "a".to_string(), - ready: true, - restart_count: 5, - }, - ContainerStatus { - name: "b".to_string(), - ready: true, - restart_count: 3, - }, - ContainerStatus { - name: "c".to_string(), - ready: true, - restart_count: 2, - }, - ], - }, - }; - let pod = item.to_pod(); - assert_eq!(pod.restarts, 10); -} - -#[test] -fn age_string_hours() { - // Use a timestamp from a few hours ago - let now = chrono::Utc::now(); - let hours_ago = now - chrono::Duration::hours(5); - let ts = hours_ago.to_rfc3339(); - - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: Some(ts), - }, - spec: None, - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![], - }, - }; - let pod = item.to_pod(); - assert!(pod.age.ends_with('h'), "Expected hours, got: {}", pod.age); -} - -#[test] -fn age_string_minutes() { - let now = chrono::Utc::now(); - let mins_ago = now - chrono::Duration::minutes(30); - let ts = mins_ago.to_rfc3339(); - - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: Some(ts), - }, - spec: None, - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![], - }, - }; - let pod = item.to_pod(); - assert!(pod.age.ends_with('m'), "Expected minutes, got: {}", pod.age); -} - -#[test] -fn age_string_seconds() { - let now = chrono::Utc::now(); - let secs_ago = now - chrono::Duration::seconds(45); - let ts = secs_ago.to_rfc3339(); - - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: Some(ts), - }, - spec: None, - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![], - }, - }; - let pod = item.to_pod(); - assert!(pod.age.ends_with('s'), "Expected seconds, got: {}", pod.age); -} - -#[test] -fn age_string_days() { - let now = chrono::Utc::now(); - let days_ago = now - chrono::Duration::days(7); - let ts = days_ago.to_rfc3339(); - - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: Some(ts), - }, - spec: None, - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![], - }, - }; - let pod = item.to_pod(); - assert!(pod.age.ends_with('d'), "Expected days, got: {}", pod.age); -} - -#[test] -fn pod_with_spec_node() { - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: None, - }, - spec: Some(PodSpec { - node_name: Some("worker-abc".to_string()), - containers: vec![], - }), - status: PodStatus { - phase: "Running".to_string(), - container_statuses: vec![], - }, - }; - let pod = item.to_pod(); - assert_eq!(pod.node, Some("worker-abc".to_string())); -} - -#[test] -fn pod_with_spec_no_node() { - let item = PodItem { - metadata: PodMetadata { - name: "test".to_string(), - namespace: "default".to_string(), - creation_timestamp: None, - }, - spec: Some(PodSpec { - node_name: None, - containers: vec![], - }), - status: PodStatus { - phase: "Pending".to_string(), - container_statuses: vec![], - }, - }; - let pod = item.to_pod(); - assert!(pod.node.is_none()); -} diff --git a/src/gh/auth.rs b/src/gh/auth.rs deleted file mode 100644 index 6d26614..0000000 --- a/src/gh/auth.rs +++ /dev/null @@ -1,201 +0,0 @@ -use anyhow::{bail, Context, Result}; - -use crate::util::{load_credentials, save_credentials, GithubCredentials}; - -#[cfg(test)] -use crate::util::{load_credentials_from, save_credentials_to}; - -#[cfg(test)] -use std::path::PathBuf; - -/// Save token and fetch username -pub async fn login(token: &str) -> Result { - let username = fetch_username_from_github(token).await?; - save_login(&username, token)?; - Ok(username) -} - -/// Start device flow authentication (uses `gh auth token` if available) -pub async fn device_flow_login() -> Result { - // Try to get token from gh CLI first - if let Some(token) = get_gh_cli_token().await { - println!("Using token from gh CLI..."); - return login(&token).await; - } - - // Fall back to prompting for PAT - bail!( - "No token found. Please either:\n \ - 1. Run 'gh auth login' first, or\n \ - 2. Use 'hu gh login --token ' with a Personal Access Token" - ); -} - -/// Try to get token from gh CLI -async fn get_gh_cli_token() -> Option { - let output = tokio::process::Command::new("gh") - .args(["auth", "token"]) - .output() - .await - .ok()?; - - if output.status.success() { - let token = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if !token.is_empty() { - return Some(token); - } - } - None -} - -/// Save login credentials (extracted for testability) -pub fn save_login(username: &str, token: &str) -> Result<()> { - let mut creds = load_credentials().unwrap_or_default(); - creds.github = Some(GithubCredentials { - token: token.to_string(), - username: username.to_string(), - }); - save_credentials(&creds) -} - -/// Save login to a specific path (for testing) -#[cfg(test)] -fn save_login_to(username: &str, token: &str, path: &PathBuf) -> Result<()> { - let mut creds = load_credentials_from(path).unwrap_or_default(); - creds.github = Some(GithubCredentials { - token: token.to_string(), - username: username.to_string(), - }); - save_credentials_to(&creds, path) -} - -/// Load login from a specific path (for testing) -#[cfg(test)] -fn load_login_from(path: &PathBuf) -> Option<(String, String)> { - load_credentials_from(path) - .ok() - .and_then(|c| c.github.map(|g| (g.username, g.token))) -} - -/// Fetch username from GitHub API (the actual network call) -async fn fetch_username_from_github(token: &str) -> Result { - let octocrab = octocrab::OctocrabBuilder::new() - .personal_token(token.to_string()) - .build() - .context("Failed to create GitHub client")?; - - let user = octocrab - .current() - .user() - .await - .context("Failed to get current user - check your token")?; - - Ok(user.login) -} - -/// Get stored token if available -pub fn get_token() -> Option { - load_credentials() - .ok() - .and_then(|c| c.github.map(|g| g.token)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn get_token_returns_option() { - let result = get_token(); - // Result is either Some(token) or None - assert!(result.is_some() || result.is_none()); - } - - #[test] - fn get_token_consistent_results() { - // Calling get_token multiple times should return the same result - let result1 = get_token(); - let result2 = get_token(); - assert_eq!(result1.is_some(), result2.is_some()); - } - - #[test] - fn credentials_struct_usage() { - // Verify we can create and use the credential structs - let creds = GithubCredentials { - token: "test_token".to_string(), - username: "testuser".to_string(), - }; - assert_eq!(creds.token, "test_token"); - assert_eq!(creds.username, "testuser"); - } - - #[test] - fn credentials_optional_in_parent() { - use crate::util::Credentials; - let creds = Credentials::default(); - assert!(creds.github.is_none()); - } - - // Tests for path-based login functions - #[test] - fn save_and_load_login_roundtrip() { - let temp_dir = std::env::temp_dir().join("hu_test_auth"); - let _ = std::fs::remove_dir_all(&temp_dir); - let path = temp_dir.join("credentials.toml"); - - // Save login - save_login_to("testuser", "test_token", &path).unwrap(); - - // Load login - let result = load_login_from(&path); - assert!(result.is_some()); - let (username, token) = result.unwrap(); - assert_eq!(username, "testuser"); - assert_eq!(token, "test_token"); - - let _ = std::fs::remove_dir_all(&temp_dir); - } - - #[test] - fn load_login_from_missing_file() { - let path = PathBuf::from("/nonexistent/credentials.toml"); - let result = load_login_from(&path); - assert!(result.is_none()); - } - - #[test] - fn load_login_from_empty_credentials() { - let temp_dir = std::env::temp_dir().join("hu_test_auth_empty"); - let _ = std::fs::create_dir_all(&temp_dir); - let path = temp_dir.join("credentials.toml"); - - // Write empty credentials - std::fs::write(&path, "").unwrap(); - - let result = load_login_from(&path); - assert!(result.is_none()); - - let _ = std::fs::remove_dir_all(&temp_dir); - } - - #[test] - fn save_login_overwrites_existing() { - let temp_dir = std::env::temp_dir().join("hu_test_auth_overwrite"); - let _ = std::fs::remove_dir_all(&temp_dir); - let path = temp_dir.join("credentials.toml"); - - // Save first login - save_login_to("user1", "token1", &path).unwrap(); - - // Save second login - save_login_to("user2", "token2", &path).unwrap(); - - // Load and verify - let (username, token) = load_login_from(&path).unwrap(); - assert_eq!(username, "user2"); - assert_eq!(token, "token2"); - - let _ = std::fs::remove_dir_all(&temp_dir); - } -} diff --git a/src/gh/cli.rs b/src/gh/cli.rs deleted file mode 100644 index 791e312..0000000 --- a/src/gh/cli.rs +++ /dev/null @@ -1,105 +0,0 @@ -use clap::{Args, Subcommand}; -use std::path::PathBuf; - -#[derive(Debug, Subcommand)] -pub enum GhCommand { - /// Authenticate with GitHub (uses gh CLI token or PAT) - Login(LoginArgs), - /// List open pull requests authored by you - Prs, - /// Extract test failures from CI - Failures(FailuresArgs), - /// Analyze CI failures and output investigation context - Fix(FixArgs), - /// List workflow runs - Runs(RunsArgs), - /// Commit and push all changes (quick sync) - Sync(SyncArgs), -} - -#[derive(Debug, Args)] -pub struct SyncArgs { - /// Path to git repository (default: current directory) - pub path: Option, - /// Skip pulling from remote - #[arg(long)] - pub no_pull: bool, - /// Create empty commit and push to trigger CI - #[arg(long, short)] - pub trigger: bool, - /// Skip git commit - #[arg(long)] - pub no_commit: bool, - /// Skip git push - #[arg(long)] - pub no_push: bool, - /// Custom commit message - #[arg(long, short)] - pub message: Option, - /// Log sync to file (one line per sync) - #[arg(long, short)] - pub log: bool, - /// Log file path (default: ~/.hu/gh-sync.log) - #[arg(long)] - pub log_file: Option, - /// Output as JSON - #[arg(long, short)] - pub json: bool, -} - -#[derive(Debug, Args)] -pub struct LoginArgs { - /// Personal Access Token (if not provided, uses device flow) - #[arg(long, short)] - pub token: Option, -} - -#[derive(Debug, Args)] -pub struct FailuresArgs { - /// PR number (defaults to current branch's PR) - #[arg(long)] - pub pr: Option, - /// Repository in owner/repo format (defaults to current directory's repo) - #[arg(long, short)] - pub repo: Option, -} - -#[derive(Debug, Args)] -pub struct FixArgs { - /// PR number - #[arg(long)] - pub pr: Option, - /// Workflow run ID - #[arg(long)] - pub run: Option, - /// Branch name - #[arg(long, short)] - pub branch: Option, - /// Repository in owner/repo format - #[arg(long, short)] - pub repo: Option, - /// Output as JSON - #[arg(long, short)] - pub json: bool, -} - -#[derive(Debug, Args)] -pub struct RunsArgs { - /// Ticket key to find runs for (e.g. BFR-1234) - pub ticket: Option, - /// Filter by status: queued, in_progress, completed, success, failure - #[arg(long, short)] - pub status: Option, - /// Filter by branch name - #[arg(long, short)] - pub branch: Option, - /// Repository in owner/repo format - #[arg(long, short)] - pub repo: Option, - /// Max results (default: 20) - #[arg(long, short = 'n', default_value = "20")] - pub limit: usize, - /// Output as JSON - #[arg(long, short)] - pub json: bool, -} diff --git a/src/gh/client/mod.rs b/src/gh/client/mod.rs deleted file mode 100644 index f6395b9..0000000 --- a/src/gh/client/mod.rs +++ /dev/null @@ -1,478 +0,0 @@ -use anyhow::{Context, Result}; -use octocrab::Octocrab; - -use super::auth::get_token; -use super::types::{CiStatus, PullRequest, RunsQuery, WorkflowRun}; - -mod parsing; - -#[cfg(test)] -use parsing::clean_ci_line; -pub use parsing::parse_test_failures; - -#[cfg(test)] -mod tests; - -/// Trait for GitHub API operations (enables mocking in tests) -pub trait GithubApi: Send + Sync { - /// List open PRs authored by the current user - fn list_user_prs(&self) -> impl std::future::Future>> + Send; - - /// Get CI status for a PR - fn get_ci_status( - &self, - owner: &str, - repo: &str, - pr_number: u64, - ) -> impl std::future::Future> + Send; - - /// Get the branch name for a PR - fn get_pr_branch( - &self, - owner: &str, - repo: &str, - pr_number: u64, - ) -> impl std::future::Future> + Send; - - /// Get the latest failed workflow run for a branch - fn get_latest_failed_run_for_branch( - &self, - owner: &str, - repo: &str, - branch: &str, - ) -> impl std::future::Future>> + Send; - - /// Get the latest failed workflow run for a repo (any branch) - fn get_latest_failed_run( - &self, - owner: &str, - repo: &str, - ) -> impl std::future::Future>> + Send; - - /// Get failed jobs for a workflow run - fn get_failed_jobs( - &self, - owner: &str, - repo: &str, - run_id: u64, - ) -> impl std::future::Future>> + Send; - - /// Download logs for a job - fn get_job_logs( - &self, - owner: &str, - repo: &str, - job_id: u64, - ) -> impl std::future::Future> + Send; - - /// Find PR number for a branch - fn find_pr_for_branch( - &self, - owner: &str, - repo: &str, - branch: &str, - ) -> impl std::future::Future>> + Send; - - /// List workflow runs for a repository - fn list_workflow_runs( - &self, - query: &RunsQuery<'_>, - ) -> impl std::future::Future>> + Send; - - /// Search PRs by title/branch containing a query string - fn search_prs_by_title( - &self, - owner: &str, - repo: &str, - query: &str, - ) -> impl std::future::Future>> + Send; -} - -/// Parse CI status from GitHub API responses (pure function, testable) -pub fn parse_ci_status(state: &str, check_runs: Option<&Vec>) -> CiStatus { - if let Some(runs) = check_runs { - if runs.is_empty() && state == "pending" { - return CiStatus::Pending; - } - - let any_failed = runs - .iter() - .any(|r| r["conclusion"].as_str() == Some("failure")); - let any_pending = runs.iter().any(|r| { - r["status"].as_str() != Some("completed") || r["conclusion"].as_str().is_none() - }); - let all_success = runs - .iter() - .all(|r| r["conclusion"].as_str() == Some("success")); - - if any_failed { - CiStatus::Failed - } else if any_pending { - CiStatus::Pending - } else if all_success && !runs.is_empty() { - CiStatus::Success - } else { - parse_state_string(state) - } - } else { - parse_state_string(state) - } -} - -/// Parse state string to CiStatus -fn parse_state_string(state: &str) -> CiStatus { - match state { - "success" => CiStatus::Success, - "pending" => CiStatus::Pending, - "failure" | "error" => CiStatus::Failed, - _ => CiStatus::Unknown, - } -} - -/// Extract failed jobs from GitHub jobs API response (pure function, testable) -pub fn extract_failed_jobs(jobs: &serde_json::Value) -> Vec<(u64, String)> { - jobs["jobs"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter(|j| j["conclusion"].as_str() == Some("failure")) - .filter_map(|j| { - let id = j["id"].as_u64()?; - let name = j["name"].as_str()?.to_string(); - Some((id, name)) - }) - .collect() -} - -/// Extract PR number from pull request list response (pure function, testable) -pub fn extract_pr_number_from_list(prs: &serde_json::Value) -> Option { - prs.as_array() - .and_then(|arr| arr.first()) - .and_then(|pr| pr["number"].as_u64()) -} - -/// Extract run ID from workflow runs response (pure function, testable) -pub fn extract_run_id(runs: &serde_json::Value) -> Option { - runs["workflow_runs"] - .as_array() - .and_then(|arr| arr.first()) - .and_then(|r| r["id"].as_u64()) -} - -/// Extract workflow runs from GitHub API response (pure function, testable) -pub fn extract_workflow_runs(response: &serde_json::Value) -> Vec { - response["workflow_runs"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|r| { - Some(WorkflowRun { - id: r["id"].as_u64()?, - name: r["name"].as_str()?.to_string(), - status: r["status"].as_str().unwrap_or("unknown").to_string(), - conclusion: r["conclusion"].as_str().map(|s| s.to_string()), - branch: r["head_branch"].as_str().unwrap_or("").to_string(), - html_url: r["html_url"].as_str().unwrap_or("").to_string(), - created_at: r["created_at"].as_str().unwrap_or("").to_string(), - updated_at: r["updated_at"].as_str().unwrap_or("").to_string(), - run_number: r["run_number"].as_u64().unwrap_or(0), - }) - }) - .collect() -} - -/// Extract PRs matching a query from GitHub PR list response (pure function, testable) -pub fn extract_matching_prs(response: &serde_json::Value, query: &str) -> Vec { - let query_lower = query.to_lowercase(); - response - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter(|pr| { - let title = pr["title"].as_str().unwrap_or("").to_lowercase(); - let branch = pr["head"]["ref"].as_str().unwrap_or("").to_lowercase(); - title.contains(&query_lower) || branch.contains(&query_lower) - }) - .filter_map(|pr| { - let repo_full_name = pr["base"]["repo"]["full_name"] - .as_str() - .unwrap_or("") - .to_string(); - Some(PullRequest { - number: pr["number"].as_u64()?, - title: pr["title"].as_str()?.to_string(), - html_url: pr["html_url"].as_str().unwrap_or("").to_string(), - state: pr["state"].as_str().unwrap_or("").to_string(), - repo_full_name, - created_at: pr["created_at"].as_str().unwrap_or("").to_string(), - updated_at: pr["updated_at"].as_str().unwrap_or("").to_string(), - ci_status: None, - }) - }) - .collect() -} - -pub struct GithubClient { - client: Octocrab, -} - -impl GithubClient { - /// Create a new authenticated GitHub client - pub fn new() -> Result { - let token = get_token().context("Not authenticated. Run `hu gh login` first.")?; - - let client = Octocrab::builder() - .personal_token(token) - .build() - .context("Failed to create GitHub client")?; - - Ok(Self { client }) - } - - /// Create client from provided token (for testing) - #[allow(dead_code)] - pub fn with_token(token: &str) -> Result { - let client = Octocrab::builder() - .personal_token(token.to_string()) - .build() - .context("Failed to create GitHub client")?; - - Ok(Self { client }) - } -} - -impl GithubApi for GithubClient { - async fn list_user_prs(&self) -> Result> { - // Use the search API to find PRs where author is current user - let result = self - .client - .search() - .issues_and_pull_requests("is:pr is:open author:@me") - .send() - .await - .context("Failed to search for PRs")?; - - let prs: Vec = result - .items - .into_iter() - .filter_map(|issue| { - // Extract repo from URL: https://api.github.com/repos/owner/repo/issues/123 - let repo_full_name = issue - .repository_url - .path_segments()? - .skip(1) // skip "repos" - .take(2) // take "owner" and "repo" - .collect::>() - .join("/"); - - let state = match issue.state { - octocrab::models::IssueState::Open => "open", - octocrab::models::IssueState::Closed => "closed", - _ => "unknown", - }; - - Some(PullRequest { - number: issue.number, - title: issue.title, - html_url: issue.html_url.to_string(), - state: state.to_string(), - repo_full_name, - created_at: issue.created_at.to_rfc3339(), - updated_at: issue.updated_at.to_rfc3339(), - ci_status: None, - }) - }) - .collect(); - - Ok(prs) - } - - async fn get_ci_status(&self, owner: &str, repo: &str, pr_number: u64) -> Result { - // Get the PR to find the head SHA - let pr = self - .client - .pulls(owner, repo) - .get(pr_number) - .await - .context("Failed to get PR")?; - - let sha = &pr.head.sha; - - // Get combined status - let status: serde_json::Value = self - .client - .get( - format!("/repos/{}/{}/commits/{}/status", owner, repo, sha), - None::<&()>, - ) - .await - .context("Failed to get commit status")?; - - let state = status["state"].as_str().unwrap_or("unknown"); - - // Also check for check runs (GitHub Actions uses this) - let checks: serde_json::Value = self - .client - .get( - format!("/repos/{}/{}/commits/{}/check-runs", owner, repo, sha), - None::<&()>, - ) - .await - .unwrap_or_default(); - - let check_runs = checks["check_runs"].as_array(); - - Ok(parse_ci_status(state, check_runs)) - } - - async fn get_pr_branch(&self, owner: &str, repo: &str, pr_number: u64) -> Result { - let pr = self - .client - .pulls(owner, repo) - .get(pr_number) - .await - .context("Failed to get PR")?; - - Ok(pr.head.ref_field) - } - - async fn get_latest_failed_run_for_branch( - &self, - owner: &str, - repo: &str, - branch: &str, - ) -> Result> { - let runs: serde_json::Value = self - .client - .get( - format!( - "/repos/{}/{}/actions/runs?branch={}&status=failure&per_page=1", - owner, repo, branch - ), - None::<&()>, - ) - .await - .context("Failed to get workflow runs")?; - - Ok(extract_run_id(&runs)) - } - - async fn get_latest_failed_run(&self, owner: &str, repo: &str) -> Result> { - let runs: serde_json::Value = self - .client - .get( - format!( - "/repos/{}/{}/actions/runs?status=failure&per_page=1", - owner, repo - ), - None::<&()>, - ) - .await - .context("Failed to get workflow runs")?; - - Ok(extract_run_id(&runs)) - } - - async fn get_failed_jobs( - &self, - owner: &str, - repo: &str, - run_id: u64, - ) -> Result> { - let jobs: serde_json::Value = self - .client - .get( - format!("/repos/{}/{}/actions/runs/{}/jobs", owner, repo, run_id), - None::<&()>, - ) - .await - .context("Failed to get jobs")?; - - Ok(extract_failed_jobs(&jobs)) - } - - async fn get_job_logs(&self, owner: &str, repo: &str, job_id: u64) -> Result { - // The logs endpoint returns a redirect to a download URL - // We need to use reqwest directly for this - let token = get_token().context("Not authenticated")?; - - let client = reqwest::Client::new(); - let url = format!( - "https://api.github.com/repos/{}/{}/actions/jobs/{}/logs", - owner, repo, job_id - ); - - let response = client - .get(&url) - .header("Authorization", format!("Bearer {}", token)) - .header("User-Agent", "hu-cli") - .header("Accept", "application/vnd.github+json") - .send() - .await - .context("Failed to request job logs")?; - - let logs = response.text().await.context("Failed to read job logs")?; - - Ok(logs) - } - - async fn find_pr_for_branch( - &self, - owner: &str, - repo: &str, - branch: &str, - ) -> Result> { - let prs: serde_json::Value = self - .client - .get( - format!( - "/repos/{}/{}/pulls?head={}:{}&state=open&per_page=1", - owner, repo, owner, branch - ), - None::<&()>, - ) - .await - .context("Failed to search for PR by branch")?; - - Ok(extract_pr_number_from_list(&prs)) - } - - async fn list_workflow_runs(&self, query: &RunsQuery<'_>) -> Result> { - let mut url = format!( - "/repos/{}/{}/actions/runs?per_page={}", - query.owner, query.repo, query.limit - ); - if let Some(b) = query.branch { - url.push_str(&format!("&branch={}", b)); - } - if let Some(s) = query.status { - url.push_str(&format!("&status={}", s)); - } - - let response: serde_json::Value = self - .client - .get(url, None::<&()>) - .await - .context("Failed to list workflow runs")?; - - Ok(extract_workflow_runs(&response)) - } - - async fn search_prs_by_title( - &self, - owner: &str, - repo: &str, - query: &str, - ) -> Result> { - let response: serde_json::Value = self - .client - .get( - format!("/repos/{}/{}/pulls?state=all&per_page=100", owner, repo), - None::<&()>, - ) - .await - .context("Failed to list PRs for search")?; - - Ok(extract_matching_prs(&response, query)) - } -} diff --git a/src/gh/client/parsing.rs b/src/gh/client/parsing.rs deleted file mode 100644 index 0ad8932..0000000 --- a/src/gh/client/parsing.rs +++ /dev/null @@ -1,95 +0,0 @@ -use super::super::types::TestFailure; - -/// Extract test failures from logs (RSpec format) -pub fn parse_test_failures(logs: &str) -> Vec { - let mut failures = Vec::new(); - - // Collect failure error messages in order - let mut error_messages: Vec = Vec::new(); - - // Find the Failures section and parse each failure block - if let Some(failures_start) = logs.find("Failures:") { - let failures_end = logs.find("Failed examples:").unwrap_or(logs.len()); - let failures_section = &logs[failures_start..failures_end]; - - // Split by numbered failure pattern "N) description" - let block_starts: Vec = regex::Regex::new(r"\d+\)\s+\S") - .ok() - .map(|re| re.find_iter(failures_section).map(|m| m.start()).collect()) - .unwrap_or_default(); - - let mut positions = block_starts.clone(); - positions.push(failures_section.len()); - - for i in 0..block_starts.len() { - let block = &failures_section[positions[i]..positions[i + 1]]; - - // Extract error: code line after Failure/Error: and the error message on next line - if let Some(fe_idx) = block.find("Failure/Error:") { - let after_fe = &block[fe_idx..]; - let lines: Vec = after_fe - .lines() - .map(clean_ci_line) - .filter(|l| !l.is_empty()) - .take(4) - .collect(); - - // lines[0] = "Failure/Error: " - // lines[1] = "" or "# " - let code_line = lines - .first() - .map(|l| l.strip_prefix("Failure/Error:").unwrap_or(l).trim()) - .unwrap_or(""); - let error_msg = lines.get(1).map(|s| s.as_str()).unwrap_or(""); - - let error_text = if error_msg.is_empty() || error_msg.starts_with("# ") { - code_line.to_string() - } else { - format!("{}\n{}", code_line, error_msg) - }; - - error_messages.push(error_text); - } - } - } - - // Extract failed examples from the "Failed examples:" section - // Format: rspec ./spec/helpers/prices_api_helper_spec.rb:289 # description - let failed_examples_re = regex::Regex::new(r"rspec\s+(\./spec/[^\s]+:\d+)").ok(); - - if let Some(re) = &failed_examples_re { - for (i, cap) in re.captures_iter(logs).enumerate() { - let spec_file = cap.get(1).map(|m| m.as_str()).unwrap_or(""); - - // Get error message by index (failures appear in same order) - let failure_text = error_messages - .get(i) - .cloned() - .unwrap_or_else(|| "Test failed".to_string()); - - // Avoid duplicates - if !failures - .iter() - .any(|f: &TestFailure| f.spec_file == spec_file) - { - failures.push(TestFailure { - spec_file: spec_file.to_string(), - failure_text, - }); - } - } - } - - failures -} - -/// Clean up CI log line by removing timestamp prefix -pub(super) fn clean_ci_line(line: &str) -> String { - // Remove timestamp prefix like "2026-01-27T18:51:46.1029380Z" - let re = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s*").ok(); - if let Some(re) = re { - re.replace(line, "").trim().to_string() - } else { - line.trim().to_string() - } -} diff --git a/src/gh/client/tests.rs b/src/gh/client/tests.rs deleted file mode 100644 index b274a3e..0000000 --- a/src/gh/client/tests.rs +++ /dev/null @@ -1,524 +0,0 @@ -use super::*; -use serde_json::json; - -#[test] -fn get_token_returns_option() { - // Just verify get_token doesn't panic - let token = get_token(); - assert!(token.is_some() || token.is_none()); -} - -// parse_ci_status tests -#[test] -fn parse_ci_status_success_from_runs() { - let runs = vec![json!({"status": "completed", "conclusion": "success"})]; - assert_eq!(parse_ci_status("pending", Some(&runs)), CiStatus::Success); -} - -#[test] -fn parse_ci_status_failed_from_runs() { - let runs = vec![ - json!({"status": "completed", "conclusion": "success"}), - json!({"status": "completed", "conclusion": "failure"}), - ]; - assert_eq!(parse_ci_status("pending", Some(&runs)), CiStatus::Failed); -} - -#[test] -fn parse_ci_status_pending_from_runs() { - let runs = vec![ - json!({"status": "completed", "conclusion": "success"}), - json!({"status": "in_progress", "conclusion": null}), - ]; - assert_eq!(parse_ci_status("pending", Some(&runs)), CiStatus::Pending); -} - -#[test] -fn parse_ci_status_empty_runs_pending() { - let runs: Vec = vec![]; - assert_eq!(parse_ci_status("pending", Some(&runs)), CiStatus::Pending); -} - -#[test] -fn parse_ci_status_no_runs_uses_state() { - assert_eq!(parse_ci_status("success", None), CiStatus::Success); - assert_eq!(parse_ci_status("failure", None), CiStatus::Failed); - assert_eq!(parse_ci_status("error", None), CiStatus::Failed); - assert_eq!(parse_ci_status("pending", None), CiStatus::Pending); - assert_eq!(parse_ci_status("unknown", None), CiStatus::Unknown); -} - -#[test] -fn parse_state_string_all_cases() { - assert_eq!(parse_state_string("success"), CiStatus::Success); - assert_eq!(parse_state_string("pending"), CiStatus::Pending); - assert_eq!(parse_state_string("failure"), CiStatus::Failed); - assert_eq!(parse_state_string("error"), CiStatus::Failed); - assert_eq!(parse_state_string("other"), CiStatus::Unknown); -} - -// extract_failed_jobs tests -#[test] -fn extract_failed_jobs_filters_failures() { - let jobs = json!({ - "jobs": [ - {"id": 1, "name": "build", "conclusion": "success"}, - {"id": 2, "name": "test", "conclusion": "failure"}, - {"id": 3, "name": "lint", "conclusion": "failure"}, - ] - }); - let failed = extract_failed_jobs(&jobs); - assert_eq!(failed.len(), 2); - assert_eq!(failed[0], (2, "test".to_string())); - assert_eq!(failed[1], (3, "lint".to_string())); -} - -#[test] -fn extract_failed_jobs_empty_when_all_success() { - let jobs = json!({ - "jobs": [ - {"id": 1, "name": "build", "conclusion": "success"}, - ] - }); - assert!(extract_failed_jobs(&jobs).is_empty()); -} - -#[test] -fn extract_failed_jobs_handles_missing_jobs() { - let jobs = json!({}); - assert!(extract_failed_jobs(&jobs).is_empty()); -} - -#[test] -fn extract_failed_jobs_handles_null_jobs() { - let jobs = json!({"jobs": null}); - assert!(extract_failed_jobs(&jobs).is_empty()); -} - -// extract_run_id tests -#[test] -fn extract_run_id_finds_first() { - let runs = json!({ - "workflow_runs": [ - {"id": 123}, - {"id": 456}, - ] - }); - assert_eq!(extract_run_id(&runs), Some(123)); -} - -#[test] -fn extract_run_id_empty_array() { - let runs = json!({"workflow_runs": []}); - assert_eq!(extract_run_id(&runs), None); -} - -#[test] -fn extract_run_id_missing_key() { - let runs = json!({}); - assert_eq!(extract_run_id(&runs), None); -} - -// extract_pr_number_from_list tests -#[test] -fn extract_pr_number_from_list_finds_first() { - let prs = json!([{"number": 42}, {"number": 99}]); - assert_eq!(extract_pr_number_from_list(&prs), Some(42)); -} - -#[test] -fn extract_pr_number_from_list_single() { - let prs = json!([{"number": 7}]); - assert_eq!(extract_pr_number_from_list(&prs), Some(7)); -} - -#[test] -fn extract_pr_number_from_list_empty() { - let prs = json!([]); - assert_eq!(extract_pr_number_from_list(&prs), None); -} - -#[test] -fn extract_pr_number_from_list_missing_number() { - let prs = json!([{"title": "no number"}]); - assert_eq!(extract_pr_number_from_list(&prs), None); -} - -#[test] -fn extract_pr_number_from_list_not_array() { - let prs = json!({"number": 42}); - assert_eq!(extract_pr_number_from_list(&prs), None); -} - -#[test] -fn extract_pr_number_from_list_null() { - let prs = json!(null); - assert_eq!(extract_pr_number_from_list(&prs), None); -} - -#[test] -fn clean_ci_line_removes_timestamp() { - let line = "2026-01-27T18:51:46.1029380Z Failure/Error: some code"; - assert_eq!(clean_ci_line(line), "Failure/Error: some code"); -} - -#[test] -fn clean_ci_line_preserves_line_without_timestamp() { - let line = " some regular line "; - assert_eq!(clean_ci_line(line), "some regular line"); -} - -#[test] -fn clean_ci_line_handles_empty() { - assert_eq!(clean_ci_line(""), ""); - assert_eq!(clean_ci_line(" "), ""); -} - -#[test] -fn parse_test_failures_extracts_rspec_failures() { - let logs = r#" -2026-01-27T18:51:46.1025638Z Failures: -2026-01-27T18:51:46.1026049Z -2026-01-27T18:51:46.1027821Z 1) MyClass does something -2026-01-27T18:51:46.1029380Z Failure/Error: expect(result).to eq(expected) -2026-01-27T18:51:46.1167230Z expected: 42 -2026-01-27T18:51:46.1168761Z # ./spec/my_class_spec.rb:10:in `block' -2026-01-27T18:51:46.1174151Z -2026-01-27T18:51:46.1253383Z Failed examples: -2026-01-27T18:51:46.1255271Z rspec ./spec/my_class_spec.rb:8 # MyClass does something -"#; - let failures = parse_test_failures(logs); - assert_eq!(failures.len(), 1); - assert_eq!(failures[0].spec_file, "./spec/my_class_spec.rb:8"); - assert!(failures[0] - .failure_text - .contains("expect(result).to eq(expected)")); - assert!(failures[0].failure_text.contains("expected: 42")); -} - -#[test] -fn parse_test_failures_handles_multiple_failures() { - let logs = r#" -Failures: - - 1) First test fails - Failure/Error: assert false - error one - # ./spec/first_spec.rb:5 - - 2) Second test fails - Failure/Error: raise "boom" - error two - # ./spec/second_spec.rb:10 - -Failed examples: - -rspec ./spec/first_spec.rb:3 # First test fails -rspec ./spec/second_spec.rb:8 # Second test fails -"#; - let failures = parse_test_failures(logs); - assert_eq!(failures.len(), 2); - assert_eq!(failures[0].spec_file, "./spec/first_spec.rb:3"); - assert_eq!(failures[1].spec_file, "./spec/second_spec.rb:8"); - assert!(failures[0].failure_text.contains("assert false")); - assert!(failures[1].failure_text.contains("raise \"boom\"")); -} - -#[test] -fn parse_test_failures_handles_no_failures() { - let logs = "All tests passed!\n0 failures"; - let failures = parse_test_failures(logs); - assert!(failures.is_empty()); -} - -#[test] -fn parse_test_failures_handles_empty_logs() { - let failures = parse_test_failures(""); - assert!(failures.is_empty()); -} - -#[test] -fn parse_test_failures_deduplicates() { - let logs = r#" -Failures: - - 1) Test fails - Failure/Error: fail - # ./spec/test_spec.rb:5 - -Failed examples: - -rspec ./spec/test_spec.rb:3 # Test fails -rspec ./spec/test_spec.rb:3 # Test fails duplicate -"#; - let failures = parse_test_failures(logs); - assert_eq!(failures.len(), 1); -} - -#[test] -fn parse_test_failures_mock_error_format() { - // Test the actual format from the CI logs - let logs = r#" -2026-01-27T18:51:46.1025638Z Failures: -2026-01-27T18:51:46.1027821Z 1) PricesApiHelper pax value includes pax -2026-01-27T18:51:46.1029380Z Failure/Error: found_lowest_prices += service.method -2026-01-27T18:51:46.1167230Z # received unexpected message :method -2026-01-27T18:51:46.1168761Z # ./app/helpers/prices_api_helper.rb:62 -2026-01-27T18:51:46.1253383Z Failed examples: -2026-01-27T18:51:46.1255271Z rspec ./spec/helpers/prices_api_helper_spec.rb:289 # PricesApiHelper pax value includes pax -"#; - let failures = parse_test_failures(logs); - assert_eq!(failures.len(), 1); - assert_eq!( - failures[0].spec_file, - "./spec/helpers/prices_api_helper_spec.rb:289" - ); - assert!(failures[0] - .failure_text - .contains("received unexpected message")); -} - -#[test] -fn parse_test_failures_code_only_when_error_is_stacktrace() { - let logs = r#" -Failures: - - 1) Test with stack trace only - Failure/Error: some_method_call - # ./spec/test_spec.rb:5 - -Failed examples: - -rspec ./spec/test_spec.rb:3 # Test with stack trace only -"#; - let failures = parse_test_failures(logs); - assert_eq!(failures.len(), 1); - // Should only have the code line since next line starts with # - assert_eq!(failures[0].failure_text, "some_method_call"); -} - -#[test] -fn parse_test_failures_handles_failures_section_only() { - // Missing "Failed examples:" section - let logs = r#" -Failures: - - 1) Test fails - Failure/Error: expect(1).to eq(2) - expected: 2 - # ./spec/test_spec.rb:5 -"#; - let failures = parse_test_failures(logs); - // No failed examples section means we can't extract spec files - assert!(failures.is_empty()); -} - -#[test] -fn parse_test_failures_handles_nested_spec_paths() { - let logs = r#" -Failures: - - 1) Deep path test - Failure/Error: fail "deep" - error msg - -Failed examples: - -rspec ./spec/features/admin/users/permissions_spec.rb:42 # Deep path test -"#; - let failures = parse_test_failures(logs); - assert_eq!(failures.len(), 1); - assert_eq!( - failures[0].spec_file, - "./spec/features/admin/users/permissions_spec.rb:42" - ); -} - -// extract_workflow_runs tests -#[test] -fn extract_workflow_runs_valid_response() { - let response = json!({ - "workflow_runs": [ - { - "id": 100, - "name": "CI", - "status": "completed", - "conclusion": "success", - "head_branch": "main", - "html_url": "https://github.com/o/r/actions/runs/100", - "created_at": "2024-01-15T10:00:00Z", - "updated_at": "2024-01-15T10:05:00Z", - "run_number": 42 - }, - { - "id": 101, - "name": "Lint", - "status": "in_progress", - "conclusion": null, - "head_branch": "feature", - "html_url": "https://github.com/o/r/actions/runs/101", - "created_at": "2024-01-15T11:00:00Z", - "updated_at": "2024-01-15T11:01:00Z", - "run_number": 43 - } - ] - }); - let runs = extract_workflow_runs(&response); - assert_eq!(runs.len(), 2); - assert_eq!(runs[0].id, 100); - assert_eq!(runs[0].name, "CI"); - assert_eq!(runs[0].conclusion, Some("success".to_string())); - assert_eq!(runs[0].branch, "main"); - assert_eq!(runs[1].id, 101); - assert!(runs[1].conclusion.is_none()); -} - -#[test] -fn extract_workflow_runs_empty() { - let response = json!({"workflow_runs": []}); - assert!(extract_workflow_runs(&response).is_empty()); -} - -#[test] -fn extract_workflow_runs_missing_key() { - let response = json!({}); - assert!(extract_workflow_runs(&response).is_empty()); -} - -#[test] -fn extract_workflow_runs_skips_invalid() { - let response = json!({ - "workflow_runs": [ - {"name": "no id"}, - { - "id": 100, - "name": "Valid", - "status": "completed", - "conclusion": "success", - "head_branch": "main", - "html_url": "url", - "created_at": "c", - "updated_at": "u", - "run_number": 1 - } - ] - }); - let runs = extract_workflow_runs(&response); - assert_eq!(runs.len(), 1); - assert_eq!(runs[0].id, 100); -} - -#[test] -fn extract_workflow_runs_null_runs() { - let response = json!({"workflow_runs": null}); - assert!(extract_workflow_runs(&response).is_empty()); -} - -// extract_matching_prs tests -#[test] -fn extract_matching_prs_by_title() { - let response = json!([ - { - "number": 1, - "title": "BFR-1234 Fix login", - "html_url": "https://github.com/o/r/pull/1", - "state": "open", - "head": {"ref": "some-branch"}, - "base": {"repo": {"full_name": "o/r"}}, - "created_at": "c", - "updated_at": "u" - }, - { - "number": 2, - "title": "Unrelated change", - "html_url": "https://github.com/o/r/pull/2", - "state": "open", - "head": {"ref": "other"}, - "base": {"repo": {"full_name": "o/r"}}, - "created_at": "c", - "updated_at": "u" - } - ]); - let prs = extract_matching_prs(&response, "BFR-1234"); - assert_eq!(prs.len(), 1); - assert_eq!(prs[0].number, 1); -} - -#[test] -fn extract_matching_prs_by_branch() { - let response = json!([ - { - "number": 1, - "title": "Some PR", - "html_url": "url", - "state": "open", - "head": {"ref": "bfr-1234-fix"}, - "base": {"repo": {"full_name": "o/r"}}, - "created_at": "c", - "updated_at": "u" - } - ]); - let prs = extract_matching_prs(&response, "BFR-1234"); - assert_eq!(prs.len(), 1); -} - -#[test] -fn extract_matching_prs_empty() { - let response = json!([]); - assert!(extract_matching_prs(&response, "BFR-1234").is_empty()); -} - -#[test] -fn extract_matching_prs_no_match() { - let response = json!([ - { - "number": 1, - "title": "Unrelated", - "html_url": "url", - "state": "open", - "head": {"ref": "other"}, - "base": {"repo": {"full_name": "o/r"}}, - "created_at": "c", - "updated_at": "u" - } - ]); - assert!(extract_matching_prs(&response, "BFR-999").is_empty()); -} - -#[test] -fn extract_matching_prs_not_array() { - let response = json!({"not": "array"}); - assert!(extract_matching_prs(&response, "query").is_empty()); -} - -#[test] -fn extract_matching_prs_case_insensitive() { - let response = json!([ - { - "number": 1, - "title": "bfr-1234 lowercase", - "html_url": "url", - "state": "open", - "head": {"ref": "main"}, - "base": {"repo": {"full_name": "o/r"}}, - "created_at": "c", - "updated_at": "u" - } - ]); - let prs = extract_matching_prs(&response, "BFR-1234"); - assert_eq!(prs.len(), 1); -} - -#[test] -fn clean_ci_line_various_timestamps() { - // Different timestamp formats from CI - assert_eq!( - clean_ci_line("2026-01-27T10:00:00.000Z some text"), - "some text" - ); - assert_eq!(clean_ci_line("2026-01-27T10:00:00.1234567Z text"), "text"); - assert_eq!( - clean_ci_line("2020-12-31T23:59:59.9Z end of year"), - "end of year" - ); -} diff --git a/src/gh/failures/mod.rs b/src/gh/failures/mod.rs deleted file mode 100644 index 6e907e6..0000000 --- a/src/gh/failures/mod.rs +++ /dev/null @@ -1,144 +0,0 @@ -use anyhow::Result; - -use super::cli::FailuresArgs; -use super::client::{parse_test_failures, GithubApi, GithubClient}; -use super::helpers::{get_current_repo, is_test_job, parse_owner_repo}; - -#[cfg(test)] -mod tests; - -/// Handle the `hu gh failures` command -#[cfg(not(tarpaulin_include))] -pub async fn run(args: FailuresArgs) -> Result<()> { - let client = GithubClient::new()?; - - // Get repo info from args or current directory - let (owner, repo) = if let Some(repo_arg) = &args.repo { - parse_owner_repo(repo_arg)? - } else { - get_current_repo()? - }; - - // If PR specified, use PR-based flow; otherwise get latest repo failures - if let Some(pr_number) = args.pr { - process_pr_failures(&client, &owner, &repo, pr_number).await - } else { - process_repo_failures(&client, &owner, &repo).await - } -} - -/// Process failures for a specific PR (testable) -pub async fn process_pr_failures( - client: &impl GithubApi, - owner: &str, - repo: &str, - pr_number: u64, -) -> Result<()> { - eprintln!( - "Fetching failures for PR #{} in {}/{}...", - pr_number, owner, repo - ); - - // Get the PR's branch name - let branch = client.get_pr_branch(owner, repo, pr_number).await?; - - // Get the latest failed workflow run for this branch - let run_id = client - .get_latest_failed_run_for_branch(owner, repo, &branch) - .await?; - - let run_id = match run_id { - Some(id) => id, - None => { - println!("No failed workflow runs found for PR #{}.", pr_number); - return Ok(()); - } - }; - - process_run_failures(client, owner, repo, run_id).await -} - -/// Process failures for the latest failed run in the repo (testable) -pub async fn process_repo_failures(client: &impl GithubApi, owner: &str, repo: &str) -> Result<()> { - eprintln!("Fetching latest failures in {}/{}...", owner, repo); - - // Get the latest failed workflow run for the repo - let run_id = client.get_latest_failed_run(owner, repo).await?; - - let run_id = match run_id { - Some(id) => id, - None => { - println!("No failed workflow runs found in {}/{}.", owner, repo); - return Ok(()); - } - }; - - process_run_failures(client, owner, repo, run_id).await -} - -/// Process failures for a specific workflow run (shared logic) -async fn process_run_failures( - client: &impl GithubApi, - owner: &str, - repo: &str, - run_id: u64, -) -> Result<()> { - // Get failed jobs in that run - let failed_jobs = client.get_failed_jobs(owner, repo, run_id).await?; - - if failed_jobs.is_empty() { - println!("No failed jobs found in run {}.", run_id); - return Ok(()); - } - - // Only process test-related jobs (rspec, jest, etc.) - let test_jobs: Vec<_> = failed_jobs - .into_iter() - .filter(|(_, name)| is_test_job(name)) - .collect(); - - if test_jobs.is_empty() { - println!("No test-related job failures found."); - return Ok(()); - } - - let mut all_failures = Vec::new(); - - for (job_id, job_name) in test_jobs { - eprintln!("Fetching logs for job: {}", job_name); - - match client.get_job_logs(owner, repo, job_id).await { - Ok(logs) => { - let failures = parse_test_failures(&logs); - all_failures.extend(failures); - } - Err(e) => { - eprintln!("Warning: Failed to fetch logs for {}: {}", job_name, e); - } - } - } - - if all_failures.is_empty() { - println!("No test failures found in logs."); - return Ok(()); - } - - // Output in a format useful for Claude - println!("\n# Test Failures\n"); - for failure in &all_failures { - println!("## {}\n", failure.spec_file); - println!("```"); - println!("{}", failure.failure_text); - println!("```\n"); - } - - // Also output the rspec commands to rerun - println!("# Rerun Commands\n"); - println!("```bash"); - for failure in &all_failures { - println!("bundle exec rspec {}", failure.spec_file); - } - println!("```"); - - Ok(()) -} diff --git a/src/gh/failures/tests.rs b/src/gh/failures/tests.rs deleted file mode 100644 index dcdf948..0000000 --- a/src/gh/failures/tests.rs +++ /dev/null @@ -1,329 +0,0 @@ -use super::*; - -// Mock implementation for testing -use crate::gh::types::PullRequest; - -struct MockGithubApi { - branch: String, - run_id: Option, - failed_jobs: Vec<(u64, String)>, - logs: String, -} - -impl GithubApi for MockGithubApi { - async fn list_user_prs(&self) -> Result> { - Ok(vec![]) - } - - async fn get_ci_status( - &self, - _owner: &str, - _repo: &str, - _pr: u64, - ) -> Result { - Ok(crate::gh::types::CiStatus::Unknown) - } - - async fn get_pr_branch(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok(self.branch.clone()) - } - - async fn get_latest_failed_run_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(self.run_id) - } - - async fn get_latest_failed_run(&self, _owner: &str, _repo: &str) -> Result> { - Ok(self.run_id) - } - - async fn get_failed_jobs( - &self, - _owner: &str, - _repo: &str, - _run_id: u64, - ) -> Result> { - Ok(self.failed_jobs.clone()) - } - - async fn get_job_logs(&self, _owner: &str, _repo: &str, _job_id: u64) -> Result { - Ok(self.logs.clone()) - } - - async fn find_pr_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(None) - } - - async fn list_workflow_runs( - &self, - _query: &crate::gh::types::RunsQuery<'_>, - ) -> Result> { - Ok(vec![]) - } - - async fn search_prs_by_title( - &self, - _owner: &str, - _repo: &str, - _query: &str, - ) -> Result> { - Ok(vec![]) - } -} - -// PR-based tests - -#[tokio::test] -async fn process_pr_failures_no_failed_runs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: None, - failed_jobs: vec![], - logs: String::new(), - }; - let result = process_pr_failures(&mock, "owner", "repo", 123).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn process_pr_failures_no_failed_jobs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: Some(1), - failed_jobs: vec![], - logs: String::new(), - }; - let result = process_pr_failures(&mock, "owner", "repo", 123).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn process_pr_failures_no_test_jobs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: Some(1), - failed_jobs: vec![(1, "build".to_string()), (2, "deploy".to_string())], - logs: String::new(), - }; - let result = process_pr_failures(&mock, "owner", "repo", 123).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn process_pr_failures_with_test_failures() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: Some(1), - failed_jobs: vec![(1, "rspec-tests".to_string())], - logs: r#" -Failures: - - 1) Test fails - Failure/Error: expect(1).to eq(2) - expected: 2 - -Failed examples: - -rspec ./spec/test_spec.rb:10 # Test fails -"# - .to_string(), - }; - let result = process_pr_failures(&mock, "owner", "repo", 123).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn process_pr_failures_empty_logs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: Some(1), - failed_jobs: vec![(1, "test-suite".to_string())], - logs: String::new(), - }; - let result = process_pr_failures(&mock, "owner", "repo", 123).await; - assert!(result.is_ok()); -} - -// Mock with error handling -struct MockGithubApiWithLogError { - branch: String, - run_id: Option, - failed_jobs: Vec<(u64, String)>, -} - -impl GithubApi for MockGithubApiWithLogError { - async fn list_user_prs(&self) -> Result> { - Ok(vec![]) - } - - async fn get_ci_status( - &self, - _owner: &str, - _repo: &str, - _pr: u64, - ) -> Result { - Ok(crate::gh::types::CiStatus::Unknown) - } - - async fn get_pr_branch(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok(self.branch.clone()) - } - - async fn get_latest_failed_run_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(self.run_id) - } - - async fn get_latest_failed_run(&self, _owner: &str, _repo: &str) -> Result> { - Ok(self.run_id) - } - - async fn get_failed_jobs( - &self, - _owner: &str, - _repo: &str, - _run_id: u64, - ) -> Result> { - Ok(self.failed_jobs.clone()) - } - - async fn get_job_logs(&self, _owner: &str, _repo: &str, _job_id: u64) -> Result { - Err(anyhow::anyhow!("Failed to fetch logs")) - } - - async fn find_pr_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(None) - } - - async fn list_workflow_runs( - &self, - _query: &crate::gh::types::RunsQuery<'_>, - ) -> Result> { - Ok(vec![]) - } - - async fn search_prs_by_title( - &self, - _owner: &str, - _repo: &str, - _query: &str, - ) -> Result> { - Ok(vec![]) - } -} - -#[tokio::test] -async fn process_pr_failures_handles_log_fetch_error() { - let mock = MockGithubApiWithLogError { - branch: "feature".to_string(), - run_id: Some(42), - failed_jobs: vec![(100, "rspec-tests".to_string())], - }; - let result = process_pr_failures(&mock, "owner", "repo", 123).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn process_pr_failures_multiple_test_jobs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: Some(1), - failed_jobs: vec![ - (1, "rspec-tests".to_string()), - (2, "jest-tests".to_string()), - (3, "build".to_string()), - ], - logs: r#" -Failures: - - 1) Test fails - Failure/Error: expect(1).to eq(2) - expected: 2 - -Failed examples: - -rspec ./spec/test_spec.rb:10 # Test fails -"# - .to_string(), - }; - let result = process_pr_failures(&mock, "owner", "repo", 123).await; - assert!(result.is_ok()); -} - -// Repo-based tests (no PR) - -#[tokio::test] -async fn process_repo_failures_no_failed_runs() { - let mock = MockGithubApi { - branch: String::new(), - run_id: None, - failed_jobs: vec![], - logs: String::new(), - }; - let result = process_repo_failures(&mock, "owner", "repo").await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn process_repo_failures_no_failed_jobs() { - let mock = MockGithubApi { - branch: String::new(), - run_id: Some(1), - failed_jobs: vec![], - logs: String::new(), - }; - let result = process_repo_failures(&mock, "owner", "repo").await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn process_repo_failures_with_test_failures() { - let mock = MockGithubApi { - branch: String::new(), - run_id: Some(1), - failed_jobs: vec![(1, "rspec-tests".to_string())], - logs: r#" -Failures: - - 1) Test fails - Failure/Error: expect(1).to eq(2) - expected: 2 - -Failed examples: - -rspec ./spec/test_spec.rb:10 # Test fails -"# - .to_string(), - }; - let result = process_repo_failures(&mock, "owner", "repo").await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn process_repo_failures_handles_log_fetch_error() { - let mock = MockGithubApiWithLogError { - branch: String::new(), - run_id: Some(42), - failed_jobs: vec![(100, "rspec-tests".to_string())], - }; - let result = process_repo_failures(&mock, "owner", "repo").await; - assert!(result.is_ok()); -} diff --git a/src/gh/fix/mapping/mod.rs b/src/gh/fix/mapping/mod.rs deleted file mode 100644 index e988528..0000000 --- a/src/gh/fix/mapping/mod.rs +++ /dev/null @@ -1,139 +0,0 @@ -/// Strip line number suffix from a path (e.g., "spec/foo_spec.rb:10" -> "spec/foo_spec.rb") -pub fn strip_line_number(path: &str) -> &str { - // Find last colon followed by digits - if let Some(idx) = path.rfind(':') { - if path[idx + 1..].chars().all(|c| c.is_ascii_digit()) && !path[idx + 1..].is_empty() { - return &path[..idx]; - } - } - path -} - -/// Detect the language/framework from a test file path -pub fn detect_language(test_file: &str) -> &'static str { - let path = strip_line_number(test_file); - - if path.ends_with("_spec.rb") || path.starts_with("spec/") || path.starts_with("./spec/") { - "ruby" - } else if path.ends_with("_test.py") - || (path.ends_with(".py") && (path.starts_with("tests/test_") || path.starts_with("test_"))) - { - "python" - } else if path.ends_with(".test.js") - || path.ends_with(".test.ts") - || path.ends_with(".test.tsx") - || path.ends_with(".test.jsx") - || path.ends_with(".spec.js") - || path.ends_with(".spec.ts") - || path.ends_with(".spec.tsx") - || path.ends_with(".spec.jsx") - { - "javascript" - } else if path.ends_with(".rs") - || (path.starts_with("tests/") && !path.ends_with(".py")) - || path.contains("/tests.rs") - || path.contains("/tests/") - { - "rust" - } else { - "unknown" - } -} - -/// Map a test file path to likely source file paths -pub fn map_test_to_source(test_file: &str) -> Vec { - let path = strip_line_number(test_file); - let lang = detect_language(test_file); - - match lang { - "ruby" => map_rspec(path), - "rust" => map_rust(path), - "python" => map_python(path), - "javascript" => map_javascript(path), - _ => vec![], - } -} - -/// Map RSpec test file to Ruby source files -/// spec/models/user_spec.rb -> app/models/user.rb -/// spec/helpers/pricing_helper_spec.rb -> app/helpers/pricing_helper.rb -fn map_rspec(path: &str) -> Vec { - let path = path - .strip_prefix("./") - .unwrap_or(path) - .strip_prefix("spec/") - .unwrap_or(path); - - let path = path.strip_suffix("_spec.rb").unwrap_or(path); - - vec![format!("app/{}.rb", path), format!("lib/{}.rb", path)] -} - -/// Map Rust test file to source files -/// tests/test_sync.rs -> src/sync.rs -/// src/data/tests.rs -> src/data/mod.rs -fn map_rust(path: &str) -> Vec { - let path = path.strip_prefix("./").unwrap_or(path); - - if let Some(parent) = path.strip_suffix("/tests.rs") { - return vec![format!("{}/mod.rs", parent)]; - } - - if let Some(rest) = path.strip_prefix("tests/") { - let without_prefix = rest.strip_prefix("test_").unwrap_or(rest); - let name = without_prefix.strip_suffix(".rs").unwrap_or(without_prefix); - return vec![format!("src/{}.rs", name), format!("src/{}/mod.rs", name)]; - } - - vec![] -} - -/// Map Python test file to source files -/// tests/test_utils.py -> src/utils.py, utils.py -/// utils_test.py -> utils.py -fn map_python(path: &str) -> Vec { - let path = path.strip_prefix("./").unwrap_or(path); - - if path.starts_with("tests/test_") || path.starts_with("test_") { - let name = path - .strip_prefix("tests/") - .unwrap_or(path) - .strip_prefix("test_") - .unwrap_or(path); - return vec![format!("src/{}", name), name.to_string()]; - } - - if let Some(base) = path.strip_suffix("_test.py") { - return vec![format!("{}.py", base), format!("src/{}.py", base)]; - } - - vec![] -} - -/// Map JS/TS test file to source files -/// components/Button.test.tsx -> components/Button.tsx -/// utils/format.spec.ts -> utils/format.ts -fn map_javascript(path: &str) -> Vec { - let path = path.strip_prefix("./").unwrap_or(path); - - for suffix in &[ - ".test.js", - ".test.ts", - ".test.tsx", - ".test.jsx", - ".spec.js", - ".spec.ts", - ".spec.tsx", - ".spec.jsx", - ] { - if let Some(base) = path.strip_suffix(suffix) { - let ext = &suffix[suffix.rfind('.').unwrap_or(0)..]; - return vec![format!("{}{}", base, ext)]; - } - } - - vec![] -} - -#[cfg(test)] -mod tests; diff --git a/src/gh/fix/mapping/tests.rs b/src/gh/fix/mapping/tests.rs deleted file mode 100644 index d83157b..0000000 --- a/src/gh/fix/mapping/tests.rs +++ /dev/null @@ -1,207 +0,0 @@ -use super::*; - -// strip_line_number tests -#[test] -fn strip_line_number_with_line() { - assert_eq!(strip_line_number("spec/foo_spec.rb:10"), "spec/foo_spec.rb"); -} - -#[test] -fn strip_line_number_without_line() { - assert_eq!(strip_line_number("spec/foo_spec.rb"), "spec/foo_spec.rb"); -} - -#[test] -fn strip_line_number_colon_no_digits() { - assert_eq!(strip_line_number("foo:bar"), "foo:bar"); -} - -#[test] -fn strip_line_number_empty() { - assert_eq!(strip_line_number(""), ""); -} - -#[test] -fn strip_line_number_multiple_colons() { - assert_eq!( - strip_line_number("./spec/foo_spec.rb:10"), - "./spec/foo_spec.rb" - ); -} - -#[test] -fn strip_line_number_trailing_colon() { - assert_eq!(strip_line_number("foo:"), "foo:"); -} - -// detect_language tests -#[test] -fn detect_ruby() { - assert_eq!(detect_language("spec/models/user_spec.rb"), "ruby"); - assert_eq!(detect_language("./spec/models/user_spec.rb:10"), "ruby"); - assert_eq!(detect_language("spec/helpers/foo_spec.rb"), "ruby"); -} - -#[test] -fn detect_rust() { - assert_eq!(detect_language("tests/test_sync.rs"), "rust"); - assert_eq!(detect_language("src/data/tests.rs"), "rust"); - assert_eq!(detect_language("src/gh/fix/tests.rs"), "rust"); -} - -#[test] -fn detect_python() { - assert_eq!(detect_language("tests/test_utils.py"), "python"); - assert_eq!(detect_language("utils_test.py"), "python"); - assert_eq!(detect_language("test_main.py"), "python"); -} - -#[test] -fn detect_javascript() { - assert_eq!(detect_language("Button.test.tsx"), "javascript"); - assert_eq!(detect_language("utils/format.spec.ts"), "javascript"); - assert_eq!(detect_language("app.test.js"), "javascript"); - assert_eq!(detect_language("component.spec.jsx"), "javascript"); -} - -#[test] -fn detect_unknown() { - assert_eq!(detect_language("README.md"), "unknown"); - assert_eq!(detect_language("main.go"), "unknown"); - assert_eq!(detect_language(""), "unknown"); -} - -// map_test_to_source: Ruby -#[test] -fn map_rspec_model() { - let sources = map_test_to_source("spec/models/user_spec.rb"); - assert!(sources.contains(&"app/models/user.rb".to_string())); - assert!(sources.contains(&"lib/models/user.rb".to_string())); -} - -#[test] -fn map_rspec_helper() { - let sources = map_test_to_source("spec/helpers/pricing_helper_spec.rb:289"); - assert!(sources.contains(&"app/helpers/pricing_helper.rb".to_string())); -} - -#[test] -fn map_rspec_with_dot_prefix() { - let sources = map_test_to_source("./spec/models/user_spec.rb:10"); - assert!(sources.contains(&"app/models/user.rb".to_string())); -} - -#[test] -fn map_rspec_nested_path() { - let sources = map_test_to_source("spec/features/admin/users/permissions_spec.rb:42"); - assert!(sources.contains(&"app/features/admin/users/permissions.rb".to_string())); -} - -// map_test_to_source: Rust -#[test] -fn map_rust_integration_test() { - let sources = map_test_to_source("tests/test_sync.rs"); - assert!(sources.contains(&"src/sync.rs".to_string())); - assert!(sources.contains(&"src/sync/mod.rs".to_string())); -} - -#[test] -fn map_rust_module_tests() { - let sources = map_test_to_source("src/data/tests.rs"); - assert_eq!(sources, vec!["src/data/mod.rs"]); -} - -#[test] -fn map_rust_integration_test_no_prefix() { - let sources = map_test_to_source("tests/utils.rs"); - assert!(sources.contains(&"src/utils.rs".to_string())); -} - -// map_test_to_source: Python -#[test] -fn map_python_test_file() { - let sources = map_test_to_source("tests/test_utils.py"); - assert!(sources.contains(&"src/utils.py".to_string())); - assert!(sources.contains(&"utils.py".to_string())); -} - -#[test] -fn map_python_suffix_test() { - let sources = map_test_to_source("utils_test.py"); - assert!(sources.contains(&"utils.py".to_string())); - assert!(sources.contains(&"src/utils.py".to_string())); -} - -#[test] -fn map_python_bare_test() { - let sources = map_test_to_source("test_main.py"); - assert!(sources.contains(&"src/main.py".to_string())); - assert!(sources.contains(&"main.py".to_string())); -} - -// map_test_to_source: JavaScript/TypeScript -#[test] -fn map_js_test() { - let sources = map_test_to_source("components/Button.test.tsx"); - assert_eq!(sources, vec!["components/Button.tsx"]); -} - -#[test] -fn map_js_spec() { - let sources = map_test_to_source("utils/format.spec.ts"); - assert_eq!(sources, vec!["utils/format.ts"]); -} - -#[test] -fn map_js_plain() { - let sources = map_test_to_source("app.test.js"); - assert_eq!(sources, vec!["app.js"]); -} - -#[test] -fn map_jsx_spec() { - let sources = map_test_to_source("component.spec.jsx"); - assert_eq!(sources, vec!["component.jsx"]); -} - -// map_test_to_source: Unknown -#[test] -fn map_unknown_returns_empty() { - let sources = map_test_to_source("README.md"); - assert!(sources.is_empty()); -} - -#[test] -fn map_empty_returns_empty() { - let sources = map_test_to_source(""); - assert!(sources.is_empty()); -} - -// Edge cases -#[test] -fn map_with_line_number_stripped() { - let sources = map_test_to_source("spec/models/user_spec.rb:42"); - assert!(sources.contains(&"app/models/user.rb".to_string())); -} - -#[test] -fn detect_language_with_line_number() { - assert_eq!(detect_language("tests/test_sync.rs:100"), "rust"); - assert_eq!(detect_language("app.test.js:55"), "javascript"); -} - -// Direct mapper fallback tests -#[test] -fn map_rust_non_test_file() { - assert!(map_rust("src/main.rs").is_empty()); -} - -#[test] -fn map_python_non_test_file() { - assert!(map_python("main.py").is_empty()); -} - -#[test] -fn map_javascript_non_test_file() { - assert!(map_javascript("index.js").is_empty()); -} diff --git a/src/gh/fix/mod.rs b/src/gh/fix/mod.rs deleted file mode 100644 index 2789773..0000000 --- a/src/gh/fix/mod.rs +++ /dev/null @@ -1,241 +0,0 @@ -use anyhow::Result; - -use super::cli::FixArgs; -use super::client::{parse_test_failures, GithubApi, GithubClient}; -use super::helpers::{get_current_branch, get_current_repo, is_test_job, parse_owner_repo}; -use super::types::{FixFailure, FixReport, TestFailure}; - -mod mapping; - -#[cfg(test)] -mod tests; - -/// Query parameters for building a fix report -#[derive(Debug, Clone)] -pub struct FixQuery { - pub owner: String, - pub repo: String, - pub pr: Option, - pub run: Option, - pub branch: Option, -} - -/// Handle the `hu gh fix` command -#[cfg(not(tarpaulin_include))] -pub async fn run(args: FixArgs) -> Result<()> { - let client = GithubClient::new()?; - - let (owner, repo) = if let Some(repo_arg) = &args.repo { - parse_owner_repo(repo_arg)? - } else { - get_current_repo()? - }; - - let query = FixQuery { - owner, - repo, - pr: args.pr, - run: args.run, - branch: args.branch, - }; - - let report = build_fix_report(&client, &query).await?; - - match report { - Some(r) => output_report(&r, args.json), - None => { - println!("No failures found."); - Ok(()) - } - } -} - -/// Build a fix report from CI failures (testable, no I/O except API calls) -pub async fn build_fix_report( - client: &impl GithubApi, - query: &FixQuery, -) -> Result> { - let repository = format!("{}/{}", query.owner, query.repo); - let owner = &query.owner; - let repo = &query.repo; - - // Determine run_id from args - let (run_id, pr_number) = if let Some(run_id) = query.run { - (run_id, query.pr) - } else if let Some(pr_number) = query.pr { - let branch = client.get_pr_branch(owner, repo, pr_number).await?; - let run_id = client - .get_latest_failed_run_for_branch(owner, repo, &branch) - .await?; - match run_id { - Some(id) => (id, Some(pr_number)), - None => return Ok(None), - } - } else { - // Use branch arg or current branch - let branch_name = match &query.branch { - Some(b) => b.clone(), - None => get_current_branch()?, - }; - - let pr_number = client.find_pr_for_branch(owner, repo, &branch_name).await?; - - let run_id = client - .get_latest_failed_run_for_branch(owner, repo, &branch_name) - .await?; - - match run_id { - Some(id) => (id, pr_number), - None => return Ok(None), - } - }; - - // Get failed jobs - let failed_jobs = client.get_failed_jobs(owner, repo, run_id).await?; - - if failed_jobs.is_empty() { - return Ok(None); - } - - // Filter to test jobs and fetch logs - let test_jobs: Vec<_> = failed_jobs - .into_iter() - .filter(|(_, name)| is_test_job(name)) - .collect(); - - if test_jobs.is_empty() { - return Ok(None); - } - - let mut all_failures = Vec::new(); - - for (job_id, job_name) in test_jobs { - eprintln!("Fetching logs for job: {}", job_name); - match client.get_job_logs(owner, repo, job_id).await { - Ok(logs) => { - let failures = parse_test_failures(&logs); - all_failures.extend(failures); - } - Err(e) => { - eprintln!("Warning: Failed to fetch logs for {}: {}", job_name, e); - } - } - } - - if all_failures.is_empty() { - return Ok(None); - } - - let fix_failures = enrich_failures(&all_failures); - let test_files: Vec = fix_failures.iter().map(|f| f.test_file.clone()).collect(); - let source_files: Vec = fix_failures - .iter() - .flat_map(|f| f.source_files.clone()) - .collect::>() - .into_iter() - .collect(); - - Ok(Some(FixReport { - repository, - pr_number, - run_id, - failures: fix_failures, - test_files, - source_files, - })) -} - -/// Enrich test failures with source file mappings (pure function) -pub fn enrich_failures(failures: &[TestFailure]) -> Vec { - failures - .iter() - .map(|f| { - let language = mapping::detect_language(&f.spec_file).to_string(); - let source_files = mapping::map_test_to_source(&f.spec_file); - let test_file = mapping::strip_line_number(&f.spec_file).to_string(); - - FixFailure { - test_file, - source_files, - failure_text: f.failure_text.clone(), - language, - } - }) - .collect() -} - -/// Output the fix report (markdown or JSON) -fn output_report(report: &FixReport, json: bool) -> Result<()> { - if json { - println!("{}", serde_json::to_string_pretty(report)?); - } else { - print!("{}", format_markdown(report)); - } - Ok(()) -} - -/// Format report as markdown (pure function, testable) -pub fn format_markdown(report: &FixReport) -> String { - let mut out = String::new(); - - out.push_str(&format!("# Fix Report: {}\n\n", report.repository)); - - if let Some(pr) = report.pr_number { - out.push_str(&format!("**PR:** #{}\n", pr)); - } - out.push_str(&format!("**Run:** {}\n", report.run_id)); - out.push_str(&format!("**Failures:** {}\n\n", report.failures.len())); - - // Failures - for failure in &report.failures { - out.push_str(&format!("## {}\n\n", failure.test_file)); - out.push_str(&format!("**Language:** {}\n", failure.language)); - - if !failure.source_files.is_empty() { - out.push_str("**Source files:**\n"); - for sf in &failure.source_files { - out.push_str(&format!("- `{}`\n", sf)); - } - } - - out.push_str("\n```\n"); - out.push_str(&failure.failure_text); - out.push_str("\n```\n\n"); - } - - // Rerun commands - out.push_str(&format_rerun_commands(&report.failures)); - - // File lists - if !report.source_files.is_empty() { - out.push_str("## Source Files to Investigate\n\n"); - for f in &report.source_files { - out.push_str(&format!("- `{}`\n", f)); - } - out.push('\n'); - } - - out -} - -/// Format rerun commands section (pure function, testable) -pub fn format_rerun_commands(failures: &[FixFailure]) -> String { - if failures.is_empty() { - return String::new(); - } - - let mut out = String::from("## Rerun Commands\n\n```bash\n"); - - for failure in failures { - match failure.language.as_str() { - "ruby" => out.push_str(&format!("bundle exec rspec {}\n", failure.test_file)), - "rust" => out.push_str(&format!("cargo test {}\n", failure.test_file)), - "python" => out.push_str(&format!("pytest {}\n", failure.test_file)), - "javascript" => out.push_str(&format!("npx jest {}\n", failure.test_file)), - _ => out.push_str(&format!("# run {}\n", failure.test_file)), - } - } - - out.push_str("```\n\n"); - out -} diff --git a/src/gh/fix/tests.rs b/src/gh/fix/tests.rs deleted file mode 100644 index 1a61856..0000000 --- a/src/gh/fix/tests.rs +++ /dev/null @@ -1,678 +0,0 @@ -use super::*; -use crate::gh::client::GithubApi; -use crate::gh::types::{CiStatus, PullRequest}; - -// Mock implementation -struct MockGithubApi { - branch: String, - run_id: Option, - failed_jobs: Vec<(u64, String)>, - logs: String, - pr_for_branch: Option, -} - -impl GithubApi for MockGithubApi { - async fn list_user_prs(&self) -> Result> { - Ok(vec![]) - } - - async fn get_ci_status(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok(CiStatus::Unknown) - } - - async fn get_pr_branch(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok(self.branch.clone()) - } - - async fn get_latest_failed_run_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(self.run_id) - } - - async fn get_latest_failed_run(&self, _owner: &str, _repo: &str) -> Result> { - Ok(self.run_id) - } - - async fn get_failed_jobs( - &self, - _owner: &str, - _repo: &str, - _run_id: u64, - ) -> Result> { - Ok(self.failed_jobs.clone()) - } - - async fn get_job_logs(&self, _owner: &str, _repo: &str, _job_id: u64) -> Result { - Ok(self.logs.clone()) - } - - async fn find_pr_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(self.pr_for_branch) - } - - async fn list_workflow_runs( - &self, - _query: &crate::gh::types::RunsQuery<'_>, - ) -> Result> { - Ok(vec![]) - } - - async fn search_prs_by_title( - &self, - _owner: &str, - _repo: &str, - _query: &str, - ) -> Result> { - Ok(vec![]) - } -} - -fn query(owner: &str, repo: &str) -> FixQuery { - FixQuery { - owner: owner.to_string(), - repo: repo.to_string(), - pr: None, - run: None, - branch: None, - } -} - -// build_fix_report tests -#[tokio::test] -async fn build_fix_report_no_runs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: None, - failed_jobs: vec![], - logs: String::new(), - pr_for_branch: None, - }; - let mut q = query("owner", "repo"); - q.pr = Some(42); - let result = build_fix_report(&mock, &q).await; - assert!(result.unwrap().is_none()); -} - -#[tokio::test] -async fn build_fix_report_no_failed_jobs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: Some(100), - failed_jobs: vec![], - logs: String::new(), - pr_for_branch: None, - }; - let mut q = query("owner", "repo"); - q.pr = Some(42); - let result = build_fix_report(&mock, &q).await; - assert!(result.unwrap().is_none()); -} - -#[tokio::test] -async fn build_fix_report_no_test_jobs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: Some(100), - failed_jobs: vec![(1, "build".to_string()), (2, "deploy".to_string())], - logs: String::new(), - pr_for_branch: None, - }; - let mut q = query("owner", "repo"); - q.pr = Some(42); - let result = build_fix_report(&mock, &q).await; - assert!(result.unwrap().is_none()); -} - -#[tokio::test] -async fn build_fix_report_with_failures() { - let mock = MockGithubApi { - branch: "feature".to_string(), - run_id: Some(100), - failed_jobs: vec![(1, "rspec-tests".to_string())], - logs: r#" -Failures: - - 1) User model validates name - Failure/Error: expect(user).to be_valid - expected true, got false - -Failed examples: - -rspec ./spec/models/user_spec.rb:10 # User model validates name -"# - .to_string(), - pr_for_branch: Some(42), - }; - - let mut q = query("owner", "repo"); - q.pr = Some(42); - let result = build_fix_report(&mock, &q).await.unwrap(); - - assert!(result.is_some()); - let report = result.unwrap(); - assert_eq!(report.repository, "owner/repo"); - assert_eq!(report.pr_number, Some(42)); - assert_eq!(report.run_id, 100); - assert_eq!(report.failures.len(), 1); - assert_eq!(report.failures[0].test_file, "./spec/models/user_spec.rb"); - assert_eq!(report.failures[0].language, "ruby"); - assert!(!report.failures[0].source_files.is_empty()); -} - -#[tokio::test] -async fn build_fix_report_with_run_id() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: Some(200), - failed_jobs: vec![(1, "test-suite".to_string())], - logs: r#" -Failures: - - 1) Test fails - Failure/Error: fail - err - -Failed examples: - -rspec ./spec/test_spec.rb:5 # Test fails -"# - .to_string(), - pr_for_branch: None, - }; - - let mut q = query("owner", "repo"); - q.run = Some(200); - let result = build_fix_report(&mock, &q).await.unwrap(); - - assert!(result.is_some()); - let report = result.unwrap(); - assert_eq!(report.run_id, 200); - assert!(report.pr_number.is_none()); -} - -#[tokio::test] -async fn build_fix_report_with_branch() { - let mock = MockGithubApi { - branch: "feature-x".to_string(), - run_id: Some(300), - failed_jobs: vec![(1, "rspec".to_string())], - logs: r#" -Failures: - - 1) Fail - Failure/Error: x - y - -Failed examples: - -rspec ./spec/x_spec.rb:1 # Fail -"# - .to_string(), - pr_for_branch: Some(99), - }; - - let mut q = query("o", "r"); - q.branch = Some("feature-x".to_string()); - let result = build_fix_report(&mock, &q).await.unwrap(); - - assert!(result.is_some()); - let report = result.unwrap(); - assert_eq!(report.pr_number, Some(99)); -} - -#[tokio::test] -async fn build_fix_report_empty_logs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: Some(100), - failed_jobs: vec![(1, "test".to_string())], - logs: String::new(), - pr_for_branch: None, - }; - let mut q = query("o", "r"); - q.pr = Some(1); - let result = build_fix_report(&mock, &q).await.unwrap(); - assert!(result.is_none()); -} - -// FixQuery tests -#[test] -fn fix_query_debug() { - let q = query("owner", "repo"); - let d = format!("{:?}", q); - assert!(d.contains("FixQuery")); -} - -#[test] -fn fix_query_clone() { - let q = query("owner", "repo"); - let c = q.clone(); - assert_eq!(c.owner, q.owner); - assert_eq!(c.repo, q.repo); -} - -// Mock that errors on get_job_logs -struct MockGithubApiWithLogError { - branch: String, - run_id: Option, - failed_jobs: Vec<(u64, String)>, - pr_for_branch: Option, -} - -impl GithubApi for MockGithubApiWithLogError { - async fn list_user_prs(&self) -> Result> { - Ok(vec![]) - } - - async fn get_ci_status(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok(CiStatus::Unknown) - } - - async fn get_pr_branch(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok(self.branch.clone()) - } - - async fn get_latest_failed_run_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(self.run_id) - } - - async fn get_latest_failed_run(&self, _owner: &str, _repo: &str) -> Result> { - Ok(self.run_id) - } - - async fn get_failed_jobs( - &self, - _owner: &str, - _repo: &str, - _run_id: u64, - ) -> Result> { - Ok(self.failed_jobs.clone()) - } - - async fn get_job_logs(&self, _owner: &str, _repo: &str, _job_id: u64) -> Result { - Err(anyhow::anyhow!("Failed to fetch logs")) - } - - async fn find_pr_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(self.pr_for_branch) - } - - async fn list_workflow_runs( - &self, - _query: &crate::gh::types::RunsQuery<'_>, - ) -> Result> { - Ok(vec![]) - } - - async fn search_prs_by_title( - &self, - _owner: &str, - _repo: &str, - _query: &str, - ) -> Result> { - Ok(vec![]) - } -} - -#[tokio::test] -async fn build_fix_report_handles_log_error() { - let mock = MockGithubApiWithLogError { - branch: "main".to_string(), - run_id: Some(100), - failed_jobs: vec![(1, "rspec-tests".to_string())], - pr_for_branch: None, - }; - let mut q = query("o", "r"); - q.pr = Some(1); - let result = build_fix_report(&mock, &q).await.unwrap(); - // Logs failed, so no failures extracted -> None - assert!(result.is_none()); -} - -#[tokio::test] -async fn build_fix_report_uses_current_branch() { - // No pr, no run, no branch -> uses get_current_branch() - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: None, - failed_jobs: vec![], - logs: String::new(), - pr_for_branch: None, - }; - let q = query("o", "r"); // no pr, run, or branch set - let result = build_fix_report(&mock, &q).await; - // In CI (detached HEAD for tags), this returns an error - that's expected - match result { - Ok(r) => assert!(r.is_none()), - Err(e) => assert!(e.to_string().contains("Not on a branch")), - } -} - -#[tokio::test] -async fn build_fix_report_branch_no_runs() { - let mock = MockGithubApi { - branch: "main".to_string(), - run_id: None, - failed_jobs: vec![], - logs: String::new(), - pr_for_branch: None, - }; - let mut q = query("o", "r"); - q.branch = Some("feature".to_string()); - let result = build_fix_report(&mock, &q).await.unwrap(); - assert!(result.is_none()); -} - -// enrich_failures tests -#[test] -fn enrich_failures_ruby() { - let failures = vec![TestFailure { - spec_file: "./spec/models/user_spec.rb:10".to_string(), - failure_text: "expected true".to_string(), - }]; - let enriched = enrich_failures(&failures); - assert_eq!(enriched.len(), 1); - assert_eq!(enriched[0].language, "ruby"); - assert_eq!(enriched[0].test_file, "./spec/models/user_spec.rb"); - assert!(enriched[0] - .source_files - .contains(&"app/models/user.rb".to_string())); -} - -#[test] -fn enrich_failures_mixed_languages() { - let failures = vec![ - TestFailure { - spec_file: "spec/user_spec.rb:5".to_string(), - failure_text: "ruby error".to_string(), - }, - TestFailure { - spec_file: "tests/test_sync.rs".to_string(), - failure_text: "rust error".to_string(), - }, - TestFailure { - spec_file: "Button.test.tsx".to_string(), - failure_text: "js error".to_string(), - }, - ]; - let enriched = enrich_failures(&failures); - assert_eq!(enriched.len(), 3); - assert_eq!(enriched[0].language, "ruby"); - assert_eq!(enriched[1].language, "rust"); - assert_eq!(enriched[2].language, "javascript"); -} - -#[test] -fn enrich_failures_empty() { - let enriched = enrich_failures(&[]); - assert!(enriched.is_empty()); -} - -#[test] -fn enrich_failures_unknown_language() { - let failures = vec![TestFailure { - spec_file: "README.md".to_string(), - failure_text: "error".to_string(), - }]; - let enriched = enrich_failures(&failures); - assert_eq!(enriched[0].language, "unknown"); - assert!(enriched[0].source_files.is_empty()); -} - -// format_markdown tests -#[test] -fn format_markdown_basic() { - let report = FixReport { - repository: "owner/repo".to_string(), - pr_number: Some(42), - run_id: 100, - failures: vec![FixFailure { - test_file: "spec/models/user_spec.rb".to_string(), - source_files: vec!["app/models/user.rb".to_string()], - failure_text: "expected true".to_string(), - language: "ruby".to_string(), - }], - test_files: vec!["spec/models/user_spec.rb".to_string()], - source_files: vec!["app/models/user.rb".to_string()], - }; - - let md = format_markdown(&report); - assert!(md.contains("# Fix Report: owner/repo")); - assert!(md.contains("**PR:** #42")); - assert!(md.contains("**Run:** 100")); - assert!(md.contains("## spec/models/user_spec.rb")); - assert!(md.contains("**Language:** ruby")); - assert!(md.contains("`app/models/user.rb`")); - assert!(md.contains("expected true")); - assert!(md.contains("bundle exec rspec")); -} - -#[test] -fn format_markdown_no_pr() { - let report = FixReport { - repository: "o/r".to_string(), - pr_number: None, - run_id: 1, - failures: vec![], - test_files: vec![], - source_files: vec![], - }; - - let md = format_markdown(&report); - assert!(!md.contains("**PR:**")); - assert!(md.contains("**Run:** 1")); -} - -#[test] -fn format_markdown_multiple_failures() { - let report = FixReport { - repository: "o/r".to_string(), - pr_number: Some(1), - run_id: 1, - failures: vec![ - FixFailure { - test_file: "spec/a_spec.rb".to_string(), - source_files: vec!["app/a.rb".to_string()], - failure_text: "err1".to_string(), - language: "ruby".to_string(), - }, - FixFailure { - test_file: "tests/test_b.rs".to_string(), - source_files: vec!["src/b.rs".to_string()], - failure_text: "err2".to_string(), - language: "rust".to_string(), - }, - ], - test_files: vec!["spec/a_spec.rb".to_string(), "tests/test_b.rs".to_string()], - source_files: vec!["app/a.rb".to_string(), "src/b.rs".to_string()], - }; - - let md = format_markdown(&report); - assert!(md.contains("## spec/a_spec.rb")); - assert!(md.contains("## tests/test_b.rs")); - assert!(md.contains("bundle exec rspec")); - assert!(md.contains("cargo test")); -} - -// format_rerun_commands tests -#[test] -fn format_rerun_commands_ruby() { - let failures = vec![FixFailure { - test_file: "spec/user_spec.rb".to_string(), - source_files: vec![], - failure_text: String::new(), - language: "ruby".to_string(), - }]; - let cmds = format_rerun_commands(&failures); - assert!(cmds.contains("bundle exec rspec spec/user_spec.rb")); -} - -#[test] -fn format_rerun_commands_rust() { - let failures = vec![FixFailure { - test_file: "tests/test_sync.rs".to_string(), - source_files: vec![], - failure_text: String::new(), - language: "rust".to_string(), - }]; - let cmds = format_rerun_commands(&failures); - assert!(cmds.contains("cargo test tests/test_sync.rs")); -} - -#[test] -fn format_rerun_commands_python() { - let failures = vec![FixFailure { - test_file: "tests/test_utils.py".to_string(), - source_files: vec![], - failure_text: String::new(), - language: "python".to_string(), - }]; - let cmds = format_rerun_commands(&failures); - assert!(cmds.contains("pytest tests/test_utils.py")); -} - -#[test] -fn format_rerun_commands_javascript() { - let failures = vec![FixFailure { - test_file: "Button.test.tsx".to_string(), - source_files: vec![], - failure_text: String::new(), - language: "javascript".to_string(), - }]; - let cmds = format_rerun_commands(&failures); - assert!(cmds.contains("npx jest Button.test.tsx")); -} - -#[test] -fn format_rerun_commands_unknown() { - let failures = vec![FixFailure { - test_file: "foo.go".to_string(), - source_files: vec![], - failure_text: String::new(), - language: "unknown".to_string(), - }]; - let cmds = format_rerun_commands(&failures); - assert!(cmds.contains("# run foo.go")); -} - -#[test] -fn format_rerun_commands_empty() { - let cmds = format_rerun_commands(&[]); - assert!(cmds.is_empty()); -} - -#[test] -fn format_rerun_commands_mixed() { - let failures = vec![ - FixFailure { - test_file: "spec/a_spec.rb".to_string(), - source_files: vec![], - failure_text: String::new(), - language: "ruby".to_string(), - }, - FixFailure { - test_file: "app.test.js".to_string(), - source_files: vec![], - failure_text: String::new(), - language: "javascript".to_string(), - }, - ]; - let cmds = format_rerun_commands(&failures); - assert!(cmds.contains("bundle exec rspec")); - assert!(cmds.contains("npx jest")); -} - -// JSON output test -#[test] -fn fix_report_json_output() { - let report = FixReport { - repository: "owner/repo".to_string(), - pr_number: Some(42), - run_id: 100, - failures: vec![FixFailure { - test_file: "spec/user_spec.rb".to_string(), - source_files: vec!["app/user.rb".to_string()], - failure_text: "error".to_string(), - language: "ruby".to_string(), - }], - test_files: vec!["spec/user_spec.rb".to_string()], - source_files: vec!["app/user.rb".to_string()], - }; - - let json = serde_json::to_string_pretty(&report).unwrap(); - assert!(json.contains("\"repository\": \"owner/repo\"")); - assert!(json.contains("\"pr_number\": 42")); - assert!(json.contains("\"run_id\": 100")); - assert!(json.contains("\"test_file\": \"spec/user_spec.rb\"")); - assert!(json.contains("\"language\": \"ruby\"")); -} - -// output_report tests -#[test] -fn output_report_json() { - let report = FixReport { - repository: "o/r".to_string(), - pr_number: None, - run_id: 1, - failures: vec![], - test_files: vec![], - source_files: vec![], - }; - let result = output_report(&report, true); - assert!(result.is_ok()); -} - -#[test] -fn output_report_markdown() { - let report = FixReport { - repository: "o/r".to_string(), - pr_number: None, - run_id: 1, - failures: vec![], - test_files: vec![], - source_files: vec![], - }; - let result = output_report(&report, false); - assert!(result.is_ok()); -} - -// format_markdown edge cases -#[test] -fn format_markdown_no_source_files() { - let report = FixReport { - repository: "o/r".to_string(), - pr_number: None, - run_id: 1, - failures: vec![FixFailure { - test_file: "README.md".to_string(), - source_files: vec![], - failure_text: "err".to_string(), - language: "unknown".to_string(), - }], - test_files: vec!["README.md".to_string()], - source_files: vec![], - }; - - let md = format_markdown(&report); - assert!(!md.contains("Source Files to Investigate")); - assert!(!md.contains("**Source files:**")); -} diff --git a/src/gh/helpers.rs b/src/gh/helpers.rs deleted file mode 100644 index 64dce5b..0000000 --- a/src/gh/helpers.rs +++ /dev/null @@ -1,230 +0,0 @@ -use anyhow::{Context, Result}; - -/// Parse owner/repo from command line argument -pub fn parse_owner_repo(repo: &str) -> Result<(String, String)> { - let parts: Vec<&str> = repo.split('/').collect(); - if parts.len() != 2 { - anyhow::bail!("Invalid repo format. Expected owner/repo, got: {}", repo); - } - Ok((parts[0].to_string(), parts[1].to_string())) -} - -/// Get owner/repo from git remote -pub fn get_current_repo() -> Result<(String, String)> { - let output = run_git_command(&["remote", "get-url", "origin"])?; - parse_github_url(output.trim()) -} - -/// Run a git command and return stdout -pub fn run_git_command(args: &[&str]) -> Result { - let output = std::process::Command::new("git") - .args(args) - .output() - .context("Failed to run git command")?; - - Ok(String::from_utf8_lossy(&output.stdout).to_string()) -} - -/// Parse GitHub URL to extract owner/repo -pub fn parse_github_url(url: &str) -> Result<(String, String)> { - let url = url.trim_end_matches(".git").trim_end_matches('/'); - - if url.contains("github.com:") { - // SSH format: git@github.com:owner/repo.git - let parts: Vec<&str> = url.split(':').collect(); - if let Some(path) = parts.last() { - let segments: Vec<&str> = path.split('/').collect(); - if segments.len() >= 2 { - return Ok(( - segments[segments.len() - 2].to_string(), - segments[segments.len() - 1].to_string(), - )); - } - } - } else if url.contains("github.com/") { - // HTTPS format: https://github.com/owner/repo.git - let parts: Vec<&str> = url.split("github.com/").collect(); - if let Some(path) = parts.last() { - let segments: Vec<&str> = path.split('/').collect(); - if segments.len() >= 2 { - return Ok((segments[0].to_string(), segments[1].to_string())); - } - } - } - - anyhow::bail!("Could not parse GitHub URL: {}", url) -} - -/// Check if a job name is test-related -pub fn is_test_job(name: &str) -> bool { - let name_lower = name.to_lowercase(); - name_lower.contains("rspec") || name_lower.contains("test") || name_lower.contains("spec") -} - -/// Get current git branch name -pub fn get_current_branch() -> Result { - let branch = run_git_command(&["branch", "--show-current"])?; - let branch = branch.trim().to_string(); - if branch.is_empty() { - anyhow::bail!("Not on a branch. Use --pr or --branch to specify."); - } - Ok(branch) -} - -#[cfg(test)] -mod tests { - use super::*; - - // parse_github_url tests - #[test] - fn parse_ssh_url() { - let (owner, repo) = parse_github_url("git@github.com:owner/repo.git").unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn parse_https_url() { - let (owner, repo) = parse_github_url("https://github.com/owner/repo.git").unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn parse_https_url_no_git_suffix() { - let (owner, repo) = parse_github_url("https://github.com/owner/repo").unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn parse_ssh_url_no_git_suffix() { - let (owner, repo) = parse_github_url("git@github.com:owner/repo").unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn parse_https_url_trailing_slash() { - let (owner, repo) = parse_github_url("https://github.com/owner/repo/").unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn parse_github_url_invalid() { - assert!(parse_github_url("not-a-github-url").is_err()); - assert!(parse_github_url("https://gitlab.com/owner/repo").is_err()); - assert!(parse_github_url("").is_err()); - } - - #[test] - fn parse_github_url_ssh_with_org() { - let (owner, repo) = parse_github_url("git@github.com:my-org/my-repo.git").unwrap(); - assert_eq!(owner, "my-org"); - assert_eq!(repo, "my-repo"); - } - - #[test] - fn parse_github_url_empty_string() { - assert!(parse_github_url("").is_err()); - } - - #[test] - fn parse_github_url_missing_repo() { - assert!(parse_github_url("git@github.com:owner").is_err()); - } - - // parse_owner_repo tests - #[test] - fn parse_owner_repo_valid() { - let (owner, repo) = parse_owner_repo("owner/repo").unwrap(); - assert_eq!(owner, "owner"); - assert_eq!(repo, "repo"); - } - - #[test] - fn parse_owner_repo_with_dashes() { - let (owner, repo) = parse_owner_repo("my-org/my-repo").unwrap(); - assert_eq!(owner, "my-org"); - assert_eq!(repo, "my-repo"); - } - - #[test] - fn parse_owner_repo_invalid_no_slash() { - assert!(parse_owner_repo("noslash").is_err()); - } - - #[test] - fn parse_owner_repo_invalid_too_many_slashes() { - assert!(parse_owner_repo("a/b/c").is_err()); - } - - #[test] - fn parse_owner_repo_invalid_empty() { - assert!(parse_owner_repo("").is_err()); - } - - // is_test_job tests - #[test] - fn is_test_job_rspec() { - assert!(is_test_job("run-rspec-tests")); - assert!(is_test_job("RSpec")); - } - - #[test] - fn is_test_job_test() { - assert!(is_test_job("unit-tests")); - assert!(is_test_job("Test Suite")); - } - - #[test] - fn is_test_job_spec() { - assert!(is_test_job("run-specs")); - assert!(is_test_job("Spec Runner")); - } - - #[test] - fn is_test_job_non_test() { - assert!(!is_test_job("build")); - assert!(!is_test_job("deploy")); - assert!(!is_test_job("lint")); - } - - #[test] - fn is_test_job_mixed_case() { - assert!(is_test_job("RSPEC")); - assert!(is_test_job("TEST")); - assert!(is_test_job("SPEC")); - } - - #[test] - fn is_test_job_partial_names() { - assert!(is_test_job("run-rspec-tests (3, 0)")); - assert!(is_test_job("unit-test-suite")); - assert!(is_test_job("integration-spec")); - } - - // run_git_command test - #[test] - fn run_git_command_version() { - let result = run_git_command(&["--version"]); - assert!(result.is_ok()); - assert!(result.unwrap().contains("git version")); - } - - // get_current_repo test - #[test] - fn get_current_repo_returns_result() { - let result = get_current_repo(); - assert!(result.is_ok() || result.is_err()); - } - - // get_current_branch test - #[test] - fn get_current_branch_returns_result() { - let result = get_current_branch(); - // In a git repo on a branch, it should succeed - assert!(result.is_ok() || result.is_err()); - } -} diff --git a/src/gh/login.rs b/src/gh/login.rs deleted file mode 100644 index 678d57c..0000000 --- a/src/gh/login.rs +++ /dev/null @@ -1,52 +0,0 @@ -use anyhow::Result; - -use super::auth; -use super::cli::LoginArgs; - -/// Handle the `hu gh login` command -pub async fn run(args: LoginArgs) -> Result<()> { - let username = match args.token { - Some(token) => auth::login(&token).await?, - None => auth::device_flow_login().await?, - }; - println!("{}", format_login_success(&username)); - Ok(()) -} - -/// Format the login success message (extracted for testability) -pub fn format_login_success(username: &str) -> String { - format!("✓ Logged in as {}", username) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn format_login_success_includes_username() { - let msg = format_login_success("testuser"); - assert!(msg.contains("testuser")); - assert!(msg.contains("✓")); - assert!(msg.contains("Logged in as")); - } - - #[test] - fn format_login_success_handles_special_chars() { - let msg = format_login_success("user-name_123"); - assert!(msg.contains("user-name_123")); - } - - #[test] - fn login_args_has_token_field() { - let args = LoginArgs { - token: Some("test_token".to_string()), - }; - assert_eq!(args.token, Some("test_token".to_string())); - } - - #[test] - fn login_args_token_is_optional() { - let args = LoginArgs { token: None }; - assert!(args.token.is_none()); - } -} diff --git a/src/gh/mod.rs b/src/gh/mod.rs deleted file mode 100644 index 309782f..0000000 --- a/src/gh/mod.rs +++ /dev/null @@ -1,99 +0,0 @@ -//! GitHub integration -//! -//! # CLI Usage -//! Use [`run_command`] for CLI commands that format and print output. -//! -//! # Programmatic Usage (MCP/HTTP) -//! Use the reusable functions that return typed data: -//! - [`list_user_prs`] - List open PRs by current user -//! - [`get_ci_status`] - Get CI status for a PR -//! - [`list_workflow_runs`] - List workflow runs -//! - [`search_prs`] - Search PRs by title/branch - -mod auth; -mod cli; -mod client; -mod failures; -mod fix; -mod helpers; -mod login; -mod prs; -mod runs; -mod service; -mod sync; -mod types; - -use anyhow::Result; - -pub use cli::GhCommand; -pub use types::{CiStatus, PullRequest, RunsQuery, WorkflowRun}; - -/// Run a GitHub command (CLI entry point - formats and prints) -#[cfg(not(tarpaulin_include))] -pub async fn run_command(cmd: GhCommand) -> anyhow::Result<()> { - match cmd { - GhCommand::Login(args) => login::run(args).await, - GhCommand::Prs => prs::run().await, - GhCommand::Failures(args) => failures::run(args).await, - GhCommand::Fix(args) => fix::run(args).await, - GhCommand::Runs(args) => runs::run(args).await, - GhCommand::Sync(args) => sync::run(args), - } -} - -// ============================================================================ -// Reusable functions for MCP/HTTP - return typed data, never print -// ============================================================================ - -/// List open PRs authored by the current user (for MCP/HTTP) -#[allow(dead_code)] -pub async fn list_user_prs() -> Result> { - let client = service::create_client()?; - service::list_user_prs(&client).await -} - -/// Get CI status for a PR (for MCP/HTTP) -#[allow(dead_code)] -pub async fn get_ci_status(owner: &str, repo: &str, pr_number: u64) -> Result { - let client = service::create_client()?; - service::get_ci_status(&client, owner, repo, pr_number).await -} - -/// List workflow runs for a repository (for MCP/HTTP) -#[allow(dead_code)] -pub async fn list_workflow_runs(query: &RunsQuery<'_>) -> Result> { - let client = service::create_client()?; - service::list_workflow_runs(&client, query).await -} - -/// Search PRs by title/branch (for MCP/HTTP) -#[allow(dead_code)] -pub async fn search_prs(owner: &str, repo: &str, query: &str) -> Result> { - let client = service::create_client()?; - service::search_prs(&client, owner, repo, query).await -} - -/// Find PR number for a branch (for MCP/HTTP) -#[allow(dead_code)] -pub async fn find_pr_for_branch(owner: &str, repo: &str, branch: &str) -> Result> { - let client = service::create_client()?; - service::find_pr_for_branch(&client, owner, repo, branch).await -} - -/// Get failed jobs for a workflow run (for MCP/HTTP) -#[allow(dead_code)] -pub async fn get_failed_jobs(owner: &str, repo: &str, run_id: u64) -> Result> { - let client = service::create_client()?; - service::get_failed_jobs(&client, owner, repo, run_id).await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn gh_command_exported() { - // Verify GhCommand is accessible - let _ = std::any::type_name::(); - } -} diff --git a/src/gh/prs.rs b/src/gh/prs.rs deleted file mode 100644 index a2849f0..0000000 --- a/src/gh/prs.rs +++ /dev/null @@ -1,343 +0,0 @@ -use anyhow::Result; - -use super::client::{GithubApi, GithubClient}; -use super::types::CiStatus; - -// ANSI color codes -const GREEN: &str = "\x1b[32m"; -const YELLOW: &str = "\x1b[33m"; -const RED: &str = "\x1b[31m"; -const GRAY: &str = "\x1b[90m"; -const RESET: &str = "\x1b[0m"; - -/// Handle the `hu gh prs` command -pub async fn run() -> Result<()> { - let client = GithubClient::new()?; - run_with_client(&client).await -} - -fn get_terminal_width() -> usize { - terminal_size::terminal_size() - .map(|(w, _)| w.0 as usize) - .unwrap_or(80) -} - -fn print_prs_table(prs: &[super::types::PullRequest]) { - let term_width = get_terminal_width(); - - // Calculate max link length - let max_link_len = prs.iter().map(|p| p.html_url.len()).max().unwrap_or(40); - - // Layout: │ S │ Title... │ Link │ - // Borders take: 1 + 1 + 3 + 3 + 1 = 9 chars (│ S │ ... │ ... │) - let status_col = 1; - let border_overhead = 10; // "│ " + " │ " + " │ " + "│" - - let available = term_width.saturating_sub(border_overhead + status_col + max_link_len); - let title_width = available.max(20); - let link_width = max_link_len; - - // Top border - println!( - "┌───┬{}┬{}┐", - "─".repeat(title_width + 2), - "─".repeat(link_width + 2) - ); - - // Rows - for pr in prs { - let status_icon = match pr.ci_status.unwrap_or(CiStatus::Unknown) { - CiStatus::Success => format!("{}{}{}", GREEN, "✓", RESET), - CiStatus::Pending => format!("{}{}{}", YELLOW, "◐", RESET), - CiStatus::Failed => format!("{}{}{}", RED, "✗", RESET), - CiStatus::Unknown => format!("{}{}{}", GRAY, "○", RESET), - }; - - let title = truncate(&pr.title, title_width); - let link = format!("{}{}{}", GRAY, &pr.html_url, RESET); - - println!( - "│ {} │ {: String { - if s.chars().count() <= max_len { - s.to_string() - } else { - let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); - format!("{}…", truncated) - } -} - -/// Fetch and display PRs using the given API client -pub async fn run_with_client(client: &impl GithubApi) -> Result<()> { - let mut prs = client.list_user_prs().await?; - - if prs.is_empty() { - println!("No open pull requests found."); - return Ok(()); - } - - // Fetch CI status for each PR - for pr in &mut prs { - let parts: Vec<&str> = pr.repo_full_name.split('/').collect(); - if parts.len() == 2 { - if let Ok(status) = client.get_ci_status(parts[0], parts[1], pr.number).await { - pr.ci_status = Some(status); - } - } - } - - print_prs_table(&prs); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::gh::types::PullRequest; - - #[test] - fn truncate_short_string() { - assert_eq!(truncate("hello", 10), "hello"); - } - - #[test] - fn truncate_long_string() { - assert_eq!(truncate("hello world", 8), "hello w…"); - } - - #[test] - fn truncate_exact_length() { - assert_eq!(truncate("hello", 5), "hello"); - } - - #[test] - fn truncate_unicode() { - // Unicode chars are counted by char, not byte - assert_eq!(truncate("héllo", 5), "héllo"); - assert_eq!(truncate("héllo world", 6), "héllo…"); - } - - #[test] - fn truncate_empty() { - assert_eq!(truncate("", 10), ""); - } - - #[test] - fn truncate_zero_length() { - // Edge case: max_len = 0 means we try to take 0 chars + ellipsis - // saturating_sub(1) on 0 = 0, so we get just "…" if string is not empty - let result = truncate("hello", 0); - // With max_len=0, chars.count()=5 > 0, so we truncate - // take(0.saturating_sub(1)) = take(0), so we get "" + "…" = "…" - assert_eq!(result, "…"); - } - - #[test] - fn status_icons_render() { - let _ = format!("{}✓{}", GREEN, RESET); - let _ = format!("{}◐{}", YELLOW, RESET); - let _ = format!("{}✗{}", RED, RESET); - } - - #[test] - fn get_terminal_width_returns_reasonable_value() { - let width = get_terminal_width(); - // Should return at least 80 (default) or actual terminal width - assert!(width >= 20); - } - - #[test] - fn status_icon_formatting_success() { - let icon = format!("{}{}{}", GREEN, "✓", RESET); - assert!(icon.contains("✓")); - assert!(icon.starts_with("\x1b[32m")); - assert!(icon.ends_with("\x1b[0m")); - } - - #[test] - fn status_icon_formatting_pending() { - let icon = format!("{}{}{}", YELLOW, "◐", RESET); - assert!(icon.contains("◐")); - } - - #[test] - fn status_icon_formatting_failed() { - let icon = format!("{}{}{}", RED, "✗", RESET); - assert!(icon.contains("✗")); - } - - #[test] - fn status_icon_formatting_unknown() { - let icon = format!("{}{}{}", GRAY, "○", RESET); - assert!(icon.contains("○")); - } - - #[test] - fn print_prs_table_renders_without_panic() { - let prs = vec![ - PullRequest { - number: 1, - title: "Short title".to_string(), - html_url: "https://github.com/o/r/pull/1".to_string(), - state: "open".to_string(), - repo_full_name: "o/r".to_string(), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - ci_status: Some(CiStatus::Success), - }, - PullRequest { - number: 2, - title: "A very long title that will definitely need truncation because it exceeds the available width".to_string(), - html_url: "https://github.com/owner/repo/pull/2".to_string(), - state: "open".to_string(), - repo_full_name: "owner/repo".to_string(), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - ci_status: Some(CiStatus::Failed), - }, - PullRequest { - number: 3, - title: "Pending PR".to_string(), - html_url: "https://github.com/o/r/pull/3".to_string(), - state: "open".to_string(), - repo_full_name: "o/r".to_string(), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - ci_status: Some(CiStatus::Pending), - }, - PullRequest { - number: 4, - title: "Unknown status".to_string(), - html_url: "https://github.com/o/r/pull/4".to_string(), - state: "open".to_string(), - repo_full_name: "o/r".to_string(), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - ci_status: None, - }, - ]; - // This just verifies it doesn't panic - print_prs_table(&prs); - } - - #[test] - fn print_prs_table_empty_list() { - let prs: Vec = vec![]; - print_prs_table(&prs); - } - - // Mock implementation for testing - struct MockGithubApi { - prs: Vec, - ci_status: CiStatus, - } - - impl GithubApi for MockGithubApi { - async fn list_user_prs(&self) -> Result> { - Ok(self.prs.clone()) - } - - async fn get_ci_status(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok(self.ci_status) - } - - async fn get_pr_branch(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok("main".to_string()) - } - - async fn get_latest_failed_run_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(None) - } - - async fn get_latest_failed_run(&self, _owner: &str, _repo: &str) -> Result> { - Ok(None) - } - - async fn get_failed_jobs( - &self, - _owner: &str, - _repo: &str, - _run_id: u64, - ) -> Result> { - Ok(vec![]) - } - - async fn get_job_logs(&self, _owner: &str, _repo: &str, _job_id: u64) -> Result { - Ok(String::new()) - } - - async fn find_pr_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(None) - } - - async fn list_workflow_runs( - &self, - _query: &crate::gh::types::RunsQuery<'_>, - ) -> Result> { - Ok(vec![]) - } - - async fn search_prs_by_title( - &self, - _owner: &str, - _repo: &str, - _query: &str, - ) -> Result> { - Ok(vec![]) - } - } - - #[tokio::test] - async fn run_with_client_empty_prs() { - let mock = MockGithubApi { - prs: vec![], - ci_status: CiStatus::Unknown, - }; - let result = run_with_client(&mock).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn run_with_client_with_prs() { - let mock = MockGithubApi { - prs: vec![PullRequest { - number: 1, - title: "Test PR".to_string(), - html_url: "https://github.com/o/r/pull/1".to_string(), - state: "open".to_string(), - repo_full_name: "o/r".to_string(), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - ci_status: None, - }], - ci_status: CiStatus::Success, - }; - let result = run_with_client(&mock).await; - assert!(result.is_ok()); - } -} diff --git a/src/gh/runs/mod.rs b/src/gh/runs/mod.rs deleted file mode 100644 index 605e031..0000000 --- a/src/gh/runs/mod.rs +++ /dev/null @@ -1,197 +0,0 @@ -use anyhow::Result; - -use super::cli::RunsArgs; -use super::client::{GithubApi, GithubClient}; -use super::helpers::{get_current_repo, parse_owner_repo}; -use super::types::{RunsQuery, WorkflowRun}; - -#[cfg(test)] -mod tests; - -// ANSI color codes -const GREEN: &str = "\x1b[32m"; -const YELLOW: &str = "\x1b[33m"; -const RED: &str = "\x1b[31m"; -const GRAY: &str = "\x1b[90m"; -const RESET: &str = "\x1b[0m"; - -/// Handle the `hu gh runs` command -pub async fn run(args: RunsArgs) -> Result<()> { - let client = GithubClient::new()?; - let (owner, repo) = match &args.repo { - Some(r) => parse_owner_repo(r)?, - None => get_current_repo()?, - }; - run_with_client(&client, &owner, &repo, &args).await -} - -/// Fetch and display workflow runs using the given API client -pub async fn run_with_client( - client: &impl GithubApi, - owner: &str, - repo: &str, - args: &RunsArgs, -) -> Result<()> { - let runs = if let Some(ticket) = &args.ticket { - fetch_runs_for_ticket(client, owner, repo, ticket, args).await? - } else { - let query = RunsQuery { - owner, - repo, - branch: args.branch.as_deref(), - status: args.status.as_deref(), - limit: args.limit, - }; - client.list_workflow_runs(&query).await? - }; - - if runs.is_empty() { - println!("No workflow runs found."); - return Ok(()); - } - - if args.json { - print_runs_json(&runs); - } else { - print_runs_table(&runs); - } - - Ok(()) -} - -/// Find runs associated with a ticket by searching PRs and their branches -async fn fetch_runs_for_ticket( - client: &impl GithubApi, - owner: &str, - repo: &str, - ticket: &str, - args: &RunsArgs, -) -> Result> { - let prs = client.search_prs_by_title(owner, repo, ticket).await?; - - if prs.is_empty() { - return Ok(vec![]); - } - - let mut all_runs = Vec::new(); - let mut seen_branches = std::collections::HashSet::new(); - - for pr in &prs { - // Get the branch for this PR - let parts: Vec<&str> = pr.repo_full_name.split('/').collect(); - let (pr_owner, pr_repo) = if parts.len() == 2 { - (parts[0], parts[1]) - } else { - (owner, repo) - }; - - if let Ok(branch) = client.get_pr_branch(pr_owner, pr_repo, pr.number).await { - if seen_branches.insert(branch.clone()) { - let query = RunsQuery { - owner: pr_owner, - repo: pr_repo, - branch: Some(&branch), - status: args.status.as_deref(), - limit: args.limit, - }; - let runs = client.list_workflow_runs(&query).await?; - all_runs.extend(runs); - } - } - } - - // Sort by created_at descending and truncate to limit - all_runs.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - all_runs.truncate(args.limit); - - Ok(all_runs) -} - -/// Get status icon with color for a workflow run -fn status_icon(run: &WorkflowRun) -> String { - match run.conclusion.as_deref() { - Some("success") => format!("{GREEN}✓{RESET}"), - Some("failure") => format!("{RED}✗{RESET}"), - Some("cancelled") => format!("{GRAY}○{RESET}"), - _ => match run.status.as_str() { - "in_progress" => format!("{YELLOW}◐{RESET}"), - "queued" => format!("{GRAY}○{RESET}"), - _ => format!("{GRAY}○{RESET}"), - }, - } -} - -fn get_terminal_width() -> usize { - terminal_size::terminal_size() - .map(|(w, _)| w.0 as usize) - .unwrap_or(80) -} - -fn truncate(s: &str, max_len: usize) -> String { - if s.chars().count() <= max_len { - s.to_string() - } else { - let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); - format!("{truncated}…") - } -} - -fn print_runs_table(runs: &[WorkflowRun]) { - let term_width = get_terminal_width(); - - let max_link_len = runs.iter().map(|r| r.html_url.len()).max().unwrap_or(40); - let max_branch_len = runs - .iter() - .map(|r| r.branch.len()) - .max() - .unwrap_or(10) - .min(30); - - // Layout: │ S │ Name │ Branch │ Link │ - let status_col = 1; - let border_overhead = 14; // "│ " + " │ " + " │ " + " │ " + "│" - - let available = - term_width.saturating_sub(border_overhead + status_col + max_branch_len + max_link_len); - let name_width = available.max(15); - let branch_width = max_branch_len; - let link_width = max_link_len; - - // Top border - println!( - "┌───┬{}┬{}┬{}┐", - "─".repeat(name_width + 2), - "─".repeat(branch_width + 2), - "─".repeat(link_width + 2), - ); - - for run in runs { - let icon = status_icon(run); - let name = truncate(&run.name, name_width); - let branch = truncate(&run.branch, branch_width); - let link = format!("{GRAY}{}{RESET}", &run.html_url); - - println!( - "│ {} │ {:, - runs: Vec, - branches: std::collections::HashMap, -} - -impl MockGithubApi { - fn new() -> Self { - Self { - prs: vec![], - runs: vec![], - branches: std::collections::HashMap::new(), - } - } - - fn with_runs(mut self, runs: Vec) -> Self { - self.runs = runs; - self - } - - fn with_prs(mut self, prs: Vec) -> Self { - self.prs = prs; - self - } - - fn with_branch(mut self, pr_number: u64, branch: String) -> Self { - self.branches.insert(pr_number, branch); - self - } -} - -impl GithubApi for MockGithubApi { - async fn list_user_prs(&self) -> Result> { - Ok(self.prs.clone()) - } - - async fn get_ci_status(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok(CiStatus::Unknown) - } - - async fn get_pr_branch(&self, _owner: &str, _repo: &str, pr: u64) -> Result { - Ok(self - .branches - .get(&pr) - .cloned() - .unwrap_or_else(|| "main".to_string())) - } - - async fn get_latest_failed_run_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(None) - } - - async fn get_latest_failed_run(&self, _owner: &str, _repo: &str) -> Result> { - Ok(None) - } - - async fn get_failed_jobs( - &self, - _owner: &str, - _repo: &str, - _run_id: u64, - ) -> Result> { - Ok(vec![]) - } - - async fn get_job_logs(&self, _owner: &str, _repo: &str, _job_id: u64) -> Result { - Ok(String::new()) - } - - async fn find_pr_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(None) - } - - async fn list_workflow_runs(&self, query: &RunsQuery<'_>) -> Result> { - let mut runs: Vec = self - .runs - .iter() - .filter(|r| query.branch.is_none_or(|b| r.branch == b)) - .filter(|r| { - query - .status - .is_none_or(|s| r.status == s || r.conclusion.as_deref() == Some(s)) - }) - .cloned() - .collect(); - runs.truncate(query.limit); - Ok(runs) - } - - async fn search_prs_by_title( - &self, - _owner: &str, - _repo: &str, - query: &str, - ) -> Result> { - let query_lower = query.to_lowercase(); - Ok(self - .prs - .iter() - .filter(|pr| pr.title.to_lowercase().contains(&query_lower)) - .cloned() - .collect()) - } -} - -fn make_run( - id: u64, - name: &str, - status: &str, - conclusion: Option<&str>, - branch: &str, -) -> WorkflowRun { - WorkflowRun { - id, - name: name.to_string(), - status: status.to_string(), - conclusion: conclusion.map(|s| s.to_string()), - branch: branch.to_string(), - html_url: format!("https://github.com/o/r/actions/runs/{id}"), - created_at: format!("2024-01-15T{:02}:00:00Z", id % 24), - updated_at: format!("2024-01-15T{:02}:05:00Z", id % 24), - run_number: id, - } -} - -fn make_pr(number: u64, title: &str) -> PullRequest { - PullRequest { - number, - title: title.to_string(), - html_url: format!("https://github.com/o/r/pull/{number}"), - state: "open".to_string(), - repo_full_name: "o/r".to_string(), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - ci_status: None, - } -} - -fn default_args() -> RunsArgs { - RunsArgs { - ticket: None, - status: None, - branch: None, - repo: None, - limit: 20, - json: false, - } -} - -// status_icon tests -#[test] -fn status_icon_success() { - let run = make_run(1, "CI", "completed", Some("success"), "main"); - let icon = status_icon(&run); - assert!(icon.contains("✓")); - assert!(icon.contains(GREEN)); -} - -#[test] -fn status_icon_failure() { - let run = make_run(1, "CI", "completed", Some("failure"), "main"); - let icon = status_icon(&run); - assert!(icon.contains("✗")); - assert!(icon.contains(RED)); -} - -#[test] -fn status_icon_in_progress() { - let run = make_run(1, "CI", "in_progress", None, "main"); - let icon = status_icon(&run); - assert!(icon.contains("◐")); - assert!(icon.contains(YELLOW)); -} - -#[test] -fn status_icon_queued() { - let run = make_run(1, "CI", "queued", None, "main"); - let icon = status_icon(&run); - assert!(icon.contains("○")); - assert!(icon.contains(GRAY)); -} - -#[test] -fn status_icon_cancelled() { - let run = make_run(1, "CI", "completed", Some("cancelled"), "main"); - let icon = status_icon(&run); - assert!(icon.contains("○")); - assert!(icon.contains(GRAY)); -} - -#[test] -fn status_icon_unknown_status() { - let run = make_run(1, "CI", "unknown", None, "main"); - let icon = status_icon(&run); - assert!(icon.contains("○")); -} - -// truncate tests -#[test] -fn truncate_short() { - assert_eq!(truncate("hello", 10), "hello"); -} - -#[test] -fn truncate_long() { - assert_eq!(truncate("hello world", 8), "hello w…"); -} - -#[test] -fn truncate_exact() { - assert_eq!(truncate("hello", 5), "hello"); -} - -#[test] -fn truncate_empty() { - assert_eq!(truncate("", 10), ""); -} - -// print_runs_table tests -#[test] -fn print_runs_table_renders_without_panic() { - let runs = vec![ - make_run(1, "CI", "completed", Some("success"), "main"), - make_run(2, "Lint", "completed", Some("failure"), "feature"), - make_run(3, "Deploy", "in_progress", None, "main"), - ]; - print_runs_table(&runs); -} - -#[test] -fn print_runs_table_empty() { - let runs: Vec = vec![]; - print_runs_table(&runs); -} - -#[test] -fn print_runs_table_long_names() { - let runs = vec![make_run( - 1, - "A very long workflow name that should be truncated", - "completed", - Some("success"), - "a-very-long-branch-name-too", - )]; - print_runs_table(&runs); -} - -// print_runs_json tests -#[test] -fn print_runs_json_renders() { - let runs = vec![make_run(1, "CI", "completed", Some("success"), "main")]; - print_runs_json(&runs); -} - -#[test] -fn print_runs_json_empty() { - let runs: Vec = vec![]; - print_runs_json(&runs); -} - -// get_terminal_width test -#[test] -fn get_terminal_width_reasonable() { - let width = get_terminal_width(); - assert!(width >= 20); -} - -// run_with_client tests -#[tokio::test] -async fn run_with_client_no_runs() { - let mock = MockGithubApi::new(); - let args = default_args(); - let result = run_with_client(&mock, "o", "r", &args).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn run_with_client_with_runs() { - let runs = vec![ - make_run(1, "CI", "completed", Some("success"), "main"), - make_run(2, "Lint", "completed", Some("failure"), "main"), - ]; - let mock = MockGithubApi::new().with_runs(runs); - let args = default_args(); - let result = run_with_client(&mock, "o", "r", &args).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn run_with_client_json_output() { - let runs = vec![make_run(1, "CI", "completed", Some("success"), "main")]; - let mock = MockGithubApi::new().with_runs(runs); - let mut args = default_args(); - args.json = true; - let result = run_with_client(&mock, "o", "r", &args).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn run_with_client_branch_filter() { - let runs = vec![ - make_run(1, "CI", "completed", Some("success"), "main"), - make_run(2, "CI", "completed", Some("failure"), "feature"), - ]; - let mock = MockGithubApi::new().with_runs(runs); - let mut args = default_args(); - args.branch = Some("feature".to_string()); - let result = run_with_client(&mock, "o", "r", &args).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn run_with_client_status_filter() { - let runs = vec![ - make_run(1, "CI", "completed", Some("success"), "main"), - make_run(2, "CI", "completed", Some("failure"), "main"), - ]; - let mock = MockGithubApi::new().with_runs(runs); - let mut args = default_args(); - args.status = Some("failure".to_string()); - let result = run_with_client(&mock, "o", "r", &args).await; - assert!(result.is_ok()); -} - -// fetch_runs_for_ticket tests -#[tokio::test] -async fn fetch_runs_for_ticket_no_prs() { - let mock = MockGithubApi::new(); - let args = default_args(); - let runs = fetch_runs_for_ticket(&mock, "o", "r", "BFR-999", &args).await; - assert!(runs.is_ok()); - assert!(runs.unwrap().is_empty()); -} - -#[tokio::test] -async fn fetch_runs_for_ticket_with_prs() { - let pr = make_pr(1, "BFR-1234 Fix bug"); - let runs = vec![ - make_run(10, "CI", "completed", Some("success"), "bfr-1234-fix"), - make_run(11, "Lint", "completed", Some("success"), "bfr-1234-fix"), - ]; - let mock = MockGithubApi::new() - .with_prs(vec![pr]) - .with_runs(runs) - .with_branch(1, "bfr-1234-fix".to_string()); - let args = default_args(); - let result = fetch_runs_for_ticket(&mock, "o", "r", "BFR-1234", &args).await; - assert!(result.is_ok()); - assert_eq!(result.unwrap().len(), 2); -} - -#[tokio::test] -async fn fetch_runs_for_ticket_deduplicates_branches() { - let prs = vec![ - make_pr(1, "BFR-1234 First PR"), - make_pr(2, "BFR-1234 Second PR"), - ]; - let runs = vec![make_run( - 10, - "CI", - "completed", - Some("success"), - "same-branch", - )]; - let mock = MockGithubApi::new() - .with_prs(prs) - .with_runs(runs) - .with_branch(1, "same-branch".to_string()) - .with_branch(2, "same-branch".to_string()); - let args = default_args(); - let result = fetch_runs_for_ticket(&mock, "o", "r", "BFR-1234", &args).await; - assert!(result.is_ok()); - // Should only query once since both PRs point to same branch - assert_eq!(result.unwrap().len(), 1); -} - -#[tokio::test] -async fn fetch_runs_for_ticket_respects_limit() { - let pr = make_pr(1, "BFR-1234 Fix"); - let runs: Vec = (0..10) - .map(|i| make_run(i, "CI", "completed", Some("success"), "feature")) - .collect(); - let mock = MockGithubApi::new() - .with_prs(vec![pr]) - .with_runs(runs) - .with_branch(1, "feature".to_string()); - let mut args = default_args(); - args.limit = 3; - let result = fetch_runs_for_ticket(&mock, "o", "r", "BFR-1234", &args).await; - assert!(result.is_ok()); - assert!(result.unwrap().len() <= 3); -} - -#[tokio::test] -async fn run_with_client_ticket_search() { - let pr = make_pr(1, "BFR-1234 Fix bug"); - let runs = vec![make_run(10, "CI", "completed", Some("success"), "bfr-1234")]; - let mock = MockGithubApi::new() - .with_prs(vec![pr]) - .with_runs(runs) - .with_branch(1, "bfr-1234".to_string()); - let mut args = default_args(); - args.ticket = Some("BFR-1234".to_string()); - let result = run_with_client(&mock, "o", "r", &args).await; - assert!(result.is_ok()); -} - -#[tokio::test] -async fn fetch_runs_for_ticket_invalid_repo_name() { - let mut pr = make_pr(1, "BFR-1234 Fix bug"); - pr.repo_full_name = "invalid-no-slash".to_string(); - let runs = vec![make_run(10, "CI", "completed", Some("success"), "feature")]; - let mock = MockGithubApi::new() - .with_prs(vec![pr]) - .with_runs(runs) - .with_branch(1, "feature".to_string()); - let args = default_args(); - let result = fetch_runs_for_ticket(&mock, "o", "r", "BFR-1234", &args).await; - assert!(result.is_ok()); - // Should still work, falling back to owner/repo params - assert_eq!(result.unwrap().len(), 1); -} - -#[tokio::test] -async fn run_with_client_ticket_no_results() { - let mock = MockGithubApi::new(); - let mut args = default_args(); - args.ticket = Some("NONE-999".to_string()); - let result = run_with_client(&mock, "o", "r", &args).await; - assert!(result.is_ok()); -} diff --git a/src/gh/service.rs b/src/gh/service.rs deleted file mode 100644 index 54f1261..0000000 --- a/src/gh/service.rs +++ /dev/null @@ -1,287 +0,0 @@ -//! GitHub service layer - business logic that returns data -//! -//! Functions in this module accept trait objects and return typed data. -//! They never print - that's the CLI layer's job. - -use anyhow::Result; - -use super::client::{GithubApi, GithubClient}; -use super::types::{CiStatus, PullRequest, RunsQuery, WorkflowRun}; - -/// List open PRs authored by the current user -pub async fn list_user_prs(api: &impl GithubApi) -> Result> { - api.list_user_prs().await -} - -/// Get CI status for a PR -pub async fn get_ci_status( - api: &impl GithubApi, - owner: &str, - repo: &str, - pr_number: u64, -) -> Result { - api.get_ci_status(owner, repo, pr_number).await -} - -/// Get the branch name for a PR -#[allow(dead_code)] -pub async fn get_pr_branch( - api: &impl GithubApi, - owner: &str, - repo: &str, - pr_number: u64, -) -> Result { - api.get_pr_branch(owner, repo, pr_number).await -} - -/// Get the latest failed workflow run for a branch -#[allow(dead_code)] -pub async fn get_latest_failed_run( - api: &impl GithubApi, - owner: &str, - repo: &str, - branch: &str, -) -> Result> { - api.get_latest_failed_run_for_branch(owner, repo, branch) - .await -} - -/// Get failed jobs for a workflow run -pub async fn get_failed_jobs( - api: &impl GithubApi, - owner: &str, - repo: &str, - run_id: u64, -) -> Result> { - api.get_failed_jobs(owner, repo, run_id).await -} - -/// Download logs for a job -#[allow(dead_code)] -pub async fn get_job_logs( - api: &impl GithubApi, - owner: &str, - repo: &str, - job_id: u64, -) -> Result { - api.get_job_logs(owner, repo, job_id).await -} - -/// Find PR number for a branch -pub async fn find_pr_for_branch( - api: &impl GithubApi, - owner: &str, - repo: &str, - branch: &str, -) -> Result> { - api.find_pr_for_branch(owner, repo, branch).await -} - -/// List workflow runs for a repository -pub async fn list_workflow_runs( - api: &impl GithubApi, - query: &RunsQuery<'_>, -) -> Result> { - api.list_workflow_runs(query).await -} - -/// Search PRs by title/branch containing a query string -pub async fn search_prs( - api: &impl GithubApi, - owner: &str, - repo: &str, - query: &str, -) -> Result> { - api.search_prs_by_title(owner, repo, query).await -} - -/// Create a new authenticated client -pub fn create_client() -> Result { - GithubClient::new() -} - -#[cfg(test)] -mod tests { - use super::*; - - struct MockApi { - prs: Vec, - runs: Vec, - } - - impl MockApi { - fn new() -> Self { - Self { - prs: vec![], - runs: vec![], - } - } - - fn with_prs(mut self, prs: Vec) -> Self { - self.prs = prs; - self - } - - fn with_runs(mut self, runs: Vec) -> Self { - self.runs = runs; - self - } - } - - impl GithubApi for MockApi { - async fn list_user_prs(&self) -> Result> { - Ok(self.prs.clone()) - } - - async fn get_ci_status(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok(CiStatus::Success) - } - - async fn get_pr_branch(&self, _owner: &str, _repo: &str, _pr: u64) -> Result { - Ok("main".to_string()) - } - - async fn get_latest_failed_run_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(self.runs.first().map(|r| r.id)) - } - - async fn get_latest_failed_run(&self, _owner: &str, _repo: &str) -> Result> { - Ok(self.runs.first().map(|r| r.id)) - } - - async fn get_failed_jobs( - &self, - _owner: &str, - _repo: &str, - _run_id: u64, - ) -> Result> { - Ok(vec![(123, "test".to_string())]) - } - - async fn get_job_logs(&self, _owner: &str, _repo: &str, _job: u64) -> Result { - Ok("Test logs".to_string()) - } - - async fn find_pr_for_branch( - &self, - _owner: &str, - _repo: &str, - _branch: &str, - ) -> Result> { - Ok(self.prs.first().map(|p| p.number)) - } - - async fn list_workflow_runs(&self, _query: &RunsQuery<'_>) -> Result> { - Ok(self.runs.clone()) - } - - async fn search_prs_by_title( - &self, - _owner: &str, - _repo: &str, - query: &str, - ) -> Result> { - let query_lower = query.to_lowercase(); - Ok(self - .prs - .iter() - .filter(|p| p.title.to_lowercase().contains(&query_lower)) - .cloned() - .collect()) - } - } - - fn make_pr(number: u64, title: &str) -> PullRequest { - PullRequest { - number, - title: title.to_string(), - html_url: format!("https://github.com/owner/repo/pull/{}", number), - state: "open".to_string(), - repo_full_name: "owner/repo".to_string(), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - ci_status: None, - } - } - - fn make_run(id: u64, name: &str, status: &str) -> WorkflowRun { - WorkflowRun { - id, - name: name.to_string(), - status: status.to_string(), - conclusion: Some("success".to_string()), - branch: "main".to_string(), - html_url: format!("https://github.com/owner/repo/actions/runs/{}", id), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - run_number: id, - } - } - - #[tokio::test] - async fn list_user_prs_returns_all() { - let api = MockApi::new().with_prs(vec![make_pr(1, "Fix bug"), make_pr(2, "Add feature")]); - - let result = list_user_prs(&api).await.unwrap(); - assert_eq!(result.len(), 2); - } - - #[tokio::test] - async fn get_ci_status_returns_status() { - let api = MockApi::new(); - let result = get_ci_status(&api, "owner", "repo", 1).await.unwrap(); - assert_eq!(result, CiStatus::Success); - } - - #[tokio::test] - async fn list_workflow_runs_returns_all() { - let api = MockApi::new().with_runs(vec![ - make_run(1, "CI", "completed"), - make_run(2, "Deploy", "in_progress"), - ]); - - let query = RunsQuery { - owner: "owner", - repo: "repo", - branch: None, - status: None, - limit: 10, - }; - let result = list_workflow_runs(&api, &query).await.unwrap(); - assert_eq!(result.len(), 2); - } - - #[tokio::test] - async fn search_prs_filters_by_title() { - let api = MockApi::new().with_prs(vec![ - make_pr(1, "Fix authentication bug"), - make_pr(2, "Add new feature"), - ]); - - let result = search_prs(&api, "owner", "repo", "bug").await.unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].title, "Fix authentication bug"); - } - - #[tokio::test] - async fn find_pr_for_branch_returns_first() { - let api = MockApi::new().with_prs(vec![make_pr(42, "My PR")]); - let result = find_pr_for_branch(&api, "owner", "repo", "feature") - .await - .unwrap(); - assert_eq!(result, Some(42)); - } - - #[tokio::test] - async fn get_failed_jobs_returns_list() { - let api = MockApi::new(); - let result = get_failed_jobs(&api, "owner", "repo", 123).await.unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].1, "test"); - } -} diff --git a/src/gh/sync.rs b/src/gh/sync.rs deleted file mode 100644 index 2d90bfe..0000000 --- a/src/gh/sync.rs +++ /dev/null @@ -1,349 +0,0 @@ -use anyhow::{Context, Result}; -use chrono::Local; -use std::fs::{self, OpenOptions}; -use std::io::Write; -use std::path::PathBuf; - -use crate::git::{self, SyncOptions, SyncResult}; - -use super::cli::SyncArgs; - -/// Default log file path -fn default_log_path() -> PathBuf { - dirs::home_dir() - .map(|h| h.join(".hu/gh-sync.log")) - .unwrap_or_else(|| PathBuf::from("gh-sync.log")) -} - -/// Format a log line for sync result -fn format_log_line(result: &SyncResult, repo_path: &str) -> String { - let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S"); - let branch = result.branch.as_deref().unwrap_or("-"); - let hash = result.commit_hash.as_deref().unwrap_or("-"); - let status = if result.pushed { - "pushed" - } else if result.commit_hash.is_some() { - "committed" - } else { - "clean" - }; - - format!( - "{} | {} | {} | {} | {} files | {}", - timestamp, repo_path, branch, hash, result.files_committed, status - ) -} - -/// Append a log line to the log file -fn append_log(log_path: &PathBuf, line: &str) -> Result<()> { - if let Some(parent) = log_path.parent() { - fs::create_dir_all(parent).context("Failed to create log directory")?; - } - - let mut file = OpenOptions::new() - .create(true) - .append(true) - .open(log_path) - .context("Failed to open log file")?; - - writeln!(file, "{}", line).context("Failed to write log")?; - Ok(()) -} - -pub fn run(args: SyncArgs) -> Result<()> { - let repo_path = args - .path - .clone() - .unwrap_or_else(|| std::env::current_dir().unwrap_or_default()); - - let options = SyncOptions { - no_pull: args.no_pull, - trigger: args.trigger, - no_commit: args.no_commit, - no_push: args.no_push, - message: args.message, - path: args.path, - }; - - let result = git::sync(&options)?; - - // Log if requested - if args.log { - let log_path = args.log_file.unwrap_or_else(default_log_path); - let repo_display = repo_path.to_string_lossy(); - let line = format_log_line(&result, &repo_display); - append_log(&log_path, &line)?; - } - - if args.json { - println!("{}", serde_json::to_string_pretty(&result)?); - return Ok(()); - } - - // Trigger mode: empty commit - if args.trigger { - if let Some(hash) = &result.commit_hash { - let branch = result.branch.as_deref().unwrap_or("unknown"); - println!("\x1b[32m\u{2713}\x1b[0m Empty commit [{}] {}", branch, hash); - } - if result.pushed { - println!("\x1b[32m\u{2713}\x1b[0m Pushed to origin (CI triggered)"); - } - return Ok(()); - } - - // Output in order: commit → pull → push - let mut any_action = false; - - // Show committed files - if let Some(hash) = &result.commit_hash { - let branch = result.branch.as_deref().unwrap_or("unknown"); - println!( - "\x1b[32m\u{2713}\x1b[0m Committed {} {} [{}] {}", - result.files_committed, - if result.files_committed == 1 { - "file" - } else { - "files" - }, - branch, - hash - ); - any_action = true; - } else if args.no_commit && result.files_committed > 0 { - println!( - "\x1b[33m\u{25D0}\x1b[0m {} {} changed (--no-commit)", - result.files_committed, - if result.files_committed == 1 { - "file" - } else { - "files" - } - ); - any_action = true; - } - - // Show pull - if result.pulled { - println!("\x1b[32m\u{2713}\x1b[0m Pulled from origin"); - any_action = true; - } - - // Show push - if result.pushed { - println!("\x1b[32m\u{2713}\x1b[0m Pushed to origin"); - any_action = true; - } - - // Nothing happened - if !any_action { - println!("Already up to date"); - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - use tempfile::tempdir; - - #[test] - fn sync_args_to_options() { - let args = SyncArgs { - path: Some(PathBuf::from("/tmp")), - no_pull: true, - trigger: false, - no_commit: true, - no_push: true, - message: Some("test".to_string()), - log: false, - log_file: None, - json: false, - }; - - let options = SyncOptions { - no_pull: args.no_pull, - trigger: args.trigger, - no_commit: args.no_commit, - no_push: args.no_push, - message: args.message.clone(), - path: args.path.clone(), - }; - - assert!(options.no_pull); - assert!(!options.trigger); - assert!(options.no_commit); - assert!(options.no_push); - assert_eq!(options.message.unwrap(), "test"); - assert_eq!(options.path.unwrap(), PathBuf::from("/tmp")); - } - - #[test] - fn sync_args_trigger_mode() { - let args = SyncArgs { - path: None, - no_pull: false, - trigger: true, - no_commit: false, - no_push: false, - message: Some("Retrigger build".to_string()), - log: false, - log_file: None, - json: false, - }; - - let options = SyncOptions { - no_pull: args.no_pull, - trigger: args.trigger, - no_commit: args.no_commit, - no_push: args.no_push, - message: args.message.clone(), - path: args.path.clone(), - }; - - assert!(options.trigger); - assert_eq!(options.message.unwrap(), "Retrigger build"); - } - - #[test] - fn run_not_git_repo() { - let args = SyncArgs { - path: Some(PathBuf::from("/tmp")), - no_pull: false, - trigger: false, - no_commit: false, - no_push: false, - message: None, - log: false, - log_file: None, - json: false, - }; - let result = run(args); - assert!(result.is_err()); - } - - #[test] - fn run_json_not_repo() { - let args = SyncArgs { - path: Some(PathBuf::from("/tmp")), - no_pull: false, - trigger: false, - no_commit: false, - no_push: false, - message: None, - log: false, - log_file: None, - json: true, - }; - let result = run(args); - assert!(result.is_err()); - } - - #[test] - fn default_log_path_in_home() { - let path = default_log_path(); - assert!(path.to_string_lossy().contains(".hu")); - assert!(path.to_string_lossy().contains("gh-sync.log")); - } - - #[test] - fn format_log_line_pushed() { - let result = SyncResult { - pulled: false, - files_committed: 3, - commit_hash: Some("abc1234".to_string()), - pushed: true, - branch: Some("main".to_string()), - }; - let line = format_log_line(&result, "/path/to/repo"); - assert!(line.contains("/path/to/repo")); - assert!(line.contains("main")); - assert!(line.contains("abc1234")); - assert!(line.contains("3 files")); - assert!(line.contains("pushed")); - } - - #[test] - fn format_log_line_committed() { - let result = SyncResult { - pulled: false, - files_committed: 1, - commit_hash: Some("def5678".to_string()), - pushed: false, - branch: Some("feature".to_string()), - }; - let line = format_log_line(&result, "/repo"); - assert!(line.contains("committed")); - assert!(line.contains("1 files")); - } - - #[test] - fn format_log_line_clean() { - let result = SyncResult { - pulled: false, - files_committed: 0, - commit_hash: None, - pushed: false, - branch: Some("main".to_string()), - }; - let line = format_log_line(&result, "/repo"); - assert!(line.contains("clean")); - assert!(line.contains("0 files")); - } - - #[test] - fn format_log_line_no_branch() { - let result = SyncResult { - pulled: false, - files_committed: 0, - commit_hash: None, - pushed: false, - branch: None, - }; - let line = format_log_line(&result, "/repo"); - assert!(line.contains(" - ")); - } - - #[test] - fn append_log_creates_file() { - let tmp = tempdir().unwrap(); - let log_path = tmp.path().join("subdir/test.log"); - - append_log(&log_path, "test line").unwrap(); - - let content = std::fs::read_to_string(&log_path).unwrap(); - assert!(content.contains("test line")); - } - - #[test] - fn append_log_appends() { - let tmp = tempdir().unwrap(); - let log_path = tmp.path().join("test.log"); - - append_log(&log_path, "line 1").unwrap(); - append_log(&log_path, "line 2").unwrap(); - - let content = std::fs::read_to_string(&log_path).unwrap(); - assert!(content.contains("line 1")); - assert!(content.contains("line 2")); - } - - #[test] - fn sync_args_with_log() { - let args = SyncArgs { - path: None, - no_pull: false, - trigger: false, - no_commit: false, - no_push: false, - message: None, - log: true, - log_file: Some(PathBuf::from("/custom/log.txt")), - json: false, - }; - assert!(args.log); - assert_eq!(args.log_file, Some(PathBuf::from("/custom/log.txt"))); - } -} diff --git a/src/gh/types.rs b/src/gh/types.rs deleted file mode 100644 index 9febcc7..0000000 --- a/src/gh/types.rs +++ /dev/null @@ -1,410 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// CI check status -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum CiStatus { - Success, - Pending, - Failed, - #[default] - Unknown, -} - -/// Pull request data for display -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PullRequest { - pub number: u64, - pub title: String, - pub html_url: String, - pub state: String, - pub repo_full_name: String, - pub created_at: String, - pub updated_at: String, - #[serde(skip)] - pub ci_status: Option, -} - -/// A GitHub Actions workflow run -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct WorkflowRun { - pub id: u64, - pub name: String, - pub status: String, - pub conclusion: Option, - pub branch: String, - pub html_url: String, - pub created_at: String, - pub updated_at: String, - pub run_number: u64, -} - -/// Parameters for listing workflow runs -#[derive(Debug, Clone, Default)] -pub struct RunsQuery<'a> { - pub owner: &'a str, - pub repo: &'a str, - pub branch: Option<&'a str>, - pub status: Option<&'a str>, - pub limit: usize, -} - -/// A test failure extracted from CI logs -#[derive(Debug, Clone)] -pub struct TestFailure { - /// The spec file path (e.g., "spec/models/user_spec.rb") - pub spec_file: String, - /// The failure message/output - pub failure_text: String, -} - -/// A test failure enriched with source file mapping -#[derive(Debug, Clone, Serialize)] -pub struct FixFailure { - pub test_file: String, - pub source_files: Vec, - pub failure_text: String, - pub language: String, -} - -/// Full fix report for a failed CI run -#[derive(Debug, Clone, Serialize)] -pub struct FixReport { - pub repository: String, - pub pr_number: Option, - pub run_id: u64, - pub failures: Vec, - pub test_files: Vec, - pub source_files: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pull_request_serializes() { - let pr = PullRequest { - number: 123, - title: "Fix bug".to_string(), - html_url: "https://github.com/org/repo/pull/123".to_string(), - state: "open".to_string(), - repo_full_name: "org/repo".to_string(), - created_at: "2024-01-15T10:00:00Z".to_string(), - updated_at: "2024-01-15T12:00:00Z".to_string(), - ci_status: None, - }; - - let json = serde_json::to_string(&pr).unwrap(); - assert!(json.contains("Fix bug")); - assert!(json.contains("org/repo")); - } - - #[test] - fn pull_request_deserializes() { - let json = r#"{ - "number": 456, - "title": "Add feature", - "html_url": "https://github.com/org/repo/pull/456", - "state": "open", - "repo_full_name": "org/repo", - "created_at": "2024-01-15T10:00:00Z", - "updated_at": "2024-01-15T12:00:00Z" - }"#; - - let pr: PullRequest = serde_json::from_str(json).unwrap(); - assert_eq!(pr.number, 456); - assert_eq!(pr.title, "Add feature"); - assert!(pr.ci_status.is_none()); - } - - #[test] - fn ci_status_default_is_unknown() { - let status = CiStatus::default(); - assert_eq!(status, CiStatus::Unknown); - } - - #[test] - fn ci_status_equality() { - assert_eq!(CiStatus::Success, CiStatus::Success); - assert_eq!(CiStatus::Pending, CiStatus::Pending); - assert_eq!(CiStatus::Failed, CiStatus::Failed); - assert_eq!(CiStatus::Unknown, CiStatus::Unknown); - assert_ne!(CiStatus::Success, CiStatus::Failed); - } - - #[test] - fn ci_status_clone() { - let status = CiStatus::Success; - let cloned = status; - assert_eq!(status, cloned); - } - - #[test] - fn ci_status_debug_format() { - let debug_str = format!("{:?}", CiStatus::Pending); - assert!(debug_str.contains("Pending")); - } - - #[test] - fn test_failure_clone() { - let failure = TestFailure { - spec_file: "./spec/test_spec.rb:10".to_string(), - failure_text: "expected true, got false".to_string(), - }; - let cloned = failure.clone(); - assert_eq!(cloned.spec_file, failure.spec_file); - assert_eq!(cloned.failure_text, failure.failure_text); - } - - #[test] - fn test_failure_debug_format() { - let failure = TestFailure { - spec_file: "./spec/test_spec.rb:10".to_string(), - failure_text: "error".to_string(), - }; - let debug_str = format!("{:?}", failure); - assert!(debug_str.contains("TestFailure")); - assert!(debug_str.contains("spec_file")); - } - - #[test] - fn pull_request_clone() { - let pr = PullRequest { - number: 123, - title: "Test".to_string(), - html_url: "https://github.com/a/b/pull/123".to_string(), - state: "open".to_string(), - repo_full_name: "a/b".to_string(), - created_at: "2024-01-01T00:00:00Z".to_string(), - updated_at: "2024-01-01T00:00:00Z".to_string(), - ci_status: Some(CiStatus::Success), - }; - let cloned = pr.clone(); - assert_eq!(cloned.number, pr.number); - assert_eq!(cloned.ci_status, pr.ci_status); - } - - #[test] - fn pull_request_debug_format() { - let pr = PullRequest { - number: 1, - title: "T".to_string(), - html_url: "u".to_string(), - state: "open".to_string(), - repo_full_name: "r".to_string(), - created_at: "c".to_string(), - updated_at: "u".to_string(), - ci_status: None, - }; - let debug_str = format!("{:?}", pr); - assert!(debug_str.contains("PullRequest")); - } - - #[test] - fn fix_failure_serializes() { - let f = FixFailure { - test_file: "spec/models/user_spec.rb:10".to_string(), - source_files: vec!["app/models/user.rb".to_string()], - failure_text: "expected true".to_string(), - language: "ruby".to_string(), - }; - let json = serde_json::to_string(&f).unwrap(); - assert!(json.contains("user_spec.rb")); - assert!(json.contains("app/models/user.rb")); - assert!(json.contains("ruby")); - } - - #[test] - fn fix_failure_clone() { - let f = FixFailure { - test_file: "test.rb".to_string(), - source_files: vec!["src.rb".to_string()], - failure_text: "err".to_string(), - language: "ruby".to_string(), - }; - let c = f.clone(); - assert_eq!(c.test_file, f.test_file); - assert_eq!(c.source_files, f.source_files); - } - - #[test] - fn fix_failure_debug() { - let f = FixFailure { - test_file: "t".to_string(), - source_files: vec![], - failure_text: "e".to_string(), - language: "rust".to_string(), - }; - let d = format!("{:?}", f); - assert!(d.contains("FixFailure")); - } - - #[test] - fn fix_report_serializes() { - let r = FixReport { - repository: "owner/repo".to_string(), - pr_number: Some(42), - run_id: 123, - failures: vec![], - test_files: vec!["spec/a_spec.rb".to_string()], - source_files: vec!["app/a.rb".to_string()], - }; - let json = serde_json::to_string(&r).unwrap(); - assert!(json.contains("owner/repo")); - assert!(json.contains("42")); - assert!(json.contains("123")); - } - - #[test] - fn fix_report_clone() { - let r = FixReport { - repository: "o/r".to_string(), - pr_number: None, - run_id: 1, - failures: vec![], - test_files: vec![], - source_files: vec![], - }; - let c = r.clone(); - assert_eq!(c.repository, r.repository); - assert_eq!(c.pr_number, r.pr_number); - } - - #[test] - fn fix_report_debug() { - let r = FixReport { - repository: "o/r".to_string(), - pr_number: None, - run_id: 1, - failures: vec![], - test_files: vec![], - source_files: vec![], - }; - let d = format!("{:?}", r); - assert!(d.contains("FixReport")); - } - - #[test] - fn workflow_run_serializes() { - let run = WorkflowRun { - id: 100, - name: "Test Suite".to_string(), - status: "completed".to_string(), - conclusion: Some("success".to_string()), - branch: "main".to_string(), - html_url: "https://github.com/o/r/actions/runs/100".to_string(), - created_at: "2024-01-15T10:00:00Z".to_string(), - updated_at: "2024-01-15T10:05:00Z".to_string(), - run_number: 42, - }; - let json = serde_json::to_string(&run).unwrap(); - assert!(json.contains("Test Suite")); - assert!(json.contains("100")); - assert!(json.contains("main")); - } - - #[test] - fn workflow_run_deserializes() { - let json = r#"{ - "id": 200, - "name": "Lint", - "status": "in_progress", - "conclusion": null, - "branch": "feature", - "html_url": "https://github.com/o/r/actions/runs/200", - "created_at": "2024-01-15T10:00:00Z", - "updated_at": "2024-01-15T10:05:00Z", - "run_number": 7 - }"#; - let run: WorkflowRun = serde_json::from_str(json).unwrap(); - assert_eq!(run.id, 200); - assert_eq!(run.name, "Lint"); - assert!(run.conclusion.is_none()); - } - - #[test] - fn workflow_run_clone() { - let run = WorkflowRun { - id: 1, - name: "CI".to_string(), - status: "completed".to_string(), - conclusion: Some("failure".to_string()), - branch: "dev".to_string(), - html_url: "u".to_string(), - created_at: "c".to_string(), - updated_at: "u".to_string(), - run_number: 1, - }; - let cloned = run.clone(); - assert_eq!(cloned.id, run.id); - assert_eq!(cloned.conclusion, run.conclusion); - } - - #[test] - fn runs_query_debug() { - let q = RunsQuery { - owner: "o", - repo: "r", - branch: Some("main"), - status: None, - limit: 20, - }; - let d = format!("{:?}", q); - assert!(d.contains("RunsQuery")); - } - - #[test] - fn runs_query_clone() { - let q = RunsQuery { - owner: "o", - repo: "r", - branch: None, - status: Some("completed"), - limit: 10, - }; - let c = q.clone(); - assert_eq!(c.owner, q.owner); - assert_eq!(c.limit, q.limit); - } - - #[test] - fn runs_query_default() { - let q = RunsQuery::default(); - assert_eq!(q.owner, ""); - assert_eq!(q.repo, ""); - assert!(q.branch.is_none()); - assert!(q.status.is_none()); - assert_eq!(q.limit, 0); - } - - #[test] - fn workflow_run_debug() { - let run = WorkflowRun { - id: 1, - name: "N".to_string(), - status: "s".to_string(), - conclusion: None, - branch: "b".to_string(), - html_url: "u".to_string(), - created_at: "c".to_string(), - updated_at: "u".to_string(), - run_number: 1, - }; - let d = format!("{:?}", run); - assert!(d.contains("WorkflowRun")); - } - - #[test] - fn fix_report_no_pr() { - let r = FixReport { - repository: "o/r".to_string(), - pr_number: None, - run_id: 1, - failures: vec![], - test_files: vec![], - source_files: vec![], - }; - let json = serde_json::to_string(&r).unwrap(); - assert!(json.contains("null")); - } -} diff --git a/src/jira/adf.rs b/src/jira/adf.rs deleted file mode 100644 index a355693..0000000 --- a/src/jira/adf.rs +++ /dev/null @@ -1,679 +0,0 @@ -//! Atlassian Document Format (ADF) helpers. -//! -//! Two pure-functional entry points used by the rest of the Jira module: -//! -//! - [`markdown_to_adf`] converts a Markdown string into an ADF v1 -//! `{type:"doc", version:1, content:[...]}` value. Used when sending -//! descriptions or comments to Jira. -//! - [`adf_to_plain_text`] flattens an ADF tree into a plain-text string. -//! Used to render Jira-side rich content (descriptions, comments) in -//! the terminal. -//! -//! ADF schema reference: -//! -//! Tables, panels, mentions, and emoji are deliberately not supported in -//! the writer — they require Jira-side context not available from a CLI. -//! The reader will pass through their text content verbatim. -//! -//! [`markdown_to_adf`]: fn.markdown_to_adf.html - -use pulldown_cmark::{CodeBlockKind, Event, HeadingLevel, Options, Parser, Tag, TagEnd}; -use serde_json::{json, Value}; - -/// ADF schema version we emit. Atlassian has only ever shipped v1 in -/// public APIs as of 2026-04 — pinned for predictability. -const ADF_VERSION: u8 = 1; - -/// Convert a Markdown string into an ADF v1 document. -/// -/// Supported Markdown constructs: -/// - Headings 1–6 → `heading` -/// - Paragraphs → `paragraph` -/// - Bullet/ordered lists with nesting → `bulletList` / `orderedList` -/// - Code blocks (fenced or indented) → `codeBlock` (preserves language) -/// - Inline code → `text` with `code` mark -/// - Bold/italic/strikethrough → `text` with `strong`/`em`/`strike` marks -/// - Links → `text` with `link` mark -/// - Block quotes → `blockquote` -/// - Horizontal rules → `rule` -/// - Hard breaks → `hardBreak` -/// -/// Tables are not currently emitted (CLAUDE.md scope cut for v0.2.0). -pub fn markdown_to_adf(md: &str) -> Value { - let parser = Parser::new_ext(md, Options::ENABLE_STRIKETHROUGH); - let mut builder = Builder::default(); - for event in parser { - builder.handle(event); - } - json!({ - "type": "doc", - "version": ADF_VERSION, - "content": builder.finish(), - }) -} - -/// Render an ADF tree (whole document or any sub-node) as plain text. -/// -/// Concatenates every `text` node it finds during a depth-first walk. -/// Block-level separation is preserved as newlines between top-level -/// paragraphs, headings, and list items. -pub fn adf_to_plain_text(node: &Value) -> String { - if let Some(content) = node["content"].as_array() { - let parts: Vec = content.iter().map(render_block).collect(); - return parts - .into_iter() - .filter(|s| !s.is_empty()) - .collect::>() - .join("\n"); - } - render_block(node) -} - -/// Recursive plain-text renderer for a single ADF node. -fn render_block(node: &Value) -> String { - if let Some(text) = node["text"].as_str() { - return text.to_string(); - } - let Some(content) = node["content"].as_array() else { - return String::new(); - }; - let separator = match node["type"].as_str().unwrap_or("") { - "doc" | "bulletList" | "orderedList" => "\n", - _ => "", - }; - content - .iter() - .map(render_block) - .collect::>() - .join(separator) -} - -// --------------------------------------------------------------------------- -// Markdown -> ADF builder -// --------------------------------------------------------------------------- - -/// Inline mark types supported by the writer. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Mark { - Strong, - Em, - Code, - Strike, - Link, -} - -impl Mark { - fn as_str(self) -> &'static str { - match self { - Self::Strong => "strong", - Self::Em => "em", - Self::Code => "code", - Self::Strike => "strike", - Self::Link => "link", - } - } -} - -/// Block context being filled. Each variant owns its in-progress ADF children. -#[derive(Debug)] -enum BlockCtx { - Heading { - level: u8, - content: Vec, - }, - Paragraph { - content: Vec, - }, - Blockquote { - content: Vec, - }, - BulletList { - items: Vec, - }, - OrderedList { - start: u32, - items: Vec, - }, - ListItem { - content: Vec, - }, - CodeBlock { - language: Option, - text: String, - }, -} - -#[derive(Default)] -struct Builder { - /// Stack of currently-open block contexts. The top of the stack is - /// where new inline content lands. - stack: Vec, - /// Inline marks active for the next emitted text node, in push order. - marks: Vec, - /// Active link href (set when a `Link` mark is on top of `marks`). - link_href: Option, - /// Top-level document content emitted so far. - blocks: Vec, -} - -impl Builder { - fn handle(&mut self, event: Event<'_>) { - match event { - Event::Start(tag) => self.start_tag(tag), - Event::End(tag) => self.end_tag(tag), - Event::Text(s) => self.push_text(&s), - Event::Code(s) => { - self.marks.push(Mark::Code); - self.push_text(&s); - self.marks.pop(); - } - Event::SoftBreak => self.push_text(" "), - Event::HardBreak => self.push_inline(json!({"type": "hardBreak"})), - Event::Rule => self.push_block(json!({"type": "rule"})), - // HTML, footnotes, math, tasklist markers — not represented in - // ADF v1 from the CLI side. Emit text fallback for HTML so - // content isn't silently dropped. - Event::Html(s) | Event::InlineHtml(s) => self.push_text(&s), - Event::FootnoteReference(_) - | Event::TaskListMarker(_) - | Event::InlineMath(_) - | Event::DisplayMath(_) => {} - } - } - - fn start_tag(&mut self, tag: Tag<'_>) { - match tag { - Tag::Paragraph => self.stack.push(BlockCtx::Paragraph { - content: Vec::new(), - }), - Tag::Heading { level, .. } => self.stack.push(BlockCtx::Heading { - level: heading_level(level), - content: Vec::new(), - }), - Tag::BlockQuote(_) => self.stack.push(BlockCtx::Blockquote { - content: Vec::new(), - }), - Tag::CodeBlock(kind) => self.stack.push(BlockCtx::CodeBlock { - language: code_language(&kind), - text: String::new(), - }), - Tag::List(None) => self.stack.push(BlockCtx::BulletList { items: Vec::new() }), - Tag::List(Some(start)) => self.stack.push(BlockCtx::OrderedList { - start: u32::try_from(start).unwrap_or(1), - items: Vec::new(), - }), - Tag::Item => self.stack.push(BlockCtx::ListItem { - content: Vec::new(), - }), - Tag::Strong => self.marks.push(Mark::Strong), - Tag::Emphasis => self.marks.push(Mark::Em), - Tag::Strikethrough => self.marks.push(Mark::Strike), - Tag::Link { dest_url, .. } => { - self.marks.push(Mark::Link); - self.link_href = Some(dest_url.into_string()); - } - // Images, tables, footnote defs, MetadataBlock, etc.: dropped - // (no ADF mapping in our supported subset). - _ => {} - } - } - - fn end_tag(&mut self, tag: TagEnd) { - match tag { - TagEnd::Paragraph - | TagEnd::Heading(_) - | TagEnd::BlockQuote(_) - | TagEnd::CodeBlock - | TagEnd::List(_) - | TagEnd::Item => { - if let Some(block) = self.stack.pop() { - let value = block_to_value(block); - self.commit_block(value); - } - } - TagEnd::Strong => { - pop_mark(&mut self.marks, Mark::Strong); - } - TagEnd::Emphasis => { - pop_mark(&mut self.marks, Mark::Em); - } - TagEnd::Strikethrough => { - pop_mark(&mut self.marks, Mark::Strike); - } - TagEnd::Link => { - pop_mark(&mut self.marks, Mark::Link); - self.link_href = None; - } - _ => {} - } - } - - /// Append text to the current context, applying active marks. - fn push_text(&mut self, text: &str) { - if text.is_empty() { - return; - } - // CodeBlock collects raw text directly. - if let Some(BlockCtx::CodeBlock { text: buf, .. }) = self.stack.last_mut() { - buf.push_str(text); - return; - } - let node = build_text_node(text, &self.marks, self.link_href.as_deref()); - self.push_inline(node); - } - - /// Append an inline node (text or hardBreak) to the current context. - fn push_inline(&mut self, node: Value) { - match self.stack.last_mut() { - Some(BlockCtx::Paragraph { content }) - | Some(BlockCtx::Heading { content, .. }) - | Some(BlockCtx::ListItem { content }) - | Some(BlockCtx::Blockquote { content }) => content.push(node), - // Inline content with no surrounding block — wrap in a - // synthetic paragraph at the document level. - None => self.blocks.push(json!({ - "type": "paragraph", - "content": [node], - })), - _ => {} - } - } - - /// Commit a finished block to the nearest enclosing container. - fn commit_block(&mut self, value: Value) { - match self.stack.last_mut() { - Some(BlockCtx::Blockquote { content }) | Some(BlockCtx::ListItem { content }) => { - content.push(value); - } - Some(BlockCtx::BulletList { items }) | Some(BlockCtx::OrderedList { items, .. }) => { - items.push(value); - } - _ => self.blocks.push(value), - } - } - - /// Push a leaf block (e.g., `rule`) at the current position. - fn push_block(&mut self, value: Value) { - self.commit_block(value); - } - - fn finish(self) -> Vec { - self.blocks - } -} - -fn heading_level(level: HeadingLevel) -> u8 { - match level { - HeadingLevel::H1 => 1, - HeadingLevel::H2 => 2, - HeadingLevel::H3 => 3, - HeadingLevel::H4 => 4, - HeadingLevel::H5 => 5, - HeadingLevel::H6 => 6, - } -} - -fn code_language(kind: &CodeBlockKind<'_>) -> Option { - match kind { - CodeBlockKind::Fenced(s) if !s.is_empty() => Some(s.to_string()), - _ => None, - } -} - -/// Remove the topmost matching mark. Avoids stack desync on malformed input. -fn pop_mark(marks: &mut Vec, mark: Mark) { - if let Some(pos) = marks.iter().rposition(|m| *m == mark) { - marks.remove(pos); - } -} - -/// Build an ADF `text` node, applying all active marks. Strips trailing -/// newlines that pulldown-cmark keeps on code-block text. -fn build_text_node(text: &str, marks: &[Mark], link_href: Option<&str>) -> Value { - let mut node = json!({"type": "text", "text": text}); - if marks.is_empty() { - return node; - } - let mark_values: Vec = marks - .iter() - .map(|m| match m { - Mark::Link => json!({ - "type": "link", - "attrs": { "href": link_href.unwrap_or("") }, - }), - other => json!({ "type": other.as_str() }), - }) - .collect(); - node["marks"] = Value::Array(mark_values); - node -} - -fn block_to_value(block: BlockCtx) -> Value { - match block { - BlockCtx::Paragraph { content } => json!({ - "type": "paragraph", - "content": content, - }), - BlockCtx::Heading { level, content } => json!({ - "type": "heading", - "attrs": { "level": level }, - "content": content, - }), - BlockCtx::Blockquote { content } => json!({ - "type": "blockquote", - "content": content, - }), - BlockCtx::BulletList { items } => json!({ - "type": "bulletList", - "content": items, - }), - BlockCtx::OrderedList { start, items } => json!({ - "type": "orderedList", - "attrs": { "order": start }, - "content": items, - }), - BlockCtx::ListItem { content } => { - // ADF requires listItem children to be block-level. Wrap loose - // inline content (the common Markdown case `- text`) in a - // paragraph. - let normalized = wrap_loose_inlines(content); - json!({ - "type": "listItem", - "content": normalized, - }) - } - BlockCtx::CodeBlock { language, text } => { - let stripped = text.trim_end_matches('\n').to_string(); - let mut node = json!({ - "type": "codeBlock", - "content": [{ "type": "text", "text": stripped }], - }); - if let Some(lang) = language { - node["attrs"] = json!({ "language": lang }); - } - node - } - } -} - -/// Wrap any inline-level nodes in `content` into a synthetic paragraph, -/// so the result satisfies ADF block-content rules. Block-level entries -/// (paragraph, list, etc.) are passed through unchanged. -fn wrap_loose_inlines(content: Vec) -> Vec { - let mut output = Vec::new(); - let mut buffer: Vec = Vec::new(); - for node in content { - if is_block(&node) { - if !buffer.is_empty() { - output.push(json!({ - "type": "paragraph", - "content": std::mem::take(&mut buffer), - })); - } - output.push(node); - } else { - buffer.push(node); - } - } - if !buffer.is_empty() { - output.push(json!({"type": "paragraph", "content": buffer})); - } - output -} - -fn is_block(node: &Value) -> bool { - matches!( - node["type"].as_str(), - Some( - "paragraph" - | "heading" - | "blockquote" - | "bulletList" - | "orderedList" - | "codeBlock" - | "rule" - ) - ) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn doc(blocks: Vec) -> Value { - json!({"type": "doc", "version": 1, "content": blocks}) - } - - #[test] - fn empty_input_produces_empty_doc() { - assert_eq!(markdown_to_adf(""), doc(vec![])); - } - - #[test] - fn plain_paragraph() { - let adf = markdown_to_adf("Hello world"); - assert_eq!( - adf, - doc(vec![json!({ - "type": "paragraph", - "content": [{"type": "text", "text": "Hello world"}], - })]) - ); - } - - #[test] - fn heading_levels() { - for (md, level) in [ - ("# h1", 1), - ("## h2", 2), - ("### h3", 3), - ("#### h4", 4), - ("##### h5", 5), - ("###### h6", 6), - ] { - let adf = markdown_to_adf(md); - assert_eq!(adf["content"][0]["type"], "heading"); - assert_eq!(adf["content"][0]["attrs"]["level"], level); - } - } - - #[test] - fn bold_italic_strike_inline() { - let adf = markdown_to_adf("**bold** *em* ~~gone~~"); - let para = &adf["content"][0]["content"]; - assert_eq!(para[0]["text"], "bold"); - assert_eq!(para[0]["marks"][0]["type"], "strong"); - assert_eq!(para[2]["marks"][0]["type"], "em"); - assert_eq!(para[4]["marks"][0]["type"], "strike"); - } - - #[test] - fn nested_marks_stack_on_text_node() { - let adf = markdown_to_adf("**bold *and* em**"); - // Output sequence: "bold " (strong), "and" (strong+em), " em" (strong) - let para = &adf["content"][0]["content"]; - let strong_marks: Vec<&str> = para[0]["marks"] - .as_array() - .unwrap() - .iter() - .map(|m| m["type"].as_str().unwrap()) - .collect(); - assert!(strong_marks.contains(&"strong")); - - let mid_marks: Vec<&str> = para[1]["marks"] - .as_array() - .unwrap() - .iter() - .map(|m| m["type"].as_str().unwrap()) - .collect(); - assert!(mid_marks.contains(&"strong")); - assert!(mid_marks.contains(&"em")); - } - - #[test] - fn inline_code_gets_code_mark() { - let adf = markdown_to_adf("use `cargo build`"); - let para = &adf["content"][0]["content"]; - assert_eq!(para[1]["text"], "cargo build"); - assert_eq!(para[1]["marks"][0]["type"], "code"); - } - - #[test] - fn link_emits_link_mark_with_href() { - let adf = markdown_to_adf("[home](https://example.com)"); - let text = &adf["content"][0]["content"][0]; - assert_eq!(text["text"], "home"); - assert_eq!(text["marks"][0]["type"], "link"); - assert_eq!(text["marks"][0]["attrs"]["href"], "https://example.com"); - } - - #[test] - fn bullet_list_with_paragraph_items() { - let adf = markdown_to_adf("- one\n- two"); - let list = &adf["content"][0]; - assert_eq!(list["type"], "bulletList"); - assert_eq!(list["content"][0]["type"], "listItem"); - // listItem -> paragraph -> text - assert_eq!( - list["content"][0]["content"][0]["content"][0]["text"], - "one" - ); - assert_eq!( - list["content"][1]["content"][0]["content"][0]["text"], - "two" - ); - } - - #[test] - fn ordered_list_preserves_start() { - let adf = markdown_to_adf("3. first\n4. second"); - let list = &adf["content"][0]; - assert_eq!(list["type"], "orderedList"); - assert_eq!(list["attrs"]["order"], 3); - assert_eq!(list["content"].as_array().unwrap().len(), 2); - } - - #[test] - fn nested_list_produces_nested_lists() { - let adf = markdown_to_adf("- a\n - nested\n- b"); - let outer = &adf["content"][0]; - assert_eq!(outer["type"], "bulletList"); - let first_item = &outer["content"][0]; - // First item should contain a paragraph plus a nested bulletList. - let item_content = first_item["content"].as_array().unwrap(); - assert!(item_content.iter().any(|n| n["type"] == "paragraph")); - assert!(item_content.iter().any(|n| n["type"] == "bulletList")); - } - - #[test] - fn code_block_with_language() { - let adf = markdown_to_adf("```rust\nfn main() {}\n```"); - let cb = &adf["content"][0]; - assert_eq!(cb["type"], "codeBlock"); - assert_eq!(cb["attrs"]["language"], "rust"); - assert_eq!(cb["content"][0]["text"], "fn main() {}"); - } - - #[test] - fn code_block_without_language() { - let adf = markdown_to_adf(" indented\n code"); - let cb = &adf["content"][0]; - assert_eq!(cb["type"], "codeBlock"); - assert!(cb["attrs"].is_null() || cb["attrs"]["language"].is_null()); - } - - #[test] - fn blockquote_wraps_paragraph() { - let adf = markdown_to_adf("> quoted"); - let bq = &adf["content"][0]; - assert_eq!(bq["type"], "blockquote"); - assert_eq!(bq["content"][0]["type"], "paragraph"); - assert_eq!(bq["content"][0]["content"][0]["text"], "quoted"); - } - - #[test] - fn horizontal_rule() { - let adf = markdown_to_adf("---"); - assert_eq!(adf["content"][0]["type"], "rule"); - } - - #[test] - fn hard_break_emits_hardbreak_node() { - let adf = markdown_to_adf("line1 \nline2"); - let para = &adf["content"][0]["content"]; - assert!(para - .as_array() - .unwrap() - .iter() - .any(|n| n["type"] == "hardBreak")); - } - - #[test] - fn soft_break_becomes_space() { - let adf = markdown_to_adf("line1\nline2"); - // Soft break inside a single paragraph collapses to one space. - let para = &adf["content"][0]["content"]; - let joined: String = para - .as_array() - .unwrap() - .iter() - .filter_map(|n| n["text"].as_str()) - .collect(); - assert!(joined.contains("line1 line2")); - } - - #[test] - fn adf_to_plain_text_renders_paragraph() { - let node = json!({ - "type": "doc", - "content": [{ - "type": "paragraph", - "content": [ - {"type": "text", "text": "Hello "}, - {"type": "text", "text": "world"}, - ], - }], - }); - assert_eq!(adf_to_plain_text(&node), "Hello world"); - } - - #[test] - fn adf_to_plain_text_joins_blocks_with_newline() { - let node = json!({ - "type": "doc", - "content": [ - {"type": "paragraph", "content": [{"type": "text", "text": "one"}]}, - {"type": "paragraph", "content": [{"type": "text", "text": "two"}]}, - ], - }); - assert_eq!(adf_to_plain_text(&node), "one\ntwo"); - } - - #[test] - fn adf_to_plain_text_handles_text_node_directly() { - let node = json!({"type": "text", "text": "naked"}); - assert_eq!(adf_to_plain_text(&node), "naked"); - } - - #[test] - fn adf_to_plain_text_returns_empty_for_unknown_shape() { - assert_eq!(adf_to_plain_text(&json!({"type": "unknown"})), ""); - assert_eq!(adf_to_plain_text(&Value::Null), ""); - } - - #[test] - fn roundtrip_through_plain_text_preserves_visible_content() { - let adf = markdown_to_adf("# Heading\n\nA paragraph with **bold**."); - let text = adf_to_plain_text(&adf); - assert!(text.contains("Heading")); - assert!(text.contains("A paragraph with bold.")); - } - - #[test] - fn html_passes_through_as_text() { - let adf = markdown_to_adf("tag"); - // No silent drop; raw HTML appears as text content. - let text = adf_to_plain_text(&adf); - assert!(text.contains("custom")); - } -} diff --git a/src/jira/auth/callback.rs b/src/jira/auth/callback.rs deleted file mode 100644 index 36e7d5e..0000000 --- a/src/jira/auth/callback.rs +++ /dev/null @@ -1,88 +0,0 @@ -use axum::{ - extract::{Query, State}, - response::Html, - routing::get, - Router, -}; -use std::collections::HashMap; -use std::net::SocketAddr; -use std::sync::Arc; -use tokio::sync::Mutex; - -use anyhow::{Context, Result}; - -/// OAuth callback state -#[derive(Debug, Clone)] -pub(super) struct CallbackState { - pub(super) expected_state: String, - pub(super) code: Option, - pub(super) error: Option, -} - -/// Start the local callback server -pub(super) async fn start_callback_server(state: Arc>) -> Result<()> { - let app = Router::new() - .route("/callback", get(handle_callback)) - .with_state(state.clone()); - - let addr = SocketAddr::from(([127, 0, 0, 1], super::CALLBACK_PORT)); - let listener = tokio::net::TcpListener::bind(addr) - .await - .context("Failed to bind callback server")?; - - axum::serve(listener, app) - .with_graceful_shutdown(async move { - // Wait until we have a code or error - loop { - tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; - let state_lock = state.lock().await; - if state_lock.code.is_some() || state_lock.error.is_some() { - break; - } - } - // Give a moment for the response to be sent - tokio::time::sleep(tokio::time::Duration::from_millis(500)).await; - }) - .await - .context("Callback server failed")?; - - Ok(()) -} - -/// Handle the OAuth callback -async fn handle_callback( - State(state): State>>, - Query(params): Query>, -) -> Html<&'static str> { - let mut state_lock = state.lock().await; - - // Check for error - if let Some(error) = params.get("error") { - state_lock.error = Some(error.clone()); - return Html( - "

Authorization Failed

You can close this window.

", - ); - } - - // Verify state parameter - if let Some(received_state) = params.get("state") { - if received_state != &state_lock.expected_state { - state_lock.error = Some("State mismatch - possible CSRF attack".to_string()); - return Html( - "

Error

State verification failed.

", - ); - } - } else { - state_lock.error = Some("Missing state parameter".to_string()); - return Html("

Error

Missing state parameter.

"); - } - - // Get authorization code - if let Some(code) = params.get("code") { - state_lock.code = Some(code.clone()); - Html("

Success!

You can close this window and return to the terminal.

") - } else { - state_lock.error = Some("Missing authorization code".to_string()); - Html("

Error

Missing authorization code.

") - } -} diff --git a/src/jira/auth/mod.rs b/src/jira/auth/mod.rs deleted file mode 100644 index 3408e3f..0000000 --- a/src/jira/auth/mod.rs +++ /dev/null @@ -1,343 +0,0 @@ -use anyhow::{bail, Context, Result}; -use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine}; -use rand::Rng; -use std::sync::Arc; -use tokio::sync::Mutex; - -use crate::util::{load_credentials, save_credentials, JiraCredentials}; - -use super::types::OAuthConfig; - -mod callback; - -use callback::{start_callback_server, CallbackState}; - -#[cfg(test)] -mod tests; - -const AUTH_URL: &str = "https://auth.atlassian.com/authorize"; -const TOKEN_URL: &str = "https://auth.atlassian.com/oauth/token"; -const RESOURCES_URL: &str = "https://api.atlassian.com/oauth/token/accessible-resources"; -const CALLBACK_PORT: u16 = 9876; -const SCOPES: &str = "read:jira-work write:jira-work read:jira-user offline_access"; - -/// Load OAuth config from environment or config file -pub fn load_oauth_config() -> Result { - // Try environment variables first - if let (Ok(client_id), Ok(client_secret)) = ( - std::env::var("JIRA_CLIENT_ID"), - std::env::var("JIRA_CLIENT_SECRET"), - ) { - return Ok(OAuthConfig { - client_id, - client_secret, - }); - } - - // Try config file - let config_path = crate::util::config_dir()?.join("jira-oauth.toml"); - if config_path.exists() { - let contents = std::fs::read_to_string(&config_path) - .with_context(|| format!("Failed to read {}", config_path.display()))?; - let config: OAuthConfig = toml::from_str(&contents) - .with_context(|| format!("Failed to parse {}", config_path.display()))?; - return Ok(config); - } - - bail!( - "Jira OAuth not configured. Set JIRA_CLIENT_ID and JIRA_CLIENT_SECRET environment variables, \ - or create {} with client_id and client_secret fields.", - crate::util::config_dir()?.join("jira-oauth.toml").display() - ) -} - -/// Generate a random state string for CSRF protection -pub fn generate_state() -> String { - let mut rng = rand::thread_rng(); - let bytes: [u8; 32] = rng.gen(); - URL_SAFE_NO_PAD.encode(bytes) -} - -/// Build the authorization URL -pub fn build_auth_url(client_id: &str, state: &str) -> String { - let redirect_uri = format!("http://localhost:{}/callback", CALLBACK_PORT); - format!( - "{}?audience=api.atlassian.com&client_id={}&scope={}&redirect_uri={}&state={}&response_type=code&prompt=consent", - AUTH_URL, - client_id, - urlencoded(SCOPES), - urlencoded(&redirect_uri), - state - ) -} - -/// URL encode a string -fn urlencoded(s: &str) -> String { - s.chars() - .map(|c| match c { - ' ' => "%20".to_string(), - ':' => "%3A".to_string(), - '/' => "%2F".to_string(), - _ => c.to_string(), - }) - .collect() -} - -/// Start OAuth flow and return user display name -pub async fn login() -> Result { - let config = load_oauth_config()?; - let state = generate_state(); - - // Start local server to receive callback - let callback_state = Arc::new(Mutex::new(CallbackState { - expected_state: state.clone(), - code: None, - error: None, - })); - - let server_state = callback_state.clone(); - let server = tokio::spawn(async move { start_callback_server(server_state).await }); - - // Open browser for authorization - let auth_url = build_auth_url(&config.client_id, &state); - open::that(&auth_url).context("Failed to open browser")?; - - // Wait for callback - server.await??; - - // Get the authorization code - let state_lock = callback_state.lock().await; - if let Some(error) = &state_lock.error { - bail!("Authorization failed: {}", error); - } - let code = state_lock - .code - .clone() - .context("No authorization code received")?; - drop(state_lock); - - // Exchange code for tokens - let tokens = exchange_code(&config, &code).await?; - - // Get accessible resources to find cloud ID - let resources = get_accessible_resources(&tokens.access_token).await?; - let resource = resources - .first() - .context("No accessible Jira sites found")?; - - // Get user info - let user = get_current_user(&tokens.access_token, &resource.id).await?; - - // Save credentials - let creds = JiraCredentials { - access_token: tokens.access_token, - refresh_token: tokens.refresh_token, - expires_at: tokens.expires_at, - cloud_id: resource.id.clone(), - site_url: resource.url.clone(), - }; - save_jira_credentials(creds)?; - - Ok(user) -} - -/// Token response from Atlassian -#[derive(Debug)] -struct TokenResponse { - access_token: String, - refresh_token: String, - expires_at: i64, -} - -/// Exchange authorization code for tokens -async fn exchange_code(config: &OAuthConfig, code: &str) -> Result { - let client = reqwest::Client::new(); - let redirect_uri = format!("http://localhost:{}/callback", CALLBACK_PORT); - - let response = client - .post(TOKEN_URL) - .json(&serde_json::json!({ - "grant_type": "authorization_code", - "client_id": config.client_id, - "client_secret": config.client_secret, - "code": code, - "redirect_uri": redirect_uri - })) - .send() - .await - .context("Failed to exchange code for tokens")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Token exchange failed: {}", error_text); - } - - let json: serde_json::Value = response - .json() - .await - .context("Failed to parse token response")?; - - let access_token = json["access_token"] - .as_str() - .context("Missing access_token")? - .to_string(); - let refresh_token = json["refresh_token"] - .as_str() - .context("Missing refresh_token")? - .to_string(); - let expires_in = json["expires_in"].as_i64().unwrap_or(3600); - let expires_at = chrono::Utc::now().timestamp() + expires_in; - - Ok(TokenResponse { - access_token, - refresh_token, - expires_at, - }) -} - -/// Get accessible Jira cloud resources -async fn get_accessible_resources( - access_token: &str, -) -> Result> { - let client = reqwest::Client::new(); - - let response = client - .get(RESOURCES_URL) - .bearer_auth(access_token) - .send() - .await - .context("Failed to get accessible resources")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to get accessible resources: {}", error_text); - } - - let json: serde_json::Value = response.json().await?; - let resources: Vec = json - .as_array() - .context("Expected array of resources")? - .iter() - .filter_map(|r| { - Some(super::types::AccessibleResource { - id: r["id"].as_str()?.to_string(), - url: r["url"].as_str()?.to_string(), - name: r["name"].as_str()?.to_string(), - }) - }) - .collect(); - - Ok(resources) -} - -/// Get current user display name -async fn get_current_user(access_token: &str, cloud_id: &str) -> Result { - let client = reqwest::Client::new(); - let url = format!( - "https://api.atlassian.com/ex/jira/{}/rest/api/3/myself", - cloud_id - ); - - let response = client - .get(&url) - .bearer_auth(access_token) - .send() - .await - .context("Failed to get current user")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to get current user: {}", error_text); - } - - let json: serde_json::Value = response.json().await?; - let display_name = json["displayName"] - .as_str() - .context("Missing displayName")? - .to_string(); - - Ok(display_name) -} - -/// Refresh access token if expired or about to expire -pub async fn refresh_token_if_needed() -> Result { - let creds = get_credentials().context("Not authenticated. Run `hu jira auth` first.")?; - - // Check if token expires in the next 5 minutes - let now = chrono::Utc::now().timestamp(); - if creds.expires_at > now + 300 { - return Ok(creds.access_token); - } - - // Need to refresh - let config = load_oauth_config()?; - let tokens = refresh_token(&config, &creds.refresh_token).await?; - - // Save updated credentials - let new_creds = JiraCredentials { - access_token: tokens.access_token.clone(), - refresh_token: tokens.refresh_token, - expires_at: tokens.expires_at, - cloud_id: creds.cloud_id, - site_url: creds.site_url, - }; - save_jira_credentials(new_creds)?; - - Ok(tokens.access_token) -} - -/// Refresh access token -async fn refresh_token(config: &OAuthConfig, refresh_token: &str) -> Result { - let client = reqwest::Client::new(); - - let response = client - .post(TOKEN_URL) - .json(&serde_json::json!({ - "grant_type": "refresh_token", - "client_id": config.client_id, - "client_secret": config.client_secret, - "refresh_token": refresh_token - })) - .send() - .await - .context("Failed to refresh token")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Token refresh failed: {}", error_text); - } - - let json: serde_json::Value = response - .json() - .await - .context("Failed to parse token response")?; - - let access_token = json["access_token"] - .as_str() - .context("Missing access_token")? - .to_string(); - let new_refresh_token = json["refresh_token"] - .as_str() - .unwrap_or(refresh_token) - .to_string(); - let expires_in = json["expires_in"].as_i64().unwrap_or(3600); - let expires_at = chrono::Utc::now().timestamp() + expires_in; - - Ok(TokenResponse { - access_token, - refresh_token: new_refresh_token, - expires_at, - }) -} - -/// Get stored Jira credentials -pub fn get_credentials() -> Option { - load_credentials().ok().and_then(|c| c.jira) -} - -/// Save Jira credentials -fn save_jira_credentials(jira: JiraCredentials) -> Result<()> { - let mut creds = load_credentials().unwrap_or_default(); - creds.jira = Some(jira); - save_credentials(&creds) -} diff --git a/src/jira/auth/tests.rs b/src/jira/auth/tests.rs deleted file mode 100644 index 16979cb..0000000 --- a/src/jira/auth/tests.rs +++ /dev/null @@ -1,265 +0,0 @@ -use super::*; -use serde_json::json; - -#[test] -fn generate_state_returns_nonempty_string() { - let state = generate_state(); - assert!(!state.is_empty()); -} - -#[test] -fn generate_state_returns_unique_values() { - let state1 = generate_state(); - let state2 = generate_state(); - assert_ne!(state1, state2); -} - -#[test] -fn generate_state_is_url_safe() { - let state = generate_state(); - // URL-safe base64 only uses alphanumeric, dash, underscore - assert!(state - .chars() - .all(|c| c.is_alphanumeric() || c == '-' || c == '_')); -} - -#[test] -fn build_auth_url_contains_required_params() { - let url = build_auth_url("test_client_id", "test_state"); - assert!(url.contains("client_id=test_client_id")); - assert!(url.contains("state=test_state")); - assert!(url.contains("response_type=code")); - assert!(url.contains("audience=api.atlassian.com")); - assert!(url.contains("prompt=consent")); -} - -#[test] -fn build_auth_url_contains_scopes() { - let url = build_auth_url("id", "state"); - assert!(url.contains("read%3Ajira-work")); // read:jira-work encoded - assert!(url.contains("write%3Ajira-work")); // write:jira-work encoded - assert!(url.contains("offline_access")); -} - -#[test] -fn build_auth_url_contains_redirect_uri() { - let url = build_auth_url("id", "state"); - assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A9876%2Fcallback")); -} - -#[test] -fn urlencoded_encodes_spaces() { - assert_eq!(urlencoded("hello world"), "hello%20world"); -} - -#[test] -fn urlencoded_encodes_colons() { - assert_eq!(urlencoded("a:b"), "a%3Ab"); -} - -#[test] -fn urlencoded_encodes_slashes() { - assert_eq!(urlencoded("a/b"), "a%2Fb"); -} - -#[test] -fn urlencoded_preserves_alphanumeric() { - assert_eq!(urlencoded("abc123"), "abc123"); -} - -#[test] -fn parse_token_response_extracts_fields() { - let json = json!({ - "access_token": "access123", - "refresh_token": "refresh456", - "expires_in": 7200 - }); - let (access, refresh, expires_in) = parse_token_response(&json).unwrap(); - assert_eq!(access, "access123"); - assert_eq!(refresh, "refresh456"); - assert_eq!(expires_in, 7200); -} - -#[test] -fn parse_token_response_uses_default_expires() { - let json = json!({ - "access_token": "access", - "refresh_token": "refresh" - }); - let (_, _, expires_in) = parse_token_response(&json).unwrap(); - assert_eq!(expires_in, 3600); -} - -#[test] -fn parse_token_response_fails_missing_access_token() { - let json = json!({ - "refresh_token": "refresh" - }); - let result = parse_token_response(&json); - assert!(result.is_err()); -} - -#[test] -fn parse_token_response_fails_missing_refresh_token() { - let json = json!({ - "access_token": "access" - }); - let result = parse_token_response(&json); - assert!(result.is_err()); -} - -#[test] -fn parse_accessible_resources_extracts_resources() { - let json = json!([ - {"id": "cloud1", "url": "https://a.atlassian.net", "name": "Site A"}, - {"id": "cloud2", "url": "https://b.atlassian.net", "name": "Site B"} - ]); - let resources = parse_accessible_resources(&json); - assert_eq!(resources.len(), 2); - assert_eq!(resources[0].id, "cloud1"); - assert_eq!(resources[0].url, "https://a.atlassian.net"); - assert_eq!(resources[0].name, "Site A"); - assert_eq!(resources[1].id, "cloud2"); -} - -#[test] -fn parse_accessible_resources_handles_empty_array() { - let json = json!([]); - let resources = parse_accessible_resources(&json); - assert!(resources.is_empty()); -} - -#[test] -fn parse_accessible_resources_handles_non_array() { - let json = json!({"not": "an array"}); - let resources = parse_accessible_resources(&json); - assert!(resources.is_empty()); -} - -#[test] -fn parse_accessible_resources_skips_incomplete_entries() { - let json = json!([ - {"id": "cloud1", "url": "https://a.atlassian.net", "name": "Site A"}, - {"id": "cloud2"}, // missing url and name - {"url": "https://c.atlassian.net", "name": "Site C"} // missing id - ]); - let resources = parse_accessible_resources(&json); - assert_eq!(resources.len(), 1); - assert_eq!(resources[0].id, "cloud1"); -} - -#[test] -fn parse_user_response_extracts_display_name() { - let json = json!({ - "displayName": "John Doe", - "accountId": "123" - }); - let name = parse_user_response(&json); - assert_eq!(name, Some("John Doe".to_string())); -} - -#[test] -fn parse_user_response_returns_none_for_missing_name() { - let json = json!({ - "accountId": "123" - }); - let name = parse_user_response(&json); - assert!(name.is_none()); -} - -#[test] -fn get_credentials_returns_option() { - let result = get_credentials(); - // Result is either Some(creds) or None - assert!(result.is_some() || result.is_none()); -} - -#[test] -fn callback_state_debug_format() { - let state = CallbackState { - expected_state: "test".to_string(), - code: None, - error: None, - }; - let debug_str = format!("{:?}", state); - assert!(debug_str.contains("CallbackState")); -} - -#[test] -fn callback_state_clone() { - let state = CallbackState { - expected_state: "state123".to_string(), - code: Some("code456".to_string()), - error: None, - }; - let cloned = state.clone(); - assert_eq!(cloned.expected_state, state.expected_state); - assert_eq!(cloned.code, state.code); - assert_eq!(cloned.error, state.error); -} - -#[test] -fn token_response_debug_format() { - let response = TokenResponse { - access_token: "access".to_string(), - refresh_token: "refresh".to_string(), - expires_at: 1234567890, - }; - let debug_str = format!("{:?}", response); - assert!(debug_str.contains("TokenResponse")); -} - -#[test] -fn constants_are_valid() { - assert!(AUTH_URL.starts_with("https://")); - assert!(TOKEN_URL.starts_with("https://")); - assert!(RESOURCES_URL.starts_with("https://")); - // CALLBACK_PORT is a u16 constant; bounds-checking is compile-time. - const _: () = assert!(CALLBACK_PORT > 0); - assert!(!SCOPES.is_empty()); -} - -#[test] -fn scopes_contain_required_permissions() { - assert!(SCOPES.contains("read:jira-work")); - assert!(SCOPES.contains("write:jira-work")); - assert!(SCOPES.contains("read:jira-user")); - assert!(SCOPES.contains("offline_access")); -} - -/// Parse token response JSON (pure function, testable) -fn parse_token_response(json: &serde_json::Value) -> Result<(String, String, i64)> { - let access_token = json["access_token"] - .as_str() - .context("Missing access_token")? - .to_string(); - let refresh_token = json["refresh_token"] - .as_str() - .context("Missing refresh_token")? - .to_string(); - let expires_in = json["expires_in"].as_i64().unwrap_or(3600); - - Ok((access_token, refresh_token, expires_in)) -} - -/// Parse accessible resources JSON (pure function, testable) -fn parse_accessible_resources( - json: &serde_json::Value, -) -> Vec { - json.as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|r| { - Some(super::super::types::AccessibleResource { - id: r["id"].as_str()?.to_string(), - url: r["url"].as_str()?.to_string(), - name: r["name"].as_str()?.to_string(), - }) - }) - .collect() -} - -/// Parse user response JSON (pure function, testable) -fn parse_user_response(json: &serde_json::Value) -> Option { - json["displayName"].as_str().map(|s| s.to_string()) -} diff --git a/src/jira/auth_handler.rs b/src/jira/auth_handler.rs deleted file mode 100644 index fd7a18a..0000000 --- a/src/jira/auth_handler.rs +++ /dev/null @@ -1,25 +0,0 @@ -use anyhow::Result; - -use super::auth; - -/// Run the jira auth command -pub async fn run() -> Result<()> { - println!("Opening browser for Jira authorization..."); - let name = auth::login().await?; - println!("\x1b[32m\u{2713}\x1b[0m Logged in as {}", name); - Ok(()) -} - -#[cfg(test)] -mod tests { - // Auth handler is thin and delegates to auth module - // Integration testing would require mocking the browser and OAuth flow - // Pure function tests are in auth.rs - - #[test] - fn module_compiles() { - // Verify the module structure is correct — the existence of this - // function symbol is the assertion. - let _: fn() = module_compiles; - } -} diff --git a/src/jira/cli.rs b/src/jira/cli.rs deleted file mode 100644 index 43d86b1..0000000 --- a/src/jira/cli.rs +++ /dev/null @@ -1,302 +0,0 @@ -use std::path::PathBuf; - -use clap::Subcommand; - -#[derive(Debug, Subcommand)] -pub enum JiraCommand { - /// Authenticate with Jira via OAuth 2.0 - Auth, - - /// List my tickets in current sprint - Tickets, - - /// Show all issues in current sprint - Sprint, - - /// List sprints (active, future, closed) - Sprints { - /// Filter: active (default), future, closed, all - #[arg(default_value = "active")] - state: String, - }, - - /// Search tickets using JQL - Search { - /// JQL query (e.g., "project = PROJ AND status = 'In Progress'") - query: String, - }, - - /// Show ticket details - Show { - /// Ticket key (e.g., PROJ-123) - key: String, - }, - - /// Create a new ticket - Create { - /// Issue summary / title (required) - #[arg(long, short = 's')] - summary: String, - - /// Issue type — case-insensitive, fuzzy-matched against the - /// project's available types. Defaults to "Task". - #[arg(long, short = 't', default_value = "Task")] - r#type: String, - - /// Project key (e.g. HU). Falls back to $HU_JIRA_PROJECT. - #[arg(long, short = 'p', env = "HU_JIRA_PROJECT")] - project: String, - - /// Description body (Markdown). Mutually exclusive with --body-adf. - #[arg(long, alias = "description")] - body: Option, - - /// Read raw ADF JSON from file as the description body. - #[arg(long = "body-adf", value_name = "PATH", conflicts_with = "body")] - body_adf: Option, - - /// Assign to user (account ID or "me") - #[arg(long, short = 'a')] - assign: Option, - - /// Emit the created issue as JSON - #[arg(long, short = 'j')] - json: bool, - }, - - /// List comments on a ticket - Comments { - /// Ticket key (e.g., PROJ-123) - key: String, - - /// Show full comment bodies (multi-line) instead of a one-row-each table - #[arg(long, short = 'f')] - full: bool, - - /// Emit JSON instead of a table - #[arg(long, short = 'j')] - json: bool, - }, - - /// Update a ticket - Update { - /// Ticket key (e.g., PROJ-123) - key: String, - - /// New summary/title - #[arg(long)] - summary: Option, - - /// New status (transition) - #[arg(long)] - status: Option, - - /// Assign to user (account ID or "me") - #[arg(long)] - assign: Option, - - /// New description/body text. Treated as Markdown — headings, - /// lists, code, links, and emphasis are rendered as rich text. - #[arg(long, alias = "description")] - body: Option, - - /// Read raw ADF JSON from the given file and use it as the - /// description verbatim. Use this when you've prepared a - /// document with mentions, panels, or other structures - /// outside the supported Markdown subset. - #[arg(long = "body-adf", value_name = "PATH", conflicts_with = "body")] - body_adf: Option, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::CommandFactory; - - // Helper to build a command for testing - fn build_cmd() -> clap::Command { - #[derive(clap::Parser)] - struct TestCli { - #[command(subcommand)] - cmd: JiraCommand, - } - TestCli::command() - } - - #[test] - fn parses_auth() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "auth"]); - assert!(matches.is_ok()); - } - - #[test] - fn parses_tickets() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "tickets"]); - assert!(matches.is_ok()); - } - - #[test] - fn parses_sprint() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "sprint"]); - assert!(matches.is_ok()); - } - - #[test] - fn parses_search() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "search", "project = TEST"]); - assert!(matches.is_ok()); - } - - #[test] - fn parses_show() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "show", "PROJ-123"]); - assert!(matches.is_ok()); - } - - #[test] - fn parses_update_with_summary() { - let cmd = build_cmd(); - let matches = - cmd.try_get_matches_from(["test", "update", "PROJ-123", "--summary", "New title"]); - assert!(matches.is_ok()); - } - - #[test] - fn parses_update_with_status() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "update", "PROJ-123", "--status", "Done"]); - assert!(matches.is_ok()); - } - - #[test] - fn parses_update_with_assign() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "update", "PROJ-123", "--assign", "me"]); - assert!(matches.is_ok()); - } - - #[test] - fn parses_update_with_all_options() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from([ - "test", - "update", - "PROJ-123", - "--summary", - "New title", - "--status", - "In Progress", - "--assign", - "user123", - ]); - assert!(matches.is_ok()); - } - - #[test] - fn parses_update_with_body_adf() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from([ - "test", - "update", - "PROJ-123", - "--body-adf", - "/tmp/some.json", - ]); - assert!(matches.is_ok()); - } - - #[test] - fn body_and_body_adf_are_mutually_exclusive() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from([ - "test", - "update", - "PROJ-123", - "--body", - "text", - "--body-adf", - "/tmp/x.json", - ]); - assert!(matches.is_err()); - } - - #[test] - fn update_requires_key() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "update", "--summary", "Title"]); - assert!(matches.is_err()); - } - - #[test] - fn search_requires_query() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "search"]); - assert!(matches.is_err()); - } - - #[test] - fn show_requires_key() { - let cmd = build_cmd(); - let matches = cmd.try_get_matches_from(["test", "show"]); - assert!(matches.is_err()); - } - - #[test] - fn jira_command_debug() { - let cmd = JiraCommand::Auth; - let debug_str = format!("{:?}", cmd); - assert!(debug_str.contains("Auth")); - } - - #[test] - fn tickets_command_debug() { - let cmd = JiraCommand::Tickets; - let debug_str = format!("{:?}", cmd); - assert!(debug_str.contains("Tickets")); - } - - #[test] - fn sprint_command_debug() { - let cmd = JiraCommand::Sprint; - let debug_str = format!("{:?}", cmd); - assert!(debug_str.contains("Sprint")); - } - - #[test] - fn search_command_debug() { - let cmd = JiraCommand::Search { - query: "test".to_string(), - }; - let debug_str = format!("{:?}", cmd); - assert!(debug_str.contains("Search")); - } - - #[test] - fn show_command_debug() { - let cmd = JiraCommand::Show { - key: "X-1".to_string(), - }; - let debug_str = format!("{:?}", cmd); - assert!(debug_str.contains("Show")); - } - - #[test] - fn update_command_debug() { - let cmd = JiraCommand::Update { - key: "X-1".to_string(), - summary: Some("S".to_string()), - status: None, - assign: None, - body: None, - body_adf: None, - }; - let debug_str = format!("{:?}", cmd); - assert!(debug_str.contains("Update")); - } -} diff --git a/src/jira/client/comments.rs b/src/jira/client/comments.rs deleted file mode 100644 index b9f0c16..0000000 --- a/src/jira/client/comments.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Comment-related Jira API operations. -//! -//! Endpoints: `GET /issue/{key}/comment`. - -use anyhow::{bail, Context, Result}; - -use super::JiraClient; -use crate::jira::adf; -use crate::jira::types::{Comment, User}; - -/// List comments on an issue. Returns them in the order Jira sends them -/// (oldest first by default). -pub(super) async fn list_comments(client: &JiraClient, key: &str) -> Result> { - // Bump the page size to 100 — Jira's default is 50 and most issues - // we deal with have fewer than that. Real pagination can come later. - let url = client.api_url(&format!("/issue/{}/comment?maxResults=100", key)); - let response = client - .http - .get(&url) - .bearer_auth(&client.access_token) - .send() - .await - .context("Failed to list comments")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to list comments for {}: {}", key, error_text); - } - - let json: serde_json::Value = response.json().await?; - Ok(parse_comments(&json)) -} - -/// Parse the comment-list response (pure function, testable). -pub fn parse_comments(json: &serde_json::Value) -> Vec { - json["comments"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(parse_single_comment) - .collect() -} - -/// Parse a single comment object. -pub fn parse_single_comment(json: &serde_json::Value) -> Option { - let body_adf = json["body"].clone(); - let body = adf::adf_to_plain_text(&body_adf); - - Some(Comment { - id: json["id"].as_str()?.to_string(), - author: parse_author(&json["author"])?, - body, - body_adf, - created: json["created"].as_str().unwrap_or_default().to_string(), - updated: json["updated"].as_str().unwrap_or_default().to_string(), - }) -} - -/// Parse a comment author. Comments may be authored by users without -/// emails (system accounts), so we don't require it. -fn parse_author(json: &serde_json::Value) -> Option { - Some(User { - account_id: json["accountId"].as_str()?.to_string(), - display_name: json["displayName"] - .as_str() - .unwrap_or("Unknown") - .to_string(), - email_address: json["emailAddress"].as_str().map(|s| s.to_string()), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn parse_comments_extracts_list() { - let json = json!({ - "startAt": 0, - "maxResults": 50, - "total": 2, - "comments": [ - { - "id": "1", - "author": {"accountId": "u1", "displayName": "Alice"}, - "body": { - "type": "doc", - "version": 1, - "content": [{ - "type": "paragraph", - "content": [{"type": "text", "text": "first"}] - }] - }, - "created": "2026-04-30T10:00:00.000Z", - "updated": "2026-04-30T10:00:00.000Z", - }, - { - "id": "2", - "author": {"accountId": "u2", "displayName": "Bob"}, - "body": { - "type": "doc", - "version": 1, - "content": [{ - "type": "paragraph", - "content": [{"type": "text", "text": "second"}] - }] - }, - "created": "2026-04-30T11:00:00.000Z", - "updated": "2026-04-30T11:00:00.000Z", - } - ] - }); - let comments = parse_comments(&json); - assert_eq!(comments.len(), 2); - assert_eq!(comments[0].id, "1"); - assert_eq!(comments[0].author.display_name, "Alice"); - assert_eq!(comments[0].body, "first"); - assert_eq!(comments[1].body, "second"); - } - - #[test] - fn parse_comments_handles_empty_list() { - let json = json!({"comments": []}); - let comments = parse_comments(&json); - assert!(comments.is_empty()); - } - - #[test] - fn parse_comments_handles_missing_field() { - let json = json!({}); - let comments = parse_comments(&json); - assert!(comments.is_empty()); - } - - #[test] - fn parse_single_comment_renders_body_to_text() { - let json = json!({ - "id": "10", - "author": {"accountId": "u", "displayName": "User"}, - "body": { - "type": "doc", - "version": 1, - "content": [ - {"type": "paragraph", "content": [{"type": "text", "text": "line 1"}]}, - {"type": "paragraph", "content": [{"type": "text", "text": "line 2"}]} - ] - }, - "created": "2026-04-30T10:00:00.000Z", - "updated": "2026-04-30T10:00:00.000Z" - }); - let comment = parse_single_comment(&json).unwrap(); - assert_eq!(comment.body, "line 1\nline 2"); - assert_eq!(comment.body_adf["type"], "doc"); - } - - #[test] - fn parse_single_comment_returns_none_without_id() { - let json = json!({ - "author": {"accountId": "u", "displayName": "User"}, - "body": {"type": "doc", "version": 1, "content": []} - }); - assert!(parse_single_comment(&json).is_none()); - } - - #[test] - fn parse_single_comment_returns_none_without_author_id() { - let json = json!({ - "id": "10", - "author": {"displayName": "Anonymous"}, - "body": {"type": "doc", "version": 1, "content": []} - }); - assert!(parse_single_comment(&json).is_none()); - } - - #[test] - fn parse_single_comment_handles_missing_timestamps() { - let json = json!({ - "id": "10", - "author": {"accountId": "u", "displayName": "User"}, - "body": {"type": "doc", "version": 1, "content": []} - }); - let comment = parse_single_comment(&json).unwrap(); - assert_eq!(comment.created, ""); - assert_eq!(comment.updated, ""); - } - - #[test] - fn parse_single_comment_falls_back_for_missing_display_name() { - let json = json!({ - "id": "10", - "author": {"accountId": "system"}, - "body": {"type": "doc", "version": 1, "content": []} - }); - let comment = parse_single_comment(&json).unwrap(); - assert_eq!(comment.author.display_name, "Unknown"); - } -} diff --git a/src/jira/client/create.rs b/src/jira/client/create.rs deleted file mode 100644 index 838a3a9..0000000 --- a/src/jira/client/create.rs +++ /dev/null @@ -1,262 +0,0 @@ -//! Issue-creation Jira API operations. -//! -//! Endpoints: -//! - `POST /issue` — create a new issue -//! - `GET /issue/createmeta/{projectIdOrKey}/issuetypes` — list issue -//! types available on a project. The `?projectKeys=` flavour was -//! deprecated by Atlassian in 2024; we use the new path-style endpoint. - -use anyhow::{bail, Context, Result}; - -use super::JiraClient; -use crate::jira::adf; -use crate::jira::types::{CreatedIssue, IssueCreate, IssueType}; - -/// Build the human-facing browse URL for a freshly created issue. -fn browse_url(client: &JiraClient, key: &str) -> String { - let base = client.site_url.trim_end_matches('/'); - format!("{}/browse/{}", base, key) -} - -/// `POST /issue` with the supplied fields. -pub(super) async fn create_issue(client: &JiraClient, new: &IssueCreate) -> Result { - let url = client.api_url("/issue"); - let body = build_create_body(new); - - let response = client - .http - .post(&url) - .bearer_auth(&client.access_token) - .json(&body) - .send() - .await - .context("Failed to create issue")?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to create issue ({}): {}", status, error_text); - } - - let json: serde_json::Value = response.json().await?; - let key = json["key"] - .as_str() - .context("Create response missing 'key'")? - .to_string(); - let id = json["id"] - .as_str() - .context("Create response missing 'id'")? - .to_string(); - let url = browse_url(client, &key); - - Ok(CreatedIssue { id, key, url }) -} - -/// Build the `POST /issue` request body. Pure function for testability. -pub fn build_create_body(new: &IssueCreate) -> serde_json::Value { - let mut fields = serde_json::Map::new(); - - fields.insert( - "project".to_string(), - serde_json::json!({ "key": new.project_key }), - ); - fields.insert("summary".to_string(), serde_json::json!(new.summary)); - fields.insert( - "issuetype".to_string(), - serde_json::json!({ "name": new.issue_type }), - ); - - // Same Markdown-vs-ADF precedence as IssueUpdate. - if let Some(adf_doc) = &new.description_adf { - fields.insert("description".to_string(), adf_doc.clone()); - } else if let Some(md) = &new.description { - fields.insert("description".to_string(), adf::markdown_to_adf(md)); - } - - if let Some(account_id) = &new.assignee { - fields.insert( - "assignee".to_string(), - serde_json::json!({ "accountId": account_id }), - ); - } - - serde_json::json!({ "fields": fields }) -} - -/// `GET /issue/createmeta/{projectKey}/issuetypes`. Returns at most the -/// first page (50 by default, raised here) — projects don't typically -/// have more than a handful of issue types. -pub(super) async fn get_issue_types( - client: &JiraClient, - project_key: &str, -) -> Result> { - let url = client.api_url(&format!( - "/issue/createmeta/{}/issuetypes?maxResults=100", - project_key - )); - let response = client - .http - .get(&url) - .bearer_auth(&client.access_token) - .send() - .await - .context("Failed to fetch issue-type metadata")?; - - if !response.status().is_success() { - let status = response.status(); - let error_text = response.text().await.unwrap_or_default(); - bail!( - "Failed to fetch issue types for project {} ({}): {}", - project_key, - status, - error_text - ); - } - - let json: serde_json::Value = response.json().await?; - Ok(parse_issue_types(&json)) -} - -/// Parse the `/issuetypes` response (pure function, testable). -pub fn parse_issue_types(json: &serde_json::Value) -> Vec { - json["issueTypes"] - .as_array() - .or_else(|| json["values"].as_array()) - .unwrap_or(&vec![]) - .iter() - .filter_map(parse_single_issue_type) - .collect() -} - -fn parse_single_issue_type(json: &serde_json::Value) -> Option { - Some(IssueType { - id: json["id"].as_str()?.to_string(), - name: json["name"].as_str()?.to_string(), - description: json["description"] - .as_str() - .filter(|s| !s.is_empty()) - .map(|s| s.to_string()), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - - #[test] - fn build_create_body_includes_required_fields() { - let new = IssueCreate { - project_key: "HU".to_string(), - summary: "S".to_string(), - issue_type: "Task".to_string(), - ..Default::default() - }; - let body = build_create_body(&new); - assert_eq!(body["fields"]["project"]["key"], "HU"); - assert_eq!(body["fields"]["summary"], "S"); - assert_eq!(body["fields"]["issuetype"]["name"], "Task"); - } - - #[test] - fn build_create_body_renders_markdown_description() { - let new = IssueCreate { - project_key: "HU".to_string(), - summary: "S".to_string(), - issue_type: "Task".to_string(), - description: Some("# heading".to_string()), - ..Default::default() - }; - let body = build_create_body(&new); - assert_eq!( - body["fields"]["description"]["content"][0]["type"], - "heading" - ); - } - - #[test] - fn build_create_body_prefers_adf_over_markdown() { - let raw = json!({ - "type": "doc", - "version": 1, - "content": [{ - "type": "paragraph", - "content": [{"type": "text", "text": "raw"}] - }] - }); - let new = IssueCreate { - project_key: "HU".to_string(), - summary: "S".to_string(), - issue_type: "Task".to_string(), - description: Some("# IGNORED markdown".to_string()), - description_adf: Some(raw), - ..Default::default() - }; - let body = build_create_body(&new); - assert_eq!( - body["fields"]["description"]["content"][0]["content"][0]["text"], - "raw" - ); - } - - #[test] - fn build_create_body_assignee() { - let new = IssueCreate { - project_key: "HU".to_string(), - summary: "S".to_string(), - issue_type: "Task".to_string(), - assignee: Some("user-123".to_string()), - ..Default::default() - }; - let body = build_create_body(&new); - assert_eq!(body["fields"]["assignee"]["accountId"], "user-123"); - } - - #[test] - fn parse_issue_types_extracts_list() { - let json = json!({ - "issueTypes": [ - {"id": "10001", "name": "Task", "description": "Work"}, - {"id": "10002", "name": "Bug", "description": ""}, - {"id": "10003", "name": "Story"} - ] - }); - let types = parse_issue_types(&json); - assert_eq!(types.len(), 3); - assert_eq!(types[0].name, "Task"); - assert_eq!(types[0].description.as_deref(), Some("Work")); - // Empty description normalised to None. - assert!(types[1].description.is_none()); - // Missing description treated the same way. - assert!(types[2].description.is_none()); - } - - #[test] - fn parse_issue_types_falls_back_to_values_key() { - // Atlassian flips between `issueTypes` and `values` depending on - // which version of the meta endpoint replies — accept both. - let json = json!({ - "values": [{"id": "1", "name": "Task"}] - }); - let types = parse_issue_types(&json); - assert_eq!(types.len(), 1); - assert_eq!(types[0].name, "Task"); - } - - #[test] - fn parse_issue_types_handles_empty() { - let json = json!({"issueTypes": []}); - assert!(parse_issue_types(&json).is_empty()); - } - - #[test] - fn parse_issue_types_handles_missing() { - assert!(parse_issue_types(&json!({})).is_empty()); - } - - #[test] - fn parse_single_issue_type_requires_id_and_name() { - assert!(parse_single_issue_type(&json!({"name": "Task"})).is_none()); - assert!(parse_single_issue_type(&json!({"id": "1"})).is_none()); - } -} diff --git a/src/jira/client/issues.rs b/src/jira/client/issues.rs deleted file mode 100644 index 6ca781a..0000000 --- a/src/jira/client/issues.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! Issue-related Jira API operations. -//! -//! Endpoints: `/myself`, `/issue/{key}`, `/search/jql`, PUT `/issue/{key}`. -//! Pure parsers live alongside their endpoints for cohesion. - -use anyhow::{bail, Context, Result}; - -use super::JiraClient; -use crate::jira::adf; -use crate::jira::types::{Issue, IssueUpdate, User}; - -/// Get current authenticated user. -pub(super) async fn get_current_user(client: &JiraClient) -> Result { - let url = client.api_url("/myself"); - let response = client - .http - .get(&url) - .bearer_auth(&client.access_token) - .send() - .await - .context("Failed to get current user")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to get current user: {}", error_text); - } - - let json: serde_json::Value = response.json().await?; - parse_user(&json).context("Failed to parse user response") -} - -/// Get a single issue by key. -pub(super) async fn get_issue(client: &JiraClient, key: &str) -> Result { - let url = client.api_url(&format!("/issue/{}", key)); - let response = client - .http - .get(&url) - .bearer_auth(&client.access_token) - .send() - .await - .context("Failed to get issue")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to get issue {}: {}", key, error_text); - } - - let json: serde_json::Value = response.json().await?; - parse_single_issue(&json).context("Failed to parse issue") -} - -/// Search issues using JQL via the modern `/search/jql` endpoint. -pub(super) async fn search_issues(client: &JiraClient, jql: &str) -> Result> { - let url = client.api_url("/search/jql"); - let response = client - .http - .post(&url) - .bearer_auth(&client.access_token) - .json(&serde_json::json!({ - "jql": jql, - "fields": ["summary", "status", "issuetype", "assignee", "description", "updated"] - })) - .send() - .await - .context("Failed to search issues")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to search issues: {}", error_text); - } - - let json: serde_json::Value = response.json().await?; - Ok(parse_issues(&json)) -} - -/// Update issue fields (summary, description, assignee). -pub(super) async fn update_issue( - client: &JiraClient, - key: &str, - update: &IssueUpdate, -) -> Result<()> { - let url = client.api_url(&format!("/issue/{}", key)); - let body = build_update_body(update); - - let response = client - .http - .put(&url) - .bearer_auth(&client.access_token) - .json(&body) - .send() - .await - .context("Failed to update issue")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to update issue {}: {}", key, error_text); - } - - Ok(()) -} - -/// Parse user from JSON (pure function, testable). -pub fn parse_user(json: &serde_json::Value) -> Option { - Some(User { - account_id: json["accountId"].as_str()?.to_string(), - display_name: json["displayName"].as_str()?.to_string(), - email_address: json["emailAddress"].as_str().map(|s| s.to_string()), - }) -} - -/// Parse issues from JSON (pure function, testable). -pub fn parse_issues(json: &serde_json::Value) -> Vec { - json["issues"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(parse_single_issue) - .collect() -} - -/// Parse a single issue from JSON (pure function, testable). -pub fn parse_single_issue(json: &serde_json::Value) -> Option { - let fields = &json["fields"]; - Some(Issue { - key: json["key"].as_str()?.to_string(), - summary: fields["summary"].as_str()?.to_string(), - status: fields["status"]["name"].as_str()?.to_string(), - issue_type: fields["issuetype"]["name"].as_str()?.to_string(), - assignee: fields["assignee"]["displayName"] - .as_str() - .map(|s| s.to_string()), - description: extract_description(fields), - updated: fields["updated"].as_str()?.to_string(), - }) -} - -/// Extract description text from ADF or string format. -/// -/// Returns [`None`] for null, missing, or empty descriptions so callers -/// can render "no description" distinct from "empty string". -pub(crate) fn extract_description(fields: &serde_json::Value) -> Option { - let description = &fields["description"]; - if description.is_null() { - return None; - } - - if let Some(s) = description.as_str() { - return if s.is_empty() { - None - } else { - Some(s.to_string()) - }; - } - - let text = adf::adf_to_plain_text(description); - if text.is_empty() { - None - } else { - Some(text) - } -} - -/// Build update request body (pure function, testable). -pub fn build_update_body(update: &IssueUpdate) -> serde_json::Value { - let mut fields = serde_json::Map::new(); - - if let Some(summary) = &update.summary { - fields.insert("summary".to_string(), serde_json::json!(summary)); - } - // Raw ADF takes precedence — escape hatch for callers who already - // have a fully-formed document (mention nodes, panels, custom - // attrs, etc.). Falls back to the Markdown path which renders - // plain prose as a single paragraph just like the legacy code. - if let Some(adf_doc) = &update.description_adf { - fields.insert("description".to_string(), adf_doc.clone()); - } else if let Some(description) = &update.description { - fields.insert("description".to_string(), adf::markdown_to_adf(description)); - } - if let Some(assignee) = &update.assignee { - fields.insert( - "assignee".to_string(), - serde_json::json!({ "accountId": assignee }), - ); - } - - serde_json::json!({ "fields": fields }) -} diff --git a/src/jira/client/mod.rs b/src/jira/client/mod.rs deleted file mode 100644 index 13a3896..0000000 --- a/src/jira/client/mod.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Jira REST API client. -//! -//! Module layout: -//! - [`JiraApi`] — trait for mockable client operations -//! - [`JiraClient`] — concrete OAuth-backed implementation -//! - [`issues`] — `/myself`, `/issue/{key}`, `/search/jql`, PUT `/issue/{key}` + parsers -//! - [`transitions`] — `/issue/{key}/transitions` GET/POST + parser - -use anyhow::{bail, Context, Result}; -use std::future::Future; - -use super::auth; -use super::types::{ - Comment, CreatedIssue, Issue, IssueCreate, IssueType, IssueUpdate, Transition, User, -}; - -mod comments; -mod create; -mod issues; -mod transitions; - -#[cfg(test)] -mod tests; - -/// Trait for Jira API operations (enables mocking in tests). -pub trait JiraApi: Send + Sync { - /// Get current authenticated user. - fn get_current_user(&self) -> impl Future> + Send; - - /// Get a single issue by key. - fn get_issue(&self, key: &str) -> impl Future> + Send; - - /// Search issues using JQL. - fn search_issues(&self, jql: &str) -> impl Future>> + Send; - - /// Update issue fields. - fn update_issue( - &self, - key: &str, - update: &IssueUpdate, - ) -> impl Future> + Send; - - /// Get available transitions for an issue. - fn get_transitions(&self, key: &str) -> impl Future>> + Send; - - /// Transition an issue to a new status. - fn transition_issue( - &self, - key: &str, - transition_id: &str, - ) -> impl Future> + Send; - - /// List all comments on an issue, ordered as Jira returns them - /// (oldest first). - fn list_comments(&self, key: &str) -> impl Future>> + Send; - - /// Create a new issue. Returns the new key + browse URL. - fn create_issue(&self, new: &IssueCreate) -> impl Future> + Send; - - /// List the issue types available on a given project. Used to - /// validate `--type` against what the project actually supports. - fn get_issue_types( - &self, - project_key: &str, - ) -> impl Future>> + Send; -} - -/// Jira API client. -pub struct JiraClient { - /// Underlying HTTP client. `pub(super)` so submodules can issue requests. - pub(super) http: reqwest::Client, - /// Cloud ID for the authenticated tenant. - pub(super) cloud_id: String, - /// OAuth access token (refreshed on `new()`). - pub(super) access_token: String, - /// Browse-URL base, e.g. `https://acme.atlassian.net`. Used to build - /// human-facing links after a successful issue creation. - pub(super) site_url: String, -} - -impl JiraClient { - /// Create a new authenticated Jira client. - pub async fn new() -> Result { - let access_token = auth::refresh_token_if_needed().await?; - let creds = - auth::get_credentials().context("Not authenticated. Run `hu jira auth` first.")?; - - Ok(Self { - http: reqwest::Client::new(), - cloud_id: creds.cloud_id, - access_token, - site_url: creds.site_url, - }) - } - - /// Build API URL for Jira REST API v3. - pub(super) fn api_url(&self, path: &str) -> String { - format!( - "https://api.atlassian.com/ex/jira/{}/rest/api/3{}", - self.cloud_id, path - ) - } - - /// List all Jira fields (to discover custom field IDs). - pub async fn list_fields(&self) -> Result> { - let url = self.api_url("/field"); - let response = self - .http - .get(&url) - .bearer_auth(&self.access_token) - .send() - .await - .context("Failed to list fields")?; - if !response.status().is_success() { - let err = response.text().await.unwrap_or_default(); - bail!("Failed to list fields: {err}"); - } - let json: serde_json::Value = response.json().await?; - Ok(json.as_array().cloned().unwrap_or_default()) - } - - /// Raw search returning the full JSON response (for custom fields). - pub async fn search_raw( - &self, - jql: &str, - fields: &[&str], - max_results: usize, - ) -> Result { - let url = self.api_url("/search/jql"); - let response = self - .http - .post(&url) - .bearer_auth(&self.access_token) - .json(&serde_json::json!({ - "jql": jql, - "fields": fields, - "maxResults": max_results, - })) - .send() - .await - .context("Failed to search")?; - if !response.status().is_success() { - let err = response.text().await.unwrap_or_default(); - bail!("Search failed: {err}"); - } - Ok(response.json().await?) - } -} - -impl JiraApi for JiraClient { - async fn get_current_user(&self) -> Result { - issues::get_current_user(self).await - } - - async fn get_issue(&self, key: &str) -> Result { - issues::get_issue(self, key).await - } - - async fn search_issues(&self, jql: &str) -> Result> { - issues::search_issues(self, jql).await - } - - async fn update_issue(&self, key: &str, update: &IssueUpdate) -> Result<()> { - issues::update_issue(self, key, update).await - } - - async fn get_transitions(&self, key: &str) -> Result> { - transitions::get_transitions(self, key).await - } - - async fn transition_issue(&self, key: &str, transition_id: &str) -> Result<()> { - transitions::transition_issue(self, key, transition_id).await - } - - async fn list_comments(&self, key: &str) -> Result> { - comments::list_comments(self, key).await - } - - async fn create_issue(&self, new: &IssueCreate) -> Result { - create::create_issue(self, new).await - } - - async fn get_issue_types(&self, project_key: &str) -> Result> { - create::get_issue_types(self, project_key).await - } -} diff --git a/src/jira/client/tests.rs b/src/jira/client/tests.rs deleted file mode 100644 index d8fd4a6..0000000 --- a/src/jira/client/tests.rs +++ /dev/null @@ -1,298 +0,0 @@ -use super::issues::{ - build_update_body, extract_description, parse_issues, parse_single_issue, parse_user, -}; -use super::transitions::parse_transitions; -use crate::jira::types::IssueUpdate; -use serde_json::json; - -#[test] -fn parse_user_extracts_fields() { - let json = json!({ - "accountId": "123", - "displayName": "John Doe", - "emailAddress": "john@example.com" - }); - let user = parse_user(&json).unwrap(); - assert_eq!(user.account_id, "123"); - assert_eq!(user.display_name, "John Doe"); - assert_eq!(user.email_address, Some("john@example.com".to_string())); -} - -#[test] -fn parse_user_without_email() { - let json = json!({ - "accountId": "456", - "displayName": "Jane" - }); - let user = parse_user(&json).unwrap(); - assert_eq!(user.account_id, "456"); - assert!(user.email_address.is_none()); -} - -#[test] -fn parse_user_returns_none_for_missing_fields() { - let json = json!({ - "displayName": "Missing ID" - }); - let user = parse_user(&json); - assert!(user.is_none()); -} - -#[test] -fn parse_issues_extracts_issues() { - let json = json!({ - "issues": [{ - "key": "PROJ-123", - "fields": { - "summary": "Fix bug", - "status": {"name": "In Progress"}, - "issuetype": {"name": "Bug"}, - "assignee": {"displayName": "John"}, - "updated": "2024-01-15T10:00:00Z" - } - }] - }); - let issues = parse_issues(&json); - assert_eq!(issues.len(), 1); - assert_eq!(issues[0].key, "PROJ-123"); - assert_eq!(issues[0].summary, "Fix bug"); - assert_eq!(issues[0].status, "In Progress"); - assert_eq!(issues[0].issue_type, "Bug"); - assert_eq!(issues[0].assignee, Some("John".to_string())); -} - -#[test] -fn parse_issues_handles_unassigned() { - let json = json!({ - "issues": [{ - "key": "PROJ-456", - "fields": { - "summary": "Task", - "status": {"name": "Open"}, - "issuetype": {"name": "Task"}, - "assignee": null, - "updated": "2024-01-15T12:00:00Z" - } - }] - }); - let issues = parse_issues(&json); - assert_eq!(issues.len(), 1); - assert!(issues[0].assignee.is_none()); -} - -#[test] -fn parse_issues_handles_empty() { - let json = json!({"issues": []}); - let issues = parse_issues(&json); - assert!(issues.is_empty()); -} - -#[test] -fn parse_single_issue_extracts_fields() { - let json = json!({ - "key": "TEST-1", - "fields": { - "summary": "Test issue", - "status": {"name": "Done"}, - "issuetype": {"name": "Story"}, - "assignee": {"displayName": "Tester"}, - "description": { - "type": "doc", - "content": [{ - "type": "paragraph", - "content": [{"type": "text", "text": "Description text"}] - }] - }, - "updated": "2024-01-01T00:00:00Z" - } - }); - let issue = parse_single_issue(&json).unwrap(); - assert_eq!(issue.key, "TEST-1"); - assert_eq!(issue.summary, "Test issue"); - assert_eq!(issue.status, "Done"); - assert_eq!(issue.issue_type, "Story"); - assert_eq!(issue.assignee, Some("Tester".to_string())); - assert_eq!(issue.description, Some("Description text".to_string())); -} - -#[test] -fn parse_single_issue_returns_none_for_missing_key() { - let json = json!({ - "fields": { - "summary": "No key", - "status": {"name": "Open"}, - "issuetype": {"name": "Task"}, - "updated": "2024-01-01T00:00:00Z" - } - }); - let issue = parse_single_issue(&json); - assert!(issue.is_none()); -} - -#[test] -fn parse_single_issue_handles_null_description() { - let json = json!({ - "key": "X-1", - "fields": { - "summary": "S", - "status": {"name": "Open"}, - "issuetype": {"name": "Task"}, - "description": null, - "updated": "2024-01-01T00:00:00Z" - } - }); - let issue = parse_single_issue(&json).unwrap(); - assert!(issue.description.is_none()); -} - -#[test] -fn extract_description_handles_string() { - let fields = json!({"description": "Simple string"}); - let desc = extract_description(&fields); - assert_eq!(desc, Some("Simple string".to_string())); -} - -#[test] -fn extract_description_handles_adf() { - let fields = json!({ - "description": { - "type": "doc", - "content": [{ - "type": "paragraph", - "content": [ - {"type": "text", "text": "Hello "}, - {"type": "text", "text": "world"} - ] - }] - } - }); - let desc = extract_description(&fields); - assert_eq!(desc, Some("Hello world".to_string())); -} - -#[test] -fn extract_description_handles_null() { - let fields = json!({"description": null}); - let desc = extract_description(&fields); - assert!(desc.is_none()); -} - -#[test] -fn extract_description_handles_empty_content() { - let fields = json!({ - "description": { - "type": "doc", - "content": [] - } - }); - let desc = extract_description(&fields); - assert!(desc.is_none()); -} - -#[test] -fn parse_transitions_extracts_transitions() { - let json = json!({ - "transitions": [ - {"id": "11", "name": "To Do"}, - {"id": "21", "name": "In Progress"}, - {"id": "31", "name": "Done"} - ] - }); - let transitions = parse_transitions(&json); - assert_eq!(transitions.len(), 3); - assert_eq!(transitions[0].id, "11"); - assert_eq!(transitions[0].name, "To Do"); - assert_eq!(transitions[2].id, "31"); - assert_eq!(transitions[2].name, "Done"); -} - -#[test] -fn parse_transitions_handles_empty() { - let json = json!({"transitions": []}); - let transitions = parse_transitions(&json); - assert!(transitions.is_empty()); -} - -#[test] -fn parse_transitions_handles_missing() { - let json = json!({}); - let transitions = parse_transitions(&json); - assert!(transitions.is_empty()); -} - -#[test] -fn build_update_body_with_summary() { - let update = IssueUpdate { - summary: Some("New summary".to_string()), - description: None, - description_adf: None, - assignee: None, - }; - let body = build_update_body(&update); - assert_eq!(body["fields"]["summary"], "New summary"); -} - -#[test] -fn build_update_body_with_description() { - let update = IssueUpdate { - summary: None, - description: Some("New description".to_string()), - description_adf: None, - assignee: None, - }; - let body = build_update_body(&update); - assert_eq!(body["fields"]["description"]["type"], "doc"); - assert_eq!(body["fields"]["description"]["version"], 1); -} - -#[test] -fn build_update_body_parses_description_as_markdown() { - // Plain text without markup characters still renders as a single - // paragraph (the old behaviour). Markdown elements are recognised - // and emitted as ADF block/inline structures. - let update = IssueUpdate { - summary: None, - description: Some("# Heading\n\n**bold** in body".to_string()), - description_adf: None, - assignee: None, - }; - let body = build_update_body(&update); - let content = &body["fields"]["description"]["content"]; - assert_eq!(content[0]["type"], "heading"); - assert_eq!(content[0]["attrs"]["level"], 1); - assert_eq!(content[1]["content"][0]["text"], "bold"); - assert_eq!(content[1]["content"][0]["marks"][0]["type"], "strong"); -} - -#[test] -fn build_update_body_with_assignee() { - let update = IssueUpdate { - summary: None, - description: None, - description_adf: None, - assignee: Some("user123".to_string()), - }; - let body = build_update_body(&update); - assert_eq!(body["fields"]["assignee"]["accountId"], "user123"); -} - -#[test] -fn build_update_body_with_all_fields() { - let update = IssueUpdate { - summary: Some("Sum".to_string()), - description: Some("Desc".to_string()), - description_adf: None, - assignee: Some("user".to_string()), - }; - let body = build_update_body(&update); - assert_eq!(body["fields"]["summary"], "Sum"); - assert!(body["fields"]["description"].is_object()); - assert_eq!(body["fields"]["assignee"]["accountId"], "user"); -} - -#[test] -fn build_update_body_empty() { - let update = IssueUpdate::default(); - let body = build_update_body(&update); - assert_eq!(body["fields"], json!({})); -} diff --git a/src/jira/client/transitions.rs b/src/jira/client/transitions.rs deleted file mode 100644 index 856c315..0000000 --- a/src/jira/client/transitions.rs +++ /dev/null @@ -1,71 +0,0 @@ -//! Transition-related Jira API operations. -//! -//! Endpoints: `GET /issue/{key}/transitions`, `POST /issue/{key}/transitions`. - -use anyhow::{bail, Context, Result}; - -use super::JiraClient; -use crate::jira::types::Transition; - -/// Get available transitions for an issue. -pub(super) async fn get_transitions(client: &JiraClient, key: &str) -> Result> { - let url = client.api_url(&format!("/issue/{}/transitions", key)); - let response = client - .http - .get(&url) - .bearer_auth(&client.access_token) - .send() - .await - .context("Failed to get transitions")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to get transitions for {}: {}", key, error_text); - } - - let json: serde_json::Value = response.json().await?; - Ok(parse_transitions(&json)) -} - -/// Transition an issue to a new status. -pub(super) async fn transition_issue( - client: &JiraClient, - key: &str, - transition_id: &str, -) -> Result<()> { - let url = client.api_url(&format!("/issue/{}/transitions", key)); - let body = serde_json::json!({ - "transition": { "id": transition_id } - }); - - let response = client - .http - .post(&url) - .bearer_auth(&client.access_token) - .json(&body) - .send() - .await - .context("Failed to transition issue")?; - - if !response.status().is_success() { - let error_text = response.text().await.unwrap_or_default(); - bail!("Failed to transition issue {}: {}", key, error_text); - } - - Ok(()) -} - -/// Parse transitions from JSON (pure function, testable). -pub fn parse_transitions(json: &serde_json::Value) -> Vec { - json["transitions"] - .as_array() - .unwrap_or(&vec![]) - .iter() - .filter_map(|t| { - Some(Transition { - id: t["id"].as_str()?.to_string(), - name: t["name"].as_str()?.to_string(), - }) - }) - .collect() -} diff --git a/src/jira/comments.rs b/src/jira/comments.rs deleted file mode 100644 index 6aa0e94..0000000 --- a/src/jira/comments.rs +++ /dev/null @@ -1,372 +0,0 @@ -//! `hu jira comments ` — list comments on an issue. - -use anyhow::Result; -use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, ContentArrangement, Table}; - -use super::client::{JiraApi, JiraClient}; -use super::types::Comment; - -/// Arguments for the comments command -#[derive(Debug, Clone)] -pub struct CommentsArgs { - pub key: String, - /// Show full comment bodies; otherwise truncate to a single-line preview. - pub full: bool, - /// Emit JSON instead of a table. - pub json: bool, -} - -/// Run the jira comments command (CLI entry point — formats and prints). -pub async fn run(args: CommentsArgs) -> Result<()> { - let client = JiraClient::new().await?; - let output = process_comments(&client, &args).await?; - print!("{}", output); - Ok(()) -} - -/// Process comments command (business logic, testable). -pub async fn process_comments(client: &impl JiraApi, args: &CommentsArgs) -> Result { - let comments = client.list_comments(&args.key).await?; - Ok(format_comments(&args.key, &comments, args.full, args.json)) -} - -/// Render the comments collection as either a table or JSON. -pub fn format_comments(key: &str, comments: &[Comment], full: bool, json: bool) -> String { - if json { - return format_json(comments); - } - if comments.is_empty() { - return format!("No comments on {}.\n", key); - } - if full { - format_full(key, comments) - } else { - format_table(key, comments) - } -} - -fn format_json(comments: &[Comment]) -> String { - serde_json::to_string_pretty(comments).unwrap_or_else(|_| "[]".to_string()) + "\n" -} - -fn format_table(key: &str, comments: &[Comment]) -> String { - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - table.set_header(vec!["WHEN", "AUTHOR", "BODY"]); - - for comment in comments { - table.add_row(vec![ - Cell::new(format_date(&comment.created)), - Cell::new(&comment.author.display_name).fg(Color::Cyan), - Cell::new(truncate_body(&comment.body, 80)), - ]); - } - - let mut output = format!( - "\x1b[1m{}\x1b[0m — {} comment{}\n", - key, - comments.len(), - if comments.len() == 1 { "" } else { "s" } - ); - output.push_str(&format!("{}\n", table)); - output -} - -fn format_full(key: &str, comments: &[Comment]) -> String { - let mut output = format!( - "\x1b[1m{}\x1b[0m — {} comment{}\n\n", - key, - comments.len(), - if comments.len() == 1 { "" } else { "s" } - ); - for (i, c) in comments.iter().enumerate() { - if i > 0 { - output.push('\n'); - } - output.push_str(&format!( - "\x1b[36m{}\x1b[0m — {}\n", - c.author.display_name, - format_date(&c.created) - )); - output.push_str(&c.body); - if !c.body.ends_with('\n') { - output.push('\n'); - } - } - output -} - -/// Format an ISO 8601 timestamp as "YYYY-MM-DD HH:MM" for terminal use. -/// Falls back to the input string for unrecognised shapes. -fn format_date(date: &str) -> String { - if date.is_empty() { - return "—".to_string(); - } - if let Some((date_part, time_part)) = date.split_once('T') { - if let Some((time, _)) = time_part.split_once('.') { - return format!("{} {}", date_part, time); - } - return format!( - "{} {}", - date_part, - time_part.split('+').next().unwrap_or(time_part) - ); - } - date.to_string() -} - -/// Single-line preview of a comment body, ellipsised at `max` chars. -fn truncate_body(body: &str, max: usize) -> String { - let single_line: String = body.replace('\n', " "); - if single_line.chars().count() <= max { - return single_line; - } - let truncated: String = single_line.chars().take(max.saturating_sub(1)).collect(); - format!("{}…", truncated) -} - -#[cfg(test)] -mod tests { - use super::super::types::User; - use super::*; - use serde_json::json; - - fn make_comment(id: &str, author: &str, body: &str, created: &str) -> Comment { - Comment { - id: id.to_string(), - author: User { - account_id: format!("a-{}", id), - display_name: author.to_string(), - email_address: None, - }, - body: body.to_string(), - body_adf: json!({"type": "doc", "version": 1, "content": []}), - created: created.to_string(), - updated: created.to_string(), - } - } - - #[test] - fn format_comments_empty_message() { - let out = format_comments("HU-1", &[], false, false); - assert!(out.contains("No comments on HU-1")); - } - - #[test] - fn format_comments_table_includes_header_and_rows() { - let comments = vec![ - make_comment("1", "Alice", "first", "2026-04-30T10:00:00.000Z"), - make_comment("2", "Bob", "second", "2026-04-30T11:30:00.000Z"), - ]; - let out = format_comments("HU-1", &comments, false, false); - assert!(out.contains("HU-1")); - assert!(out.contains("2 comments")); - assert!(out.contains("Alice")); - assert!(out.contains("Bob")); - assert!(out.contains("first")); - assert!(out.contains("2026-04-30 10:00:00")); - } - - #[test] - fn format_comments_singular_count() { - let comments = vec![make_comment( - "1", - "Alice", - "only", - "2026-04-30T10:00:00.000Z", - )]; - let out = format_comments("HU-1", &comments, false, false); - assert!(out.contains("1 comment\n") || out.contains("1 comment\u{a0}")); - assert!(!out.contains("1 comments")); - } - - #[test] - fn format_comments_full_mode_renders_complete_body() { - let body = "line one\nline two\nline three"; - let comments = vec![make_comment("1", "Alice", body, "2026-04-30T10:00:00.000Z")]; - let out = format_comments("HU-1", &comments, true, false); - assert!(out.contains("line one")); - assert!(out.contains("line two")); - assert!(out.contains("line three")); - assert!(out.contains("Alice")); - } - - #[test] - fn format_comments_table_truncates_body() { - let long = "a".repeat(200); - let comments = vec![make_comment( - "1", - "Alice", - &long, - "2026-04-30T10:00:00.000Z", - )]; - let out = format_comments("HU-1", &comments, false, false); - assert!(out.contains('…')); - } - - #[test] - fn format_comments_json_emits_valid_array() { - let comments = vec![make_comment( - "1", - "Alice", - "body", - "2026-04-30T10:00:00.000Z", - )]; - let out = format_comments("HU-1", &comments, false, true); - let parsed: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); - let arr = parsed.as_array().unwrap(); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0]["id"], "1"); - assert_eq!(arr[0]["author"]["display_name"], "Alice"); - assert_eq!(arr[0]["body"], "body"); - } - - #[test] - fn truncate_body_collapses_newlines() { - assert_eq!(truncate_body("line\nbreak", 80), "line break"); - } - - #[test] - fn truncate_body_short_unchanged() { - assert_eq!(truncate_body("short", 80), "short"); - } - - #[test] - fn truncate_body_long_ellipsised() { - let s = "a".repeat(200); - let t = truncate_body(&s, 50); - assert_eq!(t.chars().count(), 50); - assert!(t.ends_with('…')); - } - - #[test] - fn format_date_strips_milliseconds_and_timezone() { - assert_eq!( - format_date("2026-04-30T10:00:00.000Z"), - "2026-04-30 10:00:00" - ); - assert_eq!( - format_date("2026-04-30T10:00:00+0000"), - "2026-04-30 10:00:00" - ); - } - - #[test] - fn format_date_handles_empty_string() { - assert_eq!(format_date(""), "—"); - } - - #[test] - fn format_date_falls_through_unknown_shape() { - assert_eq!(format_date("yesterday"), "yesterday"); - } - - // Mock client for testing process_comments - struct MockJiraClient { - comments: Vec, - } - - impl JiraApi for MockJiraClient { - async fn get_current_user(&self) -> Result { - unimplemented!() - } - - async fn get_issue(&self, _key: &str) -> Result { - unimplemented!() - } - - async fn search_issues(&self, _jql: &str) -> Result> { - unimplemented!() - } - - async fn update_issue( - &self, - _key: &str, - _update: &super::super::types::IssueUpdate, - ) -> Result<()> { - unimplemented!() - } - - async fn get_transitions( - &self, - _key: &str, - ) -> Result> { - unimplemented!() - } - - async fn transition_issue(&self, _key: &str, _transition_id: &str) -> Result<()> { - unimplemented!() - } - - async fn list_comments(&self, _key: &str) -> Result> { - Ok(self.comments.clone()) - } - - async fn create_issue( - &self, - _new: &super::super::types::IssueCreate, - ) -> Result { - unimplemented!() - } - - async fn get_issue_types( - &self, - _project_key: &str, - ) -> Result> { - unimplemented!() - } - } - - #[tokio::test] - async fn process_comments_runs_and_formats_table() { - let client = MockJiraClient { - comments: vec![make_comment( - "1", - "Alice", - "hello", - "2026-04-30T10:00:00.000Z", - )], - }; - let args = CommentsArgs { - key: "HU-1".to_string(), - full: false, - json: false, - }; - let out = process_comments(&client, &args).await.unwrap(); - assert!(out.contains("Alice")); - assert!(out.contains("hello")); - } - - #[tokio::test] - async fn process_comments_json_path() { - let client = MockJiraClient { - comments: vec![make_comment( - "1", - "Alice", - "hello", - "2026-04-30T10:00:00.000Z", - )], - }; - let args = CommentsArgs { - key: "HU-1".to_string(), - full: false, - json: true, - }; - let out = process_comments(&client, &args).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); - assert!(parsed.is_array()); - } - - #[tokio::test] - async fn process_comments_empty_returns_friendly_message() { - let client = MockJiraClient { comments: vec![] }; - let args = CommentsArgs { - key: "HU-1".to_string(), - full: false, - json: false, - }; - let out = process_comments(&client, &args).await.unwrap(); - assert!(out.contains("No comments on HU-1")); - } -} diff --git a/src/jira/create.rs b/src/jira/create.rs deleted file mode 100644 index 138f562..0000000 --- a/src/jira/create.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! `hu jira create` — create a new issue. -//! -//! Requires a project key (passed via `--project` or the -//! `HU_JIRA_PROJECT` environment variable) and a summary. Issue type -//! defaults to "Task" but is validated against the project's -//! createmeta so typos surface a useful "available types: …" error. - -use std::path::{Path, PathBuf}; - -use anyhow::{bail, Result}; - -use super::client::{JiraApi, JiraClient}; -use super::types::{IssueCreate, IssueType}; -use super::update::load_adf; - -/// Arguments for the create command. Mirrors the CLI struct for ease -/// of testing without the clap parser. -#[derive(Debug, Clone)] -pub struct CreateArgs { - pub project_key: String, - pub summary: String, - pub issue_type: String, - pub body: Option, - pub body_adf: Option, - pub assign: Option, - pub json: bool, -} - -/// Run the jira create command (CLI entry point — formats and prints). -pub async fn run(args: CreateArgs) -> Result<()> { - let client = JiraClient::new().await?; - let output = process_create(&client, &args).await?; - print!("{}", output); - Ok(()) -} - -/// Process create command (business logic, testable). -pub async fn process_create(client: &impl JiraApi, args: &CreateArgs) -> Result { - if args.summary.trim().is_empty() { - bail!("Summary is required and cannot be empty"); - } - if args.project_key.trim().is_empty() { - bail!("Project key is required (use --project or set HU_JIRA_PROJECT)"); - } - - // Validate issue type against the project's createmeta. Fuzzy match - // so "task" matches "Task", "bug" matches "Bug", etc. - let issue_types = client.get_issue_types(&args.project_key).await?; - let resolved_type = find_issue_type(&issue_types, &args.issue_type)?; - - let description_adf = match &args.body_adf { - Some(path) => Some(load_adf_from(path)?), - None => None, - }; - - let assignee = match &args.assign { - Some(a) if a == "me" => Some(client.get_current_user().await?.account_id), - Some(a) => Some(a.clone()), - None => None, - }; - - let new = IssueCreate { - project_key: args.project_key.clone(), - summary: args.summary.clone(), - issue_type: resolved_type.name.clone(), - description: args.body.clone(), - description_adf, - assignee, - }; - - let created = client.create_issue(&new).await?; - - if args.json { - let json = serde_json::to_string_pretty(&created).unwrap_or_default(); - return Ok(format!("{}\n", json)); - } - Ok(format!( - "\x1b[32m\u{2713}\x1b[0m Created \x1b[1m{}\x1b[0m: {}\n {}\n", - created.key, args.summary, created.url - )) -} - -/// Wrapper around [`update::load_adf`] so this module can read raw ADF -/// without re-implementing validation. -fn load_adf_from(path: &Path) -> Result { - load_adf(path) -} - -/// Resolve a user-supplied type string against the project's available -/// issue types. Exact match (case-insensitive) wins; falls back to a -/// substring match. On miss, lists what was offered so the user can -/// retry without poking around in Jira. -pub fn find_issue_type<'a>(types: &'a [IssueType], requested: &str) -> Result<&'a IssueType> { - let target = requested.to_lowercase(); - - if let Some(t) = types.iter().find(|t| t.name.to_lowercase() == target) { - return Ok(t); - } - if let Some(t) = types - .iter() - .find(|t| t.name.to_lowercase().contains(&target)) - { - return Ok(t); - } - - let available: Vec<&str> = types.iter().map(|t| t.name.as_str()).collect(); - if available.is_empty() { - bail!("No issue types returned for this project. Check project key and permissions."); - } - bail!( - "Issue type '{}' not found. Available: {}", - requested, - available.join(", ") - ) -} - -#[cfg(test)] -mod tests { - use super::super::types::{Comment, CreatedIssue, Transition, User}; - use super::*; - use serde_json::json; - use std::io::Write; - use tempfile::NamedTempFile; - - fn make_types(names: &[&str]) -> Vec { - names - .iter() - .enumerate() - .map(|(i, n)| IssueType { - id: format!("{}", 10000 + i), - name: n.to_string(), - description: None, - }) - .collect() - } - - #[test] - fn find_issue_type_exact_match() { - let types = make_types(&["Task", "Bug", "Story"]); - let t = find_issue_type(&types, "Bug").unwrap(); - assert_eq!(t.name, "Bug"); - } - - #[test] - fn find_issue_type_case_insensitive() { - let types = make_types(&["Task", "Bug", "Story"]); - let t = find_issue_type(&types, "bug").unwrap(); - assert_eq!(t.name, "Bug"); - } - - #[test] - fn find_issue_type_substring_match() { - let types = make_types(&["Story", "Sub-task", "Epic"]); - let t = find_issue_type(&types, "sub").unwrap(); - assert_eq!(t.name, "Sub-task"); - } - - #[test] - fn find_issue_type_lists_available_on_miss() { - let types = make_types(&["Task", "Bug", "Story"]); - let err = find_issue_type(&types, "Feature").unwrap_err().to_string(); - assert!(err.contains("Feature")); - assert!(err.contains("Task")); - assert!(err.contains("Bug")); - assert!(err.contains("Story")); - } - - #[test] - fn find_issue_type_empty_list_explains() { - let types = make_types(&[]); - let err = find_issue_type(&types, "Task").unwrap_err().to_string(); - assert!(err.contains("No issue types")); - } - - // Mock used by process_create tests - struct MockJiraClient { - types: Vec, - user: User, - captured: std::sync::Mutex>, - created: CreatedIssue, - } - - impl JiraApi for MockJiraClient { - async fn get_current_user(&self) -> Result { - Ok(self.user.clone()) - } - - async fn get_issue(&self, _key: &str) -> Result { - unimplemented!() - } - - async fn search_issues(&self, _jql: &str) -> Result> { - unimplemented!() - } - - async fn update_issue( - &self, - _key: &str, - _update: &super::super::types::IssueUpdate, - ) -> Result<()> { - unimplemented!() - } - - async fn get_transitions(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn transition_issue(&self, _key: &str, _transition_id: &str) -> Result<()> { - unimplemented!() - } - - async fn list_comments(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn create_issue(&self, new: &IssueCreate) -> Result { - *self.captured.lock().unwrap() = Some(new.clone()); - Ok(self.created.clone()) - } - - async fn get_issue_types(&self, _project_key: &str) -> Result> { - Ok(self.types.clone()) - } - } - - fn make_mock() -> MockJiraClient { - MockJiraClient { - types: make_types(&["Task", "Bug", "Story"]), - user: User { - account_id: "me-account".to_string(), - display_name: "Me".to_string(), - email_address: None, - }, - captured: std::sync::Mutex::new(None), - created: CreatedIssue { - id: "10000".to_string(), - key: "HU-1".to_string(), - url: "https://example.atlassian.net/browse/HU-1".to_string(), - }, - } - } - - fn args(project: &str, summary: &str, issue_type: &str) -> CreateArgs { - CreateArgs { - project_key: project.to_string(), - summary: summary.to_string(), - issue_type: issue_type.to_string(), - body: None, - body_adf: None, - assign: None, - json: false, - } - } - - #[tokio::test] - async fn process_create_succeeds_and_renders_url() { - let client = make_mock(); - let out = process_create(&client, &args("HU", "Test issue", "Task")) - .await - .unwrap(); - assert!(out.contains("HU-1")); - assert!(out.contains("Test issue")); - assert!(out.contains("https://example.atlassian.net/browse/HU-1")); - - let captured = client.captured.lock().unwrap(); - let cap = captured.as_ref().unwrap(); - assert_eq!(cap.project_key, "HU"); - assert_eq!(cap.summary, "Test issue"); - assert_eq!(cap.issue_type, "Task"); - } - - #[tokio::test] - async fn process_create_resolves_lowercase_type() { - let client = make_mock(); - let _ = process_create(&client, &args("HU", "x", "task")) - .await - .unwrap(); - assert_eq!( - client.captured.lock().unwrap().as_ref().unwrap().issue_type, - "Task" - ); - } - - #[tokio::test] - async fn process_create_rejects_unknown_type() { - let client = make_mock(); - let err = process_create(&client, &args("HU", "x", "Feature")) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("Feature")); - } - - #[tokio::test] - async fn process_create_rejects_empty_summary() { - let client = make_mock(); - let err = process_create(&client, &args("HU", " ", "Task")) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("Summary")); - } - - #[tokio::test] - async fn process_create_rejects_empty_project() { - let client = make_mock(); - let err = process_create(&client, &args("", "x", "Task")) - .await - .unwrap_err() - .to_string(); - assert!(err.contains("Project key")); - } - - #[tokio::test] - async fn process_create_resolves_assign_me_to_account_id() { - let client = make_mock(); - let mut a = args("HU", "x", "Task"); - a.assign = Some("me".to_string()); - let _ = process_create(&client, &a).await.unwrap(); - assert_eq!( - client - .captured - .lock() - .unwrap() - .as_ref() - .unwrap() - .assignee - .as_deref(), - Some("me-account") - ); - } - - #[tokio::test] - async fn process_create_passes_explicit_assignee_through() { - let client = make_mock(); - let mut a = args("HU", "x", "Task"); - a.assign = Some("other-user-id".to_string()); - let _ = process_create(&client, &a).await.unwrap(); - assert_eq!( - client - .captured - .lock() - .unwrap() - .as_ref() - .unwrap() - .assignee - .as_deref(), - Some("other-user-id") - ); - } - - #[tokio::test] - async fn process_create_loads_body_adf_from_file() { - let client = make_mock(); - let mut file = NamedTempFile::new().unwrap(); - let doc = json!({ - "type": "doc", - "version": 1, - "content": [{ - "type": "paragraph", - "content": [{"type": "text", "text": "raw"}] - }] - }); - file.write_all(doc.to_string().as_bytes()).unwrap(); - - let mut a = args("HU", "x", "Task"); - a.body_adf = Some(file.path().to_path_buf()); - let _ = process_create(&client, &a).await.unwrap(); - - let captured = client.captured.lock().unwrap(); - let cap = captured.as_ref().unwrap(); - let adf = cap.description_adf.as_ref().unwrap(); - assert_eq!(adf["content"][0]["content"][0]["text"], "raw"); - } - - #[tokio::test] - async fn process_create_json_output_serialises_created_issue() { - let client = make_mock(); - let mut a = args("HU", "x", "Task"); - a.json = true; - let out = process_create(&client, &a).await.unwrap(); - let parsed: serde_json::Value = serde_json::from_str(out.trim()).unwrap(); - assert_eq!(parsed["key"], "HU-1"); - assert_eq!(parsed["url"], "https://example.atlassian.net/browse/HU-1"); - } -} diff --git a/src/jira/mod.rs b/src/jira/mod.rs deleted file mode 100644 index feaf887..0000000 --- a/src/jira/mod.rs +++ /dev/null @@ -1,162 +0,0 @@ -//! Jira integration -//! -//! # CLI Usage -//! Use [`run_command`] for CLI commands that format and print output. -//! -//! # Programmatic Usage (MCP/HTTP) -//! Use the reusable functions that return typed data: -//! - [`get_issue`] - Get a single issue -//! - [`search_issues`] - Search with JQL -//! - [`get_current_user`] - Get authenticated user -//! - [`update_issue`] - Update issue fields -//! - [`get_transitions`] - Get available transitions -//! - [`transition_issue`] - Change issue status - -mod adf; -mod auth; -mod auth_handler; -mod cli; -mod client; -mod comments; -mod create; -mod search; -mod service; -mod show; -mod sprint; -mod sprints; -mod tickets; -mod types; -mod update; - -use anyhow::Result; - -pub use cli::JiraCommand; -pub use types::{Issue, IssueUpdate, Transition, User}; - -use comments::CommentsArgs; -use create::CreateArgs; -use update::UpdateArgs; - -/// Run a Jira command (CLI entry point - formats and prints) -#[cfg(not(tarpaulin_include))] -pub async fn run_command(cmd: JiraCommand) -> anyhow::Result<()> { - match cmd { - JiraCommand::Auth => auth_handler::run().await, - JiraCommand::Tickets => tickets::run().await, - JiraCommand::Sprint => sprint::run(sprint::SprintArgs::default()).await, - JiraCommand::Sprints { state } => sprints::run(&state).await, - JiraCommand::Search { query } => search::run(&query).await, - JiraCommand::Show { key } => show::run(&key).await, - JiraCommand::Comments { key, full, json } => { - comments::run(CommentsArgs { key, full, json }).await - } - JiraCommand::Create { - summary, - r#type, - project, - body, - body_adf, - assign, - json, - } => { - create::run(CreateArgs { - project_key: project, - summary, - issue_type: r#type, - body, - body_adf, - assign, - json, - }) - .await - } - JiraCommand::Update { - key, - summary, - status, - assign, - body, - body_adf, - } => { - update::run(UpdateArgs { - key, - summary, - status, - assign, - body, - body_adf, - }) - .await - } - } -} - -// ============================================================================ -// Reusable functions for MCP/HTTP - return typed data, never print -// ============================================================================ - -/// Get a single issue by key (for MCP/HTTP) -#[allow(dead_code)] -pub async fn get_issue(key: &str) -> Result { - let client = service::create_client().await?; - service::get_issue(&client, key).await -} - -/// Search issues using JQL (for MCP/HTTP) -#[allow(dead_code)] -pub async fn search_issues(jql: &str) -> Result> { - let client = service::create_client().await?; - service::search_issues(&client, jql).await -} - -/// Get current authenticated user (for MCP/HTTP) -#[allow(dead_code)] -pub async fn get_current_user() -> Result { - let client = service::create_client().await?; - service::get_current_user(&client).await -} - -/// Update issue fields (for MCP/HTTP) -#[allow(dead_code)] -pub async fn update_issue(key: &str, update: &IssueUpdate) -> Result<()> { - let client = service::create_client().await?; - service::update_issue(&client, key, update).await -} - -/// Get available transitions for an issue (for MCP/HTTP) -#[allow(dead_code)] -pub async fn get_transitions(key: &str) -> Result> { - let client = service::create_client().await?; - service::get_transitions(&client, key).await -} - -/// Transition an issue to a new status (for MCP/HTTP) -#[allow(dead_code)] -pub async fn transition_issue(key: &str, transition_id: &str) -> Result<()> { - let client = service::create_client().await?; - service::transition_issue(&client, key, transition_id).await -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn jira_command_exported() { - // Verify JiraCommand is re-exported - let _cmd = JiraCommand::Auth; - } - - #[test] - fn update_args_created() { - let args = UpdateArgs { - key: "X-1".to_string(), - summary: None, - status: None, - assign: None, - body: None, - body_adf: None, - }; - assert_eq!(args.key, "X-1"); - } -} diff --git a/src/jira/search.rs b/src/jira/search.rs deleted file mode 100644 index ddb3f54..0000000 --- a/src/jira/search.rs +++ /dev/null @@ -1,294 +0,0 @@ -use anyhow::Result; - -use super::client::{JiraApi, JiraClient}; -use super::types::Issue; - -/// Run the jira search command -pub async fn run(query: &str) -> Result<()> { - let client = JiraClient::new().await?; - let output = process_search(&client, query).await?; - print!("{}", output); - Ok(()) -} - -/// Process search command (business logic, testable) -pub async fn process_search(client: &impl JiraApi, query: &str) -> Result { - let issues = client.search_issues(query).await?; - Ok(format_search_results(&issues, query)) -} - -/// Format search results -fn format_search_results(issues: &[Issue], query: &str) -> String { - let mut output = String::new(); - - if issues.is_empty() { - output.push_str(&format!("No issues found for: {}\n", query)); - return output; - } - - output.push_str(&format!( - "Found {} issue{} for: {}\n\n", - issues.len(), - if issues.len() == 1 { "" } else { "s" }, - query - )); - - // Calculate column widths - let key_width = issues.iter().map(|i| i.key.len()).max().unwrap_or(0).max(4); - let status_width = issues - .iter() - .map(|i| i.status.len()) - .max() - .unwrap_or(0) - .max(6); - - for issue in issues { - let assignee = issue.assignee.as_deref().unwrap_or("-"); - let status_color = match issue.status.as_str() { - "Done" => "\x1b[32m", // green - "In Progress" => "\x1b[33m", // yellow - _ => "\x1b[34m", // blue - }; - - output.push_str(&format!( - "{: String { - if s.len() <= max_len { - s.to_string() - } else { - format!("{}...", &s[..max_len.saturating_sub(3)]) - } -} - -#[cfg(test)] -mod tests { - use super::super::types::{ - Comment, CreatedIssue, IssueCreate, IssueType, IssueUpdate, Transition, User, - }; - use super::*; - - #[test] - fn truncate_short_string_unchanged() { - assert_eq!(truncate("hello", 10), "hello"); - } - - #[test] - fn truncate_exact_length_unchanged() { - assert_eq!(truncate("hello", 5), "hello"); - } - - #[test] - fn truncate_long_string_adds_ellipsis() { - assert_eq!(truncate("hello world", 8), "hello..."); - } - - #[test] - fn truncate_very_short_max() { - assert_eq!(truncate("hello", 3), "..."); - } - - #[test] - fn truncate_zero_max() { - assert_eq!(truncate("hello", 0), "..."); - } - - #[test] - fn format_search_results_empty() { - let issues: Vec = vec![]; - let output = format_search_results(&issues, "project = TEST"); - assert!(output.contains("No issues found")); - assert!(output.contains("project = TEST")); - } - - #[test] - fn format_search_results_single() { - let issues = vec![Issue { - key: "TEST-1".to_string(), - summary: "Test issue".to_string(), - status: "Open".to_string(), - issue_type: "Bug".to_string(), - assignee: Some("Alice".to_string()), - description: None, - updated: "2024-01-01T00:00:00Z".to_string(), - }]; - let output = format_search_results(&issues, "jql"); - assert!(output.contains("Found 1 issue for")); - assert!(output.contains("TEST-1")); - assert!(output.contains("Test issue")); - assert!(output.contains("Bug")); - assert!(output.contains("Alice")); - } - - #[test] - fn format_search_results_multiple() { - let issues = vec![ - Issue { - key: "A-1".to_string(), - summary: "First".to_string(), - status: "Done".to_string(), - issue_type: "Task".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }, - Issue { - key: "A-2".to_string(), - summary: "Second".to_string(), - status: "In Progress".to_string(), - issue_type: "Story".to_string(), - assignee: Some("Bob".to_string()), - description: None, - updated: "U".to_string(), - }, - ]; - let output = format_search_results(&issues, "q"); - assert!(output.contains("Found 2 issues")); - assert!(output.contains("A-1")); - assert!(output.contains("A-2")); - assert!(output.contains("-")); // unassigned - assert!(output.contains("Bob")); - } - - #[test] - fn format_search_results_truncates_long_summary() { - let issues = vec![Issue { - key: "X-1".to_string(), - summary: "This is a very long summary that should be truncated to fit on screen" - .to_string(), - status: "Open".to_string(), - issue_type: "Task".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }]; - let output = format_search_results(&issues, "q"); - assert!(output.contains("...")); - } - - #[test] - fn format_search_results_colors_status() { - let issues = vec![ - Issue { - key: "A-1".to_string(), - summary: "Done".to_string(), - status: "Done".to_string(), - issue_type: "T".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }, - Issue { - key: "A-2".to_string(), - summary: "In Progress".to_string(), - status: "In Progress".to_string(), - issue_type: "T".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }, - Issue { - key: "A-3".to_string(), - summary: "Other".to_string(), - status: "Other".to_string(), - issue_type: "T".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }, - ]; - let output = format_search_results(&issues, "q"); - assert!(output.contains("\x1b[32m")); // green for Done - assert!(output.contains("\x1b[33m")); // yellow for In Progress - assert!(output.contains("\x1b[34m")); // blue for other - } - - // Mock client for testing process_search - struct MockJiraClient { - issues: Vec, - } - - impl JiraApi for MockJiraClient { - async fn get_current_user(&self) -> Result { - unimplemented!() - } - - async fn get_issue(&self, _key: &str) -> Result { - unimplemented!() - } - - async fn search_issues(&self, _jql: &str) -> Result> { - Ok(self.issues.clone()) - } - - async fn update_issue(&self, _key: &str, _update: &IssueUpdate) -> Result<()> { - unimplemented!() - } - - async fn get_transitions(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn transition_issue(&self, _key: &str, _transition_id: &str) -> Result<()> { - unimplemented!() - } - - async fn list_comments(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn create_issue(&self, _new: &IssueCreate) -> Result { - unimplemented!() - } - - async fn get_issue_types(&self, _project_key: &str) -> Result> { - unimplemented!() - } - } - - #[tokio::test] - async fn process_search_returns_formatted_results() { - let client = MockJiraClient { - issues: vec![Issue { - key: "TEST-123".to_string(), - summary: "Test issue".to_string(), - status: "Open".to_string(), - issue_type: "Bug".to_string(), - assignee: Some("Tester".to_string()), - description: None, - updated: "2024-01-01T00:00:00Z".to_string(), - }], - }; - - let output = process_search(&client, "project = TEST").await.unwrap(); - assert!(output.contains("TEST-123")); - assert!(output.contains("Test issue")); - } - - #[tokio::test] - async fn process_search_empty_results() { - let client = MockJiraClient { issues: vec![] }; - - let output = process_search(&client, "nonexistent").await.unwrap(); - assert!(output.contains("No issues found")); - } -} diff --git a/src/jira/service.rs b/src/jira/service.rs deleted file mode 100644 index 5d04099..0000000 --- a/src/jira/service.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! Jira service layer - business logic that returns data -//! -//! Functions in this module accept trait objects and return typed data. -//! They never print - that's the CLI layer's job. - -use anyhow::Result; - -use super::client::{JiraApi, JiraClient}; -use super::types::{Issue, IssueUpdate, Transition, User}; - -/// Get a single issue by key -pub async fn get_issue(api: &impl JiraApi, key: &str) -> Result { - api.get_issue(key).await -} - -/// Search issues using JQL -pub async fn search_issues(api: &impl JiraApi, jql: &str) -> Result> { - api.search_issues(jql).await -} - -/// Get current authenticated user -pub async fn get_current_user(api: &impl JiraApi) -> Result { - api.get_current_user().await -} - -/// Update issue fields -pub async fn update_issue(api: &impl JiraApi, key: &str, update: &IssueUpdate) -> Result<()> { - api.update_issue(key, update).await -} - -/// Get available transitions for an issue -pub async fn get_transitions(api: &impl JiraApi, key: &str) -> Result> { - api.get_transitions(key).await -} - -/// Transition an issue to a new status -pub async fn transition_issue(api: &impl JiraApi, key: &str, transition_id: &str) -> Result<()> { - api.transition_issue(key, transition_id).await -} - -/// Create a new authenticated client -pub async fn create_client() -> Result { - JiraClient::new().await -} - -#[cfg(test)] -mod tests { - use super::super::types::{Comment, CreatedIssue, IssueCreate, IssueType}; - use super::*; - - struct MockApi { - issues: Vec, - user: Option, - transitions: Vec, - } - - impl MockApi { - fn new() -> Self { - Self { - issues: vec![], - user: None, - transitions: vec![], - } - } - - fn with_issues(mut self, issues: Vec) -> Self { - self.issues = issues; - self - } - - fn with_user(mut self, user: User) -> Self { - self.user = Some(user); - self - } - - fn with_transitions(mut self, transitions: Vec) -> Self { - self.transitions = transitions; - self - } - } - - impl JiraApi for MockApi { - async fn get_current_user(&self) -> Result { - self.user - .clone() - .ok_or_else(|| anyhow::anyhow!("No user configured")) - } - - async fn get_issue(&self, key: &str) -> Result { - self.issues - .iter() - .find(|i| i.key == key) - .cloned() - .ok_or_else(|| anyhow::anyhow!("Issue not found: {}", key)) - } - - async fn search_issues(&self, _jql: &str) -> Result> { - Ok(self.issues.clone()) - } - - async fn update_issue(&self, _key: &str, _update: &IssueUpdate) -> Result<()> { - Ok(()) - } - - async fn get_transitions(&self, _key: &str) -> Result> { - Ok(self.transitions.clone()) - } - - async fn transition_issue(&self, _key: &str, _transition_id: &str) -> Result<()> { - Ok(()) - } - - async fn list_comments(&self, _key: &str) -> Result> { - Ok(vec![]) - } - - async fn create_issue(&self, _new: &IssueCreate) -> Result { - Ok(CreatedIssue { - id: "0".to_string(), - key: "MOCK-0".to_string(), - url: "https://example.atlassian.net/browse/MOCK-0".to_string(), - }) - } - - async fn get_issue_types(&self, _project_key: &str) -> Result> { - Ok(vec![]) - } - } - - fn make_issue(key: &str, summary: &str, status: &str) -> Issue { - Issue { - key: key.to_string(), - summary: summary.to_string(), - status: status.to_string(), - issue_type: "Task".to_string(), - assignee: None, - description: None, - updated: "2024-01-01T00:00:00Z".to_string(), - } - } - - #[tokio::test] - async fn get_issue_returns_matching() { - let api = MockApi::new().with_issues(vec![ - make_issue("PROJ-1", "First issue", "Open"), - make_issue("PROJ-2", "Second issue", "Done"), - ]); - - let result = get_issue(&api, "PROJ-2").await.unwrap(); - assert_eq!(result.key, "PROJ-2"); - assert_eq!(result.summary, "Second issue"); - } - - #[tokio::test] - async fn get_issue_not_found() { - let api = MockApi::new(); - let result = get_issue(&api, "MISSING").await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn search_issues_returns_all() { - let api = MockApi::new().with_issues(vec![ - make_issue("PROJ-1", "First", "Open"), - make_issue("PROJ-2", "Second", "Done"), - ]); - - let result = search_issues(&api, "project = PROJ").await.unwrap(); - assert_eq!(result.len(), 2); - } - - #[tokio::test] - async fn get_current_user_returns_user() { - let api = MockApi::new().with_user(User { - account_id: "123".to_string(), - display_name: "John Doe".to_string(), - email_address: Some("john@test.com".to_string()), - }); - - let result = get_current_user(&api).await.unwrap(); - assert_eq!(result.display_name, "John Doe"); - } - - #[tokio::test] - async fn get_transitions_returns_list() { - let api = MockApi::new().with_transitions(vec![ - Transition { - id: "1".to_string(), - name: "Start Progress".to_string(), - }, - Transition { - id: "2".to_string(), - name: "Done".to_string(), - }, - ]); - - let result = get_transitions(&api, "PROJ-1").await.unwrap(); - assert_eq!(result.len(), 2); - assert_eq!(result[0].name, "Start Progress"); - } - - #[tokio::test] - async fn update_issue_succeeds() { - let api = MockApi::new(); - let update = IssueUpdate { - summary: Some("New summary".to_string()), - ..Default::default() - }; - - let result = update_issue(&api, "PROJ-1", &update).await; - assert!(result.is_ok()); - } - - #[tokio::test] - async fn transition_issue_succeeds() { - let api = MockApi::new(); - let result = transition_issue(&api, "PROJ-1", "2").await; - assert!(result.is_ok()); - } -} diff --git a/src/jira/show.rs b/src/jira/show.rs deleted file mode 100644 index c9012cd..0000000 --- a/src/jira/show.rs +++ /dev/null @@ -1,279 +0,0 @@ -use anyhow::Result; - -use super::client::{JiraApi, JiraClient}; -use super::types::Issue; - -/// Run the jira show command -pub async fn run(key: &str) -> Result<()> { - let client = JiraClient::new().await?; - let output = process_show(&client, key).await?; - print!("{}", output); - Ok(()) -} - -/// Process show command (business logic, testable) -pub async fn process_show(client: &impl JiraApi, key: &str) -> Result { - let issue = client.get_issue(key).await?; - Ok(format_issue(&issue)) -} - -/// Format issue for display -fn format_issue(issue: &Issue) -> String { - let mut output = String::new(); - - // Header - output.push_str(&format!("\x1b[1m{}\x1b[0m {}\n", issue.key, issue.summary)); - output.push('\n'); - - // Metadata - output.push_str(&format!("Type: {}\n", issue.issue_type)); - output.push_str(&format!("Status: {}\n", format_status(&issue.status))); - output.push_str(&format!( - "Assignee: {}\n", - issue.assignee.as_deref().unwrap_or("Unassigned") - )); - output.push_str(&format!("Updated: {}\n", format_date(&issue.updated))); - - // Description - if let Some(desc) = &issue.description { - output.push('\n'); - output.push_str("Description:\n"); - output.push_str(&format_description(desc)); - } - - output -} - -/// Format status with color -fn format_status(status: &str) -> String { - let color = match status { - "Done" => "\x1b[32m", // green - "In Progress" => "\x1b[33m", // yellow - "To Do" => "\x1b[34m", // blue - "In Review" => "\x1b[35m", // magenta - _ => "\x1b[36m", // cyan - }; - format!("{}{}\x1b[0m", color, status) -} - -/// Format date for display -fn format_date(date: &str) -> String { - // Parse ISO date and format nicely - // Input: "2024-01-15T10:30:00.000+0000" - if let Some((date_part, time_part)) = date.split_once('T') { - if let Some((time, _)) = time_part.split_once('.') { - return format!("{} {}", date_part, time); - } - return format!( - "{} {}", - date_part, - time_part.split('+').next().unwrap_or(time_part) - ); - } - date.to_string() -} - -/// Format description with indentation -fn format_description(desc: &str) -> String { - desc.lines().map(|line| format!(" {}\n", line)).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn format_issue_shows_key_and_summary() { - let issue = Issue { - key: "PROJ-123".to_string(), - summary: "Fix the bug".to_string(), - status: "In Progress".to_string(), - issue_type: "Bug".to_string(), - assignee: Some("John".to_string()), - description: None, - updated: "2024-01-15T10:30:00.000+0000".to_string(), - }; - let output = format_issue(&issue); - assert!(output.contains("PROJ-123")); - assert!(output.contains("Fix the bug")); - assert!(output.contains("Bug")); - assert!(output.contains("In Progress")); - assert!(output.contains("John")); - } - - #[test] - fn format_issue_shows_unassigned() { - let issue = Issue { - key: "X-1".to_string(), - summary: "S".to_string(), - status: "Open".to_string(), - issue_type: "Task".to_string(), - assignee: None, - description: None, - updated: "2024-01-01T00:00:00Z".to_string(), - }; - let output = format_issue(&issue); - assert!(output.contains("Unassigned")); - } - - #[test] - fn format_issue_shows_description() { - let issue = Issue { - key: "X-1".to_string(), - summary: "S".to_string(), - status: "Open".to_string(), - issue_type: "Task".to_string(), - assignee: None, - description: Some("This is the description.\nWith multiple lines.".to_string()), - updated: "2024-01-01T00:00:00Z".to_string(), - }; - let output = format_issue(&issue); - assert!(output.contains("Description:")); - assert!(output.contains("This is the description.")); - assert!(output.contains("With multiple lines.")); - } - - #[test] - fn format_status_colors_done() { - let output = format_status("Done"); - assert!(output.contains("\x1b[32m")); // green - assert!(output.contains("Done")); - } - - #[test] - fn format_status_colors_in_progress() { - let output = format_status("In Progress"); - assert!(output.contains("\x1b[33m")); // yellow - } - - #[test] - fn format_status_colors_to_do() { - let output = format_status("To Do"); - assert!(output.contains("\x1b[34m")); // blue - } - - #[test] - fn format_status_colors_in_review() { - let output = format_status("In Review"); - assert!(output.contains("\x1b[35m")); // magenta - } - - #[test] - fn format_status_colors_other() { - let output = format_status("Unknown Status"); - assert!(output.contains("\x1b[36m")); // cyan - } - - #[test] - fn format_date_parses_full_iso() { - let date = "2024-01-15T10:30:00.000+0000"; - let output = format_date(date); - assert_eq!(output, "2024-01-15 10:30:00"); - } - - #[test] - fn format_date_parses_iso_with_z() { - let date = "2024-01-15T10:30:00Z"; - let output = format_date(date); - assert_eq!(output, "2024-01-15 10:30:00Z"); - } - - #[test] - fn format_date_handles_simple() { - let date = "2024-01-15"; - let output = format_date(date); - assert_eq!(output, "2024-01-15"); - } - - #[test] - fn format_description_indents_lines() { - let desc = "Line 1\nLine 2\nLine 3"; - let output = format_description(desc); - assert!(output.contains(" Line 1\n")); - assert!(output.contains(" Line 2\n")); - assert!(output.contains(" Line 3\n")); - } - - #[test] - fn format_description_handles_empty() { - let output = format_description(""); - // Empty string produces empty output (no lines to format) - assert_eq!(output, ""); - } - - #[test] - fn format_description_handles_single_line() { - let output = format_description("Only one line"); - assert_eq!(output, " Only one line\n"); - } - - use super::super::types::{ - Comment, CreatedIssue, IssueCreate, IssueType, IssueUpdate, Transition, User, - }; - - // Mock client for testing process_show - struct MockJiraClient { - issue: Issue, - } - - impl JiraApi for MockJiraClient { - async fn get_current_user(&self) -> Result { - unimplemented!() - } - - async fn get_issue(&self, _key: &str) -> Result { - Ok(self.issue.clone()) - } - - async fn search_issues(&self, _jql: &str) -> Result> { - unimplemented!() - } - - async fn update_issue(&self, _key: &str, _update: &IssueUpdate) -> Result<()> { - unimplemented!() - } - - async fn get_transitions(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn transition_issue(&self, _key: &str, _transition_id: &str) -> Result<()> { - unimplemented!() - } - - async fn list_comments(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn create_issue(&self, _new: &IssueCreate) -> Result { - unimplemented!() - } - - async fn get_issue_types(&self, _project_key: &str) -> Result> { - unimplemented!() - } - } - - #[tokio::test] - async fn process_show_returns_formatted_issue() { - let client = MockJiraClient { - issue: Issue { - key: "TEST-999".to_string(), - summary: "Test issue".to_string(), - status: "Done".to_string(), - issue_type: "Story".to_string(), - assignee: Some("Tester".to_string()), - description: Some("Test description".to_string()), - updated: "2024-01-01T00:00:00Z".to_string(), - }, - }; - - let output = process_show(&client, "TEST-999").await.unwrap(); - assert!(output.contains("TEST-999")); - assert!(output.contains("Test issue")); - assert!(output.contains("Done")); - assert!(output.contains("Story")); - assert!(output.contains("Tester")); - assert!(output.contains("Test description")); - } -} diff --git a/src/jira/sprint.rs b/src/jira/sprint.rs deleted file mode 100644 index 79db074..0000000 --- a/src/jira/sprint.rs +++ /dev/null @@ -1,311 +0,0 @@ -use anyhow::Result; - -use super::client::{JiraApi, JiraClient}; -use super::types::Issue; - -/// Arguments for sprint command -#[derive(Debug, Clone, Default)] -pub struct SprintArgs { - // Reserved for future options (e.g., filter by project) - pub _placeholder: Option<()>, -} - -/// Run the jira sprint command -pub async fn run(_args: SprintArgs) -> Result<()> { - let client = JiraClient::new().await?; - let output = process_sprint(&client).await?; - print!("{}", output); - Ok(()) -} - -/// Process sprint command (business logic, testable) -pub async fn process_sprint(client: &impl JiraApi) -> Result { - // Use JQL to find all issues in active sprints - let jql = "sprint in openSprints() ORDER BY status ASC, updated DESC"; - let issues = client.search_issues(jql).await?; - - Ok(format_sprint_output(&issues)) -} - -/// Format sprint output -fn format_sprint_output(issues: &[Issue]) -> String { - let mut output = String::new(); - - // Header - output.push_str(&format!( - "\x1b[1mActive Sprint Issues\x1b[0m ({} total)\n\n", - issues.len() - )); - - if issues.is_empty() { - output.push_str("No issues in active sprints\n"); - return output; - } - - // Group by status - let mut by_status: std::collections::HashMap<&str, Vec<&Issue>> = - std::collections::HashMap::new(); - for issue in issues { - by_status.entry(&issue.status).or_default().push(issue); - } - - // Status order preference - let status_order = ["To Do", "In Progress", "In Review", "CODE REVIEW", "Done"]; - - // Output in order, then any remaining - for status in &status_order { - if let Some(issues) = by_status.remove(*status) { - output.push_str(&format_status_section(status, &issues)); - } - } - - // Remaining statuses - let mut remaining: Vec<_> = by_status.into_iter().collect(); - remaining.sort_by_key(|(status, _)| *status); - for (status, issues) in remaining { - output.push_str(&format_status_section(status, &issues)); - } - - output -} - -/// Format a status section -fn format_status_section(status: &str, issues: &[&Issue]) -> String { - let mut output = String::new(); - let status_color = match status { - "Done" => "\x1b[32m", // green - "In Progress" | "In Review" | "CODE REVIEW" => "\x1b[33m", // yellow - _ => "\x1b[34m", // blue - }; - output.push_str(&format!( - "{}{}\x1b[0m ({})\n", - status_color, - status, - issues.len() - )); - - for issue in issues { - let assignee = issue.assignee.as_deref().unwrap_or("Unassigned"); - output.push_str(&format!( - " {} {} \x1b[90m({})\x1b[0m\n", - issue.key, issue.summary, assignee - )); - } - output.push('\n'); - output -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn sprint_args_debug() { - let args = SprintArgs::default(); - let debug_str = format!("{:?}", args); - assert!(debug_str.contains("SprintArgs")); - } - - #[test] - fn sprint_args_clone() { - let args = SprintArgs::default(); - let cloned = args.clone(); - assert_eq!(cloned._placeholder, args._placeholder); - } - - #[test] - fn format_sprint_output_shows_header() { - let issues = vec![]; - let output = format_sprint_output(&issues); - assert!(output.contains("Active Sprint Issues")); - assert!(output.contains("0 total")); - assert!(output.contains("No issues in active sprints")); - } - - #[test] - fn format_sprint_output_groups_by_status() { - let issues = vec![ - Issue { - key: "A-1".to_string(), - summary: "Task 1".to_string(), - status: "To Do".to_string(), - issue_type: "Task".to_string(), - assignee: Some("Alice".to_string()), - description: None, - updated: "2024-01-01T00:00:00Z".to_string(), - }, - Issue { - key: "A-2".to_string(), - summary: "Task 2".to_string(), - status: "In Progress".to_string(), - issue_type: "Task".to_string(), - assignee: Some("Bob".to_string()), - description: None, - updated: "2024-01-01T00:00:00Z".to_string(), - }, - Issue { - key: "A-3".to_string(), - summary: "Task 3".to_string(), - status: "Done".to_string(), - issue_type: "Task".to_string(), - assignee: None, - description: None, - updated: "2024-01-01T00:00:00Z".to_string(), - }, - ]; - let output = format_sprint_output(&issues); - assert!(output.contains("A-1")); - assert!(output.contains("Task 1")); - assert!(output.contains("Alice")); - assert!(output.contains("A-2")); - assert!(output.contains("Bob")); - assert!(output.contains("A-3")); - assert!(output.contains("Unassigned")); - } - - #[test] - fn format_status_section_shows_count() { - let issue1 = Issue { - key: "X-1".to_string(), - summary: "S1".to_string(), - status: "Open".to_string(), - issue_type: "T".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }; - let issue2 = Issue { - key: "X-2".to_string(), - summary: "S2".to_string(), - status: "Open".to_string(), - issue_type: "T".to_string(), - assignee: Some("User".to_string()), - description: None, - updated: "U".to_string(), - }; - let issues = vec![&issue1, &issue2]; - let output = format_status_section("Open", &issues); - assert!(output.contains("Open")); - assert!(output.contains("(2)")); - assert!(output.contains("X-1")); - assert!(output.contains("X-2")); - } - - #[test] - fn format_status_section_color_codes() { - let empty: Vec<&Issue> = vec![]; - let done_output = format_status_section("Done", &empty); - assert!(done_output.contains("\x1b[32m")); // green - - let progress_output = format_status_section("In Progress", &empty); - assert!(progress_output.contains("\x1b[33m")); // yellow - - let other_output = format_status_section("Other", &empty); - assert!(other_output.contains("\x1b[34m")); // blue - } - - use super::super::types::{ - Comment, CreatedIssue, IssueCreate, IssueType, IssueUpdate, Transition, User, - }; - - // Mock client for testing process_sprint - struct MockJiraClient { - issues: Vec, - } - - impl JiraApi for MockJiraClient { - async fn get_current_user(&self) -> Result { - unimplemented!() - } - - async fn get_issue(&self, _key: &str) -> Result { - unimplemented!() - } - - async fn search_issues(&self, _jql: &str) -> Result> { - Ok(self.issues.clone()) - } - - async fn update_issue(&self, _key: &str, _update: &IssueUpdate) -> Result<()> { - unimplemented!() - } - - async fn get_transitions(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn transition_issue(&self, _key: &str, _transition_id: &str) -> Result<()> { - unimplemented!() - } - - async fn list_comments(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn create_issue(&self, _new: &IssueCreate) -> Result { - unimplemented!() - } - - async fn get_issue_types(&self, _project_key: &str) -> Result> { - unimplemented!() - } - } - - #[tokio::test] - async fn process_sprint_returns_issues() { - let client = MockJiraClient { - issues: vec![Issue { - key: "TEST-1".to_string(), - summary: "Test issue".to_string(), - status: "In Progress".to_string(), - issue_type: "Task".to_string(), - assignee: Some("Dev".to_string()), - description: None, - updated: "2024-01-01".to_string(), - }], - }; - - let output = process_sprint(&client).await.unwrap(); - assert!(output.contains("TEST-1")); - assert!(output.contains("Test issue")); - assert!(output.contains("In Progress")); - } - - #[tokio::test] - async fn process_sprint_handles_empty() { - let client = MockJiraClient { issues: vec![] }; - - let output = process_sprint(&client).await.unwrap(); - assert!(output.contains("No issues in active sprints")); - } - - #[test] - fn format_sprint_output_handles_unknown_status() { - // Test that unknown statuses (not in status_order) are still displayed - let issues = vec![ - Issue { - key: "A-1".to_string(), - summary: "Task with custom status".to_string(), - status: "Custom Status".to_string(), - issue_type: "Task".to_string(), - assignee: Some("Alice".to_string()), - description: None, - updated: "2024-01-01T00:00:00Z".to_string(), - }, - Issue { - key: "A-2".to_string(), - summary: "Task with another status".to_string(), - status: "Another Custom".to_string(), - issue_type: "Task".to_string(), - assignee: None, - description: None, - updated: "2024-01-01T00:00:00Z".to_string(), - }, - ]; - let output = format_sprint_output(&issues); - assert!(output.contains("Custom Status")); - assert!(output.contains("A-1")); - assert!(output.contains("Another Custom")); - assert!(output.contains("A-2")); - } -} diff --git a/src/jira/sprints.rs b/src/jira/sprints.rs deleted file mode 100644 index 6b97aa1..0000000 --- a/src/jira/sprints.rs +++ /dev/null @@ -1,131 +0,0 @@ -use anyhow::{bail, Result}; -use std::collections::HashMap; - -use super::client::JiraClient; - -/// Sprint info extracted from issue custom fields. -#[derive(Debug, Clone)] -struct SprintInfo { - id: i64, - name: String, - state: String, - start_date: Option, - end_date: Option, - goal: Option, - board_id: Option, -} - -/// Run the sprints command — list sprints via JQL. -pub async fn run(state: &str) -> Result<()> { - let valid = ["active", "future", "closed"]; - if !valid.contains(&state) { - bail!("Invalid state '{}'. Use: {}", state, valid.join(", ")); - } - - let client = JiraClient::new().await?; - - let jql = match state { - "active" => "sprint in openSprints()", - "future" => "sprint in futureSprints()", - "closed" => "sprint in closedSprints() ORDER BY updated DESC", - _ => unreachable!(), - }; - - // Sprint data lives in a custom field - let sprint_field = find_sprint_field_id(&client).await?; - - // Search issues with the sprint field - let raw = client.search_raw(jql, &[&sprint_field], 50).await?; - - // Extract unique sprints from all issues - let mut sprints: HashMap = HashMap::new(); - if let Some(issue_arr) = raw["issues"].as_array() { - for issue in issue_arr { - if let Some(sprint_arr) = issue["fields"][&sprint_field].as_array() { - for s in sprint_arr { - let id = s["id"].as_i64().unwrap_or(0); - let sprint_state = s["state"].as_str().unwrap_or(""); - if id > 0 && !sprints.contains_key(&id) && sprint_state == state { - sprints.insert( - id, - SprintInfo { - id, - name: s["name"].as_str().unwrap_or("?").to_string(), - state: sprint_state.to_string(), - start_date: s["startDate"].as_str().map(String::from), - end_date: s["endDate"].as_str().map(String::from), - goal: s["goal"] - .as_str() - .filter(|g| !g.is_empty()) - .map(String::from), - board_id: s["boardId"].as_i64(), - }, - ); - } - } - } - } - } - - if sprints.is_empty() { - println!("No {} sprints found", state); - return Ok(()); - } - - let mut sorted: Vec<_> = sprints.into_values().collect(); - sorted.sort_by_key(|s| s.id); - - println!( - "\x1b[1mSprints\x1b[0m ({} found, filter: {})\n", - sorted.len(), - state - ); - - for sprint in &sorted { - let color = match sprint.state.as_str() { - "active" => "\x1b[32m", - "future" => "\x1b[34m", - "closed" => "\x1b[90m", - _ => "\x1b[0m", - }; - println!(" {}{}\x1b[0m {}", color, sprint.state, sprint.name); - if let Some(start) = &sprint.start_date { - let end = sprint.end_date.as_deref().unwrap_or("?"); - let start = start.split('T').next().unwrap_or(start); - let end = end.split('T').next().unwrap_or(end); - println!(" {} → {}", start, end); - } - if let Some(goal) = &sprint.goal { - println!(" Goal: {}", goal); - } - if let Some(board_id) = sprint.board_id { - println!(" Board ID: {}", board_id); - } - println!(); - } - - Ok(()) -} - -/// Find the sprint custom field ID. -/// Jira instances often have multiple "Sprint" fields — prefer the -/// well-known customfield_10020. -async fn find_sprint_field_id(client: &JiraClient) -> Result { - let fields = client.list_fields().await?; - let mut candidates: Vec = Vec::new(); - for field in fields { - let name = field["name"].as_str().unwrap_or(""); - let id = field["id"].as_str().unwrap_or(""); - if name == "Sprint" && id.starts_with("customfield_") { - candidates.push(id.to_string()); - } - } - if candidates.is_empty() { - bail!("Sprint custom field not found"); - } - if candidates.contains(&"customfield_10020".to_string()) { - Ok("customfield_10020".to_string()) - } else { - Ok(candidates.remove(0)) - } -} diff --git a/src/jira/tickets.rs b/src/jira/tickets.rs deleted file mode 100644 index 180a9f4..0000000 --- a/src/jira/tickets.rs +++ /dev/null @@ -1,378 +0,0 @@ -use anyhow::Result; - -use super::client::{JiraApi, JiraClient}; -use super::types::Issue; - -// ANSI color codes -const GREEN: &str = "\x1b[32m"; -const YELLOW: &str = "\x1b[33m"; -const BLUE: &str = "\x1b[34m"; -const GRAY: &str = "\x1b[90m"; -const BOLD: &str = "\x1b[1m"; -const RESET: &str = "\x1b[0m"; - -/// Run the jira tickets command (list current sprint tickets assigned to me) -pub async fn run() -> Result<()> { - let client = JiraClient::new().await?; - let output = process_tickets(&client).await?; - print!("{}", output); - Ok(()) -} - -/// Process tickets command (business logic, testable) -pub async fn process_tickets(client: &impl JiraApi) -> Result { - // Use JQL to find issues in active sprints assigned to current user - let jql = - "sprint in openSprints() AND assignee = currentUser() ORDER BY status ASC, updated DESC"; - let issues = client.search_issues(jql).await?; - - Ok(format_tickets(&issues)) -} - -fn get_terminal_width() -> usize { - terminal_size::terminal_size() - .map(|(w, _)| w.0 as usize) - .unwrap_or(120) -} - -/// Format tickets as a table -fn format_tickets(issues: &[Issue]) -> String { - let mut output = String::new(); - let term_width = get_terminal_width(); - - // Header - output.push_str(&format!( - "{}My Sprint Tickets{} ({} issues)\n\n", - BOLD, - RESET, - issues.len() - )); - - if issues.is_empty() { - output.push_str("No tickets assigned to you in active sprints\n"); - return output; - } - - // Calculate column widths based on content - let key_width = issues - .iter() - .map(|i| i.key.chars().count()) - .max() - .unwrap_or(4) - .max(4); - let status_width = issues - .iter() - .map(|i| i.status.chars().count()) - .max() - .unwrap_or(6) - .max(6); - let type_width = issues - .iter() - .map(|i| i.issue_type.chars().count()) - .max() - .unwrap_or(4) - .max(4); - - // Layout: │ Key │ Status │ Type │ Summary │ - // Borders take: 5 separators × 3 chars = 15 chars - let border_overhead = 15; - let fixed_cols = key_width + status_width + type_width; - let available_for_summary = term_width - .saturating_sub(border_overhead + fixed_cols) - .max(20); - - // Top border - output.push_str(&format!( - "┌{}┬{}┬{}┬{}┐\n", - "─".repeat(key_width + 2), - "─".repeat(status_width + 2), - "─".repeat(type_width + 2), - "─".repeat(available_for_summary + 2) - )); - - // Header row - output.push_str(&format!( - "│ {}{: GREEN, - "In Progress" | "In Review" | "CODE REVIEW" => YELLOW, - _ => BLUE, - }; - - let summary_display = truncate(&issue.summary, available_for_summary); - - output.push_str(&format!( - "│ {: String { - if s.chars().count() <= max_len { - s.to_string() - } else { - let truncated: String = s.chars().take(max_len.saturating_sub(1)).collect(); - format!("{}…", truncated) - } -} - -#[cfg(test)] -mod tests { - use super::super::types::{ - Comment, CreatedIssue, IssueCreate, IssueType, IssueUpdate, Transition, User, - }; - use super::*; - - #[test] - fn truncate_short_unchanged() { - assert_eq!(truncate("hello", 10), "hello"); - } - - #[test] - fn truncate_long_adds_ellipsis() { - assert_eq!(truncate("hello world", 8), "hello w…"); - } - - #[test] - fn truncate_unicode() { - assert_eq!(truncate("héllo", 5), "héllo"); - assert_eq!(truncate("héllo world", 6), "héllo…"); - } - - #[test] - fn truncate_exact_length() { - assert_eq!(truncate("hello", 5), "hello"); - } - - #[test] - fn get_terminal_width_returns_reasonable_value() { - let width = get_terminal_width(); - assert!(width >= 20); - } - - #[test] - fn format_tickets_empty() { - let issues: Vec = vec![]; - let output = format_tickets(&issues); - assert!(output.contains("My Sprint Tickets")); - assert!(output.contains("0 issues")); - assert!(output.contains("No tickets assigned")); - } - - #[test] - fn format_tickets_with_issues() { - let issues = vec![ - Issue { - key: "A-1".to_string(), - summary: "First task".to_string(), - status: "Done".to_string(), - issue_type: "Task".to_string(), - assignee: Some("Alice".to_string()), - description: None, - updated: "U".to_string(), - }, - Issue { - key: "A-2".to_string(), - summary: "Second task".to_string(), - status: "In Progress".to_string(), - issue_type: "Bug".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }, - ]; - let output = format_tickets(&issues); - assert!(output.contains("My Sprint Tickets")); - assert!(output.contains("2 issues")); - assert!(output.contains("A-1")); - assert!(output.contains("A-2")); - assert!(output.contains("First task")); - assert!(output.contains("Second task")); - assert!(output.contains("Task")); - assert!(output.contains("Bug")); - // Box-drawing characters - assert!(output.contains("┌")); - assert!(output.contains("┐")); - assert!(output.contains("└")); - assert!(output.contains("┘")); - assert!(output.contains("│")); - assert!(output.contains("─")); - } - - #[test] - fn format_tickets_colors_status() { - let issues = vec![ - Issue { - key: "X-1".to_string(), - summary: "S".to_string(), - status: "Done".to_string(), - issue_type: "T".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }, - Issue { - key: "X-2".to_string(), - summary: "S".to_string(), - status: "In Progress".to_string(), - issue_type: "T".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }, - Issue { - key: "X-3".to_string(), - summary: "S".to_string(), - status: "To Do".to_string(), - issue_type: "T".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }, - ]; - let output = format_tickets(&issues); - assert!(output.contains(GREEN)); // Done - assert!(output.contains(YELLOW)); // In Progress - assert!(output.contains(BLUE)); // To Do - } - - #[test] - fn format_tickets_handles_long_summary() { - // Summary must be >200 chars to ensure truncation even on wide terminals - let issues = vec![Issue { - key: "LONG-123".to_string(), - summary: "A".repeat(250), - status: "Open".to_string(), - issue_type: "Story".to_string(), - assignee: Some("A Very Long Username".to_string()), - description: None, - updated: "U".to_string(), - }]; - let output = format_tickets(&issues); - // Should contain truncation indicator - assert!(output.contains("…")); - } - - // Mock client for testing - struct MockJiraClient { - issues: Vec, - } - - impl JiraApi for MockJiraClient { - async fn get_current_user(&self) -> Result { - unimplemented!() - } - - async fn get_issue(&self, _key: &str) -> Result { - unimplemented!() - } - - async fn search_issues(&self, _jql: &str) -> Result> { - Ok(self.issues.clone()) - } - - async fn update_issue(&self, _key: &str, _update: &IssueUpdate) -> Result<()> { - unimplemented!() - } - - async fn get_transitions(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn transition_issue(&self, _key: &str, _transition_id: &str) -> Result<()> { - unimplemented!() - } - - async fn list_comments(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn create_issue(&self, _new: &IssueCreate) -> Result { - unimplemented!() - } - - async fn get_issue_types(&self, _project_key: &str) -> Result> { - unimplemented!() - } - } - - #[tokio::test] - async fn process_tickets_returns_issues() { - let client = MockJiraClient { - issues: vec![Issue { - key: "TEST-1".to_string(), - summary: "Test issue".to_string(), - status: "Open".to_string(), - issue_type: "Task".to_string(), - assignee: Some("Me".to_string()), - description: None, - updated: "2024-01-01".to_string(), - }], - }; - - let output = process_tickets(&client).await.unwrap(); - assert!(output.contains("TEST-1")); - assert!(output.contains("Test issue")); - } - - #[tokio::test] - async fn process_tickets_handles_empty() { - let client = MockJiraClient { issues: vec![] }; - - let output = process_tickets(&client).await.unwrap(); - assert!(output.contains("No tickets assigned")); - } -} diff --git a/src/jira/types.rs b/src/jira/types.rs deleted file mode 100644 index ac18400..0000000 --- a/src/jira/types.rs +++ /dev/null @@ -1,424 +0,0 @@ -use serde::{Deserialize, Serialize}; - -/// Jira user -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct User { - pub account_id: String, - pub display_name: String, - pub email_address: Option, -} - -/// Jira issue -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Issue { - pub key: String, - pub summary: String, - pub status: String, - pub issue_type: String, - pub assignee: Option, - pub description: Option, - pub updated: String, -} - -/// Jira sprint (from Agile API) -#[derive(Debug, Clone, Serialize, Deserialize)] -#[allow(dead_code)] -pub struct Sprint { - pub id: i64, - pub name: String, - pub state: String, - #[serde(rename = "startDate")] - pub start_date: Option, - #[serde(rename = "endDate")] - pub end_date: Option, - pub goal: Option, -} - -/// Fields to update on an issue. -/// -/// `description` is interpreted as Markdown and converted to ADF before -/// upload (the modern atlassian.net editor only accepts ADF). For raw -/// passthrough — e.g. cross-tool ADF generation, mention nodes, panels -/// — set `description_adf` to a pre-built ADF document instead. When -/// both are set, `description_adf` wins. -#[derive(Debug, Clone, Default)] -pub struct IssueUpdate { - pub summary: Option, - pub description: Option, - pub description_adf: Option, - pub assignee: Option, -} - -/// Issue transition (status change) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Transition { - pub id: String, - pub name: String, -} - -/// Fields needed to create a new issue. -/// -/// `description` is interpreted as Markdown; `description_adf` is raw ADF -/// passthrough. Same precedence as [`IssueUpdate`]: ADF wins when both -/// are set. -#[derive(Debug, Clone, Default)] -pub struct IssueCreate { - pub project_key: String, - pub summary: String, - pub issue_type: String, - pub description: Option, - pub description_adf: Option, - pub assignee: Option, -} - -/// One issue type as advertised by `GET /issue/createmeta/{key}/issuetypes`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct IssueType { - pub id: String, - pub name: String, - pub description: Option, -} - -/// Result of [`JiraApi::create_issue`]. `url` is the human-facing -/// browse URL for the new issue. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct CreatedIssue { - pub id: String, - pub key: String, - pub url: String, -} - -/// A single comment on a Jira issue. -/// -/// `body` is the plain-text rendering used for table output; -/// `body_adf` is the raw ADF document preserved for JSON output and -/// any future full-fidelity rendering. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Comment { - pub id: String, - pub author: User, - pub body: String, - pub body_adf: serde_json::Value, - pub created: String, - pub updated: String, -} - -/// OAuth configuration for Jira -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OAuthConfig { - pub client_id: String, - pub client_secret: String, -} - -/// Accessible Jira Cloud resource -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct AccessibleResource { - pub id: String, - pub url: String, - pub name: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn user_clone() { - let user = User { - account_id: "123".to_string(), - display_name: "John Doe".to_string(), - email_address: Some("john@example.com".to_string()), - }; - let cloned = user.clone(); - assert_eq!(cloned.account_id, user.account_id); - assert_eq!(cloned.display_name, user.display_name); - assert_eq!(cloned.email_address, user.email_address); - } - - #[test] - fn user_without_email() { - let user = User { - account_id: "456".to_string(), - display_name: "Jane".to_string(), - email_address: None, - }; - assert!(user.email_address.is_none()); - } - - #[test] - fn user_debug_format() { - let user = User { - account_id: "id".to_string(), - display_name: "name".to_string(), - email_address: None, - }; - let debug_str = format!("{:?}", user); - assert!(debug_str.contains("User")); - } - - #[test] - fn user_serialize() { - let user = User { - account_id: "123".to_string(), - display_name: "John".to_string(), - email_address: Some("john@test.com".to_string()), - }; - let json = serde_json::to_string(&user).unwrap(); - assert!(json.contains("account_id")); - assert!(json.contains("123")); - } - - #[test] - fn user_deserialize() { - let json = r#"{"account_id":"abc","display_name":"Test","email_address":null}"#; - let user: User = serde_json::from_str(json).unwrap(); - assert_eq!(user.account_id, "abc"); - assert_eq!(user.display_name, "Test"); - assert!(user.email_address.is_none()); - } - - #[test] - fn issue_clone() { - let issue = Issue { - key: "PROJ-123".to_string(), - summary: "Fix bug".to_string(), - status: "In Progress".to_string(), - issue_type: "Bug".to_string(), - assignee: Some("john".to_string()), - description: Some("A bug description".to_string()), - updated: "2024-01-15T10:00:00Z".to_string(), - }; - let cloned = issue.clone(); - assert_eq!(cloned.key, issue.key); - assert_eq!(cloned.summary, issue.summary); - assert_eq!(cloned.status, issue.status); - } - - #[test] - fn issue_without_optional_fields() { - let issue = Issue { - key: "PROJ-456".to_string(), - summary: "Task".to_string(), - status: "Open".to_string(), - issue_type: "Task".to_string(), - assignee: None, - description: None, - updated: "2024-01-15T12:00:00Z".to_string(), - }; - assert!(issue.assignee.is_none()); - assert!(issue.description.is_none()); - } - - #[test] - fn issue_debug_format() { - let issue = Issue { - key: "K".to_string(), - summary: "S".to_string(), - status: "St".to_string(), - issue_type: "T".to_string(), - assignee: None, - description: None, - updated: "U".to_string(), - }; - let debug_str = format!("{:?}", issue); - assert!(debug_str.contains("Issue")); - } - - #[test] - fn issue_serialize() { - let issue = Issue { - key: "TEST-1".to_string(), - summary: "Test issue".to_string(), - status: "Done".to_string(), - issue_type: "Story".to_string(), - assignee: Some("user".to_string()), - description: Some("desc".to_string()), - updated: "2024-01-01T00:00:00Z".to_string(), - }; - let json = serde_json::to_string(&issue).unwrap(); - assert!(json.contains("TEST-1")); - assert!(json.contains("Test issue")); - } - - #[test] - fn issue_deserialize() { - let json = r#"{ - "key": "X-1", - "summary": "Sum", - "status": "Open", - "issue_type": "Bug", - "assignee": null, - "description": null, - "updated": "2024-01-01T00:00:00Z" - }"#; - let issue: Issue = serde_json::from_str(json).unwrap(); - assert_eq!(issue.key, "X-1"); - assert_eq!(issue.summary, "Sum"); - } - - #[test] - fn issue_update_default() { - let update = IssueUpdate::default(); - assert!(update.summary.is_none()); - assert!(update.description.is_none()); - assert!(update.assignee.is_none()); - } - - #[test] - fn issue_update_clone() { - let update = IssueUpdate { - summary: Some("New summary".to_string()), - description: Some("New desc".to_string()), - description_adf: None, - assignee: Some("user123".to_string()), - }; - let cloned = update.clone(); - assert_eq!(cloned.summary, update.summary); - assert_eq!(cloned.description, update.description); - assert_eq!(cloned.assignee, update.assignee); - } - - #[test] - fn issue_update_debug_format() { - let update = IssueUpdate::default(); - let debug_str = format!("{:?}", update); - assert!(debug_str.contains("IssueUpdate")); - } - - #[test] - fn issue_update_partial() { - let update = IssueUpdate { - summary: Some("Only summary".to_string()), - description: None, - description_adf: None, - assignee: None, - }; - assert!(update.summary.is_some()); - assert!(update.description.is_none()); - } - - #[test] - fn transition_clone() { - let transition = Transition { - id: "31".to_string(), - name: "In Progress".to_string(), - }; - let cloned = transition.clone(); - assert_eq!(cloned.id, transition.id); - assert_eq!(cloned.name, transition.name); - } - - #[test] - fn transition_debug_format() { - let transition = Transition { - id: "1".to_string(), - name: "T".to_string(), - }; - let debug_str = format!("{:?}", transition); - assert!(debug_str.contains("Transition")); - } - - #[test] - fn transition_serialize() { - let transition = Transition { - id: "21".to_string(), - name: "Done".to_string(), - }; - let json = serde_json::to_string(&transition).unwrap(); - assert!(json.contains("21")); - assert!(json.contains("Done")); - } - - #[test] - fn transition_deserialize() { - let json = r#"{"id": "11", "name": "To Do"}"#; - let transition: Transition = serde_json::from_str(json).unwrap(); - assert_eq!(transition.id, "11"); - assert_eq!(transition.name, "To Do"); - } - - #[test] - fn oauth_config_clone() { - let config = OAuthConfig { - client_id: "id123".to_string(), - client_secret: "secret456".to_string(), - }; - let cloned = config.clone(); - assert_eq!(cloned.client_id, config.client_id); - assert_eq!(cloned.client_secret, config.client_secret); - } - - #[test] - fn oauth_config_debug_format() { - let config = OAuthConfig { - client_id: "id".to_string(), - client_secret: "secret".to_string(), - }; - let debug_str = format!("{:?}", config); - assert!(debug_str.contains("OAuthConfig")); - } - - #[test] - fn oauth_config_serialize() { - let config = OAuthConfig { - client_id: "test_id".to_string(), - client_secret: "test_secret".to_string(), - }; - let json = serde_json::to_string(&config).unwrap(); - assert!(json.contains("test_id")); - assert!(json.contains("test_secret")); - } - - #[test] - fn oauth_config_deserialize() { - let json = r#"{"client_id": "cid", "client_secret": "csec"}"#; - let config: OAuthConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.client_id, "cid"); - assert_eq!(config.client_secret, "csec"); - } - - #[test] - fn accessible_resource_clone() { - let resource = AccessibleResource { - id: "cloud-123".to_string(), - url: "https://example.atlassian.net".to_string(), - name: "Example Site".to_string(), - }; - let cloned = resource.clone(); - assert_eq!(cloned.id, resource.id); - assert_eq!(cloned.url, resource.url); - assert_eq!(cloned.name, resource.name); - } - - #[test] - fn accessible_resource_debug_format() { - let resource = AccessibleResource { - id: "id".to_string(), - url: "url".to_string(), - name: "name".to_string(), - }; - let debug_str = format!("{:?}", resource); - assert!(debug_str.contains("AccessibleResource")); - } - - #[test] - fn accessible_resource_serialize() { - let resource = AccessibleResource { - id: "res-id".to_string(), - url: "https://test.atlassian.net".to_string(), - name: "Test Site".to_string(), - }; - let json = serde_json::to_string(&resource).unwrap(); - assert!(json.contains("res-id")); - assert!(json.contains("https://test.atlassian.net")); - } - - #[test] - fn accessible_resource_deserialize() { - let json = r#"{"id": "abc", "url": "https://x.atlassian.net", "name": "X"}"#; - let resource: AccessibleResource = serde_json::from_str(json).unwrap(); - assert_eq!(resource.id, "abc"); - assert_eq!(resource.url, "https://x.atlassian.net"); - assert_eq!(resource.name, "X"); - } -} diff --git a/src/jira/update/mod.rs b/src/jira/update/mod.rs deleted file mode 100644 index 58d54f7..0000000 --- a/src/jira/update/mod.rs +++ /dev/null @@ -1,155 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::{bail, Context, Result}; - -use super::client::{JiraApi, JiraClient}; -use super::types::{IssueUpdate, Transition}; - -#[cfg(test)] -mod tests; - -/// Arguments for update command -#[derive(Debug, Clone)] -pub struct UpdateArgs { - pub key: String, - pub summary: Option, - pub status: Option, - pub assign: Option, - pub body: Option, - pub body_adf: Option, -} - -/// Run the jira update command -pub async fn run(args: UpdateArgs) -> Result<()> { - let client = JiraClient::new().await?; - let output = process_update(&client, &args).await?; - print!("{}", output); - Ok(()) -} - -/// Process update command (business logic, testable) -pub async fn process_update(client: &impl JiraApi, args: &UpdateArgs) -> Result { - let mut output = String::new(); - let mut changes_made = false; - - // Resolve --body-adf to an ADF Value up-front so we can short-circuit - // on a missing or malformed file before we touch the network. - let description_adf = match &args.body_adf { - Some(path) => Some(load_adf(path)?), - None => None, - }; - - // Handle field updates - let has_field_updates = args.summary.is_some() - || args.assign.is_some() - || args.body.is_some() - || description_adf.is_some(); - if has_field_updates { - let assignee = match &args.assign { - Some(a) if a == "me" => { - let user = client.get_current_user().await?; - Some(user.account_id) - } - Some(a) => Some(a.clone()), - None => None, - }; - - let update = IssueUpdate { - summary: args.summary.clone(), - description: args.body.clone(), - description_adf, - assignee, - }; - - client.update_issue(&args.key, &update).await?; - changes_made = true; - - if let Some(summary) = &args.summary { - output.push_str(&format!( - "\x1b[32m\u{2713}\x1b[0m Updated summary: \"{}\"\n", - summary - )); - } - if args.body.is_some() { - output.push_str("\x1b[32m\u{2713}\x1b[0m Updated description\n"); - } - if args.body_adf.is_some() { - output.push_str("\x1b[32m\u{2713}\x1b[0m Updated description (raw ADF)\n"); - } - if args.assign.is_some() { - output.push_str("\x1b[32m\u{2713}\x1b[0m Updated assignee\n"); - } - } - - // Handle status transition - if let Some(target_status) = &args.status { - let transitions = client.get_transitions(&args.key).await?; - let transition = find_transition(&transitions, target_status)?; - - client.transition_issue(&args.key, &transition.id).await?; - changes_made = true; - - output.push_str(&format!( - "\x1b[32m\u{2713}\x1b[0m Transitioned to: {}\n", - transition.name - )); - } - - if !changes_made { - bail!("No changes specified. Use --summary, --body, --body-adf, --status, or --assign."); - } - - Ok(output) -} - -/// Find a transition by name (case-insensitive) -fn find_transition<'a>(transitions: &'a [Transition], target: &str) -> Result<&'a Transition> { - let target_lower = target.to_lowercase(); - - // Exact match first - if let Some(t) = transitions - .iter() - .find(|t| t.name.to_lowercase() == target_lower) - { - return Ok(t); - } - - // Partial match - if let Some(t) = transitions - .iter() - .find(|t| t.name.to_lowercase().contains(&target_lower)) - { - return Ok(t); - } - - // Build error message with available transitions - let available: Vec<_> = transitions.iter().map(|t| t.name.as_str()).collect(); - bail!( - "Status '{}' not found. Available transitions: {}", - target, - available.join(", ") - ) -} - -/// Read an ADF document from a file and validate it has the expected -/// `{"type": "doc", "version": 1, "content": [...]}` shape. Returning -/// early here gives the user a clear error before any HTTP round-trip. -pub(crate) fn load_adf(path: &Path) -> Result { - let raw = std::fs::read_to_string(path) - .with_context(|| format!("Failed to read ADF file {}", path.display()))?; - let value: serde_json::Value = serde_json::from_str(&raw) - .with_context(|| format!("Invalid JSON in ADF file {}", path.display()))?; - if value["type"].as_str() != Some("doc") { - bail!( - "ADF file {} is missing top-level \"type\": \"doc\"", - path.display() - ); - } - if !value["content"].is_array() { - bail!( - "ADF file {} is missing top-level \"content\" array", - path.display() - ); - } - Ok(value) -} diff --git a/src/jira/update/tests.rs b/src/jira/update/tests.rs deleted file mode 100644 index 794e7b3..0000000 --- a/src/jira/update/tests.rs +++ /dev/null @@ -1,410 +0,0 @@ -use std::io::Write; - -use serde_json::json; -use tempfile::NamedTempFile; - -use super::super::types::User; -use super::*; - -fn empty_args(key: &str) -> UpdateArgs { - UpdateArgs { - key: key.to_string(), - summary: None, - status: None, - assign: None, - body: None, - body_adf: None, - } -} - -#[test] -fn update_args_debug() { - let args = UpdateArgs { - summary: Some("New".to_string()), - ..empty_args("X-1") - }; - let debug_str = format!("{:?}", args); - assert!(debug_str.contains("UpdateArgs")); -} - -#[test] -fn update_args_clone() { - let args = UpdateArgs { - summary: Some("S".to_string()), - status: Some("Done".to_string()), - assign: Some("user".to_string()), - body: Some("B".to_string()), - ..empty_args("X-1") - }; - let cloned = args.clone(); - assert_eq!(cloned.key, args.key); - assert_eq!(cloned.summary, args.summary); - assert_eq!(cloned.status, args.status); - assert_eq!(cloned.assign, args.assign); -} - -#[test] -fn find_transition_exact_match() { - let transitions = vec![ - Transition { - id: "11".to_string(), - name: "To Do".to_string(), - }, - Transition { - id: "21".to_string(), - name: "In Progress".to_string(), - }, - Transition { - id: "31".to_string(), - name: "Done".to_string(), - }, - ]; - - let t = find_transition(&transitions, "Done").unwrap(); - assert_eq!(t.id, "31"); - assert_eq!(t.name, "Done"); -} - -#[test] -fn find_transition_case_insensitive() { - let transitions = vec![Transition { - id: "21".to_string(), - name: "In Progress".to_string(), - }]; - - let t = find_transition(&transitions, "in progress").unwrap(); - assert_eq!(t.id, "21"); - - let t2 = find_transition(&transitions, "IN PROGRESS").unwrap(); - assert_eq!(t2.id, "21"); -} - -#[test] -fn find_transition_partial_match() { - let transitions = vec![ - Transition { - id: "11".to_string(), - name: "Start Progress".to_string(), - }, - Transition { - id: "21".to_string(), - name: "In Progress".to_string(), - }, - ]; - - let t = find_transition(&transitions, "progress").unwrap(); - assert!(t.name.contains("Progress")); -} - -#[test] -fn find_transition_not_found() { - let transitions = vec![ - Transition { - id: "11".to_string(), - name: "To Do".to_string(), - }, - Transition { - id: "31".to_string(), - name: "Done".to_string(), - }, - ]; - - let result = find_transition(&transitions, "In Progress"); - assert!(result.is_err()); - let err = result.unwrap_err().to_string(); - assert!(err.contains("In Progress")); - assert!(err.contains("To Do")); - assert!(err.contains("Done")); -} - -#[test] -fn find_transition_empty_list() { - let transitions: Vec = vec![]; - let result = find_transition(&transitions, "Done"); - assert!(result.is_err()); -} - -#[test] -fn load_adf_accepts_well_formed_doc() { - let mut file = NamedTempFile::new().unwrap(); - let doc = json!({ - "type": "doc", - "version": 1, - "content": [{"type": "paragraph", "content": [{"type": "text", "text": "hi"}]}] - }); - file.write_all(doc.to_string().as_bytes()).unwrap(); - let loaded = load_adf(file.path()).unwrap(); - assert_eq!(loaded["type"], "doc"); - assert_eq!(loaded["content"][0]["type"], "paragraph"); -} - -#[test] -fn load_adf_rejects_missing_doc_type() { - let mut file = NamedTempFile::new().unwrap(); - file.write_all(br#"{"type": "paragraph", "content": []}"#) - .unwrap(); - let err = load_adf(file.path()).unwrap_err().to_string(); - assert!(err.contains("\"type\": \"doc\"")); -} - -#[test] -fn load_adf_rejects_missing_content_array() { - let mut file = NamedTempFile::new().unwrap(); - file.write_all(br#"{"type": "doc", "version": 1}"#).unwrap(); - let err = load_adf(file.path()).unwrap_err().to_string(); - assert!(err.contains("content")); -} - -#[test] -fn load_adf_rejects_invalid_json() { - let mut file = NamedTempFile::new().unwrap(); - file.write_all(b"not json").unwrap(); - let err = load_adf(file.path()).unwrap_err().to_string(); - assert!(err.contains("Invalid JSON")); -} - -#[test] -fn load_adf_rejects_missing_file() { - let err = load_adf(std::path::Path::new("/nonexistent/path/no.json")) - .unwrap_err() - .to_string(); - assert!(err.contains("Failed to read")); -} - -// Mock client for testing process_update -struct MockJiraClient { - user: User, - transitions: Vec, - updated_fields: std::sync::Mutex>, - transitioned_to: std::sync::Mutex>, -} - -impl JiraApi for MockJiraClient { - async fn get_current_user(&self) -> Result { - Ok(self.user.clone()) - } - - async fn get_issue(&self, _key: &str) -> Result { - unimplemented!() - } - - async fn search_issues(&self, _jql: &str) -> Result> { - unimplemented!() - } - - async fn update_issue(&self, _key: &str, update: &IssueUpdate) -> Result<()> { - *self.updated_fields.lock().unwrap() = Some(update.clone()); - Ok(()) - } - - async fn get_transitions(&self, _key: &str) -> Result> { - Ok(self.transitions.clone()) - } - - async fn transition_issue(&self, _key: &str, transition_id: &str) -> Result<()> { - *self.transitioned_to.lock().unwrap() = Some(transition_id.to_string()); - Ok(()) - } - - async fn list_comments(&self, _key: &str) -> Result> { - unimplemented!() - } - - async fn create_issue( - &self, - _new: &super::super::types::IssueCreate, - ) -> Result { - unimplemented!() - } - - async fn get_issue_types( - &self, - _project_key: &str, - ) -> Result> { - unimplemented!() - } -} - -fn make_mock(user_account_id: &str, transitions: Vec) -> MockJiraClient { - MockJiraClient { - user: User { - account_id: user_account_id.to_string(), - display_name: "Me".to_string(), - email_address: None, - }, - transitions, - updated_fields: std::sync::Mutex::new(None), - transitioned_to: std::sync::Mutex::new(None), - } -} - -#[tokio::test] -async fn process_update_changes_summary() { - let client = make_mock("me123", vec![]); - - let args = UpdateArgs { - summary: Some("New summary".to_string()), - ..empty_args("X-1") - }; - - let output = process_update(&client, &args).await.unwrap(); - assert!(output.contains("Updated summary")); - assert!(output.contains("New summary")); - - let updated = client.updated_fields.lock().unwrap(); - assert!(updated.is_some()); - assert_eq!( - updated.as_ref().unwrap().summary, - Some("New summary".to_string()) - ); -} - -#[tokio::test] -async fn process_update_assigns_to_me() { - let client = make_mock("my-account-id", vec![]); - - let args = UpdateArgs { - assign: Some("me".to_string()), - ..empty_args("X-1") - }; - - let output = process_update(&client, &args).await.unwrap(); - assert!(output.contains("Updated assignee")); - - let updated = client.updated_fields.lock().unwrap(); - assert_eq!( - updated.as_ref().unwrap().assignee, - Some("my-account-id".to_string()) - ); -} - -#[tokio::test] -async fn process_update_assigns_to_user() { - let client = make_mock("me", vec![]); - - let args = UpdateArgs { - assign: Some("other-user-123".to_string()), - ..empty_args("X-1") - }; - - let output = process_update(&client, &args).await.unwrap(); - assert!(output.contains("Updated assignee")); - - let updated = client.updated_fields.lock().unwrap(); - assert_eq!( - updated.as_ref().unwrap().assignee, - Some("other-user-123".to_string()) - ); -} - -#[tokio::test] -async fn process_update_transitions_status() { - let client = make_mock( - "me", - vec![ - Transition { - id: "11".to_string(), - name: "To Do".to_string(), - }, - Transition { - id: "21".to_string(), - name: "In Progress".to_string(), - }, - Transition { - id: "31".to_string(), - name: "Done".to_string(), - }, - ], - ); - - let args = UpdateArgs { - status: Some("Done".to_string()), - ..empty_args("X-1") - }; - - let output = process_update(&client, &args).await.unwrap(); - assert!(output.contains("Transitioned to: Done")); - - let transitioned = client.transitioned_to.lock().unwrap(); - assert_eq!(transitioned.as_ref().unwrap(), "31"); -} - -#[tokio::test] -async fn process_update_fails_no_changes() { - let client = make_mock("me", vec![]); - let args = empty_args("X-1"); - - let result = process_update(&client, &args).await; - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("No changes specified")); -} - -#[tokio::test] -async fn process_update_multiple_changes() { - let client = make_mock( - "me123", - vec![Transition { - id: "31".to_string(), - name: "Done".to_string(), - }], - ); - - let args = UpdateArgs { - summary: Some("Updated".to_string()), - status: Some("Done".to_string()), - assign: Some("me".to_string()), - ..empty_args("X-1") - }; - - let output = process_update(&client, &args).await.unwrap(); - assert!(output.contains("Updated summary")); - assert!(output.contains("Updated assignee")); - assert!(output.contains("Transitioned to: Done")); -} - -#[tokio::test] -async fn process_update_body_adf_passes_through() { - let client = make_mock("me", vec![]); - - let mut file = NamedTempFile::new().unwrap(); - let doc = json!({ - "type": "doc", - "version": 1, - "content": [{ - "type": "paragraph", - "content": [{"type": "text", "text": "raw"}] - }] - }); - file.write_all(doc.to_string().as_bytes()).unwrap(); - - let args = UpdateArgs { - body_adf: Some(file.path().to_path_buf()), - ..empty_args("X-1") - }; - - let output = process_update(&client, &args).await.unwrap(); - assert!(output.contains("Updated description (raw ADF)")); - - let updated = client.updated_fields.lock().unwrap(); - let captured = updated.as_ref().unwrap().description_adf.as_ref().unwrap(); - assert_eq!(captured["type"], "doc"); - assert_eq!(captured["content"][0]["content"][0]["text"], "raw"); -} - -#[tokio::test] -async fn process_update_body_adf_missing_file_fails_before_network() { - let client = make_mock("me", vec![]); - - let args = UpdateArgs { - body_adf: Some(std::path::PathBuf::from("/nonexistent/adf.json")), - ..empty_args("X-1") - }; - - let result = process_update(&client, &args).await; - assert!(result.is_err()); - // Mock should not have been touched. - assert!(client.updated_fields.lock().unwrap().is_none()); -} diff --git a/src/main.rs b/src/main.rs index 56ab888..6b4d484 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,20 +5,13 @@ mod context; mod cron; mod data; mod docs; -mod eks; -mod gh; mod git; mod install; -mod jira; mod mcp; mod newrelic; -mod pagerduty; -mod pipeline; mod read; -mod sentry; mod setup; mod shell; -mod slack; mod util; mod utils; @@ -40,54 +33,12 @@ async fn main() -> anyhow::Result<()> { async fn run_command(cmd: Command) -> anyhow::Result<()> { match cmd { - Command::Jira { cmd: Some(cmd) } => { - return jira::run_command(cmd).await; - } - Command::Jira { cmd: None } => { - print_subcommand_help("jira")?; - } - Command::Gh { cmd: Some(cmd) } => { - return gh::run_command(cmd).await; - } - Command::Gh { cmd: None } => { - print_subcommand_help("gh")?; - } - Command::Slack { cmd: Some(cmd) } => { - return slack::run(cmd).await; - } - Command::Slack { cmd: None } => { - print_subcommand_help("slack")?; - } - Command::PagerDuty { cmd: Some(cmd) } => { - return pagerduty::run(cmd).await; - } - Command::PagerDuty { cmd: None } => { - print_subcommand_help("pagerduty")?; - } - Command::Sentry { cmd: Some(cmd) } => { - return sentry::run(cmd).await; - } - Command::Sentry { cmd: None } => { - print_subcommand_help("sentry")?; - } Command::NewRelic { cmd: Some(cmd) } => { return newrelic::run(cmd).await; } Command::NewRelic { cmd: None } => { print_subcommand_help("newrelic")?; } - Command::Eks { cmd: Some(cmd) } => { - return eks::run(cmd).await; - } - Command::Eks { cmd: None } => { - print_subcommand_help("eks")?; - } - Command::Pipeline { cmd: Some(cmd) } => { - return pipeline::run(cmd).await; - } - Command::Pipeline { cmd: None } => { - print_subcommand_help("pipeline")?; - } Command::Utils { cmd: Some(cmd) } => { return utils::run_command(cmd).await; } @@ -173,16 +124,12 @@ mod tests { #[test] fn parses_subcommand_without_action() { - let cli = Cli::try_parse_from(["hu", "jira"]).unwrap(); - assert!(matches!(cli.command, Some(Command::Jira { cmd: None }))); + let cli = Cli::try_parse_from(["hu", "newrelic"]).unwrap(); + assert!(matches!(cli.command, Some(Command::NewRelic { cmd: None }))); } #[test] fn parses_command_aliases() { - // pd -> pagerduty - let cli = Cli::try_parse_from(["hu", "pd", "oncall"]).unwrap(); - assert!(matches!(cli.command, Some(Command::PagerDuty { .. }))); - // nr -> newrelic let cli = Cli::try_parse_from(["hu", "nr", "incidents"]).unwrap(); assert!(matches!(cli.command, Some(Command::NewRelic { .. }))); diff --git a/src/pagerduty/cli.rs b/src/pagerduty/cli.rs deleted file mode 100644 index f0f6ef9..0000000 --- a/src/pagerduty/cli.rs +++ /dev/null @@ -1,297 +0,0 @@ -//! PagerDuty CLI commands - -use clap::{Subcommand, ValueEnum}; - -/// Incident status filter -#[derive(Debug, Clone, Copy, ValueEnum)] -pub enum StatusFilter { - /// Only triggered incidents - Triggered, - /// Only acknowledged incidents - Acknowledged, - /// Only resolved incidents - Resolved, - /// Triggered and acknowledged (active) - Active, -} - -#[derive(Debug, Subcommand)] -pub enum PagerDutyCommand { - /// Show configuration status - Config, - - /// Set API token - Auth { - /// PagerDuty API token - token: String, - }, - - /// Show who's currently on call - Oncall { - /// Filter by escalation policy ID - #[arg(short = 'p', long)] - policy: Option, - - /// Filter by schedule ID - #[arg(short, long)] - schedule: Option, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// List active alerts (triggered + acknowledged incidents) - Alerts { - /// Maximum number to show - #[arg(short, long, default_value = "25")] - limit: usize, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// List incidents with filters - Incidents { - /// Filter by status - #[arg(short, long, value_enum)] - status: Option, - - /// Maximum number to show - #[arg(short, long, default_value = "25")] - limit: usize, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// Show incident details - Show { - /// Incident ID - id: String, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// Show current user info - Whoami { - /// Output as JSON - #[arg(long)] - json: bool, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::{CommandFactory, Parser}; - - #[derive(Parser)] - struct TestCli { - #[command(subcommand)] - cmd: PagerDutyCommand, - } - - #[test] - fn parses_config() { - let cli = TestCli::try_parse_from(["test", "config"]).unwrap(); - assert!(matches!(cli.cmd, PagerDutyCommand::Config)); - } - - #[test] - fn parses_auth() { - let cli = TestCli::try_parse_from(["test", "auth", "my-token"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Auth { token } => assert_eq!(token, "my-token"), - _ => panic!("Expected Auth command"), - } - } - - #[test] - fn parses_oncall_no_args() { - let cli = TestCli::try_parse_from(["test", "oncall"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Oncall { - policy, - schedule, - json, - } => { - assert!(policy.is_none()); - assert!(schedule.is_none()); - assert!(!json); - } - _ => panic!("Expected Oncall command"), - } - } - - #[test] - fn parses_oncall_with_policy() { - let cli = TestCli::try_parse_from(["test", "oncall", "-p", "EP123"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Oncall { policy, .. } => { - assert_eq!(policy, Some("EP123".to_string())); - } - _ => panic!("Expected Oncall command"), - } - } - - #[test] - fn parses_oncall_with_schedule() { - let cli = TestCli::try_parse_from(["test", "oncall", "--schedule", "S456"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Oncall { schedule, .. } => { - assert_eq!(schedule, Some("S456".to_string())); - } - _ => panic!("Expected Oncall command"), - } - } - - #[test] - fn parses_oncall_json() { - let cli = TestCli::try_parse_from(["test", "oncall", "--json"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Oncall { json, .. } => assert!(json), - _ => panic!("Expected Oncall command"), - } - } - - #[test] - fn parses_alerts_default_limit() { - let cli = TestCli::try_parse_from(["test", "alerts"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Alerts { limit, json } => { - assert_eq!(limit, 25); - assert!(!json); - } - _ => panic!("Expected Alerts command"), - } - } - - #[test] - fn parses_alerts_custom_limit() { - let cli = TestCli::try_parse_from(["test", "alerts", "-l", "50"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Alerts { limit, .. } => assert_eq!(limit, 50), - _ => panic!("Expected Alerts command"), - } - } - - #[test] - fn parses_incidents_no_filter() { - let cli = TestCli::try_parse_from(["test", "incidents"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Incidents { - status, - limit, - json, - } => { - assert!(status.is_none()); - assert_eq!(limit, 25); - assert!(!json); - } - _ => panic!("Expected Incidents command"), - } - } - - #[test] - fn parses_incidents_status_triggered() { - let cli = TestCli::try_parse_from(["test", "incidents", "-s", "triggered"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Incidents { status, .. } => { - assert!(matches!(status, Some(StatusFilter::Triggered))); - } - _ => panic!("Expected Incidents command"), - } - } - - #[test] - fn parses_incidents_status_acknowledged() { - let cli = - TestCli::try_parse_from(["test", "incidents", "--status", "acknowledged"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Incidents { status, .. } => { - assert!(matches!(status, Some(StatusFilter::Acknowledged))); - } - _ => panic!("Expected Incidents command"), - } - } - - #[test] - fn parses_incidents_status_resolved() { - let cli = TestCli::try_parse_from(["test", "incidents", "-s", "resolved"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Incidents { status, .. } => { - assert!(matches!(status, Some(StatusFilter::Resolved))); - } - _ => panic!("Expected Incidents command"), - } - } - - #[test] - fn parses_incidents_status_active() { - let cli = TestCli::try_parse_from(["test", "incidents", "-s", "active"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Incidents { status, .. } => { - assert!(matches!(status, Some(StatusFilter::Active))); - } - _ => panic!("Expected Incidents command"), - } - } - - #[test] - fn parses_show() { - let cli = TestCli::try_parse_from(["test", "show", "INC123"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Show { id, json } => { - assert_eq!(id, "INC123"); - assert!(!json); - } - _ => panic!("Expected Show command"), - } - } - - #[test] - fn parses_show_json() { - let cli = TestCli::try_parse_from(["test", "show", "INC123", "--json"]).unwrap(); - match cli.cmd { - PagerDutyCommand::Show { id, json } => { - assert_eq!(id, "INC123"); - assert!(json); - } - _ => panic!("Expected Show command"), - } - } - - #[test] - fn status_filter_debug() { - let filter = StatusFilter::Triggered; - let debug = format!("{:?}", filter); - assert!(debug.contains("Triggered")); - } - - #[test] - fn status_filter_clone() { - let filter = StatusFilter::Active; - let cloned = filter; - assert!(matches!(cloned, StatusFilter::Active)); - } - - #[test] - fn command_debug() { - let cmd = PagerDutyCommand::Config; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Config")); - } - - #[test] - fn command_has_help() { - // Verify help text is generated without panic - let mut cmd = TestCli::command(); - let help = cmd.render_help(); - assert!(!help.to_string().is_empty()); - } -} diff --git a/src/pagerduty/client/mod.rs b/src/pagerduty/client/mod.rs deleted file mode 100644 index 8737341..0000000 --- a/src/pagerduty/client/mod.rs +++ /dev/null @@ -1,226 +0,0 @@ -//! PagerDuty API client - -use anyhow::Result; -use reqwest::Client; -use serde::de::DeserializeOwned; -use std::future::Future; -use std::time::Duration; -use tokio::time::sleep; - -use super::config::{load_config, PagerDutyConfig}; -use super::types::{ - CurrentUserResponse, Incident, IncidentResponse, IncidentStatus, IncidentsResponse, Oncall, - OncallsResponse, Service, ServicesResponse, User, -}; - -#[cfg(test)] -mod tests; - -const PAGERDUTY_API_URL: &str = "https://api.pagerduty.com"; -const MAX_RETRIES: u32 = 3; -const DEFAULT_RETRY_SECS: u64 = 5; - -/// PagerDuty API trait for testability -#[allow(dead_code)] -pub trait PagerDutyApi: Send + Sync { - /// Get current user - fn get_current_user(&self) -> impl Future> + Send; - - /// List who's on call - fn list_oncalls( - &self, - schedule_ids: Option<&[String]>, - escalation_policy_ids: Option<&[String]>, - ) -> impl Future>> + Send; - - /// List incidents - fn list_incidents( - &self, - statuses: &[IncidentStatus], - limit: usize, - ) -> impl Future>> + Send; - - /// Get single incident - fn get_incident(&self, id: &str) -> impl Future> + Send; - - /// List services - fn list_services(&self) -> impl Future>> + Send; -} - -/// PagerDuty HTTP client -pub struct PagerDutyClient { - config: PagerDutyConfig, - http: Client, -} - -impl PagerDutyClient { - /// Create a new client - #[cfg(not(tarpaulin_include))] - pub fn new() -> Result { - let config = load_config()?; - let http = Client::builder().user_agent("hu-cli/0.1.0").build()?; - Ok(Self { config, http }) - } - - /// Get API token - fn api_token(&self) -> Result<&str> { - self.config - .api_token - .as_deref() - .ok_or_else(|| anyhow::anyhow!("PagerDuty API token not configured")) - } - - /// Make authenticated GET request - async fn get(&self, path: &str) -> Result { - self.get_with_params(path, &[]).await - } - - /// Make authenticated GET request with query parameters - async fn get_with_params( - &self, - path: &str, - params: &[(&str, String)], - ) -> Result { - let token = self.api_token()?.to_string(); - let url = format!("{}{}", PAGERDUTY_API_URL, path); - let params: Vec<(String, String)> = params - .iter() - .map(|(k, v)| (k.to_string(), v.clone())) - .collect(); - - self.execute_with_retry(|| { - self.http - .get(&url) - .header("Authorization", format!("Token token={}", token)) - .header("Content-Type", "application/json") - .query(¶ms) - .send() - }) - .await - } - - /// Execute request with retry on rate limit - async fn execute_with_retry(&self, request_fn: F) -> Result - where - F: Fn() -> Fut, - Fut: std::future::Future>, - T: DeserializeOwned, - { - let mut retries = 0; - - loop { - let response = request_fn().await?; - let status = response.status(); - - if status == reqwest::StatusCode::TOO_MANY_REQUESTS { - if retries >= MAX_RETRIES { - return Err(anyhow::anyhow!( - "Rate limited after {} retries", - MAX_RETRIES - )); - } - - let retry_after = response - .headers() - .get("retry-after") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_RETRY_SECS); - - eprintln!( - "Rate limited, waiting {} seconds... (retry {}/{})", - retry_after, - retries + 1, - MAX_RETRIES - ); - sleep(Duration::from_secs(retry_after)).await; - retries += 1; - continue; - } - - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("HTTP {}: {}", status.as_u16(), body)); - } - - let text = response.text().await?; - return serde_json::from_str(&text).map_err(|e| { - anyhow::anyhow!("Parse error: {}: {}", e, &text[..text.len().min(200)]) - }); - } - } -} - -impl PagerDutyApi for PagerDutyClient { - async fn get_current_user(&self) -> Result { - let resp: CurrentUserResponse = self.get("/users/me").await?; - Ok(resp.user) - } - - async fn list_oncalls( - &self, - schedule_ids: Option<&[String]>, - escalation_policy_ids: Option<&[String]>, - ) -> Result> { - let params = build_oncall_params(schedule_ids, escalation_policy_ids); - let resp: OncallsResponse = self.get_with_params("/oncalls", ¶ms).await?; - Ok(resp.oncalls) - } - - async fn list_incidents( - &self, - statuses: &[IncidentStatus], - limit: usize, - ) -> Result> { - let params = build_incidents_params(statuses, limit); - let resp: IncidentsResponse = self.get_with_params("/incidents", ¶ms).await?; - Ok(resp.incidents) - } - - async fn get_incident(&self, id: &str) -> Result { - let path = format!("/incidents/{}", id); - let resp: IncidentResponse = self.get(&path).await?; - Ok(resp.incident) - } - - async fn list_services(&self) -> Result> { - let resp: ServicesResponse = self.get("/services").await?; - Ok(resp.services) - } -} - -/// Build query parameters for oncalls endpoint -fn build_oncall_params( - schedule_ids: Option<&[String]>, - escalation_policy_ids: Option<&[String]>, -) -> Vec<(&'static str, String)> { - let mut params = Vec::new(); - - if let Some(ids) = schedule_ids { - for id in ids { - params.push(("schedule_ids[]", id.clone())); - } - } - - if let Some(ids) = escalation_policy_ids { - for id in ids { - params.push(("escalation_policy_ids[]", id.clone())); - } - } - - params -} - -/// Build query parameters for incidents endpoint -fn build_incidents_params( - statuses: &[IncidentStatus], - limit: usize, -) -> Vec<(&'static str, String)> { - let mut params = vec![("limit", limit.to_string())]; - - for status in statuses { - params.push(("statuses[]", status.as_str().to_string())); - } - - params -} diff --git a/src/pagerduty/client/tests.rs b/src/pagerduty/client/tests.rs deleted file mode 100644 index 7906688..0000000 --- a/src/pagerduty/client/tests.rs +++ /dev/null @@ -1,296 +0,0 @@ -use super::*; - -#[test] -fn build_oncall_params_empty() { - let params = build_oncall_params(None, None); - assert!(params.is_empty()); -} - -#[test] -fn build_oncall_params_with_schedule() { - let schedules = vec!["S1".to_string(), "S2".to_string()]; - let params = build_oncall_params(Some(&schedules), None); - assert_eq!(params.len(), 2); - assert_eq!(params[0], ("schedule_ids[]", "S1".to_string())); - assert_eq!(params[1], ("schedule_ids[]", "S2".to_string())); -} - -#[test] -fn build_oncall_params_with_policy() { - let policies = vec!["EP1".to_string()]; - let params = build_oncall_params(None, Some(&policies)); - assert_eq!(params.len(), 1); - assert_eq!(params[0], ("escalation_policy_ids[]", "EP1".to_string())); -} - -#[test] -fn build_oncall_params_with_both() { - let schedules = vec!["S1".to_string()]; - let policies = vec!["EP1".to_string()]; - let params = build_oncall_params(Some(&schedules), Some(&policies)); - assert_eq!(params.len(), 2); -} - -#[test] -fn build_incidents_params_basic() { - let statuses = vec![IncidentStatus::Triggered]; - let params = build_incidents_params(&statuses, 25); - assert_eq!(params.len(), 2); - assert_eq!(params[0], ("limit", "25".to_string())); - assert_eq!(params[1], ("statuses[]", "triggered".to_string())); -} - -#[test] -fn build_incidents_params_multiple_statuses() { - let statuses = vec![IncidentStatus::Triggered, IncidentStatus::Acknowledged]; - let params = build_incidents_params(&statuses, 10); - assert_eq!(params.len(), 3); - assert_eq!(params[0], ("limit", "10".to_string())); - assert_eq!(params[1], ("statuses[]", "triggered".to_string())); - assert_eq!(params[2], ("statuses[]", "acknowledged".to_string())); -} - -#[test] -fn build_incidents_params_empty_statuses() { - let statuses: Vec = vec![]; - let params = build_incidents_params(&statuses, 50); - assert_eq!(params.len(), 1); - assert_eq!(params[0], ("limit", "50".to_string())); -} - -// Mock implementation for testing handlers -pub struct MockPagerDutyApi { - pub oncalls: Vec, - pub incidents: Vec, - pub services: Vec, - pub current_user: Option, -} - -impl MockPagerDutyApi { - pub fn new() -> Self { - Self { - oncalls: vec![], - incidents: vec![], - services: vec![], - current_user: None, - } - } - - pub fn with_oncalls(mut self, oncalls: Vec) -> Self { - self.oncalls = oncalls; - self - } - - pub fn with_incidents(mut self, incidents: Vec) -> Self { - self.incidents = incidents; - self - } - - pub fn with_services(mut self, services: Vec) -> Self { - self.services = services; - self - } - - pub fn with_user(mut self, user: User) -> Self { - self.current_user = Some(user); - self - } -} - -impl PagerDutyApi for MockPagerDutyApi { - async fn get_current_user(&self) -> Result { - self.current_user - .clone() - .ok_or_else(|| anyhow::anyhow!("No user configured")) - } - - async fn list_oncalls( - &self, - _schedule_ids: Option<&[String]>, - _escalation_policy_ids: Option<&[String]>, - ) -> Result> { - Ok(self.oncalls.clone()) - } - - async fn list_incidents( - &self, - _statuses: &[IncidentStatus], - limit: usize, - ) -> Result> { - Ok(self.incidents.iter().take(limit).cloned().collect()) - } - - async fn get_incident(&self, id: &str) -> Result { - self.incidents - .iter() - .find(|i| i.id == id) - .cloned() - .ok_or_else(|| anyhow::anyhow!("Incident not found: {}", id)) - } - - async fn list_services(&self) -> Result> { - Ok(self.services.clone()) - } -} - -#[tokio::test] -async fn mock_list_oncalls() { - let oncall = make_test_oncall("U1", "Alice"); - let mock = MockPagerDutyApi::new().with_oncalls(vec![oncall]); - - let result = mock.list_oncalls(None, None).await.unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].user.display_name(), "Alice"); -} - -#[tokio::test] -async fn mock_list_incidents_respects_limit() { - let incidents = vec![ - make_test_incident("1"), - make_test_incident("2"), - make_test_incident("3"), - ]; - let mock = MockPagerDutyApi::new().with_incidents(incidents); - - let result = mock - .list_incidents(&[IncidentStatus::Triggered], 2) - .await - .unwrap(); - assert_eq!(result.len(), 2); -} - -#[tokio::test] -async fn mock_get_incident() { - let incidents = vec![make_test_incident("INC1"), make_test_incident("INC2")]; - let mock = MockPagerDutyApi::new().with_incidents(incidents); - - let result = mock.get_incident("INC1").await.unwrap(); - assert_eq!(result.id, "INC1"); -} - -#[tokio::test] -async fn mock_get_incident_not_found() { - let mock = MockPagerDutyApi::new(); - let result = mock.get_incident("MISSING").await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn mock_get_current_user() { - let user = make_test_user("U1", "Alice"); - let mock = MockPagerDutyApi::new().with_user(user); - - let result = mock.get_current_user().await.unwrap(); - assert_eq!(result.display_name(), "Alice"); -} - -#[tokio::test] -async fn mock_get_current_user_not_configured() { - let mock = MockPagerDutyApi::new(); - let result = mock.get_current_user().await; - assert!(result.is_err()); -} - -#[tokio::test] -async fn mock_list_services() { - let services = vec![make_test_service("S1", "Production")]; - let mock = MockPagerDutyApi::new().with_services(services); - - let result = mock.list_services().await.unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "Production"); -} - -#[test] -fn client_new_creates_instance() { - // This tests the happy path of client creation - let result = PagerDutyClient::new(); - assert!(result.is_ok()); -} - -#[test] -fn api_token_returns_error_when_not_set() { - let client = PagerDutyClient::new().unwrap(); - // If no token is configured, api_token() should return error - // This depends on whether PAGERDUTY_API_TOKEN env var is set - let result = client.api_token(); - // Just exercise the code path - let _ = result; -} - -#[test] -fn mock_builder_pattern() { - // Test that all builder methods work correctly - let user = make_test_user("U1", "Alice"); - let oncalls = vec![make_test_oncall("U1", "Alice")]; - let incidents = vec![make_test_incident("INC1")]; - let services = vec![make_test_service("S1", "Production")]; - - let mock = MockPagerDutyApi::new() - .with_user(user.clone()) - .with_oncalls(oncalls.clone()) - .with_incidents(incidents.clone()) - .with_services(services.clone()); - - assert_eq!(mock.current_user.as_ref().unwrap().id, "U1"); - assert_eq!(mock.oncalls.len(), 1); - assert_eq!(mock.incidents.len(), 1); - assert_eq!(mock.services.len(), 1); -} - -// Test data helpers -fn make_test_user(id: &str, name: &str) -> User { - User { - id: id.to_string(), - name: Some(name.to_string()), - summary: None, - email: format!("{}@example.com", name.to_lowercase()), - html_url: String::new(), - } -} - -fn make_test_oncall(user_id: &str, user_name: &str) -> Oncall { - use super::super::types::{EscalationPolicy, Schedule}; - - Oncall { - user: make_test_user(user_id, user_name), - schedule: Some(Schedule { - id: "S1".to_string(), - name: "Weekly Rotation".to_string(), - html_url: String::new(), - }), - escalation_policy: EscalationPolicy { - id: "EP1".to_string(), - name: "Primary".to_string(), - html_url: String::new(), - }, - escalation_level: 1, - start: Some("2026-01-01T00:00:00Z".to_string()), - end: Some("2026-01-08T00:00:00Z".to_string()), - } -} - -fn make_test_incident(id: &str) -> Incident { - use super::super::types::Urgency; - - Incident { - id: id.to_string(), - incident_number: id.parse().unwrap_or(1), - title: format!("Test incident {}", id), - status: IncidentStatus::Triggered, - urgency: Urgency::High, - created_at: "2026-01-01T12:00:00Z".to_string(), - html_url: String::new(), - service: make_test_service("S1", "Production"), - assignments: vec![], - } -} - -fn make_test_service(id: &str, name: &str) -> Service { - Service { - id: id.to_string(), - name: name.to_string(), - status: "active".to_string(), - html_url: String::new(), - } -} diff --git a/src/pagerduty/config.rs b/src/pagerduty/config.rs deleted file mode 100644 index a3a66ef..0000000 --- a/src/pagerduty/config.rs +++ /dev/null @@ -1,345 +0,0 @@ -//! PagerDuty configuration -//! -//! Loads configuration from `~/.config/hu/settings.toml` - -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; - -/// PagerDuty configuration -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct PagerDutyConfig { - /// API token - pub api_token: Option, - /// Default escalation policy IDs (for filtering oncall) - #[serde(default)] - pub escalation_policy_ids: Vec, - /// Default schedule IDs (for filtering oncall) - #[serde(default)] - pub schedule_ids: Vec, -} - -impl PagerDutyConfig { - /// Check if configured with API token - #[must_use] - pub fn is_configured(&self) -> bool { - self.api_token.is_some() - } -} - -/// Settings file structure -#[derive(Debug, Default, Deserialize)] -struct SettingsFile { - pagerduty: Option, -} - -/// Get path to config file -pub fn config_path() -> Option { - dirs::home_dir().map(|p| p.join(".config").join("hu").join("settings.toml")) -} - -/// Load PagerDuty config from settings file and environment -pub fn load_config() -> Result { - let mut config = PagerDutyConfig::default(); - - // Load from settings file - if let Some(path) = config_path() { - if path.exists() { - let contents = fs::read_to_string(&path)?; - config = parse_config(&contents)?; - } - } - - // Override with environment variables - if let Ok(token) = std::env::var("PAGERDUTY_API_TOKEN") { - config.api_token = Some(token); - } - - Ok(config) -} - -/// Parse config from TOML string -fn parse_config(contents: &str) -> Result { - let settings: SettingsFile = toml::from_str(contents)?; - Ok(settings.pagerduty.unwrap_or_default()) -} - -/// Save API token to config file -#[cfg(not(tarpaulin_include))] -pub fn save_config(api_token: &str) -> Result<()> { - let path = config_path().ok_or_else(|| anyhow::anyhow!("Cannot determine config directory"))?; - - // Read existing or create new - let contents = if path.exists() { - fs::read_to_string(&path)? - } else { - String::new() - }; - - let output = update_config_toml(&contents, api_token)?; - - // Ensure parent directory exists - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - fs::write(&path, output)?; - Ok(()) -} - -/// Update TOML config with new API token -fn update_config_toml(contents: &str, api_token: &str) -> Result { - // Parse as TOML value - let mut doc: toml::Value = - toml::from_str(contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new())); - - // Ensure pagerduty section exists - let table = doc - .as_table_mut() - .ok_or_else(|| anyhow::anyhow!("Config is not a table"))?; - - if !table.contains_key("pagerduty") { - table.insert( - "pagerduty".to_string(), - toml::Value::Table(toml::map::Map::new()), - ); - } - - let pagerduty = table - .get_mut("pagerduty") - .and_then(|v| v.as_table_mut()) - .ok_or_else(|| anyhow::anyhow!("pagerduty section is not a table"))?; - - pagerduty.insert( - "api_token".to_string(), - toml::Value::String(api_token.to_string()), - ); - - toml::to_string_pretty(&doc).map_err(Into::into) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn config_is_configured_with_token() { - let config = PagerDutyConfig { - api_token: Some("token".to_string()), - ..Default::default() - }; - assert!(config.is_configured()); - } - - #[test] - fn config_is_not_configured_without_token() { - let config = PagerDutyConfig::default(); - assert!(!config.is_configured()); - } - - #[test] - fn config_default_has_empty_vectors() { - let config = PagerDutyConfig::default(); - assert!(config.escalation_policy_ids.is_empty()); - assert!(config.schedule_ids.is_empty()); - } - - #[test] - fn config_path_returns_some() { - // May return None in CI without home dir, just verify no panic - let _ = config_path(); - } - - #[test] - fn parse_config_empty() { - let config = parse_config("").unwrap(); - assert!(!config.is_configured()); - } - - #[test] - fn parse_config_with_pagerduty_section() { - let toml = r#" -[pagerduty] -api_token = "test-token" -"#; - let config = parse_config(toml).unwrap(); - assert!(config.is_configured()); - assert_eq!(config.api_token.as_deref(), Some("test-token")); - } - - #[test] - fn parse_config_with_policy_ids() { - let toml = r#" -[pagerduty] -api_token = "test-token" -escalation_policy_ids = ["EP1", "EP2"] -schedule_ids = ["S1"] -"#; - let config = parse_config(toml).unwrap(); - assert_eq!(config.escalation_policy_ids, vec!["EP1", "EP2"]); - assert_eq!(config.schedule_ids, vec!["S1"]); - } - - #[test] - fn parse_config_other_sections_ignored() { - let toml = r#" -[sentry] -auth_token = "sentry-token" - -[pagerduty] -api_token = "pd-token" -"#; - let config = parse_config(toml).unwrap(); - assert_eq!(config.api_token.as_deref(), Some("pd-token")); - } - - #[test] - fn update_config_toml_empty() { - let result = update_config_toml("", "new-token").unwrap(); - assert!(result.contains("api_token = \"new-token\"")); - assert!(result.contains("[pagerduty]")); - } - - #[test] - fn update_config_toml_existing_section() { - let existing = r#" -[pagerduty] -api_token = "old-token" -"#; - let result = update_config_toml(existing, "new-token").unwrap(); - assert!(result.contains("api_token = \"new-token\"")); - assert!(!result.contains("old-token")); - } - - #[test] - fn update_config_toml_preserves_other_sections() { - let existing = r#" -[sentry] -auth_token = "sentry-token" -"#; - let result = update_config_toml(existing, "pd-token").unwrap(); - assert!(result.contains("sentry-token")); - assert!(result.contains("pd-token")); - } - - #[test] - fn update_config_toml_preserves_other_pagerduty_fields() { - let existing = r#" -[pagerduty] -api_token = "old-token" -escalation_policy_ids = ["EP1"] -"#; - let result = update_config_toml(existing, "new-token").unwrap(); - assert!(result.contains("api_token = \"new-token\"")); - assert!(result.contains("EP1")); - } - - #[test] - fn config_debug() { - let config = PagerDutyConfig::default(); - let debug = format!("{:?}", config); - assert!(debug.contains("PagerDutyConfig")); - } - - #[test] - fn config_clone() { - let config = PagerDutyConfig { - api_token: Some("token".to_string()), - escalation_policy_ids: vec!["EP1".to_string()], - schedule_ids: vec!["S1".to_string()], - }; - let cloned = config.clone(); - assert_eq!(cloned.api_token, config.api_token); - assert_eq!(cloned.escalation_policy_ids, config.escalation_policy_ids); - } - - #[test] - fn load_config_returns_default_when_no_file() { - // load_config should work even when config file doesn't exist - // It will return default config (possibly with env var override) - let result = load_config(); - assert!(result.is_ok()); - } - - #[test] - fn load_config_env_override() { - // Test that environment variable overrides config file - // Save current value and restore after test - let original = std::env::var("PAGERDUTY_API_TOKEN").ok(); - - std::env::set_var("PAGERDUTY_API_TOKEN", "env-token-test-12345"); - let result = load_config(); - assert!(result.is_ok()); - let config = result.unwrap(); - assert_eq!(config.api_token.as_deref(), Some("env-token-test-12345")); - - // Restore original value - match original { - Some(val) => std::env::set_var("PAGERDUTY_API_TOKEN", val), - None => std::env::remove_var("PAGERDUTY_API_TOKEN"), - } - } - - #[test] - fn parse_config_invalid_toml() { - let invalid = "this is not valid [[[toml"; - let result = parse_config(invalid); - assert!(result.is_err()); - } - - #[test] - fn parse_config_wrong_type_for_pagerduty() { - // pagerduty is a string instead of a table - let toml = r#"pagerduty = "not a table""#; - let result = parse_config(toml); - assert!(result.is_err()); - } - - #[test] - fn update_config_toml_invalid_existing() { - // Invalid TOML should still work - it creates a new table - let invalid = "this is not valid [[[toml"; - let result = update_config_toml(invalid, "new-token"); - // Should succeed by creating fresh config - assert!(result.is_ok()); - assert!(result.unwrap().contains("api_token")); - } - - #[test] - fn settings_file_default() { - let settings = SettingsFile::default(); - assert!(settings.pagerduty.is_none()); - } - - #[test] - fn settings_file_debug() { - let settings = SettingsFile::default(); - let debug = format!("{:?}", settings); - assert!(debug.contains("SettingsFile")); - } - - #[test] - fn config_serialize() { - let config = PagerDutyConfig { - api_token: Some("token".to_string()), - escalation_policy_ids: vec!["EP1".to_string()], - schedule_ids: vec![], - }; - let json = serde_json::to_string(&config).unwrap(); - assert!(json.contains("token")); - assert!(json.contains("EP1")); - } - - #[test] - fn config_deserialize() { - let json = r#"{ - "api_token": "test-token", - "escalation_policy_ids": ["EP1"], - "schedule_ids": [] - }"#; - let config: PagerDutyConfig = serde_json::from_str(json).unwrap(); - assert_eq!(config.api_token.as_deref(), Some("test-token")); - assert_eq!(config.escalation_policy_ids, vec!["EP1"]); - } -} diff --git a/src/pagerduty/display/mod.rs b/src/pagerduty/display/mod.rs deleted file mode 100644 index 500b8c7..0000000 --- a/src/pagerduty/display/mod.rs +++ /dev/null @@ -1,229 +0,0 @@ -//! PagerDuty output formatting - -use anyhow::{Context, Result}; -use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, ContentArrangement, Table}; - -use super::config::PagerDutyConfig; -use super::types::{Incident, IncidentStatus, Oncall, OutputFormat}; - -#[cfg(test)] -mod tests; - -/// Color for incident status -fn status_color(status: IncidentStatus) -> Color { - match status { - IncidentStatus::Triggered => Color::Red, - IncidentStatus::Acknowledged => Color::Yellow, - IncidentStatus::Resolved => Color::Green, - } -} - -/// Status icon -fn status_icon(status: IncidentStatus) -> &'static str { - match status { - IncidentStatus::Triggered => "!", - IncidentStatus::Acknowledged => "~", - IncidentStatus::Resolved => "✓", - } -} - -/// Format relative time from ISO8601 timestamp -fn time_ago(timestamp: &str) -> String { - let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) else { - return timestamp.to_string(); - }; - - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(dt); - - if duration.num_days() > 0 { - format!("{}d ago", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h ago", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m ago", duration.num_minutes()) - } else { - "just now".to_string() - } -} - -/// Truncate string to max length -fn truncate(s: &str, max_len: usize) -> String { - if s.len() <= max_len { - s.to_string() - } else { - format!("{}...", &s[..max_len.saturating_sub(3)]) - } -} - -/// Output oncalls list -pub fn output_oncalls(oncalls: &[Oncall], format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - if oncalls.is_empty() { - println!("No one is currently on call."); - return Ok(()); - } - - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - table.set_header(vec!["User", "Email", "Policy", "Level", "Schedule"]); - - for oncall in oncalls { - let schedule_name = oncall - .schedule - .as_ref() - .map(|s| s.name.as_str()) - .unwrap_or("-"); - - table.add_row(vec![ - Cell::new(oncall.user.display_name()).fg(Color::Cyan), - Cell::new(&oncall.user.email), - Cell::new(truncate(&oncall.escalation_policy.name, 25)), - Cell::new(oncall.escalation_level.to_string()), - Cell::new(truncate(schedule_name, 20)), - ]); - } - - println!("{table}"); - println!("\n{} on-call", oncalls.len()); - } - OutputFormat::Json => { - let json = - serde_json::to_string_pretty(oncalls).context("Failed to serialize oncalls")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output incidents list -pub fn output_incidents(incidents: &[Incident], format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - if incidents.is_empty() { - println!("No incidents found."); - return Ok(()); - } - - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - table.set_header(vec![ - "#", "Status", "Urgency", "Service", "Title", "Created", - ]); - - for incident in incidents { - let status_text = format!("{} {:?}", status_icon(incident.status), incident.status); - - table.add_row(vec![ - Cell::new(incident.incident_number.to_string()).fg(Color::Cyan), - Cell::new(&status_text).fg(status_color(incident.status)), - Cell::new(format!("{:?}", incident.urgency)), - Cell::new(truncate(&incident.service.name, 20)), - Cell::new(truncate(&incident.title, 40)), - Cell::new(time_ago(&incident.created_at)), - ]); - } - - println!("{table}"); - println!("\n{} incidents", incidents.len()); - } - OutputFormat::Json => { - let json = - serde_json::to_string_pretty(incidents).context("Failed to serialize incidents")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output single incident detail -pub fn output_incident_detail(incident: &Incident, format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - println!("{}", "-".repeat(60)); - println!( - "#{} - {}", - incident.incident_number, - truncate(&incident.title, 50) - ); - println!("{}", "-".repeat(60)); - println!( - "Status: {} {:?}", - status_icon(incident.status), - incident.status - ); - println!("Urgency: {:?}", incident.urgency); - println!("Service: {}", incident.service.name); - println!("Created: {}", time_ago(&incident.created_at)); - - if !incident.assignments.is_empty() { - println!("\nAssigned to:"); - for assignment in &incident.assignments { - println!( - " - {} ({})", - assignment.assignee.display_name(), - assignment.assignee.email - ); - } - } - - if !incident.html_url.is_empty() { - println!("\nLink: {}", incident.html_url); - } - } - OutputFormat::Json => { - let json = - serde_json::to_string_pretty(incident).context("Failed to serialize incident")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output config status -pub fn output_config_status(config: &PagerDutyConfig) { - println!("PagerDuty Configuration"); - println!("{}", "-".repeat(40)); - println!( - "API token: {}", - if config.api_token.is_some() { - "Configured" - } else { - "Not set" - } - ); - - if !config.escalation_policy_ids.is_empty() { - println!( - "Default escalation policies: {}", - config.escalation_policy_ids.join(", ") - ); - } - - if !config.schedule_ids.is_empty() { - println!("Default schedules: {}", config.schedule_ids.join(", ")); - } -} - -/// Output current user info -pub fn output_user(user: &super::types::User, format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - println!("{}", user.display_name()); - if !user.email.is_empty() { - println!("{}", user.email); - } - if !user.html_url.is_empty() { - println!("{}", user.html_url); - } - } - OutputFormat::Json => { - let json = serde_json::to_string_pretty(user).context("Failed to serialize user")?; - println!("{json}"); - } - } - Ok(()) -} diff --git a/src/pagerduty/display/tests.rs b/src/pagerduty/display/tests.rs deleted file mode 100644 index f6c1817..0000000 --- a/src/pagerduty/display/tests.rs +++ /dev/null @@ -1,425 +0,0 @@ -use super::*; - -#[test] -fn status_color_triggered_is_red() { - assert_eq!(status_color(IncidentStatus::Triggered), Color::Red); -} - -#[test] -fn status_color_acknowledged_is_yellow() { - assert_eq!(status_color(IncidentStatus::Acknowledged), Color::Yellow); -} - -#[test] -fn status_color_resolved_is_green() { - assert_eq!(status_color(IncidentStatus::Resolved), Color::Green); -} - -#[test] -fn status_icon_triggered() { - assert_eq!(status_icon(IncidentStatus::Triggered), "!"); -} - -#[test] -fn status_icon_acknowledged() { - assert_eq!(status_icon(IncidentStatus::Acknowledged), "~"); -} - -#[test] -fn status_icon_resolved() { - assert_eq!(status_icon(IncidentStatus::Resolved), "✓"); -} - -#[test] -fn truncate_short_string() { - assert_eq!(truncate("hello", 10), "hello"); -} - -#[test] -fn truncate_exact_length() { - assert_eq!(truncate("hello", 5), "hello"); -} - -#[test] -fn truncate_long_string() { - assert_eq!(truncate("hello world", 8), "hello..."); -} - -#[test] -fn truncate_very_short_max() { - // Edge case: max_len less than 3 - assert_eq!(truncate("hello", 2), "..."); -} - -#[test] -fn time_ago_invalid_timestamp() { - assert_eq!(time_ago("invalid"), "invalid"); -} - -#[test] -fn time_ago_days() { - // 5 days ago - let dt = chrono::Utc::now() - chrono::Duration::days(5); - let timestamp = dt.to_rfc3339(); - assert_eq!(time_ago(×tamp), "5d ago"); -} - -#[test] -fn time_ago_hours() { - // 3 hours ago - let dt = chrono::Utc::now() - chrono::Duration::hours(3); - let timestamp = dt.to_rfc3339(); - assert_eq!(time_ago(×tamp), "3h ago"); -} - -#[test] -fn time_ago_minutes() { - // 15 minutes ago - let dt = chrono::Utc::now() - chrono::Duration::minutes(15); - let timestamp = dt.to_rfc3339(); - assert_eq!(time_ago(×tamp), "15m ago"); -} - -#[test] -fn time_ago_just_now() { - // 30 seconds ago - let dt = chrono::Utc::now() - chrono::Duration::seconds(30); - let timestamp = dt.to_rfc3339(); - assert_eq!(time_ago(×tamp), "just now"); -} - -#[test] -fn output_config_status_not_configured() { - let config = PagerDutyConfig::default(); - // Just verify it doesn't panic - output_config_status(&config); -} - -#[test] -fn output_config_status_configured() { - let config = PagerDutyConfig { - api_token: Some("token".to_string()), - escalation_policy_ids: vec!["EP1".to_string()], - schedule_ids: vec!["S1".to_string(), "S2".to_string()], - }; - // Just verify it doesn't panic - output_config_status(&config); -} - -#[test] -fn output_oncalls_empty() { - let result = output_oncalls(&[], OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_incidents_empty() { - let result = output_incidents(&[], OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_oncalls_json_empty() { - let result = output_oncalls(&[], OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn output_incidents_json_empty() { - let result = output_incidents(&[], OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn output_oncalls_with_data() { - use super::super::types::{EscalationPolicy, Schedule, User}; - - let oncalls = vec![Oncall { - user: User { - id: "U1".to_string(), - name: Some("Alice".to_string()), - summary: None, - email: "alice@example.com".to_string(), - html_url: String::new(), - }, - schedule: Some(Schedule { - id: "S1".to_string(), - name: "Weekly".to_string(), - html_url: String::new(), - }), - escalation_policy: EscalationPolicy { - id: "EP1".to_string(), - name: "Primary".to_string(), - html_url: String::new(), - }, - escalation_level: 1, - start: None, - end: None, - }]; - - let result = output_oncalls(&oncalls, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_incidents_with_data() { - use super::super::types::{Service, Urgency}; - - let incidents = vec![Incident { - id: "INC1".to_string(), - incident_number: 42, - title: "Test incident".to_string(), - status: IncidentStatus::Triggered, - urgency: Urgency::High, - created_at: chrono::Utc::now().to_rfc3339(), - html_url: String::new(), - service: Service { - id: "S1".to_string(), - name: "Production".to_string(), - status: "active".to_string(), - html_url: String::new(), - }, - assignments: vec![], - }]; - - let result = output_incidents(&incidents, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_incident_detail_table() { - use super::super::types::{Assignment, Service, Urgency, User}; - - let incident = Incident { - id: "INC1".to_string(), - incident_number: 42, - title: "Server down".to_string(), - status: IncidentStatus::Acknowledged, - urgency: Urgency::High, - created_at: chrono::Utc::now().to_rfc3339(), - html_url: "https://pagerduty.com/incidents/INC1".to_string(), - service: Service { - id: "S1".to_string(), - name: "Production".to_string(), - status: "active".to_string(), - html_url: String::new(), - }, - assignments: vec![Assignment { - assignee: User { - id: "U1".to_string(), - name: Some("Alice".to_string()), - summary: None, - email: "alice@example.com".to_string(), - html_url: String::new(), - }, - }], - }; - - let result = output_incident_detail(&incident, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_incident_detail_json() { - use super::super::types::{Service, Urgency}; - - let incident = Incident { - id: "INC1".to_string(), - incident_number: 42, - title: "Server down".to_string(), - status: IncidentStatus::Triggered, - urgency: Urgency::Low, - created_at: "2026-01-01T12:00:00Z".to_string(), - html_url: String::new(), - service: Service { - id: "S1".to_string(), - name: "Production".to_string(), - status: "active".to_string(), - html_url: String::new(), - }, - assignments: vec![], - }; - - let result = output_incident_detail(&incident, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn output_user_table_format() { - use super::super::types::User; - - let user = User { - id: "U1".to_string(), - name: Some("Alice Smith".to_string()), - summary: None, - email: "alice@example.com".to_string(), - html_url: "https://pagerduty.com/users/U1".to_string(), - }; - - let result = output_user(&user, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_user_json_format() { - use super::super::types::User; - - let user = User { - id: "U1".to_string(), - name: Some("Alice Smith".to_string()), - summary: None, - email: "alice@example.com".to_string(), - html_url: "https://pagerduty.com/users/U1".to_string(), - }; - - let result = output_user(&user, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn output_user_empty_email() { - use super::super::types::User; - - let user = User { - id: "U1".to_string(), - name: Some("Alice".to_string()), - summary: None, - email: String::new(), - html_url: String::new(), - }; - - let result = output_user(&user, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_oncalls_without_schedule() { - use super::super::types::{EscalationPolicy, User}; - - let oncalls = vec![Oncall { - user: User { - id: "U1".to_string(), - name: Some("Alice".to_string()), - summary: None, - email: "alice@example.com".to_string(), - html_url: String::new(), - }, - schedule: None, - escalation_policy: EscalationPolicy { - id: "EP1".to_string(), - name: "Primary".to_string(), - html_url: String::new(), - }, - escalation_level: 1, - start: None, - end: None, - }]; - - let result = output_oncalls(&oncalls, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_oncalls_json_with_data() { - use super::super::types::{EscalationPolicy, Schedule, User}; - - let oncalls = vec![Oncall { - user: User { - id: "U1".to_string(), - name: Some("Alice".to_string()), - summary: None, - email: "alice@example.com".to_string(), - html_url: String::new(), - }, - schedule: Some(Schedule { - id: "S1".to_string(), - name: "Weekly".to_string(), - html_url: String::new(), - }), - escalation_policy: EscalationPolicy { - id: "EP1".to_string(), - name: "Primary".to_string(), - html_url: String::new(), - }, - escalation_level: 1, - start: None, - end: None, - }]; - - let result = output_oncalls(&oncalls, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn output_incidents_json_with_data() { - use super::super::types::{Service, Urgency}; - - let incidents = vec![Incident { - id: "INC1".to_string(), - incident_number: 42, - title: "Test incident".to_string(), - status: IncidentStatus::Acknowledged, - urgency: Urgency::Low, - created_at: chrono::Utc::now().to_rfc3339(), - html_url: String::new(), - service: Service { - id: "S1".to_string(), - name: "Production".to_string(), - status: "active".to_string(), - html_url: String::new(), - }, - assignments: vec![], - }]; - - let result = output_incidents(&incidents, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn output_incident_detail_no_url() { - use super::super::types::{Service, Urgency}; - - let incident = Incident { - id: "INC1".to_string(), - incident_number: 42, - title: "Server down".to_string(), - status: IncidentStatus::Resolved, - urgency: Urgency::High, - created_at: chrono::Utc::now().to_rfc3339(), - html_url: String::new(), - service: Service { - id: "S1".to_string(), - name: "Production".to_string(), - status: "active".to_string(), - html_url: String::new(), - }, - assignments: vec![], - }; - - let result = output_incident_detail(&incident, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn truncate_zero_max() { - // Edge case: max_len = 0 - assert_eq!(truncate("hello", 0), "..."); -} - -#[test] -fn time_ago_boundary_cases() { - // Exactly 1 day ago - let dt = chrono::Utc::now() - chrono::Duration::days(1); - let timestamp = dt.to_rfc3339(); - assert_eq!(time_ago(×tamp), "1d ago"); - - // Exactly 1 hour ago - let dt = chrono::Utc::now() - chrono::Duration::hours(1); - let timestamp = dt.to_rfc3339(); - assert_eq!(time_ago(×tamp), "1h ago"); - - // Exactly 1 minute ago - let dt = chrono::Utc::now() - chrono::Duration::minutes(1); - let timestamp = dt.to_rfc3339(); - assert_eq!(time_ago(×tamp), "1m ago"); -} diff --git a/src/pagerduty/mod.rs b/src/pagerduty/mod.rs deleted file mode 100644 index e76d53b..0000000 --- a/src/pagerduty/mod.rs +++ /dev/null @@ -1,320 +0,0 @@ -//! PagerDuty integration -//! -//! View on-call schedules and incidents. -//! -//! # CLI Usage -//! Use [`run`] for CLI commands that format and print output. -//! -//! # Programmatic Usage (MCP/HTTP) -//! Use the reusable functions that return typed data: -//! - [`get_config`] - Get configuration status -//! - [`list_oncalls`] - List on-call users -//! - [`list_alerts`] - List active alerts (triggered + acknowledged) -//! - [`list_incidents`] - List incidents with filters -//! - [`get_incident`] - Get incident details -//! - [`get_current_user`] - Get current user info - -mod cli; -mod client; -mod config; -mod display; -mod service; -pub mod types; - -use anyhow::Result; - -pub use cli::PagerDutyCommand; -use cli::StatusFilter; -use client::PagerDutyClient; -pub use config::PagerDutyConfig; -pub use service::{IncidentOptions, OncallOptions}; -pub use types::{Incident, Oncall, User}; -use types::{IncidentStatus, OutputFormat}; - -/// Run a PagerDuty command (CLI entry point - formats and prints) -#[cfg(not(tarpaulin_include))] -pub async fn run(cmd: PagerDutyCommand) -> Result<()> { - match cmd { - PagerDutyCommand::Config => cmd_config(), - PagerDutyCommand::Auth { token } => cmd_auth(&token), - PagerDutyCommand::Oncall { - policy, - schedule, - json, - } => cmd_oncall(policy.as_deref(), schedule.as_deref(), json).await, - PagerDutyCommand::Alerts { limit, json } => cmd_alerts(limit, json).await, - PagerDutyCommand::Incidents { - status, - limit, - json, - } => cmd_incidents(status, limit, json).await, - PagerDutyCommand::Show { id, json } => cmd_show(&id, json).await, - PagerDutyCommand::Whoami { json } => cmd_whoami(json).await, - } -} - -// ============================================================================ -// Reusable functions for MCP/HTTP - return typed data, never print -// ============================================================================ - -/// Get PagerDuty configuration status (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub fn get_config() -> Result { - service::get_config() -} - -/// List on-call users (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn list_oncalls(opts: &OncallOptions) -> Result> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = PagerDutyClient::new()?; - service::list_oncalls(&client, opts).await -} - -/// List active alerts - triggered + acknowledged only (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn list_alerts(limit: usize) -> Result> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = PagerDutyClient::new()?; - service::list_alerts(&client, limit).await -} - -/// List incidents with filters (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn list_incidents(opts: &IncidentOptions) -> Result> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = PagerDutyClient::new()?; - service::list_incidents(&client, opts).await -} - -/// Get incident details by ID (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn get_incident(id: &str) -> Result { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = PagerDutyClient::new()?; - service::get_incident(&client, id).await -} - -/// Get current user info (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn get_current_user() -> Result { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = PagerDutyClient::new()?; - service::get_current_user(&client).await -} - -// ============================================================================ -// CLI command handlers - create client, call service, format and print -// ============================================================================ - -/// Show config status -#[cfg(not(tarpaulin_include))] -fn cmd_config() -> Result<()> { - let config = service::get_config()?; - display::output_config_status(&config); - Ok(()) -} - -/// Save API token -#[cfg(not(tarpaulin_include))] -fn cmd_auth(token: &str) -> Result<()> { - service::save_auth(token)?; - println!("PagerDuty API token saved."); - Ok(()) -} - -/// Show who's on call -#[cfg(not(tarpaulin_include))] -async fn cmd_oncall(policy: Option<&str>, schedule: Option<&str>, json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = PagerDutyClient::new()?; - let opts = OncallOptions { - policy_id: policy.map(|p| p.to_string()), - schedule_id: schedule.map(|s| s.to_string()), - }; - - let oncalls = service::list_oncalls(&client, &opts).await?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - display::output_oncalls(&oncalls, format)?; - Ok(()) -} - -/// List active alerts (triggered + acknowledged) -#[cfg(not(tarpaulin_include))] -async fn cmd_alerts(limit: usize, json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = PagerDutyClient::new()?; - let incidents = service::list_alerts(&client, limit).await?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - display::output_incidents(&incidents, format)?; - Ok(()) -} - -/// List incidents with optional status filter -#[cfg(not(tarpaulin_include))] -async fn cmd_incidents(status: Option, limit: usize, json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = PagerDutyClient::new()?; - let opts = IncidentOptions { - statuses: status_filter_to_statuses(status), - limit, - }; - let incidents = service::list_incidents(&client, &opts).await?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - display::output_incidents(&incidents, format)?; - Ok(()) -} - -/// Show incident details -#[cfg(not(tarpaulin_include))] -async fn cmd_show(id: &str, json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = PagerDutyClient::new()?; - let incident = service::get_incident(&client, id).await?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - display::output_incident_detail(&incident, format)?; - Ok(()) -} - -/// Show current user info -#[cfg(not(tarpaulin_include))] -async fn cmd_whoami(json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = PagerDutyClient::new()?; - let user = service::get_current_user(&client).await?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - display::output_user(&user, format)?; - Ok(()) -} - -/// Convert CLI status filter to API statuses -fn status_filter_to_statuses(filter: Option) -> Vec { - match filter { - Some(StatusFilter::Triggered) => vec![IncidentStatus::Triggered], - Some(StatusFilter::Acknowledged) => vec![IncidentStatus::Acknowledged], - Some(StatusFilter::Resolved) => vec![IncidentStatus::Resolved], - Some(StatusFilter::Active) | None => { - vec![IncidentStatus::Triggered, IncidentStatus::Acknowledged] - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use config::PagerDutyConfig; - - #[test] - fn status_filter_to_statuses_none() { - let statuses = status_filter_to_statuses(None); - assert_eq!(statuses.len(), 2); - assert!(statuses.contains(&IncidentStatus::Triggered)); - assert!(statuses.contains(&IncidentStatus::Acknowledged)); - } - - #[test] - fn status_filter_to_statuses_triggered() { - let statuses = status_filter_to_statuses(Some(StatusFilter::Triggered)); - assert_eq!(statuses, vec![IncidentStatus::Triggered]); - } - - #[test] - fn status_filter_to_statuses_acknowledged() { - let statuses = status_filter_to_statuses(Some(StatusFilter::Acknowledged)); - assert_eq!(statuses, vec![IncidentStatus::Acknowledged]); - } - - #[test] - fn status_filter_to_statuses_resolved() { - let statuses = status_filter_to_statuses(Some(StatusFilter::Resolved)); - assert_eq!(statuses, vec![IncidentStatus::Resolved]); - } - - #[test] - fn status_filter_to_statuses_active() { - let statuses = status_filter_to_statuses(Some(StatusFilter::Active)); - assert_eq!(statuses.len(), 2); - assert!(statuses.contains(&IncidentStatus::Triggered)); - assert!(statuses.contains(&IncidentStatus::Acknowledged)); - } - - #[test] - fn cmd_config_runs() { - // Just verify it doesn't panic - let result = cmd_config(); - assert!(result.is_ok()); - } - - #[test] - fn ensure_configured_with_token_succeeds() { - let config = PagerDutyConfig { - api_token: Some("test-token".to_string()), - ..Default::default() - }; - let result = service::ensure_configured(&config); - assert!(result.is_ok()); - } - - #[test] - fn ensure_configured_without_token_fails() { - let config = PagerDutyConfig::default(); - let result = service::ensure_configured(&config); - assert!(result.is_err()); - } - - #[test] - fn cmd_auth_saves_token() { - // This test writes to config, which is I/O - just verify it runs - // Note: This may modify the actual config file, but we're testing the logic - // In a real scenario, we'd mock the file system - let result = cmd_auth("test-token-12345"); - // Either succeeds or fails due to file system permissions - let _ = result; - } -} diff --git a/src/pagerduty/service.rs b/src/pagerduty/service.rs deleted file mode 100644 index 9e70fa7..0000000 --- a/src/pagerduty/service.rs +++ /dev/null @@ -1,314 +0,0 @@ -//! PagerDuty service layer - business logic that returns data -//! -//! Functions in this module accept trait objects and return typed data. -//! They never print - that's the CLI layer's job. - -use anyhow::{bail, Result}; - -use super::client::PagerDutyApi; -use super::config::{self, PagerDutyConfig}; -use super::types::{Incident, IncidentStatus, Oncall, User}; - -/// Options for listing on-calls -#[derive(Debug, Default)] -pub struct OncallOptions { - /// Filter by escalation policy ID - pub policy_id: Option, - /// Filter by schedule ID - pub schedule_id: Option, -} - -/// Options for listing incidents -#[derive(Debug)] -pub struct IncidentOptions { - /// Filter by statuses - pub statuses: Vec, - /// Maximum number of results - pub limit: usize, -} - -impl Default for IncidentOptions { - fn default() -> Self { - Self { - statuses: vec![IncidentStatus::Triggered, IncidentStatus::Acknowledged], - limit: 25, - } - } -} - -/// Get current configuration -pub fn get_config() -> Result { - config::load_config() -} - -/// Save API token -pub fn save_auth(token: &str) -> Result<()> { - config::save_config(token) -} - -/// Check if API is configured, return error if not -pub fn ensure_configured(config: &PagerDutyConfig) -> Result<()> { - if !config.is_configured() { - bail!( - "PagerDuty not configured. Run: hu pagerduty auth \n\ - Or set PAGERDUTY_API_TOKEN environment variable." - ); - } - Ok(()) -} - -/// List on-call users -pub async fn list_oncalls(api: &impl PagerDutyApi, opts: &OncallOptions) -> Result> { - let policy_ids = opts.policy_id.as_ref().map(|p| vec![p.clone()]); - let schedule_ids = opts.schedule_id.as_ref().map(|s| vec![s.clone()]); - - api.list_oncalls(schedule_ids.as_deref(), policy_ids.as_deref()) - .await -} - -/// List incidents (alerts = triggered + acknowledged only) -pub async fn list_alerts(api: &impl PagerDutyApi, limit: usize) -> Result> { - let statuses = vec![IncidentStatus::Triggered, IncidentStatus::Acknowledged]; - api.list_incidents(&statuses, limit).await -} - -/// List incidents with options -pub async fn list_incidents( - api: &impl PagerDutyApi, - opts: &IncidentOptions, -) -> Result> { - api.list_incidents(&opts.statuses, opts.limit).await -} - -/// Get a single incident by ID -pub async fn get_incident(api: &impl PagerDutyApi, id: &str) -> Result { - api.get_incident(id).await -} - -/// Get current user info -pub async fn get_current_user(api: &impl PagerDutyApi) -> Result { - api.get_current_user().await -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::pagerduty::types::{EscalationPolicy, Schedule, Service, Urgency}; - - /// Mock PagerDuty API for testing - struct MockApi { - oncalls: Vec, - incidents: Vec, - user: User, - } - - impl MockApi { - fn new() -> Self { - Self { - oncalls: vec![], - incidents: vec![], - user: User { - id: "USER123".to_string(), - name: Some("Test User".to_string()), - summary: None, - email: "test@example.com".to_string(), - html_url: "https://pagerduty.com/users/USER123".to_string(), - }, - } - } - - fn with_oncalls(mut self, oncalls: Vec) -> Self { - self.oncalls = oncalls; - self - } - - fn with_incidents(mut self, incidents: Vec) -> Self { - self.incidents = incidents; - self - } - } - - impl PagerDutyApi for MockApi { - async fn get_current_user(&self) -> Result { - Ok(self.user.clone()) - } - - async fn list_oncalls( - &self, - _schedule_ids: Option<&[String]>, - _escalation_policy_ids: Option<&[String]>, - ) -> Result> { - Ok(self.oncalls.clone()) - } - - async fn list_incidents( - &self, - statuses: &[IncidentStatus], - limit: usize, - ) -> Result> { - let filtered: Vec = self - .incidents - .iter() - .filter(|i| statuses.contains(&i.status)) - .take(limit) - .cloned() - .collect(); - Ok(filtered) - } - - async fn get_incident(&self, id: &str) -> Result { - self.incidents - .iter() - .find(|i| i.id == id) - .cloned() - .ok_or_else(|| anyhow::anyhow!("Incident not found: {}", id)) - } - - async fn list_services(&self) -> Result> { - Ok(vec![]) - } - } - - fn make_oncall(user_name: &str, policy_name: &str) -> Oncall { - Oncall { - user: User { - id: format!("U{}", user_name), - name: Some(user_name.to_string()), - summary: None, - email: format!("{}@example.com", user_name.to_lowercase()), - html_url: String::new(), - }, - schedule: Some(Schedule { - id: "SCHED1".to_string(), - name: "Primary".to_string(), - html_url: String::new(), - }), - escalation_policy: EscalationPolicy { - id: "POL1".to_string(), - name: policy_name.to_string(), - html_url: String::new(), - }, - escalation_level: 1, - start: None, - end: None, - } - } - - fn make_incident(id: &str, title: &str, status: IncidentStatus) -> Incident { - Incident { - id: id.to_string(), - incident_number: 123, - title: title.to_string(), - status, - urgency: Urgency::High, - created_at: "2024-01-01T00:00:00Z".to_string(), - html_url: String::new(), - service: Service { - id: "SVC1".to_string(), - name: "Test Service".to_string(), - status: "active".to_string(), - html_url: String::new(), - }, - assignments: vec![], - } - } - - #[tokio::test] - async fn list_oncalls_returns_data() { - let api = MockApi::new().with_oncalls(vec![ - make_oncall("Alice", "Engineering"), - make_oncall("Bob", "Platform"), - ]); - - let result = list_oncalls(&api, &OncallOptions::default()).await.unwrap(); - assert_eq!(result.len(), 2); - assert_eq!(result[0].user.display_name(), "Alice"); - } - - #[tokio::test] - async fn list_alerts_filters_statuses() { - let api = MockApi::new().with_incidents(vec![ - make_incident("INC1", "Alert 1", IncidentStatus::Triggered), - make_incident("INC2", "Alert 2", IncidentStatus::Resolved), - make_incident("INC3", "Alert 3", IncidentStatus::Acknowledged), - ]); - - let result = list_alerts(&api, 10).await.unwrap(); - assert_eq!(result.len(), 2); // Only triggered and acknowledged - assert!(result.iter().all(|i| i.status != IncidentStatus::Resolved)); - } - - #[tokio::test] - async fn list_incidents_respects_limit() { - let api = MockApi::new().with_incidents(vec![ - make_incident("INC1", "Alert 1", IncidentStatus::Triggered), - make_incident("INC2", "Alert 2", IncidentStatus::Triggered), - make_incident("INC3", "Alert 3", IncidentStatus::Triggered), - ]); - - let opts = IncidentOptions { - statuses: vec![IncidentStatus::Triggered], - limit: 2, - }; - let result = list_incidents(&api, &opts).await.unwrap(); - assert_eq!(result.len(), 2); - } - - #[tokio::test] - async fn get_incident_returns_matching() { - let api = MockApi::new().with_incidents(vec![ - make_incident("INC1", "Alert 1", IncidentStatus::Triggered), - make_incident("INC2", "Alert 2", IncidentStatus::Resolved), - ]); - - let result = get_incident(&api, "INC2").await.unwrap(); - assert_eq!(result.id, "INC2"); - assert_eq!(result.title, "Alert 2"); - } - - #[tokio::test] - async fn get_incident_not_found() { - let api = MockApi::new(); - let result = get_incident(&api, "MISSING").await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn get_current_user_returns_data() { - let api = MockApi::new(); - let result = get_current_user(&api).await.unwrap(); - assert_eq!(result.display_name(), "Test User"); - } - - #[test] - fn ensure_configured_fails_without_token() { - let config = PagerDutyConfig::default(); - let result = ensure_configured(&config); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not configured")); - } - - #[test] - fn ensure_configured_succeeds_with_token() { - let config = PagerDutyConfig { - api_token: Some("test-token".to_string()), - ..Default::default() - }; - let result = ensure_configured(&config); - assert!(result.is_ok()); - } - - #[test] - fn oncall_options_default() { - let opts = OncallOptions::default(); - assert!(opts.policy_id.is_none()); - assert!(opts.schedule_id.is_none()); - } - - #[test] - fn incident_options_default() { - let opts = IncidentOptions::default(); - assert_eq!(opts.limit, 25); - assert_eq!(opts.statuses.len(), 2); - } -} diff --git a/src/pagerduty/types/mod.rs b/src/pagerduty/types/mod.rs deleted file mode 100644 index eae96b9..0000000 --- a/src/pagerduty/types/mod.rs +++ /dev/null @@ -1,199 +0,0 @@ -//! PagerDuty data types - -use serde::{Deserialize, Serialize}; - -pub use crate::util::OutputFormat; - -#[cfg(test)] -mod tests; - -/// PagerDuty user -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct User { - /// User ID - pub id: String, - /// User name (full response) - #[serde(default)] - pub name: Option, - /// Summary (reference response) - #[serde(default)] - pub summary: Option, - /// Email address - #[serde(default)] - pub email: String, - /// URL to user in PagerDuty - #[serde(default)] - pub html_url: String, -} - -impl User { - /// Get display name (prefers name over summary) - pub fn display_name(&self) -> &str { - self.name - .as_deref() - .or(self.summary.as_deref()) - .unwrap_or(&self.id) - } -} - -/// Escalation policy -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EscalationPolicy { - /// Policy ID - pub id: String, - /// Policy name (API returns "summary" for references) - #[serde(alias = "summary")] - pub name: String, - /// URL to policy in PagerDuty - #[serde(default)] - pub html_url: String, -} - -/// On-call schedule -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Schedule { - /// Schedule ID - pub id: String, - /// Schedule name (API returns "summary" for references) - #[serde(alias = "summary")] - pub name: String, - /// URL to schedule in PagerDuty - #[serde(default)] - pub html_url: String, -} - -/// On-call entry -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Oncall { - /// User on call - pub user: User, - /// Schedule (if any) - pub schedule: Option, - /// Escalation policy - pub escalation_policy: EscalationPolicy, - /// Escalation level (1 = primary, 2 = secondary, etc.) - pub escalation_level: u32, - /// Start time of on-call shift - pub start: Option, - /// End time of on-call shift - pub end: Option, -} - -/// Service -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Service { - /// Service ID - pub id: String, - /// Service name (API returns "summary" for references) - #[serde(alias = "summary")] - pub name: String, - /// Service status - #[serde(default)] - pub status: String, - /// URL to service in PagerDuty - #[serde(default)] - pub html_url: String, -} - -/// Incident urgency -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum Urgency { - /// High urgency - High, - /// Low urgency - Low, -} - -/// Incident status -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum IncidentStatus { - /// Triggered - not yet acknowledged - Triggered, - /// Acknowledged - someone is working on it - Acknowledged, - /// Resolved - incident is closed - Resolved, -} - -impl IncidentStatus { - /// Convert to API query string value - #[must_use] - pub fn as_str(&self) -> &'static str { - match self { - Self::Triggered => "triggered", - Self::Acknowledged => "acknowledged", - Self::Resolved => "resolved", - } - } -} - -/// Assignment (user assigned to incident) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Assignment { - /// Assigned user - pub assignee: User, -} - -/// Incident -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Incident { - /// Incident ID - pub id: String, - /// Incident number - pub incident_number: u64, - /// Title/summary - pub title: String, - /// Current status - pub status: IncidentStatus, - /// Urgency level - pub urgency: Urgency, - /// Creation timestamp - pub created_at: String, - /// URL to incident in PagerDuty - #[serde(default)] - pub html_url: String, - /// Service this incident belongs to - pub service: Service, - /// Users assigned to this incident - #[serde(default)] - pub assignments: Vec, -} - -/// API response wrapper for oncalls -#[derive(Debug, Deserialize)] -pub struct OncallsResponse { - /// List of oncalls - pub oncalls: Vec, -} - -/// API response wrapper for incidents -#[derive(Debug, Deserialize)] -pub struct IncidentsResponse { - /// List of incidents - pub incidents: Vec, -} - -/// API response wrapper for single incident -#[derive(Debug, Deserialize)] -pub struct IncidentResponse { - /// The incident - pub incident: Incident, -} - -/// API response wrapper for services -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -pub struct ServicesResponse { - /// List of services - pub services: Vec, -} - -/// Current user response -#[allow(dead_code)] -#[derive(Debug, Deserialize)] -pub struct CurrentUserResponse { - /// The user - pub user: User, -} diff --git a/src/pagerduty/types/tests.rs b/src/pagerduty/types/tests.rs deleted file mode 100644 index 00de461..0000000 --- a/src/pagerduty/types/tests.rs +++ /dev/null @@ -1,251 +0,0 @@ -use super::*; - -#[test] -fn incident_status_deserialize() { - let json = r#""triggered""#; - let status: IncidentStatus = serde_json::from_str(json).unwrap(); - assert_eq!(status, IncidentStatus::Triggered); - - let json = r#""acknowledged""#; - let status: IncidentStatus = serde_json::from_str(json).unwrap(); - assert_eq!(status, IncidentStatus::Acknowledged); - - let json = r#""resolved""#; - let status: IncidentStatus = serde_json::from_str(json).unwrap(); - assert_eq!(status, IncidentStatus::Resolved); -} - -#[test] -fn incident_status_serialize() { - let json = serde_json::to_string(&IncidentStatus::Triggered).unwrap(); - assert_eq!(json, r#""triggered""#); - - let json = serde_json::to_string(&IncidentStatus::Acknowledged).unwrap(); - assert_eq!(json, r#""acknowledged""#); - - let json = serde_json::to_string(&IncidentStatus::Resolved).unwrap(); - assert_eq!(json, r#""resolved""#); -} - -#[test] -fn incident_status_as_str() { - assert_eq!(IncidentStatus::Triggered.as_str(), "triggered"); - assert_eq!(IncidentStatus::Acknowledged.as_str(), "acknowledged"); - assert_eq!(IncidentStatus::Resolved.as_str(), "resolved"); -} - -#[test] -fn urgency_deserialize() { - let json = r#""high""#; - let urgency: Urgency = serde_json::from_str(json).unwrap(); - assert_eq!(urgency, Urgency::High); - - let json = r#""low""#; - let urgency: Urgency = serde_json::from_str(json).unwrap(); - assert_eq!(urgency, Urgency::Low); -} - -#[test] -fn urgency_serialize() { - let json = serde_json::to_string(&Urgency::High).unwrap(); - assert_eq!(json, r#""high""#); - - let json = serde_json::to_string(&Urgency::Low).unwrap(); - assert_eq!(json, r#""low""#); -} - -#[test] -fn user_deserialize() { - let json = r#"{ - "id": "U123", - "name": "Alice Smith", - "email": "alice@example.com", - "html_url": "https://pagerduty.com/users/U123" - }"#; - let user: User = serde_json::from_str(json).unwrap(); - assert_eq!(user.id, "U123"); - assert_eq!(user.display_name(), "Alice Smith"); - assert_eq!(user.email, "alice@example.com"); - assert_eq!(user.html_url, "https://pagerduty.com/users/U123"); -} - -#[test] -fn user_deserialize_without_html_url() { - let json = r#"{ - "id": "U123", - "name": "Alice Smith", - "email": "alice@example.com" - }"#; - let user: User = serde_json::from_str(json).unwrap(); - assert_eq!(user.html_url, ""); -} - -#[test] -fn oncall_deserialize() { - let json = r#"{ - "user": {"id": "U1", "name": "Alice", "email": "alice@example.com"}, - "escalation_policy": {"id": "EP1", "name": "Primary"}, - "escalation_level": 1, - "schedule": null, - "start": "2026-01-01T00:00:00Z", - "end": "2026-01-08T00:00:00Z" - }"#; - let oncall: Oncall = serde_json::from_str(json).unwrap(); - assert_eq!(oncall.user.display_name(), "Alice"); - assert_eq!(oncall.escalation_level, 1); - assert!(oncall.schedule.is_none()); - assert_eq!(oncall.start, Some("2026-01-01T00:00:00Z".to_string())); -} - -#[test] -fn oncall_deserialize_with_schedule() { - let json = r#"{ - "user": {"id": "U1", "name": "Alice", "email": "alice@example.com"}, - "escalation_policy": {"id": "EP1", "name": "Primary"}, - "escalation_level": 2, - "schedule": {"id": "S1", "name": "Weekly Rotation"}, - "start": null, - "end": null - }"#; - let oncall: Oncall = serde_json::from_str(json).unwrap(); - assert!(oncall.schedule.is_some()); - assert_eq!(oncall.schedule.unwrap().name, "Weekly Rotation"); - assert_eq!(oncall.escalation_level, 2); -} - -#[test] -fn incident_deserialize() { - let json = r#"{ - "id": "INC123", - "incident_number": 42, - "title": "Server down", - "status": "triggered", - "urgency": "high", - "created_at": "2026-01-01T12:00:00Z", - "html_url": "https://pagerduty.com/incidents/INC123", - "service": {"id": "S1", "name": "Production", "status": "active"}, - "assignments": [] - }"#; - let incident: Incident = serde_json::from_str(json).unwrap(); - assert_eq!(incident.id, "INC123"); - assert_eq!(incident.incident_number, 42); - assert_eq!(incident.status, IncidentStatus::Triggered); - assert_eq!(incident.urgency, Urgency::High); - assert_eq!(incident.service.name, "Production"); -} - -#[test] -fn incident_deserialize_with_assignments() { - let json = r#"{ - "id": "INC123", - "incident_number": 42, - "title": "Server down", - "status": "acknowledged", - "urgency": "low", - "created_at": "2026-01-01T12:00:00Z", - "service": {"id": "S1", "name": "Production", "status": "active"}, - "assignments": [ - {"assignee": {"id": "U1", "name": "Alice", "email": "alice@example.com"}} - ] - }"#; - let incident: Incident = serde_json::from_str(json).unwrap(); - assert_eq!(incident.assignments.len(), 1); - assert_eq!(incident.assignments[0].assignee.display_name(), "Alice"); -} - -#[test] -fn oncalls_response_deserialize() { - let json = r#"{"oncalls": []}"#; - let resp: OncallsResponse = serde_json::from_str(json).unwrap(); - assert!(resp.oncalls.is_empty()); -} - -#[test] -fn incidents_response_deserialize() { - let json = r#"{"incidents": []}"#; - let resp: IncidentsResponse = serde_json::from_str(json).unwrap(); - assert!(resp.incidents.is_empty()); -} - -#[test] -fn services_response_deserialize() { - let json = r#"{"services": []}"#; - let resp: ServicesResponse = serde_json::from_str(json).unwrap(); - assert!(resp.services.is_empty()); -} - -#[test] -fn current_user_response_deserialize() { - let json = r#"{"user": {"id": "U1", "name": "Alice", "email": "alice@example.com"}}"#; - let resp: CurrentUserResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.user.display_name(), "Alice"); -} - -#[test] -fn types_are_debug() { - // Ensure all types implement Debug - let user = User { - id: "U1".to_string(), - name: Some("Alice".to_string()), - summary: None, - email: "alice@example.com".to_string(), - html_url: String::new(), - }; - let _ = format!("{:?}", user); - let _ = format!("{:?}", IncidentStatus::Triggered); - let _ = format!("{:?}", Urgency::High); - let _ = format!("{:?}", OutputFormat::Table); -} - -#[test] -fn types_are_clone() { - let user = User { - id: "U1".to_string(), - name: Some("Alice".to_string()), - summary: None, - email: "alice@example.com".to_string(), - html_url: String::new(), - }; - let cloned = user.clone(); - assert_eq!(cloned.id, user.id); - - let status = IncidentStatus::Triggered; - let cloned = status; - assert_eq!(cloned, status); -} - -#[test] -fn user_display_name_prefers_name() { - let user = User { - id: "U1".to_string(), - name: Some("Alice".to_string()), - summary: Some("Alice Summary".to_string()), - email: String::new(), - html_url: String::new(), - }; - assert_eq!(user.display_name(), "Alice"); -} - -#[test] -fn user_display_name_falls_back_to_summary() { - let user = User { - id: "U1".to_string(), - name: None, - summary: Some("Alice Summary".to_string()), - email: String::new(), - html_url: String::new(), - }; - assert_eq!(user.display_name(), "Alice Summary"); -} - -#[test] -fn user_display_name_falls_back_to_id() { - let user = User { - id: "U1".to_string(), - name: None, - summary: None, - email: String::new(), - html_url: String::new(), - }; - assert_eq!(user.display_name(), "U1"); -} diff --git a/src/pipeline/aws.rs b/src/pipeline/aws.rs deleted file mode 100644 index 2aaf998..0000000 --- a/src/pipeline/aws.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! AWS CLI wrapper functions - -use anyhow::{Context, Result}; -use std::process::Command; - -use super::types::{ - AwsConfig, ListExecutionsResponse, ListPipelinesResponse, Pipeline, PipelineExecution, - PipelineState, -}; - -/// Build AWS CLI base command with region -#[cfg(not(tarpaulin_include))] -fn build_aws_cmd(config: &AwsConfig) -> Command { - let mut cmd = Command::new("aws"); - cmd.arg("codepipeline"); - - if let Some(region) = &config.region { - cmd.arg("--region").arg(region); - } - - cmd -} - -/// List all pipelines -#[cfg(not(tarpaulin_include))] -pub fn list_pipelines(config: &AwsConfig) -> Result> { - let mut cmd = build_aws_cmd(config); - cmd.arg("list-pipelines"); - - let output = cmd - .output() - .context("Failed to execute aws cli. Is AWS CLI installed and configured?")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("aws cli failed: {}", stderr.trim()); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - parse_list_pipelines(&stdout) -} - -/// Parse list-pipelines output -pub fn parse_list_pipelines(json: &str) -> Result> { - let resp: ListPipelinesResponse = - serde_json::from_str(json).context("Failed to parse aws cli output")?; - - Ok(resp.pipelines.iter().map(|s| s.to_pipeline()).collect()) -} - -/// Get pipeline state -#[cfg(not(tarpaulin_include))] -pub fn get_pipeline_state(config: &AwsConfig, name: &str) -> Result { - let mut cmd = build_aws_cmd(config); - cmd.arg("get-pipeline-state").arg("--name").arg(name); - - let output = cmd.output().context("Failed to execute aws cli")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("aws cli failed: {}", stderr.trim()); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - parse_pipeline_state(&stdout) -} - -/// Parse get-pipeline-state output -pub fn parse_pipeline_state(json: &str) -> Result { - serde_json::from_str(json).context("Failed to parse pipeline state") -} - -/// List pipeline executions -#[cfg(not(tarpaulin_include))] -pub fn list_executions( - config: &AwsConfig, - name: &str, - limit: usize, -) -> Result> { - let mut cmd = build_aws_cmd(config); - cmd.arg("list-pipeline-executions") - .arg("--pipeline-name") - .arg(name) - .arg("--max-results") - .arg(limit.to_string()); - - let output = cmd.output().context("Failed to execute aws cli")?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("aws cli failed: {}", stderr.trim()); - } - - let stdout = String::from_utf8_lossy(&output.stdout); - parse_list_executions(&stdout) -} - -/// Parse list-pipeline-executions output -pub fn parse_list_executions(json: &str) -> Result> { - let resp: ListExecutionsResponse = - serde_json::from_str(json).context("Failed to parse executions")?; - - Ok(resp.executions) -} - -/// Build list-pipelines args (for testing) -#[cfg(test)] -pub fn build_list_args(config: &AwsConfig) -> Vec { - let mut args = vec!["codepipeline".to_string()]; - - if let Some(region) = &config.region { - args.push("--region".to_string()); - args.push(region.clone()); - } - - args.push("list-pipelines".to_string()); - args -} - -/// Build get-pipeline-state args (for testing) -#[cfg(test)] -pub fn build_state_args(config: &AwsConfig, name: &str) -> Vec { - let mut args = vec!["codepipeline".to_string()]; - - if let Some(region) = &config.region { - args.push("--region".to_string()); - args.push(region.clone()); - } - - args.push("get-pipeline-state".to_string()); - args.push("--name".to_string()); - args.push(name.to_string()); - args -} - -/// Build list-pipeline-executions args (for testing) -#[cfg(test)] -pub fn build_executions_args(config: &AwsConfig, name: &str, limit: usize) -> Vec { - let mut args = vec!["codepipeline".to_string()]; - - if let Some(region) = &config.region { - args.push("--region".to_string()); - args.push(region.clone()); - } - - args.push("list-pipeline-executions".to_string()); - args.push("--pipeline-name".to_string()); - args.push(name.to_string()); - args.push("--max-results".to_string()); - args.push(limit.to_string()); - args -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn build_list_args_basic() { - let config = AwsConfig::default(); - let args = build_list_args(&config); - assert_eq!(args, vec!["codepipeline", "list-pipelines"]); - } - - #[test] - fn build_list_args_with_region() { - let config = AwsConfig { - region: Some("us-west-2".to_string()), - }; - let args = build_list_args(&config); - assert_eq!( - args, - vec!["codepipeline", "--region", "us-west-2", "list-pipelines"] - ); - } - - #[test] - fn build_state_args_basic() { - let config = AwsConfig::default(); - let args = build_state_args(&config, "my-pipeline"); - assert_eq!( - args, - vec![ - "codepipeline", - "get-pipeline-state", - "--name", - "my-pipeline" - ] - ); - } - - #[test] - fn build_state_args_with_region() { - let config = AwsConfig { - region: Some("eu-west-1".to_string()), - }; - let args = build_state_args(&config, "my-pipeline"); - assert_eq!( - args, - vec![ - "codepipeline", - "--region", - "eu-west-1", - "get-pipeline-state", - "--name", - "my-pipeline" - ] - ); - } - - #[test] - fn build_executions_args_basic() { - let config = AwsConfig::default(); - let args = build_executions_args(&config, "my-pipeline", 10); - assert_eq!( - args, - vec![ - "codepipeline", - "list-pipeline-executions", - "--pipeline-name", - "my-pipeline", - "--max-results", - "10" - ] - ); - } - - #[test] - fn parse_list_pipelines_empty() { - let json = r#"{"pipelines": []}"#; - let pipelines = parse_list_pipelines(json).unwrap(); - assert!(pipelines.is_empty()); - } - - #[test] - fn parse_list_pipelines_single() { - let json = r#"{"pipelines": [{"name": "test"}]}"#; - let pipelines = parse_list_pipelines(json).unwrap(); - assert_eq!(pipelines.len(), 1); - assert_eq!(pipelines[0].name, "test"); - } - - #[test] - fn parse_list_pipelines_invalid() { - let result = parse_list_pipelines("not json"); - assert!(result.is_err()); - } - - #[test] - fn parse_pipeline_state_basic() { - let json = r#"{ - "pipelineName": "test", - "stageStates": [] - }"#; - let state = parse_pipeline_state(json).unwrap(); - assert_eq!(state.name, "test"); - } - - #[test] - fn parse_list_executions_empty() { - let json = r#"{"pipelineExecutionSummaries": []}"#; - let executions = parse_list_executions(json).unwrap(); - assert!(executions.is_empty()); - } - - #[test] - fn parse_list_executions_single() { - let json = r#"{ - "pipelineExecutionSummaries": [ - {"pipelineExecutionId": "exec-1", "status": "Succeeded"} - ] - }"#; - let executions = parse_list_executions(json).unwrap(); - assert_eq!(executions.len(), 1); - assert_eq!(executions[0].id, "exec-1"); - } - - #[test] - fn build_executions_args_with_region() { - let config = AwsConfig { - region: Some("ap-northeast-1".to_string()), - }; - let args = build_executions_args(&config, "prod-pipeline", 5); - assert_eq!( - args, - vec![ - "codepipeline", - "--region", - "ap-northeast-1", - "list-pipeline-executions", - "--pipeline-name", - "prod-pipeline", - "--max-results", - "5" - ] - ); - } - - #[test] - fn parse_list_pipelines_multiple() { - let json = r#"{ - "pipelines": [ - {"name": "pipeline-1", "created": "2026-01-01", "updated": "2026-01-02"}, - {"name": "pipeline-2"}, - {"name": "pipeline-3", "created": "2026-01-03"} - ] - }"#; - let pipelines = parse_list_pipelines(json).unwrap(); - assert_eq!(pipelines.len(), 3); - assert_eq!(pipelines[0].name, "pipeline-1"); - assert_eq!(pipelines[0].created, Some("2026-01-01".to_string())); - assert_eq!(pipelines[0].updated, Some("2026-01-02".to_string())); - assert_eq!(pipelines[1].name, "pipeline-2"); - assert!(pipelines[1].created.is_none()); - assert_eq!(pipelines[2].name, "pipeline-3"); - } - - #[test] - fn parse_pipeline_state_with_stages() { - let json = r#"{ - "pipelineName": "complex-pipeline", - "stageStates": [ - { - "stageName": "Source", - "latestExecution": {"status": "Succeeded"}, - "actionStates": [ - {"actionName": "GitCheckout", "latestExecution": {"status": "Succeeded"}} - ] - }, - { - "stageName": "Build", - "latestExecution": {"status": "InProgress"}, - "actionStates": [] - } - ] - }"#; - let state = parse_pipeline_state(json).unwrap(); - assert_eq!(state.name, "complex-pipeline"); - assert_eq!(state.stages.len(), 2); - assert_eq!(state.stages[0].name, "Source"); - assert_eq!( - state.stages[0].latest_execution.as_ref().unwrap().status, - "Succeeded" - ); - assert_eq!(state.stages[0].actions.len(), 1); - assert_eq!(state.stages[0].actions[0].name, "GitCheckout"); - assert_eq!(state.stages[1].name, "Build"); - } - - #[test] - fn parse_pipeline_state_invalid_json() { - let result = parse_pipeline_state("invalid json"); - assert!(result.is_err()); - } - - #[test] - fn parse_list_executions_invalid() { - let result = parse_list_executions("not valid json"); - assert!(result.is_err()); - } - - #[test] - fn parse_list_executions_multiple() { - let json = r#"{ - "pipelineExecutionSummaries": [ - { - "pipelineExecutionId": "exec-1", - "status": "Succeeded", - "startTime": "2026-01-01T10:00:00Z", - "lastUpdateTime": "2026-01-01T10:30:00Z", - "trigger": {"triggerType": "Webhook"} - }, - { - "pipelineExecutionId": "exec-2", - "status": "Failed", - "startTime": "2026-01-01T08:00:00Z" - }, - { - "pipelineExecutionId": "exec-3", - "status": "InProgress" - } - ] - }"#; - let executions = parse_list_executions(json).unwrap(); - assert_eq!(executions.len(), 3); - assert_eq!(executions[0].id, "exec-1"); - assert_eq!(executions[0].status, "Succeeded"); - assert!(executions[0].trigger.is_some()); - assert_eq!( - executions[0].trigger.as_ref().unwrap().trigger_type, - "Webhook" - ); - assert_eq!(executions[1].id, "exec-2"); - assert_eq!(executions[1].status, "Failed"); - assert!(executions[1].trigger.is_none()); - assert_eq!(executions[2].id, "exec-3"); - assert_eq!(executions[2].status, "InProgress"); - } -} diff --git a/src/pipeline/cli.rs b/src/pipeline/cli.rs deleted file mode 100644 index edc2a8e..0000000 --- a/src/pipeline/cli.rs +++ /dev/null @@ -1,197 +0,0 @@ -//! Pipeline CLI commands - -use clap::Subcommand; - -#[derive(Debug, Subcommand)] -pub enum PipelineCommand { - /// List all pipelines - List { - /// AWS region - #[arg(short, long)] - region: Option, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// Show pipeline status (stages and actions) - Status { - /// Pipeline name - name: String, - - /// AWS region - #[arg(short, long)] - region: Option, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// Show pipeline execution history - History { - /// Pipeline name - name: String, - - /// AWS region - #[arg(short, long)] - region: Option, - - /// Maximum number of results - #[arg(short, long, default_value = "10")] - limit: usize, - - /// Output as JSON - #[arg(long)] - json: bool, - }, -} - -#[cfg(test)] -mod tests { - use super::*; - use clap::{CommandFactory, Parser}; - - #[derive(Parser)] - struct TestCli { - #[command(subcommand)] - cmd: PipelineCommand, - } - - #[test] - fn parses_list_basic() { - let cli = TestCli::try_parse_from(["test", "list"]).unwrap(); - match cli.cmd { - PipelineCommand::List { region, json } => { - assert!(region.is_none()); - assert!(!json); - } - _ => panic!("Expected List command"), - } - } - - #[test] - fn parses_list_with_region() { - let cli = TestCli::try_parse_from(["test", "list", "-r", "us-west-2"]).unwrap(); - match cli.cmd { - PipelineCommand::List { region, .. } => { - assert_eq!(region, Some("us-west-2".to_string())); - } - _ => panic!("Expected List command"), - } - } - - #[test] - fn parses_list_json() { - let cli = TestCli::try_parse_from(["test", "list", "--json"]).unwrap(); - match cli.cmd { - PipelineCommand::List { json, .. } => { - assert!(json); - } - _ => panic!("Expected List command"), - } - } - - #[test] - fn parses_status_basic() { - let cli = TestCli::try_parse_from(["test", "status", "my-pipeline"]).unwrap(); - match cli.cmd { - PipelineCommand::Status { name, region, json } => { - assert_eq!(name, "my-pipeline"); - assert!(region.is_none()); - assert!(!json); - } - _ => panic!("Expected Status command"), - } - } - - #[test] - fn parses_status_with_region() { - let cli = - TestCli::try_parse_from(["test", "status", "my-pipeline", "-r", "eu-west-1"]).unwrap(); - match cli.cmd { - PipelineCommand::Status { region, .. } => { - assert_eq!(region, Some("eu-west-1".to_string())); - } - _ => panic!("Expected Status command"), - } - } - - #[test] - fn parses_status_json() { - let cli = TestCli::try_parse_from(["test", "status", "my-pipeline", "--json"]).unwrap(); - match cli.cmd { - PipelineCommand::Status { json, .. } => { - assert!(json); - } - _ => panic!("Expected Status command"), - } - } - - #[test] - fn parses_history_basic() { - let cli = TestCli::try_parse_from(["test", "history", "my-pipeline"]).unwrap(); - match cli.cmd { - PipelineCommand::History { - name, limit, json, .. - } => { - assert_eq!(name, "my-pipeline"); - assert_eq!(limit, 10); // default - assert!(!json); - } - _ => panic!("Expected History command"), - } - } - - #[test] - fn parses_history_with_limit() { - let cli = TestCli::try_parse_from(["test", "history", "my-pipeline", "-l", "25"]).unwrap(); - match cli.cmd { - PipelineCommand::History { limit, .. } => { - assert_eq!(limit, 25); - } - _ => panic!("Expected History command"), - } - } - - #[test] - fn parses_history_with_region() { - let cli = TestCli::try_parse_from(["test", "history", "my-pipeline", "-r", "ap-south-1"]) - .unwrap(); - match cli.cmd { - PipelineCommand::History { region, .. } => { - assert_eq!(region, Some("ap-south-1".to_string())); - } - _ => panic!("Expected History command"), - } - } - - #[test] - fn parses_history_json() { - let cli = TestCli::try_parse_from(["test", "history", "my-pipeline", "--json"]).unwrap(); - match cli.cmd { - PipelineCommand::History { json, .. } => { - assert!(json); - } - _ => panic!("Expected History command"), - } - } - - #[test] - fn command_debug() { - let cmd = PipelineCommand::List { - region: None, - json: false, - }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("List")); - } - - #[test] - fn command_has_help() { - let mut cmd = TestCli::command(); - let help = cmd.render_help(); - assert!(!help.to_string().is_empty()); - } -} diff --git a/src/pipeline/display/mod.rs b/src/pipeline/display/mod.rs deleted file mode 100644 index ecae52f..0000000 --- a/src/pipeline/display/mod.rs +++ /dev/null @@ -1,178 +0,0 @@ -//! Pipeline output formatting - -use anyhow::{Context, Result}; -use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, ContentArrangement, Table}; - -use super::types::{OutputFormat, Pipeline, PipelineExecution, PipelineState, StageStatus}; - -#[cfg(test)] -mod tests; - -/// Get color for stage/execution status -fn status_color(status: &str) -> Color { - match status { - "Succeeded" => Color::Green, - "InProgress" => Color::Yellow, - "Failed" => Color::Red, - "Stopped" | "Cancelled" | "Superseded" => Color::DarkGrey, - _ => Color::White, - } -} - -/// Get icon for status -fn status_icon(status: &str) -> &'static str { - match status { - "Succeeded" => "✓", - "InProgress" => "◐", - "Failed" => "✗", - "Stopped" | "Cancelled" => "○", - _ => " ", - } -} - -/// Output pipelines list -pub fn output_pipelines(pipelines: &[Pipeline], format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - if pipelines.is_empty() { - println!("No pipelines found."); - return Ok(()); - } - - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - table.set_header(vec!["NAME", "CREATED", "UPDATED"]); - - for pipeline in pipelines { - table.add_row(vec![ - Cell::new(&pipeline.name).fg(Color::Cyan), - Cell::new(pipeline.created.as_deref().unwrap_or("-")), - Cell::new(pipeline.updated.as_deref().unwrap_or("-")), - ]); - } - - println!("{table}"); - println!("\n{} pipelines", pipelines.len()); - } - OutputFormat::Json => { - let json = - serde_json::to_string_pretty(pipelines).context("Failed to serialize pipelines")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output pipeline state (stages with status) -pub fn output_pipeline_state(state: &PipelineState, format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - println!("Pipeline: {}", state.name); - println!(); - - if state.stages.is_empty() { - println!("No stages found."); - return Ok(()); - } - - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - table.set_header(vec!["STAGE", "STATUS", "ACTIONS"]); - - for stage in &state.stages { - let status = stage - .latest_execution - .as_ref() - .map(|e| e.status.as_str()) - .unwrap_or("-"); - - let status_enum = StageStatus::from_str(status); - let icon = status_icon(status); - let display_status = format!("{} {}", icon, status); - - // Show action count - let action_count = stage.actions.len(); - let action_summary = if action_count > 0 { - let succeeded = stage - .actions - .iter() - .filter(|a| { - a.latest_execution - .as_ref() - .map(|e| e.status == "Succeeded") - .unwrap_or(false) - }) - .count(); - format!("{}/{} succeeded", succeeded, action_count) - } else { - "-".to_string() - }; - - table.add_row(vec![ - Cell::new(&stage.name).fg(Color::Cyan), - Cell::new(&display_status).fg(match status_enum { - StageStatus::Succeeded => Color::Green, - StageStatus::InProgress => Color::Yellow, - StageStatus::Failed => Color::Red, - _ => Color::White, - }), - Cell::new(&action_summary), - ]); - } - - println!("{table}"); - } - OutputFormat::Json => { - let json = serde_json::to_string_pretty(state) - .context("Failed to serialize pipeline state")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output pipeline execution history -pub fn output_executions(executions: &[PipelineExecution], format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - if executions.is_empty() { - println!("No executions found."); - return Ok(()); - } - - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - table.set_header(vec!["ID", "STATUS", "STARTED", "TRIGGER"]); - - for exec in executions { - let icon = status_icon(&exec.status); - let display_status = format!("{} {}", icon, exec.status); - - let trigger = exec - .trigger - .as_ref() - .map(|t| t.trigger_type.as_str()) - .unwrap_or("-"); - - table.add_row(vec![ - Cell::new(&exec.id).fg(Color::Cyan), - Cell::new(&display_status).fg(status_color(&exec.status)), - Cell::new(exec.started.as_deref().unwrap_or("-")), - Cell::new(trigger), - ]); - } - - println!("{table}"); - println!("\n{} executions", executions.len()); - } - OutputFormat::Json => { - let json = serde_json::to_string_pretty(executions) - .context("Failed to serialize executions")?; - println!("{json}"); - } - } - Ok(()) -} diff --git a/src/pipeline/display/tests.rs b/src/pipeline/display/tests.rs deleted file mode 100644 index 176bcfd..0000000 --- a/src/pipeline/display/tests.rs +++ /dev/null @@ -1,235 +0,0 @@ -use super::*; - -#[test] -fn status_color_succeeded() { - assert_eq!(status_color("Succeeded"), Color::Green); -} - -#[test] -fn status_color_in_progress() { - assert_eq!(status_color("InProgress"), Color::Yellow); -} - -#[test] -fn status_color_failed() { - assert_eq!(status_color("Failed"), Color::Red); -} - -#[test] -fn status_color_stopped() { - assert_eq!(status_color("Stopped"), Color::DarkGrey); -} - -#[test] -fn status_color_cancelled() { - assert_eq!(status_color("Cancelled"), Color::DarkGrey); -} - -#[test] -fn status_color_unknown() { - assert_eq!(status_color("Unknown"), Color::White); -} - -#[test] -fn status_icon_succeeded() { - assert_eq!(status_icon("Succeeded"), "✓"); -} - -#[test] -fn status_icon_in_progress() { - assert_eq!(status_icon("InProgress"), "◐"); -} - -#[test] -fn status_icon_failed() { - assert_eq!(status_icon("Failed"), "✗"); -} - -#[test] -fn status_icon_stopped() { - assert_eq!(status_icon("Stopped"), "○"); -} - -#[test] -fn status_icon_unknown() { - assert_eq!(status_icon("Other"), " "); -} - -#[test] -fn status_icon_cancelled() { - assert_eq!(status_icon("Cancelled"), "○"); -} - -#[test] -fn status_color_superseded() { - assert_eq!(status_color("Superseded"), Color::DarkGrey); -} - -#[test] -fn output_pipelines_empty() { - let result = output_pipelines(&[], OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_pipelines_table() { - let pipelines = vec![Pipeline { - name: "test-pipeline".to_string(), - created: Some("2026-01-01".to_string()), - updated: None, - }]; - let result = output_pipelines(&pipelines, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_pipelines_json() { - let pipelines = vec![Pipeline { - name: "test-pipeline".to_string(), - created: None, - updated: None, - }]; - let result = output_pipelines(&pipelines, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn output_pipeline_state_empty() { - let state = PipelineState { - name: "test".to_string(), - stages: vec![], - }; - let result = output_pipeline_state(&state, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_pipeline_state_table() { - use super::super::types::{ActionExecution, ActionState, StageExecution, StageState}; - - let state = PipelineState { - name: "test-pipeline".to_string(), - stages: vec![StageState { - name: "Source".to_string(), - latest_execution: Some(StageExecution { - status: "Succeeded".to_string(), - }), - actions: vec![ActionState { - name: "SourceAction".to_string(), - latest_execution: Some(ActionExecution { - status: "Succeeded".to_string(), - last_status_change: None, - }), - }], - }], - }; - let result = output_pipeline_state(&state, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_pipeline_state_with_all_statuses() { - use super::super::types::{ActionExecution, ActionState, StageExecution, StageState}; - - // Test with InProgress status - let state = PipelineState { - name: "in-progress-pipeline".to_string(), - stages: vec![StageState { - name: "Build".to_string(), - latest_execution: Some(StageExecution { - status: "InProgress".to_string(), - }), - actions: vec![ActionState { - name: "BuildAction".to_string(), - latest_execution: Some(ActionExecution { - status: "InProgress".to_string(), - last_status_change: Some("2026-01-01T00:00:00Z".to_string()), - }), - }], - }], - }; - assert!(output_pipeline_state(&state, OutputFormat::Table).is_ok()); - - // Test with Failed status - let state = PipelineState { - name: "failed-pipeline".to_string(), - stages: vec![StageState { - name: "Deploy".to_string(), - latest_execution: Some(StageExecution { - status: "Failed".to_string(), - }), - actions: vec![], - }], - }; - assert!(output_pipeline_state(&state, OutputFormat::Table).is_ok()); - - // Test with Stopped status (triggers Unknown branch) - let state = PipelineState { - name: "stopped-pipeline".to_string(), - stages: vec![StageState { - name: "Test".to_string(), - latest_execution: Some(StageExecution { - status: "Stopped".to_string(), - }), - actions: vec![], - }], - }; - assert!(output_pipeline_state(&state, OutputFormat::Table).is_ok()); - - // Test stage with no execution - let state = PipelineState { - name: "no-execution-pipeline".to_string(), - stages: vec![StageState { - name: "Pending".to_string(), - latest_execution: None, - actions: vec![], - }], - }; - assert!(output_pipeline_state(&state, OutputFormat::Table).is_ok()); -} - -#[test] -fn output_pipeline_state_json() { - let state = PipelineState { - name: "test".to_string(), - stages: vec![], - }; - let result = output_pipeline_state(&state, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn output_executions_empty() { - let result = output_executions(&[], OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_executions_table() { - use super::super::types::ExecutionTrigger; - - let executions = vec![PipelineExecution { - id: "exec-1".to_string(), - status: "Succeeded".to_string(), - started: Some("2026-01-01T00:00:00Z".to_string()), - updated: None, - trigger: Some(ExecutionTrigger { - trigger_type: "Webhook".to_string(), - }), - }]; - let result = output_executions(&executions, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn output_executions_json() { - let executions = vec![PipelineExecution { - id: "exec-1".to_string(), - status: "Failed".to_string(), - started: None, - updated: None, - trigger: None, - }]; - let result = output_executions(&executions, OutputFormat::Json); - assert!(result.is_ok()); -} diff --git a/src/pipeline/mod.rs b/src/pipeline/mod.rs deleted file mode 100644 index 748f327..0000000 --- a/src/pipeline/mod.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! AWS CodePipeline status (read-only) -//! -//! List pipelines, view status, and check execution history. - -mod aws; -mod cli; -mod display; -mod types; - -use anyhow::Result; - -pub use cli::PipelineCommand; -use types::{AwsConfig, OutputFormat}; - -/// Run a pipeline command -#[cfg(not(tarpaulin_include))] -pub async fn run(cmd: PipelineCommand) -> Result<()> { - match cmd { - PipelineCommand::List { region, json } => cmd_list(region, json), - PipelineCommand::Status { name, region, json } => cmd_status(&name, region, json), - PipelineCommand::History { - name, - region, - limit, - json, - } => cmd_history(&name, region, limit, json), - } -} - -/// List pipelines -#[cfg(not(tarpaulin_include))] -fn cmd_list(region: Option, json: bool) -> Result<()> { - let config = AwsConfig { region }; - let pipelines = aws::list_pipelines(&config)?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - display::output_pipelines(&pipelines, format)?; - Ok(()) -} - -/// Show pipeline status -#[cfg(not(tarpaulin_include))] -fn cmd_status(name: &str, region: Option, json: bool) -> Result<()> { - let config = AwsConfig { region }; - let state = aws::get_pipeline_state(&config, name)?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - display::output_pipeline_state(&state, format)?; - Ok(()) -} - -/// Show pipeline execution history -#[cfg(not(tarpaulin_include))] -fn cmd_history(name: &str, region: Option, limit: usize, json: bool) -> Result<()> { - let config = AwsConfig { region }; - let executions = aws::list_executions(&config, name, limit)?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - display::output_executions(&executions, format)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn aws_config_from_region() { - let config = AwsConfig { - region: Some("us-east-1".to_string()), - }; - assert_eq!(config.region, Some("us-east-1".to_string())); - } - - #[test] - fn aws_config_default() { - let config = AwsConfig { region: None }; - assert!(config.region.is_none()); - } - - #[test] - fn output_format_from_json_flag_true() { - let json = true; - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - assert_eq!(format, OutputFormat::Json); - } - - #[test] - fn output_format_from_json_flag_false() { - let json = false; - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - assert_eq!(format, OutputFormat::Table); - } - - #[test] - fn pipeline_command_list_matches() { - let cmd = PipelineCommand::List { - region: Some("us-west-2".to_string()), - json: true, - }; - match cmd { - PipelineCommand::List { region, json } => { - assert_eq!(region, Some("us-west-2".to_string())); - assert!(json); - } - _ => panic!("Expected List command"), - } - } - - #[test] - fn pipeline_command_status_matches() { - let cmd = PipelineCommand::Status { - name: "my-pipeline".to_string(), - region: None, - json: false, - }; - match cmd { - PipelineCommand::Status { name, region, json } => { - assert_eq!(name, "my-pipeline"); - assert!(region.is_none()); - assert!(!json); - } - _ => panic!("Expected Status command"), - } - } - - #[test] - fn pipeline_command_history_matches() { - let cmd = PipelineCommand::History { - name: "prod-pipeline".to_string(), - region: Some("eu-central-1".to_string()), - limit: 25, - json: true, - }; - match cmd { - PipelineCommand::History { - name, - region, - limit, - json, - } => { - assert_eq!(name, "prod-pipeline"); - assert_eq!(region, Some("eu-central-1".to_string())); - assert_eq!(limit, 25); - assert!(json); - } - _ => panic!("Expected History command"), - } - } -} diff --git a/src/pipeline/types.rs b/src/pipeline/types.rs deleted file mode 100644 index ff8d7bf..0000000 --- a/src/pipeline/types.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! CodePipeline data types - -use serde::{Deserialize, Serialize}; - -pub use crate::util::OutputFormat; - -/// Pipeline summary -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Pipeline { - /// Pipeline name - pub name: String, - /// Creation time - #[serde(default)] - pub created: Option, - /// Last update time - #[serde(default)] - pub updated: Option, -} - -/// Pipeline state (current status) -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PipelineState { - /// Pipeline name - #[serde(rename = "pipelineName")] - pub name: String, - /// Stage states - #[serde(rename = "stageStates", default)] - pub stages: Vec, -} - -/// Stage state -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StageState { - /// Stage name - #[serde(rename = "stageName")] - pub name: String, - /// Latest execution - #[serde(rename = "latestExecution", default)] - pub latest_execution: Option, - /// Action states - #[serde(rename = "actionStates", default)] - pub actions: Vec, -} - -/// Stage execution info -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct StageExecution { - /// Status - pub status: String, -} - -/// Action state -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ActionState { - /// Action name - #[serde(rename = "actionName")] - pub name: String, - /// Latest execution - #[serde(rename = "latestExecution", default)] - pub latest_execution: Option, -} - -/// Action execution info -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ActionExecution { - /// Status - pub status: String, - /// Last status change - #[serde(rename = "lastStatusChange", default)] - pub last_status_change: Option, -} - -/// Pipeline execution summary -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct PipelineExecution { - /// Execution ID - #[serde(rename = "pipelineExecutionId")] - pub id: String, - /// Status - pub status: String, - /// Start time - #[serde(rename = "startTime", default)] - pub started: Option, - /// Last update time - #[serde(rename = "lastUpdateTime", default)] - pub updated: Option, - /// Trigger info - #[serde(default)] - pub trigger: Option, -} - -/// Execution trigger -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ExecutionTrigger { - /// Trigger type - #[serde(rename = "triggerType")] - pub trigger_type: String, -} - -/// Stage status enum -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StageStatus { - /// In progress - InProgress, - /// Succeeded - Succeeded, - /// Failed - Failed, - /// Stopped - Stopped, - /// Unknown - Unknown, -} - -impl StageStatus { - /// Parse from string - pub fn from_str(s: &str) -> Self { - match s { - "InProgress" => Self::InProgress, - "Succeeded" => Self::Succeeded, - "Failed" => Self::Failed, - "Stopped" => Self::Stopped, - _ => Self::Unknown, - } - } -} - -/// AWS CLI configuration -#[derive(Debug, Clone, Default)] -pub struct AwsConfig { - /// AWS region - pub region: Option, -} - -/// List pipelines response -#[derive(Debug, Deserialize)] -pub struct ListPipelinesResponse { - /// Pipelines - pub pipelines: Vec, -} - -/// Pipeline summary from list -#[derive(Debug, Deserialize)] -pub struct PipelineSummary { - /// Name - pub name: String, - /// Created - pub created: Option, - /// Updated - pub updated: Option, -} - -impl PipelineSummary { - /// Convert to Pipeline - pub fn to_pipeline(&self) -> Pipeline { - Pipeline { - name: self.name.clone(), - created: self.created.clone(), - updated: self.updated.clone(), - } - } -} - -/// List executions response -#[derive(Debug, Deserialize)] -pub struct ListExecutionsResponse { - /// Executions - #[serde(rename = "pipelineExecutionSummaries")] - pub executions: Vec, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn pipeline_debug() { - let p = Pipeline { - name: "test".to_string(), - created: None, - updated: None, - }; - let debug = format!("{:?}", p); - assert!(debug.contains("test")); - } - - #[test] - fn pipeline_clone() { - let p = Pipeline { - name: "test".to_string(), - created: Some("2026-01-01".to_string()), - updated: None, - }; - let cloned = p.clone(); - assert_eq!(cloned.name, p.name); - } - - #[test] - fn stage_status_from_str() { - assert_eq!(StageStatus::from_str("InProgress"), StageStatus::InProgress); - assert_eq!(StageStatus::from_str("Succeeded"), StageStatus::Succeeded); - assert_eq!(StageStatus::from_str("Failed"), StageStatus::Failed); - assert_eq!(StageStatus::from_str("Stopped"), StageStatus::Stopped); - assert_eq!(StageStatus::from_str("Other"), StageStatus::Unknown); - } - - #[test] - fn aws_config_default() { - let config = AwsConfig::default(); - assert!(config.region.is_none()); - } - - #[test] - fn parse_list_pipelines_response() { - let json = r#"{ - "pipelines": [ - {"name": "pipeline-1", "created": "2026-01-01", "updated": "2026-01-02"}, - {"name": "pipeline-2"} - ] - }"#; - let resp: ListPipelinesResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.pipelines.len(), 2); - assert_eq!(resp.pipelines[0].name, "pipeline-1"); - } - - #[test] - fn parse_pipeline_state() { - let json = r#"{ - "pipelineName": "my-pipeline", - "stageStates": [ - { - "stageName": "Source", - "latestExecution": {"status": "Succeeded"}, - "actionStates": [] - } - ] - }"#; - let state: PipelineState = serde_json::from_str(json).unwrap(); - assert_eq!(state.name, "my-pipeline"); - assert_eq!(state.stages.len(), 1); - assert_eq!(state.stages[0].name, "Source"); - } - - #[test] - fn parse_list_executions_response() { - let json = r#"{ - "pipelineExecutionSummaries": [ - { - "pipelineExecutionId": "exec-1", - "status": "Succeeded", - "startTime": "2026-01-01T00:00:00Z", - "trigger": {"triggerType": "Webhook"} - } - ] - }"#; - let resp: ListExecutionsResponse = serde_json::from_str(json).unwrap(); - assert_eq!(resp.executions.len(), 1); - assert_eq!(resp.executions[0].id, "exec-1"); - assert_eq!(resp.executions[0].status, "Succeeded"); - } - - #[test] - fn pipeline_summary_to_pipeline() { - let summary = PipelineSummary { - name: "test".to_string(), - created: Some("2026-01-01".to_string()), - updated: None, - }; - let pipeline = summary.to_pipeline(); - assert_eq!(pipeline.name, "test"); - assert_eq!(pipeline.created, Some("2026-01-01".to_string())); - } -} diff --git a/src/sentry/client.rs b/src/sentry/client.rs deleted file mode 100644 index bbd894b..0000000 --- a/src/sentry/client.rs +++ /dev/null @@ -1,246 +0,0 @@ -//! Sentry HTTP client - -use anyhow::Result; -use reqwest::Client; -use serde::de::DeserializeOwned; -use std::future::Future; -use std::time::Duration; -use tokio::time::sleep; - -use super::config::{load_config, SentryConfig}; -use super::types::{Event, Issue}; - -const SENTRY_API_URL: &str = "https://sentry.io/api/0"; -const MAX_RETRIES: u32 = 3; -const DEFAULT_RETRY_SECS: u64 = 5; - -/// Trait for Sentry API operations (enables testing with mocks) -pub trait SentryApi { - /// List issues for organization - fn list_issues( - &self, - query: Option<&str>, - limit: usize, - ) -> impl Future>> + Send; - - /// List issues for a specific project - fn list_project_issues( - &self, - project: &str, - query: Option<&str>, - limit: usize, - ) -> impl Future>> + Send; - - /// Get a single issue by ID - fn get_issue(&self, issue_id: &str) -> impl Future> + Send; - - /// List events for an issue - fn list_issue_events( - &self, - issue_id: &str, - limit: usize, - ) -> impl Future>> + Send; -} - -/// Sentry API client -pub struct SentryClient { - config: SentryConfig, - http: Client, -} - -impl SentryClient { - /// Create a new Sentry client - #[cfg(not(tarpaulin_include))] - pub fn new() -> Result { - let config = load_config()?; - let http = Client::builder().user_agent("hu-cli/0.1.0").build()?; - Ok(Self { config, http }) - } - - /// Get auth token - fn auth_token(&self) -> Result<&str> { - self.config - .auth_token - .as_deref() - .ok_or_else(|| anyhow::anyhow!("Sentry auth_token not configured")) - } - - /// Get organization slug - fn organization(&self) -> Result<&str> { - self.config - .organization - .as_deref() - .ok_or_else(|| anyhow::anyhow!("Sentry organization not configured")) - } - - /// List issues for organization - #[cfg(not(tarpaulin_include))] - pub async fn list_issues(&self, query: Option<&str>, limit: usize) -> Result> { - let org = self.organization()?; - let url = format!("{}/organizations/{}/issues/", SENTRY_API_URL, org); - - let mut params = vec![("limit", limit.to_string())]; - if let Some(q) = query { - params.push(("query", q.to_string())); - } - - self.get_with_params(&url, ¶ms).await - } - - /// List issues for a specific project - #[cfg(not(tarpaulin_include))] - pub async fn list_project_issues( - &self, - project: &str, - query: Option<&str>, - limit: usize, - ) -> Result> { - let org = self.organization()?; - let url = format!("{}/projects/{}/{}/issues/", SENTRY_API_URL, org, project); - - let mut params = vec![("limit", limit.to_string())]; - if let Some(q) = query { - params.push(("query", q.to_string())); - } - - self.get_with_params(&url, ¶ms).await - } - - /// Get a single issue by ID - #[cfg(not(tarpaulin_include))] - pub async fn get_issue(&self, issue_id: &str) -> Result { - let org = self.organization()?; - let url = format!( - "{}/organizations/{}/issues/{}/", - SENTRY_API_URL, org, issue_id - ); - - self.get(&url).await - } - - /// List events for an issue - #[cfg(not(tarpaulin_include))] - pub async fn list_issue_events(&self, issue_id: &str, limit: usize) -> Result> { - let org = self.organization()?; - let url = format!( - "{}/organizations/{}/issues/{}/events/", - SENTRY_API_URL, org, issue_id - ); - - self.get_with_params(&url, &[("limit", limit.to_string())]) - .await - } - - /// Make a GET request - #[cfg(not(tarpaulin_include))] - async fn get(&self, url: &str) -> Result { - let token = self.auth_token()?.to_string(); - - self.execute_with_retry(|| { - self.http - .get(url) - .header("Authorization", format!("Bearer {}", token)) - .send() - }) - .await - } - - /// Make a GET request with parameters - #[cfg(not(tarpaulin_include))] - async fn get_with_params( - &self, - url: &str, - params: &[(&str, String)], - ) -> Result { - let token = self.auth_token()?.to_string(); - let params: Vec<(String, String)> = params - .iter() - .map(|(k, v)| (k.to_string(), v.clone())) - .collect(); - - self.execute_with_retry(|| { - self.http - .get(url) - .header("Authorization", format!("Bearer {}", token)) - .query(¶ms) - .send() - }) - .await - } - - /// Execute request with retry on rate limit - #[cfg(not(tarpaulin_include))] - async fn execute_with_retry(&self, request_fn: F) -> Result - where - F: Fn() -> Fut, - Fut: std::future::Future>, - T: DeserializeOwned, - { - let mut retries = 0; - - loop { - let response = request_fn().await?; - let status = response.status(); - - if status == reqwest::StatusCode::TOO_MANY_REQUESTS { - if retries >= MAX_RETRIES { - return Err(anyhow::anyhow!( - "Rate limited after {} retries", - MAX_RETRIES - )); - } - - let retry_after = response - .headers() - .get("retry-after") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_RETRY_SECS); - - eprintln!( - "Rate limited, waiting {} seconds... (retry {}/{})", - retry_after, - retries + 1, - MAX_RETRIES - ); - sleep(Duration::from_secs(retry_after)).await; - retries += 1; - continue; - } - - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("HTTP {}: {}", status.as_u16(), body)); - } - - let text = response.text().await?; - return serde_json::from_str(&text).map_err(|e| { - anyhow::anyhow!("Parse error: {}: {}", e, &text[..text.len().min(200)]) - }); - } - } -} - -#[cfg(not(tarpaulin_include))] -impl SentryApi for SentryClient { - async fn list_issues(&self, query: Option<&str>, limit: usize) -> Result> { - SentryClient::list_issues(self, query, limit).await - } - - async fn list_project_issues( - &self, - project: &str, - query: Option<&str>, - limit: usize, - ) -> Result> { - SentryClient::list_project_issues(self, project, query, limit).await - } - - async fn get_issue(&self, issue_id: &str) -> Result { - SentryClient::get_issue(self, issue_id).await - } - - async fn list_issue_events(&self, issue_id: &str, limit: usize) -> Result> { - SentryClient::list_issue_events(self, issue_id, limit).await - } -} diff --git a/src/sentry/config.rs b/src/sentry/config.rs deleted file mode 100644 index 0a6fc01..0000000 --- a/src/sentry/config.rs +++ /dev/null @@ -1,183 +0,0 @@ -//! Sentry configuration -//! -//! Loads configuration from `~/.config/hu/settings.toml` - -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; - -/// Sentry configuration -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct SentryConfig { - /// Auth token - pub auth_token: Option, - /// Organization slug - pub organization: Option, - /// Default project slug - pub project: Option, -} - -impl SentryConfig { - /// Check if configured with auth token - #[must_use] - pub fn is_configured(&self) -> bool { - self.auth_token.is_some() && self.organization.is_some() - } -} - -/// Settings file structure -#[derive(Debug, Default, Deserialize)] -struct SettingsFile { - sentry: Option, -} - -/// Get path to config file -pub fn config_path() -> Option { - dirs::home_dir().map(|p| p.join(".config").join("hu").join("settings.toml")) -} - -/// Load Sentry config from settings file and environment -#[cfg(not(tarpaulin_include))] -pub fn load_config() -> Result { - let mut config = SentryConfig::default(); - - // Load from settings file - if let Some(path) = config_path() { - if path.exists() { - let contents = fs::read_to_string(&path)?; - let settings: SettingsFile = toml::from_str(&contents)?; - if let Some(sentry) = settings.sentry { - config = sentry; - } - } - } - - // Override with environment variables - if let Ok(token) = std::env::var("SENTRY_AUTH_TOKEN") { - config.auth_token = Some(token); - } - if let Ok(org) = std::env::var("SENTRY_ORG") { - config.organization = Some(org); - } - if let Ok(project) = std::env::var("SENTRY_PROJECT") { - config.project = Some(project); - } - - Ok(config) -} - -/// Save auth token to config file -#[cfg(not(tarpaulin_include))] -pub fn save_auth_token(token: &str, org: &str) -> Result<()> { - let path = config_path().ok_or_else(|| anyhow::anyhow!("Cannot determine config directory"))?; - - // Read existing or create new - let contents = if path.exists() { - fs::read_to_string(&path)? - } else { - String::new() - }; - - // Parse as TOML value - let mut doc: toml::Value = - toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new())); - - // Ensure sentry section exists - let table = doc - .as_table_mut() - .ok_or_else(|| anyhow::anyhow!("Config is not a table"))?; - - if !table.contains_key("sentry") { - table.insert( - "sentry".to_string(), - toml::Value::Table(toml::map::Map::new()), - ); - } - - let sentry = table - .get_mut("sentry") - .and_then(|v| v.as_table_mut()) - .ok_or_else(|| anyhow::anyhow!("sentry section is not a table"))?; - - sentry.insert( - "auth_token".to_string(), - toml::Value::String(token.to_string()), - ); - sentry.insert( - "organization".to_string(), - toml::Value::String(org.to_string()), - ); - - // Write back - let output = toml::to_string_pretty(&doc)?; - - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } - - fs::write(&path, output)?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_sentry_config_is_configured_both_set() { - let config = SentryConfig { - auth_token: Some("token".to_string()), - organization: Some("my-org".to_string()), - project: None, - }; - assert!(config.is_configured()); - } - - #[test] - fn test_sentry_config_is_configured_only_token() { - let config = SentryConfig { - auth_token: Some("token".to_string()), - organization: None, - project: None, - }; - assert!(!config.is_configured()); - } - - #[test] - fn test_sentry_config_is_configured_only_org() { - let config = SentryConfig { - auth_token: None, - organization: Some("my-org".to_string()), - project: None, - }; - assert!(!config.is_configured()); - } - - #[test] - fn test_sentry_config_is_configured_neither() { - let config = SentryConfig { - auth_token: None, - organization: None, - project: None, - }; - assert!(!config.is_configured()); - } - - #[test] - fn test_sentry_config_default() { - let config = SentryConfig::default(); - assert!(config.auth_token.is_none()); - assert!(config.organization.is_none()); - assert!(config.project.is_none()); - assert!(!config.is_configured()); - } - - #[test] - fn test_config_path_returns_some() { - let path = config_path(); - if let Some(p) = path { - assert!(p.to_string_lossy().contains("settings.toml")); - } - } -} diff --git a/src/sentry/display/mod.rs b/src/sentry/display/mod.rs deleted file mode 100644 index af330df..0000000 --- a/src/sentry/display/mod.rs +++ /dev/null @@ -1,221 +0,0 @@ -//! Sentry output formatting - -use anyhow::{Context, Result}; -use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, ContentArrangement, Table}; - -use super::types::{Event, Issue, OutputFormat}; - -#[cfg(test)] -mod tests; - -/// Format relative time -fn time_ago(timestamp: &str) -> String { - let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) else { - return timestamp.to_string(); - }; - - let now = chrono::Utc::now(); - let duration = now.signed_duration_since(dt); - - if duration.num_days() > 0 { - format!("{}d ago", duration.num_days()) - } else if duration.num_hours() > 0 { - format!("{}h ago", duration.num_hours()) - } else if duration.num_minutes() > 0 { - format!("{}m ago", duration.num_minutes()) - } else { - "just now".to_string() - } -} - -/// Truncate string -fn truncate(s: &str, max_len: usize) -> String { - if s.len() <= max_len { - s.to_string() - } else { - format!("{}...", &s[..max_len.saturating_sub(3)]) - } -} - -/// Color for issue level -fn level_color(level: &str) -> Color { - match level { - "error" => Color::Red, - "warning" => Color::Yellow, - "info" => Color::Blue, - _ => Color::White, - } -} - -/// Color for issue status -#[allow(dead_code)] -fn status_color(status: &str) -> Color { - match status { - "resolved" => Color::Green, - "ignored" => Color::DarkGrey, - _ => Color::White, - } -} - -/// Output issues list -pub fn output_issues(issues: &[Issue], format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - if issues.is_empty() { - println!("No issues found."); - return Ok(()); - } - - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - table.set_header(vec!["ID", "Level", "Title", "Events", "Users", "Last Seen"]); - - for issue in issues { - table.add_row(vec![ - Cell::new(&issue.short_id).fg(Color::Cyan), - Cell::new(&issue.level).fg(level_color(&issue.level)), - Cell::new(truncate(&issue.title, 50)), - Cell::new(&issue.count), - Cell::new(issue.user_count.to_string()), - Cell::new(time_ago(&issue.last_seen)), - ]); - } - - println!("{table}"); - println!("\n{} issues", issues.len()); - } - OutputFormat::Json => { - let json = - serde_json::to_string_pretty(issues).context("Failed to serialize issues")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output single issue detail -pub fn output_issue_detail(issue: &Issue, format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - println!("{}", "-".repeat(60)); - println!("{} - {}", issue.short_id, issue.title); - println!("{}", "-".repeat(60)); - println!( - "Project: {} ({})", - issue.project.name, issue.project.slug - ); - println!("Level: {}", issue.level); - println!("Status: {}", issue.status); - println!("Platform: {}", issue.platform); - println!("Events: {}", issue.count); - println!("Users: {}", issue.user_count); - println!("First seen: {}", time_ago(&issue.first_seen)); - println!("Last seen: {}", time_ago(&issue.last_seen)); - - if !issue.culprit.is_empty() { - println!("\nCulprit: {}", issue.culprit); - } - - if !issue.metadata.error_type.is_empty() || !issue.metadata.value.is_empty() { - println!("\nError:"); - if !issue.metadata.error_type.is_empty() { - println!(" Type: {}", issue.metadata.error_type); - } - if !issue.metadata.value.is_empty() { - println!(" Message: {}", issue.metadata.value); - } - if !issue.metadata.filename.is_empty() { - println!(" File: {}", issue.metadata.filename); - } - if !issue.metadata.function.is_empty() { - println!(" Function: {}", issue.metadata.function); - } - } - - println!("\nLink: {}", issue.permalink); - } - OutputFormat::Json => { - let json = serde_json::to_string_pretty(issue).context("Failed to serialize issue")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output events list -pub fn output_events(events: &[Event], format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - if events.is_empty() { - println!("No events found."); - return Ok(()); - } - - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - table.set_header(vec!["Event ID", "Time", "User", "Message"]); - - for event in events { - let user = event - .user - .as_ref() - .and_then(|u| u.email.as_ref().or(u.username.as_ref()).or(u.id.as_ref())) - .map(|s| s.as_str()) - .unwrap_or("-"); - - let message = if event.message.is_empty() { - &event.title - } else { - &event.message - }; - - let event_id_short = if event.id.len() > 12 { - &event.id[..12] - } else { - &event.id - }; - let date = event.date_created.as_deref().unwrap_or("-"); - - table.add_row(vec![ - Cell::new(event_id_short).fg(Color::Cyan), - Cell::new(time_ago(date)), - Cell::new(truncate(user, 20)), - Cell::new(truncate(message, 40)), - ]); - } - - println!("{table}"); - println!("\n{} events", events.len()); - } - OutputFormat::Json => { - let json = - serde_json::to_string_pretty(events).context("Failed to serialize events")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output config status -pub fn output_config_status(config: &super::config::SentryConfig) { - println!("Sentry Configuration"); - println!("{}", "-".repeat(40)); - println!( - "Auth token: {}", - if config.auth_token.is_some() { - "Yes" - } else { - "No" - } - ); - println!( - "Organization: {}", - config.organization.as_deref().unwrap_or("Not set") - ); - println!( - "Project: {}", - config.project.as_deref().unwrap_or("Not set") - ); -} diff --git a/src/sentry/display/tests.rs b/src/sentry/display/tests.rs deleted file mode 100644 index 743f5ab..0000000 --- a/src/sentry/display/tests.rs +++ /dev/null @@ -1,301 +0,0 @@ -use super::*; -use crate::sentry::types::{EventUser, IssueMetadata, ProjectInfo}; - -#[test] -fn test_time_ago_days() { - let now = chrono::Utc::now(); - let two_days_ago = now - chrono::Duration::days(2); - let ts = two_days_ago.to_rfc3339(); - assert_eq!(time_ago(&ts), "2d ago"); -} - -#[test] -fn test_time_ago_hours() { - let now = chrono::Utc::now(); - let two_hours_ago = now - chrono::Duration::hours(2); - let ts = two_hours_ago.to_rfc3339(); - assert_eq!(time_ago(&ts), "2h ago"); -} - -#[test] -fn test_time_ago_minutes() { - let now = chrono::Utc::now(); - let five_mins_ago = now - chrono::Duration::minutes(5); - let ts = five_mins_ago.to_rfc3339(); - assert_eq!(time_ago(&ts), "5m ago"); -} - -#[test] -fn test_time_ago_just_now() { - let now = chrono::Utc::now(); - let ts = now.to_rfc3339(); - assert_eq!(time_ago(&ts), "just now"); -} - -#[test] -fn test_time_ago_invalid() { - assert_eq!(time_ago("invalid"), "invalid"); -} - -#[test] -fn test_truncate_short() { - assert_eq!(truncate("hello", 10), "hello"); -} - -#[test] -fn test_truncate_exact() { - assert_eq!(truncate("hello", 5), "hello"); -} - -#[test] -fn test_truncate_long() { - assert_eq!(truncate("hello world", 8), "hello..."); -} - -#[test] -fn test_level_color() { - assert_eq!(level_color("error"), Color::Red); - assert_eq!(level_color("warning"), Color::Yellow); - assert_eq!(level_color("info"), Color::Blue); - assert_eq!(level_color("debug"), Color::White); -} - -#[test] -fn test_status_color() { - assert_eq!(status_color("resolved"), Color::Green); - assert_eq!(status_color("ignored"), Color::DarkGrey); - assert_eq!(status_color("unresolved"), Color::White); -} - -fn make_test_issue() -> Issue { - Issue { - id: "12345".to_string(), - short_id: "PROJ-123".to_string(), - title: "Test error".to_string(), - culprit: "src/main.rs".to_string(), - level: "error".to_string(), - status: "unresolved".to_string(), - platform: "rust".to_string(), - count: "42".to_string(), - user_count: 10, - first_seen: chrono::Utc::now().to_rfc3339(), - last_seen: chrono::Utc::now().to_rfc3339(), - permalink: "https://sentry.io/issue/123".to_string(), - is_subscribed: false, - is_bookmarked: false, - project: ProjectInfo { - id: "1".to_string(), - name: "Test Project".to_string(), - slug: "test-project".to_string(), - }, - metadata: IssueMetadata { - error_type: "RuntimeError".to_string(), - value: "Something went wrong".to_string(), - filename: "main.rs".to_string(), - function: "main".to_string(), - }, - } -} - -#[test] -fn test_output_issues_empty() { - let issues: Vec = vec![]; - let result = output_issues(&issues, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_issues_table() { - let issues = vec![make_test_issue()]; - let result = output_issues(&issues, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_issues_json() { - let issues = vec![make_test_issue()]; - let result = output_issues(&issues, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn test_output_issue_detail_table() { - let issue = make_test_issue(); - let result = output_issue_detail(&issue, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_issue_detail_json() { - let issue = make_test_issue(); - let result = output_issue_detail(&issue, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn test_output_events_empty() { - let events: Vec = vec![]; - let result = output_events(&events, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_events_table() { - let events = vec![Event { - id: "abcdef123456".to_string(), - title: "Test event".to_string(), - message: "Error message".to_string(), - platform: "rust".to_string(), - date_created: Some(chrono::Utc::now().to_rfc3339()), - user: Some(EventUser { - id: Some("user123".to_string()), - email: Some("test@example.com".to_string()), - username: None, - ip_address: None, - }), - tags: vec![], - }]; - let result = output_events(&events, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_events_json() { - let events = vec![Event { - id: "abcdef123456".to_string(), - title: "Test event".to_string(), - message: "".to_string(), - platform: "".to_string(), - date_created: None, - user: None, - tags: vec![], - }]; - let result = output_events(&events, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn test_output_issue_detail_empty_metadata() { - // Test with empty culprit and empty metadata fields - let issue = Issue { - id: "12345".to_string(), - short_id: "PROJ-456".to_string(), - title: "Test error".to_string(), - culprit: "".to_string(), // empty culprit - level: "warning".to_string(), - status: "resolved".to_string(), - platform: "python".to_string(), - count: "1".to_string(), - user_count: 1, - first_seen: chrono::Utc::now().to_rfc3339(), - last_seen: chrono::Utc::now().to_rfc3339(), - permalink: "https://sentry.io/issue/456".to_string(), - is_subscribed: false, - is_bookmarked: false, - project: ProjectInfo { - id: "2".to_string(), - name: "Other Project".to_string(), - slug: "other-project".to_string(), - }, - metadata: IssueMetadata { - error_type: "".to_string(), // empty - value: "".to_string(), // empty - filename: "".to_string(), // empty - function: "".to_string(), // empty - }, - }; - let result = output_issue_detail(&issue, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_issue_detail_partial_metadata() { - // Test with only some metadata fields populated - let issue = Issue { - id: "12345".to_string(), - short_id: "PROJ-789".to_string(), - title: "Partial metadata".to_string(), - culprit: "some/path.py".to_string(), - level: "error".to_string(), - status: "unresolved".to_string(), - platform: "python".to_string(), - count: "5".to_string(), - user_count: 3, - first_seen: chrono::Utc::now().to_rfc3339(), - last_seen: chrono::Utc::now().to_rfc3339(), - permalink: "https://sentry.io/issue/789".to_string(), - is_subscribed: false, - is_bookmarked: false, - project: ProjectInfo { - id: "3".to_string(), - name: "Third Project".to_string(), - slug: "third-project".to_string(), - }, - metadata: IssueMetadata { - error_type: "ValueError".to_string(), - value: "".to_string(), // empty value - filename: "".to_string(), - function: "process_data".to_string(), - }, - }; - let result = output_issue_detail(&issue, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_events_user_variants() { - // Test event with username instead of email - let events = vec![ - Event { - id: "event1234567890".to_string(), - title: "Event with username".to_string(), - message: "Has message".to_string(), - platform: "rust".to_string(), - date_created: Some(chrono::Utc::now().to_rfc3339()), - user: Some(EventUser { - id: None, - email: None, - username: Some("testuser".to_string()), - ip_address: None, - }), - tags: vec![], - }, - Event { - id: "event2".to_string(), // short ID - title: "Event with only id".to_string(), - message: "".to_string(), // empty message - should use title - platform: "rust".to_string(), - date_created: Some(chrono::Utc::now().to_rfc3339()), - user: Some(EventUser { - id: Some("user-id-only".to_string()), - email: None, - username: None, - ip_address: None, - }), - tags: vec![], - }, - ]; - let result = output_events(&events, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_config_status() { - use crate::sentry::config::SentryConfig; - - // Test with all fields set - let config = SentryConfig { - auth_token: Some("test-token".to_string()), - organization: Some("my-org".to_string()), - project: Some("my-project".to_string()), - }; - output_config_status(&config); - - // Test with no fields set - let empty_config = SentryConfig { - auth_token: None, - organization: None, - project: None, - }; - output_config_status(&empty_config); -} diff --git a/src/sentry/mod.rs b/src/sentry/mod.rs deleted file mode 100644 index 6e446ed..0000000 --- a/src/sentry/mod.rs +++ /dev/null @@ -1,237 +0,0 @@ -//! Sentry integration -//! -//! List and view issues from Sentry. -//! -//! # CLI Usage -//! Use [`run`] for CLI commands that format and print output. -//! -//! # Programmatic Usage (MCP/HTTP) -//! Use the reusable functions that return typed data: -//! - [`get_config`] - Get configuration status -//! - [`list_issues`] - List issues with filters -//! - [`get_issue`] - Get issue details -//! - [`list_events`] - List events for an issue - -mod client; -mod config; -mod display; -mod service; -pub mod types; - -use anyhow::Result; -use clap::Subcommand; - -use client::SentryClient; -pub use config::SentryConfig; -pub use service::{EventOptions, IssueOptions}; -use types::OutputFormat; -pub use types::{Event, Issue}; - -/// Sentry subcommands -#[derive(Debug, Subcommand)] -pub enum SentryCommand { - /// Show configuration status - Config, - - /// List issues - Issues { - /// Filter by project - #[arg(short, long)] - project: Option, - - /// Search query (Sentry search syntax) - #[arg(short, long)] - query: Option, - - /// Maximum number of issues to return - #[arg(short, long, default_value = "25")] - limit: usize, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// Show issue details - Show { - /// Issue ID or short ID - issue: String, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// List events for an issue - Events { - /// Issue ID or short ID - issue: String, - - /// Maximum number of events to return - #[arg(short, long, default_value = "25")] - limit: usize, - - /// Output as JSON - #[arg(long)] - json: bool, - }, - - /// Set auth token - Auth { - /// Auth token - token: String, - - /// Organization slug - #[arg(short, long)] - org: String, - }, -} - -/// Run a Sentry command (CLI entry point - formats and prints) -#[cfg(not(tarpaulin_include))] -pub async fn run(cmd: SentryCommand) -> Result<()> { - match cmd { - SentryCommand::Config => cmd_config(), - SentryCommand::Issues { - project, - query, - limit, - json, - } => cmd_issues(project, query, limit, json).await, - SentryCommand::Show { issue, json } => cmd_show(&issue, json).await, - SentryCommand::Events { issue, limit, json } => cmd_events(&issue, limit, json).await, - SentryCommand::Auth { token, org } => cmd_auth(&token, &org), - } -} - -// ============================================================================ -// Reusable functions for MCP/HTTP - return typed data, never print -// ============================================================================ - -/// Get Sentry configuration status (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub fn get_config() -> Result { - service::get_config() -} - -/// List issues with filters (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn list_issues(opts: &IssueOptions) -> Result> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = SentryClient::new()?; - service::list_issues(&client, opts).await -} - -/// Get issue details by ID (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn get_issue(issue_id: &str) -> Result { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = SentryClient::new()?; - service::get_issue(&client, issue_id).await -} - -/// List events for an issue (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn list_events(opts: &EventOptions) -> Result> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = SentryClient::new()?; - service::list_events(&client, opts).await -} - -// ============================================================================ -// CLI command handlers - create client, call service, format and print -// ============================================================================ - -/// Show config status -#[cfg(not(tarpaulin_include))] -fn cmd_config() -> Result<()> { - let config = service::get_config()?; - display::output_config_status(&config); - Ok(()) -} - -/// Set auth token -#[cfg(not(tarpaulin_include))] -fn cmd_auth(token: &str, org: &str) -> Result<()> { - service::save_auth(token, org)?; - println!("Sentry auth token saved for organization: {}", org); - Ok(()) -} - -/// List issues -#[cfg(not(tarpaulin_include))] -async fn cmd_issues( - project: Option, - query: Option, - limit: usize, - json: bool, -) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = SentryClient::new()?; - let opts = IssueOptions { - project, - query, - limit, - }; - let issues = service::list_issues(&client, &opts).await?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - display::output_issues(&issues, format)?; - Ok(()) -} - -/// Show issue details -#[cfg(not(tarpaulin_include))] -async fn cmd_show(issue_id: &str, json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = SentryClient::new()?; - let issue = service::get_issue(&client, issue_id).await?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - display::output_issue_detail(&issue, format)?; - Ok(()) -} - -/// List events for an issue -#[cfg(not(tarpaulin_include))] -async fn cmd_events(issue_id: &str, limit: usize, json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = SentryClient::new()?; - let opts = EventOptions { - issue_id: issue_id.to_string(), - limit, - }; - let events = service::list_events(&client, &opts).await?; - - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - display::output_events(&events, format)?; - Ok(()) -} diff --git a/src/sentry/service.rs b/src/sentry/service.rs deleted file mode 100644 index 2c970be..0000000 --- a/src/sentry/service.rs +++ /dev/null @@ -1,386 +0,0 @@ -//! Sentry service layer - business logic that returns data -//! -//! Functions in this module accept trait objects and return typed data. -//! They never print - that's the CLI layer's job. - -use anyhow::{bail, Result}; - -use super::client::SentryApi; -use super::config::{self, SentryConfig}; -use super::types::{Event, Issue}; - -/// Options for listing issues -#[derive(Debug, Default)] -pub struct IssueOptions { - /// Filter by project slug - pub project: Option, - /// Search query (Sentry search syntax) - pub query: Option, - /// Maximum number of results - pub limit: usize, -} - -impl IssueOptions { - /// Create with default limit - #[allow(dead_code)] - pub fn new() -> Self { - Self { - project: None, - query: None, - limit: 25, - } - } -} - -/// Options for listing events -#[derive(Debug)] -pub struct EventOptions { - /// Issue ID - pub issue_id: String, - /// Maximum number of results - pub limit: usize, -} - -impl Default for EventOptions { - fn default() -> Self { - Self { - issue_id: String::new(), - limit: 25, - } - } -} - -/// Get current configuration -pub fn get_config() -> Result { - config::load_config() -} - -/// Save auth token and organization -pub fn save_auth(token: &str, org: &str) -> Result<()> { - config::save_auth_token(token, org) -} - -/// Check if API is configured, return error if not -pub fn ensure_configured(config: &SentryConfig) -> Result<()> { - if !config.is_configured() { - bail!( - "Sentry not configured. Run: hu sentry auth --org \n\ - Or set SENTRY_AUTH_TOKEN and SENTRY_ORG environment variables." - ); - } - Ok(()) -} - -/// List issues with options -pub async fn list_issues(api: &impl SentryApi, opts: &IssueOptions) -> Result> { - if let Some(ref project) = opts.project { - api.list_project_issues(project, opts.query.as_deref(), opts.limit) - .await - } else { - api.list_issues(opts.query.as_deref(), opts.limit).await - } -} - -/// Get a single issue by ID -pub async fn get_issue(api: &impl SentryApi, issue_id: &str) -> Result { - api.get_issue(issue_id).await -} - -/// List events for an issue -pub async fn list_events(api: &impl SentryApi, opts: &EventOptions) -> Result> { - api.list_issue_events(&opts.issue_id, opts.limit).await -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::sentry::types::{IssueMetadata, ProjectInfo}; - - /// Mock Sentry API for testing - struct MockApi { - issues: Vec, - events: Vec, - } - - impl MockApi { - fn new() -> Self { - Self { - issues: vec![], - events: vec![], - } - } - - fn with_issues(mut self, issues: Vec) -> Self { - self.issues = issues; - self - } - - fn with_events(mut self, events: Vec) -> Self { - self.events = events; - self - } - } - - impl SentryApi for MockApi { - async fn list_issues(&self, query: Option<&str>, limit: usize) -> Result> { - let filtered: Vec = self - .issues - .iter() - .filter(|i| { - query - .map(|q| i.title.contains(q) || i.short_id.contains(q)) - .unwrap_or(true) - }) - .take(limit) - .cloned() - .collect(); - Ok(filtered) - } - - async fn list_project_issues( - &self, - project: &str, - query: Option<&str>, - limit: usize, - ) -> Result> { - let filtered: Vec = self - .issues - .iter() - .filter(|i| i.project.slug == project) - .filter(|i| { - query - .map(|q| i.title.contains(q) || i.short_id.contains(q)) - .unwrap_or(true) - }) - .take(limit) - .cloned() - .collect(); - Ok(filtered) - } - - async fn get_issue(&self, issue_id: &str) -> Result { - self.issues - .iter() - .find(|i| i.id == issue_id || i.short_id == issue_id) - .cloned() - .ok_or_else(|| anyhow::anyhow!("Issue not found: {}", issue_id)) - } - - async fn list_issue_events(&self, _issue_id: &str, limit: usize) -> Result> { - Ok(self.events.iter().take(limit).cloned().collect()) - } - } - - fn make_issue(id: &str, short_id: &str, title: &str, project_slug: &str) -> Issue { - Issue { - id: id.to_string(), - short_id: short_id.to_string(), - title: title.to_string(), - culprit: "app.module".to_string(), - level: "error".to_string(), - status: "unresolved".to_string(), - platform: "python".to_string(), - project: ProjectInfo { - id: "1".to_string(), - name: "Test Project".to_string(), - slug: project_slug.to_string(), - }, - count: "100".to_string(), - user_count: 10, - first_seen: "2024-01-01T00:00:00Z".to_string(), - last_seen: "2024-01-02T00:00:00Z".to_string(), - permalink: format!("https://sentry.io/issues/{}", id), - is_subscribed: false, - is_bookmarked: false, - metadata: IssueMetadata::default(), - } - } - - fn make_event(id: &str, title: &str) -> Event { - Event { - id: id.to_string(), - title: title.to_string(), - message: "Error occurred".to_string(), - platform: "python".to_string(), - date_created: Some("2024-01-01T00:00:00Z".to_string()), - user: None, - tags: vec![], - } - } - - #[tokio::test] - async fn list_issues_returns_all() { - let api = MockApi::new().with_issues(vec![ - make_issue("1", "PROJ-1", "Error 1", "proj"), - make_issue("2", "PROJ-2", "Error 2", "proj"), - ]); - - let opts = IssueOptions { - project: None, - query: None, - limit: 10, - }; - let result = list_issues(&api, &opts).await.unwrap(); - assert_eq!(result.len(), 2); - } - - #[tokio::test] - async fn list_issues_filters_by_project() { - let api = MockApi::new().with_issues(vec![ - make_issue("1", "PROJ-1", "Error 1", "proj-a"), - make_issue("2", "PROJ-2", "Error 2", "proj-b"), - ]); - - let opts = IssueOptions { - project: Some("proj-a".to_string()), - query: None, - limit: 10, - }; - let result = list_issues(&api, &opts).await.unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].short_id, "PROJ-1"); - } - - #[tokio::test] - async fn list_issues_filters_by_query() { - let api = MockApi::new().with_issues(vec![ - make_issue("1", "PROJ-1", "Database error", "proj"), - make_issue("2", "PROJ-2", "Network timeout", "proj"), - ]); - - let opts = IssueOptions { - project: None, - query: Some("Database".to_string()), - limit: 10, - }; - let result = list_issues(&api, &opts).await.unwrap(); - assert_eq!(result.len(), 1); - assert_eq!(result[0].title, "Database error"); - } - - #[tokio::test] - async fn list_issues_respects_limit() { - let api = MockApi::new().with_issues(vec![ - make_issue("1", "PROJ-1", "Error 1", "proj"), - make_issue("2", "PROJ-2", "Error 2", "proj"), - make_issue("3", "PROJ-3", "Error 3", "proj"), - ]); - - let opts = IssueOptions { - project: None, - query: None, - limit: 2, - }; - let result = list_issues(&api, &opts).await.unwrap(); - assert_eq!(result.len(), 2); - } - - #[tokio::test] - async fn get_issue_by_id() { - let api = MockApi::new().with_issues(vec![ - make_issue("123", "PROJ-1", "Error 1", "proj"), - make_issue("456", "PROJ-2", "Error 2", "proj"), - ]); - - let result = get_issue(&api, "456").await.unwrap(); - assert_eq!(result.id, "456"); - assert_eq!(result.title, "Error 2"); - } - - #[tokio::test] - async fn get_issue_by_short_id() { - let api = MockApi::new().with_issues(vec![make_issue("123", "PROJ-42", "Error", "proj")]); - - let result = get_issue(&api, "PROJ-42").await.unwrap(); - assert_eq!(result.short_id, "PROJ-42"); - } - - #[tokio::test] - async fn get_issue_not_found() { - let api = MockApi::new(); - let result = get_issue(&api, "MISSING").await; - assert!(result.is_err()); - } - - #[tokio::test] - async fn list_events_returns_data() { - let api = MockApi::new().with_events(vec![ - make_event("evt1", "Event 1"), - make_event("evt2", "Event 2"), - ]); - - let opts = EventOptions { - issue_id: "123".to_string(), - limit: 10, - }; - let result = list_events(&api, &opts).await.unwrap(); - assert_eq!(result.len(), 2); - } - - #[tokio::test] - async fn list_events_respects_limit() { - let api = MockApi::new().with_events(vec![ - make_event("evt1", "Event 1"), - make_event("evt2", "Event 2"), - make_event("evt3", "Event 3"), - ]); - - let opts = EventOptions { - issue_id: "123".to_string(), - limit: 2, - }; - let result = list_events(&api, &opts).await.unwrap(); - assert_eq!(result.len(), 2); - } - - #[test] - fn ensure_configured_fails_without_token() { - let config = SentryConfig::default(); - let result = ensure_configured(&config); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not configured")); - } - - #[test] - fn ensure_configured_fails_without_org() { - let config = SentryConfig { - auth_token: Some("token".to_string()), - organization: None, - project: None, - }; - let result = ensure_configured(&config); - assert!(result.is_err()); - } - - #[test] - fn ensure_configured_succeeds_with_token_and_org() { - let config = SentryConfig { - auth_token: Some("token".to_string()), - organization: Some("my-org".to_string()), - project: None, - }; - let result = ensure_configured(&config); - assert!(result.is_ok()); - } - - #[test] - fn issue_options_default() { - let opts = IssueOptions::default(); - assert!(opts.project.is_none()); - assert!(opts.query.is_none()); - assert_eq!(opts.limit, 0); - } - - #[test] - fn issue_options_new() { - let opts = IssueOptions::new(); - assert_eq!(opts.limit, 25); - } - - #[test] - fn event_options_default() { - let opts = EventOptions::default(); - assert!(opts.issue_id.is_empty()); - assert_eq!(opts.limit, 25); - } -} diff --git a/src/sentry/types.rs b/src/sentry/types.rs deleted file mode 100644 index a42ec0b..0000000 --- a/src/sentry/types.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! Sentry data types - -use serde::{Deserialize, Serialize}; - -pub use crate::util::OutputFormat; - -/// Sentry issue -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Issue { - /// Issue ID - pub id: String, - /// Short ID (e.g., "PROJECT-123") - pub short_id: String, - /// Issue title - pub title: String, - /// Culprit (location in code) - #[serde(default)] - pub culprit: String, - /// Issue level (error, warning, info) - pub level: String, - /// Issue status (unresolved, resolved, ignored) - pub status: String, - /// Platform (python, javascript, etc.) - #[serde(default)] - pub platform: String, - /// Project info - pub project: ProjectInfo, - /// Number of events - pub count: String, - /// Number of affected users - pub user_count: u32, - /// First seen timestamp - pub first_seen: String, - /// Last seen timestamp - pub last_seen: String, - /// Permalink to Sentry UI - pub permalink: String, - /// Is subscribed - #[serde(default)] - pub is_subscribed: bool, - /// Is bookmarked - #[serde(default)] - pub is_bookmarked: bool, - /// Metadata - #[serde(default)] - pub metadata: IssueMetadata, -} - -/// Project info embedded in issue -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProjectInfo { - /// Project ID - pub id: String, - /// Project name - pub name: String, - /// Project slug - pub slug: String, -} - -/// Issue metadata -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct IssueMetadata { - /// Error type - #[serde(rename = "type", default)] - pub error_type: String, - /// Error value/message - #[serde(default)] - pub value: String, - /// Filename - #[serde(default)] - pub filename: String, - /// Function name - #[serde(default)] - pub function: String, -} - -/// Sentry event -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct Event { - /// Event ID - #[serde(rename = "eventID")] - pub id: String, - /// Event title - #[serde(default)] - pub title: String, - /// Event message - #[serde(default)] - pub message: String, - /// Platform - #[serde(default)] - pub platform: String, - /// Timestamp - #[serde(rename = "dateCreated")] - pub date_created: Option, - /// User info - pub user: Option, - /// Tags - #[serde(default)] - pub tags: Vec, -} - -/// User info in event -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EventUser { - /// User ID - pub id: Option, - /// Email - pub email: Option, - /// Username - pub username: Option, - /// IP address - pub ip_address: Option, -} - -/// Event tag -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct EventTag { - /// Tag key - pub key: String, - /// Tag value - pub value: String, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_issue_debug() { - let issue = Issue { - id: "12345".to_string(), - short_id: "PROJ-123".to_string(), - title: "Test error".to_string(), - culprit: "src/main.rs".to_string(), - level: "error".to_string(), - status: "unresolved".to_string(), - platform: "rust".to_string(), - count: "42".to_string(), - user_count: 10, - first_seen: "2024-01-01T00:00:00Z".to_string(), - last_seen: "2024-01-02T00:00:00Z".to_string(), - permalink: "https://sentry.io/issue/123".to_string(), - is_subscribed: false, - is_bookmarked: true, - project: ProjectInfo { - id: "1".to_string(), - name: "Test Project".to_string(), - slug: "test-project".to_string(), - }, - metadata: IssueMetadata::default(), - }; - let debug = format!("{:?}", issue); - assert!(debug.contains("Issue")); - assert!(debug.contains("PROJ-123")); - } - - #[test] - fn test_issue_clone() { - let issue = Issue { - id: "12345".to_string(), - short_id: "PROJ-123".to_string(), - title: "Test".to_string(), - culprit: "".to_string(), - level: "error".to_string(), - status: "unresolved".to_string(), - platform: "".to_string(), - count: "1".to_string(), - user_count: 1, - first_seen: "".to_string(), - last_seen: "".to_string(), - permalink: "".to_string(), - is_subscribed: false, - is_bookmarked: false, - project: ProjectInfo { - id: "1".to_string(), - name: "Test".to_string(), - slug: "test".to_string(), - }, - metadata: IssueMetadata::default(), - }; - let cloned = issue.clone(); - assert_eq!(cloned.id, issue.id); - assert_eq!(cloned.short_id, issue.short_id); - } - - #[test] - fn test_project_info_debug() { - let project = ProjectInfo { - id: "1".to_string(), - name: "My Project".to_string(), - slug: "my-project".to_string(), - }; - let debug = format!("{:?}", project); - assert!(debug.contains("ProjectInfo")); - } - - #[test] - fn test_issue_metadata_default() { - let metadata = IssueMetadata::default(); - assert!(metadata.error_type.is_empty()); - assert!(metadata.value.is_empty()); - assert!(metadata.filename.is_empty()); - assert!(metadata.function.is_empty()); - } - - #[test] - fn test_issue_metadata_debug() { - let metadata = IssueMetadata { - error_type: "RuntimeError".to_string(), - value: "Error message".to_string(), - filename: "main.rs".to_string(), - function: "main".to_string(), - }; - let debug = format!("{:?}", metadata); - assert!(debug.contains("IssueMetadata")); - } - - #[test] - fn test_event_debug() { - let event = Event { - id: "event123".to_string(), - title: "Error event".to_string(), - message: "Something went wrong".to_string(), - platform: "rust".to_string(), - date_created: Some("2024-01-01T00:00:00Z".to_string()), - user: None, - tags: vec![], - }; - let debug = format!("{:?}", event); - assert!(debug.contains("Event")); - } - - #[test] - fn test_event_clone() { - let event = Event { - id: "event123".to_string(), - title: "Test".to_string(), - message: "".to_string(), - platform: "".to_string(), - date_created: None, - user: Some(EventUser { - id: Some("user1".to_string()), - email: None, - username: None, - ip_address: None, - }), - tags: vec![EventTag { - key: "env".to_string(), - value: "prod".to_string(), - }], - }; - let cloned = event.clone(); - assert_eq!(cloned.id, event.id); - assert!(cloned.user.is_some()); - } - - #[test] - fn test_event_user_debug() { - let user = EventUser { - id: Some("user123".to_string()), - email: Some("test@example.com".to_string()), - username: Some("testuser".to_string()), - ip_address: Some("192.168.1.1".to_string()), - }; - let debug = format!("{:?}", user); - assert!(debug.contains("EventUser")); - } - - #[test] - fn test_event_tag_debug() { - let tag = EventTag { - key: "environment".to_string(), - value: "production".to_string(), - }; - let debug = format!("{:?}", tag); - assert!(debug.contains("EventTag")); - } - - #[test] - fn test_issue_serde_default_fields() { - // Test that serde default works for optional fields - let json = r#"{ - "id": "1", - "shortId": "PROJ-1", - "title": "Test", - "level": "error", - "status": "unresolved", - "count": "1", - "userCount": 1, - "firstSeen": "2024-01-01T00:00:00Z", - "lastSeen": "2024-01-01T00:00:00Z", - "permalink": "http://example.com", - "project": {"id": "1", "name": "Test", "slug": "test"} - }"#; - let issue: Issue = serde_json::from_str(json).unwrap(); - assert!(issue.culprit.is_empty()); - assert!(issue.platform.is_empty()); - assert!(!issue.is_subscribed); - assert!(!issue.is_bookmarked); - } -} diff --git a/src/slack/auth/mod.rs b/src/slack/auth/mod.rs deleted file mode 100644 index 6963436..0000000 --- a/src/slack/auth/mod.rs +++ /dev/null @@ -1,382 +0,0 @@ -//! OAuth 2.0 authentication flow for Slack -//! -//! Implements the browser-based OAuth flow to obtain bot tokens. - -use anyhow::Result; -use std::time::Duration; -use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; -use tokio::net::TcpListener; - -use super::config::{load_config, update_oauth_tokens}; - -#[cfg(test)] -mod tests; - -const SLACK_AUTH_URL: &str = "https://slack.com/oauth/v2/authorize"; -const SLACK_TOKEN_URL: &str = "https://slack.com/api/oauth.v2.access"; - -/// OAuth scopes needed for Slack bot access -const OAUTH_SCOPES: &str = - "channels:read,channels:history,chat:write,search:read,users:read,groups:read"; - -/// Result of the OAuth flow -pub struct OAuthResult { - /// Whether authentication succeeded - pub success: bool, - /// Error message if failed - pub error: Option, - /// Slack workspace name if successful - pub team_name: Option, -} - -impl OAuthResult { - const fn success(team_name: String) -> Self { - Self { - success: true, - error: None, - team_name: Some(team_name), - } - } - - const fn failure(error: String) -> Self { - Self { - success: false, - error: Some(error), - team_name: None, - } - } -} - -/// Token response from Slack OAuth -#[derive(serde::Deserialize)] -struct TokenResponse { - ok: bool, - access_token: Option, - team: Option, - error: Option, -} - -/// Team info from OAuth response -#[derive(serde::Deserialize)] -struct TeamInfo { - id: String, - name: String, -} - -/// Generate a random state parameter for OAuth -fn generate_state() -> String { - use rand::Rng; - - let mut rng = rand::thread_rng(); - let bytes: [u8; 16] = rng.gen(); - hex::encode(bytes) -} - -/// Build the OAuth authorization URL -fn build_authorization_url(client_id: &str, redirect_uri: &str, state: &str) -> String { - let params = [ - ("client_id", client_id), - ("scope", OAUTH_SCOPES), - ("redirect_uri", redirect_uri), - ("state", state), - ]; - - let query = params - .iter() - .map(|(k, v)| format!("{}={}", k, urlencoding::encode(v))) - .collect::>() - .join("&"); - - format!("{}?{}", SLACK_AUTH_URL, query) -} - -/// Exchange authorization code for tokens -#[cfg(not(tarpaulin_include))] -async fn exchange_code_for_tokens( - client: &reqwest::Client, - code: &str, - redirect_uri: &str, - client_id: &str, - client_secret: &str, -) -> Result { - let response = client - .post(SLACK_TOKEN_URL) - .form(&[ - ("client_id", client_id), - ("client_secret", client_secret), - ("code", code), - ("redirect_uri", redirect_uri), - ]) - .send() - .await?; - - if !response.status().is_success() { - let status = response.status().as_u16(); - let body = response.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("Token exchange failed ({status}): {body}")); - } - - let token_resp: TokenResponse = response.json().await?; - - if !token_resp.ok { - let error = token_resp - .error - .unwrap_or_else(|| "Unknown error".to_string()); - return Err(anyhow::anyhow!(format!("Token exchange failed: {}", error))); - } - - Ok(token_resp) -} - -/// Parse the OAuth callback request to extract code and state -fn parse_callback_request(request: &str) -> Option<(String, String)> { - let path = request.split_whitespace().nth(1)?; - let query = path.split('?').nth(1)?; - - let mut code = None; - let mut state = None; - - for param in query.split('&') { - let mut parts = param.splitn(2, '='); - let key = parts.next()?; - let value = parts.next().unwrap_or(""); - - match key { - "code" => code = Some(urlencoding::decode(value).ok()?.into_owned()), - "state" => state = Some(urlencoding::decode(value).ok()?.into_owned()), - _ => {} - } - } - - Some((code?, state?)) -} - -/// Send HTTP response to browser -#[cfg(not(tarpaulin_include))] -async fn send_response( - stream: &mut tokio::net::TcpStream, - status: &str, - title: &str, - message: &str, -) -> std::io::Result<()> { - let body = format!( - r#" - -{} - -

{}

-

{}

-

You can close this window.

- -"#, - title, title, message - ); - - let response = format!( - "HTTP/1.1 {}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - status, - body.len(), - body - ); - - stream.write_all(response.as_bytes()).await -} - -/// Run the OAuth authorization flow -/// -/// Starts a local server, opens the browser, and waits for the callback. -#[cfg(not(tarpaulin_include))] -pub async fn run_oauth_flow(port: u16) -> Result { - let config = load_config()?; - - let client_id = config.oauth.client_id.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "client_id not configured. Set slack.oauth.client_id in ~/.config/hu/settings.toml" - ) - })?; - - let client_secret = config.oauth.client_secret.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "client_secret not configured. Set slack.oauth.client_secret in ~/.config/hu/settings.toml" - ) - })?; - - let redirect_uri = format!("http://localhost:{}/callback", port); - let state = generate_state(); - let auth_url = build_authorization_url(client_id, &redirect_uri, &state); - - // Start local server - let listener = TcpListener::bind(format!("127.0.0.1:{}", port)) - .await - .map_err(|e| { - anyhow::anyhow!(format!( - "Failed to start local server on port {}: {}", - port, e - )) - })?; - - println!("\nOpen this URL in your browser to authorize:\n"); - println!("{}\n", auth_url); - println!("Waiting for authorization..."); - - // Try to open browser - if let Err(_e) = open::that(&auth_url) { - // debug!("Failed to open browser: {}", _e); - } - - // Wait for callback with timeout - let ctx = CallbackContext { - listener: &listener, - expected_state: &state, - redirect_uri: &redirect_uri, - client_id, - client_secret, - }; - - tokio::time::timeout(Duration::from_secs(300), handle_callback(ctx)) - .await - .unwrap_or_else(|_| { - Ok(OAuthResult::failure( - "Authorization timed out after 5 minutes".to_string(), - )) - }) -} - -/// Context for handling the OAuth callback -struct CallbackContext<'a> { - listener: &'a TcpListener, - expected_state: &'a str, - redirect_uri: &'a str, - client_id: &'a str, - client_secret: &'a str, -} - -/// Handle the OAuth callback - accepts connections and processes the callback -#[cfg(not(tarpaulin_include))] -async fn handle_callback(ctx: CallbackContext<'_>) -> Result { - loop { - let (mut stream, _) = ctx - .listener - .accept() - .await - .map_err(|e| anyhow::anyhow!(format!("Failed to accept connection: {}", e)))?; - - let mut reader = BufReader::new(&mut stream); - let mut request_line = String::new(); - reader - .read_line(&mut request_line) - .await - .map_err(|e| anyhow::anyhow!(format!("Failed to read request: {}", e)))?; - - // Skip non-callback requests (favicon, etc.) - if !request_line.contains("/callback") { - send_response(&mut stream, "404 Not Found", "Not Found", "") - .await - .ok(); - continue; - } - - return process_callback(&mut stream, &request_line, &ctx).await; - } -} - -/// Process the OAuth callback request -#[cfg(not(tarpaulin_include))] -async fn process_callback( - stream: &mut tokio::net::TcpStream, - request_line: &str, - ctx: &CallbackContext<'_>, -) -> Result { - // Parse callback parameters - let Some((code, returned_state)) = parse_callback_request(request_line) else { - send_response( - stream, - "400 Bad Request", - "Invalid Request", - "Missing code or state", - ) - .await - .ok(); - return Ok(OAuthResult::failure( - "Missing code or state parameter".to_string(), - )); - }; - - // Verify state - if returned_state != ctx.expected_state { - send_response(stream, "400 Bad Request", "Invalid State", "State mismatch") - .await - .ok(); - return Ok(OAuthResult::failure( - "State mismatch - possible CSRF attack".to_string(), - )); - } - - // Exchange code for tokens - let http = reqwest::Client::new(); - let tokens = match exchange_code_for_tokens( - &http, - &code, - ctx.redirect_uri, - ctx.client_id, - ctx.client_secret, - ) - .await - { - Ok(t) => t, - Err(e) => { - send_response( - stream, - "500 Internal Server Error", - "Token Exchange Failed", - &e.to_string(), - ) - .await - .ok(); - return Ok(OAuthResult::failure(e.to_string())); - } - }; - - // Save tokens and complete - complete_auth(stream, &tokens).await -} - -/// Complete authentication by saving tokens -#[cfg(not(tarpaulin_include))] -async fn complete_auth( - stream: &mut tokio::net::TcpStream, - tokens: &TokenResponse, -) -> Result { - let access_token = tokens - .access_token - .as_ref() - .ok_or_else(|| anyhow::anyhow!("No access token in response".to_string()))?; - - let team = tokens - .team - .as_ref() - .ok_or_else(|| anyhow::anyhow!("No team info in response".to_string()))?; - - // Save tokens - if let Err(e) = update_oauth_tokens(access_token, &team.id, &team.name) { - send_response( - stream, - "500 Internal Server Error", - "Failed to Save Tokens", - &e.to_string(), - ) - .await - .ok(); - return Ok(OAuthResult::failure(e.to_string())); - } - - send_response( - stream, - "200 OK", - "Authorization Successful!", - &format!("Connected to {}.", team.name), - ) - .await - .ok(); - Ok(OAuthResult::success(team.name.clone())) -} diff --git a/src/slack/auth/tests.rs b/src/slack/auth/tests.rs deleted file mode 100644 index 4cece71..0000000 --- a/src/slack/auth/tests.rs +++ /dev/null @@ -1,142 +0,0 @@ -use super::*; - -#[test] -fn test_oauth_result_success() { - let result = OAuthResult::success("Test Team".to_string()); - assert!(result.success); - assert!(result.error.is_none()); - assert_eq!(result.team_name, Some("Test Team".to_string())); -} - -#[test] -fn test_oauth_result_failure() { - let result = OAuthResult::failure("auth error".to_string()); - assert!(!result.success); - assert_eq!(result.error, Some("auth error".to_string())); - assert!(result.team_name.is_none()); -} - -#[test] -fn test_generate_state_length() { - let state = generate_state(); - // 16 bytes encoded as hex = 32 characters - assert_eq!(state.len(), 32); -} - -#[test] -fn test_generate_state_unique() { - let state1 = generate_state(); - let state2 = generate_state(); - assert_ne!(state1, state2); -} - -#[test] -fn test_generate_state_hex_chars() { - let state = generate_state(); - assert!(state.chars().all(|c| c.is_ascii_hexdigit())); -} - -#[test] -fn test_build_authorization_url() { - let url = build_authorization_url("test-client-id", "http://localhost:9877/callback", "abc123"); - assert!(url.starts_with("https://slack.com/oauth/v2/authorize?")); - assert!(url.contains("client_id=test-client-id")); - assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A9877%2Fcallback")); - assert!(url.contains("state=abc123")); - assert!(url.contains("scope=")); -} - -#[test] -fn test_build_authorization_url_encodes_special_chars() { - let url = build_authorization_url("client&id", "http://localhost/test?a=b", "state value"); - assert!(url.contains("client_id=client%26id")); - assert!(url.contains("state=state%20value")); -} - -#[test] -fn test_parse_callback_request_valid() { - let request = "GET /callback?code=abc123&state=xyz789 HTTP/1.1"; - let result = parse_callback_request(request); - assert!(result.is_some()); - let (code, state) = result.unwrap(); - assert_eq!(code, "abc123"); - assert_eq!(state, "xyz789"); -} - -#[test] -fn test_parse_callback_request_url_encoded() { - let request = "GET /callback?code=abc%20123&state=xyz%26789 HTTP/1.1"; - let result = parse_callback_request(request); - assert!(result.is_some()); - let (code, state) = result.unwrap(); - assert_eq!(code, "abc 123"); - assert_eq!(state, "xyz&789"); -} - -#[test] -fn test_parse_callback_request_missing_code() { - let request = "GET /callback?state=xyz789 HTTP/1.1"; - let result = parse_callback_request(request); - assert!(result.is_none()); -} - -#[test] -fn test_parse_callback_request_missing_state() { - let request = "GET /callback?code=abc123 HTTP/1.1"; - let result = parse_callback_request(request); - assert!(result.is_none()); -} - -#[test] -fn test_parse_callback_request_no_query() { - let request = "GET /callback HTTP/1.1"; - let result = parse_callback_request(request); - assert!(result.is_none()); -} - -#[test] -fn test_parse_callback_request_empty() { - let request = ""; - let result = parse_callback_request(request); - assert!(result.is_none()); -} - -#[test] -fn test_parse_callback_request_extra_params() { - let request = "GET /callback?code=abc&state=xyz&extra=foo HTTP/1.1"; - let result = parse_callback_request(request); - assert!(result.is_some()); - let (code, state) = result.unwrap(); - assert_eq!(code, "abc"); - assert_eq!(state, "xyz"); -} - -#[test] -fn test_token_response_deserialize_success() { - let json = - r#"{"ok": true, "access_token": "xoxb-test", "team": {"id": "T123", "name": "Test"}}"#; - let resp: TokenResponse = serde_json::from_str(json).unwrap(); - assert!(resp.ok); - assert_eq!(resp.access_token, Some("xoxb-test".to_string())); - assert!(resp.team.is_some()); - let team = resp.team.unwrap(); - assert_eq!(team.id, "T123"); - assert_eq!(team.name, "Test"); -} - -#[test] -fn test_token_response_deserialize_error() { - let json = r#"{"ok": false, "error": "invalid_code"}"#; - let resp: TokenResponse = serde_json::from_str(json).unwrap(); - assert!(!resp.ok); - assert_eq!(resp.error, Some("invalid_code".to_string())); - assert!(resp.access_token.is_none()); -} - -#[test] -fn test_team_info_deserialize() { - let json = r#"{"id": "T12345", "name": "My Team"}"#; - let team: TeamInfo = serde_json::from_str(json).unwrap(); - assert_eq!(team.id, "T12345"); - assert_eq!(team.name, "My Team"); -} diff --git a/src/slack/channels/mod.rs b/src/slack/channels/mod.rs deleted file mode 100644 index 985610c..0000000 --- a/src/slack/channels/mod.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Slack channel operations -//! -//! List channels, get channel info, and resolve channel names to IDs. - -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::fs; -use std::path::PathBuf; -use std::time::{Duration, SystemTime}; -use tokio::time::sleep; - -use super::client::SlackApi; -use super::config::config_path; -use super::types::{SlackChannel, SlackUser}; - -#[cfg(test)] -mod tests; - -/// Cache expiry time (1 hour) -const CACHE_EXPIRY_SECS: u64 = 3600; - -/// Cached user lookup data -#[derive(Serialize, Deserialize)] -struct UserCache { - /// Timestamp when cache was created - created: u64, - /// User ID to username mapping - users: HashMap, -} - -/// Get path to user cache file -fn user_cache_path() -> Option { - config_path().map(|p| p.with_file_name("slack_users_cache.json")) -} - -/// Response from conversations.list API -#[derive(Deserialize)] -struct ConversationsListResponse { - channels: Vec, - response_metadata: Option, -} - -/// Response from conversations.info API -#[derive(Deserialize)] -struct ConversationsInfoResponse { - channel: ChannelResponse, -} - -/// Response from users.list API -#[derive(Deserialize)] -struct UsersListResponse { - members: Vec, -} - -/// Raw channel data from API -#[derive(Deserialize)] -struct ChannelResponse { - id: String, - name: String, - is_private: Option, - is_member: Option, - topic: Option, - purpose: Option, - num_members: Option, - created: Option, -} - -/// Raw user data from API -#[derive(Deserialize)] -struct UserResponse { - id: String, - team_id: Option, - name: String, - real_name: Option, - is_bot: Option, - deleted: Option, - tz: Option, -} - -/// Topic or purpose field -#[derive(Deserialize)] -struct TopicResponse { - value: String, -} - -/// Pagination metadata -#[derive(Deserialize)] -struct ResponseMetadata { - next_cursor: Option, -} - -impl From for SlackChannel { - fn from(r: ChannelResponse) -> Self { - Self { - id: r.id, - name: r.name, - is_private: r.is_private.unwrap_or(false), - is_member: r.is_member.unwrap_or(false), - topic: r.topic.map(|t| t.value).filter(|s| !s.is_empty()), - purpose: r.purpose.map(|p| p.value).filter(|s| !s.is_empty()), - num_members: r.num_members, - created: r.created.unwrap_or(0), - } - } -} - -impl From for SlackUser { - fn from(r: UserResponse) -> Self { - Self { - id: r.id, - team_id: r.team_id, - name: r.name, - real_name: r.real_name, - is_bot: r.is_bot.unwrap_or(false), - deleted: r.deleted.unwrap_or(false), - tz: r.tz, - } - } -} - -/// List all accessible channels -#[cfg(not(tarpaulin_include))] -pub async fn list_channels(client: &impl SlackApi) -> Result> { - let mut all_channels = Vec::new(); - let mut cursor: Option = None; - let mut first_request = true; - - loop { - // Rate limit: delay between paginated requests (Tier 2 = ~20 req/min) - if !first_request { - sleep(Duration::from_millis(500)).await; - } - first_request = false; - - let mut params = vec![ - ("types", "public_channel"), - ("exclude_archived", "true"), - ("limit", "200"), - ]; - - let cursor_str; - if let Some(ref c) = cursor { - cursor_str = c.clone(); - params.push(("cursor", &cursor_str)); - } - - let response: ConversationsListResponse = client - .get_with_params("conversations.list", ¶ms) - .await?; - - all_channels.extend(response.channels.into_iter().map(SlackChannel::from)); - - // Check for more pages - match response.response_metadata.and_then(|m| m.next_cursor) { - Some(c) if !c.is_empty() => cursor = Some(c), - _ => break, - } - } - - // Sort by name - all_channels.sort_by(|a, b| a.name.cmp(&b.name)); - - Ok(all_channels) -} - -/// Get detailed info for a specific channel -#[cfg(not(tarpaulin_include))] -pub async fn get_channel_info(client: &impl SlackApi, channel_id: &str) -> Result { - let response: ConversationsInfoResponse = client - .get_with_params("conversations.info", &[("channel", channel_id)]) - .await?; - - Ok(SlackChannel::from(response.channel)) -} - -/// Resolve a channel name (with or without #) to a channel ID -#[cfg(not(tarpaulin_include))] -pub async fn resolve_channel(client: &impl SlackApi, name_or_id: &str) -> Result { - // If it already looks like an ID (channel, group, DM, or user), return it - // C = public channel, G = private channel, D = DM, U = user (for DM) - if name_or_id.starts_with('C') - || name_or_id.starts_with('G') - || name_or_id.starts_with('D') - || name_or_id.starts_with('U') - { - return Ok(name_or_id.to_string()); - } - - // Strip leading # if present - let name = name_or_id.trim_start_matches('#'); - - // List channels and find by name - let channels = list_channels(client).await?; - channels - .iter() - .find(|c| c.name == name) - .map(|c| c.id.clone()) - .ok_or_else(|| anyhow::anyhow!("Channel not found: {}", name)) -} - -/// List all users in the workspace -#[cfg(not(tarpaulin_include))] -pub async fn list_users(client: &impl SlackApi) -> Result> { - let response: UsersListResponse = client.get("users.list").await?; - - let users: Vec = response - .members - .into_iter() - .map(SlackUser::from) - .filter(|u| !u.deleted && !u.is_bot) - .collect(); - - Ok(users) -} - -/// Build a lookup map from user ID to username (with caching) -#[cfg(not(tarpaulin_include))] -pub async fn build_user_lookup(client: &impl SlackApi) -> Result> { - // Try to load from cache first - if let Some(cached) = load_user_cache() { - return Ok(cached); - } - - // Fetch from API - let users = list_users(client).await?; - let lookup: HashMap = users.into_iter().map(|u| (u.id, u.name)).collect(); - - // Save to cache - save_user_cache(&lookup); - - Ok(lookup) -} - -/// Load user cache if valid -#[cfg(not(tarpaulin_include))] -fn load_user_cache() -> Option> { - let path = user_cache_path()?; - let contents = fs::read_to_string(&path).ok()?; - let cache: UserCache = serde_json::from_str(&contents).ok()?; - - // Check if cache is expired - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .ok()? - .as_secs(); - - if now - cache.created > CACHE_EXPIRY_SECS { - return None; - } - - Some(cache.users) -} - -/// Save user lookup to cache -#[cfg(not(tarpaulin_include))] -fn save_user_cache(users: &HashMap) { - let Some(path) = user_cache_path() else { - return; - }; - - let now = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); - - let cache = UserCache { - created: now, - users: users.clone(), - }; - - if let Ok(json) = serde_json::to_string(&cache) { - let _ = fs::write(&path, json); - } -} diff --git a/src/slack/channels/tests.rs b/src/slack/channels/tests.rs deleted file mode 100644 index 5f5c49e..0000000 --- a/src/slack/channels/tests.rs +++ /dev/null @@ -1,251 +0,0 @@ -use super::*; - -#[test] -fn test_channel_response_to_slack_channel_full() { - let response = ChannelResponse { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: Some(true), - is_member: Some(true), - topic: Some(TopicResponse { - value: "Channel topic".to_string(), - }), - purpose: Some(TopicResponse { - value: "Channel purpose".to_string(), - }), - num_members: Some(42), - created: Some(1704067200), - }; - - let channel = SlackChannel::from(response); - assert_eq!(channel.id, "C12345"); - assert_eq!(channel.name, "general"); - assert!(channel.is_private); - assert!(channel.is_member); - assert_eq!(channel.topic, Some("Channel topic".to_string())); - assert_eq!(channel.purpose, Some("Channel purpose".to_string())); - assert_eq!(channel.num_members, Some(42)); - assert_eq!(channel.created, 1704067200); -} - -#[test] -fn test_channel_response_to_slack_channel_minimal() { - let response = ChannelResponse { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: None, - is_member: None, - topic: None, - purpose: None, - num_members: None, - created: None, - }; - - let channel = SlackChannel::from(response); - assert_eq!(channel.id, "C12345"); - assert_eq!(channel.name, "general"); - assert!(!channel.is_private); - assert!(!channel.is_member); - assert!(channel.topic.is_none()); - assert!(channel.purpose.is_none()); - assert!(channel.num_members.is_none()); - assert_eq!(channel.created, 0); -} - -#[test] -fn test_channel_response_empty_topic_filtered() { - let response = ChannelResponse { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: None, - is_member: None, - topic: Some(TopicResponse { - value: "".to_string(), - }), - purpose: Some(TopicResponse { - value: "".to_string(), - }), - num_members: None, - created: None, - }; - - let channel = SlackChannel::from(response); - assert!(channel.topic.is_none()); - assert!(channel.purpose.is_none()); -} - -#[test] -fn test_user_response_to_slack_user_full() { - let response = UserResponse { - id: "U12345".to_string(), - team_id: Some("T12345".to_string()), - name: "alice".to_string(), - real_name: Some("Alice Smith".to_string()), - is_bot: Some(false), - deleted: Some(false), - tz: Some("America/New_York".to_string()), - }; - - let user = SlackUser::from(response); - assert_eq!(user.id, "U12345"); - assert_eq!(user.team_id, Some("T12345".to_string())); - assert_eq!(user.name, "alice"); - assert_eq!(user.real_name, Some("Alice Smith".to_string())); - assert!(!user.is_bot); - assert!(!user.deleted); - assert_eq!(user.tz, Some("America/New_York".to_string())); -} - -#[test] -fn test_user_response_to_slack_user_minimal() { - let response = UserResponse { - id: "U12345".to_string(), - team_id: None, - name: "alice".to_string(), - real_name: None, - is_bot: None, - deleted: None, - tz: None, - }; - - let user = SlackUser::from(response); - assert_eq!(user.id, "U12345"); - assert!(user.team_id.is_none()); - assert_eq!(user.name, "alice"); - assert!(user.real_name.is_none()); - assert!(!user.is_bot); - assert!(!user.deleted); - assert!(user.tz.is_none()); -} - -#[test] -fn test_user_response_to_slack_user_bot() { - let response = UserResponse { - id: "U12345".to_string(), - team_id: None, - name: "bot".to_string(), - real_name: None, - is_bot: Some(true), - deleted: Some(true), - tz: None, - }; - - let user = SlackUser::from(response); - assert!(user.is_bot); - assert!(user.deleted); -} - -#[test] -fn test_user_cache_serialize_deserialize() { - let mut users = HashMap::new(); - users.insert("U12345".to_string(), "alice".to_string()); - users.insert("U67890".to_string(), "bob".to_string()); - - let cache = UserCache { - created: 1704067200, - users, - }; - - let json = serde_json::to_string(&cache).unwrap(); - let deserialized: UserCache = serde_json::from_str(&json).unwrap(); - - assert_eq!(deserialized.created, 1704067200); - assert_eq!(deserialized.users.len(), 2); - assert_eq!(deserialized.users.get("U12345"), Some(&"alice".to_string())); -} - -#[test] -fn test_user_cache_path_is_some() { - // Should return Some on systems with a home directory - let path = user_cache_path(); - if let Some(p) = path { - assert!(p.to_string_lossy().contains("slack_users_cache.json")); - } -} - -#[test] -fn test_conversations_list_response_deserialize() { - let json = r#"{ - "channels": [ - {"id": "C12345", "name": "general", "is_private": false, "is_member": true} - ], - "response_metadata": {"next_cursor": "abc123"} - }"#; - - let response: ConversationsListResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.channels.len(), 1); - assert_eq!(response.channels[0].id, "C12345"); - assert_eq!( - response.response_metadata.unwrap().next_cursor, - Some("abc123".to_string()) - ); -} - -#[test] -fn test_conversations_list_response_no_cursor() { - let json = r#"{ - "channels": [ - {"id": "C12345", "name": "general"} - ] - }"#; - - let response: ConversationsListResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.channels.len(), 1); - assert!(response.response_metadata.is_none()); -} - -#[test] -fn test_conversations_info_response_deserialize() { - let json = r#"{ - "channel": { - "id": "C12345", - "name": "general", - "is_private": true, - "is_member": true, - "topic": {"value": "Discussion"}, - "purpose": {"value": "General chat"}, - "num_members": 100, - "created": 1704067200 - } - }"#; - - let response: ConversationsInfoResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.channel.id, "C12345"); - assert_eq!(response.channel.name, "general"); -} - -#[test] -fn test_users_list_response_deserialize() { - let json = r#"{ - "members": [ - {"id": "U12345", "name": "alice", "real_name": "Alice"}, - {"id": "U67890", "name": "bob"} - ] - }"#; - - let response: UsersListResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.members.len(), 2); - assert_eq!(response.members[0].id, "U12345"); - assert_eq!(response.members[1].name, "bob"); -} - -#[test] -fn test_topic_response_deserialize() { - let json = r#"{"value": "Test topic"}"#; - let topic: TopicResponse = serde_json::from_str(json).unwrap(); - assert_eq!(topic.value, "Test topic"); -} - -#[test] -fn test_response_metadata_deserialize() { - let json = r#"{"next_cursor": "cursor123"}"#; - let meta: ResponseMetadata = serde_json::from_str(json).unwrap(); - assert_eq!(meta.next_cursor, Some("cursor123".to_string())); -} - -#[test] -fn test_response_metadata_empty_cursor() { - let json = r#"{}"#; - let meta: ResponseMetadata = serde_json::from_str(json).unwrap(); - assert!(meta.next_cursor.is_none()); -} diff --git a/src/slack/client.rs b/src/slack/client.rs deleted file mode 100644 index c7d767e..0000000 --- a/src/slack/client.rs +++ /dev/null @@ -1,433 +0,0 @@ -//! Slack HTTP client -//! -//! Handles API requests with Bot token authentication. - -use anyhow::Result; -use reqwest::Client; -use serde::de::DeserializeOwned; -use serde::Serialize; -use std::future::Future; -use std::time::Duration; -use tokio::time::sleep; - -use super::config::{load_config, SlackConfig}; - -const SLACK_API_URL: &str = "https://slack.com/api"; -const MAX_RETRIES: u32 = 3; -const DEFAULT_RETRY_SECS: u64 = 5; - -/// Slack API trait for testability -#[allow(dead_code)] -pub trait SlackApi: Send + Sync { - /// Make a GET request to the Slack API - fn get( - &self, - method: &str, - ) -> impl Future> + Send; - - /// Make a GET request with query parameters - fn get_with_params( - &self, - method: &str, - params: &[(&str, &str)], - ) -> impl Future> + Send; - - /// Make a GET request using user token (required for search API) - fn get_with_user_token( - &self, - method: &str, - params: &[(&str, &str)], - ) -> impl Future> + Send; - - /// Make a POST request to the Slack API - fn post( - &self, - method: &str, - body: &B, - ) -> impl Future> + Send; - - /// Make a POST request using user token - fn post_with_user_token( - &self, - method: &str, - body: &B, - ) -> impl Future> + Send; -} - -/// Slack HTTP client -pub struct SlackClient { - config: SlackConfig, - http: Client, -} - -impl SlackClient { - /// Create a new Slack client - #[cfg(not(tarpaulin_include))] - pub fn new() -> Result { - let config = load_config()?; - let http = Client::builder() - .user_agent("hu-cli/0.1.0") - .no_proxy() - .build() - .map_err(|e| anyhow::anyhow!(format!("Failed to create HTTP client: {}", e)))?; - Ok(Self { config, http }) - } - - /// Create a client for testing with explicit config and http client - #[cfg(test)] - pub fn with_config(config: SlackConfig, http: Client) -> Self { - Self { config, http } - } - - /// Get a reference to the current config (for testing) - #[cfg(test)] - #[must_use] - pub const fn config(&self) -> &SlackConfig { - &self.config - } - - /// Get the bot token - fn bot_token(&self) -> Result<&str> { - self.config - .oauth - .bot_token - .as_deref() - .ok_or_else(|| anyhow::anyhow!("bot_token not configured".to_string())) - } - - /// Get the user token (required for search API) - fn user_token(&self) -> Result<&str> { - self.config.oauth.user_token.as_deref().ok_or_else(|| { - anyhow::anyhow!("user_token not configured (required for search)".to_string()) - }) - } - - /// Handle API response and check for Slack-specific errors - fn parse_response(&self, text: &str) -> Result { - // Slack returns { "ok": false, "error": "..." } for API errors - let value: serde_json::Value = serde_json::from_str(text) - .map_err(|e| anyhow::anyhow!("Parse error: {}: {}", e, &text[..text.len().min(200)]))?; - - if let Some(ok) = value.get("ok").and_then(serde_json::Value::as_bool) { - if !ok { - let error = value - .get("error") - .and_then(|v| v.as_str()) - .unwrap_or("unknown error"); - return Err(anyhow::anyhow!(error.to_string())); - } - } - - serde_json::from_str(text) - .map_err(|e| anyhow::anyhow!("Parse error: {}: {}", e, &text[..text.len().min(200)])) - } - - /// Execute request with retry on rate limit - #[cfg(not(tarpaulin_include))] - async fn execute_with_retry(&self, request_fn: F) -> Result - where - F: Fn() -> Fut, - Fut: std::future::Future>, - T: DeserializeOwned, - { - let mut retries = 0; - - loop { - let response = request_fn().await?; - let status = response.status(); - - if status == reqwest::StatusCode::TOO_MANY_REQUESTS { - if retries >= MAX_RETRIES { - return Err(anyhow::anyhow!( - "Rate limited after {} retries", - MAX_RETRIES - )); - } - - // Get retry delay from header or use default - let retry_after = response - .headers() - .get("retry-after") - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.parse::().ok()) - .unwrap_or(DEFAULT_RETRY_SECS); - - eprintln!( - "Rate limited, waiting {} seconds... (retry {}/{})", - retry_after, - retries + 1, - MAX_RETRIES - ); - sleep(Duration::from_secs(retry_after)).await; - retries += 1; - continue; - } - - if !status.is_success() { - let body = response.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("HTTP {}: {}", status.as_u16(), body)); - } - - let text = response.text().await?; - return self.parse_response(&text); - } - } -} - -#[cfg(not(tarpaulin_include))] -impl SlackApi for SlackClient { - async fn get(&self, method: &str) -> Result { - let url = format!("{}/{}", SLACK_API_URL, method); - let token = self.bot_token()?.to_string(); - - self.execute_with_retry(|| { - self.http - .get(&url) - .header("Authorization", format!("Bearer {}", token)) - .header("Accept", "application/json") - .send() - }) - .await - } - - async fn get_with_params( - &self, - method: &str, - params: &[(&str, &str)], - ) -> Result { - let url = format!("{}/{}", SLACK_API_URL, method); - let token = self.bot_token()?.to_string(); - let params: Vec<(String, String)> = params - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - - self.execute_with_retry(|| { - self.http - .get(&url) - .header("Authorization", format!("Bearer {}", token)) - .header("Accept", "application/json") - .query(¶ms) - .send() - }) - .await - } - - async fn get_with_user_token( - &self, - method: &str, - params: &[(&str, &str)], - ) -> Result { - let url = format!("{}/{}", SLACK_API_URL, method); - let token = self.user_token()?.to_string(); - let params: Vec<(String, String)> = params - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect(); - - self.execute_with_retry(|| { - self.http - .get(&url) - .header("Authorization", format!("Bearer {}", token)) - .header("Accept", "application/json") - .query(¶ms) - .send() - }) - .await - } - - async fn post( - &self, - method: &str, - body: &B, - ) -> Result { - let url = format!("{}/{}", SLACK_API_URL, method); - let token = self.bot_token()?.to_string(); - let body_json = serde_json::to_string(body)?; - - self.execute_with_retry(|| { - self.http - .post(&url) - .header("Authorization", format!("Bearer {}", token)) - .header("Accept", "application/json") - .header("Content-Type", "application/json; charset=utf-8") - .body(body_json.clone()) - .send() - }) - .await - } - - async fn post_with_user_token( - &self, - method: &str, - body: &B, - ) -> Result { - let url = format!("{}/{}", SLACK_API_URL, method); - let token = self.user_token()?.to_string(); - let body_json = serde_json::to_string(body)?; - - self.execute_with_retry(|| { - self.http - .post(&url) - .header("Authorization", format!("Bearer {}", token)) - .header("Accept", "application/json") - .header("Content-Type", "application/json; charset=utf-8") - .body(body_json.clone()) - .send() - }) - .await - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::slack::config::{OAuthConfig, SlackConfig}; - - fn make_test_client() -> SlackClient { - let config = SlackConfig { - oauth: OAuthConfig { - client_id: None, - client_secret: None, - bot_token: Some("xoxb-test".to_string()), - user_token: Some("xoxp-test".to_string()), - team_id: Some("T12345".to_string()), - team_name: Some("Test Team".to_string()), - }, - default_channel: String::new(), - is_configured: true, - }; - let http = Client::builder().build().unwrap(); - SlackClient::with_config(config, http) - } - - #[test] - fn test_parse_response_success() { - let client = make_test_client(); - let json = r#"{"ok": true, "name": "test"}"#; - - #[derive(Debug, serde::Deserialize, PartialEq)] - struct TestResponse { - ok: bool, - name: String, - } - - let result: Result = client.parse_response(json); - assert!(result.is_ok()); - let resp = result.unwrap(); - assert!(resp.ok); - assert_eq!(resp.name, "test"); - } - - #[test] - fn test_parse_response_slack_error() { - let client = make_test_client(); - let json = r#"{"ok": false, "error": "channel_not_found"}"#; - - #[derive(Debug, serde::Deserialize)] - #[allow(dead_code)] - struct TestResponse { - ok: bool, - } - - let result: Result = client.parse_response(json); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.to_string().contains("channel_not_found")); - } - - #[test] - fn test_parse_response_slack_error_unknown() { - let client = make_test_client(); - let json = r#"{"ok": false}"#; - - #[derive(Debug, serde::Deserialize)] - #[allow(dead_code)] - struct TestResponse { - ok: bool, - } - - let result: Result = client.parse_response(json); - assert!(result.is_err()); - let err = result.unwrap_err(); - assert!(err.to_string().contains("unknown error")); - } - - #[test] - fn test_parse_response_invalid_json() { - let client = make_test_client(); - let json = "not json at all"; - - #[derive(Debug, serde::Deserialize)] - #[allow(dead_code)] - struct TestResponse { - ok: bool, - } - - let result: Result = client.parse_response(json); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("Parse error")); - } - - #[test] - fn test_bot_token() { - let client = make_test_client(); - assert_eq!(client.bot_token().unwrap(), "xoxb-test"); - } - - #[test] - fn test_user_token() { - let client = make_test_client(); - assert_eq!(client.user_token().unwrap(), "xoxp-test"); - } - - #[test] - fn test_bot_token_missing() { - let config = SlackConfig { - oauth: OAuthConfig { - client_id: None, - client_secret: None, - bot_token: None, - user_token: None, - team_id: None, - team_name: None, - }, - default_channel: String::new(), - is_configured: false, - }; - let http = Client::builder().build().unwrap(); - let client = SlackClient::with_config(config, http); - - assert!(client.bot_token().is_err()); - } - - #[test] - fn test_user_token_missing() { - let config = SlackConfig { - oauth: OAuthConfig { - client_id: None, - client_secret: None, - bot_token: Some("xoxb-test".to_string()), - user_token: None, - team_id: None, - team_name: None, - }, - default_channel: String::new(), - is_configured: true, - }; - let http = Client::builder().build().unwrap(); - let client = SlackClient::with_config(config, http); - - assert!(client.user_token().is_err()); - } - - #[test] - fn test_config_accessor() { - let client = make_test_client(); - assert!(client.config().is_configured); - assert_eq!( - client.config().oauth.team_name, - Some("Test Team".to_string()) - ); - } -} diff --git a/src/slack/config/mod.rs b/src/slack/config/mod.rs deleted file mode 100644 index acff218..0000000 --- a/src/slack/config/mod.rs +++ /dev/null @@ -1,286 +0,0 @@ -//! Slack configuration management -//! -//! Loads configuration from `~/.config/hu/settings.toml` with environment variable overrides. - -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::fs; -use std::path::PathBuf; - -#[cfg(test)] -mod tests; - -/// Slack configuration -#[derive(Debug, Clone, Default)] -pub struct SlackConfig { - /// Default channel (e.g., "#general") - pub default_channel: String, - /// OAuth configuration - pub oauth: OAuthConfig, - /// Whether configuration is complete - pub is_configured: bool, -} - -/// OAuth 2.0 configuration for Slack -#[derive(Debug, Clone, Default, Serialize, Deserialize)] -pub struct OAuthConfig { - /// OAuth client ID - pub client_id: Option, - /// OAuth client secret - pub client_secret: Option, - /// Bot token (xoxb-...) - pub bot_token: Option, - /// User token (xoxp-...) - required for search API - pub user_token: Option, - /// Team/workspace ID - pub team_id: Option, - /// Team/workspace name - pub team_name: Option, -} - -impl OAuthConfig { - /// Check if OAuth is fully configured (bot token present) - #[must_use] - pub fn is_configured(&self) -> bool { - self.bot_token - .as_ref() - .is_some_and(|t| t.starts_with("xoxb-")) - } - - /// Check if user token is available (required for search) - #[must_use] - pub fn has_user_token(&self) -> bool { - self.user_token - .as_ref() - .is_some_and(|t| t.starts_with("xoxp-")) - } -} - -/// Raw TOML structure for settings file -#[derive(Debug, Deserialize)] -struct SettingsFile { - slack: Option, -} - -#[derive(Debug, Deserialize)] -struct SlackSection { - default_channel: Option, - oauth: Option, -} - -#[derive(Debug, Deserialize)] -struct OAuthSection { - client_id: Option, - client_secret: Option, - bot_token: Option, - user_token: Option, - team_id: Option, - team_name: Option, -} - -/// Get the config file path -/// -/// Uses `~/.config/hu/settings.toml` following XDG convention. -#[must_use] -pub fn config_path() -> Option { - dirs::home_dir().map(|p| p.join(".config").join("hu").join("settings.toml")) -} - -/// Load Slack configuration from settings file and environment variables -#[cfg(not(tarpaulin_include))] -pub fn load_config() -> Result { - let mut config = SlackConfig::default(); - - // Try to load from settings file - if let Some(path) = config_path() { - if path.exists() { - // debug!("Loading Slack config from {}", path.display()); - let contents = fs::read_to_string(&path).map_err(|e| { - anyhow::anyhow!(format!("Failed to read {}: {}", path.display(), e)) - })?; - - let settings: SettingsFile = toml::from_str(&contents).map_err(|e| { - anyhow::anyhow!(format!("Failed to parse {}: {}", path.display(), e)) - })?; - - if let Some(slack) = settings.slack { - config.default_channel = slack.default_channel.unwrap_or_default(); - - if let Some(oauth) = slack.oauth { - config.oauth = OAuthConfig { - client_id: oauth.client_id, - client_secret: oauth.client_secret, - bot_token: oauth.bot_token, - user_token: oauth.user_token, - team_id: oauth.team_id, - team_name: oauth.team_name, - }; - } - } - } - } - - // Environment variable overrides - if let Ok(token) = std::env::var("SLACK_BOT_TOKEN") { - config.oauth.bot_token = Some(token); - } - if let Ok(token) = std::env::var("SLACK_USER_TOKEN") { - config.oauth.user_token = Some(token); - } - if let Ok(channel) = std::env::var("SLACK_DEFAULT_CHANNEL") { - config.default_channel = channel; - } - - // Determine configuration status - config.is_configured = config.oauth.is_configured(); - - Ok(config) -} - -/// Update OAuth tokens in the config file after successful authentication -#[cfg(not(tarpaulin_include))] -pub fn update_oauth_tokens(bot_token: &str, team_id: &str, team_name: &str) -> Result<()> { - let path = config_path() - .ok_or_else(|| anyhow::anyhow!("Cannot determine config directory".to_string()))?; - - // Read existing file - let contents = if path.exists() { - fs::read_to_string(&path) - .map_err(|e| anyhow::anyhow!(format!("Failed to read {}: {}", path.display(), e)))? - } else { - String::new() - }; - - // Parse as TOML value for modification - let mut doc: toml::Value = - toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new())); - - // Ensure slack.oauth section exists - let table = doc - .as_table_mut() - .ok_or_else(|| anyhow::anyhow!("Config is not a table".to_string()))?; - - if !table.contains_key("slack") { - table.insert( - "slack".to_string(), - toml::Value::Table(toml::map::Map::new()), - ); - } - - let slack = table - .get_mut("slack") - .and_then(|v| v.as_table_mut()) - .ok_or_else(|| anyhow::anyhow!("slack section is not a table".to_string()))?; - - if !slack.contains_key("oauth") { - slack.insert( - "oauth".to_string(), - toml::Value::Table(toml::map::Map::new()), - ); - } - - let oauth = slack - .get_mut("oauth") - .and_then(|v| v.as_table_mut()) - .ok_or_else(|| anyhow::anyhow!("slack.oauth section is not a table".to_string()))?; - - // Update tokens - oauth.insert( - "bot_token".to_string(), - toml::Value::String(bot_token.to_string()), - ); - oauth.insert( - "team_id".to_string(), - toml::Value::String(team_id.to_string()), - ); - oauth.insert( - "team_name".to_string(), - toml::Value::String(team_name.to_string()), - ); - - // Write back - let output = toml::to_string_pretty(&doc) - .map_err(|e| anyhow::anyhow!(format!("Failed to serialize config: {}", e)))?; - - // Ensure parent directory exists - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| anyhow::anyhow!(format!("Failed to create config directory: {}", e)))?; - } - - fs::write(&path, output) - .map_err(|e| anyhow::anyhow!(format!("Failed to write {}: {}", path.display(), e)))?; - - // debug!("Updated Slack OAuth tokens in {}", path.display()); - Ok(()) -} - -/// Update user token in the config file -#[cfg(not(tarpaulin_include))] -pub fn update_user_token(user_token: &str) -> Result<()> { - let path = config_path() - .ok_or_else(|| anyhow::anyhow!("Cannot determine config directory".to_string()))?; - - // Read existing file - let contents = if path.exists() { - fs::read_to_string(&path) - .map_err(|e| anyhow::anyhow!(format!("Failed to read {}: {}", path.display(), e)))? - } else { - String::new() - }; - - // Parse as TOML value for modification - let mut doc: toml::Value = - toml::from_str(&contents).unwrap_or_else(|_| toml::Value::Table(toml::map::Map::new())); - - // Ensure slack.oauth section exists - let table = doc - .as_table_mut() - .ok_or_else(|| anyhow::anyhow!("Config is not a table".to_string()))?; - - if !table.contains_key("slack") { - table.insert( - "slack".to_string(), - toml::Value::Table(toml::map::Map::new()), - ); - } - - let slack = table - .get_mut("slack") - .and_then(|v| v.as_table_mut()) - .ok_or_else(|| anyhow::anyhow!("slack section is not a table".to_string()))?; - - if !slack.contains_key("oauth") { - slack.insert( - "oauth".to_string(), - toml::Value::Table(toml::map::Map::new()), - ); - } - - let oauth = slack - .get_mut("oauth") - .and_then(|v| v.as_table_mut()) - .ok_or_else(|| anyhow::anyhow!("slack.oauth section is not a table".to_string()))?; - - // Update user token - oauth.insert( - "user_token".to_string(), - toml::Value::String(user_token.to_string()), - ); - - // Write back - let output = toml::to_string_pretty(&doc) - .map_err(|e| anyhow::anyhow!(format!("Failed to serialize config: {}", e)))?; - - // Ensure parent directory exists - if let Some(parent) = path.parent() { - fs::create_dir_all(parent) - .map_err(|e| anyhow::anyhow!(format!("Failed to create config directory: {}", e)))?; - } - - fs::write(&path, output) - .map_err(|e| anyhow::anyhow!(format!("Failed to write {}: {}", path.display(), e)))?; - - // debug!("Updated Slack user token in {}", path.display()); - Ok(()) -} diff --git a/src/slack/config/tests.rs b/src/slack/config/tests.rs deleted file mode 100644 index 80b443a..0000000 --- a/src/slack/config/tests.rs +++ /dev/null @@ -1,271 +0,0 @@ -use super::*; - -#[test] -fn test_oauth_config_is_configured_with_valid_bot_token() { - let config = OAuthConfig { - client_id: None, - client_secret: None, - bot_token: Some("xoxb-12345-67890".to_string()), - user_token: None, - team_id: None, - team_name: None, - }; - assert!(config.is_configured()); -} - -#[test] -fn test_oauth_config_is_configured_with_invalid_bot_token() { - let config = OAuthConfig { - client_id: None, - client_secret: None, - bot_token: Some("invalid-token".to_string()), - user_token: None, - team_id: None, - team_name: None, - }; - assert!(!config.is_configured()); -} - -#[test] -fn test_oauth_config_is_configured_without_bot_token() { - let config = OAuthConfig { - client_id: None, - client_secret: None, - bot_token: None, - user_token: None, - team_id: None, - team_name: None, - }; - assert!(!config.is_configured()); -} - -#[test] -fn test_oauth_config_has_user_token_with_valid_token() { - let config = OAuthConfig { - client_id: None, - client_secret: None, - bot_token: None, - user_token: Some("xoxp-12345-67890".to_string()), - team_id: None, - team_name: None, - }; - assert!(config.has_user_token()); -} - -#[test] -fn test_oauth_config_has_user_token_with_invalid_token() { - let config = OAuthConfig { - client_id: None, - client_secret: None, - bot_token: None, - user_token: Some("invalid-token".to_string()), - team_id: None, - team_name: None, - }; - assert!(!config.has_user_token()); -} - -#[test] -fn test_oauth_config_has_user_token_without_token() { - let config = OAuthConfig { - client_id: None, - client_secret: None, - bot_token: None, - user_token: None, - team_id: None, - team_name: None, - }; - assert!(!config.has_user_token()); -} - -#[test] -fn test_config_path_returns_some() { - // This test just verifies config_path returns Some on systems with a home dir - let path = config_path(); - // On most systems this should return Some - if let Some(p) = path { - assert!(p.to_string_lossy().contains("settings.toml")); - } -} - -#[test] -fn test_slack_config_default() { - let config = SlackConfig::default(); - assert!(!config.is_configured); - assert!(config.default_channel.is_empty()); - assert!(!config.oauth.is_configured()); -} - -#[test] -fn test_oauth_config_default() { - let config = OAuthConfig::default(); - assert!(config.client_id.is_none()); - assert!(config.client_secret.is_none()); - assert!(config.bot_token.is_none()); - assert!(config.user_token.is_none()); - assert!(config.team_id.is_none()); - assert!(config.team_name.is_none()); -} - -#[test] -fn test_oauth_config_serialize_deserialize() { - let config = OAuthConfig { - client_id: Some("client123".to_string()), - client_secret: Some("secret456".to_string()), - bot_token: Some("xoxb-test".to_string()), - user_token: Some("xoxp-test".to_string()), - team_id: Some("T12345".to_string()), - team_name: Some("Test Team".to_string()), - }; - - let json = serde_json::to_string(&config).unwrap(); - let deserialized: OAuthConfig = serde_json::from_str(&json).unwrap(); - - assert_eq!(deserialized.client_id, Some("client123".to_string())); - assert_eq!(deserialized.client_secret, Some("secret456".to_string())); - assert_eq!(deserialized.bot_token, Some("xoxb-test".to_string())); - assert_eq!(deserialized.user_token, Some("xoxp-test".to_string())); - assert_eq!(deserialized.team_id, Some("T12345".to_string())); - assert_eq!(deserialized.team_name, Some("Test Team".to_string())); -} - -#[test] -fn test_oauth_config_debug() { - let config = OAuthConfig { - client_id: Some("client123".to_string()), - client_secret: None, - bot_token: None, - user_token: None, - team_id: None, - team_name: None, - }; - - let debug = format!("{:?}", config); - assert!(debug.contains("OAuthConfig")); - assert!(debug.contains("client123")); -} - -#[test] -fn test_oauth_config_clone() { - let config = OAuthConfig { - client_id: Some("client123".to_string()), - client_secret: None, - bot_token: Some("xoxb-test".to_string()), - user_token: None, - team_id: None, - team_name: None, - }; - - let cloned = config.clone(); - assert_eq!(cloned.client_id, config.client_id); - assert_eq!(cloned.bot_token, config.bot_token); -} - -#[test] -fn test_slack_config_clone() { - let config = SlackConfig { - default_channel: "general".to_string(), - oauth: OAuthConfig::default(), - is_configured: true, - }; - - let cloned = config.clone(); - assert_eq!(cloned.default_channel, "general"); - assert!(cloned.is_configured); -} - -#[test] -fn test_slack_config_debug() { - let config = SlackConfig { - default_channel: "test".to_string(), - oauth: OAuthConfig::default(), - is_configured: false, - }; - - let debug = format!("{:?}", config); - assert!(debug.contains("SlackConfig")); - assert!(debug.contains("test")); -} - -#[test] -fn test_settings_file_parse() { - let toml_str = r##" - [slack] - default_channel = "general" - - [slack.oauth] - client_id = "client123" - client_secret = "secret456" - bot_token = "xoxb-token" - user_token = "xoxp-token" - team_id = "T12345" - team_name = "Test Team" - "##; - - let settings: SettingsFile = toml::from_str(toml_str).unwrap(); - let slack = settings.slack.unwrap(); - assert_eq!(slack.default_channel, Some("general".to_string())); - - let oauth = slack.oauth.unwrap(); - assert_eq!(oauth.client_id, Some("client123".to_string())); - assert_eq!(oauth.bot_token, Some("xoxb-token".to_string())); - assert_eq!(oauth.team_name, Some("Test Team".to_string())); -} - -#[test] -fn test_settings_file_parse_empty() { - let toml_str = ""; - let settings: SettingsFile = toml::from_str(toml_str).unwrap(); - assert!(settings.slack.is_none()); -} - -#[test] -fn test_settings_file_parse_no_oauth() { - let toml_str = r##" - [slack] - default_channel = "test" - "##; - - let settings: SettingsFile = toml::from_str(toml_str).unwrap(); - let slack = settings.slack.unwrap(); - assert_eq!(slack.default_channel, Some("test".to_string())); - assert!(slack.oauth.is_none()); -} - -#[test] -fn test_settings_file_parse_partial_oauth() { - let toml_str = r##" - [slack.oauth] - bot_token = "xoxb-test" - "##; - - let settings: SettingsFile = toml::from_str(toml_str).unwrap(); - let slack = settings.slack.unwrap(); - let oauth = slack.oauth.unwrap(); - assert_eq!(oauth.bot_token, Some("xoxb-test".to_string())); - assert!(oauth.client_id.is_none()); -} - -#[test] -fn test_slack_section_debug() { - let toml_str = r##" - [slack] - default_channel = "test" - "##; - - let settings: SettingsFile = toml::from_str(toml_str).unwrap(); - let debug = format!("{:?}", settings); - assert!(debug.contains("SettingsFile")); -} - -#[test] -fn test_oauth_section_debug() { - let toml_str = r##" - [slack.oauth] - bot_token = "xoxb-test" - "##; - - let settings: SettingsFile = toml::from_str(toml_str).unwrap(); - let debug = format!("{:?}", settings.slack.unwrap().oauth.unwrap()); - assert!(debug.contains("OAuthSection")); -} diff --git a/src/slack/display/mod.rs b/src/slack/display/mod.rs deleted file mode 100644 index abfeaf8..0000000 --- a/src/slack/display/mod.rs +++ /dev/null @@ -1,399 +0,0 @@ -//! Slack output formatting - -use std::collections::HashMap; -use std::path::Path; - -use anyhow::{Context, Result}; -use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, ContentArrangement, Table}; -use regex::Regex; - -use super::tidy; -use super::types::{ - AuthInfo, AuthResult, OutputFormat, SlackChannel, SlackMessage, SlackSearchResult, SlackUser, - TidySummary, -}; - -#[cfg(test)] -mod tests; - -/// Create a table with standard formatting -fn new_table(headers: Vec<&str>) -> Table { - let mut table = Table::new(); - table.load_preset(UTF8_FULL_CONDENSED); - table.set_content_arrangement(ContentArrangement::Dynamic); - table.set_header(headers); - table -} - -/// Truncate string to max length with ellipsis -fn truncate(s: &str, max_len: usize) -> String { - if s.len() <= max_len { - s.to_string() - } else { - format!("{}...", &s[..max_len.saturating_sub(3)]) - } -} - -/// Clean up Slack message text for display -/// - Converts <@U04H482TK6Z|Adam Ladachowski> to @Adam Ladachowski -/// - Converts <@U04H482TK6Z> to @username using lookup -/// - Converts <#C12345678|channel-name> to #channel-name -/// - Converts to text -fn clean_message_text(text: &str, user_lookup: &HashMap) -> String { - // Match Slack's special formatting: <...> - let re = Regex::new(r"<([^>]+)>").unwrap(); - - re.replace_all(text, |caps: ®ex::Captures| { - let content = &caps[1]; - - if let Some(rest) = content.strip_prefix('@') { - // User mention: <@U12345|Display Name> or <@U12345> - if let Some((_, display_name)) = rest.split_once('|') { - format!("@{}", display_name) - } else { - // No display name, look up user ID - user_lookup - .get(rest) - .map(|name| format!("@{}", name)) - .unwrap_or_else(|| format!("@{}", rest)) - } - } else if let Some(rest) = content.strip_prefix('#') { - // Channel mention: <#C12345|channel-name> - if let Some((_, channel_name)) = rest.split_once('|') { - format!("#{}", channel_name) - } else { - format!("#{}", rest) - } - } else if let Some(rest) = content.strip_prefix('!') { - // Special mention: , , - format!("@{}", rest) - } else if content.contains('|') { - // URL with display text: - let (_, display) = content.split_once('|').unwrap(); - display.to_string() - } else { - // Plain URL or other - content.to_string() - } - }) - .to_string() -} - -/// Format channel name for display -/// Converts mpdm-user1--user2--user3-1 to @user1, @user2, @user3 -/// Converts user IDs like U04H482TK6Z to @username using lookup -fn format_channel_name(name: &str, user_lookup: &HashMap) -> String { - if name.starts_with("mpdm-") { - // Multi-person DM: mpdm-user1--user2--user3-1 - let without_prefix = name.strip_prefix("mpdm-").unwrap_or(name); - // Remove trailing -1, -2, etc. - let without_suffix = without_prefix - .rsplit_once('-') - .map(|(rest, _)| rest) - .unwrap_or(without_prefix); - // Split on -- and format as @mentions - let users: Vec = without_suffix - .split("--") - .map(|u| format!("@{}", u)) - .collect(); - users.join(", ") - } else if name.starts_with('U') - && name.len() == 11 - && name.chars().all(|c| c.is_ascii_alphanumeric()) - { - // User ID (DM): resolve to @username - user_lookup - .get(name) - .map(|n| format!("@{}", n)) - .unwrap_or_else(|| "DM".to_string()) - } else { - format!("#{}", name) - } -} - -/// Format Unix timestamp to readable date -fn format_timestamp(ts: &str) -> String { - // Slack timestamps are like "1234567890.123456" - ts.split('.') - .next() - .and_then(|s| s.parse::().ok()) - .and_then(|secs| chrono::DateTime::from_timestamp(secs, 0)) - .map_or_else( - || ts.to_string(), - |dt| dt.format("%Y-%m-%d %H:%M").to_string(), - ) -} - -/// Output channels list -pub fn output_channels(channels: &[SlackChannel], format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - if channels.is_empty() { - println!("No channels found."); - return Ok(()); - } - let mut table = new_table(vec!["Name", "Type", "Members", "Topic"]); - for channel in channels { - let kind = if channel.is_private { - "private" - } else { - "public" - }; - let members = channel.num_members.map_or("-".into(), |n| n.to_string()); - let topic = channel.topic.as_deref().unwrap_or("-"); - table.add_row(vec![ - Cell::new(format!("#{}", channel.name)).fg(Color::Cyan), - Cell::new(kind), - Cell::new(members), - Cell::new(truncate(topic, 40)), - ]); - } - println!("{table}"); - println!("\n{} channels", channels.len()); - } - OutputFormat::Json => { - let json = serde_json::to_string_pretty(channels) - .context("Failed to serialize channels to JSON")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output channel detail -pub fn output_channel_detail(channel: &SlackChannel, format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - let sep = "-".repeat(60); - let kind = if channel.is_private { - "private" - } else { - "public" - }; - let member = if channel.is_member { "yes" } else { "no" }; - println!("{sep}"); - println!("#{} ({})", channel.name, channel.id); - println!("{sep}"); - println!("Type: {kind}"); - println!("Member: {member}"); - if let Some(n) = channel.num_members { - println!("Members: {n}"); - } - if let Some(ref topic) = channel.topic { - println!("\nTopic: {topic}"); - } - if let Some(ref purpose) = channel.purpose { - println!("\nPurpose: {purpose}"); - } - } - OutputFormat::Json => { - let json = serde_json::to_string_pretty(channel) - .context("Failed to serialize channel to JSON")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output message history -pub fn output_messages( - messages: &[SlackMessage], - channel_name: &str, - format: OutputFormat, -) -> Result<()> { - match format { - OutputFormat::Table => { - if messages.is_empty() { - println!("No messages found."); - return Ok(()); - } - println!("Messages in #{channel_name}"); - println!("{}", "-".repeat(60)); - for msg in messages.iter().rev() { - let time = format_timestamp(&msg.ts); - let user = msg - .username - .as_deref() - .or(msg.user.as_deref()) - .unwrap_or("unknown"); - let thread = msg - .reply_count - .map_or(String::new(), |n| format!(" [{n} replies]")); - println!("[{time}] {user}: {}{thread}", msg.text); - } - println!("\n{} messages", messages.len()); - } - OutputFormat::Json => { - let json = serde_json::to_string_pretty(messages) - .context("Failed to serialize messages to JSON")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output search results -pub fn output_search_results( - results: &SlackSearchResult, - format: OutputFormat, - user_lookup: &HashMap, -) -> Result<()> { - match format { - OutputFormat::Table => { - if results.matches.is_empty() { - println!("No messages found."); - return Ok(()); - } - let mut table = new_table(vec!["Channel", "User", "Time", "Message"]); - for m in &results.matches { - let time = format_timestamp(&m.ts); - let user = m.username.as_deref().unwrap_or("-"); - let channel = format_channel_name(&m.channel.name, user_lookup); - let text = clean_message_text(&m.text, user_lookup); - table.add_row(vec![ - Cell::new(&channel).fg(Color::Cyan), - Cell::new(user), - Cell::new(time), - Cell::new(truncate(&text, 50)), - ]); - } - - println!("{table}"); - println!( - "\nShowing {} of {} matches", - results.matches.len(), - results.total - ); - } - OutputFormat::Json => { - let json = serde_json::to_string_pretty(results) - .context("Failed to serialize search results to JSON")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output users list -pub fn output_users(users: &[SlackUser], format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Table => { - if users.is_empty() { - println!("No users found."); - return Ok(()); - } - let mut table = new_table(vec!["Username", "Name", "Timezone"]); - for user in users { - let name = user.real_name.as_deref().unwrap_or("-"); - let tz = user.tz.as_deref().unwrap_or("-"); - table.add_row(vec![ - Cell::new(format!("@{}", user.name)).fg(Color::Cyan), - Cell::new(name), - Cell::new(tz), - ]); - } - - println!("{table}"); - println!("\n{} users", users.len()); - } - OutputFormat::Json => { - let json = - serde_json::to_string_pretty(users).context("Failed to serialize users to JSON")?; - println!("{json}"); - } - } - Ok(()) -} - -/// Output config status -pub fn output_config_status( - is_configured: bool, - has_user_token: bool, - team_name: Option<&str>, - default_channel: &str, -) { - let bot = if is_configured { "Yes" } else { "No" }; - let user = if has_user_token { - "Yes (search enabled)" - } else { - "No (search disabled)" - }; - println!("Slack Configuration"); - println!("{}", "-".repeat(40)); - println!("Bot token: {bot}"); - println!("User token: {user}"); - if let Some(name) = team_name { - println!("Workspace: {name}"); - } - if !default_channel.is_empty() { - println!("Default: {default_channel}"); - } -} - -/// Output config file path -pub fn output_config_path(path: &Path) { - println!("Config: {}", path.display()); -} - -/// Output authentication result -pub fn output_auth_result(result: &AuthResult) { - match result { - AuthResult::UserTokenSaved => { - println!("User token saved successfully!"); - println!("\nYou can now use `hu slack search` command."); - } - AuthResult::BotTokenSaved { team_name } => { - println!("Token saved successfully!"); - println!("Connected to: {}", team_name); - println!("\nYou can now use `hu slack channels` and other commands."); - } - AuthResult::OAuthCompleted { team_name } => { - println!("\nAuthentication successful!"); - if let Some(team) = team_name { - println!("Connected to: {}", team); - } - println!("\nYou can now use `hu slack channels` and other commands."); - } - } -} - -/// Output whoami information -pub fn output_whoami(info: &AuthInfo) { - println!("User ID: {}", info.user_id); - println!("User: {}", info.user); - println!("Team ID: {}", info.team_id); - println!("Team: {}", info.team); -} - -/// Output send message confirmation -pub fn output_send_confirmation(channel: &str, ts: &str) { - println!("Message sent to {} (ts: {})", channel, ts); -} - -/// Output tidy dry run notice -pub fn output_tidy_dry_run() { - println!("DRY RUN - no channels will be marked as read\n"); -} - -/// Output individual tidy results (marked/mentioned channels) -pub fn output_tidy_results(results: &[tidy::TidyResult]) { - for r in results { - match &r.action { - tidy::TidyAction::Skipped => {} - tidy::TidyAction::MarkedRead => { - println!("Marked read: #{}", r.channel_name); - } - tidy::TidyAction::HasMention(mention) => { - println!("Has mention: #{} - {}", r.channel_name, mention); - } - } - } -} - -/// Output tidy summary -pub fn output_tidy_summary(summary: &TidySummary) { - println!("\nSummary:"); - println!(" Marked as read: {}", summary.marked_read); - println!(" Has mentions: {}", summary.has_mentions); - println!(" Already read: {}", summary.already_read); -} diff --git a/src/slack/display/tests.rs b/src/slack/display/tests.rs deleted file mode 100644 index c6557cc..0000000 --- a/src/slack/display/tests.rs +++ /dev/null @@ -1,523 +0,0 @@ -use super::*; - -#[test] -fn test_truncate_short_string() { - assert_eq!(truncate("hello", 10), "hello"); -} - -#[test] -fn test_truncate_exact_length() { - assert_eq!(truncate("hello", 5), "hello"); -} - -#[test] -fn test_truncate_long_string() { - assert_eq!(truncate("hello world", 8), "hello..."); -} - -#[test] -fn test_truncate_very_short_max() { - assert_eq!(truncate("hello", 3), "..."); -} - -#[test] -fn test_clean_message_text_user_mention_with_display() { - let lookup = HashMap::new(); - assert_eq!( - clean_message_text("<@U12345|John Doe>", &lookup), - "@John Doe" - ); -} - -#[test] -fn test_clean_message_text_user_mention_with_lookup() { - let mut lookup = HashMap::new(); - lookup.insert("U12345".to_string(), "johndoe".to_string()); - assert_eq!(clean_message_text("<@U12345>", &lookup), "@johndoe"); -} - -#[test] -fn test_clean_message_text_user_mention_without_lookup() { - let lookup = HashMap::new(); - assert_eq!(clean_message_text("<@U12345>", &lookup), "@U12345"); -} - -#[test] -fn test_clean_message_text_channel_mention() { - let lookup = HashMap::new(); - assert_eq!(clean_message_text("<#C12345|general>", &lookup), "#general"); -} - -#[test] -fn test_clean_message_text_channel_mention_no_name() { - let lookup = HashMap::new(); - assert_eq!(clean_message_text("<#C12345>", &lookup), "#C12345"); -} - -#[test] -fn test_clean_message_text_special_mention() { - let lookup = HashMap::new(); - assert_eq!(clean_message_text("", &lookup), "@here"); - assert_eq!(clean_message_text("", &lookup), "@channel"); - assert_eq!(clean_message_text("", &lookup), "@everyone"); -} - -#[test] -fn test_clean_message_text_url_with_display() { - let lookup = HashMap::new(); - assert_eq!( - clean_message_text("", &lookup), - "Example Site" - ); -} - -#[test] -fn test_clean_message_text_plain_url() { - let lookup = HashMap::new(); - assert_eq!( - clean_message_text("", &lookup), - "https://example.com" - ); -} - -#[test] -fn test_clean_message_text_mixed() { - let mut lookup = HashMap::new(); - lookup.insert("U12345".to_string(), "bob".to_string()); - assert_eq!( - clean_message_text("Hey <@U12345>, check <#C99999|dev>!", &lookup), - "Hey @bob, check #dev!" - ); -} - -#[test] -fn test_format_channel_name_regular() { - let lookup = HashMap::new(); - assert_eq!(format_channel_name("general", &lookup), "#general"); -} - -#[test] -fn test_format_channel_name_mpdm() { - let lookup = HashMap::new(); - assert_eq!( - format_channel_name("mpdm-alice--bob--charlie-1", &lookup), - "@alice, @bob, @charlie" - ); -} - -#[test] -fn test_format_channel_name_user_id_with_lookup() { - let mut lookup = HashMap::new(); - lookup.insert("U04H482TK6Z".to_string(), "alice".to_string()); - assert_eq!(format_channel_name("U04H482TK6Z", &lookup), "@alice"); -} - -#[test] -fn test_format_channel_name_user_id_without_lookup() { - let lookup = HashMap::new(); - assert_eq!(format_channel_name("U04H482TK6Z", &lookup), "DM"); -} - -#[test] -fn test_format_timestamp_valid() { - // 2024-01-01 00:00:00 UTC - let result = format_timestamp("1704067200.123456"); - assert_eq!(result, "2024-01-01 00:00"); -} - -#[test] -fn test_format_timestamp_no_decimal() { - let result = format_timestamp("1704067200"); - assert_eq!(result, "2024-01-01 00:00"); -} - -#[test] -fn test_format_timestamp_invalid() { - let result = format_timestamp("invalid"); - assert_eq!(result, "invalid"); -} - -#[test] -fn test_output_channels_empty() { - // Just verify it doesn't panic - let channels: Vec = vec![]; - let result = output_channels(&channels, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_channels_json() { - let channels = vec![SlackChannel { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: false, - is_member: true, - topic: Some("General discussion".to_string()), - purpose: None, - num_members: Some(100), - created: 1704067200, - }]; - let result = output_channels(&channels, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn test_output_channel_detail_table() { - let channel = SlackChannel { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: true, - is_member: false, - topic: Some("Topic".to_string()), - purpose: Some("Purpose".to_string()), - num_members: Some(50), - created: 1704067200, - }; - let result = output_channel_detail(&channel, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_messages_empty() { - let messages: Vec = vec![]; - let result = output_messages(&messages, "general", OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_messages_json() { - let messages = vec![SlackMessage { - msg_type: "message".to_string(), - user: Some("U12345".to_string()), - text: "Hello world".to_string(), - ts: "1704067200.123456".to_string(), - thread_ts: None, - reply_count: Some(5), - username: Some("alice".to_string()), - }]; - let result = output_messages(&messages, "general", OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn test_output_users_empty() { - let users: Vec = vec![]; - let result = output_users(&users, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_users_json() { - let users = vec![SlackUser { - id: "U12345".to_string(), - team_id: Some("T12345".to_string()), - name: "alice".to_string(), - real_name: Some("Alice Smith".to_string()), - is_bot: false, - deleted: false, - tz: Some("America/New_York".to_string()), - }]; - let result = output_users(&users, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn test_output_search_results_empty() { - let results = SlackSearchResult { - total: 0, - matches: vec![], - }; - let lookup = HashMap::new(); - let result = output_search_results(&results, OutputFormat::Table, &lookup); - assert!(result.is_ok()); -} - -#[test] -fn test_output_search_results_json() { - use crate::slack::types::{SlackSearchChannel, SlackSearchMatch}; - let results = SlackSearchResult { - total: 1, - matches: vec![SlackSearchMatch { - channel: SlackSearchChannel { - id: "C12345".to_string(), - name: "general".to_string(), - }, - user: Some("U12345".to_string()), - username: Some("alice".to_string()), - text: "Hello world".to_string(), - ts: "1704067200.123456".to_string(), - permalink: Some("https://slack.com/...".to_string()), - }], - }; - let lookup = HashMap::new(); - let result = output_search_results(&results, OutputFormat::Json, &lookup); - assert!(result.is_ok()); -} - -#[test] -fn test_output_channels_table_with_data() { - let channels = vec![ - SlackChannel { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: false, - is_member: true, - topic: Some("General discussion".to_string()), - purpose: None, - num_members: Some(100), - created: 1704067200, - }, - SlackChannel { - id: "C67890".to_string(), - name: "private-team".to_string(), - is_private: true, - is_member: false, - topic: None, - purpose: None, - num_members: None, - created: 1704067200, - }, - ]; - let result = output_channels(&channels, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_channel_detail_json() { - let channel = SlackChannel { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: false, - is_member: true, - topic: None, - purpose: None, - num_members: None, - created: 1704067200, - }; - let result = output_channel_detail(&channel, OutputFormat::Json); - assert!(result.is_ok()); -} - -#[test] -fn test_output_channel_detail_table_public() { - // Tests the "public" branch (line 166) in table output - let channel = SlackChannel { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: false, // public channel - is_member: true, - topic: Some("General chat".to_string()), - purpose: Some("For general discussion".to_string()), - num_members: Some(50), - created: 1704067200, - }; - let result = output_channel_detail(&channel, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_messages_table_with_data() { - let messages = vec![ - SlackMessage { - msg_type: "message".to_string(), - user: Some("U12345".to_string()), - text: "Hello world".to_string(), - ts: "1704067200.123456".to_string(), - thread_ts: None, - reply_count: Some(5), - username: Some("alice".to_string()), - }, - SlackMessage { - msg_type: "message".to_string(), - user: None, - text: "Another message".to_string(), - ts: "1704067201.123456".to_string(), - thread_ts: None, - reply_count: None, - username: None, - }, - ]; - let result = output_messages(&messages, "general", OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_users_table_with_data() { - let users = vec![ - SlackUser { - id: "U12345".to_string(), - team_id: Some("T12345".to_string()), - name: "alice".to_string(), - real_name: Some("Alice Smith".to_string()), - is_bot: false, - deleted: false, - tz: Some("America/New_York".to_string()), - }, - SlackUser { - id: "U67890".to_string(), - team_id: None, - name: "bob".to_string(), - real_name: None, - is_bot: true, - deleted: false, - tz: None, - }, - ]; - let result = output_users(&users, OutputFormat::Table); - assert!(result.is_ok()); -} - -#[test] -fn test_output_search_results_table_with_data() { - use crate::slack::types::{SlackSearchChannel, SlackSearchMatch}; - let results = SlackSearchResult { - total: 100, - matches: vec![ - SlackSearchMatch { - channel: SlackSearchChannel { - id: "C12345".to_string(), - name: "general".to_string(), - }, - user: Some("U12345".to_string()), - username: Some("alice".to_string()), - text: "Hello world".to_string(), - ts: "1704067200.123456".to_string(), - permalink: Some("https://slack.com/...".to_string()), - }, - SlackSearchMatch { - channel: SlackSearchChannel { - id: "C67890".to_string(), - name: "mpdm-alice--bob-1".to_string(), - }, - user: None, - username: None, - text: "<@U12345|Alice> mentioned <#C99999|dev>".to_string(), - ts: "1704067201.123456".to_string(), - permalink: None, - }, - ], - }; - let mut lookup = HashMap::new(); - lookup.insert("U12345".to_string(), "alice".to_string()); - let result = output_search_results(&results, OutputFormat::Table, &lookup); - assert!(result.is_ok()); -} - -#[test] -fn test_output_config_status_all_configured() { - output_config_status(true, true, Some("Acme Corp"), "#general"); -} - -#[test] -fn test_output_config_status_not_configured() { - output_config_status(false, false, None, ""); -} - -#[test] -fn test_output_config_status_partial() { - output_config_status(true, false, Some("My Team"), ""); -} - -#[test] -fn test_output_config_path() { - use std::path::PathBuf; - let path = PathBuf::from("/home/user/.config/hu/settings.toml"); - // Should not panic - output_config_path(&path); -} - -#[test] -fn test_output_auth_result_user_token() { - let result = AuthResult::UserTokenSaved; - // Should not panic - output_auth_result(&result); -} - -#[test] -fn test_output_auth_result_bot_token() { - let result = AuthResult::BotTokenSaved { - team_name: "Acme Corp".to_string(), - }; - output_auth_result(&result); -} - -#[test] -fn test_output_auth_result_oauth_with_team() { - let result = AuthResult::OAuthCompleted { - team_name: Some("Acme Corp".to_string()), - }; - output_auth_result(&result); -} - -#[test] -fn test_output_auth_result_oauth_without_team() { - let result = AuthResult::OAuthCompleted { team_name: None }; - output_auth_result(&result); -} - -#[test] -fn test_output_whoami() { - let info = AuthInfo { - user_id: "U04H482TK6Z".to_string(), - user: "alice".to_string(), - team_id: "T12345".to_string(), - team: "Acme Corp".to_string(), - }; - // Should not panic - output_whoami(&info); -} - -#[test] -fn test_output_send_confirmation() { - output_send_confirmation("#general", "1704067200.123456"); -} - -#[test] -fn test_output_tidy_dry_run() { - output_tidy_dry_run(); -} - -#[test] -fn test_output_tidy_results_empty() { - let results: Vec = vec![]; - output_tidy_results(&results); -} - -#[test] -fn test_output_tidy_results_mixed() { - let results = vec![ - tidy::TidyResult { - channel_name: "general".to_string(), - action: tidy::TidyAction::MarkedRead, - }, - tidy::TidyResult { - channel_name: "random".to_string(), - action: tidy::TidyAction::Skipped, - }, - tidy::TidyResult { - channel_name: "dev".to_string(), - action: tidy::TidyAction::HasMention("@alice mentioned you".to_string()), - }, - ]; - output_tidy_results(&results); -} - -#[test] -fn test_output_tidy_summary() { - let summary = TidySummary { - marked_read: 5, - has_mentions: 2, - already_read: 10, - }; - output_tidy_summary(&summary); -} - -#[test] -fn test_output_tidy_summary_zeros() { - let summary = TidySummary { - marked_read: 0, - has_mentions: 0, - already_read: 0, - }; - output_tidy_summary(&summary); -} diff --git a/src/slack/handlers.rs b/src/slack/handlers.rs deleted file mode 100644 index 70c6a0a..0000000 --- a/src/slack/handlers.rs +++ /dev/null @@ -1,191 +0,0 @@ -use anyhow::Result; - -use super::client::SlackClient; -use super::display; -use super::service; -use super::types::OutputFormat; -use super::SlackCommands; - -/// Run a Slack command (CLI entry point - formats and prints) -#[cfg(not(tarpaulin_include))] -pub async fn run(command: SlackCommands) -> Result<()> { - match command { - SlackCommands::Auth { - token, - user_token, - port, - } => cmd_auth(token.as_deref(), user_token.as_deref(), port).await, - SlackCommands::Channels { json } => cmd_channels(json).await, - SlackCommands::Info { channel, json } => cmd_info(&channel, json).await, - SlackCommands::Send { channel, message } => cmd_send(&channel, &message).await, - SlackCommands::History { - channel, - limit, - json, - } => cmd_history(&channel, limit, json).await, - SlackCommands::Search { query, count, json } => cmd_search(&query, count, json).await, - SlackCommands::Users { json } => cmd_users(json).await, - SlackCommands::Config => cmd_config(), - SlackCommands::Whoami => cmd_whoami().await, - SlackCommands::Tidy { dry_run } => cmd_tidy(dry_run).await, - } -} - -/// Authenticate with Slack via OAuth or direct token -#[cfg(not(tarpaulin_include))] -async fn cmd_auth(token: Option<&str>, user_token: Option<&str>, port: u16) -> Result<()> { - let result = service::authenticate(token, user_token, port).await?; - display::output_auth_result(&result); - Ok(()) -} - -/// List channels -#[cfg(not(tarpaulin_include))] -async fn cmd_channels(json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = SlackClient::new()?; - let channels = service::list_channels(&client).await?; - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - display::output_channels(&channels, format)?; - Ok(()) -} - -/// Get channel info -#[cfg(not(tarpaulin_include))] -async fn cmd_info(channel: &str, json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = SlackClient::new()?; - let info = service::get_channel_info(&client, channel).await?; - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - display::output_channel_detail(&info, format)?; - Ok(()) -} - -/// Send a message -#[cfg(not(tarpaulin_include))] -async fn cmd_send(channel: &str, text: &str) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = SlackClient::new()?; - let (sent_channel, ts) = service::send_message(&client, channel, text).await?; - - display::output_send_confirmation(&sent_channel, &ts); - Ok(()) -} - -/// Get message history -#[cfg(not(tarpaulin_include))] -async fn cmd_history(channel: &str, limit: usize, json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = SlackClient::new()?; - let messages = service::get_history(&client, channel, limit).await?; - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - let channel_name = channel.trim_start_matches('#'); - display::output_messages(&messages, channel_name, format)?; - Ok(()) -} - -/// Search messages -#[cfg(not(tarpaulin_include))] -async fn cmd_search(query: &str, count: usize, json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = SlackClient::new()?; - let results = service::search_messages(&client, query, count).await?; - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - let user_lookup = service::build_user_lookup(&client).await?; - display::output_search_results(&results, format, &user_lookup)?; - Ok(()) -} - -/// List users -#[cfg(not(tarpaulin_include))] -async fn cmd_users(json: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - - let client = SlackClient::new()?; - let users = service::list_users(&client).await?; - let format = if json { - OutputFormat::Json - } else { - OutputFormat::Table - }; - - display::output_users(&users, format)?; - Ok(()) -} - -/// Show configuration status -#[cfg(not(tarpaulin_include))] -fn cmd_config() -> Result<()> { - let config = service::get_config()?; - - display::output_config_status( - config.is_configured, - config.oauth.has_user_token(), - config.oauth.team_name.as_deref(), - &config.default_channel, - ); - - if let Some(path) = service::config_path() { - display::output_config_path(&path); - } - - Ok(()) -} - -/// Show current user info from token -#[cfg(not(tarpaulin_include))] -async fn cmd_whoami() -> Result<()> { - let config = service::get_config()?; - let info = service::whoami(&config).await?; - display::output_whoami(&info); - Ok(()) -} - -/// Tidy channels - mark as read if no mentions -#[cfg(not(tarpaulin_include))] -async fn cmd_tidy(dry_run: bool) -> Result<()> { - let config = service::get_config()?; - service::ensure_user_token(&config)?; - - if dry_run { - display::output_tidy_dry_run(); - } - - let client = SlackClient::new()?; - let (results, summary) = service::run_tidy(&client, &config, dry_run).await?; - - display::output_tidy_results(&results); - display::output_tidy_summary(&summary); - Ok(()) -} diff --git a/src/slack/messages.rs b/src/slack/messages.rs deleted file mode 100644 index 97fe7b7..0000000 --- a/src/slack/messages.rs +++ /dev/null @@ -1,185 +0,0 @@ -//! Slack message operations -//! -//! Send messages and retrieve message history. - -use anyhow::Result; -use serde::Deserialize; - -use super::client::SlackApi; -use super::types::SlackMessage; - -/// Response from conversations.history API -#[derive(Deserialize)] -struct HistoryResponse { - messages: Vec, -} - -/// Response from chat.postMessage API -#[derive(Deserialize)] -struct PostMessageResponse { - ts: String, - channel: String, -} - -/// Raw message data from API -#[derive(Deserialize)] -struct MessageResponse { - #[serde(rename = "type")] - msg_type: Option, - user: Option, - text: Option, - ts: String, - thread_ts: Option, - reply_count: Option, -} - -impl From for SlackMessage { - fn from(r: MessageResponse) -> Self { - Self { - msg_type: r.msg_type.unwrap_or_else(|| "message".to_string()), - user: r.user, - text: r.text.unwrap_or_default(), - ts: r.ts, - thread_ts: r.thread_ts, - reply_count: r.reply_count, - username: None, - } - } -} - -/// Get message history for a channel -#[cfg(not(tarpaulin_include))] -pub async fn get_history( - client: &impl SlackApi, - channel_id: &str, - limit: usize, -) -> Result> { - let limit_str = limit.to_string(); - let response: HistoryResponse = client - .get_with_params( - "conversations.history", - &[("channel", channel_id), ("limit", &limit_str)], - ) - .await?; - - let messages: Vec = response - .messages - .into_iter() - .map(SlackMessage::from) - .collect(); - - Ok(messages) -} - -/// Send a message to a channel -#[cfg(not(tarpaulin_include))] -pub async fn send_message( - client: &impl SlackApi, - channel_id: &str, - text: &str, -) -> Result<(String, String), anyhow::Error> { - let body = serde_json::json!({ - "channel": channel_id, - "text": text, - }); - - let response: PostMessageResponse = client.post("chat.postMessage", &body).await?; - - Ok((response.channel, response.ts)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_message_response_to_slack_message_full() { - let response = MessageResponse { - msg_type: Some("message".to_string()), - user: Some("U12345".to_string()), - text: Some("Hello world".to_string()), - ts: "1704067200.123456".to_string(), - thread_ts: Some("1704067100.000000".to_string()), - reply_count: Some(5), - }; - - let message = SlackMessage::from(response); - assert_eq!(message.msg_type, "message"); - assert_eq!(message.user, Some("U12345".to_string())); - assert_eq!(message.text, "Hello world"); - assert_eq!(message.ts, "1704067200.123456"); - assert_eq!(message.thread_ts, Some("1704067100.000000".to_string())); - assert_eq!(message.reply_count, Some(5)); - assert!(message.username.is_none()); - } - - #[test] - fn test_message_response_to_slack_message_minimal() { - let response = MessageResponse { - msg_type: None, - user: None, - text: None, - ts: "1704067200.123456".to_string(), - thread_ts: None, - reply_count: None, - }; - - let message = SlackMessage::from(response); - assert_eq!(message.msg_type, "message"); // default value - assert!(message.user.is_none()); - assert_eq!(message.text, ""); // default empty - assert_eq!(message.ts, "1704067200.123456"); - assert!(message.thread_ts.is_none()); - assert!(message.reply_count.is_none()); - } - - #[test] - fn test_history_response_deserialize() { - let json = r#"{ - "messages": [ - {"ts": "1704067200.123456", "text": "Hello", "user": "U12345"}, - {"ts": "1704067100.123456"} - ] - }"#; - - let response: HistoryResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.messages.len(), 2); - assert_eq!(response.messages[0].ts, "1704067200.123456"); - assert_eq!(response.messages[0].text, Some("Hello".to_string())); - } - - #[test] - fn test_post_message_response_deserialize() { - let json = r#"{"ts": "1704067200.123456", "channel": "C12345"}"#; - let response: PostMessageResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.ts, "1704067200.123456"); - assert_eq!(response.channel, "C12345"); - } - - #[test] - fn test_message_response_deserialize_with_type() { - let json = r#"{ - "type": "message", - "user": "U12345", - "text": "Test message", - "ts": "1704067200.123456", - "thread_ts": "1704067100.000000", - "reply_count": 10 - }"#; - - let response: MessageResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.msg_type, Some("message".to_string())); - assert_eq!(response.user, Some("U12345".to_string())); - assert_eq!(response.text, Some("Test message".to_string())); - assert_eq!(response.ts, "1704067200.123456"); - assert_eq!(response.thread_ts, Some("1704067100.000000".to_string())); - assert_eq!(response.reply_count, Some(10)); - } - - #[test] - fn test_message_response_deserialize_empty_messages() { - let json = r#"{"messages": []}"#; - let response: HistoryResponse = serde_json::from_str(json).unwrap(); - assert!(response.messages.is_empty()); - } -} diff --git a/src/slack/mod.rs b/src/slack/mod.rs deleted file mode 100644 index 6d55bb0..0000000 --- a/src/slack/mod.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Slack integration module -//! -//! Provides commands for interacting with Slack: -//! - Authenticate via OAuth browser flow -//! - List channels -//! - Get channel info -//! - Send messages -//! - View message history -//! - Search messages -//! - List users -//! - Show configuration status -//! -//! # CLI Usage -//! Use [`run`] for CLI commands that format and print output. -//! -//! # Programmatic Usage (MCP/HTTP) -//! Use the reusable functions that return typed data: -//! - [`get_config`] - Get configuration status -//! - [`list_channels`] - List all channels -//! - [`get_channel_info`] - Get channel details -//! - [`get_history`] - Get message history -//! - [`send_message`] - Send a message -//! - [`search_messages`] - Search messages -//! - [`list_users`] - List workspace users - -mod auth; -mod channels; -mod client; -mod config; -mod display; -mod handlers; -mod messages; -mod search; -mod service; -mod tidy; -mod types; - -use anyhow::Result; -use clap::Subcommand; - -#[allow(unused_imports)] -pub use client::SlackApi; -use client::SlackClient; -pub use config::SlackConfig; -pub use handlers::run; -pub use types::{SlackChannel, SlackMessage, SlackSearchResult, SlackUser}; - -/// Slack subcommands -#[derive(Subcommand, Debug)] -pub enum SlackCommands { - /// Authenticate with Slack (OAuth flow or direct token) - Auth { - /// Bot token to save directly (skips OAuth flow) - #[arg(short, long)] - token: Option, - /// User token for search API (xoxp-...) - #[arg(short, long)] - user_token: Option, - /// Local server port for OAuth callback - #[arg(short, long, default_value = "9877")] - port: u16, - }, - /// List channels in the workspace - Channels { - /// Output as JSON - #[arg(short, long)] - json: bool, - }, - /// Show channel details - Info { - /// Channel name or ID (e.g., "#general" or "C12345678") - channel: String, - /// Output as JSON - #[arg(short, long)] - json: bool, - }, - /// Send a message to a channel - Send { - /// Channel name or ID - channel: String, - /// Message text - message: String, - }, - /// Show message history for a channel - History { - /// Channel name or ID - channel: String, - /// Number of messages to show - #[arg(short, long, default_value = "20")] - limit: usize, - /// Output as JSON - #[arg(short, long)] - json: bool, - }, - /// Search messages - Search { - /// Search query - query: String, - /// Maximum results to return - #[arg(short = 'n', long, default_value = "20")] - count: usize, - /// Output as JSON - #[arg(short, long)] - json: bool, - }, - /// List users in the workspace - Users { - /// Output as JSON - #[arg(short, long)] - json: bool, - }, - /// Show Slack configuration status - Config, - /// Show current user info from token - Whoami, - /// Mark channels as read if no direct mentions - Tidy { - /// Dry run - show what would be marked without marking - #[arg(short, long)] - dry_run: bool, - }, -} - -// ============================================================================ -// Reusable functions for MCP/HTTP - return typed data, never print -// ============================================================================ - -/// Get Slack configuration status (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub fn get_config() -> Result { - service::get_config() -} - -/// List all channels (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn list_channels() -> Result> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = SlackClient::new()?; - service::list_channels(&client).await -} - -/// Get channel info by name or ID (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn get_channel_info(channel: &str) -> Result { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = SlackClient::new()?; - service::get_channel_info(&client, channel).await -} - -/// Get message history for a channel (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn get_history(channel: &str, limit: usize) -> Result> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = SlackClient::new()?; - service::get_history(&client, channel, limit).await -} - -/// Send a message to a channel (for MCP/HTTP) -/// Returns (channel_id, timestamp) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn send_message(channel: &str, text: &str) -> Result<(String, String)> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = SlackClient::new()?; - service::send_message(&client, channel, text).await -} - -/// Search messages (for MCP/HTTP) - requires user token -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn search_messages(query: &str, count: usize) -> Result { - let config = service::get_config()?; - service::ensure_configured(&config)?; - service::ensure_user_token(&config)?; - let client = SlackClient::new()?; - service::search_messages(&client, query, count).await -} - -/// List users in the workspace (for MCP/HTTP) -#[allow(dead_code)] -#[cfg(not(tarpaulin_include))] -pub async fn list_users() -> Result> { - let config = service::get_config()?; - service::ensure_configured(&config)?; - let client = SlackClient::new()?; - service::list_users(&client).await -} - -#[cfg(test)] -mod tests; diff --git a/src/slack/search.rs b/src/slack/search.rs deleted file mode 100644 index e305286..0000000 --- a/src/slack/search.rs +++ /dev/null @@ -1,191 +0,0 @@ -//! Slack message search -//! -//! Search messages across channels. - -use anyhow::Result; -use serde::Deserialize; - -use super::client::SlackApi; -use super::types::{SlackSearchChannel, SlackSearchMatch, SlackSearchResult}; - -/// Response from search.messages API -#[derive(Deserialize)] -struct SearchResponse { - messages: MessagesContainer, -} - -/// Container for search matches -#[derive(Deserialize)] -struct MessagesContainer { - total: u32, - matches: Vec, -} - -/// Raw match data from API -#[derive(Deserialize)] -struct MatchResponse { - channel: ChannelResponse, - user: Option, - username: Option, - text: String, - ts: String, - permalink: Option, -} - -/// Channel info in search response -#[derive(Deserialize)] -struct ChannelResponse { - id: String, - name: String, -} - -impl From for SlackSearchMatch { - fn from(r: MatchResponse) -> Self { - Self { - channel: SlackSearchChannel { - id: r.channel.id, - name: r.channel.name, - }, - user: r.user, - username: r.username, - text: r.text, - ts: r.ts, - permalink: r.permalink, - } - } -} - -/// Search messages across the workspace (requires user token) -#[cfg(not(tarpaulin_include))] -pub async fn search_messages( - client: &impl SlackApi, - query: &str, - count: usize, -) -> Result { - let count_str = count.to_string(); - let response: SearchResponse = client - .get_with_user_token( - "search.messages", - &[ - ("query", query), - ("count", &count_str), - ("sort", "timestamp"), - ("sort_dir", "desc"), - ], - ) - .await?; - - Ok(SlackSearchResult { - total: response.messages.total, - matches: response - .messages - .matches - .into_iter() - .map(SlackSearchMatch::from) - .collect(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_match_response_to_slack_search_match_full() { - let response = MatchResponse { - channel: ChannelResponse { - id: "C12345".to_string(), - name: "general".to_string(), - }, - user: Some("U12345".to_string()), - username: Some("alice".to_string()), - text: "Hello world".to_string(), - ts: "1704067200.123456".to_string(), - permalink: Some("https://slack.com/archives/C12345/p1704067200123456".to_string()), - }; - - let match_result = SlackSearchMatch::from(response); - assert_eq!(match_result.channel.id, "C12345"); - assert_eq!(match_result.channel.name, "general"); - assert_eq!(match_result.user, Some("U12345".to_string())); - assert_eq!(match_result.username, Some("alice".to_string())); - assert_eq!(match_result.text, "Hello world"); - assert_eq!(match_result.ts, "1704067200.123456"); - assert!(match_result.permalink.is_some()); - } - - #[test] - fn test_match_response_to_slack_search_match_minimal() { - let response = MatchResponse { - channel: ChannelResponse { - id: "C12345".to_string(), - name: "general".to_string(), - }, - user: None, - username: None, - text: "Message".to_string(), - ts: "1704067200.123456".to_string(), - permalink: None, - }; - - let match_result = SlackSearchMatch::from(response); - assert_eq!(match_result.channel.id, "C12345"); - assert!(match_result.user.is_none()); - assert!(match_result.username.is_none()); - assert!(match_result.permalink.is_none()); - } - - #[test] - fn test_search_response_deserialize() { - let json = r#"{ - "messages": { - "total": 42, - "matches": [ - { - "channel": {"id": "C12345", "name": "general"}, - "user": "U12345", - "username": "alice", - "text": "Hello", - "ts": "1704067200.123456", - "permalink": "https://slack.com/..." - } - ] - } - }"#; - - let response: SearchResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.messages.total, 42); - assert_eq!(response.messages.matches.len(), 1); - assert_eq!(response.messages.matches[0].text, "Hello"); - } - - #[test] - fn test_search_response_empty_matches() { - let json = r#"{ - "messages": { - "total": 0, - "matches": [] - } - }"#; - - let response: SearchResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.messages.total, 0); - assert!(response.messages.matches.is_empty()); - } - - #[test] - fn test_messages_container_deserialize() { - let json = r#"{"total": 100, "matches": []}"#; - let container: MessagesContainer = serde_json::from_str(json).unwrap(); - assert_eq!(container.total, 100); - assert!(container.matches.is_empty()); - } - - #[test] - fn test_channel_response_deserialize() { - let json = r#"{"id": "C12345", "name": "test-channel"}"#; - let channel: ChannelResponse = serde_json::from_str(json).unwrap(); - assert_eq!(channel.id, "C12345"); - assert_eq!(channel.name, "test-channel"); - } -} diff --git a/src/slack/service/mod.rs b/src/slack/service/mod.rs deleted file mode 100644 index 7e8cc90..0000000 --- a/src/slack/service/mod.rs +++ /dev/null @@ -1,273 +0,0 @@ -//! Slack service layer - business logic that returns data -//! -//! Functions in this module return typed data and never print. -//! They delegate to the existing submodule functions after config checks. - -use std::collections::HashMap; - -use anyhow::{bail, Result}; - -use super::auth; -use super::channels; -use super::client::SlackApi; -use super::config::{self, SlackConfig}; -use super::messages; -use super::search; -use super::tidy; -use super::types::{ - AuthInfo, AuthResult, SlackChannel, SlackMessage, SlackSearchResult, SlackUser, TidySummary, -}; - -#[cfg(test)] -mod tests; - -/// Get current configuration -#[cfg(not(tarpaulin_include))] -pub fn get_config() -> Result { - config::load_config() -} - -/// Check if API is configured, return error if not -pub fn ensure_configured(config: &SlackConfig) -> Result<()> { - if !config.is_configured { - bail!("Slack is not configured. Run `hu slack auth` to authenticate."); - } - Ok(()) -} - -/// Check if user token is configured, return error if not -pub fn ensure_user_token(config: &SlackConfig) -> Result<()> { - if !config.oauth.has_user_token() { - bail!("User token required. Run `hu slack auth --user-token `"); - } - Ok(()) -} - -/// List all channels -#[cfg(not(tarpaulin_include))] -pub async fn list_channels(client: &impl SlackApi) -> Result> { - channels::list_channels(client).await -} - -/// Get channel info by ID or name -#[cfg(not(tarpaulin_include))] -pub async fn get_channel_info(client: &impl SlackApi, channel: &str) -> Result { - let channel_id = channels::resolve_channel(client, channel).await?; - channels::get_channel_info(client, &channel_id).await -} - -/// Get message history for a channel -#[cfg(not(tarpaulin_include))] -pub async fn get_history( - client: &impl SlackApi, - channel: &str, - limit: usize, -) -> Result> { - let channel_id = channels::resolve_channel(client, channel).await?; - messages::get_history(client, &channel_id, limit).await -} - -/// Send a message to a channel -#[cfg(not(tarpaulin_include))] -pub async fn send_message( - client: &impl SlackApi, - channel: &str, - text: &str, -) -> Result<(String, String)> { - let channel_id = channels::resolve_channel(client, channel).await?; - messages::send_message(client, &channel_id, text).await -} - -/// Search messages (requires user token) -#[cfg(not(tarpaulin_include))] -pub async fn search_messages( - client: &impl SlackApi, - query: &str, - count: usize, -) -> Result { - search::search_messages(client, query, count).await -} - -/// List users -#[cfg(not(tarpaulin_include))] -pub async fn list_users(client: &impl SlackApi) -> Result> { - channels::list_users(client).await -} - -/// Build user lookup map for DM resolution -#[cfg(not(tarpaulin_include))] -pub async fn build_user_lookup(client: &impl SlackApi) -> Result> { - channels::build_user_lookup(client).await -} - -/// Parse an auth.test API response into structured `AuthInfo` -pub fn parse_auth_response(result: &serde_json::Value) -> AuthInfo { - AuthInfo { - user_id: result - .get("user_id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(), - user: result - .get("user") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(), - team_id: result - .get("team_id") - .and_then(|v| v.as_str()) - .unwrap_or("") - .to_string(), - team: result - .get("team") - .and_then(|v| v.as_str()) - .unwrap_or("Unknown") - .to_string(), - } -} - -/// Verify a token via auth.test API and return the raw response -#[cfg(not(tarpaulin_include))] -pub async fn verify_token(token: &str) -> Result { - let client = reqwest::Client::new(); - let response = client - .get("https://slack.com/api/auth.test") - .header("Authorization", format!("Bearer {}", token)) - .send() - .await?; - - let result: serde_json::Value = response.json().await?; - - if result.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { - let error = result - .get("error") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"); - bail!("Token validation failed: {}", error); - } - - Ok(result) -} - -/// Validate token format for bot tokens -pub fn validate_bot_token(token: &str) -> Result<()> { - if !token.starts_with("xoxb-") { - bail!("Invalid bot token format. Token should start with 'xoxb-'"); - } - Ok(()) -} - -/// Validate token format for user tokens -pub fn validate_user_token(token: &str) -> Result<()> { - if !token.starts_with("xoxp-") { - bail!("Invalid user token format. Token should start with 'xoxp-'"); - } - Ok(()) -} - -/// Authenticate with Slack -- handles bot token, user token, or OAuth flow -/// -/// Returns an `AuthResult` indicating what happened, without printing. -#[cfg(not(tarpaulin_include))] -pub async fn authenticate( - token: Option<&str>, - user_token: Option<&str>, - port: u16, -) -> Result { - if let Some(user_tok) = user_token { - validate_user_token(user_tok)?; - verify_token(user_tok).await?; - config::update_user_token(user_tok)?; - return Ok(AuthResult::UserTokenSaved); - } - - if let Some(bot_token) = token { - validate_bot_token(bot_token)?; - let result = verify_token(bot_token).await?; - let auth_info = parse_auth_response(&result); - config::update_oauth_tokens(bot_token, &auth_info.team_id, &auth_info.team)?; - return Ok(AuthResult::BotTokenSaved { - team_name: auth_info.team, - }); - } - - let result = auth::run_oauth_flow(port).await?; - - if result.success { - Ok(AuthResult::OAuthCompleted { - team_name: result.team_name, - }) - } else { - let error = result.error.unwrap_or_else(|| "Unknown error".to_string()); - bail!("Authentication failed: {}", error); - } -} - -/// Get current user info (whoami) by verifying the configured token -#[cfg(not(tarpaulin_include))] -pub async fn whoami(config: &SlackConfig) -> Result { - let token = config - .oauth - .user_token - .as_deref() - .or(config.oauth.bot_token.as_deref()) - .ok_or_else(|| anyhow::anyhow!("No token configured"))?; - - let result = verify_token(token).await?; - Ok(parse_auth_response(&result)) -} - -/// Run tidy operation and return structured results -#[cfg(not(tarpaulin_include))] -pub async fn run_tidy( - client: &impl SlackApi, - config: &SlackConfig, - dry_run: bool, -) -> Result<(Vec, TidySummary)> { - let token = config - .oauth - .user_token - .as_deref() - .ok_or_else(|| anyhow::anyhow!("User token required for tidy"))?; - - let result = verify_token(token).await?; - let auth_info = parse_auth_response(&result); - - let user_info = tidy::UserInfo { - user_id: auth_info.user_id, - name: auth_info.user, - full_name: auth_info.team.clone(), - }; - - let results = tidy::tidy_channels(client, &user_info, dry_run).await?; - let summary = compute_tidy_summary(&results); - - Ok((results, summary)) -} - -/// Compute summary counts from tidy results -pub fn compute_tidy_summary(results: &[tidy::TidyResult]) -> TidySummary { - let mut marked_read = 0; - let mut has_mentions = 0; - let mut already_read = 0; - - for r in results { - match &r.action { - tidy::TidyAction::Skipped => already_read += 1, - tidy::TidyAction::MarkedRead => marked_read += 1, - tidy::TidyAction::HasMention(_) => has_mentions += 1, - } - } - - TidySummary { - marked_read, - has_mentions, - already_read, - } -} - -/// Get config path for display purposes -#[must_use] -pub fn config_path() -> Option { - config::config_path() -} diff --git a/src/slack/service/tests.rs b/src/slack/service/tests.rs deleted file mode 100644 index 6a815fe..0000000 --- a/src/slack/service/tests.rs +++ /dev/null @@ -1,190 +0,0 @@ -use super::*; - -#[test] -fn ensure_configured_fails_when_not_configured() { - let config = SlackConfig { - oauth: config::OAuthConfig::default(), - default_channel: String::new(), - is_configured: false, - }; - let result = ensure_configured(&config); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not configured")); -} - -#[test] -fn ensure_configured_succeeds_when_configured() { - let config = SlackConfig { - oauth: config::OAuthConfig { - client_id: None, - client_secret: None, - bot_token: Some("xoxb-test".to_string()), - user_token: None, - team_id: Some("T123".to_string()), - team_name: Some("Test".to_string()), - }, - default_channel: String::new(), - is_configured: true, - }; - let result = ensure_configured(&config); - assert!(result.is_ok()); -} - -#[test] -fn ensure_user_token_fails_when_missing() { - let config = SlackConfig { - oauth: config::OAuthConfig { - client_id: None, - client_secret: None, - bot_token: Some("xoxb-test".to_string()), - user_token: None, - team_id: None, - team_name: None, - }, - default_channel: String::new(), - is_configured: true, - }; - let result = ensure_user_token(&config); - assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("User token required")); -} - -#[test] -fn ensure_user_token_succeeds_when_present() { - let config = SlackConfig { - oauth: config::OAuthConfig { - client_id: None, - client_secret: None, - bot_token: Some("xoxb-test".to_string()), - user_token: Some("xoxp-test".to_string()), - team_id: None, - team_name: None, - }, - default_channel: String::new(), - is_configured: true, - }; - let result = ensure_user_token(&config); - assert!(result.is_ok()); -} - -#[test] -fn parse_auth_response_with_all_fields() { - let json = serde_json::json!({ - "ok": true, - "user_id": "U12345", - "user": "alice", - "team_id": "T12345", - "team": "Acme Corp" - }); - let info = parse_auth_response(&json); - assert_eq!(info.user_id, "U12345"); - assert_eq!(info.user, "alice"); - assert_eq!(info.team_id, "T12345"); - assert_eq!(info.team, "Acme Corp"); -} - -#[test] -fn parse_auth_response_with_missing_fields() { - let json = serde_json::json!({"ok": true}); - let info = parse_auth_response(&json); - assert_eq!(info.user_id, "unknown"); - assert_eq!(info.user, "unknown"); - assert_eq!(info.team_id, ""); - assert_eq!(info.team, "Unknown"); -} - -#[test] -fn validate_bot_token_valid() { - assert!(validate_bot_token("xoxb-1234-5678").is_ok()); -} - -#[test] -fn validate_bot_token_invalid() { - let result = validate_bot_token("xoxp-wrong"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("xoxb-")); -} - -#[test] -fn validate_user_token_valid() { - assert!(validate_user_token("xoxp-1234-5678").is_ok()); -} - -#[test] -fn validate_user_token_invalid() { - let result = validate_user_token("xoxb-wrong"); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("xoxp-")); -} - -#[test] -fn compute_tidy_summary_empty() { - let results = vec![]; - let summary = compute_tidy_summary(&results); - assert_eq!(summary.marked_read, 0); - assert_eq!(summary.has_mentions, 0); - assert_eq!(summary.already_read, 0); -} - -#[test] -fn compute_tidy_summary_mixed_results() { - let results = vec![ - tidy::TidyResult { - channel_name: "general".to_string(), - action: tidy::TidyAction::MarkedRead, - }, - tidy::TidyResult { - channel_name: "random".to_string(), - action: tidy::TidyAction::MarkedRead, - }, - tidy::TidyResult { - channel_name: "dev".to_string(), - action: tidy::TidyAction::HasMention("@you".to_string()), - }, - tidy::TidyResult { - channel_name: "announcements".to_string(), - action: tidy::TidyAction::Skipped, - }, - tidy::TidyResult { - channel_name: "ops".to_string(), - action: tidy::TidyAction::Skipped, - }, - tidy::TidyResult { - channel_name: "team".to_string(), - action: tidy::TidyAction::Skipped, - }, - ]; - let summary = compute_tidy_summary(&results); - assert_eq!(summary.marked_read, 2); - assert_eq!(summary.has_mentions, 1); - assert_eq!(summary.already_read, 3); -} - -#[test] -fn compute_tidy_summary_all_marked() { - let results = vec![ - tidy::TidyResult { - channel_name: "a".to_string(), - action: tidy::TidyAction::MarkedRead, - }, - tidy::TidyResult { - channel_name: "b".to_string(), - action: tidy::TidyAction::MarkedRead, - }, - ]; - let summary = compute_tidy_summary(&results); - assert_eq!(summary.marked_read, 2); - assert_eq!(summary.has_mentions, 0); - assert_eq!(summary.already_read, 0); -} - -#[test] -fn config_path_returns_some() { - let path = config_path(); - assert!(path.is_some()); - let p = path.expect("should have a path"); - assert!(p.to_string_lossy().contains("settings.toml")); -} diff --git a/src/slack/tests.rs b/src/slack/tests.rs deleted file mode 100644 index d4eb943..0000000 --- a/src/slack/tests.rs +++ /dev/null @@ -1,135 +0,0 @@ -use super::*; -use config::{OAuthConfig, SlackConfig}; - -#[test] -fn test_ensure_configured_when_not_configured() { - let config = SlackConfig { - oauth: OAuthConfig::default(), - default_channel: String::new(), - is_configured: false, - }; - let result = service::ensure_configured(&config); - assert!(result.is_err()); - assert!(result.unwrap_err().to_string().contains("not configured")); -} - -#[test] -fn test_ensure_configured_when_configured() { - let config = SlackConfig { - oauth: OAuthConfig { - client_id: None, - client_secret: None, - bot_token: Some("xoxb-test".to_string()), - user_token: None, - team_id: Some("T123".to_string()), - team_name: Some("Test".to_string()), - }, - default_channel: String::new(), - is_configured: true, - }; - let result = service::ensure_configured(&config); - assert!(result.is_ok()); -} - -#[test] -fn test_slack_commands_debug() { - let cmd = SlackCommands::Channels { json: false }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Channels")); -} - -#[test] -fn test_slack_commands_auth_debug() { - let cmd = SlackCommands::Auth { - token: Some("xoxb-test".to_string()), - user_token: None, - port: 9877, - }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Auth")); - assert!(debug.contains("9877")); -} - -#[test] -fn test_slack_commands_info_debug() { - let cmd = SlackCommands::Info { - channel: "#general".to_string(), - json: true, - }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Info")); - assert!(debug.contains("#general")); -} - -#[test] -fn test_slack_commands_send_debug() { - let cmd = SlackCommands::Send { - channel: "#test".to_string(), - message: "Hello".to_string(), - }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Send")); - assert!(debug.contains("Hello")); -} - -#[test] -fn test_slack_commands_history_debug() { - let cmd = SlackCommands::History { - channel: "#dev".to_string(), - limit: 50, - json: false, - }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("History")); - assert!(debug.contains("50")); -} - -#[test] -fn test_slack_commands_search_debug() { - let cmd = SlackCommands::Search { - query: "deploy".to_string(), - count: 20, - json: true, - }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Search")); - assert!(debug.contains("deploy")); -} - -#[test] -fn test_slack_commands_users_debug() { - let cmd = SlackCommands::Users { json: false }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Users")); -} - -#[test] -fn test_slack_commands_config_debug() { - let cmd = SlackCommands::Config; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Config")); -} - -#[test] -fn test_slack_commands_whoami_debug() { - let cmd = SlackCommands::Whoami; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Whoami")); -} - -#[test] -fn test_slack_commands_tidy_debug() { - let cmd = SlackCommands::Tidy { dry_run: true }; - let debug = format!("{:?}", cmd); - assert!(debug.contains("Tidy")); - assert!(debug.contains("true")); -} - -#[test] -fn test_output_format_reexport() { - // Verify OutputFormat is accessible via types module - let format = types::OutputFormat::Table; - assert!(matches!(format, types::OutputFormat::Table)); - let format = types::OutputFormat::Json; - assert!(matches!(format, types::OutputFormat::Json)); -} diff --git a/src/slack/tidy/mod.rs b/src/slack/tidy/mod.rs deleted file mode 100644 index ad43933..0000000 --- a/src/slack/tidy/mod.rs +++ /dev/null @@ -1,313 +0,0 @@ -//! Slack tidy operations -//! -//! Mark channels as read if no direct mentions in unread messages. - -use anyhow::Result; -use serde::{Deserialize, Serialize}; -use std::time::Duration; -use tokio::time::sleep; - -use super::client::SlackApi; - -#[cfg(test)] -mod tests; - -/// User info for mention detection -pub struct UserInfo { - pub user_id: String, - pub name: String, - pub full_name: String, -} - -/// Channel with unread info -struct ChannelUnreadInfo { - last_read: String, - has_unreads: bool, -} - -/// Response from conversations.list with membership info -#[derive(Deserialize)] -struct ConversationsListResponse { - channels: Vec, - response_metadata: Option, -} - -#[derive(Deserialize)] -struct ChannelListItem { - id: String, - name: Option, - user: Option, // For DMs, contains the other user's ID - is_member: Option, - is_im: Option, -} - -#[derive(Deserialize)] -struct ResponseMetadata { - next_cursor: Option, -} - -/// Response from conversations.info -#[derive(Deserialize)] -struct ConversationsInfoResponse { - channel: ChannelInfoItem, -} - -#[derive(Deserialize)] -struct ChannelInfoItem { - last_read: Option, - latest: Option, -} - -#[derive(Deserialize)] -struct LatestMessage { - ts: String, -} - -/// Response from conversations.history -#[derive(Deserialize)] -struct HistoryResponse { - messages: Vec, -} - -#[derive(Deserialize)] -struct HistoryMessage { - ts: String, - text: Option, -} - -/// Request body for conversations.mark -#[derive(Serialize)] -struct MarkRequest { - channel: String, - ts: String, -} - -/// Empty response from conversations.mark -#[derive(Deserialize)] -struct MarkResponse {} - -/// Result of tidy operation for a single channel -#[derive(Debug)] -pub struct TidyResult { - pub channel_name: String, - pub action: TidyAction, -} - -#[derive(Debug)] -pub enum TidyAction { - Skipped, // No unreads - MarkedRead, // Marked as read (no mentions) - HasMention(String), // Has mention, not marked -} - -/// Run tidy operation on all channels -#[cfg(not(tarpaulin_include))] -pub async fn tidy_channels( - client: &impl SlackApi, - user_info: &UserInfo, - dry_run: bool, -) -> Result> { - let mut results = Vec::new(); - - // Get channels user is member of - let channels = list_member_channels(client).await?; - println!("Found {} channels you're a member of", channels.len()); - - for channel in channels { - let display_name = get_display_name(&channel); - - // Rate limit - sleep(Duration::from_millis(500)).await; - - // Get channel info with last_read - let info = get_channel_unread_info(client, &channel.id).await?; - - if !info.has_unreads { - results.push(TidyResult { - channel_name: display_name, - action: TidyAction::Skipped, - }); - continue; - } - - // Get unread messages - sleep(Duration::from_millis(500)).await; - let messages = get_messages_since(client, &channel.id, &info.last_read).await?; - - // Check for mentions - if let Some(mention) = find_mention(&messages, user_info) { - results.push(TidyResult { - channel_name: display_name, - action: TidyAction::HasMention(mention), - }); - continue; - } - - // No mentions - mark as read - if !dry_run { - if let Some(latest_ts) = messages.first().map(|m| m.ts.as_str()) { - sleep(Duration::from_millis(500)).await; - mark_channel_read(client, &channel.id, latest_ts).await?; - } - } - - results.push(TidyResult { - channel_name: display_name, - action: TidyAction::MarkedRead, - }); - } - - Ok(results) -} - -/// Get display name for a channel/DM -fn get_display_name(channel: &ChannelListItem) -> String { - if let Some(ref name) = channel.name { - name.clone() - } else if let Some(ref user_id) = channel.user { - // DM - show user ID (ideally we'd look up the name, but this works for now) - format!("DM:{}", user_id) - } else { - channel.id.clone() - } -} - -/// List channels where user is a member -#[cfg(not(tarpaulin_include))] -async fn list_member_channels(client: &impl SlackApi) -> Result> { - let mut all_channels = Vec::new(); - let mut cursor: Option = None; - let mut first = true; - - loop { - if !first { - sleep(Duration::from_millis(500)).await; - } - first = false; - - let mut params = vec![ - ("types", "public_channel,private_channel,mpim,im"), - ("exclude_archived", "true"), - ("limit", "200"), - ]; - - let cursor_str; - if let Some(ref c) = cursor { - cursor_str = c.clone(); - params.push(("cursor", &cursor_str)); - } - - let response: ConversationsListResponse = client - .get_with_user_token("conversations.list", ¶ms) - .await?; - - for ch in response.channels { - // DMs (is_im) don't have is_member field - user is implicitly a member - let is_member = ch.is_im.unwrap_or(false) || ch.is_member.unwrap_or(false); - if is_member { - all_channels.push(ch); - } - } - - match response.response_metadata.and_then(|m| m.next_cursor) { - Some(c) if !c.is_empty() => cursor = Some(c), - _ => break, - } - } - - Ok(all_channels) -} - -/// Get channel info to determine if there are unreads -#[cfg(not(tarpaulin_include))] -async fn get_channel_unread_info( - client: &impl SlackApi, - channel_id: &str, -) -> Result { - let response: ConversationsInfoResponse = client - .get_with_user_token("conversations.info", &[("channel", channel_id)]) - .await?; - - let last_read = response.channel.last_read.unwrap_or_default(); - let latest_ts = response.channel.latest.map(|l| l.ts).unwrap_or_default(); - - // Has unreads if latest message ts > last_read ts - let has_unreads = !last_read.is_empty() && !latest_ts.is_empty() && latest_ts > last_read; - - Ok(ChannelUnreadInfo { - last_read, - has_unreads, - }) -} - -/// Get messages since last_read timestamp -#[cfg(not(tarpaulin_include))] -async fn get_messages_since( - client: &impl SlackApi, - channel_id: &str, - oldest: &str, -) -> Result> { - let response: HistoryResponse = client - .get_with_user_token( - "conversations.history", - &[ - ("channel", channel_id), - ("oldest", oldest), - ("limit", "100"), - ], - ) - .await?; - - Ok(response.messages) -} - -/// Check if any message contains a mention of the user -fn find_mention(messages: &[HistoryMessage], user_info: &UserInfo) -> Option { - let user_mention = format!("<@{}>", user_info.user_id); - let name_lower = user_info.name.to_lowercase(); - let full_name_lower = user_info.full_name.to_lowercase(); - - for msg in messages { - if let Some(ref text) = msg.text { - // Check direct mention - if text.contains(&user_mention) { - return Some(format!("@mention: {}", truncate(text, 50))); - } - - // Check name (case-insensitive) - let text_lower = text.to_lowercase(); - if text_lower.contains(&name_lower) { - return Some(format!("name '{}': {}", user_info.name, truncate(text, 50))); - } - - // Check full name (case-insensitive) - if text_lower.contains(&full_name_lower) { - return Some(format!("full name: {}", truncate(text, 50))); - } - } - } - - None -} - -/// Mark a channel as read at the given timestamp -#[cfg(not(tarpaulin_include))] -async fn mark_channel_read(client: &impl SlackApi, channel_id: &str, ts: &str) -> Result<()> { - let body = MarkRequest { - channel: channel_id.to_string(), - ts: ts.to_string(), - }; - - let _: MarkResponse = client - .post_with_user_token("conversations.mark", &body) - .await?; - Ok(()) -} - -fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { - s.to_string() - } else { - format!("{}...", &s[..max.saturating_sub(3)]) - } -} diff --git a/src/slack/tidy/tests.rs b/src/slack/tidy/tests.rs deleted file mode 100644 index 3a7e784..0000000 --- a/src/slack/tidy/tests.rs +++ /dev/null @@ -1,312 +0,0 @@ -use super::*; - -#[test] -fn test_user_info_creation() { - let info = UserInfo { - user_id: "U12345".to_string(), - name: "Alice".to_string(), - full_name: "Alice Smith".to_string(), - }; - assert_eq!(info.user_id, "U12345"); - assert_eq!(info.name, "Alice"); - assert_eq!(info.full_name, "Alice Smith"); -} - -#[test] -fn test_tidy_result_debug() { - let result = TidyResult { - channel_name: "general".to_string(), - action: TidyAction::MarkedRead, - }; - let debug = format!("{:?}", result); - assert!(debug.contains("general")); - assert!(debug.contains("MarkedRead")); -} - -#[test] -fn test_tidy_action_skipped_debug() { - let action = TidyAction::Skipped; - assert_eq!(format!("{:?}", action), "Skipped"); -} - -#[test] -fn test_tidy_action_marked_read_debug() { - let action = TidyAction::MarkedRead; - assert_eq!(format!("{:?}", action), "MarkedRead"); -} - -#[test] -fn test_tidy_action_has_mention_debug() { - let action = TidyAction::HasMention("@alice mentioned you".to_string()); - let debug = format!("{:?}", action); - assert!(debug.contains("HasMention")); - assert!(debug.contains("@alice mentioned you")); -} - -#[test] -fn test_get_display_name_with_name() { - let channel = ChannelListItem { - id: "C12345".to_string(), - name: Some("general".to_string()), - user: None, - is_member: Some(true), - is_im: None, - }; - assert_eq!(get_display_name(&channel), "general"); -} - -#[test] -fn test_get_display_name_dm() { - let channel = ChannelListItem { - id: "D12345".to_string(), - name: None, - user: Some("U67890".to_string()), - is_member: None, - is_im: Some(true), - }; - assert_eq!(get_display_name(&channel), "DM:U67890"); -} - -#[test] -fn test_get_display_name_fallback_to_id() { - let channel = ChannelListItem { - id: "G12345".to_string(), - name: None, - user: None, - is_member: None, - is_im: None, - }; - assert_eq!(get_display_name(&channel), "G12345"); -} - -#[test] -fn test_truncate_short_string() { - assert_eq!(truncate("hello", 10), "hello"); -} - -#[test] -fn test_truncate_exact_length() { - assert_eq!(truncate("hello", 5), "hello"); -} - -#[test] -fn test_truncate_long_string() { - assert_eq!(truncate("hello world", 8), "hello..."); -} - -#[test] -fn test_truncate_very_short_max() { - assert_eq!(truncate("hello", 3), "..."); -} - -#[test] -fn test_find_mention_direct_user_mention() { - let messages = vec![HistoryMessage { - ts: "1704067200.123456".to_string(), - text: Some("Hey <@U12345> check this out".to_string()), - }]; - let user_info = UserInfo { - user_id: "U12345".to_string(), - name: "Alice".to_string(), - full_name: "Alice Smith".to_string(), - }; - - let result = find_mention(&messages, &user_info); - assert!(result.is_some()); - assert!(result.unwrap().contains("@mention")); -} - -#[test] -fn test_find_mention_name_match() { - let messages = vec![HistoryMessage { - ts: "1704067200.123456".to_string(), - text: Some("Hey Alice, how are you?".to_string()), - }]; - let user_info = UserInfo { - user_id: "U12345".to_string(), - name: "Alice".to_string(), - full_name: "Alice Smith".to_string(), - }; - - let result = find_mention(&messages, &user_info); - assert!(result.is_some()); - assert!(result.unwrap().contains("name 'Alice'")); -} - -#[test] -fn test_find_mention_full_name_match() { - let messages = vec![HistoryMessage { - ts: "1704067200.123456".to_string(), - text: Some("I talked to Alice Smith yesterday".to_string()), - }]; - let user_info = UserInfo { - user_id: "U12345".to_string(), - name: "Bob".to_string(), - full_name: "Alice Smith".to_string(), - }; - - let result = find_mention(&messages, &user_info); - assert!(result.is_some()); - assert!(result.unwrap().contains("full name")); -} - -#[test] -fn test_find_mention_case_insensitive() { - let messages = vec![HistoryMessage { - ts: "1704067200.123456".to_string(), - text: Some("ALICE is here".to_string()), - }]; - let user_info = UserInfo { - user_id: "U12345".to_string(), - name: "alice".to_string(), - full_name: "Alice Smith".to_string(), - }; - - let result = find_mention(&messages, &user_info); - assert!(result.is_some()); -} - -#[test] -fn test_find_mention_no_match() { - let messages = vec![HistoryMessage { - ts: "1704067200.123456".to_string(), - text: Some("Just a regular message".to_string()), - }]; - let user_info = UserInfo { - user_id: "U12345".to_string(), - name: "Alice".to_string(), - full_name: "Alice Smith".to_string(), - }; - - let result = find_mention(&messages, &user_info); - assert!(result.is_none()); -} - -#[test] -fn test_find_mention_empty_messages() { - let messages: Vec = vec![]; - let user_info = UserInfo { - user_id: "U12345".to_string(), - name: "Alice".to_string(), - full_name: "Alice Smith".to_string(), - }; - - let result = find_mention(&messages, &user_info); - assert!(result.is_none()); -} - -#[test] -fn test_find_mention_message_without_text() { - let messages = vec![HistoryMessage { - ts: "1704067200.123456".to_string(), - text: None, - }]; - let user_info = UserInfo { - user_id: "U12345".to_string(), - name: "Alice".to_string(), - full_name: "Alice Smith".to_string(), - }; - - let result = find_mention(&messages, &user_info); - assert!(result.is_none()); -} - -#[test] -fn test_conversations_list_response_deserialize() { - let json = r#"{ - "channels": [ - {"id": "C12345", "name": "general", "is_member": true}, - {"id": "D67890", "user": "U99999", "is_im": true} - ], - "response_metadata": {"next_cursor": "abc123"} - }"#; - - let response: ConversationsListResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.channels.len(), 2); - assert_eq!(response.channels[0].id, "C12345"); - assert_eq!(response.channels[1].user, Some("U99999".to_string())); -} - -#[test] -fn test_channel_list_item_deserialize() { - let json = r#"{"id": "C12345", "name": "test", "is_member": true, "is_im": false}"#; - let item: ChannelListItem = serde_json::from_str(json).unwrap(); - assert_eq!(item.id, "C12345"); - assert_eq!(item.name, Some("test".to_string())); - assert_eq!(item.is_member, Some(true)); - assert_eq!(item.is_im, Some(false)); -} - -#[test] -fn test_conversations_info_response_deserialize() { - let json = r#"{ - "channel": { - "last_read": "1704067200.000000", - "latest": {"ts": "1704067300.000000"} - } - }"#; - - let response: ConversationsInfoResponse = serde_json::from_str(json).unwrap(); - assert_eq!( - response.channel.last_read, - Some("1704067200.000000".to_string()) - ); - assert_eq!(response.channel.latest.unwrap().ts, "1704067300.000000"); -} - -#[test] -fn test_history_response_deserialize() { - let json = r#"{ - "messages": [ - {"ts": "1704067200.123456", "text": "Hello"}, - {"ts": "1704067100.123456"} - ] - }"#; - - let response: HistoryResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.messages.len(), 2); - assert_eq!(response.messages[0].ts, "1704067200.123456"); - assert_eq!(response.messages[0].text, Some("Hello".to_string())); -} - -#[test] -fn test_mark_request_serialize() { - let request = MarkRequest { - channel: "C12345".to_string(), - ts: "1704067200.123456".to_string(), - }; - - let json = serde_json::to_string(&request).unwrap(); - assert!(json.contains("C12345")); - assert!(json.contains("1704067200.123456")); -} - -#[test] -fn test_mark_response_deserialize() { - let json = r#"{}"#; - let response: MarkResponse = serde_json::from_str(json).unwrap(); - // Just verify it deserializes without error - let _ = response; -} - -#[test] -fn test_response_metadata_deserialize() { - let json = r#"{"next_cursor": "cursor123"}"#; - let meta: ResponseMetadata = serde_json::from_str(json).unwrap(); - assert_eq!(meta.next_cursor, Some("cursor123".to_string())); -} - -#[test] -fn test_channel_info_item_deserialize() { - let json = r#"{"last_read": "1704067200.000000"}"#; - let item: ChannelInfoItem = serde_json::from_str(json).unwrap(); - assert_eq!(item.last_read, Some("1704067200.000000".to_string())); - assert!(item.latest.is_none()); -} - -#[test] -fn test_latest_message_deserialize() { - let json = r#"{"ts": "1704067200.123456"}"#; - let latest: LatestMessage = serde_json::from_str(json).unwrap(); - assert_eq!(latest.ts, "1704067200.123456"); -} diff --git a/src/slack/types.rs b/src/slack/types.rs deleted file mode 100644 index 1ad2076..0000000 --- a/src/slack/types.rs +++ /dev/null @@ -1,321 +0,0 @@ -//! Slack data types and structures - -use serde::{Deserialize, Serialize}; - -pub use crate::util::OutputFormat; - -/// Slack channel information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SlackChannel { - /// Channel ID (e.g., "C12345678") - pub id: String, - /// Channel name (without #) - pub name: String, - /// Whether this is a private channel - pub is_private: bool, - /// Whether the bot is a member of this channel - pub is_member: bool, - /// Channel topic - pub topic: Option, - /// Channel purpose - pub purpose: Option, - /// Number of members - pub num_members: Option, - /// Creation timestamp - pub created: i64, -} - -/// Slack message -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SlackMessage { - /// Message type (usually "message") - #[serde(rename = "type")] - pub msg_type: String, - /// User ID who sent the message - pub user: Option, - /// Message text - pub text: String, - /// Timestamp (unique ID for the message) - pub ts: String, - /// Thread timestamp (if this is a reply) - pub thread_ts: Option, - /// Number of replies in thread - pub reply_count: Option, - /// User display name (enriched after fetch) - #[serde(skip_deserializing)] - pub username: Option, -} - -/// Slack user information -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SlackUser { - /// User ID - pub id: String, - /// Team ID - pub team_id: Option, - /// Username (handle without @) - pub name: String, - /// Display name - pub real_name: Option, - /// Whether this is a bot - pub is_bot: bool, - /// Whether this user is deleted - pub deleted: bool, - /// User's timezone - pub tz: Option, -} - -/// Search result match -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SlackSearchMatch { - /// Channel where the message was posted - pub channel: SlackSearchChannel, - /// User ID who posted - pub user: Option, - /// Username who posted - pub username: Option, - /// Message text - pub text: String, - /// Timestamp - pub ts: String, - /// Permalink to the message - pub permalink: Option, -} - -/// Channel info in search results -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SlackSearchChannel { - /// Channel ID - pub id: String, - /// Channel name - pub name: String, -} - -/// Search results container -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct SlackSearchResult { - /// Total matches found - pub total: u32, - /// Matches returned - pub matches: Vec, -} - -/// Authenticated user info returned from auth.test -#[derive(Debug, Clone)] -pub struct AuthInfo { - /// User ID (e.g., "U04H482TK6Z") - pub user_id: String, - /// Username - pub user: String, - /// Team/workspace ID - pub team_id: String, - /// Team/workspace name - pub team: String, -} - -/// Result of an auth operation -#[derive(Debug, Clone)] -pub enum AuthResult { - /// Bot token was saved - BotTokenSaved { team_name: String }, - /// User token was saved - UserTokenSaved, - /// OAuth flow completed - OAuthCompleted { team_name: Option }, -} - -/// Summary of a tidy operation -#[derive(Debug, Clone)] -pub struct TidySummary { - /// Number of channels marked as read - pub marked_read: usize, - /// Number of channels with mentions (skipped) - pub has_mentions: usize, - /// Number of channels already read (skipped) - pub already_read: usize, -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_slack_channel_debug() { - let channel = SlackChannel { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: false, - is_member: true, - topic: Some("Test topic".to_string()), - purpose: None, - num_members: Some(100), - created: 1704067200, - }; - let debug = format!("{:?}", channel); - assert!(debug.contains("SlackChannel")); - assert!(debug.contains("general")); - } - - #[test] - fn test_slack_channel_clone() { - let channel = SlackChannel { - id: "C12345".to_string(), - name: "general".to_string(), - is_private: false, - is_member: true, - topic: None, - purpose: None, - num_members: None, - created: 1704067200, - }; - let cloned = channel.clone(); - assert_eq!(cloned.id, channel.id); - assert_eq!(cloned.name, channel.name); - } - - #[test] - fn test_slack_message_debug() { - let msg = SlackMessage { - msg_type: "message".to_string(), - user: Some("U12345".to_string()), - text: "Hello world".to_string(), - ts: "1704067200.123456".to_string(), - thread_ts: None, - reply_count: Some(5), - username: None, - }; - let debug = format!("{:?}", msg); - assert!(debug.contains("SlackMessage")); - } - - #[test] - fn test_slack_user_debug() { - let user = SlackUser { - id: "U12345".to_string(), - team_id: Some("T12345".to_string()), - name: "alice".to_string(), - real_name: Some("Alice Smith".to_string()), - is_bot: false, - deleted: false, - tz: Some("America/New_York".to_string()), - }; - let debug = format!("{:?}", user); - assert!(debug.contains("SlackUser")); - } - - #[test] - fn test_slack_search_result_debug() { - let result = SlackSearchResult { - total: 42, - matches: vec![], - }; - let debug = format!("{:?}", result); - assert!(debug.contains("SlackSearchResult")); - assert!(debug.contains("42")); - } - - #[test] - fn test_slack_search_match_debug() { - let m = SlackSearchMatch { - channel: SlackSearchChannel { - id: "C12345".to_string(), - name: "general".to_string(), - }, - user: Some("U12345".to_string()), - username: Some("alice".to_string()), - text: "Hello".to_string(), - ts: "1704067200.123456".to_string(), - permalink: Some("https://slack.com/...".to_string()), - }; - let debug = format!("{:?}", m); - assert!(debug.contains("SlackSearchMatch")); - } - - #[test] - fn test_slack_search_channel_clone() { - let channel = SlackSearchChannel { - id: "C12345".to_string(), - name: "general".to_string(), - }; - let cloned = channel.clone(); - assert_eq!(cloned.id, channel.id); - } - - #[test] - fn test_auth_info_debug() { - let info = AuthInfo { - user_id: "U12345".to_string(), - user: "alice".to_string(), - team_id: "T12345".to_string(), - team: "Acme Corp".to_string(), - }; - let debug = format!("{:?}", info); - assert!(debug.contains("AuthInfo")); - assert!(debug.contains("alice")); - } - - #[test] - fn test_auth_info_clone() { - let info = AuthInfo { - user_id: "U12345".to_string(), - user: "alice".to_string(), - team_id: "T12345".to_string(), - team: "Acme Corp".to_string(), - }; - let cloned = info.clone(); - assert_eq!(cloned.user_id, "U12345"); - assert_eq!(cloned.team, "Acme Corp"); - } - - #[test] - fn test_auth_result_debug() { - let result = AuthResult::BotTokenSaved { - team_name: "Acme".to_string(), - }; - let debug = format!("{:?}", result); - assert!(debug.contains("BotTokenSaved")); - - let result = AuthResult::UserTokenSaved; - let debug = format!("{:?}", result); - assert!(debug.contains("UserTokenSaved")); - - let result = AuthResult::OAuthCompleted { - team_name: Some("Team".to_string()), - }; - let debug = format!("{:?}", result); - assert!(debug.contains("OAuthCompleted")); - } - - #[test] - fn test_auth_result_clone() { - let result = AuthResult::BotTokenSaved { - team_name: "Acme".to_string(), - }; - let cloned = result.clone(); - assert!(matches!(cloned, AuthResult::BotTokenSaved { .. })); - } - - #[test] - fn test_tidy_summary_debug() { - let summary = TidySummary { - marked_read: 5, - has_mentions: 2, - already_read: 10, - }; - let debug = format!("{:?}", summary); - assert!(debug.contains("TidySummary")); - } - - #[test] - fn test_tidy_summary_clone() { - let summary = TidySummary { - marked_read: 5, - has_mentions: 2, - already_read: 10, - }; - let cloned = summary.clone(); - assert_eq!(cloned.marked_read, 5); - assert_eq!(cloned.has_mentions, 2); - assert_eq!(cloned.already_read, 10); - } -} diff --git a/src/util/config/mod.rs b/src/util/config/mod.rs index 73ea036..0f9b1c3 100644 --- a/src/util/config/mod.rs +++ b/src/util/config/mod.rs @@ -67,12 +67,14 @@ pub fn load_credentials_from(path: &PathBuf) -> Result { } /// Save credentials to config dir +#[allow(dead_code)] pub fn save_credentials(creds: &Credentials) -> Result<()> { let path = credentials_path()?; save_credentials_to(creds, &path) } /// Save credentials to a specific path (testable) +#[allow(dead_code)] pub fn save_credentials_to(creds: &Credentials, path: &PathBuf) -> Result<()> { if let Some(dir) = path.parent() { fs::create_dir_all(dir) diff --git a/src/util/mod.rs b/src/util/mod.rs index 6fd5fcf..6a0fe2b 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -2,15 +2,9 @@ mod config; mod output; pub mod shell; -pub use config::{ - load_credentials, save_credentials, BraveCredentials, GithubCredentials, JiraCredentials, -}; +pub use config::{load_credentials, BraveCredentials}; #[allow(unused_imports)] pub use config::{config_dir, Credentials}; -// These are used in tests -#[allow(unused_imports)] -pub use config::{load_credentials_from, save_credentials_to}; - pub use output::OutputFormat; diff --git a/tests/cli.rs b/tests/cli.rs index 14be678..1f70583 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -34,16 +34,16 @@ fn version_flag_shows_version() { #[test] fn subcommand_without_action_shows_help() { - // Test all subcommands show help when called without action let cases = [ - ("jira", "Jira operations"), - ("gh", "GitHub operations"), - ("slack", "Slack operations"), - ("pagerduty", "PagerDuty"), - ("sentry", "Sentry"), ("newrelic", "NewRelic"), - ("eks", "EKS pod access"), - ("pipeline", "CodePipeline status"), + ("utils", "Utility"), + ("context", "context"), + ("data", "Claude"), + ("docs", "Documentation"), + ("cron", "Cron"), + ("shell", "Shell"), + ("mcp", "MCP"), + ("setup", "bootstrap"), ]; for (cmd, expected) in cases { @@ -52,8 +52,9 @@ fn subcommand_without_action_shows_help() { let stdout = String::from_utf8_lossy(&output.stdout); assert!( stdout.contains(expected), - "{} help missing description", - cmd + "{} help missing expected text: {}", + cmd, + expected ); } } @@ -64,15 +65,8 @@ fn all_main_commands_in_help() { let stdout = String::from_utf8_lossy(&output.stdout); let commands = [ - "jira", - "gh", - "slack", - "pagerduty", - "sentry", - "newrelic", - "eks", - "pipeline", - "utils", + "newrelic", "utils", "context", "read", "data", "install", "docs", "cron", "shell", + "mcp", "setup", ]; for cmd in commands { assert!(stdout.contains(cmd), "help missing command: {}", cmd); @@ -81,14 +75,7 @@ fn all_main_commands_in_help() { #[test] fn command_aliases_work() { - // pd -> pagerduty (config doesn't need auth) - let output = hu() - .args(["pd", "config"]) - .output() - .expect("failed to execute"); - assert!(output.status.success()); - - // nr -> newrelic (incidents may fail without auth, just check alias works) + // nr -> newrelic let output = hu() .args(["nr", "--help"]) .output() @@ -105,71 +92,7 @@ fn invalid_command_fails() { assert!(!output.status.success(), "expected non-zero exit code"); } -// Test all subcommand executions for coverage - -#[test] -fn jira_tickets_runs() { - let output = hu() - .args(["jira", "tickets"]) - .output() - .expect("failed to execute"); - // May succeed (if authenticated) or fail (if not) - // Just verify the command runs without panic - let _ = output.status; -} - -#[test] -fn gh_prs_runs() { - let output = hu() - .args(["gh", "prs"]) - .output() - .expect("failed to execute"); - // May succeed (if authenticated) or fail (if not) - // Just verify the command runs without panic - let _ = output.status; -} - -#[test] -fn gh_login_help_shows_usage() { - let output = hu() - .args(["gh", "login", "--help"]) - .output() - .expect("failed to execute"); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("Authenticate")); -} - -#[test] -fn slack_messages_runs() { - let output = hu() - .args(["slack", "messages"]) - .output() - .expect("failed to execute"); - // May succeed or fail depending on auth state - // Just verify the command runs without panic - let _ = output.status; -} - -#[test] -fn pagerduty_config_runs() { - let output = hu() - .args(["pagerduty", "config"]) - .output() - .expect("failed to execute"); - assert!(output.status.success()); -} - -#[test] -fn sentry_issues_runs() { - let output = hu() - .args(["sentry", "issues"]) - .output() - .expect("failed to execute"); - // May succeed or fail depending on auth state - // Just verify the command runs without panic - let _ = output.status; -} +// NewRelic #[test] fn newrelic_incidents_runs() { @@ -177,88 +100,11 @@ fn newrelic_incidents_runs() { .args(["newrelic", "incidents"]) .output() .expect("failed to execute"); - // May succeed or fail depending on auth state - // Just verify the command runs without panic + // May succeed or fail depending on auth state — just verify no panic let _ = output.status; } -#[test] -fn eks_list_runs() { - let output = hu() - .args(["eks", "list"]) - .output() - .expect("failed to execute"); - // May succeed or fail depending on kubectl/k8s auth state - // Just verify the command runs without panic - let _ = output.status; -} - -#[test] -fn pipeline_list_runs() { - let output = hu() - .args(["pipeline", "list"]) - .output() - .expect("failed to execute"); - // May succeed or fail depending on AWS auth state - // Just verify the command runs without panic - let _ = output.status; -} - -// GitHub subcommand tests - -#[test] -fn gh_help_shows_subcommands() { - let output = hu() - .args(["gh", "--help"]) - .output() - .expect("failed to execute"); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("login")); - assert!(stdout.contains("prs")); - assert!(stdout.contains("failures")); -} - -#[test] -fn gh_failures_help() { - let output = hu() - .args(["gh", "failures", "--help"]) - .output() - .expect("failed to execute"); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("--pr")); - assert!(stdout.contains("--repo")); -} - -#[test] -fn gh_fix_help() { - let output = hu() - .args(["gh", "fix", "--help"]) - .output() - .expect("failed to execute"); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - assert!(stdout.contains("--pr")); - assert!(stdout.contains("--run")); - assert!(stdout.contains("--branch")); - assert!(stdout.contains("--json")); -} - -#[test] -fn gh_login_help_shows_optional_token() { - let output = hu() - .args(["gh", "login", "--help"]) - .output() - .expect("failed to execute"); - assert!(output.status.success()); - let stdout = String::from_utf8_lossy(&output.stdout); - // Token is optional (uses gh CLI token if not provided) - assert!(stdout.contains("--token")); - assert!(stdout.contains("gh CLI") || stdout.contains("device flow")); -} - -// Utils subcommand tests +// Utils #[test] fn utils_shows_help() {