use crate::datastore::{CommandRunner, CommandSpec};
use crate::provisioners::descriptor_for;
const VERSION_ARGUMENT: &str = "--version";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct VersionFloor {
pub minimum: &'static str,
pub below: &'static str,
pub remedy: &'static str,
}
pub const HOMEBREW_VERSION_FLOOR: VersionFloor = VersionFloor {
minimum: "5.1.2",
below: "not all of the `Brewfile` entry types go, cargo, uv, npm, \
and krew are supported, and one of them may fail to parse",
remedy: "brew update",
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Fitness {
Fit,
BelowFloor {
found: String,
floor: VersionFloor,
},
ProbeFailed {
detail: String,
},
}
impl Fitness {
pub fn warning(&self, handler: &str, executable: &str) -> Option<String> {
match self {
Fitness::Fit => None,
Fitness::BelowFloor { found, floor } => Some(format!(
"{handler} at {executable} is {found}, older than {}: {}. \
Run `{}` to update it. dodot runs this file anyway — \
everything that does not need the newer {handler} still works.",
floor.minimum, floor.below, floor.remedy
)),
Fitness::ProbeFailed { detail } => {
let floor = floor_for(handler);
let consequence = floor
.map(|f| {
format!(
" dodot cannot tell whether it is at {}, below which {}.",
f.minimum, f.below
)
})
.unwrap_or_default();
Some(format!(
"{handler} at {executable} did not report its version: {detail}.\
{consequence} This run continues."
))
}
}
}
}
pub fn floor_for(handler: &str) -> Option<VersionFloor> {
descriptor_for(handler).and_then(|d| d.version_floor)
}
pub fn probe(
runner: &dyn CommandRunner,
handler: &str,
executable: &str,
environment: &[(String, String)],
) -> Fitness {
let Some(floor) = floor_for(handler) else {
return Fitness::Fit;
};
let arguments = [VERSION_ARGUMENT.to_string()];
let output = match runner.run(CommandSpec::with_environment(
executable,
&arguments,
environment,
)) {
Ok(output) => output,
Err(e) => {
return Fitness::ProbeFailed {
detail: e.to_string(),
}
}
};
if output.exit_code != 0 {
let said = first_line(&output.stderr)
.or_else(|| first_line(&output.stdout))
.unwrap_or("no output");
return Fitness::ProbeFailed {
detail: format!(
"`{executable} {VERSION_ARGUMENT}` exited {} ({said})",
output.exit_code
),
};
}
match reported_version(&output.stdout) {
Reported::Exact { text, version } => {
let minimum = Version::parse(floor.minimum).expect(
"every declared floor is a dotted numeric version; \
pinned by tests::every_declared_floor_parses",
);
if version < minimum {
Fitness::BelowFloor { found: text, floor }
} else {
Fitness::Fit
}
}
Reported::Inexact { text } => Fitness::ProbeFailed {
detail: format!("reported `{text}`, which is a lower bound rather than a version"),
},
Reported::Unreadable => Fitness::ProbeFailed {
detail: match first_line(&output.stdout) {
Some(line) => format!("reported `{line}`, which names no version"),
None => "printed nothing".to_string(),
},
},
}
}
#[derive(Debug, PartialEq, Eq)]
enum Reported {
Exact { text: String, version: Version },
Inexact { text: String },
Unreadable,
}
fn reported_version(stdout: &str) -> Reported {
let Some(line) = first_line(stdout) else {
return Reported::Unreadable;
};
for token in line.split_whitespace() {
if let Some(rest) = token.strip_prefix(">=") {
if rest.starts_with(|c: char| c.is_ascii_digit()) {
return Reported::Inexact {
text: token.to_string(),
};
}
continue;
}
if !token.starts_with(|c: char| c.is_ascii_digit()) {
continue;
}
let number: String = token
.chars()
.take_while(|c| c.is_ascii_digit() || *c == '.')
.collect();
if let Some(version) = Version::parse(&number) {
return Reported::Exact {
text: number,
version,
};
}
}
Reported::Unreadable
}
fn first_line(text: &str) -> Option<&str> {
text.lines().map(str::trim).find(|line| !line.is_empty())
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
struct Version(Vec<u64>);
impl Version {
fn parse(text: &str) -> Option<Self> {
let mut components = Vec::new();
for part in text.split('.') {
if part.is_empty() {
continue;
}
components.push(part.parse::<u64>().ok()?);
}
while components.last() == Some(&0) {
components.pop();
}
if components.is_empty() && !text.contains(|c: char| c.is_ascii_digit()) {
return None;
}
Some(Self(components))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datastore::CommandOutput;
use crate::handlers::{HANDLER_HOMEBREW, HANDLER_INSTALL, HANDLER_NIX};
use crate::provisioners::PROVISIONERS;
use std::sync::Mutex;
struct StagedVersion {
reply: Result<CommandOutput, String>,
spawns: Mutex<Vec<Spawn>>,
}
struct Spawn {
executable: String,
arguments: Vec<String>,
environment: Vec<(String, String)>,
}
impl StagedVersion {
fn saying(stdout: &str) -> Self {
Self::replying(Ok(CommandOutput {
exit_code: 0,
stdout: stdout.to_string(),
stderr: String::new(),
}))
}
fn replying(reply: Result<CommandOutput, String>) -> Self {
Self {
reply,
spawns: Mutex::new(Vec::new()),
}
}
fn spawn_count(&self) -> usize {
self.spawns.lock().unwrap().len()
}
}
impl CommandRunner for StagedVersion {
fn run(&self, command: CommandSpec<'_>) -> crate::Result<CommandOutput> {
self.spawns.lock().unwrap().push(Spawn {
executable: command.executable.to_string(),
arguments: command.arguments.to_vec(),
environment: command.environment.to_vec(),
});
self.reply.clone().map_err(crate::DodotError::Other)
}
}
fn probe_brew(runner: &StagedVersion) -> Fitness {
probe(runner, HANDLER_HOMEBREW, "/opt/homebrew/bin/brew", &[])
}
#[test]
fn a_brew_below_the_floor_is_reported_with_its_remedy() {
let runner = StagedVersion::saying("Homebrew 5.0.16\n");
let fitness = probe_brew(&runner);
assert_eq!(
fitness,
Fitness::BelowFloor {
found: "5.0.16".to_string(),
floor: HOMEBREW_VERSION_FLOOR,
}
);
let warning = fitness
.warning(HANDLER_HOMEBREW, "/opt/homebrew/bin/brew")
.expect("a below-floor brew has something to say");
assert!(warning.contains("5.0.16"), "{warning}");
assert!(warning.contains("5.1.2"), "{warning}");
assert!(
warning.contains("brew update"),
"the remedy is the reason the warning exists: {warning}"
);
}
#[test]
fn a_brew_at_the_floor_is_fit_and_says_nothing() {
let runner = StagedVersion::saying("Homebrew 5.1.2\n");
let fitness = probe_brew(&runner);
assert_eq!(fitness, Fitness::Fit);
assert_eq!(
fitness.warning(HANDLER_HOMEBREW, "/opt/homebrew/bin/brew"),
None,
"the floor is a minimum, and a machine that meets it is not news"
);
}
#[test]
fn a_brew_above_the_floor_is_fit() {
let runner = StagedVersion::saying(
"Homebrew 6.0.18\nHomebrew/homebrew-core (git revision abc; last commit 2026-08-20)\n",
);
assert_eq!(probe_brew(&runner), Fitness::Fit);
}
#[test]
fn a_development_build_is_read_by_its_release_number() {
let runner = StagedVersion::saying("Homebrew 6.0.18-77-g1234567\n");
assert_eq!(probe_brew(&runner), Fitness::Fit);
let old = StagedVersion::saying("Homebrew 5.0.7-3-gabcdefg\n");
assert!(matches!(probe_brew(&old), Fitness::BelowFloor { .. }));
}
#[test]
fn a_failing_version_command_is_a_probe_failure_not_an_absent_manager() {
let runner = StagedVersion::replying(Ok(CommandOutput {
exit_code: 1,
stdout: String::new(),
stderr: "dyld: library not loaded\n".to_string(),
}));
let fitness = probe_brew(&runner);
match &fitness {
Fitness::ProbeFailed { detail } => {
assert!(detail.contains("exited 1"), "{detail}");
assert!(detail.contains("dyld"), "the manager's own words: {detail}");
}
other => panic!("expected a probe failure, got {other:?}"),
}
let warning = fitness
.warning(HANDLER_HOMEBREW, "/opt/homebrew/bin/brew")
.unwrap();
assert!(
!warning.contains("not installed"),
"a brew that will not answer is not a brew that is missing: {warning}"
);
}
#[test]
fn a_runner_error_is_a_probe_failure() {
let runner = StagedVersion::replying(Err("permission denied".to_string()));
match probe_brew(&runner) {
Fitness::ProbeFailed { detail } => assert!(detail.contains("permission denied")),
other => panic!("expected a probe failure, got {other:?}"),
}
}
#[test]
fn output_with_no_version_in_it_is_a_probe_failure() {
for stdout in ["", "Homebrew\n", "error: no such command\n"] {
let runner = StagedVersion::saying(stdout);
assert!(
matches!(probe_brew(&runner), Fitness::ProbeFailed { .. }),
"unreadable output must not pass as fit: {stdout:?}"
);
}
}
#[test]
fn a_lower_bound_is_neither_fit_nor_below_the_floor() {
let runner = StagedVersion::saying("Homebrew >=4.1.0 (shallow or no git repository)\n");
match probe_brew(&runner) {
Fitness::ProbeFailed { detail } => assert!(detail.contains(">=4.1.0"), "{detail}"),
other => panic!("expected a probe failure, got {other:?}"),
}
}
#[test]
fn a_handler_with_no_floor_is_fit_without_spawning_anything() {
for handler in [HANDLER_INSTALL, HANDLER_NIX] {
let runner = StagedVersion::saying("irrelevant 0.1\n");
assert_eq!(
probe(&runner, handler, "/usr/bin/whatever", &[]),
Fitness::Fit
);
assert_eq!(
runner.spawn_count(),
0,
"{handler} declares no floor, so there is no question to ask it"
);
}
}
#[test]
fn the_probe_asks_the_executable_the_run_will_spawn_under_its_own_environment() {
let runner = StagedVersion::saying("Homebrew 6.0.18\n");
let environment = [("HOMEBREW_NO_AUTO_UPDATE".to_string(), "1".to_string())];
probe(
&runner,
HANDLER_HOMEBREW,
"/tmp/fake-prefix/bin/brew",
&environment,
);
let spawns = runner.spawns.lock().unwrap();
assert_eq!(spawns.len(), 1, "one question, one spawn");
assert_eq!(spawns[0].executable, "/tmp/fake-prefix/bin/brew");
assert_eq!(spawns[0].arguments, vec!["--version".to_string()]);
assert_eq!(
spawns[0].environment, environment,
"without HOMEBREW_NO_AUTO_UPDATE, asking brew its version would update \
brew — and the answer would describe a brew that did not exist a moment ago"
);
}
#[test]
fn every_declared_floor_parses() {
for descriptor in PROVISIONERS {
let Some(floor) = descriptor.version_floor else {
continue;
};
assert!(
Version::parse(floor.minimum).is_some(),
"{}'s floor `{}` is not a version dodot can compare",
descriptor.handler,
floor.minimum
);
}
}
#[test]
fn homebrew_is_the_only_handler_with_a_floor() {
let with_floors: Vec<&str> = PROVISIONERS
.iter()
.filter(|d| d.version_floor.is_some())
.map(|d| d.handler)
.collect();
assert_eq!(with_floors, vec![HANDLER_HOMEBREW]);
}
#[test]
fn trailing_zero_components_do_not_make_a_version_older() {
assert_eq!(Version::parse("5.1"), Version::parse("5.1.0"));
assert!(Version::parse("5.1.2") > Version::parse("5.1"));
assert!(Version::parse("6.0") > Version::parse("5.1.2"));
assert!(Version::parse("10.0.0") > Version::parse("9.9.9"));
}
}