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