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