Skip to main content

dotr_dear/package/
mod.rs

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        // Only force it on explicitly; leaving it unset (rather than `Some(false)`)
123        // lets a newly imported package still pick up a global `symlink = true`
124        // by default, the same as any other package would.
125        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    /// Whether a directory package should be deployed as individual per-file
242    /// symlinks (leaving the destination a real directory) rather than a
243    /// single symlink for the whole directory. Non-empty `ignore` implies
244    /// this, since an ignored path has nowhere real to live under a single
245    /// whole-directory symlink.
246    pub fn should_unfold(&self) -> bool {
247        self.unfold_symlink || !self.ignore.is_empty()
248    }
249
250    /// Whether this package should be deployed as a symlink. An explicit
251    /// per-package `symlink` setting always wins - `Some(true)` forces it on
252    /// and `Some(false)` opts out - regardless of the global config flag;
253    /// only when the package leaves it unset does the global flag apply.
254    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    /// Backup the package by copying files from dest to a backup location, recursively.
266    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(&copy_from) {
284                let entry = entry?;
285                let relative_path = entry.path().strip_prefix(&copy_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                    // Foreign, untracked content living alongside unfolded
294                    // symlinks - never pull it into the tracked repo.
295                    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(&copy_to, &all_copied_paths, &self.ignore, args.dry_run)?;
315            }
316        } else if !args.dry_run {
317            std::fs::copy(&copy_from, &copy_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    /// Resolves this package's effective deployment path for the active
330    /// profile: an entry in `targets` keyed by the profile's own name wins
331    /// (most specific), then one keyed by the profile's `platform` (shared
332    /// across every profile with that platform), then the package's `dest`.
333    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            // Binary file: copy as-is
465            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    /// Deploy the package by copying files from src to dest.
480    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(&copy_from) {
498                let entry = entry?;
499                let relative_path = entry.path().strip_prefix(&copy_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(&copy_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(&copy_from, &copy_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                        &copy_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(&copy_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    /// Deploy a directory package as individual per-file symlinks rather than
582    /// one symlink for the whole directory, leaving `dest_root` a real
583    /// directory so untracked, unmanaged content can coexist in it.
584    ///
585    /// Never overwrites a path that already exists at `dest_root` unless it
586    /// is itself a symlink we created (i.e. resolves into `staged_root`) -
587    /// anything else is left completely alone.
588    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                // Pre-existing real content at a path we manage (e.g. the
621                // first deploy after enabling symlinking) - replace it, the
622                // same way whole-directory symlinking replaces existing
623                // content. This never touches paths outside `staged_root`,
624                // which is what keeps genuinely foreign files untouched.
625                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
661/// Get a package name and relative path from the given import arguments and current working directory.
662/// The package name is derived from the last component of the path,
663/// with any leading '.' removed
664/// Additionally, any '-' or '.' characters are replaced with '_'. But extension is preserved.
665/// If the path is a directory, it should be prepended with d_
666/// Or, if it's a file, with f_
667/// Example:
668/// - For a directory path "/home/user/.config/nvim", the package name would be "d_nvim", rel_path "d_nvim"
669/// - For a file path "/home/user/.bashrc", the package name would be "f_bashrc", rel_path "f_bashrc"
670/// - For a file path "~/.config/starship/init.lua", the package name would be "f_init_lua", rel_path "f_init.lua"
671/// - For a file path "~/.config/starship/init.lua" with custom arg.name as "starship", the package name would be "f_starship_lua", rel_path "f_starship.lua"
672pub 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
800/// Remove stale per-file symlinks left over from a previous unfolded deploy.
801///
802/// Unlike `clean`, this only ever touches entries at `dest_root` that are
803/// themselves symlinks resolving into `staged_root` (i.e. ones this package
804/// created). Real files/directories - anyone else's content living
805/// alongside the managed symlinks - are always left untouched, and a
806/// directory is only removed once it's completely empty (never
807/// `remove_dir_all`, since it may still hold foreign content).
808pub 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
852/// Whether `path` is a symlink this package created - i.e. one that
853/// resolves into `staged_root` - as opposed to unrelated/foreign content.
854pub 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}