1use std::path::PathBuf;
30use std::process::Command;
31
32use crate::core::registry::InstalledComponentRegistry;
33use crate::platform::environment::{
34 command_on_path, mlstack_global_dir, resolve_canonical_python_bin, resolve_user_home,
35};
36
37const ROCM_SYSTEM_PACKAGES: &[&str] = &[
40 "rocm-hip-sdk",
41 "rocm-opencl-sdk",
42 "rocm-core",
43 "rocm-dev",
44 "rocm-opencl-runtime",
45 "amdgpu",
46 "amdgpu-dkms",
47];
48
49const ML_PIP_PACKAGES: &[&str] = &[
51 "torch",
52 "torchvision",
53 "torchaudio",
54 "triton",
55 "pytorch-triton-rocm",
56 "pytorch-triton",
57 "vllm",
58 "deepspeed",
59 "megatron-core",
60 "bitsandbytes",
61 "flash-attn",
62 "flash_attn",
63 "aiter",
64 "onnxruntime",
65 "onnxruntime-rocm",
66 "migraphx",
67 "mpi4py",
68 "wandb",
69 "xformers",
70 "accelerate",
71 "transformers",
72];
73
74#[derive(Debug, Clone, Default)]
76pub struct UninstallOptions {
77 pub keep_rocm: bool,
80 pub yes: bool,
82 pub purge_mlstack_dir: bool,
85}
86
87#[derive(Debug, Clone, Default)]
89pub struct UninstallReport {
90 pub pip_uninstall_attempted: Vec<String>,
91 pub system_packages_purged: bool,
92 pub opt_rocm_removed: bool,
93 pub env_files_removed: Vec<String>,
94 pub sourcing_lines_stripped: Vec<String>,
95 pub registry_cleared: bool,
96 pub warnings: Vec<String>,
97}
98
99impl UninstallReport {
100 fn note(&mut self, msg: impl Into<String>) {
101 self.warnings.push(msg.into());
102 }
103}
104
105pub fn uninstall_stack(opts: &UninstallOptions) -> anyhow::Result<UninstallReport> {
110 let mut report = UninstallReport::default();
111 let home = resolve_user_home();
112
113 if !opts.yes {
114 eprintln!(
116 "[uninstall] Proceeding (use --yes to skip this notice). keep_rocm={}, purge_dir={}",
117 opts.keep_rocm, opts.purge_mlstack_dir
118 );
119 }
120
121 let python = resolve_canonical_python_bin();
123 println!("[uninstall] Removing ML pip packages via {python} …");
124 let mut pkgs: Vec<String> = ML_PIP_PACKAGES.iter().map(|s| s.to_string()).collect();
128 pkgs.extend(crate::core::registry::InstalledComponentRegistry::load().all_pip_packages());
129 pkgs.sort();
130 pkgs.dedup();
131 report.pip_uninstall_attempted = pkgs.clone();
132 let mut pip_args = vec![
133 "-m".to_string(),
134 "pip".to_string(),
135 "uninstall".to_string(),
136 "-y".to_string(),
137 ];
138 pip_args.extend(pkgs);
139 let pip_status = Command::new(&python).args(&pip_args).status();
140 match pip_status {
141 Ok(s) if s.success() => {}
142 Ok(s) => report.note(format!(
143 "pip uninstall exited non-zero ({s}) — some packages may not have been installed"
144 )),
145 Err(e) => report.note(format!("could not run {python} -m pip ({e})")),
146 }
147
148 if !opts.keep_rocm {
150 let installed = filter_installed_system_packages(ROCM_SYSTEM_PACKAGES);
156 if installed.is_empty() {
157 report.note(
158 "no ROCm/amdgpu system packages are installed — skipping system-package purge",
159 );
160 } else if let Some((pm, purge_args)) = build_system_purge_cmd(&installed) {
161 println!("[uninstall] Purging ROCm/amdgpu system packages via {pm} …");
162 let ran = run_privileged(&pm, &purge_args, &mut report);
163 report.system_packages_purged = ran;
164 } else {
165 report.note("no supported system package manager detected (apt/dnf/pacman/zypper) — skipping ROCm system-package purge");
166 }
167
168 if PathBuf::from("/opt/rocm").exists() {
170 println!("[uninstall] Removing /opt/rocm …");
171 report.opt_rocm_removed = run_privileged(
172 "rm",
173 &["-rf".to_string(), "/opt/rocm".to_string()],
174 &mut report,
175 );
176 }
177 } else {
178 println!("[uninstall] --keep-rocm: leaving ROCm system packages and /opt/rocm in place");
179 }
180
181 remove_env_files(&home, &mut report);
183 strip_shell_sourcing(&home, &mut report);
184
185 let mut registry = InstalledComponentRegistry::load();
187 if !registry.is_empty() {
188 registry.clear();
189 if let Err(e) = registry.save() {
190 report.note(format!("failed to clear registry: {e}"));
191 } else {
192 report.registry_cleared = true;
193 }
194 }
195
196 if opts.purge_mlstack_dir {
198 let root = crate::platform::environment::mlstack_root();
199 if root.exists() {
200 if std::fs::remove_dir_all(&root).is_ok() {
203 report
204 .env_files_removed
205 .push(format!("(dir) {}", root.display()));
206 } else {
207 let removed = run_privileged(
208 "rm",
209 &["-rf".to_string(), root.to_string_lossy().into_owned()],
210 &mut report,
211 );
212 if removed {
213 report
214 .env_files_removed
215 .push(format!("(dir, sudo) {}", root.display()));
216 }
217 }
218 }
219 }
220
221 Ok(report)
222}
223
224fn build_system_purge_cmd(packages: &[&str]) -> Option<(String, Vec<String>)> {
227 let (pm, verb) = if command_on_path("apt") {
228 (
229 "apt",
230 vec!["purge".to_string(), "-y".to_string(), "-qq".to_string()],
231 )
232 } else if command_on_path("pacman") {
233 ("pacman", vec!["-Rn".to_string(), "--noconfirm".to_string()])
234 } else if command_on_path("dnf") {
235 ("dnf", vec!["remove".to_string(), "-y".to_string()])
236 } else if command_on_path("zypper") {
237 ("zypper", vec!["remove".to_string(), "-y".to_string()])
238 } else if command_on_path("yum") {
239 ("yum", vec!["remove".to_string(), "-y".to_string()])
240 } else {
241 return None;
242 };
243 let mut args = verb;
244 args.extend(packages.iter().map(|s| s.to_string()));
245 Some((pm.to_string(), args))
246}
247
248fn filter_installed_system_packages<'a>(candidates: &[&'a str]) -> Vec<&'a str> {
255 candidates
258 .iter()
259 .copied()
260 .filter(|pkg| is_system_package_installed(pkg))
261 .collect()
262}
263
264fn is_system_package_installed(pkg: &str) -> bool {
266 if command_on_path("dpkg-query") {
268 return Command::new("dpkg-query")
269 .args(["-W", "-f=${Status}", pkg])
270 .output()
271 .map(|o| {
272 o.status.success()
273 && String::from_utf8_lossy(&o.stdout).contains("install ok installed")
274 })
275 .unwrap_or(false);
276 }
277 if command_on_path("pacman") {
279 return Command::new("pacman")
280 .args(["-Q", pkg])
281 .output()
282 .map(|o| o.status.success())
283 .unwrap_or(false);
284 }
285 if command_on_path("rpm") {
287 return Command::new("rpm")
288 .args(["-q", pkg])
289 .output()
290 .map(|o| o.status.success())
291 .unwrap_or(false);
292 }
293 true
296}
297
298fn run_privileged(program: &str, args: &[String], report: &mut UninstallReport) -> bool {
304 let is_root = is_root();
305 let (cmd_program, cmd_args) = if is_root {
306 (program.to_string(), args.to_vec())
307 } else if command_on_path("sudo") {
308 let mut a = vec!["-n".to_string(), program.to_string()];
313 a.extend_from_slice(args);
314 ("sudo".to_string(), a)
315 } else {
316 report.note(format!(
317 "skipping `{program} {args:?}` — not root and sudo unavailable",
318 ));
319 return false;
320 };
321 match Command::new(&cmd_program).args(&cmd_args).status() {
322 Ok(s) if s.success() => true,
323 Ok(s) => {
324 report.note(format!("`{cmd_program}` exited non-zero ({s})"));
325 false
326 }
327 Err(e) => {
328 report.note(format!(
329 "could not run `{cmd_program}` ({e}) — sudo may require a password (no TTY)"
330 ));
331 false
332 }
333 }
334}
335
336#[cfg(unix)]
337fn is_root() -> bool {
338 unsafe { libc::geteuid() == 0 }
340}
341#[cfg(not(unix))]
342fn is_root() -> bool {
343 false
344}
345
346fn remove_env_files(home: &std::path::Path, report: &mut UninstallReport) {
348 let targets = [
349 home.join(".mlstack_env"),
350 home.join(".config/fish/conf.d/mlstack_env.fish"),
351 home.join(".rocm_env"),
352 ];
353 for t in targets {
354 if t.exists() {
355 match std::fs::remove_file(&t) {
356 Ok(()) => report
357 .env_files_removed
358 .push(t.to_string_lossy().into_owned()),
359 Err(e) => report.note(format!("could not remove {}: {e}", t.display())),
360 }
361 }
362 }
363}
364
365fn strip_shell_sourcing(home: &std::path::Path, report: &mut UninstallReport) {
375 let rcs = [
376 home.join(".bashrc"),
377 home.join(".zshrc"),
378 home.join(".config/fish/config.fish"),
379 ];
380 let had_trailing_newline = |content: &str| content.ends_with('\n');
381 for rc in rcs {
382 let Ok(content) = std::fs::read_to_string(&rc) else {
383 continue;
384 };
385 let mut filtered = String::with_capacity(content.len());
386 let mut changed = false;
387 for line in content.lines() {
388 let t = line.trim();
389 let is_source_line = t.contains(".mlstack_env")
394 && (t.contains("source") || t.contains("bass") || t.starts_with(". "));
395 if is_source_line {
396 changed = true;
397 } else {
398 filtered.push_str(line);
399 filtered.push('\n');
400 }
401 }
402 if changed {
403 if !had_trailing_newline(&content) && filtered.ends_with('\n') {
409 filtered.pop();
410 }
411 let _ = std::fs::write(&rc, filtered);
412 report
413 .sourcing_lines_stripped
414 .push(rc.to_string_lossy().into_owned());
415 }
416 }
417}
418
419pub fn global_env_dir() -> PathBuf {
421 mlstack_global_dir()
422}
423
424#[cfg(test)]
425mod tests {
426 use super::*;
427
428 #[test]
429 fn build_system_purge_cmd_apt_when_present() {
430 let cmd = build_system_purge_cmd(&["rocm-hip-sdk"]);
433 if command_on_path("apt") {
434 let (pm, args) = cmd.expect("apt present");
435 assert_eq!(pm, "apt");
436 assert!(args.contains(&"purge".to_string()));
437 assert!(args.contains(&"rocm-hip-sdk".to_string()));
438 }
439 }
440
441 #[test]
442 fn strip_sourcing_removes_mlstack_lines() {
443 let dir = tempfile::tempdir().unwrap();
444 let rc = dir.path().join(".bashrc");
445 std::fs::write(
446 &rc,
447 "# my rc\nsource \"$HOME/.mlstack_env\"\nexport FOO=bar\n. ~/.mlstack_env\n",
448 )
449 .unwrap();
450 let mut report = UninstallReport::default();
451 strip_shell_sourcing(dir.path(), &mut report);
452 let after = std::fs::read_to_string(&rc).unwrap();
453 assert!(
454 !after.contains(".mlstack_env"),
455 "sourcing lines removed: {after}"
456 );
457 assert!(
458 after.contains("export FOO=bar"),
459 "other lines preserved: {after}"
460 );
461 assert_eq!(report.sourcing_lines_stripped.len(), 1);
462 assert!(
464 after.ends_with('\n'),
465 "trailing newline preserved: {after:?}"
466 );
467 }
468
469 #[test]
470 fn strip_sourcing_preserves_non_source_mentions() {
471 let dir = tempfile::tempdir().unwrap();
475 let rc = dir.path().join(".bashrc");
476 let original = "# see ~/.mlstack_env for env\necho loaded .mlstack_env\nMLSTACK_ENV_FILE=.mlstack_env\n";
477 std::fs::write(&rc, original).unwrap();
478 let mut report = UninstallReport::default();
479 strip_shell_sourcing(dir.path(), &mut report);
480 let after = std::fs::read_to_string(&rc).unwrap();
481 assert_eq!(
482 report.sourcing_lines_stripped.len(),
483 0,
484 "no source lines, no rewrite: {after:?}"
485 );
486 assert!(
488 after.contains("# see ~/.mlstack_env for env"),
489 "comment preserved: {after}"
490 );
491 assert!(
492 after.contains("MLSTACK_ENV_FILE=.mlstack_env"),
493 "assignment preserved: {after}"
494 );
495 }
496
497 #[test]
498 fn remove_env_files_deletes_targets() {
499 let dir = tempfile::tempdir().unwrap();
500 std::fs::write(dir.path().join(".mlstack_env"), "x").unwrap();
501 let mut report = UninstallReport::default();
502 remove_env_files(dir.path(), &mut report);
503 assert!(!dir.path().join(".mlstack_env").exists());
504 assert!(!report.env_files_removed.is_empty());
505 }
506}