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 {
136 ServiceRef::Custom {
137 registry: metadata.registry.clone(),
138 service: service_name.to_string(),
139 }
140 };
141 let repo_dir = resolve_registry_dir(&service_ref).await?;
142
143 let port_overrides = read_existing_ports(service_name)?;
147
148 let port_in_use = |_p: u16| false;
152
153 let enabled_groups: BTreeSet<String> = metadata.enabled_groups.iter().cloned().collect();
154 let result = add_service(
155 service_name,
156 &exposure,
157 metadata.auth.clone(),
158 metadata.auth.is_some(),
159 metadata.smtp_enabled,
162 metadata.backup_enabled,
165 &BTreeMap::new(),
166 &enabled_groups,
167 &metadata.registry,
168 &repo_dir,
169 None,
170 &port_in_use,
171 None,
174 PlanMode::Upgrade,
175 &port_overrides,
176 )?;
177
178 let mut planned: BTreeMap<PathBuf, String> = BTreeMap::new();
179 for step in &result.steps {
180 if let Step::WriteFile(file) = step {
181 planned.insert(file.path.clone(), file.content.clone());
182 }
183 }
184 Ok((result, planned))
185}
186
187fn read_existing_env_keys(service_name: &str) -> Result<BTreeMap<String, String>> {
191 let env_path = service_home(service_name)?.join(".env");
192 let mut out: BTreeMap<String, String> = BTreeMap::new();
193 let content = match std::fs::read_to_string(&env_path) {
194 Ok(c) => c,
195 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(out),
196 Err(source) => {
197 return Err(Error::FileRead {
198 path: env_path,
199 source,
200 });
201 }
202 };
203 for line in content.lines() {
204 let line = line.trim();
205 if line.is_empty() || line.starts_with('#') {
206 continue;
207 }
208 if let Some((k, v)) = line.split_once('=') {
209 out.insert(k.trim().to_string(), v.to_string());
210 }
211 }
212 Ok(out)
213}
214
215fn read_existing_ports(service_name: &str) -> Result<BTreeMap<String, u16>> {
219 let env_path = service_home(service_name)?.join(".env");
220 let mut overrides = BTreeMap::new();
221 let content = match std::fs::read_to_string(&env_path) {
222 Ok(c) => c,
223 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(overrides),
227 Err(source) => {
228 return Err(Error::FileRead {
229 path: env_path,
230 source,
231 });
232 }
233 };
234 for line in content.lines() {
235 let line = line.trim();
236 if line.is_empty() || line.starts_with('#') {
237 continue;
238 }
239 let Some((key, value)) = line.split_once('=') else {
240 continue;
241 };
242 let Some(name) = key.strip_prefix("SERVICE_PORT_") else {
243 continue;
244 };
245 if let Ok(port) = value.trim().parse::<u16>() {
246 overrides.insert(name.to_ascii_lowercase(), port);
247 }
248 }
249 Ok(overrides)
250}
251
252fn should_skip_path(path: &std::path::Path, manifest_file: &std::path::Path) -> bool {
257 if path == manifest_file {
258 return true;
259 }
260 matches!(path.file_name().and_then(|n| n.to_str()), Some(".env"))
261}
262
263pub async fn diff_service(service_name: &str) -> Result<DiffResult> {
266 let (result, planned) = replan(service_name).await?;
267 let manifest_file = manifest::manifest_path(service_name)?;
268 let (manifest_entries, _manifest_envs) = manifest::load(service_name)?.unwrap_or_default();
269 let manifest_by_path: BTreeMap<PathBuf, String> = manifest_entries
270 .into_iter()
271 .map(|e| (e.path, e.sha256))
272 .collect();
273
274 let existing_env = read_existing_env_keys(service_name)?;
282 let env_additions: Vec<EnvAddition> = result
283 .tracked_envs
284 .iter()
285 .filter(|p| !existing_env.contains_key(&p.key))
286 .map(|p| EnvAddition {
287 key: p.key.clone(),
288 value: p.value.clone(),
289 kind: p.kind.clone(),
290 prompt: p.prompt.clone(),
291 })
292 .collect();
293
294 let mut entries: Vec<DiffEntry> = Vec::new();
295 let mut seen: BTreeSet<PathBuf> = BTreeSet::new();
296
297 for (path, content) in &planned {
299 if should_skip_path(path, &manifest_file) {
300 continue;
301 }
302 seen.insert(path.clone());
303 let planned_hash = manifest::hash_bytes(content.as_bytes());
304 let on_disk_hash = if path.exists() {
305 Some(manifest::hash_file(path)?)
306 } else {
307 None
308 };
309 let manifest_hash = manifest_by_path.get(path);
310
311 let kind = match (on_disk_hash.as_deref(), manifest_hash.map(String::as_str)) {
312 (None, Some(_)) | (None, None) => match manifest_hash {
314 Some(_) => DiffKind::Modified, None => DiffKind::Added, },
317 (Some(d), _) if d == planned_hash => DiffKind::Unchanged,
319 (Some(_), None) => DiffKind::Drift,
322 (Some(d), Some(l)) if d == l => DiffKind::Modified,
325 (Some(_), Some(_)) => DiffKind::Drift,
327 };
328 entries.push(DiffEntry {
329 path: path.clone(),
330 kind,
331 });
332 }
333
334 for path in manifest_by_path.keys() {
336 if seen.contains(path) {
337 continue;
338 }
339 if should_skip_path(path, &manifest_file) {
340 continue;
341 }
342 entries.push(DiffEntry {
343 path: path.clone(),
344 kind: DiffKind::Removed,
345 });
346 }
347
348 entries.sort_by(|a, b| a.path.cmp(&b.path));
349 Ok(DiffResult {
350 service: service_name.to_string(),
351 entries,
352 env_additions,
353 })
354}
355
356pub async fn upgrade_service(service_name: &str, force: bool) -> Result<UpgradeResult> {
362 let diff = diff_service(service_name).await?;
363
364 if !force {
365 let drifted = diff.drifted();
366 if !drifted.is_empty() {
367 return Err(Error::HandEditedFiles {
368 service: service_name.to_string(),
369 paths: drifted.iter().map(|e| e.path.clone()).collect(),
370 });
371 }
372 }
373
374 let (result, planned) = replan(service_name).await?;
375 let manifest_file = manifest::manifest_path(service_name)?;
376 let env_file = service_home(service_name)?.join(".env");
377
378 if !env_file.exists() {
383 return Err(Error::Template(format!(
384 "{service_name}: `.env` is missing at {} — upgrade can't reconstruct generated secrets. \
385 Restore the file from a backup or reinstall the service.",
386 env_file.display()
387 )));
388 }
389
390 let backup_dir = backup_directory(service_name)?;
396 let needs_backup: BTreeSet<PathBuf> = diff
397 .entries
398 .iter()
399 .filter(|e| {
400 matches!(
401 e.kind,
402 DiffKind::Modified | DiffKind::Drift | DiffKind::Removed
403 )
404 })
405 .map(|e| e.path.clone())
406 .collect();
407 let manifest_will_be_backed_up = manifest_file.exists();
408 let backup_used = !needs_backup.is_empty() || manifest_will_be_backed_up;
409
410 let mut steps: Vec<Step> = Vec::new();
419 if backup_used {
420 steps.push(Step::CreateDir(backup_dir.clone()));
421 }
422 let unchanged: BTreeSet<PathBuf> = diff
423 .entries
424 .iter()
425 .filter(|e| matches!(e.kind, DiffKind::Unchanged))
426 .map(|e| e.path.clone())
427 .collect();
428
429 let env_filename = std::ffi::OsStr::new(".env");
430 for step in result.steps {
431 match step {
432 Step::WriteFile(GeneratedFile { ref path, .. })
435 if path.file_name() == Some(env_filename) =>
436 {
437 continue;
438 }
439 Step::WriteFile(GeneratedFile { ref path, .. }) if unchanged.contains(path) => {
443 if path == &manifest_file {
447 steps.push(step);
448 }
449 continue;
450 }
451 Step::WriteFile(ref file) => {
452 let should_backup = (needs_backup.contains(&file.path)
459 || file.path == manifest_file)
460 && file.path.exists();
461 if should_backup {
462 let rel = backup_relpath(&file.path);
463 let dst = backup_dir.join(rel);
464 if let Some(parent) = dst.parent() {
465 steps.push(Step::CreateDir(parent.to_path_buf()));
466 }
467 steps.push(Step::CopyFile {
468 src: file.path.clone(),
469 dst,
470 });
471 }
472 steps.push(step);
473 }
474 Step::StartService { .. } => continue,
478 other => steps.push(other),
479 }
480 }
481
482 for entry in &diff.entries {
484 if !matches!(entry.kind, DiffKind::Removed) {
485 continue;
486 }
487 if entry.path.exists() {
488 let rel = backup_relpath(&entry.path);
489 let dst = backup_dir.join(rel);
490 if let Some(parent) = dst.parent() {
491 steps.push(Step::CreateDir(parent.to_path_buf()));
492 }
493 steps.push(Step::CopyFile {
494 src: entry.path.clone(),
495 dst,
496 });
497 }
498 steps.push(Step::RemoveFile(entry.path.clone()));
499 }
500
501 if !diff.env_additions.is_empty() {
509 let mut content = match std::fs::read_to_string(&env_file) {
510 Ok(c) => c,
511 Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
514 Err(source) => {
515 return Err(Error::FileRead {
516 path: env_file.clone(),
517 source,
518 });
519 }
520 };
521 if !content.is_empty() && !content.ends_with('\n') {
522 content.push('\n');
523 }
524 for add in &diff.env_additions {
525 content.push_str(&format!("{}={}\n", add.key, add.value));
526 }
527 steps.push(Step::WriteFile(GeneratedFile {
528 path: env_file,
529 content,
530 }));
531 }
532
533 steps.push(Step::RestartService {
537 unit: service_name.to_string(),
538 });
539
540 Ok(UpgradeResult {
541 service: service_name.to_string(),
542 diff,
543 steps,
544 backup_dir: if backup_used { Some(backup_dir) } else { None },
545 planned_files: planned,
550 })
551}
552
553pub struct UpgradeResult {
554 pub service: String,
555 pub diff: DiffResult,
556 pub steps: Vec<Step>,
557 pub backup_dir: Option<PathBuf>,
559 pub planned_files: BTreeMap<PathBuf, String>,
560}
561
562#[derive(Debug, Clone)]
564pub struct BackupSnapshot {
565 pub path: PathBuf,
567 pub timestamp: String,
569}
570
571pub struct RevertResult {
572 pub service: String,
573 pub snapshot: BackupSnapshot,
574 pub steps: Vec<Step>,
575 pub files_to_restore: Vec<PathBuf>,
577 pub files_to_delete: Vec<PathBuf>,
581}
582
583pub const DEFAULT_BACKUP_KEEP: usize = 5;
592
593pub fn prune_backups(service_name: &str, keep: usize) -> Result<Vec<PathBuf>> {
600 let backups_root = state_dir()?.join("backups");
601 prune_backups_in(&backups_root, service_name, keep)
602}
603
604fn prune_backups_in(
608 backups_root: &std::path::Path,
609 service_name: &str,
610 keep: usize,
611) -> Result<Vec<PathBuf>> {
612 let snapshots = list_backups_in(backups_root, service_name)?;
613 if snapshots.len() <= keep {
614 return Ok(Vec::new());
615 }
616 let mut removed: Vec<PathBuf> = Vec::new();
617 for snap in snapshots.into_iter().skip(keep) {
618 if let Err(e) = std::fs::remove_dir_all(&snap.path) {
619 eprintln!(
620 "warning: failed to prune backup {}: {e}",
621 snap.path.display()
622 );
623 continue;
624 }
625 removed.push(snap.path.clone());
626 if let Some(parent) = snap.path.parent()
627 && let Ok(mut entries) = std::fs::read_dir(parent)
628 && entries.next().is_none()
629 {
630 let _ = std::fs::remove_dir(parent);
631 }
632 }
633 Ok(removed)
634}
635
636pub fn list_backups(service_name: &str) -> Result<Vec<BackupSnapshot>> {
637 let backups_root = state_dir()?.join("backups");
638 list_backups_in(&backups_root, service_name)
639}
640
641fn list_backups_in(
642 backups_root: &std::path::Path,
643 service_name: &str,
644) -> Result<Vec<BackupSnapshot>> {
645 if !backups_root.is_dir() {
646 return Ok(Vec::new());
647 }
648 let mut snapshots: Vec<BackupSnapshot> = Vec::new();
649 let entries = std::fs::read_dir(backups_root).map_err(|source| Error::FileRead {
650 path: backups_root.to_path_buf(),
651 source,
652 })?;
653 for entry in entries.flatten() {
654 let stamp_dir = entry.path();
655 if !stamp_dir.is_dir() {
656 continue;
657 }
658 let svc_dir = stamp_dir.join(service_name);
659 if !svc_dir.is_dir() {
660 continue;
661 }
662 let Some(stamp) = stamp_dir.file_name().and_then(|n| n.to_str()) else {
663 continue;
664 };
665 snapshots.push(BackupSnapshot {
666 path: svc_dir,
667 timestamp: stamp.to_string(),
668 });
669 }
670 snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
672 Ok(snapshots)
673}
674
675pub fn revert_service(service_name: &str, at: Option<&str>) -> Result<RevertResult> {
682 if !is_service_installed(service_name) {
683 return Err(Error::ServiceNotInstalled(service_name.to_string()));
684 }
685 let snapshot = pick_snapshot(service_name, at)?;
686
687 let mut files_to_restore: Vec<PathBuf> = Vec::new();
692 walk_backup_files(&snapshot.path, &mut files_to_restore)?;
693
694 let backup_manifest_file =
699 absolute_to_backup_path(&snapshot.path, &manifest::manifest_path(service_name)?);
700 let (backup_manifest_entries, _) = read_manifest_at(&backup_manifest_file)?;
701 let (current_manifest_entries, _) = manifest::load(service_name)?.unwrap_or_default();
702
703 let backup_manifest_set: BTreeSet<PathBuf> = backup_manifest_entries
704 .iter()
705 .map(|e| e.path.clone())
706 .collect();
707 let mut files_to_delete: Vec<PathBuf> = if backup_manifest_entries.is_empty() {
708 Vec::new()
710 } else {
711 current_manifest_entries
712 .iter()
713 .map(|e| e.path.clone())
714 .filter(|p| !backup_manifest_set.contains(p))
715 .collect()
716 };
717 files_to_delete.sort();
718
719 let mut steps: Vec<Step> = Vec::new();
721 for backup_path in &files_to_restore {
724 let original = backup_to_absolute_path(&snapshot.path, backup_path);
725 steps.push(Step::CopyFile {
726 src: backup_path.clone(),
727 dst: original,
728 });
729 }
730 let qd = crate::quadlet_dir()?;
734 for path in &files_to_delete {
735 if path.exists() {
736 steps.push(Step::RemoveFile(path.clone()));
737 }
738 if let Some(name) = path.file_name() {
739 let symlink = qd.join(name);
740 if std::fs::symlink_metadata(&symlink).is_ok() {
741 steps.push(Step::RemoveFile(symlink));
742 }
743 }
744 }
745 steps.push(Step::DaemonReload);
746 steps.push(Step::RestartService {
747 unit: service_name.to_string(),
748 });
749
750 let files_to_restore_orig: Vec<PathBuf> = files_to_restore
751 .iter()
752 .map(|p| backup_to_absolute_path(&snapshot.path, p))
753 .collect();
754 Ok(RevertResult {
755 service: service_name.to_string(),
756 snapshot,
757 steps,
758 files_to_restore: files_to_restore_orig,
759 files_to_delete,
760 })
761}
762
763fn pick_snapshot(service_name: &str, at: Option<&str>) -> Result<BackupSnapshot> {
766 let snapshots = list_backups(service_name)?;
767 if snapshots.is_empty() {
768 return Err(Error::NoBackup(service_name.to_string()));
769 }
770 match at {
771 None => Ok(snapshots
772 .into_iter()
773 .next()
774 .expect("non-empty checked above")),
775 Some(stamp) => snapshots
776 .into_iter()
777 .find(|s| s.timestamp == stamp)
778 .ok_or_else(|| Error::BackupNotFound {
779 service: service_name.to_string(),
780 stamp: stamp.to_string(),
781 }),
782 }
783}
784
785fn walk_backup_files(root: &std::path::Path, out: &mut Vec<PathBuf>) -> Result<()> {
789 let entries = std::fs::read_dir(root).map_err(|source| Error::FileRead {
790 path: root.to_path_buf(),
791 source,
792 })?;
793 for entry in entries.flatten() {
794 let path = entry.path();
795 let meta = match entry.metadata() {
796 Ok(m) => m,
797 Err(_) => continue,
798 };
799 if meta.is_dir() {
800 walk_backup_files(&path, out)?;
801 } else if meta.is_file() {
802 out.push(path);
803 }
804 }
805 Ok(())
806}
807
808fn backup_to_absolute_path(root: &std::path::Path, backup: &std::path::Path) -> PathBuf {
811 let rel = backup.strip_prefix(root).unwrap_or(backup);
812 PathBuf::from("/").join(rel)
813}
814
815fn absolute_to_backup_path(root: &std::path::Path, abs: &std::path::Path) -> PathBuf {
817 let rel = abs.to_string_lossy();
818 let stripped = rel.trim_start_matches('/');
819 root.join(stripped)
820}
821
822fn read_manifest_at(
825 path: &std::path::Path,
826) -> Result<(Vec<manifest::ManifestEntry>, Vec<manifest::EnvEntry>)> {
827 if !path.exists() {
828 return Ok((Vec::new(), Vec::new()));
829 }
830 let content = std::fs::read_to_string(path).map_err(|source| Error::FileRead {
831 path: path.to_path_buf(),
832 source,
833 })?;
834 manifest::parse(&content)
835}
836
837fn backup_directory(service_name: &str) -> Result<PathBuf> {
841 let state = state_dir()?;
842 let now = std::time::SystemTime::now()
843 .duration_since(std::time::UNIX_EPOCH)
844 .map_err(|e| Error::Template(format!("system clock before UNIX epoch: {e}")))?
845 .as_secs();
846 let stamp = format_timestamp(now);
847 Ok(state.join("backups").join(stamp).join(service_name))
848}
849
850fn state_dir() -> Result<PathBuf> {
852 let base = dirs::state_dir()
853 .or_else(|| dirs::home_dir().map(|h| h.join(".local").join("state")))
854 .ok_or(Error::HomeDirNotFound)?;
855 Ok(base.join("ryra"))
856}
857
858fn format_timestamp(secs: u64) -> String {
861 const SECS_PER_DAY: u64 = 86_400;
863 let days = secs / SECS_PER_DAY;
864 let time_of_day = secs % SECS_PER_DAY;
865 let h = time_of_day / 3600;
866 let m = (time_of_day % 3600) / 60;
867 let s = time_of_day % 60;
868 let (y, mo, d) = ymd_from_days(days);
869 format!("{y:04}-{mo:02}-{d:02}T{h:02}-{m:02}-{s:02}Z")
870}
871
872fn ymd_from_days(days: u64) -> (i64, u32, u32) {
876 let z = days as i64 + 719_468;
877 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
878 let doe = (z - era * 146_097) as u64;
879 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
880 let y = yoe as i64 + era * 400;
881 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
882 let mp = (5 * doy + 2) / 153;
883 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
884 let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
885 let y = if m <= 2 { y + 1 } else { y };
886 (y, m, d)
887}
888
889fn backup_relpath(path: &std::path::Path) -> PathBuf {
893 PathBuf::from(path.to_string_lossy().trim_start_matches('/'))
894}
895
896#[cfg(test)]
897mod tests {
898 use super::*;
899
900 #[test]
901 fn timestamp_round_numbers() {
902 let s = format_timestamp(0);
907 assert_eq!(s, "1970-01-01T00-00-00Z");
908 let s = format_timestamp(86_400);
909 assert_eq!(s, "1970-01-02T00-00-00Z");
910 let s = format_timestamp(31_536_000); assert_eq!(s, "1971-01-01T00-00-00Z");
912 }
913
914 #[test]
915 fn backup_relpath_strips_leading_slash() {
916 let p = backup_relpath(std::path::Path::new("/home/user/foo/bar"));
917 assert_eq!(p, PathBuf::from("home/user/foo/bar"));
918 }
919
920 fn setup_and_prune(stamps: &[&str], keep: usize) -> (Vec<String>, Vec<PathBuf>) {
925 let tmp = std::env::temp_dir().join(format!(
926 "ryra-prune-test-{}-{}",
927 std::process::id(),
928 std::time::SystemTime::now()
929 .duration_since(std::time::UNIX_EPOCH)
930 .unwrap()
931 .as_nanos()
932 ));
933 let backups_root = tmp.join("backups");
934 for s in stamps {
935 std::fs::create_dir_all(backups_root.join(s).join("svc")).unwrap();
936 }
937 let removed = prune_backups_in(&backups_root, "svc", keep).unwrap();
938 let mut kept: Vec<String> = std::fs::read_dir(&backups_root)
939 .unwrap()
940 .filter_map(|e| e.ok())
941 .filter_map(|e| e.file_name().into_string().ok())
942 .collect();
943 kept.sort();
944 kept.reverse();
945 let _ = std::fs::remove_dir_all(&tmp);
946 (kept, removed)
947 }
948
949 #[test]
950 fn prune_keeps_newest_n() {
951 let (kept, removed) = setup_and_prune(
953 &[
954 "2026-01-01T00-00-00Z",
955 "2026-02-01T00-00-00Z",
956 "2026-03-01T00-00-00Z",
957 "2026-04-01T00-00-00Z",
958 "2026-05-01T00-00-00Z",
959 ],
960 3,
961 );
962 assert_eq!(kept.len(), 3);
963 assert_eq!(kept[0], "2026-05-01T00-00-00Z");
964 assert_eq!(kept[2], "2026-03-01T00-00-00Z");
965 assert_eq!(removed.len(), 2);
966 }
967
968 #[test]
969 fn prune_no_op_when_under_keep() {
970 let (kept, removed) = setup_and_prune(&["2026-01-01T00-00-00Z", "2026-02-01T00-00-00Z"], 5);
971 assert_eq!(kept.len(), 2);
972 assert!(removed.is_empty());
973 }
974
975 #[test]
976 fn should_skip_path_excludes_env_and_manifest() {
977 let lock = PathBuf::from("/svc/service.manifest");
978 assert!(should_skip_path(&PathBuf::from("/svc/.env"), &lock));
979 assert!(should_skip_path(&lock, &lock));
980 assert!(!should_skip_path(
981 &PathBuf::from("/svc/configs/x.sh"),
982 &lock
983 ));
984 }
985}