1use std::fs;
35use std::io;
36use std::path::{Path, PathBuf};
37
38use include_dir::{include_dir, Dir, DirEntry};
39use thiserror::Error;
40
41pub static DEFAULTS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/extensions");
43
44#[derive(Debug, Default, Clone, Copy)]
47pub struct MaterializeStats {
48 pub written: usize,
49 pub skipped: usize,
50}
51
52#[derive(Debug, Error)]
54pub enum MaterializeError {
55 #[error("could not create directory {path:?}: {source}")]
56 CreateDir {
57 path: PathBuf,
58 #[source]
59 source: io::Error,
60 },
61 #[error("could not write {path:?}: {source}")]
62 Write {
63 path: PathBuf,
64 #[source]
65 source: io::Error,
66 },
67 #[error("could not chmod {path:?}: {source}")]
68 Chmod {
69 path: PathBuf,
70 #[source]
71 source: io::Error,
72 },
73}
74
75pub fn materialize_to(
83 target_root: &Path,
84 force: bool,
85) -> Result<MaterializeStats, MaterializeError> {
86 let mut stats = MaterializeStats::default();
87 for entry in DEFAULTS.entries() {
91 if let DirEntry::Dir(sub) = entry {
92 let sub_target = target_root.join(sub.path());
93 fs::create_dir_all(&sub_target).map_err(|source| MaterializeError::CreateDir {
94 path: sub_target.clone(),
95 source,
96 })?;
97 materialize_dir(sub, target_root, force, &mut stats)?;
98 }
99 }
100 Ok(stats)
101}
102
103fn materialize_dir(
104 dir: &Dir<'_>,
105 target_root: &Path,
106 force: bool,
107 stats: &mut MaterializeStats,
108) -> Result<(), MaterializeError> {
109 for entry in dir.entries() {
110 match entry {
111 DirEntry::Dir(sub) => {
112 let sub_target = target_root.join(sub.path());
113 fs::create_dir_all(&sub_target).map_err(|source| MaterializeError::CreateDir {
114 path: sub_target.clone(),
115 source,
116 })?;
117 materialize_dir(sub, target_root, force, stats)?;
118 }
119 DirEntry::File(file) => {
120 let dest = target_root.join(file.path());
121 if let Some(parent) = dest.parent() {
122 fs::create_dir_all(parent).map_err(|source| MaterializeError::CreateDir {
123 path: parent.to_path_buf(),
124 source,
125 })?;
126 }
127 if dest.exists() && !force {
128 stats.skipped += 1;
129 continue;
130 }
131 fs::write(&dest, file.contents()).map_err(|source| MaterializeError::Write {
132 path: dest.clone(),
133 source,
134 })?;
135 let is_manifest = dest
136 .file_name()
137 .and_then(|s| s.to_str())
138 .is_some_and(|name| name == "_manifest.toml");
139 if !is_manifest {
140 set_executable(&dest)?;
141 }
142 stats.written += 1;
143 }
144 }
145 }
146 Ok(())
147}
148
149#[cfg(unix)]
150fn set_executable(path: &Path) -> Result<(), MaterializeError> {
151 use std::os::unix::fs::PermissionsExt;
152 let perms = fs::Permissions::from_mode(0o755);
153 fs::set_permissions(path, perms).map_err(|source| MaterializeError::Chmod {
154 path: path.to_path_buf(),
155 source,
156 })
157}
158
159#[cfg(not(unix))]
160fn set_executable(_path: &Path) -> Result<(), MaterializeError> {
161 Ok(())
165}
166
167#[cfg(test)]
168mod tests {
169 use super::*;
170
171 #[test]
172 fn defaults_contains_expected_groups() {
173 let dirs: Vec<_> = DEFAULTS
175 .entries()
176 .iter()
177 .filter_map(|e| match e {
178 DirEntry::Dir(d) => d.path().file_name().and_then(|s| s.to_str()),
179 DirEntry::File(_) => None,
180 })
181 .collect();
182 for expected in &["dev", "prod", "org"] {
183 assert!(
184 dirs.contains(expected),
185 "expected `{expected}` group in DEFAULTS, got {dirs:?}",
186 );
187 }
188 }
189
190 #[test]
191 fn materialize_writes_manifests_and_scripts() {
192 let tmp = tempfile::tempdir().unwrap();
193 let stats = materialize_to(tmp.path(), false).unwrap();
194 assert!(
195 stats.written >= 6,
196 "expected ≥6 files, got {}",
197 stats.written
198 );
199 assert_eq!(stats.skipped, 0);
200
201 for group in &["dev", "prod", "org"] {
202 let manifest = tmp.path().join(group).join("_manifest.toml");
203 assert!(
204 manifest.exists(),
205 "manifest missing for {group}: {}",
206 manifest.display(),
207 );
208 let script = tmp.path().join(group).join("hello");
209 assert!(
210 script.exists(),
211 "script missing for {group}: {}",
212 script.display(),
213 );
214 }
215 }
216
217 #[test]
218 #[cfg(unix)]
219 fn materialize_sets_exec_bit_on_scripts_only() {
220 use std::os::unix::fs::PermissionsExt;
221 let tmp = tempfile::tempdir().unwrap();
222 materialize_to(tmp.path(), false).unwrap();
223
224 let script_mode = fs::metadata(tmp.path().join("dev/hello"))
225 .unwrap()
226 .permissions()
227 .mode();
228 assert_eq!(
229 script_mode & 0o777,
230 0o755,
231 "hello script should be 0o755, got {script_mode:o}",
232 );
233
234 let manifest_mode = fs::metadata(tmp.path().join("dev/_manifest.toml"))
238 .unwrap()
239 .permissions()
240 .mode();
241 assert_eq!(
242 manifest_mode & 0o111,
243 0,
244 "manifest should not be executable, got {manifest_mode:o}",
245 );
246 }
247
248 #[test]
249 fn materialize_is_idempotent_without_force() {
250 let tmp = tempfile::tempdir().unwrap();
251 let first = materialize_to(tmp.path(), false).unwrap();
252 let second = materialize_to(tmp.path(), false).unwrap();
253 assert!(first.written >= 6);
254 assert_eq!(second.written, 0, "second run should write nothing");
255 assert_eq!(second.skipped, first.written);
256 }
257
258 #[test]
259 fn materialize_skips_top_level_files() {
260 let tmp = tempfile::tempdir().unwrap();
263 materialize_to(tmp.path(), false).unwrap();
264 assert!(
265 !tmp.path().join("README.md").exists(),
266 "top-level README.md must not be materialized",
267 );
268 }
269
270 #[test]
271 fn materialize_force_overwrites_existing_files() {
272 let tmp = tempfile::tempdir().unwrap();
273 materialize_to(tmp.path(), false).unwrap();
274 let target = tmp.path().join("dev/hello");
275 fs::write(&target, "edited by user\n").unwrap();
276 let stats = materialize_to(tmp.path(), true).unwrap();
277 assert_eq!(stats.skipped, 0);
278 assert!(stats.written >= 6);
279 let body = fs::read_to_string(&target).unwrap();
280 assert!(
281 !body.contains("edited by user"),
282 "force should overwrite, got: {body}",
283 );
284 }
285}