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.strip_prefix(base).unwrap().to_path_buf();
63 let canonical = canonical_target_path(&rel_path);
64 files.entry(canonical).or_default().push(path);
65 }
66 }
67 Ok(())
68}
69
70fn canonical_target_path(rel_path: &Path) -> PathBuf {
72 let file_name = rel_path.file_name().unwrap().to_str().unwrap();
73
74 let base_name = if let Some(idx) = file_name.find("##") {
76 &file_name[..idx]
77 } else {
78 file_name
79 };
80
81 let base_name = base_name.strip_suffix(".tera").unwrap_or(base_name);
83
84 if let Some(parent) = rel_path.parent() {
85 if parent == Path::new("") {
86 PathBuf::from(base_name)
87 } else {
88 parent.join(base_name)
89 }
90 } else {
91 PathBuf::from(base_name)
92 }
93}
94
95fn resolve_variant(
97 target_path: &Path,
98 variants: &[PathBuf],
99 hostname: &str,
100 roles: &[&str],
101) -> FileAction {
102 let host_suffix = format!("##host.{hostname}");
103
104 if let Some(source) = variants
106 .iter()
107 .find(|v| v.file_name().unwrap().to_str().unwrap().contains(&host_suffix))
108 {
109 return FileAction {
110 source: source.clone(),
111 target_rel_path: target_path.to_path_buf(),
112 kind: EntryKind::Override,
113 };
114 }
115
116 for role in roles.iter().rev() {
118 let role_suffix = format!("##role.{role}");
119 if let Some(source) = variants
120 .iter()
121 .find(|v| v.file_name().unwrap().to_str().unwrap().contains(&role_suffix))
122 {
123 return FileAction {
124 source: source.clone(),
125 target_rel_path: target_path.to_path_buf(),
126 kind: EntryKind::Override,
127 };
128 }
129 }
130
131 if let Some(source) = variants.iter().find(|v| {
133 let name = v.file_name().unwrap().to_str().unwrap();
134 name.ends_with(".tera") && !name.contains("##")
135 }) {
136 return FileAction {
137 source: source.clone(),
138 target_rel_path: target_path.to_path_buf(),
139 kind: EntryKind::Template,
140 };
141 }
142
143 let source = variants
145 .iter()
146 .find(|v| {
147 let name = v.file_name().unwrap().to_str().unwrap();
148 !name.contains("##") && !name.ends_with(".tera")
149 })
150 .unwrap_or(&variants[0]);
151
152 FileAction {
153 source: source.clone(),
154 target_rel_path: target_path.to_path_buf(),
155 kind: EntryKind::Base,
156 }
157}