use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
use miette::{Context as _, IntoDiagnostic as _, Result, bail};
use serde::Deserialize;
use super::method::{Method, PostgresqlConfig, SimpleConfig};
pub const BACKUPS_DIR_ENV: &str = "BESTOOL_BACKUPS_DIR";
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct Hook {
pub command: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct BackupDef {
pub r#type: String,
pub tags: BTreeMap<String, String>,
pub pre: Vec<Hook>,
pub post: Vec<Hook>,
pub method: Method,
}
#[derive(Debug, Deserialize)]
struct RawDef {
r#type: String,
#[serde(default)]
tags: BTreeMap<String, String>,
#[serde(default)]
pre: Vec<Hook>,
#[serde(default)]
post: Vec<Hook>,
#[serde(default)]
simple: Option<SimpleConfig>,
#[serde(default)]
postgresql: Option<PostgresqlConfig>,
}
impl RawDef {
fn into_def(self) -> Result<BackupDef> {
let method = match (self.simple, self.postgresql) {
(Some(simple), None) => Method::Simple(simple),
(None, Some(postgresql)) => Method::Postgresql(postgresql),
(None, None) => bail!(
"backup def '{}' has no method table; add exactly one of [simple] or [postgresql]",
self.r#type
),
(Some(_), Some(_)) => bail!(
"backup def '{}' has both [simple] and [postgresql]; exactly one is allowed",
self.r#type
),
};
Ok(BackupDef {
r#type: self.r#type,
tags: self.tags,
pre: self.pre,
post: self.post,
method,
})
}
}
pub fn parse_def(text: &str) -> Result<BackupDef> {
let raw: RawDef = toml::from_str(text)
.into_diagnostic()
.wrap_err("parsing backup definition TOML")?;
raw.into_def()
}
pub fn backups_dir() -> PathBuf {
if let Some(dir) = std::env::var_os(BACKUPS_DIR_ENV) {
return PathBuf::from(dir);
}
default_backups_dir()
}
#[cfg(unix)]
fn default_backups_dir() -> PathBuf {
PathBuf::from("/etc/bestool/backups")
}
#[cfg(windows)]
fn default_backups_dir() -> PathBuf {
let base = std::env::var_os("ProgramData")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(r"C:\ProgramData"));
base.join("bestool").join("backups")
}
pub async fn load_dir(dir: &Path) -> Result<Vec<BackupDef>> {
let mut entries = match tokio::fs::read_dir(dir).await {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => {
return Err(err)
.into_diagnostic()
.wrap_err_with(|| format!("reading backups dir {}", dir.display()));
}
};
let mut defs = Vec::new();
while let Some(entry) = entries
.next_entry()
.await
.into_diagnostic()
.wrap_err_with(|| format!("listing backups dir {}", dir.display()))?
{
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("toml") {
continue;
}
let text = tokio::fs::read_to_string(&path)
.await
.into_diagnostic()
.wrap_err_with(|| format!("reading backup def {}", path.display()))?;
let def = parse_def(&text).wrap_err_with(|| format!("in {}", path.display()))?;
defs.push(def);
}
defs.sort_by(|a, b| a.r#type.cmp(&b.r#type));
Ok(defs)
}
pub async fn find_def(dir: &Path, backup_type: &str) -> Result<Option<BackupDef>> {
Ok(load_dir(dir)
.await?
.into_iter()
.find(|d| d.r#type == backup_type))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_simple_def_with_hooks_and_tags() {
let def = parse_def(
r#"
type = "something-custom"
[tags]
app = "tamanu"
[[pre]]
command = ["/usr/bin/touch", "something"]
[[post]]
command = ["/usr/bin/rm", "something"]
[simple]
path = "/somewhere/custom"
"#,
)
.unwrap();
assert_eq!(def.r#type, "something-custom");
assert_eq!(def.tags.get("app").map(String::as_str), Some("tamanu"));
assert_eq!(
def.pre,
vec![Hook {
command: vec!["/usr/bin/touch".into(), "something".into()],
}]
);
assert_eq!(
def.post,
vec![Hook {
command: vec!["/usr/bin/rm".into(), "something".into()],
}]
);
assert_eq!(def.method.name(), "simple");
}
#[test]
fn parses_postgresql_def() {
let def = parse_def(
r#"
type = "tamanu-postgres"
[postgresql]
cluster = "main"
"#,
)
.unwrap();
assert_eq!(def.r#type, "tamanu-postgres");
assert_eq!(def.method.name(), "postgresql");
assert!(def.pre.is_empty());
assert!(def.tags.is_empty());
}
#[test]
fn rejects_two_method_tables() {
let err = parse_def(
r#"
type = "bad"
[simple]
path = "/a"
[postgresql]
cluster = "main"
"#,
)
.unwrap_err();
assert!(format!("{err}").contains("exactly one"));
}
#[test]
fn rejects_no_method_table() {
let err = parse_def(
r#"
type = "bad"
"#,
)
.unwrap_err();
assert!(format!("{err}").contains("no method table"));
}
#[tokio::test]
async fn load_dir_missing_is_empty() {
let dir = std::env::temp_dir().join(format!("bestool-backups-missing-{}", std::process::id()));
let defs = load_dir(&dir).await.unwrap();
assert!(defs.is_empty());
}
#[tokio::test]
async fn load_dir_reads_toml_only_and_finds_by_type() {
let dir = std::env::temp_dir().join(format!("bestool-backups-{}", std::process::id()));
tokio::fs::create_dir_all(&dir).await.unwrap();
tokio::fs::write(
dir.join("pg.toml"),
"type = \"tamanu-postgres\"\n[postgresql]\ncluster = \"main\"\n",
)
.await
.unwrap();
tokio::fs::write(
dir.join("files.toml"),
"type = \"files\"\n[simple]\npath = \"/srv/files\"\n",
)
.await
.unwrap();
tokio::fs::write(dir.join("README.md"), "ignore me").await.unwrap();
let defs = load_dir(&dir).await.unwrap();
assert_eq!(defs.len(), 2);
assert_eq!(defs[0].r#type, "files");
assert_eq!(defs[1].r#type, "tamanu-postgres");
let found = find_def(&dir, "files").await.unwrap().unwrap();
assert_eq!(found.method.name(), "simple");
assert!(find_def(&dir, "nope").await.unwrap().is_none());
tokio::fs::remove_dir_all(&dir).await.ok();
}
}