use crate::shortbread::Layer;
use crate::{TileCompression, TilePayloadFormat, TilegenConfig};
use serde_json::{Value, json};
use std::path::Path;
pub const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone)]
pub struct ContractDoc {
pub input: Value,
pub config: Value,
pub build: Value,
}
#[derive(Debug, Clone)]
pub enum ContractState {
Contract(ContractDoc),
Absent,
Unavailable(String),
Invalid,
UnknownSchema(u64),
Incomplete(&'static str),
}
pub fn extract_contract(metadata_json: &str) -> ContractState {
let metadata: Value = match serde_json::from_str(metadata_json) {
Ok(value) => value,
Err(_) => return ContractState::Invalid,
};
let Some(elivagar) = metadata.get("elivagar") else {
return ContractState::Absent;
};
let Some(object) = elivagar.as_object() else {
return ContractState::Invalid;
};
let Some(schema) = object.get("schema").and_then(Value::as_u64) else {
return ContractState::Incomplete("schema");
};
if schema != u64::from(SCHEMA_VERSION) {
return ContractState::UnknownSchema(schema);
}
let Some(input) = object.get("input") else {
return ContractState::Incomplete("input");
};
let Some(config) = object.get("config") else {
return ContractState::Incomplete("config");
};
let Some(build) = object.get("build") else {
return ContractState::Incomplete("build");
};
ContractState::Contract(ContractDoc {
input: input.clone(),
config: config.clone(),
build: build.clone(),
})
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct PbfFeatures {
pub sort_type_then_id: bool,
pub locations_on_ways: bool,
pub way_members_v1: bool,
pub shared_node_pins_v1: bool,
}
impl PbfFeatures {
fn to_json(self) -> Value {
json!({
"sort_type_then_id": self.sort_type_then_id,
"locations_on_ways": self.locations_on_ways,
"way_members_v1": self.way_members_v1,
"shared_node_pins_v1": self.shared_node_pins_v1,
})
}
}
#[derive(Debug, Clone)]
pub struct Input {
pub name: String,
pub xxh3_128: String,
pub bytes: u64,
pub replication_timestamp: Option<i64>,
pub features: PbfFeatures,
}
impl Input {
fn to_json(&self) -> Value {
json!({
"name": self.name,
"xxh3_128": self.xxh3_128,
"bytes": self.bytes,
"replication_timestamp": self.replication_timestamp,
"features": self.features.to_json(),
})
}
}
#[derive(Debug, Clone)]
pub struct OceanContract {
pub mode: &'static str,
pub runtime_simplification: bool,
pub low_zoom_source: &'static str,
pub artifact_key: Option<Value>,
}
impl OceanContract {
fn to_json(&self) -> Value {
json!({
"mode": self.mode,
"runtime_simplification": self.runtime_simplification,
"low_zoom_source": self.low_zoom_source,
"artifact_key": self.artifact_key,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Effective {
pub coordinate_source: &'static str,
pub way_members: &'static str,
pub shared_node_pins: &'static str,
}
impl Effective {
fn to_json(self) -> Value {
json!({
"coordinate_source": self.coordinate_source,
"way_members": self.way_members,
"shared_node_pins": self.shared_node_pins,
})
}
}
fn repo_json(commit: &str, dirty: &str) -> Value {
json!({
"commit": commit,
"dirty": match dirty {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
_ => Value::Null,
},
})
}
fn build_json() -> Value {
json!({
"elivagar": repo_json(
env!("ELIVAGAR_BUILD_ELIVAGAR_COMMIT"),
env!("ELIVAGAR_BUILD_ELIVAGAR_DIRTY"),
),
"pbfhogg_reader": { "version": env!("ELIVAGAR_BUILD_PBFHOGG_VERSION") },
"cargo_lock_xxh3_128": env!("ELIVAGAR_BUILD_CARGO_LOCK_XXH3_128"),
"cargo_features": env!("ELIVAGAR_BUILD_CARGO_FEATURES")
.split(',')
.filter(|f| !f.is_empty())
.collect::<Vec<_>>(),
})
}
fn tile_format_name(format: TilePayloadFormat) -> &'static str {
match format {
TilePayloadFormat::Mvt => "mvt",
TilePayloadFormat::Mlt => "mlt",
}
}
fn tile_compression_name(compression: TileCompression) -> &'static str {
match compression {
TileCompression::Gzip => "gzip",
TileCompression::Brotli => "brotli",
}
}
fn layer_map(values: &[u32], zero_is_default: bool) -> Value {
let mut map = serde_json::Map::new();
for layer in Layer::ALL {
let idx = layer as usize;
let Some(&value) = values.get(idx) else {
continue;
};
if zero_is_default && value == 0 {
continue;
}
map.insert(layer.name().to_string(), json!(value));
}
Value::Object(map)
}
fn config_json(config: &TilegenConfig, ocean: &OceanContract) -> Value {
let seam: Vec<u32> = config
.seam_reconcile_layers
.iter()
.map(|&v| u32::from(v))
.collect();
json!({
"profile": "shortbread",
"min_zoom": config.min_zoom,
"max_zoom": config.max_zoom,
"tile": {
"format": tile_format_name(config.tile_format),
"compression": tile_compression_name(config.tile_compression),
"base_compression_level": config.compression_level,
"compression_policy": "zoom-v1",
},
"seam_reconcile_layers": layer_map(&seam, true),
"fanout_caps": layer_map(&config.fanout_caps, true),
"polygon_simplify_factor": config.polygon_simplify_factor,
"ocean": ocean.to_json(),
})
}
pub fn producer_config(config: &TilegenConfig) -> Value {
let seam: Vec<u32> = config
.seam_reconcile_layers
.iter()
.map(|&v| u32::from(v))
.collect();
json!({
"min_zoom": config.min_zoom,
"max_zoom": config.max_zoom,
"fanout_caps": layer_map(&config.fanout_caps, true),
"polygon_simplify_factor": config.polygon_simplify_factor,
"seam_reconcile_layers": layer_map(&seam, true),
})
}
pub fn producer_config_diff(checkpoint: &Value, current: &Value) -> Vec<String> {
let mut diffs = Vec::new();
let empty = serde_json::Map::new();
let a = checkpoint.as_object().unwrap_or(&empty);
let b = current.as_object().unwrap_or(&empty);
for key in a.keys().chain(b.keys()) {
let (was, now) = (a.get(key), b.get(key));
if was != now && !diffs.iter().any(|d: &String| d.starts_with(key.as_str())) {
diffs.push(format!(
"{key} (chunks: {}, this run: {})",
was.unwrap_or(&Value::Null),
now.unwrap_or(&Value::Null)
));
}
}
diffs
}
pub fn build(
input: &Input,
config: &TilegenConfig,
ocean: &OceanContract,
effective: Option<Effective>,
resumed_from: Option<&str>,
) -> Value {
let mut value = json!({
"schema": SCHEMA_VERSION,
"input": input.to_json(),
"config": config_json(config, ocean),
"build": build_json(),
"execution": { "resumed_from": resumed_from },
});
if let Some(effective) = effective
&& let Some(obj) = value.as_object_mut()
{
obj.insert("effective".to_string(), effective.to_json());
}
value
}
pub fn metadata_member(value: &Value) -> String {
format!("\"elivagar\":{value}")
}
pub fn hash_file(path: &Path) -> std::io::Result<(String, u64)> {
use std::io::Read;
let mut file = std::fs::File::open(path)?;
let mut hasher = xxhash_rust::xxh3::Xxh3::new();
let mut buf = vec![0_u8; 8 << 20];
let mut len: u64 = 0;
loop {
let n = file.read(&mut buf)?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
len += n as u64;
}
let digest = hasher.digest128();
Ok((format!("{digest:032x}"), len))
}
pub struct BackgroundHash {
handle: std::thread::JoinHandle<std::io::Result<(String, u64)>>,
}
impl BackgroundHash {
pub fn spawn(path: std::path::PathBuf) -> Self {
let handle = std::thread::Builder::new()
.name("input-hash".to_string())
.spawn(move || hash_file(&path))
.expect("spawning the input-hash thread");
Self { handle }
}
pub fn join(self) -> std::io::Result<(String, u64)> {
self.handle.join().map_err(|_| {
std::io::Error::other("input-hash thread panicked while hashing the input PBF")
})?
}
}
#[cfg(test)]
mod tests {
use super::*;
fn features() -> PbfFeatures {
PbfFeatures {
sort_type_then_id: true,
locations_on_ways: true,
way_members_v1: true,
shared_node_pins_v1: true,
}
}
fn input() -> Input {
Input {
name: "denmark-locations-prepass.osm.pbf".to_string(),
xxh3_128: "58c47f32d3a55b04a56813565efc78ac".to_string(),
bytes: 531_544_047,
replication_timestamp: Some(1_771_622_445),
features: features(),
}
}
#[test]
fn metadata_member_is_a_json_object_member() {
let value = json!({"schema": SCHEMA_VERSION});
let member = metadata_member(&value);
assert!(member.starts_with("\"elivagar\":{"));
let wrapped = format!("{{{member}}}");
let parsed: Value =
serde_json::from_str(&wrapped).expect("member must splice into a valid object");
assert_eq!(parsed["elivagar"]["schema"], SCHEMA_VERSION);
}
#[test]
fn input_records_hash_and_observed_features() {
let value = input().to_json();
assert_eq!(value["xxh3_128"], "58c47f32d3a55b04a56813565efc78ac");
assert_eq!(value["bytes"], 531_544_047_u64);
assert_eq!(value["features"]["locations_on_ways"], true);
assert_eq!(value["features"]["way_members_v1"], true);
assert_eq!(value["features"]["shared_node_pins_v1"], true);
assert_eq!(value["features"]["sort_type_then_id"], true);
}
#[test]
fn raw_and_locations_inputs_differ_in_the_contract() {
let locations = input().to_json();
let mut raw_input = input();
raw_input.name = "denmark-raw.osm.pbf".to_string();
raw_input.xxh3_128 = "aa5bb8650000000000000000deadbeef".to_string();
raw_input.features = PbfFeatures {
sort_type_then_id: true,
..PbfFeatures::default()
};
let raw = raw_input.to_json();
assert_ne!(raw, locations);
assert_ne!(raw["features"], locations["features"]);
assert_ne!(raw["xxh3_128"], locations["xxh3_128"]);
}
#[test]
fn layer_map_omits_defaults_and_keys_by_name() {
let mut caps = [0_u32; Layer::count()];
caps[Layer::Boundaries as usize] = 4096;
let value = layer_map(&caps, true);
let obj = value.as_object().expect("layer map must be an object");
assert_eq!(obj.len(), 1, "zero-valued layers must be omitted");
assert_eq!(value[Layer::Boundaries.name()], 4096);
}
#[test]
fn hash_file_matches_xxh3_of_contents() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("input.bin");
let bytes = b"elivagar provenance".to_vec();
std::fs::write(&path, &bytes).expect("write");
let (hash, len) = hash_file(&path).expect("hash");
assert_eq!(len, bytes.len() as u64);
assert_eq!(
hash,
format!("{:032x}", xxhash_rust::xxh3::xxh3_128(&bytes))
);
assert_eq!(hash.len(), 32, "must be 32 lowercase hex chars like brokkr");
}
#[test]
fn hash_file_streams_across_buffer_boundaries() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("input.bin");
let mut bytes = vec![0_u8; (8 << 20) + 4097];
for (i, b) in bytes.iter_mut().enumerate() {
#[allow(clippy::cast_possible_truncation)]
{
*b = (i % 251) as u8;
}
}
std::fs::write(&path, &bytes).expect("write");
let (hash, len) = hash_file(&path).expect("hash");
assert_eq!(len, bytes.len() as u64);
assert_eq!(
hash,
format!("{:032x}", xxhash_rust::xxh3::xxh3_128(&bytes))
);
}
#[test]
fn background_hash_matches_synchronous_hash() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("input.bin");
std::fs::write(&path, b"elivagar provenance").expect("write");
let background = BackgroundHash::spawn(path.clone()).join().expect("join");
let synchronous = hash_file(&path).expect("hash");
assert_eq!(background.0, synchronous.0);
assert_eq!(background.1, synchronous.1);
}
#[test]
fn background_hash_join_reports_missing_file() {
let err = BackgroundHash::spawn(std::path::PathBuf::from(
"/nonexistent/elivagar-provenance-test.pbf",
))
.join()
.expect_err("missing file must error");
assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
}
fn contract_metadata() -> String {
json!({
"elivagar": {
"schema": SCHEMA_VERSION,
"input": {"name": "denmark", "xxh3_128": "ab", "features": {"locations_on_ways": true}},
"config": {"ocean": {"artifact_key": {"policy_version": 2}}, "fanout_cap": 8},
"build": {"elivagar": {"commit": "abc", "dirty": false}}
}
})
.to_string()
}
#[test]
fn extract_contract_names_every_no_contract_case() {
assert!(matches!(
extract_contract("not json"),
ContractState::Invalid
));
assert!(matches!(extract_contract("{}"), ContractState::Absent));
assert!(matches!(
extract_contract(r#"{"elivagar": 7}"#),
ContractState::Invalid
));
assert!(matches!(
extract_contract(r#"{"elivagar": {}}"#),
ContractState::Incomplete("schema")
));
assert!(matches!(
extract_contract(r#"{"elivagar": {"schema": 999}}"#),
ContractState::UnknownSchema(999)
));
assert!(matches!(
extract_contract(r#"{"elivagar": {"schema": 1, "config": {}, "build": {}}}"#),
ContractState::Incomplete("input")
));
assert!(matches!(
extract_contract(&contract_metadata()),
ContractState::Contract(_)
));
}
}