use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use anyhow::{Context as _, Result};
use clap::Args;
use openlogi_assets::{AssetRegistry, FRONT_RENDER_FILES, FetchOutcome, METADATA_FILES, http};
fn is_optional_asset(name: &str) -> bool {
if name == "side_core.png" || name == "side.png" {
return true;
}
let path = std::path::Path::new(name);
let ext_is_png = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("png"));
if !ext_is_png {
return false;
}
name.starts_with("front_ext") || name.starts_with("side_ext")
}
#[derive(Debug, Args)]
pub struct SyncArgs {
#[arg(long, env = "OPENLOGI_ASSETS")]
base: Option<String>,
#[arg(long, default_value = "crates/openlogi-gui/assets")]
out: PathBuf,
}
pub fn run(args: SyncArgs) -> Result<()> {
let SyncArgs { base, out } = args;
fs::create_dir_all(&out).with_context(|| format!("create {}", out.display()))?;
let registry = AssetRegistry::load(base.as_deref(), &out)?;
let client = registry.client();
let index = registry.index();
println!("asset source: {}", registry.source());
println!("index.json: {} devices", index.devices.len());
let expected: HashSet<&str> = index.devices.keys().map(String::as_str).collect();
for entry in fs::read_dir(&out)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !expected.contains(name_str.as_ref()) {
println!(" pruning {name_str}");
fs::remove_dir_all(entry.path())?;
}
}
let mut fetched = 0_u32;
let mut cache_hits = 0_u32;
let mut depots: Vec<&String> = index.devices.keys().collect();
depots.sort();
for depot in depots {
let entry = &index.devices[depot];
let dir = http::safe_component_path(&out, depot, "asset depot")?;
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let baseline = entry.baseline_files();
let wanted: Vec<&openlogi_assets::FileEntry> = entry
.files
.iter()
.filter(|f| baseline.contains(&f.name.as_str()) || is_optional_asset(&f.name))
.collect();
if entry.preferred_file(&METADATA_FILES).is_none() {
eprintln!(" WARN {depot}: no hotspot metadata (core_metadata.json / metadata.json)");
}
if entry.preferred_file(&FRONT_RENDER_FILES).is_none() {
eprintln!(" WARN {depot}: no hero render (front_core.png / front.png)");
}
for &file_entry in &wanted {
match client.fetch_entry_if_stale(&entry.asset_path, &dir, file_entry)? {
FetchOutcome::CacheHit => cache_hits += 1,
FetchOutcome::Fetched { .. } => {
fetched += 1;
println!(" {depot}/{} ({} B)", file_entry.name, file_entry.bytes);
}
}
}
}
let bundle_bytes: u64 = index
.devices
.values()
.map(|d| {
let baseline = d.baseline_files();
d.files
.iter()
.filter(|f| baseline.contains(&f.name.as_str()) || is_optional_asset(&f.name))
.map(|f| f.bytes)
.sum::<u64>()
})
.sum();
#[allow(
clippy::cast_precision_loss,
reason = "bundle sizes are well under 2^53 bytes; f64 precision is fine for a display string"
)]
let mb = bundle_bytes as f64 / 1024.0 / 1024.0;
println!(
"done: {fetched} fetched, {cache_hits} cache-hit, {mb:.1} MB total under {}",
out.display()
);
Ok(())
}
#[cfg(test)]
mod is_optional_asset_tests {
use super::is_optional_asset;
#[test]
fn side_render_names_are_optional() {
assert!(is_optional_asset("side_core.png"));
assert!(is_optional_asset("side.png"));
}
#[test]
fn colour_variant_names_are_optional() {
assert!(is_optional_asset("front_ext1.png"));
assert!(is_optional_asset("front_ext_2.png"));
assert!(is_optional_asset("side_ext_3.png"));
}
#[test]
fn baseline_and_metadata_files_are_not_optional() {
assert!(!is_optional_asset("front_core.png"));
assert!(!is_optional_asset("front.png"));
assert!(!is_optional_asset("manifest.json"));
assert!(!is_optional_asset("core_metadata.json"));
}
#[test]
fn non_png_files_are_never_optional_even_with_a_matching_prefix() {
assert!(!is_optional_asset("front_ext.json"));
}
#[test]
fn prefix_match_is_loose_and_also_matches_unintended_names() {
assert!(is_optional_asset("front_extra_special.png"));
}
#[test]
fn prefix_match_is_case_sensitive_while_the_extension_check_is_not() {
assert!(!is_optional_asset("FRONT_EXT1.PNG"));
}
}