use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::cx::Cx;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MirrorEntryKind {
File,
Dir,
Symlink,
}
impl MirrorEntryKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::File => "file",
Self::Dir => "dir",
Self::Symlink => "symlink",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MirrorExtra {
pub rel_path: String,
pub abs_path: PathBuf,
pub kind: MirrorEntryKind,
}
#[derive(Debug, Clone, Copy)]
pub struct MirrorPolicy {
pub enabled: bool,
pub max_delete_fraction: f64,
}
impl Default for MirrorPolicy {
fn default() -> Self {
Self {
enabled: false,
max_delete_fraction: 0.5,
}
}
}
#[derive(Debug, Clone)]
pub struct MirrorReport {
pub dry_run: bool,
pub dest_entries: usize,
pub planned: Vec<MirrorExtra>,
pub deleted: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum MirrorError {
#[error("mirror io error: {0}")]
Io(#[from] std::io::Error),
#[error("mirror refused: path {path} is not contained under destination root {root}")]
PathEscape {
path: String,
root: String,
},
#[error(
"mirror max-delete guard tripped: {would_delete} of {dest_entries} entries \
({fraction:.1}%) exceed the {limit:.1}% limit; refusing to delete"
)]
MaxDeleteGuard {
would_delete: usize,
dest_entries: usize,
fraction: f64,
limit: f64,
},
#[error("mirror cancelled")]
Cancelled,
}
pub async fn mirror_dest(
cx: &Cx,
dest_base: &Path,
keep_rel_paths: &BTreeSet<String>,
policy: MirrorPolicy,
) -> Result<MirrorReport, MirrorError> {
cx.checkpoint().map_err(|_| MirrorError::Cancelled)?;
match crate::fs::symlink_metadata(dest_base).await {
Ok(md) if md.is_dir() => {}
Ok(_) => {
return Ok(MirrorReport {
dry_run: !policy.enabled,
dest_entries: 0,
planned: Vec::new(),
deleted: 0,
});
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return Ok(MirrorReport {
dry_run: !policy.enabled,
dest_entries: 0,
planned: Vec::new(),
deleted: 0,
});
}
Err(e) => return Err(MirrorError::Io(e)),
}
let keep = expand_keep_set(keep_rel_paths);
let mut dest_entries: usize = 0;
let mut extras: Vec<MirrorExtra> = Vec::new();
let mut stack: Vec<(PathBuf, String)> = vec![(dest_base.to_path_buf(), String::new())];
while let Some((dir_abs, dir_rel)) = stack.pop() {
cx.checkpoint().map_err(|_| MirrorError::Cancelled)?;
let mut rd = crate::fs::read_dir(&dir_abs).await?;
while let Some(entry) = rd.next_entry().await? {
let abs = entry.path();
let name = entry.file_name().to_string_lossy().to_string();
let rel = if dir_rel.is_empty() {
name
} else {
format!("{dir_rel}/{name}")
};
let md = entry.metadata().await?;
let kind = if md.is_symlink() {
MirrorEntryKind::Symlink
} else if md.is_dir() {
MirrorEntryKind::Dir
} else {
MirrorEntryKind::File
};
dest_entries += 1;
if kind == MirrorEntryKind::Dir {
stack.push((abs.clone(), rel.clone()));
}
if !keep.contains(&rel) {
extras.push(MirrorExtra {
rel_path: rel,
abs_path: abs,
kind,
});
}
}
}
extras.sort_by(|a, b| {
depth(&b.rel_path)
.cmp(&depth(&a.rel_path))
.then_with(|| b.rel_path.cmp(&a.rel_path))
});
let would_delete = extras.len();
let dry_run = !policy.enabled;
if dry_run {
for x in &extras {
cx.trace_with_fields(
"atp.mirror.dry_run",
&[("rel_path", x.rel_path.as_str()), ("kind", x.kind.as_str())],
);
}
return Ok(MirrorReport {
dry_run: true,
dest_entries,
planned: extras,
deleted: 0,
});
}
let limit = policy.max_delete_fraction.clamp(0.0, 1.0);
if dest_entries > 0 {
#[allow(clippy::cast_precision_loss)]
let fraction = would_delete as f64 / dest_entries as f64;
if fraction > limit {
return Err(MirrorError::MaxDeleteGuard {
would_delete,
dest_entries,
fraction: fraction * 100.0,
limit: limit * 100.0,
});
}
}
let mut deleted = 0usize;
for x in &extras {
cx.checkpoint().map_err(|_| MirrorError::Cancelled)?;
if !x.abs_path.starts_with(dest_base) {
return Err(MirrorError::PathEscape {
path: x.abs_path.display().to_string(),
root: dest_base.display().to_string(),
});
}
cx.trace_with_fields(
"atp.mirror.delete",
&[("rel_path", x.rel_path.as_str()), ("kind", x.kind.as_str())],
);
match x.kind {
MirrorEntryKind::File | MirrorEntryKind::Symlink => {
crate::fs::remove_file(&x.abs_path).await?;
}
MirrorEntryKind::Dir => {
crate::fs::remove_dir(&x.abs_path).await?;
}
}
deleted += 1;
}
Ok(MirrorReport {
dry_run: false,
dest_entries,
planned: extras,
deleted,
})
}
fn expand_keep_set(keep_rel_paths: &BTreeSet<String>) -> BTreeSet<String> {
let mut keep = BTreeSet::new();
for path in keep_rel_paths {
let normalized = path.trim_matches('/');
if normalized.is_empty() {
continue;
}
let mut prefix = String::new();
for component in normalized.split('/') {
if component.is_empty() {
continue;
}
if prefix.is_empty() {
prefix = component.to_string();
} else {
prefix.push('/');
prefix.push_str(component);
}
keep.insert(prefix.clone());
}
}
keep
}
fn depth(rel: &str) -> usize {
rel.split('/').filter(|c| !c.is_empty()).count()
}
#[cfg(test)]
mod tests {
use super::*;
fn keep_set(paths: &[&str]) -> BTreeSet<String> {
paths.iter().map(|p| (*p).to_string()).collect()
}
#[test]
fn expand_keep_adds_all_ancestors() {
let keep = expand_keep_set(&keep_set(&["a/b/c.txt", "a/d.txt"]));
assert!(keep.contains("a"));
assert!(keep.contains("a/b"));
assert!(keep.contains("a/b/c.txt"));
assert!(keep.contains("a/d.txt"));
assert!(!keep.contains("a/b/c.txt/extra"));
}
#[test]
fn expand_keep_ignores_empty_and_slashes() {
let keep = expand_keep_set(&keep_set(&["", "/", "x//y/"]));
assert!(keep.contains("x"));
assert!(keep.contains("x/y"));
assert_eq!(keep.len(), 2);
}
#[test]
fn depth_counts_components() {
assert_eq!(depth(""), 0);
assert_eq!(depth("a"), 1);
assert_eq!(depth("a/b/c"), 3);
assert_eq!(depth("a//b"), 2);
}
#[test]
fn mirror_entry_kind_labels() {
assert_eq!(MirrorEntryKind::File.as_str(), "file");
assert_eq!(MirrorEntryKind::Dir.as_str(), "dir");
assert_eq!(MirrorEntryKind::Symlink.as_str(), "symlink");
}
#[test]
fn default_policy_is_dry_run() {
let p = MirrorPolicy::default();
assert!(!p.enabled);
assert!(p.max_delete_fraction > 0.0 && p.max_delete_fraction <= 1.0);
}
}