1use std::collections::{BTreeMap, BTreeSet};
28use std::path::PathBuf;
29
30use crate::error::{Error, Result};
31use crate::exposure::Exposure;
32use crate::generate::GeneratedFile;
33use crate::manifest;
34use crate::metadata::load_metadata;
35use crate::registry::resolve::ServiceRef;
36use crate::{
37 AddResult, PlanMode, REGISTRY_DEFAULT, Step, add_service, is_service_installed,
38 resolve_registry_dir, service_home,
39};
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum DiffKind {
44 Unchanged,
46 Modified,
49 Drift,
55 Added,
58 Removed,
61}
62
63#[derive(Debug, Clone)]
64pub struct DiffEntry {
65 pub path: PathBuf,
66 pub kind: DiffKind,
67}
68
69#[derive(Debug, Clone)]
80pub struct EnvAddition {
81 pub key: String,
82 pub value: String,
83 pub kind: crate::registry::service_def::EnvKind,
84 pub prompt: Option<String>,
85}
86
87#[derive(Debug, Clone)]
89pub struct DiffResult {
90 pub service: String,
91 pub entries: Vec<DiffEntry>,
92 pub env_additions: Vec<EnvAddition>,
95}
96
97impl DiffResult {
98 pub fn is_clean(&self) -> bool {
101 self.entries
102 .iter()
103 .all(|e| matches!(e.kind, DiffKind::Unchanged))
104 && self.env_additions.is_empty()
105 }
106
107 pub fn drifted(&self) -> Vec<&DiffEntry> {
110 self.entries
111 .iter()
112 .filter(|e| matches!(e.kind, DiffKind::Drift))
113 .collect()
114 }
115}
116
117async fn replan(service_name: &str) -> Result<(AddResult, BTreeMap<PathBuf, String>)> {
122 if !is_service_installed(service_name) {
123 return Err(Error::ServiceNotInstalled(service_name.to_string()));
124 }
125 let metadata = load_metadata(service_name)?
126 .ok_or_else(|| Error::ServiceNotInstalled(service_name.to_string()))?;
127
128 let exposure = match metadata.url.as_deref() {
129 Some(url) => Exposure::from_url(url),
130 None => Exposure::Loopback,
131 };
132
133 let service_ref = if metadata.registry.is_empty() || metadata.registry == REGISTRY_DEFAULT {
134 ServiceRef::Default(service_name.to_string())
135 } else if crate::registry::resolve::is_path_like(&metadata.registry) {
136 ServiceRef::Path {
138 dir: PathBuf::from(&metadata.registry),
139 name: service_name.to_string(),
140 }
141 } else {
142 ServiceRef::Custom {
143 registry: metadata.registry.clone(),
144 service: service_name.to_string(),
145 }
146 };
147 let repo_dir = resolve_registry_dir(&service_ref).await?;
148
149 let port_overrides = read_existing_ports(service_name)?;
153
154 let port_in_use = |_p: u16| false;
158
159 let enabled_groups: BTreeSet<String> = metadata.enabled_groups.iter().cloned().collect();
160 let no_env_overrides = BTreeMap::new();
161 let result = add_service(crate::AddServiceParams {
162 service_name,
163 exposure: &exposure,
164 auth: match metadata.auth.clone() {
165 Some(kind) => crate::AuthChoice::Native(kind),
166 None => crate::AuthChoice::None,
167 },
168 enable_smtp: metadata.smtp_enabled,
172 enable_backup: metadata.backup_enabled,
173 env_overrides: &no_env_overrides,
174 enabled_groups: &enabled_groups,
175 registry_name: &metadata.registry,
176 repo_dir: &repo_dir,
177 pre_built_ctx: None,
178 port_in_use: &port_in_use,
179 acme_mode: None,
182 mode: PlanMode::Upgrade,
183 port_overrides: &port_overrides,
184 })?;
185
186 let mut planned: BTreeMap<PathBuf, String> = BTreeMap::new();
187 for step in &result.steps {
188 if let Step::WriteFile(file) = step {
189 planned.insert(file.path.clone(), file.content.clone());
190 }
191 }
192 Ok((result, planned))
193}
194
195fn read_existing_env_keys(service_name: &str) -> Result<BTreeMap<String, String>> {
199 let env_path = service_home(service_name)?.join(".env");
200 let mut out: BTreeMap<String, String> = BTreeMap::new();
201 let content = match std::fs::read_to_string(&env_path) {
202 Ok(c) => c,
203 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
204 Err(source) => {
205 return Err(Error::FileRead {
206 path: env_path,
207 source,
208 });
209 }
210 };
211 for line in content.lines() {
212 let line = line.trim();
213 if line.is_empty() || line.starts_with('#') {
214 continue;
215 }
216 if let Some((k, v)) = line.split_once('=') {
217 out.insert(k.trim().to_string(), v.to_string());
218 }
219 }
220 Ok(out)
221}
222
223pub(crate) fn read_existing_ports(service_name: &str) -> Result<BTreeMap<String, u16>> {
228 let env_path = service_home(service_name)?.join(".env");
229 let mut overrides = BTreeMap::new();
230 let content = match std::fs::read_to_string(&env_path) {
231 Ok(c) => c,
232 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(overrides),
236 Err(source) => {
237 return Err(Error::FileRead {
238 path: env_path,
239 source,
240 });
241 }
242 };
243 for line in content.lines() {
244 let line = line.trim();
245 if line.is_empty() || line.starts_with('#') {
246 continue;
247 }
248 let Some((key, value)) = line.split_once('=') else {
249 continue;
250 };
251 let Some(name) = key.strip_prefix("SERVICE_PORT_") else {
252 continue;
253 };
254 if let Ok(port) = value.trim().parse::<u16>() {
255 overrides.insert(name.to_ascii_lowercase(), port);
256 }
257 }
258 Ok(overrides)
259}
260
261fn should_skip_path(path: &std::path::Path, manifest_file: &std::path::Path) -> bool {
266 if path == manifest_file {
267 return true;
268 }
269 matches!(path.file_name().and_then(|n| n.to_str()), Some(".env"))
270}
271
272pub async fn diff_service(service_name: &str) -> Result<DiffResult> {
275 let (result, planned) = replan(service_name).await?;
276 let manifest_file = manifest::manifest_path(service_name)?;
277 let (manifest_entries, _manifest_envs) = manifest::load(service_name)?.unwrap_or_default();
278 let manifest_by_path: BTreeMap<PathBuf, String> = manifest_entries
279 .into_iter()
280 .map(|e| (e.path, e.sha256))
281 .collect();
282
283 let existing_env = read_existing_env_keys(service_name)?;
291 let env_additions: Vec<EnvAddition> = result
292 .tracked_envs
293 .iter()
294 .filter(|p| !existing_env.contains_key(&p.key))
295 .map(|p| EnvAddition {
296 key: p.key.clone(),
297 value: p.value.clone(),
298 kind: p.kind.clone(),
299 prompt: p.prompt.clone(),
300 })
301 .collect();
302
303 let mut entries: Vec<DiffEntry> = Vec::new();
304 let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
305
306 for (path, content) in &planned {
308 if should_skip_path(path, &manifest_file) {
309 continue;
310 }
311 seen.insert(path.clone());
312 let planned_hash = manifest::hash_bytes(content.as_bytes());
313 let on_disk_hash = if path.exists() {
314 Some(manifest::hash_file(path)?)
315 } else {
316 None
317 };
318 let manifest_hash = manifest_by_path.get(path);
319
320 let kind = match (on_disk_hash.as_deref(), manifest_hash.map(String::as_str)) {
321 (None, Some(_)) | (None, None) => match manifest_hash {
323 Some(_) => DiffKind::Modified, None => DiffKind::Added, },
326 (Some(d), _) if d == planned_hash => DiffKind::Unchanged,
328 (Some(_), None) => DiffKind::Drift,
331 (Some(d), Some(l)) if d == l => DiffKind::Modified,
334 (Some(_), Some(_)) => DiffKind::Drift,
336 };
337 entries.push(DiffEntry {
338 path: path.clone(),
339 kind,
340 });
341 }
342
343 for path in manifest_by_path.keys() {
345 if seen.contains(path) {
346 continue;
347 }
348 if should_skip_path(path, &manifest_file) {
349 continue;
350 }
351 entries.push(DiffEntry {
352 path: path.clone(),
353 kind: DiffKind::Removed,
354 });
355 }
356
357 entries.sort_by(|a, b| a.path.cmp(&b.path));
358 Ok(DiffResult {
359 service: service_name.to_string(),
360 entries,
361 env_additions,
362 })
363}
364
365pub async fn upgrade_service(service_name: &str, force: bool) -> Result<UpgradeResult> {
371 let diff = diff_service(service_name).await?;
372
373 if !force {
374 let drifted = diff.drifted();
375 if !drifted.is_empty() {
376 return Err(Error::HandEditedFiles {
377 service: service_name.to_string(),
378 paths: drifted.iter().map(|e| e.path.clone()).collect(),
379 });
380 }
381 }
382
383 let (result, planned) = replan(service_name).await?;
384 let manifest_file = manifest::manifest_path(service_name)?;
385 let env_file = service_home(service_name)?.join(".env");
386
387 if !env_file.exists() {
392 return Err(Error::Template(format!(
393 "{service_name}: `.env` is missing at {} — upgrade can't reconstruct generated secrets. \
394 Restore the file from a backup or reinstall the service.",
395 env_file.display()
396 )));
397 }
398
399 let backup_dir = backup_directory(service_name)?;
405 let needs_backup: BTreeSet<PathBuf> = diff
406 .entries
407 .iter()
408 .filter(|e| {
409 matches!(
410 e.kind,
411 DiffKind::Modified | DiffKind::Drift | DiffKind::Removed
412 )
413 })
414 .map(|e| e.path.clone())
415 .collect();
416 let manifest_will_be_backed_up = manifest_file.exists();
417 let backup_used = !needs_backup.is_empty() || manifest_will_be_backed_up;
418
419 let mut steps: Vec<Step> = Vec::new();
428 if backup_used {
429 steps.push(Step::CreateDir(backup_dir.clone()));
430 }
431 let unchanged: BTreeSet<PathBuf> = diff
432 .entries
433 .iter()
434 .filter(|e| matches!(e.kind, DiffKind::Unchanged))
435 .map(|e| e.path.clone())
436 .collect();
437
438 let env_filename = std::ffi::OsStr::new(".env");
439 for step in result.steps {
440 match step {
441 Step::WriteFile(GeneratedFile { ref path, .. })
444 if path.file_name() == Some(env_filename) =>
445 {
446 continue;
447 }
448 Step::WriteFile(GeneratedFile { ref path, .. }) if unchanged.contains(path) => {
452 if path == &manifest_file {
456 steps.push(step);
457 }
458 continue;
459 }
460 Step::WriteFile(ref file) => {
461 let should_backup = (needs_backup.contains(&file.path)
468 || file.path == manifest_file)
469 && file.path.exists();
470 if should_backup {
471 let rel = backup_relpath(&file.path);
472 let dst = backup_dir.join(rel);
473 if let Some(parent) = dst.parent() {
474 steps.push(Step::CreateDir(parent.to_path_buf()));
475 }
476 steps.push(Step::CopyFile {
477 src: file.path.clone(),
478 dst,
479 });
480 }
481 steps.push(step);
482 }
483 Step::StartService { .. } => continue,
487 other => steps.push(other),
488 }
489 }
490
491 for entry in &diff.entries {
493 if !matches!(entry.kind, DiffKind::Removed) {
494 continue;
495 }
496 if entry.path.exists() {
497 let rel = backup_relpath(&entry.path);
498 let dst = backup_dir.join(rel);
499 if let Some(parent) = dst.parent() {
500 steps.push(Step::CreateDir(parent.to_path_buf()));
501 }
502 steps.push(Step::CopyFile {
503 src: entry.path.clone(),
504 dst,
505 });
506 }
507 steps.push(Step::RemoveFile(entry.path.clone()));
508 }
509
510 if !diff.env_additions.is_empty() {
518 let mut content = match std::fs::read_to_string(&env_file) {
519 Ok(c) => c,
520 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
523 Err(source) => {
524 return Err(Error::FileRead {
525 path: env_file.clone(),
526 source,
527 });
528 }
529 };
530 if !content.is_empty() && !content.ends_with('\n') {
531 content.push('\n');
532 }
533 for add in &diff.env_additions {
534 content.push_str(&format!("{}={}\n", add.key, add.value));
535 }
536 steps.push(Step::WriteFile(GeneratedFile {
537 path: env_file,
538 content,
539 }));
540 }
541
542 steps.push(Step::RestartService {
546 unit: service_name.to_string(),
547 });
548
549 let force_apply = matches!(
554 crate::metadata::load_metadata(service_name),
555 Ok(Some(m)) if m.runtime == crate::registry::service_def::Runtime::Native
556 );
557
558 Ok(UpgradeResult {
559 service: service_name.to_string(),
560 diff,
561 steps,
562 backup_dir: if backup_used { Some(backup_dir) } else { None },
563 planned_files: planned,
568 force_apply,
569 })
570}
571
572pub struct UpgradeResult {
573 pub service: String,
574 pub diff: DiffResult,
575 pub steps: Vec<Step>,
576 pub backup_dir: Option<PathBuf>,
578 pub planned_files: BTreeMap<PathBuf, String>,
579 pub force_apply: bool,
583}
584
585#[derive(Debug, Clone)]
587pub struct BackupSnapshot {
588 pub path: PathBuf,
590 pub timestamp: String,
592}
593
594pub struct RevertResult {
595 pub service: String,
596 pub snapshot: BackupSnapshot,
597 pub steps: Vec<Step>,
598 pub files_to_restore: Vec<PathBuf>,
600 pub files_to_delete: Vec<PathBuf>,
604}
605
606pub const DEFAULT_BACKUP_KEEP: usize = 5;
615
616pub fn prune_backups(service_name: &str, keep: usize) -> Result<Vec<PathBuf>> {
623 let backups_root = state_dir()?.join("backups");
624 prune_backups_in(&backups_root, service_name, keep)
625}
626
627fn prune_backups_in(
631 backups_root: &std::path::Path,
632 service_name: &str,
633 keep: usize,
634) -> Result<Vec<PathBuf>> {
635 let snapshots = list_backups_in(backups_root, service_name)?;
636 if snapshots.len() <= keep {
637 return Ok(Vec::new());
638 }
639 let mut removed: Vec<PathBuf> = Vec::new();
640 for snap in snapshots.into_iter().skip(keep) {
641 if let Err(e) = std::fs::remove_dir_all(&snap.path) {
642 eprintln!(
643 "warning: failed to prune backup {}: {e}",
644 snap.path.display()
645 );
646 continue;
647 }
648 removed.push(snap.path.clone());
649 if let Some(parent) = snap.path.parent()
650 && let Ok(mut entries) = std::fs::read_dir(parent)
651 && entries.next().is_none()
652 {
653 let _ = std::fs::remove_dir(parent);
654 }
655 }
656 Ok(removed)
657}
658
659pub fn list_backups(service_name: &str) -> Result<Vec<BackupSnapshot>> {
660 let backups_root = state_dir()?.join("backups");
661 list_backups_in(&backups_root, service_name)
662}
663
664fn list_backups_in(
665 backups_root: &std::path::Path,
666 service_name: &str,
667) -> Result<Vec<BackupSnapshot>> {
668 if !backups_root.is_dir() {
669 return Ok(Vec::new());
670 }
671 let mut snapshots: Vec<BackupSnapshot> = Vec::new();
672 let entries = std::fs::read_dir(backups_root).map_err(|source| Error::FileRead {
673 path: backups_root.to_path_buf(),
674 source,
675 })?;
676 for entry in entries.flatten() {
677 let stamp_dir = entry.path();
678 if !stamp_dir.is_dir() {
679 continue;
680 }
681 let svc_dir = stamp_dir.join(service_name);
682 if !svc_dir.is_dir() {
683 continue;
684 }
685 let Some(stamp) = stamp_dir.file_name().and_then(|n| n.to_str()) else {
686 continue;
687 };
688 snapshots.push(BackupSnapshot {
689 path: svc_dir,
690 timestamp: stamp.to_string(),
691 });
692 }
693 snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
695 Ok(snapshots)
696}
697
698pub fn revert_service(service_name: &str, at: Option<&str>) -> Result<RevertResult> {
705 if !is_service_installed(service_name) {
706 return Err(Error::ServiceNotInstalled(service_name.to_string()));
707 }
708 let snapshot = pick_snapshot(service_name, at)?;
709
710 let mut files_to_restore: Vec<PathBuf> = Vec::new();
715 walk_backup_files(&snapshot.path, &mut files_to_restore)?;
716
717 let backup_manifest_file =
722 absolute_to_backup_path(&snapshot.path, &manifest::manifest_path(service_name)?);
723 let (backup_manifest_entries, _) = read_manifest_at(&backup_manifest_file)?;
724 let (current_manifest_entries, _) = manifest::load(service_name)?.unwrap_or_default();
725
726 let backup_manifest_set: BTreeSet<PathBuf> = backup_manifest_entries
727 .iter()
728 .map(|e| e.path.clone())
729 .collect();
730 let mut files_to_delete: Vec<PathBuf> = if backup_manifest_entries.is_empty() {
731 Vec::new()
733 } else {
734 current_manifest_entries
735 .iter()
736 .map(|e| e.path.clone())
737 .filter(|p| !backup_manifest_set.contains(p))
738 .collect()
739 };
740 files_to_delete.sort();
741
742 let mut steps: Vec<Step> = Vec::new();
744 for backup_path in &files_to_restore {
747 let original = backup_to_absolute_path(&snapshot.path, backup_path);
748 steps.push(Step::CopyFile {
749 src: backup_path.clone(),
750 dst: original,
751 });
752 }
753 let qd = crate::quadlet_dir()?;
757 for path in &files_to_delete {
758 if path.exists() {
759 steps.push(Step::RemoveFile(path.clone()));
760 }
761 if let Some(name) = path.file_name() {
762 let symlink = qd.join(name);
763 if std::fs::symlink_metadata(&symlink).is_ok() {
764 steps.push(Step::RemoveFile(symlink));
765 }
766 }
767 }
768 steps.push(Step::DaemonReload);
769 steps.push(Step::RestartService {
770 unit: service_name.to_string(),
771 });
772
773 let files_to_restore_orig: Vec<PathBuf> = files_to_restore
774 .iter()
775 .map(|p| backup_to_absolute_path(&snapshot.path, p))
776 .collect();
777 Ok(RevertResult {
778 service: service_name.to_string(),
779 snapshot,
780 steps,
781 files_to_restore: files_to_restore_orig,
782 files_to_delete,
783 })
784}
785
786fn pick_snapshot(service_name: &str, at: Option<&str>) -> Result<BackupSnapshot> {
789 let snapshots = list_backups(service_name)?;
790 if snapshots.is_empty() {
791 return Err(Error::NoBackup(service_name.to_string()));
792 }
793 match at {
794 None => Ok(snapshots
795 .into_iter()
796 .next()
797 .expect("non-empty checked above")),
798 Some(stamp) => snapshots
799 .into_iter()
800 .find(|s| s.timestamp == stamp)
801 .ok_or_else(|| Error::BackupNotFound {
802 service: service_name.to_string(),
803 stamp: stamp.to_string(),
804 }),
805 }
806}
807
808fn walk_backup_files(root: &std::path::Path, out: &mut Vec<PathBuf>) -> Result<()> {
812 let entries = std::fs::read_dir(root).map_err(|source| Error::FileRead {
813 path: root.to_path_buf(),
814 source,
815 })?;
816 for entry in entries.flatten() {
817 let path = entry.path();
818 let meta = match entry.metadata() {
819 Ok(m) => m,
820 Err(_) => continue,
821 };
822 if meta.is_dir() {
823 walk_backup_files(&path, out)?;
824 } else if meta.is_file() {
825 out.push(path);
826 }
827 }
828 Ok(())
829}
830
831fn backup_to_absolute_path(root: &std::path::Path, backup: &std::path::Path) -> PathBuf {
834 let rel = backup.strip_prefix(root).unwrap_or(backup);
835 PathBuf::from("/").join(rel)
836}
837
838fn absolute_to_backup_path(root: &std::path::Path, abs: &std::path::Path) -> PathBuf {
840 let rel = abs.to_string_lossy();
841 let stripped = rel.trim_start_matches('/');
842 root.join(stripped)
843}
844
845fn read_manifest_at(
848 path: &std::path::Path,
849) -> Result<(Vec<manifest::ManifestEntry>, Vec<manifest::EnvEntry>)> {
850 if !path.exists() {
851 return Ok((Vec::new(), Vec::new()));
852 }
853 let content = std::fs::read_to_string(path).map_err(|source| Error::FileRead {
854 path: path.to_path_buf(),
855 source,
856 })?;
857 manifest::parse(&content)
858}
859
860fn backup_directory(service_name: &str) -> Result<PathBuf> {
864 let state = state_dir()?;
865 let now = std::time::SystemTime::now()
866 .duration_since(std::time::UNIX_EPOCH)
867 .map_err(|e| Error::Template(format!("system clock before UNIX epoch: {e}")))?
868 .as_secs();
869 let stamp = format_timestamp(now);
870 Ok(state.join("backups").join(stamp).join(service_name))
871}
872
873fn state_dir() -> Result<PathBuf> {
875 let base = dirs::state_dir()
876 .or_else(|| dirs::home_dir().map(|h| h.join(".local").join("state")))
877 .ok_or(Error::HomeDirNotFound)?;
878 Ok(base.join("ryra"))
879}
880
881fn format_timestamp(secs: u64) -> String {
884 const SECS_PER_DAY: u64 = 86_400;
886 let days = secs / SECS_PER_DAY;
887 let time_of_day = secs % SECS_PER_DAY;
888 let h = time_of_day / 3600;
889 let m = (time_of_day % 3600) / 60;
890 let s = time_of_day % 60;
891 let (y, mo, d) = ymd_from_days(days);
892 format!("{y:04}-{mo:02}-{d:02}T{h:02}-{m:02}-{s:02}Z")
893}
894
895fn ymd_from_days(days: u64) -> (i64, u32, u32) {
899 let z = days as i64 + 719_468;
900 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
901 let doe = (z - era * 146_097) as u64;
902 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
903 let y = yoe as i64 + era * 400;
904 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
905 let mp = (5 * doy + 2) / 153;
906 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
907 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
908 let y = if m <= 2 { y + 1 } else { y };
909 (y, m, d)
910}
911
912fn backup_relpath(path: &std::path::Path) -> PathBuf {
916 PathBuf::from(path.to_string_lossy().trim_start_matches('/'))
917}
918
919#[cfg(test)]
920mod tests {
921 use super::*;
922
923 #[test]
924 fn timestamp_round_numbers() {
925 let s = format_timestamp(0);
930 assert_eq!(s, "1970-01-01T00-00-00Z");
931 let s = format_timestamp(86_400);
932 assert_eq!(s, "1970-01-02T00-00-00Z");
933 let s = format_timestamp(31_536_000); assert_eq!(s, "1971-01-01T00-00-00Z");
935 }
936
937 #[test]
938 fn backup_relpath_strips_leading_slash() {
939 let p = backup_relpath(std::path::Path::new("/home/user/foo/bar"));
940 assert_eq!(p, PathBuf::from("home/user/foo/bar"));
941 }
942
943 fn setup_and_prune(stamps: &[&str], keep: usize) -> (Vec<String>, Vec<PathBuf>) {
948 let tmp = std::env::temp_dir().join(format!(
949 "ryra-prune-test-{}-{}",
950 std::process::id(),
951 std::time::SystemTime::now()
952 .duration_since(std::time::UNIX_EPOCH)
953 .unwrap()
954 .as_nanos()
955 ));
956 let backups_root = tmp.join("backups");
957 for s in stamps {
958 std::fs::create_dir_all(backups_root.join(s).join("svc")).unwrap();
959 }
960 let removed = prune_backups_in(&backups_root, "svc", keep).unwrap();
961 let mut kept: Vec<String> = std::fs::read_dir(&backups_root)
962 .unwrap()
963 .filter_map(|e| e.ok())
964 .filter_map(|e| e.file_name().into_string().ok())
965 .collect();
966 kept.sort();
967 kept.reverse();
968 let _ = std::fs::remove_dir_all(&tmp);
969 (kept, removed)
970 }
971
972 #[test]
973 fn prune_keeps_newest_n() {
974 let (kept, removed) = setup_and_prune(
976 &[
977 "2026-01-01T00-00-00Z",
978 "2026-02-01T00-00-00Z",
979 "2026-03-01T00-00-00Z",
980 "2026-04-01T00-00-00Z",
981 "2026-05-01T00-00-00Z",
982 ],
983 3,
984 );
985 assert_eq!(kept.len(), 3);
986 assert_eq!(kept[0], "2026-05-01T00-00-00Z");
987 assert_eq!(kept[2], "2026-03-01T00-00-00Z");
988 assert_eq!(removed.len(), 2);
989 }
990
991 #[test]
992 fn prune_no_op_when_under_keep() {
993 let (kept, removed) = setup_and_prune(&["2026-01-01T00-00-00Z", "2026-02-01T00-00-00Z"], 5);
994 assert_eq!(kept.len(), 2);
995 assert!(removed.is_empty());
996 }
997
998 #[test]
999 fn should_skip_path_excludes_env_and_manifest() {
1000 let lock = PathBuf::from("/svc/service.manifest");
1001 assert!(should_skip_path(&PathBuf::from("/svc/.env"), &lock));
1002 assert!(should_skip_path(&lock, &lock));
1003 assert!(!should_skip_path(
1004 &PathBuf::from("/svc/configs/x.sh"),
1005 &lock
1006 ));
1007 }
1008}