use std::{
fs,
os::unix::fs::symlink,
path::{Path, PathBuf},
};
use crate::{
config::{LinkEntry, LinkMethod},
error::{DotlingError, Result, io_err},
repo,
};
#[derive(Debug, PartialEq, Eq)]
pub enum DeployResult {
Created,
AlreadyOk,
Skipped,
}
#[derive(Debug, PartialEq, Eq)]
pub enum EntryStatus {
Ok,
Modified,
BrokenSymlink,
Missing,
Conflict,
}
pub struct Linker {
repo_root: PathBuf,
}
impl Linker {
pub fn new(repo_root: PathBuf) -> Self {
Self { repo_root }
}
fn abs_src(&self, entry: &LinkEntry) -> PathBuf {
self.repo_root.join(&entry.src)
}
fn abs_dest(entry: &LinkEntry) -> Result<PathBuf> {
repo::src_to_dest_path(&entry.dest)
}
pub fn deploy_entry(&self, entry: &LinkEntry, force: bool) -> Result<DeployResult> {
let src = self.abs_src(entry);
let dest = Self::abs_dest(entry)?;
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(io_err(parent))?;
}
match entry.method {
LinkMethod::Symlink => deploy_symlink(&src, &dest),
LinkMethod::Copy => deploy_copy(&src, &dest, force),
}
}
pub fn undeploy_entry(&self, entry: &LinkEntry) -> Result<()> {
let src = self.abs_src(entry);
let dest = Self::abs_dest(entry)?;
match entry.method {
LinkMethod::Symlink => {
if dest.is_symlink() {
fs::remove_file(&dest).map_err(io_err(&dest))?;
}
if src.exists() {
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(io_err(parent))?;
}
fs::copy(&src, &dest).map_err(io_err(&dest))?;
}
}
LinkMethod::Copy => {
}
}
Ok(())
}
pub fn check_entry(&self, entry: &LinkEntry) -> Result<EntryStatus> {
let src = self.abs_src(entry);
let dest = Self::abs_dest(entry)?;
match entry.method {
LinkMethod::Symlink => check_symlink(&src, &dest),
LinkMethod::Copy => check_copy(&src, &dest),
}
}
}
fn deploy_symlink(src: &Path, dest: &Path) -> Result<DeployResult> {
if dest.is_symlink() {
let target = fs::read_link(dest).map_err(io_err(dest))?;
if target == src {
return Ok(DeployResult::AlreadyOk);
}
return Err(DotlingError::DestinationConflict(dest.to_path_buf()));
}
if dest.exists() {
return Err(DotlingError::DestinationConflict(dest.to_path_buf()));
}
symlink(src, dest).map_err(io_err(dest))?;
Ok(DeployResult::Created)
}
fn deploy_copy(src: &Path, dest: &Path, force: bool) -> Result<DeployResult> {
if dest.exists() && !dest.is_symlink() {
let src_content = fs::read(src).map_err(io_err(src))?;
let dest_content = fs::read(dest).map_err(io_err(dest))?;
if src_content == dest_content {
return Ok(DeployResult::AlreadyOk);
}
if !force {
return Ok(DeployResult::Skipped);
}
} else if dest.is_symlink() {
return Err(DotlingError::DestinationConflict(dest.to_path_buf()));
}
fs::copy(src, dest).map_err(io_err(dest))?;
Ok(DeployResult::Created)
}
fn check_symlink(src: &Path, dest: &Path) -> Result<EntryStatus> {
if !dest.is_symlink() {
if dest.exists() {
return Ok(EntryStatus::Conflict);
}
return Ok(EntryStatus::Missing);
}
let target = fs::read_link(dest).map_err(io_err(dest))?;
if target == src {
if src.exists() {
return Ok(EntryStatus::Ok);
}
return Ok(EntryStatus::BrokenSymlink);
}
Ok(EntryStatus::Conflict)
}
fn check_copy(src: &Path, dest: &Path) -> Result<EntryStatus> {
if !dest.exists() {
return Ok(EntryStatus::Missing);
}
if dest.is_symlink() {
return Ok(EntryStatus::Conflict);
}
let src_content = fs::read(src).map_err(io_err(src))?;
let dest_content = fs::read(dest).map_err(io_err(dest))?;
if src_content == dest_content {
Ok(EntryStatus::Ok)
} else {
Ok(EntryStatus::Modified)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::platform::Platform;
fn make_entry(src: &str, dest: &str, method: LinkMethod) -> LinkEntry {
LinkEntry {
src: src.to_string(),
dest: dest.to_string(),
method,
os: Platform::default(),
}
}
#[test]
fn deploy_symlink_creates_link() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let src_file = repo_root.join("shell/zshrc");
fs::create_dir_all(src_file.parent().unwrap()).unwrap();
fs::write(&src_file, "# zshrc").unwrap();
let dest_file = dir.path().join("home/.zshrc");
let entry = make_entry(
"shell/zshrc",
&dest_file.to_string_lossy(),
LinkMethod::Symlink,
);
let linker = Linker::new(repo_root);
let result = linker.deploy_entry(&entry, false).unwrap();
assert_eq!(result, DeployResult::Created);
assert!(dest_file.is_symlink());
}
#[test]
fn deploy_symlink_idempotent() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let src_file = repo_root.join("shell/zshrc");
fs::create_dir_all(src_file.parent().unwrap()).unwrap();
fs::write(&src_file, "# zshrc").unwrap();
let dest_file = dir.path().join("home/.zshrc");
let entry = make_entry(
"shell/zshrc",
&dest_file.to_string_lossy(),
LinkMethod::Symlink,
);
let linker = Linker::new(repo_root);
linker.deploy_entry(&entry, false).unwrap();
let result = linker.deploy_entry(&entry, false).unwrap();
assert_eq!(result, DeployResult::AlreadyOk);
}
#[test]
fn deploy_symlink_conflict_regular_file() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let src_file = repo_root.join("shell/zshrc");
fs::create_dir_all(src_file.parent().unwrap()).unwrap();
fs::write(&src_file, "# zshrc").unwrap();
let dest_file = dir.path().join("home/.zshrc");
fs::create_dir_all(dest_file.parent().unwrap()).unwrap();
fs::write(&dest_file, "existing content").unwrap();
let entry = make_entry(
"shell/zshrc",
&dest_file.to_string_lossy(),
LinkMethod::Symlink,
);
let linker = Linker::new(repo_root);
let result = linker.deploy_entry(&entry, false);
assert!(result.is_err());
}
#[test]
fn check_entry_ok() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let src_file = repo_root.join("shell/zshrc");
fs::create_dir_all(src_file.parent().unwrap()).unwrap();
fs::write(&src_file, "# zshrc").unwrap();
let dest_file = dir.path().join("home/.zshrc");
let entry = make_entry(
"shell/zshrc",
&dest_file.to_string_lossy(),
LinkMethod::Symlink,
);
let linker = Linker::new(repo_root);
linker.deploy_entry(&entry, false).unwrap();
let status = linker.check_entry(&entry).unwrap();
assert_eq!(status, EntryStatus::Ok);
}
#[test]
fn check_entry_missing() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let src_file = repo_root.join("shell/zshrc");
fs::create_dir_all(src_file.parent().unwrap()).unwrap();
fs::write(&src_file, "# zshrc").unwrap();
let dest_file = dir.path().join("home/.zshrc");
let entry = make_entry(
"shell/zshrc",
&dest_file.to_string_lossy(),
LinkMethod::Symlink,
);
let linker = Linker::new(repo_root);
let status = linker.check_entry(&entry).unwrap();
assert_eq!(status, EntryStatus::Missing);
}
#[test]
fn check_entry_broken_symlink() {
let dir = tempfile::tempdir().unwrap();
let repo_root = dir.path().join("repo");
fs::create_dir_all(&repo_root).unwrap();
let src_file = repo_root.join("shell/zshrc");
fs::create_dir_all(src_file.parent().unwrap()).unwrap();
fs::write(&src_file, "# zshrc").unwrap();
let dest_file = dir.path().join("home/.zshrc");
let entry = make_entry(
"shell/zshrc",
&dest_file.to_string_lossy(),
LinkMethod::Symlink,
);
let linker = Linker::new(repo_root.clone());
linker.deploy_entry(&entry, false).unwrap();
fs::remove_file(&src_file).unwrap();
let status = linker.check_entry(&entry).unwrap();
assert_eq!(status, EntryStatus::BrokenSymlink);
}
}