Skip to main content

zoi_uninstall/
lib.rs

1//! Uninstallation logic for Zoi packages.
2//!
3//! This crate handles the safe removal of packages, including cleaning up
4//! binaries, completion scripts, service units, and dependency management.
5
6/// Logic for automatically removing unused dependencies.
7pub mod autoremove;
8
9use std::fs;
10use std::io::Write;
11use std::path::PathBuf;
12
13use anyhow::anyhow;
14use colored::Colorize;
15use mlua::Lua;
16use zoi_core::{recorder, sysroot, types, utils as core_utils};
17use zoi_db as db;
18use zoi_deps as dependencies;
19use zoi_hooks as hooks;
20use zoi_resolver::{local, resolve};
21use zoi_telemetry as telemetry;
22
23/// Gets the root directory for binaries based on the installation scope.
24fn get_bin_root(scope: types::Scope) -> anyhow::Result<PathBuf> {
25    match scope {
26        types::Scope::User => core_utils::get_user_bin_dir(),
27        types::Scope::System => Ok(core_utils::get_system_bin_dir()),
28        types::Scope::Project => {
29            let current_dir = std::env::current_dir()?;
30            Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
31        }
32    }
33}
34
35/// Gets the root directory for shell completions based on the scope and shell
36/// type.
37fn get_completions_root(
38    scope: types::Scope,
39    shell: &str
40) -> anyhow::Result<PathBuf> {
41    match scope {
42        types::Scope::User => core_utils::get_user_completions_dir(shell),
43        types::Scope::System => {
44            if cfg!(target_os = "windows") {
45                Ok(sysroot::apply_sysroot(PathBuf::from(format!(
46                    "C:\\ProgramData\\zoi\\pkgs\\shell\\{shell}",
47                ))))
48            } else {
49                let base = match shell {
50                    "bash" => "/usr/share/bash-completion/completions",
51                    "zsh" => "/usr/share/zsh/site-functions",
52                    "fish" => "/usr/share/fish/vendor_completions.d",
53                    "elvish" => "/usr/share/elvish/lib",
54                    _ => "/usr/local/share/zoi/completions"
55                };
56                Ok(sysroot::apply_sysroot(PathBuf::from(base)))
57            }
58        }
59        types::Scope::Project => {
60            let current_dir = std::env::current_dir()?;
61            Ok(current_dir
62                .join(".zoi")
63                .join("pkgs")
64                .join("shell")
65                .join(shell))
66        }
67    }
68}
69
70/// Cleans up service unit files or Windows services associated with a package.
71fn cleanup_service(
72    package_name: &str,
73    scope: types::Scope
74) -> anyhow::Result<()> {
75    let service_name = format!("zoi-{package_name}");
76    let is_user = scope != types::Scope::System;
77
78    match std::env::consts::OS {
79        "linux" => {
80            let unit_path = if is_user {
81                let home = core_utils::get_user_home()
82                    .ok_or_else(|| anyhow!("Could not find home directory"))?;
83                sysroot::apply_sysroot(
84                    home.join(".config/systemd/user")
85                        .join(format!("{service_name}.service"))
86                )
87            } else {
88                sysroot::apply_sysroot(PathBuf::from(format!(
89                    "/etc/systemd/system/{service_name}.service",
90                )))
91            };
92            if unit_path.exists() {
93                println!("Removing service unit file: {}", unit_path.display());
94                fs::remove_file(&unit_path).map_err(|e| {
95                    anyhow!(
96                        "Failed to remove unit file: {}: {}",
97                        unit_path.display(),
98                        e
99                    )
100                })?;
101                if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err() {
102                    let mut cmd = std::process::Command::new("systemctl");
103                    if is_user {
104                        cmd.arg("--user");
105                    }
106                    cmd.arg("daemon-reload").status().map_err(|e| {
107                        anyhow!("Failed to run systemctl daemon-reload: {e}")
108                    })?;
109                }
110            }
111        }
112        "macos" => {
113            let plist_path = if is_user {
114                let home = core_utils::get_user_home()
115                    .ok_or_else(|| anyhow!("Could not find home directory"))?;
116                sysroot::apply_sysroot(
117                    home.join("Library/LaunchAgents")
118                        .join(format!("{service_name}.plist"))
119                )
120            } else {
121                sysroot::apply_sysroot(PathBuf::from(format!(
122                    "/Library/LaunchDaemons/{service_name}.plist",
123                )))
124            };
125            if plist_path.exists() {
126                println!(
127                    "Removing service plist file: {}",
128                    plist_path.display()
129                );
130                fs::remove_file(&plist_path).map_err(|e| {
131                    anyhow!(
132                        "Failed to remove plist file: {}: {}",
133                        plist_path.display(),
134                        e
135                    )
136                })?;
137            }
138        }
139        "windows" => {
140            let exists = {
141                let output = std::process::Command::new("sc")
142                    .arg("query")
143                    .arg(&service_name)
144                    .output()
145                    .map_err(|e| anyhow!("Failed to run sc query: {e}"))?;
146                output.status.success()
147            };
148            if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err()
149                && exists
150            {
151                println!("Removing Windows service: {service_name}");
152                std::process::Command::new("sc")
153                    .arg("delete")
154                    .arg(&service_name)
155                    .status()
156                    .map_err(|e| anyhow!("Failed to run sc delete: {e}"))?;
157            }
158        }
159        _ => {}
160    }
161
162    Ok(())
163}
164
165/// Uninstalls a collection and its associated dependencies.
166fn uninstall_collection(
167    pkg: &types::Package,
168    manifest: &types::InstallManifest,
169    scope: types::Scope,
170    registry_handle: Option<&str>,
171    yes: bool,
172    quiet: bool,
173    dry_run: bool
174) -> anyhow::Result<types::InstallManifest> {
175    if !quiet {
176        println!("Uninstalling collection '{}'...", pkg.name.bold());
177    }
178
179    if dry_run {
180        return Ok(manifest.clone());
181    }
182
183    let dependencies_to_uninstall = &manifest.installed_dependencies;
184
185    if dependencies_to_uninstall.is_empty() {
186        if !quiet {
187            println!("Collection has no dependencies to uninstall.");
188        }
189    } else {
190        if !quiet {
191            println!("Uninstalling dependencies of the collection...");
192        }
193        for dep_str in dependencies_to_uninstall {
194            let dep = dependencies::parse_dependency_string(dep_str)?;
195
196            if dep.manager == "zoi" {
197                if !quiet {
198                    println!(
199                        "\n{} Uninstalling zoi dependency: {}...",
200                        "::".bold().blue(),
201                        dep_str.bold()
202                    );
203                }
204            } else {
205                let prompt = format!(
206                    "Uninstall native dependency '{}' ({})?",
207                    dep.package.cyan(),
208                    dep.manager.yellow()
209                );
210                let warning = "Warning: Zoi cannot track if other non-Zoi \
211                               applications depend on this package.";
212
213                if yes {
214                    if !quiet {
215                        println!(
216                            "\n{} Uninstalling native dependency: {}...",
217                            "::".bold().blue(),
218                            dep_str.bold()
219                        );
220                        println!("{}: {}", "Note".yellow(), warning);
221                    }
222                } else if core_utils::ask_for_confirmation(
223                    &format!("{}\n   {}", prompt, warning.dimmed()),
224                    false
225                ) {
226                    if !quiet {
227                        println!(
228                            "\n{} Uninstalling dependency: {}...",
229                            "::".bold().blue(),
230                            dep_str.bold()
231                        );
232                    }
233                } else {
234                    if !quiet {
235                        println!(
236                            "Skipping uninstallation of native dependency: {}",
237                            dep.package.yellow()
238                        );
239                    }
240                    continue;
241                }
242            }
243
244            if let Err(e) =
245                dependencies::uninstall_dependency(dep_str, &move |name| {
246                    run(name, Some(scope), yes, quiet, dry_run).map(|_| ())
247                })
248                && !quiet
249            {
250                eprintln!(
251                    "Warning: Could not uninstall dependency '{dep_str}': {e}"
252                );
253            }
254        }
255    }
256
257    let handle = registry_handle.unwrap_or("local");
258    let package_dir =
259        local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)?;
260    if package_dir.exists() {
261        let _ = cleanup_service(&pkg.name, scope);
262        fs::remove_dir_all(&package_dir)?;
263    }
264    if let Err(e) = recorder::remove_package_from_record(manifest)
265        && !quiet
266    {
267        eprintln!(
268            "{} Failed to remove package from lockfile: {}",
269            "Warning:".yellow(),
270            e
271        );
272    }
273
274    if let Ok(conn) = db::open_connection("local") {
275        let _ =
276            db::delete_package(&conn, &pkg.name, None, &pkg.repo, Some(scope));
277    }
278
279    if let Ok(true) = telemetry::posthog_capture_event(
280        "uninstall",
281        pkg,
282        env!("CARGO_PKG_VERSION"),
283        registry_handle.unwrap_or("local"),
284        None
285    ) && !quiet
286    {
287        println!("{} telemetry queued", "Info:".green());
288    }
289
290    Ok(manifest.clone())
291}
292
293/// Finds the active installed manifest, ignoring any version pin in the
294/// request.
295///
296/// Installed manifests are only indexed under `latest/`, so a request like
297/// `package@1.2.0` must first resolve the package identity (name, repo,
298/// handle, sub-package, scope) without the version, then decide which stored
299/// version the pin refers to.
300fn find_active_manifest(
301    request: &resolve::PackageRequest,
302    scope_override: Option<types::Scope>
303) -> anyhow::Result<(types::InstallManifest, types::Scope)> {
304    let mut unversioned = resolve::PackageRequest {
305        handle: request.handle.clone(),
306        repo: request.repo.clone(),
307        name: request.name.clone(),
308        sub_package: request.sub_package.clone(),
309        version_spec: None
310    };
311    let _ = &mut unversioned;
312    find_installed_manifest(&unversioned, scope_override)
313}
314
315/// Lists every stored version of a package that has a manifest for the given
316/// sub-package.
317///
318/// Returns `(version, manifest)` pairs sorted by version ascending (semver
319/// when parseable, lexical fallback).
320fn list_stored_version_manifests(
321    package_dir: &std::path::Path,
322    sub_package: Option<&str>
323) -> Vec<(String, types::InstallManifest)> {
324    let mut out = Vec::new();
325    let Ok(entries) = fs::read_dir(package_dir) else {
326        return out;
327    };
328    for entry in entries.flatten() {
329        let name = entry.file_name().to_string_lossy().to_string();
330        if name == "latest" || name == "dependents" {
331            continue;
332        }
333        let version_dir = entry.path();
334        if !version_dir.is_dir() {
335            continue;
336        }
337        let manifest_path =
338            local::find_store_manifest(&version_dir, sub_package);
339        let Some(manifest_path) = manifest_path else {
340            continue;
341        };
342        if let Ok(manifest) = local::read_store_manifest(&manifest_path)
343            && manifest.sub_package.as_deref() == sub_package
344        {
345            out.push((name, manifest));
346        }
347    }
348    out.sort_by(|a, b| compare_versions(&a.0, &b.0));
349    out
350}
351
352/// Searches every store directory for a specific stored version.
353///
354/// Used when the `latest` link is missing but a versioned uninstall was
355/// requested. Returns the stored manifest and its scope.
356///
357/// This is also used by the CLI to resolve `zoi uninstall package@1.2.0`
358/// when `1.2.0` is no longer the active version.
359pub fn find_stored_version_anywhere(
360    request: &resolve::PackageRequest,
361    scope_override: Option<types::Scope>
362) -> Option<(types::InstallManifest, types::Scope)> {
363    let version = request.version_spec.as_deref()?;
364    let scopes = if let Some(scope) = scope_override {
365        vec![scope]
366    } else {
367        vec![
368            types::Scope::Project,
369            types::Scope::User,
370            types::Scope::System,
371        ]
372    };
373    for scope in scopes {
374        let Ok(store_root) = local::get_store_base_dir(scope) else {
375            continue;
376        };
377        if !store_root.exists() {
378            continue;
379        }
380        let Ok(entries) = fs::read_dir(&store_root) else {
381            continue;
382        };
383        for entry in entries.flatten() {
384            let package_dir = entry.path();
385            if !package_dir.is_dir() {
386                continue;
387            }
388            for (stored_version, manifest) in list_stored_version_manifests(
389                &package_dir,
390                request.sub_package.as_deref()
391            ) {
392                if stored_version != version {
393                    continue;
394                }
395                if !manifest.name.eq_ignore_ascii_case(&request.name) {
396                    continue;
397                }
398                if request.handle.as_ref().is_some_and(|handle| {
399                    !manifest.registry_handle.eq_ignore_ascii_case(handle)
400                }) {
401                    continue;
402                }
403                if request.repo.as_ref().is_some_and(|repo| {
404                    !manifest.repo.eq_ignore_ascii_case(repo)
405                }) {
406                    continue;
407                }
408                return Some((manifest, scope));
409            }
410        }
411    }
412    None
413}
414
415/// Compares two version strings with semver when possible.
416fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
417    match (
418        semver::Version::parse(a.trim_start_matches('v')),
419        semver::Version::parse(b.trim_start_matches('v'))
420    ) {
421        (Ok(va), Ok(vb)) => va.cmp(&vb),
422        _ => a.cmp(b)
423    }
424}
425
426/// Removes a non-active stored version directory without touching the active
427/// install (shims, database, lockfile).
428fn remove_stored_version_dir(
429    package_dir: &std::path::Path,
430    version: &str,
431    sub_package: Option<&str>,
432    quiet: bool
433) -> anyhow::Result<types::InstallManifest> {
434    let version_dir = package_dir.join(version);
435    let manifest_path = local::find_store_manifest(&version_dir, sub_package)
436        .ok_or_else(|| {
437        anyhow!(
438            "Version '{version}' has no install manifest in {}",
439            version_dir.display()
440        )
441    })?;
442    let manifest = local::read_store_manifest(&manifest_path)?;
443    if manifest_path.exists() {
444        fs::remove_file(&manifest_path)?;
445    }
446    let legacy_path = version_dir.join(format!(
447        "manifest{}.yaml",
448        sub_package.map_or_else(String::new, |sub| format!("-{sub}"))
449    ));
450    if legacy_path != manifest_path && legacy_path.exists() {
451        fs::remove_file(&legacy_path)?;
452    }
453    // Remove the stored source snapshot for this version as well.
454    let source_path = version_dir.join("package.pkg.lua");
455    if source_path.exists() {
456        let _ = fs::remove_file(&source_path);
457    }
458    if version_dir.exists() {
459        let is_empty =
460            fs::read_dir(&version_dir).is_ok_and(|mut e| e.next().is_none());
461        if is_empty {
462            fs::remove_dir_all(&version_dir)?;
463        } else if !quiet {
464            println!(
465                "Keeping non-empty version directory: {}",
466                version_dir.display()
467            );
468        }
469    }
470    Ok(manifest)
471}
472
473/// Loads the richest package definition available for a stored manifest.
474///
475/// Prefers the `package.pkg.lua` snapshot stored next to the manifest so the
476/// database and lockfile keep full metadata (bins, description, tags);
477/// falls back to the manifest-derived blueprint.
478fn load_stored_package(
479    version_dir: &std::path::Path,
480    manifest: &types::InstallManifest
481) -> types::Package {
482    let stored_source = version_dir.join("package.pkg.lua");
483    if stored_source.exists()
484        && let Some(path_str) = stored_source.to_str()
485        && let Ok(mut pkg) = zoi_lua::parser::parse_lua_package(
486            path_str,
487            Some(&manifest.version),
488            Some(manifest.scope),
489            true
490        )
491    {
492        pkg.repo.clone_from(&manifest.repo);
493        pkg.scope = manifest.scope;
494        pkg.registry_handle = Some(manifest.registry_handle.clone());
495        pkg.sub_package.clone_from(&manifest.sub_package);
496        return pkg;
497    }
498    let mut pkg = manifest.clone().into_package();
499    pkg.description.clone_from(&manifest.description);
500    pkg.registry_handle = Some(manifest.registry_handle.clone());
501    pkg
502}
503
504/// Re-creates binary shims for a stored manifest being re-activated.
505fn restore_version_shims(
506    manifest: &types::InstallManifest,
507    scope: types::Scope,
508    quiet: bool
509) {
510    let bins: Vec<String> = manifest.bins.clone().unwrap_or_else(|| {
511        if manifest.sub_package.is_none() {
512            vec![manifest.name.clone()]
513        } else {
514            Vec::new()
515        }
516    });
517    if bins.is_empty() {
518        return;
519    }
520    let Ok(bin_root) = get_bin_root(scope) else {
521        return;
522    };
523    if let Err(e) = fs::create_dir_all(&bin_root) {
524        if !quiet {
525            eprintln!(
526                "{} could not create bin dir {}: {}",
527                "Warning:".yellow(),
528                bin_root.display(),
529                e
530            );
531        }
532        return;
533    }
534    let Ok(zoi_exe) = std::env::current_exe() else {
535        return;
536    };
537    for bin in &bins {
538        let link_path = bin_root.join(bin);
539        if let Err(e) = core_utils::symlink_file(&zoi_exe, &link_path) {
540            if !quiet {
541                eprintln!(
542                    "{} failed to restore shim for {bin}: {}",
543                    "Warning:".yellow(),
544                    e
545                );
546            }
547        } else if !quiet {
548            println!("Restored shim for {}...", bin.cyan());
549        }
550    }
551}
552
553/// Re-creates shell completion symlinks for a stored manifest.
554fn restore_version_completions(
555    manifest: &types::InstallManifest,
556    version_dir: &std::path::Path,
557    scope: types::Scope,
558    quiet: bool
559) {
560    let Some(completions) = &manifest.completions else {
561        return;
562    };
563    for completion in completions {
564        // Current archives stage completions under `<version>/shell/<shell>/`,
565        // older ones under `<version>/data/shell/<shell>/`; try both.
566        let candidates = [
567            version_dir
568                .join("shell")
569                .join(&completion.shell)
570                .join(&completion.filename),
571            version_dir
572                .join("data")
573                .join("shell")
574                .join(&completion.shell)
575                .join(&completion.filename)
576        ];
577        let Some(store_path) = candidates.iter().find(|p| p.exists()) else {
578            if !quiet {
579                eprintln!(
580                    "{} completion source for {} not found in store, skipping.",
581                    "Warning:".yellow(),
582                    completion.filename.cyan()
583                );
584            }
585            continue;
586        };
587        let Ok(completions_root) =
588            get_completions_root(scope, &completion.shell)
589        else {
590            continue;
591        };
592        let pkg_dir = completions_root.join(&manifest.name);
593        let link_path = pkg_dir.join(&completion.filename);
594        if let Some(parent) = link_path.parent()
595            && let Err(e) = fs::create_dir_all(parent)
596            && !quiet
597        {
598            eprintln!(
599                "{} could not create completions dir {}: {}",
600                "Warning:".yellow(),
601                parent.display(),
602                e
603            );
604        }
605        #[cfg(unix)]
606        let mut link_result =
607            std::os::unix::fs::symlink(store_path, &link_path)
608                .map_err(|e| anyhow!(e.to_string()));
609        #[cfg(windows)]
610        let mut link_result =
611            std::os::windows::fs::symlink_file(store_path, &link_path)
612                .map_err(|e| anyhow!(e.to_string()));
613        // `symlink` fails if a link already exists; replace and retry once.
614        if link_result.is_err() {
615            let _ = fs::remove_file(&link_path);
616            #[cfg(unix)]
617            {
618                link_result =
619                    std::os::unix::fs::symlink(store_path, &link_path)
620                        .map_err(|e| anyhow!(e.to_string()));
621            }
622            #[cfg(windows)]
623            {
624                link_result =
625                    std::os::windows::fs::symlink_file(store_path, &link_path)
626                        .map_err(|e| anyhow!(e.to_string()));
627            }
628        }
629        if let Err(e) = link_result
630            && !quiet
631        {
632            eprintln!(
633                "{} failed to restore completion {}: {}",
634                "Warning:".yellow(),
635                completion.filename.cyan(),
636                e
637            );
638        }
639    }
640}
641
642/// Activates a previously stored version after its newer version was
643/// uninstalled.
644///
645/// This mirrors `rollback`: it only uses what is already in the store and
646/// never downloads or reinstalls from a registry. It re-points `latest`,
647/// restores shims and completions, and updates the database and lockfile so
648/// the rolled-back version becomes the recorded install.
649fn activate_stored_version(
650    package_dir: &std::path::Path,
651    scope: types::Scope,
652    version: &str,
653    manifest: &types::InstallManifest,
654    quiet: bool
655) -> anyhow::Result<()> {
656    let version_dir = package_dir.join(version);
657    // Re-point `latest` at the previous version (same helper installs use).
658    if let Err(e) =
659        core_utils::symlink_dir(&version_dir, &package_dir.join("latest"))
660    {
661        return Err(anyhow!(
662            "Failed to point 'latest' at version '{version}': {e}"
663        ));
664    }
665    // Persist through the canonical writer as well so JSON/legacy twins and
666    // symlink handling stay consistent with installs.
667    if let Err(e) = local::write_manifest(manifest)
668        && !quiet
669    {
670        eprintln!(
671            "{} failed to rewrite manifest for version '{version}': {}",
672            "Warning:".yellow(),
673            e
674        );
675    }
676    restore_version_shims(manifest, scope, quiet);
677    restore_version_completions(manifest, &version_dir, scope, quiet);
678
679    let pkg = load_stored_package(&version_dir, manifest);
680    if let Ok(conn) = db::open_connection("local")
681        && let Ok(pkg_id) = db::update_package(
682            &conn,
683            &pkg,
684            &manifest.registry_handle,
685            Some(scope),
686            manifest.sub_package.as_deref(),
687            Some(&manifest.reason)
688        )
689    {
690        let _ = db::clear_package_files(&conn, pkg_id);
691        let _ =
692            db::index_package_files(&conn, pkg_id, &manifest.installed_files);
693    }
694    if let Err(e) = recorder::record_package(
695        &pkg,
696        &manifest.reason,
697        &manifest.installed_dependencies,
698        &manifest.registry_handle,
699        &manifest.repo_type,
700        &manifest.chosen_options,
701        &manifest.chosen_optionals,
702        manifest.sub_package.as_deref()
703    ) && !quiet
704    {
705        eprintln!(
706            "{} failed to record rolled-back version '{version}': {}",
707            "Warning:".yellow(),
708            e
709        );
710    }
711    if !quiet {
712        println!(
713            "{} Activated previous version {} from the store.",
714            "::".bold().green(),
715            version.green()
716        );
717    }
718    Ok(())
719}
720
721/// Finds an installed manifest matching the given package request.
722fn find_installed_manifest(
723    request: &resolve::PackageRequest,
724    scope_override: Option<types::Scope>
725) -> anyhow::Result<(types::InstallManifest, types::Scope)> {
726    let scopes = if let Some(scope) = scope_override {
727        vec![scope]
728    } else {
729        vec![
730            types::Scope::Project,
731            types::Scope::User,
732            types::Scope::System,
733        ]
734    };
735
736    for scope in scopes {
737        let mut matches =
738            local::find_installed_manifests_matching(request, scope)?;
739        match matches.len() {
740            0 => {}
741            1 => return Ok((matches.remove(0), scope)),
742            _ => {
743                return Err(anyhow!(
744                    "Package '{}' is ambiguous in {:?} scope. Use an explicit \
745                     source like '#handle@repo/name[:sub]@version'.",
746                    request.name,
747                    scope
748                ));
749            }
750        }
751    }
752
753    if scope_override.is_some() {
754        Err(anyhow!(
755            "Package '{}' is not installed in the specified scope.",
756            request.name
757        ))
758    } else {
759        Err(anyhow!(
760            "Package '{}' is not installed by Zoi.",
761            request.name
762        ))
763    }
764}
765
766/// Loads an installed package definition and its Lua source path from a
767/// manifest.
768fn load_installed_package(
769    manifest: &types::InstallManifest,
770    yes: bool
771) -> anyhow::Result<(types::Package, PathBuf)> {
772    let installed_source_path = local::get_package_source_path(manifest)?;
773    if installed_source_path.exists() {
774        let path = installed_source_path.to_str().ok_or_else(|| {
775            anyhow!("Stored package source path contains invalid UTF-8")
776        })?;
777        let mut pkg = zoi_lua::parser::parse_lua_package(
778            path,
779            Some(&manifest.version),
780            Some(manifest.scope),
781            true
782        )?;
783        pkg.repo.clone_from(&manifest.repo);
784        pkg.scope = manifest.scope;
785        pkg.registry_handle = Some(manifest.registry_handle.clone());
786        pkg.sub_package.clone_from(&manifest.sub_package);
787        return Ok((pkg, installed_source_path));
788    }
789
790    let source = local::installed_manifest_source(manifest);
791    let (mut pkg, _, _, pkg_lua_path, _, _, _) =
792        resolve::resolve_package_and_version(
793            &source,
794            Some(manifest.scope),
795            true,
796            yes
797        )?;
798    pkg.scope = manifest.scope;
799    pkg.sub_package.clone_from(&manifest.sub_package);
800    Ok((pkg, pkg_lua_path))
801}
802
803/// Uninstalls one or more packages from the system.
804///
805/// This is a complex multi-stage operation:
806/// - Dependent Check: Verifies if any other package requires this one (via the
807///   `dependents/` directory). Blocks if busy.
808/// - Hook Execution: Runs the `pre_remove` hook defined in `.pkg.lua`.
809/// - Lua Cleanup: Executes the `uninstall()` function and `zrm` operations.
810/// - File Removal: Deletes every file recorded in the package's
811///   `InstallManifest`.
812/// - Shim/Completion Cleanup: Unlinks binaries and completions if no other
813///   package provides them (ref-counting via the database).
814///
815/// If `recursive` is true, Zoi also attempts to uninstall any dependencies
816/// that are no longer needed by any other package.
817///
818/// # Errors
819///
820/// Returns an error if:
821/// - The package is not found or is ambiguous.
822/// - The package has dependents that must be uninstalled first.
823/// - Hook execution fails.
824/// - File system operations (removal, backup) fail.
825/// - Escalation to root fails.
826///
827/// # Panics
828///
829/// Panics if internal dependency consistency checks fail.
830pub fn run(
831    package_name: &str,
832    scope_override: Option<types::Scope>,
833    yes: bool,
834    quiet: bool,
835    dry_run: bool
836) -> anyhow::Result<types::InstallManifest> {
837    let request = resolve::parse_source_string(package_name)?;
838    // Resolve the package identity through the active (`latest`) manifest,
839    // ignoring any version pin. The pin is handled below against the store
840    // so `zoi uninstall package@1.2.0` works even when 1.2.0 is not the
841    // active version.
842    let (manifest, scope) = match find_active_manifest(&request, scope_override)
843    {
844        Ok(found) => found,
845        Err(active_err) => {
846            // Fallback for versioned requests when `latest` is missing or
847            // broken but the version still exists in the store.
848            if let Some(version) = request.version_spec.as_deref() {
849                if let Some(found) =
850                    find_stored_version_anywhere(&request, scope_override)
851                {
852                    found
853                } else {
854                    return Err(anyhow!(
855                        "Version '{version}' of package '{}' is not \
856                         installed. {}",
857                        request.name,
858                        active_err
859                    ));
860                }
861            } else {
862                return Err(active_err);
863            }
864        }
865    };
866    // A version pin that does not point at the active version means "remove
867    // just this stored version" - shims, database and lockfile stay on the
868    // active install.
869    if let Some(version) = request.version_spec.clone()
870        && version != manifest.version
871    {
872        let handle = manifest.registry_handle.as_str();
873        let package_dir = local::get_package_dir(
874            scope,
875            handle,
876            &manifest.repo,
877            &manifest.name
878        )?;
879        let stored = list_stored_version_manifests(
880            &package_dir,
881            manifest.sub_package.as_deref()
882        );
883        if stored.iter().any(|(v, _)| v == &version) {
884            if dry_run {
885                return Ok(manifest);
886            }
887            let dependents = local::get_dependents(&package_dir)?;
888            if !dependents.is_empty() {
889                return Err(anyhow::anyhow!(
890                    "Cannot uninstall '{}' because other packages depend on \
891                     it:\n  -{}\n\nPlease uninstall these packages first.",
892                    manifest.name,
893                    dependents.join("\n  - ")
894                ));
895            }
896            let removed = remove_stored_version_dir(
897                &package_dir,
898                &version,
899                manifest.sub_package.as_deref(),
900                quiet
901            )?;
902            if !quiet {
903                println!(
904                    "Removed stored version {} of '{}'. Active version {} is \
905                     unchanged.",
906                    version.cyan(),
907                    manifest.name.bold(),
908                    manifest.version.green()
909                );
910            }
911            return Ok(removed);
912        }
913        let available: Vec<String> =
914            stored.iter().map(|(v, _)| v.clone()).collect();
915        if available.is_empty() {
916            return Err(anyhow!(
917                "Version '{version}' of package '{}' is not installed.",
918                request.name
919            ));
920        }
921        return Err(anyhow!(
922            "Version '{version}' of package '{}' is not installed. Stored \
923             versions: {}.",
924            request.name,
925            available.join(", ")
926        ));
927    }
928    let sub_package_to_uninstall = manifest.sub_package.clone();
929    let registry_handle = Some(manifest.registry_handle.clone());
930    let (pkg, pkg_lua_path) = load_installed_package(&manifest, yes)?;
931
932    if pkg.package_type == types::PackageType::Collection {
933        return uninstall_collection(
934            &pkg,
935            &manifest,
936            scope,
937            registry_handle.as_deref(),
938            yes,
939            quiet,
940            dry_run
941        );
942    }
943
944    if dry_run {
945        return Ok(manifest);
946    }
947
948    let handle = manifest.registry_handle.as_str();
949    let package_dir =
950        local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)?;
951    let version_dir = package_dir.join(&manifest.version);
952
953    let dependents = local::get_dependents(&package_dir)?;
954    if !dependents.is_empty() {
955        return Err(anyhow::anyhow!(
956            "Cannot uninstall '{}' because other packages depend on it:\n  \
957             -{}\n\nPlease uninstall these packages first.",
958            pkg.name,
959            dependents.join("\n  - ")
960        ));
961    }
962
963    let needs_escalation =
964        scope == types::Scope::System && !core_utils::is_admin();
965
966    if needs_escalation {
967        let escalator =
968            core_utils::get_privilege_escalator().ok_or_else(|| {
969                anyhow!(
970                    "Root privileges required to remove system package, but \
971                     neither 'sudo' nor 'doas' was found."
972                )
973            })?;
974
975        if !quiet {
976            println!(
977                "{} Escalating to root via {} to remove system package...",
978                "::".bold().blue(),
979                escalator
980            );
981        }
982        let manifest_json = serde_json::to_string(&manifest)?;
983        let mut temp_file = tempfile::NamedTempFile::new()?;
984        temp_file.write_all(manifest_json.as_bytes())?;
985        let temp_path = temp_file.path();
986
987        let mut cmd = std::process::Command::new(escalator);
988        cmd.arg(std::env::current_exe()?);
989        cmd.arg("helper").arg("elevate-uninstall");
990        cmd.arg("--manifest-json").arg(temp_path);
991        if yes {
992            cmd.arg("--yes");
993        }
994
995        let status = cmd.status().map_err(|e| {
996            anyhow::anyhow!("Failed to spawn privilege escalator: {e}")
997        })?;
998        if !status.success() {
999            return Err(anyhow::anyhow!("Escalated uninstallation failed."));
1000        }
1001    } else {
1002        if let Some(hooks) = &pkg.hooks
1003            && let Err(e) =
1004                hooks::run_hooks(hooks, hooks::HookType::PreRemove, scope)
1005        {
1006            return Err(anyhow::anyhow!("Pre-remove hook failed: {e}"));
1007        }
1008
1009        let lua = Lua::new();
1010        zoi_lua::functions::setup_lua_environment(
1011            &lua,
1012            &core_utils::get_platform()?,
1013            Some(&manifest.version),
1014            pkg_lua_path.to_str(),
1015            None,
1016            None,
1017            None,
1018            sub_package_to_uninstall.as_deref(),
1019            Some(scope),
1020            None,
1021            true
1022        )
1023        .map_err(|e| anyhow!(e.to_string()))?;
1024        let lua_code = fs::read_to_string(pkg_lua_path)?;
1025        lua.load(&lua_code)
1026            .exec()
1027            .map_err(|e| anyhow!(e.to_string()))?;
1028
1029        if let Ok(uninstall_fn) =
1030            lua.globals().get::<mlua::Function>("uninstall")
1031        {
1032            if !quiet {
1033                println!("Running uninstall() script...");
1034            }
1035            uninstall_fn
1036                .call::<()>(())
1037                .map_err(|e| anyhow!(e.to_string()))?;
1038        }
1039
1040        if let Ok(uninstall_ops) =
1041            lua.globals().get::<mlua::Table>("__ZoiUninstallOperations")
1042        {
1043            for op in uninstall_ops.sequence_values::<mlua::Table>() {
1044                let op = op.map_err(|e| anyhow!(e.to_string()))?;
1045                if let Ok(op_type) = op.get::<String>("op")
1046                    && op_type == "zrm"
1047                {
1048                    let mut path_to_remove: String =
1049                        op.get("path").map_err(|e| anyhow!(e.to_string()))?;
1050
1051                    path_to_remove = path_to_remove
1052                        .replace("${pkgstore}", &version_dir.to_string_lossy());
1053
1054                    if let Some(home_dir) = core_utils::get_user_home() {
1055                        path_to_remove = path_to_remove
1056                            .replace("${usrhome}", &home_dir.to_string_lossy());
1057                    }
1058                    path_to_remove = path_to_remove.replace(
1059                        "${usrroot}",
1060                        &sysroot::apply_sysroot(PathBuf::from("/"))
1061                            .to_string_lossy()
1062                    );
1063
1064                    let path = std::path::PathBuf::from(path_to_remove);
1065                    if path.exists() {
1066                        if !quiet {
1067                            println!("Removing {}...", path.display());
1068                        }
1069                        if path.is_dir() {
1070                            fs::remove_dir_all(path)?;
1071                        } else {
1072                            fs::remove_file(path)?;
1073                        }
1074                    }
1075                }
1076            }
1077        }
1078
1079        if let Some(backup_files) = &manifest.backup {
1080            if !quiet {
1081                println!("Saving configuration files...");
1082            }
1083            for backup_file_rel in backup_files {
1084                let expanded_path = zoi_core::utils::expand_placeholders(
1085                    backup_file_rel,
1086                    &version_dir,
1087                    manifest.scope
1088                )?;
1089                let backup_src = PathBuf::from(expanded_path);
1090
1091                if backup_src.exists() {
1092                    let backup_filename = backup_src
1093                        .file_name()
1094                        .ok_or_else(|| anyhow!("Invalid backup source name"))?
1095                        .to_string_lossy();
1096                    let backup_dest = version_dir
1097                        .parent()
1098                        .ok_or_else(|| {
1099                            anyhow!(
1100                                "version_dir should have a parent \
1101                                 (package_dir)"
1102                            )
1103                        })?
1104                        .join(format!("{backup_filename}.zoisave"));
1105
1106                    if let Some(p) = backup_dest.parent()
1107                        && let Err(e) = fs::create_dir_all(p)
1108                    {
1109                        if !quiet {
1110                            eprintln!(
1111                                "Warning: could not create backup directory \
1112                                 {}: {}",
1113                                p.display(),
1114                                e
1115                            );
1116                        }
1117                        continue;
1118                    }
1119                    if !quiet {
1120                        println!(
1121                            "Saving {} to {}",
1122                            backup_src.display(),
1123                            backup_dest.display()
1124                        );
1125                    }
1126                    // Use copy + remove for potential cross-device moves
1127                    if let Err(e) = fs::copy(&backup_src, &backup_dest) {
1128                        if !quiet {
1129                            eprintln!(
1130                                "Warning: failed to copy backup {}: {}",
1131                                backup_src.display(),
1132                                e
1133                            );
1134                        }
1135                    } else {
1136                        let _ = fs::remove_file(&backup_src);
1137                    }
1138                }
1139            }
1140        }
1141
1142        if !quiet {
1143            println!(
1144                "Uninstalling '{}'...",
1145                if let Some(sub) = &manifest.sub_package {
1146                    format!("{}:{}", pkg.name, sub)
1147                } else {
1148                    pkg.name.clone()
1149                }
1150                .bold()
1151            );
1152        }
1153
1154        if let Some(bins) = &manifest.bins {
1155            let bin_root = get_bin_root(scope)?;
1156            for bin in bins {
1157                let symlink_path = bin_root.join(bin);
1158                if symlink_path.is_symlink() || symlink_path.exists() {
1159                    let other_providers = db::find_provides("local", bin)?;
1160                    let still_provided =
1161                        other_providers.iter().any(|(p, _)| {
1162                            p.name != pkg.name
1163                                || (p.sub_package != manifest.sub_package)
1164                        });
1165
1166                    if still_provided {
1167                        if !quiet {
1168                            println!(
1169                                "Keeping shim for {} as it is still provided \
1170                                 by other packages.",
1171                                bin.cyan()
1172                            );
1173                        }
1174                    } else {
1175                        if !quiet {
1176                            println!(
1177                                "Removing shim for {} from {}...",
1178                                bin.cyan(),
1179                                symlink_path.display()
1180                            );
1181                        }
1182                        fs::remove_file(&symlink_path)?;
1183                    }
1184                }
1185            }
1186        } else if manifest.sub_package.is_none() {
1187            let bin = &pkg.name;
1188            let symlink_path = get_bin_root(scope)?.join(bin);
1189            if symlink_path.is_symlink() || symlink_path.exists() {
1190                let other_providers = db::find_provides("local", bin)?;
1191                let still_provided = other_providers.iter().any(|(p, _)| {
1192                    p.name != pkg.name
1193                        || (p.sub_package != manifest.sub_package)
1194                });
1195
1196                if !still_provided {
1197                    if !quiet {
1198                        println!(
1199                            "Removing shim for {} from {}...",
1200                            bin.cyan(),
1201                            symlink_path.display()
1202                        );
1203                    }
1204                    fs::remove_file(symlink_path)?;
1205                }
1206            }
1207        }
1208
1209        if let Some(completions) = &manifest.completions {
1210            for completion in completions {
1211                let completions_root =
1212                    get_completions_root(scope, &completion.shell)?;
1213                let pkg_dir = completions_root.join(&pkg.name);
1214                let symlink_path = pkg_dir.join(&completion.filename);
1215                if symlink_path.is_symlink() || symlink_path.exists() {
1216                    let other_providers =
1217                        db::find_provides("local", &completion.filename)?;
1218                    let still_provided =
1219                        other_providers.iter().any(|(p, _)| {
1220                            p.name != pkg.name
1221                                || (p.sub_package != manifest.sub_package)
1222                        });
1223
1224                    if !still_provided {
1225                        if !quiet {
1226                            println!(
1227                                "Removing {} completion for {} from {}...",
1228                                completion.shell.cyan(),
1229                                completion.filename.cyan(),
1230                                symlink_path.display()
1231                            );
1232                        }
1233                        fs::remove_file(&symlink_path)?;
1234                    } else if !quiet {
1235                        println!(
1236                            "Keeping {} completion for {} as it is still \
1237                             provided by other packages.",
1238                            completion.shell.cyan(),
1239                            completion.filename.cyan()
1240                        );
1241                    }
1242                }
1243            }
1244
1245            let shells: std::collections::HashSet<String> =
1246                completions.iter().map(|c| c.shell.clone()).collect();
1247            for shell_name in shells {
1248                let pkg_dir =
1249                    get_completions_root(scope, &shell_name)?.join(&pkg.name);
1250                if pkg_dir.exists()
1251                    && fs::read_dir(&pkg_dir)
1252                        .is_ok_and(|mut e| e.next().is_none())
1253                {
1254                    let _ = fs::remove_dir(&pkg_dir);
1255                }
1256            }
1257        }
1258
1259        let pkg_id_opt = if let Ok(conn) = db::open_connection("local") {
1260            db::get_package_id(
1261                &conn,
1262                &pkg.name,
1263                manifest.sub_package.as_deref(),
1264                &pkg.repo,
1265                handle
1266            )
1267            .ok()
1268        } else {
1269            None
1270        };
1271
1272        for file_path_str in &manifest.installed_files {
1273            let expanded = core_utils::expand_placeholders(
1274                file_path_str,
1275                &version_dir,
1276                scope
1277            )?;
1278            let file_path = PathBuf::from(&expanded);
1279
1280            if let Some(pkg_id) = pkg_id_opt
1281                && let Ok(conn) = db::open_connection("local")
1282                && let Ok(true) =
1283                    db::has_other_owners(&conn, file_path_str, pkg_id)
1284            {
1285                if !quiet {
1286                    println!(
1287                        "Keeping {} as it is still owned by other packages.",
1288                        file_path_str.dimmed()
1289                    );
1290                }
1291                continue;
1292            }
1293
1294            // Use symlink_metadata so links are removed even when they dangle
1295            // or point to a non-empty directory; only the link is deleted.
1296            let Ok(meta) = fs::symlink_metadata(&file_path) else {
1297                continue;
1298            };
1299
1300            if let Some(pkg_id) = pkg_id_opt
1301                && let Ok(conn) = db::open_connection("local")
1302                && let Ok(true) =
1303                    db::has_other_owners(&conn, file_path_str, pkg_id)
1304            {
1305                if !quiet {
1306                    println!(
1307                        "Keeping {} as it is still owned by other packages.",
1308                        file_path_str.dimmed()
1309                    );
1310                }
1311                continue;
1312            }
1313
1314            if meta.file_type().is_symlink() {
1315                let _ = fs::remove_file(&file_path);
1316            } else if meta.is_dir() {
1317                // Only remove if empty to be safe
1318                if fs::read_dir(&file_path)
1319                    .is_ok_and(|mut e| e.next().is_none())
1320                {
1321                    let _ = fs::remove_dir(&file_path);
1322                }
1323            } else {
1324                let _ = fs::remove_file(&file_path);
1325            }
1326        }
1327
1328        let manifest_path = version_dir.join(local::manifest_filename(
1329            sub_package_to_uninstall.as_deref()
1330        ));
1331        if manifest_path.exists() {
1332            fs::remove_file(&manifest_path)?;
1333        }
1334        // Remove the legacy YAML twin if an old install left one behind.
1335        let legacy_path = version_dir.join(format!(
1336            "manifest{}.yaml",
1337            sub_package_to_uninstall
1338                .as_deref()
1339                .map_or_else(String::new, |sub| format!("-{sub}"))
1340        ));
1341        if legacy_path != manifest_path && legacy_path.exists() {
1342            fs::remove_file(legacy_path)?;
1343        }
1344
1345        if version_dir.exists() {
1346            let mut has_other_manifests = false;
1347            if let Ok(entries) = fs::read_dir(&version_dir) {
1348                for entry in entries.flatten() {
1349                    let name = entry.file_name().to_string_lossy().to_string();
1350                    if name.starts_with("manifest")
1351                        && std::path::Path::new(&name).extension().is_some_and(
1352                            |ext| {
1353                                ext.eq_ignore_ascii_case("json")
1354                                    || ext.eq_ignore_ascii_case("yaml")
1355                            }
1356                        )
1357                    {
1358                        has_other_manifests = true;
1359                        break;
1360                    }
1361                }
1362            }
1363            if !has_other_manifests {
1364                if !quiet {
1365                    println!(
1366                        "Removing empty version directory: {}",
1367                        version_dir.display()
1368                    );
1369                }
1370                fs::remove_dir_all(&version_dir)?;
1371            }
1372        }
1373
1374        if package_dir.exists() {
1375            let mut has_other_versions = false;
1376            if let Ok(entries) = fs::read_dir(&package_dir) {
1377                for entry in entries.flatten() {
1378                    let name = entry.file_name().to_string_lossy().to_string();
1379                    if name != "latest" && name != "dependents" {
1380                        has_other_versions = true;
1381                        break;
1382                    }
1383                }
1384            }
1385            if !has_other_versions {
1386                let _ = cleanup_service(&pkg.name, scope);
1387                if !quiet {
1388                    println!(
1389                        "Removing package store: {}",
1390                        package_dir.display()
1391                    );
1392                }
1393                fs::remove_dir_all(&package_dir)?;
1394            }
1395        }
1396
1397        let parent_id = format!(
1398            "#{}@{}/{}@{}",
1399            manifest.registry_handle,
1400            manifest.repo,
1401            manifest.name,
1402            manifest.version
1403        );
1404        for dep_str in &manifest.installed_dependencies {
1405            if let Ok(dep) = dependencies::parse_dependency_string(dep_str)
1406                && dep.manager == "zoi"
1407            {
1408                let dep_req = resolve::parse_source_string(dep.package)?;
1409                let dep_matches =
1410                    local::find_installed_manifests_matching(&dep_req, scope)?;
1411                if dep_matches.len() == 1 {
1412                    let dep_manifest =
1413                        dep_matches.first().expect("Already checked length");
1414                    match local::get_package_dir(
1415                        dep_manifest.scope,
1416                        &dep_manifest.registry_handle,
1417                        &dep_manifest.repo,
1418                        &dep_manifest.name
1419                    ) {
1420                        Ok(dep_pkg_dir) => {
1421                            if let Err(e) = local::remove_dependent(
1422                                &dep_pkg_dir,
1423                                &parent_id
1424                            ) && !quiet
1425                            {
1426                                eprintln!(
1427                                    "Warning: failed to remove dependent link \
1428                                     for {}: {}",
1429                                    dep.package, e
1430                                );
1431                            }
1432                        }
1433                        Err(e) => {
1434                            if !quiet {
1435                                eprintln!(
1436                                    "Warning: failed to get package dir for \
1437                                     {}: {}",
1438                                    dep.package, e
1439                                );
1440                            }
1441                        }
1442                    }
1443                }
1444            }
1445        }
1446
1447        if let Some(hooks) = &pkg.hooks
1448            && let Err(e) =
1449                hooks::run_hooks(hooks, hooks::HookType::PostRemove, scope)
1450            && !quiet
1451        {
1452            eprintln!("{} post-remove hook failed: {}", "Warning:".yellow(), e);
1453        }
1454    }
1455
1456    // The active version is gone from the store. Decide between complete
1457    // removal and rollback:
1458    // - Pinned uninstall (`package@1.2.0`) of the active version rolls back to
1459    //   the newest remaining stored version, store-only like `rollback`.
1460    // - Unpinned uninstall removes the package completely, including any other
1461    //   stored versions of the same (sub-)package.
1462    let remaining = if package_dir.exists() {
1463        list_stored_version_manifests(
1464            &package_dir,
1465            sub_package_to_uninstall.as_deref()
1466        )
1467    } else {
1468        Vec::new()
1469    };
1470    if !remaining.is_empty() {
1471        if request.version_spec.is_some() {
1472            let (prev_version, prev_manifest) =
1473                remaining.last().expect("remaining is not empty").clone();
1474            if !quiet {
1475                println!(
1476                    "Version {} still available in the store. Rolling back \
1477                     '{}' to {}...",
1478                    prev_version.cyan(),
1479                    pkg.name.bold(),
1480                    prev_version.green()
1481                );
1482            }
1483            match activate_stored_version(
1484                &package_dir,
1485                scope,
1486                &prev_version,
1487                &prev_manifest,
1488                quiet
1489            ) {
1490                Ok(()) => {
1491                    if let Ok(true) = telemetry::posthog_capture_event(
1492                        "uninstall",
1493                        &pkg,
1494                        env!("CARGO_PKG_VERSION"),
1495                        &manifest.registry_handle,
1496                        None
1497                    ) && !quiet
1498                    {
1499                        println!("{} telemetry queued", "Info:".green());
1500                    }
1501                    return Ok(manifest);
1502                }
1503                Err(e) => {
1504                    if needs_escalation {
1505                        eprintln!(
1506                            "{} rollback activation needs root and failed \
1507                             ({}); the old version stays in the store.",
1508                            "Warning:".yellow(),
1509                            e
1510                        );
1511                    } else {
1512                        return Err(e);
1513                    }
1514                }
1515            }
1516        } else {
1517            // Complete removal: drop every remaining stored version of this
1518            // (sub-)package. Directories shared with other sub-packages are
1519            // kept by `remove_stored_version_dir`.
1520            for (stored_version, _) in &remaining {
1521                if let Err(e) = remove_stored_version_dir(
1522                    &package_dir,
1523                    stored_version,
1524                    sub_package_to_uninstall.as_deref(),
1525                    quiet
1526                ) && !quiet
1527                {
1528                    eprintln!(
1529                        "{} failed to remove stored version \
1530                         '{stored_version}': {}",
1531                        "Warning:".yellow(),
1532                        e
1533                    );
1534                }
1535            }
1536            if package_dir.exists() {
1537                let mut has_versions = false;
1538                if let Ok(entries) = fs::read_dir(&package_dir) {
1539                    for entry in entries.flatten() {
1540                        let name =
1541                            entry.file_name().to_string_lossy().to_string();
1542                        if name != "latest" && name != "dependents" {
1543                            has_versions = true;
1544                            break;
1545                        }
1546                    }
1547                }
1548                if !has_versions {
1549                    let _ = cleanup_service(&pkg.name, scope);
1550                    let latest_link = package_dir.join("latest");
1551                    if latest_link.is_symlink() {
1552                        let _ = fs::remove_file(&latest_link);
1553                    }
1554                    // Only remove the package dir when nothing but metadata
1555                    // (latest link, empty dependents) is left.
1556                    let mut leftovers = 0;
1557                    if let Ok(entries) = fs::read_dir(&package_dir) {
1558                        for entry in entries.flatten() {
1559                            let name =
1560                                entry.file_name().to_string_lossy().to_string();
1561                            if name == "dependents" {
1562                                let empty = entry
1563                                    .path()
1564                                    .read_dir()
1565                                    .is_ok_and(|mut e| e.next().is_none());
1566                                if empty {
1567                                    continue;
1568                                }
1569                            }
1570                            leftovers += 1;
1571                        }
1572                    }
1573                    if leftovers == 0 {
1574                        if !quiet {
1575                            println!(
1576                                "Removing package store: {}",
1577                                package_dir.display()
1578                            );
1579                        }
1580                        let _ = fs::remove_dir_all(&package_dir);
1581                    }
1582                }
1583            }
1584        }
1585    }
1586
1587    if let Err(e) = recorder::remove_package_from_record(&manifest)
1588        && !quiet
1589    {
1590        eprintln!(
1591            "{} Failed to remove package from lockfile: {}",
1592            "Warning:".yellow(),
1593            e
1594        );
1595    }
1596
1597    if let Ok(conn) = db::open_connection("local") {
1598        let _ = db::delete_package(
1599            &conn,
1600            &pkg.name,
1601            sub_package_to_uninstall.as_deref(),
1602            &pkg.repo,
1603            Some(scope)
1604        );
1605    }
1606
1607    if !quiet {
1608        println!("Removed manifest for '{}'.", pkg.name);
1609    }
1610
1611    if let Ok(true) = telemetry::posthog_capture_event(
1612        "uninstall",
1613        &pkg,
1614        env!("CARGO_PKG_VERSION"),
1615        &manifest.registry_handle,
1616        None
1617    ) && !quiet
1618    {
1619        println!("{} telemetry queued", "Info:".green());
1620    }
1621
1622    Ok(manifest)
1623}