Skip to main content

rusty_stack/
uninstall.rs

1//! Uninstall orchestrator — removes the Rusty-managed ML stack so a clean
2//! reinstall is possible without manual purging.
3//!
4//! # Tenet (v0.3.0 remediation)
5//!
6//! "Reinstall functionality is currently broken and DOES NOT WORK. Rusty
7//! uninstall AND force-reinstall options MUST be fully functional across shells
8//! (fish, bash, zsh) AND across OS's (cross Linux distributions)."
9//!
10//! This module existed in v0.2.0 as dead primitives (`PackageManager::purge`,
11//! `purge_component_packages`) that were never wired into a user-facing flow.
12//! It is now a real `uninstall_stack()` reachable via `rusty uninstall`.
13//!
14//! # What it removes
15//!
16//! 1. Python ML packages (pip) installed by Rusty components.
17//! 2. ROCm/amdgpu system packages via the detected package manager (apt/dnf/
18//!    pacman/zypper) — cross-distro; skipped with `--keep-rocm`.
19//! 3. `/opt/rocm` (sudo) — skipped with `--keep-rocm`.
20//! 4. The env files Rusty wrote (`~/.mlstack_env`, the fish `conf.d` file) and
21//!    the legacy `source ~/.mlstack_env` lines from `~/.bashrc` / `~/.zshrc` /
22//!    `~/.config/fish/config.fish` (cross-shell).
23//! 5. The installed-component registry (`~/.mlstack/installed.json`).
24//!
25//! Sudo steps shell out to `sudo` (the user authenticates); they are skipped
26//! (with a warning) if not root and sudo is unavailable, so the command never
27//! stalls on a hidden prompt.
28
29use 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
38/// ROCm/amdgpu system-package name candidates tried across distros. The package
39/// manager ignores names that aren't installed.
40const 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
50/// Python ML packages Rusty installs (pip). Each uninstall is non-fatal.
51const 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/// Options for [`uninstall_stack`].
76#[derive(Debug, Clone, Default)]
77pub struct UninstallOptions {
78    /// Keep ROCm/amdgpu system packages and `/opt/rocm` (only remove Python +
79    /// env/registry). Useful when reinstalling just the Python stack.
80    pub keep_rocm: bool,
81    /// Skip the interactive confirmation prompt.
82    pub yes: bool,
83    /// Also remove the `~/.mlstack/` data root (logs, cache, global venv).
84    /// Off by default — preserves logs/diagnostics across reinstalls.
85    pub purge_mlstack_dir: bool,
86    /// Optional sudo password for the privileged steps (system-package purge,
87    /// `/opt/rocm` removal). When set, privileged commands run via `sudo -A`
88    /// with an askpass helper so they work without a TTY or cached credentials
89    /// (which `sudo -n` cannot). When unset, falls back to `sudo -n`.
90    pub sudo_password: Option<String>,
91}
92
93/// A summary of what uninstall did.
94#[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
111/// Uninstall the Rusty-managed ML stack.
112///
113/// See the module docs for what is removed. Never panics; sudo steps degrade
114/// gracefully when not root and sudo is unavailable.
115pub 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        // Caller (CLI) is expected to confirm; this is the non-interactive guard.
121        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    // 1. pip uninstall ML packages via the canonical Python.
128    let python = resolve_canonical_python_bin();
129
130    // 1a. Remove pip's corrupted `~`-prefixed leftovers BEFORE the pip
131    // uninstall, so pip's own site-packages scan doesn't emit "Ignoring
132    // invalid distribution ~pkg" warnings. These are frequently root-owned
133    // (interrupted `sudo pip`), so this needs the privileged path.
134    clean_corrupted_pip_distributions(&python, opts.sudo_password.as_deref(), &mut report);
135
136    println!("[uninstall] Removing ML pip packages via {python} …");
137    // Union the curated list with the installed-component registry's recorded
138    // pip packages (Tenet 2: the registry is now populated at install time, so
139    // uninstall consults it instead of relying solely on the hardcoded list).
140    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        // Rusty manages these ML packages; the resolved interpreter may be a
151        // uv/system Python marked EXTERNALLY-MANAGED (PEP 668) — especially
152        // when the managed ~/.mlstack/global venv is absent and we fall back
153        // to a discovered interpreter holding legacy installs. Override the
154        // guard so the curated ML set is actually removed (mirrors the install
155        // side, which uses PIP_BREAK_SYSTEM_PACKAGES=1).
156        "--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    // 2. ROCm/amdgpu system packages (cross-distro) unless --keep-rocm.
169    if !opts.keep_rocm {
170        // Pre-filter to only the installed packages: apt/pacman exit non-zero
171        // if ANY named package in the purge list is absent (e.g. amdgpu-dkms on
172        // a partial stack), which would abort the whole purge and leave valid
173        // packages (e.g. rocm-core) installed. dnf/yum tolerate missing names,
174        // but filtering is harmless there too. Only purge what's present.
175        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        // 3. /opt/rocm (sudo).
189        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    // 4. Env files + shell sourcing lines.
203    remove_env_files(&home, &mut report);
204    strip_shell_sourcing(&home, &mut report);
205
206    // 5. Registry.
207    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    // Optional: ~/.mlstack data root.
218    if opts.purge_mlstack_dir {
219        let root = crate::platform::environment::mlstack_root();
220        if root.exists() {
221            // The global venv / cache may contain root-owned bits; try plain,
222            // then sudo if needed.
223            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
246/// Build a (program, args) purge command for the detected package manager, or
247/// `None` if no supported manager is on PATH.
248fn 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        // `-Rcn` cascade is unsafe — it removes ALL dependents, including
256        // non-ROCm packages that happen to depend on ROCm libs. Instead,
257        // enumerate the full ROCm package set explicitly and use `-Rn` (no
258        // cascade) to guarantee only ROCm is removed.
259        let full_set = enumerate_rocm_explicit_purge_set(packages);
260        if full_set.is_empty() {
261            // Nothing to purge (or enumeration failed — logged inside).
262            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
281/// Enumerate the full set of ROCm packages to remove explicitly (targets +
282/// their ROCm-internal dependents). Returns a sorted Vec of package names to
283/// pass to `pacman -Rn`. Only includes packages matching the ROCm allowlist.
284/// Non-ROCm dependents are excluded (logged to stderr).
285fn 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
315/// ROCm package name patterns — any package matching these prefixes/names is
316/// considered part of the ROCm stack. This covers the full ROCm package
317/// namespace on Arch (rocm-core, hip-*-, roc-*-, comgr, migraphx, miopen-hip,
318/// rccl, rocwmma, hsa-*, amdgpu-*, libdrm_amd, etc.).
319fn 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
346/// True if pkg matches any ROCm allowlist pattern (prefix or exact match).
347fn 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
353/// Compute the reverse dependency tree for `package` via `pactree -r`. Returns
354/// all packages that depend on `package` (directly or transitively). Empty on
355/// failure (pactree missing, or package not installed).
356///
357/// `--unique` (`-u`) is required: `pactree -r` defaults to a *tree* view with
358/// box-drawing prefixes and indentation, so a naive line-by-line parse would
359/// capture the structural glyphs / duplicated entries and the allowlist would
360/// never see bare package names. `--unique` implies `--linear` (one package per
361/// line, deduplicated) which is exactly the format the caller filters.
362fn reverse_tree_pacman(package: &str) -> Vec<String> {
363    if !command_on_path("pactree") {
364        // pactree is part of pacman-contrib — assumed present on any ROCm
365        // system (Arch installs the contrib package with base-devel).
366        // If absent, we can't compute dependents safely — return empty to
367        // avoid partial removal.
368        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
398/// Filter `candidates` to only those that are actually installed, using the
399/// detected package manager's "is installed" query. This makes the purge
400/// tolerant of partial stacks: apt/pacman exit non-zero if any named package in
401/// a purge list is absent, so we only pass installed names. Returns the
402/// original list verbatim if no manager is detected (let the purge path emit its
403/// own "no manager" note).
404fn filter_installed_system_packages<'a>(candidates: &[&'a str]) -> Vec<&'a str> {
405    // Return the installed candidates with the same lifetime as the input
406    // (ROCM_SYSTEM_PACKAGES is &'static, so the call site gets &'static strs).
407    candidates
408        .iter()
409        .copied()
410        .filter(|pkg| is_system_package_installed(pkg))
411        .collect()
412}
413
414/// Is `pkg` installed according to the detected package manager?
415fn is_system_package_installed(pkg: &str) -> bool {
416    // dpkg-query: exit 0 iff installed. (apt-backed distros.)
417    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    // pacman -Q: exit 0 iff installed.
428    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    // rpm -q: exit 0 iff installed (dnf/yum/zypper all back onto rpm).
436    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    // No detector: assume installed so the purge is attempted (the purge path
444    // will note "no manager" if none exists).
445    true
446}
447
448/// Run a privileged command: directly if root, otherwise via sudo. Returns
449/// `true` if it ran successfully. Never stalls on a hidden prompt.
450///
451/// When `sudo_password` is supplied, the command runs as `sudo -A` with a
452/// temporary askpass helper (`SUDO_ASKPASS`), so it works with no TTY and no
453/// cached credentials — the case `sudo -n` cannot handle (it fails whenever a
454/// password is required, e.g. a normal desktop without NOPASSWD). When no
455/// password is supplied, it falls back to `sudo -n` (cached/NOPASSWD only),
456/// degrading to a warning rather than hanging in CI/CD or SSH-without-TTY.
457fn 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    // Prefer askpass when a password is available — it works unattended where
473    // `sudo -n` cannot. The Askpass guard keeps its temp files alive for the
474    // duration of the (synchronous) command below.
475    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    // No password (or askpass failed) → non-interactive sudo: fail fast instead
490    // of prompting when no cached credentials exist.
491    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
496/// Spawn `program args` (optionally with `SUDO_ASKPASS` set) and return whether
497/// it succeeded. Records a note on failure.
498fn 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    // SAFETY: geteuid is always safe on Unix.
527    unsafe { libc::geteuid() == 0 }
528}
529#[cfg(not(unix))]
530fn is_root() -> bool {
531    false
532}
533
534/// Remove pip's corrupted `~`-prefixed leftovers from interrupted uninstalls.
535///
536/// pip renames a package dir to `~pkg` (and `~pkg-VERSION.dist-info`) just
537/// before deleting it; if the delete is interrupted (OOM, signal, a file
538/// locked under a managed uv Python), those `~` entries survive and pip emits
539/// "Ignoring invalid distribution ~pkg" warnings on every later invocation.
540///
541/// These leftovers are frequently **root-owned** (the interrupted uninstall
542/// ran under `sudo pip`), so a user-level `remove_dir_all` silently fails. We
543/// try the user-level removal first and fall back to a single privileged
544/// `sudo rm -rf` (via askpass) for any that remain — typically all of them on
545/// a stack that was ever installed with sudo.
546fn clean_corrupted_pip_distributions(
547    python: &str,
548    sudo_password: Option<&str>,
549    report: &mut UninstallReport,
550) {
551    // Ask the interpreter for its site-packages dirs (global + user).
552    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            // Try user-level removal first (covers user-owned leftovers).
581            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                // User-level failed (likely root-owned) — defer to a privileged rm.
592                needs_sudo.push(p.to_string());
593            }
594        }
595    }
596    if needs_sudo.is_empty() {
597        return;
598    }
599    // Batch-remove the root-owned leftovers in one privileged transaction.
600    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
616/// Remove the env files Rusty wrote.
617fn 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
635/// Strip legacy `source ~/.mlstack_env` / `.mlstack_env` lines from shell rc
636/// files (fish/bash/zsh). Idempotent: only rewrites if a line was actually
637/// removed, and preserves the original trailing newline.
638///
639/// Only drops lines that actually SOURCE the env file — `source .mlstack_env`,
640/// `. ~/.mlstack_env` (POSIX dot), or `bass source ...mlstack_env` (fish). A
641/// bare mention of `.mlstack_env` in a comment/echo/assignment is NOT a source
642/// line and is preserved. (The previous `t.contains(".")` condition was always
643/// true for any `.mlstack_env` line and dropped everything mentioning it.)
644fn 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            // A source line: it mentions .mlstack_env AND uses a source command
660            // — `source`/`.` (POSIX dot-source at the start of the line)/`bass`
661            // (fish wrapper). NOT a bare `.mlstack_env` mention (the old
662            // `t.contains(".")` matched the dot in the filename itself).
663            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            // Preserve the original trailing-newline state. `content.lines()`
674            // drops a trailing newline; we always re-add '\n' per line above, so
675            // a file that originally ended without one would gain one — trim it
676            // back to match. A file that ended with '\n' keeps it (the last
677            // pushed '\n' stands in for the original terminator).
678            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
689/// Path to the managed global env (used by callers/tests).
690pub 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        // Only meaningful on a Debian host; just ensure it doesn't panic and
701        // returns either Some(apt...) or None.
702        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        // Trailing newline preserved (original ended with '\n').
733        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        // D1 regression: a comment / echo / assignment that merely MENTIONS
742        // .mlstack_env is NOT a source line and must be kept. The old
743        // `t.contains(".")` matched the dot in the filename and dropped these.
744        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        // File untouched (changed == false → no write at all).
757        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}