Skip to main content

zoi_cli/cmd/uninstall/
mod.rs

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