use std::fmt;
#[derive(Debug)]
pub enum InstallerError {
MissingDependency {
name: String,
hint: Option<String>,
},
PythonVersionUnsupported {
detected: String,
minimum: String,
maximum: Option<String>,
},
PythonNotFound {
detail: String,
},
CommandFailed {
command: String,
exit_code: Option<i32>,
stderr: String,
},
EnvError {
var: String,
detail: String,
},
PathNotFound {
path: String,
purpose: String,
},
NvidiaContamination {
packages: Vec<String>,
},
PackageBlocked {
package: String,
component: String,
},
RocmNotFound {
detail: String,
},
VenvError {
path: String,
detail: String,
},
Other {
message: String,
},
}
impl fmt::Display for InstallerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingDependency { name, hint } => {
write!(f, "Missing dependency: {name}")?;
if let Some(h) = hint {
write!(f, " (hint: {h})")?;
}
Ok(())
}
Self::PythonVersionUnsupported {
detected,
minimum,
maximum,
} => {
write!(f, "Unsupported Python {detected} (minimum: {minimum}")?;
if let Some(max) = maximum {
write!(f, ", maximum: {max}")?;
}
write!(f, ")")
}
Self::PythonNotFound { detail } => {
write!(f, "Python interpreter not found: {detail}")
}
Self::CommandFailed {
command,
exit_code,
stderr,
} => {
write!(f, "Command failed: {command}")?;
if let Some(code) = exit_code {
write!(f, " (exit code {code})")?;
}
if !stderr.is_empty() {
write!(f, ": {stderr}")?;
}
Ok(())
}
Self::EnvError { var, detail } => {
write!(f, "Environment error: {var}: {detail}")
}
Self::PathNotFound { path, purpose } => {
write!(f, "Path not found: {path} (needed for {purpose})")
}
Self::NvidiaContamination { packages } => {
write!(
f,
"NVIDIA/CUDA contamination detected: {}",
packages.join(", ")
)
}
Self::PackageBlocked { package, component } => {
write!(f, "Blocked package '{package}' in component '{component}'")
}
Self::RocmNotFound { detail } => {
write!(f, "ROCm not found: {detail}")
}
Self::VenvError { path, detail } => {
write!(f, "Virtualenv error at {path}: {detail}")
}
Self::Other { message } => write!(f, "{message}"),
}
}
}
impl std::error::Error for InstallerError {}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct PythonVersion {
pub major: u32,
pub minor: u32,
}
impl PythonVersion {
pub fn parse(version_str: &str) -> Option<Self> {
let parts: Vec<&str> = version_str.trim().split('.').collect();
if parts.len() < 2 {
return None;
}
let major = parts[0].parse::<u32>().ok()?;
let minor = parts[1].parse::<u32>().ok()?;
Some(Self { major, minor })
}
pub fn to_short_string(&self) -> String {
format!("{}.{}", self.major, self.minor)
}
}
impl fmt::Display for PythonVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}", self.major, self.minor)
}
}
pub const MIN_PYTHON_VERSION: PythonVersion = PythonVersion {
major: 3,
minor: 10,
};
pub const MAX_ROCM_TORCH_PYTHON: PythonVersion = PythonVersion {
major: 3,
minor: 13,
};
pub fn is_python_supported(version: &PythonVersion) -> bool {
version >= &MIN_PYTHON_VERSION
}
pub fn is_python_supported_for_rocm_torch(version: &PythonVersion) -> bool {
version >= &MIN_PYTHON_VERSION && version <= &MAX_ROCM_TORCH_PYTHON
}
pub fn detect_python_version(python_bin: &str) -> Option<PythonVersion> {
let output = std::process::Command::new(python_bin)
.args([
"-c",
"import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')",
])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
PythonVersion::parse(&stdout)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogLevel {
Info,
Warn,
Error,
}
impl fmt::Display for LogLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LogLevel::Info => write!(f, "INFO"),
LogLevel::Warn => write!(f, "WARN"),
LogLevel::Error => write!(f, "ERROR"),
}
}
}
fn should_disable_color() -> bool {
if std::env::var("NO_COLOR").is_ok() {
return true;
}
!atty_is_stderr()
}
fn atty_is_stderr() -> bool {
use std::io::IsTerminal;
std::io::stderr().is_terminal()
}
pub fn log(level: LogLevel, message: &str) {
let formatted = format!("[mlstack][{level}] {message}");
eprintln!("{formatted}");
}
pub fn log_info(message: &str) {
log(LogLevel::Info, message);
}
pub fn log_warn(message: &str) {
log(LogLevel::Warn, message);
}
pub fn log_error(message: &str) {
log(LogLevel::Error, message);
}
pub fn log_colored(level: LogLevel, message: &str) {
if should_disable_color() {
log(level, message);
} else {
let color_code = match level {
LogLevel::Info => "\x1b[0;34m", LogLevel::Warn => "\x1b[1;33m", LogLevel::Error => "\x1b[0;31m", };
let reset = "\x1b[0m";
eprintln!("{color_code}[mlstack][{level}]{reset} {message}");
}
}
#[derive(Debug)]
pub struct ProgressTracker {
current: u32,
total: u32,
component: Option<String>,
}
impl ProgressTracker {
pub fn new(total: u32) -> Self {
Self {
current: 0,
total,
component: None,
}
}
pub fn with_component(total: u32, component: &str) -> Self {
Self {
current: 0,
total,
component: Some(component.to_string()),
}
}
pub fn advance(&mut self, description: &str) -> u32 {
self.current += 1;
let prefix = match &self.component {
Some(c) => format!(
"Step {}/{} [{}]: {}",
self.current, self.total, c, description
),
None => format!("Step {}/{}: {}", self.current, self.total, description),
};
log_info(&prefix);
self.current
}
pub fn current(&self) -> u32 {
self.current
}
pub fn total(&self) -> u32 {
self.total
}
pub fn is_complete(&self) -> bool {
self.current >= self.total
}
pub fn format_step(&self, description: &str) -> String {
match &self.component {
Some(c) => format!(
"Step {}/{} [{}]: {}",
self.current, self.total, c, description
),
None => format!("Step {}/{}: {}", self.current, self.total, description),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_error_display_missing_dependency() {
let err = InstallerError::MissingDependency {
name: "cmake".to_string(),
hint: Some("install via apt: sudo apt install cmake".to_string()),
};
let msg = format!("{err}");
assert!(msg.contains("Missing dependency: cmake"));
assert!(msg.contains("hint: install via apt"));
}
#[test]
fn test_error_display_missing_dependency_no_hint() {
let err = InstallerError::MissingDependency {
name: "git".to_string(),
hint: None,
};
let msg = format!("{err}");
assert!(msg.contains("Missing dependency: git"));
assert!(!msg.contains("hint:"));
}
#[test]
fn test_error_display_python_unsupported() {
let err = InstallerError::PythonVersionUnsupported {
detected: "3.8".to_string(),
minimum: "3.10".to_string(),
maximum: None,
};
let msg = format!("{err}");
assert!(msg.contains("Unsupported Python 3.8"));
assert!(msg.contains("minimum: 3.10"));
}
#[test]
fn test_error_display_python_unsupported_rocm() {
let err = InstallerError::PythonVersionUnsupported {
detected: "3.14".to_string(),
minimum: "3.10".to_string(),
maximum: Some("3.13".to_string()),
};
let msg = format!("{err}");
assert!(msg.contains("maximum: 3.13"));
}
#[test]
fn test_error_display_python_not_found() {
let err = InstallerError::PythonNotFound {
detail: "no python3 binary in PATH".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("Python interpreter not found"));
assert!(msg.contains("no python3 binary"));
}
#[test]
fn test_error_display_command_failed() {
let err = InstallerError::CommandFailed {
command: "pip install torch".to_string(),
exit_code: Some(1),
stderr: "error: no matching distribution".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("Command failed: pip install torch"));
assert!(msg.contains("exit code 1"));
assert!(msg.contains("no matching distribution"));
}
#[test]
fn test_error_display_command_failed_no_stderr() {
let err = InstallerError::CommandFailed {
command: "make".to_string(),
exit_code: Some(2),
stderr: String::new(),
};
let msg = format!("{err}");
assert!(msg.contains("Command failed: make"));
assert!(msg.contains("exit code 2"));
}
#[test]
fn test_error_display_env_error() {
let err = InstallerError::EnvError {
var: "ROCM_PATH".to_string(),
detail: "not set and ROCm not detected".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("ROCM_PATH"));
assert!(msg.contains("not set"));
}
#[test]
fn test_error_display_path_not_found() {
let err = InstallerError::PathNotFound {
path: "/opt/rocm".to_string(),
purpose: "ROCm installation".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("/opt/rocm"));
assert!(msg.contains("ROCm installation"));
}
#[test]
fn test_error_display_nvidia_contamination() {
let err = InstallerError::NvidiaContamination {
packages: vec!["nvidia-cublas".to_string(), "pytorch-cuda".to_string()],
};
let msg = format!("{err}");
assert!(msg.contains("NVIDIA/CUDA contamination"));
assert!(msg.contains("nvidia-cublas"));
}
#[test]
fn test_error_display_package_blocked() {
let err = InstallerError::PackageBlocked {
package: "triton".to_string(),
component: "pytorch".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("Blocked package 'triton'"));
assert!(msg.contains("pytorch"));
}
#[test]
fn test_error_display_rocm_not_found() {
let err = InstallerError::RocmNotFound {
detail: "no ROCm installation detected".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("ROCm not found"));
}
#[test]
fn test_error_display_venv_error() {
let err = InstallerError::VenvError {
path: "/home/user/.mlstack/venvs/main".to_string(),
detail: "python binary not found".to_string(),
};
let msg = format!("{err}");
assert!(msg.contains("Virtualenv error"));
assert!(msg.contains("/home/user/.mlstack/venvs/main"));
}
#[test]
fn test_error_display_other() {
let err = InstallerError::Other {
message: "something went wrong".to_string(),
};
let msg = format!("{err}");
assert_eq!(msg, "something went wrong");
}
#[test]
fn test_error_implements_std_error() {
fn assert_error<E: std::error::Error>(_: &E) {}
let err = InstallerError::MissingDependency {
name: "test".to_string(),
hint: None,
};
assert_error(&err);
}
#[test]
fn test_python_version_parse() {
let v = PythonVersion::parse("3.10").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, 10);
}
#[test]
fn test_python_version_parse_three_part() {
let v = PythonVersion::parse("3.10.4").unwrap();
assert_eq!(v.major, 3);
assert_eq!(v.minor, 10);
}
#[test]
fn test_python_version_parse_invalid() {
assert!(PythonVersion::parse("").is_none());
assert!(PythonVersion::parse("3").is_none());
assert!(PythonVersion::parse("abc").is_none());
assert!(PythonVersion::parse("x.y").is_none());
}
#[test]
fn test_python_version_ordering() {
let v310 = PythonVersion {
major: 3,
minor: 10,
};
let v311 = PythonVersion {
major: 3,
minor: 11,
};
let v313 = PythonVersion {
major: 3,
minor: 13,
};
let v314 = PythonVersion {
major: 3,
minor: 14,
};
assert!(v310 < v311);
assert!(v311 < v313);
assert!(v313 < v314);
}
#[test]
fn test_is_python_supported_general() {
assert!(!is_python_supported(&PythonVersion { major: 3, minor: 8 }));
assert!(!is_python_supported(&PythonVersion { major: 3, minor: 9 }));
assert!(is_python_supported(&PythonVersion {
major: 3,
minor: 10
}));
assert!(is_python_supported(&PythonVersion {
major: 3,
minor: 11
}));
assert!(is_python_supported(&PythonVersion {
major: 3,
minor: 12
}));
assert!(is_python_supported(&PythonVersion {
major: 3,
minor: 13
}));
assert!(is_python_supported(&PythonVersion {
major: 3,
minor: 14
}));
}
#[test]
fn test_is_python_supported_rocm_torch() {
assert!(!is_python_supported_for_rocm_torch(&PythonVersion {
major: 3,
minor: 8
}));
assert!(!is_python_supported_for_rocm_torch(&PythonVersion {
major: 3,
minor: 9
}));
assert!(is_python_supported_for_rocm_torch(&PythonVersion {
major: 3,
minor: 10
}));
assert!(is_python_supported_for_rocm_torch(&PythonVersion {
major: 3,
minor: 11
}));
assert!(is_python_supported_for_rocm_torch(&PythonVersion {
major: 3,
minor: 12
}));
assert!(is_python_supported_for_rocm_torch(&PythonVersion {
major: 3,
minor: 13
}));
assert!(!is_python_supported_for_rocm_torch(&PythonVersion {
major: 3,
minor: 14
}));
}
#[test]
fn test_python_version_display() {
let v = PythonVersion {
major: 3,
minor: 10,
};
assert_eq!(format!("{v}"), "3.10");
}
#[test]
fn test_python_version_to_short_string() {
let v = PythonVersion {
major: 3,
minor: 11,
};
assert_eq!(v.to_short_string(), "3.11");
}
#[test]
fn test_log_format_info() {
let level = LogLevel::Info;
let msg = "test message";
let formatted = format!("[mlstack][{level}] {msg}");
assert_eq!(formatted, "[mlstack][INFO] test message");
}
#[test]
fn test_log_format_warn() {
let level = LogLevel::Warn;
let formatted = format!("[mlstack][{level}] warning!");
assert_eq!(formatted, "[mlstack][WARN] warning!");
}
#[test]
fn test_log_format_error() {
let level = LogLevel::Error;
let formatted = format!("[mlstack][{level}] something failed");
assert_eq!(formatted, "[mlstack][ERROR] something failed");
}
#[test]
fn test_no_color_env_disables_color() {
let msg = "[mlstack][INFO] plain text";
assert!(!msg.contains('\x1b'));
}
#[test]
fn test_progress_tracker_basic() {
let mut tracker = ProgressTracker::new(3);
assert_eq!(tracker.current(), 0);
assert_eq!(tracker.total(), 3);
assert!(!tracker.is_complete());
let step = tracker.advance("Downloading");
assert_eq!(step, 1);
assert_eq!(tracker.current(), 1);
assert!(!tracker.is_complete());
tracker.advance("Building");
assert_eq!(tracker.current(), 2);
tracker.advance("Installing");
assert_eq!(tracker.current(), 3);
assert!(tracker.is_complete());
}
#[test]
fn test_progress_tracker_with_component() {
let tracker = ProgressTracker::with_component(2, "pytorch");
let msg = tracker.format_step("Downloading wheels");
assert!(msg.contains("[pytorch]"));
assert!(msg.contains("Step 0/2"));
}
#[test]
fn test_progress_tracker_format_step() {
let tracker = ProgressTracker::new(5);
let msg = tracker.format_step("initializing");
assert_eq!(msg, "Step 0/5: initializing");
}
#[test]
fn test_detect_python_version_system() {
if which_exists("python3") {
let version = detect_python_version("python3");
assert!(version.is_some());
let v = version.unwrap();
assert!(v.major >= 3);
}
}
#[test]
fn test_detect_python_version_nonexistent() {
let version = detect_python_version("/nonexistent/python3");
assert!(version.is_none());
}
fn which_exists(cmd: &str) -> bool {
std::process::Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
}