use crate::config::Config;
use anyhow::{Context, Result};
use clap::Args as ClapArgs;
use geojson::{GeoJson, Value};
use std::path::PathBuf;
async fn validate_remote(geojson_str: &str, config: &Config) -> Result<String> {
let client = reqwest::Client::new();
let url = format!("{}/api/v1/validate", config.backend_url());
tracing::info!("Sending GeoJSON to remote validation: {}", url);
let response = client
.post(&url)
.header("Content-Type", "application/json")
.timeout(std::time::Duration::from_secs(config.timeout_secs))
.body(geojson_str.to_string())
.send()
.await
.context("Failed to connect to remote validation server")?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
anyhow::bail!(
"Remote validation server returned {}: {}",
status,
body.chars().take(200).collect::<String>()
);
}
let result: serde_json::Value = response
.json()
.await
.context("Failed to parse remote validation response")?;
let valid = result.get("valid").and_then(|v| v.as_bool()).unwrap_or(false);
let errors = result.get("errors")
.and_then(|e| e.as_array())
.map(|arr| arr.len())
.unwrap_or(0);
let warnings = result.get("warnings")
.and_then(|w| w.as_array())
.map(|arr| arr.len())
.unwrap_or(0);
if valid {
Ok(format!("VALID (remote: 0 errors, {} warnings)", warnings))
} else {
Ok(format!("INVALID (remote: {} errors, {} warnings)", errors, warnings))
}
}
#[derive(Debug, ClapArgs)]
pub struct Args {
pub input: PathBuf,
#[arg(long)]
pub remote: bool,
#[arg(short, long)]
pub verbose: bool,
}
#[derive(Default)]
struct ValidationStats {
total_features: usize,
valid_features: usize,
errors: Vec<String>,
warnings: Vec<String>,
}
impl ValidationStats {
fn is_valid(&self) -> bool {
self.errors.is_empty()
}
}
fn validate_coordinate(coord: &[f64]) -> Vec<String> {
let mut errors = Vec::new();
if coord.len() < 2 {
errors.push("Coordinate has fewer than 2 dimensions".to_string());
return errors;
}
let lon = coord[0];
let lat = coord[1];
if !(-180.0..=180.0).contains(&lon) {
errors.push(format!("Longitude {} out of range [-180, 180]", lon));
}
if !(-90.0..=90.0).contains(&lat) {
errors.push(format!("Latitude {} out of range [-90, 90]", lat));
}
errors
}
fn validate_linestring(coords: &[Vec<f64>]) -> Vec<String> {
let mut errors = Vec::new();
if coords.len() < 2 {
errors.push("LineString must have at least 2 coordinates".to_string());
return errors;
}
for (i, coord) in coords.iter().enumerate() {
for err in validate_coordinate(coord) {
errors.push(format!("Coordinate {}: {}", i, err));
}
}
for i in 0..coords.len().saturating_sub(1) {
if coords[i].len() >= 2 && coords[i + 1].len() >= 2 {
let dlat = (coords[i][1] - coords[i + 1][1]).abs();
let dlon = (coords[i][0] - coords[i + 1][0]).abs();
if dlat < 1e-12 && dlon < 1e-12 {
errors.push(format!(
"Consecutive duplicate coordinates at index {}",
i
));
}
}
}
errors
}
fn validate_polygon(rings: &[Vec<Vec<f64>>]) -> Vec<String> {
let mut errors = Vec::new();
if rings.is_empty() {
errors.push("Polygon must have at least one ring".to_string());
return errors;
}
for (ri, ring) in rings.iter().enumerate() {
if ring.len() < 4 {
errors.push(format!(
"Ring {} must have at least 4 coordinates (closed ring)",
ri
));
}
if ring.len() >= 2 {
let first = &ring[0];
let last = &ring[ring.len() - 1];
if first.len() >= 2 && last.len() >= 2 {
let dlat = (first[1] - last[1]).abs();
let dlon = (first[0] - last[0]).abs();
if dlat > 1e-9 || dlon > 1e-9 {
errors.push(format!("Ring {} is not closed", ri));
}
}
}
for (i, coord) in ring.iter().enumerate() {
for err in validate_coordinate(coord) {
errors.push(format!("Ring {} coord {}: {}", ri, i, err));
}
}
}
errors
}
pub async fn run(args: Args) -> Result<()> {
let config = Config::load().unwrap_or_default();
config.init_logging();
tracing::info!("Validating GeoJSON: {}", args.input.display());
let geojson_str = std::fs::read_to_string(&args.input)
.with_context(|| format!("Failed to read {}", args.input.display()))?;
let json_value: serde_json::Value = serde_json::from_str(&geojson_str)
.context("File is not valid JSON")?;
let geojson: GeoJson = geojson_str.parse()
.context("File is not valid GeoJSON")?;
let mut stats = ValidationStats::default();
match &geojson {
GeoJson::FeatureCollection(fc) => {
stats.total_features = fc.features.len();
if args.verbose {
println!("FeatureCollection with {} features", fc.features.len());
}
for (i, feature) in fc.features.iter().enumerate() {
let mut feature_valid = true;
let geom = match feature.geometry.as_ref() {
Some(g) => g,
None => {
stats.warnings.push(format!("Feature {}: No geometry", i));
continue;
}
};
let geom_errors = match &geom.value {
Value::Point(coord) => validate_coordinate(coord),
Value::MultiPoint(coords) => {
let mut errs = Vec::new();
for (j, c) in coords.iter().enumerate() {
for err in validate_coordinate(c) {
errs.push(format!("Point {}: {}", j, err));
}
}
errs
}
Value::LineString(coords) => validate_linestring(coords),
Value::MultiLineString(lines) => {
let mut errs = Vec::new();
for (j, line) in lines.iter().enumerate() {
for err in validate_linestring(line) {
errs.push(format!("Line {}: {}", j, err));
}
}
errs
}
Value::Polygon(rings) => validate_polygon(rings),
Value::MultiPolygon(polygons) => {
let mut errs = Vec::new();
for (j, poly) in polygons.iter().enumerate() {
for err in validate_polygon(poly) {
errs.push(format!("Polygon {}: {}", j, err));
}
}
errs
}
Value::GeometryCollection(geoms) => {
stats.warnings.push(format!(
"Feature {}: GeometryCollection with {} geometries",
i,
geoms.len()
));
Vec::new()
}
};
if !geom_errors.is_empty() {
feature_valid = false;
for err in &geom_errors {
stats.errors.push(format!("Feature {}: {}", i, err));
}
}
if feature.properties.is_none() && args.verbose {
stats.warnings.push(format!("Feature {}: No properties", i));
}
if feature_valid {
stats.valid_features += 1;
}
}
}
GeoJson::Feature(f) => {
stats.total_features = 1;
if f.geometry.is_some() {
stats.valid_features = 1;
} else {
stats.errors.push("Feature has no geometry".to_string());
}
}
GeoJson::Geometry(_) => {
stats.warnings.push("Top-level Geometry (not a FeatureCollection)".to_string());
stats.total_features = 1;
stats.valid_features = 1;
}
}
if args.verbose {
println!("\nValidation Results:");
println!(" Total features: {}", stats.total_features);
println!(" Valid features: {}", stats.valid_features);
println!(" Errors: {}", stats.errors.len());
println!(" Warnings: {}", stats.warnings.len());
if !stats.errors.is_empty() {
println!("\nErrors:");
for err in &stats.errors {
println!(" - {}", err);
}
}
if !stats.warnings.is_empty() {
println!("\nWarnings:");
for warn in &stats.warnings {
println!(" - {}", warn);
}
}
}
if stats.is_valid() {
tracing::info!(
"Validation passed: {}/{} features valid",
stats.valid_features,
stats.total_features
);
println!("VALID: {}/{} features valid", stats.valid_features, stats.total_features);
} else {
tracing::warn!(
"Validation failed: {} errors found",
stats.errors.len()
);
println!(
"INVALID: {} errors, {}/{} features valid",
stats.errors.len(),
stats.valid_features,
stats.total_features
);
if args.verbose {
for err in &stats.errors {
println!(" ERROR: {}", err);
}
}
}
if args.remote {
match validate_remote(&geojson_str, &config).await {
Ok(remote_result) => {
println!("Remote validation: {}", remote_result);
}
Err(e) => {
tracing::warn!("Remote validation failed: {}", e);
println!("Note: Remote validation failed ({}). Local validation was still performed.", e);
}
}
}
if stats.is_valid() {
Ok(())
} else {
Err(anyhow::anyhow!(
"Validation failed with {} errors",
stats.errors.len()
))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_args() {
let args = Args {
input: PathBuf::from("test.geojson"),
remote: false,
verbose: true,
};
assert_eq!(args.input, PathBuf::from("test.geojson"));
assert!(!args.remote);
assert!(args.verbose);
}
#[test]
fn test_validate_coordinate() {
assert!(validate_coordinate(&vec![0.0, 0.0]).is_empty());
assert!(validate_coordinate(&vec![-73.6, 45.5]).is_empty());
assert!(validate_coordinate(&vec![181.0, 45.5]).len() > 0); assert!(validate_coordinate(&vec![-73.6, 91.0]).len() > 0); }
#[test]
fn test_validate_linestring() {
let valid = vec![vec![-73.6, 45.5], vec![-73.61, 45.51]];
assert!(validate_linestring(&valid).is_empty());
let too_short = vec![vec![-73.6, 45.5]];
assert!(validate_linestring(&too_short).len() > 0);
let duplicate = vec![vec![-73.6, 45.5], vec![-73.6, 45.5]];
assert!(validate_linestring(&duplicate).len() > 0);
}
#[test]
fn test_validate_polygon() {
let valid = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![1.0, 1.0], vec![0.0, 0.0]];
assert!(validate_polygon(&[valid.clone()]).is_empty());
let unclosed = vec![vec![0.0, 0.0], vec![1.0, 0.0], vec![1.0, 1.0]];
let errors = validate_polygon(&[unclosed]);
assert!(errors.iter().any(|e| e.contains("not closed")));
}
}