use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use crate::error::{Error, Result};
use crate::exposure::Exposure;
use crate::generate::GeneratedFile;
use crate::manifest;
use crate::metadata::load_metadata;
use crate::registry::resolve::ServiceRef;
use crate::{
AddResult, PlanMode, REGISTRY_DEFAULT, Step, add_service, is_service_installed,
resolve_registry_dir, service_home,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffKind {
Unchanged,
Modified,
Drift,
Added,
Removed,
}
#[derive(Debug, Clone)]
pub struct DiffEntry {
pub path: PathBuf,
pub kind: DiffKind,
}
#[derive(Debug, Clone)]
pub struct EnvAddition {
pub key: String,
pub value: String,
pub kind: crate::registry::service_def::EnvKind,
pub prompt: Option<String>,
}
#[derive(Debug, Clone)]
pub struct DiffResult {
pub service: String,
pub entries: Vec<DiffEntry>,
pub env_additions: Vec<EnvAddition>,
}
impl DiffResult {
pub fn is_clean(&self) -> bool {
self.entries
.iter()
.all(|e| matches!(e.kind, DiffKind::Unchanged))
&& self.env_additions.is_empty()
}
pub fn drifted(&self) -> Vec<&DiffEntry> {
self.entries
.iter()
.filter(|e| matches!(e.kind, DiffKind::Drift))
.collect()
}
}
async fn replan(service_name: &str) -> Result<(AddResult, BTreeMap<PathBuf, String>)> {
if !is_service_installed(service_name) {
return Err(Error::ServiceNotInstalled(service_name.to_string()));
}
let metadata = load_metadata(service_name)?
.ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
let exposure = match metadata.url.as_deref() {
Some(url) => Exposure::from_url(url),
None => Exposure::Loopback,
};
let service_ref = if metadata.registry.is_empty() || metadata.registry == REGISTRY_DEFAULT {
ServiceRef::Default(service_name.to_string())
} else if crate::registry::resolve::is_path_like(&metadata.registry) {
ServiceRef::Path {
dir: PathBuf::from(&metadata.registry),
name: service_name.to_string(),
}
} else {
ServiceRef::Custom {
registry: metadata.registry.clone(),
service: service_name.to_string(),
}
};
let repo_dir = resolve_registry_dir(&service_ref).await?;
let port_overrides = read_existing_ports(service_name)?;
let port_in_use = |_p: u16| false;
let enabled_groups: BTreeSet<String> = metadata.enabled_groups.iter().cloned().collect();
let result = add_service(
service_name,
&exposure,
metadata.auth.clone(),
metadata.auth.is_some(),
metadata.smtp_enabled,
metadata.backup_enabled,
&BTreeMap::new(),
&enabled_groups,
&metadata.registry,
&repo_dir,
None,
&port_in_use,
None,
PlanMode::Upgrade,
&port_overrides,
)?;
let mut planned: BTreeMap<PathBuf, String> = BTreeMap::new();
for step in &result.steps {
if let Step::WriteFile(file) = step {
planned.insert(file.path.clone(), file.content.clone());
}
}
Ok((result, planned))
}
fn read_existing_env_keys(service_name: &str) -> Result<BTreeMap<String, String>> {
let env_path = service_home(service_name)?.join(".env");
let mut out: BTreeMap<String, String> = BTreeMap::new();
let content = match std::fs::read_to_string(&env_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
Err(source) => {
return Err(Error::FileRead {
path: env_path,
source,
});
}
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
out.insert(k.trim().to_string(), v.to_string());
}
}
Ok(out)
}
fn read_existing_ports(service_name: &str) -> Result<BTreeMap<String, u16>> {
let env_path = service_home(service_name)?.join(".env");
let mut overrides = BTreeMap::new();
let content = match std::fs::read_to_string(&env_path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(overrides),
Err(source) => {
return Err(Error::FileRead {
path: env_path,
source,
});
}
};
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let Some(name) = key.strip_prefix("SERVICE_PORT_") else {
continue;
};
if let Ok(port) = value.trim().parse::<u16>() {
overrides.insert(name.to_ascii_lowercase(), port);
}
}
Ok(overrides)
}
fn should_skip_path(path: &std::path::Path, manifest_file: &std::path::Path) -> bool {
if path == manifest_file {
return true;
}
matches!(path.file_name().and_then(|n| n.to_str()), Some(".env"))
}
pub async fn diff_service(service_name: &str) -> Result<DiffResult> {
let (result, planned) = replan(service_name).await?;
let manifest_file = manifest::manifest_path(service_name)?;
let (manifest_entries, _manifest_envs) = manifest::load(service_name)?.unwrap_or_default();
let manifest_by_path: BTreeMap<PathBuf, String> = manifest_entries
.into_iter()
.map(|e| (e.path, e.sha256))
.collect();
let existing_env = read_existing_env_keys(service_name)?;
let env_additions: Vec<EnvAddition> = result
.tracked_envs
.iter()
.filter(|p| !existing_env.contains_key(&p.key))
.map(|p| EnvAddition {
key: p.key.clone(),
value: p.value.clone(),
kind: p.kind.clone(),
prompt: p.prompt.clone(),
})
.collect();
let mut entries: Vec<DiffEntry> = Vec::new();
let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
for (path, content) in &planned {
if should_skip_path(path, &manifest_file) {
continue;
}
seen.insert(path.clone());
let planned_hash = manifest::hash_bytes(content.as_bytes());
let on_disk_hash = if path.exists() {
Some(manifest::hash_file(path)?)
} else {
None
};
let manifest_hash = manifest_by_path.get(path);
let kind = match (on_disk_hash.as_deref(), manifest_hash.map(String::as_str)) {
(None, Some(_)) | (None, None) => match manifest_hash {
Some(_) => DiffKind::Modified, None => DiffKind::Added, },
(Some(d), _) if d == planned_hash => DiffKind::Unchanged,
(Some(_), None) => DiffKind::Drift,
(Some(d), Some(l)) if d == l => DiffKind::Modified,
(Some(_), Some(_)) => DiffKind::Drift,
};
entries.push(DiffEntry {
path: path.clone(),
kind,
});
}
for path in manifest_by_path.keys() {
if seen.contains(path) {
continue;
}
if should_skip_path(path, &manifest_file) {
continue;
}
entries.push(DiffEntry {
path: path.clone(),
kind: DiffKind::Removed,
});
}
entries.sort_by(|a, b| a.path.cmp(&b.path));
Ok(DiffResult {
service: service_name.to_string(),
entries,
env_additions,
})
}
pub async fn upgrade_service(service_name: &str, force: bool) -> Result<UpgradeResult> {
let diff = diff_service(service_name).await?;
if !force {
let drifted = diff.drifted();
if !drifted.is_empty() {
return Err(Error::HandEditedFiles {
service: service_name.to_string(),
paths: drifted.iter().map(|e| e.path.clone()).collect(),
});
}
}
let (result, planned) = replan(service_name).await?;
let manifest_file = manifest::manifest_path(service_name)?;
let env_file = service_home(service_name)?.join(".env");
if !env_file.exists() {
return Err(Error::Template(format!(
"{service_name}: `.env` is missing at {} — upgrade can't reconstruct generated secrets. \
Restore the file from a backup or reinstall the service.",
env_file.display()
)));
}
let backup_dir = backup_directory(service_name)?;
let needs_backup: BTreeSet<PathBuf> = diff
.entries
.iter()
.filter(|e| {
matches!(
e.kind,
DiffKind::Modified | DiffKind::Drift | DiffKind::Removed
)
})
.map(|e| e.path.clone())
.collect();
let manifest_will_be_backed_up = manifest_file.exists();
let backup_used = !needs_backup.is_empty() || manifest_will_be_backed_up;
let mut steps: Vec<Step> = Vec::new();
if backup_used {
steps.push(Step::CreateDir(backup_dir.clone()));
}
let unchanged: BTreeSet<PathBuf> = diff
.entries
.iter()
.filter(|e| matches!(e.kind, DiffKind::Unchanged))
.map(|e| e.path.clone())
.collect();
let env_filename = std::ffi::OsStr::new(".env");
for step in result.steps {
match step {
Step::WriteFile(GeneratedFile { ref path, .. })
if path.file_name() == Some(env_filename) =>
{
continue;
}
Step::WriteFile(GeneratedFile { ref path, .. }) if unchanged.contains(path) => {
if path == &manifest_file {
steps.push(step);
}
continue;
}
Step::WriteFile(ref file) => {
let should_backup = (needs_backup.contains(&file.path)
|| file.path == manifest_file)
&& file.path.exists();
if should_backup {
let rel = backup_relpath(&file.path);
let dst = backup_dir.join(rel);
if let Some(parent) = dst.parent() {
steps.push(Step::CreateDir(parent.to_path_buf()));
}
steps.push(Step::CopyFile {
src: file.path.clone(),
dst,
});
}
steps.push(step);
}
Step::StartService { .. } => continue,
other => steps.push(other),
}
}
for entry in &diff.entries {
if !matches!(entry.kind, DiffKind::Removed) {
continue;
}
if entry.path.exists() {
let rel = backup_relpath(&entry.path);
let dst = backup_dir.join(rel);
if let Some(parent) = dst.parent() {
steps.push(Step::CreateDir(parent.to_path_buf()));
}
steps.push(Step::CopyFile {
src: entry.path.clone(),
dst,
});
}
steps.push(Step::RemoveFile(entry.path.clone()));
}
if !diff.env_additions.is_empty() {
let mut content = match std::fs::read_to_string(&env_file) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(source) => {
return Err(Error::FileRead {
path: env_file.clone(),
source,
});
}
};
if !content.is_empty() && !content.ends_with('\n') {
content.push('\n');
}
for add in &diff.env_additions {
content.push_str(&format!("{}={}\n", add.key, add.value));
}
steps.push(Step::WriteFile(GeneratedFile {
path: env_file,
content,
}));
}
steps.push(Step::RestartService {
unit: service_name.to_string(),
});
let force_apply = matches!(
crate::metadata::load_metadata(service_name),
Ok(Some(m)) if m.runtime == crate::registry::service_def::Runtime::Native
);
Ok(UpgradeResult {
service: service_name.to_string(),
diff,
steps,
backup_dir: if backup_used { Some(backup_dir) } else { None },
planned_files: planned,
force_apply,
})
}
pub struct UpgradeResult {
pub service: String,
pub diff: DiffResult,
pub steps: Vec<Step>,
pub backup_dir: Option<PathBuf>,
pub planned_files: BTreeMap<PathBuf, String>,
pub force_apply: bool,
}
#[derive(Debug, Clone)]
pub struct BackupSnapshot {
pub path: PathBuf,
pub timestamp: String,
}
pub struct RevertResult {
pub service: String,
pub snapshot: BackupSnapshot,
pub steps: Vec<Step>,
pub files_to_restore: Vec<PathBuf>,
pub files_to_delete: Vec<PathBuf>,
}
pub const DEFAULT_BACKUP_KEEP: usize = 5;
pub fn prune_backups(service_name: &str, keep: usize) -> Result<Vec<PathBuf>> {
let backups_root = state_dir()?.join("backups");
prune_backups_in(&backups_root, service_name, keep)
}
fn prune_backups_in(
backups_root: &std::path::Path,
service_name: &str,
keep: usize,
) -> Result<Vec<PathBuf>> {
let snapshots = list_backups_in(backups_root, service_name)?;
if snapshots.len() <= keep {
return Ok(Vec::new());
}
let mut removed: Vec<PathBuf> = Vec::new();
for snap in snapshots.into_iter().skip(keep) {
if let Err(e) = std::fs::remove_dir_all(&snap.path) {
eprintln!(
"warning: failed to prune backup {}: {e}",
snap.path.display()
);
continue;
}
removed.push(snap.path.clone());
if let Some(parent) = snap.path.parent()
&& let Ok(mut entries) = std::fs::read_dir(parent)
&& entries.next().is_none()
{
let _ = std::fs::remove_dir(parent);
}
}
Ok(removed)
}
pub fn list_backups(service_name: &str) -> Result<Vec<BackupSnapshot>> {
let backups_root = state_dir()?.join("backups");
list_backups_in(&backups_root, service_name)
}
fn list_backups_in(
backups_root: &std::path::Path,
service_name: &str,
) -> Result<Vec<BackupSnapshot>> {
if !backups_root.is_dir() {
return Ok(Vec::new());
}
let mut snapshots: Vec<BackupSnapshot> = Vec::new();
let entries = std::fs::read_dir(backups_root).map_err(|source| Error::FileRead {
path: backups_root.to_path_buf(),
source,
})?;
for entry in entries.flatten() {
let stamp_dir = entry.path();
if !stamp_dir.is_dir() {
continue;
}
let svc_dir = stamp_dir.join(service_name);
if !svc_dir.is_dir() {
continue;
}
let Some(stamp) = stamp_dir.file_name().and_then(|n| n.to_str()) else {
continue;
};
snapshots.push(BackupSnapshot {
path: svc_dir,
timestamp: stamp.to_string(),
});
}
snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
Ok(snapshots)
}
pub fn revert_service(service_name: &str, at: Option<&str>) -> Result<RevertResult> {
if !is_service_installed(service_name) {
return Err(Error::ServiceNotInstalled(service_name.to_string()));
}
let snapshot = pick_snapshot(service_name, at)?;
let mut files_to_restore: Vec<PathBuf> = Vec::new();
walk_backup_files(&snapshot.path, &mut files_to_restore)?;
let backup_manifest_file =
absolute_to_backup_path(&snapshot.path, &manifest::manifest_path(service_name)?);
let (backup_manifest_entries, _) = read_manifest_at(&backup_manifest_file)?;
let (current_manifest_entries, _) = manifest::load(service_name)?.unwrap_or_default();
let backup_manifest_set: BTreeSet<PathBuf> = backup_manifest_entries
.iter()
.map(|e| e.path.clone())
.collect();
let mut files_to_delete: Vec<PathBuf> = if backup_manifest_entries.is_empty() {
Vec::new()
} else {
current_manifest_entries
.iter()
.map(|e| e.path.clone())
.filter(|p| !backup_manifest_set.contains(p))
.collect()
};
files_to_delete.sort();
let mut steps: Vec<Step> = Vec::new();
for backup_path in &files_to_restore {
let original = backup_to_absolute_path(&snapshot.path, backup_path);
steps.push(Step::CopyFile {
src: backup_path.clone(),
dst: original,
});
}
let qd = crate::quadlet_dir()?;
for path in &files_to_delete {
if path.exists() {
steps.push(Step::RemoveFile(path.clone()));
}
if let Some(name) = path.file_name() {
let symlink = qd.join(name);
if std::fs::symlink_metadata(&symlink).is_ok() {
steps.push(Step::RemoveFile(symlink));
}
}
}
steps.push(Step::DaemonReload);
steps.push(Step::RestartService {
unit: service_name.to_string(),
});
let files_to_restore_orig: Vec<PathBuf> = files_to_restore
.iter()
.map(|p| backup_to_absolute_path(&snapshot.path, p))
.collect();
Ok(RevertResult {
service: service_name.to_string(),
snapshot,
steps,
files_to_restore: files_to_restore_orig,
files_to_delete,
})
}
fn pick_snapshot(service_name: &str, at: Option<&str>) -> Result<BackupSnapshot> {
let snapshots = list_backups(service_name)?;
if snapshots.is_empty() {
return Err(Error::NoBackup(service_name.to_string()));
}
match at {
None => Ok(snapshots
.into_iter()
.next()
.expect("non-empty checked above")),
Some(stamp) => snapshots
.into_iter()
.find(|s| s.timestamp == stamp)
.ok_or_else(|| Error::BackupNotFound {
service: service_name.to_string(),
stamp: stamp.to_string(),
}),
}
}
fn walk_backup_files(root: &std::path::Path, out: &mut Vec<PathBuf>) -> Result<()> {
let entries = std::fs::read_dir(root).map_err(|source| Error::FileRead {
path: root.to_path_buf(),
source,
})?;
for entry in entries.flatten() {
let path = entry.path();
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
if meta.is_dir() {
walk_backup_files(&path, out)?;
} else if meta.is_file() {
out.push(path);
}
}
Ok(())
}
fn backup_to_absolute_path(root: &std::path::Path, backup: &std::path::Path) -> PathBuf {
let rel = backup.strip_prefix(root).unwrap_or(backup);
PathBuf::from("/").join(rel)
}
fn absolute_to_backup_path(root: &std::path::Path, abs: &std::path::Path) -> PathBuf {
let rel = abs.to_string_lossy();
let stripped = rel.trim_start_matches('/');
root.join(stripped)
}
fn read_manifest_at(
path: &std::path::Path,
) -> Result<(Vec<manifest::ManifestEntry>, Vec<manifest::EnvEntry>)> {
if !path.exists() {
return Ok((Vec::new(), Vec::new()));
}
let content = std::fs::read_to_string(path).map_err(|source| Error::FileRead {
path: path.to_path_buf(),
source,
})?;
manifest::parse(&content)
}
fn backup_directory(service_name: &str) -> Result<PathBuf> {
let state = state_dir()?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| Error::Template(format!("system clock before UNIX epoch: {e}")))?
.as_secs();
let stamp = format_timestamp(now);
Ok(state.join("backups").join(stamp).join(service_name))
}
fn state_dir() -> Result<PathBuf> {
let base = dirs::state_dir()
.or_else(|| dirs::home_dir().map(|h| h.join(".local").join("state")))
.ok_or(Error::HomeDirNotFound)?;
Ok(base.join("ryra"))
}
fn format_timestamp(secs: u64) -> String {
const SECS_PER_DAY: u64 = 86_400;
let days = secs / SECS_PER_DAY;
let time_of_day = secs % SECS_PER_DAY;
let h = time_of_day / 3600;
let m = (time_of_day % 3600) / 60;
let s = time_of_day % 60;
let (y, mo, d) = ymd_from_days(days);
format!("{y:04}-{mo:02}-{d:02}T{h:02}-{m:02}-{s:02}Z")
}
fn ymd_from_days(days: u64) -> (i64, u32, u32) {
let z = days as i64 + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
fn backup_relpath(path: &std::path::Path) -> PathBuf {
PathBuf::from(path.to_string_lossy().trim_start_matches('/'))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn timestamp_round_numbers() {
let s = format_timestamp(0);
assert_eq!(s, "1970-01-01T00-00-00Z");
let s = format_timestamp(86_400);
assert_eq!(s, "1970-01-02T00-00-00Z");
let s = format_timestamp(31_536_000); assert_eq!(s, "1971-01-01T00-00-00Z");
}
#[test]
fn backup_relpath_strips_leading_slash() {
let p = backup_relpath(std::path::Path::new("/home/user/foo/bar"));
assert_eq!(p, PathBuf::from("home/user/foo/bar"));
}
fn setup_and_prune(stamps: &[&str], keep: usize) -> (Vec<String>, Vec<PathBuf>) {
let tmp = std::env::temp_dir().join(format!(
"ryra-prune-test-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let backups_root = tmp.join("backups");
for s in stamps {
std::fs::create_dir_all(backups_root.join(s).join("svc")).unwrap();
}
let removed = prune_backups_in(&backups_root, "svc", keep).unwrap();
let mut kept: Vec<String> = std::fs::read_dir(&backups_root)
.unwrap()
.filter_map(|e| e.ok())
.filter_map(|e| e.file_name().into_string().ok())
.collect();
kept.sort();
kept.reverse();
let _ = std::fs::remove_dir_all(&tmp);
(kept, removed)
}
#[test]
fn prune_keeps_newest_n() {
let (kept, removed) = setup_and_prune(
&[
"2026-01-01T00-00-00Z",
"2026-02-01T00-00-00Z",
"2026-03-01T00-00-00Z",
"2026-04-01T00-00-00Z",
"2026-05-01T00-00-00Z",
],
3,
);
assert_eq!(kept.len(), 3);
assert_eq!(kept[0], "2026-05-01T00-00-00Z");
assert_eq!(kept[2], "2026-03-01T00-00-00Z");
assert_eq!(removed.len(), 2);
}
#[test]
fn prune_no_op_when_under_keep() {
let (kept, removed) = setup_and_prune(&["2026-01-01T00-00-00Z", "2026-02-01T00-00-00Z"], 5);
assert_eq!(kept.len(), 2);
assert!(removed.is_empty());
}
#[test]
fn should_skip_path_excludes_env_and_manifest() {
let lock = PathBuf::from("/svc/service.manifest");
assert!(should_skip_path(&PathBuf::from("/svc/.env"), &lock));
assert!(should_skip_path(&lock, &lock));
assert!(!should_skip_path(
&PathBuf::from("/svc/configs/x.sh"),
&lock
));
}
}