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 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
48fn 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
88fn 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
96fn canonical_target_path(rel_path: &Path) -> PathBuf {
98 let file_name = file_name_str(rel_path);
99
100 let base_name = if let Some(idx) = file_name.find("##") {
102 &file_name[..idx]
103 } else {
104 file_name
105 };
106
107 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
121fn 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 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 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 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 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}