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 => {
28            if cfg!(target_os = "windows") {
29                Ok(sysroot::apply_sysroot(PathBuf::from(
30                    "C:\\ProgramData\\zoi\\pkgs\\bin"
31                )))
32            } else {
33                Ok(sysroot::apply_sysroot(PathBuf::from("/usr/local/bin")))
34            }
35        }
36        types::Scope::Project => {
37            let current_dir = std::env::current_dir()?;
38            Ok(current_dir.join(".zoi").join("pkgs").join("bin"))
39        }
40    }
41}
42
43/// Gets the root directory for shell completions based on the scope and shell
44/// type.
45fn get_completions_root(
46    scope: types::Scope,
47    shell: &str
48) -> anyhow::Result<PathBuf> {
49    match scope {
50        types::Scope::User => core_utils::get_user_completions_dir(shell),
51        types::Scope::System => {
52            if cfg!(target_os = "windows") {
53                Ok(sysroot::apply_sysroot(PathBuf::from(format!(
54                    "C:\\ProgramData\\zoi\\pkgs\\shell\\{shell}",
55                ))))
56            } else {
57                let base = match shell {
58                    "bash" => "/usr/share/bash-completion/completions",
59                    "zsh" => "/usr/share/zsh/site-functions",
60                    "fish" => "/usr/share/fish/vendor_completions.d",
61                    "elvish" => "/usr/share/elvish/lib",
62                    _ => "/usr/local/share/zoi/completions"
63                };
64                Ok(sysroot::apply_sysroot(PathBuf::from(base)))
65            }
66        }
67        types::Scope::Project => {
68            let current_dir = std::env::current_dir()?;
69            Ok(current_dir
70                .join(".zoi")
71                .join("pkgs")
72                .join("shell")
73                .join(shell))
74        }
75    }
76}
77
78/// Cleans up service unit files or Windows services associated with a package.
79fn cleanup_service(
80    package_name: &str,
81    scope: types::Scope
82) -> anyhow::Result<()> {
83    let service_name = format!("zoi-{package_name}");
84    let is_user = scope != types::Scope::System;
85
86    match std::env::consts::OS {
87        "linux" => {
88            let unit_path = if is_user {
89                let home = core_utils::get_user_home()
90                    .ok_or_else(|| anyhow!("Could not find home directory"))?;
91                sysroot::apply_sysroot(
92                    home.join(".config/systemd/user")
93                        .join(format!("{service_name}.service"))
94                )
95            } else {
96                sysroot::apply_sysroot(PathBuf::from(format!(
97                    "/etc/systemd/system/{service_name}.service",
98                )))
99            };
100            if unit_path.exists() {
101                println!("Removing service unit file: {}", unit_path.display());
102                fs::remove_file(&unit_path).map_err(|e| {
103                    anyhow!(
104                        "Failed to remove unit file: {}: {}",
105                        unit_path.display(),
106                        e
107                    )
108                })?;
109                if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err() {
110                    let mut cmd = std::process::Command::new("systemctl");
111                    if is_user {
112                        cmd.arg("--user");
113                    }
114                    cmd.arg("daemon-reload").status().map_err(|e| {
115                        anyhow!("Failed to run systemctl daemon-reload: {e}")
116                    })?;
117                }
118            }
119        }
120        "macos" => {
121            let plist_path = if is_user {
122                let home = core_utils::get_user_home()
123                    .ok_or_else(|| anyhow!("Could not find home directory"))?;
124                sysroot::apply_sysroot(
125                    home.join("Library/LaunchAgents")
126                        .join(format!("{service_name}.plist"))
127                )
128            } else {
129                sysroot::apply_sysroot(PathBuf::from(format!(
130                    "/Library/LaunchDaemons/{service_name}.plist",
131                )))
132            };
133            if plist_path.exists() {
134                println!(
135                    "Removing service plist file: {}",
136                    plist_path.display()
137                );
138                fs::remove_file(&plist_path).map_err(|e| {
139                    anyhow!(
140                        "Failed to remove plist file: {}: {}",
141                        plist_path.display(),
142                        e
143                    )
144                })?;
145            }
146        }
147        "windows" => {
148            let exists = {
149                let output = std::process::Command::new("sc")
150                    .arg("query")
151                    .arg(&service_name)
152                    .output()
153                    .map_err(|e| anyhow!("Failed to run sc query: {e}"))?;
154                output.status.success()
155            };
156            if std::env::var("ZOI_TEST_SKIP_SERVICE_COMMANDS").is_err()
157                && exists
158            {
159                println!("Removing Windows service: {service_name}");
160                std::process::Command::new("sc")
161                    .arg("delete")
162                    .arg(&service_name)
163                    .status()
164                    .map_err(|e| anyhow!("Failed to run sc delete: {e}"))?;
165            }
166        }
167        _ => {}
168    }
169
170    Ok(())
171}
172
173/// Uninstalls a collection and its associated dependencies.
174fn uninstall_collection(
175    pkg: &types::Package,
176    manifest: &types::InstallManifest,
177    scope: types::Scope,
178    registry_handle: Option<&str>,
179    yes: bool,
180    quiet: bool,
181    dry_run: bool
182) -> anyhow::Result<types::InstallManifest> {
183    if !quiet {
184        println!("Uninstalling collection '{}'...", pkg.name.bold());
185    }
186
187    if dry_run {
188        return Ok(manifest.clone());
189    }
190
191    let dependencies_to_uninstall = &manifest.installed_dependencies;
192
193    if dependencies_to_uninstall.is_empty() {
194        if !quiet {
195            println!("Collection has no dependencies to uninstall.");
196        }
197    } else {
198        if !quiet {
199            println!("Uninstalling dependencies of the collection...");
200        }
201        for dep_str in dependencies_to_uninstall {
202            let dep = dependencies::parse_dependency_string(dep_str)?;
203
204            if dep.manager == "zoi" {
205                if !quiet {
206                    println!(
207                        "\n{} Uninstalling zoi dependency: {}...",
208                        "::".bold().blue(),
209                        dep_str.bold()
210                    );
211                }
212            } else {
213                let prompt = format!(
214                    "Uninstall native dependency '{}' ({})?",
215                    dep.package.cyan(),
216                    dep.manager.yellow()
217                );
218                let warning = "Warning: Zoi cannot track if other non-Zoi \
219                               applications depend on this package.";
220
221                if yes {
222                    if !quiet {
223                        println!(
224                            "\n{} Uninstalling native dependency: {}...",
225                            "::".bold().blue(),
226                            dep_str.bold()
227                        );
228                        println!("{}: {}", "Note".yellow(), warning);
229                    }
230                } else if core_utils::ask_for_confirmation(
231                    &format!("{}\n   {}", prompt, warning.dimmed()),
232                    false
233                ) {
234                    if !quiet {
235                        println!(
236                            "\n{} Uninstalling dependency: {}...",
237                            "::".bold().blue(),
238                            dep_str.bold()
239                        );
240                    }
241                } else {
242                    if !quiet {
243                        println!(
244                            "Skipping uninstallation of native dependency: {}",
245                            dep.package.yellow()
246                        );
247                    }
248                    continue;
249                }
250            }
251
252            if let Err(e) =
253                dependencies::uninstall_dependency(dep_str, &move |name| {
254                    run(name, Some(scope), yes, quiet, dry_run).map(|_| ())
255                })
256                && !quiet
257            {
258                eprintln!(
259                    "Warning: Could not uninstall dependency '{dep_str}': {e}"
260                );
261            }
262        }
263    }
264
265    let handle = registry_handle.unwrap_or("local");
266    let package_dir =
267        local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)?;
268    if package_dir.exists() {
269        let _ = cleanup_service(&pkg.name, scope);
270        fs::remove_dir_all(&package_dir)?;
271    }
272    if let Err(e) = recorder::remove_package_from_record(manifest)
273        && !quiet
274    {
275        eprintln!(
276            "{} Failed to remove package from lockfile: {}",
277            "Warning:".yellow(),
278            e
279        );
280    }
281
282    if let Ok(conn) = db::open_connection("local") {
283        let _ =
284            db::delete_package(&conn, &pkg.name, None, &pkg.repo, Some(scope));
285    }
286
287    if let Ok(true) = telemetry::posthog_capture_event(
288        "uninstall",
289        pkg,
290        env!("CARGO_PKG_VERSION"),
291        registry_handle.unwrap_or("local"),
292        None
293    ) && !quiet
294    {
295        println!("{} telemetry sent", "Info:".green());
296    }
297
298    Ok(manifest.clone())
299}
300
301/// Finds an installed manifest matching the given package request.
302fn find_installed_manifest(
303    request: &resolve::PackageRequest,
304    scope_override: Option<types::Scope>
305) -> anyhow::Result<(types::InstallManifest, types::Scope)> {
306    let scopes = if let Some(scope) = scope_override {
307        vec![scope]
308    } else {
309        vec![
310            types::Scope::Project,
311            types::Scope::User,
312            types::Scope::System,
313        ]
314    };
315
316    for scope in scopes {
317        let mut matches =
318            local::find_installed_manifests_matching(request, scope)?;
319        match matches.len() {
320            0 => {}
321            1 => return Ok((matches.remove(0), scope)),
322            _ => {
323                return Err(anyhow!(
324                    "Package '{}' is ambiguous in {:?} scope. Use an explicit \
325                     source like '#handle@repo/name[:sub]@version'.",
326                    request.name,
327                    scope
328                ));
329            }
330        }
331    }
332
333    if scope_override.is_some() {
334        Err(anyhow!(
335            "Package '{}' is not installed in the specified scope.",
336            request.name
337        ))
338    } else {
339        Err(anyhow!(
340            "Package '{}' is not installed by Zoi.",
341            request.name
342        ))
343    }
344}
345
346/// Loads an installed package definition and its Lua source path from a
347/// manifest.
348fn load_installed_package(
349    manifest: &types::InstallManifest,
350    yes: bool
351) -> anyhow::Result<(types::Package, PathBuf)> {
352    let installed_source_path = local::get_package_source_path(manifest)?;
353    if installed_source_path.exists() {
354        let path = installed_source_path.to_str().ok_or_else(|| {
355            anyhow!("Stored package source path contains invalid UTF-8")
356        })?;
357        let mut pkg = zoi_lua::parser::parse_lua_package(
358            path,
359            Some(&manifest.version),
360            Some(manifest.scope),
361            true
362        )?;
363        pkg.repo.clone_from(&manifest.repo);
364        pkg.scope = manifest.scope;
365        pkg.registry_handle = Some(manifest.registry_handle.clone());
366        pkg.sub_package.clone_from(&manifest.sub_package);
367        return Ok((pkg, installed_source_path));
368    }
369
370    let source = local::installed_manifest_source(manifest);
371    let (mut pkg, _, _, pkg_lua_path, _, _, _) =
372        resolve::resolve_package_and_version(
373            &source,
374            Some(manifest.scope),
375            true,
376            yes
377        )?;
378    pkg.scope = manifest.scope;
379    pkg.sub_package.clone_from(&manifest.sub_package);
380    Ok((pkg, pkg_lua_path))
381}
382
383/// Uninstalls one or more packages from the system.
384///
385/// This is a complex multi-stage operation:
386/// - Dependent Check: Verifies if any other package requires this one (via the
387///   `dependents/` directory). Blocks if busy.
388/// - Hook Execution: Runs the `pre_remove` hook defined in `.pkg.lua`.
389/// - Lua Cleanup: Executes the `uninstall()` function and `zrm` operations.
390/// - File Removal: Deletes every file recorded in the package's
391///   `InstallManifest`.
392/// - Shim/Completion Cleanup: Unlinks binaries and completions if no other
393///   package provides them (ref-counting via the database).
394///
395/// If `recursive` is true, Zoi also attempts to uninstall any dependencies
396/// that are no longer needed by any other package.
397///
398/// # Errors
399///
400/// Returns an error if:
401/// - The package is not found or is ambiguous.
402/// - The package has dependents that must be uninstalled first.
403/// - Hook execution fails.
404/// - File system operations (removal, backup) fail.
405/// - Escalation to root fails.
406///
407/// # Panics
408///
409/// Panics if internal dependency consistency checks fail.
410pub fn run(
411    package_name: &str,
412    scope_override: Option<types::Scope>,
413    yes: bool,
414    quiet: bool,
415    dry_run: bool
416) -> anyhow::Result<types::InstallManifest> {
417    let request = resolve::parse_source_string(package_name)?;
418    let (manifest, scope) = find_installed_manifest(&request, scope_override)?;
419    let sub_package_to_uninstall = manifest.sub_package.clone();
420    let registry_handle = Some(manifest.registry_handle.clone());
421    let (pkg, pkg_lua_path) = load_installed_package(&manifest, yes)?;
422
423    if pkg.package_type == types::PackageType::Collection {
424        return uninstall_collection(
425            &pkg,
426            &manifest,
427            scope,
428            registry_handle.as_deref(),
429            yes,
430            quiet,
431            dry_run
432        );
433    }
434
435    if dry_run {
436        return Ok(manifest);
437    }
438
439    let handle = manifest.registry_handle.as_str();
440    let package_dir =
441        local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)?;
442    let version_dir = package_dir.join(&manifest.version);
443
444    let dependents = local::get_dependents(&package_dir)?;
445    if !dependents.is_empty() {
446        return Err(anyhow::anyhow!(
447            "Cannot uninstall '{}' because other packages depend on it:\n  \
448             -{}\n\nPlease uninstall these packages first.",
449            pkg.name,
450            dependents.join("\n  - ")
451        ));
452    }
453
454    let needs_escalation =
455        scope == types::Scope::System && !core_utils::is_admin();
456
457    if needs_escalation {
458        let escalator =
459            core_utils::get_privilege_escalator().ok_or_else(|| {
460                anyhow!(
461                    "Root privileges required to remove system package, but \
462                     neither 'sudo' nor 'doas' was found."
463                )
464            })?;
465
466        if !quiet {
467            println!(
468                "{} Escalating to root via {} to remove system package...",
469                "::".bold().blue(),
470                escalator
471            );
472        }
473        let manifest_json = serde_json::to_string(&manifest)?;
474        let mut temp_file = tempfile::NamedTempFile::new()?;
475        temp_file.write_all(manifest_json.as_bytes())?;
476        let temp_path = temp_file.path();
477
478        let mut cmd = std::process::Command::new(escalator);
479        cmd.arg(std::env::current_exe()?);
480        cmd.arg("helper").arg("elevate-uninstall");
481        cmd.arg("--manifest-json").arg(temp_path);
482        if yes {
483            cmd.arg("--yes");
484        }
485
486        let status = cmd.status().map_err(|e| {
487            anyhow::anyhow!("Failed to spawn privilege escalator: {e}")
488        })?;
489        if !status.success() {
490            return Err(anyhow::anyhow!("Escalated uninstallation failed."));
491        }
492    } else {
493        if let Some(hooks) = &pkg.hooks
494            && let Err(e) =
495                hooks::run_hooks(hooks, hooks::HookType::PreRemove, scope)
496        {
497            return Err(anyhow::anyhow!("Pre-remove hook failed: {e}"));
498        }
499
500        let lua = Lua::new();
501        zoi_lua::functions::setup_lua_environment(
502            &lua,
503            &core_utils::get_platform()?,
504            Some(&manifest.version),
505            pkg_lua_path.to_str(),
506            None,
507            None,
508            None,
509            sub_package_to_uninstall.as_deref(),
510            Some(scope),
511            None,
512            true
513        )
514        .map_err(|e| anyhow!(e.to_string()))?;
515        let lua_code = fs::read_to_string(pkg_lua_path)?;
516        lua.load(&lua_code)
517            .exec()
518            .map_err(|e| anyhow!(e.to_string()))?;
519
520        if let Ok(uninstall_fn) =
521            lua.globals().get::<mlua::Function>("uninstall")
522        {
523            if !quiet {
524                println!("Running uninstall() script...");
525            }
526            uninstall_fn
527                .call::<()>(())
528                .map_err(|e| anyhow!(e.to_string()))?;
529        }
530
531        if let Ok(uninstall_ops) =
532            lua.globals().get::<mlua::Table>("__ZoiUninstallOperations")
533        {
534            for op in uninstall_ops.sequence_values::<mlua::Table>() {
535                let op = op.map_err(|e| anyhow!(e.to_string()))?;
536                if let Ok(op_type) = op.get::<String>("op")
537                    && op_type == "zrm"
538                {
539                    let mut path_to_remove: String =
540                        op.get("path").map_err(|e| anyhow!(e.to_string()))?;
541
542                    path_to_remove = path_to_remove
543                        .replace("${pkgstore}", &version_dir.to_string_lossy());
544
545                    if let Some(home_dir) = core_utils::get_user_home() {
546                        path_to_remove = path_to_remove
547                            .replace("${usrhome}", &home_dir.to_string_lossy());
548                    }
549                    path_to_remove = path_to_remove.replace(
550                        "${usrroot}",
551                        &sysroot::apply_sysroot(PathBuf::from("/"))
552                            .to_string_lossy()
553                    );
554
555                    let path = std::path::PathBuf::from(path_to_remove);
556                    if path.exists() {
557                        if !quiet {
558                            println!("Removing {}...", path.display());
559                        }
560                        if path.is_dir() {
561                            fs::remove_dir_all(path)?;
562                        } else {
563                            fs::remove_file(path)?;
564                        }
565                    }
566                }
567            }
568        }
569
570        if let Some(backup_files) = &manifest.backup {
571            if !quiet {
572                println!("Saving configuration files...");
573            }
574            for backup_file_rel in backup_files {
575                let expanded_path = zoi_core::utils::expand_placeholders(
576                    backup_file_rel,
577                    &version_dir,
578                    manifest.scope
579                )?;
580                let backup_src = PathBuf::from(expanded_path);
581
582                if backup_src.exists() {
583                    let backup_filename = backup_src
584                        .file_name()
585                        .ok_or_else(|| anyhow!("Invalid backup source name"))?
586                        .to_string_lossy();
587                    let backup_dest = version_dir
588                        .parent()
589                        .ok_or_else(|| {
590                            anyhow!(
591                                "version_dir should have a parent \
592                                 (package_dir)"
593                            )
594                        })?
595                        .join(format!("{backup_filename}.zoisave"));
596
597                    if let Some(p) = backup_dest.parent()
598                        && let Err(e) = fs::create_dir_all(p)
599                    {
600                        if !quiet {
601                            eprintln!(
602                                "Warning: could not create backup directory \
603                                 {}: {}",
604                                p.display(),
605                                e
606                            );
607                        }
608                        continue;
609                    }
610                    if !quiet {
611                        println!(
612                            "Saving {} to {}",
613                            backup_src.display(),
614                            backup_dest.display()
615                        );
616                    }
617                    // Use copy + remove for potential cross-device moves
618                    if let Err(e) = fs::copy(&backup_src, &backup_dest) {
619                        if !quiet {
620                            eprintln!(
621                                "Warning: failed to copy backup {}: {}",
622                                backup_src.display(),
623                                e
624                            );
625                        }
626                    } else {
627                        let _ = fs::remove_file(&backup_src);
628                    }
629                }
630            }
631        }
632
633        if !quiet {
634            println!(
635                "Uninstalling '{}'...",
636                if let Some(sub) = &manifest.sub_package {
637                    format!("{}:{}", pkg.name, sub)
638                } else {
639                    pkg.name.clone()
640                }
641                .bold()
642            );
643        }
644
645        if let Some(bins) = &manifest.bins {
646            let bin_root = get_bin_root(scope)?;
647            for bin in bins {
648                let symlink_path = bin_root.join(bin);
649                if symlink_path.is_symlink() || symlink_path.exists() {
650                    let other_providers = db::find_provides("local", bin)?;
651                    let still_provided =
652                        other_providers.iter().any(|(p, _)| {
653                            p.name != pkg.name
654                                || (p.sub_package != manifest.sub_package)
655                        });
656
657                    if still_provided {
658                        if !quiet {
659                            println!(
660                                "Keeping shim for {} as it is still provided \
661                                 by other packages.",
662                                bin.cyan()
663                            );
664                        }
665                    } else {
666                        if !quiet {
667                            println!(
668                                "Removing shim for {} from {}...",
669                                bin.cyan(),
670                                symlink_path.display()
671                            );
672                        }
673                        fs::remove_file(&symlink_path)?;
674                    }
675                }
676            }
677        } else if manifest.sub_package.is_none() {
678            let bin = &pkg.name;
679            let symlink_path = get_bin_root(scope)?.join(bin);
680            if symlink_path.is_symlink() || symlink_path.exists() {
681                let other_providers = db::find_provides("local", bin)?;
682                let still_provided = other_providers.iter().any(|(p, _)| {
683                    p.name != pkg.name
684                        || (p.sub_package != manifest.sub_package)
685                });
686
687                if !still_provided {
688                    if !quiet {
689                        println!(
690                            "Removing shim for {} from {}...",
691                            bin.cyan(),
692                            symlink_path.display()
693                        );
694                    }
695                    fs::remove_file(symlink_path)?;
696                }
697            }
698        }
699
700        if let Some(completions) = &manifest.completions {
701            for completion in completions {
702                let completions_root =
703                    get_completions_root(scope, &completion.shell)?;
704                let pkg_dir = completions_root.join(&pkg.name);
705                let symlink_path = pkg_dir.join(&completion.filename);
706                if symlink_path.is_symlink() || symlink_path.exists() {
707                    let other_providers =
708                        db::find_provides("local", &completion.filename)?;
709                    let still_provided =
710                        other_providers.iter().any(|(p, _)| {
711                            p.name != pkg.name
712                                || (p.sub_package != manifest.sub_package)
713                        });
714
715                    if !still_provided {
716                        if !quiet {
717                            println!(
718                                "Removing {} completion for {} from {}...",
719                                completion.shell.cyan(),
720                                completion.filename.cyan(),
721                                symlink_path.display()
722                            );
723                        }
724                        fs::remove_file(&symlink_path)?;
725                    } else if !quiet {
726                        println!(
727                            "Keeping {} completion for {} as it is still \
728                             provided by other packages.",
729                            completion.shell.cyan(),
730                            completion.filename.cyan()
731                        );
732                    }
733                }
734            }
735
736            let shells: std::collections::HashSet<String> =
737                completions.iter().map(|c| c.shell.clone()).collect();
738            for shell_name in shells {
739                let pkg_dir =
740                    get_completions_root(scope, &shell_name)?.join(&pkg.name);
741                if pkg_dir.exists()
742                    && fs::read_dir(&pkg_dir)
743                        .is_ok_and(|mut e| e.next().is_none())
744                {
745                    let _ = fs::remove_dir(&pkg_dir);
746                }
747            }
748        }
749
750        let pkg_id_opt = if let Ok(conn) = db::open_connection("local") {
751            db::get_package_id(
752                &conn,
753                &pkg.name,
754                manifest.sub_package.as_deref(),
755                &pkg.repo,
756                handle
757            )
758            .ok()
759        } else {
760            None
761        };
762
763        for file_path_str in &manifest.installed_files {
764            let expanded = core_utils::expand_placeholders(
765                file_path_str,
766                &version_dir,
767                scope
768            )?;
769            let file_path = PathBuf::from(&expanded);
770
771            if let Some(pkg_id) = pkg_id_opt
772                && let Ok(conn) = db::open_connection("local")
773                && let Ok(true) =
774                    db::has_other_owners(&conn, file_path_str, pkg_id)
775            {
776                if !quiet {
777                    println!(
778                        "Keeping {} as it is still owned by other packages.",
779                        file_path_str.dimmed()
780                    );
781                }
782                continue;
783            }
784
785            // Use symlink_metadata so links are removed even when they dangle
786            // or point to a non-empty directory; only the link is deleted.
787            let Ok(meta) = fs::symlink_metadata(&file_path) else {
788                continue;
789            };
790
791            if let Some(pkg_id) = pkg_id_opt
792                && let Ok(conn) = db::open_connection("local")
793                && let Ok(true) =
794                    db::has_other_owners(&conn, file_path_str, pkg_id)
795            {
796                if !quiet {
797                    println!(
798                        "Keeping {} as it is still owned by other packages.",
799                        file_path_str.dimmed()
800                    );
801                }
802                continue;
803            }
804
805            if meta.file_type().is_symlink() {
806                let _ = fs::remove_file(&file_path);
807            } else if meta.is_dir() {
808                // Only remove if empty to be safe
809                if fs::read_dir(&file_path)
810                    .is_ok_and(|mut e| e.next().is_none())
811                {
812                    let _ = fs::remove_dir(&file_path);
813                }
814            } else {
815                let _ = fs::remove_file(&file_path);
816            }
817        }
818
819        let manifest_filename = if let Some(sub) = &sub_package_to_uninstall {
820            format!("manifest-{sub}.yaml")
821        } else {
822            "manifest.yaml".to_string()
823        };
824
825        let manifest_path = version_dir.join(manifest_filename);
826        if manifest_path.exists() {
827            fs::remove_file(manifest_path)?;
828        }
829
830        if version_dir.exists() {
831            let mut has_other_manifests = false;
832            if let Ok(entries) = fs::read_dir(&version_dir) {
833                for entry in entries.flatten() {
834                    let name = entry.file_name().to_string_lossy().to_string();
835                    if name.starts_with("manifest")
836                        && std::path::Path::new(&name)
837                            .extension()
838                            .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
839                    {
840                        has_other_manifests = true;
841                        break;
842                    }
843                }
844            }
845            if !has_other_manifests {
846                if !quiet {
847                    println!(
848                        "Removing empty version directory: {}",
849                        version_dir.display()
850                    );
851                }
852                fs::remove_dir_all(&version_dir)?;
853            }
854        }
855
856        if package_dir.exists() {
857            let _ = cleanup_service(&pkg.name, scope);
858            let mut has_other_versions = false;
859            if let Ok(entries) = fs::read_dir(&package_dir) {
860                for entry in entries.flatten() {
861                    let name = entry.file_name().to_string_lossy().to_string();
862                    if name != "latest" && name != "dependents" {
863                        has_other_versions = true;
864                        break;
865                    }
866                }
867            }
868            if !has_other_versions {
869                if !quiet {
870                    println!(
871                        "Removing package store: {}",
872                        package_dir.display()
873                    );
874                }
875                fs::remove_dir_all(&package_dir)?;
876            }
877        }
878
879        let parent_id = format!(
880            "#{}@{}/{}@{}",
881            manifest.registry_handle,
882            manifest.repo,
883            manifest.name,
884            manifest.version
885        );
886        for dep_str in &manifest.installed_dependencies {
887            if let Ok(dep) = dependencies::parse_dependency_string(dep_str)
888                && dep.manager == "zoi"
889            {
890                let dep_req = resolve::parse_source_string(dep.package)?;
891                let dep_matches =
892                    local::find_installed_manifests_matching(&dep_req, scope)?;
893                if dep_matches.len() == 1 {
894                    let dep_manifest =
895                        dep_matches.first().expect("Already checked length");
896                    match local::get_package_dir(
897                        dep_manifest.scope,
898                        &dep_manifest.registry_handle,
899                        &dep_manifest.repo,
900                        &dep_manifest.name
901                    ) {
902                        Ok(dep_pkg_dir) => {
903                            if let Err(e) = local::remove_dependent(
904                                &dep_pkg_dir,
905                                &parent_id
906                            ) && !quiet
907                            {
908                                eprintln!(
909                                    "Warning: failed to remove dependent link \
910                                     for {}: {}",
911                                    dep.package, e
912                                );
913                            }
914                        }
915                        Err(e) => {
916                            if !quiet {
917                                eprintln!(
918                                    "Warning: failed to get package dir for \
919                                     {}: {}",
920                                    dep.package, e
921                                );
922                            }
923                        }
924                    }
925                }
926            }
927        }
928
929        if let Some(hooks) = &pkg.hooks
930            && let Err(e) =
931                hooks::run_hooks(hooks, hooks::HookType::PostRemove, scope)
932            && !quiet
933        {
934            eprintln!("{} post-remove hook failed: {}", "Warning:".yellow(), e);
935        }
936    }
937
938    if let Err(e) = recorder::remove_package_from_record(&manifest)
939        && !quiet
940    {
941        eprintln!(
942            "{} Failed to remove package from lockfile: {}",
943            "Warning:".yellow(),
944            e
945        );
946    }
947
948    if let Ok(conn) = db::open_connection("local") {
949        let _ = db::delete_package(
950            &conn,
951            &pkg.name,
952            sub_package_to_uninstall.as_deref(),
953            &pkg.repo,
954            Some(scope)
955        );
956    }
957
958    if !quiet {
959        println!("Removed manifest for '{}'.", pkg.name);
960    }
961
962    if let Ok(true) = telemetry::posthog_capture_event(
963        "uninstall",
964        &pkg,
965        env!("CARGO_PKG_VERSION"),
966        &manifest.registry_handle,
967        None
968    ) && !quiet
969    {
970        println!("{} telemetry sent", "Info:".green());
971    }
972
973    Ok(manifest)
974}