use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
use crate::error::{Error, Result};
pub fn walk_dir(root: &Path, include_hidden: bool) -> Result<Vec<PathBuf>> {
let mut results = Vec::new();
walk_dir_inner(root, include_hidden, &mut results)?;
results.sort();
Ok(results)
}
fn walk_dir_inner(dir: &Path, include_hidden: bool, results: &mut Vec<PathBuf>) -> Result<()> {
let entries = fs::read_dir(dir).map_err(|e| Error::io(dir, "read directory", e))?;
for entry in entries {
let entry = entry.map_err(|e| Error::io(dir, "read directory entry", e))?;
let path = entry.path();
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !include_hidden && name_str.starts_with('.') {
continue;
}
if path.is_dir() {
walk_dir_inner(&path, include_hidden, results)?;
} else {
results.push(path);
}
}
Ok(())
}
pub fn copy_file(src: &Path, dst: &Path) -> Result<()> {
if let Some(parent) = dst.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, "create directory", e))?;
}
let tmp = dst.with_extension("dotling-tmp");
let content = fs::read(src).map_err(|e| Error::io(src, "read", e))?;
let mut file = fs::File::create(&tmp).map_err(|e| Error::io(&tmp, "create temp file", e))?;
file.write_all(&content)
.map_err(|e| Error::io(&tmp, "write temp file", e))?;
file.sync_all()
.map_err(|e| Error::io(&tmp, "sync temp file", e))?;
drop(file);
fs::rename(&tmp, dst).map_err(|e| Error::io(dst, "rename temp file", e))?;
Ok(())
}
pub fn create_symlink(target: &Path, link: &Path) -> Result<()> {
if let Some(parent) = link.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, "create directory", e))?;
}
#[cfg(unix)]
{
std::os::unix::fs::symlink(target, link)
.map_err(|e| Error::io(link, "create symlink", e))?;
}
#[cfg(windows)]
{
if target.is_dir() {
std::os::windows::fs::symlink_dir(target, link)
.map_err(|e| Error::io(link, "create symlink", e))?;
} else {
std::os::windows::fs::symlink_file(target, link)
.map_err(|e| Error::io(link, "create symlink", e))?;
}
}
Ok(())
}
pub fn remove_symlink(path: &Path) -> Result<()> {
#[cfg(unix)]
{
fs::remove_file(path).map_err(|e| Error::io(path, "remove symlink", e))?;
}
#[cfg(windows)]
{
let meta = fs::symlink_metadata(path).map_err(|e| Error::io(path, "read metadata", e))?;
if meta.is_dir() {
fs::remove_dir(path).map_err(|e| Error::io(path, "remove symlink", e))?;
} else {
fs::remove_file(path).map_err(|e| Error::io(path, "remove symlink", e))?;
}
}
Ok(())
}
pub fn atomic_write(path: &Path, data: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(|e| Error::io(parent, "create directory", e))?;
}
let tmp = path.with_extension("dotling-tmp");
let mut file = fs::File::create(&tmp).map_err(|e| Error::io(&tmp, "create temp file", e))?;
file.write_all(data)
.map_err(|e| Error::io(&tmp, "write temp file", e))?;
file.sync_all()
.map_err(|e| Error::io(&tmp, "sync temp file", e))?;
drop(file);
fs::rename(&tmp, path).map_err(|e| Error::io(path, "rename temp file", e))?;
Ok(())
}
pub fn is_symlink(path: &Path) -> bool {
fs::symlink_metadata(path).is_ok_and(|m| m.file_type().is_symlink())
}
pub fn read_link(path: &Path) -> Result<PathBuf> {
fs::read_link(path).map_err(|e| Error::io(path, "read symlink target", e))
}
pub fn files_identical(a: &Path, b: &Path) -> Result<bool> {
let content_a = fs::read(a).map_err(|e| Error::io(a, "read", e))?;
let content_b = fs::read(b).map_err(|e| Error::io(b, "read", e))?;
Ok(content_a == content_b)
}
pub fn cleanup_empty_parents(path: &Path, stop_at: &Path) -> Result<()> {
let mut current = path.parent();
while let Some(dir) = current {
if dir == stop_at || dir.components().count() <= 1 {
break;
}
if fs::remove_dir(dir).is_err() {
break;
}
current = dir.parent();
}
Ok(())
}
#[cfg(unix)]
pub fn set_permissions(path: &Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
let perms = fs::Permissions::from_mode(mode);
fs::set_permissions(path, perms).map_err(|e| Error::io(path, "set permissions", e))?;
Ok(())
}
#[cfg(not(unix))]
pub fn set_permissions(_path: &Path, _mode: u32) -> Result<()> {
Ok(())
}
#[cfg(unix)]
pub fn get_permissions(path: &Path) -> Result<Option<u32>> {
use std::os::unix::fs::PermissionsExt;
let meta = fs::metadata(path).map_err(|e| Error::io(path, "read metadata", e))?;
Ok(Some(meta.permissions().mode() & 0o777))
}
#[cfg(not(unix))]
pub fn get_permissions(_path: &Path) -> Result<Option<u32>> {
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn walk_finds_files() {
let temp_dir_obj = tempfile::tempdir().unwrap();
let dir = temp_dir_obj.path();
fs::create_dir_all(dir.join("a/b")).unwrap();
fs::write(dir.join("a/file1.txt"), "hello").unwrap();
fs::write(dir.join("a/b/file2.txt"), "world").unwrap();
fs::write(dir.join("a/.hidden"), "secret").unwrap();
let files = walk_dir(dir, false).unwrap();
assert_eq!(files.len(), 2);
let files_hidden = walk_dir(dir, true).unwrap();
assert_eq!(files_hidden.len(), 3);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn atomic_write_roundtrip() {
let temp_dir_obj = tempfile::tempdir().unwrap();
let path = temp_dir_obj.path().join("dotling_test_atomic");
atomic_write(&path, b"test data").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "test data");
let _ = fs::remove_file(&path);
}
#[test]
fn copy_file_preserves_content() {
let temp = tempfile::tempdir().unwrap();
let src = temp.path().join("src.txt");
let dst = temp.path().join("dst.txt");
fs::write(&src, "hello world").unwrap();
copy_file(&src, &dst).unwrap();
assert_eq!(fs::read_to_string(&dst).unwrap(), "hello world");
}
#[test]
fn copy_file_creates_parent_dirs() {
let temp = tempfile::tempdir().unwrap();
let src = temp.path().join("src.txt");
let dst = temp.path().join("deep/nested/dir/dst.txt");
fs::write(&src, "data").unwrap();
copy_file(&src, &dst).unwrap();
assert_eq!(fs::read_to_string(&dst).unwrap(), "data");
}
#[test]
fn copy_file_overwrites_existing() {
let temp = tempfile::tempdir().unwrap();
let src = temp.path().join("src.txt");
let dst = temp.path().join("dst.txt");
fs::write(&src, "new content").unwrap();
fs::write(&dst, "old content").unwrap();
copy_file(&src, &dst).unwrap();
assert_eq!(fs::read_to_string(&dst).unwrap(), "new content");
}
#[test]
fn create_symlink_and_read_link() {
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("target");
let link = temp.path().join("link");
fs::write(&target, "data").unwrap();
create_symlink(&target, &link).unwrap();
assert!(is_symlink(&link));
assert_eq!(read_link(&link).unwrap(), target);
}
#[test]
fn create_symlink_creates_parent_dirs() {
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("target");
let link = temp.path().join("deep/nested/link");
fs::write(&target, "data").unwrap();
create_symlink(&target, &link).unwrap();
assert!(is_symlink(&link));
}
#[test]
fn remove_symlink_preserves_target() {
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("target");
let link = temp.path().join("link");
fs::write(&target, "data").unwrap();
create_symlink(&target, &link).unwrap();
remove_symlink(&link).unwrap();
assert!(!is_symlink(&link));
assert!(target.exists(), "target should be preserved");
}
#[test]
fn is_symlink_true_for_symlink() {
let temp = tempfile::tempdir().unwrap();
let target = temp.path().join("target");
let link = temp.path().join("link");
fs::write(&target, "data").unwrap();
create_symlink(&target, &link).unwrap();
assert!(is_symlink(&link));
}
#[test]
fn is_symlink_false_for_regular_file() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("file");
fs::write(&path, "data").unwrap();
assert!(!is_symlink(&path));
}
#[test]
fn is_symlink_false_for_nonexistent() {
let temp = tempfile::tempdir().unwrap();
assert!(!is_symlink(&temp.path().join("nonexistent")));
}
#[test]
fn files_identical_same_content() {
let temp = tempfile::tempdir().unwrap();
let a = temp.path().join("a.txt");
let b = temp.path().join("b.txt");
fs::write(&a, "same").unwrap();
fs::write(&b, "same").unwrap();
assert!(files_identical(&a, &b).unwrap());
}
#[test]
fn files_identical_different_content() {
let temp = tempfile::tempdir().unwrap();
let a = temp.path().join("a.txt");
let b = temp.path().join("b.txt");
fs::write(&a, "foo").unwrap();
fs::write(&b, "bar").unwrap();
assert!(!files_identical(&a, &b).unwrap());
}
#[test]
fn files_identical_nonexistent() {
let temp = tempfile::tempdir().unwrap();
let a = temp.path().join("a.txt");
let b = temp.path().join("missing.txt");
fs::write(&a, "data").unwrap();
assert!(files_identical(&a, &b).is_err());
}
#[test]
fn atomic_write_creates_parent() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("deep/nested/file.txt");
atomic_write(&path, b"data").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "data");
}
#[test]
fn atomic_write_overwrites() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("file.txt");
atomic_write(&path, b"first").unwrap();
atomic_write(&path, b"second").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "second");
}
#[cfg(unix)]
#[test]
fn set_and_get_permissions_roundtrip() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("file.txt");
fs::write(&path, "data").unwrap();
set_permissions(&path, 0o755).unwrap();
let perms = get_permissions(&path).unwrap();
assert_eq!(perms, Some(0o755));
set_permissions(&path, 0o600).unwrap();
let perms = get_permissions(&path).unwrap();
assert_eq!(perms, Some(0o600));
}
#[test]
fn walk_dir_nested_with_hidden() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::create_dir_all(dir.join("a/.hidden_dir")).unwrap();
fs::write(dir.join("a/visible.txt"), "v").unwrap();
fs::write(dir.join("a/.hidden_dir/secret.txt"), "s").unwrap();
let without_hidden = walk_dir(dir, false).unwrap();
assert_eq!(without_hidden.len(), 1);
let with_hidden = walk_dir(dir, true).unwrap();
assert_eq!(with_hidden.len(), 2);
}
#[test]
fn walk_dir_sorted_output() {
let temp = tempfile::tempdir().unwrap();
let dir = temp.path();
fs::write(dir.join("z.txt"), "").unwrap();
fs::write(dir.join("a.txt"), "").unwrap();
fs::write(dir.join("m.txt"), "").unwrap();
let files = walk_dir(dir, false).unwrap();
let names: Vec<_> = files
.iter()
.map(|p| p.file_name().unwrap().to_str().unwrap().to_string())
.collect();
assert_eq!(names, vec!["a.txt", "m.txt", "z.txt"]);
}
#[test]
fn cleanup_empty_parents_removes_chain() {
let temp = tempfile::tempdir().unwrap();
let stop = temp.path().join("repo");
let file = stop.join("a/b/c/file.txt");
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(&file, "data").unwrap();
fs::remove_file(&file).unwrap();
cleanup_empty_parents(&file, &stop).unwrap();
assert!(!stop.join("a").exists());
assert!(stop.exists());
}
#[test]
fn cleanup_empty_parents_stops_at_nonempty() {
let temp = tempfile::tempdir().unwrap();
let stop = temp.path().join("repo");
let file = stop.join("a/b/c/file.txt");
let sibling = stop.join("a/b/keep.txt");
fs::create_dir_all(file.parent().unwrap()).unwrap();
fs::write(&file, "data").unwrap();
fs::write(&sibling, "keep").unwrap();
fs::remove_file(&file).unwrap();
cleanup_empty_parents(&file, &stop).unwrap();
assert!(!file.parent().unwrap().exists());
assert!(sibling.exists());
}
}