Skip to main content

zoi_uninstall/
lib.rs

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