Skip to main content

zoi_cli/cmd/
uninstall.rs

1use crate::cmd::utils;
2use crate::cmd::ux;
3use crate::pkg::{self, lock, transaction, types};
4use anyhow::{Result, anyhow};
5use colored::*;
6use mlua::LuaSerdeExt;
7use serde_json::json;
8use std::fs;
9use std::path::Path;
10use walkdir::WalkDir;
11
12pub fn run(
13    package_names: &[String],
14    scope: Option<crate::cli::InstallScope>,
15    local: bool,
16    global: bool,
17    save: bool,
18    yes: bool,
19    recursive: bool,
20    plugin_manager: Option<&crate::pkg::plugin::PluginManager>,
21    explain: bool,
22    plan_json: bool,
23    dry_run: bool,
24) -> Result<()> {
25    let mut scope_override = scope.map(|s| match s {
26        crate::cli::InstallScope::User => types::Scope::User,
27        crate::cli::InstallScope::System => types::Scope::System,
28        crate::cli::InstallScope::Project => types::Scope::Project,
29    });
30
31    if local {
32        scope_override = Some(types::Scope::Project);
33    } else if global || scope_override.is_none() {
34        scope_override = Some(types::Scope::User);
35    }
36
37    if save && scope_override != Some(types::Scope::Project) {
38        return Err(anyhow!(
39            "The --save flag can only be used with project-scoped uninstalls."
40        ));
41    }
42
43    let installed_packages = pkg::local::get_installed_packages()?;
44
45    let mut manifests_to_uninstall: Vec<types::InstallManifest> = Vec::new();
46    let mut failed_resolution = false;
47
48    let expanded_names = utils::expand_split_packages(package_names, "Uninstalling")?;
49
50    for name in &expanded_names {
51        if let Err(e) = resolve_and_add_manifest(
52            name,
53            &installed_packages,
54            &mut manifests_to_uninstall,
55            scope_override,
56            yes,
57        ) {
58            eprintln!("{}", e);
59            failed_resolution = true;
60        }
61    }
62
63    if failed_resolution {
64        return Err(anyhow!(
65            "Failed to resolve some packages for uninstallation."
66        ));
67    }
68
69    if recursive {
70        collect_recursive_uninstalls(&mut manifests_to_uninstall, &installed_packages)?;
71    }
72
73    if manifests_to_uninstall.is_empty() {
74        println!("No packages to uninstall.");
75        ux::print_transaction_summary(&ux::TransactionSummary {
76            command: "uninstall".to_string(),
77            success: 0,
78            failed: 0,
79            skipped: 0,
80        });
81        return Ok(());
82    }
83
84    manifests_to_uninstall.sort_by(|a, b| {
85        a.name
86            .cmp(&b.name)
87            .then_with(|| scope_rank(a.scope).cmp(&scope_rank(b.scope)))
88            .then_with(|| a.registry_handle.cmp(&b.registry_handle))
89            .then_with(|| a.repo.cmp(&b.repo))
90            .then_with(|| a.sub_package.cmp(&b.sub_package))
91    });
92    manifests_to_uninstall.dedup_by(|a, b| {
93        a.name == b.name
94            && a.sub_package == b.sub_package
95            && a.repo == b.repo
96            && a.registry_handle == b.registry_handle
97            && a.scope == b.scope
98    });
99
100    let mut total_size_freed_bytes: u64 = 0;
101    for manifest in &manifests_to_uninstall {
102        if let Some(size) = manifest.installed_size
103            && size > 0
104        {
105            total_size_freed_bytes += size;
106            continue;
107        }
108
109        let version_dir = match pkg::local::get_package_version_dir(
110            manifest.scope,
111            &manifest.registry_handle,
112            &manifest.repo,
113            &manifest.name,
114            &manifest.version,
115        ) {
116            Ok(d) => d,
117            Err(_) => continue,
118        };
119
120        let mut package_size: u64 = 0;
121        for file_path_str in &manifest.installed_files {
122            let expanded = match pkg::utils::expand_placeholders(
123                file_path_str,
124                &version_dir,
125                manifest.scope,
126            ) {
127                Ok(e) => e,
128                Err(_) => continue,
129            };
130            let path = Path::new(&expanded);
131            if !path.exists() {
132                continue;
133            }
134            if path.is_dir() {
135                package_size += WalkDir::new(path)
136                    .into_iter()
137                    .filter_map(|e| e.ok())
138                    .filter_map(|e| e.metadata().ok())
139                    .filter(|m| m.is_file())
140                    .map(|m| m.len())
141                    .sum::<u64>();
142            } else if let Ok(metadata) = fs::metadata(path) {
143                package_size += metadata.len();
144            }
145        }
146        total_size_freed_bytes += package_size;
147    }
148
149    println!("Packages to remove:");
150    for manifest in &manifests_to_uninstall {
151        let source_str = if let Some(sub) = &manifest.sub_package {
152            format!(
153                "#{}@{}/{}:{}",
154                manifest.registry_handle, manifest.repo, manifest.name, sub
155            )
156        } else {
157            format!(
158                "#{}@{}/{}",
159                manifest.registry_handle, manifest.repo, manifest.name
160            )
161        };
162        println!("  - {}", source_str);
163    }
164
165    println!(
166        "\nTotal size to be freed: {}",
167        crate::pkg::utils::format_bytes(total_size_freed_bytes)
168    );
169
170    let removal_ids: std::collections::HashSet<String> = manifests_to_uninstall
171        .iter()
172        .map(removal_identity)
173        .collect();
174    let mut dangerous = Vec::new();
175    let mut impact_json = Vec::new();
176    for manifest in &manifests_to_uninstall {
177        let package_dir = pkg::local::get_package_dir(
178            manifest.scope,
179            &manifest.registry_handle,
180            &manifest.repo,
181            &manifest.name,
182        )?;
183        let external_dependents =
184            collect_external_dependents(&removal_ids, pkg::local::get_dependents(&package_dir)?);
185
186        let source = if let Some(sub) = &manifest.sub_package {
187            format!(
188                "#{}@{}/{}:{}",
189                manifest.registry_handle, manifest.repo, manifest.name, sub
190            )
191        } else {
192            format!(
193                "#{}@{}/{}",
194                manifest.registry_handle, manifest.repo, manifest.name
195            )
196        };
197
198        if !external_dependents.is_empty() {
199            dangerous.push((source.clone(), external_dependents.clone()));
200        }
201        impact_json.push(json!({
202            "source": source,
203            "name": manifest.name,
204            "version": manifest.version,
205            "sub_package": manifest.sub_package,
206            "scope": format!("{:?}", manifest.scope),
207            "registry": manifest.registry_handle,
208            "repo": manifest.repo,
209            "external_dependents": external_dependents,
210            "installed_files": manifest.installed_files.len(),
211        }));
212    }
213
214    let preflight = ux::PreflightSummary::new("Uninstall preflight")
215        .row(
216            "Scope override",
217            scope_override
218                .map(|s| format!("{:?}", s))
219                .unwrap_or_else(|| "None".to_string()),
220        )
221        .row("Recursive", recursive.to_string())
222        .row("Packages", manifests_to_uninstall.len().to_string())
223        .row("Dangerous removals", dangerous.len().to_string())
224        .row(
225            "Estimated freed size",
226            crate::pkg::utils::format_bytes(total_size_freed_bytes),
227        );
228    ux::print_preflight(&preflight);
229
230    if explain {
231        let mut report = ux::ExplainReport::new("Uninstall explanation");
232        for manifest in &manifests_to_uninstall {
233            let source = if let Some(sub) = &manifest.sub_package {
234                format!(
235                    "#{}@{}/{}:{}",
236                    manifest.registry_handle, manifest.repo, manifest.name, sub
237                )
238            } else {
239                format!(
240                    "#{}@{}/{}",
241                    manifest.registry_handle, manifest.repo, manifest.name
242                )
243            };
244            report = report.item(
245                format!("{} [{}]", source, manifest.version),
246                format!("reason={:?}", manifest.reason),
247                Vec::new(),
248            );
249        }
250        if !dangerous.is_empty() {
251            for (source, deps) in &dangerous {
252                report = report.item(
253                    source.clone(),
254                    format!("blocks {} dependent(s)", deps.len()),
255                    deps.iter()
256                        .map(|dep| format!("dependent: {}", dep))
257                        .collect::<Vec<_>>(),
258                );
259            }
260        }
261        ux::print_explain(&report);
262    }
263
264    if plan_json {
265        let plan = json!({
266            "recursive": recursive,
267            "scope_override": format!("{:?}", scope_override),
268            "totals": {
269                "packages": manifests_to_uninstall.len(),
270                "dangerous_removals": dangerous.len(),
271                "freed_bytes": total_size_freed_bytes,
272            },
273            "packages": impact_json,
274        });
275        ux::emit_plan_json_v1("uninstall", plan)?;
276    }
277
278    if !dangerous.is_empty() {
279        println!(
280            "\n{} Removing these packages will break dependents:",
281            "Warning".yellow().bold()
282        );
283        for (source, deps) in &dangerous {
284            println!("  - {}", source.cyan());
285            for dep in deps {
286                println!("    * {}", dep);
287            }
288        }
289        if !crate::utils::ask_for_confirmation("Dangerous removal detected. Continue anyway?", yes)
290        {
291            ux::print_transaction_summary(&ux::TransactionSummary {
292                command: "uninstall".to_string(),
293                success: 0,
294                failed: 0,
295                skipped: manifests_to_uninstall.len(),
296            });
297            return Ok(());
298        }
299    }
300
301    if dry_run {
302        println!("\n{}", "Dry-run mode: no changes will be made.".yellow());
303        ux::print_transaction_summary(&ux::TransactionSummary {
304            command: "uninstall".to_string(),
305            success: 0,
306            failed: 0,
307            skipped: manifests_to_uninstall.len(),
308        });
309        return Ok(());
310    }
311
312    if !crate::utils::ask_for_confirmation(":: Proceed with removal?", yes) {
313        let _ = lock::release_lock();
314        ux::print_transaction_summary(&ux::TransactionSummary {
315            command: "uninstall".to_string(),
316            success: 0,
317            failed: 0,
318            skipped: manifests_to_uninstall.len(),
319        });
320        return Ok(());
321    }
322
323    let mut transaction = transaction::begin()?;
324
325    let mut failed_packages = Vec::new();
326    let mut successfully_uninstalled = Vec::new();
327
328    if let Some(pm) = plugin_manager {
329        pm.set_context(scope_override.unwrap_or_default())?;
330    }
331
332    for manifest in &manifests_to_uninstall {
333        let mut pkg_val = None;
334        if let Some(pm) = plugin_manager {
335            let v = pm
336                .lua
337                .to_value(manifest)
338                .map_err(|e: mlua::Error| anyhow!(e.to_string()))?;
339            pm.trigger_hook("on_pre_uninstall", Some(v.clone()))?;
340            pkg_val = Some(v);
341        }
342
343        let source_str = if let Some(sub) = &manifest.sub_package {
344            format!(
345                "#{}@{}/{}:{}",
346                manifest.registry_handle, manifest.repo, manifest.name, sub
347            )
348        } else {
349            format!(
350                "#{}@{}/{}",
351                manifest.registry_handle, manifest.repo, manifest.name
352            )
353        };
354
355        println!(
356            "{} Uninstalling package '{}'...",
357            "::".bold().blue(),
358            source_str.blue().bold()
359        );
360
361        match pkg::uninstall::run(&source_str, scope_override, yes, false, false) {
362            Ok(uninstalled_manifest) => {
363                if let Err(e) = transaction::record_operation(
364                    &mut transaction,
365                    types::TransactionOperation::Uninstall {
366                        manifest: Box::new(uninstalled_manifest),
367                    },
368                ) {
369                    eprintln!(
370                        "Failed to record transaction operation for {}: {}",
371                        source_str, e
372                    );
373                    failed_packages.push(source_str.clone());
374                } else {
375                    successfully_uninstalled.push(source_str.clone());
376                    if let (Some(pm), Some(v)) = (plugin_manager, pkg_val) {
377                        pm.trigger_hook_nonfatal("on_post_uninstall", Some(v));
378                    }
379                    println!("\n{} Uninstallation complete.", "Success:".green());
380                }
381            }
382            Err(e) => {
383                eprintln!("\nError: {}", e);
384                failed_packages.push(source_str.clone());
385            }
386        }
387    }
388
389    if !failed_packages.is_empty() {
390        eprintln!("\nError: Uninstallation failed for some packages.");
391        eprintln!("\n{} Rolling back changes...", "::".bold().yellow());
392        if let Err(e) = transaction::rollback(&transaction.id) {
393            eprintln!("\nCRITICAL: Rollback failed: {}", e);
394            eprintln!(
395                "The system may be in an inconsistent state. The transaction log is at ~/.zoi/transactions/{}.json",
396                transaction.id
397            );
398        } else {
399            println!("\n{} Rollback successful.", "Success:".green().bold());
400        }
401        ux::print_transaction_summary(&ux::TransactionSummary {
402            command: "uninstall".to_string(),
403            success: successfully_uninstalled.len(),
404            failed: failed_packages.len(),
405            skipped: 0,
406        });
407        return Err(anyhow!(
408            "Uninstallation failed for: {}",
409            failed_packages.join(", ")
410        ));
411    } else {
412        if let Ok(modified_files) = transaction::get_modified_files(&transaction.id) {
413            let modified_packages =
414                transaction::get_modified_packages(&transaction.id).unwrap_or_default();
415            let _ = crate::pkg::hooks::global::run_global_hooks(
416                crate::pkg::hooks::global::HookWhen::PostTransaction,
417                &modified_files,
418                &modified_packages,
419                "remove",
420                scope_override.unwrap_or_default(),
421            );
422        }
423
424        if let Err(e) = transaction::commit(&transaction.id) {
425            eprintln!("Warning: Failed to commit transaction: {}", e);
426        }
427    }
428
429    if save {
430        if std::path::Path::new("zoi.lua").exists() {
431            println!(
432                "\n{} Project uses zoi.lua. Automatic saving is not supported for Lua configurations.",
433                "Note:".yellow().bold()
434            );
435            println!("   Please remove the following from your packages() block in zoi.lua:");
436            for pkg in &successfully_uninstalled {
437                println!("   - \"{}\"", pkg);
438            }
439        } else if let Err(e) =
440            zoi_project::config::remove_packages_from_config(&successfully_uninstalled)
441        {
442            eprintln!(
443                "{}: Failed to remove packages from zoi.yaml: {}",
444                "Warning".yellow().bold(),
445                e
446            );
447        }
448    }
449    ux::print_transaction_summary(&ux::TransactionSummary {
450        command: "uninstall".to_string(),
451        success: successfully_uninstalled.len(),
452        failed: 0,
453        skipped: 0,
454    });
455    Ok(())
456}
457
458fn removal_identity(manifest: &types::InstallManifest) -> String {
459    if let Some(sub) = &manifest.sub_package {
460        format!("{}@{}:{}", manifest.name, manifest.version, sub)
461    } else {
462        format!("{}@{}", manifest.name, manifest.version)
463    }
464}
465
466fn scope_rank(scope: types::Scope) -> u8 {
467    match scope {
468        types::Scope::Project => 0,
469        types::Scope::User => 1,
470        types::Scope::System => 2,
471    }
472}
473
474fn collect_external_dependents(
475    removal_ids: &std::collections::HashSet<String>,
476    dependents: Vec<String>,
477) -> Vec<String> {
478    let mut external = dependents
479        .into_iter()
480        .filter(|dep| !removal_ids.contains(dep))
481        .collect::<Vec<_>>();
482    external.sort();
483    external
484}
485
486fn resolve_and_add_manifest(
487    name: &str,
488    installed_packages: &[types::InstallManifest],
489    manifests_to_uninstall: &mut Vec<types::InstallManifest>,
490    scope_override: Option<types::Scope>,
491    yes: bool,
492) -> Result<(), String> {
493    let request = match pkg::resolve::parse_source_string(name) {
494        Ok(req) => req,
495        Err(e) => return Err(format!("Error: Invalid package name '{}': {}", name, e)),
496    };
497
498    let mut candidates: Vec<_> = installed_packages
499        .iter()
500        .filter(|m| {
501            let name_matches = m.name == request.name;
502            let sub_matches = m.sub_package == request.sub_package;
503            let scope_matches = scope_override.is_none_or(|scope| m.scope == scope);
504            name_matches && sub_matches && scope_matches
505        })
506        .collect();
507
508    if let Some(repo) = &request.repo {
509        candidates.retain(|m| m.repo == *repo);
510    }
511    if let Some(handle) = &request.handle {
512        candidates.retain(|m| m.registry_handle == *handle);
513    }
514
515    match candidates.len() {
516        0 => Err(format!("Error: Package '{}' is not installed.", name)),
517        1 => {
518            if !manifests_to_uninstall.iter().any(|m| {
519                m.name == candidates[0].name
520                    && m.sub_package == candidates[0].sub_package
521                    && m.repo == candidates[0].repo
522                    && m.registry_handle == candidates[0].registry_handle
523            }) {
524                manifests_to_uninstall.push(candidates[0].clone());
525            }
526            Ok(())
527        }
528        _ => {
529            let owned_candidates = candidates.into_iter().cloned().collect::<Vec<_>>();
530            let chosen = crate::cmd::installed_select::choose_installed_manifest(
531                name,
532                &owned_candidates,
533                yes,
534            )
535            .map_err(|e| format!("Error: {}", e))?;
536
537            if !manifests_to_uninstall.iter().any(|m| {
538                m.name == chosen.name
539                    && m.sub_package == chosen.sub_package
540                    && m.repo == chosen.repo
541                    && m.registry_handle == chosen.registry_handle
542                    && m.scope == chosen.scope
543            }) {
544                manifests_to_uninstall.push(chosen);
545            }
546            Ok(())
547        }
548    }
549}
550
551fn collect_recursive_uninstalls(
552    manifests_to_uninstall: &mut Vec<types::InstallManifest>,
553    installed_packages: &[types::InstallManifest],
554) -> Result<()> {
555    let mut changed = true;
556    while changed {
557        changed = false;
558        let mut new_to_add = Vec::new();
559
560        for manifest in manifests_to_uninstall.iter() {
561            for dep_str in &manifest.installed_dependencies {
562                if let Ok(dep) = pkg::dependencies::parse_dependency_string(dep_str)
563                    && dep.manager == "zoi"
564                {
565                    let dep_req = match pkg::resolve::parse_source_string(dep.package) {
566                        Ok(req) => req,
567                        Err(_) => continue,
568                    };
569
570                    let matching_dep_manifests = installed_packages
571                        .iter()
572                        .filter(|m| {
573                            m.name == dep_req.name
574                                && m.sub_package == dep_req.sub_package
575                                && dep_req.repo.as_ref().is_none_or(|repo| m.repo == *repo)
576                                && dep_req
577                                    .handle
578                                    .as_ref()
579                                    .is_none_or(|handle| m.registry_handle == *handle)
580                                && dep_req
581                                    .version_spec
582                                    .as_ref()
583                                    .is_none_or(|version| m.version == *version)
584                        })
585                        .collect::<Vec<_>>();
586
587                    if matching_dep_manifests.len() == 1 {
588                        let dm = matching_dep_manifests[0];
589                        if !matches!(dm.reason, types::InstallReason::Dependency { .. }) {
590                            continue;
591                        }
592
593                        if manifests_to_uninstall
594                            .iter()
595                            .any(|m| m.name == dm.name && m.sub_package == dm.sub_package)
596                            || new_to_add.iter().any(|m: &&types::InstallManifest| {
597                                m.name == dm.name && m.sub_package == dm.sub_package
598                            })
599                        {
600                            continue;
601                        }
602
603                        let pkg_dir = pkg::local::get_package_dir(
604                            dm.scope,
605                            &dm.registry_handle,
606                            &dm.repo,
607                            &dm.name,
608                        )?;
609                        let dependents = pkg::local::get_dependents(&pkg_dir)?;
610
611                        let all_dependents_will_be_removed = dependents.iter().all(|dep_id| {
612                            manifests_to_uninstall.iter().any(|m| {
613                                let m_id = if let Some(sub) = &m.sub_package {
614                                    format!("{}@{}:{}", m.name, m.version, sub)
615                                } else {
616                                    format!("{}@{}", m.name, m.version)
617                                };
618                                m_id == *dep_id
619                            })
620                        });
621
622                        if all_dependents_will_be_removed {
623                            new_to_add.push(dm);
624                            changed = true;
625                        }
626                    }
627                }
628            }
629        }
630
631        for nm in new_to_add {
632            manifests_to_uninstall.push(nm.clone());
633        }
634    }
635    Ok(())
636}
637
638#[cfg(test)]
639mod tests {
640    use super::collect_external_dependents;
641    use std::collections::HashSet;
642
643    #[test]
644    fn dangerous_removal_ignores_dependents_in_same_removal_set() {
645        let mut removal_ids = HashSet::new();
646        removal_ids.insert("foo@1.0.0".to_string());
647
648        let external = collect_external_dependents(
649            &removal_ids,
650            vec![
651                "foo@1.0.0".to_string(),
652                "bar@2.0.0".to_string(),
653                "baz@3.0.0".to_string(),
654            ],
655        );
656
657        assert_eq!(
658            external,
659            vec!["bar@2.0.0".to_string(), "baz@3.0.0".to_string()]
660        );
661    }
662
663    #[test]
664    fn dangerous_removal_dependents_are_sorted_for_stable_output() {
665        let removal_ids = HashSet::new();
666        let external = collect_external_dependents(
667            &removal_ids,
668            vec!["c@1".to_string(), "a@1".to_string(), "b@1".to_string()],
669        );
670        assert_eq!(
671            external,
672            vec!["a@1".to_string(), "b@1".to_string(), "c@1".to_string()]
673        );
674    }
675}