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