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