diff --git a/Cargo.lock b/Cargo.lock index 4b44e7b..81ea877 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,12 +73,24 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "bitflags" version = "2.10.0" @@ -134,6 +146,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.42" @@ -237,6 +255,17 @@ dependencies = [ "winapi", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "either" version = "1.15.0" @@ -271,6 +300,75 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "pin-utils", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -294,6 +392,107 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "iana-time-zone" version = "0.1.64" @@ -318,6 +517,108 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.12.1" @@ -330,6 +631,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -376,6 +693,12 @@ version = "0.2.179" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5a2d376baa530d1238d133232d15e239abad80d05838b4b59354e5268af431f" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.14" @@ -400,6 +723,18 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "mio" version = "0.8.11" @@ -431,7 +766,7 @@ checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ "bitflags", "cfg-if", - "cfg_aliases", + "cfg_aliases 0.1.1", "libc", ] @@ -485,12 +820,42 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro2" version = "1.0.105" @@ -500,6 +865,61 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases 0.2.1", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases 0.2.1", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.43" @@ -509,6 +929,41 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "ratatui" version = "0.26.3" @@ -538,6 +993,58 @@ dependencies = [ "bitflags", ] +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ropey" version = "1.6.1" @@ -550,7 +1057,7 @@ dependencies = [ [[package]] name = "rote-mux" -version = "0.1.4" +version = "0.1.5" dependencies = [ "anyhow", "chrono", @@ -559,6 +1066,7 @@ dependencies = [ "indexmap", "nix", "ratatui", + "reqwest", "ropey", "serde", "serde_yaml", @@ -568,13 +1076,54 @@ dependencies = [ ] [[package]] -name = "rustversion" -version = "1.0.22" +name = "rustc-hash" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" [[package]] -name = "ryu" +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" @@ -615,6 +1164,31 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" @@ -671,12 +1245,28 @@ dependencies = [ "libc", ] +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + [[package]] name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + [[package]] name = "stability" version = "0.2.1" @@ -687,6 +1277,12 @@ dependencies = [ "syn", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "static_assertions" version = "1.1.0" @@ -727,6 +1323,12 @@ dependencies = [ "syn", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.113" @@ -738,6 +1340,71 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.49.0" @@ -749,6 +1416,7 @@ dependencies = [ "mio 1.1.1", "pin-project-lite", "signal-hook-registry", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -764,6 +1432,86 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "unicode-ident" version = "1.0.22" @@ -799,18 +1547,60 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.106" @@ -824,6 +1614,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.106" @@ -856,6 +1659,35 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12bed680863276c63889429bfd6cab3b99943659923822de1c8a39c49e4d722c" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "winapi" version = "0.3.9" @@ -943,7 +1775,25 @@ version = "0.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" dependencies = [ - "windows-targets", + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", ] [[package]] @@ -961,13 +1811,46 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -976,38 +1859,255 @@ version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/README.md b/README.md index 95216af..630f96b 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ Each task can have the following properties: - `require` (optional): List of tasks that must be started before this one - `autorestart` (optional): If true, automatically restart the task when it exits (default: false) - `timestamps` (optional): If true, show timestamps for log messages (default: false) +- `healthcheck` (optional): Healthcheck configuration for the task (see below) ### Actions: `run` vs `ensure` @@ -92,6 +93,38 @@ Each task can have the following properties: These are mutually exclusive - a task can only have one or the other. +### Healthchecks + +Tasks with a `run` action can optionally specify a healthcheck. When a healthcheck is configured, dependent tasks will wait for the healthcheck to pass before starting (similar to how `ensure` tasks block dependents until complete). + +```yaml +tasks: + postgres: + run: docker run --rm -p 5432:5432 postgres + healthcheck: + tool: is-port-open 5432 + interval: 1 + + api: + run: ./server + require: [postgres] # Won't start until postgres healthcheck passes +``` + +Healthcheck fields: +- `cmd`: A shell command to run. Healthcheck passes when it exits with code 0. +- `tool`: A built-in tool to run directly (without spawning a process). See below for available tools. +- `interval`: How often to run the healthcheck, in seconds (supports decimals like `0.5`). + +You must specify either `cmd` or `tool`, but not both. + +#### Built-in Healthcheck Tools + +- `is-port-open `: Check if a TCP port is open on localhost. +- `http-get `: Perform an HTTP GET request. If given a port number, hits `http://127.0.0.1:{port}/`. If given a full URL (starting with `http://` or `https://`), uses that URL directly. Passes if the server responds (any status code). +- `http-get-ok `: Same as `http-get`, but only passes if the server returns a 2xx status code. + +Using `tool` is equivalent to `cmd: "rote tool ..."` but more efficient since it doesn't spawn a new process for each healthcheck. + ### Example: Full-Stack Application ```yaml @@ -102,36 +135,42 @@ tasks: cwd: backend ensure: bash -c '[ -f .env ] || cp env_template .env' - # Install dependencies: + # Install dependencies frontend-install: cwd: frontend ensure: npm install - # Database + # Database with healthcheck - migrations wait until postgres is accepting connections postgres: run: docker run --rm -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres display: [stderr] + healthcheck: + tool: is-port-open 5432 + interval: 0.5 - # Run migrations after DB is ready + # Run migrations after DB is ready (healthcheck must pass first) migrate: - ensure: run-migrations.sh - require: [postgres, install] + ensure: ./run-migrations.sh + require: [postgres] - # Backend server + # Backend server with healthcheck - frontend waits until API is responding api: cwd: backend run: cargo run --bin api require: [migrate, init-config] + healthcheck: + tool: http-get 8080 + interval: 1 - # Frontend dev server + # Frontend dev server - starts after API is healthy web: cwd: frontend - run: npm run http-server - require: [install] + run: npm run dev + require: [frontend-install, api] # Development target dev: - require: [api, web] + require: [web] ``` ### Display Streams diff --git a/example.yaml b/example.yaml index f66564d..2dd157a 100644 --- a/example.yaml +++ b/example.yaml @@ -15,5 +15,21 @@ tasks: autorestart: true setup-task: ensure: true + healthcheck-demo: + run: bash -c 'echo "Starting service..."; sleep 2; echo "Service ready"; while true; do sleep 1; done' + healthcheck: + cmd: "true" + interval: 1 + healthcheck-tool-demo: + run: bash -c 'echo "Waiting..."; sleep 5; echo "Starting port listener..."; nc -l 12345; sleep 5; echo "Done"' + autorestart: true + healthcheck: + tool: is-port-open 12345 + interval: 0.5 + healthcheck-http-demo: + run: bash -c 'echo "Waiting for google.com to be reachable..."; sleep infinity' + healthcheck: + tool: http-get-ok https://www.google.com + interval: 1 ping-demo: require: [google-ping, cloudflare-ping, short-lived, auto-restarting] diff --git a/rote/Cargo.toml b/rote/Cargo.toml index 1067e30..b3bb42b 100644 --- a/rote/Cargo.toml +++ b/rote/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rote-mux" -version = "0.1.4" +version = "0.1.5" edition = "2024" description = "A terminal multiplexer for monitoring and managing multiple processes" license = "MIT" @@ -26,3 +26,4 @@ serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" shell-words = "1.1.1" unicode-width = "0.1" +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } diff --git a/rote/src/app.rs b/rote/src/app.rs index dd78e72..64159ea 100644 --- a/rote/src/app.rs +++ b/rote/src/app.rs @@ -13,14 +13,103 @@ const STATUS_CHECK_INTERVAL_MS: u64 = 250; const KEYBOARD_POLL_INTERVAL_MS: u64 = 250; use crate::{ - config::{Config, TaskAction}, + config::{Config, Healthcheck, HealthcheckMethod, HealthcheckTool, TaskAction}, panel::{MessageKind, Panel, PanelIndex, StatusPanel, StreamKind}, process::TaskInstance, render, signals::is_process_exited_by_pid, task_manager::{TaskManager, resolve_dependencies}, + tools, ui::{ProcessStatus, UiEvent}, }; + +/// Spawn a healthcheck task that periodically runs the healthcheck. +/// Returns the spawned task handle. +fn spawn_healthcheck( + task_name: String, + healthcheck: Healthcheck, + tx: tokio::sync::mpsc::Sender, + mut shutdown_rx: tokio::sync::broadcast::Receiver<()>, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(healthcheck.interval); + + loop { + tokio::select! { + _ = interval.tick() => { + let passed = match &healthcheck.method { + HealthcheckMethod::Cmd(cmd) => { + // Run the healthcheck command via shell, capturing output + let result = tokio::process::Command::new("sh") + .arg("-c") + .arg(cmd) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .await; + + match result { + Ok(output) => { + // Send stdout lines + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let _ = tx.send(UiEvent::HealthcheckLine { + task_name: task_name.clone(), + text: line.to_string(), + }).await; + } + // Send stderr lines + let stderr = String::from_utf8_lossy(&output.stderr); + for line in stderr.lines() { + let _ = tx.send(UiEvent::HealthcheckLine { + task_name: task_name.clone(), + text: line.to_string(), + }).await; + } + output.status.success() + } + Err(_) => false, + } + } + HealthcheckMethod::Tool(tool) => { + // Call the tool directly without spawning a process + run_healthcheck_tool(tool).await.is_ok() + } + }; + + if passed { + // Healthcheck passed! + let _ = tx + .send(UiEvent::HealthcheckPassed { + task_name: task_name.clone(), + }) + .await; + break; + } else { + // Healthcheck failed, notify and try again after interval + let _ = tx + .send(UiEvent::HealthcheckFailed { + task_name: task_name.clone(), + }) + .await; + } + } + _ = shutdown_rx.recv() => { + break; + } + } + } + }) +} + +/// Run a built-in healthcheck tool directly. +async fn run_healthcheck_tool(tool: &HealthcheckTool) -> anyhow::Result<()> { + match tool { + HealthcheckTool::IsPortOpen { port } => tools::is_port_open(*port).await, + HealthcheckTool::HttpGet { url } => tools::http_get(url).await, + HealthcheckTool::HttpGetOk { url } => tools::http_get_ok(url).await, + } +} fn format_timestamp(timestamps: bool) -> Option { if timestamps { Some( @@ -161,12 +250,19 @@ pub async fn run_with_input( .entry_indices .insert(task_name.clone(), usize::MAX); status_panel.update_dependencies(task_name.clone(), task_config.require.clone()); + // Initialize healthcheck_passed to Some(false) if task has a healthcheck + if task_config.healthcheck.is_some() { + status_panel.set_has_healthcheck(task_name); + } } } // Initialize process slots let mut procs: Vec> = (0..panels.len()).map(|_| None).collect(); + // Track healthcheck tasks by task name + let mut healthcheck_tasks: HashMap> = HashMap::new(); + // Task manager tracks pending tasks and completed Run tasks let mut task_manager = TaskManager::new(tasks_list.clone(), task_to_panel.clone()); @@ -209,6 +305,7 @@ pub async fn run_with_input( KeyCode::Char('t') => UiEvent::Stop, KeyCode::Char('o') => UiEvent::ToggleStdout, KeyCode::Char('e') => UiEvent::ToggleStderr, + KeyCode::Char('h') => UiEvent::ToggleHealthcheck, KeyCode::Char('s') => UiEvent::SwitchToStatus, KeyCode::Char(c @ '1'..='9') => { UiEvent::SwitchPanel(PanelIndex::new((c as u8 - b'1') as usize)) @@ -343,6 +440,11 @@ pub async fn run_with_input( let _ = proc.stderr_task.await; } + // Cancel any existing healthcheck for this task + if let Some(hc_task) = healthcheck_tasks.remove(&task_name) { + hc_task.abort(); + } + let p = &mut panels[*panel]; let was_following = p.follow; let timestamp = format_timestamp(p.timestamps); @@ -369,6 +471,20 @@ pub async fn run_with_input( procs[*panel] = Some(proc); status_panel .update_entry(task_name.clone(), ProcessStatus::Running); + + // Spawn healthcheck task if configured + if let Some(healthcheck) = &task_config.healthcheck { + // Reset healthcheck status to pending + status_panel.set_has_healthcheck(&task_name); + + let hc_task = spawn_healthcheck( + task_name.clone(), + healthcheck.clone(), + tx.clone(), + shutdown_tx.subscribe(), + ); + healthcheck_tasks.insert(task_name.clone(), hc_task); + } } Err(e) => { let timestamp = format_timestamp(panels[*panel].timestamps); @@ -411,6 +527,13 @@ pub async fn run_with_input( redraw = true; } + UiEvent::ToggleHealthcheck => { + let p = &mut panels[*active]; + p.show_healthcheck = !p.show_healthcheck; + toggle_stream_visibility(p, p.show_healthcheck); + redraw = true; + } + UiEvent::SwitchPanel(i) if *i < panels.len() => { active = i; showing_status = false; @@ -565,6 +688,13 @@ pub async fn run_with_input( } panels[*active].follow = was_following; + let task_name = panels[*active].task_name.clone(); + + // Cancel any existing healthcheck for this task + if let Some(hc_task) = healthcheck_tasks.remove(&task_name) { + hc_task.abort(); + } + let cwd = panels[*active].cwd.as_deref(); match TaskInstance::spawn( active, @@ -575,6 +705,22 @@ pub async fn run_with_input( ) { Ok(proc) => { procs[*active] = Some(proc); + + // Spawn healthcheck task if configured + if let Some(task_config) = config.tasks.get(&task_name) + && let Some(healthcheck) = &task_config.healthcheck + { + // Reset healthcheck status to pending + status_panel.set_has_healthcheck(&task_name); + + let hc_task = spawn_healthcheck( + task_name.clone(), + healthcheck.clone(), + tx.clone(), + shutdown_tx.subscribe(), + ); + healthcheck_tasks.insert(task_name, hc_task); + } } Err(e) => { let timestamp = format_timestamp(panels[*active].timestamps); @@ -695,6 +841,11 @@ pub async fn run_with_input( task.abort(); } + // Abort any remaining healthcheck tasks + for (_, task) in healthcheck_tasks.drain() { + task.abort(); + } + if enable_terminal { println!(); } @@ -723,6 +874,20 @@ pub async fn run_with_input( status_panel .update_entry(task_name.clone(), ProcessStatus::Running); started_any = true; + + // Spawn healthcheck task if configured + if let Some(task_config) = config.tasks.get(&task_name) + && let Some(healthcheck) = &task_config.healthcheck + { + status_panel.set_has_healthcheck(&task_name); + let hc_task = spawn_healthcheck( + task_name.clone(), + healthcheck.clone(), + tx.clone(), + shutdown_tx.subscribe(), + ); + healthcheck_tasks.insert(task_name.clone(), hc_task); + } } Err(e) => { let timestamp = format_timestamp(panels[*panel_idx].timestamps); @@ -742,6 +907,72 @@ pub async fn run_with_input( } } + UiEvent::HealthcheckPassed { task_name } => { + // Mark the task as healthy + task_manager.mark_healthy(&task_name); + status_panel.update_healthcheck_passed(&task_name); + + // Log the healthcheck success + if let Some(panel_idx) = task_manager.get_panel_index(&task_name) { + let timestamp = format_timestamp(panels[*panel_idx].timestamps); + panels[*panel_idx].messages.push( + MessageKind::Healthcheck, + "[healthcheck passed]", + timestamp.as_deref(), + ); + } + + // Always redraw - the status sidebar is always visible + redraw = true; + + // Remove the healthcheck task (it has completed) + healthcheck_tasks.remove(&task_name); + + // Try to start tasks that were waiting for this healthcheck + let _ = tx.send(UiEvent::StartNextTask).await; + } + + UiEvent::HealthcheckLine { task_name, text } => { + // Send healthcheck output to the panel + if let Some(panel_idx) = task_manager.get_panel_index(&task_name) { + let p = &mut panels[*panel_idx]; + let at_bottom = p.follow; + let timestamp = format_timestamp(p.timestamps); + p.messages + .push(MessageKind::Healthcheck, &text, timestamp.as_deref()); + + if at_bottom { + p.scroll = p.visible_len().saturating_sub(1); + } + + if panel_idx == active { + redraw = true; + } + } + } + + UiEvent::HealthcheckFailed { task_name } => { + // Log the healthcheck failure + if let Some(panel_idx) = task_manager.get_panel_index(&task_name) { + let p = &mut panels[*panel_idx]; + let at_bottom = p.follow; + let timestamp = format_timestamp(p.timestamps); + p.messages.push( + MessageKind::Healthcheck, + "[healthcheck failed, retrying...]", + timestamp.as_deref(), + ); + + if at_bottom { + p.scroll = p.visible_len().saturating_sub(1); + } + + if panel_idx == active { + redraw = true; + } + } + } + _ => {} } @@ -818,6 +1049,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -831,6 +1063,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -844,6 +1077,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); // Task without action should be excluded from panels @@ -856,6 +1090,7 @@ mod tests { require: vec!["first".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -952,6 +1187,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -976,6 +1212,7 @@ mod tests { require: vec!["dep1".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -987,6 +1224,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -1011,6 +1249,7 @@ mod tests { require: vec!["dep1".to_string(), "dep2".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1022,6 +1261,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1033,6 +1273,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -1060,6 +1301,7 @@ mod tests { require: vec!["dep1".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1071,6 +1313,7 @@ mod tests { require: vec!["dep2".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1082,6 +1325,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -1106,6 +1350,7 @@ mod tests { require: vec!["task2".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1117,6 +1362,7 @@ mod tests { require: vec!["task1".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -1164,6 +1410,7 @@ mod tests { require: vec!["nonexistent".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -1188,6 +1435,7 @@ mod tests { require: vec!["dep1".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1199,6 +1447,7 @@ mod tests { require: vec!["dep1".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1210,6 +1459,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -1238,6 +1488,7 @@ mod tests { require: vec!["dep1".to_string(), "dep2".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1249,6 +1500,7 @@ mod tests { require: vec!["base".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1260,6 +1512,7 @@ mod tests { require: vec!["base".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); tasks.insert( @@ -1271,6 +1524,7 @@ mod tests { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); diff --git a/rote/src/bin/rote.rs b/rote/src/bin/rote.rs index c78483d..6c3c082 100644 --- a/rote/src/bin/rote.rs +++ b/rote/src/bin/rote.rs @@ -1,15 +1,45 @@ use anyhow::Context as _; -use clap::Parser; +use clap::{Parser, Subcommand}; use std::fs; use std::path::PathBuf; +use std::time::Duration; use rote_mux::Config; const EXAMPLE_YAML: &str = include_str!("../../tests/data/example.yaml"); #[derive(Parser, Debug)] -#[command(author, version, about)] +#[command(author, version, about, args_conflicts_with_subcommands = true)] struct Args { + #[command(subcommand)] + command: Option, + + /// The path to the configuration file. If omitted will look for `rote.yaml` + /// in the current directory. + #[arg(short, long, value_name = "FILE")] + config: Option, + + /// The services to run. If omitted, the default service from the config + /// file will be run. If the default service is not specified in the config, + /// no services will be run. + #[arg(value_name = "SERVICE", required = false)] + services: Vec, + + /// Print an example configuration file to stdout and exit. + #[arg(long)] + generate_example: bool, +} + +#[derive(Subcommand, Debug)] +enum Command { + /// Run rote with a configuration file + Run(RunArgs), + /// Run utility tools + Tool(ToolArgs), +} + +#[derive(Parser, Debug)] +struct RunArgs { /// The path to the configuration file. If omitted will look for `rote.yaml` /// in the current directory. #[arg(short, long, value_name = "FILE")] @@ -24,6 +54,56 @@ struct Args { generate_example: bool, } +#[derive(Parser, Debug)] +struct ToolArgs { + /// Wait and retry until the tool succeeds (exits with code 0) + #[arg(long)] + wait: bool, + /// Interval between retries when --wait is specified (e.g., "1s", "500ms") + #[arg(long, default_value = "1s", value_parser = parse_duration)] + interval: Duration, + #[command(subcommand)] + tool: Tool, +} + +#[derive(Subcommand, Debug)] +enum Tool { + /// Check if a port is open on localhost + IsPortOpen { + /// The port number to check + port: u16, + }, + /// Make an HTTP GET request. Succeeds if the request completes (any status code). + /// Accepts either a port number (assumes http://127.0.0.1:{port}/) or a full http(s) URL. + HttpGet { + /// Port number or full http(s) URL + target: String, + }, + /// Make an HTTP GET request and check for success (2xx status). + /// Accepts either a port number (assumes http://127.0.0.1:{port}/) or a full http(s) URL. + HttpGetOk { + /// Port number or full http(s) URL + target: String, + }, +} + +fn parse_duration(s: &str) -> Result { + let s = s.trim(); + if let Some(ms) = s.strip_suffix("ms") { + ms.parse::() + .map(Duration::from_millis) + .map_err(|e| format!("invalid milliseconds: {e}")) + } else if let Some(secs) = s.strip_suffix('s') { + secs.parse::() + .map(Duration::from_secs) + .map_err(|e| format!("invalid seconds: {e}")) + } else { + s.parse::() + .map(Duration::from_secs) + .map_err(|_| "expected duration like '1s' or '500ms'".to_string()) + } +} + #[tokio::main] async fn main() { if let Err(e) = run().await { @@ -38,6 +118,22 @@ async fn main() { async fn run() -> anyhow::Result<()> { let args = Args::parse(); + match args.command { + Some(Command::Tool(tool_args)) => run_tool(tool_args).await, + Some(Command::Run(run_args)) => run_main(run_args).await, + None => { + // Default behavior: use top-level args (backwards compatible) + run_main(RunArgs { + config: args.config, + services: args.services, + generate_example: args.generate_example, + }) + .await + } + } +} + +async fn run_main(args: RunArgs) -> anyhow::Result<()> { if args.generate_example { println!("{EXAMPLE_YAML}"); return Ok(()); @@ -64,3 +160,48 @@ async fn run() -> anyhow::Result<()> { Ok(()) } + +async fn run_tool(args: ToolArgs) -> anyhow::Result<()> { + use rote_mux::tools; + + loop { + let result = match &args.tool { + Tool::IsPortOpen { port } => tools::is_port_open(*port).await, + Tool::HttpGet { target } => { + // If it starts with http:// or https://, treat as URL; otherwise treat as port + let url = if target.starts_with("http://") || target.starts_with("https://") { + target.clone() + } else { + let port: u16 = target + .parse() + .map_err(|_| anyhow::anyhow!("invalid port number or URL: {}", target))?; + format!("http://127.0.0.1:{port}/") + }; + tools::http_get(&url).await + } + Tool::HttpGetOk { target } => { + // If it starts with http:// or https://, treat as URL; otherwise treat as port + let url = if target.starts_with("http://") || target.starts_with("https://") { + target.clone() + } else { + let port: u16 = target + .parse() + .map_err(|_| anyhow::anyhow!("invalid port number or URL: {}", target))?; + format!("http://127.0.0.1:{port}/") + }; + tools::http_get_ok(&url).await + } + }; + + match result { + Ok(()) => return Ok(()), + Err(e) => { + if args.wait { + tokio::time::sleep(args.interval).await; + } else { + return Err(e); + } + } + } + } +} diff --git a/rote/src/config.rs b/rote/src/config.rs index 0cad99c..55b715f 100644 --- a/rote/src/config.rs +++ b/rote/src/config.rs @@ -1,6 +1,133 @@ use indexmap::IndexMap; use serde::Deserialize; use std::borrow::Cow; +use std::time::Duration; + +/// Represents a healthcheck method - either a shell command or a built-in tool. +#[derive(Debug, Clone, PartialEq)] +pub enum HealthcheckMethod { + /// A shell command to run (via sh -c) + Cmd(String), + /// A built-in tool to call directly (without spawning a process) + Tool(HealthcheckTool), +} + +/// Built-in healthcheck tools that can be called directly without spawning a process. +#[derive(Debug, Clone, PartialEq)] +pub enum HealthcheckTool { + /// Check if a port is open on localhost + IsPortOpen { port: u16 }, + /// Make an HTTP GET request. Succeeds if the request completes (any status code). + /// The URL can be a full http(s) URL or just a port number (assumes http://127.0.0.1:{port}/). + HttpGet { url: String }, + /// Make an HTTP GET request and check for success (2xx status). + /// The URL can be a full http(s) URL or just a port number (assumes http://127.0.0.1:{port}/). + HttpGetOk { url: String }, +} + +/// Healthcheck configuration for a task. +/// When specified, a task with `run` action is not considered healthy +/// until the healthcheck command exits with code 0. +#[derive(Debug, Clone, PartialEq)] +pub struct Healthcheck { + /// The method to use for the healthcheck (either cmd or tool). + pub method: HealthcheckMethod, + /// How often to run the healthcheck (in seconds). + pub interval: Duration, +} + +impl<'de> serde::Deserialize<'de> for Healthcheck { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + struct RawHealthcheck { + cmd: Option, + tool: Option, + #[serde(deserialize_with = "deserialize_duration_secs")] + interval: Duration, + } + + let raw = RawHealthcheck::deserialize(deserializer)?; + + let method = match (raw.cmd, raw.tool) { + (Some(cmd), None) => HealthcheckMethod::Cmd(cmd), + (None, Some(tool_str)) => { + let tool = parse_tool(&tool_str).map_err(serde::de::Error::custom)?; + HealthcheckMethod::Tool(tool) + } + (Some(_), Some(_)) => { + return Err(serde::de::Error::custom( + "healthcheck cannot have both 'cmd' and 'tool' specified", + )); + } + (None, None) => { + return Err(serde::de::Error::custom( + "healthcheck must have either 'cmd' or 'tool' specified", + )); + } + }; + + Ok(Healthcheck { + method, + interval: raw.interval, + }) + } +} + +/// Parse a tool string like "is-port-open 5432" into a HealthcheckTool. +fn parse_tool(s: &str) -> Result { + let parts: Vec<&str> = s.split_whitespace().collect(); + if parts.is_empty() { + return Err("empty tool specification".to_string()); + } + + match parts[0] { + "is-port-open" => { + if parts.len() != 2 { + return Err("is-port-open requires exactly one argument: port".to_string()); + } + let port: u16 = parts[1] + .parse() + .map_err(|_| format!("invalid port number: {}", parts[1]))?; + Ok(HealthcheckTool::IsPortOpen { port }) + } + "http-get" | "http-get-ok" => { + let tool_name = parts[0]; + if parts.len() != 2 { + return Err(format!( + "{} requires exactly one argument: port or URL", + tool_name + )); + } + let arg = parts[1]; + // If it starts with http:// or https://, treat as URL; otherwise treat as port + let url = if arg.starts_with("http://") || arg.starts_with("https://") { + arg.to_string() + } else { + let port: u16 = arg + .parse() + .map_err(|_| format!("invalid port number or URL: {}", arg))?; + format!("http://127.0.0.1:{port}/") + }; + if tool_name == "http-get-ok" { + Ok(HealthcheckTool::HttpGetOk { url }) + } else { + Ok(HealthcheckTool::HttpGet { url }) + } + } + _ => Err(format!("unknown tool: {}", parts[0])), + } +} + +fn deserialize_duration_secs<'de, D>(deserializer: D) -> Result +where + D: serde::Deserializer<'de>, +{ + let secs: f64 = Deserialize::deserialize(deserializer)?; + Ok(Duration::from_secs_f64(secs)) +} #[derive(Debug, Deserialize)] pub struct Config { @@ -32,6 +159,10 @@ pub struct TaskConfiguration { /// Whether to show timestamps for log messages. #[serde(default)] pub timestamps: bool, + /// Optional healthcheck configuration. When specified, dependents will + /// wait for this task's healthcheck to pass before starting. + #[serde(default)] + pub healthcheck: Option, } /// Represents the action to be performed for a task. @@ -310,4 +441,268 @@ tasks: assert_eq!(command.as_command(), Cow::Borrowed("false")); } } + + #[test] + fn test_healthcheck_parsing_cmd() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + cmd: "rote tool is-port-open 8080" + interval: 1 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task = &config.tasks["task"]; + assert!(task.healthcheck.is_some()); + let hc = task.healthcheck.as_ref().unwrap(); + assert_eq!( + hc.method, + HealthcheckMethod::Cmd("rote tool is-port-open 8080".to_string()) + ); + assert_eq!(hc.interval, std::time::Duration::from_secs(1)); + } + + #[test] + fn test_healthcheck_parsing_tool() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + tool: is-port-open 8080 + interval: 1 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task = &config.tasks["task"]; + assert!(task.healthcheck.is_some()); + let hc = task.healthcheck.as_ref().unwrap(); + assert_eq!( + hc.method, + HealthcheckMethod::Tool(HealthcheckTool::IsPortOpen { port: 8080 }) + ); + assert_eq!(hc.interval, std::time::Duration::from_secs(1)); + } + + #[test] + fn test_healthcheck_parsing_fractional_interval() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + cmd: curl http://localhost:8080/health + interval: 0.5 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task = &config.tasks["task"]; + let hc = task.healthcheck.as_ref().unwrap(); + assert_eq!(hc.interval, std::time::Duration::from_millis(500)); + } + + #[test] + fn test_healthcheck_optional() { + let yaml = r#" +default: task +tasks: + task: + run: ./server +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task = &config.tasks["task"]; + assert!(task.healthcheck.is_none()); + } + + #[test] + fn test_healthcheck_both_cmd_and_tool_error() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + cmd: "true" + tool: is-port-open 8080 + interval: 1 +"#; + let result: Result = serde_yaml::from_str(yaml); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("both")); + } + + #[test] + fn test_healthcheck_neither_cmd_nor_tool_error() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + interval: 1 +"#; + let result: Result = serde_yaml::from_str(yaml); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("either")); + } + + #[test] + fn test_healthcheck_invalid_tool() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + tool: unknown-tool 123 + interval: 1 +"#; + let result: Result = serde_yaml::from_str(yaml); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("unknown tool")); + } + + #[test] + fn test_healthcheck_tool_invalid_port() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + tool: is-port-open not-a-number + interval: 1 +"#; + let result: Result = serde_yaml::from_str(yaml); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("invalid port")); + } + + #[test] + fn test_healthcheck_tool_http_get_with_url() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + tool: http-get https://example.com/health + interval: 1 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task = &config.tasks["task"]; + let hc = task.healthcheck.as_ref().unwrap(); + assert_eq!( + hc.method, + HealthcheckMethod::Tool(HealthcheckTool::HttpGet { + url: "https://example.com/health".to_string() + }) + ); + } + + #[test] + fn test_healthcheck_tool_http_get_with_port() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + tool: http-get 8080 + interval: 1 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task = &config.tasks["task"]; + let hc = task.healthcheck.as_ref().unwrap(); + assert_eq!( + hc.method, + HealthcheckMethod::Tool(HealthcheckTool::HttpGet { + url: "http://127.0.0.1:8080/".to_string() + }) + ); + } + + #[test] + fn test_healthcheck_tool_http_get_ok_with_url() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + tool: http-get-ok http://localhost:3000/ready + interval: 0.5 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task = &config.tasks["task"]; + let hc = task.healthcheck.as_ref().unwrap(); + assert_eq!( + hc.method, + HealthcheckMethod::Tool(HealthcheckTool::HttpGetOk { + url: "http://localhost:3000/ready".to_string() + }) + ); + } + + #[test] + fn test_healthcheck_tool_http_get_ok_with_port() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + tool: http-get-ok 9000 + interval: 1 +"#; + let config: Config = serde_yaml::from_str(yaml).unwrap(); + let task = &config.tasks["task"]; + let hc = task.healthcheck.as_ref().unwrap(); + assert_eq!( + hc.method, + HealthcheckMethod::Tool(HealthcheckTool::HttpGetOk { + url: "http://127.0.0.1:9000/".to_string() + }) + ); + } + + #[test] + fn test_healthcheck_tool_http_get_invalid_arg() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + tool: http-get not-a-port-or-url + interval: 1 +"#; + let result: Result = serde_yaml::from_str(yaml); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("invalid port number or URL")); + } + + #[test] + fn test_healthcheck_tool_http_get_missing_arg() { + let yaml = r#" +default: task +tasks: + task: + run: ./server + healthcheck: + tool: http-get + interval: 1 +"#; + let result: Result = serde_yaml::from_str(yaml); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("requires exactly one argument")); + } } diff --git a/rote/src/lib.rs b/rote/src/lib.rs index 21b8ff9..8e0f954 100644 --- a/rote/src/lib.rs +++ b/rote/src/lib.rs @@ -6,6 +6,7 @@ pub mod process; pub mod render; pub mod signals; pub mod task_manager; +pub mod tools; pub mod ui; pub use app::{run, run_with_input}; diff --git a/rote/src/panel.rs b/rote/src/panel.rs index c2f9c3d..e20ba9f 100644 --- a/rote/src/panel.rs +++ b/rote/src/panel.rs @@ -46,6 +46,7 @@ pub enum MessageKind { Stdout, Stderr, Status, + Healthcheck, } pub struct MessageBuf { @@ -68,6 +69,7 @@ impl MessageBuf { MessageKind::Stdout => b'o', MessageKind::Stderr => b'e', MessageKind::Status => b's', + MessageKind::Healthcheck => b'h', }; let content = match timestamp { Some(ts) => format!("{ts} {line}"), @@ -89,6 +91,7 @@ impl MessageBuf { show_stdout: bool, show_stderr: bool, show_status: bool, + show_healthcheck: bool, ) -> Vec<(MessageKind, String)> { let mut result = Vec::new(); for line in self.rope.lines() { @@ -103,12 +106,14 @@ impl MessageBuf { 'o' => MessageKind::Stdout, 'e' => MessageKind::Stderr, 's' => MessageKind::Status, + 'h' => MessageKind::Healthcheck, _ => continue, }; let should_include = match kind { MessageKind::Stdout => show_stdout, MessageKind::Stderr => show_stderr, MessageKind::Status => show_status, + MessageKind::Healthcheck => show_healthcheck, }; if should_include { result.push((kind, content.trim_end_matches('\n').to_string())); @@ -177,6 +182,7 @@ pub struct Panel { pub show_stdout: bool, pub show_stderr: bool, pub show_status: bool, + pub show_healthcheck: bool, pub timestamps: bool, pub process_status: Option, } @@ -201,6 +207,7 @@ impl Panel { show_stdout, show_stderr, show_status: true, + show_healthcheck: true, timestamps, process_status: None, } @@ -213,14 +220,24 @@ impl Panel { pub fn visible_len(&self) -> usize { self.messages - .lines_filtered(self.show_stdout, self.show_stderr, self.show_status) + .lines_filtered( + self.show_stdout, + self.show_stderr, + self.show_status, + self.show_healthcheck, + ) .len() } /// Compute total visual lines when wrapped to the given width. pub fn total_visual_lines(&self, width: usize) -> usize { self.messages - .lines_filtered(self.show_stdout, self.show_stderr, self.show_status) + .lines_filtered( + self.show_stdout, + self.show_stderr, + self.show_status, + self.show_healthcheck, + ) .iter() .map(|(_, line)| wrap_line(line, width).len()) .sum() @@ -241,6 +258,8 @@ pub struct StatusEntry { pub exit_code: Option, pub action_type: Option, pub dependencies: Vec, + /// None = no healthcheck configured, Some(false) = pending, Some(true) = passed + pub healthcheck_passed: Option, } impl StatusPanel { @@ -260,6 +279,7 @@ impl StatusPanel { exit_code: None, action_type: None, dependencies: Vec::new(), + healthcheck_passed: None, }); self.entries.last_mut().unwrap() } @@ -293,6 +313,24 @@ impl StatusPanel { } } + pub fn set_has_healthcheck(&mut self, task_name: &str) { + if let Some(entry) = self.entries.iter_mut().find(|e| e.task_name == task_name) { + // Initialize to Some(false) meaning healthcheck configured but not yet passed + entry.healthcheck_passed = Some(false); + } + } + + pub fn update_healthcheck_passed(&mut self, task_name: &str) { + if let Some(entry) = self.entries.iter_mut().find(|e| e.task_name == task_name) { + entry.healthcheck_passed = Some(true); + } + } + + /// Get the status entry for a task by name. + pub fn get_entry(&self, task_name: &str) -> Option<&StatusEntry> { + self.entries.iter().find(|e| e.task_name == task_name) + } + pub fn get_health_status(&self) -> (usize, usize, bool) { let mut total = 0; let mut healthy = 0; @@ -314,7 +352,11 @@ impl StatusPanel { ( Some(crate::config::TaskAction::Run { .. }), crate::ui::ProcessStatus::Running, - ) => true, + ) => { + // If healthcheck is configured but not yet passed, not healthy + // If no healthcheck or healthcheck passed, healthy + entry.healthcheck_passed != Some(false) + } _ => false, }; @@ -377,15 +419,18 @@ mod tests { buf.push(MessageKind::Stdout, "stdout line", None); buf.push(MessageKind::Stderr, "stderr line", None); buf.push(MessageKind::Status, "status line", None); + buf.push(MessageKind::Healthcheck, "healthcheck line", None); - let lines = buf.lines_filtered(true, true, true); - assert_eq!(lines.len(), 3); + let lines = buf.lines_filtered(true, true, true, true); + assert_eq!(lines.len(), 4); assert_eq!(lines[0].0, MessageKind::Stdout); assert_eq!(lines[0].1, "stdout line"); assert_eq!(lines[1].0, MessageKind::Stderr); assert_eq!(lines[1].1, "stderr line"); assert_eq!(lines[2].0, MessageKind::Status); assert_eq!(lines[2].1, "status line"); + assert_eq!(lines[3].0, MessageKind::Healthcheck); + assert_eq!(lines[3].1, "healthcheck line"); } #[test] @@ -395,7 +440,7 @@ mod tests { buf.push(MessageKind::Stderr, "stderr line", None); buf.push(MessageKind::Status, "status line", None); - let lines = buf.lines_filtered(true, false, false); + let lines = buf.lines_filtered(true, false, false, false); assert_eq!(lines.len(), 1); assert_eq!(lines[0].0, MessageKind::Stdout); assert_eq!(lines[0].1, "stdout line"); @@ -408,7 +453,7 @@ mod tests { buf.push(MessageKind::Stderr, "stderr line", None); buf.push(MessageKind::Status, "status line", None); - let lines = buf.lines_filtered(false, true, false); + let lines = buf.lines_filtered(false, true, false, false); assert_eq!(lines.len(), 1); assert_eq!(lines[0].0, MessageKind::Stderr); assert_eq!(lines[0].1, "stderr line"); @@ -421,12 +466,26 @@ mod tests { buf.push(MessageKind::Stderr, "stderr line", None); buf.push(MessageKind::Status, "status line", None); - let lines = buf.lines_filtered(false, false, true); + let lines = buf.lines_filtered(false, false, true, false); assert_eq!(lines.len(), 1); assert_eq!(lines[0].0, MessageKind::Status); assert_eq!(lines[0].1, "status line"); } + #[test] + fn test_message_buf_lines_filtered_healthcheck_only() { + let mut buf = MessageBuf::new(); + buf.push(MessageKind::Stdout, "stdout line", None); + buf.push(MessageKind::Stderr, "stderr line", None); + buf.push(MessageKind::Status, "status line", None); + buf.push(MessageKind::Healthcheck, "healthcheck line", None); + + let lines = buf.lines_filtered(false, false, false, true); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0].0, MessageKind::Healthcheck); + assert_eq!(lines[0].1, "healthcheck line"); + } + #[test] fn test_panel_new() { let panel = Panel::new( @@ -446,6 +505,7 @@ mod tests { assert!(panel.follow); assert!(panel.show_stdout); assert!(panel.show_stderr); + assert!(panel.show_healthcheck); assert!(!panel.timestamps); assert_eq!(panel.process_status, None); } @@ -465,6 +525,7 @@ mod tests { assert_eq!(panel.cwd, None); assert!(!panel.show_stdout); assert!(!panel.show_stderr); + assert!(panel.show_healthcheck); assert!(!panel.timestamps); } @@ -634,6 +695,7 @@ mod tests { exit_code: None, action_type: None, dependencies: Vec::new(), + healthcheck_passed: None, }; let cloned = entry.clone(); assert_eq!(entry.task_name, cloned.task_name); diff --git a/rote/src/render.rs b/rote/src/render.rs index 6fe6582..c5cbb20 100644 --- a/rote/src/render.rs +++ b/rote/src/render.rs @@ -13,10 +13,40 @@ use std::io; use crate::{ config::TaskAction, - panel::{Panel, StatusPanel, WRAP_INDICATOR, wrap_line}, + panel::{Panel, StatusEntry, StatusPanel, WRAP_INDICATOR, wrap_line}, ui::ProcessStatus, }; +/// Get the health status (icon, text, color) for a task based on its StatusEntry. +fn get_health_status(entry: &StatusEntry) -> (&'static str, &'static str, Color) { + match (&entry.action_type, entry.status) { + (_, ProcessStatus::NotStarted) => ("○", "Not started", Color::Gray), + (Some(TaskAction::Ensure { .. }), ProcessStatus::Exited) => { + if entry.exit_code == Some(0) { + ("✓", "Completed", Color::Green) + } else { + ("✗", "Failed", Color::Red) + } + } + (Some(TaskAction::Run { .. }), ProcessStatus::Running) => { + // Check if healthcheck is configured and pending + match entry.healthcheck_passed { + Some(false) => ("⏳", "Starting", Color::Yellow), + Some(true) => ("✓", "Healthy", Color::Green), + None => ("●", "Running", Color::Green), + } + } + (Some(TaskAction::Run { .. }), ProcessStatus::Exited) => ("✗", "Exited", Color::Red), + (_, ProcessStatus::Running) => ("●", "Running", Color::Green), + (_, ProcessStatus::Exited) => ("○", "Exited", Color::Gray), + } +} + +/// Get the default health status for when there's no entry. +fn get_default_health_status() -> (&'static str, &'static str, Color) { + ("○", "Not started", Color::Gray) +} + pub fn draw_shutdown( terminal: &mut Terminal>, status_panel: &StatusPanel, @@ -125,24 +155,8 @@ pub fn draw_status( .iter() .enumerate() .map(|(i, entry)| { - let (status_text, status_color) = match (&entry.action_type, entry.status) { - (_, ProcessStatus::NotStarted) => ("○ Not started", Color::Gray), - (Some(TaskAction::Ensure { .. }), ProcessStatus::Exited) => { - if entry.exit_code == Some(0) { - ("✓ Completed", Color::Green) - } else { - ("✗ Failed", Color::Red) - } - } - (Some(TaskAction::Run { .. }), ProcessStatus::Running) => { - ("● Running", Color::Green) - } - (Some(TaskAction::Run { .. }), ProcessStatus::Exited) => { - ("✗ Exited", Color::Red) - } - (_, ProcessStatus::Running) => ("● Running", Color::Green), - (_, ProcessStatus::Exited) => ("✓ Exited", Color::Gray), - }; + let (icon, text, status_color) = get_health_status(entry); + let status_text = format!("{} {}", icon, text); let (exit_code_text, exit_code_color) = match entry.exit_code { Some(code) => { @@ -261,10 +275,12 @@ pub fn draw( // Inner width for text (subtract 2 for borders) let inner_width = content_area.width.saturating_sub(2) as usize; - let filtered_lines = - panel - .messages - .lines_filtered(panel.show_stdout, panel.show_stderr, panel.show_status); + let filtered_lines = panel.messages.lines_filtered( + panel.show_stdout, + panel.show_stderr, + panel.show_status, + panel.show_healthcheck, + ); let total_lines = filtered_lines.len(); @@ -329,12 +345,38 @@ pub fn draw( let text = visual_lines.join("\n"); - let title = format!( - "{} [stdout: {}, stderr: {}]", - panel.title, - if panel.show_stdout { "on" } else { "off" }, - if panel.show_stderr { "on" } else { "off" }, - ); + // Get health status icon and color for this panel's task + let (icon, _, color) = status_panel + .get_entry(&panel.task_name) + .map(get_health_status) + .unwrap_or_else(get_default_health_status); + + // Check if this task has a healthcheck configured + let has_healthcheck = status_panel + .get_entry(&panel.task_name) + .is_some_and(|e| e.healthcheck_passed.is_some()); + + let title_text = if has_healthcheck { + format!( + "{} [stdout: {}, stderr: {}, health: {}]", + panel.title, + if panel.show_stdout { "on" } else { "off" }, + if panel.show_stderr { "on" } else { "off" }, + if panel.show_healthcheck { "on" } else { "off" }, + ) + } else { + format!( + "{} [stdout: {}, stderr: {}]", + panel.title, + if panel.show_stdout { "on" } else { "off" }, + if panel.show_stderr { "on" } else { "off" }, + ) + }; + + let title = Line::from(vec![ + Span::styled(format!("{} ", icon), Style::default().fg(color)), + Span::raw(title_text), + ]); let widget = Paragraph::new(text).block(Block::default().title(title).borders(Borders::ALL)); @@ -365,19 +407,23 @@ pub fn draw( let status_widget = render_task_status(status_panel); f.render_widget(status_widget, status_area); - let help_text = [ + let mut help_lines = vec![ "1-9 view process", "←/→ navigate", "↑/↓ scroll", - "PgUp/PgDn scroll fast", + "PgUp scroll faster", + "PgDn scroll faster", "s status", "q quit", "r restart", "t stop", "o toggle stdout", "e toggle stderr", - ] - .join("\n"); + ]; + if has_healthcheck { + help_lines.push("h toggle health"); + } + let help_text = help_lines.join("\n"); let help_widget = Paragraph::new(help_text) .alignment(Alignment::Left) diff --git a/rote/src/task_manager.rs b/rote/src/task_manager.rs index 3401978..e580072 100644 --- a/rote/src/task_manager.rs +++ b/rote/src/task_manager.rs @@ -10,6 +10,8 @@ pub struct TaskManager { pending_tasks: Vec, /// Ensure tasks that have completed successfully. completed_ensure_tasks: HashSet, + /// Run tasks with healthchecks that have passed. + healthy_tasks: HashSet, /// Mapping from task name to panel index. task_to_panel: HashMap, } @@ -20,6 +22,7 @@ impl TaskManager { Self { pending_tasks: tasks_to_start, completed_ensure_tasks: HashSet::new(), + healthy_tasks: HashSet::new(), task_to_panel, } } @@ -29,12 +32,22 @@ impl TaskManager { self.completed_ensure_tasks.insert(task_name.to_string()); } + /// Mark a Run task with a healthcheck as healthy. + pub fn mark_healthy(&mut self, task_name: &str) { + self.healthy_tasks.insert(task_name.to_string()); + } + + /// Check if a task is marked as healthy. + pub fn is_healthy(&self, task_name: &str) -> bool { + self.healthy_tasks.contains(task_name) + } + /// Get the panel index for a task. pub fn get_panel_index(&self, task_name: &str) -> Option { self.task_to_panel.get(task_name).copied() } - /// Get tasks that are ready to start (all Ensure dependencies satisfied). + /// Get tasks that are ready to start (all blocking dependencies satisfied). /// Returns the tasks and removes them from the pending list. pub fn take_ready_tasks(&mut self, config: &Config) -> Vec { let mut ready = Vec::new(); @@ -42,7 +55,7 @@ impl TaskManager { while i < self.pending_tasks.len() { let task_name = &self.pending_tasks[i]; - if self.are_ensure_deps_satisfied(task_name, config) { + if self.are_deps_satisfied(task_name, config) { ready.push(self.pending_tasks.remove(i)); } else { i += 1; @@ -52,18 +65,30 @@ impl TaskManager { ready } - /// Check if all Ensure dependencies for a task have completed successfully. - fn are_ensure_deps_satisfied(&self, task_name: &str, config: &Config) -> bool { + /// Check if all blocking dependencies for a task have been satisfied. + /// A dependency blocks if it's an Ensure task (must complete with exit 0) + /// or a Run task with a healthcheck (must pass healthcheck). + fn are_deps_satisfied(&self, task_name: &str, config: &Config) -> bool { let Some(task_config) = config.tasks.get(task_name) else { return true; }; task_config.require.iter().all(|dep| { if let Some(dep_config) = config.tasks.get(dep) { - if matches!(dep_config.action, Some(TaskAction::Ensure { .. })) { - self.completed_ensure_tasks.contains(dep) - } else { - true // Run dependencies don't block + match &dep_config.action { + Some(TaskAction::Ensure { .. }) => { + // Ensure tasks must complete successfully + self.completed_ensure_tasks.contains(dep) + } + Some(TaskAction::Run { .. }) => { + // Run tasks with healthchecks must become healthy + if dep_config.healthcheck.is_some() { + self.healthy_tasks.contains(dep) + } else { + true // Run tasks without healthchecks don't block + } + } + None => true, // No action, assume satisfied } } else { true // Unknown dep, assume satisfied @@ -145,6 +170,7 @@ mod tests { require: require.into_iter().map(String::from).collect(), autorestart: false, timestamps: false, + healthcheck: None, }, ); } @@ -258,4 +284,64 @@ mod tests { let ready = tm.take_ready_tasks(&config); assert_eq!(ready.len(), 2); } + + #[test] + fn test_task_manager_run_with_healthcheck_blocks() { + use crate::config::{Healthcheck, HealthcheckMethod}; + use std::time::Duration; + + let mut task_map = IndexMap::new(); + task_map.insert( + "server".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("./server")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + healthcheck: Some(Healthcheck { + method: HealthcheckMethod::Cmd("curl localhost:8080".to_string()), + interval: Duration::from_secs(1), + }), + }, + ); + task_map.insert( + "client".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("./client")), + }), + cwd: None, + display: None, + require: vec!["server".to_string()], + autorestart: false, + timestamps: false, + healthcheck: None, + }, + ); + + let config = Config { + default: None, + tasks: task_map, + }; + + let mut tm = TaskManager::new( + vec!["server".to_string(), "client".to_string()], + HashMap::new(), + ); + + // Only server should be ready - client is blocked by healthcheck + let ready = tm.take_ready_tasks(&config); + assert_eq!(ready, vec!["server"]); + assert_eq!(tm.pending_tasks, vec!["client"]); + + // After marking server as healthy, client should be ready + tm.mark_healthy("server"); + let ready = tm.take_ready_tasks(&config); + assert_eq!(ready, vec!["client"]); + assert!(tm.pending_tasks.is_empty()); + } } diff --git a/rote/src/tools.rs b/rote/src/tools.rs new file mode 100644 index 0000000..e5e43e3 --- /dev/null +++ b/rote/src/tools.rs @@ -0,0 +1,191 @@ +use anyhow::{Result, anyhow}; +use std::net::TcpStream; + +/// Check if a port is open on localhost. +/// Returns Ok(()) if the port is open, Err if it's closed or unreachable. +pub async fn is_port_open(port: u16) -> Result<()> { + let addr = format!("127.0.0.1:{port}"); + + // Use blocking connect in a spawn_blocking since TcpStream::connect is blocking + let result = tokio::task::spawn_blocking(move || TcpStream::connect(&addr)).await?; + + match result { + Ok(_) => Ok(()), + Err(_) => Err(anyhow!("port {port} is not open")), + } +} + +/// Make an HTTP GET request. +/// The URL should be a full http(s) URL. +/// Returns Ok(()) if the request completes (any status code), Err if connection fails. +pub async fn http_get(url: &str) -> Result<()> { + let _response = reqwest::get(url).await?; + Ok(()) +} + +/// Make an HTTP GET request and check for a successful response. +/// The URL should be a full http(s) URL. +/// Returns Ok(()) if the response status is 2xx, Err otherwise. +pub async fn http_get_ok(url: &str) -> Result<()> { + let response = reqwest::get(url).await?; + + if response.status().is_success() { + Ok(()) + } else { + Err(anyhow!( + "HTTP GET {} returned status {}", + url, + response.status() + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + #[tokio::test] + async fn test_is_port_open_with_open_port() { + // Bind to a random available port + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + // Port should be open + let result = is_port_open(port).await; + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_is_port_open_with_closed_port() { + // Use a port that's very likely not in use (high ephemeral port) + // We create and immediately drop a listener to get a port that was just freed + let port = { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + // Listener is now dropped, port should be closed + + let result = is_port_open(port).await; + assert!(result.is_err()); + } + + /// Spawn a simple HTTP server that responds with the given status code. + fn spawn_http_server(status_code: u16) -> (u16, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + let handle = std::thread::spawn(move || { + if let Ok((mut stream, _)) = listener.accept() { + // Read the request (we don't care about the content) + let mut buf = [0u8; 1024]; + let _ = stream.read(&mut buf); + + // Send HTTP response + let response = format!( + "HTTP/1.1 {} OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + status_code + ); + let _ = stream.write_all(response.as_bytes()); + } + }); + + (port, handle) + } + + #[tokio::test] + async fn test_http_get_success() { + let (port, handle) = spawn_http_server(200); + let url = format!("http://127.0.0.1:{}/", port); + + let result = http_get(&url).await; + assert!(result.is_ok(), "http_get should succeed for any response"); + + handle.join().unwrap(); + } + + #[tokio::test] + async fn test_http_get_success_with_non_2xx() { + // http_get should succeed even with 404 status + let (port, handle) = spawn_http_server(404); + let url = format!("http://127.0.0.1:{}/", port); + + let result = http_get(&url).await; + assert!( + result.is_ok(), + "http_get should succeed even with non-2xx status" + ); + + handle.join().unwrap(); + } + + #[tokio::test] + async fn test_http_get_connection_refused() { + // Get a port that's not listening + let port = { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let url = format!("http://127.0.0.1:{}/", port); + + let result = http_get(&url).await; + assert!( + result.is_err(), + "http_get should fail when connection is refused" + ); + } + + #[tokio::test] + async fn test_http_get_ok_success() { + let (port, handle) = spawn_http_server(200); + let url = format!("http://127.0.0.1:{}/", port); + + let result = http_get_ok(&url).await; + assert!(result.is_ok(), "http_get_ok should succeed for 2xx status"); + + handle.join().unwrap(); + } + + #[tokio::test] + async fn test_http_get_ok_fails_on_404() { + let (port, handle) = spawn_http_server(404); + let url = format!("http://127.0.0.1:{}/", port); + + let result = http_get_ok(&url).await; + assert!( + result.is_err(), + "http_get_ok should fail for non-2xx status" + ); + let err = result.unwrap_err().to_string(); + assert!(err.contains("404"), "error should mention status code"); + + handle.join().unwrap(); + } + + #[tokio::test] + async fn test_http_get_ok_fails_on_500() { + let (port, handle) = spawn_http_server(500); + let url = format!("http://127.0.0.1:{}/", port); + + let result = http_get_ok(&url).await; + assert!(result.is_err(), "http_get_ok should fail for 5xx status"); + + handle.join().unwrap(); + } + + #[tokio::test] + async fn test_http_get_ok_connection_refused() { + let port = { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + let url = format!("http://127.0.0.1:{}/", port); + + let result = http_get_ok(&url).await; + assert!( + result.is_err(), + "http_get_ok should fail when connection is refused" + ); + } +} diff --git a/rote/src/ui.rs b/rote/src/ui.rs index 4342dd4..f6ac4c7 100644 --- a/rote/src/ui.rs +++ b/rote/src/ui.rs @@ -30,6 +30,7 @@ pub enum UiEvent { Scroll(i32), ToggleStdout, ToggleStderr, + ToggleHealthcheck, Restart, Stop, Exit, @@ -39,6 +40,19 @@ pub enum UiEvent { NextPanel, /// Trigger starting the next pending task StartNextTask, + /// Healthcheck passed for a task + HealthcheckPassed { + task_name: String, + }, + /// Healthcheck output line (from command healthchecks) + HealthcheckLine { + task_name: String, + text: String, + }, + /// Healthcheck failed for a task + HealthcheckFailed { + task_name: String, + }, } #[cfg(test)] diff --git a/rote/tests/integration_test.rs b/rote/tests/integration_test.rs index ff1068d..d2ac2f5 100644 --- a/rote/tests/integration_test.rs +++ b/rote/tests/integration_test.rs @@ -114,6 +114,7 @@ async fn test_ensure_dependency_blocks_until_complete() { require: vec![], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -129,6 +130,7 @@ async fn test_ensure_dependency_blocks_until_complete() { require: vec!["setup".to_string()], autorestart: false, timestamps: false, + healthcheck: None, }, ); @@ -288,3 +290,232 @@ async fn test_switch_status_and_panel_views() { assert!(result.is_ok(), "App should exit within 5 seconds"); assert!(result.unwrap().is_ok(), "App should exit successfully"); } + +/// Test that a task with a healthcheck blocks its dependents until the healthcheck passes. +#[tokio::test] +async fn test_healthcheck_blocks_dependent_until_passed() { + use rote_mux::config::{ + CommandValue, Healthcheck, HealthcheckMethod, TaskAction, TaskConfiguration, + }; + use std::borrow::Cow; + + let mut tasks = IndexMap::new(); + + // A Run task with a healthcheck that passes quickly + tasks.insert( + "server".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("echo server started; sleep 10")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + healthcheck: Some(Healthcheck { + method: HealthcheckMethod::Cmd("true".to_string()), + interval: Duration::from_millis(100), + }), + }, + ); + + // A task that depends on the server (should wait for healthcheck) + tasks.insert( + "client".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("echo client started")), + }), + cwd: None, + display: None, + require: vec!["server".to_string()], + autorestart: false, + timestamps: false, + healthcheck: None, + }, + ); + + let config = Config { + default: Some("client".to_string()), + tasks, + }; + + let (tx, rx) = tokio::sync::mpsc::channel::(100); + + let app_task = tokio::spawn(async move { + rote_mux::run_with_input(config, vec![], std::path::PathBuf::from("."), Some(rx)).await + }); + + // Wait for healthcheck to pass and client to start + // The healthcheck interval is 100ms, so 500ms should be plenty + tokio::time::sleep(Duration::from_millis(500)).await; + + // Send exit event + let _ = tx.send(UiEvent::Exit).await; + drop(tx); + + // App should exit cleanly + let result = timeout(Duration::from_secs(3), app_task).await; + assert!(result.is_ok(), "App should exit within 3 seconds"); + assert!(result.unwrap().is_ok(), "App should exit successfully"); +} + +/// Test healthcheck with is-port-open tool. +#[tokio::test] +async fn test_healthcheck_with_port_tool() { + use rote_mux::config::{ + CommandValue, Healthcheck, HealthcheckMethod, HealthcheckTool, TaskAction, + TaskConfiguration, + }; + use std::borrow::Cow; + use std::net::TcpListener; + + // Bind to a random port that we'll use for the healthcheck + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + + let mut tasks = IndexMap::new(); + + // A Run task with a healthcheck that uses is-port-open + // The port is already open (we have a listener), so healthcheck should pass immediately + tasks.insert( + "server".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("echo server; sleep 10")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + healthcheck: Some(Healthcheck { + method: HealthcheckMethod::Tool(HealthcheckTool::IsPortOpen { port }), + interval: Duration::from_millis(100), + }), + }, + ); + + // A task that depends on the server + tasks.insert( + "client".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("echo client started")), + }), + cwd: None, + display: None, + require: vec!["server".to_string()], + autorestart: false, + timestamps: false, + healthcheck: None, + }, + ); + + let config = Config { + default: Some("client".to_string()), + tasks, + }; + + let (tx, rx) = tokio::sync::mpsc::channel::(100); + + let app_task = tokio::spawn(async move { + rote_mux::run_with_input(config, vec![], std::path::PathBuf::from("."), Some(rx)).await + }); + + // Wait for healthcheck to pass + tokio::time::sleep(Duration::from_millis(500)).await; + + // Send exit event + let _ = tx.send(UiEvent::Exit).await; + drop(tx); + + // Keep the listener alive until we're done + drop(listener); + + // App should exit cleanly + let result = timeout(Duration::from_secs(3), app_task).await; + assert!(result.is_ok(), "App should exit within 3 seconds"); + assert!(result.unwrap().is_ok(), "App should exit successfully"); +} + +/// Test healthcheck with is-port-open tool where port opens after a delay. +/// This simulates the healthcheck-tool-demo scenario from example.yaml. +#[tokio::test] +async fn test_healthcheck_delayed_port() { + use rote_mux::config::{ + CommandValue, Healthcheck, HealthcheckMethod, HealthcheckTool, TaskAction, + TaskConfiguration, + }; + use std::borrow::Cow; + use std::net::TcpListener; + + // Find an available port + let port = { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.local_addr().unwrap().port() + }; + // Port is now closed + eprintln!("TEST: Using port {}", port); + + let mut tasks = IndexMap::new(); + + // A Run task with a healthcheck that uses is-port-open + // The port is NOT open initially - it will be opened after a delay + // Note: Commands with shell metacharacters like ; need to use bash -c + tasks.insert( + "server".to_string(), + TaskConfiguration { + action: Some(TaskAction::Run { + command: CommandValue::String(Cow::Borrowed("sleep 10")), + }), + cwd: None, + display: None, + require: vec![], + autorestart: false, + timestamps: false, + healthcheck: Some(Healthcheck { + method: HealthcheckMethod::Tool(HealthcheckTool::IsPortOpen { port }), + interval: Duration::from_millis(100), + }), + }, + ); + + let config = Config { + default: Some("server".to_string()), + tasks, + }; + + let (tx, rx) = tokio::sync::mpsc::channel::(100); + + let app_task = tokio::spawn(async move { + rote_mux::run_with_input(config, vec![], std::path::PathBuf::from("."), Some(rx)).await + }); + + // Wait a bit for the task to start + eprintln!("TEST: Waiting 300ms before opening port"); + tokio::time::sleep(Duration::from_millis(300)).await; + + // Open the port - the healthcheck should detect this + eprintln!("TEST: Opening port {}", port); + let _listener = TcpListener::bind(format!("127.0.0.1:{}", port)).unwrap(); + + // Verify port is open + let is_open = std::net::TcpStream::connect(format!("127.0.0.1:{}", port)).is_ok(); + eprintln!("TEST: Port {} is open: {}", port, is_open); + + // Wait for healthcheck to pass + eprintln!("TEST: Waiting 500ms for healthcheck to pass"); + tokio::time::sleep(Duration::from_millis(500)).await; + + // Send exit event + eprintln!("TEST: Sending Exit event"); + let _ = tx.send(UiEvent::Exit).await; + drop(tx); + + // App should exit cleanly + let result = timeout(Duration::from_secs(3), app_task).await; + assert!(result.is_ok(), "App should exit within 3 seconds"); + assert!(result.unwrap().is_ok(), "App should exit successfully"); +} diff --git a/rote/tests/process_tests.rs b/rote/tests/process_tests.rs index 726d4f4..33383f0 100644 --- a/rote/tests/process_tests.rs +++ b/rote/tests/process_tests.rs @@ -303,10 +303,12 @@ async fn test_draw_logic_with_few_lines() { // Simulate draw function with large terminal (height > number of lines) let height: usize = 10; - let filtered_lines = - panel - .messages - .lines_filtered(panel.show_stdout, panel.show_stderr, panel.show_status); + let filtered_lines = panel.messages.lines_filtered( + panel.show_stdout, + panel.show_stderr, + panel.show_status, + panel.show_healthcheck, + ); let start = scroll .saturating_sub(height.saturating_sub(1)) @@ -376,10 +378,12 @@ async fn test_draw_logic_with_scrolling() { // Simulate draw function with small terminal (height < number of lines) let height: usize = 3; - let filtered_lines = - panel - .messages - .lines_filtered(panel.show_stdout, panel.show_stderr, panel.show_status); + let filtered_lines = panel.messages.lines_filtered( + panel.show_stdout, + panel.show_stderr, + panel.show_status, + panel.show_healthcheck, + ); let start = scroll .saturating_sub(height.saturating_sub(1)) @@ -469,10 +473,12 @@ async fn test_mixed_output_order_preservation() { assert_eq!(panel.visible_len(), 6); // Verify order using lines_filtered - let filtered_lines = - panel - .messages - .lines_filtered(panel.show_stdout, panel.show_stderr, panel.show_status); + let filtered_lines = panel.messages.lines_filtered( + panel.show_stdout, + panel.show_stderr, + panel.show_status, + panel.show_healthcheck, + ); // Note: Due to buffering, stderr messages may come before stdout messages // The important thing is that chronological order is preserved @@ -494,10 +500,12 @@ async fn test_mixed_output_order_preservation() { panel.show_stderr = false; assert_eq!(panel.visible_len(), 3); - let stdout_only = - panel - .messages - .lines_filtered(panel.show_stdout, panel.show_stderr, panel.show_status); + let stdout_only = panel.messages.lines_filtered( + panel.show_stdout, + panel.show_stderr, + panel.show_status, + panel.show_healthcheck, + ); assert_eq!(stdout_only.len(), 3); assert_eq!(stdout_only[0].0, MessageKind::Stdout); assert_eq!(stdout_only[0].1, "stdout1"); @@ -509,10 +517,12 @@ async fn test_mixed_output_order_preservation() { // Toggle stderr back on - both should still be present panel.show_stderr = true; assert_eq!(panel.visible_len(), 6); - let both = - panel - .messages - .lines_filtered(panel.show_stdout, panel.show_stderr, panel.show_status); + let both = panel.messages.lines_filtered( + panel.show_stdout, + panel.show_stderr, + panel.show_status, + panel.show_healthcheck, + ); assert_eq!(both.len(), 6); // Assert that stdout1 comes before stdout2 and stdout3, and similarly for stderr