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