Skip to main content

dotm/
scanner.rs

1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5/// What kind of entry a file action represents, determining how it gets deployed.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub enum EntryKind {
8    /// Plain base file — deployed as a symlink
9    Base,
10    /// Host or role override — deployed as a copy
11    Override,
12    /// Tera template — rendered and written as a file
13    Template,
14}
15
16/// Describes what to do with a single file during deployment.
17#[derive(Debug)]
18pub struct FileAction {
19    /// The source file in the dotfiles repo
20    pub source: PathBuf,
21    /// The relative path where this file should be deployed (relative to target dir)
22    pub target_rel_path: PathBuf,
23    /// What kind of entry this is (base, override, or template)
24    pub kind: EntryKind,
25}
26
27/// Scan a package directory and resolve overrides for the given host and roles.
28///
29/// Returns a list of FileActions describing what to deploy.
30pub fn scan_package(pkg_dir: &Path, hostname: &str, roles: &[&str]) -> Result<Vec<FileAction>> {
31    let mut files: HashMap<PathBuf, Vec<PathBuf>> = HashMap::new();
32
33    collect_files(pkg_dir, pkg_dir, &mut files)
34        .with_context(|| format!("failed to scan package directory: {}", pkg_dir.display()))?;
35
36    let mut actions = Vec::new();
37
38    for (target_path, variants) in &files {
39        let action = resolve_variant(target_path, variants, hostname, roles);
40        actions.push(action);
41    }
42
43    actions.sort_by(|a, b| a.target_rel_path.cmp(&b.target_rel_path));
44    Ok(actions)
45}
46
47/// Recursively collect files, grouping override variants by their canonical path.
48fn collect_files(
49    base: &Path,
50    dir: &Path,
51    files: &mut HashMap<PathBuf, Vec<PathBuf>>,
52) -> Result<()> {
53    for entry in
54        std::fs::read_dir(dir).with_context(|| format!("failed to read directory: {}", dir.display()))?
55    {
56        let entry = entry?;
57        let path = entry.path();
58
59        if path.is_dir() {
60            collect_files(base, &path, files)?;
61        } else {
62            let rel_path = path.strip_prefix(base).unwrap().to_path_buf();
63            let canonical = canonical_target_path(&rel_path);
64            files.entry(canonical).or_default().push(path);
65        }
66    }
67    Ok(())
68}
69
70/// Strip `##` suffix and `.tera` extension to get the canonical target path.
71fn canonical_target_path(rel_path: &Path) -> PathBuf {
72    let file_name = rel_path.file_name().unwrap().to_str().unwrap();
73
74    // Strip ## suffix first
75    let base_name = if let Some(idx) = file_name.find("##") {
76        &file_name[..idx]
77    } else {
78        file_name
79    };
80
81    // Strip .tera extension
82    let base_name = base_name.strip_suffix(".tera").unwrap_or(base_name);
83
84    if let Some(parent) = rel_path.parent() {
85        if parent == Path::new("") {
86            PathBuf::from(base_name)
87        } else {
88            parent.join(base_name)
89        }
90    } else {
91        PathBuf::from(base_name)
92    }
93}
94
95/// Given all variants of a file, pick the best one for this host/roles.
96fn resolve_variant(
97    target_path: &Path,
98    variants: &[PathBuf],
99    hostname: &str,
100    roles: &[&str],
101) -> FileAction {
102    let host_suffix = format!("##host.{hostname}");
103
104    // Priority 1: host override
105    if let Some(source) = variants
106        .iter()
107        .find(|v| v.file_name().unwrap().to_str().unwrap().contains(&host_suffix))
108    {
109        return FileAction {
110            source: source.clone(),
111            target_rel_path: target_path.to_path_buf(),
112            kind: EntryKind::Override,
113        };
114    }
115
116    // Priority 2: role override (last matching role wins)
117    for role in roles.iter().rev() {
118        let role_suffix = format!("##role.{role}");
119        if let Some(source) = variants
120            .iter()
121            .find(|v| v.file_name().unwrap().to_str().unwrap().contains(&role_suffix))
122        {
123            return FileAction {
124                source: source.clone(),
125                target_rel_path: target_path.to_path_buf(),
126                kind: EntryKind::Override,
127            };
128        }
129    }
130
131    // Priority 3: template (base file with .tera extension)
132    if let Some(source) = variants.iter().find(|v| {
133        let name = v.file_name().unwrap().to_str().unwrap();
134        name.ends_with(".tera") && !name.contains("##")
135    }) {
136        return FileAction {
137            source: source.clone(),
138            target_rel_path: target_path.to_path_buf(),
139            kind: EntryKind::Template,
140        };
141    }
142
143    // Priority 4: plain base file
144    let source = variants
145        .iter()
146        .find(|v| {
147            let name = v.file_name().unwrap().to_str().unwrap();
148            !name.contains("##") && !name.ends_with(".tera")
149        })
150        .unwrap_or(&variants[0]);
151
152    FileAction {
153        source: source.clone(),
154        target_rel_path: target_path.to_path_buf(),
155        kind: EntryKind::Base,
156    }
157}