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 result = add_service(
161 service_name,
162 &exposure,
163 metadata.auth.clone(),
164 metadata.auth.is_some(),
165 metadata.smtp_enabled,
168 metadata.backup_enabled,
171 &BTreeMap::new(),
172 &enabled_groups,
173 &metadata.registry,
174 &repo_dir,
175 None,
176 &port_in_use,
177 None,
180 PlanMode::Upgrade,
181 &port_overrides,
182 )?;
183
184 let mut planned: BTreeMap<PathBuf, String> = BTreeMap::new();
185 for step in &result.steps {
186 if let Step::WriteFile(file) = step {
187 planned.insert(file.path.clone(), file.content.clone());
188 }
189 }
190 Ok((result, planned))
191}
192
193fn read_existing_env_keys(service_name: &str) -> Result<BTreeMap<String, String>> {
197 let env_path = service_home(service_name)?.join(".env");
198 let mut out: BTreeMap<String, String> = BTreeMap::new();
199 let content = match std::fs::read_to_string(&env_path) {
200 Ok(c) => c,
201 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
202 Err(source) => {
203 return Err(Error::FileRead {
204 path: env_path,
205 source,
206 });
207 }
208 };
209 for line in content.lines() {
210 let line = line.trim();
211 if line.is_empty() || line.starts_with('#') {
212 continue;
213 }
214 if let Some((k, v)) = line.split_once('=') {
215 out.insert(k.trim().to_string(), v.to_string());
216 }
217 }
218 Ok(out)
219}
220
221fn read_existing_ports(service_name: &str) -> Result<BTreeMap<String, u16>> {
225 let env_path = service_home(service_name)?.join(".env");
226 let mut overrides = BTreeMap::new();
227 let content = match std::fs::read_to_string(&env_path) {
228 Ok(c) => c,
229 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(overrides),
233 Err(source) => {
234 return Err(Error::FileRead {
235 path: env_path,
236 source,
237 });
238 }
239 };
240 for line in content.lines() {
241 let line = line.trim();
242 if line.is_empty() || line.starts_with('#') {
243 continue;
244 }
245 let Some((key, value)) = line.split_once('=') else {
246 continue;
247 };
248 let Some(name) = key.strip_prefix("SERVICE_PORT_") else {
249 continue;
250 };
251 if let Ok(port) = value.trim().parse::<u16>() {
252 overrides.insert(name.to_ascii_lowercase(), port);
253 }
254 }
255 Ok(overrides)
256}
257
258fn should_skip_path(path: &std::path::Path, manifest_file: &std::path::Path) -> bool {
263 if path == manifest_file {
264 return true;
265 }
266 matches!(path.file_name().and_then(|n| n.to_str()), Some(".env"))
267}
268
269pub async fn diff_service(service_name: &str) -> Result<DiffResult> {
272 let (result, planned) = replan(service_name).await?;
273 let manifest_file = manifest::manifest_path(service_name)?;
274 let (manifest_entries, _manifest_envs) = manifest::load(service_name)?.unwrap_or_default();
275 let manifest_by_path: BTreeMap<PathBuf, String> = manifest_entries
276 .into_iter()
277 .map(|e| (e.path, e.sha256))
278 .collect();
279
280 let existing_env = read_existing_env_keys(service_name)?;
288 let env_additions: Vec<EnvAddition> = result
289 .tracked_envs
290 .iter()
291 .filter(|p| !existing_env.contains_key(&p.key))
292 .map(|p| EnvAddition {
293 key: p.key.clone(),
294 value: p.value.clone(),
295 kind: p.kind.clone(),
296 prompt: p.prompt.clone(),
297 })
298 .collect();
299
300 let mut entries: Vec<DiffEntry> = Vec::new();
301 let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
302
303 for (path, content) in &planned {
305 if should_skip_path(path, &manifest_file) {
306 continue;
307 }
308 seen.insert(path.clone());
309 let planned_hash = manifest::hash_bytes(content.as_bytes());
310 let on_disk_hash = if path.exists() {
311 Some(manifest::hash_file(path)?)
312 } else {
313 None
314 };
315 let manifest_hash = manifest_by_path.get(path);
316
317 let kind = match (on_disk_hash.as_deref(), manifest_hash.map(String::as_str)) {
318 (None, Some(_)) | (None, None) => match manifest_hash {
320 Some(_) => DiffKind::Modified, None => DiffKind::Added, },
323 (Some(d), _) if d == planned_hash => DiffKind::Unchanged,
325 (Some(_), None) => DiffKind::Drift,
328 (Some(d), Some(l)) if d == l => DiffKind::Modified,
331 (Some(_), Some(_)) => DiffKind::Drift,
333 };
334 entries.push(DiffEntry {
335 path: path.clone(),
336 kind,
337 });
338 }
339
340 for path in manifest_by_path.keys() {
342 if seen.contains(path) {
343 continue;
344 }
345 if should_skip_path(path, &manifest_file) {
346 continue;
347 }
348 entries.push(DiffEntry {
349 path: path.clone(),
350 kind: DiffKind::Removed,
351 });
352 }
353
354 entries.sort_by(|a, b| a.path.cmp(&b.path));
355 Ok(DiffResult {
356 service: service_name.to_string(),
357 entries,
358 env_additions,
359 })
360}
361
362pub async fn upgrade_service(service_name: &str, force: bool) -> Result<UpgradeResult> {
368 let diff = diff_service(service_name).await?;
369
370 if !force {
371 let drifted = diff.drifted();
372 if !drifted.is_empty() {
373 return Err(Error::HandEditedFiles {
374 service: service_name.to_string(),
375 paths: drifted.iter().map(|e| e.path.clone()).collect(),
376 });
377 }
378 }
379
380 let (result, planned) = replan(service_name).await?;
381 let manifest_file = manifest::manifest_path(service_name)?;
382 let env_file = service_home(service_name)?.join(".env");
383
384 if !env_file.exists() {
389 return Err(Error::Template(format!(
390 "{service_name}: `.env` is missing at {} — upgrade can't reconstruct generated secrets. \
391 Restore the file from a backup or reinstall the service.",
392 env_file.display()
393 )));
394 }
395
396 let backup_dir = backup_directory(service_name)?;
402 let needs_backup: BTreeSet<PathBuf> = diff
403 .entries
404 .iter()
405 .filter(|e| {
406 matches!(
407 e.kind,
408 DiffKind::Modified | DiffKind::Drift | DiffKind::Removed
409 )
410 })
411 .map(|e| e.path.clone())
412 .collect();
413 let manifest_will_be_backed_up = manifest_file.exists();
414 let backup_used = !needs_backup.is_empty() || manifest_will_be_backed_up;
415
416 let mut steps: Vec<Step> = Vec::new();
425 if backup_used {
426 steps.push(Step::CreateDir(backup_dir.clone()));
427 }
428 let unchanged: BTreeSet<PathBuf> = diff
429 .entries
430 .iter()
431 .filter(|e| matches!(e.kind, DiffKind::Unchanged))
432 .map(|e| e.path.clone())
433 .collect();
434
435 let env_filename = std::ffi::OsStr::new(".env");
436 for step in result.steps {
437 match step {
438 Step::WriteFile(GeneratedFile { ref path, .. })
441 if path.file_name() == Some(env_filename) =>
442 {
443 continue;
444 }
445 Step::WriteFile(GeneratedFile { ref path, .. }) if unchanged.contains(path) => {
449 if path == &manifest_file {
453 steps.push(step);
454 }
455 continue;
456 }
457 Step::WriteFile(ref file) => {
458 let should_backup = (needs_backup.contains(&file.path)
465 || file.path == manifest_file)
466 && file.path.exists();
467 if should_backup {
468 let rel = backup_relpath(&file.path);
469 let dst = backup_dir.join(rel);
470 if let Some(parent) = dst.parent() {
471 steps.push(Step::CreateDir(parent.to_path_buf()));
472 }
473 steps.push(Step::CopyFile {
474 src: file.path.clone(),
475 dst,
476 });
477 }
478 steps.push(step);
479 }
480 Step::StartService { .. } => continue,
484 other => steps.push(other),
485 }
486 }
487
488 for entry in &diff.entries {
490 if !matches!(entry.kind, DiffKind::Removed) {
491 continue;
492 }
493 if entry.path.exists() {
494 let rel = backup_relpath(&entry.path);
495 let dst = backup_dir.join(rel);
496 if let Some(parent) = dst.parent() {
497 steps.push(Step::CreateDir(parent.to_path_buf()));
498 }
499 steps.push(Step::CopyFile {
500 src: entry.path.clone(),
501 dst,
502 });
503 }
504 steps.push(Step::RemoveFile(entry.path.clone()));
505 }
506
507 if !diff.env_additions.is_empty() {
515 let mut content = match std::fs::read_to_string(&env_file) {
516 Ok(c) => c,
517 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
520 Err(source) => {
521 return Err(Error::FileRead {
522 path: env_file.clone(),
523 source,
524 });
525 }
526 };
527 if !content.is_empty() && !content.ends_with('\n') {
528 content.push('\n');
529 }
530 for add in &diff.env_additions {
531 content.push_str(&format!("{}={}\n", add.key, add.value));
532 }
533 steps.push(Step::WriteFile(GeneratedFile {
534 path: env_file,
535 content,
536 }));
537 }
538
539 steps.push(Step::RestartService {
543 unit: service_name.to_string(),
544 });
545
546 let force_apply = matches!(
551 crate::metadata::load_metadata(service_name),
552 Ok(Some(m)) if m.runtime == crate::registry::service_def::Runtime::Native
553 );
554
555 Ok(UpgradeResult {
556 service: service_name.to_string(),
557 diff,
558 steps,
559 backup_dir: if backup_used { Some(backup_dir) } else { None },
560 planned_files: planned,
565 force_apply,
566 })
567}
568
569pub struct UpgradeResult {
570 pub service: String,
571 pub diff: DiffResult,
572 pub steps: Vec<Step>,
573 pub backup_dir: Option<PathBuf>,
575 pub planned_files: BTreeMap<PathBuf, String>,
576 pub force_apply: bool,
580}
581
582#[derive(Debug, Clone)]
584pub struct BackupSnapshot {
585 pub path: PathBuf,
587 pub timestamp: String,
589}
590
591pub struct RevertResult {
592 pub service: String,
593 pub snapshot: BackupSnapshot,
594 pub steps: Vec<Step>,
595 pub files_to_restore: Vec<PathBuf>,
597 pub files_to_delete: Vec<PathBuf>,
601}
602
603pub const DEFAULT_BACKUP_KEEP: usize = 5;
612
613pub fn prune_backups(service_name: &str, keep: usize) -> Result<Vec<PathBuf>> {
620 let backups_root = state_dir()?.join("backups");
621 prune_backups_in(&backups_root, service_name, keep)
622}
623
624fn prune_backups_in(
628 backups_root: &std::path::Path,
629 service_name: &str,
630 keep: usize,
631) -> Result<Vec<PathBuf>> {
632 let snapshots = list_backups_in(backups_root, service_name)?;
633 if snapshots.len() <= keep {
634 return Ok(Vec::new());
635 }
636 let mut removed: Vec<PathBuf> = Vec::new();
637 for snap in snapshots.into_iter().skip(keep) {
638 if let Err(e) = std::fs::remove_dir_all(&snap.path) {
639 eprintln!(
640 "warning: failed to prune backup {}: {e}",
641 snap.path.display()
642 );
643 continue;
644 }
645 removed.push(snap.path.clone());
646 if let Some(parent) = snap.path.parent()
647 && let Ok(mut entries) = std::fs::read_dir(parent)
648 && entries.next().is_none()
649 {
650 let _ = std::fs::remove_dir(parent);
651 }
652 }
653 Ok(removed)
654}
655
656pub fn list_backups(service_name: &str) -> Result<Vec<BackupSnapshot>> {
657 let backups_root = state_dir()?.join("backups");
658 list_backups_in(&backups_root, service_name)
659}
660
661fn list_backups_in(
662 backups_root: &std::path::Path,
663 service_name: &str,
664) -> Result<Vec<BackupSnapshot>> {
665 if !backups_root.is_dir() {
666 return Ok(Vec::new());
667 }
668 let mut snapshots: Vec<BackupSnapshot> = Vec::new();
669 let entries = std::fs::read_dir(backups_root).map_err(|source| Error::FileRead {
670 path: backups_root.to_path_buf(),
671 source,
672 })?;
673 for entry in entries.flatten() {
674 let stamp_dir = entry.path();
675 if !stamp_dir.is_dir() {
676 continue;
677 }
678 let svc_dir = stamp_dir.join(service_name);
679 if !svc_dir.is_dir() {
680 continue;
681 }
682 let Some(stamp) = stamp_dir.file_name().and_then(|n| n.to_str()) else {
683 continue;
684 };
685 snapshots.push(BackupSnapshot {
686 path: svc_dir,
687 timestamp: stamp.to_string(),
688 });
689 }
690 snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
692 Ok(snapshots)
693}
694
695pub fn revert_service(service_name: &str, at: Option<&str>) -> Result<RevertResult> {
702 if !is_service_installed(service_name) {
703 return Err(Error::ServiceNotInstalled(service_name.to_string()));
704 }
705 let snapshot = pick_snapshot(service_name, at)?;
706
707 let mut files_to_restore: Vec<PathBuf> = Vec::new();
712 walk_backup_files(&snapshot.path, &mut files_to_restore)?;
713
714 let backup_manifest_file =
719 absolute_to_backup_path(&snapshot.path, &manifest::manifest_path(service_name)?);
720 let (backup_manifest_entries, _) = read_manifest_at(&backup_manifest_file)?;
721 let (current_manifest_entries, _) = manifest::load(service_name)?.unwrap_or_default();
722
723 let backup_manifest_set: BTreeSet<PathBuf> = backup_manifest_entries
724 .iter()
725 .map(|e| e.path.clone())
726 .collect();
727 let mut files_to_delete: Vec<PathBuf> = if backup_manifest_entries.is_empty() {
728 Vec::new()
730 } else {
731 current_manifest_entries
732 .iter()
733 .map(|e| e.path.clone())
734 .filter(|p| !backup_manifest_set.contains(p))
735 .collect()
736 };
737 files_to_delete.sort();
738
739 let mut steps: Vec<Step> = Vec::new();
741 for backup_path in &files_to_restore {
744 let original = backup_to_absolute_path(&snapshot.path, backup_path);
745 steps.push(Step::CopyFile {
746 src: backup_path.clone(),
747 dst: original,
748 });
749 }
750 let qd = crate::quadlet_dir()?;
754 for path in &files_to_delete {
755 if path.exists() {
756 steps.push(Step::RemoveFile(path.clone()));
757 }
758 if let Some(name) = path.file_name() {
759 let symlink = qd.join(name);
760 if std::fs::symlink_metadata(&symlink).is_ok() {
761 steps.push(Step::RemoveFile(symlink));
762 }
763 }
764 }
765 steps.push(Step::DaemonReload);
766 steps.push(Step::RestartService {
767 unit: service_name.to_string(),
768 });
769
770 let files_to_restore_orig: Vec<PathBuf> = files_to_restore
771 .iter()
772 .map(|p| backup_to_absolute_path(&snapshot.path, p))
773 .collect();
774 Ok(RevertResult {
775 service: service_name.to_string(),
776 snapshot,
777 steps,
778 files_to_restore: files_to_restore_orig,
779 files_to_delete,
780 })
781}
782
783fn pick_snapshot(service_name: &str, at: Option<&str>) -> Result<BackupSnapshot> {
786 let snapshots = list_backups(service_name)?;
787 if snapshots.is_empty() {
788 return Err(Error::NoBackup(service_name.to_string()));
789 }
790 match at {
791 None => Ok(snapshots
792 .into_iter()
793 .next()
794 .expect("non-empty checked above")),
795 Some(stamp) => snapshots
796 .into_iter()
797 .find(|s| s.timestamp == stamp)
798 .ok_or_else(|| Error::BackupNotFound {
799 service: service_name.to_string(),
800 stamp: stamp.to_string(),
801 }),
802 }
803}
804
805fn walk_backup_files(root: &std::path::Path, out: &mut Vec<PathBuf>) -> Result<()> {
809 let entries = std::fs::read_dir(root).map_err(|source| Error::FileRead {
810 path: root.to_path_buf(),
811 source,
812 })?;
813 for entry in entries.flatten() {
814 let path = entry.path();
815 let meta = match entry.metadata() {
816 Ok(m) => m,
817 Err(_) => continue,
818 };
819 if meta.is_dir() {
820 walk_backup_files(&path, out)?;
821 } else if meta.is_file() {
822 out.push(path);
823 }
824 }
825 Ok(())
826}
827
828fn backup_to_absolute_path(root: &std::path::Path, backup: &std::path::Path) -> PathBuf {
831 let rel = backup.strip_prefix(root).unwrap_or(backup);
832 PathBuf::from("/").join(rel)
833}
834
835fn absolute_to_backup_path(root: &std::path::Path, abs: &std::path::Path) -> PathBuf {
837 let rel = abs.to_string_lossy();
838 let stripped = rel.trim_start_matches('/');
839 root.join(stripped)
840}
841
842fn read_manifest_at(
845 path: &std::path::Path,
846) -> Result<(Vec<manifest::ManifestEntry>, Vec<manifest::EnvEntry>)> {
847 if !path.exists() {
848 return Ok((Vec::new(), Vec::new()));
849 }
850 let content = std::fs::read_to_string(path).map_err(|source| Error::FileRead {
851 path: path.to_path_buf(),
852 source,
853 })?;
854 manifest::parse(&content)
855}
856
857fn backup_directory(service_name: &str) -> Result<PathBuf> {
861 let state = state_dir()?;
862 let now = std::time::SystemTime::now()
863 .duration_since(std::time::UNIX_EPOCH)
864 .map_err(|e| Error::Template(format!("system clock before UNIX epoch: {e}")))?
865 .as_secs();
866 let stamp = format_timestamp(now);
867 Ok(state.join("backups").join(stamp).join(service_name))
868}
869
870fn state_dir() -> Result<PathBuf> {
872 let base = dirs::state_dir()
873 .or_else(|| dirs::home_dir().map(|h| h.join(".local").join("state")))
874 .ok_or(Error::HomeDirNotFound)?;
875 Ok(base.join("ryra"))
876}
877
878fn format_timestamp(secs: u64) -> String {
881 const SECS_PER_DAY: u64 = 86_400;
883 let days = secs / SECS_PER_DAY;
884 let time_of_day = secs % SECS_PER_DAY;
885 let h = time_of_day / 3600;
886 let m = (time_of_day % 3600) / 60;
887 let s = time_of_day % 60;
888 let (y, mo, d) = ymd_from_days(days);
889 format!("{y:04}-{mo:02}-{d:02}T{h:02}-{m:02}-{s:02}Z")
890}
891
892fn ymd_from_days(days: u64) -> (i64, u32, u32) {
896 let z = days as i64 + 719_468;
897 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
898 let doe = (z - era * 146_097) as u64;
899 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
900 let y = yoe as i64 + era * 400;
901 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
902 let mp = (5 * doy + 2) / 153;
903 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
904 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
905 let y = if m <= 2 { y + 1 } else { y };
906 (y, m, d)
907}
908
909fn backup_relpath(path: &std::path::Path) -> PathBuf {
913 PathBuf::from(path.to_string_lossy().trim_start_matches('/'))
914}
915
916#[cfg(test)]
917mod tests {
918 use super::*;
919
920 #[test]
921 fn timestamp_round_numbers() {
922 let s = format_timestamp(0);
927 assert_eq!(s, "1970-01-01T00-00-00Z");
928 let s = format_timestamp(86_400);
929 assert_eq!(s, "1970-01-02T00-00-00Z");
930 let s = format_timestamp(31_536_000); assert_eq!(s, "1971-01-01T00-00-00Z");
932 }
933
934 #[test]
935 fn backup_relpath_strips_leading_slash() {
936 let p = backup_relpath(std::path::Path::new("/home/user/foo/bar"));
937 assert_eq!(p, PathBuf::from("home/user/foo/bar"));
938 }
939
940 fn setup_and_prune(stamps: &[&str], keep: usize) -> (Vec<String>, Vec<PathBuf>) {
945 let tmp = std::env::temp_dir().join(format!(
946 "ryra-prune-test-{}-{}",
947 std::process::id(),
948 std::time::SystemTime::now()
949 .duration_since(std::time::UNIX_EPOCH)
950 .unwrap()
951 .as_nanos()
952 ));
953 let backups_root = tmp.join("backups");
954 for s in stamps {
955 std::fs::create_dir_all(backups_root.join(s).join("svc")).unwrap();
956 }
957 let removed = prune_backups_in(&backups_root, "svc", keep).unwrap();
958 let mut kept: Vec<String> = std::fs::read_dir(&backups_root)
959 .unwrap()
960 .filter_map(|e| e.ok())
961 .filter_map(|e| e.file_name().into_string().ok())
962 .collect();
963 kept.sort();
964 kept.reverse();
965 let _ = std::fs::remove_dir_all(&tmp);
966 (kept, removed)
967 }
968
969 #[test]
970 fn prune_keeps_newest_n() {
971 let (kept, removed) = setup_and_prune(
973 &[
974 "2026-01-01T00-00-00Z",
975 "2026-02-01T00-00-00Z",
976 "2026-03-01T00-00-00Z",
977 "2026-04-01T00-00-00Z",
978 "2026-05-01T00-00-00Z",
979 ],
980 3,
981 );
982 assert_eq!(kept.len(), 3);
983 assert_eq!(kept[0], "2026-05-01T00-00-00Z");
984 assert_eq!(kept[2], "2026-03-01T00-00-00Z");
985 assert_eq!(removed.len(), 2);
986 }
987
988 #[test]
989 fn prune_no_op_when_under_keep() {
990 let (kept, removed) = setup_and_prune(&["2026-01-01T00-00-00Z", "2026-02-01T00-00-00Z"], 5);
991 assert_eq!(kept.len(), 2);
992 assert!(removed.is_empty());
993 }
994
995 #[test]
996 fn should_skip_path_excludes_env_and_manifest() {
997 let lock = PathBuf::from("/svc/service.manifest");
998 assert!(should_skip_path(&PathBuf::from("/svc/.env"), &lock));
999 assert!(should_skip_path(&lock, &lock));
1000 assert!(!should_skip_path(
1001 &PathBuf::from("/svc/configs/x.sh"),
1002 &lock
1003 ));
1004 }
1005}