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            eprintln!(
441                "The system may be in an inconsistent state. The transaction \
442                 log is at ~/.zoi/transactions/{}.json",
443                transaction.id
444            );
445        } else {
446            println!("\n{} Rollback successful.", "Success:".green().bold());
447        }
448        ux::print_transaction_summary(&ux::TransactionSummary {
449            command: "uninstall".to_string(),
450            success: successfully_uninstalled.len(),
451            failed: failed_packages.len(),
452            skipped: 0
453        });
454        return Err(anyhow!(
455            "Uninstallation failed for: {}",
456            failed_packages.join(", ")
457        ));
458    }
459
460    if let Ok(modified_files) = transaction::get_modified_files(&transaction.id)
461    {
462        let modified_packages =
463            transaction::get_modified_packages(&transaction.id)
464                .unwrap_or_default();
465        let _ = crate::pkg::hooks::global::run_global_hooks(
466            crate::pkg::hooks::global::HookWhen::PostTransaction,
467            &modified_files,
468            &modified_packages,
469            "remove",
470            scope_override.unwrap_or_default()
471        );
472    }
473
474    if let Err(e) = transaction::commit(&transaction.id) {
475        eprintln!("Warning: Failed to commit transaction: {e}");
476    }
477
478    if save {
479        if std::path::Path::new("zoi.lua").exists() {
480            println!(
481                "\n{} Project uses zoi.lua. Automatic saving is not supported \
482                 for Lua configurations.",
483                "Note:".yellow().bold()
484            );
485            println!(
486                "   Please remove the following from your packages() block in \
487                 zoi.lua:"
488            );
489            for pkg in &successfully_uninstalled {
490                println!("   - \"{pkg}\"");
491            }
492        } else if let Err(e) = zoi_project::config::remove_packages_from_config(
493            &successfully_uninstalled
494        ) {
495            eprintln!(
496                "{}: Failed to remove packages from zoi.yaml: {}",
497                "Warning".yellow().bold(),
498                e
499            );
500        }
501    }
502    ux::print_transaction_summary(&ux::TransactionSummary {
503        command: "uninstall".to_string(),
504        success: successfully_uninstalled.len(),
505        failed: 0,
506        skipped: 0
507    });
508    Ok(())
509}
510
511/// Generates a unique string identity for a package to be removed.
512fn removal_identity(manifest: &types::InstallManifest) -> String {
513    if let Some(sub) = &manifest.sub_package {
514        format!("{}@{}:{}", manifest.name, manifest.version, sub)
515    } else {
516        format!("{}@{}", manifest.name, manifest.version)
517    }
518}
519
520/// Returns a rank for the scope to prioritize removal order.
521fn scope_rank(scope: types::Scope) -> u8 {
522    match scope {
523        types::Scope::Project => 0,
524        types::Scope::User => 1,
525        types::Scope::System => 2
526    }
527}
528
529/// Filters a list of dependents to find those that are NOT part of the current
530/// removal set.
531fn collect_external_dependents(
532    removal_ids: &std::collections::HashSet<String>,
533    dependents: Vec<String>
534) -> Vec<String> {
535    let mut external = dependents
536        .into_iter()
537        .filter(|dep| !removal_ids.contains(dep))
538        .collect::<Vec<_>>();
539    external.sort();
540    external
541}
542
543/// Resolves a package source string and adds the matching installed manifest to
544/// the removal list.
545///
546/// # Errors
547///
548/// Returns an error if the package name is invalid or if the package is not
549/// installed.
550fn resolve_and_add_manifest(
551    name: &str,
552    installed_packages: &[types::InstallManifest],
553    manifests_to_uninstall: &mut Vec<types::InstallManifest>,
554    scope_override: Option<types::Scope>,
555    yes: bool
556) -> Result<(), String> {
557    let request = match pkg::resolve::parse_source_string(name) {
558        Ok(req) => req,
559        Err(e) => {
560            return Err(format!("Error: Invalid package name '{name}': {e}"));
561        }
562    };
563
564    let mut candidates: Vec<_> = installed_packages
565        .iter()
566        .filter(|m| {
567            let name_matches = m.name == request.name;
568            let sub_matches = m.sub_package == request.sub_package;
569            let scope_matches =
570                scope_override.is_none_or(|scope| m.scope == scope);
571            name_matches && sub_matches && scope_matches
572        })
573        .collect();
574
575    if let Some(repo) = &request.repo {
576        candidates.retain(|m| m.repo == *repo);
577    }
578    if let Some(handle) = &request.handle {
579        candidates.retain(|m| m.registry_handle == *handle);
580    }
581
582    match candidates.len() {
583        0 => Err(format!("Error: Package '{name}' is not installed.")),
584        1 => {
585            if let Some(first) = candidates.first()
586                && !manifests_to_uninstall.iter().any(|m| {
587                    m.name == first.name
588                        && m.sub_package == first.sub_package
589                        && m.repo == first.repo
590                        && m.registry_handle == first.registry_handle
591                })
592            {
593                manifests_to_uninstall.push((*first).clone());
594            }
595            Ok(())
596        }
597        _ => {
598            let owned_candidates =
599                candidates.into_iter().cloned().collect::<Vec<_>>();
600            let chosen =
601                crate::cmd::installed_select::choose_installed_manifest(
602                    name,
603                    &owned_candidates,
604                    yes
605                )
606                .map_err(|e| format!("Error: {e}"))?;
607
608            if !manifests_to_uninstall.iter().any(|m| {
609                m.name == chosen.name
610                    && m.sub_package == chosen.sub_package
611                    && m.repo == chosen.repo
612                    && m.registry_handle == chosen.registry_handle
613                    && m.scope == chosen.scope
614            }) {
615                manifests_to_uninstall.push(chosen);
616            }
617            Ok(())
618        }
619    }
620}
621
622/// Recursively finds dependency-reason packages that would become orphaned if
623/// the target packages were removed.
624///
625/// # Errors
626///
627/// Returns an error if package dependencies cannot be parsed.
628fn collect_recursive_uninstalls(
629    manifests_to_uninstall: &mut Vec<types::InstallManifest>,
630    installed_packages: &[types::InstallManifest]
631) -> Result<()> {
632    let mut changed = true;
633    while changed {
634        changed = false;
635        let mut new_to_add = Vec::new();
636
637        for manifest in manifests_to_uninstall.iter() {
638            for dep_str in &manifest.installed_dependencies {
639                if let Ok(dep) =
640                    pkg::dependencies::parse_dependency_string(dep_str)
641                    && dep.manager == "zoi"
642                {
643                    let Ok(dep_req) =
644                        pkg::resolve::parse_source_string(dep.package)
645                    else {
646                        continue;
647                    };
648
649                    let matching_dep_manifests = installed_packages
650                        .iter()
651                        .filter(|m| {
652                            m.name == dep_req.name
653                                && m.sub_package == dep_req.sub_package
654                                && dep_req
655                                    .repo
656                                    .as_ref()
657                                    .is_none_or(|repo| m.repo == *repo)
658                                && dep_req.handle.as_ref().is_none_or(
659                                    |handle| m.registry_handle == *handle
660                                )
661                                && dep_req
662                                    .version_spec
663                                    .as_ref()
664                                    .is_none_or(|version| m.version == *version)
665                        })
666                        .collect::<Vec<_>>();
667
668                    if let [dm] = matching_dep_manifests.as_slice() {
669                        if !matches!(
670                            dm.reason,
671                            types::InstallReason::Dependency { .. }
672                        ) {
673                            continue;
674                        }
675
676                        if manifests_to_uninstall.iter().any(|m| {
677                            m.name == dm.name && m.sub_package == dm.sub_package
678                        }) || new_to_add.iter().any(
679                            |m: &&types::InstallManifest| {
680                                m.name == dm.name
681                                    && m.sub_package == dm.sub_package
682                            }
683                        ) {
684                            continue;
685                        }
686
687                        let pkg_dir = pkg::local::get_package_dir(
688                            dm.scope,
689                            &dm.registry_handle,
690                            &dm.repo,
691                            &dm.name
692                        )?;
693                        let dependents_from_store =
694                            pkg::local::get_dependents(&pkg_dir)?;
695                        let dependents_from_db = pkg::db::get_dependents(
696                            &dm.registry_handle,
697                            &dm.name,
698                            dm.sub_package.as_deref()
699                        )?;
700
701                        let mut all_dependents = HashSet::new();
702                        for dep_id in dependents_from_store {
703                            all_dependents.insert(dep_id);
704                        }
705                        for dep_pkg in dependents_from_db {
706                            let dep_id = if let Some(sub) = &dep_pkg.sub_package
707                            {
708                                format!(
709                                    "#{}@{}/{}@{}:{}",
710                                    dep_pkg
711                                        .registry_handle
712                                        .as_deref()
713                                        .unwrap_or("local"),
714                                    dep_pkg.repo,
715                                    dep_pkg.name,
716                                    dep_pkg
717                                        .version
718                                        .as_deref()
719                                        .unwrap_or("unknown"),
720                                    sub
721                                )
722                            } else {
723                                format!(
724                                    "#{}@{}/{}@{}",
725                                    dep_pkg
726                                        .registry_handle
727                                        .as_deref()
728                                        .unwrap_or("local"),
729                                    dep_pkg.repo,
730                                    dep_pkg.name,
731                                    dep_pkg
732                                        .version
733                                        .as_deref()
734                                        .unwrap_or("unknown")
735                                )
736                            };
737                            all_dependents.insert(dep_id);
738                        }
739
740                        let all_dependents_will_be_removed =
741                            all_dependents.iter().all(|dep_id| {
742                                manifests_to_uninstall.iter().any(|m| {
743                                    let m_id_short =
744                                        if let Some(sub) = &m.sub_package {
745                                            format!(
746                                                "{}@{}:{}",
747                                                m.name, m.version, sub
748                                            )
749                                        } else {
750                                            format!("{}@{}", m.name, m.version)
751                                        };
752                                    let m_id_full =
753                                        if let Some(sub) = &m.sub_package {
754                                            format!(
755                                                "#{}@{}/{}@{}:{}",
756                                                m.registry_handle,
757                                                m.repo,
758                                                m.name,
759                                                m.version,
760                                                sub
761                                            )
762                                        } else {
763                                            format!(
764                                                "#{}@{}/{}@{}",
765                                                m.registry_handle,
766                                                m.repo,
767                                                m.name,
768                                                m.version
769                                            )
770                                        };
771                                    *dep_id == m_id_short
772                                        || *dep_id == m_id_full
773                                })
774                            });
775
776                        if all_dependents_will_be_removed {
777                            new_to_add.push(dm);
778                            changed = true;
779                        }
780                    }
781                }
782            }
783        }
784
785        for nm in new_to_add {
786            manifests_to_uninstall.push(nm.clone());
787        }
788    }
789    Ok(())
790}
791
792#[cfg(test)]
793mod tests {
794    use std::collections::HashSet;
795
796    use super::collect_external_dependents;
797
798    #[test]
799    fn dangerous_removal_ignores_dependents_in_same_removal_set() {
800        let mut removal_ids = HashSet::new();
801        removal_ids.insert("foo@1.0.0".to_string());
802
803        let external = collect_external_dependents(
804            &removal_ids,
805            vec![
806                "foo@1.0.0".to_string(),
807                "bar@2.0.0".to_string(),
808                "baz@3.0.0".to_string(),
809            ]
810        );
811
812        assert_eq!(
813            external,
814            vec!["bar@2.0.0".to_string(), "baz@3.0.0".to_string()]
815        );
816    }
817
818    #[test]
819    fn dangerous_removal_dependents_are_sorted_for_stable_output() {
820        let removal_ids = HashSet::new();
821        let external = collect_external_dependents(
822            &removal_ids,
823            vec!["c@1".to_string(), "a@1".to_string(), "b@1".to_string()]
824        );
825        assert_eq!(
826            external,
827            vec!["a@1".to_string(), "b@1".to_string(), "c@1".to_string()]
828        );
829    }
830}