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