mgrs 0.4.2

Bidirectional MGRS/lat-long coordinate conversion CLI with multi-format I/O: CSV, GeoJSON, KML, KMZ, GPX, WKT, TopoJSON, Shapefile, GeoPackage, FlatGeobuf
Documentation
use std::io::{Read, Write};
use anyhow::{Context, Result};
use serde_json::{json, Value};
use crate::formats::{ConvertedRow, OutputFormat, InputFormat, InputRecord};

pub struct TopoJsonOutput<W: Write> {
    output: W,
    geometries: Vec<Value>,
}

impl<W: Write> TopoJsonOutput<W> {
    pub fn new(output: W) -> Self { Self { output, geometries: Vec::new() } }
}

impl<W: Write> OutputFormat for TopoJsonOutput<W> {
    fn write_header(&mut self, _headers: &[String]) -> Result<()> { Ok(()) }

    fn write_row(&mut self, row: &ConvertedRow) -> Result<()> {
        if let (Some(lat), Some(lon)) = (row.latitude, row.longitude) {
            let mut props = serde_json::Map::new();
            for (h, f) in row.headers.iter().zip(row.fields.iter()) {
                props.insert(h.clone(), Value::String(f.clone()));
            }
            self.geometries.push(json!({
                "type": "Point", "coordinates": [lon, lat], "properties": props
            }));
        }
        Ok(())
    }

    fn finish(&mut self) -> Result<()> {
        let topo = json!({
            "type": "Topology",
            "objects": { "points": { "type": "GeometryCollection", "geometries": self.geometries } },
            "arcs": []
        });
        serde_json::to_writer_pretty(&mut self.output, &topo)?;
        self.output.flush()?;
        Ok(())
    }
}

pub struct TopoJsonInput {
    headers: Vec<String>,
    records: std::vec::IntoIter<InputRecord>,
}

impl TopoJsonInput {
    pub fn new<R: Read>(mut input: R) -> Result<Self> {
        let mut buf = String::new();
        input.read_to_string(&mut buf)?;
        let json: Value = serde_json::from_str(&buf).context("Failed to parse TopoJSON")?;
        if json.get("type").and_then(|t| t.as_str()) != Some("Topology") {
            anyhow::bail!("Not a Topology");
        }
        let objects = json.get("objects").and_then(|o| o.as_object())
            .context("Missing objects")?;

        let mut all_geoms = Vec::new();
        for (_, obj) in objects {
            if let Some(gs) = obj.get("geometries").and_then(|g| g.as_array()) {
                all_geoms.extend(gs.iter());
            }
        }

        let mut keys = Vec::new();
        for g in &all_geoms {
            if let Some(props) = g.get("properties").and_then(|p| p.as_object()) {
                for k in props.keys() {
                    if !keys.contains(k) { keys.push(k.clone()); }
                }
            }
        }

        let mut records = Vec::new();
        for g in &all_geoms {
            let (lat, lon) = extract_point(g);
            let mut fields = Vec::new();
            if let Some(props) = g.get("properties").and_then(|p| p.as_object()) {
                for k in &keys {
                    let v = props.get(k).map(|v| match v {
                        Value::String(s) => s.clone(), Value::Null => String::new(),
                        o => o.to_string(),
                    }).unwrap_or_default();
                    fields.push((k.clone(), v));
                }
            }
            records.push(InputRecord { fields, latitude: lat, longitude: lon });
        }
        Ok(Self { headers: keys, records: records.into_iter() })
    }
}

fn extract_point(g: &Value) -> (Option<f64>, Option<f64>) {
    let inner = || -> Option<(f64, f64)> {
        if g.get("type")?.as_str()? != "Point" { return None; }
        let c = g.get("coordinates")?.as_array()?;
        Some((c.get(1)?.as_f64()?, c.first()?.as_f64()?))
    };
    match inner() { Some((lat, lon)) => (Some(lat), Some(lon)), None => (None, None) }
}

impl InputFormat for TopoJsonInput {
    fn headers(&self) -> Vec<String> { self.headers.clone() }
    fn next_record(&mut self) -> Result<Option<InputRecord>> { Ok(self.records.next()) }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Cursor;

    #[test]
    fn test_valid_structure() {
        let mut buf = Vec::new();
        let mut w = TopoJsonOutput::new(&mut buf);
        w.write_header(&["Name".into()]).unwrap();
        w.write_row(&ConvertedRow {
            fields: vec!["DC".into()], headers: vec!["Name".into()],
            latitude: Some(38.8977), longitude: Some(-77.0365), mgrs_source: None,
        }).unwrap();
        w.finish().unwrap();
        let json: Value = serde_json::from_slice(&buf).unwrap();
        assert_eq!(json["type"], "Topology");
        assert!(json["objects"].is_object());
        assert_eq!(json["arcs"].as_array().unwrap().len(), 0);
    }

    #[test]
    fn test_point_geometry() {
        let mut buf = Vec::new();
        let mut w = TopoJsonOutput::new(&mut buf);
        w.write_header(&[]).unwrap();
        w.write_row(&ConvertedRow {
            fields: vec![], headers: vec![],
            latitude: Some(38.8977), longitude: Some(-77.0365), mgrs_source: None,
        }).unwrap();
        w.finish().unwrap();
        let json: Value = serde_json::from_slice(&buf).unwrap();
        let geom = &json["objects"]["points"]["geometries"][0];
        assert_eq!(geom["type"], "Point");
        assert_eq!(geom["coordinates"][0], -77.0365);
        assert_eq!(geom["coordinates"][1], 38.8977);
    }

    #[test]
    fn test_properties() {
        let mut buf = Vec::new();
        let mut w = TopoJsonOutput::new(&mut buf);
        w.write_header(&["Name".into()]).unwrap();
        w.write_row(&ConvertedRow {
            fields: vec!["DC".into()], headers: vec!["Name".into()],
            latitude: Some(38.0), longitude: Some(-77.0), mgrs_source: None,
        }).unwrap();
        w.finish().unwrap();
        let json: Value = serde_json::from_slice(&buf).unwrap();
        assert_eq!(json["objects"]["points"]["geometries"][0]["properties"]["Name"], "DC");
    }

    #[test]
    fn test_input_reads_topology() {
        let data = r#"{"type":"Topology","objects":{"points":{"type":"GeometryCollection",
            "geometries":[{"type":"Point","coordinates":[-77.0365,38.8977],
            "properties":{"Name":"DC"}}]}},"arcs":[]}"#;
        let mut r = TopoJsonInput::new(Cursor::new(data)).unwrap();
        let rec = r.next_record().unwrap().unwrap();
        assert!((rec.latitude.unwrap() - 38.8977).abs() < 0.0001);
    }

    #[test]
    fn test_roundtrip() {
        let mut buf = Vec::new();
        let mut w = TopoJsonOutput::new(&mut buf);
        w.write_header(&["Name".into()]).unwrap();
        w.write_row(&ConvertedRow {
            fields: vec!["DC".into()], headers: vec!["Name".into()],
            latitude: Some(38.8977), longitude: Some(-77.0365), mgrs_source: None,
        }).unwrap();
        w.finish().unwrap();
        let mut r = TopoJsonInput::new(Cursor::new(&buf)).unwrap();
        let rec = r.next_record().unwrap().unwrap();
        assert!((rec.latitude.unwrap() - 38.8977).abs() < 0.0001);
        let name = rec.fields.iter().find(|(k,_)| k == "Name").unwrap();
        assert_eq!(name.1, "DC");
        assert!(r.next_record().unwrap().is_none());
    }
}