use std::collections::HashSet;
use std::path::Path;
use std::time::Duration;
use sha2::{Digest, Sha256};
use crate::atp::object::{ContentId, MetadataPolicy, ObjectId};
use crate::cx::Cx;
use super::metadata::{FileKind, inode_key_if_regular, read_entry_metadata};
use super::streaming::{
EntryDigest, StreamingError, collect_entries, flat_merkle_root_from_digests,
hash_file_streaming, hex_encode,
};
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct PlanEntry {
pub rel_path: String,
pub size: u64,
pub sha256_hex: String,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct TransferPlan {
pub root_name: String,
pub is_directory: bool,
pub total_bytes: u64,
pub file_count: u64,
pub merkle_root_hex: String,
pub entries: Vec<PlanEntry>,
}
#[derive(Debug, thiserror::Error)]
pub enum PlanError {
#[error("dry-run plan source error: {0}")]
Source(String),
#[error("dry-run plan cancelled")]
Cancelled,
}
impl From<StreamingError> for PlanError {
fn from(err: StreamingError) -> Self {
Self::Source(err.into_message())
}
}
pub async fn plan_transfer(
cx: &Cx,
source: &Path,
chunk_size: usize,
metadata_policy: &MetadataPolicy,
preserve_hardlinks: bool,
) -> Result<TransferPlan, PlanError> {
cx.checkpoint().map_err(|_| PlanError::Cancelled)?;
let (root_name, is_directory, entries) = collect_entries(source).await?;
let mut read_buf = vec![0u8; chunk_size.max(1)];
let mut digests: Vec<EntryDigest> = Vec::with_capacity(entries.len());
let mut total_bytes: u64 = 0;
let mut seen_inodes: HashSet<(u64, u64)> = HashSet::new();
for entry in &entries {
cx.checkpoint().map_err(|_| PlanError::Cancelled)?;
let metadata = read_entry_metadata(&entry.abs_path, metadata_policy).await?;
let mut is_hardlink_secondary = false;
if preserve_hardlinks && matches!(metadata.file_kind, FileKind::Regular) {
if let Some(key) = inode_key_if_regular(&entry.abs_path).await? {
is_hardlink_secondary = !seen_inodes.insert(key);
}
}
let zero_content =
!matches!(metadata.file_kind, FileKind::Regular) || is_hardlink_secondary;
let (size, content_id, content_sha256) = if zero_content {
let empty_sha: [u8; 32] = Sha256::digest(b"").into();
(
0u64,
ObjectId::content(ContentId::from_bytes(b"")),
empty_sha,
)
} else {
hash_file_streaming(&entry.abs_path, &mut read_buf).await?
};
total_bytes = total_bytes.saturating_add(size);
digests.push(EntryDigest {
rel_path: entry.rel_path.clone(),
size,
content_id,
content_sha256,
});
}
let merkle_root_hex = flat_merkle_root_from_digests(&digests);
let plan_entries: Vec<PlanEntry> = digests
.iter()
.map(|d| PlanEntry {
rel_path: d.rel_path.clone(),
size: d.size,
sha256_hex: hex_encode(&d.content_sha256),
})
.collect();
Ok(TransferPlan {
root_name,
is_directory,
total_bytes,
file_count: entries.len() as u64,
merkle_root_hex,
entries: plan_entries,
})
}
#[derive(Debug, Clone, PartialEq)]
pub struct ProgressSnapshot {
pub bytes_done: u64,
pub total_bytes: u64,
pub files_done: u64,
pub total_files: u64,
pub fraction: f64,
pub rate_bytes_per_sec: f64,
pub eta: Option<Duration>,
}
#[derive(Debug, Clone)]
pub struct TransferProgress {
total_bytes: u64,
total_files: u64,
bytes_done: u64,
files_done: u64,
}
impl TransferProgress {
#[must_use]
pub fn new(total_bytes: u64, total_files: u64) -> Self {
Self {
total_bytes,
total_files,
bytes_done: 0,
files_done: 0,
}
}
pub fn record_bytes(&mut self, n: u64) {
self.bytes_done = self.bytes_done.saturating_add(n).min(self.total_bytes);
}
pub fn record_file(&mut self) {
self.files_done = self.files_done.saturating_add(1).min(self.total_files);
}
#[must_use]
pub fn bytes_done(&self) -> u64 {
self.bytes_done
}
#[must_use]
pub fn files_done(&self) -> u64 {
self.files_done
}
#[must_use]
pub fn is_complete(&self) -> bool {
self.bytes_done >= self.total_bytes
}
#[must_use]
#[allow(clippy::cast_precision_loss)]
pub fn snapshot(&self, elapsed: Duration) -> ProgressSnapshot {
let fraction = if self.total_bytes == 0 {
1.0
} else {
self.bytes_done as f64 / self.total_bytes as f64
};
let secs = elapsed.as_secs_f64();
let rate = if secs > 0.0 {
self.bytes_done as f64 / secs
} else {
0.0
};
let eta = if rate > 0.0 && self.bytes_done < self.total_bytes {
let remaining = (self.total_bytes - self.bytes_done) as f64;
Duration::try_from_secs_f64(remaining / rate).ok()
} else {
None
};
ProgressSnapshot {
bytes_done: self.bytes_done,
total_bytes: self.total_bytes,
files_done: self.files_done,
total_files: self.total_files,
fraction,
rate_bytes_per_sec: rate,
eta,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn progress_is_monotonic_and_capped() {
let mut p = TransferProgress::new(1000, 3);
p.record_bytes(400);
assert_eq!(p.bytes_done(), 400);
p.record_bytes(400);
assert_eq!(p.bytes_done(), 800);
p.record_bytes(9999);
assert_eq!(p.bytes_done(), 1000);
assert!(p.is_complete());
p.record_file();
p.record_file();
p.record_file();
p.record_file(); assert_eq!(p.files_done(), 3);
}
#[test]
fn snapshot_fraction_rate_and_eta() {
let mut p = TransferProgress::new(1000, 4);
p.record_bytes(250);
let s = p.snapshot(Duration::from_secs(1));
assert!((s.fraction - 0.25).abs() < 1e-9);
assert!((s.rate_bytes_per_sec - 250.0).abs() < 1e-9);
let eta = s.eta.expect("eta while in flight");
assert!((eta.as_secs_f64() - 3.0).abs() < 1e-6);
}
#[test]
fn snapshot_no_eta_when_done_or_no_time() {
let mut p = TransferProgress::new(100, 1);
assert!(p.snapshot(Duration::ZERO).eta.is_none());
p.record_bytes(100);
let s = p.snapshot(Duration::from_secs(2));
assert!(s.eta.is_none());
assert!((s.fraction - 1.0).abs() < 1e-9);
}
#[test]
fn empty_transfer_is_fully_complete() {
let p = TransferProgress::new(0, 0);
let s = p.snapshot(Duration::from_secs(1));
assert!((s.fraction - 1.0).abs() < 1e-9);
assert!(s.eta.is_none());
assert!(p.is_complete());
}
#[test]
fn plan_error_maps_from_streaming() {
let e: PlanError = StreamingError::new("boom").into();
assert!(matches!(e, PlanError::Source(m) if m.contains("boom")));
}
}