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 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
80fn 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
88fn canonical_target_path(rel_path: &Path) -> PathBuf {
90 let file_name = file_name_str(rel_path);
91
92 let base_name = if let Some(idx) = file_name.find("##") {
94 &file_name[..idx]
95 } else {
96 file_name
97 };
98
99 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
113fn 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 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 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 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 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}