use anyhow::Result;
use crate::progress::Step;
use crate::remote;
fn newest(listing: &str) -> Option<&str> {
listing.lines().map(str::trim).filter(|line| is_backup_name(line)).max()
}
fn is_backup_name(entry: &str) -> bool {
let Some(stem) = entry.strip_suffix(".tar.gz") else {
return false;
};
let bytes = stem.as_bytes();
bytes.len() == 15 && bytes[8] == b'-' && stem.chars().enumerate().all(|(i, c)| if i == 8 { c == '-' } else { c.is_ascii_digit() })
}
pub fn backup(app_name: Option<String>, server_name: Option<String>) -> Result<()> {
let target = remote::resolve(app_name, server_name)?;
let ssh = remote::require_ssh(&target.server)?;
let deploy = remote::require_deploy_target(&target.server, &target.app.name)?;
let step = Step::start(format!("Connecting to {}@{}:{} ...", ssh.user, ssh.host, ssh.port));
let session = remote::connect(ssh, &target.server.name)?;
let dialect = remote::dialect(&session, &target.server);
step.update(format!("Backing up {} ...", deploy.path));
let name = remote::run_backup(&session, dialect, &deploy.path)?;
step.done(format!("Backup {} created in {}", name.trim(), remote::backups_dir(&deploy.path)));
crate::journal::record("backup", Some(&target.app.name), Some(&target.server.name), Some(name.trim()));
Ok(())
}
pub fn restore(app_name: Option<String>, server_name: Option<String>, from: Option<String>, list: bool) -> Result<()> {
let target = remote::resolve(app_name, server_name)?;
let ssh = remote::require_ssh(&target.server)?;
let deploy = remote::require_deploy_target(&target.server, &target.app.name)?;
let backups_raw = remote::backups_dir(&deploy.path);
let step = Step::start(format!("Connecting to {}@{}:{} ...", ssh.user, ssh.host, ssh.port));
let session = remote::connect(ssh, &target.server.name)?;
let dialect = remote::dialect(&session, &target.server);
remote::check_quotable(dialect, &[&deploy.path, &backups_raw])?;
step.clear();
if list {
let output = remote::exec(&session, &dialect.list_dir(&backups_raw))?;
if output.trim().is_empty() {
println!("No backups yet in {backups_raw} - create one with `turnout backup {}`.", target.app.name);
} else {
println!("{}", output.trim_end());
}
return Ok(());
}
let name = match from {
Some(name) => name,
None => {
let listing = remote::exec(&session, &dialect.list_dir(&backups_raw))?;
newest(&listing)
.ok_or_else(|| anyhow::anyhow!("no backups in {backups_raw} - create one with `turnout backup {}`", target.app.name))?
.to_string()
}
};
remote::check_quotable(dialect, &[&name])?;
let archive = remote::join_remote(&backups_raw, &name);
let dir = deploy.path.trim_end_matches(['/', '\\']);
let step = Step::start(format!("Restoring {name} into {} ...", deploy.path));
remote::exec(
&session,
&dialect.and_then(
&dialect.file_exists(&archive),
&dialect.and_then(&dialect.clear_dir(dir), &dialect.untar(&archive, dir)),
),
)?;
step.done(format!("Restored {name} into {}", deploy.path));
crate::journal::record("restore", Some(&target.app.name), Some(&target.server.name), Some(&name));
if let Some(restart) = &deploy.restart {
let step = Step::start(format!("Running: {restart}"));
let output = remote::exec(&session, restart)?;
step.done(format!("Ran: {restart}"));
if !output.trim().is_empty() {
println!("{}", output.trim_end());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{is_backup_name, newest};
#[test]
fn the_newest_backup_wins_regardless_of_listing_order() {
let shuffled = "20260810-120000.tar.gz\n20260812-181500.tar.gz\n20260811-090000.tar.gz\n";
assert_eq!(newest(shuffled), Some("20260812-181500.tar.gz"));
let crlf = "20260810-120000.tar.gz\r\n20260812-181500.tar.gz\r\n";
assert_eq!(newest(crlf), Some("20260812-181500.tar.gz"));
}
#[test]
fn backups_from_the_same_day_compare_by_time() {
let listing = "20260812-090000.tar.gz\n20260812-181500.tar.gz\n20260812-093000.tar.gz";
assert_eq!(newest(listing), Some("20260812-181500.tar.gz"));
}
#[test]
fn only_our_own_archives_are_candidates() {
assert!(is_backup_name("20260812-181500.tar.gz"));
assert!(!is_backup_name("notes.txt"));
assert!(!is_backup_name("site-final.tar.gz"), "not a timestamp");
assert!(!is_backup_name("20260812-181500.zip"));
assert!(!is_backup_name("2026081-181500.tar.gz"), "wrong width");
let mixed = "readme.txt\n20260812-181500.tar.gz\nold-backup.tar.gz\n";
assert_eq!(newest(mixed), Some("20260812-181500.tar.gz"));
}
#[test]
fn nothing_to_restore_is_not_a_backup() {
assert_eq!(newest(""), None);
assert_eq!(newest(" \n\n"), None);
assert_eq!(newest("readme.txt\n"), None);
}
}