use std::path::{Path, PathBuf};
use miette::{Result, bail};
use super::super::method::PostgresqlConfig;
const HEADROOM_DIVISOR: u64 = 5;
const HEADROOM_FLOOR: u64 = 1024 * 1024 * 1024;
#[cfg(windows)]
const VSS_FLOOR: u64 = 1024 * 1024 * 1024;
pub fn required_free(need: u64) -> u64 {
need.saturating_add((need / HEADROOM_DIVISOR).max(HEADROOM_FLOOR))
}
#[cfg(windows)]
pub fn vss_required_free(need: Option<u64>) -> u64 {
need.map_or(VSS_FLOOR, |n| (n / 10).max(VSS_FLOOR))
}
pub fn available(path: &Path) -> Option<u64> {
let mut current = Some(path);
while let Some(p) = current {
if p.exists() {
return fs4::available_space(p).ok();
}
current = p.parent();
}
None
}
pub async fn estimate_needed(config: &PostgresqlConfig, data_dir: &Path) -> Option<u64> {
let sql = db_size_sql(config).await;
let walk = match dir_size(data_dir).await {
0 => None,
n => Some(n),
};
match (sql, walk) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
}
}
async fn db_size_sql(config: &PostgresqlConfig) -> Option<u64> {
let mut cmd = super::pg_command(&super::postgres_bin("psql", &config_data_dir(config)));
cmd.args(["-X", "-q", "-w", "-tAc"]);
cmd.arg("SELECT COALESCE(sum(pg_database_size(oid)), 0)::bigint FROM pg_database");
super::apply_connection(&mut cmd, config);
cmd.stdin(std::process::Stdio::null());
let output = cmd.output().await.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
fn config_data_dir(config: &PostgresqlConfig) -> PathBuf {
config.data_dir.clone().unwrap_or_default()
}
pub async fn dir_size(root: &Path) -> u64 {
let root = root.to_path_buf();
tokio::task::spawn_blocking(move || walk_size(&root))
.await
.unwrap_or(0)
}
fn walk_size(root: &Path) -> u64 {
let mut total = 0u64;
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
stack.push(entry.path());
} else if file_type.is_file()
&& let Ok(meta) = entry.metadata()
{
total = total.saturating_add(meta.len());
}
}
}
total
}
struct Candidate {
root: PathBuf,
available: Option<u64>,
detected: bool,
}
fn staging_root_under(base: &Path, backup_type: &str) -> PathBuf {
base.join("bestool")
.join("backup-source")
.join(backup_type)
}
pub fn choose_staging_root(
backup_type: &str,
override_dir: Option<&Path>,
need: Option<u64>,
) -> Result<PathBuf> {
if let Some(dir) = override_dir {
let root = staging_root_under(dir, backup_type);
if let Some(need) = need {
let free = available(&root);
if free.is_some_and(|f| f < required_free(need)) {
bail!(
"configured staging directory {} has {} free, but the base backup needs about {} ({} with headroom); free space there or point staging at a larger disk",
dir.display(),
fmt_bytes(free.unwrap_or(0)),
fmt_bytes(need),
fmt_bytes(required_free(need)),
);
}
}
return Ok(root);
}
let default_root = super::stable_source_dir(backup_type);
let Some(need) = need else {
return Ok(default_root);
};
let mut candidates = vec![Candidate {
available: available(&default_root),
root: default_root,
detected: false,
}];
for base in detected_backup_dirs() {
let root = staging_root_under(&base, backup_type);
candidates.push(Candidate {
available: available(&root),
root,
detected: true,
});
}
match pick(&candidates, required_free(need)) {
Some(root) => Ok(root),
None => bail!("{}", shortfall_message(&candidates, need)),
}
}
fn pick(candidates: &[Candidate], required: u64) -> Option<PathBuf> {
let fits = |c: &&Candidate| c.available.is_some_and(|a| a >= required);
candidates
.iter()
.filter(|c| c.detected)
.filter(fits)
.max_by_key(|c| c.available.unwrap_or(0))
.or_else(|| candidates.iter().filter(|c| !c.detected).find(fits))
.map(|c| c.root.clone())
}
fn shortfall_message(candidates: &[Candidate], need: u64) -> String {
let mut msg = format!(
"not enough disk space to stage the base backup: it needs about {} ({} with headroom), but",
fmt_bytes(need),
fmt_bytes(required_free(need)),
);
for candidate in candidates {
msg.push_str(&format!(
"\n - {} has {}",
candidate.root.display(),
candidate
.available
.map_or_else(|| "unknown free space".to_owned(), fmt_bytes),
));
}
msg
}
#[cfg(windows)]
fn detected_backup_dirs() -> Vec<PathBuf> {
let disks = sysinfo::Disks::new_with_refreshed_list();
let mut out = Vec::new();
for disk in &disks {
if disk.is_removable() {
continue;
}
let mount = disk.mount_point();
for name in ["Backup", "Backups"] {
let folder = mount.join(name);
if folder.is_dir() {
out.push(folder);
}
}
}
out
}
#[cfg(not(windows))]
fn detected_backup_dirs() -> Vec<PathBuf> {
Vec::new()
}
pub(super) fn fmt_bytes(bytes: u64) -> String {
const UNITS: [&str; 6] = ["B", "KiB", "MiB", "GiB", "TiB", "PiB"];
let mut value = bytes as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{bytes} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn required_free_uses_the_larger_of_a_fifth_or_a_gib() {
assert_eq!(required_free(100 * 1024 * 1024), 100 * 1024 * 1024 + HEADROOM_FLOOR);
let ten_gib = 10 * 1024 * 1024 * 1024;
assert_eq!(required_free(ten_gib), ten_gib + ten_gib / 5);
}
#[cfg(windows)]
#[test]
fn vss_required_free_floors_at_a_gib() {
assert_eq!(vss_required_free(None), VSS_FLOOR);
assert_eq!(vss_required_free(Some(1024)), VSS_FLOOR);
let hundred_gib = 100 * 1024 * 1024 * 1024;
assert_eq!(vss_required_free(Some(hundred_gib)), hundred_gib / 10);
}
#[test]
fn estimate_absent_when_both_unknown() {
let combine = |sql: Option<u64>, walk: Option<u64>| match (sql, walk) {
(Some(a), Some(b)) => Some(a.max(b)),
(a, b) => a.or(b),
};
assert_eq!(combine(None, None), None);
assert_eq!(combine(Some(5), None), Some(5));
assert_eq!(combine(None, Some(7)), Some(7));
assert_eq!(combine(Some(5), Some(7)), Some(7));
}
fn candidate(root: &str, available: Option<u64>, detected: bool) -> Candidate {
Candidate {
root: PathBuf::from(root),
available,
detected,
}
}
#[test]
fn pick_prefers_the_roomiest_detected_disk_with_room() {
let candidates = [
candidate("/system", Some(100), false),
candidate("/disk-d", Some(500), true),
candidate("/disk-e", Some(800), true),
];
assert_eq!(pick(&candidates, 50), Some(PathBuf::from("/disk-e")));
}
#[test]
fn pick_falls_back_to_system_when_no_detected_disk_fits() {
let candidates = [
candidate("/system", Some(1000), false),
candidate("/disk-d", Some(10), true),
];
assert_eq!(pick(&candidates, 500), Some(PathBuf::from("/system")));
}
#[test]
fn pick_none_when_nothing_fits() {
let candidates = [
candidate("/system", Some(10), false),
candidate("/disk-d", Some(20), true),
candidate("/disk-e", None, true),
];
assert_eq!(pick(&candidates, 500), None);
}
#[test]
fn choose_uses_override_verbatim_when_it_fits() {
let root = choose_staging_root("pg", Some(Path::new("/mnt/backups")), None).unwrap();
assert_eq!(root, PathBuf::from("/mnt/backups/bestool/backup-source/pg"));
}
#[test]
fn fmt_bytes_scales_units() {
assert_eq!(fmt_bytes(512), "512 B");
assert_eq!(fmt_bytes(1024), "1.0 KiB");
assert_eq!(fmt_bytes(5 * 1024 * 1024), "5.0 MiB");
assert_eq!(fmt_bytes(3 * 1024 * 1024 * 1024), "3.0 GiB");
}
#[tokio::test]
async fn dir_size_sums_files_recursively() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("a"), vec![0u8; 100]).unwrap();
let sub = tmp.path().join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("b"), vec![0u8; 200]).unwrap();
assert_eq!(dir_size(tmp.path()).await, 300);
}
}