use std::{
fs::{self, File},
io::{self, Write},
path::{Path, PathBuf},
};
use chrono::Local;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::{
console::{Console, ProgressFormat, ProgressReporter},
error::{DoomError, Result},
};
const MANIFEST_FILE_NAME: &str = "mirror-manifest.json";
const DEFAULT_MANIFEST_VERSION: u32 = 1;
const SUPPORTED_EXPORT_EXTENSIONS: &[&str] = &["png", "tif", "tiff", "dds"];
#[derive(Debug, Clone)]
pub struct BuildMirrorRequest {
pub samuel_export_root: Option<PathBuf>,
pub model_export_root: Option<PathBuf>,
pub output_root: PathBuf,
pub max_chunk_bytes: u64,
pub dry_run: bool,
}
#[derive(Debug, Clone)]
pub struct HydrateMirrorRequest {
pub base_url: String,
pub cache_root: PathBuf,
pub dry_run: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MirrorManifest {
pub version: u32,
pub created_at: String,
pub chunks: Vec<MirrorChunk>,
pub total_files: u64,
pub total_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct MirrorChunk {
pub file_name: String,
pub file_count: u64,
pub uncompressed_bytes: u64,
pub roots: Vec<String>,
}
#[derive(Debug, Clone)]
struct MirrorFileEntry {
disk_path: PathBuf,
archive_path: PathBuf,
size: u64,
root_label: String,
}
#[derive(Debug, Clone)]
struct BuiltChunk {
manifest: MirrorChunk,
entries: Vec<MirrorFileEntry>,
}
pub fn manifest_file_name() -> &'static str {
MANIFEST_FILE_NAME
}
pub fn default_chunk_size_mb() -> u64 {
24
}
pub fn default_chunk_size_bytes() -> u64 {
default_chunk_size_mb() * 1024 * 1024
}
pub fn build_export_mirror(
console: &Console,
request: BuildMirrorRequest,
) -> Result<MirrorManifest> {
let mut entries = Vec::new();
if let Some(root) = request.samuel_export_root.as_deref() {
collect_root_entries(root, "exports", &mut entries)?;
}
if let Some(root) = request.model_export_root.as_deref() {
collect_root_entries(root, "modelExports", &mut entries)?;
}
if entries.is_empty() {
return Err(DoomError::message(
"No supported Samuel export files were found to mirror.",
));
}
entries.sort_by(|left, right| left.archive_path.cmp(&right.archive_path));
let built_chunks = chunk_entries(&entries, request.max_chunk_bytes.max(1));
let total_files = entries.len() as u64;
let total_bytes = entries.iter().map(|entry| entry.size).sum::<u64>();
let manifest = MirrorManifest {
version: DEFAULT_MANIFEST_VERSION,
created_at: Local::now().to_rfc3339(),
chunks: built_chunks
.iter()
.map(|chunk| chunk.manifest.clone())
.collect::<Vec<_>>(),
total_files,
total_bytes,
};
console.log_info(format!(
"Packing {} mirrored export file(s) into {} chunk(s).",
total_files,
built_chunks.len()
));
if request.dry_run {
for chunk in &built_chunks {
console.log_dry_run(format!(
"write {} with {} file(s)",
console.format_path(request.output_root.join(&chunk.manifest.file_name)),
chunk.manifest.file_count
));
}
console.log_dry_run(format!(
"write {}",
console.format_path(request.output_root.join(MANIFEST_FILE_NAME))
));
return Ok(manifest);
}
if request.output_root.exists() {
fs::remove_dir_all(&request.output_root)?;
}
fs::create_dir_all(&request.output_root)?;
let mut reporter = ProgressReporter::new(
console,
"Mirror chunks",
built_chunks.len() as u64,
ProgressFormat::FileCount,
);
reporter.update(0, true);
for chunk in &built_chunks {
write_chunk_archive(&request.output_root, chunk)?;
reporter.advance(1);
}
reporter.finish();
let manifest_path = request.output_root.join(MANIFEST_FILE_NAME);
fs::write(&manifest_path, serde_json::to_string_pretty(&manifest)?)?;
console.log_success(format!(
"Mirror manifest: {}",
console.format_path(&manifest_path)
));
Ok(manifest)
}
pub async fn hydrate_export_mirror(
console: &Console,
request: HydrateMirrorRequest,
) -> Result<(Option<PathBuf>, Option<PathBuf>)> {
let base_url = request.base_url.trim_end_matches('/').to_string();
if base_url.is_empty() {
return Err(DoomError::message("Mirror URL cannot be empty."));
}
let downloads_root = request.cache_root.join("downloads");
let extract_root = request.cache_root.join("extracted");
let manifest_path = downloads_root.join(MANIFEST_FILE_NAME);
let manifest_url = format!("{base_url}/{MANIFEST_FILE_NAME}");
console.log_info(format!("Fetching mirror manifest from {manifest_url}"));
if request.dry_run {
console.log_dry_run(format!(
"download {manifest_url} -> {}",
console.format_path(&manifest_path)
));
return Ok((
Some(extract_root.join("exports")),
Some(extract_root.join("modelExports")),
));
}
fs::create_dir_all(&downloads_root)?;
fs::create_dir_all(&extract_root)?;
let client = client()?;
download_to_path(&client, &manifest_url, &manifest_path).await?;
let manifest = serde_json::from_str::<MirrorManifest>(&fs::read_to_string(&manifest_path)?)?;
let mut reporter = ProgressReporter::new(
console,
"Mirror download",
manifest.chunks.len() as u64,
ProgressFormat::FileCount,
);
reporter.update(0, true);
for chunk in &manifest.chunks {
let chunk_url = format!("{base_url}/{}", chunk.file_name);
let chunk_path = downloads_root.join(&chunk.file_name);
if !chunk_path.is_file() {
download_to_path(&client, &chunk_url, &chunk_path).await?;
}
extract_zip(&chunk_path, &extract_root)?;
reporter.advance(1);
}
reporter.finish();
let samuel_root = extract_root.join("exports");
let model_root = extract_root.join("modelExports");
Ok((
samuel_root.is_dir().then_some(samuel_root),
model_root.is_dir().then_some(model_root),
))
}
fn collect_root_entries(root: &Path, label: &str, output: &mut Vec<MirrorFileEntry>) -> Result<()> {
if !root.is_dir() {
return Err(DoomError::message(format!(
"Missing mirror source root: {}",
root.display()
)));
}
for entry in walkdir::WalkDir::new(root)
.into_iter()
.filter_map(|entry| entry.ok())
{
if !entry.file_type().is_file() {
continue;
}
let path = entry.into_path();
if !is_supported_export_file(&path) {
continue;
}
let relative = path.strip_prefix(root)?.to_path_buf();
let metadata = fs::metadata(&path)?;
output.push(MirrorFileEntry {
disk_path: path,
archive_path: PathBuf::from(label).join(relative),
size: metadata.len(),
root_label: label.to_string(),
});
}
Ok(())
}
fn is_supported_export_file(path: &Path) -> bool {
path.extension()
.and_then(|value| value.to_str())
.map(|value| {
SUPPORTED_EXPORT_EXTENSIONS
.iter()
.any(|extension| value.eq_ignore_ascii_case(extension))
})
.unwrap_or(false)
}
fn chunk_entries(entries: &[MirrorFileEntry], max_chunk_bytes: u64) -> Vec<BuiltChunk> {
let mut chunks = Vec::new();
let mut current_entries = Vec::new();
let mut current_size = 0_u64;
let mut chunk_index = 1_u64;
for entry in entries {
let would_overflow = !current_entries.is_empty()
&& current_size.saturating_add(entry.size) > max_chunk_bytes;
if would_overflow {
chunks.push(make_chunk(chunk_index, ¤t_entries, current_size));
chunk_index += 1;
current_entries.clear();
current_size = 0;
}
current_size = current_size.saturating_add(entry.size);
current_entries.push(entry.clone());
}
if !current_entries.is_empty() {
chunks.push(make_chunk(chunk_index, ¤t_entries, current_size));
}
chunks
}
fn make_chunk(index: u64, entries: &[MirrorFileEntry], size: u64) -> BuiltChunk {
let mut roots = entries
.iter()
.map(|entry| entry.root_label.clone())
.collect::<Vec<_>>();
roots.sort();
roots.dedup();
BuiltChunk {
manifest: MirrorChunk {
file_name: format!("mirror-part-{index:04}.zip"),
file_count: entries.len() as u64,
uncompressed_bytes: size,
roots,
},
entries: entries.to_vec(),
}
}
fn write_chunk_archive(output_root: &Path, chunk: &BuiltChunk) -> Result<()> {
let archive_path = output_root.join(&chunk.manifest.file_name);
let file = File::create(&archive_path)?;
let mut archive = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated);
for entry in &chunk.entries {
let archive_name = entry
.archive_path
.components()
.map(|component| component.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/");
archive.start_file(archive_name, options)?;
let mut input = File::open(&entry.disk_path)?;
io::copy(&mut input, &mut archive)?;
}
archive.finish()?;
Ok(())
}
fn extract_zip(zip_path: &Path, destination: &Path) -> Result<()> {
let file = File::open(zip_path)?;
let mut archive = zip::ZipArchive::new(file)?;
for index in 0..archive.len() {
let mut item = archive.by_index(index)?;
let enclosed = item
.enclosed_name()
.ok_or_else(|| DoomError::message("Archive contained an invalid path."))?;
let output_path = destination.join(enclosed);
if item.name().ends_with('/') {
fs::create_dir_all(&output_path)?;
continue;
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let mut output = File::create(&output_path)?;
io::copy(&mut item, &mut output)?;
output.flush()?;
}
Ok(())
}
fn client() -> Result<Client> {
Ok(Client::builder().user_agent("doom-eternal/0.1.0").build()?)
}
async fn download_to_path(client: &Client, url: &str, destination: &Path) -> Result<()> {
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
let mut response = client.get(url).send().await?.error_for_status()?;
let mut file = File::create(destination)?;
while let Some(chunk) = response.chunk().await? {
file.write_all(&chunk)?;
}
file.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use std::{fs, path::Path};
use tempfile::TempDir;
use crate::console::Console;
use super::{build_export_mirror, BuildMirrorRequest};
#[test]
fn mirror_builder_splits_exports_into_multiple_archives() {
let temp_dir = TempDir::new().expect("tempdir");
let exports_root = temp_dir.path().join("exports");
let models_root = temp_dir.path().join("modelExports");
write_blob(&exports_root.join("a").join("one.png"), 64);
write_blob(&exports_root.join("a").join("two.png"), 64);
write_blob(&models_root.join("b").join("three.dds"), 64);
let output_root = temp_dir.path().join("mirror");
let manifest = build_export_mirror(
&Console::new(temp_dir.path().to_path_buf()),
BuildMirrorRequest {
samuel_export_root: Some(exports_root),
model_export_root: Some(models_root),
output_root: output_root.clone(),
max_chunk_bytes: 100,
dry_run: false,
},
)
.expect("build mirror");
assert_eq!(manifest.total_files, 3);
assert_eq!(manifest.chunks.len(), 3);
assert!(output_root.join("mirror-manifest.json").is_file());
assert!(output_root.join("mirror-part-0001.zip").is_file());
assert!(output_root.join("mirror-part-0002.zip").is_file());
assert!(output_root.join("mirror-part-0003.zip").is_file());
}
fn write_blob(path: &Path, size: usize) {
fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
fs::write(path, vec![b'x'; size]).expect("blob");
}
}