use std::fs::File;
use std::io::{self, Cursor};
use std::path::{Path, PathBuf};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use zip::ZipArchive;
use bamboo_infrastructure::paths::bamboo_dir;
include!(concat!(env!("OUT_DIR"), "/frontend_package_embedded.rs"));
pub const DUPLICATE_FRONTEND_DIR_NAME: &str = "frontend";
pub const DUPLICATE_FRONTEND_MANIFEST_NAME: &str = ".frontend-manifest.json";
pub const FRONTEND_PACKAGE_ENV: &str = "BAMBOO_FRONTEND_PACKAGE";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct FrontendPackageManifest {
pub schema_version: u32,
pub frontend_name: String,
pub frontend_version: String,
pub bundle_hash: String,
pub built_at: DateTime<Utc>,
pub entry: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FrontendPackageStatus {
pub package_path: Option<PathBuf>,
pub frontend_dir: PathBuf,
pub local_manifest_path: PathBuf,
pub bundled_manifest: FrontendPackageManifest,
pub local_manifest: Option<FrontendPackageManifest>,
pub refreshed: bool,
}
pub fn duplicate_frontend_dir_in(bamboo_home_dir: &Path) -> PathBuf {
bamboo_home_dir.join(DUPLICATE_FRONTEND_DIR_NAME)
}
pub fn duplicate_frontend_dir() -> PathBuf {
duplicate_frontend_dir_in(&bamboo_dir())
}
pub fn duplicate_frontend_manifest_path_in(bamboo_home_dir: &Path) -> PathBuf {
duplicate_frontend_dir_in(bamboo_home_dir).join(DUPLICATE_FRONTEND_MANIFEST_NAME)
}
pub fn duplicate_frontend_manifest_path() -> PathBuf {
duplicate_frontend_manifest_path_in(&bamboo_dir())
}
pub fn has_embedded_frontend_package() -> bool {
DUPLICATE_FRONTEND_PACKAGE_ZIP.is_some() && DUPLICATE_FRONTEND_PACKAGE_MANIFEST.is_some()
}
fn frontend_package_candidates_under(base_dir: &Path) -> Vec<PathBuf> {
vec![
base_dir.join("frontend_package/lotus-frontend.zip"),
base_dir.join(".frontend-package/lotus-frontend.zip"),
base_dir.join("bodhi/.frontend-package/lotus-frontend.zip"),
base_dir.join("../frontend_package/lotus-frontend.zip"),
base_dir.join("../.frontend-package/lotus-frontend.zip"),
base_dir.join("../bodhi/.frontend-package/lotus-frontend.zip"),
]
}
pub fn resolve_frontend_package_path(explicit_path: Option<&Path>) -> Option<PathBuf> {
if let Some(path) = explicit_path {
return path.exists().then(|| path.to_path_buf());
}
if let Ok(raw) = std::env::var(FRONTEND_PACKAGE_ENV) {
let trimmed = raw.trim();
if !trimmed.is_empty() {
let candidate = PathBuf::from(trimmed);
if candidate.exists() {
return Some(candidate);
}
}
}
let mut candidates = Vec::new();
if let Ok(dir) = std::env::current_dir() {
candidates.extend(frontend_package_candidates_under(&dir));
}
if let Ok(current_exe) = std::env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
candidates.extend(frontend_package_candidates_under(exe_dir));
candidates.push(exe_dir.join("../Resources/frontend_package/lotus-frontend.zip"));
candidates.push(exe_dir.join("../Resources/.frontend-package/lotus-frontend.zip"));
}
}
candidates.into_iter().find(|path| path.exists())
}
pub fn read_bundled_manifest(
package_path: Option<&Path>,
) -> Result<FrontendPackageManifest, FrontendPackageError> {
if let Some(bytes) = DUPLICATE_FRONTEND_PACKAGE_MANIFEST {
return serde_json::from_slice(bytes).map_err(FrontendPackageError::Json);
}
let package_path = package_path.ok_or(FrontendPackageError::PackageNotFound)?;
let sidecar_manifest = package_path.with_file_name("frontend-manifest.json");
if sidecar_manifest.exists() {
let file = File::open(sidecar_manifest).map_err(FrontendPackageError::Io)?;
return serde_json::from_reader(file).map_err(FrontendPackageError::Json);
}
read_bundled_manifest_from_zip(package_path)
}
pub fn read_bundled_manifest_from_zip(
package_path: &Path,
) -> Result<FrontendPackageManifest, FrontendPackageError> {
let file = File::open(package_path).map_err(FrontendPackageError::Io)?;
let mut archive = ZipArchive::new(file).map_err(FrontendPackageError::Zip)?;
let mut manifest_file = archive
.by_name("frontend-manifest.json")
.map_err(FrontendPackageError::Zip)?;
let manifest: FrontendPackageManifest =
serde_json::from_reader(&mut manifest_file).map_err(FrontendPackageError::Json)?;
Ok(manifest)
}
pub fn read_local_manifest(
manifest_path: &Path,
) -> Result<Option<FrontendPackageManifest>, FrontendPackageError> {
if !manifest_path.exists() {
return Ok(None);
}
let file = File::open(manifest_path).map_err(FrontendPackageError::Io)?;
let manifest = serde_json::from_reader(file).map_err(FrontendPackageError::Json)?;
Ok(Some(manifest))
}
pub fn should_refresh_frontend(
bundled: &FrontendPackageManifest,
local: Option<&FrontendPackageManifest>,
frontend_dir: &Path,
) -> bool {
let Some(local) = local else {
return true;
};
if bundled.schema_version != local.schema_version {
return true;
}
if bundled.frontend_version != local.frontend_version {
return true;
}
if bundled.bundle_hash != local.bundle_hash {
return true;
}
if !frontend_dir.join(&bundled.entry).is_file() {
return true;
}
false
}
pub fn ensure_current_frontend_dir_in(
bamboo_home_dir: &Path,
explicit_package_path: Option<&Path>,
) -> Result<FrontendPackageStatus, FrontendPackageError> {
let package_path = if has_embedded_frontend_package() {
None
} else {
Some(
resolve_frontend_package_path(explicit_package_path)
.ok_or(FrontendPackageError::PackageNotFound)?,
)
};
let bundled_manifest = read_bundled_manifest(package_path.as_deref())?;
let frontend_dir = duplicate_frontend_dir_in(bamboo_home_dir);
let local_manifest_path = duplicate_frontend_manifest_path_in(bamboo_home_dir);
let local_manifest = read_local_manifest(&local_manifest_path)?;
let refresh_needed =
should_refresh_frontend(&bundled_manifest, local_manifest.as_ref(), &frontend_dir);
if refresh_needed {
refresh_frontend_dir(package_path.as_deref(), &bundled_manifest, &frontend_dir)?;
}
Ok(FrontendPackageStatus {
package_path,
frontend_dir,
local_manifest_path,
bundled_manifest,
local_manifest,
refreshed: refresh_needed,
})
}
pub fn ensure_current_frontend_dir(
explicit_package_path: Option<&Path>,
) -> Result<FrontendPackageStatus, FrontendPackageError> {
ensure_current_frontend_dir_in(&bamboo_dir(), explicit_package_path)
}
fn refresh_frontend_dir(
package_path: Option<&Path>,
bundled_manifest: &FrontendPackageManifest,
frontend_dir: &Path,
) -> Result<(), FrontendPackageError> {
let parent = frontend_dir
.parent()
.ok_or_else(|| FrontendPackageError::InvalidTarget(frontend_dir.to_path_buf()))?;
std::fs::create_dir_all(parent).map_err(FrontendPackageError::Io)?;
let temp_dir = parent.join(format!(
".{}-tmp-{}",
DUPLICATE_FRONTEND_DIR_NAME,
bundled_manifest.frontend_version.replace('/', "-")
));
if temp_dir.exists() {
std::fs::remove_dir_all(&temp_dir).map_err(FrontendPackageError::Io)?;
}
std::fs::create_dir_all(&temp_dir).map_err(FrontendPackageError::Io)?;
if let Some(bytes) = DUPLICATE_FRONTEND_PACKAGE_ZIP {
extract_frontend_zip_bytes(bytes, &temp_dir)?;
} else {
let package_path = package_path.ok_or(FrontendPackageError::PackageNotFound)?;
extract_frontend_zip(package_path, &temp_dir)?;
}
let extracted_entry = temp_dir.join(&bundled_manifest.entry);
if !extracted_entry.is_file() {
return Err(FrontendPackageError::MissingEntry(extracted_entry));
}
let manifest_path = temp_dir.join(DUPLICATE_FRONTEND_MANIFEST_NAME);
let manifest_file = File::create(&manifest_path).map_err(FrontendPackageError::Io)?;
serde_json::to_writer_pretty(manifest_file, bundled_manifest)
.map_err(FrontendPackageError::Json)?;
let old_dir = parent.join(format!("{}.old", DUPLICATE_FRONTEND_DIR_NAME));
if old_dir.exists() {
std::fs::remove_dir_all(&old_dir).map_err(FrontendPackageError::Io)?;
}
if frontend_dir.exists() {
std::fs::rename(frontend_dir, &old_dir).map_err(FrontendPackageError::Io)?;
}
std::fs::rename(&temp_dir, frontend_dir).map_err(FrontendPackageError::Io)?;
if old_dir.exists() {
std::fs::remove_dir_all(&old_dir).map_err(FrontendPackageError::Io)?;
}
Ok(())
}
fn extract_frontend_zip(
package_path: &Path,
target_dir: &Path,
) -> Result<(), FrontendPackageError> {
let file = File::open(package_path).map_err(FrontendPackageError::Io)?;
let mut archive = ZipArchive::new(file).map_err(FrontendPackageError::Zip)?;
extract_archive_entries(&mut archive, target_dir)
}
fn extract_frontend_zip_bytes(bytes: &[u8], target_dir: &Path) -> Result<(), FrontendPackageError> {
let cursor = Cursor::new(bytes);
let mut archive = ZipArchive::new(cursor).map_err(FrontendPackageError::Zip)?;
extract_archive_entries(&mut archive, target_dir)
}
fn extract_archive_entries<R: io::Read + io::Seek>(
archive: &mut ZipArchive<R>,
target_dir: &Path,
) -> Result<(), FrontendPackageError> {
for index in 0..archive.len() {
let mut entry = archive.by_index(index).map_err(FrontendPackageError::Zip)?;
let enclosed = entry
.enclosed_name()
.map(|path| path.to_path_buf())
.ok_or_else(|| FrontendPackageError::InvalidArchivePath(entry.name().to_string()))?;
let out_path = target_dir.join(enclosed);
if entry.is_dir() {
std::fs::create_dir_all(&out_path).map_err(FrontendPackageError::Io)?;
continue;
}
if let Some(parent) = out_path.parent() {
std::fs::create_dir_all(parent).map_err(FrontendPackageError::Io)?;
}
let mut out_file = File::create(&out_path).map_err(FrontendPackageError::Io)?;
io::copy(&mut entry, &mut out_file).map_err(FrontendPackageError::Io)?;
}
Ok(())
}
#[derive(Debug)]
pub enum FrontendPackageError {
PackageNotFound,
InvalidTarget(PathBuf),
MissingEntry(PathBuf),
InvalidArchivePath(String),
Io(io::Error),
Json(serde_json::Error),
Zip(zip::result::ZipError),
}
impl std::fmt::Display for FrontendPackageError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PackageNotFound => write!(f, "duplicate frontend package not found"),
Self::InvalidTarget(path) => {
write!(
f,
"invalid duplicate frontend target path: {}",
path.display()
)
}
Self::MissingEntry(path) => {
write!(f, "missing extracted frontend entry: {}", path.display())
}
Self::InvalidArchivePath(path) => write!(f, "invalid archive path: {}", path),
Self::Io(error) => write!(f, "i/o error: {}", error),
Self::Json(error) => write!(f, "json error: {}", error),
Self::Zip(error) => write!(f, "zip error: {}", error),
}
}
}
impl std::error::Error for FrontendPackageError {}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use tempfile::tempdir;
fn manifest_fixture() -> FrontendPackageManifest {
FrontendPackageManifest {
schema_version: 1,
frontend_name: "lotus".to_string(),
frontend_version: "1.0.0".to_string(),
bundle_hash: "sha256:abc".to_string(),
built_at: Utc::now(),
entry: "index.html".to_string(),
}
}
fn write_test_zip(path: &Path, manifest: &FrontendPackageManifest) {
let file = File::create(path).expect("zip file should be created");
let mut writer = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default();
writer
.start_file("index.html", options)
.expect("index.html entry should start");
writer
.write_all(b"<html><body>ok</body></html>")
.expect("index.html should write");
writer
.start_file("frontend-manifest.json", options)
.expect("manifest entry should start");
writer
.write_all(serde_json::to_string_pretty(manifest).unwrap().as_bytes())
.expect("manifest should write");
writer.finish().expect("zip should finish");
}
#[test]
fn refresh_is_required_when_local_manifest_missing() {
let bundled = manifest_fixture();
let temp = tempdir().unwrap();
assert!(should_refresh_frontend(&bundled, None, temp.path()));
}
#[test]
fn resolve_frontend_package_path_finds_bodhi_frontend_package_layout() {
let temp = tempdir().unwrap();
let bodhi_package_dir = temp.path().join("bodhi/.frontend-package");
std::fs::create_dir_all(&bodhi_package_dir).unwrap();
let package_path = bodhi_package_dir.join("lotus-frontend.zip");
std::fs::write(&package_path, b"zip-bytes").unwrap();
let original_dir = std::env::current_dir().unwrap();
std::env::set_current_dir(temp.path()).unwrap();
let resolved = resolve_frontend_package_path(None);
std::env::set_current_dir(original_dir).unwrap();
let resolved = resolved.expect("frontend package path should resolve");
let resolved_canonical = resolved.canonicalize().unwrap();
let expected_canonical = package_path.canonicalize().unwrap();
assert_eq!(resolved_canonical, expected_canonical);
}
#[test]
fn refresh_not_required_when_manifest_matches_and_entry_exists() {
let bundled = manifest_fixture();
let temp = tempdir().unwrap();
std::fs::write(temp.path().join("index.html"), "ok").unwrap();
assert!(!should_refresh_frontend(
&bundled,
Some(&bundled),
temp.path()
));
}
#[test]
fn ensure_current_frontend_dir_extracts_bundle_into_bamboo_frontend() {
let package_temp = tempdir().unwrap();
let package_path = package_temp.path().join("lotus-frontend.zip");
let manifest = manifest_fixture();
write_test_zip(&package_path, &manifest);
let data_temp = tempdir().unwrap();
bamboo_infrastructure::paths::init_bamboo_dir(data_temp.path().to_path_buf());
let status = ensure_current_frontend_dir(Some(&package_path))
.expect("frontend extraction should succeed");
assert!(status.refreshed);
assert!(status
.frontend_dir
.join(&status.bundled_manifest.entry)
.is_file());
assert!(status
.frontend_dir
.join(DUPLICATE_FRONTEND_MANIFEST_NAME)
.is_file());
let local_manifest = read_local_manifest(&status.local_manifest_path)
.expect("local manifest should read")
.expect("local manifest should exist");
assert_eq!(
local_manifest.frontend_version,
status.bundled_manifest.frontend_version
);
assert_eq!(
local_manifest.bundle_hash,
status.bundled_manifest.bundle_hash
);
}
}