From ab65b79b0a14aa166183019fb3f4df1efa2e88da Mon Sep 17 00:00:00 2001 From: David Henning Date: Fri, 9 Jan 2026 19:20:50 +0100 Subject: [PATCH] refactor: fix small issues --- .github/workflows/build.yml | 52 ++- .github/workflows/lint.yml | 40 +++ .github/workflows/test.yml | 14 +- README.md | 83 +++-- src/main.rs | 624 ++++++++++++++++++------------------ tests/cli.rs | 344 ++++++++++---------- 6 files changed, 621 insertions(+), 536 deletions(-) create mode 100644 .github/workflows/lint.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 304d37e..0432764 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -13,17 +13,17 @@ jobs: runs-on: ubuntu-latest outputs: upload_url: ${{ steps.create_release.outputs.upload_url }} - steps: - - name: Create Release - id: create_release - uses: actions/create-release@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: ${{ github.event.inputs.version }} - release_name: ${{ github.event.inputs.version }} - draft: true - prerelease: false + steps: + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.event.inputs.version }} + name: ${{ github.event.inputs.version }} + draft: true + prerelease: false build: runs-on: ${{ matrix.runners.image }} @@ -62,14 +62,12 @@ jobs: target: aarch64-pc-windows-msvc artifact: server-runner.exe - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable - name: Install Linux aarch64 gcc if: ${{ matrix.runners.name == 'linux-aarch64' }} @@ -90,12 +88,10 @@ jobs: - name: Compress artifact ${{ matrix.runners.name }} run: tar -czf server-runner-${{ github.event.inputs.version }}-${{ matrix.runners.name }}.tar.gz -C ./target/${{ matrix.runners.target }}/release ${{ matrix.runners.artifact }} - - name: Upload Release Asset ${{ matrix.runners.name }} - uses: actions/upload-release-asset@v1 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ needs.create_release.outputs.upload_url }} - asset_path: ./server-runner-${{ github.event.inputs.version }}-${{ matrix.runners.name }}.tar.gz - asset_name: server-runner-${{ github.event.inputs.version }}-${{ matrix.runners.name }}.tar.gz - asset_content_type: application/gzip + - name: Upload Release Asset ${{ matrix.runners.name }} + uses: softprops/action-gh-release@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ github.event.inputs.version }} + files: ./server-runner-${{ github.event.inputs.version }}-${{ matrix.runners.name }}.tar.gz diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..3da79bc --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,40 @@ +name: Lint + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + clippy: + runs-on: ubuntu-latest + name: Clippy + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Run Clippy + run: cargo clippy -- -D warnings + + rustfmt: + runs-on: ubuntu-latest + name: Format + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt + + - name: Check Formatting + run: cargo fmt -- --check diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e28210a..97fa145 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,14 +10,12 @@ jobs: test: runs-on: ubuntu-latest - steps: - - name: Checkout Repository - uses: actions/checkout@v2 - - - name: Set up Rust - uses: actions-rs/toolchain@v1 - with: - toolchain: stable + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable - name: Install simple http server run: cargo install simple-http-server diff --git a/README.md b/README.md index ab106bc..58f4c4a 100644 --- a/README.md +++ b/README.md @@ -18,22 +18,67 @@ and it's much easier to publish than to many other package managers. cargo install server-runner ~~~ -## Configuration File - -Example - -~~~ yaml -servers: - - name: "My web server" - url: "http://localhost:8080" - command: "node webserver.js" -command: "node cypress" -~~~ - -~~~ sh -server-runner -c config.yaml -~~~ - -Default name of the config file is `servers.yaml` in your current working directory. - -Server Runner will attempt to check a server's status up to ten times with one second between each attempt. If a server is not responding with HTTP 200 after that, Server Runner will shutdown all servers and exit. +## Usage + +~~~ sh +server-runner [OPTIONS] +~~~ + +### 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) +- `-h, --help` - Print help information +- `-V, --version` - Print version information + +### Example + +~~~ sh +server-runner -c config.yaml -v -a 15 +~~~ + +## Configuration File + +The configuration file is written in YAML format and defines the servers to start and the command to run when all servers are ready. + +### Example Configuration + +~~~ yaml +servers: + - name: "My web server" + url: "http://localhost:8080" + command: "node webserver.js" + timeout: 5 # Optional: HTTP request timeout in seconds (default: 5) + + - name: "API server" + url: "http://localhost:3000/health" + command: "python api_server.py" + timeout: 10 + +command: "npm test" +~~~ + +### Configuration Fields + +**servers** (required): List of servers to start and monitor + +Each server requires: +- `name`: Display name for the server +- `url`: HTTP endpoint to check for availability (must return HTTP 200 when ready) +- `command`: Shell command to start the server +- `timeout`: (optional) HTTP request timeout in seconds (default: 5) + +**command** (required): Command to execute when all servers are ready + +## How It Works + +Server Runner will: + +1. Start all configured servers simultaneously +2. Poll each server's URL every second until it returns HTTP 200 +3. Retry up to the maximum number of attempts (default: 10, configurable with `-a`) +4. Execute the specified command once all servers are ready +5. Shut down all servers when the command completes or if any error occurs + +If any server fails to respond with HTTP 200 after the maximum attempts, Server Runner will shut down all servers and exit with an error. diff --git a/src/main.rs b/src/main.rs index ce7c425..2f8bd6e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,308 +1,316 @@ -use anyhow::{bail, Context}; -use clap::Parser; -use log::info; -use std::collections::HashMap; -use std::ops::AddAssign; -#[cfg(windows)] -use std::os::windows::process::CommandExt; -use std::process::{Child, Command}; -use std::sync::{Arc, LockResult, Mutex, MutexGuard}; -use std::thread; -use std::time::Duration; -use std::{env, fmt}; - -#[derive(Parser)] -#[command(version)] -struct Args { - #[arg(short, long, default_value = "servers.yaml")] - config: String, - - #[arg(short, long, default_value_t = false)] - verbose: bool, - - #[arg(short, long, default_value_t = 10)] - attempts: u8, -} - -#[derive(serde::Deserialize)] -struct Server { - name: String, - url: String, - command: String, - #[serde(default = "default_timeout")] - timeout: u64, -} - -fn default_timeout() -> u64 { - 5 -} - -#[derive(serde::Deserialize)] -struct Config { - servers: Vec, - command: String, -} - -struct ServerProcess { - name: String, - process: Child, -} - -#[derive(PartialEq, Eq)] -enum ServerStatus { - Waiting, - Running, -} - -#[derive(Copy, Clone, Debug)] -struct Attempts(u8); - -impl AddAssign for Attempts { - fn add_assign(&mut self, other: u8) { - self.0 = self.0.wrapping_add(other); - } -} - -impl fmt::Display for Attempts { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - write!(f, "{}", self.0) - } -} - -impl PartialEq for Attempts { - fn eq(&self, other: &u8) -> bool { - self.0 == *other - } -} - -#[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct ServerName(String); - -fn run(args: Args) -> anyhow::Result<()> { - let Config { servers, command } = get_config(&args.config)?; - let server_processes = start_servers(&servers)?; - let server_processes_arc_mutex = Arc::new(Mutex::new(server_processes)); - let server_processes_clone = Arc::clone(&server_processes_arc_mutex); - let mut attempts = HashMap::::new(); - let log_level = if args.verbose { - simplelog::LevelFilter::Info - } else { - simplelog::LevelFilter::Warn - }; - - simplelog::TermLogger::init( - log_level, - simplelog::Config::default(), - simplelog::TerminalMode::Mixed, - simplelog::ColorChoice::Auto, - )?; - - ctrlc::set_handler(move || { - let mut processes = server_processes_clone.lock(); - - match stop_servers(&mut processes) { - Ok(_) => info!("All servers stopped successfully"), - Err(e) => { - eprintln!("Error stopping servers: {}", e); - std::process::exit(1); - } - }; - - std::process::exit(0); - })?; - - loop { - let mut ready = true; - - for server in &servers { - match check_server(server, &mut attempts, args.attempts) { - Ok(result) => { - if result == ServerStatus::Waiting { - ready = false; - } - } - Err(e) => { - stop_servers(&mut server_processes_arc_mutex.lock())?; - - return Err(e); - } - } - } - - if ready { - let mut process = - run_command(&command).context(format!("Could not start process {}", command))?; - - info!("Running command {}", command); - - process.wait()?; - - info!("Command {} finished successfully", command); - - break; - } - - thread::sleep(Duration::from_secs(1)); - } - - stop_servers(&mut server_processes_arc_mutex.lock())?; - - Ok(()) -} - -fn get_config(filename: &str) -> anyhow::Result { - let cwd = env::current_dir()?; - let tmp_path = cwd.join(filename); - let config_file_path = tmp_path.to_str().context(format!( - "Could not create String from Path {}", - tmp_path.display() - ))?; - - info!("Loading config file {}", config_file_path); - - let settings = config::Config::builder() - .add_source(config::File::new( - config_file_path, - config::FileFormat::Yaml, - )) - .build() - .context(format!("Could not find config file {}", filename))?; - - let config = settings - .try_deserialize::() - .context(format!("Could not parse config file {}", filename))?; - - if config.servers.is_empty() { - bail!("Configuration must include at least one server"); - } - - if config.command.trim().is_empty() { - bail!("Configuration must include a command to run"); - } - - Ok(config) -} - -fn start_servers(servers: &Vec) -> anyhow::Result> { - let mut server_processes = Vec::with_capacity(servers.len()); - - for s in servers { - info!("Starting server {}", s.name); - - let server_process = ServerProcess { - name: s.name.to_string(), - process: run_command(&s.command)?, - }; - - server_processes.push(server_process); - } - - Ok(server_processes) -} - -fn stop_servers( - server_processes: &mut LockResult>>, -) -> anyhow::Result<()> { - let processes = match server_processes { - Ok(p) => p, - Err(e) => bail!("{}", e), - }; - - for p in processes.iter_mut() { - info!("Stopping server {}", p.name); - - if let Ok(_) = p.process.kill() { - let _ = p.process.wait(); - } else { - bail!("Failed to stop process {}", p.name); - } - } - - info!("All servers stopped successfully"); - - Ok(()) -} - -fn run_command(command: &str) -> anyhow::Result { - let command_parts = shlex::split(command) - .ok_or_else(|| anyhow::anyhow!("Invalid command: {}", command))?; - - if command_parts.is_empty() { - bail!("Empty command provided"); - } - - let mut cmd = Command::new(&command_parts[0]); - - for part in command_parts.iter().skip(1) { - cmd.arg(part); - } - - #[cfg(windows)] - { - cmd.creation_flags(0x08000000); - } - - Ok(cmd.spawn()?) -} - -fn check_server( - server: &Server, - server_attempts: &mut HashMap, - max_attempts: u8, -) -> anyhow::Result { - let Server { name, url, timeout, .. } = server; - - let attempts = server_attempts - .entry(ServerName(name.to_owned())) - .and_modify(|attempts| *attempts += 1) - .or_insert(Attempts(1)); - - if *attempts == max_attempts { - bail!( - "Could not connect to server {} after {} attempts", - name, - attempts - ); - } - - info!( - "Checking server {} on url {}, attempt {}, waiting one second ...", - name, url, attempts - ); - - let client = reqwest::blocking::Client::builder() - .timeout(Duration::from_secs(*timeout)) - .build()?; - - let result = match client.get(url).send() { - Ok(response) => response.status(), - Err(error) => { - if error.is_connect() { - return Ok(ServerStatus::Waiting); - } else { - bail!("Could not connect to server {} on url {}", name, url); - } - } - }; - - if result.is_success() { - Ok(ServerStatus::Running) - } else { - Ok(ServerStatus::Waiting) - } -} - -fn exit_with_error(e: anyhow::Error) -> ! { - eprintln!("An error occurred: {}", e); - - std::process::exit(1) -} - -fn main() { - let args = Args::parse(); - - match run(args) { - Ok(_) => {} - Err(e) => exit_with_error(e), - } -} +use anyhow::{Context, bail}; +use clap::Parser; +use log::info; +use std::collections::HashMap; +use std::ops::AddAssign; +#[cfg(windows)] +use std::os::windows::process::CommandExt; +use std::process::{Child, Command}; +use std::sync::{Arc, LockResult, Mutex, MutexGuard}; +use std::thread; +use std::time::Duration; +use std::{env, fmt}; + +#[derive(Parser)] +#[command(version)] +struct Args { + #[arg(short, long, default_value = "servers.yaml")] + config: String, + + #[arg(short, long, default_value_t = false)] + verbose: bool, + + #[arg(short, long, default_value_t = 10)] + attempts: u8, +} + +#[derive(serde::Deserialize)] +struct Server { + name: String, + url: String, + command: String, + #[serde(default = "default_timeout")] + timeout: u64, +} + +fn default_timeout() -> u64 { + 5 +} + +#[derive(serde::Deserialize)] +struct Config { + servers: Vec, + command: String, +} + +struct ServerProcess { + name: String, + process: Child, +} + +#[derive(PartialEq, Eq)] +enum ServerStatus { + Waiting, + Running, +} + +#[derive(Copy, Clone, Debug)] +struct Attempts(u8); + +impl AddAssign for Attempts { + fn add_assign(&mut self, other: u8) { + self.0 = self.0.wrapping_add(other); + } +} + +impl fmt::Display for Attempts { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl PartialEq for Attempts { + fn eq(&self, other: &u8) -> bool { + self.0 == *other + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +struct ServerName(String); + +fn run(args: Args) -> anyhow::Result<()> { + let Config { servers, command } = get_config(&args.config)?; + let server_processes = start_servers(&servers)?; + let server_processes_arc_mutex = Arc::new(Mutex::new(server_processes)); + let server_processes_clone = Arc::clone(&server_processes_arc_mutex); + let mut attempts = HashMap::::new(); + let log_level = if args.verbose { + simplelog::LevelFilter::Info + } else { + simplelog::LevelFilter::Warn + }; + + simplelog::TermLogger::init( + log_level, + simplelog::Config::default(), + simplelog::TerminalMode::Mixed, + simplelog::ColorChoice::Auto, + )?; + + ctrlc::set_handler(move || { + let mut processes = server_processes_clone.lock(); + + match stop_servers(&mut processes) { + Ok(_) => info!("All servers stopped successfully"), + Err(e) => { + eprintln!("Error stopping servers: {}", e); + std::process::exit(1); + } + }; + + std::process::exit(0); + })?; + + loop { + let mut ready = true; + + for server in &servers { + match check_server(server, &mut attempts, args.attempts) { + Ok(result) => { + if result == ServerStatus::Waiting { + ready = false; + } + } + Err(e) => { + stop_servers(&mut server_processes_arc_mutex.lock())?; + + return Err(e); + } + } + } + + if ready { + let mut process = + run_command(&command).context(format!("Could not start process {}", command))?; + + info!("Running command {}", command); + + process.wait()?; + + info!("Command {} finished successfully", command); + + break; + } + + thread::sleep(Duration::from_secs(1)); + } + + stop_servers(&mut server_processes_arc_mutex.lock())?; + + Ok(()) +} + +fn get_config(filename: &str) -> anyhow::Result { + let cwd = env::current_dir()?; + let tmp_path = cwd.join(filename); + let config_file_path = tmp_path.to_str().context(format!( + "Could not create String from Path {}", + tmp_path.display() + ))?; + + info!("Loading config file {}", config_file_path); + + let settings = config::Config::builder() + .add_source(config::File::new( + config_file_path, + config::FileFormat::Yaml, + )) + .build() + .context(format!("Could not find config file {}", filename))?; + + let config = settings + .try_deserialize::() + .context(format!("Could not parse config file {}", filename))?; + + if config.servers.is_empty() { + bail!("Configuration must include at least one server"); + } + + if config.command.trim().is_empty() { + bail!("Configuration must include a command to run"); + } + + Ok(config) +} + +fn start_servers(servers: &Vec) -> anyhow::Result> { + let mut server_processes = Vec::with_capacity(servers.len()); + + for s in servers { + info!("Starting server {}", s.name); + + let server_process = ServerProcess { + name: s.name.to_string(), + process: run_command(&s.command)?, + }; + + server_processes.push(server_process); + } + + Ok(server_processes) +} + +fn stop_servers( + server_processes: &mut LockResult>>, +) -> anyhow::Result<()> { + let processes = match server_processes { + Ok(p) => p, + Err(e) => bail!("{}", e), + }; + + for p in processes.iter_mut() { + info!("Stopping server {}", p.name); + + if p.process.kill().is_ok() { + let _ = p.process.wait(); + } else { + bail!("Failed to stop process {}", p.name); + } + } + + info!("All servers stopped successfully"); + + Ok(()) +} + +fn run_command(command: &str) -> anyhow::Result { + let command_parts = + shlex::split(command).ok_or_else(|| anyhow::anyhow!("Invalid command: {}", command))?; + + if command_parts.is_empty() { + bail!("Empty command provided"); + } + + let mut cmd = Command::new(&command_parts[0]); + + for part in command_parts.iter().skip(1) { + cmd.arg(part); + } + + #[cfg(windows)] + { + cmd.creation_flags(0x08000000); + } + + Ok(cmd.spawn()?) +} + +fn check_server( + server: &Server, + server_attempts: &mut HashMap, + max_attempts: u8, +) -> anyhow::Result { + let Server { + name, url, timeout, .. + } = server; + + let attempts = server_attempts + .entry(ServerName(name.to_owned())) + .and_modify(|attempts| *attempts += 1) + .or_insert(Attempts(1)); + + if *attempts == max_attempts { + let attempt_word = if max_attempts == 1 { + "attempt" + } else { + "attempts" + }; + bail!( + "Could not connect to server {} after {} {}", + name, + attempts, + attempt_word + ); + } + + info!( + "Checking server {} on url {}, attempt {}, waiting one second ...", + name, url, attempts + ); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(*timeout)) + .build()?; + + let result = match client.get(url).send() { + Ok(response) => response.status(), + Err(error) => { + if error.is_connect() { + return Ok(ServerStatus::Waiting); + } else { + bail!("Could not connect to server {} on url {}", name, url); + } + } + }; + + if result.is_success() { + Ok(ServerStatus::Running) + } else { + Ok(ServerStatus::Waiting) + } +} + +fn exit_with_error(e: anyhow::Error) -> ! { + eprintln!("An error occurred: {}", e); + + std::process::exit(1) +} + +fn main() { + let args = Args::parse(); + + match run(args) { + Ok(_) => {} + Err(e) => exit_with_error(e), + } +} diff --git a/tests/cli.rs b/tests/cli.rs index f364374..aefb2d7 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -1,173 +1,171 @@ -use assert_cmd::Command; -use predicates::prelude::*; - -#[test] -fn runs() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command.assert().success(); -} - -#[test] -fn fails_on_missing_config_file() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("foobar.yaml") - .assert() - .failure() - .stderr(predicate::str::contains( - "Could not find config file foobar.yaml", - )); -} - -#[test] -fn fails_on_too_many_attempts() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/max_attempts.yaml") - .assert() - .failure() - .stderr(predicate::str::contains( - "Could not connect to server Hello World after 10 attempts", - )); -} - -#[test] -fn fails_on_too_many_attempts_custom() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/max_attempts.yaml") - .arg("-a") - .arg("5") - .assert() - .failure() - .stderr(predicate::str::contains( - "Could not connect to server Hello World after 5 attempts", - )); -} - -#[test] -fn fails_on_empty_server_list() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/empty_servers.yaml") - .assert() - .failure() - .stderr(predicate::str::contains( - "Configuration must include at least one server", - )); -} - -#[test] -fn fails_on_timeout_with_custom_timeout() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/timeout.yaml") - .arg("-a") - .arg("2") - .assert() - .failure() - .stderr(predicate::str::contains( - "Could not connect to server Timeout Test Server after 2 attempts", - )); -} - -#[test] -fn fails_on_empty_command() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/empty_command.yaml") - .assert() - .failure() - .stderr(predicate::str::contains( - "Configuration must include a command to run", - )); -} - -#[test] -fn fails_on_invalid_yaml() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/invalid_yaml.yaml") - .assert() - .failure() - .stderr(predicate::str::contains( - "Could not find config file tests/invalid_yaml.yaml", - )); -} - -#[test] -fn fails_on_missing_required_fields() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/missing_fields.yaml") - .assert() - .failure() - .stderr(predicate::str::contains( - "Could not parse config file tests/missing_fields.yaml", - )); -} - -#[test] -fn fails_on_multiple_unreachable_servers() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/multiple_servers.yaml") - .arg("-a") - .arg("2") - .assert() - .failure() - .stderr(predicate::str::contains( - "Could not connect to server", - )); -} - -#[test] -fn fails_on_zero_timeout() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/zero_timeout.yaml") - .arg("-a") - .arg("1") - .assert() - .failure() - .stderr(predicate::str::contains( - "Could not connect to server Zero Timeout Server after 1 attempts", - )); -} - -#[test] -fn fails_on_one_attempt() { - let mut command = Command::cargo_bin("server-runner").unwrap(); - - command - .arg("-c") - .arg("tests/timeout.yaml") - .arg("-a") - .arg("1") - .assert() - .failure() - .stderr(predicate::str::contains( - "Could not connect to server Timeout Test Server after 1 attempts", - )); -} +use assert_cmd::Command; +use predicates::prelude::*; + +#[test] +fn runs() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command.assert().success(); +} + +#[test] +fn fails_on_missing_config_file() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("foobar.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not find config file foobar.yaml", + )); +} + +#[test] +fn fails_on_too_many_attempts() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/max_attempts.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not connect to server Hello World after 10 attempts", + )); +} + +#[test] +fn fails_on_too_many_attempts_custom() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/max_attempts.yaml") + .arg("-a") + .arg("5") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not connect to server Hello World after 5 attempts", + )); +} + +#[test] +fn fails_on_empty_server_list() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/empty_servers.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Configuration must include at least one server", + )); +} + +#[test] +fn fails_on_timeout_with_custom_timeout() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/timeout.yaml") + .arg("-a") + .arg("2") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not connect to server Timeout Test Server after 2 attempts", + )); +} + +#[test] +fn fails_on_empty_command() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/empty_command.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Configuration must include a command to run", + )); +} + +#[test] +fn fails_on_invalid_yaml() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/invalid_yaml.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not find config file tests/invalid_yaml.yaml", + )); +} + +#[test] +fn fails_on_missing_required_fields() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/missing_fields.yaml") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not parse config file tests/missing_fields.yaml", + )); +} + +#[test] +fn fails_on_multiple_unreachable_servers() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/multiple_servers.yaml") + .arg("-a") + .arg("2") + .assert() + .failure() + .stderr(predicate::str::contains("Could not connect to server")); +} + +#[test] +fn fails_on_zero_timeout() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/zero_timeout.yaml") + .arg("-a") + .arg("1") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not connect to server Zero Timeout Server after 1 attempt", + )); +} + +#[test] +fn fails_on_one_attempt() { + let mut command = Command::cargo_bin("server-runner").unwrap(); + + command + .arg("-c") + .arg("tests/timeout.yaml") + .arg("-a") + .arg("1") + .assert() + .failure() + .stderr(predicate::str::contains( + "Could not connect to server Timeout Test Server after 1 attempt", + )); +}