1use std::collections::HashSet;
30use std::path::PathBuf;
31use std::process::Command;
32
33use crate::core::registry::InstalledComponentRegistry;
34use crate::platform::environment::{
35 command_on_path, mlstack_global_dir, resolve_canonical_python_bin, resolve_user_home,
36};
37
38const ROCM_SYSTEM_PACKAGES: &[&str] = &[
41 "rocm-hip-sdk",
42 "rocm-opencl-sdk",
43 "rocm-core",
44 "rocm-dev",
45 "rocm-opencl-runtime",
46 "amdgpu",
47 "amdgpu-dkms",
48];
49
50const ML_PIP_PACKAGES: &[&str] = &[
52 "torch",
53 "torchvision",
54 "torchaudio",
55 "triton",
56 "pytorch-triton-rocm",
57 "pytorch-triton",
58 "vllm",
59 "deepspeed",
60 "megatron-core",
61 "bitsandbytes",
62 "flash-attn",
63 "flash_attn",
64 "aiter",
65 "onnxruntime",
66 "onnxruntime-rocm",
67 "migraphx",
68 "mpi4py",
69 "wandb",
70 "xformers",
71 "accelerate",
72 "transformers",
73];
74
75#[derive(Debug, Clone, Default)]
77pub struct UninstallOptions {
78 pub keep_rocm: bool,
81 pub yes: bool,
83 pub purge_mlstack_dir: bool,
86 pub sudo_password: Option<String>,
91}
92
93#[derive(Debug, Clone, Default)]
95pub struct UninstallReport {
96 pub pip_uninstall_attempted: Vec<String>,
97 pub system_packages_purged: bool,
98 pub opt_rocm_removed: bool,
99 pub env_files_removed: Vec<String>,
100 pub sourcing_lines_stripped: Vec<String>,
101 pub registry_cleared: bool,
102 pub warnings: Vec<String>,
103}
104
105impl UninstallReport {
106 fn note(&mut self, msg: impl Into<String>) {
107 self.warnings.push(msg.into());
108 }
109}
110
111pub fn uninstall_stack(opts: &UninstallOptions) -> anyhow::Result<UninstallReport> {
116 let mut report = UninstallReport::default();
117 let home = resolve_user_home();
118
119 if !opts.yes {
120 eprintln!(
122 "[uninstall] Proceeding (use --yes to skip this notice). keep_rocm={}, purge_dir={}",
123 opts.keep_rocm, opts.purge_mlstack_dir
124 );
125 }
126
127 let python = resolve_canonical_python_bin();
129
130 clean_corrupted_pip_distributions(&python, opts.sudo_password.as_deref(), &mut report);
135
136 println!("[uninstall] Removing ML pip packages via {python} …");
137 let mut pkgs: Vec<String> = ML_PIP_PACKAGES.iter().map(|s| s.to_string()).collect();
141 pkgs.extend(crate::core::registry::InstalledComponentRegistry::load().all_pip_packages());
142 pkgs.sort();
143 pkgs.dedup();
144 report.pip_uninstall_attempted = pkgs.clone();
145 let mut pip_args = vec![
146 "-m".to_string(),
147 "pip".to_string(),
148 "uninstall".to_string(),
149 "-y".to_string(),
150 "--break-system-packages".to_string(),
157 ];
158 pip_args.extend(pkgs);
159 let pip_status = Command::new(&python).args(&pip_args).status();
160 match pip_status {
161 Ok(s) if s.success() => {}
162 Ok(s) => report.note(format!(
163 "pip uninstall exited non-zero ({s}) — some packages may not have been installed"
164 )),
165 Err(e) => report.note(format!("could not run {python} -m pip ({e})")),
166 }
167
168 if !opts.keep_rocm {
170 let installed = filter_installed_system_packages(ROCM_SYSTEM_PACKAGES);
176 if installed.is_empty() {
177 report.note(
178 "no ROCm/amdgpu system packages are installed — skipping system-package purge",
179 );
180 } else if let Some((pm, purge_args)) = build_system_purge_cmd(&installed) {
181 println!("[uninstall] Purging ROCm/amdgpu system packages via {pm} …");
182 let ran = run_privileged(&pm, &purge_args, opts.sudo_password.as_deref(), &mut report);
183 report.system_packages_purged = ran;
184 } else {
185 report.note("no supported system package manager detected (apt/dnf/pacman/zypper) — skipping ROCm system-package purge");
186 }
187
188 if PathBuf::from("/opt/rocm").exists() {
190 println!("[uninstall] Removing /opt/rocm …");
191 report.opt_rocm_removed = run_privileged(
192 "rm",
193 &["-rf".to_string(), "/opt/rocm".to_string()],
194 opts.sudo_password.as_deref(),
195 &mut report,
196 );
197 }
198 } else {
199 println!("[uninstall] --keep-rocm: leaving ROCm system packages and /opt/rocm in place");
200 }
201
202 remove_env_files(&home, &mut report);
204 strip_shell_sourcing(&home, &mut report);
205
206 let mut registry = InstalledComponentRegistry::load();
208 if !registry.is_empty() {
209 registry.clear();
210 if let Err(e) = registry.save() {
211 report.note(format!("failed to clear registry: {e}"));
212 } else {
213 report.registry_cleared = true;
214 }
215 }
216
217 if opts.purge_mlstack_dir {
219 let root = crate::platform::environment::mlstack_root();
220 if root.exists() {
221 if std::fs::remove_dir_all(&root).is_ok() {
224 report
225 .env_files_removed
226 .push(format!("(dir) {}", root.display()));
227 } else {
228 let removed = run_privileged(
229 "rm",
230 &["-rf".to_string(), root.to_string_lossy().into_owned()],
231 opts.sudo_password.as_deref(),
232 &mut report,
233 );
234 if removed {
235 report
236 .env_files_removed
237 .push(format!("(dir, sudo) {}", root.display()));
238 }
239 }
240 }
241 }
242
243 Ok(report)
244}
245
246fn build_system_purge_cmd(packages: &[&str]) -> Option<(String, Vec<String>)> {
249 let (pm, verb) = if command_on_path("apt") {
250 (
251 "apt",
252 vec!["purge".to_string(), "-y".to_string(), "-qq".to_string()],
253 )
254 } else if command_on_path("pacman") {
255 let full_set = enumerate_rocm_explicit_purge_set(packages);
260 if full_set.is_empty() {
261 return None;
263 }
264 let mut args = vec!["-Rn".to_string(), "--noconfirm".to_string()];
265 args.extend(full_set.into_iter().map(|s| s.to_string()));
266 return Some(("pacman".to_string(), args));
267 } else if command_on_path("dnf") {
268 ("dnf", vec!["remove".to_string(), "-y".to_string()])
269 } else if command_on_path("zypper") {
270 ("zypper", vec!["remove".to_string(), "-y".to_string()])
271 } else if command_on_path("yum") {
272 ("yum", vec!["remove".to_string(), "-y".to_string()])
273 } else {
274 return None;
275 };
276 let mut args = verb;
277 args.extend(packages.iter().map(|s| s.to_string()));
278 Some((pm.to_string(), args))
279}
280
281fn enumerate_rocm_explicit_purge_set(targets: &[&str]) -> Vec<String> {
286 let allowlist = rocm_allowlist_patterns();
287 let mut to_remove: HashSet<String> = targets.iter().map(|s| s.to_string()).collect();
288 let mut skipped_non_rocm: Vec<String> = Vec::new();
289
290 for target in targets {
291 let dependents = reverse_tree_pacman(target);
292 for dep in dependents {
293 if matches_rocm_allowlist(&dep, &allowlist) {
294 to_remove.insert(dep);
295 } else {
296 skipped_non_rocm.push(dep);
297 }
298 }
299 }
300
301 if !skipped_non_rocm.is_empty() {
302 eprintln!(
303 "[uninstall] Skipping {} non-ROCm dependent(s) that would be caught by cascade: {:?}. Only ROCm packages are removed.",
304 skipped_non_rocm.len(),
305 skipped_non_rocm
306 );
307 }
308
309 let mut sorted: Vec<_> = to_remove.into_iter().collect();
310 sorted.sort();
311 sorted.dedup();
312 sorted
313}
314
315fn rocm_allowlist_patterns() -> Vec<&'static str> {
320 vec![
321 "rocm-",
322 "hip",
323 "roc",
324 "hsa",
325 "comgr",
326 "migraphx",
327 "miopen",
328 "rccl",
329 "amdgpu",
330 "libdrm_amd",
331 "rocwmma",
332 "amd-comgr",
333 "hsakmt",
334 "hipfft",
335 "hipsparse",
336 "hipcub",
337 "hiprand",
338 "rocsolver",
339 "rocprim",
340 "rocrand",
341 "rocfft",
342 "llvm-libs",
343 ]
344}
345
346fn matches_rocm_allowlist(pkg: &str, allowlist: &[&str]) -> bool {
348 allowlist
349 .iter()
350 .any(|pat| pkg.starts_with(pat) || pkg == pat.trim_end_matches('-'))
351}
352
353fn reverse_tree_pacman(package: &str) -> Vec<String> {
363 if !command_on_path("pactree") {
364 eprintln!("[uninstall] pactree not found — cannot enumerate ROCm dependents for {package}. Skipping dependent enumeration.");
369 return Vec::new();
370 }
371
372 Command::new("pactree")
373 .args(["-r", "-u", package])
374 .output()
375 .ok()
376 .and_then(|o| {
377 if o.status.success() {
378 String::from_utf8(o.stdout).ok()
379 } else {
380 None
381 }
382 })
383 .map(|s| {
384 s.lines()
385 .filter_map(|line| {
386 let trimmed = line.trim();
387 if trimmed.is_empty() || trimmed.starts_with('#') {
388 None
389 } else {
390 Some(trimmed.to_string())
391 }
392 })
393 .collect()
394 })
395 .unwrap_or_default()
396}
397
398fn filter_installed_system_packages<'a>(candidates: &[&'a str]) -> Vec<&'a str> {
405 candidates
408 .iter()
409 .copied()
410 .filter(|pkg| is_system_package_installed(pkg))
411 .collect()
412}
413
414fn is_system_package_installed(pkg: &str) -> bool {
416 if command_on_path("dpkg-query") {
418 return Command::new("dpkg-query")
419 .args(["-W", "-f=${Status}", pkg])
420 .output()
421 .map(|o| {
422 o.status.success()
423 && String::from_utf8_lossy(&o.stdout).contains("install ok installed")
424 })
425 .unwrap_or(false);
426 }
427 if command_on_path("pacman") {
429 return Command::new("pacman")
430 .args(["-Q", pkg])
431 .output()
432 .map(|o| o.status.success())
433 .unwrap_or(false);
434 }
435 if command_on_path("rpm") {
437 return Command::new("rpm")
438 .args(["-q", pkg])
439 .output()
440 .map(|o| o.status.success())
441 .unwrap_or(false);
442 }
443 true
446}
447
448fn run_privileged(
458 program: &str,
459 args: &[String],
460 sudo_password: Option<&str>,
461 report: &mut UninstallReport,
462) -> bool {
463 if is_root() {
464 return run_command_status(program, args, None, report);
465 }
466 if !command_on_path("sudo") {
467 report.note(format!(
468 "skipping `{program} {args:?}` — not root and sudo unavailable",
469 ));
470 return false;
471 }
472 if let Some(pw) = sudo_password.filter(|p| !p.is_empty()) {
476 match crate::installers::common::askpass::Askpass::new(pw) {
477 Ok(helper) => {
478 let mut sudo_args = vec!["-A".to_string(), program.to_string()];
479 sudo_args.extend_from_slice(args);
480 return run_command_status("sudo", &sudo_args, Some(helper.path()), report);
481 }
482 Err(e) => {
483 report.note(format!(
484 "askpass setup failed ({e}); falling back to sudo -n"
485 ));
486 }
487 }
488 }
489 let mut a = vec!["-n".to_string(), program.to_string()];
492 a.extend_from_slice(args);
493 run_command_status("sudo", &a, None, report)
494}
495
496fn run_command_status(
499 program: &str,
500 args: &[String],
501 askpass_path: Option<&str>,
502 report: &mut UninstallReport,
503) -> bool {
504 let mut cmd = Command::new(program);
505 cmd.args(args);
506 if let Some(p) = askpass_path {
507 cmd.env("SUDO_ASKPASS", p);
508 }
509 match cmd.status() {
510 Ok(s) if s.success() => true,
511 Ok(s) => {
512 report.note(format!("`{program}` exited non-zero ({s})"));
513 false
514 }
515 Err(e) => {
516 report.note(format!(
517 "could not run `{program}` ({e}) — sudo may require a password (no TTY)"
518 ));
519 false
520 }
521 }
522}
523
524#[cfg(unix)]
525fn is_root() -> bool {
526 unsafe { libc::geteuid() == 0 }
528}
529#[cfg(not(unix))]
530fn is_root() -> bool {
531 false
532}
533
534fn clean_corrupted_pip_distributions(
547 python: &str,
548 sudo_password: Option<&str>,
549 report: &mut UninstallReport,
550) {
551 let sites_out = Command::new(python)
553 .args([
554 "-c",
555 "import site,sys; sys.stdout.write('\\n'.join(site.getsitepackages()+[site.getusersitepackages()]))",
556 ])
557 .output();
558 let sites: Vec<String> = match sites_out {
559 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
560 .lines()
561 .map(|s| s.trim().to_string())
562 .filter(|s| !s.is_empty())
563 .collect(),
564 _ => return,
565 };
566 let mut needs_sudo: Vec<String> = Vec::new();
567 for site_dir in &sites {
568 let Ok(entries) = std::fs::read_dir(site_dir) else {
569 continue;
570 };
571 for ent in entries.flatten() {
572 let file_name = ent.file_name();
573 let Some(name) = file_name.to_str() else {
574 continue;
575 };
576 if !name.starts_with('~') {
577 continue;
578 }
579 let path = ent.path();
580 let user_removed = match std::fs::metadata(&path) {
582 Ok(m) if m.is_dir() => std::fs::remove_dir_all(&path).is_ok(),
583 Ok(_) => std::fs::remove_file(&path).is_ok(),
584 Err(_) => false,
585 };
586 if user_removed {
587 report
588 .env_files_removed
589 .push(format!("(corrupted pip dist) {site_dir}/{name}"));
590 } else if let Some(p) = path.to_str() {
591 needs_sudo.push(p.to_string());
593 }
594 }
595 }
596 if needs_sudo.is_empty() {
597 return;
598 }
599 let count = needs_sudo.len();
601 let mut args = vec!["-rf".to_string()];
602 args.extend(needs_sudo.iter().cloned());
603 if run_privileged("rm", &args, sudo_password, report) {
604 for p in &needs_sudo {
605 report
606 .env_files_removed
607 .push(format!("(corrupted pip dist, sudo) {p}"));
608 }
609 } else {
610 report.note(format!(
611 "failed to remove {count} root-owned corrupted pip dist dir(s) — sudo required"
612 ));
613 }
614}
615
616fn remove_env_files(home: &std::path::Path, report: &mut UninstallReport) {
618 let targets = [
619 home.join(".mlstack_env"),
620 home.join(".config/fish/conf.d/mlstack_env.fish"),
621 home.join(".rocm_env"),
622 ];
623 for t in targets {
624 if t.exists() {
625 match std::fs::remove_file(&t) {
626 Ok(()) => report
627 .env_files_removed
628 .push(t.to_string_lossy().into_owned()),
629 Err(e) => report.note(format!("could not remove {}: {e}", t.display())),
630 }
631 }
632 }
633}
634
635fn strip_shell_sourcing(home: &std::path::Path, report: &mut UninstallReport) {
645 let rcs = [
646 home.join(".bashrc"),
647 home.join(".zshrc"),
648 home.join(".config/fish/config.fish"),
649 ];
650 let had_trailing_newline = |content: &str| content.ends_with('\n');
651 for rc in rcs {
652 let Ok(content) = std::fs::read_to_string(&rc) else {
653 continue;
654 };
655 let mut filtered = String::with_capacity(content.len());
656 let mut changed = false;
657 for line in content.lines() {
658 let t = line.trim();
659 let is_source_line = t.contains(".mlstack_env")
664 && (t.contains("source") || t.contains("bass") || t.starts_with(". "));
665 if is_source_line {
666 changed = true;
667 } else {
668 filtered.push_str(line);
669 filtered.push('\n');
670 }
671 }
672 if changed {
673 if !had_trailing_newline(&content) && filtered.ends_with('\n') {
679 filtered.pop();
680 }
681 let _ = std::fs::write(&rc, filtered);
682 report
683 .sourcing_lines_stripped
684 .push(rc.to_string_lossy().into_owned());
685 }
686 }
687}
688
689pub fn global_env_dir() -> PathBuf {
691 mlstack_global_dir()
692}
693
694#[cfg(test)]
695mod tests {
696 use super::*;
697
698 #[test]
699 fn build_system_purge_cmd_apt_when_present() {
700 let cmd = build_system_purge_cmd(&["rocm-hip-sdk"]);
703 if command_on_path("apt") {
704 let (pm, args) = cmd.expect("apt present");
705 assert_eq!(pm, "apt");
706 assert!(args.contains(&"purge".to_string()));
707 assert!(args.contains(&"rocm-hip-sdk".to_string()));
708 }
709 }
710
711 #[test]
712 fn strip_sourcing_removes_mlstack_lines() {
713 let dir = tempfile::tempdir().unwrap();
714 let rc = dir.path().join(".bashrc");
715 std::fs::write(
716 &rc,
717 "# my rc\nsource \"$HOME/.mlstack_env\"\nexport FOO=bar\n. ~/.mlstack_env\n",
718 )
719 .unwrap();
720 let mut report = UninstallReport::default();
721 strip_shell_sourcing(dir.path(), &mut report);
722 let after = std::fs::read_to_string(&rc).unwrap();
723 assert!(
724 !after.contains(".mlstack_env"),
725 "sourcing lines removed: {after}"
726 );
727 assert!(
728 after.contains("export FOO=bar"),
729 "other lines preserved: {after}"
730 );
731 assert_eq!(report.sourcing_lines_stripped.len(), 1);
732 assert!(
734 after.ends_with('\n'),
735 "trailing newline preserved: {after:?}"
736 );
737 }
738
739 #[test]
740 fn strip_sourcing_preserves_non_source_mentions() {
741 let dir = tempfile::tempdir().unwrap();
745 let rc = dir.path().join(".bashrc");
746 let original = "# see ~/.mlstack_env for env\necho loaded .mlstack_env\nMLSTACK_ENV_FILE=.mlstack_env\n";
747 std::fs::write(&rc, original).unwrap();
748 let mut report = UninstallReport::default();
749 strip_shell_sourcing(dir.path(), &mut report);
750 let after = std::fs::read_to_string(&rc).unwrap();
751 assert_eq!(
752 report.sourcing_lines_stripped.len(),
753 0,
754 "no source lines, no rewrite: {after:?}"
755 );
756 assert!(
758 after.contains("# see ~/.mlstack_env for env"),
759 "comment preserved: {after}"
760 );
761 assert!(
762 after.contains("MLSTACK_ENV_FILE=.mlstack_env"),
763 "assignment preserved: {after}"
764 );
765 }
766
767 #[test]
768 fn remove_env_files_deletes_targets() {
769 let dir = tempfile::tempdir().unwrap();
770 std::fs::write(dir.path().join(".mlstack_env"), "x").unwrap();
771 let mut report = UninstallReport::default();
772 remove_env_files(dir.path(), &mut report);
773 assert!(!dir.path().join(".mlstack_env").exists());
774 assert!(!report.env_files_removed.is_empty());
775 }
776}