Skip to main content

oxigeo_cli/util/
vector.rs

1//! Vector format conversion utilities
2//!
3//! Provides format-to-format vector conversion with optional attribute filtering.
4//! Supports GeoJSON, Shapefile, and FlatGeobuf as input and output formats.
5
6use anyhow::{Context, Result, anyhow};
7use std::fs::File;
8use std::io::{BufReader, BufWriter};
9use std::path::Path;
10
11// ─── Public enums / structs ───────────────────────────────────────────────────
12
13/// Supported vector output formats for CLI conversion.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum VectorFormat {
16    /// GeoJSON format (`.geojson`, `.json`).
17    GeoJson,
18    /// ESRI Shapefile format (`.shp`).
19    Shapefile,
20    /// FlatGeobuf format (`.fgb`).
21    FlatGeobuf,
22}
23
24impl VectorFormat {
25    /// Detect format from file extension.
26    pub fn from_path(path: &Path) -> Option<Self> {
27        path.extension()
28            .and_then(|e| e.to_str())
29            .and_then(|e| match e.to_lowercase().as_str() {
30                "geojson" | "json" => Some(Self::GeoJson),
31                "shp" => Some(Self::Shapefile),
32                "fgb" => Some(Self::FlatGeobuf),
33                _ => None,
34            })
35    }
36}
37
38/// Attribute filter operator.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum FilterOp {
41    /// Exact string equality (case-insensitive).
42    Eq,
43    /// Inequality (case-insensitive).
44    Ne,
45    /// Substring containment (case-insensitive).
46    Contains,
47}
48
49/// Attribute filter: matches a named field against a value using an operator.
50#[derive(Debug, Clone)]
51pub struct AttributeFilter {
52    /// The name of the feature attribute field to filter on.
53    pub field: String,
54    /// The comparison operator to apply.
55    pub op: FilterOp,
56    /// The value to compare against.
57    pub value: String,
58}
59
60impl AttributeFilter {
61    /// Test whether a JSON properties map matches this filter.
62    pub fn matches_json(&self, props: &serde_json::Map<String, serde_json::Value>) -> bool {
63        match props.get(&self.field) {
64            None => false,
65            Some(v) => {
66                let candidate = json_value_to_string(v).to_lowercase();
67                let target = self.value.to_lowercase();
68                match self.op {
69                    FilterOp::Eq => candidate == target,
70                    FilterOp::Ne => candidate != target,
71                    FilterOp::Contains => candidate.contains(&target),
72                }
73            }
74        }
75    }
76
77    /// Test whether an `oxigeo_core::vector::FieldValue` map matches this filter.
78    pub fn matches_field_map(
79        &self,
80        props: &std::collections::HashMap<String, oxigeo_core::vector::FieldValue>,
81    ) -> bool {
82        match props.get(&self.field) {
83            None => false,
84            Some(v) => {
85                let candidate = field_value_to_string(v).to_lowercase();
86                let target = self.value.to_lowercase();
87                match self.op {
88                    FilterOp::Eq => candidate == target,
89                    FilterOp::Ne => candidate != target,
90                    FilterOp::Contains => candidate.contains(&target),
91                }
92            }
93        }
94    }
95}
96
97// ─── Helper: stringify values ─────────────────────────────────────────────────
98
99fn json_value_to_string(v: &serde_json::Value) -> String {
100    match v {
101        serde_json::Value::Null => String::new(),
102        serde_json::Value::Bool(b) => b.to_string(),
103        serde_json::Value::Number(n) => n.to_string(),
104        serde_json::Value::String(s) => s.clone(),
105        serde_json::Value::Array(a) => format!("{a:?}"),
106        serde_json::Value::Object(o) => format!("{o:?}"),
107    }
108}
109
110fn field_value_to_string(v: &oxigeo_core::vector::FieldValue) -> String {
111    use oxigeo_core::vector::FieldValue;
112    match v {
113        FieldValue::Null => String::new(),
114        FieldValue::Bool(b) => b.to_string(),
115        FieldValue::Integer(i) => i.to_string(),
116        FieldValue::UInteger(u) => u.to_string(),
117        FieldValue::Float(f) => f.to_string(),
118        FieldValue::String(s) => s.clone(),
119        FieldValue::Array(a) => format!("{a:?}"),
120        FieldValue::Object(o) => format!("{o:?}"),
121        FieldValue::Date(d) => d.to_string(),
122        FieldValue::Blob(b) => format!("{b:?}"),
123    }
124}
125
126// ─── GeoJSON Geometry ↔ Core Geometry converters ─────────────────────────────
127
128/// Convert a GeoJSON geometry (`oxigeo_geojson::Geometry`) to a core geometry
129/// (`oxigeo_core::vector::Geometry`).  Returns an error if coordinates are malformed.
130pub fn geojson_geom_to_core(
131    geom: &oxigeo_geojson::Geometry,
132) -> Result<oxigeo_core::vector::Geometry> {
133    use oxigeo_core::vector::{
134        Geometry as CoreGeom, GeometryCollection as CoreGC, LineString as CoreLS,
135        MultiLineString as CoreMLS, MultiPoint as CoreMP, MultiPolygon as CoreMPoly,
136        Point as CorePoint, Polygon as CorePoly,
137    };
138    use oxigeo_geojson::Geometry as GjGeom;
139
140    match geom {
141        GjGeom::Point(p) => {
142            let coord = pos_to_coord(&p.coordinates)?;
143            Ok(CoreGeom::Point(CorePoint::from_coord(coord)))
144        }
145        GjGeom::LineString(ls) => {
146            let coords = positions_to_coords(&ls.coordinates)?;
147            let core_ls =
148                CoreLS::new(coords).map_err(|e| anyhow!("LineString conversion error: {e}"))?;
149            Ok(CoreGeom::LineString(core_ls))
150        }
151        GjGeom::Polygon(p) => {
152            let rings = p
153                .coordinates
154                .iter()
155                .map(|ring| positions_to_coords(ring))
156                .collect::<Result<Vec<_>>>()?;
157            let (exterior, interiors) = rings_to_exterior_interiors(rings)?;
158            let core_poly = CorePoly::new(exterior, interiors)
159                .map_err(|e| anyhow!("Polygon conversion error: {e}"))?;
160            Ok(CoreGeom::Polygon(core_poly))
161        }
162        GjGeom::MultiPoint(mp) => {
163            let points = mp
164                .coordinates
165                .iter()
166                .map(|pos| {
167                    let coord = pos_to_coord(pos)?;
168                    Ok(CorePoint::from_coord(coord))
169                })
170                .collect::<Result<Vec<_>>>()?;
171            Ok(CoreGeom::MultiPoint(CoreMP::new(points)))
172        }
173        GjGeom::MultiLineString(mls) => {
174            let lines = mls
175                .coordinates
176                .iter()
177                .map(|ring| {
178                    let coords = positions_to_coords(ring)?;
179                    CoreLS::new(coords).map_err(|e| anyhow!("MultiLineString line error: {e}"))
180                })
181                .collect::<Result<Vec<_>>>()?;
182            Ok(CoreGeom::MultiLineString(CoreMLS {
183                line_strings: lines,
184            }))
185        }
186        GjGeom::MultiPolygon(mpoly) => {
187            let polygons = mpoly
188                .coordinates
189                .iter()
190                .map(|rings| {
191                    let coord_rings = rings
192                        .iter()
193                        .map(|ring| positions_to_coords(ring))
194                        .collect::<Result<Vec<_>>>()?;
195                    let (exterior, interiors) = rings_to_exterior_interiors(coord_rings)?;
196                    CorePoly::new(exterior, interiors)
197                        .map_err(|e| anyhow!("MultiPolygon polygon error: {e}"))
198                })
199                .collect::<Result<Vec<_>>>()?;
200            Ok(CoreGeom::MultiPolygon(CoreMPoly { polygons }))
201        }
202        GjGeom::GeometryCollection(gc) => {
203            let geoms = gc
204                .geometries
205                .iter()
206                .map(geojson_geom_to_core)
207                .collect::<Result<Vec<_>>>()?;
208            Ok(CoreGeom::GeometryCollection(CoreGC { geometries: geoms }))
209        }
210    }
211}
212
213/// Convert a core geometry to a GeoJSON geometry.
214pub fn core_geom_to_geojson(
215    geom: &oxigeo_core::vector::Geometry,
216) -> Result<oxigeo_geojson::Geometry> {
217    use oxigeo_core::vector::Geometry as CoreGeom;
218    use oxigeo_geojson::types::{
219        Geometry as GjGeom, GeometryCollection as GjGC, LineString as GjLS,
220        MultiLineString as GjMLS, MultiPoint as GjMP, MultiPolygon as GjMPoly, Point as GjPoint,
221        Polygon as GjPoly,
222    };
223
224    match geom {
225        CoreGeom::Point(p) => {
226            let pos = coord_to_pos(&p.coord);
227            let gj_point = GjPoint::new(pos).map_err(|e| anyhow!("Point conversion error: {e}"))?;
228            Ok(GjGeom::Point(gj_point))
229        }
230        CoreGeom::LineString(ls) => {
231            let coords = ls.coords.iter().map(coord_to_pos).collect();
232            let gj_ls =
233                GjLS::new(coords).map_err(|e| anyhow!("LineString conversion error: {e}"))?;
234            Ok(GjGeom::LineString(gj_ls))
235        }
236        CoreGeom::Polygon(p) => {
237            let exterior: Vec<Vec<f64>> = p.exterior.coords.iter().map(coord_to_pos).collect();
238            let mut rings = vec![exterior];
239            for interior in &p.interiors {
240                rings.push(interior.coords.iter().map(coord_to_pos).collect());
241            }
242            let gj_poly =
243                GjPoly::new(rings).map_err(|e| anyhow!("Polygon conversion error: {e}"))?;
244            Ok(GjGeom::Polygon(gj_poly))
245        }
246        CoreGeom::MultiPoint(mp) => {
247            let positions = mp.points.iter().map(|p| coord_to_pos(&p.coord)).collect();
248            let gj_mp = GjMP {
249                coordinates: positions,
250                bbox: None,
251            };
252            Ok(GjGeom::MultiPoint(gj_mp))
253        }
254        CoreGeom::MultiLineString(mls) => {
255            let lines = mls
256                .line_strings
257                .iter()
258                .map(|ls| ls.coords.iter().map(coord_to_pos).collect::<Vec<_>>())
259                .collect();
260            let gj_mls = GjMLS {
261                coordinates: lines,
262                bbox: None,
263            };
264            Ok(GjGeom::MultiLineString(gj_mls))
265        }
266        CoreGeom::MultiPolygon(mpoly) => {
267            let polys = mpoly
268                .polygons
269                .iter()
270                .map(|p| {
271                    let ext: Vec<Vec<f64>> = p.exterior.coords.iter().map(coord_to_pos).collect();
272                    let mut rings = vec![ext];
273                    for interior in &p.interiors {
274                        rings.push(interior.coords.iter().map(coord_to_pos).collect());
275                    }
276                    rings
277                })
278                .collect();
279            let gj_mpoly = GjMPoly {
280                coordinates: polys,
281                bbox: None,
282            };
283            Ok(GjGeom::MultiPolygon(gj_mpoly))
284        }
285        CoreGeom::GeometryCollection(gc) => {
286            let geoms = gc
287                .geometries
288                .iter()
289                .map(core_geom_to_geojson)
290                .collect::<Result<Vec<_>>>()?;
291            let gj_gc = GjGC {
292                geometries: geoms,
293                bbox: None,
294            };
295            Ok(GjGeom::GeometryCollection(gj_gc))
296        }
297    }
298}
299
300// ─── Small coordinate helpers ─────────────────────────────────────────────────
301
302fn pos_to_coord(pos: &[f64]) -> Result<oxigeo_core::vector::Coordinate> {
303    if pos.len() < 2 {
304        return Err(anyhow!(
305            "position needs at least 2 elements, got {}",
306            pos.len()
307        ));
308    }
309    Ok(oxigeo_core::vector::Coordinate {
310        x: pos[0],
311        y: pos[1],
312        z: pos.get(2).copied(),
313        m: None,
314    })
315}
316
317fn positions_to_coords(positions: &[Vec<f64>]) -> Result<Vec<oxigeo_core::vector::Coordinate>> {
318    positions.iter().map(|p| pos_to_coord(p)).collect()
319}
320
321fn coord_to_pos(c: &oxigeo_core::vector::Coordinate) -> Vec<f64> {
322    match c.z {
323        Some(z) => vec![c.x, c.y, z],
324        None => vec![c.x, c.y],
325    }
326}
327
328/// Split a vec of rings into (exterior, interiors).
329/// The exterior ring is the first ring; the rest are interior holes.
330fn rings_to_exterior_interiors(
331    mut rings: Vec<Vec<oxigeo_core::vector::Coordinate>>,
332) -> Result<(
333    oxigeo_core::vector::LineString,
334    Vec<oxigeo_core::vector::LineString>,
335)> {
336    use oxigeo_core::vector::LineString as CoreLS;
337
338    if rings.is_empty() {
339        return Err(anyhow!("polygon has no rings"));
340    }
341
342    let exterior_coords = rings.remove(0);
343    let exterior = CoreLS::new(exterior_coords).map_err(|e| anyhow!("exterior ring error: {e}"))?;
344
345    let interiors = rings
346        .into_iter()
347        .map(|ring| CoreLS::new(ring).map_err(|e| anyhow!("interior ring error: {e}")))
348        .collect::<Result<Vec<_>>>()?;
349
350    Ok((exterior, interiors))
351}
352
353// ─── Shapefile schema inference ───────────────────────────────────────────────
354
355/// Infer the `ShapeType` and `FieldDescriptor`s from a slice of Shapefile features.
356/// Used when the input is GeoJSON and output is Shapefile.
357pub fn infer_shapefile_schema_from_geojson(
358    features: &[oxigeo_geojson::Feature],
359) -> Result<(
360    oxigeo_shapefile::shp::shapes::ShapeType,
361    Vec<oxigeo_shapefile::dbf::FieldDescriptor>,
362)> {
363    use oxigeo_geojson::Geometry as GjGeom;
364    use oxigeo_shapefile::{
365        dbf::{FieldDescriptor, FieldType},
366        shp::shapes::ShapeType,
367    };
368
369    // Determine shape type from first feature with geometry
370    let shape_type = features
371        .iter()
372        .find_map(|f| f.geometry.as_ref())
373        .map(|g| match g {
374            GjGeom::Point(_) => ShapeType::Point,
375            GjGeom::LineString(_) | GjGeom::MultiLineString(_) => ShapeType::PolyLine,
376            GjGeom::Polygon(_) | GjGeom::MultiPolygon(_) => ShapeType::Polygon,
377            GjGeom::MultiPoint(_) => ShapeType::MultiPoint,
378            GjGeom::GeometryCollection(_) => ShapeType::Point, // fallback
379        })
380        .unwrap_or(ShapeType::Point);
381
382    // Scan all features for property names and their max string width
383    let mut field_widths: std::collections::HashMap<String, u8> = std::collections::HashMap::new();
384
385    for feature in features {
386        if let Some(props) = &feature.properties {
387            for (key, value) in props {
388                let key_short = truncate_field_name(key);
389                let width = json_value_str_width(value);
390                let entry = field_widths.entry(key_short).or_insert(0);
391                if width > *entry {
392                    *entry = width;
393                }
394            }
395        }
396    }
397
398    let mut descriptors = Vec::new();
399    for (name, width) in &field_widths {
400        let length = width.clamp(&1, &254);
401        let desc = FieldDescriptor::new(name.clone(), FieldType::Character, *length, 0)
402            .with_context(|| format!("invalid field descriptor for '{name}'"))?;
403        descriptors.push(desc);
404    }
405
406    // Sort deterministically
407    descriptors.sort_by(|a, b| a.name.cmp(&b.name));
408
409    Ok((shape_type, descriptors))
410}
411
412/// Infer the `ShapeType` and `FieldDescriptor`s from core vector features
413/// (used for Shapefile-to-Shapefile passthrough is not needed here; used for
414/// Shapefile→FGB schema inference via core).
415pub fn infer_shapefile_schema_from_shapefiles(
416    features: &[oxigeo_shapefile::reader::ShapefileFeature],
417) -> Result<(
418    oxigeo_shapefile::shp::shapes::ShapeType,
419    Vec<oxigeo_shapefile::dbf::FieldDescriptor>,
420)> {
421    use oxigeo_core::vector::Geometry as CoreGeom;
422    use oxigeo_shapefile::{
423        dbf::{FieldDescriptor, FieldType},
424        shp::shapes::ShapeType,
425    };
426
427    let shape_type = features
428        .iter()
429        .find_map(|f| f.geometry.as_ref())
430        .map(|g| match g {
431            CoreGeom::Point(_) => ShapeType::Point,
432            CoreGeom::LineString(_) | CoreGeom::MultiLineString(_) => ShapeType::PolyLine,
433            CoreGeom::Polygon(_) | CoreGeom::MultiPolygon(_) => ShapeType::Polygon,
434            CoreGeom::MultiPoint(_) => ShapeType::MultiPoint,
435            CoreGeom::GeometryCollection(_) => ShapeType::Point,
436        })
437        .unwrap_or(ShapeType::Point);
438
439    let mut field_widths: std::collections::HashMap<String, u8> = std::collections::HashMap::new();
440
441    for feature in features {
442        for (key, value) in &feature.attributes {
443            let key_short = truncate_field_name(key);
444            let width = field_value_str_width(value);
445            let entry = field_widths.entry(key_short).or_insert(0);
446            if width > *entry {
447                *entry = width;
448            }
449        }
450    }
451
452    let mut descriptors = Vec::new();
453    for (name, width) in &field_widths {
454        let length = width.clamp(&1, &254);
455        let desc = FieldDescriptor::new(name.clone(), FieldType::Character, *length, 0)
456            .with_context(|| format!("invalid field descriptor for '{name}'"))?;
457        descriptors.push(desc);
458    }
459
460    descriptors.sort_by(|a, b| a.name.cmp(&b.name));
461
462    Ok((shape_type, descriptors))
463}
464
465/// Truncate a field name to the 10-character DBF limit.
466fn truncate_field_name(name: &str) -> String {
467    name.chars().take(10).collect()
468}
469
470fn json_value_str_width(v: &serde_json::Value) -> u8 {
471    let s = json_value_to_string(v);
472    s.len().min(254) as u8
473}
474
475fn field_value_str_width(v: &oxigeo_core::vector::FieldValue) -> u8 {
476    let s = field_value_to_string(v);
477    s.len().min(254) as u8
478}
479
480// ─── GeoJSON features → Shapefile features ───────────────────────────────────
481
482fn geojson_feature_to_shapefile(
483    feature: &oxigeo_geojson::Feature,
484    record_number: i32,
485    field_names: &[String],
486) -> Result<oxigeo_shapefile::reader::ShapefileFeature> {
487    use oxigeo_core::vector::FieldValue;
488    use oxigeo_shapefile::reader::ShapefileFeature;
489
490    let geometry = match &feature.geometry {
491        Some(gj_geom) => Some(geojson_geom_to_core(gj_geom)?),
492        None => None,
493    };
494
495    let mut attributes = std::collections::HashMap::new();
496
497    if let Some(props) = &feature.properties {
498        for name in field_names {
499            // find original key (may have been truncated to 10 chars)
500            let original_key = props
501                .keys()
502                .find(|k| truncate_field_name(k) == *name)
503                .cloned();
504
505            if let Some(key) = original_key {
506                let json_val = props.get(&key).cloned().unwrap_or(serde_json::Value::Null);
507                let fv = json_to_field_value(&json_val);
508                attributes.insert(name.clone(), fv);
509            } else {
510                attributes.insert(name.clone(), FieldValue::Null);
511            }
512        }
513    } else {
514        for name in field_names {
515            attributes.insert(name.clone(), FieldValue::Null);
516        }
517    }
518
519    Ok(ShapefileFeature::new(record_number, geometry, attributes))
520}
521
522fn json_to_field_value(v: &serde_json::Value) -> oxigeo_core::vector::FieldValue {
523    use oxigeo_core::vector::FieldValue;
524    match v {
525        serde_json::Value::Null => FieldValue::Null,
526        serde_json::Value::Bool(b) => FieldValue::Bool(*b),
527        serde_json::Value::Number(n) => {
528            if let Some(i) = n.as_i64() {
529                FieldValue::Integer(i)
530            } else if let Some(u) = n.as_u64() {
531                FieldValue::UInteger(u)
532            } else {
533                FieldValue::Float(n.as_f64().unwrap_or(0.0))
534            }
535        }
536        serde_json::Value::String(s) => FieldValue::String(s.clone()),
537        other => FieldValue::String(other.to_string()),
538    }
539}
540
541// ─── Shapefile features → GeoJSON FeatureCollection ──────────────────────────
542
543fn shapefile_feature_to_geojson(
544    sf: &oxigeo_shapefile::reader::ShapefileFeature,
545) -> Result<oxigeo_geojson::Feature> {
546    use oxigeo_geojson::Feature as GjFeature;
547
548    let geometry = match &sf.geometry {
549        Some(core_geom) => Some(core_geom_to_geojson(core_geom)?),
550        None => None,
551    };
552
553    let mut props = serde_json::Map::new();
554    for (key, value) in &sf.attributes {
555        props.insert(key.clone(), value.to_json_value());
556    }
557
558    Ok(GjFeature::new(geometry, Some(props)))
559}
560
561// ─── Main conversion entry point ─────────────────────────────────────────────
562
563/// Convert a vector dataset between supported formats.
564///
565/// Returns the number of features written.
566pub fn convert_vector(
567    input: &Path,
568    output: &Path,
569    filter: Option<&AttributeFilter>,
570) -> Result<usize> {
571    let input_fmt = VectorFormat::from_path(input)
572        .ok_or_else(|| anyhow!("Unknown input vector format: {}", input.display()))?;
573    let output_fmt = VectorFormat::from_path(output)
574        .ok_or_else(|| anyhow!("Cannot determine output format from: {}", output.display()))?;
575
576    match (input_fmt, output_fmt) {
577        (VectorFormat::GeoJson, VectorFormat::GeoJson) => {
578            convert_geojson_to_geojson(input, output, filter)
579        }
580        (VectorFormat::GeoJson, VectorFormat::Shapefile) => {
581            convert_geojson_to_shapefile(input, output, filter)
582        }
583        (VectorFormat::Shapefile, VectorFormat::GeoJson) => {
584            convert_shapefile_to_geojson(input, output, filter)
585        }
586        (VectorFormat::Shapefile, VectorFormat::Shapefile) => {
587            convert_shapefile_to_shapefile(input, output, filter)
588        }
589        (VectorFormat::GeoJson, VectorFormat::FlatGeobuf) => {
590            convert_geojson_to_fgb(input, output, filter)
591        }
592        (VectorFormat::Shapefile, VectorFormat::FlatGeobuf) => {
593            convert_shapefile_to_fgb(input, output, filter)
594        }
595        (VectorFormat::FlatGeobuf, VectorFormat::GeoJson) => {
596            convert_fgb_to_geojson(input, output, filter)
597        }
598        (VectorFormat::FlatGeobuf, VectorFormat::Shapefile) => {
599            convert_fgb_to_shapefile(input, output, filter)
600        }
601        (VectorFormat::FlatGeobuf, VectorFormat::FlatGeobuf) => {
602            convert_fgb_to_fgb(input, output, filter)
603        }
604    }
605}
606
607// ─── GeoJSON → GeoJSON ────────────────────────────────────────────────────────
608
609fn convert_geojson_to_geojson(
610    input: &Path,
611    output: &Path,
612    filter: Option<&AttributeFilter>,
613) -> Result<usize> {
614    use oxigeo_geojson::{FeatureCollection, GeoJsonReader, GeoJsonWriter};
615
616    let file =
617        File::open(input).with_context(|| format!("Failed to open input: {}", input.display()))?;
618    let buf_reader = BufReader::new(file);
619    let mut reader = GeoJsonReader::new(buf_reader);
620
621    let fc = reader
622        .read_feature_collection()
623        .context("Failed to read GeoJSON feature collection")?;
624
625    let features: Vec<_> = fc
626        .features
627        .into_iter()
628        .filter(|f| match filter {
629            None => true,
630            Some(filt) => {
631                let props = f
632                    .properties
633                    .as_ref()
634                    .map_or(serde_json::Map::new(), |p| p.clone());
635                filt.matches_json(&props)
636            }
637        })
638        .collect();
639
640    let count = features.len();
641
642    let out_fc = FeatureCollection::new(features);
643
644    let out_file = File::create(output)
645        .with_context(|| format!("Failed to create output: {}", output.display()))?;
646    let buf_writer = BufWriter::new(out_file);
647    let mut writer = GeoJsonWriter::pretty(buf_writer);
648
649    writer
650        .write_feature_collection(&out_fc)
651        .context("Failed to write GeoJSON")?;
652
653    Ok(count)
654}
655
656// ─── GeoJSON → Shapefile ──────────────────────────────────────────────────────
657
658fn convert_geojson_to_shapefile(
659    input: &Path,
660    output: &Path,
661    filter: Option<&AttributeFilter>,
662) -> Result<usize> {
663    use oxigeo_geojson::GeoJsonReader;
664    use oxigeo_shapefile::ShapefileWriter;
665
666    let file =
667        File::open(input).with_context(|| format!("Failed to open input: {}", input.display()))?;
668    let buf_reader = BufReader::new(file);
669    let mut reader = GeoJsonReader::new(buf_reader);
670
671    let fc = reader
672        .read_feature_collection()
673        .context("Failed to read GeoJSON feature collection")?;
674
675    let features: Vec<_> = fc
676        .features
677        .into_iter()
678        .filter(|f| match filter {
679            None => true,
680            Some(filt) => {
681                let props = f
682                    .properties
683                    .as_ref()
684                    .map_or(serde_json::Map::new(), |p| p.clone());
685                filt.matches_json(&props)
686            }
687        })
688        .collect();
689
690    if features.is_empty() {
691        anyhow::bail!("No features remain after filtering; cannot write empty Shapefile");
692    }
693
694    let count = features.len();
695
696    // Infer schema from filtered features
697    let (shape_type, field_descriptors) = infer_shapefile_schema_from_geojson(&features)?;
698
699    let field_names: Vec<String> = field_descriptors.iter().map(|d| d.name.clone()).collect();
700
701    // Output base path (strip .shp extension)
702    let base_path = output.with_extension("");
703
704    let mut writer = ShapefileWriter::new(&base_path, shape_type, field_descriptors)
705        .context("Failed to create Shapefile writer")?;
706
707    // Convert features to ShapefileFeature
708    let sf_features: Vec<_> = features
709        .iter()
710        .enumerate()
711        .map(|(i, f)| geojson_feature_to_shapefile(f, (i + 1) as i32, &field_names))
712        .collect::<Result<Vec<_>>>()?;
713
714    writer
715        .write_features(&sf_features)
716        .context("Failed to write Shapefile")?;
717
718    Ok(count)
719}
720
721// ─── Shapefile → GeoJSON ──────────────────────────────────────────────────────
722
723fn convert_shapefile_to_geojson(
724    input: &Path,
725    output: &Path,
726    filter: Option<&AttributeFilter>,
727) -> Result<usize> {
728    use oxigeo_geojson::{FeatureCollection, GeoJsonWriter};
729    use oxigeo_shapefile::ShapefileReader;
730
731    // Strip .shp extension for base path
732    let base_path = input.with_extension("");
733
734    let reader = ShapefileReader::open(&base_path)
735        .with_context(|| format!("Failed to open Shapefile: {}", input.display()))?;
736
737    let sf_features = reader
738        .read_features()
739        .context("Failed to read Shapefile features")?;
740
741    let filtered: Vec<_> = sf_features
742        .into_iter()
743        .filter(|f| match filter {
744            None => true,
745            Some(filt) => filt.matches_field_map(&f.attributes),
746        })
747        .collect();
748
749    let count = filtered.len();
750
751    // Convert to GeoJSON features
752    let gj_features = filtered
753        .iter()
754        .map(shapefile_feature_to_geojson)
755        .collect::<Result<Vec<_>>>()?;
756
757    let out_fc = FeatureCollection::new(gj_features);
758
759    let out_file = File::create(output)
760        .with_context(|| format!("Failed to create output: {}", output.display()))?;
761    let buf_writer = BufWriter::new(out_file);
762    let mut writer = GeoJsonWriter::pretty(buf_writer);
763
764    writer
765        .write_feature_collection(&out_fc)
766        .context("Failed to write GeoJSON")?;
767
768    Ok(count)
769}
770
771// ─── Shapefile → Shapefile ────────────────────────────────────────────────────
772
773fn convert_shapefile_to_shapefile(
774    input: &Path,
775    output: &Path,
776    filter: Option<&AttributeFilter>,
777) -> Result<usize> {
778    use oxigeo_shapefile::{ShapefileReader, ShapefileWriter};
779
780    let base_in = input.with_extension("");
781    let reader = ShapefileReader::open(&base_in)
782        .with_context(|| format!("Failed to open Shapefile: {}", input.display()))?;
783
784    let sf_features = reader
785        .read_features()
786        .context("Failed to read Shapefile features")?;
787
788    let filtered: Vec<_> = sf_features
789        .into_iter()
790        .filter(|f| match filter {
791            None => true,
792            Some(filt) => filt.matches_field_map(&f.attributes),
793        })
794        .collect();
795
796    if filtered.is_empty() {
797        anyhow::bail!("No features remain after filtering; cannot write empty Shapefile");
798    }
799
800    let count = filtered.len();
801
802    let (shape_type, field_descriptors) = infer_shapefile_schema_from_shapefiles(&filtered)?;
803
804    let base_out = output.with_extension("");
805    let mut writer = ShapefileWriter::new(&base_out, shape_type, field_descriptors)
806        .context("Failed to create Shapefile writer")?;
807
808    writer
809        .write_features(&filtered)
810        .context("Failed to write Shapefile")?;
811
812    Ok(count)
813}
814
815// ─── GeoJSON → FlatGeobuf ─────────────────────────────────────────────────────
816
817fn convert_geojson_to_fgb(
818    input: &Path,
819    output: &Path,
820    filter: Option<&AttributeFilter>,
821) -> Result<usize> {
822    use oxigeo_flatgeobuf::{FlatGeobufWriterBuilder, GeometryType};
823    use oxigeo_geojson::GeoJsonReader;
824
825    let file =
826        File::open(input).with_context(|| format!("Failed to open input: {}", input.display()))?;
827    let buf_reader = BufReader::new(file);
828    let mut reader = GeoJsonReader::new(buf_reader);
829
830    let fc = reader
831        .read_feature_collection()
832        .context("Failed to read GeoJSON feature collection")?;
833
834    // Apply attribute filter
835    let features: Vec<_> = fc
836        .features
837        .into_iter()
838        .filter(|f| match filter {
839            None => true,
840            Some(filt) => {
841                let props = f
842                    .properties
843                    .as_ref()
844                    .map_or(serde_json::Map::new(), |p| p.clone());
845                filt.matches_json(&props)
846            }
847        })
848        .collect();
849
850    let count = features.len();
851
852    // Convert to core features, collecting schema info
853    let core_features: Vec<oxigeo_core::vector::Feature> = features
854        .iter()
855        .map(geojson_driver_feature_to_core)
856        .collect::<Result<Vec<_>>>()?;
857
858    // Infer geometry type from first feature with geometry
859    let fgb_geom_type = core_features
860        .iter()
861        .find_map(|f| f.geometry.as_ref())
862        .map(core_geom_to_fgb_geometry_type)
863        .unwrap_or(GeometryType::Unknown);
864
865    // Collect column definitions from properties of all features
866    let columns = infer_fgb_columns(&core_features);
867
868    // Build the writer with index enabled
869    let mut builder = FlatGeobufWriterBuilder::new(fgb_geom_type).with_index();
870    for col in &columns {
871        builder = builder.with_column(col.clone());
872    }
873
874    let out_file = File::create(output)
875        .with_context(|| format!("Failed to create output: {}", output.display()))?;
876    let buf_writer = BufWriter::new(out_file);
877    let mut writer = builder
878        .build(buf_writer)
879        .context("Failed to create FlatGeobuf writer")?;
880
881    for feature in &core_features {
882        writer
883            .add_feature(feature)
884            .context("Failed to add feature to FlatGeobuf writer")?;
885    }
886
887    writer
888        .finish()
889        .context("Failed to finalise FlatGeobuf file")?;
890
891    Ok(count)
892}
893
894// ─── Shapefile → FlatGeobuf ───────────────────────────────────────────────────
895
896fn convert_shapefile_to_fgb(
897    input: &Path,
898    output: &Path,
899    filter: Option<&AttributeFilter>,
900) -> Result<usize> {
901    use oxigeo_flatgeobuf::{FlatGeobufWriterBuilder, GeometryType};
902    use oxigeo_shapefile::ShapefileReader;
903
904    let base_path = input.with_extension("");
905    let reader = ShapefileReader::open(&base_path)
906        .with_context(|| format!("Failed to open Shapefile: {}", input.display()))?;
907
908    let sf_features = reader
909        .read_features()
910        .context("Failed to read Shapefile features")?;
911
912    let filtered: Vec<_> = sf_features
913        .into_iter()
914        .filter(|f| match filter {
915            None => true,
916            Some(filt) => filt.matches_field_map(&f.attributes),
917        })
918        .collect();
919
920    let count = filtered.len();
921
922    // Convert ShapefileFeatures to core Features
923    let core_features: Vec<oxigeo_core::vector::Feature> = filtered
924        .iter()
925        .map(shapefile_feature_to_core)
926        .collect::<Result<Vec<_>>>()?;
927
928    let fgb_geom_type = core_features
929        .iter()
930        .find_map(|f| f.geometry.as_ref())
931        .map(core_geom_to_fgb_geometry_type)
932        .unwrap_or(GeometryType::Unknown);
933
934    let columns = infer_fgb_columns(&core_features);
935
936    let mut builder = FlatGeobufWriterBuilder::new(fgb_geom_type).with_index();
937    for col in &columns {
938        builder = builder.with_column(col.clone());
939    }
940
941    let out_file = File::create(output)
942        .with_context(|| format!("Failed to create output: {}", output.display()))?;
943    let buf_writer = BufWriter::new(out_file);
944    let mut writer = builder
945        .build(buf_writer)
946        .context("Failed to create FlatGeobuf writer")?;
947
948    for feature in &core_features {
949        writer
950            .add_feature(feature)
951            .context("Failed to add feature to FlatGeobuf writer")?;
952    }
953
954    writer
955        .finish()
956        .context("Failed to finalise FlatGeobuf file")?;
957
958    Ok(count)
959}
960
961// ─── FlatGeobuf → GeoJSON ─────────────────────────────────────────────────────
962
963fn convert_fgb_to_geojson(
964    input: &Path,
965    output: &Path,
966    filter: Option<&AttributeFilter>,
967) -> Result<usize> {
968    use oxigeo_flatgeobuf::FlatGeobufReader;
969    use oxigeo_geojson::{Feature as GjFeature, FeatureCollection, GeoJsonWriter};
970
971    let file =
972        File::open(input).with_context(|| format!("Failed to open input: {}", input.display()))?;
973    let mut reader = FlatGeobufReader::new(file)
974        .with_context(|| format!("Failed to parse FlatGeobuf header: {}", input.display()))?;
975
976    let mut gj_features: Vec<GjFeature> = Vec::new();
977
978    let iter = reader
979        .features()
980        .context("Failed to get FlatGeobuf feature iterator")?;
981
982    for result in iter {
983        let core_feat = result.context("Failed to read FlatGeobuf feature")?;
984
985        // Apply attribute filter using the core feature's properties
986        if let Some(filt) = filter
987            && !filt.matches_field_map(&core_feat.properties)
988        {
989            continue;
990        }
991
992        let gj_feat = core_feature_to_geojson_driver_feature(&core_feat)?;
993        gj_features.push(gj_feat);
994    }
995
996    let count = gj_features.len();
997
998    let out_fc = FeatureCollection::new(gj_features);
999
1000    let out_file = File::create(output)
1001        .with_context(|| format!("Failed to create output: {}", output.display()))?;
1002    let buf_writer = BufWriter::new(out_file);
1003    let mut writer = GeoJsonWriter::pretty(buf_writer);
1004
1005    writer
1006        .write_feature_collection(&out_fc)
1007        .context("Failed to write GeoJSON")?;
1008
1009    Ok(count)
1010}
1011
1012// ─── FlatGeobuf → Shapefile ───────────────────────────────────────────────────
1013
1014fn convert_fgb_to_shapefile(
1015    input: &Path,
1016    output: &Path,
1017    filter: Option<&AttributeFilter>,
1018) -> Result<usize> {
1019    use oxigeo_flatgeobuf::FlatGeobufReader;
1020    use oxigeo_shapefile::{ShapefileWriter, reader::ShapefileFeature};
1021
1022    let file =
1023        File::open(input).with_context(|| format!("Failed to open input: {}", input.display()))?;
1024    let mut reader = FlatGeobufReader::new(file)
1025        .with_context(|| format!("Failed to parse FlatGeobuf header: {}", input.display()))?;
1026
1027    let mut core_features: Vec<oxigeo_core::vector::Feature> = Vec::new();
1028
1029    let iter = reader
1030        .features()
1031        .context("Failed to get FlatGeobuf feature iterator")?;
1032
1033    for result in iter {
1034        let core_feat = result.context("Failed to read FlatGeobuf feature")?;
1035        if let Some(filt) = filter
1036            && !filt.matches_field_map(&core_feat.properties)
1037        {
1038            continue;
1039        }
1040        core_features.push(core_feat);
1041    }
1042
1043    if core_features.is_empty() {
1044        anyhow::bail!("No features remain after filtering; cannot write empty Shapefile");
1045    }
1046
1047    let count = core_features.len();
1048
1049    // Convert to ShapefileFeature list for schema inference
1050    let sf_features: Vec<ShapefileFeature> = core_features
1051        .iter()
1052        .enumerate()
1053        .map(|(i, f)| {
1054            let attrs = f.properties.clone();
1055            ShapefileFeature::new((i + 1) as i32, f.geometry.clone(), attrs)
1056        })
1057        .collect();
1058
1059    let (shape_type, field_descriptors) = infer_shapefile_schema_from_shapefiles(&sf_features)?;
1060
1061    let base_out = output.with_extension("");
1062    let mut writer = ShapefileWriter::new(&base_out, shape_type, field_descriptors)
1063        .context("Failed to create Shapefile writer")?;
1064
1065    writer
1066        .write_features(&sf_features)
1067        .context("Failed to write Shapefile")?;
1068
1069    Ok(count)
1070}
1071
1072// ─── FlatGeobuf → FlatGeobuf (identity copy) ─────────────────────────────────
1073
1074fn convert_fgb_to_fgb(
1075    input: &Path,
1076    output: &Path,
1077    filter: Option<&AttributeFilter>,
1078) -> Result<usize> {
1079    use oxigeo_flatgeobuf::{FlatGeobufReader, FlatGeobufWriterBuilder};
1080
1081    let file =
1082        File::open(input).with_context(|| format!("Failed to open input: {}", input.display()))?;
1083    let mut reader = FlatGeobufReader::new(file)
1084        .with_context(|| format!("Failed to parse FlatGeobuf header: {}", input.display()))?;
1085
1086    let source_header = reader.header().clone();
1087
1088    let mut core_features: Vec<oxigeo_core::vector::Feature> = Vec::new();
1089
1090    let iter = reader
1091        .features()
1092        .context("Failed to get FlatGeobuf feature iterator")?;
1093
1094    for result in iter {
1095        let core_feat = result.context("Failed to read FlatGeobuf feature")?;
1096        if let Some(filt) = filter
1097            && !filt.matches_field_map(&core_feat.properties)
1098        {
1099            continue;
1100        }
1101        core_features.push(core_feat);
1102    }
1103
1104    let count = core_features.len();
1105
1106    let mut builder = FlatGeobufWriterBuilder::new(source_header.geometry_type).with_index();
1107    for col in &source_header.columns {
1108        builder = builder.with_column(col.clone());
1109    }
1110
1111    let out_file = File::create(output)
1112        .with_context(|| format!("Failed to create output: {}", output.display()))?;
1113    let buf_writer = BufWriter::new(out_file);
1114    let mut writer = builder
1115        .build(buf_writer)
1116        .context("Failed to create FlatGeobuf writer")?;
1117
1118    for feature in &core_features {
1119        writer
1120            .add_feature(feature)
1121            .context("Failed to add feature to FlatGeobuf writer")?;
1122    }
1123
1124    writer
1125        .finish()
1126        .context("Failed to finalise FlatGeobuf file")?;
1127
1128    Ok(count)
1129}
1130
1131// ─── FlatGeobuf schema / geometry helpers ────────────────────────────────────
1132
1133/// Map a core `Geometry` variant to the corresponding FlatGeobuf `GeometryType`.
1134fn core_geom_to_fgb_geometry_type(
1135    geom: &oxigeo_core::vector::Geometry,
1136) -> oxigeo_flatgeobuf::GeometryType {
1137    use oxigeo_core::vector::Geometry as CoreGeom;
1138    use oxigeo_flatgeobuf::GeometryType as FgbGT;
1139    match geom {
1140        CoreGeom::Point(_) => FgbGT::Point,
1141        CoreGeom::LineString(_) => FgbGT::LineString,
1142        CoreGeom::Polygon(_) => FgbGT::Polygon,
1143        CoreGeom::MultiPoint(_) => FgbGT::MultiPoint,
1144        CoreGeom::MultiLineString(_) => FgbGT::MultiLineString,
1145        CoreGeom::MultiPolygon(_) => FgbGT::MultiPolygon,
1146        CoreGeom::GeometryCollection(_) => FgbGT::GeometryCollection,
1147    }
1148}
1149
1150/// Infer FlatGeobuf column definitions from a slice of core features.
1151///
1152/// Scans all features' property maps and emits one `Column` per unique key.
1153/// All properties are stored as `ColumnType::String` because the FGB format
1154/// requires all values to be stored consistently across features.
1155fn infer_fgb_columns(features: &[oxigeo_core::vector::Feature]) -> Vec<oxigeo_flatgeobuf::Column> {
1156    use oxigeo_core::vector::FieldValue;
1157    use oxigeo_flatgeobuf::{Column, ColumnType};
1158
1159    // Collect unique column names (preserving first-seen insertion order)
1160    let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
1161    let mut ordered: Vec<String> = Vec::new();
1162
1163    for feat in features {
1164        for key in feat.properties.keys() {
1165            if seen.insert(key.clone()) {
1166                ordered.push(key.clone());
1167            }
1168        }
1169    }
1170
1171    // Choose column types based on the first non-null value for each key
1172    let mut col_types: std::collections::HashMap<String, ColumnType> =
1173        std::collections::HashMap::new();
1174
1175    for feat in features {
1176        for (key, val) in &feat.properties {
1177            if col_types.contains_key(key) {
1178                continue;
1179            }
1180            let ct = match val {
1181                FieldValue::Null => continue,
1182                FieldValue::Bool(_) => ColumnType::Bool,
1183                FieldValue::Integer(_) => ColumnType::Long,
1184                FieldValue::UInteger(_) => ColumnType::ULong,
1185                FieldValue::Float(_) => ColumnType::Double,
1186                FieldValue::String(_) | FieldValue::Array(_) | FieldValue::Object(_) => {
1187                    ColumnType::String
1188                }
1189                FieldValue::Date(_) => ColumnType::DateTime,
1190                FieldValue::Blob(_) => ColumnType::Binary,
1191            };
1192            col_types.insert(key.clone(), ct);
1193        }
1194    }
1195
1196    ordered
1197        .into_iter()
1198        .map(|name| {
1199            let ct = col_types.get(&name).copied().unwrap_or(ColumnType::String);
1200            Column::new(name, ct)
1201        })
1202        .collect()
1203}
1204
1205/// Convert a GeoJSON driver `Feature` (from `oxigeo_geojson`) to a core `Feature`.
1206fn geojson_driver_feature_to_core(
1207    f: &oxigeo_geojson::Feature,
1208) -> Result<oxigeo_core::vector::Feature> {
1209    use oxigeo_core::vector::{Feature as CoreFeature, FieldValue};
1210
1211    let geometry = match &f.geometry {
1212        Some(gj_geom) => Some(geojson_geom_to_core(gj_geom)?),
1213        None => None,
1214    };
1215
1216    let mut core_feat = match geometry {
1217        Some(g) => CoreFeature::new(g),
1218        None => CoreFeature::new_attribute_only(),
1219    };
1220
1221    if let Some(props) = &f.properties {
1222        for (k, v) in props {
1223            core_feat.set_property(k.clone(), FieldValue::from_json(v));
1224        }
1225    }
1226
1227    Ok(core_feat)
1228}
1229
1230/// Convert a core `Feature` (from `oxigeo_core`) to a GeoJSON driver `Feature`.
1231fn core_feature_to_geojson_driver_feature(
1232    core_feat: &oxigeo_core::vector::Feature,
1233) -> Result<oxigeo_geojson::Feature> {
1234    use oxigeo_geojson::Feature as GjFeature;
1235
1236    let geometry = match &core_feat.geometry {
1237        Some(g) => Some(core_geom_to_geojson(g)?),
1238        None => None,
1239    };
1240
1241    let properties: oxigeo_geojson::types::Properties = core_feat
1242        .properties
1243        .iter()
1244        .map(|(k, v)| (k.clone(), v.to_json_value()))
1245        .collect();
1246
1247    let props_opt = if properties.is_empty() {
1248        None
1249    } else {
1250        Some(properties)
1251    };
1252
1253    Ok(GjFeature::new(geometry, props_opt))
1254}
1255
1256/// Convert a `ShapefileFeature` to a core `Feature` for FGB writing.
1257fn shapefile_feature_to_core(
1258    sf: &oxigeo_shapefile::reader::ShapefileFeature,
1259) -> Result<oxigeo_core::vector::Feature> {
1260    use oxigeo_core::vector::Feature as CoreFeature;
1261
1262    let mut core_feat = match &sf.geometry {
1263        Some(g) => CoreFeature::new(g.clone()),
1264        None => CoreFeature::new_attribute_only(),
1265    };
1266
1267    for (k, v) in &sf.attributes {
1268        core_feat.set_property(k.clone(), v.clone());
1269    }
1270
1271    Ok(core_feat)
1272}