1use std::fmt;
18
19#[derive(Debug)]
34pub enum InstallerError {
35 MissingDependency {
37 name: String,
39 hint: Option<String>,
41 },
42
43 PythonVersionUnsupported {
45 detected: String,
47 minimum: String,
49 maximum: Option<String>,
51 },
52
53 PythonNotFound {
55 detail: String,
57 },
58
59 CommandFailed {
61 command: String,
63 exit_code: Option<i32>,
65 stderr: String,
67 },
68
69 EnvError {
71 var: String,
73 detail: String,
75 },
76
77 PathNotFound {
79 path: String,
81 purpose: String,
83 },
84
85 NvidiaContamination {
87 packages: Vec<String>,
89 },
90
91 PackageBlocked {
93 package: String,
95 component: String,
97 },
98
99 RocmNotFound {
101 detail: String,
103 },
104
105 VenvError {
107 path: String,
109 detail: String,
111 },
112
113 Other {
115 message: String,
117 },
118}
119
120impl fmt::Display for InstallerError {
121 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122 match self {
123 Self::MissingDependency { name, hint } => {
124 write!(f, "Missing dependency: {name}")?;
125 if let Some(h) = hint {
126 write!(f, " (hint: {h})")?;
127 }
128 Ok(())
129 }
130 Self::PythonVersionUnsupported {
131 detected,
132 minimum,
133 maximum,
134 } => {
135 write!(f, "Unsupported Python {detected} (minimum: {minimum}")?;
136 if let Some(max) = maximum {
137 write!(f, ", maximum: {max}")?;
138 }
139 write!(f, ")")
140 }
141 Self::PythonNotFound { detail } => {
142 write!(f, "Python interpreter not found: {detail}")
143 }
144 Self::CommandFailed {
145 command,
146 exit_code,
147 stderr,
148 } => {
149 write!(f, "Command failed: {command}")?;
150 if let Some(code) = exit_code {
151 write!(f, " (exit code {code})")?;
152 }
153 if !stderr.is_empty() {
154 write!(f, ": {stderr}")?;
155 }
156 Ok(())
157 }
158 Self::EnvError { var, detail } => {
159 write!(f, "Environment error: {var}: {detail}")
160 }
161 Self::PathNotFound { path, purpose } => {
162 write!(f, "Path not found: {path} (needed for {purpose})")
163 }
164 Self::NvidiaContamination { packages } => {
165 write!(
166 f,
167 "NVIDIA/CUDA contamination detected: {}",
168 packages.join(", ")
169 )
170 }
171 Self::PackageBlocked { package, component } => {
172 write!(f, "Blocked package '{package}' in component '{component}'")
173 }
174 Self::RocmNotFound { detail } => {
175 write!(f, "ROCm not found: {detail}")
176 }
177 Self::VenvError { path, detail } => {
178 write!(f, "Virtualenv error at {path}: {detail}")
179 }
180 Self::Other { message } => write!(f, "{message}"),
181 }
182 }
183}
184
185impl std::error::Error for InstallerError {}
186
187#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
193pub struct PythonVersion {
194 pub major: u32,
196 pub minor: u32,
198}
199
200impl PythonVersion {
201 pub fn parse(version_str: &str) -> Option<Self> {
205 let parts: Vec<&str> = version_str.trim().split('.').collect();
206 if parts.len() < 2 {
207 return None;
208 }
209 let major = parts[0].parse::<u32>().ok()?;
210 let minor = parts[1].parse::<u32>().ok()?;
211 Some(Self { major, minor })
212 }
213
214 pub fn to_short_string(&self) -> String {
216 format!("{}.{}", self.major, self.minor)
217 }
218}
219
220impl fmt::Display for PythonVersion {
221 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222 write!(f, "{}.{}", self.major, self.minor)
223 }
224}
225
226pub const MIN_PYTHON_VERSION: PythonVersion = PythonVersion {
228 major: 3,
229 minor: 10,
230};
231
232pub const MAX_ROCM_TORCH_PYTHON: PythonVersion = PythonVersion {
234 major: 3,
235 minor: 13,
236};
237
238pub fn is_python_supported(version: &PythonVersion) -> bool {
244 version >= &MIN_PYTHON_VERSION
245}
246
247pub fn is_python_supported_for_rocm_torch(version: &PythonVersion) -> bool {
255 version >= &MIN_PYTHON_VERSION && version <= &MAX_ROCM_TORCH_PYTHON
256}
257
258pub fn detect_python_version(python_bin: &str) -> Option<PythonVersion> {
262 let output = std::process::Command::new(python_bin)
263 .args([
264 "-c",
265 "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')",
266 ])
267 .output()
268 .ok()?;
269
270 if !output.status.success() {
271 return None;
272 }
273
274 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
275 PythonVersion::parse(&stdout)
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq)]
284pub enum LogLevel {
285 Info,
286 Warn,
287 Error,
288}
289
290impl fmt::Display for LogLevel {
291 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
292 match self {
293 LogLevel::Info => write!(f, "INFO"),
294 LogLevel::Warn => write!(f, "WARN"),
295 LogLevel::Error => write!(f, "ERROR"),
296 }
297 }
298}
299
300fn should_disable_color() -> bool {
304 if std::env::var("NO_COLOR").is_ok() {
305 return true;
306 }
307 !atty_is_stderr()
309}
310
311fn atty_is_stderr() -> bool {
313 use std::io::IsTerminal;
315 std::io::stderr().is_terminal()
316}
317
318pub fn log(level: LogLevel, message: &str) {
326 let formatted = format!("[mlstack][{level}] {message}");
327 eprintln!("{formatted}");
328}
329
330pub fn log_info(message: &str) {
332 log(LogLevel::Info, message);
333}
334
335pub fn log_warn(message: &str) {
337 log(LogLevel::Warn, message);
338}
339
340pub fn log_error(message: &str) {
342 log(LogLevel::Error, message);
343}
344
345pub fn log_colored(level: LogLevel, message: &str) {
350 if should_disable_color() {
351 log(level, message);
352 } else {
353 let color_code = match level {
354 LogLevel::Info => "\x1b[0;34m", LogLevel::Warn => "\x1b[1;33m", LogLevel::Error => "\x1b[0;31m", };
358 let reset = "\x1b[0m";
359 eprintln!("{color_code}[mlstack][{level}]{reset} {message}");
360 }
361}
362
363#[derive(Debug)]
376pub struct ProgressTracker {
377 current: u32,
379 total: u32,
381 component: Option<String>,
383}
384
385impl ProgressTracker {
386 pub fn new(total: u32) -> Self {
388 Self {
389 current: 0,
390 total,
391 component: None,
392 }
393 }
394
395 pub fn with_component(total: u32, component: &str) -> Self {
397 Self {
398 current: 0,
399 total,
400 component: Some(component.to_string()),
401 }
402 }
403
404 pub fn advance(&mut self, description: &str) -> u32 {
408 self.current += 1;
409 let prefix = match &self.component {
410 Some(c) => format!(
411 "Step {}/{} [{}]: {}",
412 self.current, self.total, c, description
413 ),
414 None => format!("Step {}/{}: {}", self.current, self.total, description),
415 };
416 log_info(&prefix);
417 self.current
418 }
419
420 pub fn current(&self) -> u32 {
422 self.current
423 }
424
425 pub fn total(&self) -> u32 {
427 self.total
428 }
429
430 pub fn is_complete(&self) -> bool {
432 self.current >= self.total
433 }
434
435 pub fn format_step(&self, description: &str) -> String {
437 match &self.component {
438 Some(c) => format!(
439 "Step {}/{} [{}]: {}",
440 self.current, self.total, c, description
441 ),
442 None => format!("Step {}/{}: {}", self.current, self.total, description),
443 }
444 }
445}
446
447#[cfg(test)]
452mod tests {
453 use super::*;
454
455 #[test]
458 fn test_error_display_missing_dependency() {
459 let err = InstallerError::MissingDependency {
460 name: "cmake".to_string(),
461 hint: Some("install via apt: sudo apt install cmake".to_string()),
462 };
463 let msg = format!("{err}");
464 assert!(msg.contains("Missing dependency: cmake"));
465 assert!(msg.contains("hint: install via apt"));
466 }
467
468 #[test]
469 fn test_error_display_missing_dependency_no_hint() {
470 let err = InstallerError::MissingDependency {
471 name: "git".to_string(),
472 hint: None,
473 };
474 let msg = format!("{err}");
475 assert!(msg.contains("Missing dependency: git"));
476 assert!(!msg.contains("hint:"));
477 }
478
479 #[test]
480 fn test_error_display_python_unsupported() {
481 let err = InstallerError::PythonVersionUnsupported {
482 detected: "3.8".to_string(),
483 minimum: "3.10".to_string(),
484 maximum: None,
485 };
486 let msg = format!("{err}");
487 assert!(msg.contains("Unsupported Python 3.8"));
488 assert!(msg.contains("minimum: 3.10"));
489 }
490
491 #[test]
492 fn test_error_display_python_unsupported_rocm() {
493 let err = InstallerError::PythonVersionUnsupported {
494 detected: "3.14".to_string(),
495 minimum: "3.10".to_string(),
496 maximum: Some("3.13".to_string()),
497 };
498 let msg = format!("{err}");
499 assert!(msg.contains("maximum: 3.13"));
500 }
501
502 #[test]
503 fn test_error_display_python_not_found() {
504 let err = InstallerError::PythonNotFound {
505 detail: "no python3 binary in PATH".to_string(),
506 };
507 let msg = format!("{err}");
508 assert!(msg.contains("Python interpreter not found"));
509 assert!(msg.contains("no python3 binary"));
510 }
511
512 #[test]
513 fn test_error_display_command_failed() {
514 let err = InstallerError::CommandFailed {
515 command: "pip install torch".to_string(),
516 exit_code: Some(1),
517 stderr: "error: no matching distribution".to_string(),
518 };
519 let msg = format!("{err}");
520 assert!(msg.contains("Command failed: pip install torch"));
521 assert!(msg.contains("exit code 1"));
522 assert!(msg.contains("no matching distribution"));
523 }
524
525 #[test]
526 fn test_error_display_command_failed_no_stderr() {
527 let err = InstallerError::CommandFailed {
528 command: "make".to_string(),
529 exit_code: Some(2),
530 stderr: String::new(),
531 };
532 let msg = format!("{err}");
533 assert!(msg.contains("Command failed: make"));
534 assert!(msg.contains("exit code 2"));
535 }
536
537 #[test]
538 fn test_error_display_env_error() {
539 let err = InstallerError::EnvError {
540 var: "ROCM_PATH".to_string(),
541 detail: "not set and ROCm not detected".to_string(),
542 };
543 let msg = format!("{err}");
544 assert!(msg.contains("ROCM_PATH"));
545 assert!(msg.contains("not set"));
546 }
547
548 #[test]
549 fn test_error_display_path_not_found() {
550 let err = InstallerError::PathNotFound {
551 path: "/opt/rocm".to_string(),
552 purpose: "ROCm installation".to_string(),
553 };
554 let msg = format!("{err}");
555 assert!(msg.contains("/opt/rocm"));
556 assert!(msg.contains("ROCm installation"));
557 }
558
559 #[test]
560 fn test_error_display_nvidia_contamination() {
561 let err = InstallerError::NvidiaContamination {
562 packages: vec!["nvidia-cublas".to_string(), "pytorch-cuda".to_string()],
563 };
564 let msg = format!("{err}");
565 assert!(msg.contains("NVIDIA/CUDA contamination"));
566 assert!(msg.contains("nvidia-cublas"));
567 }
568
569 #[test]
570 fn test_error_display_package_blocked() {
571 let err = InstallerError::PackageBlocked {
572 package: "triton".to_string(),
573 component: "pytorch".to_string(),
574 };
575 let msg = format!("{err}");
576 assert!(msg.contains("Blocked package 'triton'"));
577 assert!(msg.contains("pytorch"));
578 }
579
580 #[test]
581 fn test_error_display_rocm_not_found() {
582 let err = InstallerError::RocmNotFound {
583 detail: "no ROCm installation detected".to_string(),
584 };
585 let msg = format!("{err}");
586 assert!(msg.contains("ROCm not found"));
587 }
588
589 #[test]
590 fn test_error_display_venv_error() {
591 let err = InstallerError::VenvError {
592 path: "/home/user/.mlstack/venvs/main".to_string(),
593 detail: "python binary not found".to_string(),
594 };
595 let msg = format!("{err}");
596 assert!(msg.contains("Virtualenv error"));
597 assert!(msg.contains("/home/user/.mlstack/venvs/main"));
598 }
599
600 #[test]
601 fn test_error_display_other() {
602 let err = InstallerError::Other {
603 message: "something went wrong".to_string(),
604 };
605 let msg = format!("{err}");
606 assert_eq!(msg, "something went wrong");
607 }
608
609 #[test]
610 fn test_error_implements_std_error() {
611 fn assert_error<E: std::error::Error>(_: &E) {}
612 let err = InstallerError::MissingDependency {
613 name: "test".to_string(),
614 hint: None,
615 };
616 assert_error(&err);
617 }
618
619 #[test]
622 fn test_python_version_parse() {
623 let v = PythonVersion::parse("3.10").unwrap();
624 assert_eq!(v.major, 3);
625 assert_eq!(v.minor, 10);
626 }
627
628 #[test]
629 fn test_python_version_parse_three_part() {
630 let v = PythonVersion::parse("3.10.4").unwrap();
631 assert_eq!(v.major, 3);
632 assert_eq!(v.minor, 10);
633 }
634
635 #[test]
636 fn test_python_version_parse_invalid() {
637 assert!(PythonVersion::parse("").is_none());
638 assert!(PythonVersion::parse("3").is_none());
639 assert!(PythonVersion::parse("abc").is_none());
640 assert!(PythonVersion::parse("x.y").is_none());
641 }
642
643 #[test]
644 fn test_python_version_ordering() {
645 let v310 = PythonVersion {
646 major: 3,
647 minor: 10,
648 };
649 let v311 = PythonVersion {
650 major: 3,
651 minor: 11,
652 };
653 let v313 = PythonVersion {
654 major: 3,
655 minor: 13,
656 };
657 let v314 = PythonVersion {
658 major: 3,
659 minor: 14,
660 };
661 assert!(v310 < v311);
662 assert!(v311 < v313);
663 assert!(v313 < v314);
664 }
665
666 #[test]
667 fn test_is_python_supported_general() {
668 assert!(!is_python_supported(&PythonVersion { major: 3, minor: 8 }));
670 assert!(!is_python_supported(&PythonVersion { major: 3, minor: 9 }));
671 assert!(is_python_supported(&PythonVersion {
672 major: 3,
673 minor: 10
674 }));
675 assert!(is_python_supported(&PythonVersion {
676 major: 3,
677 minor: 11
678 }));
679 assert!(is_python_supported(&PythonVersion {
680 major: 3,
681 minor: 12
682 }));
683 assert!(is_python_supported(&PythonVersion {
684 major: 3,
685 minor: 13
686 }));
687 assert!(is_python_supported(&PythonVersion {
688 major: 3,
689 minor: 14
690 }));
691 }
692
693 #[test]
694 fn test_is_python_supported_rocm_torch() {
695 assert!(!is_python_supported_for_rocm_torch(&PythonVersion {
697 major: 3,
698 minor: 8
699 }));
700 assert!(!is_python_supported_for_rocm_torch(&PythonVersion {
701 major: 3,
702 minor: 9
703 }));
704 assert!(is_python_supported_for_rocm_torch(&PythonVersion {
705 major: 3,
706 minor: 10
707 }));
708 assert!(is_python_supported_for_rocm_torch(&PythonVersion {
709 major: 3,
710 minor: 11
711 }));
712 assert!(is_python_supported_for_rocm_torch(&PythonVersion {
713 major: 3,
714 minor: 12
715 }));
716 assert!(is_python_supported_for_rocm_torch(&PythonVersion {
717 major: 3,
718 minor: 13
719 }));
720 assert!(!is_python_supported_for_rocm_torch(&PythonVersion {
721 major: 3,
722 minor: 14
723 }));
724 }
725
726 #[test]
727 fn test_python_version_display() {
728 let v = PythonVersion {
729 major: 3,
730 minor: 10,
731 };
732 assert_eq!(format!("{v}"), "3.10");
733 }
734
735 #[test]
736 fn test_python_version_to_short_string() {
737 let v = PythonVersion {
738 major: 3,
739 minor: 11,
740 };
741 assert_eq!(v.to_short_string(), "3.11");
742 }
743
744 #[test]
747 fn test_log_format_info() {
748 let level = LogLevel::Info;
750 let msg = "test message";
751 let formatted = format!("[mlstack][{level}] {msg}");
752 assert_eq!(formatted, "[mlstack][INFO] test message");
753 }
754
755 #[test]
756 fn test_log_format_warn() {
757 let level = LogLevel::Warn;
758 let formatted = format!("[mlstack][{level}] warning!");
759 assert_eq!(formatted, "[mlstack][WARN] warning!");
760 }
761
762 #[test]
763 fn test_log_format_error() {
764 let level = LogLevel::Error;
765 let formatted = format!("[mlstack][{level}] something failed");
766 assert_eq!(formatted, "[mlstack][ERROR] something failed");
767 }
768
769 #[test]
770 fn test_no_color_env_disables_color() {
771 let msg = "[mlstack][INFO] plain text";
775 assert!(!msg.contains('\x1b'));
776 }
777
778 #[test]
781 fn test_progress_tracker_basic() {
782 let mut tracker = ProgressTracker::new(3);
783 assert_eq!(tracker.current(), 0);
784 assert_eq!(tracker.total(), 3);
785 assert!(!tracker.is_complete());
786
787 let step = tracker.advance("Downloading");
788 assert_eq!(step, 1);
789 assert_eq!(tracker.current(), 1);
790 assert!(!tracker.is_complete());
791
792 tracker.advance("Building");
793 assert_eq!(tracker.current(), 2);
794
795 tracker.advance("Installing");
796 assert_eq!(tracker.current(), 3);
797 assert!(tracker.is_complete());
798 }
799
800 #[test]
801 fn test_progress_tracker_with_component() {
802 let tracker = ProgressTracker::with_component(2, "pytorch");
803 let msg = tracker.format_step("Downloading wheels");
804 assert!(msg.contains("[pytorch]"));
805 assert!(msg.contains("Step 0/2"));
806 }
807
808 #[test]
809 fn test_progress_tracker_format_step() {
810 let tracker = ProgressTracker::new(5);
811 let msg = tracker.format_step("initializing");
812 assert_eq!(msg, "Step 0/5: initializing");
813 }
814
815 #[test]
818 fn test_detect_python_version_system() {
819 if which_exists("python3") {
821 let version = detect_python_version("python3");
822 assert!(version.is_some());
823 let v = version.unwrap();
824 assert!(v.major >= 3);
825 }
826 }
827
828 #[test]
829 fn test_detect_python_version_nonexistent() {
830 let version = detect_python_version("/nonexistent/python3");
831 assert!(version.is_none());
832 }
833
834 fn which_exists(cmd: &str) -> bool {
836 std::process::Command::new("which")
837 .arg(cmd)
838 .output()
839 .map(|o| o.status.success())
840 .unwrap_or(false)
841 }
842}