use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
pub struct FileManager {
home_dir: PathBuf,
}
#[derive(Debug, Clone)]
pub struct Dotfile {
pub original_path: PathBuf,
pub relative_path: PathBuf,
pub synced: bool,
pub description: Option<String>,
pub is_common: bool,
pub is_custom: bool,
}
impl FileManager {
pub fn new() -> Result<Self> {
let home_dir = dirs::home_dir().context("Could not determine home directory")?;
Ok(Self { home_dir })
}
#[must_use]
pub fn scan_dotfiles(&self, dotfile_names: &[String]) -> Vec<Dotfile> {
let mut found = Vec::new();
for name in dotfile_names {
let path = self.home_dir.join(name);
if path.exists() {
let relative = path
.strip_prefix(&self.home_dir)
.unwrap_or(&path)
.to_path_buf();
let description = crate::dotfile_candidates::find_candidate(name)
.map(|c| c.description.to_string());
found.push(Dotfile {
original_path: path.clone(),
relative_path: relative,
synced: false,
description,
is_common: false,
is_custom: false,
});
}
}
found
}
pub fn resolve_symlink(&self, path: &Path) -> Result<PathBuf> {
debug!("Resolving symlink: {:?}", path);
let mut current = path.to_path_buf();
let mut depth = 0;
const MAX_SYMLINK_DEPTH: usize = 20;
while current.is_symlink() && depth < MAX_SYMLINK_DEPTH {
let target = fs::read_link(¤t)
.with_context(|| format!("Failed to read symlink: {current:?}"))?;
debug!("Symlink at depth {}: {:?} -> {:?}", depth, current, target);
if target.is_relative() {
if let Some(parent) = current.parent() {
current = parent.join(&target);
debug!("Resolved relative symlink: {:?}", current);
} else {
current = target;
}
} else {
current = target;
}
depth += 1;
}
if depth >= MAX_SYMLINK_DEPTH {
warn!(
"Symlink depth exceeded (max {}) for: {:?}",
MAX_SYMLINK_DEPTH, path
);
return Err(anyhow::anyhow!("Symlink depth exceeded for: {path:?}"));
}
debug!(
"Resolved symlink: {:?} -> {:?} (depth: {})",
path, current, depth
);
Ok(current)
}
#[must_use]
pub fn is_symlink(&self, path: &Path) -> bool {
if let Ok(metadata) = fs::symlink_metadata(path) {
metadata.file_type().is_symlink()
} else {
false
}
}
pub fn copy_to_repo(&self, source: &Path, dest: &Path) -> Result<()> {
info!("Starting copy operation: {:?} -> {:?}", source, dest);
if dest.exists() {
if dest.is_dir() {
info!("Removing existing directory at destination: {:?}", dest);
fs::remove_dir_all(dest)
.with_context(|| format!("Failed to remove existing directory: {dest:?}"))?;
debug!("Successfully removed existing directory: {:?}", dest);
} else {
info!("Removing existing file at destination: {:?}", dest);
fs::remove_file(dest)
.with_context(|| format!("Failed to remove existing file: {dest:?}"))?;
debug!("Successfully removed existing file: {:?}", dest);
}
}
if let Some(parent) = dest.parent() {
if !parent.exists() {
debug!("Creating parent directory: {:?}", parent);
fs::create_dir_all(parent)
.with_context(|| format!("Failed to create parent directory: {parent:?}"))?;
info!("Created parent directory: {:?}", parent);
}
}
let source_metadata = fs::metadata(source)
.with_context(|| format!("Failed to read metadata for source: {source:?}"))?;
if source_metadata.is_file() {
let file_size = source_metadata.len();
info!(
"Copying file ({} bytes): {:?} -> {:?}",
file_size, source, dest
);
let bytes_copied = fs::copy(source, dest)
.with_context(|| format!("Failed to copy file from {source:?} to {dest:?}"))?;
info!(
"Successfully copied file ({} bytes): {:?}",
bytes_copied, dest
);
debug!(
"File copy complete: source={:?}, dest={:?}, size={}",
source, dest, bytes_copied
);
} else if source_metadata.is_dir() {
info!("Copying directory recursively: {:?} -> {:?}", source, dest);
copy_dir_all(source, dest)
.with_context(|| format!("Failed to copy directory from {source:?} to {dest:?}"))?;
info!("Successfully copied directory: {:?} -> {:?}", source, dest);
} else {
warn!("Source path is neither file nor directory: {:?}", source);
return Err(anyhow::anyhow!(
"Source path is neither file nor directory: {source:?}"
));
}
Ok(())
}
#[allow(dead_code)]
#[must_use]
pub fn home_dir(&self) -> &Path {
&self.home_dir
}
}
pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
debug!("Creating destination directory: {:?}", dst);
fs::create_dir_all(dst)
.with_context(|| format!("Failed to create destination directory: {dst:?}"))?;
let mut files_copied = 0;
let mut dirs_copied = 0;
let mut symlinks_copied = 0;
let mut skipped = 0;
for entry in fs::read_dir(src).with_context(|| format!("Failed to read directory: {src:?}"))? {
let entry = entry?;
let path = entry.path();
let file_name = entry.file_name();
let dst_path = dst.join(&file_name);
let file_type = entry.file_type()?;
if file_type.is_symlink() {
match fs::read_link(&path) {
Ok(link_target) => {
debug!(
"Copying symlink: {:?} -> {:?} (target: {:?})",
path, dst_path, link_target
);
if dst_path.symlink_metadata().is_ok() {
let _ = fs::remove_file(&dst_path);
}
#[cfg(unix)]
{
if let Err(e) = std::os::unix::fs::symlink(&link_target, &dst_path) {
warn!(
"Failed to create symlink {:?} -> {:?}: {}",
dst_path, link_target, e
);
skipped += 1;
continue;
}
}
#[cfg(windows)]
{
let target_is_dir = link_target.is_dir();
let result = if target_is_dir {
std::os::windows::fs::symlink_dir(&link_target, &dst_path)
} else {
std::os::windows::fs::symlink_file(&link_target, &dst_path)
};
if let Err(e) = result {
warn!(
"Failed to create symlink {:?} -> {:?}: {}",
dst_path, link_target, e
);
skipped += 1;
continue;
}
}
symlinks_copied += 1;
}
Err(e) => {
warn!("Failed to read symlink target for {:?}: {}", path, e);
skipped += 1;
}
}
} else if file_type.is_dir() {
debug!("Copying subdirectory: {:?} -> {:?}", path, dst_path);
copy_dir_all(&path, &dst_path)?;
dirs_copied += 1;
} else {
if let Ok(metadata) = path.metadata() {
let file_size = metadata.len();
debug!(
"Copying file ({} bytes): {:?} -> {:?}",
file_size, path, dst_path
);
} else {
debug!("Copying file: {:?} -> {:?}", path, dst_path);
}
fs::copy(&path, &dst_path).with_context(|| format!("Failed to copy file: {path:?}"))?;
files_copied += 1;
}
}
debug!(
"Directory copy complete: {:?} -> {:?} ({} files, {} dirs, {} symlinks, {} skipped)",
src, dst, files_copied, dirs_copied, symlinks_copied, skipped
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
#[test]
fn test_file_manager_creation() {
let fm = FileManager::new().unwrap();
assert!(fm.home_dir().exists());
}
#[test]
fn test_scan_dotfiles() {
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path();
let fm = FileManager {
home_dir: home_dir.to_path_buf(),
};
let test_file1 = home_dir.join(".testrc");
File::create(&test_file1)
.unwrap()
.write_all(b"test")
.unwrap();
let dotfiles = fm.scan_dotfiles(&[".testrc".to_string(), ".nonexistent".to_string()]);
assert_eq!(dotfiles.len(), 1);
assert_eq!(dotfiles[0].relative_path, PathBuf::from(".testrc"));
assert_eq!(dotfiles[0].original_path, test_file1);
assert!(!dotfiles[0].synced);
}
#[test]
fn test_scan_dotfiles_with_subdirectory() {
let temp_dir = TempDir::new().unwrap();
let home_dir = temp_dir.path();
let fm = FileManager {
home_dir: home_dir.to_path_buf(),
};
let nested_dir = home_dir.join(".config").join("test");
std::fs::create_dir_all(&nested_dir).unwrap();
let dotfiles = fm.scan_dotfiles(&[".config/test".to_string()]);
assert_eq!(dotfiles.len(), 1);
assert_eq!(dotfiles[0].relative_path, PathBuf::from(".config/test"));
}
#[test]
fn test_is_symlink() {
let temp_dir = TempDir::new().unwrap();
let fm = FileManager::new().unwrap();
let real_file = temp_dir.path().join("real_file");
File::create(&real_file).unwrap();
assert!(!fm.is_symlink(&real_file));
let symlink_target = temp_dir.path().join("target");
File::create(&symlink_target).unwrap();
let symlink = temp_dir.path().join("symlink");
std::os::unix::fs::symlink(&symlink_target, &symlink).unwrap();
assert!(fm.is_symlink(&symlink));
assert!(!fm.is_symlink(&symlink_target));
}
#[test]
fn test_resolve_symlink() {
let temp_dir = TempDir::new().unwrap();
let fm = FileManager::new().unwrap();
let target = temp_dir.path().join("target");
File::create(&target)
.unwrap()
.write_all(b"content")
.unwrap();
let symlink = temp_dir.path().join("symlink");
std::os::unix::fs::symlink(&target, &symlink).unwrap();
let resolved = fm.resolve_symlink(&symlink).unwrap();
assert_eq!(resolved, target);
assert!(resolved.exists());
}
#[test]
fn test_resolve_symlink_chain() {
let temp_dir = TempDir::new().unwrap();
let fm = FileManager::new().unwrap();
let target = temp_dir.path().join("target");
File::create(&target).unwrap();
let symlink2 = temp_dir.path().join("symlink2");
std::os::unix::fs::symlink(&target, &symlink2).unwrap();
let symlink1 = temp_dir.path().join("symlink1");
std::os::unix::fs::symlink(&symlink2, &symlink1).unwrap();
let resolved = fm.resolve_symlink(&symlink1).unwrap();
assert_eq!(resolved, target);
}
#[test]
fn test_resolve_symlink_relative() {
let temp_dir = TempDir::new().unwrap();
let fm = FileManager::new().unwrap();
let target = temp_dir.path().join("target");
File::create(&target).unwrap();
let symlink = temp_dir.path().join("symlink");
std::os::unix::fs::symlink("target", &symlink).unwrap();
let resolved = fm.resolve_symlink(&symlink).unwrap();
assert!(resolved.exists());
}
#[test]
fn test_copy_to_repo_file() {
let temp_dir = TempDir::new().unwrap();
let fm = FileManager::new().unwrap();
let source = temp_dir.path().join("source.txt");
File::create(&source)
.unwrap()
.write_all(b"test content")
.unwrap();
let dest = temp_dir.path().join("dest.txt");
fm.copy_to_repo(&source, &dest).unwrap();
assert!(dest.exists());
assert!(dest.is_file());
let content = std::fs::read_to_string(&dest).unwrap();
assert_eq!(content, "test content");
}
#[test]
fn test_copy_to_repo_directory() {
let temp_dir = TempDir::new().unwrap();
let fm = FileManager::new().unwrap();
let source_dir = temp_dir.path().join("source_dir");
std::fs::create_dir_all(&source_dir).unwrap();
let file1 = source_dir.join("file1.txt");
File::create(&file1).unwrap().write_all(b"file1").unwrap();
let nested_dir = source_dir.join("nested");
std::fs::create_dir_all(&nested_dir).unwrap();
let file2 = nested_dir.join("file2.txt");
File::create(&file2).unwrap().write_all(b"file2").unwrap();
let dest_dir = temp_dir.path().join("dest_dir");
fm.copy_to_repo(&source_dir, &dest_dir).unwrap();
assert!(dest_dir.exists());
assert!(dest_dir.is_dir());
assert!(dest_dir.join("file1.txt").exists());
assert!(dest_dir.join("nested").is_dir());
assert!(dest_dir.join("nested/file2.txt").exists());
let content1 = std::fs::read_to_string(dest_dir.join("file1.txt")).unwrap();
assert_eq!(content1, "file1");
let content2 = std::fs::read_to_string(dest_dir.join("nested/file2.txt")).unwrap();
assert_eq!(content2, "file2");
}
#[test]
fn test_copy_to_repo_overwrites_existing() {
let temp_dir = TempDir::new().unwrap();
let fm = FileManager::new().unwrap();
let dest = temp_dir.path().join("dest.txt");
File::create(&dest)
.unwrap()
.write_all(b"old content")
.unwrap();
let source = temp_dir.path().join("source.txt");
File::create(&source)
.unwrap()
.write_all(b"new content")
.unwrap();
fm.copy_to_repo(&source, &dest).unwrap();
let content = std::fs::read_to_string(&dest).unwrap();
assert_eq!(content, "new content");
}
#[test]
fn test_copy_dir_all() {
let temp_dir = TempDir::new().unwrap();
let source = temp_dir.path().join("source");
std::fs::create_dir_all(&source).unwrap();
File::create(source.join("a.txt"))
.unwrap()
.write_all(b"a")
.unwrap();
File::create(source.join("b.txt"))
.unwrap()
.write_all(b"b")
.unwrap();
let nested = source.join("nested");
std::fs::create_dir_all(&nested).unwrap();
File::create(nested.join("c.txt"))
.unwrap()
.write_all(b"c")
.unwrap();
let dest = temp_dir.path().join("dest");
copy_dir_all(&source, &dest).unwrap();
assert!(dest.exists());
assert!(dest.is_dir());
assert_eq!(std::fs::read_to_string(dest.join("a.txt")).unwrap(), "a");
assert_eq!(std::fs::read_to_string(dest.join("b.txt")).unwrap(), "b");
assert_eq!(
std::fs::read_to_string(dest.join("nested/c.txt")).unwrap(),
"c"
);
}
#[test]
fn test_resolve_symlink_max_depth() {
let temp_dir = TempDir::new().unwrap();
let fm = FileManager::new().unwrap();
let mut current = temp_dir.path().join("target");
File::create(¤t).unwrap();
for i in 0..25 {
let next = temp_dir.path().join(format!("link{i}"));
std::os::unix::fs::symlink(¤t, &next).unwrap();
current = next;
}
let result = fm.resolve_symlink(¤t);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Symlink depth exceeded"));
}
#[test]
fn test_scan_dotfiles_empty_list() {
let temp_dir = TempDir::new().unwrap();
let fm = FileManager {
home_dir: temp_dir.path().to_path_buf(),
};
let dotfiles = fm.scan_dotfiles(&[]);
assert!(dotfiles.is_empty());
}
#[test]
fn test_is_symlink_nonexistent() {
let fm = FileManager::new().unwrap();
let nonexistent = PathBuf::from("/nonexistent/path/that/does/not/exist");
assert!(!fm.is_symlink(&nonexistent));
}
}