use anyhow::Result;
use serde::Serialize;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::{
check_safe_to_delete_guard, expand_tilde, LinkCommands, LinkEntry, LinkEntryStatus,
LinkStatusReport, SystemPolicy,
};
pub(crate) fn evaluate_link(entry: &LinkEntry) -> LinkEntryStatus {
let link = expand_tilde(&entry.link);
let target = expand_tilde(&entry.target);
let target_exists = target.exists();
let meta = fs::symlink_metadata(&link).ok();
let exists = meta.is_some();
let is_symlink = meta
.as_ref()
.map(|m| m.file_type().is_symlink())
.unwrap_or(false);
let mut points_to = String::new();
let mut in_sync = false;
let issue = if !target_exists {
"target_missing".to_string()
} else if !exists {
"link_missing".to_string()
} else if !is_symlink {
"path_not_symlink".to_string()
} else {
match fs::read_link(&link) {
Ok(actual) => {
let actual_abs = if actual.is_absolute() {
actual
} else {
link.parent().unwrap_or_else(|| Path::new("/")).join(actual)
};
points_to = path_display(&actual_abs);
if normalize_path(&actual_abs) == normalize_path(&target) {
in_sync = true;
"ok".to_string()
} else {
"link_target_mismatch".to_string()
}
}
Err(_) => "readlink_failed".to_string(),
}
};
LinkEntryStatus {
link: path_display(&link),
target: path_display(&target),
exists,
is_symlink,
target_exists,
points_to,
in_sync,
issue,
}
}
fn normalize_path(path: &Path) -> PathBuf {
path.canonicalize()
.unwrap_or_else(|_| lexical_normalize(path))
}
pub(crate) fn lexical_normalize(path: &Path) -> PathBuf {
use std::path::Component;
let mut out: Vec<Component> = Vec::new();
for comp in path.components() {
match comp {
Component::CurDir => {}
Component::ParentDir => {
if matches!(out.last(), Some(Component::Normal(_))) {
out.pop();
} else {
out.push(comp);
}
}
other => out.push(other),
}
}
out.iter().collect()
}
pub(crate) fn build_link_report(policy: &SystemPolicy) -> LinkStatusReport {
build_link_report_with(policy, &[])
}
pub(crate) fn build_link_report_with(
policy: &SystemPolicy,
extra: &[LinkEntry],
) -> LinkStatusReport {
let mut entries = Vec::with_capacity(policy.links.entries.len() + extra.len());
let mut healthy = 0usize;
let mut drifted = 0usize;
let mut missing_target = 0usize;
let mut missing_link = 0usize;
for entry in policy.links.entries.iter().chain(extra.iter()) {
let status = evaluate_link(entry);
match status.issue.as_str() {
"ok" => healthy += 1,
"target_missing" => {
drifted += 1;
missing_target += 1;
}
"link_missing" => {
drifted += 1;
missing_link += 1;
}
_ => drifted += 1,
}
entries.push(status);
}
LinkStatusReport {
total: entries.len(),
entries,
healthy,
drifted,
missing_target,
missing_link,
errors: Vec::new(),
}
}
pub(crate) fn backup_path_for(link: &Path) -> PathBuf {
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let name = link
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| "link".to_string());
let base = format!("{name}.dracon-system-backup-{ts}");
unique_backup_path(link.parent().unwrap_or_else(|| Path::new(".")), &base)
}
pub(crate) fn unique_backup_path(dir: &Path, base: &str) -> PathBuf {
let mut candidate = dir.join(base);
let mut n = 1u32;
while fs::symlink_metadata(&candidate).is_ok() {
candidate = dir.join(format!("{base}-{n}"));
n += 1;
}
candidate
}
pub(crate) fn apply_link_policy(
policy: &SystemPolicy,
force_replace: bool,
) -> Result<LinkStatusReport> {
let mut errors: Vec<String> = Vec::new();
for entry in &policy.links.entries {
if let Err(e) = apply_one_link(entry, force_replace, &policy.guard.protected_paths) {
eprintln!("✗ link {}: {e:#}", entry.link);
errors.push(format!("{}: {e:#}", entry.link));
}
}
let mut report = build_link_report(policy);
report.errors = errors;
Ok(report)
}
#[cfg(unix)]
fn restore_after_failed_symlink(backup: &Path, link: &Path, symlink_err: &std::io::Error) {
match fs::rename(backup, link) {
Ok(()) => eprintln!(
"link {}: symlink failed ({symlink_err}); the original file was restored",
link.display()
),
Err(rb) => eprintln!(
"link {}: symlink failed ({symlink_err}) AND restoring the backup failed ({rb}); the original file is at {}",
link.display(),
backup.display()
),
}
}
#[cfg(test)]
pub(crate) fn links_restore_after_failed_symlink_for_tests(
backup: &Path,
link: &Path,
symlink_err: &std::io::Error,
) {
restore_after_failed_symlink(backup, link, symlink_err);
}
fn apply_one_link(
entry: &LinkEntry,
force_replace: bool,
protected_paths: &[String],
) -> Result<()> {
let link = expand_tilde(&entry.link);
let target = expand_tilde(&entry.target);
if !target.exists() {
return Ok(());
}
if let Some(parent) = link.parent() {
fs::create_dir_all(parent)?;
}
let mut backup_to_restore: Option<PathBuf> = None;
let meta = fs::symlink_metadata(&link).ok();
if let Some(meta) = meta {
if meta.file_type().is_symlink() {
if evaluate_link(entry).in_sync {
return Ok(());
}
fs::remove_file(&link)?;
} else if force_replace {
let safe_link = check_safe_to_delete_guard(&link, protected_paths)?;
let backup = backup_path_for(&link);
fs::rename(&safe_link, &backup)?;
backup_to_restore = Some(backup);
} else {
return Ok(());
}
}
#[cfg(unix)]
{
if let Err(e) = std::os::unix::fs::symlink(&target, &link) {
if let Some(backup) = backup_to_restore {
restore_after_failed_symlink(&backup, &link, &e);
}
return Err(e.into());
}
}
#[cfg(not(unix))]
{
return Err(anyhow::anyhow!("link apply is only supported on unix"));
}
Ok(())
}
fn path_display(path: &Path) -> String {
if let Some(home) = dirs::home_dir() {
if let Ok(rel) = path.strip_prefix(&home) {
return format!("~/{}", rel.display());
}
}
path.display().to_string()
}
pub(crate) fn cmd_link(cmd: LinkCommands) -> Result<()> {
use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, ContentArrangement, Table};
let (_, policy) = crate::load_system_policy()?;
let auto_entries = crate::relocation_link_entries();
match cmd {
LinkCommands::Status { json } | LinkCommands::Doctor { json } => {
let report = build_link_report_with(&policy, &auto_entries);
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("STATUS"),
Cell::new("LINK"),
Cell::new("TARGET"),
Cell::new("ISSUE"),
]);
for item in &report.entries {
let (icon, color) = if item.issue == "ok" {
("\u{2705}", Color::Green)
} else if item.issue.contains("missing") || item.issue.contains("not exist") {
("\u{274c}", Color::Red)
} else {
("\u{26a0}\u{fe0f}", Color::Yellow)
};
let issue = if item.issue == "ok" {
String::new()
} else {
format!("\u{2192} {}", item.issue)
};
table.add_row(vec![
Cell::new(icon).fg(color),
Cell::new(&item.link),
Cell::new(&item.target),
Cell::new(issue),
]);
}
if report.entries.is_empty() {
println!("No configured links");
} else {
println!("{table}");
}
println!(
"{} links: {} ok, {} drifted, {} missing target, {} missing link",
report.total,
report.healthy,
report.drifted,
report.missing_target,
report.missing_link
);
}
}
LinkCommands::Apply {
json,
force_replace,
} => {
let report = apply_link_policy(&policy, force_replace)?;
if json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("Applied link policy.");
println!(
"{} links: {} ok, {} drifted",
report.total, report.healthy, report.drifted
);
}
if !report.errors.is_empty() {
if !json {
eprintln!();
eprintln!("Some entries could not be applied:");
}
for e in &report.errors {
eprintln!(" ✗ {e}");
}
anyhow::bail!(
"{} of {} link entries failed; the rest were applied",
report.errors.len(),
report.total
);
}
}
}
Ok(())
}
fn default_symlink_scan_roots() -> Vec<PathBuf> {
let home = match dirs::home_dir() {
Some(h) => h,
None => return Vec::new(),
};
let candidates = [
home.join("Dev"),
home.join(".dracon"),
home.join(".local/bin"),
home.join(".config"),
];
candidates.into_iter().filter(|p| p.exists()).collect()
}
#[derive(Debug, Serialize)]
pub(crate) struct BrokenSymlink {
pub(crate) path: String,
pub(crate) target: String,
pub(crate) target_exists: bool,
}
#[derive(Debug, Serialize)]
pub(crate) struct BrokenSymlinkReport {
pub(crate) roots_scanned: Vec<String>,
pub(crate) total_scanned: usize,
pub(crate) broken: Vec<BrokenSymlink>,
}
pub(crate) fn scan_broken_symlinks(root: &Path, max_depth: usize) -> (usize, Vec<BrokenSymlink>) {
let mut count = 0usize;
let mut broken = Vec::new();
let entries = match fs::read_dir(root) {
Ok(e) => e,
Err(_) => return (0, Vec::new()),
};
for entry in entries.flatten() {
let path = entry.path();
let meta = match fs::symlink_metadata(&path) {
Ok(m) => m,
Err(_) => continue,
};
if meta.file_type().is_symlink() {
count += 1;
let target = match fs::read_link(&path) {
Ok(t) => t,
Err(_) => continue,
};
let resolved = if target.is_absolute() {
target.clone()
} else {
path.parent()
.map(|p| p.join(&target))
.unwrap_or(target.clone())
};
let target_exists = fs::metadata(&resolved).is_ok();
if !target_exists {
broken.push(BrokenSymlink {
path: path.display().to_string(),
target: target.display().to_string(),
target_exists: false,
});
}
} else if meta.is_dir() && max_depth > 0 {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if matches!(
name,
"target"
| "node_modules"
| ".git"
| ".cache"
| ".venv"
| "dist"
| "build"
| "archives"
) {
continue;
}
let (sub_count, sub_broken) = scan_broken_symlinks(&path, max_depth - 1);
count += sub_count;
broken.extend(sub_broken);
}
}
(count, broken)
}
pub(crate) fn cmd_symlinks(roots: Vec<PathBuf>, json: bool, max_depth: usize) -> Result<()> {
use comfy_table::{presets::UTF8_FULL_CONDENSED, Cell, Color, ContentArrangement, Table};
let roots = if roots.is_empty() {
default_symlink_scan_roots()
} else {
roots
};
let mut total_scanned = 0usize;
let mut all_broken: Vec<BrokenSymlink> = Vec::new();
let mut root_strings: Vec<String> = Vec::new();
for root in &roots {
root_strings.push(path_display(root));
let (count, broken) = scan_broken_symlinks(root, max_depth);
total_scanned += count;
all_broken.extend(broken);
}
if json {
let report = BrokenSymlinkReport {
roots_scanned: root_strings,
total_scanned,
broken: all_broken,
};
println!("{}", serde_json::to_string_pretty(&report)?);
} else if all_broken.is_empty() {
println!(
"\u{2705} Scanned {} symlinks across {} root(s) — no broken links found",
total_scanned,
root_strings.len()
);
} else {
let mut table = Table::new();
table
.load_preset(UTF8_FULL_CONDENSED)
.set_content_arrangement(ContentArrangement::Dynamic)
.set_header(vec![
Cell::new("STATUS"),
Cell::new("BROKEN LINK"),
Cell::new("TARGET"),
]);
for item in &all_broken {
table.add_row(vec![
Cell::new("\u{274c}").fg(Color::Red),
Cell::new(&item.path),
Cell::new(&item.target),
]);
}
println!("{table}");
println!(
"\u{274c} Found {} broken symlink(s) out of {} scanned across {} root(s)",
all_broken.len(),
total_scanned,
root_strings.len()
);
}
Ok(())
}