#![allow(clippy::unwrap_used)]
#![allow(clippy::too_many_lines)]
mod common;
use std::path::Path;
use common::cli::CliInvoker;
use common::{
assert_indexed, assert_non_indexed, generate_nodes, write_multi_block_test_pbf,
write_test_pbf, write_test_pbf_non_indexed, write_test_pbf_sorted, TestMember, TestMeta,
TestNode, TestRelation, TestWay,
};
use pbfhogg::MemberId;
use tempfile::TempDir;
#[derive(Default, Clone, Copy)]
struct DiffOpts {
suppress_common: bool,
verbose: bool,
type_filter: Option<&'static str>,
jobs: Option<usize>,
}
#[derive(Debug, PartialEq, Eq, Clone, Copy, Default)]
struct DiffStats {
common: u64,
created: u64,
modified: u64,
deleted: u64,
}
impl DiffStats {
fn has_differences(&self) -> bool {
self.created > 0 || self.modified > 0 || self.deleted > 0
}
}
fn run_diff(old: &Path, new: &Path, opts: DiffOpts) -> (String, DiffStats) {
let dir_for_output = old.parent().expect("old has parent");
let suffix = format!(
"diff-out-c{}-v{}-t{}-j{}.txt",
u8::from(opts.suppress_common),
u8::from(opts.verbose),
opts.type_filter.unwrap_or("none"),
opts.jobs.map_or("def".into(), |j| j.to_string()),
);
let out_path = dir_for_output.join(suffix);
let mut cli = CliInvoker::new()
.arg("diff")
.arg(old)
.arg(new)
.arg("-o")
.arg(&out_path);
if opts.suppress_common {
cli = cli.arg("-c");
}
if opts.verbose {
cli = cli.arg("-v");
}
if let Some(t) = opts.type_filter {
cli = cli.arg("-t").arg(t);
}
if let Some(j) = opts.jobs {
cli = cli.arg("-j").arg(j.to_string());
}
let out = cli.run();
let code = out.status.code();
assert!(
code == Some(0) || code == Some(1),
"pbfhogg diff failed unexpectedly (exit {code:?}); stderr:\n{}",
out.stderr_str(),
);
let stats = parse_diff_stats(&out.stderr_str())
.unwrap_or_else(|| panic!("could not parse diff stats; stderr:\n{}", out.stderr_str()));
let text = std::fs::read_to_string(&out_path).expect("read diff output file");
(text, stats)
}
fn run_diff_failing(old: &Path, new: &Path) -> common::cli::CliOutput {
CliInvoker::new()
.arg("diff")
.arg(old)
.arg(new)
.arg("--osmium-summary")
.run()
}
fn parse_diff_stats(stderr: &str) -> Option<DiffStats> {
if let Some(line) = stderr.lines().find(|l| l.starts_with("Files are identical")) {
let common = first_u64(line)?;
return Some(DiffStats { common, ..DiffStats::default() });
}
let line = stderr.lines().find(|l| l.contains(" differences: "))?;
let nums: Vec<u64> = line
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.filter_map(|s| s.parse().ok())
.collect();
if nums.len() < 5 { return None; }
Some(DiffStats {
created: nums[1],
modified: nums[2],
deleted: nums[3],
common: nums[4],
})
}
fn first_u64(line: &str) -> Option<u64> {
line.split(|c: char| !c.is_ascii_digit())
.find(|s| !s.is_empty())
.and_then(|s| s.parse().ok())
}
#[test]
fn identical_files_empty_output() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
let nodes = [
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![], meta: None },
];
let ways = [TestWay { id: 10, refs: vec![1, 2], tags: vec![("highway", "primary")], meta: None }];
write_test_pbf_sorted(&old, &nodes, &ways, &[]);
write_test_pbf_sorted(&new, &nodes, &ways, &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, ..DiffOpts::default() });
assert!(text.is_empty(), "suppress_common output should be empty for identical files");
assert!(stats.common > 0);
assert!(!stats.has_differences());
}
#[test]
fn identical_files_shows_common() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
let nodes = [
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![], meta: None },
];
let ways = [TestWay { id: 10, refs: vec![1, 2], tags: vec![("highway", "primary")], meta: None }];
write_test_pbf_sorted(&old, &nodes, &ways, &[]);
write_test_pbf_sorted(&new, &nodes, &ways, &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts::default());
for line in text.lines() {
assert!(line.starts_with(' '), "all lines should start with space; got: {line:?}");
}
assert_eq!(stats.common, 3);
}
#[test]
fn added_elements() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None }], &[], &[]);
write_test_pbf_sorted(
&new,
&[
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![], meta: None },
],
&[TestWay { id: 10, refs: vec![1, 2], tags: vec![("highway", "primary")], meta: None }],
&[],
);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, ..DiffOpts::default() });
assert!(text.contains("+n2"), "stdout should contain +n2; got:\n{text}");
assert!(text.contains("+w10"), "stdout should contain +w10; got:\n{text}");
assert_eq!(stats.created, 2);
}
#[test]
fn deleted_elements() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(
&old,
&[
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![], meta: None },
],
&[TestWay { id: 10, refs: vec![1, 2], tags: vec![], meta: None }],
&[],
);
write_test_pbf_sorted(&new, &[TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None }], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, ..DiffOpts::default() });
assert!(text.contains("-n2"), "stdout should contain -n2; got:\n{text}");
assert!(text.contains("-w10"), "stdout should contain -w10; got:\n{text}");
assert_eq!(stats.deleted, 2);
}
#[test]
fn modified_node_coordinates() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None }], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode { id: 1, lat: 150_000_000, lon: 250_000_000, tags: vec![], meta: None }], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, ..DiffOpts::default() });
assert!(text.contains("*n1"), "stdout should contain *n1; got:\n{text}");
assert_eq!(stats.modified, 1);
}
#[test]
fn modified_node_tags() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![("name", "old")], meta: None }], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![("name", "new")], meta: None }], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, ..DiffOpts::default() });
assert!(text.contains("*n1"), "stdout: {text}");
assert_eq!(stats.modified, 1);
}
#[test]
fn modified_way_refs() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[], &[TestWay { id: 10, refs: vec![1, 2], tags: vec![("highway", "primary")], meta: None }], &[]);
write_test_pbf_sorted(&new, &[], &[TestWay { id: 10, refs: vec![1, 2, 3], tags: vec![("highway", "primary")], meta: None }], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, ..DiffOpts::default() });
assert!(text.contains("*w10"), "stdout: {text}");
assert_eq!(stats.modified, 1);
}
#[test]
fn modified_relation_members() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[], &[], &[TestRelation {
id: 100,
members: vec![TestMember { id: MemberId::Node(1), role: "stop" }],
tags: vec![("type", "route")],
meta: None,
}]);
write_test_pbf_sorted(&new, &[], &[], &[TestRelation {
id: 100,
members: vec![
TestMember { id: MemberId::Node(1), role: "stop" },
TestMember { id: MemberId::Way(2), role: "outer" },
],
tags: vec![("type", "route")],
meta: None,
}]);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, ..DiffOpts::default() });
assert!(text.contains("*r100"), "stdout: {text}");
assert_eq!(stats.modified, 1);
}
#[test]
fn suppress_common_hides_unchanged() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(
&old,
&[
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![("name", "old")], meta: None },
TestNode { id: 3, lat: 120_000_000, lon: 220_000_000, tags: vec![], meta: None },
],
&[TestWay { id: 10, refs: vec![1, 2, 3], tags: vec![("highway", "primary")], meta: None }],
&[],
);
write_test_pbf_sorted(
&new,
&[
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![("name", "new")], meta: None },
TestNode { id: 4, lat: 130_000_000, lon: 230_000_000, tags: vec![], meta: None },
],
&[TestWay { id: 10, refs: vec![1, 2, 3], tags: vec![("highway", "primary")], meta: None }],
&[],
);
let (text, _) = run_diff(&old, &new, DiffOpts { suppress_common: true, ..DiffOpts::default() });
for line in text.lines() {
assert!(!line.starts_with(' '), "suppress_common should hide unchanged; got: {line:?}");
}
}
#[test]
fn verbose_shows_tag_details() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000,
tags: vec![("name", "old"), ("amenity", "cafe")],
meta: None,
}], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000,
tags: vec![("name", "new"), ("highway", "primary")],
meta: None,
}], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, verbose: true, ..DiffOpts::default() });
assert_eq!(stats.modified, 1);
assert!(text.contains("~name: old -> new"), "verbose tag change; text:\n{text}");
assert!(text.contains("-amenity=cafe"), "verbose tag remove; text:\n{text}");
assert!(text.contains("+highway=primary"), "verbose tag add; text:\n{text}");
}
#[test]
fn verbose_shows_coordinate_details() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None }], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode { id: 1, lat: 150_000_000, lon: 250_000_000, tags: vec![], meta: None }], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, verbose: true, ..DiffOpts::default() });
assert_eq!(stats.modified, 1);
assert!(text.contains("coordinates:"), "verbose coords; text:\n{text}");
}
#[test]
fn type_filter_restricts_output() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None }],
&[TestWay { id: 10, refs: vec![1], tags: vec![], meta: None }], &[]);
write_test_pbf_sorted(&new,
&[
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![], meta: None },
],
&[
TestWay { id: 10, refs: vec![1, 2], tags: vec![], meta: None },
TestWay { id: 20, refs: vec![2], tags: vec![], meta: None },
],
&[],
);
let (text, _) = run_diff(&old, &new, DiffOpts { suppress_common: true, type_filter: Some("node"), ..DiffOpts::default() });
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
let second_char = trimmed.chars().nth(1);
assert_ne!(second_char, Some('w'), "type_filter=node should exclude ways; got: {line:?}");
}
}
#[test]
fn unsorted_input_rejected() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf(&old, &[TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None }], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None }], &[], &[]);
let out = run_diff_failing(&old, &new);
assert!(!out.status.success(), "should reject unsorted; stderr:\n{}", out.stderr_str());
let stderr = out.stderr_str();
assert!(stderr.contains("not sorted"), "stderr:\n{stderr}");
assert!(stderr.contains("Sort.Type_then_ID"), "stderr:\n{stderr}");
assert!(stderr.contains("pbfhogg sort"), "stderr:\n{stderr}");
}
#[test]
fn empty_files_no_data_blocks() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[], &[], &[]);
write_test_pbf_sorted(&new, &[], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts::default());
assert!(text.is_empty(), "empty files should produce no output; got:\n{text}");
assert!(!stats.has_differences());
assert_eq!(stats.common, 0);
}
#[test]
fn multi_block_boundary_asymmetric() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
#[allow(clippy::cast_possible_truncation)]
let old_nodes: Vec<TestNode> = (1_i64..=9000)
.map(|id| TestNode { id, lat: id as i32, lon: id as i32, tags: vec![], meta: None })
.collect();
#[allow(clippy::cast_possible_truncation)]
let new_nodes: Vec<TestNode> = (1_i64..=7500)
.map(|id| TestNode { id, lat: id as i32, lon: id as i32, tags: vec![], meta: None })
.collect();
write_test_pbf_sorted(&old, &old_nodes, &[], &[]);
write_test_pbf_sorted(&new, &new_nodes, &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts { suppress_common: true, ..DiffOpts::default() });
assert_eq!(stats.deleted, 1500, "expected 1500 deleted; got {}", stats.deleted);
assert_eq!(stats.common, 7500, "expected 7500 common; got {}", stats.common);
assert_eq!(stats.created, 0);
assert_eq!(stats.modified, 0);
for line in text.lines() {
assert!(line.starts_with('-'), "all lines should be deletions; got: {line:?}");
}
}
#[test]
fn type_filter_way_skips_phases() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old,
&[
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![], meta: None },
],
&[TestWay { id: 10, refs: vec![1, 2], tags: vec![("highway", "primary")], meta: None }],
&[TestRelation {
id: 100,
members: vec![TestMember { id: MemberId::Node(1), role: "stop" }],
tags: vec![("type", "route")],
meta: None,
}],
);
write_test_pbf_sorted(&new,
&[
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 3, lat: 120_000_000, lon: 220_000_000, tags: vec![], meta: None },
],
&[
TestWay { id: 10, refs: vec![1, 2], tags: vec![("highway", "primary")], meta: None },
TestWay { id: 20, refs: vec![1, 3], tags: vec![("highway", "secondary")], meta: None },
],
&[TestRelation {
id: 200,
members: vec![TestMember { id: MemberId::Way(10), role: "outer" }],
tags: vec![("type", "multipolygon")],
meta: None,
}],
);
let (text, stats) = run_diff(&old, &new, DiffOpts { type_filter: Some("way"), ..DiffOpts::default() });
for line in text.lines() {
if line.is_empty() { continue; }
let type_char = line.chars().nth(1);
assert_eq!(type_char, Some('w'), "type_filter=way; got: {line:?}");
}
assert_eq!(stats.common, 1);
assert_eq!(stats.created, 1);
assert_eq!(stats.modified, 0);
assert_eq!(stats.deleted, 0);
}
#[test]
fn osmium_summary_format() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old,
&[
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![("name", "old")], meta: None },
TestNode { id: 3, lat: 120_000_000, lon: 220_000_000, tags: vec![], meta: None },
],
&[TestWay { id: 10, refs: vec![1, 2, 3], tags: vec![("highway", "primary")], meta: None }],
&[],
);
write_test_pbf_sorted(&new,
&[
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![("name", "new")], meta: None },
TestNode { id: 4, lat: 130_000_000, lon: 230_000_000, tags: vec![], meta: None },
],
&[TestWay { id: 10, refs: vec![1, 2, 3], tags: vec![("highway", "primary")], meta: None }],
&[],
);
let (_, stats) = run_diff(&old, &new, DiffOpts::default());
assert_eq!(stats.common, 2);
assert_eq!(stats.modified, 1);
assert_eq!(stats.deleted, 1);
assert_eq!(stats.created, 1);
let left = stats.common + stats.modified + stats.deleted;
let right = stats.common + stats.modified + stats.created;
let different = stats.created + stats.modified + stats.deleted;
assert_eq!(left, 4);
assert_eq!(right, 4);
assert_eq!(different, 3);
}
fn fallback_fixture_old(path: &Path, non_indexed: bool) {
let nodes = [
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![("name", "old")], meta: None },
TestNode { id: 3, lat: 120_000_000, lon: 220_000_000, tags: vec![], meta: None },
];
let ways = [TestWay { id: 10, refs: vec![1, 2, 3], tags: vec![("highway", "primary")], meta: None }];
if non_indexed {
write_test_pbf_non_indexed(path, &nodes, &ways, &[]);
} else {
write_test_pbf_sorted(path, &nodes, &ways, &[]);
}
}
fn fallback_fixture_new(path: &Path, non_indexed: bool) {
let nodes = [
TestNode { id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![], meta: None },
TestNode { id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![("name", "new")], meta: None },
TestNode { id: 4, lat: 130_000_000, lon: 230_000_000, tags: vec![], meta: None },
];
let ways = [TestWay { id: 10, refs: vec![1, 2, 3], tags: vec![("highway", "primary")], meta: None }];
if non_indexed {
write_test_pbf_non_indexed(path, &nodes, &ways, &[]);
} else {
write_test_pbf_sorted(path, &nodes, &ways, &[]);
}
}
fn assert_fallback_stats(stats: &DiffStats) {
assert_eq!(stats.common, 2);
assert_eq!(stats.modified, 1);
assert_eq!(stats.deleted, 1);
assert_eq!(stats.created, 1);
}
#[test]
fn fallback_old_non_indexed() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
fallback_fixture_old(&old, true);
fallback_fixture_new(&new, false);
assert_non_indexed(&old);
assert_indexed(&new);
let (_, stats) = run_diff(&old, &new, DiffOpts::default());
assert_fallback_stats(&stats);
}
#[test]
fn fallback_new_non_indexed() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
fallback_fixture_old(&old, false);
fallback_fixture_new(&new, true);
assert_indexed(&old);
assert_non_indexed(&new);
let (_, stats) = run_diff(&old, &new, DiffOpts::default());
assert_fallback_stats(&stats);
}
#[test]
fn fallback_both_non_indexed() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
fallback_fixture_old(&old, true);
fallback_fixture_new(&new, true);
assert_non_indexed(&old);
assert_non_indexed(&new);
let (_, stats) = run_diff(&old, &new, DiffOpts::default());
assert_fallback_stats(&stats);
}
#[test]
fn fallback_parity_with_indexed_path() {
let dir = TempDir::new().expect("tempdir");
let oi = dir.path().join("old_indexed.osm.pbf");
let on = dir.path().join("old_nonindexed.osm.pbf");
let ni = dir.path().join("new_indexed.osm.pbf");
let nn = dir.path().join("new_nonindexed.osm.pbf");
fallback_fixture_old(&oi, false);
fallback_fixture_old(&on, true);
fallback_fixture_new(&ni, false);
fallback_fixture_new(&nn, true);
let (text_ii, stats_ii) = run_diff(&oi, &ni, DiffOpts::default());
let (text_ni, stats_ni) = run_diff(&on, &ni, DiffOpts::default());
let (text_in, stats_in) = run_diff(&oi, &nn, DiffOpts::default());
let (text_nn, stats_nn) = run_diff(&on, &nn, DiffOpts::default());
assert_eq!(text_ii, text_ni, "old non-indexed drifts");
assert_eq!(text_ii, text_in, "new non-indexed drifts");
assert_eq!(text_ii, text_nn, "both non-indexed drift");
for s in [&stats_ii, &stats_ni, &stats_in, &stats_nn] {
assert_fallback_stats(s);
}
}
#[test]
fn fallback_verbose_parity() {
let dir = TempDir::new().expect("tempdir");
let oi = dir.path().join("old_indexed.osm.pbf");
let on = dir.path().join("old_nonindexed.osm.pbf");
let ni = dir.path().join("new_indexed.osm.pbf");
let nn = dir.path().join("new_nonindexed.osm.pbf");
fallback_fixture_old(&oi, false);
fallback_fixture_old(&on, true);
fallback_fixture_new(&ni, false);
fallback_fixture_new(&nn, true);
let opts = DiffOpts { verbose: true, ..DiffOpts::default() };
let (text_ii, _) = run_diff(&oi, &ni, opts);
let (text_nn, _) = run_diff(&on, &nn, opts);
assert_eq!(text_ii, text_nn, "verbose output differs between indexed and fallback paths");
}
fn meta(v: i32, user: &'static str) -> TestMeta {
TestMeta { version: v, timestamp: 0, changeset: 0, uid: 0, user, visible: true }
}
#[test]
fn diff_compact_line_shows_version_when_meta_present() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
let nodes = [TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![],
meta: Some(meta(1, "")),
}];
write_test_pbf_sorted(&old, &nodes, &[], &[]);
write_test_pbf_sorted(&new, &nodes, &[], &[]);
let (text, _) = run_diff(&old, &new, DiffOpts::default());
assert!(text.contains(" n1 v1"), "common line should include v1; text:\n{text}");
}
#[test]
fn diff_modified_line_shows_version_bump_on_payload_change() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![("name", "old")],
meta: Some(meta(1, "alice")),
}], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![("name", "new")],
meta: Some(meta(2, "bob")),
}], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts::default());
assert!(text.contains("*n1 v1 -> v2"), "v-bump line; text:\n{text}");
assert_eq!(stats.modified, 1);
assert_eq!(stats.common, 0);
}
#[test]
fn diff_pure_metadata_bump_is_common_not_modified() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![("name", "same")],
meta: Some(meta(1, "alice")),
}], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![("name", "same")],
meta: Some(meta(2, "bob")),
}], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts::default());
assert_eq!(stats.modified, 0, "pure metadata bumps must NOT be modified");
assert_eq!(stats.common, 1);
assert!(text.contains(" n1 v1"), "common line should carry old-side version; text:\n{text}");
}
#[test]
fn diff_created_deleted_lines_carry_version() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![],
meta: Some(meta(3, "alice")),
}], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode {
id: 2, lat: 110_000_000, lon: 210_000_000, tags: vec![],
meta: Some(meta(5, "bob")),
}], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts::default());
assert!(text.contains("-n1 v3"), "deleted line includes version; text:\n{text}");
assert!(text.contains("+n2 v5"), "created line includes version; text:\n{text}");
assert_eq!(stats.deleted, 1);
assert_eq!(stats.created, 1);
}
#[test]
fn diff_mixed_metadata_sides() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![("name", "old")],
meta: Some(meta(4, "alice")),
}], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![("name", "new")],
meta: None,
}], &[], &[]);
let (text, stats) = run_diff(&old, &new, DiffOpts::default());
assert!(text.contains("*n1 v4"), "mixed-metadata modified line; text:\n{text}");
assert_eq!(stats.modified, 1);
}
#[test]
fn diff_verbose_emits_payload_deltas_not_metadata_deltas() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
write_test_pbf_sorted(&old, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![],
meta: Some(meta(1, "alice")),
}], &[], &[]);
write_test_pbf_sorted(&new, &[TestNode {
id: 1, lat: 100_000_000, lon: 200_000_000, tags: vec![("name", "added")],
meta: Some(meta(2, "bob")),
}], &[], &[]);
let (text, _) = run_diff(&old, &new, DiffOpts { verbose: true, ..DiffOpts::default() });
assert!(text.contains("*n1 v1 -> v2"), "v-bump line; text:\n{text}");
assert!(text.contains("+name=added"), "tag-add detail; text:\n{text}");
for line in text.lines() {
if line.is_empty() { continue; }
assert!(
!line.contains("alice") && !line.contains("bob"),
"verbose must not leak metadata; line:\n{line}",
);
assert!(
!line.starts_with(" user") && !line.starts_with(" version"),
"verbose must not emit metadata-keyed detail; line:\n{line}",
);
}
}
#[test]
fn diff_block_pair_covers_all_four_branches() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
let old_nodes = generate_nodes(30, 1);
write_multi_block_test_pbf(&old, &old_nodes, &[], &[], 10);
let mut new_nodes = generate_nodes(10, 1);
let mut blob_b = generate_nodes(10, 11);
blob_b[4].tags = vec![("touched", "yes")];
new_nodes.extend(blob_b);
new_nodes.extend(generate_nodes(10, 31));
write_multi_block_test_pbf(&new, &new_nodes, &[], &[], 10);
let (_, stats) = run_diff(&old, &new, DiffOpts::default());
assert_eq!(stats.common, 19, "byte-equal blob A (10) + overlap-equal blob B (9)");
assert_eq!(stats.modified, 1, "exactly one element (id 15) modified");
assert_eq!(stats.deleted, 10, "blob-old-C BlobOldOnly");
assert_eq!(stats.created, 10, "blob-new-C BlobNewOnly");
}
#[test]
fn diff_block_pair_parallel_matches_sequential_on_multi_blob() {
let dir = TempDir::new().expect("tempdir");
let old = dir.path().join("old.osm.pbf");
let new = dir.path().join("new.osm.pbf");
let old_nodes = generate_nodes(30, 1);
write_multi_block_test_pbf(&old, &old_nodes, &[], &[], 10);
let mut new_nodes = generate_nodes(10, 1);
let mut blob_b = generate_nodes(10, 11);
blob_b[4].tags = vec![("touched", "yes")];
new_nodes.extend(blob_b);
new_nodes.extend(generate_nodes(10, 31));
write_multi_block_test_pbf(&new, &new_nodes, &[], &[], 10);
let (text_seq, stats_seq) = run_diff(&old, &new, DiffOpts { jobs: Some(1), ..DiffOpts::default() });
let (text_par, stats_par) = run_diff(&old, &new, DiffOpts { jobs: Some(4), ..DiffOpts::default() });
assert_eq!(stats_seq, stats_par, "parallel stats drift");
assert_eq!(text_seq, text_par, "parallel text diverges from sequential on multi-blob input");
}