Skip to main content

feedparser_rs/namespace/
georss.rs

1//! GeoRSS namespace support for geographic location data
2//!
3//! Supports parsing GeoRSS Simple elements for specifying geographic locations
4//! in RSS and Atom feeds. GeoRSS is commonly used in mapping applications,
5//! location-based services, and geocoded content.
6//!
7//! # Supported Elements
8//!
9//! - `georss:point` - Single latitude/longitude point
10//! - `georss:line` - Line string (multiple points)
11//! - `georss:polygon` - Polygon (closed shape)
12//! - `georss:box` - Bounding box (lower-left + upper-right)
13//!
14//! Also supports the GeoRSS GML profile (`georss:where` wrapping
15//! `gml:Point`/`gml:LineString`/`gml:Polygon`/`gml:MultiSurface`/
16//! `gml:Envelope`), including `srsName`-driven axis-order normalization.
17//! XML traversal for the GML profile lives in the parser's internal
18//! `common::parse_georss_where` since it needs the `quick-xml` reader; this
19//! module provides the pure coordinate/axis-order logic it calls into.
20//!
21//! # Specification
22//!
23//! - GeoRSS Simple: <http://www.georss.org/simple>
24//! - GeoRSS GML profile: <http://www.georss.org/gml>
25
26use crate::limits::ParserLimits;
27use crate::types::{Entry, FeedMeta};
28
29/// `GeoRSS` namespace URI
30pub const GEORSS: &str = "http://www.georss.org/georss";
31
32/// GML (Geography Markup Language) namespace URI used by the `GeoRSS` GML profile
33pub const GML: &str = "http://www.opengis.net/gml";
34
35/// Type of geographic shape
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
37pub enum GeoType {
38    /// Single point (latitude, longitude)
39    #[default]
40    Point,
41    /// Line connecting multiple points
42    Line,
43    /// Closed polygon shape
44    Polygon,
45    /// Bounding box (lower-left, upper-right corners)
46    Box,
47}
48
49/// Geographic location data from `GeoRSS`
50#[derive(Debug, Clone, Default, PartialEq)]
51pub struct GeoLocation {
52    /// Type of geographic shape
53    pub geo_type: GeoType,
54    /// Coordinate pairs as (latitude, longitude)
55    ///
56    /// - Point: 1 coordinate pair
57    /// - Line: 2+ coordinate pairs
58    /// - Polygon: 3+ coordinate pairs (first == last for closed polygon)
59    /// - Box: 2 coordinate pairs (lower-left, upper-right)
60    pub coordinates: Vec<(f64, f64)>,
61    /// Coordinate reference system (e.g., "EPSG:4326" for WGS84)
62    ///
63    /// Default is WGS84 (latitude/longitude) if not specified
64    pub srs_name: Option<String>,
65    /// Elevation in meters (from `georss:elev`)
66    pub elev: Option<f64>,
67    /// Feature type classification (from `georss:featuretypetag`)
68    pub feature_type_tag: Option<String>,
69    /// Human-readable place name (from `georss:featurename`)
70    pub feature_name: Option<String>,
71    /// Relationship type (from `georss:relationshiptag`)
72    pub relationship_tag: Option<String>,
73}
74
75impl GeoLocation {
76    /// Creates new point location
77    ///
78    /// # Arguments
79    ///
80    /// * `lat` - Latitude in decimal degrees
81    /// * `lon` - Longitude in decimal degrees
82    ///
83    /// # Examples
84    ///
85    /// ```
86    /// use feedparser_rs::namespace::georss::GeoLocation;
87    ///
88    /// let loc = GeoLocation::point(45.256, -71.92);
89    /// assert_eq!(loc.coordinates.len(), 1);
90    /// ```
91    #[must_use]
92    pub fn point(lat: f64, lon: f64) -> Self {
93        Self {
94            geo_type: GeoType::Point,
95            coordinates: vec![(lat, lon)],
96            ..Default::default()
97        }
98    }
99
100    /// Creates new line location
101    ///
102    /// # Arguments
103    ///
104    /// * `coords` - Vector of (latitude, longitude) pairs
105    ///
106    /// # Examples
107    ///
108    /// ```
109    /// use feedparser_rs::namespace::georss::GeoLocation;
110    ///
111    /// let coords = vec![(45.256, -71.92), (46.0, -72.0)];
112    /// let loc = GeoLocation::line(coords);
113    /// assert_eq!(loc.coordinates.len(), 2);
114    /// ```
115    #[must_use]
116    pub fn line(coords: Vec<(f64, f64)>) -> Self {
117        Self {
118            geo_type: GeoType::Line,
119            coordinates: coords,
120            ..Default::default()
121        }
122    }
123
124    /// Creates new polygon location
125    ///
126    /// # Arguments
127    ///
128    /// * `coords` - Vector of (latitude, longitude) pairs
129    ///
130    /// # Examples
131    ///
132    /// ```
133    /// use feedparser_rs::namespace::georss::GeoLocation;
134    ///
135    /// let coords = vec![
136    ///     (45.0, -71.0),
137    ///     (46.0, -71.0),
138    ///     (46.0, -72.0),
139    ///     (45.0, -71.0), // Close the polygon
140    /// ];
141    /// let loc = GeoLocation::polygon(coords);
142    /// ```
143    #[must_use]
144    pub fn polygon(coords: Vec<(f64, f64)>) -> Self {
145        Self {
146            geo_type: GeoType::Polygon,
147            coordinates: coords,
148            ..Default::default()
149        }
150    }
151
152    /// Creates new bounding box location
153    ///
154    /// # Arguments
155    ///
156    /// * `lower_lat` - Lower-left latitude
157    /// * `lower_lon` - Lower-left longitude
158    /// * `upper_lat` - Upper-right latitude
159    /// * `upper_lon` - Upper-right longitude
160    ///
161    /// # Examples
162    ///
163    /// ```
164    /// use feedparser_rs::namespace::georss::GeoLocation;
165    ///
166    /// let loc = GeoLocation::bbox(45.0, -72.0, 46.0, -71.0);
167    /// assert_eq!(loc.coordinates.len(), 2);
168    /// ```
169    #[must_use]
170    pub fn bbox(lower_lat: f64, lower_lon: f64, upper_lat: f64, upper_lon: f64) -> Self {
171        Self {
172            geo_type: GeoType::Box,
173            coordinates: vec![(lower_lat, lower_lon), (upper_lat, upper_lon)],
174            ..Default::default()
175        }
176    }
177}
178
179/// Parse W3C Basic Geo element and update entry
180///
181/// Handles `geo:lat` and `geo:long` elements. When both are present,
182/// auto-constructs `entry.r#where` as a point location.
183///
184/// # Arguments
185///
186/// * `tag` - Element local name (e.g., "lat", "long", "lon")
187/// * `text` - Element text content
188/// * `entry` - Entry to update
189///
190/// # Returns
191///
192/// `true` if element was recognized and handled, `false` otherwise
193pub fn handle_entry_geo_element(tag: &[u8], text: &str, entry: &mut Entry) -> bool {
194    match tag {
195        b"lat" => {
196            entry.geo_lat = Some(text.to_string());
197            try_build_entry_where(entry);
198            true
199        }
200        b"long" | b"lon" => {
201            entry.geo_long = Some(text.to_string());
202            try_build_entry_where(entry);
203            true
204        }
205        _ => false,
206    }
207}
208
209/// Parse W3C Basic Geo element and update feed metadata
210///
211/// Handles `geo:lat` and `geo:long` elements. When both are present,
212/// auto-constructs `feed.r#where` as a point location.
213///
214/// # Arguments
215///
216/// * `tag` - Element local name (e.g., "lat", "long", "lon")
217/// * `text` - Element text content
218/// * `feed` - Feed metadata to update
219///
220/// # Returns
221///
222/// `true` if element was recognized and handled, `false` otherwise
223pub fn handle_feed_geo_element(tag: &[u8], text: &str, feed: &mut FeedMeta) -> bool {
224    match tag {
225        b"lat" => {
226            feed.geo_lat = Some(text.to_string());
227            try_build_feed_where(feed);
228            true
229        }
230        b"long" | b"lon" => {
231            feed.geo_long = Some(text.to_string());
232            try_build_feed_where(feed);
233            true
234        }
235        _ => false,
236    }
237}
238
239fn try_build_entry_where(entry: &mut Entry) {
240    if let (Some(lat_str), Some(lon_str)) = (entry.geo_lat.as_deref(), entry.geo_long.as_deref())
241        && let (Ok(lat), Ok(lon)) = (lat_str.parse::<f64>(), lon_str.parse::<f64>())
242        && (-90.0..=90.0).contains(&lat)
243        && (-180.0..=180.0).contains(&lon)
244    {
245        entry.r#where = Some(Box::new(GeoLocation::point(lat, lon)));
246    }
247}
248
249fn try_build_feed_where(feed: &mut FeedMeta) {
250    if let (Some(lat_str), Some(lon_str)) = (feed.geo_lat.as_deref(), feed.geo_long.as_deref())
251        && let (Ok(lat), Ok(lon)) = (lat_str.parse::<f64>(), lon_str.parse::<f64>())
252        && (-90.0..=90.0).contains(&lat)
253        && (-180.0..=180.0).contains(&lon)
254    {
255        feed.r#where = Some(Box::new(GeoLocation::point(lat, lon)));
256    }
257}
258
259/// Merge a freshly parsed geometry into an entry/feed's `where` field.
260///
261/// The geometry fields (`geo_type`, `coordinates`, `srs_name`) are replaced
262/// wholesale — including resetting `srs_name` to `None` if `loc` has none,
263/// e.g. a `GeoRSS` Simple element following a GML one — since a geometry
264/// and its CRS are one unit; extended attributes (`elev`, `featureName`,
265/// etc.) that may have been set from elements appearing before or after the
266/// geometry element are preserved. Shared by the `GeoRSS` Simple element
267/// handlers below and by the parser's internal `common::parse_georss_where`
268/// (GML profile).
269pub fn merge_geometry(target: &mut Option<Box<GeoLocation>>, loc: GeoLocation) {
270    let existing = target.get_or_insert_with(|| Box::new(GeoLocation::default()));
271    existing.geo_type = loc.geo_type;
272    existing.coordinates = loc.coordinates;
273    existing.srs_name = loc.srs_name;
274}
275
276/// Parse `GeoRSS` element and update entry
277///
278/// # Arguments
279///
280/// * `tag` - Element local name (e.g., "point", "line", "polygon", "box")
281/// * `text` - Element text content
282/// * `entry` - Entry to update
283/// * `_limits` - Parser limits (unused but kept for API consistency)
284///
285/// # Returns
286///
287/// `true` if element was recognized and handled, `false` otherwise
288pub fn handle_entry_element(
289    tag: &[u8],
290    text: &str,
291    entry: &mut Entry,
292    _limits: &ParserLimits,
293) -> bool {
294    match tag {
295        b"point" => {
296            if let Some(loc) = parse_point(text) {
297                merge_geometry(&mut entry.r#where, loc);
298            }
299            true
300        }
301        b"line" => {
302            if let Some(loc) = parse_line(text) {
303                merge_geometry(&mut entry.r#where, loc);
304            }
305            true
306        }
307        b"polygon" => {
308            if let Some(loc) = parse_polygon(text) {
309                merge_geometry(&mut entry.r#where, loc);
310            }
311            true
312        }
313        b"box" => {
314            if let Some(loc) = parse_box(text) {
315                merge_geometry(&mut entry.r#where, loc);
316            }
317            true
318        }
319        b"elev" => {
320            if let Ok(v) = text.trim().parse::<f64>()
321                && v.is_finite()
322            {
323                entry
324                    .r#where
325                    .get_or_insert_with(|| Box::new(GeoLocation::default()))
326                    .elev = Some(v);
327            }
328            true
329        }
330        b"featuretypetag" => {
331            entry
332                .r#where
333                .get_or_insert_with(|| Box::new(GeoLocation::default()))
334                .feature_type_tag = Some(text.to_string());
335            true
336        }
337        b"featurename" => {
338            entry
339                .r#where
340                .get_or_insert_with(|| Box::new(GeoLocation::default()))
341                .feature_name = Some(text.to_string());
342            true
343        }
344        b"relationshiptag" => {
345            entry
346                .r#where
347                .get_or_insert_with(|| Box::new(GeoLocation::default()))
348                .relationship_tag = Some(text.to_string());
349            true
350        }
351        _ => false,
352    }
353}
354
355/// Parse `GeoRSS` element and update feed metadata
356///
357/// # Arguments
358///
359/// * `tag` - Element local name (e.g., "point", "line", "polygon", "box")
360/// * `text` - Element text content
361/// * `feed` - Feed metadata to update
362/// * `_limits` - Parser limits (unused but kept for API consistency)
363///
364/// # Returns
365///
366/// `true` if element was recognized and handled, `false` otherwise
367pub fn handle_feed_element(
368    tag: &[u8],
369    text: &str,
370    feed: &mut FeedMeta,
371    _limits: &ParserLimits,
372) -> bool {
373    match tag {
374        b"point" => {
375            if let Some(loc) = parse_point(text) {
376                merge_geometry(&mut feed.r#where, loc);
377            }
378            true
379        }
380        b"line" => {
381            if let Some(loc) = parse_line(text) {
382                merge_geometry(&mut feed.r#where, loc);
383            }
384            true
385        }
386        b"polygon" => {
387            if let Some(loc) = parse_polygon(text) {
388                merge_geometry(&mut feed.r#where, loc);
389            }
390            true
391        }
392        b"box" => {
393            if let Some(loc) = parse_box(text) {
394                merge_geometry(&mut feed.r#where, loc);
395            }
396            true
397        }
398        b"elev" => {
399            if let Ok(v) = text.trim().parse::<f64>()
400                && v.is_finite()
401            {
402                feed.r#where
403                    .get_or_insert_with(|| Box::new(GeoLocation::default()))
404                    .elev = Some(v);
405            }
406            true
407        }
408        b"featuretypetag" => {
409            feed.r#where
410                .get_or_insert_with(|| Box::new(GeoLocation::default()))
411                .feature_type_tag = Some(text.to_string());
412            true
413        }
414        b"featurename" => {
415            feed.r#where
416                .get_or_insert_with(|| Box::new(GeoLocation::default()))
417                .feature_name = Some(text.to_string());
418            true
419        }
420        b"relationshiptag" => {
421            feed.r#where
422                .get_or_insert_with(|| Box::new(GeoLocation::default()))
423                .relationship_tag = Some(text.to_string());
424            true
425        }
426        _ => false,
427    }
428}
429
430/// Parse georss:point element
431///
432/// Format: "lat lon" (space-separated)
433/// Example: "45.256 -71.92"
434fn parse_point(text: &str) -> Option<GeoLocation> {
435    let coords = parse_coordinates(text)?;
436    if coords.len() == 1 {
437        Some(GeoLocation {
438            geo_type: GeoType::Point,
439            coordinates: coords,
440            ..Default::default()
441        })
442    } else {
443        None
444    }
445}
446
447/// Parse georss:line element
448///
449/// Format: "lat1 lon1 lat2 lon2 ..." (space-separated)
450/// Example: "45.256 -71.92 46.0 -72.0"
451fn parse_line(text: &str) -> Option<GeoLocation> {
452    let coords = parse_coordinates(text)?;
453    if coords.len() >= 2 {
454        Some(GeoLocation {
455            geo_type: GeoType::Line,
456            coordinates: coords,
457            ..Default::default()
458        })
459    } else {
460        None
461    }
462}
463
464/// Parse georss:polygon element
465///
466/// Format: "lat1 lon1 lat2 lon2 lat3 lon3 ..." (space-separated)
467/// Example: "45.0 -71.0 46.0 -71.0 46.0 -72.0 45.0 -71.0"
468fn parse_polygon(text: &str) -> Option<GeoLocation> {
469    let coords = parse_coordinates(text)?;
470    if coords.len() >= 3 {
471        Some(GeoLocation {
472            geo_type: GeoType::Polygon,
473            coordinates: coords,
474            ..Default::default()
475        })
476    } else {
477        None
478    }
479}
480
481/// Parse georss:box element
482///
483/// Format: space-separated values (lower-left, upper-right)
484/// Example: "45.0 -72.0 46.0 -71.0"
485fn parse_box(text: &str) -> Option<GeoLocation> {
486    let coords = parse_coordinates(text)?;
487    if coords.len() == 2 {
488        Some(GeoLocation {
489            geo_type: GeoType::Box,
490            coordinates: coords,
491            ..Default::default()
492        })
493    } else {
494        None
495    }
496}
497
498/// Parse space-separated coordinate pairs
499///
500/// Format: "lat1 lon1 lat2 lon2 ..." (pairs of floats)
501fn parse_coordinates(text: &str) -> Option<Vec<(f64, f64)>> {
502    parse_coordinates_ordered(text, true, 2).into_option()
503}
504
505/// Outcome of [`parse_coordinates_ordered`], distinguishing a coordinate
506/// count that doesn't divide evenly by `dims` from other malformed input
507/// (non-numeric tokens, out-of-range values, empty text) — the former is a
508/// distinct, more specific "bozo" condition the GML profile callers surface
509/// to the caller instead of collapsing to a generic failure.
510enum CoordParse {
511    /// Successfully parsed coordinate pairs.
512    Ok(Vec<(f64, f64)>),
513    /// Token count present but not a multiple of `dims`.
514    DimsMismatch,
515    /// Empty text, a non-numeric token, or an out-of-range/non-finite value.
516    Invalid,
517}
518
519impl CoordParse {
520    fn into_option(self) -> Option<Vec<(f64, f64)>> {
521        match self {
522            Self::Ok(coords) => Some(coords),
523            Self::DimsMismatch | Self::Invalid => None,
524        }
525    }
526}
527
528/// Parse coordinate tuples, applying the given axis order and dimensionality.
529///
530/// When `lat_lon_order` is `true`, each tuple's first two values are read as
531/// `(lat, lon)` directly and validated against the `[-90, 90]`/`[-180, 180]`
532/// degree ranges — the convention `GeoRSS` Simple always uses, and GML uses
533/// for geographic CRSes. When `false` (a projected/non-geographic GML CRS),
534/// the first two values are read as `(lon, lat)` and swapped, and — since
535/// projected coordinates are not degrees (typically meters) — only checked
536/// for finiteness rather than the degree ranges.
537///
538/// `dims` is the coordinate dimensionality (`gml:srsDimension`, GML only):
539/// `3` chunks by three and drops the third (elevation) component per tuple
540/// rather than letting it corrupt the next tuple's latitude; any other
541/// value (including the `GeoRSS` Simple default) chunks by two. Comma
542/// separators (a common non-conformant real-world variant) are normalized
543/// to whitespace before splitting.
544fn parse_coordinates_ordered(text: &str, lat_lon_order: bool, dims: usize) -> CoordParse {
545    let dims = if dims == 3 { 3 } else { 2 };
546    let normalized = text.replace(',', " ");
547    let parts: Vec<&str> = normalized.split_whitespace().collect();
548
549    if parts.is_empty() {
550        return CoordParse::Invalid;
551    }
552    if !parts.len().is_multiple_of(dims) {
553        return CoordParse::DimsMismatch;
554    }
555
556    let mut coords = Vec::with_capacity(parts.len() / dims);
557
558    for chunk in parts.chunks(dims) {
559        let Ok(a) = chunk[0].parse::<f64>() else {
560            return CoordParse::Invalid;
561        };
562        let Ok(b) = chunk[1].parse::<f64>() else {
563            return CoordParse::Invalid;
564        };
565        // chunk[2] (present only when dims == 3) is the elevation component;
566        // intentionally dropped here — GeoLocation has no z-coordinate slot
567        // (see `georss:elev` for the crate's separate elevation field).
568        let (lat, lon) = if lat_lon_order { (a, b) } else { (b, a) };
569
570        if lat_lon_order {
571            if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) {
572                return CoordParse::Invalid;
573            }
574        } else if !lat.is_finite() || !lon.is_finite() {
575            return CoordParse::Invalid;
576        }
577
578        coords.push((lat, lon));
579    }
580
581    CoordParse::Ok(coords)
582}
583
584/// EPSG codes for geographic (latitude/longitude-axis) coordinate reference
585/// systems, per the axis order defined by the EPSG registry. Mirrors the
586/// `_geogCS` table in Python feedparser's `GeoRSS` GML support, used to
587/// decide whether `gml:pos`/`gml:posList` values need swapping to match
588/// this crate's `(latitude, longitude)` `GeoLocation::coordinates` order.
589const GEOGRAPHIC_EPSG_CODES: &[u32] = &[
590    3819, 3821, 3824, 3889, 3906, 4001, 4002, 4003, 4004, 4005, 4006, 4007, 4008, 4009, 4010, 4011,
591    4012, 4013, 4014, 4015, 4016, 4018, 4019, 4020, 4021, 4022, 4023, 4024, 4025, 4027, 4028, 4029,
592    4030, 4031, 4032, 4033, 4034, 4035, 4036, 4041, 4042, 4043, 4044, 4045, 4046, 4047, 4052, 4053,
593    4054, 4055, 4075, 4081, 4120, 4121, 4122, 4123, 4124, 4125, 4126, 4127, 4128, 4129, 4130, 4131,
594    4132, 4133, 4134, 4135, 4136, 4137, 4138, 4139, 4140, 4141, 4142, 4143, 4144, 4145, 4146, 4147,
595    4148, 4149, 4150, 4151, 4152, 4153, 4154, 4155, 4156, 4157, 4158, 4159, 4160, 4161, 4162, 4163,
596    4164, 4165, 4166, 4167, 4168, 4169, 4170, 4171, 4172, 4173, 4174, 4175, 4176, 4178, 4179, 4180,
597    4181, 4182, 4183, 4184, 4185, 4188, 4189, 4190, 4191, 4192, 4193, 4194, 4195, 4196, 4197, 4198,
598    4199, 4200, 4201, 4202, 4203, 4204, 4205, 4206, 4207, 4208, 4209, 4210, 4211, 4212, 4213, 4214,
599    4215, 4216, 4218, 4219, 4220, 4221, 4222, 4223, 4224, 4225, 4226, 4227, 4228, 4229, 4230, 4231,
600    4232, 4233, 4234, 4235, 4236, 4237, 4238, 4239, 4240, 4241, 4242, 4243, 4244, 4245, 4246, 4247,
601    4248, 4249, 4250, 4251, 4252, 4253, 4254, 4255, 4256, 4257, 4258, 4259, 4260, 4261, 4262, 4263,
602    4264, 4265, 4266, 4267, 4268, 4269, 4270, 4271, 4272, 4273, 4274, 4275, 4276, 4277, 4278, 4279,
603    4280, 4281, 4282, 4283, 4284, 4285, 4286, 4287, 4288, 4289, 4291, 4292, 4293, 4294, 4295, 4296,
604    4297, 4298, 4299, 4300, 4301, 4302, 4303, 4304, 4306, 4307, 4308, 4309, 4310, 4311, 4312, 4313,
605    4314, 4315, 4316, 4317, 4318, 4319, 4322, 4324, 4326, 4463, 4470, 4475, 4483, 4490, 4555, 4558,
606    4600, 4601, 4602, 4603, 4604, 4605, 4606, 4607, 4608, 4609, 4610, 4611, 4612, 4613, 4614, 4615,
607    4616, 4617, 4618, 4619, 4620, 4621, 4622, 4623, 4624, 4625, 4626, 4627, 4628, 4629, 4630, 4631,
608    4632, 4633, 4634, 4635, 4636, 4637, 4638, 4639, 4640, 4641, 4642, 4643, 4644, 4645, 4646, 4657,
609    4658, 4659, 4660, 4661, 4662, 4663, 4664, 4665, 4666, 4667, 4668, 4669, 4670, 4671, 4672, 4673,
610    4674, 4675, 4676, 4677, 4678, 4679, 4680, 4681, 4682, 4683, 4684, 4685, 4686, 4687, 4688, 4689,
611    4690, 4691, 4692, 4693, 4694, 4695, 4696, 4697, 4698, 4699, 4700, 4701, 4702, 4703, 4704, 4705,
612    4706, 4707, 4708, 4709, 4710, 4711, 4712, 4713, 4714, 4715, 4716, 4717, 4718, 4719, 4720, 4721,
613    4722, 4723, 4724, 4725, 4726, 4727, 4728, 4729, 4730, 4731, 4732, 4733, 4734, 4735, 4736, 4737,
614    4738, 4739, 4740, 4741, 4742, 4743, 4744, 4745, 4746, 4747, 4748, 4749, 4750, 4751, 4752, 4753,
615    4754, 4755, 4756, 4757, 4758, 4759, 4760, 4761, 4762, 4763, 4764, 4765, 4801, 4802, 4803, 4804,
616    4805, 4806, 4807, 4808, 4809, 4810, 4811, 4813, 4814, 4815, 4816, 4817, 4818, 4819, 4820, 4821,
617    4823, 4824, 4901, 4902, 4903, 4904, 4979,
618];
619
620/// Returns `true` if `code` is a known geographic (lat/lon-axis) EPSG CRS.
621fn is_geographic_epsg(code: u32) -> bool {
622    GEOGRAPHIC_EPSG_CODES.binary_search(&code).is_ok()
623}
624
625/// Extract a trailing numeric EPSG code from a `srsName` value.
626///
627/// Handles the common forms `"EPSG:4326"`, `"urn:ogc:def:crs:EPSG::4326"`,
628/// `"http://www.opengis.net/def/crs/EPSG/0/4326"`, and the classic GML 2
629/// fragment form `"http://www.opengis.net/gml/srs/epsg.xml#4326"`. Tolerates
630/// leading/trailing whitespace (from XML attribute-value normalization of a
631/// line-wrapped attribute). Returns `None` if the value doesn't mention EPSG
632/// or has no trailing numeric segment.
633fn extract_epsg_code(srs_name: &str) -> Option<u32> {
634    let trimmed = srs_name.trim();
635    if !trimmed.to_ascii_uppercase().contains("EPSG") {
636        return None;
637    }
638    trimmed
639        .rsplit([':', '/', '#'])
640        .map(str::trim)
641        .find(|segment| !segment.is_empty())
642        .and_then(|segment| segment.parse().ok())
643}
644
645/// Determine whether `gml:pos`/`gml:posList` values for `srs_name` are
646/// ordered `(latitude, longitude)` — matching this crate's
647/// `GeoLocation::coordinates` order directly — or need swapping from
648/// `(longitude, latitude)`.
649///
650/// Per the `GeoRSS` GML profile, geographic CRSes (including the implied
651/// default, WGS84 / EPSG:4326) use `(lat, lon)` axis order; most projected
652/// CRSes use easting/northing order instead. `OGC:CRS84` (and its `urn`/
653/// `http` forms) is special-cased to `(lon, lat)`: it is WGS84 like
654/// EPSG:4326, but defined with the opposite axis order, and carries no
655/// `EPSG` token so it would otherwise fall through to the geographic
656/// default. Defaults to `(lat, lon)` when `srs_name` is absent or doesn't
657/// reference a recognized EPSG code or CRS84.
658fn srs_uses_lat_lon_order(srs_name: Option<&str>) -> bool {
659    match srs_name {
660        None => true,
661        Some(name) if name.to_ascii_uppercase().contains("CRS84") => false,
662        Some(name) => extract_epsg_code(name).is_none_or(is_geographic_epsg),
663    }
664}
665
666/// Marker error: a GML geometry's coordinate text was present and non-empty,
667/// but its token count wasn't a multiple of the resolved `srsDimension`.
668///
669/// Returned by [`build_gml_geometry`] and [`build_gml_envelope`] as a
670/// distinct outcome from `Ok(None)` (other malformed input, which stays
671/// silent per the tolerant "bozo" pattern): this specific condition is one
672/// the caller should surface as `bozo = true` with a description, since a
673/// coordinate-count mismatch is otherwise indistinguishable from a feed with
674/// no GML geometry at all (#478).
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676pub struct GmlDimsMismatch;
677
678/// Build a `GeoLocation` from a parsed `GeoRSS` GML profile geometry.
679///
680/// `geo_type` must be `Point`, `Line`, or `Polygon` — `Box` (`gml:Envelope`)
681/// is handled separately by [`build_gml_envelope`], since it has no
682/// `gml:pos`/`gml:posList` coordinate text; passing `Box` here always
683/// returns `Ok(None)`. `text` is the raw
684/// `gml:pos`/`gml:posList` coordinate text; axis order is normalized to
685/// `(latitude, longitude)` using `srs_name` per the referenced CRS's axis
686/// order (geographic CRSes, including the WGS84 default, use `(lat, lon)`;
687/// most projected CRSes use easting/northing order instead, and — since
688/// those values are typically meters, not degrees — are only checked for
689/// finiteness rather than the `[-90, 90]`/`[-180, 180]` degree ranges).
690/// `dims` is `gml:srsDimension` (`3` for 3D
691/// `gml:pos`/`gml:posList`; anything else, including the common absence of
692/// the attribute, means 2D).
693///
694/// Returns `Ok(None)` — the tolerant "bozo" pattern — if the coordinate text
695/// is malformed, out of range, or has too few points for `geo_type`; the
696/// caller should skip the geometry (including `srs_name`, since there is no
697/// geometry left to attach it to) rather than fail parsing. Returns
698/// `Err(GmlDimsMismatch)` instead when the coordinate text's token count
699/// wasn't a multiple of the resolved `srsDimension` — a distinct anomaly the
700/// caller should surface as bozo, unlike the other malformed-input cases
701/// collapsed into `Ok(None)`.
702///
703/// # Errors
704///
705/// Returns `Err(GmlDimsMismatch)` when the coordinate text's token count
706/// isn't a multiple of the resolved `srsDimension` — see above.
707///
708/// # Examples
709///
710/// ```
711/// use feedparser_rs::namespace::georss::{GeoType, GmlDimsMismatch, build_gml_geometry};
712///
713/// let loc =
714///     build_gml_geometry(GeoType::Point, Some("EPSG:4326".to_string()), "45.256 -71.92", 2);
715/// assert_eq!(loc.unwrap().unwrap().coordinates[0], (45.256, -71.92));
716///
717/// // A projected (non-geographic) EPSG CRS uses (lon, lat) order and gets swapped;
718/// // note projected coordinates are typically meters, not degrees (EPSG:3857 here).
719/// let loc =
720///     build_gml_geometry(GeoType::Point, Some("EPSG:3857".to_string()), "-8004866.0 5675670.0", 2);
721/// assert_eq!(loc.unwrap().unwrap().coordinates[0], (5_675_670.0, -8_004_866.0));
722///
723/// // srsDimension="3": the third (elevation) value per tuple is dropped, not
724/// // misaligned into the next tuple's latitude.
725/// let loc = build_gml_geometry(GeoType::Line, None, "45.0 -71.0 10.0 46.0 -72.0 20.0", 3);
726/// assert_eq!(
727///     loc.unwrap().unwrap().coordinates,
728///     vec![(45.0, -71.0), (46.0, -72.0)]
729/// );
730///
731/// // 5 values isn't a multiple of dims=3 — a distinct bozo condition.
732/// let result = build_gml_geometry(GeoType::Point, None, "45.0 -71.0 10.0 46.0 -72.0", 3);
733/// assert_eq!(result, Err(GmlDimsMismatch));
734/// ```
735pub fn build_gml_geometry(
736    geo_type: GeoType,
737    srs_name: Option<String>,
738    text: &str,
739    dims: usize,
740) -> Result<Option<GeoLocation>, GmlDimsMismatch> {
741    let min_points = match geo_type {
742        GeoType::Point => 1,
743        GeoType::Line => 2,
744        GeoType::Polygon => 3,
745        GeoType::Box => return Ok(None),
746    };
747
748    let lat_lon_order = srs_uses_lat_lon_order(srs_name.as_deref());
749    let coords = match parse_coordinates_ordered(text, lat_lon_order, dims) {
750        CoordParse::Ok(coords) => coords,
751        CoordParse::DimsMismatch => return Err(GmlDimsMismatch),
752        CoordParse::Invalid => return Ok(None),
753    };
754    if coords.len() < min_points || (geo_type == GeoType::Point && coords.len() != 1) {
755        return Ok(None);
756    }
757
758    Ok(Some(GeoLocation {
759        geo_type,
760        coordinates: coords,
761        srs_name,
762        ..Default::default()
763    }))
764}
765
766/// Build a `GeoLocation` (`GeoType::Box`) from a `GeoRSS` GML profile
767/// `gml:Envelope`.
768///
769/// `lower_text`/`upper_text` are the raw `gml:lowerCorner`/`gml:upperCorner`
770/// coordinate text, each a single coordinate tuple; axis order is
771/// normalized to `(latitude, longitude)` using `srs_name`, the same rule
772/// [`build_gml_geometry`] applies to `gml:pos`/`gml:posList`. `dims` is
773/// `gml:srsDimension` (`3` drops the elevation component per corner;
774/// anything else means 2D).
775///
776/// Returns `Ok(None)` — the tolerant "bozo" pattern — if either corner's
777/// text is malformed, out of range, or not a single coordinate tuple; the
778/// caller should skip the geometry rather than fail parsing. Returns
779/// `Err(GmlDimsMismatch)` instead when a corner's token count wasn't a
780/// multiple of the resolved `srsDimension` — a distinct anomaly the caller
781/// should surface as bozo, unlike the other malformed-input cases collapsed
782/// into `Ok(None)`.
783///
784/// # Errors
785///
786/// Returns `Err(GmlDimsMismatch)` when a corner's token count isn't a
787/// multiple of the resolved `srsDimension` — see above.
788///
789/// # Examples
790///
791/// ```
792/// use feedparser_rs::namespace::georss::build_gml_envelope;
793///
794/// let loc = build_gml_envelope(None, "42.9 -71.9", "43.1 -71.5", 2);
795/// assert_eq!(
796///     loc.unwrap().unwrap().coordinates,
797///     vec![(42.9, -71.9), (43.1, -71.5)]
798/// );
799/// ```
800pub fn build_gml_envelope(
801    srs_name: Option<String>,
802    lower_text: &str,
803    upper_text: &str,
804    dims: usize,
805) -> Result<Option<GeoLocation>, GmlDimsMismatch> {
806    let lat_lon_order = srs_uses_lat_lon_order(srs_name.as_deref());
807    let lower = match parse_coordinates_ordered(lower_text, lat_lon_order, dims) {
808        CoordParse::Ok(coords) => coords,
809        CoordParse::DimsMismatch => return Err(GmlDimsMismatch),
810        CoordParse::Invalid => return Ok(None),
811    };
812    let upper = match parse_coordinates_ordered(upper_text, lat_lon_order, dims) {
813        CoordParse::Ok(coords) => coords,
814        CoordParse::DimsMismatch => return Err(GmlDimsMismatch),
815        CoordParse::Invalid => return Ok(None),
816    };
817
818    if lower.len() != 1 || upper.len() != 1 {
819        return Ok(None);
820    }
821
822    Ok(Some(GeoLocation {
823        geo_type: GeoType::Box,
824        coordinates: vec![lower[0], upper[0]],
825        srs_name,
826        ..Default::default()
827    }))
828}
829
830#[cfg(test)]
831mod tests {
832    use super::*;
833
834    #[test]
835    fn test_parse_point() {
836        let loc = parse_point("45.256 -71.92").unwrap();
837        assert_eq!(loc.geo_type, GeoType::Point);
838        assert_eq!(loc.coordinates.len(), 1);
839        assert_eq!(loc.coordinates[0], (45.256, -71.92));
840    }
841
842    #[test]
843    fn test_parse_point_invalid() {
844        assert!(parse_point("45.256").is_none());
845        assert!(parse_point("45.256 -71.92 extra").is_none());
846        assert!(parse_point("not numbers").is_none());
847        assert!(parse_point("").is_none());
848    }
849
850    #[test]
851    fn test_parse_line() {
852        let loc = parse_line("45.256 -71.92 46.0 -72.0").unwrap();
853        assert_eq!(loc.geo_type, GeoType::Line);
854        assert_eq!(loc.coordinates.len(), 2);
855        assert_eq!(loc.coordinates[0], (45.256, -71.92));
856        assert_eq!(loc.coordinates[1], (46.0, -72.0));
857    }
858
859    #[test]
860    fn test_parse_line_single_point() {
861        // Line needs at least 2 points
862        assert!(parse_line("45.256 -71.92").is_none());
863    }
864
865    #[test]
866    fn test_parse_polygon() {
867        let loc = parse_polygon("45.0 -71.0 46.0 -71.0 46.0 -72.0 45.0 -71.0").unwrap();
868        assert_eq!(loc.geo_type, GeoType::Polygon);
869        assert_eq!(loc.coordinates.len(), 4);
870        assert_eq!(loc.coordinates[0], (45.0, -71.0));
871        assert_eq!(loc.coordinates[3], (45.0, -71.0)); // Closed polygon
872    }
873
874    #[test]
875    fn test_parse_box() {
876        let loc = parse_box("45.0 -72.0 46.0 -71.0").unwrap();
877        assert_eq!(loc.geo_type, GeoType::Box);
878        assert_eq!(loc.coordinates.len(), 2);
879        assert_eq!(loc.coordinates[0], (45.0, -72.0)); // Lower-left
880        assert_eq!(loc.coordinates[1], (46.0, -71.0)); // Upper-right
881    }
882
883    #[test]
884    fn test_parse_box_invalid() {
885        // Box needs exactly 2 points (4 values)
886        assert!(parse_box("45.0 -72.0").is_none());
887        assert!(parse_box("45.0 -72.0 46.0 -71.0 extra values").is_none());
888    }
889
890    #[test]
891    fn test_coordinate_validation() {
892        // Invalid latitude (> 90)
893        assert!(parse_point("91.0 0.0").is_none());
894        // Invalid latitude (< -90)
895        assert!(parse_point("-91.0 0.0").is_none());
896        // Invalid longitude (> 180)
897        assert!(parse_point("0.0 181.0").is_none());
898        // Invalid longitude (< -180)
899        assert!(parse_point("0.0 -181.0").is_none());
900    }
901
902    #[test]
903    fn test_handle_entry_element_point() {
904        let mut entry = Entry::default();
905        let limits = ParserLimits::default();
906
907        let handled = handle_entry_element(b"point", "45.256 -71.92", &mut entry, &limits);
908        assert!(handled);
909        assert!(entry.r#where.is_some());
910
911        let geo = entry.r#where.as_ref().unwrap();
912        assert_eq!(geo.geo_type, GeoType::Point);
913        assert_eq!(geo.coordinates[0], (45.256, -71.92));
914    }
915
916    #[test]
917    fn test_handle_entry_element_line() {
918        let mut entry = Entry::default();
919        let limits = ParserLimits::default();
920
921        let handled =
922            handle_entry_element(b"line", "45.256 -71.92 46.0 -72.0", &mut entry, &limits);
923        assert!(handled);
924        assert!(entry.r#where.is_some());
925        assert_eq!(entry.r#where.as_ref().unwrap().geo_type, GeoType::Line);
926    }
927
928    #[test]
929    fn test_handle_entry_element_unknown() {
930        let mut entry = Entry::default();
931        let limits = ParserLimits::default();
932
933        let handled = handle_entry_element(b"unknown", "data", &mut entry, &limits);
934        assert!(!handled);
935        assert!(entry.r#where.is_none());
936    }
937
938    #[test]
939    fn test_geo_location_constructors() {
940        let point = GeoLocation::point(45.0, -71.0);
941        assert_eq!(point.geo_type, GeoType::Point);
942        assert_eq!(point.coordinates.len(), 1);
943
944        let line = GeoLocation::line(vec![(45.0, -71.0), (46.0, -72.0)]);
945        assert_eq!(line.geo_type, GeoType::Line);
946        assert_eq!(line.coordinates.len(), 2);
947
948        let polygon = GeoLocation::polygon(vec![(45.0, -71.0), (46.0, -71.0), (45.0, -71.0)]);
949        assert_eq!(polygon.geo_type, GeoType::Polygon);
950        assert_eq!(polygon.coordinates.len(), 3);
951
952        let bbox = GeoLocation::bbox(45.0, -72.0, 46.0, -71.0);
953        assert_eq!(bbox.geo_type, GeoType::Box);
954        assert_eq!(bbox.coordinates.len(), 2);
955    }
956
957    #[test]
958    fn test_whitespace_handling() {
959        let loc = parse_point("  45.256   -71.92  ").unwrap();
960        assert_eq!(loc.coordinates[0], (45.256, -71.92));
961    }
962
963    #[test]
964    fn test_handle_feed_element_point() {
965        let mut feed = FeedMeta::default();
966        let limits = ParserLimits::default();
967
968        let handled = handle_feed_element(b"point", "45.256 -71.92", &mut feed, &limits);
969        assert!(handled);
970        assert!(feed.r#where.is_some());
971
972        let geo = feed.r#where.as_ref().unwrap();
973        assert_eq!(geo.geo_type, GeoType::Point);
974        assert_eq!(geo.coordinates[0], (45.256, -71.92));
975    }
976
977    #[test]
978    fn test_handle_feed_element_line() {
979        let mut feed = FeedMeta::default();
980        let limits = ParserLimits::default();
981
982        let handled = handle_feed_element(b"line", "45.256 -71.92 46.0 -72.0", &mut feed, &limits);
983        assert!(handled);
984        assert!(feed.r#where.is_some());
985        assert_eq!(feed.r#where.as_ref().unwrap().geo_type, GeoType::Line);
986    }
987
988    #[test]
989    fn test_handle_feed_element_polygon() {
990        let mut feed = FeedMeta::default();
991        let limits = ParserLimits::default();
992
993        let handled = handle_feed_element(
994            b"polygon",
995            "45.0 -71.0 46.0 -71.0 46.0 -72.0 45.0 -71.0",
996            &mut feed,
997            &limits,
998        );
999        assert!(handled);
1000        assert!(feed.r#where.is_some());
1001        assert_eq!(feed.r#where.as_ref().unwrap().geo_type, GeoType::Polygon);
1002    }
1003
1004    #[test]
1005    fn test_handle_feed_element_box() {
1006        let mut feed = FeedMeta::default();
1007        let limits = ParserLimits::default();
1008
1009        let handled = handle_feed_element(b"box", "45.0 -72.0 46.0 -71.0", &mut feed, &limits);
1010        assert!(handled);
1011        assert!(feed.r#where.is_some());
1012        assert_eq!(feed.r#where.as_ref().unwrap().geo_type, GeoType::Box);
1013    }
1014
1015    #[test]
1016    fn test_handle_feed_element_unknown() {
1017        let mut feed = FeedMeta::default();
1018        let limits = ParserLimits::default();
1019
1020        let handled = handle_feed_element(b"unknown", "data", &mut feed, &limits);
1021        assert!(!handled);
1022        assert!(feed.r#where.is_none());
1023    }
1024
1025    #[test]
1026    fn test_handle_feed_element_invalid_data() {
1027        let mut feed = FeedMeta::default();
1028        let limits = ParserLimits::default();
1029
1030        let handled = handle_feed_element(b"point", "invalid data", &mut feed, &limits);
1031        assert!(handled);
1032        assert!(feed.r#where.is_none());
1033    }
1034
1035    #[test]
1036    fn test_handle_entry_element_elev() {
1037        let mut entry = Entry::default();
1038        let limits = ParserLimits::default();
1039
1040        let handled = handle_entry_element(b"elev", "1337.5", &mut entry, &limits);
1041        assert!(handled);
1042        let geo = entry.r#where.as_ref().unwrap();
1043        assert_eq!(geo.elev, Some(1337.5));
1044    }
1045
1046    #[test]
1047    fn test_handle_entry_element_feature_name() {
1048        let mut entry = Entry::default();
1049        let limits = ParserLimits::default();
1050
1051        let handled = handle_entry_element(b"featurename", "Mont Mégantic", &mut entry, &limits);
1052        assert!(handled);
1053        let geo = entry.r#where.as_ref().unwrap();
1054        assert_eq!(geo.feature_name.as_deref(), Some("Mont Mégantic"));
1055    }
1056
1057    #[test]
1058    fn test_handle_entry_element_feature_type_tag() {
1059        let mut entry = Entry::default();
1060        let limits = ParserLimits::default();
1061
1062        let handled = handle_entry_element(b"featuretypetag", "mountain", &mut entry, &limits);
1063        assert!(handled);
1064        let geo = entry.r#where.as_ref().unwrap();
1065        assert_eq!(geo.feature_type_tag.as_deref(), Some("mountain"));
1066    }
1067
1068    #[test]
1069    fn test_handle_entry_element_relationship_tag() {
1070        let mut entry = Entry::default();
1071        let limits = ParserLimits::default();
1072
1073        let handled =
1074            handle_entry_element(b"relationshiptag", "is-located-at", &mut entry, &limits);
1075        assert!(handled);
1076        let geo = entry.r#where.as_ref().unwrap();
1077        assert_eq!(geo.relationship_tag.as_deref(), Some("is-located-at"));
1078    }
1079
1080    #[test]
1081    fn test_extended_attrs_without_geometry() {
1082        let mut entry = Entry::default();
1083        let limits = ParserLimits::default();
1084
1085        handle_entry_element(b"featurename", "Unknown Location", &mut entry, &limits);
1086        let geo = entry.r#where.as_ref().unwrap();
1087        assert_eq!(geo.feature_name.as_deref(), Some("Unknown Location"));
1088        assert!(geo.coordinates.is_empty());
1089    }
1090
1091    #[test]
1092    fn test_extended_attrs_invalid_elev() {
1093        let mut entry = Entry::default();
1094        let limits = ParserLimits::default();
1095
1096        let handled = handle_entry_element(b"elev", "not-a-number", &mut entry, &limits);
1097        assert!(handled);
1098        // GeoLocation not created because elev parse failed
1099        assert!(entry.r#where.is_none());
1100    }
1101
1102    #[test]
1103    fn test_extended_attrs_elev_non_finite_ignored() {
1104        let limits = ParserLimits::default();
1105
1106        for value in ["NaN", "Infinity", "-Infinity"] {
1107            let mut entry = Entry::default();
1108            let handled = handle_entry_element(b"elev", value, &mut entry, &limits);
1109            assert!(handled, "element must be recognized for value {value}");
1110            assert!(
1111                entry.r#where.is_none(),
1112                "non-finite elev '{value}' must not create GeoLocation"
1113            );
1114        }
1115    }
1116
1117    #[test]
1118    fn test_extended_attrs_before_geometry() {
1119        let mut entry = Entry::default();
1120        let limits = ParserLimits::default();
1121
1122        handle_entry_element(b"featurename", "Reverse Order", &mut entry, &limits);
1123        handle_entry_element(b"elev", "500.0", &mut entry, &limits);
1124        handle_entry_element(b"point", "40.0 -74.0", &mut entry, &limits);
1125
1126        let geo = entry.r#where.as_ref().unwrap();
1127        assert_eq!(geo.geo_type, GeoType::Point);
1128        assert_eq!(geo.coordinates[0], (40.0, -74.0));
1129        assert_eq!(geo.feature_name.as_deref(), Some("Reverse Order"));
1130        assert_eq!(geo.elev, Some(500.0));
1131    }
1132
1133    #[test]
1134    fn test_extended_attrs_after_geometry() {
1135        let mut entry = Entry::default();
1136        let limits = ParserLimits::default();
1137
1138        handle_entry_element(b"point", "45.256 -71.92", &mut entry, &limits);
1139        handle_entry_element(b"featurename", "Mont Mégantic", &mut entry, &limits);
1140        handle_entry_element(b"elev", "1337.5", &mut entry, &limits);
1141
1142        let geo = entry.r#where.as_ref().unwrap();
1143        assert_eq!(geo.geo_type, GeoType::Point);
1144        assert_eq!(geo.coordinates[0], (45.256, -71.92));
1145        assert_eq!(geo.feature_name.as_deref(), Some("Mont Mégantic"));
1146        assert_eq!(geo.elev, Some(1337.5));
1147    }
1148
1149    #[test]
1150    fn test_extract_epsg_code() {
1151        assert_eq!(extract_epsg_code("EPSG:4326"), Some(4326));
1152        assert_eq!(extract_epsg_code("urn:ogc:def:crs:EPSG::4326"), Some(4326));
1153        assert_eq!(
1154            extract_epsg_code("http://www.opengis.net/def/crs/EPSG/0/4326"),
1155            Some(4326)
1156        );
1157        // Classic GML 2 fragment form (#452).
1158        assert_eq!(
1159            extract_epsg_code("http://www.opengis.net/gml/srs/epsg.xml#3857"),
1160            Some(3857)
1161        );
1162        // XML attribute-value normalization of a line-wrapped attribute (#452).
1163        assert_eq!(extract_epsg_code(" EPSG:3857 "), Some(3857));
1164        assert_eq!(extract_epsg_code("http://www.opengis.net/gml"), None);
1165        assert_eq!(extract_epsg_code("not-a-crs"), None);
1166    }
1167
1168    #[test]
1169    fn test_srs_uses_lat_lon_order() {
1170        assert!(srs_uses_lat_lon_order(None));
1171        assert!(srs_uses_lat_lon_order(Some("EPSG:4326")));
1172        assert!(srs_uses_lat_lon_order(Some("urn:ogc:def:crs:EPSG::4326")));
1173        assert!(!srs_uses_lat_lon_order(Some("EPSG:3857")));
1174        assert!(srs_uses_lat_lon_order(Some("some-custom-crs")));
1175        // CRS84 is WGS84 with (lon, lat) axis order despite no EPSG token (#454).
1176        assert!(!srs_uses_lat_lon_order(Some(
1177            "urn:ogc:def:crs:OGC:1.3:CRS84"
1178        )));
1179        assert!(!srs_uses_lat_lon_order(Some("OGC:CRS84")));
1180    }
1181
1182    #[test]
1183    fn test_build_gml_geometry_point_epsg4326() {
1184        let loc = build_gml_geometry(
1185            GeoType::Point,
1186            Some("EPSG:4326".to_string()),
1187            "45.256 -71.92",
1188            2,
1189        );
1190        let loc = loc.unwrap().unwrap();
1191        assert_eq!(loc.geo_type, GeoType::Point);
1192        assert_eq!(loc.coordinates, vec![(45.256, -71.92)]);
1193        assert_eq!(loc.srs_name.as_deref(), Some("EPSG:4326"));
1194    }
1195
1196    #[test]
1197    fn test_build_gml_geometry_point_no_srs_name_defaults_lat_lon() {
1198        let loc = build_gml_geometry(GeoType::Point, None, "45.256 -71.92", 2);
1199        let loc = loc.unwrap().unwrap();
1200        assert_eq!(loc.coordinates, vec![(45.256, -71.92)]);
1201        assert_eq!(loc.srs_name, None);
1202    }
1203
1204    #[test]
1205    fn test_build_gml_geometry_swaps_projected_crs_realistic_meters() {
1206        // EPSG:3857 (Web Mercator) is not geographic: raw order is (lon, lat),
1207        // and real values are meters, not degrees — this must not be rejected
1208        // by the [-90,90]/[-180,180] degree-range check (#454 / issue S5).
1209        let loc = build_gml_geometry(
1210            GeoType::Point,
1211            Some("EPSG:3857".to_string()),
1212            "-8004866.0 5675670.0",
1213            2,
1214        );
1215        assert_eq!(
1216            loc.unwrap().unwrap().coordinates,
1217            vec![(5_675_670.0, -8_004_866.0)]
1218        );
1219    }
1220
1221    #[test]
1222    fn test_build_gml_geometry_projected_crs_rejects_non_finite() {
1223        let result =
1224            build_gml_geometry(GeoType::Point, Some("EPSG:3857".to_string()), "NaN NaN", 2);
1225        assert_eq!(result, Ok(None));
1226    }
1227
1228    #[test]
1229    fn test_build_gml_geometry_linestring() {
1230        let loc = build_gml_geometry(
1231            GeoType::Line,
1232            Some("urn:ogc:def:crs:EPSG::4326".to_string()),
1233            "45.256 -71.92 46.0 -72.0",
1234            2,
1235        );
1236        let loc = loc.unwrap().unwrap();
1237        assert_eq!(loc.geo_type, GeoType::Line);
1238        assert_eq!(loc.coordinates.len(), 2);
1239    }
1240
1241    #[test]
1242    fn test_build_gml_geometry_srs_dimension_3_drops_elevation() {
1243        // C1: dims=3 must chunk by 3 and drop the elevation component,
1244        // never let it corrupt the next tuple's latitude.
1245        let loc = build_gml_geometry(GeoType::Line, None, "45.0 -71.0 10.0 46.0 -72.0 20.0", 3);
1246        assert_eq!(
1247            loc.unwrap().unwrap().coordinates,
1248            vec![(45.0, -71.0), (46.0, -72.0)]
1249        );
1250    }
1251
1252    #[test]
1253    fn test_build_gml_geometry_srs_dimension_mismatch_sets_bozo() {
1254        // 5 values isn't a multiple of dims=3 — must not silently misalign,
1255        // and must surface as bozo instead of being indistinguishable from a
1256        // feed with no GML geometry at all (#478).
1257        let result = build_gml_geometry(GeoType::Point, None, "45.0 -71.0 10.0 46.0 -72.0", 3);
1258        assert_eq!(result, Err(GmlDimsMismatch));
1259    }
1260
1261    #[test]
1262    fn test_build_gml_geometry_comma_separated_coordinates() {
1263        let loc = build_gml_geometry(GeoType::Point, None, "45.256,-71.92", 2);
1264        assert_eq!(loc.unwrap().unwrap().coordinates, vec![(45.256, -71.92)]);
1265    }
1266
1267    #[test]
1268    fn test_build_gml_geometry_polygon_too_few_points() {
1269        let result = build_gml_geometry(GeoType::Polygon, None, "45.0 -71.0 46.0 -71.0", 2);
1270        assert_eq!(result, Ok(None));
1271    }
1272
1273    #[test]
1274    fn test_build_gml_geometry_box_unsupported() {
1275        let result = build_gml_geometry(GeoType::Box, None, "45.0 -71.0 46.0 -71.0", 2);
1276        assert_eq!(result, Ok(None));
1277    }
1278
1279    #[test]
1280    fn test_build_gml_geometry_malformed_text() {
1281        assert_eq!(
1282            build_gml_geometry(GeoType::Point, None, "not numbers", 2),
1283            Ok(None)
1284        );
1285        assert_eq!(build_gml_geometry(GeoType::Point, None, "", 2), Ok(None));
1286    }
1287
1288    #[test]
1289    fn test_build_gml_envelope() {
1290        let loc = build_gml_envelope(None, "42.9 -71.9", "43.1 -71.5", 2);
1291        let loc = loc.unwrap().unwrap();
1292        assert_eq!(loc.geo_type, GeoType::Box);
1293        assert_eq!(loc.coordinates, vec![(42.9, -71.9), (43.1, -71.5)]);
1294    }
1295
1296    #[test]
1297    fn test_build_gml_envelope_swaps_projected_crs() {
1298        let loc = build_gml_envelope(
1299            Some("EPSG:3857".to_string()),
1300            "-8004866.0 5675670.0",
1301            "-8000000.0 5680000.0",
1302            2,
1303        );
1304        assert_eq!(
1305            loc.unwrap().unwrap().coordinates,
1306            vec![(5_675_670.0, -8_004_866.0), (5_680_000.0, -8_000_000.0)]
1307        );
1308    }
1309
1310    #[test]
1311    fn test_build_gml_envelope_srs_dimension_3_drops_elevation() {
1312        let loc = build_gml_envelope(None, "42.9 -71.9 10.0", "43.1 -71.5 20.0", 3);
1313        assert_eq!(
1314            loc.unwrap().unwrap().coordinates,
1315            vec![(42.9, -71.9), (43.1, -71.5)]
1316        );
1317    }
1318
1319    #[test]
1320    fn test_build_gml_envelope_malformed_corner() {
1321        assert_eq!(
1322            build_gml_envelope(None, "not numbers", "43.1 -71.5", 2),
1323            Ok(None)
1324        );
1325        assert_eq!(build_gml_envelope(None, "42.9 -71.9", "", 2), Ok(None));
1326    }
1327
1328    #[test]
1329    fn test_build_gml_envelope_corner_wrong_arity() {
1330        // Each corner must be exactly one coordinate tuple.
1331        let result = build_gml_envelope(None, "42.9 -71.9 43.1 -71.5", "43.1 -71.5", 2);
1332        assert_eq!(result, Ok(None));
1333    }
1334
1335    #[test]
1336    fn test_build_gml_envelope_dims_mismatch_sets_bozo() {
1337        // Lower corner has 2 values, not a multiple of dims=3 (#478).
1338        let result = build_gml_envelope(None, "42.9 -71.9", "43.1 -71.5 20.0", 3);
1339        assert_eq!(result, Err(GmlDimsMismatch));
1340    }
1341}