1use std::collections::BTreeMap;
28use std::fs;
29use std::path::{Path, PathBuf};
30
31use crate::{BundleError, BundleHash, BundleManifest, BundleRuntime, SCHEMA_VERSION};
32
33fn io(context: impl AsRef<str>, e: std::io::Error) -> BundleError {
36 BundleError::Io(format!("{}: {e}", context.as_ref()))
37}
38
39pub type BundleFile = (String, PathBuf);
43
44pub fn collect_dir(prefix: &str, dir: &Path) -> Result<Vec<BundleFile>, BundleError> {
47 let mut out = Vec::new();
48 let mut stack = vec![dir.to_path_buf()];
49 while let Some(d) = stack.pop() {
50 let entries = fs::read_dir(&d).map_err(|e| io(format!("reading {}", d.display()), e))?;
51 for entry in entries {
52 let entry = entry.map_err(|e| io(format!("reading {}", d.display()), e))?;
53 let ft = entry
54 .file_type()
55 .map_err(|e| io(format!("stat {}", entry.path().display()), e))?;
56 let p = entry.path();
57 if ft.is_dir() {
58 stack.push(p);
59 } else if ft.is_file() {
60 let rel = p.strip_prefix(dir).expect("walker yields paths under dir");
61 let rel_str = rel.to_string_lossy().replace('\\', "/");
62 out.push((format!("{prefix}/{rel_str}"), p));
63 }
64 }
65 }
66 out.sort();
67 Ok(out)
68}
69
70pub fn assemble_bundle(
79 dest: &Path,
80 name: &str,
81 runtime: BundleRuntime,
82 files: &[BundleFile],
83) -> Result<BundleManifest, BundleError> {
84 fs::create_dir_all(dest).map_err(|e| io(format!("creating bundle dir {}", dest.display()), e))?;
85
86 let mut content = BTreeMap::new();
87 for (bundle_path, src) in files {
88 let bytes = fs::read(src).map_err(|e| io(format!("reading {}", src.display()), e))?;
89 let hash = BundleHash::of(&bytes);
90 let out = dest.join(bundle_path);
91 if let Some(parent) = out.parent() {
92 fs::create_dir_all(parent)
93 .map_err(|e| io(format!("creating {}", parent.display()), e))?;
94 }
95 fs::write(&out, &bytes).map_err(|e| io(format!("writing {}", out.display()), e))?;
96
97 #[cfg(unix)]
99 if bundle_path.starts_with("bins/") {
100 use std::os::unix::fs::PermissionsExt;
101 let mut perm = fs::metadata(&out)
102 .map_err(|e| io(format!("stat {}", out.display()), e))?
103 .permissions();
104 perm.set_mode(0o755);
105 fs::set_permissions(&out, perm)
106 .map_err(|e| io(format!("chmod +x {}", out.display()), e))?;
107 }
108
109 content.insert(bundle_path.clone(), hash);
110 }
111
112 let manifest = BundleManifest {
113 schema_version: SCHEMA_VERSION,
114 name: name.to_string(),
115 runtime,
116 content,
117 };
118 let toml = manifest.to_toml_string()?;
119 fs::write(dest.join("manifest.toml"), toml).map_err(|e| {
120 io(
121 format!("writing {}", dest.join("manifest.toml").display()),
122 e,
123 )
124 })?;
125 Ok(manifest)
126}
127
128pub fn assemble_vanilla_bundle(
140 dest: &Path,
141 name: &str,
142 runtime_version: &str,
143 project_root: &Path,
144 out_dir: &Path,
145) -> Result<BundleManifest, BundleError> {
146 let mut files = collect_dir("app/dist", out_dir)?;
147 let routes = project_root.join("mesofact.routes.ts");
148 if routes.exists() {
149 files.push(("app/mesofact.routes.ts".to_string(), routes));
150 }
151 files.sort();
152 assemble_bundle(
153 dest,
154 name,
155 BundleRuntime::Mesofact {
156 version: runtime_version.to_string(),
157 },
158 &files,
159 )
160}
161
162pub fn assemble_self_bundle(
170 dest: &Path,
171 name: &str,
172 project_root: &Path,
173 out_dir: &Path,
174 serve_bins: &[(String, PathBuf)],
175) -> Result<BundleManifest, BundleError> {
176 let mut files = collect_dir("app/dist", out_dir)?;
177 let routes = project_root.join("mesofact.routes.ts");
178 if routes.exists() {
179 files.push(("app/mesofact.routes.ts".to_string(), routes));
180 }
181 for (triple, bin) in serve_bins {
182 files.push((format!("bins/{triple}/serve"), bin.clone()));
183 }
184 files.sort();
185 assemble_bundle(dest, name, BundleRuntime::SelfContained, &files)
186}
187
188#[cfg(test)]
189mod tests {
190 use super::*;
191 use tempfile::TempDir;
192
193 fn write(path: &Path, bytes: &[u8]) {
194 fs::create_dir_all(path.parent().unwrap()).unwrap();
195 fs::write(path, bytes).unwrap();
196 }
197
198 #[test]
199 fn collect_dir_walks_recursively_and_prefixes() {
200 let dir = TempDir::new().unwrap();
201 write(&dir.path().join("index.html"), b"a");
202 write(&dir.path().join("assets/main.js"), b"b");
203 write(&dir.path().join("assets/nested/x.css"), b"c");
204
205 let files = collect_dir("app/dist", dir.path()).unwrap();
206 let paths: Vec<&str> = files.iter().map(|(p, _)| p.as_str()).collect();
207 assert_eq!(
208 paths,
209 vec![
210 "app/dist/assets/main.js",
211 "app/dist/assets/nested/x.css",
212 "app/dist/index.html",
213 ]
214 );
215 }
216
217 #[test]
218 fn assemble_bundle_copies_files_hashes_and_writes_manifest() {
219 let src = TempDir::new().unwrap();
220 let dest = TempDir::new().unwrap();
221 write(&src.path().join("index.html"), b"<html>home</html>");
222
223 let files = vec![("app/index.html".to_string(), src.path().join("index.html"))];
224 let manifest = assemble_bundle(
225 dest.path(),
226 "yah-marketing",
227 BundleRuntime::Mesofact {
228 version: "0.8.20".into(),
229 },
230 &files,
231 )
232 .unwrap();
233
234 assert_eq!(
236 manifest.content.get("app/index.html").unwrap(),
237 &BundleHash::of(b"<html>home</html>")
238 );
239 assert_eq!(
241 fs::read(dest.path().join("app/index.html")).unwrap(),
242 b"<html>home</html>"
243 );
244 let text = fs::read_to_string(dest.path().join("manifest.toml")).unwrap();
246 assert_eq!(BundleManifest::from_toml_str(&text).unwrap(), manifest);
247 }
248
249 #[test]
250 fn assemble_vanilla_bundle_stages_routes_and_dist() {
251 let project = TempDir::new().unwrap();
252 let out_dir = project.path().join("dist");
253 let dest = TempDir::new().unwrap();
254 write(
255 &project.path().join("mesofact.routes.ts"),
256 b"export default {}",
257 );
258 write(&out_dir.join("index.html"), b"<html>");
259 write(&out_dir.join("hydrate/app.hash.js"), b"hydrate");
260
261 let manifest =
262 assemble_vanilla_bundle(dest.path(), "yah-marketing", "0.8.20", project.path(), &out_dir)
263 .unwrap();
264
265 assert!(matches!(manifest.runtime, BundleRuntime::Mesofact { .. }));
266 assert!(manifest.content.contains_key("app/mesofact.routes.ts"));
267 assert!(manifest.content.contains_key("app/dist/index.html"));
268 assert!(manifest.content.contains_key("app/dist/hydrate/app.hash.js"));
269 assert!(!manifest.content.keys().any(|k| k.starts_with("bins/")));
271 assert!(dest.path().join("app/mesofact.routes.ts").exists());
272 }
273
274 #[test]
275 fn assemble_self_bundle_carries_its_serve_binary() {
276 let project = TempDir::new().unwrap();
277 let out_dir = project.path().join("dist");
278 let dest = TempDir::new().unwrap();
279 let bin = project.path().join("serve");
280 write(&out_dir.join("index.html"), b"<html>");
281 write(&bin, b"\x7fELF-ish");
282
283 let manifest = assemble_self_bundle(
284 dest.path(),
285 "yah-marketing",
286 project.path(),
287 &out_dir,
288 &[("x86_64-unknown-linux-musl".to_string(), bin)],
289 )
290 .unwrap();
291
292 assert!(matches!(manifest.runtime, BundleRuntime::SelfContained));
293 assert!(manifest
294 .content
295 .contains_key("bins/x86_64-unknown-linux-musl/serve"));
296 assert!(manifest.content.contains_key("app/dist/index.html"));
297 assert_eq!(manifest.runtime.as_wire(), "self");
299 }
300
301 #[test]
302 #[cfg(unix)]
303 fn bins_are_made_executable() {
304 use std::os::unix::fs::PermissionsExt;
305 let src = TempDir::new().unwrap();
306 let dest = TempDir::new().unwrap();
307 write(&src.path().join("serve"), b"\x7fELF-ish");
308
309 let files = vec![(
310 "bins/x86_64-unknown-linux-musl/serve".to_string(),
311 src.path().join("serve"),
312 )];
313 assemble_bundle(dest.path(), "app", BundleRuntime::SelfContained, &files).unwrap();
314
315 let mode = fs::metadata(dest.path().join("bins/x86_64-unknown-linux-musl/serve"))
316 .unwrap()
317 .permissions()
318 .mode();
319 assert_eq!(mode & 0o111, 0o111, "serve binary should be executable");
320 }
321}