1use std::collections::HashSet;
4use std::fs;
5use std::path::Path;
6
7pub 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
19pub 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
519fn 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
528fn 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
537fn 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
551fn 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
630fn 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}