1use crate::install::link_map::{LinkMap, LinkMapError};
9use crate::install::overlay::{self, LinkMapOverlay, OverlaySummary};
10use crate::install::plan::{InstallPlan, PlanAction, PlanError};
11use crate::live_surface;
12use serde::Serialize;
13use std::collections::BTreeSet;
14use std::fs;
15use std::path::{Component, Path, PathBuf};
16use thiserror::Error;
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "kebab-case")]
20pub enum Mode {
21 DryRun,
22 Apply,
23}
24
25impl Mode {
26 pub fn label(self) -> &'static str {
27 match self {
28 Mode::DryRun => "dry-run",
29 Mode::Apply => "apply",
30 }
31 }
32}
33
34#[derive(Debug, Clone)]
35pub struct PruneOptions {
36 pub overlay_enabled: bool,
37 pub overlay_path: Option<PathBuf>,
38}
39
40impl Default for PruneOptions {
41 fn default() -> Self {
42 Self {
43 overlay_enabled: true,
44 overlay_path: None,
45 }
46 }
47}
48
49#[derive(Debug, Error)]
50pub enum PruneError {
51 #[error("link-map: {0}")]
52 LinkMap(#[from] LinkMapError),
53 #[error("plan: {0}")]
54 Plan(#[from] PlanError),
55 #[error("io error at `{path}`: {source}")]
56 Io {
57 path: PathBuf,
58 #[source]
59 source: std::io::Error,
60 },
61}
62
63#[derive(Debug, Serialize)]
64pub struct PruneOutcome {
65 pub product: String,
66 pub source_root: PathBuf,
67 pub live_home: PathBuf,
68 pub mode: Mode,
69 pub changes: Vec<PruneChange>,
70 #[serde(skip_serializing_if = "Option::is_none")]
71 pub overlay: Option<OverlaySummary>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
75#[serde(tag = "kind", rename_all = "kebab-case")]
76pub enum PruneChange {
77 WouldRemoveSymlink {
78 rel_path: PathBuf,
79 path: PathBuf,
80 target: PathBuf,
81 },
82 RemovedSymlink {
83 rel_path: PathBuf,
84 path: PathBuf,
85 target: PathBuf,
86 },
87 NoOpSymlink {
88 rel_path: PathBuf,
89 path: PathBuf,
90 target: PathBuf,
91 },
92 WouldRemoveEmptyDirectory {
93 rel_path: PathBuf,
94 path: PathBuf,
95 },
96 RemovedEmptyDirectory {
97 rel_path: PathBuf,
98 path: PathBuf,
99 },
100 NoOpEmptyDirectory {
101 rel_path: PathBuf,
102 path: PathBuf,
103 },
104 SkippedForeignSymlink {
105 rel_path: PathBuf,
106 path: PathBuf,
107 target: PathBuf,
108 },
109 SkippedRegularFile {
110 rel_path: PathBuf,
111 path: PathBuf,
112 },
113 SkippedNonEmptyDirectory {
114 rel_path: PathBuf,
115 path: PathBuf,
116 },
117}
118
119impl PruneChange {
120 pub fn is_change(&self) -> bool {
121 matches!(
122 self,
123 PruneChange::WouldRemoveSymlink { .. }
124 | PruneChange::RemovedSymlink { .. }
125 | PruneChange::WouldRemoveEmptyDirectory { .. }
126 | PruneChange::RemovedEmptyDirectory { .. }
127 )
128 }
129
130 pub fn is_skip(&self) -> bool {
131 matches!(
132 self,
133 PruneChange::SkippedForeignSymlink { .. }
134 | PruneChange::SkippedRegularFile { .. }
135 | PruneChange::SkippedNonEmptyDirectory { .. }
136 )
137 }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141enum Candidate {
142 Symlink {
143 rel_path: PathBuf,
144 path: PathBuf,
145 target: PathBuf,
146 },
147 EmptyDirectory {
148 rel_path: PathBuf,
149 path: PathBuf,
150 },
151 SkippedForeignSymlink {
152 rel_path: PathBuf,
153 path: PathBuf,
154 target: PathBuf,
155 },
156 SkippedRegularFile {
157 rel_path: PathBuf,
158 path: PathBuf,
159 },
160 SkippedNonEmptyDirectory {
161 rel_path: PathBuf,
162 path: PathBuf,
163 },
164}
165
166pub fn run(
167 product: &str,
168 source_root: &Path,
169 live_home: &Path,
170 mode: Mode,
171 options: &PruneOptions,
172) -> Result<PruneOutcome, PruneError> {
173 let mut link_map = LinkMap::load(source_root, product)?;
174 let overlay = merge_overlay(&mut link_map, source_root, options)?;
175 let plan = InstallPlan::build(product, source_root, live_home, Path::new(""), &link_map)?;
176 let expected = expected_paths_from_plan(live_home, &plan);
177 let scan_roots = live_surface::scan_roots(&link_map);
178 let candidates = collect_candidates(live_home, source_root, &expected, &scan_roots)?;
179 let changes = match mode {
180 Mode::DryRun => candidates.into_iter().map(dry_run_change).collect(),
181 Mode::Apply => apply_candidates(candidates)?,
182 };
183
184 Ok(PruneOutcome {
185 product: product.to_string(),
186 source_root: source_root.to_path_buf(),
187 live_home: live_home.to_path_buf(),
188 mode,
189 changes,
190 overlay,
191 })
192}
193
194fn merge_overlay(
195 link_map: &mut LinkMap,
196 source_root: &Path,
197 options: &PruneOptions,
198) -> Result<Option<OverlaySummary>, PruneError> {
199 if !options.overlay_enabled {
200 return Ok(None);
201 }
202 let overlay_opt = match options.overlay_path.as_deref() {
203 Some(path) => LinkMapOverlay::load_from(path)?,
204 None => LinkMapOverlay::load_optional(source_root)?,
205 };
206 match overlay_opt {
207 Some(overlay) => {
208 let summary = overlay::apply(link_map, &overlay)?;
209 Ok(Some(summary))
210 }
211 None => Ok(None),
212 }
213}
214
215fn expected_paths_from_plan(live_home: &Path, plan: &InstallPlan) -> BTreeSet<PathBuf> {
216 let mut out = BTreeSet::new();
217 for action in &plan.actions {
218 let PlanAction::Symlink { dest, .. } = action else {
219 continue;
220 };
221 if let Ok(rel) = dest.strip_prefix(live_home) {
222 out.insert(rel.to_path_buf());
223 }
224 }
225 out
226}
227
228fn collect_candidates(
229 live_home: &Path,
230 source_root: &Path,
231 expected: &BTreeSet<PathBuf>,
232 scan_roots: &BTreeSet<PathBuf>,
233) -> Result<Vec<Candidate>, PruneError> {
234 let mut candidates = Vec::new();
235 for rel_root in scan_roots {
236 collect_dir(
237 live_home,
238 source_root,
239 expected,
240 rel_root,
241 rel_root,
242 true,
243 &mut candidates,
244 )?;
245 }
246 candidates.sort_by(candidate_order);
247 candidates.dedup();
248 Ok(candidates)
249}
250
251fn collect_dir(
252 live_home: &Path,
253 source_root: &Path,
254 expected: &BTreeSet<PathBuf>,
255 scan_root: &Path,
256 rel_dir: &Path,
257 is_scan_root: bool,
258 candidates: &mut Vec<Candidate>,
259) -> Result<bool, PruneError> {
260 let dir = live_home.join(rel_dir);
261 let Ok(meta) = fs::symlink_metadata(&dir) else {
262 return Ok(false);
263 };
264 if !meta.is_dir() || meta.file_type().is_symlink() {
265 return Ok(false);
266 }
267
268 let mut entries = Vec::new();
269 for entry in fs::read_dir(&dir).map_err(|source| PruneError::Io {
270 path: dir.clone(),
271 source,
272 })? {
273 let entry = entry.map_err(|source| PruneError::Io {
274 path: dir.clone(),
275 source,
276 })?;
277 entries.push(entry.file_name());
278 }
279 entries.sort();
280
281 let mut all_children_removable = true;
282 let mut saw_removable_child = false;
283 for name in entries {
284 let child_rel = rel_dir.join(&name);
285 let child_abs = live_home.join(&child_rel);
286 if expected.contains(&child_rel) || live_surface::ignored_live_file(&child_rel) {
287 all_children_removable = false;
288 continue;
289 }
290 let Ok(child_meta) = fs::symlink_metadata(&child_abs) else {
291 continue;
292 };
293 if child_meta.file_type().is_symlink() {
294 let target = fs::read_link(&child_abs).map_err(|source| PruneError::Io {
295 path: child_abs.clone(),
296 source,
297 })?;
298 if symlink_target_is_owned(source_root, &target) {
299 candidates.push(Candidate::Symlink {
300 rel_path: child_rel,
301 path: child_abs,
302 target,
303 });
304 saw_removable_child = true;
305 } else {
306 candidates.push(Candidate::SkippedForeignSymlink {
307 rel_path: child_rel,
308 path: child_abs,
309 target,
310 });
311 all_children_removable = false;
312 }
313 } else if child_meta.is_file() {
314 candidates.push(Candidate::SkippedRegularFile {
315 rel_path: child_rel,
316 path: child_abs,
317 });
318 all_children_removable = false;
319 } else if child_meta.is_dir() {
320 let child_removable = collect_dir(
321 live_home,
322 source_root,
323 expected,
324 scan_root,
325 &child_rel,
326 false,
327 candidates,
328 )?;
329 if !child_removable {
330 all_children_removable = false;
331 } else {
332 saw_removable_child = true;
333 }
334 } else {
335 all_children_removable = false;
336 }
337 }
338
339 if is_scan_root {
340 return Ok(false);
341 }
342
343 if all_children_removable && saw_removable_child {
344 candidates.push(Candidate::EmptyDirectory {
345 rel_path: rel_dir.to_path_buf(),
346 path: dir,
347 });
348 return Ok(true);
349 }
350
351 if !has_expected_descendant(expected, rel_dir) && rel_dir.starts_with(scan_root) {
352 candidates.push(Candidate::SkippedNonEmptyDirectory {
353 rel_path: rel_dir.to_path_buf(),
354 path: dir,
355 });
356 }
357 Ok(false)
358}
359
360fn has_expected_descendant(expected: &BTreeSet<PathBuf>, rel_dir: &Path) -> bool {
361 expected.iter().any(|path| path.starts_with(rel_dir))
362}
363
364fn dry_run_change(candidate: Candidate) -> PruneChange {
365 match candidate {
366 Candidate::Symlink {
367 rel_path,
368 path,
369 target,
370 } => PruneChange::WouldRemoveSymlink {
371 rel_path,
372 path,
373 target,
374 },
375 Candidate::EmptyDirectory { rel_path, path } => {
376 PruneChange::WouldRemoveEmptyDirectory { rel_path, path }
377 }
378 Candidate::SkippedForeignSymlink {
379 rel_path,
380 path,
381 target,
382 } => PruneChange::SkippedForeignSymlink {
383 rel_path,
384 path,
385 target,
386 },
387 Candidate::SkippedRegularFile { rel_path, path } => {
388 PruneChange::SkippedRegularFile { rel_path, path }
389 }
390 Candidate::SkippedNonEmptyDirectory { rel_path, path } => {
391 PruneChange::SkippedNonEmptyDirectory { rel_path, path }
392 }
393 }
394}
395
396fn apply_candidates(candidates: Vec<Candidate>) -> Result<Vec<PruneChange>, PruneError> {
397 let mut symlinks = Vec::new();
398 let mut dirs = Vec::new();
399 let mut skips = Vec::new();
400 for candidate in candidates {
401 match candidate {
402 Candidate::Symlink { .. } => symlinks.push(candidate),
403 Candidate::EmptyDirectory { .. } => dirs.push(candidate),
404 Candidate::SkippedForeignSymlink { .. }
405 | Candidate::SkippedRegularFile { .. }
406 | Candidate::SkippedNonEmptyDirectory { .. } => skips.push(dry_run_change(candidate)),
407 }
408 }
409 dirs.sort_by(|a, b| {
410 candidate_depth(b)
411 .cmp(&candidate_depth(a))
412 .then_with(|| candidate_order(a, b))
413 });
414
415 let mut changes = Vec::new();
416 for candidate in symlinks {
417 let Candidate::Symlink {
418 rel_path,
419 path,
420 target,
421 } = candidate
422 else {
423 unreachable!();
424 };
425 match fs::remove_file(&path) {
426 Ok(()) => changes.push(PruneChange::RemovedSymlink {
427 rel_path,
428 path,
429 target,
430 }),
431 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
432 changes.push(PruneChange::NoOpSymlink {
433 rel_path,
434 path,
435 target,
436 });
437 }
438 Err(source) => return Err(PruneError::Io { path, source }),
439 }
440 }
441 for candidate in dirs {
442 let Candidate::EmptyDirectory { rel_path, path } = candidate else {
443 unreachable!();
444 };
445 match fs::remove_dir(&path) {
446 Ok(()) => changes.push(PruneChange::RemovedEmptyDirectory { rel_path, path }),
447 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
448 changes.push(PruneChange::NoOpEmptyDirectory { rel_path, path });
449 }
450 Err(err) if err.kind() == std::io::ErrorKind::DirectoryNotEmpty => {
451 changes.push(PruneChange::SkippedNonEmptyDirectory { rel_path, path });
452 }
453 Err(source) => return Err(PruneError::Io { path, source }),
454 }
455 }
456 changes.extend(skips);
457 changes.sort_by(change_order);
458 Ok(changes)
459}
460
461fn candidate_depth(candidate: &Candidate) -> usize {
462 candidate_rel_path(candidate).components().count()
463}
464
465fn candidate_order(a: &Candidate, b: &Candidate) -> std::cmp::Ordering {
466 candidate_rel_path(a)
467 .cmp(candidate_rel_path(b))
468 .then_with(|| candidate_rank(a).cmp(&candidate_rank(b)))
469}
470
471fn candidate_rank(candidate: &Candidate) -> u8 {
472 match candidate {
473 Candidate::Symlink { .. } => 0,
474 Candidate::EmptyDirectory { .. } => 1,
475 Candidate::SkippedForeignSymlink { .. } => 2,
476 Candidate::SkippedRegularFile { .. } => 3,
477 Candidate::SkippedNonEmptyDirectory { .. } => 4,
478 }
479}
480
481fn candidate_rel_path(candidate: &Candidate) -> &Path {
482 match candidate {
483 Candidate::Symlink { rel_path, .. }
484 | Candidate::EmptyDirectory { rel_path, .. }
485 | Candidate::SkippedForeignSymlink { rel_path, .. }
486 | Candidate::SkippedRegularFile { rel_path, .. }
487 | Candidate::SkippedNonEmptyDirectory { rel_path, .. } => rel_path,
488 }
489}
490
491fn change_order(a: &PruneChange, b: &PruneChange) -> std::cmp::Ordering {
492 change_rel_path(a)
493 .cmp(change_rel_path(b))
494 .then_with(|| change_rank(a).cmp(&change_rank(b)))
495}
496
497fn change_rank(change: &PruneChange) -> u8 {
498 match change {
499 PruneChange::WouldRemoveSymlink { .. } | PruneChange::RemovedSymlink { .. } => 0,
500 PruneChange::NoOpSymlink { .. } => 1,
501 PruneChange::WouldRemoveEmptyDirectory { .. }
502 | PruneChange::RemovedEmptyDirectory { .. } => 2,
503 PruneChange::NoOpEmptyDirectory { .. } => 3,
504 PruneChange::SkippedForeignSymlink { .. } => 4,
505 PruneChange::SkippedRegularFile { .. } => 5,
506 PruneChange::SkippedNonEmptyDirectory { .. } => 6,
507 }
508}
509
510fn change_rel_path(change: &PruneChange) -> &Path {
511 match change {
512 PruneChange::WouldRemoveSymlink { rel_path, .. }
513 | PruneChange::RemovedSymlink { rel_path, .. }
514 | PruneChange::NoOpSymlink { rel_path, .. }
515 | PruneChange::WouldRemoveEmptyDirectory { rel_path, .. }
516 | PruneChange::RemovedEmptyDirectory { rel_path, .. }
517 | PruneChange::NoOpEmptyDirectory { rel_path, .. }
518 | PruneChange::SkippedForeignSymlink { rel_path, .. }
519 | PruneChange::SkippedRegularFile { rel_path, .. }
520 | PruneChange::SkippedNonEmptyDirectory { rel_path, .. } => rel_path,
521 }
522}
523
524fn symlink_target_is_owned(source_root: &Path, target: &Path) -> bool {
525 if !target.is_absolute() {
526 return false;
527 }
528 let source_root = normalize_absolute_path(source_root);
529 let target = normalize_absolute_path(target);
530 target.starts_with(source_root)
531}
532
533fn normalize_absolute_path(path: &Path) -> PathBuf {
534 let mut out = PathBuf::new();
535 for component in path.components() {
536 match component {
537 Component::Prefix(prefix) => out.push(prefix.as_os_str()),
538 Component::RootDir => out.push(Path::new("/")),
539 Component::CurDir => {}
540 Component::ParentDir => {
541 out.pop();
542 }
543 Component::Normal(part) => out.push(part),
544 }
545 }
546 out
547}