use log::{debug, info, trace, warn};
use std::collections::{BTreeMap, HashSet};
use std::fmt::Debug;
use std::fs::File;
use std::os::unix;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use std::{cmp, fs, io};
mod file_compare;
mod manifest;
mod state;
pub use manifest::*;
pub use state::*;
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum CheckError {
#[error(transparent)]
ApplyError(#[from] ApplyError),
#[error("file {0} already exists")]
FileAlreadyExistsError(PathBuf),
#[error("file {0} is read-only")]
FileReadOnlyError(PathBuf),
#[error("parent directory {0} already exists as file")]
ParentDirAlreadyExistsAsFileError(PathBuf),
#[error("parent directory {0} is readonly")]
ParentDirReadonlyError(PathBuf),
#[error("check errors found:\n- {}", .0.iter().map(ToString::to_string).collect::<Vec<_>>().join("\n- "))]
Errors(Vec<CheckError>),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ApplyDryRunError {
#[error(transparent)]
ApplyError(#[from] ApplyError),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ApplyRealError {
#[error(transparent)]
ApplyError(#[from] ApplyError),
#[error("cannot create directory {0}")]
CannotCreateDirError(PathBuf, #[source] io::Error),
#[error("cannot remove {0}")]
CannotRemoveFileError(PathBuf, #[source] io::Error),
#[error("file {0} already exists")]
FileAlreadyExistsError(PathBuf),
#[error("cannot symlink {} to {}", source_path.display(), target_path.display())]
CannotSymlinkError {
source_path: PathBuf,
target_path: PathBuf,
#[source]
source: io::Error,
},
#[error("cannot copy {} to {}", source_path.display(), target_path.display())]
CannotCopyError {
source_path: PathBuf,
target_path: PathBuf,
#[source]
source: io::Error,
},
#[error("backup file {0} already present")]
BackupPathAlreadyExistsError(PathBuf),
#[error("cannot create backup {0}")]
CannotCreateBackupError(PathBuf, #[source] io::Error),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ApplyError {
#[error("cannot inspect the parent directory of {0}")]
CannotInspectParentError(PathBuf),
#[error("cannot inspect {0}")]
CannotInspectError(PathBuf, #[source] io::Error),
#[error("cannot check equality of {0} and {1}")]
CannotCompareError(PathBuf, PathBuf, #[source] io::Error),
#[error("cannot do non-recursive copy of {0}")]
CannotCopyError(PathBuf),
}
#[repr(transparent)]
struct TempPathBuf(PathBuf);
impl TempPathBuf {
fn new(buf: PathBuf) -> Result<TempPathBuf, io::Error> {
match std::fs::remove_file(&buf) {
Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e),
_ => Ok(TempPathBuf(buf)),
}
}
}
impl Drop for TempPathBuf {
fn drop(&mut self) {
match std::fs::remove_file(&self.0) {
Ok(()) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => warn!(
"Failed to delete temporary file {}: {}",
self.0.display(),
e
),
}
}
}
impl AsRef<Path> for TempPathBuf {
fn as_ref(&self) -> &Path {
&self.0
}
}
trait Applier {
type Error: From<ApplyError>;
fn create_dir_all(&mut self, path: &Path) -> Result<(), Self::Error>;
fn remove_file(&mut self, path: &Path, reason: &str) -> Result<(), Self::Error>;
fn remove_file_skip(&mut self, path: &Path, reason: &str) -> Result<(), Self::Error>;
fn backup(&mut self, file_path: &Path, backup_path: &Path) -> Result<(), Self::Error>;
fn symlink(&mut self, source: &Path, target: &Path) -> Result<(), Self::Error>;
fn symlink_force(
&mut self,
source: &Path,
target: &Path,
reason: &str,
) -> Result<(), Self::Error>;
fn symlink_skip(
&mut self,
source: &Path,
target: &Path,
reason: &str,
) -> Result<(), Self::Error>;
fn copy(
&mut self,
source: &Path,
target: &Path,
mode: Option<&std::fs::Permissions>,
) -> Result<(), Self::Error>;
fn copy_force(
&mut self,
source: &Path,
target: &Path,
mode: Option<&std::fs::Permissions>,
reason: &str,
) -> Result<(), Self::Error>;
fn copy_skip(&mut self, source: &Path, target: &Path, reason: &str) -> Result<(), Self::Error>;
}
struct ApplyDryRun();
impl Applier for ApplyDryRun {
type Error = ApplyError;
fn create_dir_all(&mut self, path: &Path) -> Result<(), Self::Error> {
info!("mkdir -p {}", path.display());
Ok(())
}
fn remove_file(&mut self, path: &Path, reason: &str) -> Result<(), Self::Error> {
info!("rm {} # {}", path.display(), reason);
if let Some(parent_path) = path.parent() {
info!("rmdir -p {} # {}", parent_path.display(), reason);
}
Ok(())
}
fn remove_file_skip(&mut self, path: &Path, reason: &str) -> Result<(), Self::Error> {
debug!("# skip removing {}: {}", path.display(), reason);
Ok(())
}
fn backup(&mut self, file_path: &Path, backup_path: &Path) -> Result<(), Self::Error> {
info!("mv {} {}", file_path.display(), backup_path.display());
Ok(())
}
fn symlink(&mut self, source: &Path, target: &Path) -> Result<(), Self::Error> {
info!("ln -s {} {}", source.display(), target.display());
Ok(())
}
fn symlink_force(
&mut self,
source: &Path,
target: &Path,
reason: &str,
) -> Result<(), Self::Error> {
info!(
"ln -sf {} {} # {}",
source.display(),
target.display(),
reason
);
Ok(())
}
fn symlink_skip(
&mut self,
source: &Path,
target: &Path,
reason: &str,
) -> Result<(), Self::Error> {
debug!(
"# skip linking {} -> {}: {}",
source.display(),
target.display(),
reason
);
Ok(())
}
fn copy(
&mut self,
source: &Path,
target: &Path,
_mode: Option<&std::fs::Permissions>,
) -> Result<(), Self::Error> {
info!("cp {} {}", source.display(), target.display());
Ok(())
}
fn copy_force(
&mut self,
source: &Path,
target: &Path,
_mode: Option<&std::fs::Permissions>,
reason: &str,
) -> Result<(), Self::Error> {
info!(
"cp -f {} {} # {}",
source.display(),
target.display(),
reason
);
Ok(())
}
fn copy_skip(&mut self, source: &Path, target: &Path, reason: &str) -> Result<(), Self::Error> {
debug!(
"# skip copying {} -> {}: {}",
source.display(),
target.display(),
reason
);
Ok(())
}
}
struct ApplyCheck {
deleted: HashSet<PathBuf>,
problems: BTreeMap<PathBuf, CheckError>,
}
impl Applier for ApplyCheck {
type Error = ApplyError;
fn create_dir_all(&mut self, path: &Path) -> Result<(), Self::Error> {
info!("{}: checking create directory", path.display());
{
let mut cur_parent = Some(path);
while let Some(parent) = cur_parent {
if lax_exists(parent)? {
if !parent.is_dir() {
self.problems.insert(
path.to_path_buf(),
CheckError::ParentDirAlreadyExistsAsFileError(parent.to_path_buf()),
);
} else if path_readonly(parent)? {
self.problems.insert(
path.to_path_buf(),
CheckError::ParentDirReadonlyError(parent.to_path_buf()),
);
}
break;
}
cur_parent = parent.parent();
}
}
Ok(())
}
fn remove_file(&mut self, path: &Path, reason: &str) -> Result<(), Self::Error> {
info!("{}: checking delete ({})", path.display(), reason);
if lax_exists(path)? && (path_readonly(path)? || parent_path_readonly(path)?) {
self.problems.insert(
path.to_path_buf(),
CheckError::FileReadOnlyError(path.to_path_buf()),
);
}
self.deleted.insert(path.to_path_buf());
Ok(())
}
fn remove_file_skip(&mut self, path: &Path, reason: &str) -> Result<(), Self::Error> {
info!("{}: checking delete ({})", path.display(), reason);
Ok(())
}
fn backup(&mut self, file_path: &Path, _backup_path: &Path) -> Result<(), Self::Error> {
debug!("{}: assuming removed backup", file_path.display());
self.deleted.insert(file_path.to_path_buf());
Ok(())
}
fn symlink(&mut self, _source: &Path, target: &Path) -> Result<(), Self::Error> {
info!("{}: checking symlink target", target.display());
if lax_exists(target)? && !self.deleted.contains(target) {
self.problems.insert(
target.to_path_buf(),
CheckError::FileAlreadyExistsError(target.to_path_buf()),
);
}
Ok(())
}
fn symlink_force(
&mut self,
_source: &Path,
target: &Path,
reason: &str,
) -> Result<(), Self::Error> {
info!(
"{}: checking forced symlink target ({})",
target.display(),
reason
);
if lax_exists(target)? && path_readonly(target)? {
self.problems.insert(
target.to_path_buf(),
CheckError::FileReadOnlyError(target.to_path_buf()),
);
}
Ok(())
}
fn symlink_skip(
&mut self,
_source: &Path,
target: &Path,
reason: &str,
) -> Result<(), Self::Error> {
info!(
"{}: skip checking symlink target ({})",
target.display(),
reason
);
Ok(())
}
fn copy(
&mut self,
_source: &Path,
target: &Path,
_mode: Option<&std::fs::Permissions>,
) -> Result<(), Self::Error> {
info!("{}: checking copy target", target.display());
if lax_exists(target)? && !self.deleted.contains(target) {
self.problems.insert(
target.to_path_buf(),
CheckError::FileAlreadyExistsError(target.to_path_buf()),
);
}
Ok(())
}
fn copy_force(
&mut self,
_source: &Path,
target: &Path,
_mode: Option<&std::fs::Permissions>,
reason: &str,
) -> Result<(), Self::Error> {
info!(
"{}: checking forced copy target ({})",
target.display(),
reason
);
if lax_exists(target)? && path_readonly(target)? {
self.problems.insert(
target.to_path_buf(),
CheckError::FileReadOnlyError(target.to_path_buf()),
);
}
Ok(())
}
fn copy_skip(
&mut self,
_source: &Path,
target: &Path,
reason: &str,
) -> Result<(), Self::Error> {
info!(
"{}: skip checking copy target ({})",
target.display(),
reason
);
Ok(())
}
}
struct ApplyReal(FilesState);
impl Applier for ApplyReal {
type Error = ApplyRealError;
fn create_dir_all(&mut self, path: &Path) -> Result<(), ApplyRealError> {
if !lax_exists(path)? || !path.is_dir() {
debug!("{}: create directory", path.display());
std::fs::create_dir_all(path)
.map_err(|e| ApplyRealError::CannotCreateDirError(path.to_path_buf(), e))?;
}
Ok(())
}
fn remove_file(&mut self, path: &Path, reason: &str) -> Result<(), ApplyRealError> {
debug!("{}: remove file ({})", path.display(), reason);
std::fs::remove_file(path)
.map_err(|e| ApplyRealError::CannotRemoveFileError(path.to_path_buf(), e))?;
let mut cur_path = path;
while let Some(parent_path) = cur_path.parent() {
match std::fs::remove_dir(parent_path) {
Ok(()) => {
trace!(
"{}: removed parent directory {}",
path.display(),
parent_path.display()
);
cur_path = parent_path;
}
Err(_) => break,
}
}
Ok(())
}
fn remove_file_skip(&mut self, path: &Path, reason: &str) -> Result<(), ApplyRealError> {
warn!("{}: skip remove ({})", path.display(), reason);
Ok(())
}
fn backup(&mut self, file_path: &Path, backup_path: &Path) -> Result<(), ApplyRealError> {
info!(
"{}: backup to {}",
file_path.display(),
backup_path.display()
);
match renamore::rename_exclusive(file_path, backup_path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => Err(
ApplyRealError::BackupPathAlreadyExistsError(backup_path.to_path_buf()),
),
Err(e) => Err(ApplyRealError::CannotCreateBackupError(
backup_path.to_path_buf(),
e,
)),
}
}
fn symlink(&mut self, source: &Path, target: &Path) -> Result<(), ApplyRealError> {
let mk_err = |e| ApplyRealError::CannotSymlinkError {
source_path: source.to_path_buf(),
target_path: target.to_path_buf(),
source: e,
};
let via = create_via_path(target).map_err(mk_err)?;
unix::fs::symlink(source, &via).map_err(mk_err)?;
let modified = path_modified_time(&via.0)?;
match renamore::rename_exclusive(via, target) {
Ok(()) => {
debug!("{}: symlink from {}", target.display(), source.display());
self.0.files.insert(
target.to_path_buf(),
FileState {
modified,
source: source.to_path_buf(),
},
);
Ok(())
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
Err(ApplyRealError::FileAlreadyExistsError(target.to_path_buf()))
}
Err(e) => Err(mk_err(e)),
}
}
fn symlink_force(
&mut self,
source: &Path,
target: &Path,
reason: &str,
) -> Result<(), ApplyRealError> {
let mk_err = |e| ApplyRealError::CannotSymlinkError {
source_path: source.to_path_buf(),
target_path: target.to_path_buf(),
source: e,
};
debug!(
"{}: symlink from {} ({})",
target.display(),
source.display(),
reason
);
let via = create_via_path(target).map_err(mk_err)?;
unix::fs::symlink(source, &via).map_err(mk_err)?;
let modified = path_modified_time(&via.0)?;
std::fs::rename(&via, target).map_err(mk_err)?;
self.0.files.insert(
target.to_path_buf(),
FileState {
modified,
source: source.to_path_buf(),
},
);
Ok(())
}
fn symlink_skip(
&mut self,
source: &Path,
target: &Path,
reason: &str,
) -> Result<(), ApplyRealError> {
debug!("{}: ignored ({})", target.display(), reason);
let modified = path_modified_time(target)?;
self.0.files.insert(
target.to_path_buf(),
FileState {
modified,
source: source.to_path_buf(),
},
);
Ok(())
}
fn copy(
&mut self,
source: &Path,
target: &Path,
mode: Option<&std::fs::Permissions>,
) -> Result<(), ApplyRealError> {
let mk_err = |e| ApplyRealError::CannotCopyError {
source_path: source.to_path_buf(),
target_path: target.to_path_buf(),
source: e,
};
let source_metadata = source.metadata().map_err(mk_err)?;
let via = create_via_path(target).map_err(mk_err)?;
fs::copy(source, &via).map_err(mk_err)?;
let modified = source_metadata.modified().map_err(mk_err)?;
{
let via_file = File::open(&via).map_err(mk_err)?;
via_file.set_modified(modified).map_err(mk_err)?;
if let Some(mode) = mode {
via_file.set_permissions(mode.clone()).map_err(mk_err)?;
}
}
match renamore::rename_exclusive(via, target) {
Ok(()) => {
debug!("{}: copy from {}", target.display(), source.display());
self.0.files.insert(
target.to_path_buf(),
FileState {
modified,
source: source.to_path_buf(),
},
);
Ok(())
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
Err(ApplyRealError::FileAlreadyExistsError(target.to_path_buf()))
}
Err(e) => Err(mk_err(e)),
}
}
fn copy_force(
&mut self,
source: &Path,
target: &Path,
mode: Option<&std::fs::Permissions>,
reason: &str,
) -> Result<(), ApplyRealError> {
let mk_err = |e| ApplyRealError::CannotCopyError {
source_path: source.to_path_buf(),
target_path: target.to_path_buf(),
source: e,
};
debug!(
"{}: copy from {} ({})",
target.display(),
source.display(),
reason
);
let source_metadata = source.metadata().map_err(mk_err)?;
let via = create_via_path(target).map_err(mk_err)?;
fs::copy(source, &via).map_err(mk_err)?;
let modified = source_metadata.modified().map_err(mk_err)?;
{
let via_file = File::options().write(true).open(&via).map_err(mk_err)?;
via_file.set_modified(modified).map_err(mk_err)?;
if let Some(mode) = mode {
via_file.set_permissions(mode.clone()).map_err(mk_err)?;
}
}
std::fs::rename(&via, target).map_err(mk_err)?;
self.0.files.insert(
target.to_path_buf(),
FileState {
modified,
source: source.to_path_buf(),
},
);
Ok(())
}
fn copy_skip(
&mut self,
source: &Path,
target: &Path,
reason: &str,
) -> Result<(), ApplyRealError> {
let mk_err = |e| ApplyRealError::CannotCopyError {
source_path: source.to_path_buf(),
target_path: target.to_path_buf(),
source: e,
};
debug!("{}: ignored ({})", target.display(), reason);
let modified = source
.metadata()
.map_err(mk_err)?
.modified()
.map_err(mk_err)?;
self.0.files.insert(
target.to_path_buf(),
FileState {
modified,
source: source.to_path_buf(),
},
);
Ok(())
}
}
fn create_via_path(target: &Path) -> io::Result<TempPathBuf> {
const VIA_NAME_SUFFIX: &str = ".pttrRMPqk5gp";
let target_name = target.file_name().expect("no target file name");
let mut via_name = target_name.to_os_string();
via_name.push(VIA_NAME_SUFFIX);
TempPathBuf::new(target.with_file_name(via_name))
}
pub fn check(state: &FilesState, manifest: &ManifestLow) -> Result<(), CheckError> {
let mut applier = ApplyCheck {
deleted: HashSet::new(),
problems: BTreeMap::new(),
};
apply_helper(&mut applier, state, manifest)?;
let mut values = applier.problems.into_values();
match values.len() {
0 => Ok(()),
1 => Err(values.next().expect("should contain exactly one element")),
_ => Err(CheckError::Errors(values.collect())),
}
}
pub fn apply(state: &FilesState, manifest: &ManifestLow) -> Result<FilesState, ApplyRealError> {
let mut apply_state = ApplyReal(FilesState {
files: BTreeMap::new(),
});
apply_helper(&mut apply_state, state, manifest)?;
Ok(apply_state.0)
}
pub fn apply_dry_run(state: &FilesState, manifest: &ManifestLow) -> Result<(), ApplyDryRunError> {
apply_helper(&mut ApplyDryRun(), state, manifest)?;
Ok(())
}
fn apply_helper<A: Applier>(
applier: &mut A,
state: &FilesState,
manifest: &ManifestLow,
) -> Result<(), A::Error> {
let manifest_targets: HashSet<&Path> =
manifest.files.iter().map(|f| f.target.as_path()).collect();
for (target, state) in state
.files
.iter()
.filter(|(target, _)| !manifest_targets.contains(target.as_path()))
{
delete_file(applier, state, target)?;
}
let mut files: Vec<&FileManifestLow> = manifest.files.iter().collect();
files.sort_by_key(|m| cmp::Reverse(m.target.components().count()));
for hm_file in files {
apply_file(applier, state.files.get(&hm_file.target), hm_file)?;
}
Ok(())
}
fn delete_file<A: Applier>(
applier: &mut A,
state: &FileState,
target: &Path,
) -> Result<(), A::Error> {
if !lax_exists(target)? {
applier.remove_file_skip(target, "already gone")
} else if target.is_symlink() && symlink_target(target)? == state.source.as_path() {
applier.remove_file(target, "managed")
} else {
applier.remove_file_skip(target, "unmanaged")
}
}
fn lax_exists(path: &Path) -> Result<bool, ApplyError> {
match std::fs::symlink_metadata(path) {
Ok(_) => Ok(true),
Err(e) if e.kind() == io::ErrorKind::NotADirectory => Ok(false),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(false),
Err(e) => Err(ApplyError::CannotInspectError(path.to_path_buf(), e)),
}
}
fn path_readonly(path: &Path) -> Result<bool, ApplyError> {
path.symlink_metadata()
.map(|m| m.permissions().readonly())
.map_err(|e| ApplyError::CannotInspectError(path.to_path_buf(), e))
}
fn parent_path_readonly(path: &Path) -> Result<bool, ApplyError> {
let parent = path
.parent()
.ok_or_else(|| ApplyError::CannotInspectParentError(path.to_path_buf()))?;
path_readonly(parent)
}
fn path_modified_time(path: &Path) -> Result<SystemTime, ApplyError> {
let cannot_inspect = |e| ApplyError::CannotInspectError(path.to_path_buf(), e);
path.symlink_metadata()
.map_err(cannot_inspect)?
.modified()
.map_err(cannot_inspect)
}
fn symlink_target(path: &Path) -> Result<PathBuf, ApplyError> {
path.read_link()
.map_err(|e| ApplyError::CannotInspectError(path.to_path_buf(), e))
}
fn file_eq(path1: &Path, path2: &Path) -> Result<bool, ApplyError> {
file_compare::file_eq(path1, path2)
.map_err(|e| ApplyError::CannotCompareError(path1.to_path_buf(), path2.to_path_buf(), e))
}
fn check_path_is_copiable(path: &Path) -> Result<(), ApplyError> {
if path.is_file() || path.is_symlink() {
Ok(())
} else {
Err(ApplyError::CannotCopyError(path.to_path_buf()))
}
}
fn apply_file<A: Applier>(
applier: &mut A,
state: Option<&FileState>,
file: &FileManifestLow,
) -> Result<(), A::Error> {
let target = &file.target;
let managed = state.is_some();
if let Some(parent) = target.parent() {
applier.create_dir_all(parent)?;
}
if !managed {
if let Collision::Backup { backup_path } = &file.collision {
if lax_exists(target)? {
applier.backup(target, backup_path)?;
}
}
}
match &file.action {
FileActionLow::Symlink => apply_file_symlink(applier, state, file),
FileActionLow::Copy { mode } => apply_file_copy(applier, state, file, mode.as_ref()),
}
}
fn apply_file_symlink<A: Applier>(
applier: &mut A,
state: Option<&FileState>,
file: &FileManifestLow,
) -> Result<(), A::Error> {
let source = &file.source;
let target = &file.target;
if target.is_symlink() && symlink_target(target)? == *source {
applier.symlink_skip(source, target, "managed and unchanged")
} else {
let apply_standard = |applier: &mut A| match file.collision {
Collision::Force => applier.symlink_force(source, target, "forced overwrite"),
Collision::Abort if lax_exists(target)? && file_eq(source, target)? => {
applier.symlink_force(source, target, "overwrite due to same content")
}
_ => applier.symlink(source, target),
};
if let Some(state) = state {
if target.is_symlink() && symlink_target(target)? == state.source {
applier.symlink_force(source, target, "overwrite due to being managed")
} else {
apply_standard(applier)
}
} else {
apply_standard(applier)
}
}
}
fn apply_file_copy<A: Applier>(
applier: &mut A,
state: Option<&FileState>,
file: &FileManifestLow,
mode: Option<&std::fs::Permissions>,
) -> Result<(), <A as Applier>::Error> {
let source = &file.source;
let target = &file.target;
check_path_is_copiable(source)?;
let apply_standard = |applier: &mut A| match file.collision {
Collision::Force => applier.copy_force(source, target, mode, "forced overwrite"),
Collision::Abort if lax_exists(target)? && file_eq(source, target)? => {
applier.copy_force(source, target, mode, "overwrite due to same content")
}
_ => applier.copy(source, target, mode),
};
if let Some(state) = state {
if target.is_file() && path_modified_time(target)? == state.modified {
if path_modified_time(source)? == state.modified {
applier.copy_skip(source, target, "managed and unchanged")
} else {
applier.copy_force(source, target, mode, "overwrite due to being managed")
}
} else {
apply_standard(applier)
}
} else {
apply_standard(applier)
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::{
fixture::ChildPath,
prelude::{FileWriteStr, PathAssert, PathChild, PathCreateDir, SymlinkToFile},
TempDir,
};
use log::Level;
use predicates::prelude::predicate;
use std::os::unix::fs::PermissionsExt;
use std::time::SystemTime;
#[allow(unused)]
fn init_logger() -> anyhow::Result<()> {
env_logger::builder()
.is_test(true)
.filter_level(log::LevelFilter::Trace)
.try_init()?;
Ok(())
}
fn matches_log(expected: &[String]) {
testing_logger::validate(|captured_logs| {
let checked_logs: Vec<_> = captured_logs
.iter()
.filter(|l| l.level <= Level::Debug)
.collect();
let actual = checked_logs
.iter()
.map(|l| format!("[{}] {}", l.level, l.body))
.collect::<Vec<_>>();
assert_eq!(actual, expected);
assert_eq!(checked_logs.len(), expected.len());
})
}
mod symlink_no_collision_empty_state {
use super::*;
struct Scenario {
source_dir: TempDir,
target_dir: TempDir,
source_file: ChildPath,
target_file: ChildPath,
manifest: ManifestLow,
}
impl Scenario {
fn init() -> anyhow::Result<Scenario> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file = source_dir.child("s_file");
let target_file = target_dir.child("t_file");
source_file.write_str("content")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file.to_path_buf(),
target: target_file.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
Ok(Scenario {
source_dir,
target_dir,
source_file,
target_file,
manifest,
})
}
fn close(self) -> anyhow::Result<()> {
self.source_dir.close()?;
self.target_dir.close()?;
Ok(())
}
}
#[test]
fn check_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
check(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: checking symlink target",
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::missing());
scenario.close()
}
#[test]
fn apply_dry_run_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
apply_dry_run(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[INFO] ln -s {} {}",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::missing());
scenario.close()
}
#[test]
fn apply_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
apply(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: symlink from {}",
scenario.target_file.display(),
scenario.source_file.display()
)]);
scenario
.target_file
.assert(predicate::path::is_symlink())
.assert("content");
scenario.close()
}
}
mod symlink_collision_matching_state {
use super::*;
struct Scenario {
source_dir: TempDir,
target_dir: TempDir,
source_file: ChildPath,
target_file: ChildPath,
manifest: ManifestLow,
state: FilesState,
}
impl Scenario {
fn init() -> anyhow::Result<Scenario> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file = source_dir.child("s_file");
let target_file = target_dir.child("t_file");
source_file.write_str("content")?;
target_file.symlink_to_file(&source_file)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file.to_path_buf(),
target: target_file.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file.to_path_buf(),
FileState {
modified: target_file.symlink_metadata()?.modified()?,
source: source_file.to_path_buf(),
},
)]),
};
Ok(Scenario {
source_dir,
target_dir,
source_file,
target_file,
manifest,
state,
})
}
fn close(self) -> anyhow::Result<()> {
self.source_dir.close()?;
self.target_dir.close()?;
Ok(())
}
}
#[test]
fn check_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
check(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: skip checking symlink target (managed and unchanged)",
scenario.target_file.display()
),
]);
scenario
.target_file
.assert(predicate::path::is_symlink())
.assert("content");
scenario.close()
}
#[test]
fn apply_dry_run_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
apply_dry_run(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[DEBUG] # skip linking {} -> {}: managed and unchanged",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario
.target_file
.assert(predicate::path::is_symlink())
.assert("content");
scenario.close()
}
#[test]
fn apply_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
let state = apply(&scenario.state, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: ignored (managed and unchanged)",
scenario.target_file.display()
)]);
scenario
.target_file
.assert(predicate::path::is_symlink())
.assert("content");
assert_eq!(state, scenario.state);
scenario.close()
}
}
mod symlink_collision {
use super::*;
struct Scenario {
source_dir: TempDir,
target_dir: TempDir,
source_file: ChildPath,
target_file: ChildPath,
manifest: ManifestLow,
}
impl Scenario {
fn init(collision: Collision) -> anyhow::Result<Scenario> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file = source_dir.child("s_file");
let target_file = target_dir.child("t_file");
source_file.write_str("content")?;
target_file.write_str("existing")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file.to_path_buf(),
target: target_file.to_path_buf(),
action: FileActionLow::Symlink,
collision,
}],
};
Ok(Scenario {
source_dir,
target_dir,
source_file,
target_file,
manifest,
})
}
fn close(self) -> anyhow::Result<()> {
self.source_dir.close()?;
self.target_dir.close()?;
Ok(())
}
}
#[test]
fn check_collision_abort() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Abort)?;
let result = check(&FilesState::EMPTY, &scenario.manifest);
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: checking symlink target",
scenario.target_file.display()
),
]);
assert!(matches!(result, Err(CheckError::FileAlreadyExistsError(_))));
scenario.target_file.assert("existing");
scenario.close()
}
#[test]
fn apply_dry_run_collision_abort() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Abort)?;
apply_dry_run(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[INFO] ln -s {} {}",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario.target_file.assert("existing");
scenario.close()
}
#[test]
fn apply_collision_abort() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Abort)?;
let result = apply(&FilesState::EMPTY, &scenario.manifest);
matches_log(&[]);
assert!(matches!(
result,
Err(ApplyRealError::FileAlreadyExistsError(_))
));
scenario
.target_file
.assert(predicate::path::is_file())
.assert("existing");
scenario
.target_dir
.child("t_file.pttrRMPqk5gp")
.assert(predicate::path::missing());
scenario.close()
}
#[test]
fn check_collision_backup() -> anyhow::Result<()> {
testing_logger::setup();
let backup_dir = TempDir::new()?;
let backup_file = backup_dir.child("file.backup");
let scenario = Scenario::init(Collision::Backup {
backup_path: backup_file.to_path_buf(),
})?;
check(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[DEBUG] {}: assuming removed backup",
scenario.target_file.display()
),
format!(
"[INFO] {}: checking symlink target",
scenario.target_file.display()
),
]);
scenario.target_file.assert("existing");
backup_file.assert(predicate::path::missing());
backup_dir.close()?;
scenario.close()
}
#[test]
fn apply_dry_run_collision_backup() -> anyhow::Result<()> {
testing_logger::setup();
let backup_dir = TempDir::new()?;
let backup_file = backup_dir.child("file.backup");
let scenario = Scenario::init(Collision::Backup {
backup_path: backup_file.to_path_buf(),
})?;
apply_dry_run(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[INFO] mv {} {}",
scenario.target_file.display(),
backup_file.display()
),
format!(
"[INFO] ln -s {} {}",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario.target_file.assert("existing");
backup_file.assert(predicate::path::missing());
backup_dir.close()?;
scenario.close()
}
#[test]
fn apply_collision_backup() -> anyhow::Result<()> {
testing_logger::setup();
let backup_dir = TempDir::new()?;
let backup_file = backup_dir.child("file.backup");
let scenario = Scenario::init(Collision::Backup {
backup_path: backup_file.to_path_buf(),
})?;
apply(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: backup to {}",
scenario.target_file.display(),
backup_file.display()
),
format!(
"[DEBUG] {}: symlink from {}",
scenario.target_file.display(),
scenario.source_file.display()
),
]);
scenario
.target_file
.assert(predicate::path::is_symlink())
.assert("content");
scenario
.target_dir
.child("t_file.pttrRMPqk5gp")
.assert(predicate::path::missing());
backup_file
.assert(predicate::path::is_file())
.assert("existing");
backup_dir.close()?;
scenario.close()
}
#[test]
fn check_collision_force() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Force)?;
check(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: checking forced symlink target (forced overwrite)",
scenario.target_file.display()
),
]);
scenario.target_file.assert("existing");
scenario.close()
}
#[test]
fn apply_dry_run_collision_force() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Force)?;
apply_dry_run(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[INFO] ln -sf {} {} # forced overwrite",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario.target_file.assert("existing");
scenario.close()
}
#[test]
fn apply_collision_force() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Force)?;
apply(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: symlink from {} (forced overwrite)",
scenario.target_file.display(),
scenario.source_file.display()
)]);
scenario
.target_file
.assert(predicate::path::is_symlink())
.assert("content");
scenario
.target_dir
.child("t_file.pttrRMPqk5gp")
.assert(predicate::path::missing());
scenario.close()
}
}
mod symlink_missing_managed_source {
use super::*;
struct Scenario {
source_dir: TempDir,
target_dir: TempDir,
source_file: ChildPath,
target_file: ChildPath,
manifest: ManifestLow,
state: FilesState,
}
impl Scenario {
fn init(collision: Collision) -> anyhow::Result<Scenario> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file = source_dir.child("s_file");
let target_file = target_dir.child("t_file");
target_file.symlink_to_file(&source_file)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file.to_path_buf(),
target: target_file.to_path_buf(),
action: FileActionLow::Symlink,
collision,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file.to_path_buf(),
FileState {
modified: path_modified_time(target_file.path())?,
source: source_file.to_path_buf(),
},
)]),
};
Ok(Scenario {
source_dir,
target_dir,
source_file,
target_file,
manifest,
state,
})
}
fn close(self) -> anyhow::Result<()> {
self.source_dir.close()?;
self.target_dir.close()?;
Ok(())
}
}
#[test]
fn check_collision_abort() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Abort)?;
check(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: skip checking symlink target (managed and unchanged)",
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::missing());
scenario.close()
}
#[test]
fn apply_dry_run_collision_abort() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Abort)?;
apply_dry_run(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[DEBUG] # skip linking {} -> {}: managed and unchanged",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::missing());
scenario.close()
}
#[test]
fn apply_collision_abort() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Abort)?;
let state = apply(&scenario.state, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: ignored (managed and unchanged)",
scenario.target_file.display()
)]);
assert_eq!(scenario.state, state);
scenario.target_file.assert(predicate::path::is_symlink());
scenario
.target_dir
.child("t_file.pttrRMPqk5gp")
.assert(predicate::path::missing());
scenario.close()
}
#[test]
fn check_collision_backup() -> anyhow::Result<()> {
testing_logger::setup();
let backup_dir = TempDir::new()?;
let backup_file = backup_dir.child("file.backup");
let scenario = Scenario::init(Collision::Backup {
backup_path: backup_file.to_path_buf(),
})?;
check(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: skip checking symlink target (managed and unchanged)",
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::missing());
backup_file.assert(predicate::path::missing());
backup_dir.close()?;
scenario.close()
}
#[test]
fn apply_dry_run_collision_backup() -> anyhow::Result<()> {
testing_logger::setup();
let backup_dir = TempDir::new()?;
let backup_file = backup_dir.child("file.backup");
let scenario = Scenario::init(Collision::Backup {
backup_path: backup_file.to_path_buf(),
})?;
apply_dry_run(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[DEBUG] # skip linking {} -> {}: managed and unchanged",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::missing());
backup_file.assert(predicate::path::missing());
backup_dir.close()?;
scenario.close()
}
#[test]
fn apply_collision_backup() -> anyhow::Result<()> {
testing_logger::setup();
let backup_dir = TempDir::new()?;
let backup_file = backup_dir.child("file.backup");
let scenario = Scenario::init(Collision::Backup {
backup_path: backup_file.to_path_buf(),
})?;
apply(&scenario.state, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: ignored (managed and unchanged)",
scenario.target_file.display()
)]);
scenario.target_file.assert(predicate::path::is_symlink());
scenario
.target_dir
.child("t_file.pttrRMPqk5gp")
.assert(predicate::path::missing());
backup_file.assert(predicate::path::missing());
backup_dir.close()?;
scenario.close()
}
#[test]
fn check_collision_force() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Force)?;
check(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: skip checking symlink target (managed and unchanged)",
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::is_symlink());
scenario.close()
}
#[test]
fn apply_dry_run_collision_force() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Force)?;
apply_dry_run(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[DEBUG] # skip linking {} -> {}: managed and unchanged",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::is_symlink());
scenario.close()
}
#[test]
fn apply_collision_force() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init(Collision::Force)?;
apply(&scenario.state, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: ignored (managed and unchanged)",
scenario.target_file.display()
)]);
scenario.target_file.assert(predicate::path::is_symlink());
scenario
.target_dir
.child("t_file.pttrRMPqk5gp")
.assert(predicate::path::missing());
scenario.close()
}
}
mod symlink_delete_managed_file {
use super::*;
struct Scenario {
source_dir: TempDir,
target_dir: TempDir,
target_subdir: ChildPath,
target_file: ChildPath,
manifest: ManifestLow,
state: FilesState,
}
impl Scenario {
fn init() -> anyhow::Result<Scenario> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file = source_dir.child("s_file");
let target_subdir = target_dir.child("subdir");
let target_file = target_subdir.child("t_file");
source_file.write_str("content")?;
target_subdir.create_dir_all()?;
target_file.symlink_to_file(&source_file)?;
let target_stopper = target_dir.child("stop");
target_stopper.write_str("stop")?;
let manifest = Manifest { files: vec![] };
let state = FilesState {
files: BTreeMap::from([(
target_file.to_path_buf(),
FileState {
modified: target_file.symlink_metadata()?.modified()?,
source: source_file.to_path_buf(),
},
)]),
};
Ok(Scenario {
source_dir,
target_dir,
target_subdir,
target_file,
manifest,
state,
})
}
fn close(self) -> anyhow::Result<()> {
self.source_dir.close()?;
self.target_dir.close()?;
Ok(())
}
}
#[test]
fn check_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
check(&scenario.state, &scenario.manifest)?;
matches_log(&[format!(
"[INFO] {}: checking delete (managed)",
scenario.target_file.display()
)]);
scenario
.target_file
.assert(predicate::path::is_symlink())
.assert("content");
scenario.close()
}
#[test]
fn apply_dry_run_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
apply_dry_run(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!("[INFO] rm {} # managed", scenario.target_file.display()),
format!(
"[INFO] rmdir -p {} # managed",
scenario.target_subdir.display()
),
]);
scenario
.target_file
.assert(predicate::path::is_symlink())
.assert("content");
scenario.close()
}
#[test]
fn apply_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
let state = apply(&scenario.state, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: remove file (managed)",
scenario.target_file.display()
)]);
scenario.target_file.assert(predicate::path::missing());
scenario.target_subdir.assert(predicate::path::missing());
assert_eq!(state, FilesState::EMPTY);
scenario.close()
}
}
mod copy_no_collision_empty_state {
use super::*;
struct Scenario {
source_dir: TempDir,
target_dir: TempDir,
source_file: ChildPath,
target_file: ChildPath,
manifest: ManifestLow,
}
impl Scenario {
fn init() -> anyhow::Result<Scenario> {
let source_dir = assert_fs::TempDir::new()?;
let target_dir = assert_fs::TempDir::new()?;
let source_file = source_dir.child("s_file");
let target_file = target_dir.child("t_file");
source_file.write_str("content")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file.to_path_buf(),
target: target_file.to_path_buf(),
action: FileActionLow::Copy { mode: None },
collision: Collision::Abort,
}],
};
Ok(Scenario {
source_dir,
target_dir,
source_file,
target_file,
manifest,
})
}
fn close(self) -> anyhow::Result<()> {
self.source_dir.close()?;
self.target_dir.close()?;
Ok(())
}
}
#[test]
fn check_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
check(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: checking copy target",
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::missing());
scenario.close()
}
#[test]
fn apply_dry_run_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
apply_dry_run(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[INFO] cp {} {}",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario.target_file.assert(predicate::path::missing());
scenario.close()
}
#[test]
fn apply_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
{
let mut perm = scenario.source_file.metadata()?.permissions();
perm.set_mode(0o646);
fs::set_permissions(&scenario.source_file, perm)?
}
apply(&FilesState::EMPTY, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: copy from {}",
scenario.target_file.display(),
scenario.source_file.display()
)]);
scenario
.target_file
.assert(predicate::path::is_file())
.assert(predicate::function(|p: &Path| {
dbg!(p.metadata().unwrap().permissions());
p.metadata().unwrap().permissions().mode() & 0o777 == 0o646
}))
.assert("content");
scenario.close()
}
}
mod copy_collision_matching_state {
use super::*;
struct Scenario {
source_dir: TempDir,
target_dir: TempDir,
source_file: ChildPath,
target_file: ChildPath,
manifest: ManifestLow,
state: FilesState,
}
impl Scenario {
fn init() -> anyhow::Result<Scenario> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file = source_dir.child("s_file");
let target_file = target_dir.child("t_file");
source_file.write_str("content")?;
target_file.write_str("content")?;
let modified = source_file.metadata()?.modified()?;
File::open(&target_file)?.set_modified(modified)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file.to_path_buf(),
target: target_file.to_path_buf(),
action: FileActionLow::Copy { mode: None },
collision: Collision::Abort,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file.to_path_buf(),
FileState {
modified,
source: source_file.to_path_buf(),
},
)]),
};
Ok(Scenario {
source_dir,
target_dir,
source_file,
target_file,
manifest,
state,
})
}
fn close(self) -> anyhow::Result<()> {
self.source_dir.close()?;
self.target_dir.close()?;
Ok(())
}
}
#[test]
fn check_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
check(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: skip checking copy target (managed and unchanged)",
scenario.target_file.display()
),
]);
scenario
.target_file
.assert(predicate::path::is_file())
.assert("content");
scenario.close()
}
#[test]
fn apply_dry_run_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
apply_dry_run(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[DEBUG] # skip copying {} -> {}: managed and unchanged",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario
.target_file
.assert(predicate::path::is_file())
.assert("content");
scenario.close()
}
#[test]
fn apply_no_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
let state = apply(&scenario.state, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: ignored (managed and unchanged)",
scenario.target_file.display()
)]);
scenario
.target_file
.assert(predicate::path::is_file())
.assert("content");
assert_eq!(state, scenario.state);
scenario.close()
}
}
mod copy_collision_force {
use super::*;
struct Scenario {
source_dir: TempDir,
target_dir: TempDir,
source_file: ChildPath,
target_file: ChildPath,
manifest: ManifestLow,
state: FilesState,
}
impl Scenario {
fn init() -> anyhow::Result<Scenario> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file = source_dir.child("s_file");
let target_file = target_dir.child("t_file");
source_file.write_str("source content")?;
target_file.write_str("target content")?;
let modified = SystemTime::now();
let manifest = Manifest {
files: vec![FileManifest {
source: source_file.to_path_buf(),
target: target_file.to_path_buf(),
action: FileActionLow::Copy { mode: None },
collision: Collision::Force,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file.to_path_buf(),
FileState {
modified,
source: source_file.to_path_buf(),
},
)]),
};
Ok(Scenario {
source_dir,
target_dir,
source_file,
target_file,
manifest,
state,
})
}
fn close(self) -> anyhow::Result<()> {
self.source_dir.close()?;
self.target_dir.close()?;
Ok(())
}
}
#[test]
fn check_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
check(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
),
format!(
"[INFO] {}: checking forced copy target (forced overwrite)",
scenario.target_file.display()
),
]);
scenario
.target_file
.assert(predicate::path::is_file())
.assert("target content");
scenario.close()
}
#[test]
fn apply_dry_run_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
apply_dry_run(&scenario.state, &scenario.manifest)?;
matches_log(&[
format!("[INFO] mkdir -p {}", scenario.target_dir.display()),
format!(
"[INFO] cp -f {} {} # forced overwrite",
scenario.source_file.display(),
scenario.target_file.display()
),
]);
scenario
.target_file
.assert(predicate::path::is_file())
.assert("target content");
scenario.close()
}
#[test]
fn apply_collision() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
let state = apply(&scenario.state, &scenario.manifest)?;
matches_log(&[format!(
"[DEBUG] {}: copy from {} (forced overwrite)",
scenario.target_file.display(),
scenario.source_file.display()
)]);
scenario
.target_file
.assert(predicate::path::is_file())
.assert("source content");
assert_eq!(
state,
FilesState {
files: BTreeMap::from([(
scenario.target_file.to_path_buf(),
FileState {
modified: scenario.source_file.metadata()?.modified()?,
source: scenario.source_file.to_path_buf(),
},
)]),
}
);
scenario.close()
}
}
mod copy_directory {
use super::*;
struct Scenario {
source_dir: TempDir,
target_dir: TempDir,
target_subdir: ChildPath,
manifest: ManifestLow,
}
impl Scenario {
fn init() -> anyhow::Result<Scenario> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_subdir = source_dir.child("s_dir");
let target_subdir = target_dir.child("t_dir");
source_subdir.child("file").write_str("source content")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_subdir.to_path_buf(),
target: target_subdir.to_path_buf(),
action: FileActionLow::Copy { mode: None },
collision: Collision::Force,
}],
};
Ok(Scenario {
source_dir,
target_dir,
target_subdir,
manifest,
})
}
fn close(self) -> anyhow::Result<()> {
self.source_dir.close()?;
self.target_dir.close()?;
Ok(())
}
}
#[test]
fn test_check() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
let result = check(&FilesState::EMPTY, &scenario.manifest);
matches_log(&[format!(
"[INFO] {}: checking create directory",
scenario.target_dir.display()
)]);
scenario.target_subdir.assert(predicate::path::missing());
assert!(matches!(
result,
Err(CheckError::ApplyError(ApplyError::CannotCopyError(_)))
));
scenario.close()
}
#[test]
fn test_apply_dry_run() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
let result = apply_dry_run(&FilesState::EMPTY, &scenario.manifest);
matches_log(&[format!("[INFO] mkdir -p {}", scenario.target_dir.display())]);
scenario.target_subdir.assert(predicate::path::missing());
assert!(matches!(
result,
Err(ApplyDryRunError::ApplyError(ApplyError::CannotCopyError(_)))
));
scenario.close()
}
#[test]
fn test_apply() -> anyhow::Result<()> {
testing_logger::setup();
let scenario = Scenario::init()?;
let result = apply(&FilesState::EMPTY, &scenario.manifest);
matches_log(&[]);
scenario.target_subdir.assert(predicate::path::missing());
assert!(matches!(
result,
Err(ApplyRealError::ApplyError(ApplyError::CannotCopyError(_)))
));
scenario.close()
}
}
#[test]
fn check_collision_with_matching_state() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let source_file_2 = source_dir.child("s_file_2");
let target_file_1 = target_dir.child("t_file_1");
source_file_1.write_str("content1")?;
source_file_2.write_str("content2")?;
target_file_1.symlink_to_file(&source_file_1)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_2.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
)]),
};
check(&state, &manifest)?;
target_file_1.assert("content1");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn check_collision_with_mismatching_state() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let source_file_2 = source_dir.child("s_file_2");
let target_file_1 = target_dir.child("t_file_1");
source_file_1.write_str("content1")?;
source_file_2.write_str("content2")?;
target_file_1.symlink_to_file(&source_file_1)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_2.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_2.to_path_buf(),
},
)]),
};
let result = check(&state, &manifest);
assert!(matches!(result, Err(CheckError::FileAlreadyExistsError(_))));
target_file_1.assert("content1");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn check_dir_collision() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_subdir_1 = &target_dir.child("t_subdir_1");
let target_file_1 = target_subdir_1.child("t_file_1");
source_file_1.write_str("content")?;
target_subdir_1.write_str("existing")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let result = check(&FilesState::EMPTY, &manifest);
assert!(matches!(
result,
Err(CheckError::ParentDirAlreadyExistsAsFileError(_))
));
target_subdir_1.assert("existing");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn check_source_and_target_same_content() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
source_file_1.write_str("content")?;
target_file_1.write_str("content")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
check(&FilesState::EMPTY, &manifest)?;
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_with_same_source() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
source_file_1.write_str("content")?;
target_file_1.symlink_to_file(&source_file_1)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
)]),
};
apply(&state, &manifest)?;
target_file_1
.assert(predicate::path::is_symlink())
.assert("content");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_with_subdir() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("subdir").child("t_file_1");
source_file_1.write_str("content")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
apply(&FilesState::EMPTY, &manifest)?;
target_file_1
.assert(predicate::path::is_symlink())
.assert("content");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_with_delete() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
source_file_1.write_str("content")?;
target_file_1.symlink_to_file(&source_file_1)?;
target_dir.child("delete_stopper").write_str("")?;
let manifest = Manifest { files: vec![] };
let state = FilesState {
files: BTreeMap::from([(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
)]),
};
apply(&state, &manifest)?;
target_file_1.assert(predicate::path::missing());
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_with_delete_missing() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
source_file_1.write_str("content")?;
target_dir.child("delete_stopper").write_str("")?;
let manifest = Manifest { files: vec![] };
let state = FilesState {
files: BTreeMap::from([(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
)]),
};
apply(&state, &manifest)?;
target_file_1.assert(predicate::path::missing());
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_with_delete_unmanaged() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let target_file_1 = target_dir.child("t_file_1");
target_file_1.write_str("content")?;
let manifest = Manifest { files: vec![] };
let state = FilesState {
files: BTreeMap::from([(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: PathBuf::from("/source/file"),
},
)]),
};
apply(&state, &manifest)?;
target_file_1.assert("content");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_with_delete_inside_empty_dir() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("dir1/dir2/dir3/t_file_1");
let target_file_2 = target_dir.child("dir1/t_file_2");
source_file_1.write_str("managed")?;
target_dir.child("dir1/dir2/dir3").create_dir_all()?;
target_file_1.symlink_to_file(&source_file_1)?;
target_file_2.symlink_to_file(&source_file_1)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_2.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let state = FilesState {
files: BTreeMap::from([
(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
),
(
target_file_2.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
),
]),
};
apply(&state, &manifest)?;
target_dir
.child("dir1/dir2")
.assert(predicate::path::missing());
target_file_2.assert(predicate::path::is_symlink());
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_with_delete_readonly() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_2 = target_dir.child("t_file_2");
source_file_1.write_str("previously managed")?;
target_file_2.symlink_to_file(&source_file_1)?;
{
let mut perm = target_dir.metadata()?.permissions();
perm.set_mode(0o555);
std::fs::set_permissions(&target_dir, perm)?;
}
let manifest = Manifest { files: vec![] };
let state = FilesState {
files: BTreeMap::from([(
target_file_2.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
)]),
};
let result = apply(&state, &manifest);
assert!(matches!(
result,
Err(ApplyRealError::CannotRemoveFileError(_, _))
));
{
let mut perm = target_dir.metadata()?.permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&target_dir, perm)?;
}
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn check_with_delete_readonly() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_2 = target_dir.child("t_file_2");
source_file_1.write_str("previously managed")?;
target_file_2.symlink_to_file(&source_file_1)?;
{
let mut perm = target_dir.metadata()?.permissions();
perm.set_mode(0o555);
std::fs::set_permissions(&target_dir, perm)?;
}
let manifest = Manifest { files: vec![] };
let state = FilesState {
files: BTreeMap::from([(
target_file_2.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
)]),
};
let result = check(&state, &manifest);
assert!(matches!(result, Err(CheckError::FileReadOnlyError(_))));
{
let mut perm = target_dir.metadata()?.permissions();
perm.set_mode(0o755);
std::fs::set_permissions(&target_dir, perm)?;
}
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_with_file_inside_previously_managed_link() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
let target_file_2 = target_dir.child("t_file_1/t_file_2");
source_file_1.write_str("content")?;
target_file_1.symlink_to_file(&source_file_1)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_2.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
)]),
};
apply(&state, &manifest)?;
target_file_2.assert("content");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_collision() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
let target_file_via_1 = target_dir.child("t_file_1.pttrRMPqk5gp");
source_file_1.write_str("content")?;
target_file_1.write_str("existing")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let result = apply(&FilesState::EMPTY, &manifest);
assert!(matches!(
result,
Err(ApplyRealError::FileAlreadyExistsError(_))
));
target_file_1
.assert(predicate::path::is_file())
.assert("existing");
target_file_via_1.assert(predicate::path::missing());
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_collision_with_matching_state() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let source_file_2 = source_dir.child("s_file_2");
let target_file_1 = target_dir.child("t_file_1");
source_file_1.write_str("content1")?;
source_file_2.write_str("content2")?;
target_file_1.symlink_to_file(&source_file_1)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_2.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_1.to_path_buf(),
},
)]),
};
apply(&state, &manifest)?;
target_file_1.assert("content2");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_collision_with_mismatching_state() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let source_file_2 = source_dir.child("s_file_2");
let target_file_1 = target_dir.child("t_file_1");
source_file_1.write_str("content1")?;
source_file_2.write_str("content2")?;
target_file_1.symlink_to_file(&source_file_1)?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_2.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
let state = FilesState {
files: BTreeMap::from([(
target_file_1.to_path_buf(),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: source_file_2.to_path_buf(),
},
)]),
};
let result = apply(&state, &manifest);
assert!(matches!(
result,
Err(ApplyRealError::FileAlreadyExistsError(_))
));
target_file_1.assert("content1");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_source_and_target_same_content() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
source_file_1.write_str("content")?;
target_file_1.write_str("content")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Abort,
}],
};
apply(&FilesState::EMPTY, &manifest)?;
target_file_1.assert("content");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_collision_backup_no_collision() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
let backup_path = target_dir.child("t_file_1.bck");
source_file_1.write_str("content")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Backup {
backup_path: backup_path.to_path_buf(),
},
}],
};
apply(&FilesState::EMPTY, &manifest)?;
target_file_1.assert("content");
backup_path.assert(predicate::path::missing());
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_collision_backup_collision() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
let backup_path = target_dir.child("t_file_1.bck");
source_file_1.write_str("content")?;
target_file_1.write_str("existing")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Backup {
backup_path: backup_path.to_path_buf(),
},
}],
};
apply(&FilesState::EMPTY, &manifest)?;
target_file_1.assert("content");
backup_path.assert("existing");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
#[test]
fn apply_collision_backup_collision_backup_exists() -> anyhow::Result<()> {
let source_dir = TempDir::new()?;
let target_dir = TempDir::new()?;
let source_file_1 = source_dir.child("s_file_1");
let target_file_1 = target_dir.child("t_file_1");
let backup_path = target_dir.child("t_file_1.bck");
source_file_1.write_str("content")?;
target_file_1.write_str("existing")?;
backup_path.write_str("old backup")?;
let manifest = Manifest {
files: vec![FileManifest {
source: source_file_1.to_path_buf(),
target: target_file_1.to_path_buf(),
action: FileActionLow::Symlink,
collision: Collision::Backup {
backup_path: backup_path.to_path_buf(),
},
}],
};
let result = apply(&FilesState::EMPTY, &manifest);
assert!(matches!(
result,
Err(ApplyRealError::BackupPathAlreadyExistsError(_))
));
target_file_1.assert("existing");
backup_path.assert("old backup");
source_dir.close()?;
target_dir.close()?;
Ok(())
}
}