use super::{
in_cloud_synced_dir, AdoptAction, DedupAction, DedupOptions, DedupPlan, FileSnapshot,
SkippedCopy,
};
use crate::paths::ShelfPaths;
use crate::registry::{LocationRole, Registry};
use crate::Result;
pub(crate) fn build(
paths: &ShelfPaths,
reg: &Registry,
options: &DedupOptions,
) -> Result<DedupPlan> {
let mut plan = DedupPlan {
strategy: options.strategy,
..Default::default()
};
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
let blobs_dev = device_of(&paths.blobs_dir());
for model in ®.models {
let Some(hex) = model.id.sha256_hex() else {
continue;
};
let blob = paths.blob_file(hex);
let blob_snapshot = FileSnapshot::of(&blob).ok();
let mut candidates = Vec::new();
for loc in &model.locations {
if loc.role == LocationRole::Store || loc.path == blob {
continue;
}
if !loc.ecosystem.is_writable() {
continue;
}
let Ok(snapshot) = FileSnapshot::of(&loc.path) else {
plan.skipped.push(SkippedCopy {
path: loc.path.clone(),
reason: "cannot stat file".into(),
});
continue;
};
if let (Some(bs), Some(inode), Some(binode)) = (
blob_snapshot.as_ref(),
snapshot.inode,
blob_snapshot.as_ref().and_then(|b| b.inode),
) {
if snapshot.device == bs.device && inode == binode {
continue; }
}
if snapshot.size_bytes < options.min_size_bytes {
plan.skipped.push(SkippedCopy {
path: loc.path.clone(),
reason: format!(
"below size threshold ({} < {})",
snapshot.size_bytes, options.min_size_bytes
),
});
continue;
}
if now_ms - snapshot.mtime_unix_ms < (options.recent_grace_secs as i64) * 1000 {
plan.skipped.push(SkippedCopy {
path: loc.path.clone(),
reason: "modified too recently (may be in use)".into(),
});
continue;
}
if in_cloud_synced_dir(&loc.path) {
plan.skipped.push(SkippedCopy {
path: loc.path.clone(),
reason: "inside a cloud-synced folder (links break on sync)".into(),
});
continue;
}
if std::fs::File::open(&loc.path).is_err() {
plan.skipped.push(SkippedCopy {
path: loc.path.clone(),
reason: "cannot open for reading (in use?)".into(),
});
continue;
}
candidates.push((loc, snapshot));
}
if candidates.is_empty() {
continue;
}
if !blob.is_file() {
let adopt_src = model
.locations
.iter()
.map(|l| &l.path)
.filter(|p| p.is_file())
.max_by_key(|p| (device_of(p) == blobs_dev) as u8);
let Some(src) = adopt_src else {
for (loc, _) in candidates {
plan.skipped.push(SkippedCopy {
path: loc.path.clone(),
reason: "no readable copy left to adopt into the store".into(),
});
}
continue;
};
plan.adoptions.push(AdoptAction {
id: model.id.clone(),
source: src.clone(),
sha256_hex: hex.to_owned(),
});
}
for (loc, snapshot) in candidates {
if options.strategy == super::LinkStrategy::Hardlink {
if let (Some(target_dev), Some(blob_dev)) = (snapshot.device, blobs_dev) {
if target_dev != blob_dev {
plan.skipped.push(SkippedCopy {
path: loc.path.clone(),
reason: "different filesystem (hardlink impossible)".into(),
});
continue;
}
}
}
plan.reclaimable_bytes += snapshot.size_bytes;
plan.actions.push(DedupAction {
id: model.id.clone(),
target: loc.path.clone(),
ecosystem: loc.ecosystem,
snapshot,
blob: blob.clone(),
});
}
}
Ok(plan)
}
fn device_of(path: &std::path::Path) -> Option<u64> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
std::fs::metadata(path)
.or_else(|_| {
path.parent()
.map(std::fs::metadata)
.unwrap_or_else(|| std::fs::metadata(path))
})
.ok()
.map(|m| m.dev())
}
#[cfg(not(unix))]
{
let _ = path;
None
}
}