1use std::{
2 collections::HashMap,
3 ffi::OsStr,
4 path::{Path, PathBuf},
5 sync::LazyLock,
6};
7
8use serde::{Deserialize, Serialize};
9use toml::Table;
10
11use crate::{
12 cli::{DeployArgs, ImportArgs, UpdateArgs},
13 context::Context,
14 utils::{
15 self, BACKUP_EXT, LogLevel, cprintln, get_string_from_value, get_string_hashmap_from_value,
16 get_vec_string_from_value, is_empty_table, normalize_home_path, resolve_path,
17 },
18};
19
20#[cfg(test)]
21mod tests;
22
23static TEMPLATE_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
24 regex::Regex::new(r"(\{\{[-]?|[-]?\}\}|\{[%][-]?|[-]?%\}|\{[#][-]?|[-]?#\})")
25 .expect("Failed to compile template regex")
26});
27
28fn is_false(b: &bool) -> bool {
29 !*b
30}
31
32fn is_true(b: &bool) -> bool {
33 *b
34}
35
36fn default_clean() -> bool {
37 true
38}
39
40#[derive(Deserialize, Serialize, Debug, Clone)]
41pub struct Package {
42 #[serde(skip)]
43 pub name: String,
44 pub src: String,
45 pub dest: String,
46 #[serde(skip_serializing_if = "Option::is_none")]
47 pub dependencies: Option<Vec<String>>,
48 #[serde(skip_serializing_if = "is_empty_table")]
49 pub variables: Table,
50 #[serde(skip_serializing_if = "Vec::is_empty")]
51 pub pre_actions: Vec<String>,
52 #[serde(skip_serializing_if = "Vec::is_empty")]
53 pub post_actions: Vec<String>,
54 #[serde(skip_serializing_if = "HashMap::is_empty")]
55 pub targets: HashMap<String, String>,
56 #[serde(skip_serializing_if = "is_false", default)]
57 pub skip: bool,
58 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
59 pub prompts: HashMap<String, String>,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
61 pub ignore: Vec<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 pub symlink: Option<bool>,
64 #[serde(skip_serializing_if = "is_false", default)]
65 pub unfold_symlink: bool,
66 #[serde(skip_serializing_if = "is_true", default = "default_clean")]
67 pub clean: bool,
68}
69
70impl Default for Package {
71 fn default() -> Self {
72 Self::new("", "", "")
73 }
74}
75
76#[derive(Eq, Hash, PartialEq, Debug, Clone, Copy)]
77pub enum BackupDeployResult {
78 Success,
79 Skipped,
80 Failed,
81}
82
83impl Package {
84 pub fn new(name: &str, src: &str, dest: &str) -> Self {
85 Self {
86 name: name.to_string(),
87 src: src.to_string(),
88 dest: dest.to_string(),
89 dependencies: None,
90 variables: Table::new(),
91 pre_actions: Vec::new(),
92 post_actions: Vec::new(),
93 targets: HashMap::new(),
94 skip: false,
95 prompts: HashMap::new(),
96 ignore: Vec::new(),
97 symlink: None,
98 unfold_symlink: false,
99 clean: true,
100 }
101 }
102
103 pub fn from_path(args: &ImportArgs, cwd: &Path) -> Result<Self, anyhow::Error> {
104 let resolved_path = resolve_path(&args.path, cwd)?;
105 if !resolved_path.exists() {
106 anyhow::bail!("Path '{}' does not exist", resolved_path.display());
107 }
108 let (package_name, pkg_ns) = get_pkg_name_and_rel_path(args, cwd)?;
109 let src_path_str = format!("dotfiles/{}", pkg_ns);
110
111 let mut dest_path_str = resolved_path
112 .to_str()
113 .ok_or_else(|| anyhow::anyhow!("Invalid path: contains non-UTF-8 characters"))?
114 .to_string();
115 dest_path_str = normalize_home_path(&dest_path_str);
116
117 if args.path.ends_with('/') && !dest_path_str.ends_with('/') && resolved_path.is_dir() {
118 dest_path_str.push('/');
119 }
120
121 let mut pkg = Self::new(&package_name, &src_path_str, &dest_path_str);
122 if args.symlink {
126 pkg.symlink = Some(true);
127 }
128 Ok(pkg)
129 }
130
131 pub fn from_table(pkg_name: &str, pkg_val: &Table) -> Result<Self, anyhow::Error> {
132 let dependencies: Option<Vec<String>> =
133 get_vec_string_from_value(pkg_val.get("dependencies")).ok();
134 let variables = match pkg_val.get("variables") {
135 Some(var_block) => var_block
136 .as_table()
137 .ok_or_else(|| anyhow::anyhow!("The 'variables' field must be a table"))?
138 .clone(),
139 None => Table::new(),
140 };
141 let pre_actions = get_vec_string_from_value(pkg_val.get("pre_actions"))?;
142 let post_actions = get_vec_string_from_value(pkg_val.get("post_actions"))?;
143 let targets = get_string_hashmap_from_value(pkg_val.get("targets"))?;
144 let src = get_string_from_value(pkg_val.get("src"), "src")?;
145 let dest = get_string_from_value(pkg_val.get("dest"), "dest")?;
146 let skip = pkg_val
147 .get("skip")
148 .and_then(|v| v.as_bool())
149 .unwrap_or(false);
150 let prompts = get_string_hashmap_from_value(pkg_val.get("prompts"))?;
151 let ignore = get_vec_string_from_value(pkg_val.get("ignore"))?;
152 let symlink = pkg_val.get("symlink").and_then(|v| v.as_bool());
153 let unfold_symlink = pkg_val
154 .get("unfold_symlink")
155 .and_then(|v| v.as_bool())
156 .unwrap_or(false);
157 let clean = pkg_val
158 .get("clean")
159 .and_then(|v| v.as_bool())
160 .unwrap_or(true);
161
162 Ok(Self {
163 name: pkg_name.to_string(),
164 src,
165 dest,
166 skip,
167 dependencies,
168 variables,
169 pre_actions,
170 post_actions,
171 targets,
172 prompts,
173 ignore,
174 symlink,
175 unfold_symlink,
176 clean,
177 })
178 }
179
180 pub fn execute_action(
181 &self,
182 action: &str,
183 variables: &Table,
184 working_dir: &Path,
185 dry_run: bool,
186 ) -> anyhow::Result<()> {
187 let compiled_action = compile_string(action, variables)?;
188 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
189 if dry_run {
190 cprintln(
191 &format!("(Dry Run) Would execute action: {}", compiled_action),
192 &LogLevel::Info,
193 );
194 return Ok(());
195 }
196 let status = std::process::Command::new(shell)
197 .arg("-c")
198 .arg(compiled_action)
199 .current_dir(working_dir)
200 .status()?;
201 if !status.success() {
202 let msg = format!(
203 "Action '{}' failed with exit code: {:?}",
204 action,
205 status.code()
206 );
207 cprintln(&msg, &LogLevel::Error);
208 return Err(anyhow::anyhow!(msg));
209 }
210 Ok(())
211 }
212
213 pub fn execute_pre_actions(&self, ctx: &Context, dry_run: bool) -> anyhow::Result<()> {
214 let vars = self.get_context_variables(ctx);
215 for action in &self.pre_actions {
216 self.execute_action(action, &vars, &ctx.working_dir, dry_run)?;
217 }
218 Ok(())
219 }
220
221 pub fn execute_post_actions(&self, ctx: &Context, dry_run: bool) -> anyhow::Result<()> {
222 let vars = self.get_context_variables(ctx);
223 for action in &self.post_actions {
224 self.execute_action(action, &vars, &ctx.working_dir, dry_run)?;
225 }
226 Ok(())
227 }
228
229 pub fn get_context_variables(&self, ctx: &Context) -> Table {
230 let mut vars = ctx.get_variables().clone();
231 vars.extend(self.variables.clone());
232 vars.extend(ctx.profile.variables.clone());
233 vars.extend(ctx.get_user_variables().clone());
234 vars
235 }
236
237 pub fn should_ignore(&self, rel_path: &Path) -> bool {
238 should_ignore(&self.ignore, rel_path)
239 }
240
241 pub fn should_unfold(&self) -> bool {
247 self.unfold_symlink || !self.ignore.is_empty()
248 }
249
250 pub fn effective_symlink(&self, ctx: &Context) -> bool {
255 self.symlink.unwrap_or(ctx.symlink)
256 }
257
258 pub fn resolve_deployment_path(&self, ctx: &Context) -> anyhow::Result<PathBuf> {
259 if !self.effective_symlink(ctx) {
260 anyhow::bail!("Package '{}' is not configured for symlinking", self.name);
261 }
262 Ok(ctx.working_dir.join(utils::SYMLINK_FOLDER).join(&self.name))
263 }
264
265 pub fn backup(&self, ctx: &Context, args: &UpdateArgs) -> anyhow::Result<BackupDeployResult> {
267 if self.package_is_templated(&ctx.working_dir) {
268 cprintln(
269 &format!("Skipping backup for templated '{}'", self.name),
270 &LogLevel::Warning,
271 );
272 return Ok(BackupDeployResult::Skipped);
273 }
274 let copy_from = self.resolve_dest(ctx)?;
275 let copy_to = ctx.working_dir.join(self.src.clone());
276 let unfolded_staging_root = if self.effective_symlink(ctx) && self.should_unfold() {
277 Some(self.resolve_deployment_path(ctx)?)
278 } else {
279 None
280 };
281 if copy_from.is_dir() {
282 let mut all_copied_paths = vec![];
283 for entry in walkdir::WalkDir::new(©_from) {
284 let entry = entry?;
285 let relative_path = entry.path().strip_prefix(©_from)?;
286 if self.should_ignore(relative_path) {
287 continue;
288 }
289 if let Some(staged_root) = &unfolded_staging_root
290 && !entry.path().is_dir()
291 && !is_managed_symlink(entry.path(), staged_root)
292 {
293 continue;
296 }
297 let dest_path = copy_to.clone().join(relative_path);
298 if entry.path().is_dir() {
299 if !args.dry_run {
300 std::fs::create_dir_all(&dest_path)?;
301 }
302 all_copied_paths.push(dest_path);
303 } else if entry.path().extension() != Some(OsStr::new(BACKUP_EXT)) {
304 if !args.dry_run {
305 if let Some(parent) = dest_path.parent() {
306 std::fs::create_dir_all(parent)?;
307 }
308 std::fs::copy(entry.path(), &dest_path)?;
309 }
310 all_copied_paths.push(dest_path);
311 }
312 }
313 if self.should_clean(args.clean) {
314 clean(©_to, &all_copied_paths, &self.ignore, args.dry_run)?;
315 }
316 } else if !args.dry_run {
317 std::fs::copy(©_from, ©_to)?;
318 }
319 Ok(BackupDeployResult::Success)
320 }
321
322 pub fn should_clean(&self, arg_val: Option<bool>) -> bool {
323 match arg_val {
324 Some(v) => v,
325 None => self.clean,
326 }
327 }
328
329 pub fn resolve_dest(&self, ctx: &Context) -> anyhow::Result<PathBuf> {
334 let dest = self
335 .targets
336 .get(ctx.profile.name.as_str())
337 .or_else(|| {
338 ctx.profile
339 .platform
340 .as_ref()
341 .and_then(|platform| self.targets.get(platform.as_str()))
342 })
343 .unwrap_or(&self.dest);
344 let dest = compile_string(dest, &self.get_context_variables(ctx))?;
345 resolve_path(&dest, &ctx.working_dir)
346 }
347
348 pub fn diff_file(
349 &self,
350 src: &PathBuf,
351 dest: &PathBuf,
352 ctx: &Context,
353 ) -> Result<(), anyhow::Error> {
354 if let Ok(src_content) = std::fs::read_to_string(src) {
355 let compiled_content = if is_templated_str(&src_content) {
356 compile_string(&src_content, &self.get_context_variables(ctx))?
357 } else {
358 src_content
359 };
360
361 let mut should_diff = false;
362 if dest.exists() {
363 let existing_content = std::fs::read_to_string(dest).unwrap_or_default();
364 if existing_content != compiled_content {
365 should_diff = true;
366 }
367 if !should_diff {
368 cprintln(
369 &format!(
370 "No changes in {}",
371 src.file_name().unwrap_or_default().to_string_lossy()
372 ),
373 &LogLevel::Info,
374 );
375 } else {
376 cprintln(
377 &format!(
378 "Changes in {} -> {}:",
379 src.file_name().unwrap_or_default().to_string_lossy(),
380 dest.display()
381 ),
382 &LogLevel::Info,
383 );
384 for diff in diff::lines(&existing_content, &compiled_content) {
385 match diff {
386 diff::Result::Left(l) => {
387 let s = format!("-{}", l);
388 print_with_color(s.as_str(), RED);
389 }
390 diff::Result::Both(l, _) => {
391 println!(" {}", l);
392 }
393 diff::Result::Right(r) => {
394 let s = format!("+{}", r);
395 print_with_color(s.as_str(), GREEN);
396 }
397 };
398 }
399 }
400 }
401 }
402 Ok(())
403 }
404
405 pub fn diff(&self, ctx: &Context) -> Result<(), anyhow::Error> {
406 let src = resolve_path(&self.src, &ctx.working_dir)?;
407 let dest = self.resolve_dest(ctx)?;
408 if src.is_dir() {
409 for entry in walkdir::WalkDir::new(&src) {
410 let entry = entry?;
411 let relative_path = entry.path().strip_prefix(&src)?;
412 if self.should_ignore(relative_path) {
413 continue;
414 }
415 let dest_path = dest.join(relative_path);
416 if entry.path().is_file() {
417 self.diff_file(&entry.path().to_path_buf(), &dest_path, ctx)?;
418 }
419 }
420 } else {
421 self.diff_file(&src, &dest, ctx)?;
422 }
423 Ok(())
424 }
425
426 pub fn deploy_file(
427 &self,
428 src: &PathBuf,
429 dest: &PathBuf,
430 ctx: &Context,
431 backup: bool,
432 dry_run: bool,
433 ) -> Result<BackupDeployResult, anyhow::Error> {
434 if let Ok(src_content) = std::fs::read_to_string(src) {
435 let compiled_content = if is_templated_str(&src_content) {
436 compile_string(&src_content, &self.get_context_variables(ctx))?
437 } else {
438 src_content
439 };
440
441 let mut should_copy = false;
442 if !dest.exists() {
443 should_copy = true;
444 } else {
445 let existing_content = std::fs::read_to_string(dest)?;
446 if existing_content != compiled_content {
447 should_copy = true;
448 }
449 }
450 if !should_copy {
451 return Ok(BackupDeployResult::Skipped);
452 }
453 if !dry_run {
454 if let Some(parent) = dest.parent() {
455 std::fs::create_dir_all(parent)?;
456 }
457 if backup && dest.exists() && !self.effective_symlink(ctx) {
458 let backup_path = create_backup_path(dest);
459 std::fs::copy(dest, &backup_path)?;
460 }
461 std::fs::write(dest, compiled_content)?;
462 }
463 } else {
464 if !dry_run {
466 if let Some(parent) = dest.parent() {
467 std::fs::create_dir_all(parent)?;
468 }
469 if backup && dest.exists() && !self.effective_symlink(ctx) {
470 let backup_path = create_backup_path(dest);
471 std::fs::copy(dest, &backup_path)?;
472 }
473 std::fs::copy(src, dest)?;
474 }
475 }
476 Ok(BackupDeployResult::Success)
477 }
478
479 pub fn deploy(
481 &self,
482 ctx: &Context,
483 args: &DeployArgs,
484 ) -> Result<BackupDeployResult, anyhow::Error> {
485 let copy_from = resolve_path(&self.src, &ctx.working_dir)?;
486 let copy_to = if self.effective_symlink(ctx) {
487 self.resolve_deployment_path(ctx)?
488 } else {
489 self.resolve_dest(ctx)?
490 };
491 if !args.skip_pre_actions && !args.skip_actions {
492 self.execute_pre_actions(ctx, args.dry_run)?;
493 }
494 let mut result = BackupDeployResult::Skipped;
495 if copy_from.is_dir() {
496 let mut all_deployed_paths = vec![];
497 for entry in walkdir::WalkDir::new(©_from) {
498 let entry = entry?;
499 let relative_path = entry.path().strip_prefix(©_from)?;
500 if self.should_ignore(relative_path) {
501 continue;
502 }
503 let dest_path = copy_to.join(relative_path);
504 if entry.path().is_dir() && !args.dry_run {
505 std::fs::create_dir_all(&dest_path)?;
506 } else {
507 let dep_result = self.deploy_file(
508 &entry.path().to_path_buf(),
509 &dest_path,
510 ctx,
511 true,
512 args.dry_run,
513 )?;
514 if let BackupDeployResult::Success = dep_result {
515 result = BackupDeployResult::Success;
516 println!("=> Deployed {}", dest_path.display());
517 }
518 }
519 all_deployed_paths.push(dest_path);
520 }
521 if self.should_clean(args.clean) {
522 clean(©_to, &all_deployed_paths, &self.ignore, args.dry_run)?;
523 }
524 } else {
525 if self.effective_symlink(ctx)
526 && !args.dry_run
527 && let Some(parent) = copy_to.parent()
528 {
529 std::fs::create_dir_all(parent)?;
530 }
531 let dep_result = self.deploy_file(©_from, ©_to, ctx, true, args.dry_run)?;
532 if let BackupDeployResult::Success = dep_result {
533 result = BackupDeployResult::Success;
534 println!("=> Deployed {}", copy_to.display());
535 }
536 }
537 if self.effective_symlink(ctx) {
538 let symlink_to = self.resolve_dest(ctx)?;
539 if copy_from.is_dir() && self.should_unfold() {
540 if !args.dry_run {
541 self.deploy_unfolded_symlinks(
542 ©_to,
543 &symlink_to,
544 self.should_clean(args.clean),
545 )?;
546 }
547 } else if !args.dry_run {
548 if symlink_to.exists() {
549 if symlink_to.is_symlink() {
550 std::fs::remove_file(&symlink_to)?;
551 } else if symlink_to.is_dir() {
552 if symlink_to.read_dir()?.next().is_none() {
553 std::fs::remove_dir(&symlink_to)?;
554 } else {
555 std::fs::remove_dir_all(&symlink_to)?;
556 }
557 } else {
558 std::fs::remove_file(&symlink_to)?;
559 }
560 }
561 if let Some(parent) = symlink_to.parent() {
562 std::fs::create_dir_all(parent)?;
563 }
564 let symlink_str = symlink_to
565 .to_str()
566 .ok_or_else(|| anyhow::anyhow!("Symlink path contains non-UTF-8 characters"))?
567 .trim_end_matches('/');
568 std::os::unix::fs::symlink(©_to, symlink_str)?;
569 }
570 }
571 cprintln(
572 &format!("Package '{}' deployed", self.name),
573 &LogLevel::Info,
574 );
575 if !args.skip_post_actions && !args.skip_actions {
576 self.execute_post_actions(ctx, args.dry_run)?;
577 }
578 Ok(result)
579 }
580
581 fn deploy_unfolded_symlinks(
589 &self,
590 staged_root: &Path,
591 dest_root: &Path,
592 should_clean: bool,
593 ) -> anyhow::Result<()> {
594 if dest_root.is_symlink() || (dest_root.exists() && !dest_root.is_dir()) {
595 std::fs::remove_file(dest_root)?;
596 }
597 std::fs::create_dir_all(dest_root)?;
598
599 let mut linked = vec![];
600 for entry in walkdir::WalkDir::new(staged_root) {
601 let entry = entry?;
602 let path = entry.path();
603 if path == staged_root {
604 continue;
605 }
606 let relative_path = path.strip_prefix(staged_root)?;
607 let target_path = dest_root.join(relative_path);
608 if path.is_dir() {
609 std::fs::create_dir_all(&target_path)?;
610 continue;
611 }
612 if target_path.is_symlink() {
613 let current_target = std::fs::read_link(&target_path)?;
614 if current_target == path {
615 linked.push(target_path);
616 continue;
617 }
618 std::fs::remove_file(&target_path)?;
619 } else if target_path.is_dir() {
620 std::fs::remove_dir_all(&target_path)?;
626 } else if target_path.exists() {
627 std::fs::remove_file(&target_path)?;
628 }
629 if let Some(parent) = target_path.parent() {
630 std::fs::create_dir_all(parent)?;
631 }
632 std::os::unix::fs::symlink(path, &target_path)?;
633 linked.push(target_path);
634 }
635
636 if should_clean {
637 clean_unfolded(dest_root, staged_root, &linked, &self.ignore)?;
638 }
639 Ok(())
640 }
641
642 pub fn package_is_templated(&self, cwd: &Path) -> bool {
643 let src_path = cwd.join(&self.src);
644 if !src_path.exists() {
645 return false;
646 }
647 if src_path.is_dir() {
648 for entry in walkdir::WalkDir::new(&src_path).into_iter().flatten() {
649 if entry.path().is_file() {
650 return is_templated(&entry.path().to_path_buf());
651 }
652 }
653 }
654 if src_path.is_file() {
655 return is_templated(&src_path);
656 }
657 false
658 }
659}
660
661pub fn get_pkg_name_and_rel_path(
673 args: &ImportArgs,
674 cwd: &Path,
675) -> Result<(String, String), anyhow::Error> {
676 let path = resolve_path(&args.path, cwd)?;
677 let prefix = if path.is_dir() { "d_" } else { "f_" };
678 let extension = path.extension().and_then(|ext| ext.to_str());
679
680 let name = match args.name.as_ref() {
681 Some(n) => n.clone(),
682 None => {
683 let file_name = path
684 .file_name()
685 .and_then(|s| s.to_str())
686 .ok_or_else(|| anyhow::anyhow!("Failed to get file name"))?;
687 file_name.trim_start_matches('.').to_string()
688 }
689 };
690
691 let sanitized_name = name.replace(['-', '.'], "_");
692 let package_name = format!("{}{}", prefix, sanitized_name);
693
694 let pkg_ns = if let Some(ext) = extension {
695 let base_name = name.trim_end_matches(&format!(".{}", ext));
696 let sanitized_base = base_name.replace(['-', '.'], "_");
697 format!("{}{}.{}", prefix, sanitized_base, ext)
698 } else {
699 package_name.clone()
700 };
701
702 Ok((package_name, pkg_ns))
703}
704
705fn create_backup_path(path: &Path) -> PathBuf {
706 let mut backup_path = path.as_os_str().to_os_string();
707 backup_path.push(".");
708 backup_path.push(BACKUP_EXT);
709 PathBuf::from(backup_path)
710}
711
712pub fn compile_string(template_str: &str, context: &Table) -> anyhow::Result<String> {
713 let ctx = tera::Context::from_serialize(context)?;
714 Ok(tera::Tera::one_off(template_str, &ctx, false)?)
715}
716
717pub fn is_templated(p: &PathBuf) -> bool {
718 if !p.exists() {
719 return false;
720 }
721 let content = std::fs::read_to_string(p);
722 if let Ok(text) = content {
723 is_templated_str(&text)
724 } else {
725 false
726 }
727}
728
729pub fn is_templated_str(s: &str) -> bool {
730 TEMPLATE_REGEX.is_match(s)
731}
732
733const RED: &str = "31";
734const GREEN: &str = "32";
735
736pub fn print_with_color(s: &str, color_code: &str) {
737 println!("\x1b[{}m{}\x1b[0m", color_code, s);
738}
739
740pub fn clean(
741 operation_path: &PathBuf,
742 keep: &[PathBuf],
743 ignore: &[String],
744 dry_run: bool,
745) -> anyhow::Result<()> {
746 if !operation_path.exists() {
747 return Ok(());
748 }
749
750 let mut dirs = vec![];
751 for entry in walkdir::WalkDir::new(operation_path) {
752 let entry = entry?;
753 let path = entry.path().to_path_buf();
754 if path == *operation_path {
755 continue;
756 }
757 let rel_path = path.strip_prefix(operation_path)?;
758 if should_ignore(ignore, rel_path) {
759 continue;
760 }
761 if !keep.contains(&path) {
762 if path.is_file() {
763 if path.extension() == Some(OsStr::new(BACKUP_EXT)) {
764 continue;
765 }
766 if dry_run {
767 cprintln(
768 &format!("(Dry Run) Would remove file: {}", path.display()),
769 &LogLevel::Info,
770 );
771 } else {
772 std::fs::remove_file(&path)?;
773 }
774 } else if path.is_dir() {
775 dirs.push(path);
776 }
777 }
778 }
779 dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
780 for dir in dirs {
781 if !dir.exists() {
782 continue;
783 }
784 if dry_run {
785 cprintln(
786 &format!("(Dry Run) Would remove directory: {}", dir.display()),
787 &LogLevel::Info,
788 );
789 continue;
790 }
791 if dir.read_dir()?.next().is_none() {
792 std::fs::remove_dir(&dir)?;
793 } else {
794 std::fs::remove_dir_all(&dir)?;
795 }
796 }
797 Ok(())
798}
799
800pub fn clean_unfolded(
809 dest_root: &Path,
810 staged_root: &Path,
811 keep: &[PathBuf],
812 ignore: &[String],
813) -> anyhow::Result<()> {
814 if !dest_root.exists() {
815 return Ok(());
816 }
817
818 let mut dirs = vec![];
819 for entry in walkdir::WalkDir::new(dest_root) {
820 let entry = entry?;
821 let path = entry.path().to_path_buf();
822 if path == *dest_root {
823 continue;
824 }
825 let rel_path = path.strip_prefix(dest_root)?;
826 if should_ignore(ignore, rel_path) {
827 continue;
828 }
829 if path.is_symlink() {
830 if keep.contains(&path) {
831 continue;
832 }
833 if is_managed_symlink(&path, staged_root) {
834 std::fs::remove_file(&path)?;
835 }
836 } else if path.is_dir() {
837 dirs.push(path);
838 }
839 }
840 dirs.sort_by_key(|d| std::cmp::Reverse(d.components().count()));
841 for dir in dirs {
842 if !dir.exists() {
843 continue;
844 }
845 if dir.read_dir()?.next().is_none() {
846 std::fs::remove_dir(&dir)?;
847 }
848 }
849 Ok(())
850}
851
852pub fn is_managed_symlink(path: &Path, staged_root: &Path) -> bool {
855 path.is_symlink()
856 && std::fs::read_link(path)
857 .map(|target| target.starts_with(staged_root))
858 .unwrap_or(false)
859}
860
861pub fn should_ignore(ignore: &[String], rel_path: &Path) -> bool {
862 let rel_path_str = rel_path.to_string_lossy();
863 for pattern in ignore {
864 if glob_match::glob_match(pattern, &rel_path_str) {
865 return true;
866 }
867 }
868 false
869}