#![allow(clippy::unwrap_used)]
mod common;
use std::path::Path;
use common::cli::CliInvoker;
use common::{
TestNode, TestRelation, TestWay, assert_has_tagdata, assert_indexed, assert_no_tagdata,
assert_no_tagdata_all_blobs, assert_non_indexed, assert_sorted_file, count_tagdata_blobs,
generate_nodes, generate_relations, generate_ways, read_header, read_normalized,
write_multi_block_test_pbf,
};
use pbfhogg::block_builder::{BlockBuilder, HeaderBuilder, MemberData, Metadata};
use pbfhogg::writer::{Compression, PbfWriter};
use pbfhogg::{BlobDecode, BlobReader, Element};
fn write_degrade_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 mut ways = generate_ways(12, 1, 3, 1);
ways[0].tags = vec![("highway", "residential")];
let mut rels = generate_relations(6, 1, 2, 1);
rels[0].tags = vec![("type", "route")];
write_multi_block_test_pbf(path, &nodes, &ways, &rels, 20);
(nodes, ways, rels)
}
const FIXTURE_BBOX: (f64, f64, f64, f64) = (0.0, 0.0, 0.01, 0.01);
const FIXTURE_WRITING_PROGRAM: &str = "degrade-test-writer/3.1";
const FIXTURE_SOURCE: &str = "survey-import-2019";
const FIXTURE_CUSTOM_FEATURE: &str = "Custom.Extension-v9";
const FIXTURE_REPL_TS: i64 = 1_700_000_000;
const FIXTURE_REPL_SEQ: i64 = 4242;
const FIXTURE_REPL_URL: &str = "https://example.org/replication";
fn push_varint(buf: &mut Vec<u8>, mut v: u64) {
loop {
let byte = (v & 0x7f) as u8;
v >>= 7;
if v != 0 {
buf.push(byte | 0x80);
} else {
buf.push(byte);
break;
}
}
}
fn push_len_field(buf: &mut Vec<u8>, field: u32, data: &[u8]) {
push_varint(buf, (u64::from(field) << 3) | 2);
push_varint(buf, data.len() as u64);
buf.extend_from_slice(data);
}
fn rich_bbox_header_bytes() -> Vec<u8> {
let (left, bottom, right, top) = FIXTURE_BBOX;
let mut header = HeaderBuilder::new()
.sorted()
.bbox(left, bottom, right, top)
.writing_program(FIXTURE_WRITING_PROGRAM)
.optional_feature(FIXTURE_CUSTOM_FEATURE)
.replication_timestamp(FIXTURE_REPL_TS)
.replication_sequence_number(FIXTURE_REPL_SEQ)
.replication_base_url(FIXTURE_REPL_URL)
.build()
.expect("build header");
push_len_field(&mut header, 17, FIXTURE_SOURCE.as_bytes());
header
}
fn write_fixture_elements(
writer: &mut PbfWriter<impl std::io::Write>,
nodes: &[TestNode],
ways: &[TestWay],
rels: &[TestRelation],
) {
let no_meta: Option<&Metadata> = None;
let mut bb = BlockBuilder::new();
for n in nodes {
bb.add_node(n.id, n.lat, n.lon, n.tags.iter().copied(), no_meta);
}
if let Some(bytes) = bb.take().expect("take") {
writer.write_primitive_block(bytes).expect("write nodes");
}
for w in ways {
bb.add_way(w.id, w.tags.iter().copied(), &w.refs, no_meta);
}
if let Some(bytes) = bb.take().expect("take") {
writer.write_primitive_block(bytes).expect("write ways");
}
for r in rels {
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, no_meta);
}
if let Some(bytes) = bb.take().expect("take") {
writer
.write_primitive_block(bytes)
.expect("write relations");
}
}
fn write_bbox_fixture(path: &Path) -> (Vec<TestNode>, Vec<TestWay>, Vec<TestRelation>) {
let nodes = generate_nodes(60, 1);
let ways = generate_ways(12, 1, 3, 1);
let rels = generate_relations(6, 1, 2, 1);
let header = rich_bbox_header_bytes();
let mut writer =
PbfWriter::to_path(path, Compression::default(), &header).expect("create writer");
write_fixture_elements(&mut writer, &nodes, &ways, &rels);
writer.flush().expect("flush");
(nodes, ways, rels)
}
fn assert_rich_header_fields_present(path: &Path) {
let h = read_header(path);
assert_eq!(h.writing_program(), Some(FIXTURE_WRITING_PROGRAM));
assert_eq!(h.source(), Some(FIXTURE_SOURCE));
assert_eq!(h.osmosis_replication_timestamp(), Some(FIXTURE_REPL_TS));
assert_eq!(
h.osmosis_replication_sequence_number(),
Some(FIXTURE_REPL_SEQ)
);
assert_eq!(h.osmosis_replication_base_url(), Some(FIXTURE_REPL_URL));
assert!(
h.optional_features()
.iter()
.any(|f| f == FIXTURE_CUSTOM_FEATURE),
"fixture must declare the custom optional feature"
);
assert!(h.is_sorted());
assert!(h.bbox().is_some());
}
fn assert_rich_header_fields_survived(path: &Path) {
let h = read_header(path);
assert_eq!(
h.writing_program(),
Some(FIXTURE_WRITING_PROGRAM),
"writingprogram must survive (a HeaderBuilder rebuild would reset it to pbfhogg)"
);
assert_eq!(
h.source(),
Some(FIXTURE_SOURCE),
"source (field 17) must survive (HeaderBuilder cannot even emit it)"
);
assert_eq!(h.osmosis_replication_timestamp(), Some(FIXTURE_REPL_TS));
assert_eq!(
h.osmosis_replication_sequence_number(),
Some(FIXTURE_REPL_SEQ)
);
assert_eq!(h.osmosis_replication_base_url(), Some(FIXTURE_REPL_URL));
assert!(
h.optional_features()
.iter()
.any(|f| f == FIXTURE_CUSTOM_FEATURE),
"custom optional feature must survive (a HeaderBuilder rebuild drops it)"
);
assert!(h.is_sorted(), "Sort.Type_then_ID must survive");
}
fn osm_data_frames(path: &Path) -> Vec<Vec<u8>> {
fn read_varint_at(buf: &[u8], pos: &mut usize) -> u64 {
let mut result = 0u64;
let mut shift = 0u32;
loop {
let byte = buf[*pos];
*pos += 1;
result |= u64::from(byte & 0x7f) << shift;
if byte & 0x80 == 0 {
break;
}
shift += 7;
}
result
}
let data = std::fs::read(path).expect("read pbf");
let mut pos = 0usize;
let mut frames = Vec::new();
while pos < data.len() {
let frame_start = pos;
let header_len =
u32::from_be_bytes([data[pos], data[pos + 1], data[pos + 2], data[pos + 3]]) as usize;
pos += 4;
let header_end = pos + header_len;
let mut hpos = pos;
let mut blob_type = String::new();
let mut datasize = 0usize;
while hpos < header_end {
let tag = read_varint_at(&data, &mut hpos);
let field = tag >> 3;
let wire = tag & 7;
match (field, wire) {
(1, 2) => {
let len = usize::try_from(read_varint_at(&data, &mut hpos)).expect("len fits");
blob_type = String::from_utf8(data[hpos..hpos + len].to_vec()).expect("utf8");
hpos += len;
}
(3, 0) => {
datasize =
usize::try_from(read_varint_at(&data, &mut hpos)).expect("datasize fits");
}
(_, 0) => {
read_varint_at(&data, &mut hpos);
}
(_, 2) => {
let len = usize::try_from(read_varint_at(&data, &mut hpos)).expect("len fits");
hpos += len;
}
_ => panic!("unexpected wire type {wire} in BlobHeader"),
}
}
let frame_end = header_end + datasize;
if blob_type == "OSMData" {
frames.push(data[frame_start..frame_end].to_vec());
}
pos = frame_end;
}
frames
}
fn write_non_indexed_bbox_fixture(path: &Path) -> (Vec<TestNode>, Vec<TestWay>, Vec<TestRelation>) {
let nodes = generate_nodes(60, 1);
let ways = generate_ways(12, 1, 3, 1);
let rels = generate_relations(6, 1, 2, 1);
let file = std::fs::File::create(path).expect("create file");
let buf = std::io::BufWriter::new(file);
let mut writer = PbfWriter::new(buf, Compression::default());
let header = rich_bbox_header_bytes();
writer.write_header(&header).expect("write header");
let no_meta: Option<&Metadata> = None;
let mut bb = BlockBuilder::new();
for n in &nodes {
bb.add_node(n.id, n.lat, n.lon, n.tags.iter().copied(), no_meta);
}
if let Some(bytes) = bb.take().expect("take") {
writer
.write_primitive_block_no_indexdata(bytes)
.expect("write nodes");
}
for w in &ways {
bb.add_way(w.id, w.tags.iter().copied(), &w.refs, no_meta);
}
if let Some(bytes) = bb.take().expect("take") {
writer
.write_primitive_block_no_indexdata(bytes)
.expect("write ways");
}
for r in &rels {
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, no_meta);
}
if let Some(bytes) = bb.take().expect("take") {
writer
.write_primitive_block_no_indexdata(bytes)
.expect("write relations");
}
writer.flush().expect("flush");
(nodes, ways, rels)
}
fn write_low_fixture(path: &Path) -> (Vec<TestNode>, Vec<TestWay>, Vec<TestRelation>) {
let nodes = generate_nodes(60, 1);
let ways = generate_ways(12, 1, 3, 1);
let rels = generate_relations(6, 1, 2, 1);
let header = HeaderBuilder::new()
.sorted()
.optional_feature("LocationsOnWays")
.build()
.expect("build header");
let mut writer =
PbfWriter::to_path(path, Compression::default(), &header).expect("create writer");
let no_meta: Option<&Metadata> = None;
let mut bb = BlockBuilder::new();
for n in &nodes {
bb.add_node(n.id, n.lat, n.lon, n.tags.iter().copied(), no_meta);
}
if let Some(bytes) = bb.take().expect("take") {
writer.write_primitive_block(bytes).expect("write nodes");
}
for w in &ways {
let locations: Vec<(i32, i32)> = w.refs.iter().map(|_| (1_000_000, 2_000_000)).collect();
bb.add_way_with_locations(w.id, w.tags.iter().copied(), &w.refs, &locations, no_meta);
}
if let Some(bytes) = bb.take().expect("take") {
writer.write_primitive_block(bytes).expect("write ways");
}
for r in &rels {
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, no_meta);
}
if let Some(bytes) = bb.take().expect("take") {
writer
.write_primitive_block(bytes)
.expect("write relations");
}
writer.flush().expect("flush");
(nodes, ways, rels)
}
fn write_unsort_fixture(path: &Path) -> (Vec<TestNode>, Vec<TestWay>, Vec<TestRelation>) {
let nodes = generate_nodes(60, 1);
let ways = generate_ways(24, 1, 3, 1);
let rels = generate_relations(24, 1, 2, 1);
write_multi_block_test_pbf(path, &nodes, &ways, &rels, 4);
(nodes, ways, rels)
}
const UNSORT_CAP: &str = "10";
fn write_large_blob_unsort_fixture(
path: &Path,
) -> (Vec<TestNode>, Vec<TestWay>, Vec<TestRelation>) {
let nodes = generate_nodes(60, 1);
let ways = generate_ways(60, 1, 3, 1);
let rels = generate_relations(60, 1, 2, 1);
write_multi_block_test_pbf(path, &nodes, &ways, &rels, 20);
(nodes, ways, rels)
}
const LARGE_BLOB_CAP: &str = "5";
fn blob_elements(path: &Path) -> Vec<(BlobKindLabel, Vec<i64>)> {
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") {
let mut ids = Vec::new();
let mut nodes = 0;
let mut ways = 0;
for element in block.elements() {
match element {
Element::Node(n) => {
nodes += 1;
ids.push(n.id());
}
Element::DenseNode(dn) => {
nodes += 1;
ids.push(dn.id());
}
Element::Way(w) => {
ways += 1;
ids.push(w.id());
}
Element::Relation(r) => {
ids.push(r.id());
}
_ => {}
}
}
let kind = if nodes > 0 {
BlobKindLabel::Node
} else if ways > 0 {
BlobKindLabel::Way
} else {
BlobKindLabel::Relation
};
out.push((kind, ids));
}
}
out
}
fn count_adjacent_overlaps(
blobs: &[(BlobKindLabel, i64, i64, usize)],
kind: BlobKindLabel,
) -> usize {
let same: Vec<_> = blobs.iter().filter(|(k, ..)| *k == kind).collect();
same.windows(2)
.filter(|w| {
let (_, _, a_max, _) = w[0];
let (_, b_min, _, _) = w[1];
a_max >= b_min
})
.count()
}
fn count_intra_blob_inversions(blobs: &[(BlobKindLabel, Vec<i64>)], kind: BlobKindLabel) -> usize {
blobs
.iter()
.filter(|(k, _)| *k == kind)
.map(|(_, ids)| ids.windows(2).filter(|w| w[0] > w[1]).count())
.sum()
}
fn blob_index_summary(path: &Path) -> Vec<(BlobKindLabel, i64, i64, usize)> {
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") {
let mut min_id = i64::MAX;
let mut max_id = i64::MIN;
let mut nodes = 0;
let mut ways = 0;
let mut rels = 0;
for element in block.elements() {
match element {
Element::Node(n) => {
nodes += 1;
min_id = min_id.min(n.id());
max_id = max_id.max(n.id());
}
Element::DenseNode(dn) => {
nodes += 1;
min_id = min_id.min(dn.id());
max_id = max_id.max(dn.id());
}
Element::Way(w) => {
ways += 1;
min_id = min_id.min(w.id());
max_id = max_id.max(w.id());
}
Element::Relation(r) => {
rels += 1;
min_id = min_id.min(r.id());
max_id = max_id.max(r.id());
}
_ => {}
}
}
let kind = if nodes > 0 {
BlobKindLabel::Node
} else if ways > 0 {
BlobKindLabel::Way
} else {
BlobKindLabel::Relation
};
let count = nodes + ways + rels;
out.push((kind, min_id, max_id, count));
}
}
out
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BlobKindLabel {
Node,
Way,
Relation,
}
#[test]
fn degrade_strip_indexdata_drops_indexdata() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
assert_indexed(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-indexdata")
.assert_success();
assert_non_indexed(&output);
assert!(
read_header(&output).is_sorted(),
"--strip-indexdata should not clear Sort.Type_then_ID"
);
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_strip_locations_clears_low_and_preserves_elements() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_low_fixture(&input);
assert!(
read_header(&input).has_locations_on_ways(),
"fixture must declare LocationsOnWays for the strip to be meaningful"
);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-locations")
.assert_success();
assert!(
!read_header(&output).has_locations_on_ways(),
"--strip-locations output must not declare LocationsOnWays"
);
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_strip_tagdata_drops_tagdata() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
assert_has_tagdata(&input);
assert!(
count_tagdata_blobs(&input) > 1,
"fixture must carry tagdata in more than one blob to make the \
whole-file strip assertion meaningful, got {}",
count_tagdata_blobs(&input)
);
assert_indexed(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-tagdata")
.assert_success();
assert_no_tagdata_all_blobs(&output);
assert_indexed(&output);
assert!(
read_header(&output).is_sorted(),
"--strip-tagdata should not clear Sort.Type_then_ID"
);
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_strip_bbox_clears_header_bbox() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_bbox_fixture(&input);
assert!(
read_header(&input).bbox().is_some(),
"fixture must declare a HeaderBlock.bbox for the strip to be meaningful"
);
assert_rich_header_fields_present(&input);
assert_indexed(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-bbox")
.assert_success();
assert!(
read_header(&output).bbox().is_none(),
"--strip-bbox output must not declare a HeaderBlock.bbox"
);
assert_rich_header_fields_survived(&output);
assert_indexed(&output);
assert_eq!(
osm_data_frames(&output),
osm_data_frames(&input),
"--strip-bbox must leave every OsmData frame byte-identical"
);
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_strip_bbox_no_indexdata_uses_passthrough() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_non_indexed_bbox_fixture(&input);
assert_non_indexed(&input);
assert!(read_header(&input).bbox().is_some());
assert_rich_header_fields_present(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-bbox")
.assert_success();
assert!(read_header(&output).bbox().is_none());
assert_non_indexed(&output);
assert_rich_header_fields_survived(&output);
assert_eq!(
osm_data_frames(&output),
osm_data_frames(&input),
"non-indexed --strip-bbox must copy OsmData frames byte-for-byte"
);
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_strip_bbox_and_strip_indexdata_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_bbox_fixture(&input);
assert!(read_header(&input).bbox().is_some());
assert_indexed(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-bbox")
.arg("--strip-indexdata")
.assert_success();
assert!(read_header(&output).bbox().is_none());
assert_non_indexed(&output);
assert!(read_header(&output).is_sorted());
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_strip_bbox_and_strip_locations_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_bbox_fixture(&input);
assert!(read_header(&input).bbox().is_some());
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-bbox")
.arg("--strip-locations")
.assert_success();
assert!(
read_header(&output).bbox().is_none(),
"--strip-bbox output must not declare a HeaderBlock.bbox (decode path)"
);
assert!(
!read_header(&output).has_locations_on_ways(),
"--strip-locations output must not declare LocationsOnWays"
);
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_strip_bbox_extract_bbox_matches_original() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let stripped = dir.path().join("stripped.osm.pbf");
let extract_orig = dir.path().join("extract_orig.osm.pbf");
let extract_stripped = dir.path().join("extract_stripped.osm.pbf");
write_bbox_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&stripped)
.arg("--strip-bbox")
.assert_success();
assert!(read_header(&stripped).bbox().is_none());
let region = "0.0,0.0,0.00055,0.00055";
for (src, out) in [(&input, &extract_orig), (&stripped, &extract_stripped)] {
CliInvoker::new()
.arg("extract")
.arg(src)
.arg("-o")
.arg(out)
.arg("--bbox")
.arg(region)
.assert_success();
}
let orig = read_normalized(&extract_orig);
let strip = read_normalized(&extract_stripped);
let full = read_normalized(&input);
assert!(
!orig.nodes.is_empty(),
"extract region must select at least one node"
);
assert!(
orig.nodes.len() < full.nodes.len(),
"extract must select strictly fewer than all {} nodes, got {}",
full.nodes.len(),
orig.nodes.len()
);
assert_eq!(orig.nodes, strip.nodes);
assert_eq!(orig.ways, strip.ways);
assert_eq!(orig.relations, strip.relations);
}
#[test]
fn degrade_strip_bbox_with_generator_override_rebuilds_header() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_bbox_fixture(&input);
assert!(
read_header(&input).bbox().is_some(),
"fixture must declare a HeaderBlock.bbox for the strip to be meaningful"
);
assert_eq!(
read_header(&input).writing_program(),
Some(FIXTURE_WRITING_PROGRAM),
"fixture's original writingprogram must differ from the override below"
);
const OVERRIDE_GENERATOR: &str = "degrade-rebuild-override/9.0";
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-bbox")
.arg("--generator")
.arg(OVERRIDE_GENERATOR)
.assert_success();
let out_header = read_header(&output);
assert!(
out_header.bbox().is_none(),
"--strip-bbox must still clear HeaderBlock.bbox on the override-rebuild path"
);
assert_eq!(
out_header.writing_program(),
Some(OVERRIDE_GENERATOR),
"--generator must win on the rebuild path, proving passthrough_header_bytes \
took the HeaderBuilder rebuild branch rather than the verbatim-forward branch"
);
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_unsort_creates_adjacent_overlap_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_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort")
.arg("--block-cap")
.arg(UNSORT_CAP)
.assert_success();
assert_unsort_cross_blob_shape(&output, &input);
}
fn assert_unsort_cross_blob_shape(output: &Path, input: &Path) {
assert!(
!read_header(output).is_sorted(),
"--unsort output must not declare Sort.Type_then_ID"
);
let summary = blob_index_summary(output);
let elements = blob_elements(output);
for kind in [
BlobKindLabel::Node,
BlobKindLabel::Way,
BlobKindLabel::Relation,
] {
let same_count = summary.iter().filter(|(k, ..)| *k == kind).count();
assert!(
same_count >= 2,
"kind {kind:?}: need at least 2 blobs to verify overlap, got {same_count}"
);
assert_eq!(
count_adjacent_overlaps(&summary, kind),
1,
"kind {kind:?}: expected exactly one adjacent cross-blob overlap, \
blobs were {:?}",
summary
.iter()
.filter(|(k, ..)| *k == kind)
.collect::<Vec<_>>()
);
assert_eq!(
count_intra_blob_inversions(&elements, kind),
0,
"kind {kind:?}: --unsort blobs must be internally ID-monotone, \
blobs were {:?}",
elements
.iter()
.filter(|(k, _)| *k == kind)
.collect::<Vec<_>>()
);
}
let original = read_normalized(input);
let degraded = read_normalized(output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_unsort_intra_creates_intra_blob_inversion() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort-intra")
.arg("--block-cap")
.arg(UNSORT_CAP)
.assert_success();
assert_unsort_intra_shape(&output, &input);
}
#[test]
fn degrade_unsort_intra_large_input_blobs_stay_intra_blob() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_large_blob_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort-intra")
.arg("--block-cap")
.arg(LARGE_BLOB_CAP)
.assert_success();
assert_unsort_intra_shape(&output, &input);
}
fn assert_unsort_intra_shape(output: &Path, input: &Path) {
assert!(
!read_header(output).is_sorted(),
"--unsort-intra output must not declare Sort.Type_then_ID"
);
let summary = blob_index_summary(output);
let elements = blob_elements(output);
for kind in [
BlobKindLabel::Node,
BlobKindLabel::Way,
BlobKindLabel::Relation,
] {
assert_eq!(
count_intra_blob_inversions(&elements, kind),
1,
"kind {kind:?}: expected exactly one intra-blob inversion, \
blobs were {:?}",
elements
.iter()
.filter(|(k, _)| *k == kind)
.collect::<Vec<_>>()
);
assert_eq!(
count_adjacent_overlaps(&summary, kind),
0,
"kind {kind:?}: --unsort-intra must not produce cross-blob overlap, \
blobs were {:?}",
summary
.iter()
.filter(|(k, ..)| *k == kind)
.collect::<Vec<_>>()
);
}
let original = read_normalized(input);
let degraded = read_normalized(output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_unsort_and_unsort_intra_are_mutually_exclusive() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort")
.arg("--unsort-intra")
.assert_failure()
.assert_stderr_contains("unsort-intra");
}
#[test]
fn degrade_unsort_then_sort_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let unsorted = dir.path().join("unsorted.osm.pbf");
let resorted = dir.path().join("resorted.osm.pbf");
write_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&unsorted)
.arg("--unsort")
.arg("--block-cap")
.arg(UNSORT_CAP)
.assert_success();
let sort_out = CliInvoker::new()
.arg("sort")
.arg(&unsorted)
.arg("-o")
.arg(&resorted)
.arg("--force")
.assert_success();
sort_out.assert_stderr_contains("blobs in overlap runs");
assert_sorted_file(&resorted);
let original = read_normalized(&input);
let recovered = read_normalized(&resorted);
assert_eq!(original.nodes, recovered.nodes);
assert_eq!(original.ways, recovered.ways);
assert_eq!(original.relations, recovered.relations);
}
#[test]
fn degrade_unsort_and_strip_indexdata_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort")
.arg("--strip-indexdata")
.arg("--block-cap")
.arg("5")
.assert_success();
assert_non_indexed(&output);
assert!(!read_header(&output).is_sorted());
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_unsort_and_strip_locations_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort")
.arg("--strip-locations")
.arg("--block-cap")
.arg(UNSORT_CAP)
.assert_success();
assert!(
!read_header(&output).has_locations_on_ways(),
"--strip-locations output must not declare LocationsOnWays"
);
assert_unsort_cross_blob_shape(&output, &input);
}
#[test]
fn degrade_unsort_intra_and_strip_locations_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort-intra")
.arg("--strip-locations")
.arg("--block-cap")
.arg(UNSORT_CAP)
.assert_success();
assert!(
!read_header(&output).has_locations_on_ways(),
"--strip-locations output must not declare LocationsOnWays"
);
assert_unsort_intra_shape(&output, &input);
}
#[test]
fn degrade_unsort_intra_and_strip_indexdata_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort-intra")
.arg("--strip-indexdata")
.arg("--block-cap")
.arg(UNSORT_CAP)
.assert_success();
assert_non_indexed(&output);
assert_unsort_intra_shape(&output, &input);
}
#[test]
fn degrade_strip_tagdata_and_strip_indexdata_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
assert_has_tagdata(&input);
assert_indexed(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-tagdata")
.arg("--strip-indexdata")
.assert_success();
assert_no_tagdata_all_blobs(&output);
assert_non_indexed(&output);
assert!(read_header(&output).is_sorted());
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_unsort_and_strip_tagdata_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
assert_has_tagdata(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort")
.arg("--strip-tagdata")
.arg("--block-cap")
.arg("5")
.assert_success();
assert_no_tagdata(&output);
assert!(!read_header(&output).is_sorted());
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_strip_locations_and_strip_tagdata_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
assert_has_tagdata(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--strip-locations")
.arg("--strip-tagdata")
.arg("--block-cap")
.arg("10")
.assert_success();
assert_no_tagdata(&output);
assert!(
!read_header(&output).has_locations_on_ways(),
"--strip-locations output must not declare LocationsOnWays"
);
let original = read_normalized(&input);
let degraded = read_normalized(&output);
assert_eq!(original.nodes, degraded.nodes);
assert_eq!(original.ways, degraded.ways);
assert_eq!(original.relations, degraded.relations);
}
#[test]
fn degrade_requires_at_least_one_flag() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.assert_failure()
.assert_stderr_contains("at least one transformation flag");
}
fn normalized_count(path: &Path) -> usize {
let pbf = read_normalized(path);
pbf.nodes.len() + pbf.ways.len() + pbf.relations.len()
}
fn refs_field(stdout: &str, field: &str) -> u64 {
let needle = format!("\"{field}\"");
let line = stdout
.lines()
.find(|l| l.trim_start().starts_with(&needle))
.unwrap_or_else(|| panic!("field {field:?} not found in check --refs json:\n{stdout}"));
let value = line
.split_once(':')
.expect("json field line has a colon")
.1
.trim()
.trim_end_matches(',');
value
.parse()
.unwrap_or_else(|_| panic!("field {field:?} value {value:?} is not an integer"))
}
fn expected_dangles(out: &common::NormalizedPbf) -> (u64, u64, u64, u64) {
use std::collections::BTreeSet;
let node_ids: BTreeSet<i64> = out.nodes.iter().map(|n| n.id).collect();
let way_ids: BTreeSet<i64> = out.ways.iter().map(|w| w.id).collect();
let rel_ids: BTreeSet<i64> = out.relations.iter().map(|r| r.id).collect();
let mut missing_node_refs = BTreeSet::new();
for w in &out.ways {
for r in &w.refs {
if !node_ids.contains(r) {
missing_node_refs.insert(*r);
}
}
}
let mut missing_node_members = BTreeSet::new();
let mut missing_way_refs = BTreeSet::new();
let mut missing_relation_members = BTreeSet::new();
for rel in &out.relations {
for m in &rel.members {
match m.member_type.as_str() {
"node" if !node_ids.contains(&m.ref_id) => {
missing_node_members.insert(m.ref_id);
}
"way" if !way_ids.contains(&m.ref_id) => {
missing_way_refs.insert(m.ref_id);
}
"relation" if !rel_ids.contains(&m.ref_id) => {
missing_relation_members.insert(m.ref_id);
}
_ => {}
}
}
}
(
missing_node_refs.len() as u64,
missing_way_refs.len() as u64,
missing_node_members.len() as u64,
missing_relation_members.len() as u64,
)
}
fn assert_check_refs_matches_output(path: &Path) -> u64 {
let out = read_normalized(path);
let (mnr, mwr, mnm, mrm) = expected_dangles(&out);
let check = CliInvoker::new()
.arg("check")
.arg(path)
.arg("--refs")
.arg("--check-relations")
.arg("--json")
.run();
let stdout = check.stdout_str();
assert_eq!(
refs_field(&stdout, "missing_node_refs"),
mnr,
"missing_node_refs"
);
assert_eq!(
refs_field(&stdout, "missing_way_refs"),
mwr,
"missing_way_refs"
);
assert_eq!(
refs_field(&stdout, "missing_node_members"),
mnm,
"missing_node_members"
);
assert_eq!(
refs_field(&stdout, "missing_relation_members"),
mrm,
"missing_relation_members"
);
mnr + mwr + mnm + mrm
}
#[test]
fn degrade_drop_ids_dangling_refs_match_check_refs() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--drop-ids")
.arg("10:16")
.assert_success();
assert_eq!(normalized_count(&output), normalized_count(&input) - 10);
let sum = assert_check_refs_matches_output(&output);
assert!(
sum > 0,
"10:16 must drop referenced elements so dangles are produced, got sum 0"
);
}
#[test]
fn degrade_drop_ids_removes_exactly_n() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--drop-ids")
.arg("10:1")
.assert_success();
assert_eq!(normalized_count(&output), normalized_count(&input) - 10);
assert!(read_header(&output).is_sorted());
assert_sorted_file(&output);
}
#[test]
fn degrade_drop_ids_is_reproducible_and_seed_changes_selection() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let first = dir.path().join("first.osm.pbf");
let second = dir.path().join("second.osm.pbf");
let third = dir.path().join("third.osm.pbf");
write_degrade_fixture(&input);
for (output, spec) in [(&first, "10:7"), (&second, "10:7"), (&third, "10:8")] {
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(output)
.arg("--drop-ids")
.arg(spec)
.assert_success();
}
assert_eq!(
std::fs::read(&first).expect("first"),
std::fs::read(&second).expect("second")
);
assert_ne!(
std::fs::read(&first).expect("first"),
std::fs::read(&third).expect("third")
);
}
#[test]
fn degrade_drop_ids_validates_arguments_and_total() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
for (spec, message) in [
("0:1", "N must be >= 1"),
("10", "N:SEED"),
("1000000:1", "input has only"),
] {
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--drop-ids")
.arg(spec)
.assert_failure()
.assert_stderr_contains(message);
}
}
#[test]
fn degrade_rejects_zero_block_cap() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort")
.arg("--block-cap")
.arg("0")
.assert_failure()
.assert_stderr_contains("must be > 0");
}
#[test]
fn degrade_unsort_intra_rejects_block_cap_one() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort-intra")
.arg("--block-cap")
.arg("1")
.assert_failure()
.assert_stderr_contains("block-cap >= 2");
}
#[test]
fn degrade_unsort_accepts_block_cap_one() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_unsort_fixture(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.arg("--unsort")
.arg("--block-cap")
.arg("1")
.assert_success();
assert_unsort_cross_blob_shape(&output, &input);
}
mod tier2 {
use super::*;
#[test]
fn degrade_drop_ids_and_strip_locations_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
let input_count = normalized_count(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.args(["--drop-ids", "10:16", "--strip-locations"])
.assert_success();
assert_eq!(normalized_count(&output), input_count - 10);
assert!(!read_header(&output).has_locations_on_ways());
assert!(read_header(&output).is_sorted());
}
#[test]
fn degrade_drop_ids_and_strip_indexdata_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_degrade_fixture(&input);
let input_count = normalized_count(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.args(["--drop-ids", "10:16", "--strip-indexdata"])
.assert_success();
assert_eq!(normalized_count(&output), input_count - 10);
assert_non_indexed(&output);
}
#[test]
fn degrade_drop_ids_and_unsort_compose() {
let dir = tempfile::tempdir().expect("tempdir");
let input = dir.path().join("in.osm.pbf");
let output = dir.path().join("out.osm.pbf");
write_unsort_fixture(&input);
let input_count = normalized_count(&input);
CliInvoker::new()
.arg("degrade")
.arg(&input)
.arg("-o")
.arg(&output)
.args(["--drop-ids", "10:16", "--unsort", "--block-cap", UNSORT_CAP])
.assert_success();
assert_eq!(normalized_count(&output), input_count - 10);
assert!(!read_header(&output).is_sorted());
for kind in [
BlobKindLabel::Node,
BlobKindLabel::Way,
BlobKindLabel::Relation,
] {
assert_eq!(
count_adjacent_overlaps(&blob_index_summary(&output), kind),
1
);
assert_eq!(
count_intra_blob_inversions(&blob_elements(&output), kind),
0
);
}
let sum = assert_check_refs_matches_output(&output);
assert!(sum > 0, "10:16 on the unsort fixture must produce dangles");
}
}