use anyhow::Result;
use serde::Serialize;
use crate::cli::Format;
use crate::progress::Step;
use crate::shell::Dialect;
use crate::ssh::Session;
use crate::{output, progress, remote};
fn newest(listing: &str) -> Option<String> {
remote::backups_in(listing).into_iter().next()
}
pub fn backup(target_name: Option<String>, overrides: remote::Overrides) -> Result<()> {
let target = remote::resolve(target_name, overrides)?;
let dir = &target.path.dir;
let step = Step::start(format!("Connecting to {} ...", target.connection_label()));
let session = remote::connect(&target.server, &target.credential)?;
let dialect = remote::dialect(&session, &target.server);
step.update(format!("Backing up {dir} ..."));
let name = remote::run_backup(&session, dialect, dir)?;
step.done(format!("Backup {} created in {}", name.trim(), remote::backups_dir(dir)));
crate::journal::record("backup", Some(&target.app.name), Some(&target.server.name), Some(name.trim()));
keep_only(&session, dialect, &target);
Ok(())
}
pub fn keep_only(session: &Session, dialect: Dialect, target: &remote::Resolved) {
let keep = target.keep_backups;
match remote::prune_backups(session, dialect, &target.path.dir, keep) {
Ok(pruned) if pruned.removed.is_empty() => {}
Ok(pruned) => crate::progress::info(&format!(
"Removed {} - the stand keeps the newest {keep} backup{}{}",
describe(&pruned.removed),
if keep == 1 { "" } else { "s" },
match &target.target {
Some(name) => format!(" (`turnout target edit {name} --keep-backups N` changes it)"),
None => String::new(),
}
)),
Err(err) => crate::progress::warn(&format!("could not prune the old backups: {err:#}")),
}
}
fn describe(removed: &[String]) -> String {
match removed {
[one] => format!("the old backup {one}"),
many => format!("{} old backups", many.len()),
}
}
#[derive(Serialize)]
struct PruneView {
dir: String,
keep: u32,
removed: Vec<String>,
kept: usize,
}
pub fn prune(target_name: Option<String>, overrides: remote::Overrides, keep: Option<u32>, format: Format) -> Result<()> {
let target = remote::resolve(target_name, overrides)?;
let keep = keep.unwrap_or(target.keep_backups);
let dir = remote::backups_dir(&target.path.dir);
let step = Step::start(format!("Connecting to {} ...", target.connection_label()));
let session = remote::connect(&target.server, &target.credential)?;
let dialect = remote::dialect(&session, &target.server);
step.update(format!("Pruning {dir} ..."));
let pruned = remote::prune_backups(&session, dialect, &target.path.dir, keep)?;
step.clear();
if !pruned.removed.is_empty() {
crate::journal::record(
"backup.prune",
Some(&target.app.name),
Some(&target.server.name),
Some(&format!("{} removed", pruned.removed.len())),
);
}
let view = PruneView {
dir,
keep,
removed: pruned.removed,
kept: pruned.kept,
};
output::emit(format, &view, |view| {
let plural = |count: usize| if count == 1 { "" } else { "s" };
if view.removed.is_empty() {
return format!(
"Nothing to prune: {} backup{} in {}, and it keeps {}.",
view.kept,
plural(view.kept),
view.dir,
view.keep
);
}
let mut lines = vec![format!(
"Removed {} backup{} from {}, kept the newest {}:",
view.removed.len(),
plural(view.removed.len()),
view.dir,
view.kept
)];
lines.extend(view.removed.iter().map(|name| format!(" {name}")));
lines.join("\n")
})
}
#[derive(Serialize)]
struct Backups {
dir: String,
backups: Vec<String>,
}
pub fn restore(target_name: Option<String>, overrides: remote::Overrides, from: Option<String>, list: Option<Format>) -> Result<()> {
let target = remote::resolve(target_name, overrides)?;
let deploy_dir = target.path.dir.clone();
let backups_raw = remote::backups_dir(&deploy_dir);
let step = Step::start(format!("Connecting to {} ...", target.connection_label()));
let session = remote::connect(&target.server, &target.credential)?;
let dialect = remote::dialect(&session, &target.server);
remote::check_quotable(dialect, &[&deploy_dir, &backups_raw])?;
step.clear();
if let Some(format) = list {
let listing = remote::exec(&session, &dialect.list_dir(&backups_raw))?;
let backups = Backups {
backups: remote::backups_in(&listing),
dir: backups_raw,
};
let app = target.app.name.clone();
return output::emit(format, &backups, |backups| {
if backups.backups.is_empty() {
return format!("No backups yet in {} - create one with `turnout backup {app}`.", backups.dir);
}
backups.backups.join("\n")
});
}
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))?
}
};
let step = Step::start(format!("Restoring {name} into {deploy_dir} ..."));
let left = remote::restore_backup(&session, dialect, &deploy_dir, &name)?;
step.done(format!("Restored {name} into {deploy_dir}"));
if !left.is_empty() {
progress::warn(&remote::not_removed_note(&deploy_dir, &left));
}
crate::journal::record("restore", Some(&target.app.name), Some(&target.server.name), Some(&name));
if let Some(restart) = &target.path.restart {
let mut session = session;
let connect = || remote::connect(&target.server, &target.credential);
let step = Step::start(format!("Running: {restart}"));
let output = remote::restart(&mut session, dialect, &target.path, restart, &connect, |line| {
step.update(format!("Running: {restart} ยท {line}"))
})?;
step.done(format!("Restarted: {restart}"));
if !output.trim().is_empty() {
println!("{}", output.trim_end());
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::newest;
use crate::remote::is_backup_name;
#[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).as_deref(), Some("20260812-181500.tar.gz"));
let crlf = "20260810-120000.tar.gz\r\n20260812-181500.tar.gz\r\n";
assert_eq!(newest(crlf).as_deref(), 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).as_deref(), 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).as_deref(), 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);
}
}