1use anyhow::{Context, Result};
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7pub enum EntryKind {
8 Base,
10 Override,
12 Template,
14}
15
16#[derive(Debug)]
18pub struct FileAction {
19 pub source: PathBuf,
21 pub target_rel_path: PathBuf,
23 pub kind: EntryKind,
25}
26
27pub 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
47fn 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
73fn 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
81fn canonical_target_path(rel_path: &Path) -> PathBuf {
83 let file_name = file_name_str(rel_path);
84
85 let base_name = if let Some(idx) = file_name.find("##") {
87 &file_name[..idx]
88 } else {
89 file_name
90 };
91
92 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
106fn 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 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 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 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 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}