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