pub(crate) mod execute;
pub(crate) mod plan;
pub(crate) mod undo;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::registry::{Ecosystem, ModelId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LinkStrategy {
#[default]
Hardlink,
Symlink,
}
#[derive(Debug, Clone)]
pub struct DedupOptions {
pub strategy: LinkStrategy,
pub min_size_bytes: u64,
pub recent_grace_secs: u64,
}
impl Default for DedupOptions {
fn default() -> Self {
DedupOptions {
strategy: LinkStrategy::default(),
min_size_bytes: 100 * 1024 * 1024,
recent_grace_secs: 600,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct FileSnapshot {
pub size_bytes: u64,
pub mtime_unix_ms: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inode: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub device: Option<u64>,
}
impl FileSnapshot {
pub(crate) fn of(path: &std::path::Path) -> crate::Result<Self> {
let meta = std::fs::metadata(path).map_err(|e| crate::Error::io(path, e))?;
Ok(Self::from_metadata(&meta))
}
pub(crate) fn from_metadata(meta: &std::fs::Metadata) -> Self {
#[cfg(unix)]
let (inode, device) = {
use std::os::unix::fs::MetadataExt;
(Some(meta.ino()), Some(meta.dev()))
};
#[cfg(not(unix))]
let (inode, device) = (None, None);
FileSnapshot {
size_bytes: meta.len(),
mtime_unix_ms: meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0),
inode,
device,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DedupAction {
pub id: ModelId,
pub target: PathBuf,
pub ecosystem: Ecosystem,
pub snapshot: FileSnapshot,
pub blob: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdoptAction {
pub id: ModelId,
pub source: PathBuf,
pub sha256_hex: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkippedCopy {
pub path: PathBuf,
pub reason: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct DedupPlan {
pub strategy: LinkStrategy,
pub adoptions: Vec<AdoptAction>,
pub actions: Vec<DedupAction>,
pub skipped: Vec<SkippedCopy>,
pub reclaimable_bytes: u64,
}
impl DedupPlan {
pub fn summary(&self) -> String {
format!(
"{} replacement(s), {} adoption(s), {} skipped, ~{} reclaimable",
self.actions.len(),
self.adoptions.len(),
self.skipped.len(),
human_bytes(self.reclaimable_bytes)
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActionOutcome {
Applied,
Skipped,
Undone,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalAction {
#[serde(flatten)]
pub action: DedupAction,
pub outcome: ActionOutcome,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct JournalOp {
pub op_id: String,
pub created_at: String,
pub strategy: LinkStrategy,
pub actions: Vec<JournalAction>,
}
#[derive(Debug, Clone, Default)]
pub struct DedupReport {
pub op_id: String,
pub applied: usize,
pub skipped: Vec<SkippedCopy>,
pub reclaimed_bytes: u64,
}
#[derive(Debug, Clone, Default)]
pub struct UndoReport {
pub restored: usize,
pub skipped: Vec<SkippedCopy>,
}
pub(crate) fn human_bytes(n: u64) -> String {
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
let mut value = n as f64;
let mut unit = 0;
while value >= 1024.0 && unit < UNITS.len() - 1 {
value /= 1024.0;
unit += 1;
}
if unit == 0 {
format!("{n} B")
} else {
format!("{value:.1} {}", UNITS[unit])
}
}
pub(crate) fn in_cloud_synced_dir(path: &std::path::Path) -> bool {
path.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
matches!(
s.as_ref(),
"OneDrive" | "Dropbox" | "iCloud Drive" | "Google Drive" | "GoogleDrive"
) || s.starts_with("OneDrive -")
})
}