use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::Path;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::engine::BlankTileStrategy;
use crate::pixel::PixelFormat;
use crate::planner::Layout;
use crate::sink::TileFormat;
#[derive(Debug, Error)]
pub enum ManifestError {
#[error("manifest I/O error: {0}")]
Io(#[from] io::Error),
#[error("manifest JSON error: {0}")]
Json(#[from] serde_json::Error),
}
pub type BlankReferences = BTreeMap<String, String>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ChecksumAlgo {
Blake3,
Sha256,
}
impl Default for ChecksumAlgo {
fn default() -> Self {
Self::Blake3
}
}
impl ChecksumAlgo {
pub fn as_str(&self) -> &'static str {
match self {
Self::Blake3 => "blake3",
Self::Sha256 => "sha256",
}
}
pub fn hash(&self, bytes: &[u8]) -> String {
match self {
Self::Blake3 => {
let h = blake3::hash(bytes);
hex_lower(h.as_bytes())
}
Self::Sha256 => {
use sha2::Digest;
let mut hasher = sha2::Sha256::new();
hasher.update(bytes);
let out = hasher.finalize();
hex_lower(&out)
}
}
}
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut s = String::with_capacity(bytes.len() * 2);
for &b in bytes {
s.push(HEX[(b >> 4) as usize] as char);
s.push(HEX[(b & 0x0f) as usize] as char);
}
s
}
mod layout_serde {
use super::Layout;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &Layout, s: S) -> Result<S::Ok, S::Error> {
let name = match v {
Layout::DeepZoom => "deep_zoom",
Layout::Xyz => "xyz",
Layout::Google => "google",
};
s.serialize_str(name)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Layout, D::Error> {
let s = String::deserialize(d)?;
match s.as_str() {
"deep_zoom" => Ok(Layout::DeepZoom),
"xyz" => Ok(Layout::Xyz),
"google" => Ok(Layout::Google),
other => Err(serde::de::Error::custom(format!("unknown layout: {other}"))),
}
}
}
mod tile_format_serde {
use super::TileFormat;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "lowercase")]
enum Repr {
Png,
Jpeg { quality: u8 },
Raw,
}
pub fn serialize<S: Serializer>(v: &TileFormat, s: S) -> Result<S::Ok, S::Error> {
let r = match *v {
TileFormat::Png => Repr::Png,
TileFormat::Jpeg { quality } => Repr::Jpeg { quality },
TileFormat::Raw => Repr::Raw,
};
r.serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<TileFormat, D::Error> {
let r = Repr::deserialize(d)?;
Ok(match r {
Repr::Png => TileFormat::Png,
Repr::Jpeg { quality } => TileFormat::Jpeg { quality },
Repr::Raw => TileFormat::Raw,
})
}
}
mod blank_strategy_serde {
use super::BlankTileStrategy;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
enum Repr {
Emit,
Placeholder,
PlaceholderWithTolerance {
#[serde(default)]
tolerance: u8,
},
}
pub fn serialize<S: Serializer>(v: &BlankTileStrategy, s: S) -> Result<S::Ok, S::Error> {
let r = match *v {
BlankTileStrategy::Emit => Repr::Emit,
BlankTileStrategy::Placeholder => Repr::Placeholder,
BlankTileStrategy::PlaceholderWithTolerance { max_channel_delta } => {
Repr::PlaceholderWithTolerance {
tolerance: max_channel_delta,
}
}
};
r.serialize(s)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<BlankTileStrategy, D::Error> {
let r = Repr::deserialize(d)?;
Ok(match r {
Repr::Emit => BlankTileStrategy::Emit,
Repr::Placeholder => BlankTileStrategy::Placeholder,
Repr::PlaceholderWithTolerance { tolerance } => {
BlankTileStrategy::PlaceholderWithTolerance {
max_channel_delta: tolerance,
}
}
})
}
}
mod pixel_format_serde {
use super::PixelFormat;
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(v: &PixelFormat, s: S) -> Result<S::Ok, S::Error> {
let name = match v {
PixelFormat::Gray8 => "gray8",
PixelFormat::Gray16 => "gray16",
PixelFormat::Rgb8 => "rgb8",
PixelFormat::Rgba8 => "rgba8",
PixelFormat::Rgb16 => "rgb16",
PixelFormat::Rgba16 => "rgba16",
};
s.serialize_str(name)
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<PixelFormat, D::Error> {
let s = String::deserialize(d)?;
match s.as_str() {
"gray8" => Ok(PixelFormat::Gray8),
"gray16" => Ok(PixelFormat::Gray16),
"rgb8" => Ok(PixelFormat::Rgb8),
"rgba8" => Ok(PixelFormat::Rgba8),
"rgb16" => Ok(PixelFormat::Rgb16),
"rgba16" => Ok(PixelFormat::Rgba16),
other => Err(serde::de::Error::custom(format!(
"unknown pixel_format: {other}"
))),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct GenerationSettings {
pub tile_size: u32,
pub overlap: u32,
#[serde(with = "layout_serde")]
pub layout: Layout,
#[serde(with = "tile_format_serde")]
pub format: TileFormat,
pub concurrency: usize,
pub background_rgb: [u8; 3],
#[serde(with = "blank_strategy_serde")]
pub blank_strategy: BlankTileStrategy,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SourceMetadata {
pub width: u32,
pub height: u32,
#[serde(with = "pixel_format_serde")]
pub pixel_format: PixelFormat,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bytes_hash: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct LevelMetadata {
pub level_index: u32,
pub width: u32,
pub height: u32,
pub tiles_produced: u64,
pub tiles_skipped: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct SparsePolicy {
pub tolerance: u8,
pub dedupe: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct Checksums {
pub algo: ChecksumAlgo,
pub per_tile: BTreeMap<String, String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct BlankReference {
pub path: String,
pub digest: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub struct ManifestV1 {
pub generation: GenerationSettings,
pub source: SourceMetadata,
pub levels: Vec<LevelMetadata>,
pub sparse_policy: SparsePolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub checksums: Option<Checksums>,
pub created_at: String,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub blank_references: BlankReferences,
}
impl ManifestV1 {
pub const SCHEMA_VERSION: &'static str = "1";
pub fn new(generation: GenerationSettings, source: SourceMetadata) -> Self {
Self {
generation,
source,
levels: Vec::new(),
sparse_policy: SparsePolicy {
tolerance: 0,
dedupe: false,
},
checksums: None,
created_at: String::new(),
blank_references: BTreeMap::new(),
}
}
pub fn into_manifest(self) -> Manifest {
Manifest::V1(self)
}
pub fn to_json_string(&self) -> Result<String, ManifestError> {
self.clone().into_manifest().to_json_string()
}
pub fn write_to(&self, path: &Path) -> Result<(), ManifestError> {
self.clone().into_manifest().write_to(path)
}
pub fn read_from(path: &Path) -> Result<Self, ManifestError> {
match Manifest::read_from(path)? {
Manifest::V1(m) => Ok(m),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "schema_version")]
pub enum Manifest {
#[serde(rename = "1")]
V1(ManifestV1),
}
impl Manifest {
pub fn as_v1(&self) -> &ManifestV1 {
match self {
Manifest::V1(m) => m,
}
}
pub fn into_v1(self) -> ManifestV1 {
match self {
Manifest::V1(m) => m,
}
}
pub fn to_json_string(&self) -> Result<String, ManifestError> {
serde_json::to_string_pretty(self).map_err(ManifestError::Json)
}
pub fn write_to(&self, path: &Path) -> Result<(), ManifestError> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
}
let json = self.to_json_string()?;
fs::write(path, json)?;
Ok(())
}
pub fn read_from(path: &Path) -> Result<Self, ManifestError> {
let bytes = fs::read(path)?;
Self::from_json_slice(&bytes)
}
pub fn from_json_slice(bytes: &[u8]) -> Result<Self, ManifestError> {
serde_json::from_slice(bytes).map_err(ManifestError::Json)
}
pub fn from_json_reader<R: io::Read>(reader: R) -> Result<Self, ManifestError> {
serde_json::from_reader(reader).map_err(ManifestError::Json)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestBuilder {
checksums: Option<ChecksumAlgo>,
include_source_hash: bool,
dedupe: Option<bool>,
tolerance: Option<u8>,
}
impl ManifestBuilder {
pub fn new() -> Self {
Self {
checksums: None,
include_source_hash: false,
dedupe: None,
tolerance: None,
}
}
pub fn with_checksums(mut self, algo: ChecksumAlgo) -> Self {
self.checksums = Some(algo);
self
}
pub fn include_source_hash(mut self, enabled: bool) -> Self {
self.include_source_hash = enabled;
self
}
pub fn with_dedupe(mut self, dedupe: bool) -> Self {
self.dedupe = Some(dedupe);
self
}
pub fn with_tolerance(mut self, tolerance: u8) -> Self {
self.tolerance = Some(tolerance);
self
}
pub fn checksum_algo(&self) -> Option<ChecksumAlgo> {
self.checksums
}
pub fn wants_source_hash(&self) -> bool {
self.include_source_hash
}
pub fn dedupe_override(&self) -> Option<bool> {
self.dedupe
}
pub fn tolerance_override(&self) -> Option<u8> {
self.tolerance
}
}
impl Default for ManifestBuilder {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_manifest() -> ManifestV1 {
ManifestV1 {
generation: GenerationSettings {
tile_size: 256,
overlap: 0,
layout: Layout::DeepZoom,
format: TileFormat::Jpeg { quality: 80 },
concurrency: 4,
background_rgb: [255, 255, 255],
blank_strategy: BlankTileStrategy::Emit,
},
source: SourceMetadata {
width: 1024,
height: 768,
pixel_format: PixelFormat::Rgba8,
bytes_hash: None,
},
levels: vec![LevelMetadata {
level_index: 0,
width: 1,
height: 1,
tiles_produced: 1,
tiles_skipped: 0,
}],
sparse_policy: SparsePolicy {
tolerance: 0,
dedupe: false,
},
checksums: None,
created_at: "2026-01-01T00:00:00Z".to_string(),
blank_references: BTreeMap::new(),
}
}
#[test]
fn round_trips_through_json() {
let m = sample_manifest().into_manifest();
let s = m.to_json_string().unwrap();
let parsed: Manifest = serde_json::from_str(&s).unwrap();
assert_eq!(parsed, m);
}
#[test]
fn ignores_unknown_fields() {
let m = sample_manifest().into_manifest();
let mut v: serde_json::Value = serde_json::from_str(&m.to_json_string().unwrap()).unwrap();
v.as_object_mut()
.unwrap()
.insert("future".into(), serde_json::json!("ignored"));
let bumped = serde_json::to_string(&v).unwrap();
let parsed: Manifest = serde_json::from_str(&bumped).unwrap();
match parsed {
Manifest::V1(_) => {}
}
}
#[test]
fn rejects_unknown_schema_version() {
let m = sample_manifest().into_manifest();
let mut v: serde_json::Value = serde_json::from_str(&m.to_json_string().unwrap()).unwrap();
v.as_object_mut()
.unwrap()
.insert("schema_version".into(), serde_json::json!("99"));
let bumped = serde_json::to_string(&v).unwrap();
let parsed: Result<Manifest, _> = serde_json::from_str(&bumped);
assert!(parsed.is_err(), "unknown schema_version must fail to parse");
}
#[test]
fn checksum_algo_serializes_lowercase() {
let s = serde_json::to_string(&ChecksumAlgo::Blake3).unwrap();
assert_eq!(s, "\"blake3\"");
let s2 = serde_json::to_string(&ChecksumAlgo::Sha256).unwrap();
assert_eq!(s2, "\"sha256\"");
}
#[test]
fn blake3_hex_digest_is_64_chars() {
let h = ChecksumAlgo::Blake3.hash(b"hello world");
assert_eq!(h.len(), 64);
assert!(
h.chars()
.all(|c| c.is_ascii_hexdigit() && !c.is_uppercase())
);
}
#[test]
fn sha256_hex_digest_is_64_chars() {
let h = ChecksumAlgo::Sha256.hash(b"hello world");
assert_eq!(h.len(), 64);
assert_eq!(
h,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn builder_records_options() {
let b = ManifestBuilder::new()
.with_checksums(ChecksumAlgo::Sha256)
.include_source_hash(true)
.with_dedupe(true)
.with_tolerance(4);
assert_eq!(b.checksum_algo(), Some(ChecksumAlgo::Sha256));
assert!(b.wants_source_hash());
assert_eq!(b.dedupe_override(), Some(true));
assert_eq!(b.tolerance_override(), Some(4));
}
#[test]
fn blank_strategy_with_tolerance_round_trips() {
let m_in = ManifestV1 {
generation: GenerationSettings {
tile_size: 256,
overlap: 0,
layout: Layout::Xyz,
format: TileFormat::Png,
concurrency: 1,
background_rgb: [0, 0, 0],
blank_strategy: BlankTileStrategy::PlaceholderWithTolerance {
max_channel_delta: 7,
},
},
source: SourceMetadata {
width: 10,
height: 10,
pixel_format: PixelFormat::Rgb8,
bytes_hash: None,
},
levels: Vec::new(),
sparse_policy: SparsePolicy {
tolerance: 7,
dedupe: true,
},
checksums: None,
created_at: String::new(),
blank_references: BTreeMap::new(),
};
let wire = m_in.to_json_string().unwrap();
let parsed = Manifest::from_json_slice(wire.as_bytes())
.unwrap()
.into_v1();
assert_eq!(
parsed.generation.blank_strategy,
BlankTileStrategy::PlaceholderWithTolerance {
max_channel_delta: 7,
}
);
}
}