use super::duration::Duration;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
pub id: Uuid,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default)]
pub include: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub template: HashMap<String, String>,
#[serde(default, rename = "group")]
pub groups: HashMap<String, Group>,
#[serde(default)]
pub config: ActionConfig,
#[serde(default, rename = "rule")]
pub rules: Vec<Rule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Group {
pub actions: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct ActionConfig {
pub backup: Option<BackupConfig>,
pub archive: Option<ArchiveConfig>,
pub fs: Option<FsConfig>,
pub vcs: Option<VcsConfig>,
#[serde(default, rename = "hook")]
pub hooks: HashMap<String, HookConfig>,
#[serde(default, rename = "notify")]
pub notifies: HashMap<String, NotifyConfig>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct VcsConfig {
#[serde(default)]
pub skip_if_no_vcs: bool,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub overrides: std::collections::HashMap<String, VcsConfigOverride>,
}
impl VcsConfig {
#[must_use]
pub fn resolve(&self, tag: Option<&str>) -> ResolvedVcsConfig {
let ov = tag.and_then(|t| self.overrides.get(t));
ResolvedVcsConfig {
skip_if_no_vcs: ov
.and_then(|o| o.skip_if_no_vcs)
.unwrap_or(self.skip_if_no_vcs),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VcsConfigOverride {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub skip_if_no_vcs: Option<bool>,
}
#[derive(Debug, Clone)]
pub struct ResolvedVcsConfig {
pub skip_if_no_vcs: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotifyConfig {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupConfig {
pub server: String,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub overrides: std::collections::HashMap<String, BackupConfigOverride>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackupConfigOverride {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub server: Option<String>,
}
impl BackupConfig {
#[must_use]
pub fn resolve(&self, tag: Option<&str>) -> ResolvedBackupConfig {
let ov = tag.and_then(|t| self.overrides.get(t));
ResolvedBackupConfig {
server: ov
.and_then(|o| o.server.clone())
.unwrap_or_else(|| self.server.clone()),
}
}
}
#[derive(Debug, Clone)]
pub struct ResolvedBackupConfig {
pub server: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveConfig {
#[serde(default = "ArchiveConfig::default_compression")]
pub compression: Compression,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub overrides: std::collections::HashMap<String, ArchiveConfigOverride>,
}
impl Default for ArchiveConfig {
fn default() -> Self {
Self {
compression: Compression::Gz,
overrides: std::collections::HashMap::new(),
}
}
}
impl ArchiveConfig {
fn default_compression() -> Compression {
Compression::Gz
}
#[must_use]
pub fn resolve(&self, tag: Option<&str>) -> ResolvedArchiveConfig {
let ov = tag.and_then(|t| self.overrides.get(t));
ResolvedArchiveConfig {
compression: ov
.and_then(|o| o.compression.clone())
.unwrap_or_else(|| self.compression.clone()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ArchiveConfigOverride {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub compression: Option<Compression>,
}
#[derive(Debug, Clone)]
pub struct ResolvedArchiveConfig {
pub compression: Compression,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum Compression {
Gz,
Zstd,
Xz,
}
impl Compression {
#[must_use]
pub fn extension(&self) -> &str {
match self {
Self::Gz => "tar.gz",
Self::Zstd => "tar.zst",
Self::Xz => "tar.xz",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FsConfig {
#[serde(default)]
pub extra_paths: Vec<String>,
#[serde(default)]
pub cleaners: CleanersConfig,
#[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
pub overrides: std::collections::HashMap<String, FsConfigOverride>,
}
impl FsConfig {
#[must_use]
pub fn resolve(&self, tag: Option<&str>) -> ResolvedFsConfig {
let ov = tag.and_then(|t| self.overrides.get(t));
ResolvedFsConfig {
extra_paths: ov
.and_then(|o| o.extra_paths.clone())
.unwrap_or_else(|| self.extra_paths.clone()),
cleaners: ov
.and_then(|o| o.cleaners.clone())
.unwrap_or_else(|| self.cleaners.clone()),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FsConfigOverride {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub extra_paths: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cleaners: Option<CleanersConfig>,
}
#[derive(Debug, Clone)]
pub struct ResolvedFsConfig {
pub extra_paths: Vec<String>,
pub cleaners: CleanersConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CleanersConfig {
#[serde(default = "default_true")]
pub rust: bool,
#[serde(default = "default_true")]
pub node: bool,
#[serde(default = "default_true")]
pub python: bool,
}
impl Default for CleanersConfig {
fn default() -> Self {
Self {
rust: true,
node: true,
python: true,
}
}
}
fn default_true() -> bool {
true
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
pub command: String,
#[serde(default)]
pub kind: HookKind,
#[serde(default)]
pub run_on_archive: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum HookKind {
#[default]
Mutation,
Check,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Rule {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub after: Duration,
pub actions: Vec<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub once: bool,
}
impl Rule {
#[must_use]
pub fn rule_hash(&self) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(self.after.to_string().as_bytes());
for action in &self.actions {
hasher.update(b"\n");
hasher.update(action.as_bytes());
}
hasher.finalize().iter().fold(String::new(), |mut acc, b| {
use std::fmt::Write as _;
let _ = write!(acc, "{b:02x}");
acc
})
}
}
impl ProjectConfig {
pub fn expand_groups(&self) -> Result<Vec<Vec<String>>, crate::error::FrostxError> {
self.rules
.iter()
.map(|rule| expand_action_list(&rule.actions, &self.groups, &mut vec![]))
.collect()
}
pub fn require_backup(&self) -> Result<&BackupConfig, crate::error::FrostxError> {
self.config
.backup
.as_ref()
.ok_or(crate::error::FrostxError::BackupConfigMissing)
}
pub fn resolve_backup(
&self,
tag: Option<&str>,
) -> Result<ResolvedBackupConfig, crate::error::FrostxError> {
Ok(self.require_backup()?.resolve(tag))
}
#[must_use]
pub fn resolve_archive(&self, tag: Option<&str>) -> ResolvedArchiveConfig {
self.config
.archive
.as_ref()
.map_or_else(|| ArchiveConfig::default().resolve(tag), |a| a.resolve(tag))
}
#[must_use]
pub fn resolve_fs(&self, tag: Option<&str>) -> ResolvedFsConfig {
self.config
.fs
.as_ref()
.map_or_else(|| FsConfig::default().resolve(tag), |f| f.resolve(tag))
}
#[must_use]
pub fn resolve_vcs(&self, tag: Option<&str>) -> ResolvedVcsConfig {
self.config
.vcs
.as_ref()
.map_or_else(|| VcsConfig::default().resolve(tag), |v| v.resolve(tag))
}
}
fn expand_action_list(
actions: &[String],
groups: &HashMap<String, Group>,
visited: &mut Vec<String>,
) -> Result<Vec<String>, crate::error::FrostxError> {
let mut out = Vec::new();
for action in actions {
if let Some(group_name) = action.strip_prefix("group.") {
if visited.contains(&group_name.to_string()) {
return Err(crate::error::FrostxError::Config(format!(
"circular group reference: {group_name}"
)));
}
let group = groups.get(group_name).ok_or_else(|| {
crate::error::FrostxError::Config(format!("unknown group: {group_name}"))
})?;
visited.push(group_name.to_string());
let expanded = expand_action_list(&group.actions, groups, visited)?;
visited.pop();
out.extend(expanded);
} else {
out.push(action.clone());
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
fn minimal_config(id: Uuid) -> ProjectConfig {
ProjectConfig {
id,
name: None,
description: None,
include: vec![],
template: HashMap::new(),
groups: HashMap::new(),
config: ActionConfig::default(),
rules: vec![],
}
}
#[test]
fn expand_no_groups() {
let mut cfg = minimal_config(Uuid::new_v4());
cfg.rules.push(Rule {
name: None,
after: Duration::parse("90d").unwrap(),
actions: vec!["git.check_clean".into(), "git.check_pushed".into()],
once: false,
});
let expanded = cfg.expand_groups().unwrap();
assert_eq!(expanded[0], vec!["git.check_clean", "git.check_pushed"]);
}
#[test]
fn expand_simple_group() {
let mut cfg = minimal_config(Uuid::new_v4());
cfg.groups.insert(
"checks".into(),
Group {
actions: vec!["git.check_clean".into(), "git.check_pushed".into()],
},
);
cfg.rules.push(Rule {
name: None,
after: Duration::parse("90d").unwrap(),
actions: vec!["group.checks".into(), "backup.check".into()],
once: false,
});
let expanded = cfg.expand_groups().unwrap();
assert_eq!(
expanded[0],
vec!["git.check_clean", "git.check_pushed", "backup.check"]
);
}
#[test]
fn expand_nested_group() {
let mut cfg = minimal_config(Uuid::new_v4());
cfg.groups.insert(
"git".into(),
Group {
actions: vec!["git.check_clean".into()],
},
);
cfg.groups.insert(
"all".into(),
Group {
actions: vec!["group.git".into(), "backup.check".into()],
},
);
cfg.rules.push(Rule {
name: None,
after: Duration::parse("90d").unwrap(),
actions: vec!["group.all".into()],
once: false,
});
let expanded = cfg.expand_groups().unwrap();
assert_eq!(expanded[0], vec!["git.check_clean", "backup.check"]);
}
#[test]
fn circular_group_detected() {
let mut cfg = minimal_config(Uuid::new_v4());
cfg.groups.insert(
"a".into(),
Group {
actions: vec!["group.b".into()],
},
);
cfg.groups.insert(
"b".into(),
Group {
actions: vec!["group.a".into()],
},
);
cfg.rules.push(Rule {
name: None,
after: Duration::parse("90d").unwrap(),
actions: vec!["group.a".into()],
once: false,
});
assert!(cfg.expand_groups().is_err());
}
#[test]
fn unknown_group_error() {
let mut cfg = minimal_config(Uuid::new_v4());
cfg.rules.push(Rule {
name: None,
after: Duration::parse("90d").unwrap(),
actions: vec!["group.missing".into()],
once: false,
});
assert!(cfg.expand_groups().is_err());
}
#[test]
fn compression_extension() {
assert_eq!(Compression::Gz.extension(), "tar.gz");
assert_eq!(Compression::Zstd.extension(), "tar.zst");
assert_eq!(Compression::Xz.extension(), "tar.xz");
}
#[test]
fn backup_resolve_no_tag_returns_base() {
let cfg = BackupConfig {
server: "rsync://base.example.com/".into(),
overrides: std::collections::HashMap::new(),
};
assert_eq!(cfg.resolve(None).server, "rsync://base.example.com/");
}
#[test]
fn backup_resolve_unknown_tag_falls_back_to_base() {
let cfg = BackupConfig {
server: "rsync://base.example.com/".into(),
overrides: std::collections::HashMap::new(),
};
assert_eq!(
cfg.resolve(Some("nonexistent")).server,
"rsync://base.example.com/"
);
}
#[test]
fn backup_resolve_tag_overrides_server() {
let mut cfg = BackupConfig {
server: "rsync://base.example.com/".into(),
overrides: std::collections::HashMap::new(),
};
cfg.overrides.insert(
"offsite".into(),
BackupConfigOverride {
server: Some("rsync://offsite.example.com/".into()),
},
);
assert_eq!(
cfg.resolve(Some("offsite")).server,
"rsync://offsite.example.com/"
);
assert_eq!(cfg.resolve(None).server, "rsync://base.example.com/");
}
#[test]
fn backup_resolve_tag_with_absent_server_inherits_base() {
let mut cfg = BackupConfig {
server: "rsync://base.example.com/".into(),
overrides: std::collections::HashMap::new(),
};
cfg.overrides
.insert("partial".into(), BackupConfigOverride { server: None });
assert_eq!(
cfg.resolve(Some("partial")).server,
"rsync://base.example.com/"
);
}
}