use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use crate::{
check_safe_to_delete_guard, expand_tilde, human_bytes, load_system_policy, parse_df_details,
};
#[derive(Debug, Serialize)]
pub(crate) struct RelocatePlan {
pub(crate) source: String,
pub(crate) dest: String,
pub(crate) files: u64,
pub(crate) bytes: u64,
pub(crate) dest_avail_bytes: Option<u64>,
pub(crate) fits: bool,
pub(crate) ready: bool,
pub(crate) issues: Vec<String>,
pub(crate) link_safety: String,
pub(crate) exposes_after_swap: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct RelocateReport {
pub(crate) link: String,
pub(crate) target: String,
pub(crate) files: u64,
pub(crate) bytes: u64,
pub(crate) skipped_special: u64,
pub(crate) policy_snippet: String,
}
pub(crate) fn walk_stats(root: &Path) -> (u64, u64) {
let mut files = 0u64;
let mut bytes = 0u64;
for entry in walkdir::WalkDir::new(root)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
files += 1;
bytes += entry.metadata().map(|m| m.len()).unwrap_or(0);
}
}
(files, bytes)
}
pub(crate) fn walk_stats_strict(root: &Path) -> Result<(u64, u64)> {
fn walk_err(root: &Path, e: &walkdir::Error) -> anyhow::Error {
match e.path() {
Some(p) => anyhow::anyhow!("cannot inspect {}: {}", p.display(), e),
None => anyhow::anyhow!("directory walk failed under {}: {}", root.display(), e),
}
}
let mut files = 0u64;
let mut bytes = 0u64;
for entry in walkdir::WalkDir::new(root).follow_links(false).into_iter() {
let entry = entry.map_err(|e| walk_err(root, &e))?;
if entry.file_type().is_file() {
files += 1;
bytes += entry
.metadata()
.map(|m| m.len())
.map_err(|e| anyhow::anyhow!("cannot stat {}: {}", entry.path().display(), e))?;
}
}
Ok((files, bytes))
}
pub(crate) fn copy_tree(src: &Path, dst: &Path) -> Result<u64> {
let mut skipped = 0u64;
fs::create_dir_all(dst)?;
for entry in walkdir::WalkDir::new(src)
.follow_links(false)
.min_depth(1)
.into_iter()
{
let entry = entry.map_err(|e| match e.path() {
Some(p) => anyhow::anyhow!("cannot copy {}: {}", p.display(), e),
None => anyhow::anyhow!("copy walk failed under {}: {}", src.display(), e),
})?;
let rel = entry
.path()
.strip_prefix(src)
.map_err(|e| anyhow::anyhow!("strip prefix {}: {}", entry.path().display(), e))?;
let target = dst.join(rel);
let ftype = entry.file_type();
if ftype.is_dir() {
fs::create_dir_all(&target)?;
} else if ftype.is_symlink() {
let link_target = fs::read_link(entry.path())?;
#[cfg(unix)]
std::os::unix::fs::symlink(&link_target, &target)?;
#[cfg(not(unix))]
anyhow::bail!("symlink copy is only supported on unix");
} else if ftype.is_file() {
if let Some(parent) = target.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(entry.path(), &target)?;
let mtime = entry
.metadata()
.map_err(anyhow::Error::from)
.and_then(|m| m.modified().map_err(anyhow::Error::from))
.map_err(|e| anyhow::anyhow!("cannot read mtime of {}: {e}", entry.path().display()))?;
fs::OpenOptions::new()
.write(true)
.open(&target)
.and_then(|f| f.set_modified(mtime))
.map_err(|e| anyhow::anyhow!("cannot set mtime on {}: {e}", target.display()))?;
} else {
skipped += 1;
}
}
Ok(skipped)
}
#[cfg(unix)]
fn make_symlink(dest: &Path, source: &Path) -> Result<()> {
std::os::unix::fs::symlink(dest, source)?;
Ok(())
}
#[cfg(not(unix))]
fn make_symlink(_dest: &Path, _source: &Path) -> Result<()> {
anyhow::bail!("relocate is only supported on unix");
}
pub(crate) fn fits_in_avail(avail: Option<u64>, bytes: u64) -> bool {
avail.is_some_and(|a| a >= bytes)
}
fn avail_bytes_for(path: &Path) -> Option<u64> {
let out = std::process::Command::new("df")
.args(["-P"])
.arg(path)
.output()
.ok()?;
if !out.status.success() {
return None;
}
parse_df_details(&String::from_utf8_lossy(&out.stdout)).map(|d| d.avail_bytes)
}
pub(crate) fn is_git_tracked(path: &Path) -> Result<bool> {
is_git_tracked_with(path, "git")
}
pub(crate) fn is_git_tracked_with(path: &Path, git_bin: &str) -> Result<bool> {
if let Ok(canon) = fs::canonicalize(path) {
if let Ok(top_out) = std::process::Command::new(git_bin)
.arg("-C")
.arg(path)
.args(["rev-parse", "--show-toplevel"])
.output()
{
if top_out.status.success() {
let top = String::from_utf8_lossy(&top_out.stdout).trim().to_string();
if !top.is_empty() {
let matches = fs::canonicalize(&top)
.map(|canon_top| canon_top == canon)
.unwrap_or_else(|_| Path::new(&top) == canon.as_path());
if matches {
return Ok(true);
}
}
}
}
}
let parent = path.parent().unwrap_or_else(|| Path::new("/"));
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
match std::process::Command::new(git_bin)
.arg("-C")
.arg(parent)
.args(["rev-parse", "--show-toplevel"])
.output()
{
Err(e) => {
return Err(anyhow::anyhow!(
"cannot run git rev-parse for {}: {e}",
parent.display()
));
}
Ok(o) if o.status.success() => {}
_ => return Ok(false),
}
let out = std::process::Command::new(git_bin)
.arg("-C")
.arg(parent)
.args(["ls-files", "--", &name])
.output()
.map_err(|e| anyhow::anyhow!("cannot run git ls-files for {}: {e}", path.display()))?;
if !out.status.success() {
anyhow::bail!("git ls-files failed for {}", path.display());
}
Ok(!out.stdout.iter().all(|b| b.is_ascii_whitespace()))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum LinkSafety {
NotInWorkTree,
IgnoredAsSymlink,
DirectoryOnlyIgnore { pattern: String },
ExposedAsSymlink,
}
impl LinkSafety {
pub(crate) fn exposes_after_swap(&self) -> bool {
matches!(
self,
LinkSafety::DirectoryOnlyIgnore { .. } | LinkSafety::ExposedAsSymlink
)
}
pub(crate) fn refusal_reason(&self) -> Option<String> {
match self {
LinkSafety::NotInWorkTree | LinkSafety::IgnoredAsSymlink => None,
LinkSafety::DirectoryOnlyIgnore { pattern } => Some(format!(
"ignored only by the DIRECTORY-ONLY pattern `{pattern}` — a symlink would stop \
matching it and the whole tree would become visible to git (untracked gitlink \
or every file). Add a pattern without the trailing slash (e.g. `/out`) to that \
.gitignore first, or manage the move as an explicit [[links.entries]] link"
)),
LinkSafety::ExposedAsSymlink => Some(
"not ignored by any git rule — replacing it with a symlink would expose the whole \
tree to git. Untrack it, ignore it (including a non-trailing-slash pattern), or \
keep it where it is"
.to_string(),
),
}
}
}
pub(crate) fn link_safety(path: &Path) -> Result<LinkSafety> {
link_safety_with(path, "git")
}
pub(crate) fn link_safety_with(path: &Path, git_bin: &str) -> Result<LinkSafety> {
let parent = path.parent().unwrap_or_else(|| Path::new("/"));
let probe = Command::new(git_bin)
.arg("-C")
.arg(parent)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|e| {
anyhow::anyhow!(
"cannot run git rev-parse for {}: {e}",
parent.display()
)
})?;
if !probe.status.success() {
return Ok(LinkSafety::NotInWorkTree);
}
let root_text = String::from_utf8_lossy(&probe.stdout).trim().to_string();
if root_text.is_empty() {
anyhow::bail!("git reported an empty work-tree root for {}", parent.display());
}
let check = Command::new(git_bin)
.arg("-C")
.arg(&root_text)
.args(["check-ignore", "--no-index", "-v"])
.arg(path)
.output()
.map_err(|e| {
anyhow::anyhow!("cannot run git check-ignore for {}: {e}", path.display())
})?;
match check.status.code() {
Some(0) => {}
Some(1) => return Ok(LinkSafety::ExposedAsSymlink),
_ => anyhow::bail!(
"git check-ignore failed for {}: {}",
path.display(),
String::from_utf8_lossy(&check.stderr).trim()
),
}
let line = String::from_utf8_lossy(&check.stdout).trim_end().to_string();
let Some(pattern) = ignore_pattern_of(&line) else {
anyhow::bail!(
"cannot parse the git ignore rule for {}: {line}",
path.display()
);
};
match swapped_still_ignored(git_bin, &root_text, path) {
Ok(true) => Ok(LinkSafety::IgnoredAsSymlink),
Ok(false) if pattern.ends_with('/') => Ok(LinkSafety::DirectoryOnlyIgnore { pattern }),
Ok(false) => Ok(LinkSafety::ExposedAsSymlink),
Err(e) => anyhow::bail!(
"cannot verify whether the swap keeps {} ignored: {e:#}",
path.display()
),
}
}
fn swapped_still_ignored(git_bin: &str, repo_root: &str, target: &Path) -> Result<bool> {
let name = target
.file_name()
.ok_or_else(|| anyhow::anyhow!("no file name for {}", target.display()))?;
let aside = target.with_file_name(format!(
".dracon-swap-probe-{}-{}",
name.to_string_lossy(),
std::process::id()
));
if fs::symlink_metadata(&aside).is_ok() {
anyhow::bail!(
"swap probe staging {} already exists — refusing to guess",
aside.display()
);
}
fs::rename(target, &aside)
.map_err(|e| anyhow::anyhow!("cannot stage {} for the swap probe: {e}", target.display()))?;
let probe_result = (|| -> Result<bool> {
#[cfg(unix)]
{
std::os::unix::fs::symlink(&aside, target).map_err(|e| {
anyhow::anyhow!("cannot create the probe symlink for {}: {e}", target.display())
})?;
}
#[cfg(not(unix))]
{
anyhow::bail!("the swap probe is only supported on unix");
}
let out = Command::new(git_bin)
.arg("-C")
.arg(repo_root)
.args(["check-ignore", "--no-index", "-q"])
.arg(target)
.output()
.map_err(|e| {
anyhow::anyhow!("cannot run git check-ignore for {}: {e}", target.display())
})?;
match out.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => anyhow::bail!(
"git check-ignore failed after the swap for {}: {}",
target.display(),
String::from_utf8_lossy(&out.stderr).trim()
),
}
})();
let _ = fs::remove_file(target);
let restore = fs::rename(&aside, target);
match (probe_result, restore) {
(Ok(ignored), Ok(())) => Ok(ignored),
(Ok(_), Err(e)) => anyhow::bail!(
"the tree is intact at {} but restoring {} failed: {e}",
aside.display(),
target.display()
),
(Err(e), Ok(())) => Err(e),
(Err(e), Err(restore_err)) => anyhow::bail!(
"swap probe failed ({e:#}) AND restoring {} from {} failed ({restore_err})",
target.display(),
aside.display()
),
}
}
fn ignore_pattern_of(line: &str) -> Option<String> {
let source_and_pattern = line.split('\t').next()?;
let mut parts = source_and_pattern.rsplitn(3, ':');
let pattern = parts.next()?.to_string();
if pattern.is_empty() {
return None;
}
Some(pattern)
}
pub(crate) fn plan_relocate(
source: &Path,
dest_root: &Path,
user_protected: &[String],
allow_tracked: bool,
) -> Result<RelocatePlan> {
let mut issues = Vec::new();
let meta = fs::symlink_metadata(source)
.map_err(|e| anyhow::anyhow!("cannot inspect source {}: {}", source.display(), e))?;
if meta.file_type().is_symlink() {
anyhow::bail!(
"refusing to relocate symlink {} (already relocated?)",
source.display()
);
}
if !meta.is_dir() {
anyhow::bail!("relocate supports directories only: {}", source.display());
}
let canon_src = check_safe_to_delete_guard(source, user_protected)?;
if !allow_tracked && is_git_tracked(&canon_src)? {
anyhow::bail!(
"refusing to relocate git-tracked {} — untrack it first or pass --allow-tracked",
source.display()
);
}
let safety = link_safety(&canon_src)?;
let exposes = safety.exposes_after_swap();
if let Some(reason) = safety.refusal_reason() {
issues.push(if allow_tracked {
format!("link-safety (overridden by --allow-tracked): {reason}")
} else {
format!("link-safety: {reason}")
});
}
let dest_meta = fs::symlink_metadata(dest_root)
.map_err(|e| anyhow::anyhow!("destination root {}: {}", dest_root.display(), e))?;
if !dest_meta.is_dir() || dest_meta.file_type().is_symlink() {
anyhow::bail!(
"destination root {} must be an existing real directory",
dest_root.display()
);
}
let canon_root = dest_root
.canonicalize()
.map_err(|e| anyhow::anyhow!("cannot canonicalize {}: {}", dest_root.display(), e))?;
let name = canon_src
.file_name()
.ok_or_else(|| anyhow::anyhow!("source has no file name"))?;
let dest = canon_root.join(name);
if fs::symlink_metadata(&dest).is_ok() {
anyhow::bail!("destination {} already exists — refusing", dest.display());
}
if dest.starts_with(&canon_src) || canon_src.starts_with(&dest) {
anyhow::bail!("source and destination nest — refusing");
}
let (files, bytes) = walk_stats_strict(&canon_src)?;
let avail = avail_bytes_for(&canon_root);
let fits = fits_in_avail(avail, bytes);
if !fits {
issues.push(match avail {
Some(a) => format!(
"destination has {} free but source needs {}",
human_bytes(a),
human_bytes(bytes)
),
None => "destination free space unknown (df failed) — refusing to relocate blind"
.to_string(),
});
}
Ok(RelocatePlan {
source: canon_src.display().to_string(),
dest: dest.display().to_string(),
files,
bytes,
dest_avail_bytes: avail,
fits,
ready: fits,
exposes_after_swap: exposes,
link_safety: format!("{safety:?}"),
issues,
})
}
pub(crate) const RELOCATE_STAGING_SUFFIX: &str = ".dracon-relocate-staging";
fn staging_path_for(source: &Path) -> PathBuf {
source.with_file_name(format!(
"{}{RELOCATE_STAGING_SUFFIX}",
source
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "relocated".to_string())
))
}
fn stale_staging_refusal(source: &Path, dest: &Path, staging: &Path) -> String {
match fs::symlink_metadata(source) {
Err(_) => format!(
"stale staging dir {} from an interrupted run — and {} is MISSING (crash between the staging rename and the symlink). Data is intact in staging (plus the verified copy at {}). Recover with: mv {} {} — then re-run relocate. Refusing.",
staging.display(),
source.display(),
dest.display(),
staging.display(),
source.display()
),
Ok(meta) if meta.file_type().is_symlink() => {
let target = fs::read_link(source)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "(unreadable)".to_string());
format!(
"stale staging dir {} from a previous run — but {} is already a symlink to {} (the move completed; only staging cleanup failed). Staging is a redundant duplicate: verify the symlink, then delete staging by hand: rm -rf {}. Refusing.",
staging.display(),
source.display(),
target,
staging.display()
)
}
Ok(_) => format!(
"stale staging dir {} from a previous run — while {} is still a real directory. Staging holds the pre-move tree; inspect both before clearing staging by hand (the source may have been recreated after an interrupted run). Refusing.",
staging.display(),
source.display()
),
}
}
#[cfg(test)]
pub(crate) fn post_swap_exposure(after_swap: &Path) -> Result<Option<String>> {
post_swap_exposure_with(after_swap, "git")
}
pub(crate) fn post_swap_exposure_with(
after_swap: &Path,
git_bin: &str,
) -> Result<Option<String>> {
let parent = after_swap.parent().unwrap_or_else(|| Path::new("/"));
let probe = match Command::new(git_bin)
.arg("-C")
.arg(parent)
.args(["rev-parse", "--show-toplevel"])
.output()
{
Ok(o) => o,
Err(e) => {
return Err(anyhow::anyhow!(
"cannot verify the swap for {} (git unavailable: {e})",
after_swap.display()
))
}
};
if !probe.status.success() {
return Ok(None);
}
let status = Command::new(git_bin)
.arg("-C")
.arg(parent)
.args(["--no-optional-locks", "status", "--porcelain", "--"])
.arg(after_swap)
.output()
.map_err(|e| {
anyhow::anyhow!("cannot run git status for {}: {e}", after_swap.display())
})?;
if !status.status.success() {
return Err(anyhow::anyhow!(
"git status failed for {}: {}",
after_swap.display(),
String::from_utf8_lossy(&status.stderr).trim()
));
}
let lines = String::from_utf8_lossy(&status.stdout);
match lines.lines().find(|l| !l.trim().is_empty()) {
Some(line) => Ok(Some(format!(
"git reports `{}` for the relocated path (a symlink is not hidden by a \
directory-only ignore pattern)",
line.trim()
))),
None => Ok(None),
}
}
pub(crate) fn find_stale_staging_dirs(roots: &[PathBuf]) -> Vec<PathBuf> {
let mut stale = Vec::new();
for root in roots {
let entries = match fs::read_dir(root) {
Ok(e) => e,
Err(_) => continue,
};
for entry in entries.flatten() {
if entry
.file_name()
.to_str()
.is_some_and(|n| n.ends_with(RELOCATE_STAGING_SUFFIX))
{
stale.push(entry.path());
}
}
}
stale.sort();
stale
}
pub(crate) fn apply_relocate(plan: &RelocatePlan) -> Result<RelocateReport> {
apply_relocate_with(plan, "git", &|| {})
}
fn copy_check_manifest(root: &Path) -> Result<HashMap<PathBuf, (u64, i64)>> {
let mut map = HashMap::new();
for entry in walkdir::WalkDir::new(root).follow_links(false) {
let entry = entry.map_err(|e| anyhow::anyhow!("cannot walk {}: {e}", root.display()))?;
if !entry.file_type().is_file() {
continue;
}
let meta = entry
.metadata()
.map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", entry.path().display()))?;
let secs = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs() as i64)
.unwrap_or(-1);
let rel = entry
.path()
.strip_prefix(root)
.map_err(|e| anyhow::anyhow!("cannot relativize {}: {e}", entry.path().display()))?
.to_path_buf();
map.insert(rel, (meta.len(), secs));
}
Ok(map)
}
pub(crate) fn verify_staging_matches_copy(staging: &Path, dest: &Path) -> Result<()> {
let before = copy_check_manifest(staging)?;
let after = copy_check_manifest(dest)?;
if before == after {
return Ok(());
}
let mut changed: Vec<String> = before
.iter()
.filter(|(p, v)| after.get(*p) != Some(*v))
.map(|(p, _)| p.display().to_string())
.chain(
after
.keys()
.filter(|p| !before.contains_key(*p))
.map(|p| p.display().to_string()),
)
.collect();
changed.sort();
let shown = changed.iter().take(5).cloned().collect::<Vec<_>>().join(", ");
anyhow::bail!(
"the source changed while it was being copied ({} file(s) differ: {shown})",
changed.len()
)
}
pub(crate) fn apply_relocate_with(
plan: &RelocatePlan,
git_bin: &str,
before_stage: &dyn Fn(),
) -> Result<RelocateReport> {
#[cfg(not(unix))]
{
anyhow::bail!("relocate is only supported on unix");
}
if !plan.ready {
anyhow::bail!("plan is not ready: {}", plan.issues.join("; "));
}
let source = Path::new(&plan.source);
let dest = Path::new(&plan.dest);
let staging = staging_path_for(source);
if fs::symlink_metadata(&staging).is_ok() {
anyhow::bail!("{}", stale_staging_refusal(source, dest, &staging));
}
if fs::symlink_metadata(dest).is_ok() {
anyhow::bail!(
"destination {} appeared since planning — refusing",
dest.display()
);
}
let meta = fs::symlink_metadata(source)
.map_err(|e| anyhow::anyhow!("source {} vanished or changed: {}", source.display(), e))?;
if !meta.is_dir() || meta.file_type().is_symlink() {
anyhow::bail!(
"source {} is no longer a real directory — refusing",
source.display()
);
}
let dest_parent = dest.parent().unwrap_or(dest);
let avail = avail_bytes_for(dest_parent);
if !fits_in_avail(avail, plan.bytes) {
match avail {
Some(a) => anyhow::bail!(
"destination has {} free but plan needs {} — refusing (re-plan to retry)",
human_bytes(a),
human_bytes(plan.bytes)
),
None => anyhow::bail!(
"destination free space unknown (df failed) — refusing to relocate blind"
),
}
}
let copy_outcome: Result<u64> = (|| {
let skipped_special = copy_tree(source, dest)?;
let (dest_files, dest_bytes) = walk_stats_strict(dest)?;
if dest_files != plan.files || dest_bytes != plan.bytes {
anyhow::bail!(
"copy verification failed: expected {} files / {} bytes, got {} / {} — source untouched",
plan.files,
plan.bytes,
dest_files,
dest_bytes
);
}
Ok(skipped_special)
})();
let skipped_special = match copy_outcome {
Ok(s) => s,
Err(e) => {
if let Err(rm_err) = fs::remove_dir_all(dest) {
if fs::symlink_metadata(dest).is_ok() {
eprintln!(
"relocate: copy failed ({e:#}); cleanup of partial {} also failed: {rm_err:#}",
dest.display()
);
}
}
return Err(e);
}
};
before_stage();
fs::rename(source, &staging).map_err(|e| {
anyhow::anyhow!(
"cannot stage {} aside: {} — source untouched",
source.display(),
e
)
})?;
if let Err(mismatch) = verify_staging_matches_copy(&staging, dest) {
return Err(match fs::rename(&staging, source) {
Ok(()) => {
let _ = fs::remove_dir_all(dest);
anyhow::anyhow!(
"{mismatch:#} — move ABORTED, source restored and the copy at {} removed; re-run once the tree is idle",
dest.display()
)
}
Err(restore_err) => anyhow::anyhow!(
"{mismatch:#} AND restoring the source failed ({restore_err}) — the live tree is intact at {}; recover with: mv {} {}",
staging.display(),
staging.display(),
source.display()
),
});
}
if let Err(e) = make_symlink(dest, source) {
fs::rename(&staging, source).map_err(|restore_err| {
anyhow::anyhow!(
"symlink failed ({e:#}) AND restore failed ({restore_err:#}) — data is intact at {}",
staging.display()
)
})?;
anyhow::bail!("symlink failed ({e:#}) — source restored");
}
let exposure = match post_swap_exposure_with(source, git_bin) {
Ok(found) => found,
Err(e) => Some(format!("the swap could not be verified ({e:#})")),
};
if let Some(exposure) = exposure {
let restore = fs::remove_file(source)
.map_err(|e| anyhow::anyhow!("cannot remove the exposed symlink {}: {e}", source.display()))
.and_then(|()| {
fs::rename(&staging, source).map_err(|e| {
anyhow::anyhow!(
"the tree is intact at {} but restoring {} failed: {e}",
staging.display(),
source.display()
)
})
})
.and_then(|()| fs::remove_dir_all(dest).map_err(|e| e.into()));
return Err(match restore {
Ok(()) => anyhow::anyhow!(
"{} is not confirmed hidden from git after the swap — move ROLLED BACK, source restored and the copy at {} removed: {exposure}",
source.display(),
dest.display()
),
Err(e) => anyhow::anyhow!(
"{} is not confirmed hidden from git after the swap ({exposure}) AND the rollback failed ({e:#}) — recover with: rm {} (the symlink), mv {} {}",
source.display(),
source.display(),
staging.display(),
source.display()
),
});
}
if let Err(cleanup_err) = fs::remove_dir_all(&staging) {
eprintln!(
"relocate: moved {} -> {}, but the staging copy at {} could not be removed: {cleanup_err:#}\n \
the move is complete; delete that directory by hand when convenient",
plan.source,
plan.dest,
staging.display()
);
}
let snippet = format!(
"[[links.entries]]\nlink = \"{}\"\ntarget = \"{}\"\n",
plan.source, plan.dest
);
Ok(RelocateReport {
link: plan.source.clone(),
target: plan.dest.clone(),
files: plan.files,
bytes: plan.bytes,
skipped_special,
policy_snippet: snippet,
})
}
fn display_home(path: &str) -> String {
if let Some(home) = dirs::home_dir() {
let home = home.display().to_string();
if let Some(rest) = path.strip_prefix(&home) {
return format!("~{rest}");
}
}
path.to_string()
}
pub(crate) fn cmd_relocate(
path: PathBuf,
to: String,
apply: bool,
allow_tracked: bool,
json: bool,
) -> Result<()> {
use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, ContentArrangement, Table};
let (_, policy) = load_system_policy()?;
let dest_root = expand_tilde(&to);
let plan = plan_relocate(
&path,
&dest_root,
&policy.guard.protected_paths,
allow_tracked,
)?;
if !apply {
if json {
println!("{}", serde_json::to_string_pretty(&plan)?);
} else {
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![Cell::new("FIELD"), Cell::new("VALUE")]);
table.add_row(vec![
Cell::new("Source"),
Cell::new(display_home(&plan.source)),
]);
table.add_row(vec![Cell::new("Dest"), Cell::new(display_home(&plan.dest))]);
table.add_row(vec![
Cell::new("Size"),
Cell::new(format!(
"{} in {} files",
human_bytes(plan.bytes),
plan.files
)),
]);
table.add_row(vec![
Cell::new("Dest free"),
Cell::new(
plan.dest_avail_bytes
.map(human_bytes)
.unwrap_or_else(|| "unknown".to_string()),
),
]);
println!("{table}");
for issue in &plan.issues {
println!("⚠️ {issue}");
}
if plan.ready {
println!("Dry-run: pass --apply to move and link.");
} else {
println!("❌ Plan is not ready.");
}
}
return Ok(());
}
let report = apply_relocate(&plan)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!(
"✅ Relocated {} ({}) → {} (symlink left behind)",
display_home(&report.link),
human_bytes(report.bytes),
display_home(&report.target)
);
if report.skipped_special > 0 {
println!("⚠️ skipped {} special files", report.skipped_special);
}
println!(
"Add to policy to keep the link managed:\n{}",
report.policy_snippet
);
}
Ok(())
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct ColdCandidate {
pub(crate) path: String,
pub(crate) bytes: u64,
pub(crate) files: u64,
pub(crate) age_days: u64,
}
const SCAN_MAX_DEPTH: usize = 8;
const SCAN_MAX_CANDIDATES: usize = 20;
const SCAN_SKIP_NAMES: &[&str] = &["target", "node_modules", ".git"];
fn dir_sizes_bottom_up(root: &Path) -> HashMap<PathBuf, (u64, u64, u64)> {
let mut sizes: HashMap<PathBuf, (u64, u64, u64)> = HashMap::new();
let iter = walkdir::WalkDir::new(root)
.follow_links(false)
.max_depth(SCAN_MAX_DEPTH)
.into_iter()
.filter_entry(|e| {
if e.file_type().is_symlink() {
return false;
}
if e.depth() > 0 {
if let Some(name) = e.file_name().to_str() {
if SCAN_SKIP_NAMES.contains(&name) {
return false;
}
}
}
true
})
.filter_map(|e| e.ok());
for entry in iter {
if !entry.file_type().is_file() {
continue;
}
let meta = match entry.metadata() {
Ok(m) => m,
Err(_) => continue,
};
let mtime = meta
.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
.unwrap_or(0);
for ancestor in entry.path().ancestors() {
if !ancestor.starts_with(root) {
break;
}
let slot = sizes.entry(ancestor.to_path_buf()).or_insert((0, 0, 0));
slot.0 += meta.len();
slot.1 += 1;
slot.2 = slot.2.max(mtime);
}
}
sizes
}
pub(crate) fn already_cold_under(source: &Path, cold_root: &Path) -> Result<bool> {
let name = source
.file_name()
.ok_or_else(|| anyhow::anyhow!("no file name for {}", source.display()))?;
let twin = cold_root.join(name);
if !twin.is_dir() {
return Ok(false);
}
let (src_files, src_bytes) = walk_stats_strict(source)?;
let (twin_files, twin_bytes) = walk_stats_strict(&twin)?;
if src_files != twin_files || src_bytes != twin_bytes {
return Ok(false);
}
source_manifest_matches(source, &twin)
}
fn source_manifest_matches(a: &Path, b: &Path) -> Result<bool> {
fn manifest(root: &Path) -> Result<HashMap<String, u64>> {
let mut map = HashMap::new();
for entry in walkdir::WalkDir::new(root).follow_links(false) {
let entry = entry
.map_err(|e| anyhow::anyhow!("cannot walk {}: {e}", root.display()))?;
if !entry.file_type().is_file() {
continue;
}
let rel = entry
.path()
.strip_prefix(root)
.map_err(|e| anyhow::anyhow!("cannot relativize {}: {e}", entry.path().display()))?
.to_string_lossy()
.to_string();
let len = entry
.metadata()
.map_err(|e| anyhow::anyhow!("cannot stat {}: {e}", entry.path().display()))?
.len();
map.insert(rel, len);
}
Ok(map)
}
Ok(manifest(a)? == manifest(b)?)
}
#[derive(Debug, Clone, Serialize)]
pub(crate) struct AlreadyColdTree {
pub(crate) path: String,
pub(crate) twin: String,
pub(crate) bytes: u64,
pub(crate) files: u64,
}
pub(crate) fn find_already_cold_trees(
roots: &[PathBuf],
min_bytes: u64,
cold_root: &Path,
) -> Vec<AlreadyColdTree> {
if !cold_root.is_dir() {
return Vec::new();
}
let mut found: Vec<AlreadyColdTree> = Vec::new();
for root in roots {
if !root.is_dir() {
continue;
}
let canon_root = root.canonicalize().unwrap_or_else(|_| root.clone());
for (dir, (bytes, files, _newest)) in dir_sizes_bottom_up(&canon_root) {
if dir == canon_root || bytes < min_bytes {
continue;
}
if fs::symlink_metadata(&dir).map(|m| m.file_type().is_symlink()).unwrap_or(true) {
continue;
}
if !already_cold_under(&dir, cold_root).unwrap_or(false) {
continue;
}
let twin = cold_root
.join(dir.file_name().unwrap_or_default())
.display()
.to_string();
found.push(AlreadyColdTree {
path: dir.display().to_string(),
twin,
bytes,
files,
});
}
}
found.sort_by_key(|c| Path::new(&c.path).components().count());
let mut kept: Vec<AlreadyColdTree> = Vec::new();
for item in found {
if kept
.iter()
.any(|k| Path::new(&item.path).starts_with(&k.path))
{
continue;
}
kept.push(item);
}
kept.sort_by_key(|c| std::cmp::Reverse(c.bytes));
kept.truncate(SCAN_MAX_CANDIDATES);
kept
}
#[cfg(test)]
pub(crate) fn find_cold_candidates(
roots: &[PathBuf],
min_bytes: u64,
min_age_days: u64,
) -> Vec<ColdCandidate> {
let (candidates, _) = find_cold_candidates_reporting_cold(roots, min_bytes, min_age_days, None);
candidates
}
pub(crate) fn find_cold_candidates_reporting_cold(
roots: &[PathBuf],
min_bytes: u64,
min_age_days: u64,
cold_root: Option<&Path>,
) -> (Vec<ColdCandidate>, Vec<AlreadyColdTree>) {
let already = match cold_root {
Some(cold) => find_already_cold_trees(roots, min_bytes, cold),
None => Vec::new(),
};
let candidates = find_cold_candidates_with(roots, min_bytes, min_age_days, &already);
(candidates, already)
}
pub(crate) fn find_cold_candidates_with(
roots: &[PathBuf],
min_bytes: u64,
min_age_days: u64,
already_cold: &[AlreadyColdTree],
) -> Vec<ColdCandidate> {
let now = crate::now_unix();
let mut qualified: Vec<(PathBuf, u64, u64, u64)> = Vec::new();
for root in roots {
if !root.is_dir() {
continue;
}
let canon_root = root.canonicalize().unwrap_or_else(|_| root.clone());
for (dir, (bytes, files, newest)) in dir_sizes_bottom_up(&canon_root) {
if dir == canon_root || bytes < min_bytes {
continue;
}
let age_days = now.saturating_sub(newest) / 86_400;
if age_days < min_age_days {
continue;
}
if is_git_tracked(&dir).unwrap_or(true) {
continue;
}
if already_cold
.iter()
.any(|tree| Path::new(&tree.path) == dir.as_path() || dir.starts_with(&tree.path))
{
continue;
}
if link_safety(&dir)
.map(|s| s.exposes_after_swap())
.unwrap_or(true)
{
continue;
}
qualified.push((dir, bytes, files, age_days));
}
}
qualified.sort_by_key(|(p, _, _, _)| p.components().count());
let mut kept: Vec<(PathBuf, u64, u64, u64)> = Vec::new();
for item in qualified {
if kept.iter().any(|(k, _, _, _)| item.0.starts_with(k)) {
continue;
}
kept.push(item);
}
let mut out: Vec<ColdCandidate> = kept
.into_iter()
.map(|(path, bytes, files, age_days)| ColdCandidate {
path: path.display().to_string(),
bytes,
files,
age_days,
})
.collect();
out.sort_by_key(|c| std::cmp::Reverse(c.bytes));
out.truncate(SCAN_MAX_CANDIDATES);
out
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct RelocationRecord {
pub(crate) link: String,
pub(crate) target: String,
pub(crate) moved_at_unix: u64,
pub(crate) bytes: u64,
}
pub(crate) fn relocation_state_path() -> PathBuf {
relocation_state_path_with(std::env::var_os("DRACON_SYSTEM_RELOCATION_STATE"))
}
pub(crate) fn relocation_state_path_with(override_path: Option<std::ffi::OsString>) -> PathBuf {
if let Some(path) = override_path {
if !path.is_empty() {
return PathBuf::from(path);
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("/home"))
.join(".local/state/dracon/dracon-system-relocations.json")
}
pub(crate) fn load_relocation_records() -> Vec<RelocationRecord> {
load_relocation_records_at(&relocation_state_path())
}
pub(crate) fn record_relocation(link: &str, target: &str, bytes: u64) -> Result<()> {
record_relocation_at(&relocation_state_path(), link, target, bytes)
}
pub(crate) fn record_relocation_at(
path: &Path,
link: &str,
target: &str,
bytes: u64,
) -> Result<()> {
let mut records = load_relocation_records_at(path);
records.retain(|r| r.link != link);
records.push(RelocationRecord {
link: link.to_string(),
target: target.to_string(),
moved_at_unix: crate::now_unix(),
bytes,
});
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, serde_json::to_string_pretty(&records)?)?;
Ok(())
}
pub(crate) fn load_relocation_records_at(path: &Path) -> Vec<RelocationRecord> {
fs::read_to_string(path)
.ok()
.and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_default()
}
pub(crate) fn relocation_link_entries() -> Vec<crate::LinkEntry> {
load_relocation_records()
.into_iter()
.map(|r| crate::LinkEntry {
link: r.link,
target: r.target,
})
.collect()
}