use std::collections::HashMap;
use std::path::{Path, PathBuf};
use crate::fs::Fs;
use crate::provisioners::{descriptor_for, CandidatePath, ExecutableLocation, PROVISIONERS};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Availability {
Present { at: Option<PathBuf> },
Absent { probed: Vec<PathBuf> },
ProbeFailed { at: PathBuf, detail: String },
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnavailableRow {
pub style: &'static str,
pub label: String,
pub note: String,
pub note_kind: &'static str,
}
impl Availability {
pub fn is_present(&self) -> bool {
matches!(self, Availability::Present { .. })
}
pub fn unavailable_row(&self, handler: &str) -> Option<UnavailableRow> {
match self {
Availability::Present { .. } => None,
Availability::Absent { probed } => Some(UnavailableRow {
style: "skipped",
label: format!("{handler} not installed"),
note: absent_note(handler, probed),
note_kind: "warning",
}),
Availability::ProbeFailed { at, detail } => Some(UnavailableRow {
style: "broken",
label: format!("cannot probe {handler}"),
note: format!(
"could not check whether {handler} is installed: {} ({detail}). \
Nothing was run for this file.",
at.display()
),
note_kind: "error",
}),
}
}
}
impl Availability {
pub fn probed_locations(&self) -> Option<String> {
match self {
Availability::Absent { probed } => Some(format!("probed {}", locations(probed))),
_ => None,
}
}
}
fn locations(probed: &[PathBuf]) -> String {
probed
.iter()
.map(|p| p.display().to_string())
.collect::<Vec<_>>()
.join(", ")
}
fn absent_note(handler: &str, probed: &[PathBuf]) -> String {
let locations = locations(probed);
let where_to_get = descriptor_for(handler)
.and_then(|d| d.project_url)
.map(|url| format!(" ({url})"))
.unwrap_or_default();
format!(
"{handler} is not installed — probed {locations}. Nothing was recorded, so \
installing {handler}{where_to_get} and re-running `dodot up` runs this file."
)
}
#[derive(Debug, Clone, Default)]
pub struct ProvisionHost {
located: HashMap<&'static str, Vec<PathBuf>>,
}
impl ProvisionHost {
pub fn detect(home: &Path) -> Self {
let mut located = HashMap::new();
for descriptor in PROVISIONERS {
let ExecutableLocation::Candidates(candidates) = descriptor.location else {
continue;
};
located.insert(descriptor.handler, resolve_candidates(candidates, home));
}
Self { located }
}
pub fn assume_present() -> Self {
Self::default()
}
pub fn with_candidates(handler: &'static str, candidates: Vec<PathBuf>) -> Self {
let mut located = HashMap::new();
located.insert(handler, candidates);
Self { located }
}
pub fn candidates(&self, handler: &str) -> &[PathBuf] {
self.located
.get(handler)
.map(Vec::as_slice)
.unwrap_or_default()
}
}
fn resolve_candidates(candidates: &[CandidatePath], home: &Path) -> Vec<PathBuf> {
let mut resolved = Vec::with_capacity(candidates.len());
for candidate in candidates {
match candidate {
CandidatePath::Absolute(path) => resolved.push(PathBuf::from(path)),
CandidatePath::UnderHome(suffix) => {
if !home.as_os_str().is_empty() {
resolved.push(home.join(suffix));
}
}
CandidatePath::UnderEnv { var, suffix } => {
let Some(prefix) = std::env::var_os(var) else {
continue;
};
let blank = prefix
.to_str()
.map_or_else(|| prefix.is_empty(), |v| v.trim().is_empty());
if !blank {
resolved.push(PathBuf::from(prefix).join(suffix));
}
}
}
}
resolved.retain(|candidate| candidate.is_absolute());
resolved
}
pub fn probe(fs: &dyn Fs, host: &ProvisionHost, handler: &str) -> Availability {
let candidates = host.candidates(handler);
if candidates.is_empty() {
return Availability::Present { at: None };
}
let mut probed = Vec::with_capacity(candidates.len());
for candidate in candidates {
probed.push(candidate.clone());
match fs.stat(candidate) {
Ok(meta) if meta.is_file && meta.mode & 0o111 != 0 => {
return Availability::Present {
at: Some(candidate.clone()),
}
}
Ok(_) => continue,
Err(e) if is_missing(&e) => continue,
Err(e) => {
return Availability::ProbeFailed {
at: candidate.clone(),
detail: e.to_string(),
}
}
}
}
Availability::Absent { probed }
}
fn is_missing(error: &crate::DodotError) -> bool {
const ENOTDIR: i32 = 20;
match error {
crate::DodotError::Fs { source, .. } => {
matches!(
source.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) || source.raw_os_error() == Some(ENOTDIR)
}
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{DirEntry, FsMetadata};
use crate::handlers::{HANDLER_HOMEBREW, HANDLER_INSTALL, HANDLER_NIX};
use crate::DodotError;
enum Entry {
Executable,
Plain,
Directory,
Fails(std::io::ErrorKind),
}
struct FakeFs {
entries: HashMap<PathBuf, Entry>,
}
impl FakeFs {
fn new(entries: Vec<(&str, Entry)>) -> Self {
Self {
entries: entries
.into_iter()
.map(|(p, e)| (PathBuf::from(p), e))
.collect(),
}
}
}
impl Fs for FakeFs {
fn stat(&self, path: &Path) -> crate::Result<FsMetadata> {
let missing = || DodotError::Fs {
path: path.to_path_buf(),
source: std::io::Error::from(std::io::ErrorKind::NotFound),
};
match self.entries.get(path) {
None => Err(missing()),
Some(Entry::Executable) => Ok(FsMetadata {
is_file: true,
is_dir: false,
is_symlink: false,
len: 0,
mode: 0o755,
id: Default::default(),
}),
Some(Entry::Plain) => Ok(FsMetadata {
is_file: true,
is_dir: false,
is_symlink: false,
len: 0,
mode: 0o644,
id: Default::default(),
}),
Some(Entry::Directory) => Ok(FsMetadata {
is_file: false,
is_dir: true,
is_symlink: false,
len: 0,
mode: 0o755,
id: Default::default(),
}),
Some(Entry::Fails(kind)) => Err(DodotError::Fs {
path: path.to_path_buf(),
source: std::io::Error::from(*kind),
}),
}
}
fn lstat(&self, _: &Path) -> crate::Result<FsMetadata> {
unimplemented!("the presence probe stats and does nothing else")
}
fn open_read(&self, _: &Path) -> crate::Result<Box<dyn std::io::Read + Send + Sync>> {
unimplemented!("the presence probe stats and does nothing else")
}
fn read_file(&self, _: &Path) -> crate::Result<Vec<u8>> {
unimplemented!("the presence probe stats and does nothing else")
}
fn read_to_string(&self, _: &Path) -> crate::Result<String> {
unimplemented!("the presence probe stats and does nothing else")
}
fn write_file(&self, _: &Path, _: &[u8]) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn set_permissions(&self, _: &Path, _: u32) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn mkdir_all(&self, _: &Path) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn mkdir_exclusive(&self, _: &Path) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn symlink(&self, _: &Path, _: &Path) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn readlink(&self, _: &Path) -> crate::Result<PathBuf> {
unimplemented!("the presence probe stats and does nothing else")
}
fn remove_file(&self, _: &Path) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn remove_dir_all(&self, _: &Path) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn remove_dir_empty(&self, _: &Path) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn exists(&self, _: &Path) -> bool {
unimplemented!("the presence probe asks for mode bits, not existence")
}
fn is_symlink(&self, _: &Path) -> bool {
unimplemented!("the presence probe stats and does nothing else")
}
fn is_dir(&self, _: &Path) -> bool {
unimplemented!("the presence probe stats and does nothing else")
}
fn read_dir(&self, _: &Path) -> crate::Result<Vec<DirEntry>> {
unimplemented!("the presence probe stats and does nothing else")
}
fn rename(&self, _: &Path, _: &Path) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn rename_noreplace(&self, _: &Path, _: &Path) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
fn copy_file(&self, _: &Path, _: &Path) -> crate::Result<()> {
unimplemented!("the presence probe never writes")
}
}
fn host(candidates: &[&str]) -> ProvisionHost {
ProvisionHost::with_candidates(
HANDLER_HOMEBREW,
candidates.iter().map(PathBuf::from).collect(),
)
}
#[test]
fn present_at_the_first_candidate_holding_an_executable() {
let fs = FakeFs::new(vec![
("/opt/homebrew/bin/brew", Entry::Executable),
("/usr/local/bin/brew", Entry::Executable),
]);
assert_eq!(
probe(
&fs,
&host(&["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]),
HANDLER_HOMEBREW
),
Availability::Present {
at: Some(PathBuf::from("/opt/homebrew/bin/brew"))
}
);
}
#[test]
fn a_candidate_that_is_not_runnable_lets_the_next_one_answer() {
let fs = FakeFs::new(vec![
("/opt/homebrew/bin/brew", Entry::Directory),
("/home/linuxbrew/.linuxbrew/bin/brew", Entry::Plain),
("/usr/local/bin/brew", Entry::Executable),
]);
assert_eq!(
probe(
&fs,
&host(&[
"/opt/homebrew/bin/brew",
"/home/linuxbrew/.linuxbrew/bin/brew",
"/usr/local/bin/brew",
]),
HANDLER_HOMEBREW
),
Availability::Present {
at: Some(PathBuf::from("/usr/local/bin/brew"))
}
);
}
#[test]
fn absent_names_every_location_probed_in_order() {
let fs = FakeFs::new(vec![("/opt/homebrew/bin/brew", Entry::Plain)]);
assert_eq!(
probe(
&fs,
&host(&["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]),
HANDLER_HOMEBREW
),
Availability::Absent {
probed: vec![
PathBuf::from("/opt/homebrew/bin/brew"),
PathBuf::from("/usr/local/bin/brew"),
]
}
);
}
#[test]
fn a_candidate_that_cannot_be_examined_is_a_failure_not_an_absence() {
let fs = FakeFs::new(vec![(
"/opt/homebrew/bin/brew",
Entry::Fails(std::io::ErrorKind::PermissionDenied),
)]);
let outcome = probe(
&fs,
&host(&["/opt/homebrew/bin/brew", "/usr/local/bin/brew"]),
HANDLER_HOMEBREW,
);
match outcome {
Availability::ProbeFailed { at, detail } => {
assert_eq!(at, PathBuf::from("/opt/homebrew/bin/brew"));
assert!(detail.contains("permission denied"), "detail: {detail}");
}
other => panic!("expected ProbeFailed, got {other:?}"),
}
}
#[test]
fn a_candidate_under_a_non_directory_is_a_miss() {
let fs = FakeFs::new(vec![(
"/usr/local/bin/brew",
Entry::Fails(std::io::ErrorKind::NotADirectory),
)]);
assert!(is_missing(&DodotError::Fs {
path: PathBuf::from("/usr/local/bin/brew"),
source: std::io::Error::from_raw_os_error(20),
}));
assert!(matches!(
probe(&fs, &host(&["/usr/local/bin/brew"]), HANDLER_HOMEBREW),
Availability::Absent { .. }
));
}
#[test]
fn a_handler_dodot_does_not_locate_is_present_with_no_path() {
let fs = FakeFs::new(vec![]);
assert_eq!(
probe(
&fs,
&ProvisionHost::detect(Path::new("/home/u")),
HANDLER_INSTALL
),
Availability::Present { at: None }
);
}
#[test]
fn a_test_context_that_has_not_opted_in_probes_nothing() {
let fs = FakeFs::new(vec![]);
for handler in [HANDLER_INSTALL, HANDLER_HOMEBREW, HANDLER_NIX] {
assert_eq!(
probe(&fs, &ProvisionHost::assume_present(), handler),
Availability::Present { at: None },
"{handler} must not reach the real machine from a test"
);
}
}
#[test]
fn detect_anchors_home_candidates_to_the_callers_home() {
let host = ProvisionHost::detect(Path::new("/home/ada"));
assert!(host
.candidates(HANDLER_NIX)
.contains(&PathBuf::from("/home/ada/.nix-profile/bin/nix")));
assert!(host
.candidates(HANDLER_HOMEBREW)
.contains(&PathBuf::from("/home/ada/.linuxbrew/bin/brew")));
}
#[test]
fn detect_locates_the_managers_and_leaves_install_alone() {
let host = ProvisionHost::detect(Path::new("/home/ada"));
assert!(!host.candidates(HANDLER_HOMEBREW).is_empty());
assert!(!host.candidates(HANDLER_NIX).is_empty());
assert!(
host.candidates(HANDLER_INSTALL).is_empty(),
"install resolves its interpreter through PATH and is not located"
);
}
#[test]
fn an_empty_home_drops_home_anchored_candidates_rather_than_inventing_one() {
let host = ProvisionHost::detect(Path::new(""));
assert!(host
.candidates(HANDLER_NIX)
.iter()
.all(|c| c.is_absolute() && !c.starts_with("/.nix-profile")));
}
#[test]
fn an_unset_environment_variable_drops_its_candidate() {
let resolved = resolve_candidates(
&[
CandidatePath::UnderEnv {
var: "DODOT_TEST_NO_SUCH_PREFIX",
suffix: "bin/brew",
},
CandidatePath::Absolute("/usr/local/bin/brew"),
],
Path::new("/home/ada"),
);
assert_eq!(resolved, vec![PathBuf::from("/usr/local/bin/brew")]);
}
#[test]
fn a_set_environment_variable_leads_the_candidate_list() {
let expected = PathBuf::from(std::env::var_os("PATH").unwrap()).join("bin/brew");
let resolved = resolve_candidates(
&[
CandidatePath::UnderEnv {
var: "PATH",
suffix: "bin/brew",
},
CandidatePath::Absolute("/usr/local/bin/brew"),
],
Path::new("/home/ada"),
);
assert_eq!(resolved.first(), Some(&expected));
}
#[cfg(unix)]
#[test]
fn a_non_utf8_environment_prefix_keeps_its_candidate() {
use std::ffi::OsStr;
use std::os::unix::ffi::OsStrExt;
let prefix = OsStr::from_bytes(b"/opt/br\xff/prefix");
let _guard = crate::testing::EnvVarGuard::set_os("DODOT_TEST_ODD_PREFIX", prefix);
let resolved = resolve_candidates(
&[CandidatePath::UnderEnv {
var: "DODOT_TEST_ODD_PREFIX",
suffix: "bin/brew",
}],
Path::new("/home/ada"),
);
assert_eq!(resolved, vec![PathBuf::from(prefix).join("bin/brew")]);
}
#[test]
fn a_blank_environment_variable_drops_its_candidate() {
for value in ["", " "] {
let _guard = crate::testing::EnvVarGuard::set("DODOT_TEST_BLANK_PREFIX", value);
let resolved = resolve_candidates(
&[
CandidatePath::UnderEnv {
var: "DODOT_TEST_BLANK_PREFIX",
suffix: "bin/brew",
},
CandidatePath::Absolute("/usr/local/bin/brew"),
],
Path::new("/home/ada"),
);
assert_eq!(resolved, vec![PathBuf::from("/usr/local/bin/brew")]);
}
}
#[test]
fn a_relative_candidate_is_dropped_rather_than_resolved_against_the_cwd() {
let _guard = crate::testing::EnvVarGuard::set("DODOT_TEST_RELATIVE_PREFIX", "opt/brew");
let resolved = resolve_candidates(
&[
CandidatePath::UnderEnv {
var: "DODOT_TEST_RELATIVE_PREFIX",
suffix: "bin/brew",
},
CandidatePath::UnderHome(".linuxbrew/bin/brew"),
CandidatePath::Absolute("/usr/local/bin/brew"),
],
Path::new("relative/home"),
);
assert_eq!(resolved, vec![PathBuf::from("/usr/local/bin/brew")]);
}
#[test]
fn an_absent_row_names_the_manager_the_locations_and_the_project_page() {
let row = Availability::Absent {
probed: vec![
PathBuf::from("/opt/homebrew/bin/brew"),
PathBuf::from("/usr/local/bin/brew"),
],
}
.unavailable_row(HANDLER_HOMEBREW)
.expect("an absent manager renders a row");
assert_eq!(row.style, "skipped");
assert_eq!(row.label, "homebrew not installed");
assert_eq!(row.note_kind, "warning");
assert!(row.note.contains("/opt/homebrew/bin/brew"));
assert!(row.note.contains("/usr/local/bin/brew"));
assert!(row.note.contains(crate::provisioners::HOMEBREW_PROJECT_URL));
assert!(row.note.contains("dodot up"));
}
#[test]
fn a_probe_failure_renders_as_an_error_not_a_skip() {
let row = Availability::ProbeFailed {
at: PathBuf::from("/opt/homebrew/bin/brew"),
detail: "permission denied".into(),
}
.unavailable_row(HANDLER_HOMEBREW)
.expect("a probe failure renders a row");
assert_eq!(row.style, "broken");
assert_eq!(row.note_kind, "error");
assert!(row.label.contains("homebrew"));
assert!(row.note.contains("permission denied"));
}
#[test]
fn a_row_with_a_receipt_gets_the_locations_without_the_wrong_remedy() {
let absent = Availability::Absent {
probed: vec![PathBuf::from("/opt/homebrew/bin/brew")],
};
assert_eq!(
absent.probed_locations(),
Some("probed /opt/homebrew/bin/brew".to_string())
);
assert_eq!(Availability::Present { at: None }.probed_locations(), None);
}
#[test]
fn a_present_manager_renders_no_row_of_its_own() {
assert!(Availability::Present { at: None }
.unavailable_row(HANDLER_HOMEBREW)
.is_none());
}
}