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 -- symlink (user-mode) or copy (system-mode)
9    Base,
10    /// Host or role override -- symlink (user-mode) or copy (system-mode)
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        if let Some(action) = resolve_variant(target_path, variants, hostname, roles) {
40            actions.push(action);
41        }
42    }
43
44    actions.sort_by(|a, b| a.target_rel_path.cmp(&b.target_rel_path));
45    Ok(actions)
46}
47
48/// Recursively collect files, grouping override variants by their canonical path.
49fn collect_files(
50    base: &Path,
51    dir: &Path,
52    files: &mut HashMap<PathBuf, Vec<PathBuf>>,
53) -> Result<()> {
54    for entry in std::fs::read_dir(dir)
55        .with_context(|| format!("failed to read directory: {}", dir.display()))?
56    {
57        let entry = entry?;
58        let path = entry.path();
59
60        if path.is_dir() {
61            if path.is_symlink() {
62                eprintln!(
63                    "warning: skipping symlink to directory: {}",
64                    path.strip_prefix(base).unwrap_or(&path).display()
65                );
66                continue;
67            }
68            collect_files(base, &path, files)?;
69        } else {
70            let rel_path = path
71                .strip_prefix(base)
72                .expect("collected path must be under base directory")
73                .to_path_buf();
74            if rel_path.file_name().and_then(|n| n.to_str()).is_none() {
75                eprintln!(
76                    "warning: skipping non-UTF-8 filename: {}",
77                    rel_path.display()
78                );
79                continue;
80            }
81            let canonical = canonical_target_path(&rel_path);
82            files.entry(canonical).or_default().push(path);
83        }
84    }
85    Ok(())
86}
87
88/// Extract filename as a UTF-8 string, panicking with a descriptive message on non-UTF-8 paths.
89fn file_name_str(path: &Path) -> &str {
90    path.file_name()
91        .expect("path has no filename")
92        .to_str()
93        .expect("filename is not valid UTF-8")
94}
95
96/// Strip `##` suffix and `.tera` extension to get the canonical target path.
97fn canonical_target_path(rel_path: &Path) -> PathBuf {
98    let file_name = file_name_str(rel_path);
99
100    // Strip ## suffix first
101    let base_name = if let Some(idx) = file_name.find("##") {
102        &file_name[..idx]
103    } else {
104        file_name
105    };
106
107    // Strip .tera extension
108    let base_name = base_name.strip_suffix(".tera").unwrap_or(base_name);
109
110    if let Some(parent) = rel_path.parent() {
111        if parent == Path::new("") {
112            PathBuf::from(base_name)
113        } else {
114            parent.join(base_name)
115        }
116    } else {
117        PathBuf::from(base_name)
118    }
119}
120
121/// Given all variants of a file, pick the best one for this host/roles.
122/// Returns None if no variant matches the current host/roles and no base file exists.
123fn resolve_variant(
124    target_path: &Path,
125    variants: &[PathBuf],
126    hostname: &str,
127    roles: &[&str],
128) -> Option<FileAction> {
129    let host_suffix = format!("##host.{hostname}");
130    let host_suffix_tera = format!("{host_suffix}.tera");
131
132    // Priority 1: host override
133    if let Some(source) = variants.iter().find(|v| {
134        let name = file_name_str(v);
135        name.ends_with(&host_suffix) || name.ends_with(&host_suffix_tera)
136    }) {
137        let kind = if file_name_str(source).ends_with(".tera") {
138            EntryKind::Template
139        } else {
140            EntryKind::Override
141        };
142        return Some(FileAction {
143            source: source.clone(),
144            target_rel_path: target_path.to_path_buf(),
145            kind,
146        });
147    }
148
149    // Priority 2: role override (last matching role wins)
150    for role in roles.iter().rev() {
151        let role_suffix = format!("##role.{role}");
152        let role_suffix_tera = format!("{role_suffix}.tera");
153        if let Some(source) = variants.iter().find(|v| {
154            let name = file_name_str(v);
155            name.ends_with(&role_suffix) || name.ends_with(&role_suffix_tera)
156        }) {
157            let kind = if file_name_str(source).ends_with(".tera") {
158                EntryKind::Template
159            } else {
160                EntryKind::Override
161            };
162            return Some(FileAction {
163                source: source.clone(),
164                target_rel_path: target_path.to_path_buf(),
165                kind,
166            });
167        }
168    }
169
170    // Priority 3: template (base file with .tera extension)
171    if let Some(source) = variants.iter().find(|v| {
172        let name = file_name_str(v);
173        name.ends_with(".tera") && !name.contains("##")
174    }) {
175        return Some(FileAction {
176            source: source.clone(),
177            target_rel_path: target_path.to_path_buf(),
178            kind: EntryKind::Template,
179        });
180    }
181
182    // Priority 4: plain base file
183    variants
184        .iter()
185        .find(|v| {
186            let name = file_name_str(v);
187            !name.contains("##") && !name.ends_with(".tera")
188        })
189        .map(|source| FileAction {
190            source: source.clone(),
191            target_rel_path: target_path.to_path_buf(),
192            kind: EntryKind::Base,
193        })
194}