switchyard/fs/backup/
index.rs

1use std::{
2    collections::HashSet,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use super::sidecar::sidecar_path_for_backup;
8
9/// Return (`backup_path_if_present`, `sidecar_path`) for the latest timestamped pair.
10pub(crate) fn find_latest_backup_and_sidecar(
11    target: &Path,
12    tag: &str,
13) -> Option<(Option<PathBuf>, PathBuf)> {
14    let name = target.file_name()?.to_str()?; // relies on UTF-8 file name
15    let parent = target.parent().unwrap_or_else(|| Path::new("."));
16    let rd = fs::read_dir(parent).ok()?;
17
18    // Track best (timestamp, base_path) as we scan — no Vec, no sort.
19    let mut best: Option<(u128, PathBuf)> = None;
20
21    for entry in rd.flatten() {
22        let fname = entry.file_name();
23        let Some(s) = fname.to_str() else { continue }; // skip non-UTF-8
24
25        // Two modes:
26        // - Tagged: expect ".{name}.{tag}.{ts}.bak[.meta.json]"
27        // - Untagged (wildcard): accept any tag: ".{name}.<any>.<ts>.bak[.meta.json]"
28        let (rest_opt, wildcard) = if tag.is_empty() {
29            let pre = format!(".{name}.");
30            (s.strip_prefix(&pre), true)
31        } else {
32            let pre = format!(".{name}.{tag}.");
33            (s.strip_prefix(&pre), false)
34        };
35        let Some(rest) = rest_opt else { continue };
36
37        // Accept both payload and sidecar filenames.
38        let core = if let Some(core) = rest.strip_suffix(".bak") {
39            core
40        } else if let Some(core) = rest.strip_suffix(".bak.meta.json") {
41            core
42        } else {
43            continue;
44        };
45
46        // When wildcard, `core` has the form "<ts>" only if the tag had no body.
47        // We want the last dotted segment to be the timestamp.
48        let ts_part = if wildcard {
49            core.rsplit('.').next().unwrap_or(core)
50        } else {
51            core
52        };
53        let Ok(ts) = ts_part.parse::<u128>() else {
54            continue;
55        };
56
57        // Build the base path back. For wildcard we cannot reconstruct tag; however, we only need base path.
58        // Reconstruct from the actual filename: drop any ".meta.json" suffix if present.
59        let base = if s.ends_with(".bak.meta.json") {
60            parent.join(&s[..s.len() - ".meta.json".len()])
61        } else {
62            parent.join(s)
63        };
64
65        let is_better = match best.as_ref() {
66            None => true,
67            Some((cur, _)) => ts > *cur,
68        };
69        if is_better {
70            best = Some((ts, base));
71        }
72    }
73
74    let (_, base) = best?;
75    let sidecar = sidecar_path_for_backup(&base);
76    let backup_present = if base.exists() { Some(base) } else { None };
77    Some((backup_present, sidecar))
78}
79
80/// Return the previous (second newest) backup pair if present.
81pub(crate) fn find_previous_backup_and_sidecar(
82    target: &Path,
83    tag: &str,
84) -> Option<(Option<PathBuf>, PathBuf)> {
85    let name = target.file_name()?.to_str()?;
86    let parent = target.parent().unwrap_or_else(|| Path::new("."));
87
88    let mut seen = HashSet::<u128>::new();
89    let mut stamps: Vec<(u128, PathBuf)> = fs::read_dir(parent)
90        .ok()?
91        .filter_map(Result::ok)
92        .filter_map(|e| e.file_name().into_string().ok())
93        .filter_map(|s| {
94            // Prefix acceptance
95            let ok_prefix = if tag.is_empty() {
96                s.strip_prefix(&format!(".{name}.")).is_some()
97            } else {
98                s.strip_prefix(&format!(".{name}.{tag}.")).is_some()
99            };
100            if !ok_prefix {
101                return None;
102            }
103
104            // Strip sidecar suffix if present
105            let rest_opt = s
106                .strip_suffix(".bak")
107                .or_else(|| s.strip_suffix(".bak.meta.json"))
108                .map(ToString::to_string);
109            let core = rest_opt?;
110
111            // Timestamp is the last dotted segment
112            let ts_s = core.rsplit('.').next().unwrap_or("");
113            let Ok(ts) = ts_s.parse::<u128>() else {
114                return None;
115            };
116
117            if !seen.insert(ts) {
118                return None;
119            }
120            // Base path is without .meta.json when present
121            let base = if s.ends_with(".bak.meta.json") {
122                parent.join(&s[..s.len() - ".meta.json".len()])
123            } else {
124                parent.join(&s)
125            };
126            Some((ts, base))
127        })
128        .collect();
129
130    if stamps.len() < 2 {
131        return None;
132    }
133    stamps.sort_unstable_by_key(|(ts, _)| *ts);
134    let (_ts, base) = stamps.get(stamps.len() - 2)?.clone();
135
136    let sidecar = sidecar_path_for_backup(&base);
137    let backup_present = if base.exists() { Some(base) } else { None };
138    Some((backup_present, sidecar))
139}