#![allow(dead_code)]
use super::common::{InstallError, PackageManager, cmd_satisfies, has, run};
pub type ToolHandler = fn(&str) -> Result<(), InstallError>;
pub struct ToolEntry {
pub spec: &'static ToolSpec,
pub handler: ToolHandler,
}
inventory::collect!(ToolEntry);
pub fn iter_tools() -> impl Iterator<Item = &'static ToolEntry> {
inventory::iter::<ToolEntry>.into_iter()
}
#[cfg(target_os = "linux")]
use super::common::{PkgOps, default_use_sudo};
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct MacOsInstall {
pub brew: Option<&'static str>,
pub cask: Option<&'static str>,
}
impl MacOsInstall {
pub const fn brew(name: &'static str) -> Self {
Self {
brew: Some(name),
cask: None,
}
}
pub const fn cask(name: &'static str) -> Self {
Self {
brew: None,
cask: Some(name),
}
}
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct LinuxInstall {
pub apt: Option<&'static str>,
pub dnf: Option<&'static str>,
pub yum: Option<&'static str>,
pub zypper: Option<&'static str>,
pub pacman: Option<&'static str>,
pub apk: Option<&'static str>,
pub brew: Option<&'static str>,
}
impl LinuxInstall {
pub const fn uniform(name: &'static str) -> Self {
Self {
apt: Some(name),
dnf: Some(name),
yum: Some(name),
zypper: Some(name),
pacman: Some(name),
apk: Some(name),
brew: None,
}
}
pub const fn brew(name: &'static str) -> Self {
Self {
apt: None,
dnf: None,
yum: None,
zypper: None,
pacman: None,
apk: None,
brew: Some(name),
}
}
pub const fn none() -> Self {
Self {
apt: None,
dnf: None,
yum: None,
zypper: None,
pacman: None,
apk: None,
brew: None,
}
}
pub fn get(&self, pm: PackageManager) -> Option<&'static str> {
match pm {
PackageManager::Apt => self.apt,
PackageManager::Dnf => self.dnf,
PackageManager::Yum => self.yum,
PackageManager::Zypper => self.zypper,
PackageManager::Pacman => self.pacman,
PackageManager::Apk => self.apk,
PackageManager::Brew => self.brew,
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct WindowsInstall {
pub winget: Option<&'static str>,
pub choco: Option<&'static str>,
}
impl WindowsInstall {
pub const fn winget(id: &'static str) -> Self {
Self {
winget: Some(id),
choco: None,
}
}
pub const fn choco(name: &'static str) -> Self {
Self {
winget: None,
choco: Some(name),
}
}
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct BsdInstall {
pub pkg: Option<&'static str>,
}
impl BsdInstall {
pub const fn pkg(name: &'static str) -> Self {
Self { pkg: Some(name) }
}
}
pub type CustomInstallFn = fn(&str) -> Result<(), InstallError>;
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct DefaultHook {
pub description: &'static str,
pub script: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub platform: Option<&'static str>,
}
impl DefaultHook {
pub const fn new(description: &'static str, script: &'static str) -> Self {
Self {
description,
script,
platform: None,
}
}
pub const fn for_platform(
description: &'static str,
script: &'static str,
platform: &'static str,
) -> Self {
Self {
description,
script,
platform: Some(platform),
}
}
pub fn should_run_on_current_platform(&self) -> bool {
match self.platform {
None => true,
Some("macos") => cfg!(target_os = "macos"),
Some("linux") => cfg!(target_os = "linux"),
Some("windows") => cfg!(target_os = "windows"),
Some("bsd") | Some("freebsd") => cfg!(target_os = "freebsd"),
Some(_) => false,
}
}
}
#[derive(Debug, Clone, Copy, serde::Serialize)]
pub struct ToolSpec {
pub name: &'static str,
pub command: &'static str,
pub macos: Option<MacOsInstall>,
pub linux: Option<LinuxInstall>,
pub windows: Option<WindowsInstall>,
pub bsd: Option<BsdInstall>,
#[serde(skip)]
pub custom_install: Option<CustomInstallFn>,
#[serde(skip_serializing_if = "Option::is_none")]
pub default_hook: Option<DefaultHook>,
#[serde(skip_serializing_if = "Option::is_none")]
pub depends_on: Option<&'static [&'static str]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub depends_on_one_of: Option<&'static [&'static str]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<&'static str>,
}
impl ToolSpec {
pub fn is_satisfied(&self, min_hint: &str) -> bool {
cmd_satisfies(self.command, min_hint)
}
pub fn get_default_hook(&self) -> Option<&DefaultHook> {
self.default_hook
.as_ref()
.filter(|h| h.should_run_on_current_platform())
}
pub fn has_default_hook(&self) -> bool {
self.get_default_hook().is_some()
}
pub fn ensure(&self, min_hint: &str) -> Result<(), InstallError> {
if self.is_satisfied(min_hint) {
return Ok(());
}
self.install(min_hint)
}
fn install(&self, min_hint: &str) -> Result<(), InstallError> {
if let Some(custom_fn) = self.custom_install {
return custom_fn(min_hint);
}
#[cfg(target_os = "macos")]
{
return self.install_macos();
}
#[cfg(target_os = "linux")]
{
return self.install_linux();
}
#[cfg(target_os = "windows")]
{
return self.install_windows();
}
#[cfg(target_os = "freebsd")]
{
return self.install_bsd();
}
#[allow(unreachable_code)]
Err(InstallError::Unsupported)
}
#[cfg(target_os = "macos")]
fn install_macos(&self) -> Result<(), InstallError> {
let macos = self.macos.ok_or(InstallError::Unsupported)?;
if let Some(cask_name) = macos.cask {
if !has("brew") {
return Err(InstallError::Prereq(
"Homebrew not found. Install https://brew.sh and re-run.",
));
}
run("brew", &["install", "--cask", cask_name])?;
return Ok(());
}
if let Some(formula) = macos.brew {
if !has("brew") {
return Err(InstallError::Prereq(
"Homebrew not found. Install https://brew.sh and re-run.",
));
}
run("brew", &["install", formula])?;
return Ok(());
}
Err(InstallError::Unsupported)
}
#[cfg(target_os = "linux")]
fn install_linux(&self) -> Result<(), InstallError> {
let linux = self.linux.ok_or(InstallError::Unsupported)?;
if let Some(pm) = super::common::detect_linux_pm() {
if let Some(pkg_name) = linux.get(pm) {
let _ = PkgOps::update(pm, default_use_sudo());
return PkgOps::install(pm, pkg_name, default_use_sudo());
}
}
if let Some(brew_pkg) = linux.brew {
if has("brew") {
run("brew", &["install", brew_pkg])?;
return Ok(());
}
}
Err(InstallError::Prereq(
"No supported Linux package manager on PATH (apt/dnf/yum/zypper/pacman/apk/brew)",
))
}
#[cfg(target_os = "windows")]
fn install_windows(&self) -> Result<(), InstallError> {
let windows = self.windows.ok_or(InstallError::Unsupported)?;
if let Some(winget_id) = windows.winget {
if has("winget") {
run("winget", &["install", "-e", "--id", winget_id])?;
return Ok(());
}
}
if let Some(choco_pkg) = windows.choco {
if has("choco") {
run("choco", &["install", "-y", choco_pkg])?;
return Ok(());
}
}
if windows.winget.is_some() {
Err(InstallError::Prereq(
"winget not found. Install Windows Package Manager, then re-run.",
))
} else {
Err(InstallError::Prereq(
"chocolatey not found. Install Chocolatey, then re-run.",
))
}
}
#[cfg(target_os = "freebsd")]
fn install_bsd(&self) -> Result<(), InstallError> {
use super::common::{PkgOps, default_use_sudo};
let bsd = self.bsd.ok_or(InstallError::Unsupported)?;
if let Some(pkg_name) = bsd.pkg {
if let Some(pm) = super::common::detect_bsd_pm() {
let _ = PkgOps::update(pm, default_use_sudo());
return PkgOps::install(pm, pkg_name, default_use_sudo());
}
}
Err(InstallError::Prereq(
"No supported BSD package manager on PATH (pkg)",
))
}
}
#[macro_export]
macro_rules! define_tool {
($name:ident, {
command: $cmd:expr,
$(macos: { $($macos_key:ident: $macos_val:expr),* $(,)? },)?
$(linux: { $($linux_key:ident: $linux_val:expr),* $(,)? },)?
$(windows: { $($windows_key:ident: $windows_val:expr),* $(,)? },)?
$(bsd: { $($bsd_key:ident: $bsd_val:expr),* $(,)? },)?
$(custom_install: $custom:expr,)?
$(default_hook: { description: $hook_desc:expr, script: $hook_script:expr $(, platform: $hook_platform:expr)? },)?
$(depends_on: $deps:expr,)?
$(depends_on_one_of: $flex_deps:expr,)?
$(category: $category:expr,)?
}) => {
pub static $name: $crate::tools::spec::ToolSpec = $crate::tools::spec::ToolSpec {
name: stringify!($name),
command: $cmd,
macos: define_tool!(@macos $($($macos_key: $macos_val),*)?),
linux: define_tool!(@linux $($($linux_key: $linux_val),*)?),
windows: define_tool!(@windows $($($windows_key: $windows_val),*)?),
bsd: define_tool!(@bsd $($($bsd_key: $bsd_val),*)?),
custom_install: define_tool!(@custom $($custom)?),
default_hook: define_tool!(@default_hook $($hook_desc, $hook_script $(, $hook_platform)?)?),
depends_on: define_tool!(@depends_on $($deps)?),
depends_on_one_of: define_tool!(@depends_on_one_of $($flex_deps)?),
category: define_tool!(@category $($category)?),
};
#[allow(dead_code)] pub fn ensure(min_hint: &str) -> Result<(), $crate::tools::common::InstallError> {
$name.ensure(min_hint)
}
#[allow(dead_code)] pub fn add_handler(min_hint: &str) -> Result<(), $crate::tools::common::InstallError> {
$name.ensure(min_hint)
}
::inventory::submit! {
$crate::tools::spec::ToolEntry {
spec: &$name,
handler: add_handler,
}
}
};
(@macos) => { None };
(@macos brew: $val:expr) => {
Some($crate::tools::spec::MacOsInstall::brew($val))
};
(@macos cask: $val:expr) => {
Some($crate::tools::spec::MacOsInstall::cask($val))
};
(@macos brew: $brew:expr, cask: $cask:expr) => {
Some($crate::tools::spec::MacOsInstall { brew: Some($brew), cask: Some($cask) })
};
(@linux) => { None };
(@linux uniform: $val:expr) => {
Some($crate::tools::spec::LinuxInstall::uniform($val))
};
(@linux brew: $val:expr) => {
Some($crate::tools::spec::LinuxInstall::brew($val))
};
(@linux brew: $brew:expr, apk: $apk:expr) => {
Some($crate::tools::spec::LinuxInstall {
apt: None,
dnf: None,
yum: None,
zypper: None,
pacman: None,
apk: Some($apk),
brew: Some($brew),
})
};
(@linux apt: $apt:expr, dnf: $dnf:expr, pacman: $pacman:expr, apk: $apk:expr) => {
Some($crate::tools::spec::LinuxInstall {
apt: Some($apt),
dnf: Some($dnf),
yum: Some($dnf), zypper: Some($dnf),
pacman: Some($pacman),
apk: Some($apk),
brew: None,
})
};
(@linux apt: $apt:expr, dnf: $dnf:expr, yum: $yum:expr, zypper: $zypper:expr, pacman: $pacman:expr, apk: $apk:expr) => {
Some($crate::tools::spec::LinuxInstall {
apt: Some($apt),
dnf: Some($dnf),
yum: Some($yum),
zypper: Some($zypper),
pacman: Some($pacman),
apk: Some($apk),
brew: None,
})
};
(@windows) => { None };
(@windows winget: $val:expr) => {
Some($crate::tools::spec::WindowsInstall::winget($val))
};
(@windows choco: $val:expr) => {
Some($crate::tools::spec::WindowsInstall::choco($val))
};
(@windows winget: $winget:expr, choco: $choco:expr) => {
Some($crate::tools::spec::WindowsInstall { winget: Some($winget), choco: Some($choco) })
};
(@bsd) => { None };
(@bsd pkg: $val:expr) => {
Some($crate::tools::spec::BsdInstall::pkg($val))
};
(@custom) => { None };
(@custom $fn:expr) => { Some($fn) };
(@default_hook) => { None };
(@default_hook $desc:expr, $script:expr) => {
Some($crate::tools::spec::DefaultHook::new($desc, $script))
};
(@default_hook $desc:expr, $script:expr, $platform:expr) => {
Some($crate::tools::spec::DefaultHook::for_platform($desc, $script, $platform))
};
(@depends_on) => { None };
(@depends_on $deps:expr) => { Some($deps) };
(@depends_on_one_of) => { None };
(@depends_on_one_of $deps:expr) => { Some($deps) };
(@category) => { None };
(@category $cat:expr) => { Some($cat) };
}
#[allow(unused_imports)]
pub use define_tool;
#[derive(Debug, Clone, serde::Serialize)]
pub struct CustomInstallInfo {
pub has_custom_installer: bool,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ToolIndexEntry {
pub name: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub macos: Option<MacOsInstall>,
#[serde(skip_serializing_if = "Option::is_none")]
pub linux: Option<LinuxInstall>,
#[serde(skip_serializing_if = "Option::is_none")]
pub windows: Option<WindowsInstall>,
#[serde(skip_serializing_if = "Option::is_none")]
pub bsd: Option<BsdInstall>,
pub custom_install: CustomInstallInfo,
#[serde(skip_serializing_if = "Option::is_none")]
pub category: Option<String>,
}
impl From<&ToolSpec> for ToolIndexEntry {
fn from(spec: &ToolSpec) -> Self {
Self {
name: spec.name.to_lowercase(),
command: spec.command.to_string(),
macos: spec.macos,
linux: spec.linux,
windows: spec.windows,
bsd: spec.bsd,
custom_install: CustomInstallInfo {
has_custom_installer: spec.custom_install.is_some(),
},
category: spec.category.map(|s| s.to_string()),
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ToolIndex {
pub version: &'static str,
pub count: usize,
pub tools: Vec<ToolIndexEntry>,
}
impl ToolIndex {
pub const VERSION: &'static str = "1.0.0";
}
const MANUAL_TOOLS: &[(&str, &str)] = &[("nvm", "nvm"), ("rust", "rustc"), ("brew", "brew")];
pub fn generate_tool_index() -> ToolIndex {
let mut tools: Vec<ToolIndexEntry> = Vec::new();
for entry in iter_tools() {
tools.push(ToolIndexEntry::from(entry.spec));
}
for (name, command) in MANUAL_TOOLS {
tools.push(ToolIndexEntry {
name: name.to_string(),
command: command.to_string(),
macos: None,
linux: None,
windows: None,
bsd: None,
custom_install: CustomInstallInfo {
has_custom_installer: true,
},
category: None,
});
}
tools.sort_by(|a, b| a.name.cmp(&b.name));
ToolIndex {
version: ToolIndex::VERSION,
count: tools.len(),
tools,
}
}
pub fn generate_tool_index_json() -> String {
let index = generate_tool_index();
serde_json::to_string_pretty(&index).unwrap_or_else(|e| format!(r#"{{"error": "{}"}}"#, e))
}
pub fn list_tool_names() -> Vec<String> {
let mut names: Vec<String> = iter_tools().map(|e| e.spec.name.to_lowercase()).collect();
for (name, _) in MANUAL_TOOLS {
names.push(name.to_string());
}
names.sort();
names
}
pub fn get_tool_spec(name: &str) -> Option<&'static ToolSpec> {
let name_lower = name.to_lowercase();
iter_tools()
.find(|entry| entry.spec.name.to_lowercase() == name_lower)
.map(|entry| entry.spec)
}
pub fn get_tool_default_hook(name: &str) -> Option<&'static DefaultHook> {
get_tool_spec(name).and_then(|spec| spec.get_default_hook())
}
pub fn list_tools_with_default_hooks() -> Vec<(&'static str, &'static DefaultHook)> {
iter_tools()
.filter_map(|entry| {
entry
.spec
.get_default_hook()
.map(|hook| (entry.spec.name, hook))
})
.collect()
}
use rayon::prelude::*;
#[derive(Debug, Clone)]
pub struct ToolVersionStatus {
pub name: String,
pub version: String,
pub satisfied: bool,
pub known: bool,
}
#[derive(Debug, Default)]
pub struct VersionCheckSummary {
pub satisfied: Vec<(String, String)>,
pub needs_install: Vec<(String, String)>,
pub unknown: Vec<(String, String)>,
pub duration_ms: u64,
}
impl VersionCheckSummary {
pub fn summary_string(&self) -> String {
format!(
"Version check: {} satisfied, {} need install, {} unknown ({}ms)",
self.satisfied.len(),
self.needs_install.len(),
self.unknown.len(),
self.duration_ms
)
}
}
fn check_tool_version(name: &str, version: &str) -> ToolVersionStatus {
let name_lower = name.to_lowercase();
let spec = get_tool_spec(&name_lower);
let manual_cmd = MANUAL_TOOLS
.iter()
.find_map(|(n, cmd)| if *n == name_lower { Some(*cmd) } else { None });
if spec.is_none() && manual_cmd.is_none() {
return ToolVersionStatus {
name: name.to_string(),
version: version.to_string(),
satisfied: false,
known: false,
};
}
let satisfied = match (spec, manual_cmd) {
(Some(spec), _) => spec.is_satisfied(version),
(None, Some(cmd)) => has(cmd),
(None, None) => false,
};
ToolVersionStatus {
name: name.to_string(),
version: version.to_string(),
satisfied,
known: true,
}
}
pub fn check_tools_parallel<'a, I>(tools: I) -> VersionCheckSummary
where
I: Iterator<Item = (&'a str, &'a str)>,
{
let start = std::time::Instant::now();
let tool_list: Vec<(&str, &str)> = tools.collect();
let results: Vec<ToolVersionStatus> = tool_list
.par_iter()
.map(|(name, version)| check_tool_version(name, version))
.collect();
let mut summary = VersionCheckSummary::default();
for status in results {
if !status.known {
summary.unknown.push((status.name, status.version));
} else if status.satisfied {
summary.satisfied.push((status.name, status.version));
} else {
summary.needs_install.push((status.name, status.version));
}
}
summary.duration_ms = start.elapsed().as_millis() as u64;
summary
}
pub fn check_tools_sequential<'a, I>(tools: I) -> VersionCheckSummary
where
I: Iterator<Item = (&'a str, &'a str)>,
{
let start = std::time::Instant::now();
let mut summary = VersionCheckSummary::default();
for (name, version) in tools {
let status = check_tool_version(name, version);
if !status.known {
summary.unknown.push((status.name, status.version));
} else if status.satisfied {
summary.satisfied.push((status.name, status.version));
} else {
summary.needs_install.push((status.name, status.version));
}
}
summary.duration_ms = start.elapsed().as_millis() as u64;
summary
}
#[derive(Debug, Clone)]
pub struct ToolInstallInfo {
pub name: String,
pub version: String,
pub package_manager: PackageManager,
pub package_name: String,
}
#[derive(Debug, Default)]
pub struct ToolGroups {
pub by_package_manager:
std::collections::HashMap<PackageManager, Vec<(String, String, String)>>,
pub custom_install: Vec<(String, String)>,
pub unknown: Vec<(String, String)>,
}
impl ToolGroups {
pub fn has_package_manager_tools(&self) -> bool {
!self.by_package_manager.is_empty()
}
pub fn total_count(&self) -> usize {
let pm_count: usize = self.by_package_manager.values().map(|v| v.len()).sum();
pm_count + self.custom_install.len() + self.unknown.len()
}
}
pub fn get_tool_install_info(tool_name: &str, version: &str) -> Option<ToolInstallInfo> {
let spec = get_tool_spec(tool_name)?;
if spec.custom_install.is_some() {
return None;
}
#[cfg(target_os = "macos")]
{
if let Some(macos) = spec.macos {
if let Some(cask_name) = macos.cask {
return Some(ToolInstallInfo {
name: tool_name.to_string(),
version: version.to_string(),
package_manager: PackageManager::BrewCask,
package_name: cask_name.to_string(),
});
}
if let Some(brew_name) = macos.brew {
return Some(ToolInstallInfo {
name: tool_name.to_string(),
version: version.to_string(),
package_manager: PackageManager::Brew,
package_name: brew_name.to_string(),
});
}
}
}
#[cfg(target_os = "linux")]
{
if let Some(linux) = spec.linux {
if let Some(pm) = super::common::detect_linux_pm() {
if let Some(pkg_name) = linux.get(pm) {
return Some(ToolInstallInfo {
name: tool_name.to_string(),
version: version.to_string(),
package_manager: pm,
package_name: pkg_name.to_string(),
});
}
}
if let Some(brew_name) = linux.brew {
if super::common::has("brew") {
return Some(ToolInstallInfo {
name: tool_name.to_string(),
version: version.to_string(),
package_manager: PackageManager::Brew,
package_name: brew_name.to_string(),
});
}
}
}
}
#[cfg(target_os = "windows")]
{
if let Some(windows) = spec.windows {
if let Some(winget_id) = windows.winget {
if super::common::has("winget") {
return Some(ToolInstallInfo {
name: tool_name.to_string(),
version: version.to_string(),
package_manager: PackageManager::Winget,
package_name: winget_id.to_string(),
});
}
}
if let Some(choco_name) = windows.choco {
if super::common::has("choco") {
return Some(ToolInstallInfo {
name: tool_name.to_string(),
version: version.to_string(),
package_manager: PackageManager::Choco,
package_name: choco_name.to_string(),
});
}
}
}
}
#[cfg(target_os = "freebsd")]
{
if let Some(bsd) = spec.bsd {
if let Some(pkg_name) = bsd.pkg {
if super::common::has("pkg") {
return Some(ToolInstallInfo {
name: tool_name.to_string(),
version: version.to_string(),
package_manager: PackageManager::Pkg,
package_name: pkg_name.to_string(),
});
}
}
}
}
None
}
pub fn has_custom_installer(tool_name: &str) -> bool {
get_tool_spec(tool_name)
.map(|spec| spec.custom_install.is_some())
.unwrap_or(false)
|| MANUAL_TOOLS
.iter()
.any(|(name, _)| *name == tool_name.to_lowercase())
}
pub fn group_tools_for_installation<'a, I>(tools: I) -> ToolGroups
where
I: Iterator<Item = (&'a str, &'a str)>,
{
let mut groups = ToolGroups::default();
for (name, version) in tools {
let name_lower = name.to_lowercase();
let is_known = get_tool_spec(&name_lower).is_some()
|| MANUAL_TOOLS.iter().any(|(n, _)| *n == name_lower);
if !is_known {
groups.unknown.push((name.to_string(), version.to_string()));
continue;
}
if has_custom_installer(&name_lower) {
groups
.custom_install
.push((name.to_string(), version.to_string()));
continue;
}
if let Some(info) = get_tool_install_info(&name_lower, version) {
groups
.by_package_manager
.entry(info.package_manager)
.or_default()
.push((info.name, info.package_name, info.version));
} else {
groups
.custom_install
.push((name.to_string(), version.to_string()));
}
}
groups
}
pub fn get_tool_dependencies(tool_name: &str) -> &'static [&'static str] {
get_tool_spec(tool_name)
.and_then(|spec| spec.depends_on)
.unwrap_or(&[])
}
pub fn get_tool_flexible_dependencies(tool_name: &str) -> &'static [&'static str] {
get_tool_spec(tool_name)
.and_then(|spec| spec.depends_on_one_of)
.unwrap_or(&[])
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DependencyCheckResult {
Satisfied,
MissingRequired(Vec<String>),
WillInstallFlexible(String),
MissingFlexible {
needed: &'static str,
options: Vec<String>,
suggestion: Option<String>,
},
}
impl DependencyCheckResult {
pub fn is_satisfied(&self) -> bool {
matches!(
self,
DependencyCheckResult::Satisfied | DependencyCheckResult::WillInstallFlexible(_)
)
}
pub fn has_missing_required(&self) -> bool {
matches!(self, DependencyCheckResult::MissingRequired(_))
}
pub fn has_missing_flexible(&self) -> bool {
matches!(self, DependencyCheckResult::MissingFlexible { .. })
}
}
pub fn should_ignore_missing_deps() -> bool {
std::env::var("JARVY_IGNORE_MISSING_DEPS")
.map(|v| v == "1" || v.to_lowercase() == "true")
.unwrap_or(false)
}
pub fn check_tool_dependencies(
tool_name: &str,
config_tools: &std::collections::HashSet<String>,
installed_tools: &std::collections::HashSet<String>,
) -> DependencyCheckResult {
let spec = match get_tool_spec(tool_name) {
Some(s) => s,
None => return DependencyCheckResult::Satisfied, };
if let Some(strict_deps) = spec.depends_on {
let missing: Vec<String> = strict_deps
.iter()
.filter(|dep| {
let dep_lower = dep.to_lowercase();
!installed_tools.contains(&dep_lower) && !config_tools.contains(&dep_lower)
})
.map(|s| s.to_string())
.collect();
if !missing.is_empty() {
return DependencyCheckResult::MissingRequired(missing);
}
}
if let Some(flex_deps) = spec.depends_on_one_of {
let any_installed = flex_deps.iter().any(|dep| {
let dep_lower = dep.to_lowercase();
installed_tools.contains(&dep_lower)
});
if any_installed {
return DependencyCheckResult::Satisfied;
}
let in_config: Vec<&str> = flex_deps
.iter()
.filter(|dep| {
let dep_lower = dep.to_lowercase();
config_tools.contains(&dep_lower)
})
.copied()
.collect();
if !in_config.is_empty() {
return DependencyCheckResult::WillInstallFlexible(in_config[0].to_string());
}
return DependencyCheckResult::MissingFlexible {
needed: "one of the following",
options: flex_deps.iter().map(|s| s.to_string()).collect(),
suggestion: flex_deps.first().map(|s| s.to_string()),
};
}
DependencyCheckResult::Satisfied
}
pub fn order_tools_by_dependencies<'a, I>(tools: I) -> Vec<(String, String)>
where
I: Iterator<Item = (&'a str, &'a str)>,
{
use std::collections::{HashMap, HashSet, VecDeque};
let tool_list: Vec<(String, String)> = tools
.map(|(n, v)| (n.to_lowercase(), v.to_string()))
.collect();
let tool_set: HashSet<&str> = tool_list.iter().map(|(n, _)| n.as_str()).collect();
let mut dependents: HashMap<&str, Vec<&str>> = HashMap::new();
let mut in_degree: HashMap<&str, usize> = HashMap::new();
for (name, _) in &tool_list {
in_degree.entry(name.as_str()).or_insert(0);
let deps = get_tool_dependencies(name);
for dep in deps {
let dep_lower = dep.to_lowercase();
if tool_set.contains(dep_lower.as_str()) {
*in_degree.entry(name.as_str()).or_insert(0) += 1;
dependents
.entry(dep_lower.leak()) .or_default()
.push(name.as_str());
}
}
let flex_deps = get_tool_flexible_dependencies(name);
if !flex_deps.is_empty() {
if let Some(flex_dep) = flex_deps.iter().find(|dep| {
let dep_lower = dep.to_lowercase();
tool_set.contains(dep_lower.as_str())
}) {
let dep_lower = flex_dep.to_lowercase();
*in_degree.entry(name.as_str()).or_insert(0) += 1;
dependents
.entry(dep_lower.leak())
.or_default()
.push(name.as_str());
}
}
}
let version_map: HashMap<&str, &str> = tool_list
.iter()
.map(|(n, v)| (n.as_str(), v.as_str()))
.collect();
let mut queue: VecDeque<&str> = in_degree
.iter()
.filter(|(_, deg)| **deg == 0)
.map(|(name, _)| *name)
.collect();
let mut result: Vec<(String, String)> = Vec::with_capacity(tool_list.len());
while let Some(tool) = queue.pop_front() {
if let Some(&version) = version_map.get(tool) {
result.push((tool.to_string(), version.to_string()));
}
if let Some(deps) = dependents.get(tool) {
for &dependent in deps {
if let Some(deg) = in_degree.get_mut(dependent) {
*deg = deg.saturating_sub(1);
if *deg == 0 {
queue.push_back(dependent);
}
}
}
}
}
if result.len() != tool_list.len() {
eprintln!("Warning: Circular dependency detected. Installing tools in original order.");
return tool_list;
}
result
}
pub fn tool_has_dependencies(tool_name: &str) -> bool {
!get_tool_dependencies(tool_name).is_empty()
}
pub fn tool_has_flexible_dependencies(tool_name: &str) -> bool {
!get_tool_flexible_dependencies(tool_name).is_empty()
}
pub fn tool_has_any_dependencies(tool_name: &str) -> bool {
tool_has_dependencies(tool_name) || tool_has_flexible_dependencies(tool_name)
}
#[cfg(test)]
mod tests {
use super::*;
static TEST_TOOL: ToolSpec = ToolSpec {
name: "test",
command: "test_cmd",
macos: Some(MacOsInstall {
brew: Some("test"),
cask: None,
}),
linux: Some(LinuxInstall::uniform("test")),
windows: Some(WindowsInstall {
winget: Some("Test.Test"),
choco: Some("test"),
}),
bsd: None,
custom_install: None,
default_hook: None,
depends_on: None,
depends_on_one_of: None,
category: None,
};
static TEST_TOOL_WITH_HOOK: ToolSpec = ToolSpec {
name: "test_hooked",
command: "test_hooked_cmd",
macos: Some(MacOsInstall::brew("test")),
linux: Some(LinuxInstall::uniform("test")),
windows: None,
bsd: None,
custom_install: None,
default_hook: Some(DefaultHook::new(
"Configure test tool",
"echo 'test hook executed'",
)),
depends_on: None,
depends_on_one_of: None,
category: None,
};
#[test]
fn test_tool_spec_fields() {
assert_eq!(TEST_TOOL.name, "test");
assert_eq!(TEST_TOOL.command, "test_cmd");
assert!(TEST_TOOL.macos.is_some());
assert!(TEST_TOOL.linux.is_some());
assert!(TEST_TOOL.windows.is_some());
}
#[test]
fn test_linux_install_uniform() {
let linux = LinuxInstall::uniform("git");
assert_eq!(linux.apt, Some("git"));
assert_eq!(linux.dnf, Some("git"));
assert_eq!(linux.pacman, Some("git"));
}
#[test]
fn test_linux_install_get() {
let linux = LinuxInstall::uniform("git");
assert_eq!(linux.get(PackageManager::Apt), Some("git"));
assert_eq!(linux.get(PackageManager::Dnf), Some("git"));
assert_eq!(linux.get(PackageManager::Brew), None); }
#[test]
fn test_linux_install_brew() {
let linux = LinuxInstall::brew("upbound/tap/up");
assert_eq!(linux.brew, Some("upbound/tap/up"));
assert_eq!(linux.apt, None);
assert_eq!(linux.get(PackageManager::Brew), Some("upbound/tap/up"));
}
#[test]
fn test_macos_install_helpers() {
let brew = MacOsInstall::brew("git");
assert_eq!(brew.brew, Some("git"));
assert_eq!(brew.cask, None);
let cask = MacOsInstall::cask("docker");
assert_eq!(cask.brew, None);
assert_eq!(cask.cask, Some("docker"));
}
#[test]
fn test_windows_install_helpers() {
let winget = WindowsInstall::winget("Git.Git");
assert_eq!(winget.winget, Some("Git.Git"));
assert_eq!(winget.choco, None);
let choco = WindowsInstall::choco("git");
assert_eq!(choco.winget, None);
assert_eq!(choco.choco, Some("git"));
}
#[test]
fn test_is_satisfied_nonexistent() {
let tool = ToolSpec {
name: "nonexistent",
command: "definitely_not_a_real_command_xyz",
macos: None,
linux: None,
windows: None,
bsd: None,
custom_install: None,
default_hook: None,
depends_on: None,
depends_on_one_of: None,
category: None,
};
assert!(!tool.is_satisfied("1.0"));
}
#[test]
fn test_tool_index_entry_from_spec() {
let entry = ToolIndexEntry::from(&TEST_TOOL);
assert_eq!(entry.name, "test");
assert_eq!(entry.command, "test_cmd");
assert!(entry.macos.is_some());
assert!(entry.linux.is_some());
assert!(entry.windows.is_some());
assert!(!entry.custom_install.has_custom_installer);
}
#[test]
fn test_tool_index_entry_with_custom_installer() {
let custom_tool = ToolSpec {
name: "custom",
command: "custom_cmd",
macos: None,
linux: None,
windows: None,
bsd: None,
custom_install: Some(|_| Ok(())),
default_hook: None,
depends_on: None,
depends_on_one_of: None,
category: None,
};
let entry = ToolIndexEntry::from(&custom_tool);
assert!(entry.custom_install.has_custom_installer);
}
#[test]
fn test_generate_tool_index_has_tools() {
let index = generate_tool_index();
assert!(
index.count >= 3,
"Expected at least 3 tools, got {}",
index.count
);
assert_eq!(index.tools.len(), index.count);
}
#[test]
fn test_generate_tool_index_version() {
let index = generate_tool_index();
assert_eq!(index.version, ToolIndex::VERSION);
}
#[test]
fn test_generate_tool_index_sorted() {
let index = generate_tool_index();
let names: Vec<&str> = index.tools.iter().map(|t| t.name.as_str()).collect();
let mut sorted_names = names.clone();
sorted_names.sort();
assert_eq!(names, sorted_names, "Tool index should be sorted by name");
}
#[test]
fn test_generate_tool_index_contains_manual_tools() {
let index = generate_tool_index();
let names: Vec<&str> = index.tools.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"nvm"), "Should contain nvm");
assert!(names.contains(&"rust"), "Should contain rust");
assert!(names.contains(&"brew"), "Should contain brew");
}
#[test]
fn test_generate_tool_index_json_valid() {
let json = generate_tool_index_json();
let parsed: Result<serde_json::Value, _> = serde_json::from_str(&json);
assert!(parsed.is_ok(), "Generated JSON should be valid: {}", json);
let value = parsed.unwrap();
assert!(value.get("version").is_some());
assert!(value.get("count").is_some());
assert!(value.get("tools").is_some());
}
#[test]
fn test_list_tool_names_not_empty() {
let names = list_tool_names();
assert!(!names.is_empty(), "Tool names list should not be empty");
}
#[test]
fn test_list_tool_names_sorted() {
let names = list_tool_names();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "Tool names should be sorted");
}
#[test]
fn test_list_tool_names_contains_manual_tools() {
let names = list_tool_names();
assert!(names.contains(&"nvm".to_string()), "Should contain nvm");
assert!(names.contains(&"rust".to_string()), "Should contain rust");
assert!(names.contains(&"brew".to_string()), "Should contain brew");
}
#[test]
fn test_tool_spec_serialization() {
let json = serde_json::to_string(&TEST_TOOL);
assert!(json.is_ok(), "ToolSpec should serialize to JSON");
let json_str = json.unwrap();
assert!(json_str.contains("\"name\":\"test\""));
assert!(json_str.contains("\"command\":\"test_cmd\""));
assert!(!json_str.contains("custom_install"));
}
#[test]
fn test_tool_index_serialization() {
let index = generate_tool_index();
let json = serde_json::to_string_pretty(&index);
assert!(json.is_ok(), "ToolIndex should serialize to JSON");
let json_str = json.unwrap();
assert!(json_str.contains("\"version\""));
assert!(json_str.contains("\"count\""));
assert!(json_str.contains("\"tools\""));
}
#[test]
fn test_default_hook_new() {
let hook = DefaultHook::new("Test hook", "echo test");
assert_eq!(hook.description, "Test hook");
assert_eq!(hook.script, "echo test");
assert!(hook.platform.is_none());
}
#[test]
fn test_default_hook_for_platform() {
let hook = DefaultHook::for_platform("macOS only", "brew info", "macos");
assert_eq!(hook.description, "macOS only");
assert_eq!(hook.script, "brew info");
assert_eq!(hook.platform, Some("macos"));
}
#[test]
fn test_default_hook_should_run_no_platform() {
let hook = DefaultHook::new("All platforms", "echo hello");
assert!(hook.should_run_on_current_platform());
}
#[test]
fn test_default_hook_should_run_current_platform() {
#[cfg(target_os = "macos")]
{
let hook = DefaultHook::for_platform("macOS hook", "ls", "macos");
assert!(hook.should_run_on_current_platform());
let other = DefaultHook::for_platform("Linux hook", "ls", "linux");
assert!(!other.should_run_on_current_platform());
}
#[cfg(target_os = "linux")]
{
let hook = DefaultHook::for_platform("Linux hook", "ls", "linux");
assert!(hook.should_run_on_current_platform());
let other = DefaultHook::for_platform("macOS hook", "ls", "macos");
assert!(!other.should_run_on_current_platform());
}
#[cfg(target_os = "windows")]
{
let hook = DefaultHook::for_platform("Windows hook", "dir", "windows");
assert!(hook.should_run_on_current_platform());
let other = DefaultHook::for_platform("Linux hook", "ls", "linux");
assert!(!other.should_run_on_current_platform());
}
}
#[test]
fn test_default_hook_unknown_platform() {
let hook = DefaultHook::for_platform("Unknown", "test", "bsd");
assert!(!hook.should_run_on_current_platform());
}
#[test]
fn test_tool_spec_get_default_hook() {
assert!(TEST_TOOL.get_default_hook().is_none());
assert!(!TEST_TOOL.has_default_hook());
let hook = TEST_TOOL_WITH_HOOK.get_default_hook();
assert!(hook.is_some());
assert!(TEST_TOOL_WITH_HOOK.has_default_hook());
let h = hook.unwrap();
assert_eq!(h.description, "Configure test tool");
assert_eq!(h.script, "echo 'test hook executed'");
}
#[test]
fn test_tool_spec_with_platform_hook() {
static PLATFORM_TOOL: ToolSpec = ToolSpec {
name: "platform_test",
command: "platform_cmd",
macos: None,
linux: None,
windows: None,
bsd: None,
custom_install: None,
#[cfg(target_os = "macos")]
default_hook: Some(DefaultHook::for_platform(
"macOS hook",
"brew info",
"macos",
)),
#[cfg(target_os = "linux")]
default_hook: Some(DefaultHook::for_platform("Linux hook", "apt info", "linux")),
#[cfg(target_os = "windows")]
default_hook: Some(DefaultHook::for_platform(
"Windows hook",
"winget info",
"windows",
)),
depends_on: None,
depends_on_one_of: None,
category: None,
};
assert!(PLATFORM_TOOL.has_default_hook());
}
#[test]
fn test_default_hook_serialization() {
let hook = DefaultHook::new("Test", "echo test");
let json = serde_json::to_string(&hook);
assert!(json.is_ok());
let json_str = json.unwrap();
assert!(json_str.contains("\"description\":\"Test\""));
assert!(json_str.contains("\"script\":\"echo test\""));
assert!(!json_str.contains("platform"));
}
#[test]
fn test_default_hook_with_platform_serialization() {
let hook = DefaultHook::for_platform("macOS", "ls", "macos");
let json = serde_json::to_string(&hook);
assert!(json.is_ok());
let json_str = json.unwrap();
assert!(json_str.contains("\"platform\":\"macos\""));
}
#[test]
fn test_tool_groups_default() {
let groups = ToolGroups::default();
assert!(groups.by_package_manager.is_empty());
assert!(groups.custom_install.is_empty());
assert!(groups.unknown.is_empty());
}
#[test]
fn test_tool_install_info_struct() {
use crate::tools::common::PackageManager;
let info = ToolInstallInfo {
name: "jq".to_string(),
version: "latest".to_string(),
package_manager: PackageManager::Brew,
package_name: "jq".to_string(),
};
assert_eq!(info.name, "jq");
assert_eq!(info.version, "latest");
assert_eq!(info.package_manager, PackageManager::Brew);
assert_eq!(info.package_name, "jq");
}
#[test]
fn test_group_tools_empty() {
let tools: Vec<(&str, &str)> = vec![];
let groups = group_tools_for_installation(tools.into_iter());
assert!(groups.by_package_manager.is_empty());
assert!(groups.custom_install.is_empty());
assert!(groups.unknown.is_empty());
}
#[test]
fn test_group_tools_with_unknown() {
let tools = vec![("nonexistent_tool_xyz", "1.0")];
let groups = group_tools_for_installation(tools.into_iter());
assert_eq!(groups.unknown.len(), 1);
assert_eq!(groups.unknown[0].0, "nonexistent_tool_xyz");
}
#[test]
fn test_has_custom_installer_known_tools() {
assert!(has_custom_installer("brew"));
assert!(has_custom_installer("rust"));
assert!(has_custom_installer("nvm"));
assert!(!has_custom_installer("jq"));
assert!(!has_custom_installer("git"));
}
#[test]
fn test_get_tool_dependencies_no_deps() {
let deps = get_tool_dependencies("nonexistent");
assert!(deps.is_empty());
}
#[test]
fn test_tool_has_dependencies() {
assert!(!tool_has_dependencies("nonexistent"));
assert!(!tool_has_dependencies("git"));
}
#[test]
fn test_order_tools_no_dependencies() {
let tools = vec![("git", "2.0"), ("jq", "1.6"), ("curl", "7.0")];
let ordered = order_tools_by_dependencies(tools.into_iter());
assert_eq!(ordered.len(), 3);
assert!(ordered.iter().any(|(n, _)| n == "git"));
assert!(ordered.iter().any(|(n, _)| n == "jq"));
assert!(ordered.iter().any(|(n, _)| n == "curl"));
}
#[test]
fn test_order_tools_empty_input() {
let tools: Vec<(&str, &str)> = vec![];
let ordered = order_tools_by_dependencies(tools.into_iter());
assert!(ordered.is_empty());
}
#[test]
fn test_order_tools_single_tool() {
let tools = vec![("git", "2.0")];
let ordered = order_tools_by_dependencies(tools.into_iter());
assert_eq!(ordered.len(), 1);
assert_eq!(ordered[0].0, "git");
assert_eq!(ordered[0].1, "2.0");
}
#[test]
fn test_order_tools_preserves_versions() {
let tools = vec![("git", "2.40.0"), ("jq", "1.7")];
let ordered = order_tools_by_dependencies(tools.into_iter());
let git_entry = ordered.iter().find(|(n, _)| n == "git").unwrap();
let jq_entry = ordered.iter().find(|(n, _)| n == "jq").unwrap();
assert_eq!(git_entry.1, "2.40.0");
assert_eq!(jq_entry.1, "1.7");
}
#[test]
fn test_order_tools_case_insensitive() {
let tools = vec![("GIT", "2.0"), ("JQ", "1.6")];
let ordered = order_tools_by_dependencies(tools.into_iter());
for (name, _) in &ordered {
assert_eq!(name, &name.to_lowercase());
}
}
#[test]
fn test_get_tool_flexible_dependencies_no_deps() {
let deps = get_tool_flexible_dependencies("nonexistent");
assert!(deps.is_empty());
let deps = get_tool_flexible_dependencies("git");
assert!(deps.is_empty());
}
#[test]
fn test_tool_has_flexible_dependencies() {
assert!(!tool_has_flexible_dependencies("nonexistent"));
assert!(!tool_has_flexible_dependencies("git"));
}
#[test]
fn test_tool_has_any_dependencies() {
assert!(!tool_has_any_dependencies("nonexistent"));
assert!(!tool_has_any_dependencies("git"));
}
#[test]
fn test_dependency_check_result_methods() {
let satisfied = DependencyCheckResult::Satisfied;
assert!(satisfied.is_satisfied());
assert!(!satisfied.has_missing_required());
assert!(!satisfied.has_missing_flexible());
let will_install = DependencyCheckResult::WillInstallFlexible("docker".to_string());
assert!(will_install.is_satisfied()); assert!(!will_install.has_missing_required());
assert!(!will_install.has_missing_flexible());
let missing_req = DependencyCheckResult::MissingRequired(vec!["docker".to_string()]);
assert!(!missing_req.is_satisfied());
assert!(missing_req.has_missing_required());
assert!(!missing_req.has_missing_flexible());
let missing_flex = DependencyCheckResult::MissingFlexible {
needed: "container runtime",
options: vec!["docker".to_string(), "podman".to_string()],
suggestion: Some("docker".to_string()),
};
assert!(!missing_flex.is_satisfied());
assert!(!missing_flex.has_missing_required());
assert!(missing_flex.has_missing_flexible());
}
#[test]
fn test_check_tool_dependencies_unknown_tool() {
use std::collections::HashSet;
let config_tools = HashSet::new();
let installed_tools = HashSet::new();
let result =
check_tool_dependencies("nonexistent_tool_xyz", &config_tools, &installed_tools);
assert_eq!(result, DependencyCheckResult::Satisfied);
}
#[test]
fn test_check_tool_dependencies_no_deps() {
use std::collections::HashSet;
let config_tools = HashSet::new();
let installed_tools = HashSet::new();
let result = check_tool_dependencies("git", &config_tools, &installed_tools);
assert_eq!(result, DependencyCheckResult::Satisfied);
}
#[test]
fn test_check_tool_dependencies_strict_in_config() {
use std::collections::HashSet;
let mut config_tools = HashSet::new();
config_tools.insert("docker".to_string());
let installed_tools = HashSet::new();
let result = check_tool_dependencies("lazydocker", &config_tools, &installed_tools);
assert!(result.is_satisfied());
}
#[test]
fn test_check_tool_dependencies_strict_installed() {
use std::collections::HashSet;
let config_tools = HashSet::new();
let mut installed_tools = HashSet::new();
installed_tools.insert("docker".to_string());
let result = check_tool_dependencies("lazydocker", &config_tools, &installed_tools);
assert!(result.is_satisfied());
}
#[test]
fn test_check_tool_dependencies_strict_missing() {
use std::collections::HashSet;
let config_tools = HashSet::new();
let installed_tools = HashSet::new();
let result = check_tool_dependencies("lazydocker", &config_tools, &installed_tools);
assert!(result.has_missing_required());
if let DependencyCheckResult::MissingRequired(missing) = result {
assert!(missing.contains(&"docker".to_string()));
} else {
panic!("Expected MissingRequired");
}
}
}