use std::ffi::OsStr;
use std::fs;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::time::Duration;
use anyhow::{Context as _, Result as AnyResult, anyhow, bail};
use indicatif::{ProgressBar, ProgressStyle};
use moka::sync::Cache;
use rayon::iter::{ParallelBridge as _, ParallelIterator as _};
use size_format::SizeFormatterSI;
use usize_cast::FromUsize as _;
use walkdir::WalkDir;
use xxhash_rust::xxh3::xxh3_128;
use super::{EncoderConfig, TileFormat, convert_buffer, whole_rate_per_sec};
const MAX_TILE_TRACK_SIZE: usize = 1024;
const CACHE_MAX_BYTES: u64 = 512 * 1024 * 1024;
type EncodedCache = Cache<u128, Arc<Vec<u8>>>;
fn make_cache(max_bytes: u64) -> EncodedCache {
Cache::builder()
.max_capacity(max_bytes)
.weigher(|_key, value: &Arc<Vec<u8>>| u32::try_from(value.len()).unwrap_or(u32::MAX))
.build()
}
#[derive(Default)]
struct DedupStats {
hits: AtomicU64,
encoded: AtomicU64,
bytes_saved: AtomicU64,
}
impl DedupStats {
fn record_hit(&self, size: usize) {
self.hits.fetch_add(1, Ordering::Relaxed);
self.bytes_saved
.fetch_add(u64::from_usize(size), Ordering::Relaxed);
}
fn record_encode(&self) {
self.encoded.fetch_add(1, Ordering::Relaxed);
}
}
#[expect(
clippy::cast_precision_loss,
reason = "hit/miss counts are well below 2^52 for realistic tilesets"
)]
fn format_dedup_line(stats: &DedupStats, cache: &EncodedCache) -> String {
cache.run_pending_tasks();
let hits = stats.hits.load(Ordering::Relaxed);
let encoded = stats.encoded.load(Ordering::Relaxed);
let bytes_saved = stats.bytes_saved.load(Ordering::Relaxed);
let total = hits + encoded;
let hit_rate = if total == 0 {
0.0
} else {
(hits as f64 * 100.0) / (total as f64)
};
format!(
" dedup: {encoded} unique encoded, {hits} cached ({hit_rate:.1}% hit rate, \
~{:.1}B of encode work skipped); cache weight {:.1}B",
SizeFormatterSI::new(bytes_saved),
SizeFormatterSI::new(cache.weighted_size()),
)
}
fn is_convert_extension(path: &Path) -> bool {
matches!(
path.extension().and_then(OsStr::to_str),
Some("mlt" | "mvt" | "pbf")
)
}
struct WalkCtx<'a> {
base: &'a Path,
output: &'a Path,
cfg: EncoderConfig,
to: TileFormat,
cache: &'a EncodedCache,
stats: &'a DedupStats,
}
pub fn convert(input: &Path, output: &Path, cfg: EncoderConfig, to: TileFormat) -> AnyResult<()> {
let base = if input.is_dir() {
input
} else {
input.parent().unwrap_or(Path::new("."))
};
let cache: EncodedCache = make_cache(CACHE_MAX_BYTES);
let stats = DedupStats::default();
let failed = AtomicUsize::new(0);
let ctx = WalkCtx {
base,
output,
cfg,
to,
cache: &cache,
stats: &stats,
};
let bar = ProgressBar::new_spinner();
bar.set_style(
ProgressStyle::default_spinner()
.template("{spinner} {elapsed_precise} [{pos} files, {rate}] {msg}")
.expect("invalid spinner template")
.with_key("rate", whole_rate_per_sec),
);
bar.enable_steady_tick(Duration::from_millis(100));
let emit = |msg: String| {
if bar.is_hidden() {
eprintln!("{msg}");
} else {
bar.println(msg);
}
};
WalkDir::new(input)
.into_iter()
.filter_map(|r| match r {
Ok(e) => Some(e),
Err(e) => {
emit(format!("warning: walkdir: {e}"));
failed.fetch_add(1, Ordering::Relaxed);
None
}
})
.filter(|e| e.file_type().is_file() && is_convert_extension(e.path()))
.par_bridge()
.for_each(|entry| {
let in_path = entry.into_path();
let result = convert_file(&in_path, &ctx);
bar.inc(1);
if let Err(e) = result {
emit(format!("error: {}: {e:#}", in_path.display()));
failed.fetch_add(1, Ordering::Relaxed);
}
});
bar.finish_and_clear();
let n = failed.into_inner();
if n > 0 {
bail!("{n} file(s) failed to convert");
}
let processed = stats.hits.load(Ordering::Relaxed) + stats.encoded.load(Ordering::Relaxed);
if processed == 0 {
eprintln!("No .mlt, .mvt, or .pbf files found in {}", input.display());
return Ok(());
}
eprintln!("{}", format_dedup_line(&stats, &cache));
Ok(())
}
fn convert_file(file: &Path, ctx: &WalkCtx<'_>) -> AnyResult<()> {
let rel = file
.strip_prefix(ctx.base)
.with_context(|| format!("stripping prefix from {}", file.display()))?;
let out_path = ctx.output.join(rel).with_extension(ctx.to.extension());
if let Some(parent) = out_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("creating directory {}", parent.display()))?;
}
let buffer = fs::read(file).with_context(|| format!("reading {}", file.display()))?;
let from = TileFormat::from_path(file);
let err_ctx = || {
format!(
"converting {} {}",
from.extension().to_uppercase(),
file.display()
)
};
if buffer.len() > MAX_TILE_TRACK_SIZE {
let out_bytes = convert_buffer(buffer, from, ctx.to, ctx.cfg).with_context(err_ctx)?;
ctx.stats.record_encode();
fs::write(&out_path, &out_bytes)
.with_context(|| format!("writing {}", out_path.display()))?;
return Ok(());
}
let key = xxh3_128(&buffer);
let entry = ctx
.cache
.entry(key)
.or_try_insert_with(|| -> AnyResult<Arc<Vec<u8>>> {
let out_bytes = convert_buffer(buffer, from, ctx.to, ctx.cfg).with_context(err_ctx)?;
Ok(Arc::new(out_bytes))
})
.map_err(|e: Arc<anyhow::Error>| anyhow!("{e:#}"))?;
let is_fresh = entry.is_fresh();
let out_arc = entry.into_value();
if is_fresh {
ctx.stats.record_encode();
} else {
ctx.stats.record_hit(out_arc.len());
}
fs::write(&out_path, out_arc.as_slice())
.with_context(|| format!("writing {}", out_path.display()))?;
Ok(())
}