use std::path::{Path, PathBuf};
use super::error::{Result, SafetyLockError};
use super::roots::{ResolvedRoot, RootSource};
use super::util::{canonical_root_identity, encode_native_path, PathProbe, UnusableRoot};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileRootInput {
pub current_dir: PathBuf,
pub git_top_level: Option<PathBuf>,
}
impl FileRootInput {
pub fn outside_repository(current_dir: impl Into<PathBuf>) -> Self {
Self {
current_dir: current_dir.into(),
git_top_level: None,
}
}
pub fn inside_repository(
current_dir: impl Into<PathBuf>,
git_top_level: impl Into<PathBuf>,
) -> Self {
Self {
current_dir: current_dir.into(),
git_top_level: Some(git_top_level.into()),
}
}
fn candidate(&self) -> (&Path, RootSource) {
match self.git_top_level.as_deref() {
Some(top_level) => (top_level, RootSource::Git),
None => (self.current_dir.as_path(), RootSource::CurrentDirectory),
}
}
}
pub fn resolve_file_root(input: &FileRootInput, probe: &dyn PathProbe) -> Result<ResolvedRoot> {
let (candidate, selected_by) = input.candidate();
let unusable = |reason: String| SafetyLockError::ImplicitRootUnusable {
spelling: encode_native_path(candidate),
selected_by,
reason,
};
let identity = canonical_root_identity(candidate, probe).map_err(|failure| match failure {
UnusableRoot::Relative => unusable(
"it is relative, so anchoring it would depend on the process working \
directory rather than the captured invocation directory"
.to_owned(),
),
UnusableRoot::Missing => unusable("it does not exist".to_owned()),
UnusableRoot::Unresolvable(err) => unusable(format!("it could not be resolved: {err}")),
UnusableRoot::NotADirectory => unusable("it is not a directory".to_owned()),
UnusableRoot::Unreadable => {
unusable("it is a directory whose contents cannot be read".to_owned())
}
UnusableRoot::NotAnIdentity(err) => {
unusable(format!("it did not resolve to a usable root: {err}"))
}
})?;
Ok(ResolvedRoot::new(identity, selected_by))
}
#[cfg(test)]
mod tests {
use std::collections::HashSet;
use std::ffi::OsString;
use std::io;
use std::os::unix::ffi::OsStringExt;
use super::super::roots::RootIdentity;
use super::super::test_probe::{Entry, FakeProbe};
use super::super::util::OsPathProbe;
use super::*;
fn unusable_reason(error: SafetyLockError) -> String {
match error {
SafetyLockError::ImplicitRootUnusable { reason, .. } => reason,
other => panic!("expected an unusable-candidate error, got: {other}"),
}
}
fn selecting_mechanism(error: SafetyLockError) -> RootSource {
match error {
SafetyLockError::ImplicitRootUnusable { selected_by, .. } => selected_by,
other => panic!("expected an unusable-candidate error, got: {other}"),
}
}
#[test]
fn repository_membership_is_stated_as_data() {
assert_eq!(
FileRootInput::outside_repository("/tmp/scratch").git_top_level,
None
);
assert_eq!(
FileRootInput::inside_repository("/srv/dots/vim", "/srv/dots"),
FileRootInput {
current_dir: PathBuf::from("/srv/dots/vim"),
git_top_level: Some(PathBuf::from("/srv/dots")),
}
);
}
#[test]
fn a_git_top_level_wins_over_the_directory_the_user_stands_in() {
let probe = FakeProbe::dir("/srv/dots").and_dir("/srv/dots/vim/colors");
let input = FileRootInput::inside_repository("/srv/dots/vim/colors", "/srv/dots");
let root = resolve_file_root(&input, &probe).unwrap();
assert_eq!(root.as_path(), Path::new("/srv/dots"));
assert_eq!(root.source(), RootSource::Git);
assert_eq!(
probe.canonicalized(),
vec![PathBuf::from("/srv/dots")],
"the current directory was probed even though Git had an answer"
);
}
#[test]
fn a_nested_repository_selects_its_own_top_level() {
let probe = FakeProbe::dir("/srv/dots").and_dir("/srv/dots/vendor/plugin");
let input = FileRootInput::inside_repository(
"/srv/dots/vendor/plugin/lua",
"/srv/dots/vendor/plugin",
);
let root = resolve_file_root(&input, &probe).unwrap();
assert_eq!(root.as_path(), Path::new("/srv/dots/vendor/plugin"));
assert_eq!(root.source(), RootSource::Git);
}
#[test]
fn without_a_git_top_level_the_current_directory_is_the_root() {
let probe = FakeProbe::dir("/tmp/scratch");
let input = FileRootInput::outside_repository("/tmp/scratch");
let root = resolve_file_root(&input, &probe).unwrap();
assert_eq!(root.as_path(), Path::new("/tmp/scratch"));
assert_eq!(root.source(), RootSource::CurrentDirectory);
}
#[test]
fn a_conventional_home_dotfiles_directory_is_not_an_implicit_fallback() {
let probe = FakeProbe::dir("/home/alice").and_dir("/home/alice/dotfiles");
let input = FileRootInput::outside_repository("/home/alice");
let root = resolve_file_root(&input, &probe).unwrap();
assert_eq!(root.as_path(), Path::new("/home/alice"));
assert_eq!(root.source(), RootSource::CurrentDirectory);
assert_eq!(
probe.canonicalized(),
vec![PathBuf::from("/home/alice")],
"a conventional path was probed as a candidate"
);
}
#[test]
fn aliases_of_one_directory_resolve_to_one_identity() {
let through_link = resolve_file_root(
&FileRootInput::inside_repository("/srv/link/vim", "/srv/link"),
&FakeProbe::default().link("/srv/link", "/srv/dots"),
)
.unwrap();
let direct = resolve_file_root(
&FileRootInput::inside_repository("/srv/dots/vim", "/srv/dots"),
&FakeProbe::dir("/srv/dots"),
)
.unwrap();
assert_eq!(through_link.identity(), direct.identity());
assert_eq!(through_link, direct);
let identities: HashSet<RootIdentity> = [through_link, direct]
.into_iter()
.map(ResolvedRoot::into_identity)
.collect();
assert_eq!(identities.len(), 1);
}
#[test]
fn moving_a_root_changes_its_identity_and_replacing_it_in_place_does_not() {
let before = resolve_file_root(
&FileRootInput::outside_repository("/srv/dots"),
&FakeProbe::dir("/srv/dots"),
)
.unwrap();
let moved = resolve_file_root(
&FileRootInput::outside_repository("/srv/moved/dots"),
&FakeProbe::dir("/srv/moved/dots"),
)
.unwrap();
assert_ne!(before.identity(), moved.identity());
let replaced = resolve_file_root(
&FileRootInput::outside_repository("/srv/dots"),
&FakeProbe::dir("/srv/dots"),
)
.unwrap();
assert_eq!(before.identity(), replaced.identity());
}
#[test]
fn a_relocated_symlink_target_is_a_new_identity() {
let before = resolve_file_root(
&FileRootInput::outside_repository("/srv/link"),
&FakeProbe::default().link("/srv/link", "/srv/dots"),
)
.unwrap();
let after = resolve_file_root(
&FileRootInput::outside_repository("/srv/link"),
&FakeProbe::default().link("/srv/link", "/srv/moved/dots"),
)
.unwrap();
assert_ne!(before.identity(), after.identity());
assert_eq!(after.as_path(), Path::new("/srv/moved/dots"));
}
#[test]
fn a_relative_candidate_is_refused_before_it_reaches_the_filesystem() {
for (input, expected) in [
(
FileRootInput::outside_repository("dots"),
RootSource::CurrentDirectory,
),
(
FileRootInput::inside_repository("/srv/dots/vim", "../dots"),
RootSource::Git,
),
] {
let probe = FakeProbe::dir("dots").and_dir("../dots");
let error = resolve_file_root(&input, &probe).unwrap_err();
let SafetyLockError::ImplicitRootUnusable {
selected_by,
reason,
..
} = error
else {
panic!("expected an unusable-candidate error");
};
assert_eq!(selected_by, expected);
assert!(reason.contains("it is relative"), "{reason}");
assert!(
probe.canonicalized().is_empty(),
"a relative path reached the filesystem probe"
);
}
}
#[test]
fn a_relative_current_directory_does_not_spoil_a_usable_git_top_level() {
let probe = FakeProbe::dir("/srv/dots");
let input = FileRootInput::inside_repository("vim", "/srv/dots");
let root = resolve_file_root(&input, &probe).unwrap();
assert_eq!(root.as_path(), Path::new("/srv/dots"));
}
#[test]
fn each_unusable_class_produces_its_own_diagnostic() {
let cases = [
("does not exist", FakeProbe::default()),
(
"is not a directory",
FakeProbe::with("/srv/dots", Entry::NotADirectory),
),
(
"cannot be read",
FakeProbe::with("/srv/dots", Entry::UnreadableDir),
),
(
"could not be resolved",
FakeProbe::with(
"/srv/dots",
Entry::Unresolvable(io::ErrorKind::PermissionDenied),
),
),
];
let mut reasons = HashSet::new();
for (expected, probe) in cases {
let error = resolve_file_root(&FileRootInput::outside_repository("/srv/dots"), &probe)
.unwrap_err();
let reason = unusable_reason(error);
assert!(reason.contains(expected), "{reason}");
reasons.insert(reason);
}
assert_eq!(reasons.len(), 4, "two classes share one diagnostic");
}
#[test]
fn an_unusable_git_top_level_never_falls_through_to_the_current_directory() {
let probe = FakeProbe::dir("/srv/dots/vim");
let input = FileRootInput::inside_repository("/srv/dots/vim", "/srv/dots");
let error = resolve_file_root(&input, &probe).unwrap_err();
assert_eq!(selecting_mechanism(error), RootSource::Git);
assert_eq!(
probe.canonicalized(),
vec![PathBuf::from("/srv/dots")],
"the current directory was probed as a second candidate"
);
}
#[test]
fn the_diagnostic_names_the_mechanism_that_chose_the_path() {
let git = resolve_file_root(
&FileRootInput::inside_repository("/srv/dots/vim", "/srv/dots"),
&FakeProbe::default(),
)
.unwrap_err();
let cwd = resolve_file_root(
&FileRootInput::outside_repository("/srv/dots"),
&FakeProbe::default(),
)
.unwrap_err();
assert_eq!(
git.to_string(),
"the git top-level `/srv/dots` cannot be used as a dotfiles root: \
it does not exist"
);
assert_eq!(
cwd.to_string(),
"the current directory `/srv/dots` cannot be used as a dotfiles root: \
it does not exist"
);
}
#[test]
fn a_non_unicode_root_keeps_its_native_form() {
let native = PathBuf::from(OsString::from_vec(b"/srv/\x80dots".to_vec()));
let probe = FakeProbe::dir(native.clone());
let root = resolve_file_root(
&FileRootInput::inside_repository(native.join("vim"), native.clone()),
&probe,
)
.unwrap();
assert_eq!(root.as_path(), native);
assert_eq!(root.identity().spelling(), "os-bytes:2f7372762f80646f7473");
assert_eq!(
RootIdentity::parse(&root.identity().spelling()).unwrap(),
*root.identity()
);
}
#[test]
fn a_non_unicode_failure_is_named_reversibly() {
let native = PathBuf::from(OsString::from_vec(b"/srv/\x80dots".to_vec()));
let error = resolve_file_root(
&FileRootInput::outside_repository(native),
&FakeProbe::default(),
)
.unwrap_err();
let SafetyLockError::ImplicitRootUnusable { spelling, .. } = error else {
panic!("expected an unusable-candidate error");
};
assert_eq!(spelling, "os-bytes:2f7372762f80646f7473");
}
#[test]
fn every_implicit_root_requires_approval() {
for input in [
FileRootInput::inside_repository("/srv/dots/vim", "/srv/dots"),
FileRootInput::outside_repository("/srv/dots"),
] {
let root = resolve_file_root(&input, &FakeProbe::dir("/srv/dots")).unwrap();
assert!(root.requires_approval());
assert!(root.source().is_implicit());
}
}
#[test]
fn a_real_repository_resolves_through_the_os_probe() {
let home = tempfile::tempdir().unwrap();
let repository = home.path().join("dotfiles");
std::fs::create_dir_all(repository.join("vim/colors")).unwrap();
let canonical = std::fs::canonicalize(&repository).unwrap();
let from_subdirectory = resolve_file_root(
&FileRootInput::inside_repository(repository.join("vim/colors"), &repository),
&OsPathProbe,
)
.unwrap();
assert_eq!(from_subdirectory.as_path(), canonical);
assert_eq!(from_subdirectory.source(), RootSource::Git);
let alias = home.path().join("dots-link");
std::os::unix::fs::symlink(&repository, &alias).unwrap();
let through_alias =
resolve_file_root(&FileRootInput::outside_repository(&alias), &OsPathProbe).unwrap();
assert_eq!(through_alias.identity(), from_subdirectory.identity());
assert_eq!(through_alias.source(), RootSource::CurrentDirectory);
let file = home.path().join("not-a-root");
std::fs::write(&file, b"").unwrap();
let reason = unusable_reason(
resolve_file_root(&FileRootInput::outside_repository(&file), &OsPathProbe).unwrap_err(),
);
assert!(reason.contains("is not a directory"), "{reason}");
let removed = home.path().join("gone");
let reason = unusable_reason(
resolve_file_root(&FileRootInput::outside_repository(&removed), &OsPathProbe)
.unwrap_err(),
);
assert!(reason.contains("does not exist"), "{reason}");
}
}