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 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
511fn 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
520fn 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
529fn 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
543fn 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
622fn 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}