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 pub sudo_password: Option<String>,
90}
91
92#[derive(Debug, Clone, Default)]
94pub struct UninstallReport {
95 pub pip_uninstall_attempted: Vec<String>,
96 pub system_packages_purged: bool,
97 pub opt_rocm_removed: bool,
98 pub env_files_removed: Vec<String>,
99 pub sourcing_lines_stripped: Vec<String>,
100 pub registry_cleared: bool,
101 pub warnings: Vec<String>,
102}
103
104impl UninstallReport {
105 fn note(&mut self, msg: impl Into<String>) {
106 self.warnings.push(msg.into());
107 }
108}
109
110pub fn uninstall_stack(opts: &UninstallOptions) -> anyhow::Result<UninstallReport> {
115 let mut report = UninstallReport::default();
116 let home = resolve_user_home();
117
118 if !opts.yes {
119 eprintln!(
121 "[uninstall] Proceeding (use --yes to skip this notice). keep_rocm={}, purge_dir={}",
122 opts.keep_rocm, opts.purge_mlstack_dir
123 );
124 }
125
126 let python = resolve_canonical_python_bin();
128 println!("[uninstall] Removing ML pip packages via {python} …");
129 let mut pkgs: Vec<String> = ML_PIP_PACKAGES.iter().map(|s| s.to_string()).collect();
133 pkgs.extend(crate::core::registry::InstalledComponentRegistry::load().all_pip_packages());
134 pkgs.sort();
135 pkgs.dedup();
136 report.pip_uninstall_attempted = pkgs.clone();
137 let mut pip_args = vec![
138 "-m".to_string(),
139 "pip".to_string(),
140 "uninstall".to_string(),
141 "-y".to_string(),
142 ];
143 pip_args.extend(pkgs);
144 let pip_status = Command::new(&python).args(&pip_args).status();
145 match pip_status {
146 Ok(s) if s.success() => {}
147 Ok(s) => report.note(format!(
148 "pip uninstall exited non-zero ({s}) — some packages may not have been installed"
149 )),
150 Err(e) => report.note(format!("could not run {python} -m pip ({e})")),
151 }
152
153 if !opts.keep_rocm {
155 let installed = filter_installed_system_packages(ROCM_SYSTEM_PACKAGES);
161 if installed.is_empty() {
162 report.note(
163 "no ROCm/amdgpu system packages are installed — skipping system-package purge",
164 );
165 } else if let Some((pm, purge_args)) = build_system_purge_cmd(&installed) {
166 println!("[uninstall] Purging ROCm/amdgpu system packages via {pm} …");
167 let ran = run_privileged(&pm, &purge_args, opts.sudo_password.as_deref(), &mut report);
168 report.system_packages_purged = ran;
169 } else {
170 report.note("no supported system package manager detected (apt/dnf/pacman/zypper) — skipping ROCm system-package purge");
171 }
172
173 if PathBuf::from("/opt/rocm").exists() {
175 println!("[uninstall] Removing /opt/rocm …");
176 report.opt_rocm_removed = run_privileged(
177 "rm",
178 &["-rf".to_string(), "/opt/rocm".to_string()],
179 opts.sudo_password.as_deref(),
180 &mut report,
181 );
182 }
183 } else {
184 println!("[uninstall] --keep-rocm: leaving ROCm system packages and /opt/rocm in place");
185 }
186
187 remove_env_files(&home, &mut report);
189 strip_shell_sourcing(&home, &mut report);
190
191 let mut registry = InstalledComponentRegistry::load();
193 if !registry.is_empty() {
194 registry.clear();
195 if let Err(e) = registry.save() {
196 report.note(format!("failed to clear registry: {e}"));
197 } else {
198 report.registry_cleared = true;
199 }
200 }
201
202 if opts.purge_mlstack_dir {
204 let root = crate::platform::environment::mlstack_root();
205 if root.exists() {
206 if std::fs::remove_dir_all(&root).is_ok() {
209 report
210 .env_files_removed
211 .push(format!("(dir) {}", root.display()));
212 } else {
213 let removed = run_privileged(
214 "rm",
215 &["-rf".to_string(), root.to_string_lossy().into_owned()],
216 opts.sudo_password.as_deref(),
217 &mut report,
218 );
219 if removed {
220 report
221 .env_files_removed
222 .push(format!("(dir, sudo) {}", root.display()));
223 }
224 }
225 }
226 }
227
228 Ok(report)
229}
230
231fn build_system_purge_cmd(packages: &[&str]) -> Option<(String, Vec<String>)> {
234 let (pm, verb) = if command_on_path("apt") {
235 (
236 "apt",
237 vec!["purge".to_string(), "-y".to_string(), "-qq".to_string()],
238 )
239 } else if command_on_path("pacman") {
240 ("pacman", vec!["-Rn".to_string(), "--noconfirm".to_string()])
241 } else if command_on_path("dnf") {
242 ("dnf", vec!["remove".to_string(), "-y".to_string()])
243 } else if command_on_path("zypper") {
244 ("zypper", vec!["remove".to_string(), "-y".to_string()])
245 } else if command_on_path("yum") {
246 ("yum", vec!["remove".to_string(), "-y".to_string()])
247 } else {
248 return None;
249 };
250 let mut args = verb;
251 args.extend(packages.iter().map(|s| s.to_string()));
252 Some((pm.to_string(), args))
253}
254
255fn filter_installed_system_packages<'a>(candidates: &[&'a str]) -> Vec<&'a str> {
262 candidates
265 .iter()
266 .copied()
267 .filter(|pkg| is_system_package_installed(pkg))
268 .collect()
269}
270
271fn is_system_package_installed(pkg: &str) -> bool {
273 if command_on_path("dpkg-query") {
275 return Command::new("dpkg-query")
276 .args(["-W", "-f=${Status}", pkg])
277 .output()
278 .map(|o| {
279 o.status.success()
280 && String::from_utf8_lossy(&o.stdout).contains("install ok installed")
281 })
282 .unwrap_or(false);
283 }
284 if command_on_path("pacman") {
286 return Command::new("pacman")
287 .args(["-Q", pkg])
288 .output()
289 .map(|o| o.status.success())
290 .unwrap_or(false);
291 }
292 if command_on_path("rpm") {
294 return Command::new("rpm")
295 .args(["-q", pkg])
296 .output()
297 .map(|o| o.status.success())
298 .unwrap_or(false);
299 }
300 true
303}
304
305fn run_privileged(
315 program: &str,
316 args: &[String],
317 sudo_password: Option<&str>,
318 report: &mut UninstallReport,
319) -> bool {
320 if is_root() {
321 return run_command_status(program, args, None, report);
322 }
323 if !command_on_path("sudo") {
324 report.note(format!(
325 "skipping `{program} {args:?}` — not root and sudo unavailable",
326 ));
327 return false;
328 }
329 if let Some(pw) = sudo_password.filter(|p| !p.is_empty()) {
333 match crate::installers::common::askpass::Askpass::new(pw) {
334 Ok(helper) => {
335 let mut sudo_args = vec!["-A".to_string(), program.to_string()];
336 sudo_args.extend_from_slice(args);
337 return run_command_status("sudo", &sudo_args, Some(helper.path()), report);
338 }
339 Err(e) => {
340 report.note(format!(
341 "askpass setup failed ({e}); falling back to sudo -n"
342 ));
343 }
344 }
345 }
346 let mut a = vec!["-n".to_string(), program.to_string()];
349 a.extend_from_slice(args);
350 run_command_status("sudo", &a, None, report)
351}
352
353fn run_command_status(
356 program: &str,
357 args: &[String],
358 askpass_path: Option<&str>,
359 report: &mut UninstallReport,
360) -> bool {
361 let mut cmd = Command::new(program);
362 cmd.args(args);
363 if let Some(p) = askpass_path {
364 cmd.env("SUDO_ASKPASS", p);
365 }
366 match cmd.status() {
367 Ok(s) if s.success() => true,
368 Ok(s) => {
369 report.note(format!("`{program}` exited non-zero ({s})"));
370 false
371 }
372 Err(e) => {
373 report.note(format!(
374 "could not run `{program}` ({e}) — sudo may require a password (no TTY)"
375 ));
376 false
377 }
378 }
379}
380
381#[cfg(unix)]
382fn is_root() -> bool {
383 unsafe { libc::geteuid() == 0 }
385}
386#[cfg(not(unix))]
387fn is_root() -> bool {
388 false
389}
390
391fn remove_env_files(home: &std::path::Path, report: &mut UninstallReport) {
393 let targets = [
394 home.join(".mlstack_env"),
395 home.join(".config/fish/conf.d/mlstack_env.fish"),
396 home.join(".rocm_env"),
397 ];
398 for t in targets {
399 if t.exists() {
400 match std::fs::remove_file(&t) {
401 Ok(()) => report
402 .env_files_removed
403 .push(t.to_string_lossy().into_owned()),
404 Err(e) => report.note(format!("could not remove {}: {e}", t.display())),
405 }
406 }
407 }
408}
409
410fn strip_shell_sourcing(home: &std::path::Path, report: &mut UninstallReport) {
420 let rcs = [
421 home.join(".bashrc"),
422 home.join(".zshrc"),
423 home.join(".config/fish/config.fish"),
424 ];
425 let had_trailing_newline = |content: &str| content.ends_with('\n');
426 for rc in rcs {
427 let Ok(content) = std::fs::read_to_string(&rc) else {
428 continue;
429 };
430 let mut filtered = String::with_capacity(content.len());
431 let mut changed = false;
432 for line in content.lines() {
433 let t = line.trim();
434 let is_source_line = t.contains(".mlstack_env")
439 && (t.contains("source") || t.contains("bass") || t.starts_with(". "));
440 if is_source_line {
441 changed = true;
442 } else {
443 filtered.push_str(line);
444 filtered.push('\n');
445 }
446 }
447 if changed {
448 if !had_trailing_newline(&content) && filtered.ends_with('\n') {
454 filtered.pop();
455 }
456 let _ = std::fs::write(&rc, filtered);
457 report
458 .sourcing_lines_stripped
459 .push(rc.to_string_lossy().into_owned());
460 }
461 }
462}
463
464pub fn global_env_dir() -> PathBuf {
466 mlstack_global_dir()
467}
468
469#[cfg(test)]
470mod tests {
471 use super::*;
472
473 #[test]
474 fn build_system_purge_cmd_apt_when_present() {
475 let cmd = build_system_purge_cmd(&["rocm-hip-sdk"]);
478 if command_on_path("apt") {
479 let (pm, args) = cmd.expect("apt present");
480 assert_eq!(pm, "apt");
481 assert!(args.contains(&"purge".to_string()));
482 assert!(args.contains(&"rocm-hip-sdk".to_string()));
483 }
484 }
485
486 #[test]
487 fn strip_sourcing_removes_mlstack_lines() {
488 let dir = tempfile::tempdir().unwrap();
489 let rc = dir.path().join(".bashrc");
490 std::fs::write(
491 &rc,
492 "# my rc\nsource \"$HOME/.mlstack_env\"\nexport FOO=bar\n. ~/.mlstack_env\n",
493 )
494 .unwrap();
495 let mut report = UninstallReport::default();
496 strip_shell_sourcing(dir.path(), &mut report);
497 let after = std::fs::read_to_string(&rc).unwrap();
498 assert!(
499 !after.contains(".mlstack_env"),
500 "sourcing lines removed: {after}"
501 );
502 assert!(
503 after.contains("export FOO=bar"),
504 "other lines preserved: {after}"
505 );
506 assert_eq!(report.sourcing_lines_stripped.len(), 1);
507 assert!(
509 after.ends_with('\n'),
510 "trailing newline preserved: {after:?}"
511 );
512 }
513
514 #[test]
515 fn strip_sourcing_preserves_non_source_mentions() {
516 let dir = tempfile::tempdir().unwrap();
520 let rc = dir.path().join(".bashrc");
521 let original = "# see ~/.mlstack_env for env\necho loaded .mlstack_env\nMLSTACK_ENV_FILE=.mlstack_env\n";
522 std::fs::write(&rc, original).unwrap();
523 let mut report = UninstallReport::default();
524 strip_shell_sourcing(dir.path(), &mut report);
525 let after = std::fs::read_to_string(&rc).unwrap();
526 assert_eq!(
527 report.sourcing_lines_stripped.len(),
528 0,
529 "no source lines, no rewrite: {after:?}"
530 );
531 assert!(
533 after.contains("# see ~/.mlstack_env for env"),
534 "comment preserved: {after}"
535 );
536 assert!(
537 after.contains("MLSTACK_ENV_FILE=.mlstack_env"),
538 "assignment preserved: {after}"
539 );
540 }
541
542 #[test]
543 fn remove_env_files_deletes_targets() {
544 let dir = tempfile::tempdir().unwrap();
545 std::fs::write(dir.path().join(".mlstack_env"), "x").unwrap();
546 let mut report = UninstallReport::default();
547 remove_env_files(dir.path(), &mut report);
548 assert!(!dir.path().join(".mlstack_env").exists());
549 assert!(!report.env_files_removed.is_empty());
550 }
551}