use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use tokio::fs;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SymlinkEntry {
pub link: PathBuf,
pub target: PathBuf,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_verified: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct SymlinkState {
#[serde(default)]
pub symlinks: Vec<SymlinkEntry>,
}
impl SymlinkState {
pub async fn load() -> Result<Self> {
let path = Self::get_state_path()?;
if !path.exists() {
log::debug!("Symlink state file does not exist, returning empty state");
return Ok(Self::default());
}
let contents = fs::read_to_string(&path)
.await
.context("Failed to read symlink state file")?;
let state: Self =
serde_yaml::from_str(&contents).context("Failed to parse symlink state file")?;
log::debug!("Loaded {} symlink entries from state", state.symlinks.len());
Ok(state)
}
pub async fn save(&self) -> Result<()> {
let path = Self::get_state_path()?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.await
.context("Failed to create .dotme directory")?;
}
let contents = serde_yaml::to_string(self).context("Failed to serialize symlink state")?;
fs::write(&path, contents)
.await
.context("Failed to write symlink state file")?;
log::debug!("Saved {} symlink entries to state", self.symlinks.len());
Ok(())
}
fn get_state_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("Failed to get home directory")?;
Ok(home.join(".dotme").join("symlinks.yml"))
}
pub fn add_entry(&mut self, link: PathBuf, target: PathBuf) {
let now = chrono::Utc::now().to_rfc3339();
if let Some(entry) = self.symlinks.iter_mut().find(|e| e.link == link) {
entry.target = target;
entry.last_verified = Some(now);
log::debug!("Updated existing symlink entry: {:?}", link);
} else {
let entry = SymlinkEntry {
link,
target,
created_at: now.clone(),
last_verified: Some(now),
};
self.symlinks.push(entry);
log::debug!("Added new symlink entry");
}
}
pub fn remove_entry(&mut self, link: &Path) -> bool {
let before = self.symlinks.len();
self.symlinks.retain(|e| e.link != link);
let removed = before != self.symlinks.len();
if removed {
log::debug!("Removed symlink entry: {:?}", link);
}
removed
}
pub fn find_entry(&self, link: &Path) -> Option<&SymlinkEntry> {
self.symlinks.iter().find(|e| e.link == link)
}
pub fn update_verified(&mut self, link: &Path) {
if let Some(entry) = self.symlinks.iter_mut().find(|e| e.link == link) {
entry.last_verified = Some(chrono::Utc::now().to_rfc3339());
}
}
pub async fn verify_all(&mut self) -> Vec<(PathBuf, Result<bool>)> {
let mut results = Vec::new();
for entry in &mut self.symlinks {
let status = Self::verify_symlink(&entry.link, &entry.target).await;
if status.is_ok() {
entry.last_verified = Some(chrono::Utc::now().to_rfc3339());
}
results.push((entry.link.clone(), status));
}
results
}
async fn verify_symlink(link: &Path, expected_target: &Path) -> Result<bool> {
if !link.exists() && link.symlink_metadata().is_err() {
return Err(anyhow::anyhow!("Symlink does not exist"));
}
let metadata = fs::symlink_metadata(link)
.await
.context("Failed to read symlink metadata")?;
if !metadata.is_symlink() {
return Err(anyhow::anyhow!("Path exists but is not a symlink"));
}
let actual_target = fs::read_link(link)
.await
.context("Failed to read symlink target")?;
let expected = normalize_path(expected_target)?;
let actual = normalize_path(&actual_target)?;
Ok(expected == actual)
}
}
pub async fn create_symlink(link: &Path, target: &Path) -> Result<()> {
log::debug!("Creating symlink: {:?} -> {:?}", link, target);
if !target.exists() {
anyhow::bail!(
"Target does not exist: {}. Cannot create symlink.",
target.display()
);
}
if link.symlink_metadata().is_ok() {
let metadata = fs::symlink_metadata(link).await?;
if metadata.is_symlink() {
let current_target = fs::read_link(link).await?;
let expected = normalize_path(target)?;
let actual = normalize_path(¤t_target)?;
if expected == actual {
log::debug!("Symlink already exists and points to correct target");
let mut state = SymlinkState::load().await?;
state.add_entry(link.to_path_buf(), target.to_path_buf());
state.save().await?;
return Ok(());
} else {
log::warn!(
"Symlink exists but points to wrong target. Current: {:?}, Expected: {:?}",
current_target,
target
);
anyhow::bail!(
"Symlink exists but points to {:?} instead of {:?}. \
Please remove it manually or use a different link path.",
current_target,
target
);
}
} else {
anyhow::bail!(
"Path exists but is not a symlink: {}. \
Please move or remove it before creating a symlink.",
link.display()
);
}
}
if let Some(parent) = link.parent() {
if !parent.exists() {
fs::create_dir_all(parent)
.await
.context("Failed to create parent directory for symlink")?;
log::debug!("Created parent directory: {:?}", parent);
}
}
#[cfg(unix)]
fs::symlink(target, link)
.await
.context("Failed to create symlink")?;
#[cfg(windows)]
{
if target.is_dir() {
fs::symlink_dir(target, link)
.await
.context("Failed to create directory symlink")?;
} else {
fs::symlink_file(target, link)
.await
.context("Failed to create file symlink")?;
}
}
log::debug!(
"✓ Created symlink: {} -> {}",
link.display(),
target.display()
);
let mut state = SymlinkState::load().await?;
state.add_entry(link.to_path_buf(), target.to_path_buf());
state.save().await?;
Ok(())
}
pub async fn remove_symlink(link: &Path) -> Result<()> {
log::debug!("Removing symlink: {:?}", link);
if link.symlink_metadata().is_ok() {
let metadata = fs::symlink_metadata(link).await?;
if !metadata.is_symlink() {
anyhow::bail!(
"Path exists but is not a symlink: {}. Will not remove.",
link.display()
);
}
fs::remove_file(link)
.await
.context("Failed to remove symlink")?;
log::debug!("✓ Removed symlink: {}", link.display());
} else {
log::warn!("Symlink does not exist: {:?}", link);
}
let mut state = SymlinkState::load().await?;
state.remove_entry(link);
state.save().await?;
Ok(())
}
pub async fn verify_symlink(link: &Path, expected_target: &Path) -> Result<bool> {
SymlinkState::verify_symlink(link, expected_target).await
}
pub async fn list_symlinks() -> Result<Vec<(SymlinkEntry, Result<bool>)>> {
let state = SymlinkState::load().await?;
let mut results = Vec::new();
for entry in &state.symlinks {
let status = SymlinkState::verify_symlink(&entry.link, &entry.target).await;
results.push((entry.clone(), status));
}
Ok(results)
}
pub async fn cleanup_broken_symlinks() -> Result<usize> {
let mut state = SymlinkState::load().await?;
let original_count = state.symlinks.len();
let mut to_remove = Vec::new();
for entry in &state.symlinks {
let status = SymlinkState::verify_symlink(&entry.link, &entry.target).await;
if status.is_err() {
to_remove.push(entry.link.clone());
}
}
for link in to_remove {
state.remove_entry(&link);
}
let removed_count = original_count - state.symlinks.len();
if removed_count > 0 {
state.save().await?;
log::info!("Cleaned up {} broken symlink entries", removed_count);
}
Ok(removed_count)
}
fn normalize_path(path: &Path) -> Result<PathBuf> {
if path.is_absolute() {
Ok(path.to_path_buf())
} else {
std::env::current_dir()
.map(|cwd| cwd.join(path))
.context("Failed to normalize path")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_symlink_state_default() {
let state = SymlinkState::default();
assert!(state.symlinks.is_empty());
}
#[test]
fn test_add_entry() {
let mut state = SymlinkState::default();
let link = PathBuf::from("/home/user/.bashrc");
let target = PathBuf::from("/home/user/dotfiles/bashrc");
state.add_entry(link.clone(), target.clone());
assert_eq!(state.symlinks.len(), 1);
assert_eq!(state.symlinks[0].link, link);
assert_eq!(state.symlinks[0].target, target);
}
#[test]
fn test_remove_entry() {
let mut state = SymlinkState::default();
let link = PathBuf::from("/home/user/.bashrc");
let target = PathBuf::from("/home/user/dotfiles/bashrc");
state.add_entry(link.clone(), target);
assert_eq!(state.symlinks.len(), 1);
let removed = state.remove_entry(&link);
assert!(removed);
assert_eq!(state.symlinks.len(), 0);
}
#[test]
fn test_find_entry() {
let mut state = SymlinkState::default();
let link = PathBuf::from("/home/user/.bashrc");
let target = PathBuf::from("/home/user/dotfiles/bashrc");
state.add_entry(link.clone(), target.clone());
let found = state.find_entry(&link);
assert!(found.is_some());
assert_eq!(found.unwrap().target, target);
let not_found = state.find_entry(Path::new("/nonexistent"));
assert!(not_found.is_none());
}
}