jsonfg 0.1.0

Types for OGC Features and Geometries JSON (JSON-FG), a GeoJSON superset with support for arbitrary coordinate reference systems, solids, and curved geometries.
Documentation
//! Round-trip every `*.json` file under a directory through the JSON-FG types and report
//! any that do not re-serialize identically.
//!
//! Usage: `cargo run --example validate -- <dir>`

use std::path::Path;
use std::{env, fs};

use jsonfg::{Feature, FeatureCollection, Geometry};
use serde_json::Value;

fn main() {
    let dir = env::args().nth(1).expect("usage: validate <dir>");
    let (mut total, mut ok, mut mismatch, mut err) = (0u32, 0u32, 0u32, 0u32);
    let mut files: Vec<_> = Vec::new();
    collect(Path::new(&dir), &mut files);
    files.sort();

    for path in files {
        total += 1;
        let text = fs::read_to_string(&path).unwrap();
        let original: Value = match serde_json::from_str(&text) {
            Ok(v) => v,
            Err(e) => {
                err += 1;
                println!("ERR  parse   {}: {e}", path.display());
                continue;
            }
        };
        let kind = original.get("type").and_then(Value::as_str).unwrap_or("");
        let back = match kind {
            "Feature" => {
                serde_json::from_value::<Feature>(original.clone()).and_then(serde_json::to_value)
            }
            "FeatureCollection" => serde_json::from_value::<FeatureCollection>(original.clone())
                .and_then(serde_json::to_value),
            _ => {
                serde_json::from_value::<Geometry>(original.clone()).and_then(serde_json::to_value)
            }
        };
        match back {
            // Compare with numbers normalized to f64, so that whole-number coordinates
            // written as integers (`100`) match their f64 round-trip (`100.0`).
            Ok(back) if normalize(&back) == normalize(&original) => {
                ok += 1;
                println!("OK   {:<18} {}", kind, path.display());
            }
            Ok(back) => {
                mismatch += 1;
                println!("DIFF {:<18} {}", kind, path.display());
                for key in dropped_keys(&normalize(&original), &normalize(&back)) {
                    println!("       changed member: {key}");
                }
            }
            Err(e) => {
                err += 1;
                println!("ERR  decode  {}: {e}", path.display());
            }
        }
    }

    println!("\n{total} files: {ok} ok, {mismatch} mismatch, {err} error");
}

/// Top-level members present in `a` but missing/changed in `b`.
fn dropped_keys(a: &Value, b: &Value) -> Vec<String> {
    match (a, b) {
        (Value::Object(a), Value::Object(b)) => a
            .iter()
            .filter(|(k, v)| b.get(*k) != Some(*v))
            .map(|(k, _)| k.clone())
            .collect(),
        _ => Vec::new(),
    }
}

/// Recursively rewrite every JSON number to its `f64` value, so integer and float
/// spellings of the same value compare equal.
fn normalize(v: &Value) -> Value {
    match v {
        Value::Number(n) => n
            .as_f64()
            .and_then(serde_json::Number::from_f64)
            .map(Value::Number)
            .unwrap_or_else(|| v.clone()),
        Value::Array(a) => Value::Array(a.iter().map(normalize).collect()),
        Value::Object(o) => {
            Value::Object(o.iter().map(|(k, x)| (k.clone(), normalize(x))).collect())
        }
        other => other.clone(),
    }
}

fn collect(dir: &Path, out: &mut Vec<std::path::PathBuf>) {
    for entry in fs::read_dir(dir).unwrap() {
        let path = entry.unwrap().path();
        if path.is_dir() {
            collect(&path, out);
        } else if path.extension().is_some_and(|e| e == "json") {
            out.push(path);
        }
    }
}