diff --git a/src/local.rs b/src/local.rs new file mode 100644 index 0000000..af83ed0 --- /dev/null +++ b/src/local.rs @@ -0,0 +1,126 @@ +use std::path::Path; +use std::process::Command; + +use anyhow::{Context, Result, bail}; + +pub fn run_commands(commands: &[String], workdir: &Path) -> Result<()> { + for cmd in commands { + run_command(cmd, workdir)?; + } + Ok(()) +} + +fn run_command(cmd: &str, workdir: &Path) -> Result<()> { + let mut command = shell_command(cmd); + command.current_dir(workdir); + + println!("[Local] running: {cmd} ..."); + + let status = command + .status() + .with_context(|| format!("failed to spawn '{cmd}' in {}", workdir.display()))?; + + if !status.success() { + let code = match status.code() { + Some(code) => format!("exit code {code}"), + None => "terminated by signal".to_string(), + }; + bail!("[Local] running: {cmd} ... FAILED ({code})"); + } + Ok(()) +} + +fn shell_command(cmd: &str) -> Command { + #[cfg(target_os = "windows")] + { + let mut command = Command::new("cmd"); + command.arg("/C").arg(cmd); + command + } + #[cfg(not(target_os = "windows"))] + { + let mut command = Command::new("sh"); + command.arg("-c").arg(cmd); + command + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use std::path::PathBuf; + + fn tmpdir(tag: &str) -> PathBuf { + let dir = + std::env::temp_dir().join(format!("xboctploy-local-{}-{tag}", std::process::id())); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn ok_cmd() -> String { + if cfg!(windows) { + "exit /b 0".into() + } else { + "true".into() + } + } + + fn fail_cmd(code: u8) -> String { + if cfg!(windows) { + format!("exit /b {code}") + } else { + format!("exit {code}") + } + } + + fn marker_cmd(name: &str) -> String { + if cfg!(windows) { + format!("type nul > {name}") + } else { + format!("touch {name}") + } + } + + #[test] + fn successful_commands_pass_silently() { + let cmds = vec![ok_cmd(), ok_cmd()]; + run_commands(&cmds, Path::new(".")).unwrap(); + } + + #[test] + fn fail_fast_stops_on_first_error_with_exit_code() { + let workdir = tmpdir("failfast"); + let marker = workdir.join("first-ran.txt"); + let cmds = vec![ + marker_cmd("first-ran.txt"), + fail_cmd(7), + marker_cmd("never.txt"), + ]; + let err = run_commands(&cmds, &workdir).unwrap_err(); + assert!(err.to_string().contains("exit code 7"), "{err:#}"); + assert!(marker.exists(), "first command did not run"); + assert!( + !workdir.join("never.txt").exists(), + "fail-fast violated: commands ran after the failure" + ); + let _ = fs::remove_dir_all(workdir); + } + + #[test] + fn commands_run_in_workdir() { + let workdir = tmpdir("workdir"); + run_commands(&[marker_cmd("in-workdir.txt")], &workdir).unwrap(); + assert!(workdir.join("in-workdir.txt").exists()); + let _ = fs::remove_dir_all(workdir); + } + + #[test] + fn missing_workdir_is_contextual_error() { + let missing = tmpdir("gone"); + fs::remove_dir(&missing).unwrap(); + let err = run_commands(&[ok_cmd()], &missing).unwrap_err(); + assert!(err.to_string().contains("failed to spawn"), "{err:#}"); + } +} diff --git a/src/main.rs b/src/main.rs index 8902b64..735e119 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod config; +mod local; use std::process::ExitCode; @@ -50,13 +51,18 @@ fn run(cli: &cli::Cli) -> Result<()> { project.sync.len(), project.remote.as_ref().map_or(0, |c| c.commands.len()), ); + let local_cmds = project + .local + .as_ref() + .map_or([].as_slice(), |c| c.commands.as_slice()); + let remote_cmds = project + .remote + .as_ref() + .map_or([].as_slice(), |c| c.commands.as_slice()); + if cli.dry_run { println!("dry-run plan:"); - for cmd in project - .local - .as_ref() - .map_or([].as_slice(), |c| c.commands.as_slice()) - { + for cmd in local_cmds { println!(" local : {cmd}"); } for rule in &project.sync { @@ -66,15 +72,18 @@ fn run(cli: &cli::Cli) -> Result<()> { rule.target.display() ); } - for cmd in project - .remote - .as_ref() - .map_or([].as_slice(), |c| c.commands.as_slice()) - { + for cmd in remote_cmds { println!(" remote: {cmd}"); } } else { - println!("stage 2 not implemented yet: local command execution"); + local::run_commands(local_cmds, &project.workdir)?; + println!("[Local] done: {} cmd(s) succeeded", local_cmds.len()); + if !project.sync.is_empty() { + println!("stage 4 not implemented yet: SFTP file transfer"); + } + if !remote_cmds.is_empty() { + println!("stage 3 not implemented yet: SSH remote execution"); + } } Ok(()) }