├── .github └── workflows │ └── rust.yml ├── .gitignore ├── Cargo.lock ├── Cargo.toml ├── README.md ├── coverage_data.csv ├── src ├── errors.rs ├── file_io.rs ├── main.rs ├── output_display.rs └── subcommands.rs └── x.py /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Install and switch to nightly 20 | run: rustup toolchain install nightly && rustup default nightly 21 | - name: Install linter (clippy) 22 | run: rustup component add clippy 23 | - name: Run clippy 24 | run: cargo clippy -- -D warnings 25 | - name: Run tests 26 | run: cargo test --verbose -- --test-threads=1 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | ~ 3 | *.profraw 4 | coverage/ 5 | *.testfile 6 | TEST 7 | -------------------------------------------------------------------------------- /Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 3 4 | 5 | [[package]] 6 | name = "atty" 7 | version = "0.2.14" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" 10 | dependencies = [ 11 | "hermit-abi", 12 | "libc", 13 | "winapi", 14 | ] 15 | 16 | [[package]] 17 | name = "autocfg" 18 | version = "1.0.1" 19 | source = "registry+https://github.com/rust-lang/crates.io-index" 20 | checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" 21 | 22 | [[package]] 23 | name = "bitflags" 24 | version = "1.3.1" 25 | source = "registry+https://github.com/rust-lang/crates.io-index" 26 | checksum = "2da1976d75adbe5fbc88130ecd119529cf1cc6a93ae1546d8696ee66f0d21af1" 27 | 28 | [[package]] 29 | name = "cfg-if" 30 | version = "1.0.0" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" 33 | 34 | [[package]] 35 | name = "clap" 36 | version = "3.1.14" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "535434c063ced786eb04aaf529308092c5ab60889e8fe24275d15de07b01fa97" 39 | dependencies = [ 40 | "atty", 41 | "bitflags", 42 | "clap_lex", 43 | "indexmap", 44 | "strsim", 45 | "termcolor", 46 | "textwrap", 47 | ] 48 | 49 | [[package]] 50 | name = "clap_lex" 51 | version = "0.2.0" 52 | source = "registry+https://github.com/rust-lang/crates.io-index" 53 | checksum = "a37c35f1112dad5e6e0b1adaff798507497a18fceeb30cceb3bae7d1427b9213" 54 | dependencies = [ 55 | "os_str_bytes", 56 | ] 57 | 58 | [[package]] 59 | name = "dirs-next" 60 | version = "2.0.0" 61 | source = "registry+https://github.com/rust-lang/crates.io-index" 62 | checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" 63 | dependencies = [ 64 | "cfg-if", 65 | "dirs-sys-next", 66 | ] 67 | 68 | [[package]] 69 | name = "dirs-sys-next" 70 | version = "0.1.2" 71 | source = "registry+https://github.com/rust-lang/crates.io-index" 72 | checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" 73 | dependencies = [ 74 | "libc", 75 | "redox_users", 76 | "winapi", 77 | ] 78 | 79 | [[package]] 80 | name = "eggsecutor" 81 | version = "1.2.0" 82 | dependencies = [ 83 | "clap", 84 | "serde", 85 | "serde_json", 86 | "shellexpand", 87 | "uuid", 88 | ] 89 | 90 | [[package]] 91 | name = "getrandom" 92 | version = "0.2.3" 93 | source = "registry+https://github.com/rust-lang/crates.io-index" 94 | checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" 95 | dependencies = [ 96 | "cfg-if", 97 | "libc", 98 | "wasi", 99 | ] 100 | 101 | [[package]] 102 | name = "hashbrown" 103 | version = "0.11.2" 104 | source = "registry+https://github.com/rust-lang/crates.io-index" 105 | checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" 106 | 107 | [[package]] 108 | name = "hermit-abi" 109 | version = "0.1.19" 110 | source = "registry+https://github.com/rust-lang/crates.io-index" 111 | checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" 112 | dependencies = [ 113 | "libc", 114 | ] 115 | 116 | [[package]] 117 | name = "indexmap" 118 | version = "1.7.0" 119 | source = "registry+https://github.com/rust-lang/crates.io-index" 120 | checksum = "bc633605454125dec4b66843673f01c7df2b89479b32e0ed634e43a91cff62a5" 121 | dependencies = [ 122 | "autocfg", 123 | "hashbrown", 124 | ] 125 | 126 | [[package]] 127 | name = "itoa" 128 | version = "0.4.7" 129 | source = "registry+https://github.com/rust-lang/crates.io-index" 130 | checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" 131 | 132 | [[package]] 133 | name = "libc" 134 | version = "0.2.99" 135 | source = "registry+https://github.com/rust-lang/crates.io-index" 136 | checksum = "a7f823d141fe0a24df1e23b4af4e3c7ba9e5966ec514ea068c93024aa7deb765" 137 | 138 | [[package]] 139 | name = "os_str_bytes" 140 | version = "6.0.0" 141 | source = "registry+https://github.com/rust-lang/crates.io-index" 142 | checksum = "8e22443d1643a904602595ba1cd8f7d896afe56d26712531c5ff73a15b2fbf64" 143 | 144 | [[package]] 145 | name = "proc-macro2" 146 | version = "1.0.28" 147 | source = "registry+https://github.com/rust-lang/crates.io-index" 148 | checksum = "5c7ed8b8c7b886ea3ed7dde405212185f423ab44682667c8c6dd14aa1d9f6612" 149 | dependencies = [ 150 | "unicode-xid", 151 | ] 152 | 153 | [[package]] 154 | name = "quote" 155 | version = "1.0.9" 156 | source = "registry+https://github.com/rust-lang/crates.io-index" 157 | checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" 158 | dependencies = [ 159 | "proc-macro2", 160 | ] 161 | 162 | [[package]] 163 | name = "redox_syscall" 164 | version = "0.2.10" 165 | source = "registry+https://github.com/rust-lang/crates.io-index" 166 | checksum = "8383f39639269cde97d255a32bdb68c047337295414940c68bdd30c2e13203ff" 167 | dependencies = [ 168 | "bitflags", 169 | ] 170 | 171 | [[package]] 172 | name = "redox_users" 173 | version = "0.4.0" 174 | source = "registry+https://github.com/rust-lang/crates.io-index" 175 | checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" 176 | dependencies = [ 177 | "getrandom", 178 | "redox_syscall", 179 | ] 180 | 181 | [[package]] 182 | name = "ryu" 183 | version = "1.0.5" 184 | source = "registry+https://github.com/rust-lang/crates.io-index" 185 | checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" 186 | 187 | [[package]] 188 | name = "serde" 189 | version = "1.0.127" 190 | source = "registry+https://github.com/rust-lang/crates.io-index" 191 | checksum = "f03b9878abf6d14e6779d3f24f07b2cfa90352cfec4acc5aab8f1ac7f146fae8" 192 | dependencies = [ 193 | "serde_derive", 194 | ] 195 | 196 | [[package]] 197 | name = "serde_derive" 198 | version = "1.0.127" 199 | source = "registry+https://github.com/rust-lang/crates.io-index" 200 | checksum = "a024926d3432516606328597e0f224a51355a493b49fdd67e9209187cbe55ecc" 201 | dependencies = [ 202 | "proc-macro2", 203 | "quote", 204 | "syn", 205 | ] 206 | 207 | [[package]] 208 | name = "serde_json" 209 | version = "1.0.66" 210 | source = "registry+https://github.com/rust-lang/crates.io-index" 211 | checksum = "336b10da19a12ad094b59d870ebde26a45402e5b470add4b5fd03c5048a32127" 212 | dependencies = [ 213 | "itoa", 214 | "ryu", 215 | "serde", 216 | ] 217 | 218 | [[package]] 219 | name = "shellexpand" 220 | version = "2.1.0" 221 | source = "registry+https://github.com/rust-lang/crates.io-index" 222 | checksum = "83bdb7831b2d85ddf4a7b148aa19d0587eddbe8671a436b7bd1182eaad0f2829" 223 | dependencies = [ 224 | "dirs-next", 225 | ] 226 | 227 | [[package]] 228 | name = "strsim" 229 | version = "0.10.0" 230 | source = "registry+https://github.com/rust-lang/crates.io-index" 231 | checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" 232 | 233 | [[package]] 234 | name = "syn" 235 | version = "1.0.74" 236 | source = "registry+https://github.com/rust-lang/crates.io-index" 237 | checksum = "1873d832550d4588c3dbc20f01361ab00bfe741048f71e3fecf145a7cc18b29c" 238 | dependencies = [ 239 | "proc-macro2", 240 | "quote", 241 | "unicode-xid", 242 | ] 243 | 244 | [[package]] 245 | name = "termcolor" 246 | version = "1.1.2" 247 | source = "registry+https://github.com/rust-lang/crates.io-index" 248 | checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" 249 | dependencies = [ 250 | "winapi-util", 251 | ] 252 | 253 | [[package]] 254 | name = "textwrap" 255 | version = "0.15.0" 256 | source = "registry+https://github.com/rust-lang/crates.io-index" 257 | checksum = "b1141d4d61095b28419e22cb0bbf02755f5e54e0526f97f1e3d1d160e60885fb" 258 | 259 | [[package]] 260 | name = "unicode-xid" 261 | version = "0.2.2" 262 | source = "registry+https://github.com/rust-lang/crates.io-index" 263 | checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" 264 | 265 | [[package]] 266 | name = "uuid" 267 | version = "0.8.2" 268 | source = "registry+https://github.com/rust-lang/crates.io-index" 269 | checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" 270 | dependencies = [ 271 | "getrandom", 272 | ] 273 | 274 | [[package]] 275 | name = "wasi" 276 | version = "0.10.2+wasi-snapshot-preview1" 277 | source = "registry+https://github.com/rust-lang/crates.io-index" 278 | checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" 279 | 280 | [[package]] 281 | name = "winapi" 282 | version = "0.3.9" 283 | source = "registry+https://github.com/rust-lang/crates.io-index" 284 | checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" 285 | dependencies = [ 286 | "winapi-i686-pc-windows-gnu", 287 | "winapi-x86_64-pc-windows-gnu", 288 | ] 289 | 290 | [[package]] 291 | name = "winapi-i686-pc-windows-gnu" 292 | version = "0.4.0" 293 | source = "registry+https://github.com/rust-lang/crates.io-index" 294 | checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" 295 | 296 | [[package]] 297 | name = "winapi-util" 298 | version = "0.1.5" 299 | source = "registry+https://github.com/rust-lang/crates.io-index" 300 | checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" 301 | dependencies = [ 302 | "winapi", 303 | ] 304 | 305 | [[package]] 306 | name = "winapi-x86_64-pc-windows-gnu" 307 | version = "0.4.0" 308 | source = "registry+https://github.com/rust-lang/crates.io-index" 309 | checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 310 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "eggsecutor" 3 | version = "2.0.0" 4 | edition = "2018" 5 | license = "MIT OR Apache-2.0" 6 | description = "A simple and lightweight stateful daemon tracking CLI" 7 | readme = "README.md" 8 | repository = "https://github.com/astherath/eggsecutor" 9 | keywords = ["daemon", "task-manager"] 10 | categories = ["command-line-utilities"] 11 | exclude = [ 12 | "coverage*", 13 | "*.py", 14 | ] 15 | 16 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 17 | 18 | [dependencies] 19 | clap = "3.1.14" 20 | shellexpand = "2.1" 21 | serde_json = "1.0.59" 22 | serde = {version = "1.0.127", features = ["derive"]} 23 | uuid = {version = "0.8", features = ["v4"]} 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍳eggsecutor 🥚 2 | 3 | A friendly file based stateful daemon tracker. 4 | 5 | # Table of contents 6 | 7 | - [Installation](#installation) 8 | - [Usage](#usage) 9 | - [Common usage example](#common-usage-example) 10 | - [Customization](#customization) 11 | 12 | # Installation 13 | 14 | - Install from [crates.io](https://crates.io/crates/eggsecutor) 15 | 16 | - `cargo install eggsecutor` 17 | 18 | - Build manually from source 19 | ```sh 20 | $ git clone https://github.com/astherath/eggsecutor 21 | $ cd eggsecutor 22 | $ cargo install --path=. 23 | ``` 24 | 25 | # Usage 26 | 27 | `eggsecutor` works best when launching single-file binaries that are meant to run as background processes. 28 | 29 | ``` 30 | eggsecutor 1.0 31 | 32 | astherath 33 | 34 | A friendly background process task manager 35 | 36 | USAGE: 37 | eggsecutor [SUBCOMMAND] 38 | 39 | OPTIONS: 40 | -h, --help Print help information 41 | -V, --version Print version information 42 | 43 | SUBCOMMANDS: 44 | clear stops all of the processes being tracked and clears the tracking list 45 | hatch start managing a binary process 46 | help Print this message or the help of the given subcommand(s) 47 | list list all managed processes 48 | stop stop a process by name or pid 49 | ``` 50 | 51 | ## Common usage example 52 | 53 | A simple example that should run as-is to showcase the main usage loop (*flask and python3 required*) 54 | 55 | ```sh 56 | # create a simple flask server daemon in a file named "FLASK_SERVER" 57 | cat << EOT >> FLASK_SERVER 58 | #!/usr/bin/python3 59 | from flask import Flask 60 | 61 | app = Flask(__name__) 62 | 63 | @app.route("/") 64 | def index(): 65 | return {"status": 200} 66 | 67 | if __name__ == "__main__": 68 | app.run() 69 | EOT 70 | 71 | # make the file executable 72 | chmod +x FLASK_SERVER 73 | 74 | # start the process from an executable file 75 | eggsecutor hatch FLASK_SERVER 76 | > Hatching process "FLASK_SERVER" and starting to track... 77 | > egg hatched, tracking process with pid: "3670" 78 | 79 | # check the process is healthy 80 | eggsecutor list 81 | > Process name pid status 82 | > ----------------------------------- 83 | > FLASK_SERVER 3670 Running 84 | 85 | # once ready shut down the server by name (or pid) 86 | # the following are equivalent 87 | eggsecutor stop FLASK_SERVER 88 | eggsecutor stop 3670 89 | > stopping process with pid: 3670 90 | 91 | # or, if you want to stop ALL running processes being tracked 92 | eggsecutor clear 93 | ``` 94 | 95 | # Customization 96 | 97 | By design, `eggsecutor` is meant to be a low-maintenance (and therefore, low-option) tool. 98 | 99 | The only user-defined variable is the location of the JSON state tracking file, which defaults to 100 | 101 | `~/.eggsecutor.state` 102 | 103 | 104 | 105 | If need be, this path can be overwritten by setting the `EGGSECUTOR_STATE_FILE` environment variable to a valid file path (if the path does not exist, it will be created upon first usage). 106 | -------------------------------------------------------------------------------- /coverage_data.csv: -------------------------------------------------------------------------------- 1 | datetime,cov(%),sloc 2 | 2021-11-17 11:06:09,32%,536 3 | 2021-11-19 11:07:27,36%,561 4 | 2021-11-19 12:17:45,40%,574 5 | 2021-11-19 12:22:55,38%,573 6 | 2021-11-19 13:43:21,41%,596 7 | 2021-11-19 13:51:56,41%,596 8 | 2021-11-19 15:15:14,39%,608 9 | 2021-11-19 15:49:25,44%,609 10 | 2021-11-21 21:21:04,45%,621 11 | 2021-11-21 21:34:48,50%,651 12 | 2021-11-21 21:35:47,49%,649 13 | 2021-11-21 21:48:28,48%,684 14 | 2021-11-21 21:54:16,46%,682 15 | 2021-11-21 23:04:51,50%,722 16 | 2021-11-22 09:36:43.098567,50%,722 17 | 2021-11-22 10:24:46.469873,54%,772 18 | 2021-11-22 11:25:07.573888,54%,802 19 | 2021-11-22 12:02:51.807584,55%,811 20 | 2021-11-22 21:56:35.719078,57%,850 21 | 2021-11-23 11:36:10.233601,57%,850 22 | 2021-11-23 11:38:04.360627,58%,863 23 | 2021-11-23 12:41:37.778214,59%,885 24 | 2021-11-23 12:45:38.115609,59%,885 25 | 2021-11-23 12:57:57.120812,60%,889 26 | 2021-11-23 13:16:27.045669,61%,913 27 | 2021-11-23 13:26:16.333708,62%,934 28 | 2021-11-23 13:28:43.576094,62%,934 29 | 2021-11-24 20:19:26.671140,62%,932 30 | 2022-05-02 11:05:08.321937,74%,935 31 | 2022-05-02 11:12:41.566403,72%,934 32 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | use clap::{Error, ErrorKind}; 2 | use std::io; 3 | 4 | pub fn handle_spawn_failure(err_reason: io::Error) -> ! { 5 | get_spawn_failure_error(err_reason).exit(); 6 | } 7 | 8 | pub fn handle_no_file_data_error() -> ! { 9 | get_no_file_data_error().exit(); 10 | } 11 | 12 | pub fn handle_no_such_process_error(process_info: &str) -> ! { 13 | get_no_such_process_error(process_info).exit(); 14 | } 15 | 16 | pub fn handle_process_boot_error(err_reason: io::Error) -> ! { 17 | get_process_boot_error(err_reason).exit(); 18 | } 19 | 20 | pub fn get_invalid_file_path_error() -> Error { 21 | Error::with_description( 22 | "invalid path to binary: file does not exist or is inaccessible".to_string(), 23 | ErrorKind::InvalidValue, 24 | ) 25 | } 26 | 27 | fn get_spawn_failure_error(err_reason: io::Error) -> Error { 28 | Error::with_description( 29 | format!( 30 | "could not hatch process: binary could not be executed. Details: {}", 31 | err_reason 32 | ), 33 | ErrorKind::Io, 34 | ) 35 | } 36 | 37 | fn get_no_file_data_error() -> Error { 38 | Error::with_description( 39 | "no state file data found. Add a process to track first".to_string(), 40 | ErrorKind::Io, 41 | ) 42 | } 43 | 44 | fn get_no_such_process_error(process_info: &str) -> Error { 45 | Error::with_description( 46 | format!( 47 | r#"couldn not stop process. no matching process with identifier: "{}""#, 48 | process_info 49 | ), 50 | ErrorKind::InvalidValue, 51 | ) 52 | } 53 | 54 | fn get_process_boot_error(err_reason: io::Error) -> Error { 55 | Error::with_description( 56 | format!( 57 | "process abruptly exited after being hatched, details: {}", 58 | err_reason 59 | ), 60 | ErrorKind::Io, 61 | ) 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | use std::io; 68 | 69 | #[test] 70 | fn process_boot_error_should_return_io_clap_err() { 71 | let kind = clap::ErrorKind::Io; 72 | let err_msg = "test boot error"; 73 | let io_err = get_io_error(err_msg); 74 | 75 | let clap_err_fn = || get_process_boot_error(io_err); 76 | 77 | check_err_matches_spec(err_msg, kind, clap_err_fn); 78 | } 79 | 80 | #[test] 81 | fn no_such_process_error_should_return_invalid_value_clap_err() { 82 | let process_err_msg = "test process not found error"; 83 | let kind = clap::ErrorKind::InvalidValue; 84 | 85 | let clap_err_fn = || get_no_such_process_error(process_err_msg); 86 | check_err_matches_spec(process_err_msg, kind, clap_err_fn); 87 | } 88 | 89 | #[test] 90 | fn no_file_data_error_should_return_clap_io_err() { 91 | let process_err_msg = "no state file data found. Add a process to track first"; 92 | let kind = ErrorKind::Io; 93 | 94 | let clap_err_fn = || get_no_file_data_error(); 95 | check_err_matches_spec(process_err_msg, kind, clap_err_fn); 96 | } 97 | 98 | #[test] 99 | fn spawn_failure_error_should_return_clap_io_error() { 100 | let kind = ErrorKind::Io; 101 | let process_err_msg = "test spawn error"; 102 | let io_err = get_io_error(process_err_msg); 103 | 104 | let clap_err_fn = || get_spawn_failure_error(io_err); 105 | 106 | check_err_matches_spec(process_err_msg, kind, clap_err_fn); 107 | } 108 | 109 | #[test] 110 | fn invalid_file_path_error_should_return_invalid_value_clap_error() { 111 | let kind = ErrorKind::InvalidValue; 112 | let process_err_msg = "invalid path to binary: file does not exist or is inaccessible"; 113 | 114 | let clap_err_fn = || get_invalid_file_path_error(); 115 | 116 | check_err_matches_spec(process_err_msg, kind, clap_err_fn); 117 | } 118 | 119 | fn check_err_matches_spec(err_msg: &str, error_kind: ErrorKind, err_factory: F) 120 | where 121 | F: FnOnce() -> clap::Error, 122 | { 123 | let clap_err = err_factory(); 124 | 125 | assert!(clap_err.to_string().contains(err_msg)); 126 | assert_eq!(clap_err.kind, error_kind); 127 | } 128 | 129 | fn get_io_error(err_msg: &str) -> io::Error { 130 | io::Error::new(io::ErrorKind::Other, err_msg) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/file_io.rs: -------------------------------------------------------------------------------- 1 | use super::errors; 2 | use super::ProcessInfo; 3 | use std::env; 4 | use std::fs::{self, File}; 5 | use std::io; 6 | use std::path::Path; 7 | 8 | // TODO: this is not a good cross dependency; find fix. 9 | use super::is_process_alive; 10 | 11 | type Processes = Vec; 12 | 13 | pub fn write_processes_to_state_file(processes: Processes) -> io::Result<()> { 14 | let state_file_path = get_state_file_path(); 15 | let updated_processes = serde_json::to_string(&processes)?; 16 | fs::write(state_file_path, updated_processes.as_bytes())?; 17 | 18 | Ok(()) 19 | } 20 | 21 | pub fn get_running_processes_from_state_file() -> io::Result { 22 | let mut processes = get_all_processes_from_state_file()?; 23 | processes.retain(|process| is_process_alive(&process.pid).unwrap()); 24 | Ok(processes) 25 | } 26 | 27 | pub fn check_if_file_is_valid(filename: &str) -> Result<(), clap::Error> { 28 | match Path::new(filename).exists() { 29 | true => Ok(()), 30 | false => Err(errors::get_invalid_file_path_error()), 31 | } 32 | } 33 | 34 | pub fn create_state_file_if_not_exists() -> io::Result<()> { 35 | let state_file_path = get_state_file_path(); 36 | if !Path::new(&state_file_path).exists() { 37 | File::create(&state_file_path)?; 38 | } 39 | Ok(()) 40 | } 41 | 42 | fn get_all_processes_from_state_file() -> io::Result { 43 | let state_file_path = get_state_file_path(); 44 | let contents = fs::read_to_string(state_file_path)?; 45 | let processes: Vec = serde_json::from_str(&contents)?; 46 | Ok(processes) 47 | } 48 | 49 | fn get_state_file_path() -> String { 50 | let path_string = match env::var(get_state_file_env_key()) { 51 | Ok(state_path) => state_path, 52 | Err(_) => get_default_state_file_path_string(), 53 | }; 54 | 55 | shellexpand::tilde(&path_string).to_string() 56 | } 57 | 58 | fn get_state_file_env_key() -> String { 59 | "EGGSECUTOR_STATE_FILE".to_string() 60 | } 61 | 62 | fn get_default_state_file_path_string() -> String { 63 | "~/.eggsecutor.state".to_string() 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use super::*; 69 | use uuid::Uuid; 70 | 71 | #[test] 72 | fn writing_empty_process_vec_to_state_file_should_overwrite_current_data() { 73 | let file_path = &generate_path_string(); 74 | set_path_to_use(file_path); 75 | let process_data = get_valid_process_data(); 76 | 77 | let _test_file = TestFile::touch(file_path, &process_data); 78 | 79 | // make sure data was written in the first place 80 | let old_file_data = read_file_data(file_path); 81 | assert_eq!(old_file_data, process_data); 82 | 83 | let empty_processes = vec![]; 84 | let result = write_processes_to_state_file(empty_processes); 85 | 86 | assert!(result.is_ok()); 87 | 88 | // new data should overwrite old data 89 | let new_file_data = read_file_data(file_path); 90 | let expected_empty_process_list_data = "[]"; 91 | assert_eq!(new_file_data, expected_empty_process_list_data); 92 | } 93 | 94 | #[test] 95 | fn writing_empty_process_vec_to_state_file_should_be_ok_if_file_does_not_exist() { 96 | let file_path = &generate_path_string(); 97 | set_path_to_use(file_path); 98 | let empty_processes = vec![]; 99 | 100 | let _test_file = TestFile::track(file_path); 101 | 102 | let result = write_processes_to_state_file(empty_processes); 103 | 104 | assert!(result.is_ok()); 105 | 106 | let file_data = read_file_data(file_path); 107 | let expected_empty_process_list_data = "[]"; 108 | assert_eq!(file_data, expected_empty_process_list_data); 109 | } 110 | 111 | #[test] 112 | fn get_running_processes_should_return_empty_if_no_process_alive() { 113 | let file_path = &generate_path_string(); 114 | set_path_to_use(file_path); 115 | let empty_process_data = "[]"; 116 | 117 | let _test_file = TestFile::touch(file_path, &empty_process_data) 118 | .expect("test file with process data could not be created"); 119 | 120 | let processes = get_running_processes_from_state_file() 121 | .expect("getting processes from file returned unexpected error"); 122 | 123 | assert!(processes.is_empty()); 124 | } 125 | 126 | #[test] 127 | fn get_running_processes_should_return_err_if_no_file_found() { 128 | let file_path = &generate_path_string(); 129 | set_path_to_use(file_path); 130 | 131 | assert!(!Path::new(file_path).exists()); 132 | 133 | let result = get_running_processes_from_state_file(); 134 | assert!(result.is_err()); 135 | } 136 | 137 | #[test] 138 | fn getting_processes_should_return_empty_vec_if_file_empty() { 139 | let file_path = &generate_path_string(); 140 | let empty_process_data = "[]"; 141 | set_path_to_use(file_path); 142 | 143 | let _test_file = TestFile::touch(file_path, &empty_process_data) 144 | .expect("test file with process data could not be created"); 145 | 146 | let processes = get_all_processes_from_state_file() 147 | .expect("getting processes from file returned unexpected error"); 148 | 149 | assert_eq!(processes.len(), 0); 150 | } 151 | 152 | #[test] 153 | fn getting_processes_from_file_should_be_ok_given_valid_file() { 154 | let file_path = &generate_path_string(); 155 | let process_data = get_valid_process_data(); 156 | set_path_to_use(file_path); 157 | 158 | let _test_file = TestFile::touch(file_path, &process_data) 159 | .expect("test file with process data could not be created"); 160 | 161 | let processes = get_all_processes_from_state_file() 162 | .expect("getting processes from file returned unexpected error"); 163 | 164 | assert!(processes.len() > 0); 165 | } 166 | 167 | #[test] 168 | fn get_processes_from_state_file_should_return_err_if_no_file() { 169 | let file_path = &generate_path_string(); 170 | set_path_to_use(file_path); 171 | 172 | assert!(!Path::new(file_path).exists()); 173 | 174 | let result = get_all_processes_from_state_file(); 175 | assert!(result.is_err()); 176 | } 177 | 178 | #[test] 179 | fn file_valid_check_should_err_with_nonexistent_file_path() { 180 | let nonexistent_file_path = &generate_path_string(); 181 | let result = check_if_file_is_valid(nonexistent_file_path); 182 | 183 | assert!(result.is_err()); 184 | assert_eq!(result.unwrap_err().kind, clap::ErrorKind::InvalidValue); 185 | } 186 | 187 | #[test] 188 | fn file_valid_check_should_be_ok_with_existing_file() { 189 | let file_path = &generate_path_string(); 190 | let empty_data = ""; 191 | let _test_file = 192 | TestFile::touch(file_path, empty_data).expect("test file couldnt be created"); 193 | 194 | let result = check_if_file_is_valid(file_path); 195 | assert!(result.is_ok()); 196 | } 197 | 198 | #[test] 199 | fn state_file_should_be_created_if_not_exists() { 200 | // set path to a file that does not exist 201 | let file_path = &generate_path_string(); 202 | set_path_to_use(file_path); 203 | 204 | // ensure file does not exists prior to call 205 | assert!(!Path::new(file_path).exists()); 206 | 207 | // start tracking file so we can cleanup after 208 | let _test_file = TestFile::track(file_path); 209 | 210 | create_state_file_if_not_exists().expect("state file check returned err"); 211 | 212 | // check file was created and is empty 213 | assert!(Path::new(file_path).exists()); 214 | let file_data = read_file_data(file_path); 215 | assert!(file_data.is_empty()); 216 | } 217 | 218 | #[test] 219 | fn state_file_should_not_be_created_if_exists() { 220 | // create empty file and set path to point to it 221 | let file_path = &generate_path_string(); 222 | let test_data = "test data"; 223 | 224 | let _test_file = 225 | TestFile::touch(file_path, test_data).expect("state file path could not be created"); 226 | set_path_to_use(file_path); 227 | 228 | create_state_file_if_not_exists().expect("state file check returned err"); 229 | 230 | // check no data was overwritten 231 | let file_data = read_file_data(file_path); 232 | assert_eq!(file_data, test_data); 233 | } 234 | 235 | #[test] 236 | fn state_file_env_key_should_be_default_value() { 237 | let default_env_key = "EGGSECUTOR_STATE_FILE"; 238 | assert_eq!(default_env_key, &get_state_file_env_key()); 239 | } 240 | 241 | #[test] 242 | fn default_state_file_path_string_should_be_set() { 243 | let expected_default_path = "~/.eggsecutor.state"; 244 | assert_eq!(expected_default_path, &get_default_state_file_path_string()); 245 | } 246 | 247 | #[test] 248 | fn state_file_path_should_return_default_if_env_not_set() { 249 | env::remove_var(get_state_file_env_key()); 250 | 251 | let state_file_path = get_state_file_path(); 252 | 253 | // we have to expand the tilde for the path 254 | let expected_path = shellexpand::tilde(&get_default_state_file_path_string()).to_string(); 255 | 256 | assert_eq!(expected_path, state_file_path); 257 | } 258 | 259 | #[test] 260 | fn state_file_path_should_return_user_set_path_if_env_key_present() { 261 | let test_path_value = "test-dir"; 262 | env::set_var(get_state_file_env_key(), test_path_value); 263 | 264 | let state_file_path = get_state_file_path(); 265 | assert_eq!(test_path_value, state_file_path); 266 | } 267 | 268 | struct TestFile<'a> { 269 | path: &'a str, 270 | } 271 | 272 | impl<'a> Drop for TestFile<'a> { 273 | fn drop(&mut self) { 274 | // we don't actually care if the file can't be removed because a 275 | // panic would mean an abort anyway, so the result can be ignored 276 | let _result = fs::remove_file(self.path); 277 | } 278 | } 279 | 280 | impl<'a> TestFile<'a> { 281 | fn track(path: &'a str) -> Self { 282 | Self { path } 283 | } 284 | 285 | fn touch(path: &'a str, data: &str) -> io::Result { 286 | fs::write(path, data)?; 287 | Ok(Self { path }) 288 | } 289 | } 290 | 291 | fn generate_path_string() -> String { 292 | format!("{}.testfile", Uuid::new_v4().to_simple()) 293 | } 294 | 295 | fn set_path_to_use(path_str: &str) { 296 | env::set_var(get_state_file_env_key(), path_str); 297 | } 298 | 299 | fn get_valid_process_data() -> String { 300 | r#"[{"name":"TEST_PROCES","pid":"0000","status":"Running"}]"#.to_string() 301 | } 302 | 303 | fn read_file_data(path: &str) -> String { 304 | String::from_utf8(fs::read(path).expect("data could not be read from state file")) 305 | .expect("error casting bytes to string from state file") 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | // our specific clap version doesn't use the new error syntax, 2 | // but the beta version does, so we technically use the 3 | // deprecated functions to the same effect. 4 | #![allow(deprecated)] 5 | // our closures actually -> !, so they can't be unwrapped easily 6 | #![allow(clippy::redundant_closure)] 7 | extern crate clap; 8 | extern crate shellexpand; 9 | 10 | use clap::{App, AppSettings}; 11 | use serde::{Deserialize, Serialize}; 12 | 13 | use std::io; 14 | use std::process::{Command, Stdio}; 15 | mod errors; 16 | mod file_io; 17 | mod output_display; 18 | mod subcommands; 19 | 20 | fn main() { 21 | const PROGRAM_TITLE: &str = "eggsecutor"; 22 | const VERSION: &str = "1.0"; 23 | const AUTHOR: &str = "astherath "; 24 | const ABOUT: &str = "A friendly background process task manager"; 25 | 26 | let mut app = App::new(PROGRAM_TITLE) 27 | .version(VERSION) 28 | .author(AUTHOR) 29 | .about(ABOUT) 30 | .setting(AppSettings::ArgRequiredElseHelp); 31 | 32 | app = subcommands::get_all_subcommands() 33 | .into_iter() 34 | .fold(app, |acc, subcommand| acc.subcommand(subcommand)); 35 | let matches = app.get_matches(); 36 | 37 | // get matches and execute commands here 38 | if let Some(matches) = matches.subcommand_matches("hatch") { 39 | if let Some(filename) = matches.value_of("file") { 40 | process_file_input_for_hatch_subcommand(filename).unwrap(); 41 | } 42 | } else if let Some(matches) = matches.subcommand_matches("stop") { 43 | if let Some(process_identifier) = matches.value_of("process identifier") { 44 | stop_process_by_process_identifier(process_identifier).unwrap(); 45 | } 46 | } else if matches.subcommand_matches("list").is_some() { 47 | print_list_of_processes().unwrap(); 48 | } else if let Some(matches) = matches.subcommand_matches("clear") { 49 | if matches.is_present("only-clear") { 50 | clear_all_processes_from_file().unwrap(); 51 | } else { 52 | stop_and_clear_all_processes().unwrap(); 53 | } 54 | } 55 | } 56 | 57 | fn process_file_input_for_hatch_subcommand(filename: &str) -> io::Result<()> { 58 | if let Err(clap_err) = file_io::check_if_file_is_valid(filename) { 59 | clap_err.exit(); 60 | } 61 | 62 | hatch_subprocess_from_file(filename)?; 63 | 64 | Ok(()) 65 | } 66 | 67 | fn hatch_subprocess_from_file(filename: &str) -> io::Result<()> { 68 | output_display::print_pre_hatch_message(filename); 69 | let bin_path = format!("./{}", filename); 70 | let child = Command::new(bin_path) 71 | .stdout(Stdio::null()) 72 | .stderr(Stdio::null()) 73 | .spawn() 74 | .unwrap_or_else(|err| errors::handle_spawn_failure(err)); 75 | 76 | let pid = child.id(); 77 | let child_info = ProcessInfo { 78 | name: filename.to_string(), 79 | pid: pid.to_string(), 80 | status: ProcessStatus::Running, 81 | }; 82 | 83 | add_process_to_state_tracker(child_info) 84 | .unwrap_or_else(|err| errors::handle_process_boot_error(err)); 85 | 86 | output_display::print_post_hatch_message(pid); 87 | 88 | Ok(()) 89 | } 90 | 91 | #[derive(Serialize, Deserialize, Debug)] 92 | enum ProcessStatus { 93 | Running, 94 | Stopped, 95 | } 96 | 97 | #[derive(Serialize, Deserialize, Debug)] 98 | pub struct ProcessInfo { 99 | name: String, 100 | pid: String, 101 | status: ProcessStatus, 102 | } 103 | 104 | impl ProcessInfo { 105 | fn to_console_string(&self) -> String { 106 | format!( 107 | "\ 108 | {:<15} {:<7} {:<10?}\n", 109 | self.name, self.pid, self.status 110 | ) 111 | } 112 | } 113 | 114 | fn add_process_to_state_tracker(process_info: ProcessInfo) -> io::Result<()> { 115 | file_io::create_state_file_if_not_exists()?; 116 | 117 | let mut processes = file_io::get_running_processes_from_state_file()?; 118 | 119 | // add new process 120 | processes.push(process_info); 121 | 122 | // write info 123 | file_io::write_processes_to_state_file(processes)?; 124 | 125 | Ok(()) 126 | } 127 | 128 | fn print_list_of_processes() -> io::Result<()> { 129 | let processes = file_io::get_running_processes_from_state_file() 130 | .unwrap_or_else(|_| errors::handle_no_file_data_error()) 131 | .into_iter() 132 | .filter(|process| is_process_alive(&process.pid).unwrap()) 133 | .collect(); 134 | 135 | let display_str_for_processes = output_display::get_display_output_str_for_processes(processes); 136 | println!("{}", display_str_for_processes); 137 | Ok(()) 138 | } 139 | 140 | fn remove_process_from_state_tracker(pid: &str) -> io::Result<()> { 141 | if find_process_by_pid(pid).is_some() { 142 | let mut processes = file_io::get_running_processes_from_state_file()?; 143 | processes.retain(|x| x.pid != pid); 144 | 145 | file_io::write_processes_to_state_file(processes)?; 146 | } 147 | Ok(()) 148 | } 149 | 150 | fn stop_process_by_process_identifier(process_identifier: &str) -> io::Result<()> { 151 | // check if the process identfied passed is actually a pid 152 | let pid = &{ 153 | if let Some(process) = find_process_by_name(process_identifier) { 154 | process.pid 155 | } else if is_existing_pid(process_identifier) { 156 | process_identifier.to_string() 157 | } else { 158 | errors::handle_no_such_process_error(process_identifier); 159 | } 160 | }; 161 | 162 | stop_process_by_pid(pid)?; 163 | remove_process_from_state_tracker(pid)?; 164 | Ok(()) 165 | } 166 | 167 | fn find_process_by_name(name: &str) -> Option { 168 | for process in file_io::get_running_processes_from_state_file().unwrap() { 169 | if process.name == name { 170 | return Some(process); 171 | } 172 | } 173 | None 174 | } 175 | 176 | fn find_process_by_pid(pid: &str) -> Option { 177 | for process in file_io::get_running_processes_from_state_file().unwrap() { 178 | if process.pid == pid { 179 | return Some(process); 180 | } 181 | } 182 | None 183 | } 184 | 185 | fn is_existing_pid(pid: &str) -> bool { 186 | [ 187 | pid.parse::().is_ok(), 188 | is_pid_being_tracked(pid), 189 | is_process_alive(pid).unwrap(), 190 | ] 191 | .iter() 192 | .all(|x| *x) 193 | } 194 | 195 | fn is_pid_being_tracked(pid: &str) -> bool { 196 | file_io::get_running_processes_from_state_file() 197 | .unwrap() 198 | .iter() 199 | .map(|x| &x.pid) 200 | .any(|x| *x == pid) 201 | } 202 | 203 | fn stop_process_by_pid(pid: &str) -> io::Result<()> { 204 | println!("stopping process with pid: {}", pid); 205 | let command = "kill"; 206 | Command::new(command) 207 | .arg("15") 208 | .arg(pid) 209 | .stdout(Stdio::null()) 210 | .stderr(Stdio::null()) 211 | .spawn()? 212 | .wait()?; 213 | Ok(()) 214 | } 215 | 216 | fn stop_and_clear_all_processes() -> io::Result<()> { 217 | file_io::get_running_processes_from_state_file()? 218 | .iter() 219 | .for_each(|x| stop_process_by_pid(&x.pid).unwrap()); 220 | clear_all_processes_from_file()?; 221 | Ok(()) 222 | } 223 | 224 | fn clear_all_processes_from_file() -> io::Result<()> { 225 | file_io::write_processes_to_state_file(vec![])?; 226 | Ok(()) 227 | } 228 | 229 | fn is_process_alive(pid: &str) -> io::Result { 230 | let command = "kill"; 231 | match Command::new(command) 232 | .arg("-0") 233 | .arg(pid) 234 | .stdout(Stdio::null()) 235 | .stderr(Stdio::null()) 236 | .spawn()? 237 | .wait()? 238 | .code() 239 | { 240 | Some(code) => Ok(code == 0), 241 | None => Ok(false), 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/output_display.rs: -------------------------------------------------------------------------------- 1 | 2 | use super::ProcessInfo; 3 | 4 | pub fn get_display_output_str_for_processes(processes: Vec) -> String { 5 | format!( 6 | "{}\n{}", 7 | get_display_header_string(), 8 | processes 9 | .iter() 10 | .map(|x| x.to_console_string()) 11 | .collect::>() 12 | .join("") 13 | ) 14 | } 15 | 16 | fn get_display_header_string() -> String { 17 | format!( 18 | "{:<15} {:<7} {:<10}\n{:-<35}", 19 | "Process name", "pid", "status", "" 20 | ) 21 | } 22 | 23 | pub fn print_pre_hatch_message(filename: &str) { 24 | println!("{}", get_pre_hatch_message_string(filename)); 25 | } 26 | 27 | pub fn print_post_hatch_message(pid: u32) { 28 | println!("{}", get_post_hatch_message_string(pid)); 29 | } 30 | 31 | fn get_post_hatch_message_string(pid: u32) -> String { 32 | format!(r#"egg hatched, tracking process with pid: "{}""#, &pid) 33 | } 34 | 35 | fn get_pre_hatch_message_string(filename: &str) -> String { 36 | format!( 37 | r#"Hatching process "{}" and starting to track..."#, 38 | filename 39 | ) 40 | } 41 | 42 | #[cfg(test)] 43 | mod tests { 44 | use super::*; 45 | 46 | #[test] 47 | fn display_output_str_for_empty_vec_should_just_be_header() { 48 | let empty_process_list = vec![]; 49 | let display_string = get_display_output_str_for_processes(empty_process_list); 50 | 51 | // since no processes, should only be header 52 | let header_string = get_display_header_string(); 53 | 54 | // trim both strings for consistency 55 | assert_eq!(display_string.trim(), header_string.trim()); 56 | } 57 | 58 | #[test] 59 | fn display_header_string_should_be_non_empty() { 60 | let msg = get_display_header_string(); 61 | assert!(msg.len() > 0); 62 | } 63 | 64 | #[test] 65 | fn pre_hatch_mesage_ok() { 66 | let filename = "test-filename"; 67 | let message = get_pre_hatch_message_string(filename); 68 | assert!(message.contains(filename)); 69 | 70 | // printing the message should work without error as well 71 | print_pre_hatch_message(filename); 72 | } 73 | 74 | #[test] 75 | fn post_hatch_message_ok() { 76 | let pid = 1234; 77 | let message = get_post_hatch_message_string(pid); 78 | assert!(message.contains(&pid.to_string())); 79 | 80 | // printing the message should work without error as well 81 | print_post_hatch_message(pid); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/subcommands.rs: -------------------------------------------------------------------------------- 1 | use clap::{App, Arg}; 2 | pub fn get_all_subcommands<'a>() -> Vec> { 3 | vec![ 4 | get_hatch_subcommand(), 5 | get_list_processes_subcommand(), 6 | get_stop_process_subcommand(), 7 | get_clear_state_subcommand(), 8 | ] 9 | } 10 | 11 | fn get_clear_state_subcommand<'a>() -> App<'a> { 12 | const SUBCOMMAND_NAME: &str = "clear"; 13 | const ABOUT: &str = "stops all of the processes being tracked and clears the tracking list"; 14 | 15 | App::new(SUBCOMMAND_NAME).about(ABOUT).arg( 16 | Arg::new("only-clear") 17 | .long("--only-clear") 18 | .help("don't stop any processes, just clear the tracking list"), 19 | ) 20 | } 21 | 22 | fn get_stop_process_subcommand<'a>() -> App<'a> { 23 | const SUBCOMMAND_NAME: &str = "stop"; 24 | const ABOUT: &str = "stop a process by name or pid"; 25 | 26 | App::new(SUBCOMMAND_NAME).about(ABOUT).arg( 27 | Arg::new("process identifier") 28 | .help("Name or pid of process to stop") 29 | .required(true) 30 | .takes_value(true) 31 | .value_name("PROCESS_IDENTIFIER"), 32 | ) 33 | } 34 | 35 | fn get_list_processes_subcommand<'a>() -> App<'a> { 36 | const SUBCOMMAND_NAME: &str = "list"; 37 | const ABOUT: &str = "list all managed processes"; 38 | 39 | App::new(SUBCOMMAND_NAME).about(ABOUT) 40 | } 41 | 42 | fn get_hatch_subcommand<'a>() -> App<'a> { 43 | const SUBCOMMAND_NAME: &str = "hatch"; 44 | const ABOUT: &str = "start managing a binary process"; 45 | 46 | App::new(SUBCOMMAND_NAME).about(ABOUT).arg( 47 | Arg::new("file") 48 | .help("Sets the input file to use") 49 | .required(true) 50 | .takes_value(true) 51 | .value_name("INPUT"), 52 | ) 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::*; 58 | mod subcommand_testing_utils { 59 | use clap::App; 60 | pub fn test_subcommand_should_return_app_instance<'a, T>( 61 | subcommand_getter: T, 62 | expected_name: &str, 63 | expected_about: &str, 64 | ) where 65 | T: Fn() -> App<'a>, 66 | { 67 | let command = subcommand_getter(); 68 | assert_eq!(command.get_name(), expected_name); 69 | assert_eq!(command.get_about().unwrap(), expected_about); 70 | } 71 | } 72 | mod stop_subcommand { 73 | use super::get_stop_process_subcommand; 74 | use super::subcommand_testing_utils as utils; 75 | 76 | #[test] 77 | fn subcommand_should_return_app_instance() { 78 | let expected_name = "stop"; 79 | let expected_about = "stop a process by name or pid"; 80 | utils::test_subcommand_should_return_app_instance( 81 | get_stop_process_subcommand, 82 | expected_name, 83 | expected_about, 84 | ); 85 | } 86 | } 87 | 88 | mod list_subcommand { 89 | use super::get_list_processes_subcommand; 90 | use super::subcommand_testing_utils as utils; 91 | 92 | #[test] 93 | fn subcommand_should_return_app_instance() { 94 | let expected_name = "list"; 95 | let expected_about = "list all managed processes"; 96 | utils::test_subcommand_should_return_app_instance( 97 | get_list_processes_subcommand, 98 | expected_name, 99 | expected_about, 100 | ); 101 | } 102 | } 103 | 104 | mod hatch_subcommand { 105 | use super::get_hatch_subcommand; 106 | use super::subcommand_testing_utils as utils; 107 | 108 | #[test] 109 | fn subcommand_should_return_app_instance() { 110 | let expected_name = "hatch"; 111 | let expected_about = "start managing a binary process"; 112 | utils::test_subcommand_should_return_app_instance( 113 | get_hatch_subcommand, 114 | expected_name, 115 | expected_about, 116 | ); 117 | } 118 | } 119 | 120 | mod clear_subcommand { 121 | use super::get_clear_state_subcommand; 122 | use super::subcommand_testing_utils as utils; 123 | 124 | #[test] 125 | fn subcommand_should_return_app_instance() { 126 | let expected_name = "clear"; 127 | let expected_about = 128 | "stops all of the processes being tracked and clears the tracking list"; 129 | utils::test_subcommand_should_return_app_instance( 130 | get_clear_state_subcommand, 131 | expected_name, 132 | expected_about, 133 | ); 134 | } 135 | 136 | #[test] 137 | fn subcommand_should_have_args() { 138 | let command = get_clear_state_subcommand(); 139 | let expected_arg_name = "only-clear"; 140 | let expected_arg_about = "don't stop any processes, just clear the tracking list"; 141 | let arg = command 142 | .get_arguments() 143 | .into_iter() 144 | .filter(|x| x.get_name() == expected_arg_name) 145 | .next() 146 | .expect("arg iterator should return valid argument"); 147 | 148 | assert_eq!(arg.get_name(), expected_arg_name); 149 | assert_eq!(arg.get_help().unwrap(), expected_arg_about); 150 | } 151 | } 152 | 153 | #[test] 154 | fn get_all_subcommands_return_should_be_foldable_into_app() { 155 | let all_subcommands = get_all_subcommands(); 156 | let expected_count_of_subcommands = all_subcommands.len(); 157 | 158 | // smoke check for safety 159 | assert!(expected_count_of_subcommands > 0); 160 | 161 | let count_of_subcommands_in_app = all_subcommands 162 | .into_iter() 163 | .fold(App::new("test-app"), |acc, sub| acc.subcommand(sub)) 164 | .get_subcommands() 165 | .count(); 166 | 167 | assert_eq!(count_of_subcommands_in_app, expected_count_of_subcommands); 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /x.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | from pathlib import Path 4 | import json 5 | from datetime import datetime 6 | import click 7 | 8 | 9 | @click.group() 10 | def cli(): 11 | pass 12 | 13 | 14 | @cli.command() 15 | def clean(): 16 | """cleans cov files""" 17 | files = [x for x in os.listdir() if x.endswith("profraw")] 18 | for file_path in files: 19 | os.remove(file_path) 20 | 21 | 22 | @cli.command() 23 | def view(): 24 | """views the geenrated html files in default browser""" 25 | os.system(" ".join(["open", str(Path("coverage/src/index.html"))])) 26 | 27 | 28 | @cli.command() 29 | def cov(): 30 | """generates cov file(s)""" 31 | # check flag is set before starting 32 | os.system('export RUSTFLAGS="-Zinstrument-coverage"') 33 | 34 | run_grcov_cmd() 35 | append_cov_data_to_file() 36 | 37 | 38 | @cli.command() 39 | @click.option("-f", "--fast", required=False, is_flag=True) 40 | @click.option("-n", "--name", required=False, is_flag=False, type=str) 41 | @click.option("-e", "--exact", required=False, is_flag=True) 42 | def test(fast: bool, name: str, exact: bool): 43 | """runs tests and generates cov file(s)""" 44 | # check flag is set before starting 45 | if fast: 46 | rust_flag = "" 47 | else: 48 | rust_flag = "-Zinstrument-coverage" 49 | os.environ["RUSTFLAGS"] = rust_flag 50 | 51 | args = [] 52 | 53 | if exact: 54 | args.append("--exact") 55 | 56 | exit_code = run_tests(named=name, args=args) 57 | 58 | should_gen_cov = exit_code == 0 and not any([fast, exact, name]) 59 | if should_gen_cov: 60 | run_grcov_cmd() 61 | append_cov_data_to_file() 62 | 63 | 64 | def run_tests(named=None, args=None) -> int: 65 | cmd = ["cargo", "test", "--", "--test-threads=1"] 66 | if named: 67 | cmd.insert(2, named) 68 | if args: 69 | cmd.extend(args) 70 | final_cmd = " ".join(cmd) 71 | return os.system(final_cmd) 72 | 73 | 74 | def run_grcov_cmd(): 75 | cmd = " ".join([ 76 | "grcov", 77 | ".", 78 | "--binary-path", 79 | "./target/debug", 80 | "-s", 81 | ".", 82 | "-t", 83 | "html", 84 | "--branch", 85 | "--ignore-not-existing", 86 | "-o", 87 | "./coverage/", 88 | ]) 89 | os.system(cmd) 90 | 91 | 92 | def get_cov_data_path() -> str: 93 | return "coverage_data.csv" 94 | 95 | 96 | def append_cov_data_to_file(): 97 | cov_data_path = get_cov_data_path() 98 | if not os.path.exists(cov_data_path): 99 | create_header_for_file() 100 | data = get_output_ready_data() 101 | with open(cov_data_path, "a") as f: 102 | f.write(data) 103 | 104 | 105 | def get_output_ready_data() -> str: 106 | timestamp = datetime.now() 107 | cov_percent = get_cov_percentage() 108 | sloc = get_current_sloc() 109 | return f"{timestamp},{cov_percent},{sloc}\n" 110 | 111 | 112 | def get_current_sloc() -> str: 113 | sloc_str = os.popen("sloc src/ tests/").read().replace("\n", "").replace( 114 | " ", "") 115 | start = sloc_str.index(":") + 1 116 | end = start + sloc_str[start:].index("Source") 117 | sloc = sloc_str[start:end] 118 | return sloc 119 | 120 | 121 | def create_header_for_file(): 122 | header_str = "datetime,cov(%),sloc\n" 123 | cov_data_path = get_cov_data_path() 124 | with open(cov_data_path, "w") as f: 125 | f.write(header_str) 126 | 127 | 128 | def get_cov_percentage() -> str: 129 | generated_cov_path = str(Path("./coverage/coverage.json")) 130 | with open(generated_cov_path, "r") as f: 131 | data = json.loads(f.read()) 132 | return data["message"] 133 | 134 | 135 | if __name__ == "__main__": 136 | cli() 137 | --------------------------------------------------------------------------------