#![allow(clippy::unwrap_used)]
mod common;
use std::path::Path;
use common::cli::CliInvoker;
use common::{
TestNode, TestRelation, TestWay, generate_nodes, generate_relations, generate_ways,
read_header, read_normalized, write_multi_block_test_pbf,
};
use pbfhogg::{BlobDecode, BlobReader, Element};
fn run_repack(input: &Path, output: &Path, elements_per_blob: usize) -> common::cli::CliOutput {
CliInvoker::new()
.arg("repack")
.arg(input)
.arg("-o")
.arg(output)
.arg("--elements-per-blob")
.arg(elements_per_blob.to_string())
.assert_success()
}
fn elements_per_blob(path: &Path) -> Vec<(usize, usize, usize)> {
let reader = BlobReader::from_path(path).expect("open pbf");
let mut per_blob = Vec::new();
for blob in reader {
let blob = blob.expect("read blob");
if let BlobDecode::OsmData(block) = blob.decode().expect("decode blob") {
let mut nodes = 0;
let mut ways = 0;
let mut rels = 0;
for element in block.elements() {
match element {
Element::Node(_) | Element::DenseNode(_) => nodes += 1,
Element::Way(_) => ways += 1,
Element::Relation(_) => rels += 1,
_ => {}
}
}
per_blob.push((nodes, ways, rels));
}
}
per_blob
}
fn write_repack_fixture(path: &Path) -> (Vec<TestNode>, Vec<TestWay>, Vec<TestRelation>) {
let mut nodes = generate_nodes(60, 1);
nodes[0].tags = vec![("place", "city"), ("name", "Origo")];
nodes[7].tags = vec![("amenity", "cafe")];
nodes[42].tags = vec![("highway", "bus_stop")];
let ways = generate_ways(12, 1, 3, 1);
let rels = generate_relations(3, 1, 2, 1);
write_multi_block_test_pbf(path, &nodes, &ways, &rels, 20);
(nodes, ways, rels)
}
#[test]
fn repack_round_trip_preserves_elements_on_shrink() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
let (nodes, ways, rels) = write_repack_fixture(&input);
run_repack(&input, &output, 10);
let original = read_normalized(&input);
let repacked = read_normalized(&output);
assert_eq!(repacked.nodes.len(), nodes.len());
assert_eq!(repacked.ways.len(), ways.len());
assert_eq!(repacked.relations.len(), rels.len());
assert_eq!(original.nodes, repacked.nodes);
assert_eq!(original.ways, repacked.ways);
assert_eq!(original.relations, repacked.relations);
}
#[test]
fn repack_respects_element_cap() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_repack_fixture(&input);
run_repack(&input, &output, 10);
let per_blob = elements_per_blob(&output);
assert!(!per_blob.is_empty(), "output has no OsmData blobs");
for (i, (n, w, r)) in per_blob.iter().enumerate() {
let total = n + w + r;
assert!(total <= 10, "blob {i}: {total} elements exceeds cap 10");
let kinds_present = u8::from(*n > 0) + u8::from(*w > 0) + u8::from(*r > 0);
assert_eq!(kinds_present, 1, "blob {i} is mixed-kind: ({n}, {w}, {r})");
}
}
#[test]
fn repack_blob_count_matches_prediction() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_repack_fixture(&input);
run_repack(&input, &output, 10);
let per_blob = elements_per_blob(&output);
let node_blobs = per_blob.iter().filter(|(n, _, _)| *n > 0).count();
let way_blobs = per_blob.iter().filter(|(_, w, _)| *w > 0).count();
let rel_blobs = per_blob.iter().filter(|(_, _, r)| *r > 0).count();
assert_eq!(node_blobs, 6, "expected 6 node blobs (60 nodes / cap 10)");
assert_eq!(
way_blobs, 2,
"expected 2 way blobs (12 ways: 10 + 2 partial)"
);
assert_eq!(
rel_blobs, 1,
"expected 1 relation blob (3 relations partial)"
);
}
#[test]
fn repack_propagates_sorted_flag() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_repack_fixture(&input);
assert!(read_header(&input).is_sorted(), "fixture must be sorted");
run_repack(&input, &output, 10);
assert!(
read_header(&output).is_sorted(),
"output dropped Sort.Type_then_ID"
);
}
#[test]
fn repack_rejects_zero_cap() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_repack_fixture(&input);
CliInvoker::new()
.arg("repack")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--elements-per-blob")
.arg("0")
.assert_failure()
.assert_stderr_contains("must be > 0");
}
#[test]
fn repack_grow_collapses_to_one_blob_per_kind() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_repack_fixture(&input);
let out = run_repack(&input, &output, 8000);
out.assert_stderr_contains("--elements-per-blob 8000 never fired");
let original = read_normalized(&input);
let repacked = read_normalized(&output);
assert_eq!(original.nodes, repacked.nodes);
assert_eq!(original.ways, repacked.ways);
assert_eq!(original.relations, repacked.relations);
let per_blob = elements_per_blob(&output);
let node_blobs = per_blob.iter().filter(|(n, _, _)| *n > 0).count();
let way_blobs = per_blob.iter().filter(|(_, w, _)| *w > 0).count();
let rel_blobs = per_blob.iter().filter(|(_, _, r)| *r > 0).count();
assert_eq!(
node_blobs, 1,
"expected 60 nodes to collapse to 1 blob (cap 8000)"
);
assert_eq!(
way_blobs, 1,
"expected 12 ways to collapse to 1 blob (cap 8000)"
);
assert_eq!(
rel_blobs, 1,
"expected 3 relations to collapse to 1 blob (cap 8000)"
);
}
#[test]
fn repack_round_trip_preserves_elements_on_grow() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
let (nodes, ways, rels) = write_repack_fixture(&input);
run_repack(&input, &output, 30);
let original = read_normalized(&input);
let repacked = read_normalized(&output);
assert_eq!(repacked.nodes.len(), nodes.len());
assert_eq!(repacked.ways.len(), ways.len());
assert_eq!(repacked.relations.len(), rels.len());
assert_eq!(original.nodes, repacked.nodes);
assert_eq!(original.ways, repacked.ways);
assert_eq!(original.relations, repacked.relations);
}
#[test]
fn repack_grow_blob_count_matches_prediction() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_repack_fixture(&input);
run_repack(&input, &output, 30);
let per_blob = elements_per_blob(&output);
let node_blobs = per_blob.iter().filter(|(n, _, _)| *n > 0).count();
let way_blobs = per_blob.iter().filter(|(_, w, _)| *w > 0).count();
let rel_blobs = per_blob.iter().filter(|(_, _, r)| *r > 0).count();
assert_eq!(node_blobs, 2, "expected 2 node blobs (60 / cap 30)");
assert_eq!(way_blobs, 1, "expected 1 way blob (12 < cap 30)");
assert_eq!(rel_blobs, 1, "expected 1 relation blob (3 < cap 30)");
}
#[test]
fn repack_grow_no_warning_when_cap_fires() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_repack_fixture(&input);
let out = run_repack(&input, &output, 30);
let stderr = out.stderr_str();
assert!(
!stderr.contains("never fired"),
"no-op-cap warning should not fire when the cap fires mid-stream; stderr was:\n{stderr}"
);
}
#[test]
fn repack_no_warning_when_cap_fires() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_repack_fixture(&input);
let out = run_repack(&input, &output, 10);
let stderr = out.stderr_str();
assert!(
!stderr.contains("never fired"),
"no-op-cap warning should not fire on a real shrink; stderr was:\n{stderr}"
);
}
fn relation_ids_in_output_order(path: &Path) -> Vec<i64> {
let reader = BlobReader::from_path(path).expect("open pbf");
let mut ids = Vec::new();
for blob in reader {
let blob = blob.expect("read blob");
if let BlobDecode::OsmData(block) = blob.decode().expect("decode blob") {
for element in block.elements() {
if let Element::Relation(r) = element {
ids.push(r.id());
}
}
}
}
ids
}
#[test]
fn repack_output_is_monotonic_across_coalesced_blob_boundaries() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
let nodes = generate_nodes(5, 1);
let ways = generate_ways(5, 1, 3, 1);
let rels = generate_relations(40, 1, 2, 1);
write_multi_block_test_pbf(&input, &nodes, &ways, &rels, 20);
assert!(read_header(&input).is_sorted(), "fixture must be sorted");
run_repack(&input, &output, 12);
assert!(
read_header(&output).is_sorted(),
"output dropped Sort.Type_then_ID"
);
let original = read_normalized(&input);
let repacked = read_normalized(&output);
assert_eq!(original.relations, repacked.relations);
let ids = relation_ids_in_output_order(&output);
assert_eq!(ids.len(), 40, "expected all 40 relations in the output");
for pair in ids.windows(2) {
assert!(
pair[1] > pair[0],
"relation IDs are non-monotonic in output order: {} follows {} \
(output violates the Sort.Type_then_ID it claims). full ID \
sequence: {ids:?}",
pair[1],
pair[0],
);
}
}
fn ids_in_output_order(path: &Path) -> (Vec<i64>, Vec<i64>, Vec<i64>) {
let reader = BlobReader::from_path(path).expect("open pbf");
let mut nodes = Vec::new();
let mut ways = Vec::new();
let mut rels = Vec::new();
for blob in reader {
let blob = blob.expect("read blob");
if let BlobDecode::OsmData(block) = blob.decode().expect("decode blob") {
for element in block.elements() {
match element {
Element::Node(n) => nodes.push(n.id()),
Element::DenseNode(dn) => nodes.push(dn.id()),
Element::Way(w) => ways.push(w.id()),
Element::Relation(r) => rels.push(r.id()),
_ => {}
}
}
}
}
(nodes, ways, rels)
}
fn assert_strictly_increasing(ids: &[i64], kind: &str) {
for pair in ids.windows(2) {
assert!(
pair[1] > pair[0],
"{kind} IDs are non-monotonic in output order: {} follows {} \
(output violates the Sort.Type_then_ID it claims). full ID \
sequence: {ids:?}",
pair[1],
pair[0],
);
}
}
#[test]
fn repack_output_is_monotonic_for_nodes_and_ways() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
let nodes = generate_nodes(40, 1);
let ways = generate_ways(40, 1, 3, 1);
let rels = generate_relations(3, 1, 2, 1);
write_multi_block_test_pbf(&input, &nodes, &ways, &rels, 20);
assert!(read_header(&input).is_sorted(), "fixture must be sorted");
run_repack(&input, &output, 12);
assert!(
read_header(&output).is_sorted(),
"output dropped Sort.Type_then_ID"
);
let original = read_normalized(&input);
let repacked = read_normalized(&output);
assert_eq!(original.nodes, repacked.nodes);
assert_eq!(original.ways, repacked.ways);
let (node_ids, way_ids, _) = ids_in_output_order(&output);
assert_eq!(node_ids.len(), 40, "expected all 40 nodes in the output");
assert_eq!(way_ids.len(), 40, "expected all 40 ways in the output");
assert_strictly_increasing(&node_ids, "node");
assert_strictly_increasing(&way_ids, "way");
}
fn write_pbf_with_relation_blob_sizes(
path: &Path,
nodes: &[TestNode],
ways: &[TestWay],
relations: &[TestRelation],
rel_blob_sizes: &[usize],
) {
use pbfhogg::block_builder::{self, BlockBuilder, MemberData};
use pbfhogg::writer::{Compression, PbfWriter};
let total: usize = rel_blob_sizes.iter().sum();
assert_eq!(
total,
relations.len(),
"rel_blob_sizes must sum to the relation count"
);
let file = std::fs::File::create(path).expect("create file");
let buf = std::io::BufWriter::with_capacity(256 * 1024, file);
let mut writer = PbfWriter::new(buf, Compression::default());
let header = block_builder::HeaderBuilder::new()
.sorted()
.build()
.expect("build header");
writer.write_header(&header).expect("write header");
let mut bb = BlockBuilder::new();
for n in nodes {
bb.add_node(n.id, n.lat, n.lon, n.tags.iter().copied(), None);
}
if let Some(bytes) = bb.take().expect("take") {
writer.write_primitive_block(bytes).expect("write block");
}
for w in ways {
bb.add_way(w.id, w.tags.iter().copied(), &w.refs, None);
}
if let Some(bytes) = bb.take().expect("take") {
writer.write_primitive_block(bytes).expect("write block");
}
let mut idx = 0usize;
for &sz in rel_blob_sizes {
for r in &relations[idx..idx + sz] {
let members: Vec<MemberData<'_>> = r
.members
.iter()
.map(|m| MemberData {
id: m.id,
role: m.role,
})
.collect();
bb.add_relation(r.id, r.tags.iter().copied(), &members, None);
}
idx += sz;
if let Some(bytes) = bb.take().expect("take") {
writer.write_primitive_block(bytes).expect("write block");
}
}
writer.flush().expect("flush");
}
#[test]
fn repack_output_is_monotonic_with_pending_prepopulated_at_guard() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
let nodes = generate_nodes(5, 1);
let ways = generate_ways(5, 1, 3, 1);
let rels = generate_relations(30, 1, 2, 1);
write_pbf_with_relation_blob_sizes(&input, &nodes, &ways, &rels, &[5, 5, 5, 15]);
assert!(read_header(&input).is_sorted(), "fixture must be sorted");
run_repack(&input, &output, 12);
assert!(
read_header(&output).is_sorted(),
"output dropped Sort.Type_then_ID"
);
let original = read_normalized(&input);
let repacked = read_normalized(&output);
assert_eq!(original.relations, repacked.relations);
let (_, _, rel_ids) = ids_in_output_order(&output);
assert_eq!(rel_ids.len(), 30, "expected all 30 relations in the output");
assert_strictly_increasing(&rel_ids, "relation");
}
type LowWay = (
i64,
Vec<i64>,
Vec<(i32, i32)>,
Vec<(&'static str, &'static str)>,
);
fn write_low_pbf(path: &Path, nodes: &[TestNode], ways: &[LowWay]) {
write_way_coords_pbf(path, nodes, ways, true);
}
fn write_way_coords_pbf(path: &Path, nodes: &[TestNode], ways: &[LowWay], declare_low: bool) {
use pbfhogg::block_builder::{self, BlockBuilder};
use pbfhogg::writer::{Compression, PbfWriter};
let file = std::fs::File::create(path).expect("create file");
let buf = std::io::BufWriter::with_capacity(256 * 1024, file);
let mut writer = PbfWriter::new(buf, Compression::default());
let mut header_builder = block_builder::HeaderBuilder::new().sorted();
if declare_low {
header_builder = header_builder.optional_feature("LocationsOnWays");
}
let header = header_builder.build().expect("build header");
writer.write_header(&header).expect("write header");
let mut bb = BlockBuilder::new();
for n in nodes {
bb.add_node(n.id, n.lat, n.lon, n.tags.iter().copied(), None);
}
if let Some(bytes) = bb.take().expect("take") {
writer.write_primitive_block(bytes).expect("write block");
}
for (id, refs, locs, tags) in ways {
assert_eq!(refs.len(), locs.len(), "refs and locations must match");
bb.add_way_with_locations(*id, tags.iter().copied(), refs, locs, None);
}
if let Some(bytes) = bb.take().expect("take") {
writer.write_primitive_block(bytes).expect("write block");
}
writer.flush().expect("flush");
}
fn way_locations_in_output(path: &Path) -> Vec<(i64, Vec<(i32, i32)>)> {
let reader = BlobReader::from_path(path).expect("open pbf");
let mut out = Vec::new();
for blob in reader {
let blob = blob.expect("read blob");
if let BlobDecode::OsmData(block) = blob.decode().expect("decode blob") {
for element in block.elements() {
if let Element::Way(w) = element {
let locs: Vec<(i32, i32)> = w
.node_locations()
.map(|l| (l.decimicro_lat(), l.decimicro_lon()))
.collect();
out.push((w.id(), locs));
}
}
}
}
out
}
#[test]
fn repack_preserves_locations_on_ways() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
let nodes = generate_nodes(6, 1);
let ways: Vec<LowWay> = (1..=5i64)
.map(|w| {
let refs: Vec<i64> = (0..3i64).map(|r| w * 10 + r).collect();
let locs: Vec<(i32, i32)> = (0..3i64)
.map(|r| {
let base = i32::try_from(w * 1_000_000 + r * 1000).expect("coord fits in i32");
(base, base + 7)
})
.collect();
(w, refs, locs, vec![("highway", "residential")])
})
.collect();
write_low_pbf(&input, &nodes, &ways);
assert!(
read_header(&input).has_locations_on_ways(),
"fixture must declare LocationsOnWays"
);
run_repack(&input, &output, 2);
assert!(
read_header(&output).has_locations_on_ways(),
"output dropped LocationsOnWays"
);
let mut got = way_locations_in_output(&output);
got.sort_by_key(|(id, _)| *id);
let mut expected: Vec<(i64, Vec<(i32, i32)>)> = ways
.iter()
.map(|(id, _, locs, _)| (*id, locs.clone()))
.collect();
expected.sort_by_key(|(id, _)| *id);
assert_eq!(
got, expected,
"way-ref coordinates did not round-trip exactly"
);
}
#[test]
fn repack_strips_no_low_when_input_has_none() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_repack_fixture(&input);
assert!(
!read_header(&input).has_locations_on_ways(),
"fixture must not declare LocationsOnWays"
);
run_repack(&input, &output, 10);
assert!(
!read_header(&output).has_locations_on_ways(),
"output emitted LocationsOnWays when the input lacked it"
);
let got = way_locations_in_output(&output);
assert!(!got.is_empty(), "expected ways in the output");
for (id, locs) in &got {
assert!(
locs.is_empty(),
"way {id} unexpectedly carries inline coordinates"
);
}
}
#[test]
fn repack_strips_coords_when_input_flag_absent() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
let nodes = generate_nodes(6, 1);
let ways: Vec<LowWay> = (1..=5i64)
.map(|w| {
let refs: Vec<i64> = (0..3i64).map(|r| w * 10 + r).collect();
let locs: Vec<(i32, i32)> = (0..3i64)
.map(|r| {
let base = i32::try_from(w * 1_000_000 + r * 1000).expect("coord fits in i32");
(base, base + 7)
})
.collect();
(w, refs, locs, vec![("highway", "residential")])
})
.collect();
write_way_coords_pbf(&input, &nodes, &ways, false);
assert!(
!read_header(&input).has_locations_on_ways(),
"fixture must not declare LocationsOnWays"
);
run_repack(&input, &output, 2);
assert!(
!read_header(&output).has_locations_on_ways(),
"output emitted LocationsOnWays when the input lacked the flag"
);
let got = way_locations_in_output(&output);
assert_eq!(got.len(), ways.len(), "expected all ways in the output");
for (id, locs) in &got {
assert!(
locs.is_empty(),
"way {id} exposed inline coordinates despite absent LocationsOnWays flag"
);
}
}