Skip to main content

aviso_validators/
point.rs

1// (C) Copyright 2024- ECMWF and individual contributors.
2//
3// This software is licensed under the terms of the Apache Licence Version 2.0
4// which can be obtained at http://www.apache.org/licenses/LICENSE-2.0.
5// In applying this licence, ECMWF does not waive the privileges and immunities
6// granted to it by virtue of its status as an intergovernmental organisation nor
7// does it submit to any jurisdiction.
8
9use crate::coordinate::{Coordinate, coordinate_to_json, parse_coordinate, validate_coordinate};
10use anyhow::{Result, bail};
11use serde_json::Value;
12use tracing::debug;
13
14/// Point coordinate validator.
15///
16/// Accepts `[lat,lon]`, `lat,lon`, and `(lat,lon)` input.
17pub struct PointHandler;
18
19impl PointHandler {
20    pub fn validate_and_canonicalize(value: &str, field_name: &str) -> Result<String> {
21        let (lat, lon) = Self::parse_point_coordinates(value)?;
22        debug!(
23            field = field_name,
24            lat = lat,
25            lon = lon,
26            "Point validated successfully"
27        );
28        Ok(format!("{lat},{lon}"))
29    }
30
31    /// Validate a JSON point value and return canonical `[lat,lon]` JSON.
32    pub fn validate_json_and_canonicalize(value: &Value, field_name: &str) -> Result<Value> {
33        let coordinate = Self::parse_point_value(value).map_err(|error| {
34            anyhow::anyhow!("field '{}' must be a valid point: {}", field_name, error)
35        })?;
36        coordinate_to_json(coordinate).map_err(Into::into)
37    }
38
39    /// Parse a JSON point array or a compatible legacy point string.
40    pub fn parse_point_value(value: &Value) -> Result<Coordinate> {
41        match value {
42            Value::Array(_) => parse_coordinate(value).map_err(Into::into),
43            Value::String(value) => Self::parse_point_coordinates(value),
44            _ => bail!("point must be a two-element array [lat,lon] or a compatible string"),
45        }
46    }
47
48    pub fn parse_point_coordinates(value: &str) -> Result<(f64, f64)> {
49        let trimmed = value.trim();
50        if trimmed.is_empty() {
51            bail!("Point coordinate string cannot be empty");
52        }
53
54        if trimmed.starts_with('[') {
55            let value: Value = serde_json::from_str(trimmed)
56                .map_err(|error| anyhow::anyhow!("invalid point JSON array: {}", error))?;
57            return Self::parse_point_value(&value);
58        }
59
60        let inner = trimmed
61            .strip_prefix('(')
62            .and_then(|s| s.strip_suffix(')'))
63            .unwrap_or(trimmed)
64            .trim();
65
66        let mut parts = inner.split(',').map(str::trim);
67        let lat_str = parts
68            .next()
69            .ok_or_else(|| anyhow::anyhow!("Point must be in 'lat,lon' format"))?;
70        let lon_str = parts
71            .next()
72            .ok_or_else(|| anyhow::anyhow!("Point must be in 'lat,lon' format"))?;
73
74        if parts.next().is_some() {
75            bail!("Point must contain exactly two values: 'lat,lon'");
76        }
77
78        let lat: f64 = lat_str
79            .parse()
80            .map_err(|_| anyhow::anyhow!("Invalid latitude value: {}", lat_str))?;
81        let lon: f64 = lon_str
82            .parse()
83            .map_err(|_| anyhow::anyhow!("Invalid longitude value: {}", lon_str))?;
84
85        validate_coordinate(lat, lon).map_err(Into::into)
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::PointHandler;
92
93    #[test]
94    fn parse_point_coordinates_accepts_basic_format() {
95        let (lat, lon) = PointHandler::parse_point_coordinates("52.55,13.5").unwrap();
96        assert_eq!(lat, 52.55);
97        assert_eq!(lon, 13.5);
98    }
99
100    #[test]
101    fn parse_point_coordinates_accepts_parenthesized_format() {
102        let (lat, lon) = PointHandler::parse_point_coordinates("(52.55,13.5)").unwrap();
103        assert_eq!(lat, 52.55);
104        assert_eq!(lon, 13.5);
105    }
106
107    #[test]
108    fn parse_point_coordinates_accepts_canonical_json_string() {
109        let point = PointHandler::parse_point_coordinates("[52.55,13.5]").unwrap();
110        assert_eq!(point, (52.55, 13.5));
111    }
112
113    #[test]
114    fn parse_point_coordinates_rejects_bad_format() {
115        assert!(PointHandler::parse_point_coordinates("52.55").is_err());
116        assert!(PointHandler::parse_point_coordinates("52.55,13.5,1.0").is_err());
117    }
118
119    #[test]
120    fn parse_point_coordinates_rejects_out_of_range() {
121        assert!(PointHandler::parse_point_coordinates("91,13.5").is_err());
122        assert!(PointHandler::parse_point_coordinates("52.5,181").is_err());
123    }
124
125    #[test]
126    fn validate_and_canonicalize_returns_canonical_form() {
127        let canonical = PointHandler::validate_and_canonicalize("(52.5500,13.5000)", "p").unwrap();
128        assert_eq!(canonical, "52.55,13.5");
129    }
130
131    #[test]
132    fn json_array_is_canonical() {
133        let canonical = PointHandler::validate_json_and_canonicalize(
134            &serde_json::json!([52.5500, 13.5000]),
135            "point",
136        )
137        .unwrap();
138        assert_eq!(canonical, serde_json::json!([52.55, 13.5]));
139    }
140}