1use anyhow::{Result, anyhow, bail};
10use tracing::debug;
11
12pub struct PolygonHandler;
17
18impl PolygonHandler {
19 pub fn validate_and_canonicalize(value: &str, field_name: &str) -> Result<String> {
20 debug!(
21 "Validating polygon field '{}' with value: {}",
22 field_name, value
23 );
24
25 let coordinates = Self::parse_polygon_coordinates(value)
26 .map_err(|e| anyhow!("field '{}' must be a valid polygon: {}", field_name, e))?;
27 debug!(
28 "Parsed {} coordinate pairs for field '{}'",
29 coordinates.len(),
30 field_name
31 );
32
33 Self::validate_polygon_geometry(&coordinates)
34 .map_err(|e| anyhow!("field '{}' must be a valid polygon: {}", field_name, e))?;
35 debug!(
36 "Polygon geometry validation passed for field '{}'",
37 field_name
38 );
39
40 Ok(value.to_string())
41 }
42
43 pub fn parse_polygon_coordinates(coord_string: &str) -> Result<Vec<(f64, f64)>> {
59 let raw = coord_string.trim();
60 if raw.is_empty() {
61 bail!("polygon coordinate string is empty");
62 }
63
64 let inner = match (raw.starts_with('('), raw.ends_with(')')) {
65 (true, true) => &raw[1..raw.len() - 1],
66 (false, false) => raw,
67 (true, false) => {
68 bail!("polygon coordinate string has opening '(' but is missing the closing ')'")
69 }
70 (false, true) => {
71 bail!("polygon coordinate string has closing ')' but is missing the opening '('")
72 }
73 };
74
75 if inner.contains('(') || inner.contains(')') {
76 bail!(
77 "polygon coordinate string must have at most one outer pair of parentheses; \
78 nested '(' or ')' are not allowed"
79 );
80 }
81
82 let inner = inner.trim();
83 if inner.is_empty() {
84 bail!("polygon coordinate string is empty between parentheses");
85 }
86
87 let coord_parts: Vec<&str> = inner.split(',').collect();
88
89 if !coord_parts.len().is_multiple_of(2) {
90 bail!("polygon coordinates must be in lat,lon pairs (got an odd number of values)");
91 }
92
93 let mut coordinates = Vec::new();
94 let mut iter = coord_parts.iter();
95
96 while let Some(lat_str) = iter.next() {
97 let lon_str = iter.next().unwrap(); let lat: f64 = lat_str.trim().parse().map_err(|_| {
100 anyhow!("could not parse latitude '{}' as a number", lat_str.trim())
101 })?;
102
103 let lon: f64 = lon_str.trim().parse().map_err(|_| {
104 anyhow!("could not parse longitude '{}' as a number", lon_str.trim())
105 })?;
106
107 if !(-90.0..=90.0).contains(&lat) {
112 bail!("latitude {} is outside the valid range [-90, 90]", lat);
113 }
114 if !(-180.0..=180.0).contains(&lon) {
115 bail!("longitude {} is outside the valid range [-180, 180]", lon);
116 }
117
118 coordinates.push((lat, lon));
119 }
120
121 Ok(coordinates)
122 }
123
124 fn validate_polygon_geometry(coordinates: &[(f64, f64)]) -> Result<()> {
126 if coordinates.len() < 4 {
127 bail!(
128 "polygon must have at least 4 coordinate pairs (3 unique vertices plus a \
129 closing repeat of the first vertex)"
130 );
131 }
132
133 let first = coordinates.first().unwrap();
134 let last = coordinates.last().unwrap();
135
136 if first != last {
137 bail!("polygon must be closed (first and last coordinates must be identical)");
138 }
139
140 Ok(())
141 }
142
143 pub fn calculate_bounding_box(coordinates: &[(f64, f64)]) -> String {
146 let mut min_lat = f64::INFINITY;
147 let mut min_lon = f64::INFINITY;
148 let mut max_lat = f64::NEG_INFINITY;
149 let mut max_lon = f64::NEG_INFINITY;
150
151 for &(lat, lon) in coordinates {
152 min_lat = min_lat.min(lat);
153 min_lon = min_lon.min(lon);
154 max_lat = max_lat.max(lat);
155 max_lon = max_lon.max(lon);
156 }
157
158 format!("{},{},{},{}", min_lat, min_lon, max_lat, max_lon)
159 }
160
161 pub fn parse_bbox_coordinates(s: &str) -> Result<(f64, f64, f64, f64)> {
162 let s = s.trim_matches(|c| c == '(' || c == ')');
164 let coords: Vec<f64> = s
165 .split(',')
166 .map(|part| part.trim().parse())
167 .collect::<Result<_, _>>()?;
168 if coords.len() != 4 {
169 anyhow::bail!("BBox must have 4 numbers");
170 }
171 Ok((coords[0], coords[1], coords[2], coords[3]))
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn test_validate_and_canonicalize_valid_polygon() {
181 let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
182 let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
183
184 assert!(result.is_ok());
185 assert_eq!(result.unwrap(), polygon_str);
186 }
187
188 #[test]
189 fn test_validate_and_canonicalize_with_spaces() {
190 let polygon_str = "( 52.5 , 13.4 , 52.6 , 13.5 , 52.5 , 13.6 , 52.4 , 13.5 , 52.5 , 13.4 )";
191 let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
192
193 assert!(result.is_ok());
194 assert_eq!(result.unwrap(), polygon_str);
195 }
196
197 #[test]
198 fn test_validate_and_canonicalize_not_closed() {
199 let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5)"; let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
201
202 assert!(result.is_err());
203 }
204
205 #[test]
206 fn test_validate_and_canonicalize_too_few_points() {
207 let polygon_str = "(52.5,13.4,52.6,13.5)"; let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
209
210 assert!(result.is_err());
211 }
212
213 #[test]
214 fn test_validate_and_canonicalize_empty_string() {
215 let polygon_str = "";
216 let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
217
218 assert!(result.is_err());
219 }
220
221 #[test]
222 fn test_validate_and_canonicalize_empty_parentheses() {
223 let polygon_str = "()";
224 let result = PolygonHandler::validate_and_canonicalize(polygon_str, "polygon");
225
226 assert!(result.is_err());
227 }
228
229 #[test]
230 fn test_parse_polygon_coordinates_valid() {
231 let coord_string = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
232 let result = PolygonHandler::parse_polygon_coordinates(coord_string);
233
234 assert!(result.is_ok());
235 let coordinates = result.unwrap();
236 assert_eq!(coordinates.len(), 5);
237 assert_eq!(coordinates[0], (52.5, 13.4));
238 assert_eq!(coordinates[1], (52.6, 13.5));
239 assert_eq!(coordinates[4], (52.5, 13.4)); }
241
242 #[test]
243 fn test_parse_polygon_coordinates_without_parentheses() {
244 let coord_string = "52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4";
245 let result = PolygonHandler::parse_polygon_coordinates(coord_string);
246
247 assert!(result.is_ok());
248 let coordinates = result.unwrap();
249 assert_eq!(coordinates.len(), 5);
250 assert_eq!(coordinates[0], (52.5, 13.4));
251 }
252
253 #[test]
254 fn test_parse_polygon_coordinates_with_spaces() {
255 let coord_string = "( 52.5 , 13.4 , 52.6 , 13.5 , 52.5 , 13.4 )";
256 let result = PolygonHandler::parse_polygon_coordinates(coord_string);
257
258 assert!(result.is_ok());
259 let coordinates = result.unwrap();
260 assert_eq!(coordinates.len(), 3);
261 assert_eq!(coordinates[0], (52.5, 13.4));
262 assert_eq!(coordinates[1], (52.6, 13.5));
263 assert_eq!(coordinates[2], (52.5, 13.4));
264 }
265
266 #[test]
267 fn test_parse_polygon_coordinates_odd_number() {
268 let coord_string = "(52.5,13.4,52.6)"; let result = PolygonHandler::parse_polygon_coordinates(coord_string);
270
271 assert!(result.is_err());
272 }
273
274 #[test]
275 fn test_parse_polygon_coordinates_invalid_latitude() {
276 let coord_string = "(invalid,13.4,52.6,13.5,52.5,13.4)";
277 let result = PolygonHandler::parse_polygon_coordinates(coord_string);
278
279 assert!(result.is_err());
280 }
281
282 #[test]
283 fn test_parse_polygon_coordinates_invalid_longitude() {
284 let coord_string = "(52.5,invalid,52.6,13.5,52.5,13.4)";
285 let result = PolygonHandler::parse_polygon_coordinates(coord_string);
286
287 assert!(result.is_err());
288 }
289
290 #[test]
291 fn test_parse_polygon_coordinates_empty() {
292 let coord_string = "()";
293 let result = PolygonHandler::parse_polygon_coordinates(coord_string);
294
295 assert!(result.is_err());
296 }
297
298 #[test]
299 fn rejects_polygon_with_opening_paren_but_no_closing_paren() {
300 let coord_string = "(50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0";
301 let err = PolygonHandler::parse_polygon_coordinates(coord_string)
302 .expect_err("unbalanced parens must be rejected");
303 let msg = err.to_string();
304 assert!(
305 msg.contains("opening") && msg.contains("missing the closing"),
306 "error should pinpoint the missing closing paren; got: {msg}"
307 );
308 }
309
310 #[test]
311 fn rejects_polygon_with_closing_paren_but_no_opening_paren() {
312 let coord_string = "50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0)";
313 let err = PolygonHandler::parse_polygon_coordinates(coord_string)
314 .expect_err("unbalanced parens must be rejected");
315 let msg = err.to_string();
316 assert!(
317 msg.contains("closing") && msg.contains("missing the opening"),
318 "error should pinpoint the missing opening paren; got: {msg}"
319 );
320 }
321
322 #[test]
323 fn rejects_polygon_with_extra_nested_parens() {
324 let coord_string = "(50.0,10.0),52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0)";
325 let err = PolygonHandler::parse_polygon_coordinates(coord_string)
326 .expect_err("nested parens must be rejected, not produce a confusing parse error");
327 let msg = err.to_string();
328 assert!(
329 msg.contains("nested") || msg.contains("outer pair"),
330 "error should mention parentheses placement, not e.g. a number-parse failure; got: {msg}"
331 );
332 }
333
334 #[test]
335 fn rejects_latitude_above_90() {
336 let coord_string = "(91.0,10.0,50.0,10.0,50.0,11.0,91.0,10.0)";
337 let err = PolygonHandler::parse_polygon_coordinates(coord_string)
338 .expect_err("latitude > 90 must be rejected");
339 let msg = err.to_string();
340 assert!(
341 msg.contains("latitude") && msg.contains("-90") && msg.contains("90"),
342 "error should pinpoint the latitude range; got: {msg}"
343 );
344 }
345
346 #[test]
347 fn rejects_latitude_below_minus_90() {
348 let coord_string = "(-90.5,10.0,50.0,10.0,50.0,11.0,-90.5,10.0)";
349 let err = PolygonHandler::parse_polygon_coordinates(coord_string)
350 .expect_err("latitude < -90 must be rejected");
351 let msg = err.to_string();
352 assert!(msg.contains("latitude") && msg.contains("-90.5"));
353 }
354
355 #[test]
356 fn rejects_longitude_above_180() {
357 let coord_string = "(50.0,181.0,50.0,10.0,50.0,11.0,50.0,181.0)";
358 let err = PolygonHandler::parse_polygon_coordinates(coord_string)
359 .expect_err("longitude > 180 must be rejected");
360 let msg = err.to_string();
361 assert!(
362 msg.contains("longitude") && msg.contains("-180") && msg.contains("180"),
363 "error should pinpoint the longitude range; got: {msg}"
364 );
365 }
366
367 #[test]
368 fn rejects_longitude_below_minus_180() {
369 let coord_string = "(50.0,-180.5,50.0,10.0,50.0,11.0,50.0,-180.5)";
370 let err = PolygonHandler::parse_polygon_coordinates(coord_string)
371 .expect_err("longitude < -180 must be rejected");
372 let msg = err.to_string();
373 assert!(msg.contains("longitude") && msg.contains("-180.5"));
374 }
375
376 #[test]
377 fn rejects_non_finite_coordinates_via_range_check() {
378 for bad_value in &["NaN", "inf", "-inf"] {
381 let coord_string = format!("({bad_value},10.0,50.0,10.0,50.0,11.0,{bad_value},10.0)");
382 assert!(
383 PolygonHandler::parse_polygon_coordinates(&coord_string).is_err(),
384 "non-finite coordinate `{bad_value}` must be rejected"
385 );
386 }
387 }
388
389 #[test]
390 fn accepts_exact_boundary_coordinates() {
391 let polygon = "(90.0,-180.0,89.0,-180.0,89.0,-179.0,90.0,-180.0)";
392 let result = PolygonHandler::parse_polygon_coordinates(polygon);
393 assert!(
394 result.is_ok(),
395 "lat=90 and lon=-180 are valid (inclusive) boundary values: {:?}",
396 result.err()
397 );
398 }
399
400 #[test]
401 fn validate_and_canonicalize_wraps_errors_with_field_name_and_validation_marker() {
402 let bad = "(50.0,10.0,52.0,10.0,52.0,12.0,50.0,12.0,50.0,10.0";
407 let err = PolygonHandler::validate_and_canonicalize(bad, "polygon")
408 .expect_err("unbalanced polygon must error");
409 let msg = err.to_string();
410 assert!(
411 msg.contains("field 'polygon'") && msg.contains("must be a valid"),
412 "error must carry the validation-classifier markers; got: {msg}"
413 );
414 }
415
416 #[test]
417 fn test_validate_polygon_geometry_valid_triangle() {
418 let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)];
419 let result = PolygonHandler::validate_polygon_geometry(&coordinates);
420
421 assert!(result.is_ok());
422 }
423
424 #[test]
425 fn test_validate_polygon_geometry_valid_rectangle() {
426 let coordinates = vec![
427 (52.5, 13.4),
428 (52.6, 13.4),
429 (52.6, 13.5),
430 (52.5, 13.5),
431 (52.5, 13.4),
432 ];
433 let result = PolygonHandler::validate_polygon_geometry(&coordinates);
434
435 assert!(result.is_ok());
436 }
437
438 #[test]
439 fn test_validate_polygon_geometry_too_few_points() {
440 let coordinates = vec![(0.0, 0.0), (1.0, 0.0)]; let result = PolygonHandler::validate_polygon_geometry(&coordinates);
442
443 assert!(result.is_err());
444 }
445
446 #[test]
447 fn rejects_three_pair_closed_line_segment_as_degenerate_polygon() {
448 let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.0, 0.0)];
453 let err = PolygonHandler::validate_polygon_geometry(&coordinates)
454 .expect_err("3-pair closed line segment must be rejected as a polygon");
455 let msg = err.to_string();
456 assert!(
457 msg.contains("at least 4 coordinate pairs"),
458 "error must specify the new minimum; got: {msg}"
459 );
460 }
461
462 #[test]
463 fn test_validate_polygon_geometry_not_closed() {
464 let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.1, 0.1)]; let result = PolygonHandler::validate_polygon_geometry(&coordinates);
466
467 assert!(result.is_err());
468 }
469
470 #[test]
471 fn test_validate_polygon_geometry_minimum_valid() {
472 let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)]; let result = PolygonHandler::validate_polygon_geometry(&coordinates);
474
475 assert!(result.is_ok());
476 }
477
478 #[test]
479 fn test_calculate_bounding_box_rectangle() {
480 let coordinates = vec![
481 (52.5, 13.4),
482 (52.6, 13.4),
483 (52.6, 13.5),
484 (52.5, 13.5),
485 (52.5, 13.4),
486 ];
487 let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
488
489 assert_eq!(bbox, "52.5,13.4,52.6,13.5");
490 }
491
492 #[test]
493 fn test_calculate_bounding_box_triangle() {
494 let coordinates = vec![(0.0, 0.0), (1.0, 0.0), (0.5, 1.0), (0.0, 0.0)];
495 let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
496
497 assert_eq!(bbox, "0,0,1,1");
498 }
499
500 #[test]
501 fn test_calculate_bounding_box_single_point() {
502 let coordinates = vec![(52.5, 13.4), (52.5, 13.4), (52.5, 13.4), (52.5, 13.4)];
503 let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
504
505 assert_eq!(bbox, "52.5,13.4,52.5,13.4");
506 }
507
508 #[test]
509 fn test_calculate_bounding_box_negative_coordinates() {
510 let coordinates = vec![
511 (-1.0, -1.0),
512 (1.0, -1.0),
513 (1.0, 1.0),
514 (-1.0, 1.0),
515 (-1.0, -1.0),
516 ];
517 let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
518
519 assert_eq!(bbox, "-1,-1,1,1");
520 }
521
522 #[test]
523 fn test_integration_parse_and_validate() {
524 let polygon_str = "(52.5,13.4,52.6,13.5,52.5,13.6,52.4,13.5,52.5,13.4)";
525
526 let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
528 let validation_result = PolygonHandler::validate_polygon_geometry(&coordinates);
529 assert!(validation_result.is_ok());
530
531 let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
532 assert_eq!(bbox, "52.4,13.4,52.6,13.6");
533 }
534
535 #[test]
536 fn test_real_world_berlin_polygon() {
537 let polygon_str =
539 "(52.5200,13.4050,52.5200,13.4500,52.4800,13.4500,52.4800,13.4050,52.5200,13.4050)";
540 let result = PolygonHandler::validate_and_canonicalize(polygon_str, "berlin_area");
541
542 assert!(result.is_ok());
543
544 let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
545 let bbox = PolygonHandler::calculate_bounding_box(&coordinates);
546 assert_eq!(bbox, "52.48,13.405,52.52,13.45");
547 }
548
549 #[test]
550 fn test_precision_handling() {
551 let polygon_str = "(52.123456789,13.987654321,52.234567890,13.876543210,52.345678901,13.765432109,52.123456789,13.987654321)";
553 let result = PolygonHandler::validate_and_canonicalize(polygon_str, "precision_test");
554
555 assert!(result.is_ok());
556
557 let coordinates = PolygonHandler::parse_polygon_coordinates(polygon_str).unwrap();
558 assert_eq!(coordinates[0].0, 52.123456789);
559 assert_eq!(coordinates[0].1, 13.987654321);
560 }
561}