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 sent", "Info:".green());
288    }
289
290    Ok(manifest.clone())
291}
292
293/// Finds an installed manifest matching the given package request.
294fn find_installed_manifest(
295    request: &resolve::PackageRequest,
296    scope_override: Option<types::Scope>
297) -> anyhow::Result<(types::InstallManifest, types::Scope)> {
298    let scopes = if let Some(scope) = scope_override {
299        vec![scope]
300    } else {
301        vec![
302            types::Scope::Project,
303            types::Scope::User,
304            types::Scope::System,
305        ]
306    };
307
308    for scope in scopes {
309        let mut matches =
310            local::find_installed_manifests_matching(request, scope)?;
311        match matches.len() {
312            0 => {}
313            1 => return Ok((matches.remove(0), scope)),
314            _ => {
315                return Err(anyhow!(
316                    "Package '{}' is ambiguous in {:?} scope. Use an explicit \
317                     source like '#handle@repo/name[:sub]@version'.",
318                    request.name,
319                    scope
320                ));
321            }
322        }
323    }
324
325    if scope_override.is_some() {
326        Err(anyhow!(
327            "Package '{}' is not installed in the specified scope.",
328            request.name
329        ))
330    } else {
331        Err(anyhow!(
332            "Package '{}' is not installed by Zoi.",
333            request.name
334        ))
335    }
336}
337
338/// Loads an installed package definition and its Lua source path from a
339/// manifest.
340fn load_installed_package(
341    manifest: &types::InstallManifest,
342    yes: bool
343) -> anyhow::Result<(types::Package, PathBuf)> {
344    let installed_source_path = local::get_package_source_path(manifest)?;
345    if installed_source_path.exists() {
346        let path = installed_source_path.to_str().ok_or_else(|| {
347            anyhow!("Stored package source path contains invalid UTF-8")
348        })?;
349        let mut pkg = zoi_lua::parser::parse_lua_package(
350            path,
351            Some(&manifest.version),
352            Some(manifest.scope),
353            true
354        )?;
355        pkg.repo.clone_from(&manifest.repo);
356        pkg.scope = manifest.scope;
357        pkg.registry_handle = Some(manifest.registry_handle.clone());
358        pkg.sub_package.clone_from(&manifest.sub_package);
359        return Ok((pkg, installed_source_path));
360    }
361
362    let source = local::installed_manifest_source(manifest);
363    let (mut pkg, _, _, pkg_lua_path, _, _, _) =
364        resolve::resolve_package_and_version(
365            &source,
366            Some(manifest.scope),
367            true,
368            yes
369        )?;
370    pkg.scope = manifest.scope;
371    pkg.sub_package.clone_from(&manifest.sub_package);
372    Ok((pkg, pkg_lua_path))
373}
374
375/// Uninstalls one or more packages from the system.
376///
377/// This is a complex multi-stage operation:
378/// - Dependent Check: Verifies if any other package requires this one (via the
379///   `dependents/` directory). Blocks if busy.
380/// - Hook Execution: Runs the `pre_remove` hook defined in `.pkg.lua`.
381/// - Lua Cleanup: Executes the `uninstall()` function and `zrm` operations.
382/// - File Removal: Deletes every file recorded in the package's
383///   `InstallManifest`.
384/// - Shim/Completion Cleanup: Unlinks binaries and completions if no other
385///   package provides them (ref-counting via the database).
386///
387/// If `recursive` is true, Zoi also attempts to uninstall any dependencies
388/// that are no longer needed by any other package.
389///
390/// # Errors
391///
392/// Returns an error if:
393/// - The package is not found or is ambiguous.
394/// - The package has dependents that must be uninstalled first.
395/// - Hook execution fails.
396/// - File system operations (removal, backup) fail.
397/// - Escalation to root fails.
398///
399/// # Panics
400///
401/// Panics if internal dependency consistency checks fail.
402pub fn run(
403    package_name: &str,
404    scope_override: Option<types::Scope>,
405    yes: bool,
406    quiet: bool,
407    dry_run: bool
408) -> anyhow::Result<types::InstallManifest> {
409    let request = resolve::parse_source_string(package_name)?;
410    let (manifest, scope) = find_installed_manifest(&request, scope_override)?;
411    let sub_package_to_uninstall = manifest.sub_package.clone();
412    let registry_handle = Some(manifest.registry_handle.clone());
413    let (pkg, pkg_lua_path) = load_installed_package(&manifest, yes)?;
414
415    if pkg.package_type == types::PackageType::Collection {
416        return uninstall_collection(
417            &pkg,
418            &manifest,
419            scope,
420            registry_handle.as_deref(),
421            yes,
422            quiet,
423            dry_run
424        );
425    }
426
427    if dry_run {
428        return Ok(manifest);
429    }
430
431    let handle = manifest.registry_handle.as_str();
432    let package_dir =
433        local::get_package_dir(scope, handle, &pkg.repo, &pkg.name)?;
434    let version_dir = package_dir.join(&manifest.version);
435
436    let dependents = local::get_dependents(&package_dir)?;
437    if !dependents.is_empty() {
438        return Err(anyhow::anyhow!(
439            "Cannot uninstall '{}' because other packages depend on it:\n  \
440             -{}\n\nPlease uninstall these packages first.",
441            pkg.name,
442            dependents.join("\n  - ")
443        ));
444    }
445
446    let needs_escalation =
447        scope == types::Scope::System && !core_utils::is_admin();
448
449    if needs_escalation {
450        let escalator =
451            core_utils::get_privilege_escalator().ok_or_else(|| {
452                anyhow!(
453                    "Root privileges required to remove system package, but \
454                     neither 'sudo' nor 'doas' was found."
455                )
456            })?;
457
458        if !quiet {
459            println!(
460                "{} Escalating to root via {} to remove system package...",
461                "::".bold().blue(),
462                escalator
463            );
464        }
465        let manifest_json = serde_json::to_string(&manifest)?;
466        let mut temp_file = tempfile::NamedTempFile::new()?;
467        temp_file.write_all(manifest_json.as_bytes())?;
468        let temp_path = temp_file.path();
469
470        let mut cmd = std::process::Command::new(escalator);
471        cmd.arg(std::env::current_exe()?);
472        cmd.arg("helper").arg("elevate-uninstall");
473        cmd.arg("--manifest-json").arg(temp_path);
474        if yes {
475            cmd.arg("--yes");
476        }
477
478        let status = cmd.status().map_err(|e| {
479            anyhow::anyhow!("Failed to spawn privilege escalator: {e}")
480        })?;
481        if !status.success() {
482            return Err(anyhow::anyhow!("Escalated uninstallation failed."));
483        }
484    } else {
485        if let Some(hooks) = &pkg.hooks
486            && let Err(e) =
487                hooks::run_hooks(hooks, hooks::HookType::PreRemove, scope)
488        {
489            return Err(anyhow::anyhow!("Pre-remove hook failed: {e}"));
490        }
491
492        let lua = Lua::new();
493        zoi_lua::functions::setup_lua_environment(
494            &lua,
495            &core_utils::get_platform()?,
496            Some(&manifest.version),
497            pkg_lua_path.to_str(),
498            None,
499            None,
500            None,
501            sub_package_to_uninstall.as_deref(),
502            Some(scope),
503            None,
504            true
505        )
506        .map_err(|e| anyhow!(e.to_string()))?;
507        let lua_code = fs::read_to_string(pkg_lua_path)?;
508        lua.load(&lua_code)
509            .exec()
510            .map_err(|e| anyhow!(e.to_string()))?;
511
512        if let Ok(uninstall_fn) =
513            lua.globals().get::<mlua::Function>("uninstall")
514        {
515            if !quiet {
516                println!("Running uninstall() script...");
517            }
518            uninstall_fn
519                .call::<()>(())
520                .map_err(|e| anyhow!(e.to_string()))?;
521        }
522
523        if let Ok(uninstall_ops) =
524            lua.globals().get::<mlua::Table>("__ZoiUninstallOperations")
525        {
526            for op in uninstall_ops.sequence_values::<mlua::Table>() {
527                let op = op.map_err(|e| anyhow!(e.to_string()))?;
528                if let Ok(op_type) = op.get::<String>("op")
529                    && op_type == "zrm"
530                {
531                    let mut path_to_remove: String =
532                        op.get("path").map_err(|e| anyhow!(e.to_string()))?;
533
534                    path_to_remove = path_to_remove
535                        .replace("${pkgstore}", &version_dir.to_string_lossy());
536
537                    if let Some(home_dir) = core_utils::get_user_home() {
538                        path_to_remove = path_to_remove
539                            .replace("${usrhome}", &home_dir.to_string_lossy());
540                    }
541                    path_to_remove = path_to_remove.replace(
542                        "${usrroot}",
543                        &sysroot::apply_sysroot(PathBuf::from("/"))
544                            .to_string_lossy()
545                    );
546
547                    let path = std::path::PathBuf::from(path_to_remove);
548                    if path.exists() {
549                        if !quiet {
550                            println!("Removing {}...", path.display());
551                        }
552                        if path.is_dir() {
553                            fs::remove_dir_all(path)?;
554                        } else {
555                            fs::remove_file(path)?;
556                        }
557                    }
558                }
559            }
560        }
561
562        if let Some(backup_files) = &manifest.backup {
563            if !quiet {
564                println!("Saving configuration files...");
565            }
566            for backup_file_rel in backup_files {
567                let expanded_path = zoi_core::utils::expand_placeholders(
568                    backup_file_rel,
569                    &version_dir,
570                    manifest.scope
571                )?;
572                let backup_src = PathBuf::from(expanded_path);
573
574                if backup_src.exists() {
575                    let backup_filename = backup_src
576                        .file_name()
577                        .ok_or_else(|| anyhow!("Invalid backup source name"))?
578                        .to_string_lossy();
579                    let backup_dest = version_dir
580                        .parent()
581                        .ok_or_else(|| {
582                            anyhow!(
583                                "version_dir should have a parent \
584                                 (package_dir)"
585                            )
586                        })?
587                        .join(format!("{backup_filename}.zoisave"));
588
589                    if let Some(p) = backup_dest.parent()
590                        && let Err(e) = fs::create_dir_all(p)
591                    {
592                        if !quiet {
593                            eprintln!(
594                                "Warning: could not create backup directory \
595                                 {}: {}",
596                                p.display(),
597                                e
598                            );
599                        }
600                        continue;
601                    }
602                    if !quiet {
603                        println!(
604                            "Saving {} to {}",
605                            backup_src.display(),
606                            backup_dest.display()
607                        );
608                    }
609                    // Use copy + remove for potential cross-device moves
610                    if let Err(e) = fs::copy(&backup_src, &backup_dest) {
611                        if !quiet {
612                            eprintln!(
613                                "Warning: failed to copy backup {}: {}",
614                                backup_src.display(),
615                                e
616                            );
617                        }
618                    } else {
619                        let _ = fs::remove_file(&backup_src);
620                    }
621                }
622            }
623        }
624
625        if !quiet {
626            println!(
627                "Uninstalling '{}'...",
628                if let Some(sub) = &manifest.sub_package {
629                    format!("{}:{}", pkg.name, sub)
630                } else {
631                    pkg.name.clone()
632                }
633                .bold()
634            );
635        }
636
637        if let Some(bins) = &manifest.bins {
638            let bin_root = get_bin_root(scope)?;
639            for bin in bins {
640                let symlink_path = bin_root.join(bin);
641                if symlink_path.is_symlink() || symlink_path.exists() {
642                    let other_providers = db::find_provides("local", bin)?;
643                    let still_provided =
644                        other_providers.iter().any(|(p, _)| {
645                            p.name != pkg.name
646                                || (p.sub_package != manifest.sub_package)
647                        });
648
649                    if still_provided {
650                        if !quiet {
651                            println!(
652                                "Keeping shim for {} as it is still provided \
653                                 by other packages.",
654                                bin.cyan()
655                            );
656                        }
657                    } else {
658                        if !quiet {
659                            println!(
660                                "Removing shim for {} from {}...",
661                                bin.cyan(),
662                                symlink_path.display()
663                            );
664                        }
665                        fs::remove_file(&symlink_path)?;
666                    }
667                }
668            }
669        } else if manifest.sub_package.is_none() {
670            let bin = &pkg.name;
671            let symlink_path = get_bin_root(scope)?.join(bin);
672            if symlink_path.is_symlink() || symlink_path.exists() {
673                let other_providers = db::find_provides("local", bin)?;
674                let still_provided = other_providers.iter().any(|(p, _)| {
675                    p.name != pkg.name
676                        || (p.sub_package != manifest.sub_package)
677                });
678
679                if !still_provided {
680                    if !quiet {
681                        println!(
682                            "Removing shim for {} from {}...",
683                            bin.cyan(),
684                            symlink_path.display()
685                        );
686                    }
687                    fs::remove_file(symlink_path)?;
688                }
689            }
690        }
691
692        if let Some(completions) = &manifest.completions {
693            for completion in completions {
694                let completions_root =
695                    get_completions_root(scope, &completion.shell)?;
696                let pkg_dir = completions_root.join(&pkg.name);
697                let symlink_path = pkg_dir.join(&completion.filename);
698                if symlink_path.is_symlink() || symlink_path.exists() {
699                    let other_providers =
700                        db::find_provides("local", &completion.filename)?;
701                    let still_provided =
702                        other_providers.iter().any(|(p, _)| {
703                            p.name != pkg.name
704                                || (p.sub_package != manifest.sub_package)
705                        });
706
707                    if !still_provided {
708                        if !quiet {
709                            println!(
710                                "Removing {} completion for {} from {}...",
711                                completion.shell.cyan(),
712                                completion.filename.cyan(),
713                                symlink_path.display()
714                            );
715                        }
716                        fs::remove_file(&symlink_path)?;
717                    } else if !quiet {
718                        println!(
719                            "Keeping {} completion for {} as it is still \
720                             provided by other packages.",
721                            completion.shell.cyan(),
722                            completion.filename.cyan()
723                        );
724                    }
725                }
726            }
727
728            let shells: std::collections::HashSet<String> =
729                completions.iter().map(|c| c.shell.clone()).collect();
730            for shell_name in shells {
731                let pkg_dir =
732                    get_completions_root(scope, &shell_name)?.join(&pkg.name);
733                if pkg_dir.exists()
734                    && fs::read_dir(&pkg_dir)
735                        .is_ok_and(|mut e| e.next().is_none())
736                {
737                    let _ = fs::remove_dir(&pkg_dir);
738                }
739            }
740        }
741
742        let pkg_id_opt = if let Ok(conn) = db::open_connection("local") {
743            db::get_package_id(
744                &conn,
745                &pkg.name,
746                manifest.sub_package.as_deref(),
747                &pkg.repo,
748                handle
749            )
750            .ok()
751        } else {
752            None
753        };
754
755        for file_path_str in &manifest.installed_files {
756            let expanded = core_utils::expand_placeholders(
757                file_path_str,
758                &version_dir,
759                scope
760            )?;
761            let file_path = PathBuf::from(&expanded);
762
763            if let Some(pkg_id) = pkg_id_opt
764                && let Ok(conn) = db::open_connection("local")
765                && let Ok(true) =
766                    db::has_other_owners(&conn, file_path_str, pkg_id)
767            {
768                if !quiet {
769                    println!(
770                        "Keeping {} as it is still owned by other packages.",
771                        file_path_str.dimmed()
772                    );
773                }
774                continue;
775            }
776
777            // Use symlink_metadata so links are removed even when they dangle
778            // or point to a non-empty directory; only the link is deleted.
779            let Ok(meta) = fs::symlink_metadata(&file_path) else {
780                continue;
781            };
782
783            if let Some(pkg_id) = pkg_id_opt
784                && let Ok(conn) = db::open_connection("local")
785                && let Ok(true) =
786                    db::has_other_owners(&conn, file_path_str, pkg_id)
787            {
788                if !quiet {
789                    println!(
790                        "Keeping {} as it is still owned by other packages.",
791                        file_path_str.dimmed()
792                    );
793                }
794                continue;
795            }
796
797            if meta.file_type().is_symlink() {
798                let _ = fs::remove_file(&file_path);
799            } else if meta.is_dir() {
800                // Only remove if empty to be safe
801                if fs::read_dir(&file_path)
802                    .is_ok_and(|mut e| e.next().is_none())
803                {
804                    let _ = fs::remove_dir(&file_path);
805                }
806            } else {
807                let _ = fs::remove_file(&file_path);
808            }
809        }
810
811        let manifest_filename = if let Some(sub) = &sub_package_to_uninstall {
812            format!("manifest-{sub}.yaml")
813        } else {
814            "manifest.yaml".to_string()
815        };
816
817        let manifest_path = version_dir.join(manifest_filename);
818        if manifest_path.exists() {
819            fs::remove_file(manifest_path)?;
820        }
821
822        if version_dir.exists() {
823            let mut has_other_manifests = false;
824            if let Ok(entries) = fs::read_dir(&version_dir) {
825                for entry in entries.flatten() {
826                    let name = entry.file_name().to_string_lossy().to_string();
827                    if name.starts_with("manifest")
828                        && std::path::Path::new(&name)
829                            .extension()
830                            .is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"))
831                    {
832                        has_other_manifests = true;
833                        break;
834                    }
835                }
836            }
837            if !has_other_manifests {
838                if !quiet {
839                    println!(
840                        "Removing empty version directory: {}",
841                        version_dir.display()
842                    );
843                }
844                fs::remove_dir_all(&version_dir)?;
845            }
846        }
847
848        if package_dir.exists() {
849            let _ = cleanup_service(&pkg.name, scope);
850            let mut has_other_versions = false;
851            if let Ok(entries) = fs::read_dir(&package_dir) {
852                for entry in entries.flatten() {
853                    let name = entry.file_name().to_string_lossy().to_string();
854                    if name != "latest" && name != "dependents" {
855                        has_other_versions = true;
856                        break;
857                    }
858                }
859            }
860            if !has_other_versions {
861                if !quiet {
862                    println!(
863                        "Removing package store: {}",
864                        package_dir.display()
865                    );
866                }
867                fs::remove_dir_all(&package_dir)?;
868            }
869        }
870
871        let parent_id = format!(
872            "#{}@{}/{}@{}",
873            manifest.registry_handle,
874            manifest.repo,
875            manifest.name,
876            manifest.version
877        );
878        for dep_str in &manifest.installed_dependencies {
879            if let Ok(dep) = dependencies::parse_dependency_string(dep_str)
880                && dep.manager == "zoi"
881            {
882                let dep_req = resolve::parse_source_string(dep.package)?;
883                let dep_matches =
884                    local::find_installed_manifests_matching(&dep_req, scope)?;
885                if dep_matches.len() == 1 {
886                    let dep_manifest =
887                        dep_matches.first().expect("Already checked length");
888                    match local::get_package_dir(
889                        dep_manifest.scope,
890                        &dep_manifest.registry_handle,
891                        &dep_manifest.repo,
892                        &dep_manifest.name
893                    ) {
894                        Ok(dep_pkg_dir) => {
895                            if let Err(e) = local::remove_dependent(
896                                &dep_pkg_dir,
897                                &parent_id
898                            ) && !quiet
899                            {
900                                eprintln!(
901                                    "Warning: failed to remove dependent link \
902                                     for {}: {}",
903                                    dep.package, e
904                                );
905                            }
906                        }
907                        Err(e) => {
908                            if !quiet {
909                                eprintln!(
910                                    "Warning: failed to get package dir for \
911                                     {}: {}",
912                                    dep.package, e
913                                );
914                            }
915                        }
916                    }
917                }
918            }
919        }
920
921        if let Some(hooks) = &pkg.hooks
922            && let Err(e) =
923                hooks::run_hooks(hooks, hooks::HookType::PostRemove, scope)
924            && !quiet
925        {
926            eprintln!("{} post-remove hook failed: {}", "Warning:".yellow(), e);
927        }
928    }
929
930    if let Err(e) = recorder::remove_package_from_record(&manifest)
931        && !quiet
932    {
933        eprintln!(
934            "{} Failed to remove package from lockfile: {}",
935            "Warning:".yellow(),
936            e
937        );
938    }
939
940    if let Ok(conn) = db::open_connection("local") {
941        let _ = db::delete_package(
942            &conn,
943            &pkg.name,
944            sub_package_to_uninstall.as_deref(),
945            &pkg.repo,
946            Some(scope)
947        );
948    }
949
950    if !quiet {
951        println!("Removed manifest for '{}'.", pkg.name);
952    }
953
954    if let Ok(true) = telemetry::posthog_capture_event(
955        "uninstall",
956        &pkg,
957        env!("CARGO_PKG_VERSION"),
958        &manifest.registry_handle,
959        None
960    ) && !quiet
961    {
962        println!("{} telemetry sent", "Info:".green());
963    }
964
965    Ok(manifest)
966}