diff --git a/Cargo.lock b/Cargo.lock index 95667ab..641cdb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,6 +11,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "anstream" version = "1.0.0" @@ -47,7 +53,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -58,7 +64,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -146,6 +152,21 @@ version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cc" version = "1.2.63" @@ -220,6 +241,20 @@ dependencies = [ "winapi", ] +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + [[package]] name = "config" version = "0.15.23" @@ -248,6 +283,65 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deranged" version = "0.5.8" @@ -274,6 +368,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -296,7 +396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -408,6 +508,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", ] @@ -627,6 +729,12 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -660,6 +768,28 @@ dependencies = [ "serde_core", ] +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -672,6 +802,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -702,6 +841,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -714,12 +859,30 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "memchr" version = "2.8.1" @@ -733,8 +896,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", + "log", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -860,6 +1024,35 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "pathdiff" version = "0.2.3" @@ -963,6 +1156,36 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1028,6 +1251,19 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1037,8 +1273,8 @@ dependencies = [ "bitflags", "errno", "libc", - "linux-raw-sys", - "windows-sys", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", ] [[package]] @@ -1068,9 +1304,15 @@ version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "security-framework" version = "3.7.0" @@ -1164,8 +1406,10 @@ dependencies = [ "clap", "command-group", "config", + "crossterm", "log", "predicates", + "ratatui", "reqwest", "serde", "shlex 1.3.0", @@ -1185,6 +1429,27 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -1225,7 +1490,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1234,12 +1499,40 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + [[package]] name = "syn" version = "2.0.117" @@ -1280,8 +1573,8 @@ dependencies = [ "fastrand", "getrandom", "once_cell", - "rustix", - "windows-sys", + "rustix 1.1.4", + "windows-sys 0.61.2", ] [[package]] @@ -1355,7 +1648,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1455,6 +1748,35 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -1654,7 +1976,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1669,6 +1991,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1678,6 +2009,70 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "winnow" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index efa0810..ceed5a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,9 @@ anyhow = "1.0.98" clap = { version = "4.5.39", features = ["derive"] } command-group = { version = "5.0.1", features = ["with-tokio"] } config = { version = "0.15.11", default-features = false, features = ["yaml"] } +crossterm = "0.28" log = "0.4.27" +ratatui = "0.29" reqwest = { version = "0.12.19", default-features = false, features = [ "native-tls-vendored", ] } diff --git a/README.md b/README.md index 1a85419..4496a68 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ server-runner [OPTIONS] - `-c, --config ` - Path to configuration file (default: `servers.yaml`) - `-v, --verbose` - Enable verbose logging - `-a, --attempts ` - Maximum number of connection attempts per server (default: 10) +- `--tui` - Run the interactive control panel with live status, logs, and server controls - `-h, --help` - Print help information - `-V, --version` - Print version information diff --git a/src/core/actor.rs b/src/core/actor.rs new file mode 100644 index 0000000..0be8adb --- /dev/null +++ b/src/core/actor.rs @@ -0,0 +1,946 @@ +use tokio::sync::{mpsc, oneshot}; +use tokio::task::JoinHandle; + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::config::Config; +use crate::core::server::ServerProcess; +use crate::core::state::{AppState, FinalCmdStatus, ServerStatus}; + +#[derive(Clone, Debug, PartialEq, Eq)] +#[allow(dead_code)] // wired into the TUI in a later task +pub enum EngineCommand { + Restart(usize), + StopStart(usize), + RerunFinalCommand, + Quit, +} + +struct FinalCommandTask { + handle: JoinHandle>, + cancel: oneshot::Sender<()>, +} + +#[allow(dead_code)] // wired into the TUI in a later task +pub async fn run_tui_engine( + config: Config, + max_attempts: u8, + state: Arc>, + mut commands: mpsc::Receiver, +) -> anyhow::Result<()> { + let config = Arc::new(config); + let mut processes: Vec> = Vec::with_capacity(config.servers.len()); + for (idx, server) in config.servers.iter().enumerate() { + match ServerProcess::spawn_captured(&server.name, &server.command) { + Ok(process) => { + link_server_log(&state, idx, Arc::clone(&process.log)); + processes.push(Some(process)); + } + Err(error) => { + mark_server_failed_with_message( + &state, + idx, + format!("Failed to start server {}: {error}", server.name), + ); + processes.push(None); + } + } + } + + let mut final_status_started = false; + let mut final_command_task: Option = None; + let mut poll_task: Option>> = None; + let mut ticker = tokio::time::interval(Duration::from_secs(1)); + + loop { + tokio::select! { + command = commands.recv() => { + match command { + Some(EngineCommand::Quit) | None => { + abort_poll_task(&mut poll_task).await; + abort_final_command(&mut final_command_task, &state).await; + stop_all(&mut processes).await?; + return Ok(()); + } + Some(EngineCommand::Restart(idx)) => { + if idx < processes.len() { + abort_poll_task(&mut poll_task).await; + abort_final_command(&mut final_command_task, &state).await; + start_server_process( + &mut processes, + &state, + &config.servers[idx], + idx, + "Restarting server", + ) + .await; + final_status_started = false; + } + } + Some(EngineCommand::StopStart(idx)) => { + if idx < processes.len() { + abort_poll_task(&mut poll_task).await; + abort_final_command(&mut final_command_task, &state).await; + let status = server_status(&state, idx); + if matches!( + status, + Some(ServerStatus::Stopped) | Some(ServerStatus::Failed) + ) { + start_server_process( + &mut processes, + &state, + &config.servers[idx], + idx, + "Starting server", + ) + .await; + } else { + if let Some(mut process) = processes[idx].take() { + let _ = process.stop().await; + } + mark_server_stopped(&state, idx); + } + final_status_started = false; + } + } + Some(EngineCommand::RerunFinalCommand) => { + if all_servers_running(&state) + && start_final_command_task_if_idle( + &mut final_command_task, + config.command.clone(), + Arc::clone(&state), + ) + { + final_status_started = true; + } + } + } + } + result = async { + (&mut poll_task + .as_mut() + .expect("poll task should exist")) + .await + }, if poll_task.is_some() => { + poll_task = None; + let result = result.map_err(anyhow::Error::from)?; + if let Err(error) = result { + abort_final_command(&mut final_command_task, &state).await; + stop_all(&mut processes).await?; + return Err(error); + } + } + result = async { + (&mut final_command_task + .as_mut() + .expect("final command task should exist") + .handle) + .await + }, if final_command_task.is_some() => { + final_command_task = None; + let result = result.map_err(anyhow::Error::from)?; + if let Err(error) = result { + stop_all(&mut processes).await?; + return Err(error); + } + } + _ = ticker.tick() => { + if poll_task.is_none() { + let poll_config = Arc::clone(&config); + let poll_state = Arc::clone(&state); + poll_task = Some(tokio::spawn(async move { + poll_servers(&poll_config, max_attempts, &poll_state).await + })); + } + if !final_status_started + && all_servers_running(&state) + && start_final_command_task_if_idle( + &mut final_command_task, + config.command.clone(), + Arc::clone(&state), + ) + { + final_status_started = true; + } + } + } + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +async fn poll_servers( + config: &Config, + max_attempts: u8, + state: &Arc>, +) -> anyhow::Result<()> { + for (idx, server) in config.servers.iter().enumerate() { + if server_status(state, idx) != Some(ServerStatus::Waiting) { + continue; + } + + let status = crate::core::health::check(&server.name, &server.url, server.timeout).await?; + if status == ServerStatus::Running { + mark_server_running(state, idx); + } else { + increment_attempt_or_fail(state, idx, max_attempts); + } + } + + Ok(()) +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn mark_server_running(state: &Arc>, idx: usize) { + if let Ok(mut state) = state.lock() + && let Some(server) = state.servers.get_mut(idx) + { + server.status = ServerStatus::Running; + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn reset_server_for_start(state: &Arc>, idx: usize, reason: &str) { + if let Ok(mut guard) = state.lock() + && let Some(server) = guard.servers.get_mut(idx) + { + server.status = ServerStatus::Waiting; + server.attempts = crate::core::state::Attempts(0); + if let Ok(mut log) = server.log.lock() { + log.push(format!("--- {reason} ---")); + } + } +} + +/// Take and stop any process in the slot, spawn a fresh captured replacement, +/// link its log into shared state, store it, and reset the server to Waiting. +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +async fn start_server_process( + processes: &mut [Option], + state: &Arc>, + server: &crate::config::Server, + idx: usize, + reason: &str, +) { + if let Some(mut process) = processes[idx].take() { + let _ = process.stop().await; + } + match ServerProcess::spawn_captured(&server.name, &server.command) { + Ok(process) => { + link_server_log(state, idx, Arc::clone(&process.log)); + processes[idx] = Some(process); + reset_server_for_start(state, idx, reason); + } + Err(error) => { + mark_server_failed_with_message( + state, + idx, + format!("Failed to start server {}: {error}", server.name), + ); + } + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn link_server_log( + state: &Arc>, + idx: usize, + log: Arc>, +) { + if let Ok(mut guard) = state.lock() + && let Some(server) = guard.servers.get_mut(idx) + { + server.log = log; + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn mark_server_stopped(state: &Arc>, idx: usize) { + if let Ok(mut guard) = state.lock() + && let Some(server) = guard.servers.get_mut(idx) + { + server.status = ServerStatus::Stopped; + if let Ok(mut log) = server.log.lock() { + log.push("--- Server stopped ---".to_string()); + } + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn mark_server_failed_with_message( + state: &Arc>, + idx: usize, + message: impl Into, +) { + if let Ok(mut guard) = state.lock() + && let Some(server) = guard.servers.get_mut(idx) + { + server.status = ServerStatus::Failed; + if let Ok(mut log) = server.log.lock() { + log.push(message.into()); + } + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn increment_attempt_or_fail(state: &Arc>, idx: usize, max_attempts: u8) { + if let Ok(mut state) = state.lock() + && let Some(server) = state.servers.get_mut(idx) + { + server.attempts += 1; + if server.attempts.0 >= max_attempts { + server.status = ServerStatus::Failed; + let word = if max_attempts == 1 { + "attempt" + } else { + "attempts" + }; + if let Ok(mut log) = server.log.lock() { + log.push(format!( + "Could not connect to server {} after {} {}", + server.name, server.attempts, word + )); + } + } + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn all_servers_running(state: &Arc>) -> bool { + state + .lock() + .map(|state| { + state + .servers + .iter() + .all(|server| server.status == ServerStatus::Running) + }) + .unwrap_or(false) +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn final_command_is_idle(state: &Arc>) -> bool { + state + .lock() + .map(|state| state.final_cmd.status != FinalCmdStatus::Running) + .unwrap_or(false) +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn start_final_command_task_if_idle( + final_command_task: &mut Option, + command: String, + state: Arc>, +) -> bool { + if final_command_task.is_some() || !final_command_is_idle(&state) { + return false; + } + + set_final_status(&state, FinalCmdStatus::Running); + let (cancel, cancel_rx) = oneshot::channel(); + let handle = + tokio::spawn( + async move { run_final_command_for_tui_inner(&command, &state, cancel_rx).await }, + ); + *final_command_task = Some(FinalCommandTask { handle, cancel }); + true +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +async fn abort_final_command( + final_command_task: &mut Option, + state: &Arc>, +) { + if let Some(task) = final_command_task.take() { + let _ = task.cancel.send(()); + let _ = task.handle.await; + } + if !final_command_is_idle(state) { + set_final_status(state, FinalCmdStatus::Failed(1)); + } +} + +async fn abort_poll_task(poll_task: &mut Option>>) { + if let Some(task) = poll_task.take() { + task.abort(); + let _ = task.await; + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +async fn run_final_command_for_tui( + command: &str, + state: &Arc>, +) -> anyhow::Result<()> { + set_final_status(state, FinalCmdStatus::Running); + let (_cancel, cancel_rx) = oneshot::channel(); + run_final_command_for_tui_inner(command, state, cancel_rx).await +} + +async fn run_final_command_for_tui_inner( + command: &str, + state: &Arc>, + cancel_rx: oneshot::Receiver<()>, +) -> anyhow::Result<()> { + let mut final_cmd = match crate::core::command::spawn_captured_group(command) { + Ok(final_cmd) => final_cmd, + Err(error) => { + mark_final_failed_with_message( + state, + format!("Failed to start final command: {error}"), + ); + return Ok(()); + } + }; + if let Ok(mut guard) = state.lock() { + guard.final_cmd.log = Arc::clone(&final_cmd.log); + } + + let status = tokio::select! { + status = final_cmd.wait() => status?, + _ = cancel_rx => { + let _ = final_cmd.cancel().await; + set_final_status(state, FinalCmdStatus::Failed(1)); + for reader in final_cmd.readers { + let _ = reader.await; + } + return Ok(()); + } + }; + for reader in final_cmd.readers { + let _ = reader.await; + } + + let code = status.code().unwrap_or(1); + if status.success() { + set_final_status(state, FinalCmdStatus::Succeeded(code)); + } else { + set_final_status(state, FinalCmdStatus::Failed(code)); + } + Ok(()) +} + +fn mark_final_failed_with_message(state: &Arc>, message: impl Into) { + if let Ok(mut guard) = state.lock() { + guard.final_cmd.status = FinalCmdStatus::Failed(1); + if let Ok(mut log) = guard.final_cmd.log.lock() { + log.push(message.into()); + } + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn set_final_status(state: &Arc>, status: FinalCmdStatus) { + if let Ok(mut state) = state.lock() { + state.final_cmd.status = status; + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +async fn stop_all(processes: &mut [Option]) -> anyhow::Result<()> { + let mut first_error = None; + + for process in processes.iter_mut().flatten() { + if let Err(error) = process.stop().await { + remember_first_error(&mut first_error, error); + } + } + + if let Some(error) = first_error { + return Err(error); + } + + Ok(()) +} + +#[allow(dead_code)] // used through stop_all once the TUI is wired in +fn remember_first_error(first_error: &mut Option, error: anyhow::Error) { + if first_error.is_none() { + *first_error = Some(error); + } +} + +#[allow(dead_code)] // used through run_tui_engine once the TUI is wired in +fn server_status(state: &Arc>, idx: usize) -> Option { + state + .lock() + .ok() + .and_then(|state| state.servers.get(idx).map(|server| server.status)) +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use super::{mark_server_stopped, reset_server_for_start, set_final_status}; + use crate::config::{Config, Server}; + use crate::core::state::{AppState, FinalCmdStatus, ServerStatus}; + + fn one_server_config() -> Config { + Config { + servers: vec![Server { + name: "API".to_string(), + url: "http://127.0.0.1:3000".to_string(), + command: "server-command".to_string(), + timeout: 1, + }], + command: "test-command".to_string(), + } + } + + #[test] + fn all_running_detects_only_running_servers() { + let config = one_server_config(); + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + + assert!(!super::all_servers_running(&state)); + + state.lock().unwrap().servers[0].status = ServerStatus::Running; + + assert!(super::all_servers_running(&state)); + } + + #[test] + fn marks_failed_when_attempts_exhausted() { + let config = one_server_config(); + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + + super::increment_attempt_or_fail(&state, 0, 1); + + let state = state.lock().unwrap(); + assert_eq!(state.servers[0].attempts, 1u8); + assert_eq!(state.servers[0].status, ServerStatus::Failed); + } + + #[test] + fn mark_server_running_ignores_missing_state_index() { + let state = Arc::new(Mutex::new(AppState::new(&[], "test-command"))); + + super::mark_server_running(&state, 0); + + assert!(state.lock().unwrap().servers.is_empty()); + } + + #[test] + fn remember_first_error_preserves_original_error() { + let mut first = None; + + super::remember_first_error(&mut first, anyhow::anyhow!("first")); + super::remember_first_error(&mut first, anyhow::anyhow!("second")); + + assert_eq!(first.unwrap().to_string(), "first"); + } + + #[test] + fn restart_resets_server_state() { + let config = crate::config::Config { + servers: vec![crate::config::Server { + name: "A".to_string(), + url: "http://127.0.0.1:1".to_string(), + command: "echo a".to_string(), + timeout: 1, + }], + command: "echo done".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + { + let mut guard = state.lock().unwrap(); + guard.servers[0].status = ServerStatus::Failed; + guard.servers[0].attempts = crate::core::state::Attempts(5); + } + + reset_server_for_start(&state, 0, "Restarting server"); + + let guard = state.lock().unwrap(); + assert_eq!(guard.servers[0].status, ServerStatus::Waiting); + assert_eq!(guard.servers[0].attempts, 0u8); + let lines: Vec<_> = guard.servers[0] + .log + .lock() + .unwrap() + .iter() + .cloned() + .collect(); + assert!(lines.contains(&"--- Restarting server ---".to_string())); + } + + #[test] + fn stop_marks_server_stopped() { + let config = crate::config::Config { + servers: vec![crate::config::Server { + name: "A".to_string(), + url: "http://127.0.0.1:1".to_string(), + command: "echo a".to_string(), + timeout: 1, + }], + command: "echo done".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + + mark_server_stopped(&state, 0); + + assert_eq!( + state.lock().unwrap().servers[0].status, + ServerStatus::Stopped + ); + } + + #[test] + fn final_running_status_blocks_auto_rerun() { + let config = crate::config::Config { + servers: Vec::new(), + command: "echo done".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + set_final_status(&state, FinalCmdStatus::Running); + + assert!(!super::final_command_is_idle(&state)); + set_final_status(&state, FinalCmdStatus::Succeeded(0)); + assert!(super::final_command_is_idle(&state)); + } + + #[tokio::test] + async fn final_task_start_marks_running_and_refuses_duplicate() { + let config = crate::config::Config { + servers: Vec::new(), + command: "echo done".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + let mut final_command_task = None; + + assert!(super::start_final_command_task_if_idle( + &mut final_command_task, + config.command.clone(), + Arc::clone(&state), + )); + assert_eq!( + state.lock().unwrap().final_cmd.status, + FinalCmdStatus::Running + ); + assert!(!super::start_final_command_task_if_idle( + &mut final_command_task, + config.command, + Arc::clone(&state), + )); + + super::abort_final_command(&mut final_command_task, &state).await; + } +} + +#[cfg(all(test, unix))] +mod final_command_tests { + use std::sync::{Arc, Mutex}; + + use tokio::sync::mpsc; + + use super::{EngineCommand, run_final_command_for_tui, run_tui_engine}; + use crate::core::state::{AppState, FinalCmdStatus, ServerStatus}; + + fn pid_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + } + + fn final_log_lines(state: &Arc>) -> Vec { + state + .lock() + .unwrap() + .final_cmd + .log + .lock() + .unwrap() + .iter() + .cloned() + .collect() + } + + #[tokio::test] + async fn run_final_command_updates_success_status_and_log() { + let config = crate::config::Config { + servers: Vec::new(), + command: "sh -c 'echo final-ok'".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + + run_final_command_for_tui(&config.command, &state) + .await + .unwrap(); + + let guard = state.lock().unwrap(); + assert_eq!(guard.final_cmd.status, FinalCmdStatus::Succeeded(0)); + let lines: Vec<_> = guard + .final_cmd + .log + .lock() + .unwrap() + .iter() + .cloned() + .collect(); + assert!(lines.contains(&"final-ok".to_string())); + } + + #[tokio::test] + async fn run_final_command_updates_failed_status() { + let config = crate::config::Config { + servers: Vec::new(), + command: "sh -c 'exit 7'".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + + run_final_command_for_tui(&config.command, &state) + .await + .unwrap(); + + assert_eq!( + state.lock().unwrap().final_cmd.status, + FinalCmdStatus::Failed(7) + ); + } + + #[tokio::test] + async fn quit_returns_promptly_while_final_command_is_running() { + let config = crate::config::Config { + servers: Vec::new(), + command: "sh -c 'sleep 5'".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + let (tx, rx) = mpsc::channel(1); + let actor = tokio::spawn(run_tui_engine(config, 1, Arc::clone(&state), rx)); + + for _ in 0..20 { + if state.lock().unwrap().final_cmd.status == FinalCmdStatus::Running { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!( + state.lock().unwrap().final_cmd.status, + FinalCmdStatus::Running + ); + + tx.send(EngineCommand::Quit).await.unwrap(); + let result = tokio::time::timeout(std::time::Duration::from_millis(500), actor).await; + + if result.is_err() { + panic!("actor did not quit promptly while final command was running"); + } + result.unwrap().unwrap().unwrap(); + } + + #[tokio::test] + async fn quit_kills_running_final_command_descendant() { + let config = crate::config::Config { + servers: Vec::new(), + command: "sh -c 'sleep 5 & echo child:$!; wait'".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + let (tx, rx) = mpsc::channel(1); + let actor = tokio::spawn(run_tui_engine(config, 1, Arc::clone(&state), rx)); + + let mut child_pid = None; + for _ in 0..40 { + child_pid = final_log_lines(&state).iter().find_map(|line| { + line.strip_prefix("child:") + .and_then(|pid| pid.parse::().ok()) + }); + if child_pid.is_some() + && state.lock().unwrap().final_cmd.status == FinalCmdStatus::Running + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + let child_pid = child_pid.expect("final command should log descendant pid"); + assert!( + pid_alive(child_pid), + "descendant should be alive before quit" + ); + + tx.send(EngineCommand::Quit).await.unwrap(); + tokio::time::timeout(std::time::Duration::from_millis(500), actor) + .await + .unwrap() + .unwrap() + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + if pid_alive(child_pid) { + let _ = std::process::Command::new("kill") + .arg("-9") + .arg(child_pid.to_string()) + .status(); + panic!("final command descendant was not killed on quit"); + } + } + + #[tokio::test] + async fn quit_returns_promptly_while_readiness_poll_is_blocked() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let _acceptor = std::thread::spawn(move || { + if let Ok((_stream, _addr)) = listener.accept() { + std::thread::sleep(std::time::Duration::from_secs(5)); + } + }); + let config = crate::config::Config { + servers: vec![crate::config::Server { + name: "Slow".to_string(), + url: format!("http://{addr}"), + command: "sh -c 'sleep 5'".to_string(), + timeout: 5, + }], + command: "echo done".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + let (tx, rx) = mpsc::channel(1); + let actor = tokio::spawn(run_tui_engine(config, 1, Arc::clone(&state), rx)); + + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + tx.send(EngineCommand::Quit).await.unwrap(); + tokio::time::timeout(std::time::Duration::from_millis(500), actor) + .await + .unwrap() + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn initial_server_spawn_failure_marks_failed_and_actor_stays_alive() { + let config = crate::config::Config { + servers: vec![crate::config::Server { + name: "Bad".to_string(), + url: "http://127.0.0.1:1".to_string(), + command: "definitely-not-a-server-runner-command".to_string(), + timeout: 1, + }], + command: "echo done".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + let (tx, rx) = mpsc::channel(1); + let actor = tokio::spawn(run_tui_engine(config, 1, Arc::clone(&state), rx)); + + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + assert_eq!( + state.lock().unwrap().servers[0].status, + ServerStatus::Failed + ); + assert!( + !actor.is_finished(), + "actor should stay alive after spawn failure" + ); + + tx.send(EngineCommand::Quit).await.unwrap(); + actor.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn final_command_spawn_failure_marks_failed_and_actor_stays_alive() { + let config = crate::config::Config { + servers: Vec::new(), + command: "definitely-not-a-server-runner-command".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + let (tx, rx) = mpsc::channel(1); + let actor = tokio::spawn(run_tui_engine(config, 1, Arc::clone(&state), rx)); + + for _ in 0..20 { + if state.lock().unwrap().final_cmd.status == FinalCmdStatus::Failed(1) { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + } + assert_eq!( + state.lock().unwrap().final_cmd.status, + FinalCmdStatus::Failed(1) + ); + assert!( + !actor.is_finished(), + "actor should stay alive after final spawn failure" + ); + + tx.send(EngineCommand::Quit).await.unwrap(); + actor.await.unwrap().unwrap(); + } +} + +#[cfg(all(test, unix))] +mod command_path_tests { + use std::sync::{Arc, Mutex}; + + use super::start_server_process; + use crate::core::server::ServerProcess; + use crate::core::state::{AppState, ServerStatus}; + + fn pid_alive(pid: u32) -> bool { + std::process::Command::new("kill") + .arg("-0") + .arg(pid.to_string()) + .stderr(std::process::Stdio::null()) + .status() + .map(|status| status.success()) + .unwrap_or(false) + } + + #[tokio::test] + async fn starting_failed_server_stops_old_process() { + let config = crate::config::Config { + servers: vec![crate::config::Server { + name: "A".to_string(), + url: "http://127.0.0.1:1".to_string(), + command: "sh -c 'sleep 5'".to_string(), + timeout: 1, + }], + command: "echo done".to_string(), + }; + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + + // A Failed server still owns a live process in its slot. + let old = ServerProcess::spawn_captured("A", "sh -c 'sleep 5'").unwrap(); + let old_pid = old.id().expect("old process should expose a pid"); + let mut processes: Vec> = vec![Some(old)]; + state.lock().unwrap().servers[0].status = ServerStatus::Failed; + + assert!( + pid_alive(old_pid), + "old process should be alive before respawn" + ); + + start_server_process( + &mut processes, + &state, + &config.servers[0], + 0, + "Starting server", + ) + .await; + + // Give the OS a moment to reap the killed group. + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + assert!( + !pid_alive(old_pid), + "old process must be stopped, not leaked" + ); + + let new_pid = processes[0] + .as_ref() + .expect("a fresh process should occupy the slot") + .id(); + assert!(new_pid.is_some()); + assert_ne!(Some(old_pid), new_pid, "slot should hold a new process"); + assert_eq!( + state.lock().unwrap().servers[0].status, + ServerStatus::Waiting + ); + + // Clean up the replacement so the test leaves no lingering process. + if let Some(mut process) = processes[0].take() { + let _ = process.stop().await; + } + } +} diff --git a/src/core/command.rs b/src/core/command.rs index da2ded6..f171349 100644 --- a/src/core/command.rs +++ b/src/core/command.rs @@ -1,3 +1,4 @@ +use command_group::{AsyncCommandGroup, AsyncGroupChild}; use tokio::io::AsyncReadExt; use tokio::process::Child; use tokio::task::JoinHandle; @@ -16,18 +17,69 @@ pub struct FinalCommand { pub readers: Vec>, } +/// A captured final command spawned as a process group for TUI cancellation. +pub struct FinalCommandGroup { + child: AsyncGroupChild, + pub log: Arc>, + pub readers: Vec>, +} + +impl FinalCommandGroup { + pub async fn wait(&mut self) -> std::io::Result { + self.child.wait().await + } + + pub async fn cancel(&mut self) -> std::io::Result<()> { + self.child.kill().await + } +} + /// Spawn the final command (NOT as a process group — matches today's `Command::spawn`). pub fn spawn(command: &str) -> anyhow::Result { + spawn_inner(command, true) +} + +#[allow(dead_code)] // used by TUI command execution in Task 7 +pub fn spawn_captured(command: &str) -> anyhow::Result { + spawn_inner(command, false) +} + +#[allow(dead_code)] // used by TUI command execution +pub fn spawn_captured_group(command: &str) -> anyhow::Result { let mut cmd = build_command(command)?; + let mut child = cmd.group_spawn()?; + let log = Arc::new(Mutex::new(RingBuffer::new(LOG_CAPACITY))); + let mut readers = Vec::new(); + + if let Some(stdout) = child.inner().stdout.take() { + readers.push(spawn_reader(stdout, Arc::clone(&log), false, false)); + } + if let Some(stderr) = child.inner().stderr.take() { + readers.push(spawn_reader(stderr, Arc::clone(&log), true, false)); + } + + Ok(FinalCommandGroup { + child, + log, + readers, + }) +} + +fn spawn_inner(command: &str, tee_output: bool) -> anyhow::Result { + let mut cmd = build_command(command)?; + if !tee_output { + cmd.kill_on_drop(true); + } + let mut child = cmd.spawn()?; let log = Arc::new(Mutex::new(RingBuffer::new(LOG_CAPACITY))); let mut readers = Vec::new(); if let Some(stdout) = child.stdout.take() { - readers.push(spawn_reader(stdout, Arc::clone(&log), false)); + readers.push(spawn_reader(stdout, Arc::clone(&log), false, tee_output)); } if let Some(stderr) = child.stderr.take() { - readers.push(spawn_reader(stderr, Arc::clone(&log), true)); + readers.push(spawn_reader(stderr, Arc::clone(&log), true, tee_output)); } Ok(FinalCommand { @@ -37,7 +89,12 @@ pub fn spawn(command: &str) -> anyhow::Result { }) } -fn spawn_reader(mut stream: R, log: Arc>, stderr: bool) -> JoinHandle<()> +fn spawn_reader( + mut stream: R, + log: Arc>, + stderr: bool, + tee_output: bool, +) -> JoinHandle<()> where R: tokio::io::AsyncRead + Unpin + Send + 'static, { @@ -50,7 +107,9 @@ where break; } - write_output(&buf[..n], stderr); + if tee_output { + write_output(&buf[..n], stderr); + } capture_lines(&buf[..n], &mut line, &log); } @@ -87,3 +146,22 @@ fn capture_lines(bytes: &[u8], line: &mut String, log: &Arc>) } } } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[tokio::test] + async fn spawn_captured_records_output_lines() { + let mut command = spawn_captured("sh -c 'echo out; echo err >&2'").unwrap(); + let status = command.child.wait().await.unwrap(); + for reader in command.readers { + let _ = reader.await; + } + + assert!(status.success()); + let lines: Vec<_> = command.log.lock().unwrap().iter().cloned().collect(); + assert!(lines.contains(&"out".to_string())); + assert!(lines.contains(&"err".to_string())); + } +} diff --git a/src/core/mod.rs b/src/core/mod.rs index 4cc8037..18bb7db 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,3 +1,4 @@ +pub mod actor; pub mod command; pub mod health; pub mod server; diff --git a/src/core/server.rs b/src/core/server.rs index 787c736..69d4d73 100644 --- a/src/core/server.rs +++ b/src/core/server.rs @@ -48,6 +48,15 @@ pub struct ServerProcess { impl ServerProcess { /// Spawn the server as a process group and start capturing its stdout/stderr. pub fn spawn(name: &str, command: &str) -> anyhow::Result { + Self::spawn_inner(name, command, true) + } + + #[allow(dead_code)] // used by TUI server execution in Task 7 + pub fn spawn_captured(name: &str, command: &str) -> anyhow::Result { + Self::spawn_inner(name, command, false) + } + + fn spawn_inner(name: &str, command: &str, tee_output: bool) -> anyhow::Result { let mut cmd = build_command(command)?; let mut child = cmd.group_spawn()?; let log = Arc::new(Mutex::new(RingBuffer::new(LOG_CAPACITY))); @@ -55,10 +64,10 @@ impl ServerProcess { // Take the piped streams from the inner tokio Child before handing // ownership of `child` to the struct. `.inner()` gives `&mut Child`. if let Some(stdout) = child.inner().stdout.take() { - spawn_reader(stdout, Arc::clone(&log), false); + spawn_reader(stdout, Arc::clone(&log), false, tee_output); } if let Some(stderr) = child.inner().stderr.take() { - spawn_reader(stderr, Arc::clone(&log), true); + spawn_reader(stderr, Arc::clone(&log), true, tee_output); } Ok(Self { @@ -68,6 +77,12 @@ impl ServerProcess { }) } + /// Process-group leader PID, if still available. + #[allow(dead_code)] // used by TUI control-command tests + pub fn id(&self) -> Option { + self.child.id() + } + /// Kill the process group (kill includes wait internally). pub async fn stop(&mut self) -> anyhow::Result<()> { self.child @@ -77,7 +92,7 @@ impl ServerProcess { } } -fn spawn_reader(mut stream: R, log: Arc>, stderr: bool) +fn spawn_reader(mut stream: R, log: Arc>, stderr: bool, tee_output: bool) where R: tokio::io::AsyncRead + Unpin + Send + 'static, { @@ -90,7 +105,9 @@ where break; } - write_output(&buf[..n], stderr); + if tee_output { + write_output(&buf[..n], stderr); + } capture_lines(&buf[..n], &mut line, &log); } @@ -127,3 +144,19 @@ fn capture_lines(bytes: &[u8], line: &mut String, log: &Arc>) } } } + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + #[tokio::test] + async fn spawn_captured_server_records_output_lines() { + let mut server = + ServerProcess::spawn_captured("Test", "sh -c 'echo server-out; sleep 5'").unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + server.stop().await.unwrap(); + + let lines: Vec<_> = server.log.lock().unwrap().iter().cloned().collect(); + assert!(lines.contains(&"server-out".to_string())); + } +} diff --git a/src/core/state.rs b/src/core/state.rs index 2071be1..1e74823 100644 --- a/src/core/state.rs +++ b/src/core/state.rs @@ -3,6 +3,9 @@ use std::fmt; use std::ops::AddAssign; use std::sync::{Arc, Mutex}; +use crate::config::Server; +use crate::core::server::LOG_CAPACITY; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ServerStatus { Waiting, @@ -74,38 +77,79 @@ impl RingBuffer { } } -#[allow(dead_code)] // used by TUI in Plan 2 #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum FinalCmdStatus { Idle, + #[allow(dead_code)] // used by TUI in Plan 2 Running, + #[allow(dead_code)] // used by TUI in Plan 2 Succeeded(i32), + #[allow(dead_code)] // used by TUI in Plan 2 Failed(i32), } -#[allow(dead_code)] // used by TUI in Plan 2 pub struct ServerView { + #[allow(dead_code)] // used by TUI in Plan 2 pub name: String, + #[allow(dead_code)] // used by TUI in Plan 2 pub url: String, + #[allow(dead_code)] // used by TUI in Plan 2 pub status: ServerStatus, + #[allow(dead_code)] // used by TUI in Plan 2 pub attempts: Attempts, + #[allow(dead_code)] // used by TUI in Plan 2 pub log: Arc>, } -#[allow(dead_code)] // used by TUI in Plan 2 pub struct FinalCmdView { + #[allow(dead_code)] // used by TUI in Plan 2 pub command: String, + #[allow(dead_code)] // used by TUI in Plan 2 pub status: FinalCmdStatus, + #[allow(dead_code)] // used by TUI in Plan 2 pub log: Arc>, } /// Shared, render-friendly snapshot of the engine, owned behind Arc>. -#[allow(dead_code)] // used by TUI in Plan 2 pub struct AppState { pub servers: Vec, + #[allow(dead_code)] // used by TUI in Plan 2 pub final_cmd: FinalCmdView, } +impl AppState { + #[allow(dead_code)] // used by TUI in Plan 2 + pub fn new(servers: &[Server], command: &str) -> Self { + Self { + servers: servers + .iter() + .map(|server| ServerView { + name: server.name.clone(), + url: server.url.clone(), + status: ServerStatus::Waiting, + attempts: Attempts(0), + log: Arc::new(Mutex::new(RingBuffer::new(LOG_CAPACITY))), + }) + .collect(), + final_cmd: FinalCmdView { + command: command.to_string(), + status: FinalCmdStatus::Idle, + log: Arc::new(Mutex::new(RingBuffer::new(LOG_CAPACITY))), + }, + } + } + + #[allow(dead_code)] // used by TUI in Plan 2 + pub fn selectable_len(&self) -> usize { + self.servers.len() + 1 + } + + #[allow(dead_code)] // used by TUI in Plan 2 + pub fn is_final_selection(&self, selected: usize) -> bool { + selected == self.servers.len() + } +} + #[cfg(test)] mod tests { use super::*; @@ -135,9 +179,72 @@ mod tests { mod app_state_tests { use super::*; + fn assert_log_uses_capacity(log: &Arc>) { + let mut log = log.lock().unwrap(); + for i in 0..=LOG_CAPACITY { + log.push(format!("line-{i}")); + } + + assert_eq!(log.len(), LOG_CAPACITY); + let lines: Vec<_> = log.iter().cloned().collect(); + assert_eq!(lines.first(), Some(&"line-1".to_string())); + assert_eq!(lines.last(), Some(&format!("line-{LOG_CAPACITY}"))); + } + #[test] fn final_cmd_status_equality() { assert_eq!(FinalCmdStatus::Succeeded(0), FinalCmdStatus::Succeeded(0)); assert_ne!(FinalCmdStatus::Succeeded(0), FinalCmdStatus::Failed(1)); } + + #[test] + fn app_state_initializes_servers_and_final_command() { + let servers = vec![ + crate::config::Server { + name: "API".to_string(), + url: "http://127.0.0.1:3000".to_string(), + command: "python3 -m http.server 3000".to_string(), + timeout: 5, + }, + crate::config::Server { + name: "Worker".to_string(), + url: "http://127.0.0.1:3001".to_string(), + command: "python3 -m http.server 3001".to_string(), + timeout: 3, + }, + ]; + + let app = AppState::new(&servers, "npm test"); + + assert_eq!(app.servers.len(), 2); + assert_eq!(app.servers[0].name, "API"); + assert_eq!(app.servers[0].url, "http://127.0.0.1:3000"); + assert_eq!(app.servers[0].status, ServerStatus::Waiting); + assert_eq!(app.servers[0].attempts, 0u8); + assert_log_uses_capacity(&app.servers[0].log); + assert_eq!(app.servers[1].name, "Worker"); + assert_eq!(app.servers[1].url, "http://127.0.0.1:3001"); + assert_eq!(app.servers[1].status, ServerStatus::Waiting); + assert_eq!(app.servers[1].attempts, 0u8); + assert_log_uses_capacity(&app.servers[1].log); + assert_eq!(app.final_cmd.command, "npm test"); + assert_eq!(app.final_cmd.status, FinalCmdStatus::Idle); + assert_log_uses_capacity(&app.final_cmd.log); + } + + #[test] + fn app_state_counts_selectable_rows() { + let servers = vec![crate::config::Server { + name: "API".to_string(), + url: "http://127.0.0.1:3000".to_string(), + command: "python3 -m http.server 3000".to_string(), + timeout: 5, + }]; + + let app = AppState::new(&servers, "npm test"); + + assert_eq!(app.selectable_len(), 2); + assert!(app.is_final_selection(1)); + assert!(!app.is_final_selection(0)); + } } diff --git a/src/main.rs b/src/main.rs index 45c7f90..76b0ed2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -42,8 +42,8 @@ async fn async_main(args: Args) -> anyhow::Result<()> { let config = config::get_config(&args.config)?; if args.tui { - anyhow::bail!("TUI mode is not yet implemented"); + runner::tui::run(config, args.attempts).await + } else { + runner::plain::run(config, args.attempts).await } - - runner::plain::run(config, args.attempts).await } diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 6c83de8..aa6e32d 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -1 +1,2 @@ pub mod plain; +pub mod tui; diff --git a/src/runner/tui/app.rs b/src/runner/tui/app.rs new file mode 100644 index 0000000..85193c5 --- /dev/null +++ b/src/runner/tui/app.rs @@ -0,0 +1,184 @@ +use crate::runner::tui::input::UiAction; + +#[allow(dead_code)] // used by TUI in Plan 2 +pub struct TuiApp { + selected: usize, + server_count: usize, + scroll: Vec, + follow_tail: Vec, + footer_message: Option, + should_quit: bool, +} + +#[allow(dead_code)] // used by TUI in Plan 2 +impl TuiApp { + pub fn new(server_count: usize) -> Self { + let row_count = server_count + 1; + Self { + selected: 0, + server_count, + scroll: vec![0; row_count], + follow_tail: vec![true; row_count], + footer_message: None, + should_quit: false, + } + } + + pub fn selected(&self) -> usize { + self.selected + } + + pub fn is_final_row(&self) -> bool { + self.selected == self.server_count + } + + pub fn should_quit(&self) -> bool { + self.should_quit + } + + pub fn request_quit(&mut self) { + self.should_quit = true; + } + + pub fn select_next(&mut self) { + if self.scroll.is_empty() { + return; + } + + self.selected = (self.selected + 1) % self.scroll.len(); + } + + pub fn select_previous(&mut self) { + if self.scroll.is_empty() { + return; + } + + self.selected = self + .selected + .checked_sub(1) + .unwrap_or_else(|| self.scroll.len() - 1); + } + + pub fn scroll_up(&mut self, amount: usize) { + self.scroll[self.selected] += amount; + self.follow_tail[self.selected] = false; + } + + pub fn scroll_down(&mut self, amount: usize) { + self.scroll[self.selected] = self.scroll[self.selected].saturating_sub(amount); + if self.scroll[self.selected] == 0 { + self.follow_tail[self.selected] = true; + } + } + + pub fn follow_tail(&mut self) { + self.scroll[self.selected] = 0; + self.follow_tail[self.selected] = true; + } + + pub fn scroll_offset(&self) -> usize { + self.scroll[self.selected] + } + + pub fn follows_tail(&self) -> bool { + self.follow_tail[self.selected] + } + + pub fn set_footer_message(&mut self, message: impl Into) { + self.footer_message = Some(message.into()); + } + + pub fn clear_footer_message(&mut self) { + self.footer_message = None; + } + + pub fn footer_message(&self) -> Option<&str> { + self.footer_message.as_deref() + } + + pub fn apply_action(&mut self, action: UiAction) { + match action { + UiAction::SelectNext => self.select_next(), + UiAction::SelectPrevious => self.select_previous(), + UiAction::ScrollUp(amount) => self.scroll_up(amount), + UiAction::ScrollDown(amount) => self.scroll_down(amount), + UiAction::FollowTail => self.follow_tail(), + UiAction::Quit => self.request_quit(), + UiAction::Restart + | UiAction::StopStart + | UiAction::RerunFinalCommand + | UiAction::None => {} + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_wraps_across_servers_and_final_command() { + let mut app = TuiApp::new(3); + + assert_eq!(app.selected(), 0); + app.select_next(); + app.select_next(); + app.select_next(); + assert_eq!(app.selected(), 3); + app.select_next(); + assert_eq!(app.selected(), 0); + app.select_previous(); + assert_eq!(app.selected(), 3); + } + + #[test] + fn scrolling_disables_tail_follow_until_end() { + let mut app = TuiApp::new(1); + + assert!(app.follows_tail()); + app.scroll_up(3); + assert_eq!(app.scroll_offset(), 3); + assert!(!app.follows_tail()); + app.scroll_down(1); + assert_eq!(app.scroll_offset(), 2); + assert!(!app.follows_tail()); + app.follow_tail(); + assert_eq!(app.scroll_offset(), 0); + assert!(app.follows_tail()); + } + + #[test] + fn footer_message_can_be_set_and_cleared() { + let mut app = TuiApp::new(1); + + app.set_footer_message("Servers are not ready"); + assert_eq!(app.footer_message(), Some("Servers are not ready")); + app.clear_footer_message(); + assert_eq!(app.footer_message(), None); + } + + #[test] + fn applies_input_actions_to_local_state() { + let mut app = TuiApp::new(2); + + app.apply_action(crate::runner::tui::input::UiAction::SelectNext); + assert_eq!(app.selected(), 1); + app.apply_action(crate::runner::tui::input::UiAction::ScrollUp(3)); + assert_eq!(app.scroll_offset(), 3); + app.apply_action(crate::runner::tui::input::UiAction::FollowTail); + assert_eq!(app.scroll_offset(), 0); + app.apply_action(crate::runner::tui::input::UiAction::Quit); + assert!(app.should_quit()); + } + + #[test] + fn final_row_detection_uses_server_count() { + let app = TuiApp::new(2); + assert!(!app.is_final_row()); + + let mut app = TuiApp::new(2); + app.select_next(); + app.select_next(); + assert!(app.is_final_row()); + } +} diff --git a/src/runner/tui/input.rs b/src/runner/tui/input.rs new file mode 100644 index 0000000..ec54c39 --- /dev/null +++ b/src/runner/tui/input.rs @@ -0,0 +1,144 @@ +#![allow(dead_code)] // used by TUI in Plan 2 + +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum InputEvent { + Key(KeyEvent), + Mouse(MouseEvent), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum UiAction { + SelectNext, + SelectPrevious, + ScrollUp(usize), + ScrollDown(usize), + FollowTail, + Restart, + StopStart, + RerunFinalCommand, + Quit, + None, +} + +pub fn map_event(event: InputEvent) -> UiAction { + match event { + InputEvent::Key(key) => map_key_event(key), + InputEvent::Mouse(mouse) => map_mouse_event(mouse), + } +} + +fn map_key_event(key: KeyEvent) -> UiAction { + match (key.code, key.modifiers) { + (KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab, KeyModifiers::NONE) => { + UiAction::SelectNext + } + (KeyCode::Up | KeyCode::Char('k'), KeyModifiers::NONE) => UiAction::SelectPrevious, + (KeyCode::PageUp, KeyModifiers::NONE) => UiAction::ScrollUp(10), + (KeyCode::PageDown, KeyModifiers::NONE) => UiAction::ScrollDown(10), + (KeyCode::End, KeyModifiers::NONE) => UiAction::FollowTail, + (KeyCode::Char('r'), KeyModifiers::NONE) => UiAction::Restart, + (KeyCode::Char('s'), KeyModifiers::NONE) => UiAction::StopStart, + (KeyCode::Char('e'), KeyModifiers::NONE) => UiAction::RerunFinalCommand, + (KeyCode::Char('q'), KeyModifiers::NONE) => UiAction::Quit, + (KeyCode::Char('c'), KeyModifiers::CONTROL) => UiAction::Quit, + _ => UiAction::None, + } +} + +fn map_mouse_event(mouse: MouseEvent) -> UiAction { + match mouse.kind { + MouseEventKind::ScrollUp => UiAction::ScrollUp(3), + MouseEventKind::ScrollDown => UiAction::ScrollDown(3), + _ => UiAction::None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crossterm::event::{ + KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, + }; + + fn key(code: KeyCode) -> InputEvent { + InputEvent::Key(KeyEvent::new(code, KeyModifiers::NONE)) + } + + fn ctrl_key(code: KeyCode) -> InputEvent { + InputEvent::Key(KeyEvent::new(code, KeyModifiers::CONTROL)) + } + + fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> InputEvent { + InputEvent::Key(KeyEvent::new(code, modifiers)) + } + + fn mouse(kind: MouseEventKind) -> InputEvent { + InputEvent::Mouse(MouseEvent { + kind, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }) + } + + #[test] + fn maps_navigation_keys() { + assert_eq!(map_event(key(KeyCode::Down)), UiAction::SelectNext); + assert_eq!(map_event(key(KeyCode::Char('j'))), UiAction::SelectNext); + assert_eq!(map_event(key(KeyCode::Tab)), UiAction::SelectNext); + assert_eq!(map_event(key(KeyCode::Up)), UiAction::SelectPrevious); + assert_eq!(map_event(key(KeyCode::Char('k'))), UiAction::SelectPrevious); + } + + #[test] + fn maps_scroll_keys_and_mouse_wheel() { + assert_eq!(map_event(key(KeyCode::PageUp)), UiAction::ScrollUp(10)); + assert_eq!(map_event(key(KeyCode::PageDown)), UiAction::ScrollDown(10)); + assert_eq!(map_event(key(KeyCode::End)), UiAction::FollowTail); + assert_eq!( + map_event(mouse(MouseEventKind::ScrollUp)), + UiAction::ScrollUp(3) + ); + assert_eq!( + map_event(mouse(MouseEventKind::ScrollDown)), + UiAction::ScrollDown(3) + ); + assert_eq!( + map_event(mouse(MouseEventKind::Down(MouseButton::Left))), + UiAction::None + ); + } + + #[test] + fn maps_engine_commands_and_quit() { + assert_eq!(map_event(key(KeyCode::Char('r'))), UiAction::Restart); + assert_eq!(map_event(key(KeyCode::Char('s'))), UiAction::StopStart); + assert_eq!( + map_event(key(KeyCode::Char('e'))), + UiAction::RerunFinalCommand + ); + assert_eq!(map_event(key(KeyCode::Char('q'))), UiAction::Quit); + assert_eq!(map_event(ctrl_key(KeyCode::Char('c'))), UiAction::Quit); + } + + #[test] + fn ignores_modified_shortcut_keys_except_ctrl_c() { + assert_eq!(map_event(ctrl_key(KeyCode::Char('r'))), UiAction::None); + assert_eq!( + map_event(modified_key(KeyCode::Char('e'), KeyModifiers::ALT)), + UiAction::None + ); + assert_eq!(map_event(ctrl_key(KeyCode::Char('q'))), UiAction::None); + assert_eq!( + map_event(modified_key(KeyCode::Down, KeyModifiers::SHIFT)), + UiAction::None + ); + assert_eq!( + map_event(modified_key(KeyCode::Tab, KeyModifiers::CONTROL)), + UiAction::None + ); + assert_eq!(map_event(ctrl_key(KeyCode::Char('c'))), UiAction::Quit); + } +} diff --git a/src/runner/tui/mod.rs b/src/runner/tui/mod.rs new file mode 100644 index 0000000..717f51e --- /dev/null +++ b/src/runner/tui/mod.rs @@ -0,0 +1,234 @@ +use anyhow::Context; +use crossterm::event::{self, DisableMouseCapture, EnableMouseCapture, Event}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use tokio::sync::mpsc; + +use std::io::{Stdout, stdout}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use crate::config::Config; +use crate::core::actor::{EngineCommand, run_tui_engine}; +use crate::core::state::{AppState, ServerStatus}; +use crate::runner::tui::app::TuiApp; +use crate::runner::tui::input::{InputEvent, UiAction, map_event}; + +pub mod app; +pub mod input; +pub mod ui; + +pub struct TerminalMode { + pub mouse_capture: bool, +} + +impl TerminalMode { + pub fn new(mouse_capture: bool) -> Self { + Self { mouse_capture } + } +} + +struct TerminalGuard { + mode: TerminalMode, +} + +impl TerminalGuard { + fn enter(mode: TerminalMode) -> anyhow::Result { + enable_raw_mode()?; + let mut stdout = stdout(); + if let Err(error) = enter_terminal_mode(&mut stdout, &mode) { + let _ = disable_raw_mode(); + return Err(error.into()); + } + + Ok(Self { mode }) + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + restore_terminal_mode(&self.mode); + } +} + +fn enter_terminal_mode(stdout: &mut Stdout, mode: &TerminalMode) -> std::io::Result<()> { + execute!(stdout, EnterAlternateScreen)?; + if mode.mouse_capture { + execute!(stdout, EnableMouseCapture)?; + } + Ok(()) +} + +fn restore_terminal_mode(mode: &TerminalMode) { + let mut stdout = stdout(); + if mode.mouse_capture { + let _ = execute!(stdout, DisableMouseCapture); + } + let _ = execute!(stdout, LeaveAlternateScreen); + let _ = disable_raw_mode(); +} + +fn install_panic_hook() { + let previous_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + restore_terminal_mode(&TerminalMode::new(true)); + previous_hook(panic_info); + })); +} + +pub async fn run(config: Config, max_attempts: u8) -> anyhow::Result<()> { + install_panic_hook(); + + let state = Arc::new(Mutex::new(AppState::new(&config.servers, &config.command))); + let mut app = TuiApp::new(config.servers.len()); + let (tx, rx) = mpsc::channel(32); + let actor_state = Arc::clone(&state); + let actor = tokio::spawn(run_tui_engine(config, max_attempts, actor_state, rx)); + tokio::pin!(actor); + + let ui_loop = run_terminal_loop(&mut app, Arc::clone(&state), tx.clone()); + tokio::pin!(ui_loop); + + tokio::select! { + ui_result = &mut ui_loop => { + match ui_result { + Ok(true) => {} + Ok(false) => { + let _ = tx.send(EngineCommand::Quit).await; + } + Err(error) => { + let _ = tx.send(EngineCommand::Quit).await; + let actor_result = (&mut actor).await.context("TUI engine task failed")?; + actor_result?; + return Err(error); + } + } + let actor_result = (&mut actor).await.context("TUI engine task failed")?; + actor_result?; + Ok(()) + } + actor_result = &mut actor => { + actor_result.context("TUI engine task failed")? + } + } +} + +async fn run_terminal_loop( + app: &mut TuiApp, + state: Arc>, + tx: mpsc::Sender, +) -> anyhow::Result { + let _guard = TerminalGuard::enter(TerminalMode::new(true))?; + let backend = CrosstermBackend::new(stdout()); + let mut terminal = Terminal::new(backend)?; + + while !app.should_quit() { + terminal.draw(|frame| ui::render(frame, app, &state))?; + + if event::poll(Duration::from_millis(50))? { + match event::read()? { + Event::Key(key) => { + handle_action(map_event(InputEvent::Key(key)), app, &state, &tx).await?; + } + Event::Mouse(mouse) => { + handle_action(map_event(InputEvent::Mouse(mouse)), app, &state, &tx).await?; + } + _ => {} + } + } + } + + Ok(app.should_quit()) +} + +async fn handle_action( + action: UiAction, + app: &mut TuiApp, + state: &Arc>, + tx: &mpsc::Sender, +) -> anyhow::Result<()> { + match action { + UiAction::Restart => { + if app.is_final_row() { + app.set_footer_message("Restart applies to servers only"); + } else { + tx.send(EngineCommand::Restart(app.selected())).await?; + } + } + UiAction::StopStart => { + if app.is_final_row() { + app.set_footer_message("Stop/start applies to servers only"); + } else { + tx.send(EngineCommand::StopStart(app.selected())).await?; + } + } + UiAction::RerunFinalCommand => { + if !app.is_final_row() { + app.set_footer_message("Re-run applies to the final command only"); + } else if all_servers_running(state) { + tx.send(EngineCommand::RerunFinalCommand).await?; + } else { + app.set_footer_message("All servers must be running before re-run"); + } + } + UiAction::Quit => { + let _ = tx.send(EngineCommand::Quit).await; + app.request_quit(); + } + other => app.apply_action(other), + } + + Ok(()) +} + +fn all_servers_running(state: &Arc>) -> bool { + state + .lock() + .map(|state| { + state + .servers + .iter() + .all(|server| server.status == ServerStatus::Running) + }) + .unwrap_or(false) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::sync::mpsc::error::TryRecvError; + + #[test] + fn terminal_guard_mode_tracks_mouse_capture_setting() { + let mode = TerminalMode::new(true); + assert!(mode.mouse_capture); + } + + #[tokio::test] + async fn rerun_final_command_on_server_row_sets_footer_without_command() { + let servers = vec![crate::config::Server { + name: "API".to_string(), + url: "http://127.0.0.1:3000".to_string(), + command: "python3 -m http.server 3000".to_string(), + timeout: 5, + }]; + let state = Arc::new(Mutex::new(AppState::new(&servers, "npm test"))); + state.lock().unwrap().servers[0].status = ServerStatus::Running; + let mut app = TuiApp::new(1); + let (tx, mut rx) = mpsc::channel(1); + + handle_action(UiAction::RerunFinalCommand, &mut app, &state, &tx) + .await + .unwrap(); + + assert_eq!( + app.footer_message(), + Some("Re-run applies to the final command only") + ); + assert_eq!(rx.try_recv(), Err(TryRecvError::Empty)); + } +} diff --git a/src/runner/tui/ui.rs b/src/runner/tui/ui.rs new file mode 100644 index 0000000..bc49216 --- /dev/null +++ b/src/runner/tui/ui.rs @@ -0,0 +1,277 @@ +#![allow(dead_code)] // wired into the TUI runtime in a later Plan 2 task + +use ratatui::Frame; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::widgets::{Block, Paragraph}; +use std::sync::{Arc, Mutex}; + +use crate::core::state::{ + AppState, FinalCmdStatus, FinalCmdView, RingBuffer, ServerStatus, ServerView, +}; +use crate::runner::tui::app::TuiApp; + +pub fn render(frame: &mut Frame, tui: &TuiApp, state: &Arc>) { + let Ok(state) = state.lock() else { + return; + }; + + let root = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(1), Constraint::Length(1)]) + .split(frame.area()); + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(28), Constraint::Min(10)]) + .split(root[0]); + + let sidebar = sidebar_items(tui, &state).join("\n"); + frame.render_widget( + Paragraph::new(sidebar).block(Block::bordered().title("server-runner")), + body[0], + ); + + let detail_height = body[1].height.saturating_sub(2) as usize; + let detail = selected_log(tui, &state, detail_height).join("\n"); + frame.render_widget( + Paragraph::new(detail).block(Block::bordered().title("log")), + body[1], + ); + + let footer = tui + .footer_message() + .unwrap_or("j/k select | PgUp/PgDn scroll | r restart | s stop/start | e rerun | q quit"); + frame.render_widget(Paragraph::new(footer), root[1]); +} + +fn sidebar_items(tui: &TuiApp, state: &AppState) -> Vec { + let mut items: Vec = state + .servers + .iter() + .enumerate() + .map(|(index, server)| server_item(index, tui.selected(), server)) + .collect(); + items.push(final_cmd_item( + state.servers.len(), + tui.selected(), + &state.final_cmd, + )); + items +} + +fn server_item(index: usize, selected: usize, server: &ServerView) -> String { + format!( + "{} {:<6} {}", + selection_marker(index, selected), + server_status(server.status, server.attempts), + server.name + ) +} + +fn final_cmd_item(index: usize, selected: usize, final_cmd: &FinalCmdView) -> String { + format!( + "{} {:<5} {}", + selection_marker(index, selected), + final_cmd_status(final_cmd.status), + final_cmd.command + ) +} + +fn selection_marker(index: usize, selected: usize) -> &'static str { + if index == selected { ">" } else { " " } +} + +fn server_status(status: ServerStatus, attempts: crate::core::state::Attempts) -> String { + match status { + ServerStatus::Waiting => format!("WAIT {attempts}"), + ServerStatus::Running => "RUN".to_string(), + ServerStatus::Failed => "FAIL".to_string(), + ServerStatus::Stopped => "STOP".to_string(), + } +} + +fn final_cmd_status(status: FinalCmdStatus) -> String { + match status { + FinalCmdStatus::Idle => "IDLE".to_string(), + FinalCmdStatus::Running => "RUN".to_string(), + FinalCmdStatus::Succeeded(code) => format!("OK {code}"), + FinalCmdStatus::Failed(code) => format!("ERR {code}"), + } +} + +fn selected_log(tui: &TuiApp, state: &AppState, height: usize) -> Vec { + if state.is_final_selection(tui.selected()) { + return log_lines(&state.final_cmd.log, tui.scroll_offset(), height); + } + + state + .servers + .get(tui.selected()) + .map(|server| log_lines(&server.log, tui.scroll_offset(), height)) + .unwrap_or_default() +} + +fn log_lines(log: &Arc>, scroll_offset: usize, height: usize) -> Vec { + let Ok(log) = log.lock() else { + return Vec::new(); + }; + + let lines: Vec = log.iter().cloned().collect(); + if height == 0 || lines.is_empty() { + return Vec::new(); + } + + let end = lines.len().saturating_sub(scroll_offset).max(1); + let start = end.saturating_sub(height); + lines[start..end].to_vec() +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + use ratatui::buffer::Buffer; + use std::sync::{Arc, Mutex}; + + use crate::core::state::{ + AppState, Attempts, FinalCmdStatus, FinalCmdView, RingBuffer, ServerStatus, ServerView, + }; + use crate::runner::tui::app::TuiApp; + + fn sample_state() -> Arc> { + let api_log = Arc::new(Mutex::new(RingBuffer::new(10))); + api_log.lock().unwrap().push("api ready".to_string()); + let worker_log = Arc::new(Mutex::new(RingBuffer::new(10))); + worker_log.lock().unwrap().push("worker failed".to_string()); + let final_log = Arc::new(Mutex::new(RingBuffer::new(10))); + final_log.lock().unwrap().push("tests passed".to_string()); + + Arc::new(Mutex::new(AppState { + servers: vec![ + ServerView { + name: "API".to_string(), + url: "http://localhost:3000".to_string(), + status: ServerStatus::Running, + attempts: Attempts(2), + log: api_log, + }, + ServerView { + name: "Worker".to_string(), + url: "http://localhost:3001".to_string(), + status: ServerStatus::Failed, + attempts: Attempts(5), + log: worker_log, + }, + ], + final_cmd: FinalCmdView { + command: "npm test".to_string(), + status: FinalCmdStatus::Succeeded(0), + log: final_log, + }, + })) + } + + fn render_to_string(tui: &TuiApp, state: &Arc>) -> String { + let backend = TestBackend::new(80, 20); + let mut terminal = Terminal::new(backend).unwrap(); + + terminal + .draw(|frame| super::render(frame, tui, state)) + .unwrap(); + + buffer_to_string(terminal.backend().buffer()) + } + + fn rendered_line<'a>(output: &'a str, needle: &str) -> &'a str { + output.lines().find(|line| line.contains(needle)).unwrap() + } + + #[allow(deprecated)] + fn buffer_to_string(buffer: &Buffer) -> String { + let mut output = String::new(); + for y in buffer.area.y..buffer.area.y + buffer.area.height { + for x in buffer.area.x..buffer.area.x + buffer.area.width { + output.push_str(buffer.get(x, y).symbol()); + } + output.push('\n'); + } + output + } + + #[test] + fn renders_sidebar_statuses_and_selected_log() { + let tui = TuiApp::new(2); + let state = sample_state(); + + let output = render_to_string(&tui, &state); + + assert!(output.contains("server-runner")); + assert!(output.contains("API")); + assert!(output.contains("RUN")); + assert!(output.contains("Worker")); + assert!(output.contains("FAIL")); + assert!(output.contains("npm test")); + assert!(output.contains("OK 0")); + assert!(output.contains("api ready")); + + assert!(!rendered_line(&output, "API").contains("2")); + assert!(!rendered_line(&output, "Worker").contains("5")); + } + + #[test] + fn renders_wait_status_with_attempts() { + let state = sample_state(); + state.lock().unwrap().servers[0].status = ServerStatus::Waiting; + let tui = TuiApp::new(2); + + let output = render_to_string(&tui, &state); + + assert!(rendered_line(&output, "API").contains("WAIT 2")); + } + + #[test] + fn scroll_offset_renders_older_log_window() { + let state = sample_state(); + { + let mut state = state.lock().unwrap(); + state.servers[0].log = Arc::new(Mutex::new(RingBuffer::new(40))); + let mut log = state.servers[0].log.lock().unwrap(); + for index in 0..30 { + log.push(format!("line-{index:02}")); + } + } + let mut tui = TuiApp::new(2); + tui.scroll_up(5); + + let output = render_to_string(&tui, &state); + + assert!(output.contains("line-08")); + assert!(output.contains("line-24")); + assert!(!output.contains("line-29")); + } + + #[test] + fn renders_default_footer_controls() { + let tui = TuiApp::new(2); + let state = sample_state(); + + let output = render_to_string(&tui, &state); + + assert!(output.contains("select")); + assert!(output.contains("scroll")); + assert!(output.contains("r restart")); + assert!(output.contains("s stop/start")); + assert!(output.contains("e rerun")); + assert!(output.contains("q quit")); + } + + #[test] + fn renders_footer_message_when_present() { + let mut tui = TuiApp::new(2); + tui.set_footer_message("All servers must be running"); + let state = sample_state(); + + let output = render_to_string(&tui, &state); + + assert!(output.contains("All servers must be running")); + } +}