1use std::fs::File;
2use std::io::{self, Cursor};
3use std::path::{Path, PathBuf};
4
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7use zip::ZipArchive;
8
9use bamboo_infrastructure::paths::bamboo_dir;
10
11include!(concat!(env!("OUT_DIR"), "/frontend_package_embedded.rs"));
12
13pub const DUPLICATE_FRONTEND_DIR_NAME: &str = "frontend";
14pub const DUPLICATE_FRONTEND_MANIFEST_NAME: &str = ".frontend-manifest.json";
15pub const FRONTEND_PACKAGE_ENV: &str = "BAMBOO_FRONTEND_PACKAGE";
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
18pub struct FrontendPackageManifest {
19 pub schema_version: u32,
20 pub frontend_name: String,
21 pub frontend_version: String,
22 pub bundle_hash: String,
23 pub built_at: DateTime<Utc>,
24 pub entry: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct FrontendPackageStatus {
29 pub package_path: Option<PathBuf>,
30 pub frontend_dir: PathBuf,
31 pub local_manifest_path: PathBuf,
32 pub bundled_manifest: FrontendPackageManifest,
33 pub local_manifest: Option<FrontendPackageManifest>,
34 pub refreshed: bool,
35}
36
37pub fn duplicate_frontend_dir_in(bamboo_home_dir: &Path) -> PathBuf {
38 bamboo_home_dir.join(DUPLICATE_FRONTEND_DIR_NAME)
39}
40
41pub fn duplicate_frontend_dir() -> PathBuf {
42 duplicate_frontend_dir_in(&bamboo_dir())
43}
44
45pub fn duplicate_frontend_manifest_path_in(bamboo_home_dir: &Path) -> PathBuf {
46 duplicate_frontend_dir_in(bamboo_home_dir).join(DUPLICATE_FRONTEND_MANIFEST_NAME)
47}
48
49pub fn duplicate_frontend_manifest_path() -> PathBuf {
50 duplicate_frontend_manifest_path_in(&bamboo_dir())
51}
52
53pub fn has_embedded_frontend_package() -> bool {
54 DUPLICATE_FRONTEND_PACKAGE_ZIP.is_some() && DUPLICATE_FRONTEND_PACKAGE_MANIFEST.is_some()
55}
56
57fn frontend_package_candidates_under(base_dir: &Path) -> Vec<PathBuf> {
58 vec![
59 base_dir.join("frontend_package/lotus-frontend.zip"),
60 base_dir.join(".frontend-package/lotus-frontend.zip"),
61 base_dir.join("bodhi/.frontend-package/lotus-frontend.zip"),
62 base_dir.join("../frontend_package/lotus-frontend.zip"),
63 base_dir.join("../.frontend-package/lotus-frontend.zip"),
64 base_dir.join("../bodhi/.frontend-package/lotus-frontend.zip"),
65 ]
66}
67
68pub fn resolve_frontend_package_path(explicit_path: Option<&Path>) -> Option<PathBuf> {
69 if let Some(path) = explicit_path {
70 return path.exists().then(|| path.to_path_buf());
71 }
72
73 if let Ok(raw) = std::env::var(FRONTEND_PACKAGE_ENV) {
74 let trimmed = raw.trim();
75 if !trimmed.is_empty() {
76 let candidate = PathBuf::from(trimmed);
77 if candidate.exists() {
78 return Some(candidate);
79 }
80 }
81 }
82
83 let mut candidates = Vec::new();
84 if let Ok(dir) = std::env::current_dir() {
85 candidates.extend(frontend_package_candidates_under(&dir));
86 }
87
88 if let Ok(current_exe) = std::env::current_exe() {
89 if let Some(exe_dir) = current_exe.parent() {
90 candidates.extend(frontend_package_candidates_under(exe_dir));
91 candidates.push(exe_dir.join("../Resources/frontend_package/lotus-frontend.zip"));
92 candidates.push(exe_dir.join("../Resources/.frontend-package/lotus-frontend.zip"));
93 }
94 }
95
96 candidates.into_iter().find(|path| path.exists())
97}
98
99pub fn read_bundled_manifest(
100 package_path: Option<&Path>,
101) -> Result<FrontendPackageManifest, FrontendPackageError> {
102 if let Some(bytes) = DUPLICATE_FRONTEND_PACKAGE_MANIFEST {
103 return serde_json::from_slice(bytes).map_err(FrontendPackageError::Json);
104 }
105
106 let package_path = package_path.ok_or(FrontendPackageError::PackageNotFound)?;
107 let sidecar_manifest = package_path.with_file_name("frontend-manifest.json");
108 if sidecar_manifest.exists() {
109 let file = File::open(sidecar_manifest).map_err(FrontendPackageError::Io)?;
110 return serde_json::from_reader(file).map_err(FrontendPackageError::Json);
111 }
112
113 read_bundled_manifest_from_zip(package_path)
114}
115
116pub fn read_bundled_manifest_from_zip(
117 package_path: &Path,
118) -> Result<FrontendPackageManifest, FrontendPackageError> {
119 let file = File::open(package_path).map_err(FrontendPackageError::Io)?;
120 let mut archive = ZipArchive::new(file).map_err(FrontendPackageError::Zip)?;
121 let mut manifest_file = archive
122 .by_name("frontend-manifest.json")
123 .map_err(FrontendPackageError::Zip)?;
124 let manifest: FrontendPackageManifest =
125 serde_json::from_reader(&mut manifest_file).map_err(FrontendPackageError::Json)?;
126 Ok(manifest)
127}
128
129pub fn read_local_manifest(
130 manifest_path: &Path,
131) -> Result<Option<FrontendPackageManifest>, FrontendPackageError> {
132 if !manifest_path.exists() {
133 return Ok(None);
134 }
135 let file = File::open(manifest_path).map_err(FrontendPackageError::Io)?;
136 let manifest = serde_json::from_reader(file).map_err(FrontendPackageError::Json)?;
137 Ok(Some(manifest))
138}
139
140pub fn should_refresh_frontend(
141 bundled: &FrontendPackageManifest,
142 local: Option<&FrontendPackageManifest>,
143 frontend_dir: &Path,
144) -> bool {
145 let Some(local) = local else {
146 return true;
147 };
148
149 if bundled.schema_version != local.schema_version {
150 return true;
151 }
152 if bundled.frontend_version != local.frontend_version {
153 return true;
154 }
155 if bundled.bundle_hash != local.bundle_hash {
156 return true;
157 }
158 if !frontend_dir.join(&bundled.entry).is_file() {
159 return true;
160 }
161
162 false
163}
164
165pub fn ensure_current_frontend_dir_in(
166 bamboo_home_dir: &Path,
167 explicit_package_path: Option<&Path>,
168) -> Result<FrontendPackageStatus, FrontendPackageError> {
169 let package_path = if has_embedded_frontend_package() {
170 None
171 } else {
172 Some(
173 resolve_frontend_package_path(explicit_package_path)
174 .ok_or(FrontendPackageError::PackageNotFound)?,
175 )
176 };
177
178 let bundled_manifest = read_bundled_manifest(package_path.as_deref())?;
179 let frontend_dir = duplicate_frontend_dir_in(bamboo_home_dir);
180 let local_manifest_path = duplicate_frontend_manifest_path_in(bamboo_home_dir);
181 let local_manifest = read_local_manifest(&local_manifest_path)?;
182
183 let refresh_needed =
184 should_refresh_frontend(&bundled_manifest, local_manifest.as_ref(), &frontend_dir);
185
186 if refresh_needed {
187 refresh_frontend_dir(package_path.as_deref(), &bundled_manifest, &frontend_dir)?;
188 }
189
190 Ok(FrontendPackageStatus {
191 package_path,
192 frontend_dir,
193 local_manifest_path,
194 bundled_manifest,
195 local_manifest,
196 refreshed: refresh_needed,
197 })
198}
199
200pub fn ensure_current_frontend_dir(
201 explicit_package_path: Option<&Path>,
202) -> Result<FrontendPackageStatus, FrontendPackageError> {
203 ensure_current_frontend_dir_in(&bamboo_dir(), explicit_package_path)
204}
205
206fn refresh_frontend_dir(
207 package_path: Option<&Path>,
208 bundled_manifest: &FrontendPackageManifest,
209 frontend_dir: &Path,
210) -> Result<(), FrontendPackageError> {
211 let parent = frontend_dir
212 .parent()
213 .ok_or_else(|| FrontendPackageError::InvalidTarget(frontend_dir.to_path_buf()))?;
214 std::fs::create_dir_all(parent).map_err(FrontendPackageError::Io)?;
215
216 let temp_dir = parent.join(format!(
217 ".{}-tmp-{}",
218 DUPLICATE_FRONTEND_DIR_NAME,
219 bundled_manifest.frontend_version.replace('/', "-")
220 ));
221 if temp_dir.exists() {
222 std::fs::remove_dir_all(&temp_dir).map_err(FrontendPackageError::Io)?;
223 }
224 std::fs::create_dir_all(&temp_dir).map_err(FrontendPackageError::Io)?;
225
226 if let Some(bytes) = DUPLICATE_FRONTEND_PACKAGE_ZIP {
227 extract_frontend_zip_bytes(bytes, &temp_dir)?;
228 } else {
229 let package_path = package_path.ok_or(FrontendPackageError::PackageNotFound)?;
230 extract_frontend_zip(package_path, &temp_dir)?;
231 }
232
233 let extracted_entry = temp_dir.join(&bundled_manifest.entry);
234 if !extracted_entry.is_file() {
235 return Err(FrontendPackageError::MissingEntry(extracted_entry));
236 }
237
238 let manifest_path = temp_dir.join(DUPLICATE_FRONTEND_MANIFEST_NAME);
239 let manifest_file = File::create(&manifest_path).map_err(FrontendPackageError::Io)?;
240 serde_json::to_writer_pretty(manifest_file, bundled_manifest)
241 .map_err(FrontendPackageError::Json)?;
242
243 let old_dir = parent.join(format!("{}.old", DUPLICATE_FRONTEND_DIR_NAME));
244 if old_dir.exists() {
245 std::fs::remove_dir_all(&old_dir).map_err(FrontendPackageError::Io)?;
246 }
247 if frontend_dir.exists() {
248 std::fs::rename(frontend_dir, &old_dir).map_err(FrontendPackageError::Io)?;
249 }
250 std::fs::rename(&temp_dir, frontend_dir).map_err(FrontendPackageError::Io)?;
251 if old_dir.exists() {
252 std::fs::remove_dir_all(&old_dir).map_err(FrontendPackageError::Io)?;
253 }
254
255 Ok(())
256}
257
258fn extract_frontend_zip(
259 package_path: &Path,
260 target_dir: &Path,
261) -> Result<(), FrontendPackageError> {
262 let file = File::open(package_path).map_err(FrontendPackageError::Io)?;
263 let mut archive = ZipArchive::new(file).map_err(FrontendPackageError::Zip)?;
264 extract_archive_entries(&mut archive, target_dir)
265}
266
267fn extract_frontend_zip_bytes(bytes: &[u8], target_dir: &Path) -> Result<(), FrontendPackageError> {
268 let cursor = Cursor::new(bytes);
269 let mut archive = ZipArchive::new(cursor).map_err(FrontendPackageError::Zip)?;
270 extract_archive_entries(&mut archive, target_dir)
271}
272
273fn extract_archive_entries<R: io::Read + io::Seek>(
274 archive: &mut ZipArchive<R>,
275 target_dir: &Path,
276) -> Result<(), FrontendPackageError> {
277 for index in 0..archive.len() {
278 let mut entry = archive.by_index(index).map_err(FrontendPackageError::Zip)?;
279 let enclosed = entry
280 .enclosed_name()
281 .map(|path| path.to_path_buf())
282 .ok_or_else(|| FrontendPackageError::InvalidArchivePath(entry.name().to_string()))?;
283 let out_path = target_dir.join(enclosed);
284
285 if entry.is_dir() {
286 std::fs::create_dir_all(&out_path).map_err(FrontendPackageError::Io)?;
287 continue;
288 }
289
290 if let Some(parent) = out_path.parent() {
291 std::fs::create_dir_all(parent).map_err(FrontendPackageError::Io)?;
292 }
293
294 let mut out_file = File::create(&out_path).map_err(FrontendPackageError::Io)?;
295 io::copy(&mut entry, &mut out_file).map_err(FrontendPackageError::Io)?;
296 }
297
298 Ok(())
299}
300
301#[derive(Debug)]
302pub enum FrontendPackageError {
303 PackageNotFound,
304 InvalidTarget(PathBuf),
305 MissingEntry(PathBuf),
306 InvalidArchivePath(String),
307 Io(io::Error),
308 Json(serde_json::Error),
309 Zip(zip::result::ZipError),
310}
311
312impl std::fmt::Display for FrontendPackageError {
313 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 match self {
315 Self::PackageNotFound => write!(f, "duplicate frontend package not found"),
316 Self::InvalidTarget(path) => {
317 write!(
318 f,
319 "invalid duplicate frontend target path: {}",
320 path.display()
321 )
322 }
323 Self::MissingEntry(path) => {
324 write!(f, "missing extracted frontend entry: {}", path.display())
325 }
326 Self::InvalidArchivePath(path) => write!(f, "invalid archive path: {}", path),
327 Self::Io(error) => write!(f, "i/o error: {}", error),
328 Self::Json(error) => write!(f, "json error: {}", error),
329 Self::Zip(error) => write!(f, "zip error: {}", error),
330 }
331 }
332}
333
334impl std::error::Error for FrontendPackageError {}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use std::io::Write;
340 use tempfile::tempdir;
341
342 fn manifest_fixture() -> FrontendPackageManifest {
343 FrontendPackageManifest {
344 schema_version: 1,
345 frontend_name: "lotus".to_string(),
346 frontend_version: "1.0.0".to_string(),
347 bundle_hash: "sha256:abc".to_string(),
348 built_at: Utc::now(),
349 entry: "index.html".to_string(),
350 }
351 }
352
353 fn write_test_zip(path: &Path, manifest: &FrontendPackageManifest) {
354 let file = File::create(path).expect("zip file should be created");
355 let mut writer = zip::ZipWriter::new(file);
356 let options = zip::write::SimpleFileOptions::default();
357
358 writer
359 .start_file("index.html", options)
360 .expect("index.html entry should start");
361 writer
362 .write_all(b"<html><body>ok</body></html>")
363 .expect("index.html should write");
364
365 writer
366 .start_file("frontend-manifest.json", options)
367 .expect("manifest entry should start");
368 writer
369 .write_all(serde_json::to_string_pretty(manifest).unwrap().as_bytes())
370 .expect("manifest should write");
371
372 writer.finish().expect("zip should finish");
373 }
374
375 #[test]
376 fn refresh_is_required_when_local_manifest_missing() {
377 let bundled = manifest_fixture();
378 let temp = tempdir().unwrap();
379 assert!(should_refresh_frontend(&bundled, None, temp.path()));
380 }
381
382 #[test]
383 fn resolve_frontend_package_path_finds_bodhi_frontend_package_layout() {
384 let temp = tempdir().unwrap();
385 let bodhi_package_dir = temp.path().join("bodhi/.frontend-package");
386 std::fs::create_dir_all(&bodhi_package_dir).unwrap();
387 let package_path = bodhi_package_dir.join("lotus-frontend.zip");
388 std::fs::write(&package_path, b"zip-bytes").unwrap();
389
390 let original_dir = std::env::current_dir().unwrap();
391 std::env::set_current_dir(temp.path()).unwrap();
392
393 let resolved = resolve_frontend_package_path(None);
394
395 std::env::set_current_dir(original_dir).unwrap();
396
397 let resolved = resolved.expect("frontend package path should resolve");
398 let resolved_canonical = resolved.canonicalize().unwrap();
399 let expected_canonical = package_path.canonicalize().unwrap();
400 assert_eq!(resolved_canonical, expected_canonical);
401 }
402
403 #[test]
404 fn refresh_not_required_when_manifest_matches_and_entry_exists() {
405 let bundled = manifest_fixture();
406 let temp = tempdir().unwrap();
407 std::fs::write(temp.path().join("index.html"), "ok").unwrap();
408 assert!(!should_refresh_frontend(
409 &bundled,
410 Some(&bundled),
411 temp.path()
412 ));
413 }
414
415 #[test]
416 fn ensure_current_frontend_dir_extracts_bundle_into_bamboo_frontend() {
417 let package_temp = tempdir().unwrap();
418 let package_path = package_temp.path().join("lotus-frontend.zip");
419 let manifest = manifest_fixture();
420 write_test_zip(&package_path, &manifest);
421
422 let data_temp = tempdir().unwrap();
423 bamboo_infrastructure::paths::init_bamboo_dir(data_temp.path().to_path_buf());
424
425 let status = ensure_current_frontend_dir(Some(&package_path))
426 .expect("frontend extraction should succeed");
427
428 assert!(status.refreshed);
429 assert!(status
430 .frontend_dir
431 .join(&status.bundled_manifest.entry)
432 .is_file());
433 assert!(status
434 .frontend_dir
435 .join(DUPLICATE_FRONTEND_MANIFEST_NAME)
436 .is_file());
437 let local_manifest = read_local_manifest(&status.local_manifest_path)
438 .expect("local manifest should read")
439 .expect("local manifest should exist");
440 assert_eq!(
441 local_manifest.frontend_version,
442 status.bundled_manifest.frontend_version
443 );
444 assert_eq!(
445 local_manifest.bundle_hash,
446 status.bundled_manifest.bundle_hash
447 );
448 }
449}