feat: add local commands flow
This commit is contained in:
+126
@@ -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:#}");
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-11
@@ -1,5 +1,6 @@
|
|||||||
mod cli;
|
mod cli;
|
||||||
mod config;
|
mod config;
|
||||||
|
mod local;
|
||||||
|
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
@@ -50,13 +51,18 @@ fn run(cli: &cli::Cli) -> Result<()> {
|
|||||||
project.sync.len(),
|
project.sync.len(),
|
||||||
project.remote.as_ref().map_or(0, |c| c.commands.len()),
|
project.remote.as_ref().map_or(0, |c| c.commands.len()),
|
||||||
);
|
);
|
||||||
if cli.dry_run {
|
let local_cmds = project
|
||||||
println!("dry-run plan:");
|
|
||||||
for cmd in project
|
|
||||||
.local
|
.local
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or([].as_slice(), |c| c.commands.as_slice())
|
.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 local_cmds {
|
||||||
println!(" local : {cmd}");
|
println!(" local : {cmd}");
|
||||||
}
|
}
|
||||||
for rule in &project.sync {
|
for rule in &project.sync {
|
||||||
@@ -66,15 +72,18 @@ fn run(cli: &cli::Cli) -> Result<()> {
|
|||||||
rule.target.display()
|
rule.target.display()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for cmd in project
|
for cmd in remote_cmds {
|
||||||
.remote
|
|
||||||
.as_ref()
|
|
||||||
.map_or([].as_slice(), |c| c.commands.as_slice())
|
|
||||||
{
|
|
||||||
println!(" remote: {cmd}");
|
println!(" remote: {cmd}");
|
||||||
}
|
}
|
||||||
} else {
|
} 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user