diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 110fd0f..33de8f6 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -130,7 +130,9 @@ commands не пустые для секций, которые присутст - жёлтый [Remote] ... Success! - `Deploy successful!` / красный блок ошибки с stderr. 3. Тайминги каждого шага (Instant::now). -4. Опционально: indicatif progress-bar на этапе передачи файлов. +4. indicatif progress-bar решено не добавлять: статистика файлов/байт и тайминги + уже дают достаточно информации, а прогресс-бар усложнил бы вывод при + параллельных логах команд (ADR-002, минимум зависимостей). Критерий приёмки: вывод визуально совпадает с примером UX из PRD; --dry-run печатает весь план всех трёх фаз без единого действия. diff --git a/src/deploy.rs b/src/deploy.rs new file mode 100644 index 0000000..6bd8bc3 --- /dev/null +++ b/src/deploy.rs @@ -0,0 +1,152 @@ +use std::time::{Duration, Instant}; + +use anyhow::Result; +use colored::Colorize; + +use crate::config::Project; +use crate::{local, ssh, sync}; + +pub fn deploy(name: &str, project: &Project, dry_run: bool) -> Result<()> { + println!("deploying '{name}'"); + + 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 dry_run { + print_plan(project, local_cmds, remote_cmds); + return Ok(()); + } + + if local_cmds.is_empty() && project.sync.is_empty() && remote_cmds.is_empty() { + println!("nothing to do: no local commands, sync rules or remote commands"); + return Ok(()); + } + + let total = Instant::now(); + + for cmd in local_cmds { + let step = Instant::now(); + if let Err(err) = local::execute(cmd, &project.workdir) { + fail_line("🟢 [Local]", &format!("running: {cmd}..."), err.to_string()); + return Err(err); + } + success_line( + "🟢 [Local]", + &format!("running: {cmd}..."), + "Success!", + step.elapsed(), + ); + } + + if !project.sync.is_empty() || !remote_cmds.is_empty() { + let mut session = connect(project)?; + + for rule in &project.sync { + let step = Instant::now(); + let target = rule.target.to_string_lossy().to_string(); + let result = sync::upload(&mut session, &rule.source, &target); + let label = format!("transferring {} to {}...", rule.source.display(), target); + match result { + Ok(stats) => success_line( + "🔵 [SFTP]", + &label, + &format!("Done! ({stats})"), + step.elapsed(), + ), + Err(err) => { + fail_line("🔵 [SFTP]", &label, err.to_string()); + return Err(err); + } + } + } + + for cmd in remote_cmds { + let step = Instant::now(); + if let Err(err) = session.exec(cmd) { + fail_line( + "🟡 [Remote]", + &format!("running: {cmd}..."), + err.to_string(), + ); + return Err(err); + } + success_line( + "🟡 [Remote]", + &format!("running: {cmd}..."), + "Success!", + step.elapsed(), + ); + } + } + + println!( + "🎉 {} (total {})", + "Deploy successful!".green().bold(), + fmt_dur(total.elapsed()) + ); + Ok(()) +} + +fn print_plan(project: &Project, local_cmds: &[String], remote_cmds: &[String]) { + println!("dry-run plan:"); + for cmd in local_cmds { + println!(" 🟢 [Local] {cmd}"); + } + for rule in &project.sync { + println!( + " 🔵 [SFTP] {} -> {}", + rule.source.display(), + rule.target.display() + ); + } + for cmd in remote_cmds { + println!(" 🟡 [Remote] {cmd}"); + } + println!("{}", "nothing executed".dimmed()); +} + +fn connect(project: &Project) -> Result { + let target = ssh::Target { + host_alias: project.host.clone(), + user: project.user.clone(), + port: project.port, + key_path: project.key_path.clone(), + }; + let resolved = ssh::resolve(&target)?; + ssh::connect(&resolved) +} + +fn success_line(tag: &str, action: &str, status: &str, took: Duration) { + println!( + "{} {} {} ({})", + tag.green().bold(), + action, + status.green(), + fmt_dur(took).dimmed() + ); +} + +fn fail_line(tag: &str, action: &str, message: String) { + println!( + "{} {} {}", + tag.green().bold(), + action, + "FAILED".red().bold() + ); + eprintln!("{}", message.red()); +} + +fn fmt_dur(d: Duration) -> String { + let ms = d.as_millis(); + if ms < 1000 { + format!("{ms}ms") + } else { + format!("{:.2}s", d.as_secs_f64()) + } +} diff --git a/src/local.rs b/src/local.rs index af83ed0..ef4a796 100644 --- a/src/local.rs +++ b/src/local.rs @@ -3,19 +3,10 @@ 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<()> { +pub fn execute(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()))?; @@ -25,7 +16,7 @@ fn run_command(cmd: &str, workdir: &Path) -> Result<()> { Some(code) => format!("exit code {code}"), None => "terminated by signal".to_string(), }; - bail!("[Local] running: {cmd} ... FAILED ({code})"); + bail!("{cmd} FAILED ({code})"); } Ok(()) } @@ -85,20 +76,24 @@ mod tests { #[test] fn successful_commands_pass_silently() { - let cmds = vec![ok_cmd(), ok_cmd()]; - run_commands(&cmds, Path::new(".")).unwrap(); + execute(&ok_cmd(), Path::new(".")).unwrap(); + execute(&ok_cmd(), 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![ + let cmds = [ marker_cmd("first-ran.txt"), fail_cmd(7), marker_cmd("never.txt"), ]; - let err = run_commands(&cmds, &workdir).unwrap_err(); + let err = cmds + .iter() + .map(|cmd| execute(cmd, &workdir)) + .collect::>>() + .unwrap_err(); assert!(err.to_string().contains("exit code 7"), "{err:#}"); assert!(marker.exists(), "first command did not run"); assert!( @@ -111,7 +106,7 @@ mod tests { #[test] fn commands_run_in_workdir() { let workdir = tmpdir("workdir"); - run_commands(&[marker_cmd("in-workdir.txt")], &workdir).unwrap(); + execute(&marker_cmd("in-workdir.txt"), &workdir).unwrap(); assert!(workdir.join("in-workdir.txt").exists()); let _ = fs::remove_dir_all(workdir); } @@ -120,7 +115,7 @@ mod tests { fn missing_workdir_is_contextual_error() { let missing = tmpdir("gone"); fs::remove_dir(&missing).unwrap(); - let err = run_commands(&[ok_cmd()], &missing).unwrap_err(); + let err = execute(&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 83b30a1..20d9532 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ mod cli; mod config; +mod deploy; mod local; mod ssh; mod sync; @@ -43,66 +44,5 @@ fn run(cli: &cli::Cli) -> Result<()> { .get(name) .with_context(|| format!("project '{name}' not found in {}", path.display()))?; - let target = match &project.user { - Some(user) => format!("{user}@{}:{}", project.host, project.port.unwrap_or(22)), - None => format!("{}:{}", project.host, project.port.unwrap_or(22)), - }; - println!( - "deploying '{name}' to {target}: {} local cmd(s), {} sync rule(s), {} remote cmd(s)", - project.local.as_ref().map_or(0, |c| c.commands.len()), - 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 local_cmds { - println!(" local : {cmd}"); - } - for rule in &project.sync { - println!( - " sync : {} -> {}", - rule.source.display(), - rule.target.display() - ); - } - for cmd in remote_cmds { - println!(" remote: {cmd}"); - } - } else { - local::run_commands(local_cmds, &project.workdir)?; - println!("[Local] done: {} cmd(s) succeeded", local_cmds.len()); - - let needs_session = !project.sync.is_empty() || !remote_cmds.is_empty(); - let mut session = None; - if needs_session { - let target = ssh::Target { - host_alias: project.host.clone(), - user: project.user.clone(), - port: project.port, - key_path: project.key_path.clone(), - }; - let resolved = ssh::resolve(&target)?; - session = Some(ssh::connect(&resolved)?); - } - - if let Some(session) = session.as_mut() { - for rule in &project.sync { - sync::upload(session, &rule.source, &rule.target.to_string_lossy())?; - } - for cmd in remote_cmds { - session.exec(cmd)?; - } - println!("[Remote] done: {} cmd(s) succeeded", remote_cmds.len()); - } - } - Ok(()) + deploy::deploy(name, project, cli.dry_run) } diff --git a/src/ssh.rs b/src/ssh.rs index 45bec8f..b399af8 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -190,12 +190,7 @@ pub(crate) fn runtime() -> &'static tokio::runtime::Runtime { } pub fn connect(resolved: &Resolved) -> Result { - println!( - "connecting to {}@{} (key {}) ...", - resolved.user, - format_host_port(&resolved.host, resolved.port), - resolved.key_path.display() - ); + println!("connecting..."); runtime().block_on(async { let handler = ClientHandler { host: resolved.host.clone(), @@ -248,7 +243,6 @@ async fn authenticate(handle: &mut Handle, r: &Resolved) -> Resul impl Session { pub fn exec(&mut self, cmd: &str) -> Result<()> { - println!("[Remote] running: {cmd} ..."); runtime().block_on(async { use std::io::Write; @@ -286,7 +280,7 @@ impl Session { } else { format!("\nstderr:\n{stderr}") }; - bail!("[Remote] running: {cmd} ... FAILED (exit code {status}){tail}"); + bail!("{cmd} FAILED (exit code {status}){tail}"); } Ok(()) }) diff --git a/src/sync.rs b/src/sync.rs index a18dbab..cb4d353 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -32,8 +32,7 @@ fn human_size(bytes: u64) -> String { } pub fn upload(session: &mut Session, source: &Path, target: &str) -> Result { - println!("[SFTP] transferring {} -> {} ...", source.display(), target); - let stats = runtime().block_on(async { + runtime().block_on(async { if source.is_dir() { upload_dir(session, source, target).await } else if source.is_file() { @@ -41,9 +40,7 @@ pub fn upload(session: &mut Session, source: &Path, target: &str) -> Result Result {