maxminddb/result.rs
1//! Lookup result types for deferred decoding.
2//!
3//! This module provides `LookupResult`, which enables lazy decoding of
4//! MaxMind DB records. Instead of immediately deserializing data, you
5//! get a lightweight handle that can be decoded later or navigated
6//! selectively via paths.
7
8use std::net::IpAddr;
9
10use ipnetwork::IpNetwork;
11use serde::Deserialize;
12
13use crate::decoder::{TYPE_ARRAY, TYPE_MAP};
14use crate::error::MaxMindDbError;
15use crate::reader::Reader;
16
17/// The result of looking up an IP address in a MaxMind DB.
18///
19/// This is a lightweight handle (~40 bytes) that stores the lookup result
20/// without immediately decoding the data. You can:
21///
22/// - Check if data exists with [`has_data()`](Self::has_data)
23/// - Get the network containing the IP with [`network()`](Self::network)
24/// - Decode the full record with [`decode()`](Self::decode)
25/// - Decode a specific path with [`decode_path()`](Self::decode_path)
26///
27/// # Example
28///
29/// ```
30/// use maxminddb::{Reader, geoip2, PathElement};
31/// use std::net::IpAddr;
32///
33/// let reader = Reader::open_readfile("test-data/test-data/GeoIP2-City-Test.mmdb").unwrap();
34/// let ip: IpAddr = "89.160.20.128".parse().unwrap();
35///
36/// let result = reader.lookup(ip).unwrap();
37///
38/// if result.has_data() {
39/// // Full decode
40/// let city: geoip2::City = result.decode().unwrap().unwrap();
41///
42/// // Or selective decode via path
43/// let country_code: Option<String> = result.decode_path(&[
44/// PathElement::Key("country"),
45/// PathElement::Key("iso_code"),
46/// ]).unwrap();
47/// println!("Country: {:?}", country_code);
48/// }
49/// ```
50#[derive(Debug, Clone, Copy)]
51pub struct LookupResult<'a, S: AsRef<[u8]>> {
52 reader: &'a Reader<S>,
53 /// Offset into the data section, or None if not found.
54 data_offset: Option<usize>,
55 prefix_len: u8,
56 ip: IpAddr,
57}
58
59impl<'a, S: AsRef<[u8]>> LookupResult<'a, S> {
60 /// Creates a new LookupResult for a found IP.
61 pub(crate) fn new_found(
62 reader: &'a Reader<S>,
63 data_offset: usize,
64 prefix_len: u8,
65 ip: IpAddr,
66 ) -> Self {
67 LookupResult {
68 reader,
69 data_offset: Some(data_offset),
70 prefix_len,
71 ip,
72 }
73 }
74
75 /// Creates a new LookupResult for an IP not in the database.
76 pub(crate) fn new_not_found(reader: &'a Reader<S>, prefix_len: u8, ip: IpAddr) -> Self {
77 LookupResult {
78 reader,
79 data_offset: None,
80 prefix_len,
81 ip,
82 }
83 }
84
85 /// Returns true if the database contains data for this IP address.
86 ///
87 /// Note that `false` means the database has no data for this IP,
88 /// which is different from an error during lookup.
89 #[inline]
90 pub fn has_data(&self) -> bool {
91 self.data_offset.is_some()
92 }
93
94 /// Returns the network containing the looked-up IP address.
95 ///
96 /// This is the most specific network in the database that contains
97 /// the IP, regardless of whether data was found.
98 ///
99 /// The returned network preserves the IP version of the original lookup:
100 /// - IPv4 lookups return IPv4 networks (unless prefix < 96, see below)
101 /// - IPv6 lookups return IPv6 networks (including IPv4-mapped addresses)
102 ///
103 /// Special case: If an IPv4 address is looked up in an IPv6 database but
104 /// the matching record is at a prefix length < 96 (e.g., a database with
105 /// no IPv4 subtree), an IPv6 network is returned since there's no valid
106 /// IPv4 representation.
107 pub fn network(&self) -> Result<IpNetwork, MaxMindDbError> {
108 let (ip, prefix) = match self.ip {
109 IpAddr::V4(v4) => {
110 // For IPv4 lookups in IPv6 databases, prefix_len includes the
111 // 96-bit offset. Subtract it to get the IPv4 prefix.
112 // For IPv4 databases, prefix_len is already 0-32.
113 if self.prefix_len >= 96 {
114 // IPv6 database: subtract 96 to get IPv4 prefix
115 (IpAddr::V4(v4), self.prefix_len - 96)
116 } else if self.prefix_len > 32 {
117 // IPv6 database with record at prefix < 96 (e.g., ::/64).
118 // Return IPv6 network since there's no valid IPv4 representation.
119 use std::net::Ipv6Addr;
120 (IpAddr::V6(Ipv6Addr::UNSPECIFIED), self.prefix_len)
121 } else {
122 // IPv4 database: use prefix directly
123 (IpAddr::V4(v4), self.prefix_len)
124 }
125 }
126 IpAddr::V6(v6) => {
127 // For IPv6 lookups, preserve the IPv6 form (including IPv4-mapped)
128 (IpAddr::V6(v6), self.prefix_len)
129 }
130 };
131
132 // Mask the IP to the network address
133 let network_ip = mask_ip(ip, prefix);
134 IpNetwork::new(network_ip, prefix).map_err(MaxMindDbError::InvalidNetwork)
135 }
136
137 /// Returns the data section offset if found, for use as a cache key.
138 ///
139 /// Multiple IP addresses often point to the same data record. This
140 /// offset can be used to deduplicate decoding or cache results.
141 ///
142 /// Returns `None` if the IP was not found.
143 #[inline]
144 pub fn offset(&self) -> Option<usize> {
145 self.data_offset
146 }
147
148 /// Decodes the full record into the specified type.
149 ///
150 /// Returns:
151 /// - `Ok(Some(T))` if found and successfully decoded
152 /// - `Ok(None)` if the IP was not found in the database
153 /// - `Err(...)` if decoding fails
154 ///
155 /// # Example
156 ///
157 /// ```
158 /// use maxminddb::{Reader, geoip2};
159 /// use std::net::IpAddr;
160 ///
161 /// let reader = Reader::open_readfile("test-data/test-data/GeoIP2-City-Test.mmdb").unwrap();
162 /// let ip: IpAddr = "89.160.20.128".parse().unwrap();
163 ///
164 /// let result = reader.lookup(ip).unwrap();
165 /// if let Some(city) = result.decode::<geoip2::City>()? {
166 /// println!("Found city data");
167 /// }
168 /// # Ok::<(), maxminddb::MaxMindDbError>(())
169 /// ```
170 pub fn decode<T>(&self) -> Result<Option<T>, MaxMindDbError>
171 where
172 T: Deserialize<'a>,
173 {
174 let Some(offset) = self.data_offset else {
175 return Ok(None);
176 };
177
178 let buf = &self.reader.buf.as_ref()[self.reader.pointer_base..];
179 let mut decoder = super::decoder::Decoder::new(buf, offset);
180 T::deserialize(&mut decoder).map(Some)
181 }
182
183 /// Decodes a value at a specific path within the record.
184 ///
185 /// Returns:
186 /// - `Ok(Some(T))` if the path exists and was successfully decoded
187 /// - `Ok(None)` if the path doesn't exist (key missing, index out of bounds)
188 /// - `Err(...)` if there's a type mismatch during navigation (e.g., `Key` on an array)
189 ///
190 /// If `has_data() == false`, returns `Ok(None)`.
191 ///
192 /// # Path Elements
193 ///
194 /// - `PathElement::Key("name")` - Navigate into a map by key
195 /// - `PathElement::Index(0)` - Navigate into an array by index (0 = first element)
196 /// - `PathElement::IndexFromEnd(0)` - Navigate from the end (0 = last element)
197 ///
198 /// # Example
199 ///
200 /// ```
201 /// use maxminddb::{Reader, PathElement};
202 /// use std::net::IpAddr;
203 ///
204 /// let reader = Reader::open_readfile("test-data/test-data/GeoIP2-City-Test.mmdb").unwrap();
205 /// let ip: IpAddr = "89.160.20.128".parse().unwrap();
206 ///
207 /// let result = reader.lookup(ip).unwrap();
208 ///
209 /// // Navigate to country.iso_code
210 /// let iso_code: Option<String> = result.decode_path(&[
211 /// PathElement::Key("country"),
212 /// PathElement::Key("iso_code"),
213 /// ]).unwrap();
214 ///
215 /// // Navigate to subdivisions[0].names.en
216 /// let subdiv_name: Option<String> = result.decode_path(&[
217 /// PathElement::Key("subdivisions"),
218 /// PathElement::Index(0),
219 /// PathElement::Key("names"),
220 /// PathElement::Key("en"),
221 /// ]).unwrap();
222 /// ```
223 pub fn decode_path<T>(&self, path: &[PathElement<'_>]) -> Result<Option<T>, MaxMindDbError>
224 where
225 T: Deserialize<'a>,
226 {
227 let Some(offset) = self.data_offset else {
228 return Ok(None);
229 };
230
231 let buf = &self.reader.buf.as_ref()[self.reader.pointer_base..];
232 let mut decoder = super::decoder::Decoder::new(buf, offset);
233
234 // Navigate through the path, tracking position for error context
235 for (i, element) in path.iter().enumerate() {
236 // Closure to add path context to errors during navigation.
237 // Shows path up to and including the current element where the error occurred.
238 let with_path = |e| add_path_context(e, &path[..=i]);
239
240 match *element {
241 PathElement::Key(key) => {
242 let (_, type_num) = decoder.peek_type().map_err(with_path)?;
243 if type_num != TYPE_MAP {
244 return Err(MaxMindDbError::decoding_at_path(
245 format!("expected map for Key(\"{key}\"), got type {type_num}"),
246 decoder.offset(),
247 render_path(&path[..=i]),
248 ));
249 }
250
251 // Consume the map header and get size
252 let size = decoder.consume_map_header().map_err(with_path)?;
253
254 let mut found = false;
255 for _ in 0..size {
256 let k = decoder.read_string().map_err(with_path)?;
257 if k == key {
258 found = true;
259 break;
260 } else {
261 decoder.skip_value().map_err(with_path)?;
262 }
263 }
264
265 if !found {
266 return Ok(None);
267 }
268 }
269 PathElement::Index(idx) => {
270 let (_, type_num) = decoder.peek_type().map_err(with_path)?;
271 if type_num != TYPE_ARRAY {
272 return Err(MaxMindDbError::decoding_at_path(
273 format!("expected array for Index({idx}), got type {type_num}"),
274 decoder.offset(),
275 render_path(&path[..=i]),
276 ));
277 }
278
279 // Consume the array header and get size
280 let size = decoder.consume_array_header().map_err(with_path)?;
281
282 if idx >= size {
283 return Ok(None); // Out of bounds
284 }
285
286 // Skip to the target index
287 for _ in 0..idx {
288 decoder.skip_value().map_err(with_path)?;
289 }
290 }
291 PathElement::IndexFromEnd(idx) => {
292 let (_, type_num) = decoder.peek_type().map_err(with_path)?;
293 if type_num != TYPE_ARRAY {
294 return Err(MaxMindDbError::decoding_at_path(
295 format!("expected array for IndexFromEnd({idx}), got type {type_num}"),
296 decoder.offset(),
297 render_path(&path[..=i]),
298 ));
299 }
300
301 // Consume the array header and get size
302 let size = decoder.consume_array_header().map_err(with_path)?;
303
304 if idx >= size {
305 return Ok(None); // Out of bounds
306 }
307
308 let actual_idx = size - 1 - idx;
309
310 // Skip to the target index
311 for _ in 0..actual_idx {
312 decoder.skip_value().map_err(with_path)?;
313 }
314 }
315 }
316 }
317
318 // Decode the value at the current position
319 T::deserialize(&mut decoder)
320 .map(Some)
321 .map_err(|e| add_path_context(e, path))
322 }
323}
324
325/// Adds path context to a Decoding error if it doesn't already have one.
326fn add_path_context(err: MaxMindDbError, path: &[PathElement<'_>]) -> MaxMindDbError {
327 match err {
328 MaxMindDbError::Decoding {
329 message,
330 offset,
331 path: None,
332 } => MaxMindDbError::Decoding {
333 message,
334 offset,
335 path: Some(render_path(path)),
336 },
337 _ => err,
338 }
339}
340
341/// Renders path elements as a JSON-pointer-like string (e.g., "/city/names/0").
342fn render_path(path: &[PathElement<'_>]) -> String {
343 use std::fmt::Write;
344 let mut s = String::new();
345 for elem in path {
346 s.push('/');
347 match elem {
348 PathElement::Key(k) => s.push_str(k),
349 PathElement::Index(i) => write!(s, "{i}").unwrap(),
350 PathElement::IndexFromEnd(i) => write!(s, "{}", -((*i as isize) + 1)).unwrap(),
351 }
352 }
353 s
354}
355
356/// A path element for navigating into nested data structures.
357///
358/// Used with [`LookupResult::decode_path()`] to selectively decode
359/// specific fields without parsing the entire record.
360///
361/// # Creating Path Elements
362///
363/// You can create path elements directly or use the [`path!`](crate::path) macro
364/// for a more convenient syntax:
365///
366/// ```
367/// use maxminddb::{path, PathElement};
368///
369/// // Direct construction
370/// let path = [PathElement::Key("country"), PathElement::Key("iso_code")];
371///
372/// // Using the macro - string literals become Keys, integers become Indexes
373/// let path = path!["country", "iso_code"];
374/// let path = path!["subdivisions", 0, "names"]; // Mixed keys and indexes
375/// let path = path!["array", -1]; // Negative indexes count from the end
376/// ```
377#[derive(Debug, Clone, PartialEq, Eq)]
378pub enum PathElement<'a> {
379 /// Navigate into a map by key.
380 Key(&'a str),
381 /// Navigate into an array by index (0-based from the start).
382 ///
383 /// - `Index(0)` - first element
384 /// - `Index(1)` - second element
385 Index(usize),
386 /// Navigate into an array by index from the end.
387 ///
388 /// - `IndexFromEnd(0)` - last element
389 /// - `IndexFromEnd(1)` - second-to-last element
390 IndexFromEnd(usize),
391}
392
393impl<'a> From<&'a str> for PathElement<'a> {
394 fn from(s: &'a str) -> Self {
395 PathElement::Key(s)
396 }
397}
398
399impl From<i32> for PathElement<'_> {
400 /// Converts an integer to a path element.
401 ///
402 /// - Non-negative values become `Index(n)`
403 /// - Negative values become `IndexFromEnd(-n - 1)`, so `-1` is the last element
404 fn from(n: i32) -> Self {
405 if n >= 0 {
406 PathElement::Index(n as usize)
407 } else {
408 PathElement::IndexFromEnd((-n - 1) as usize)
409 }
410 }
411}
412
413impl From<usize> for PathElement<'_> {
414 fn from(n: usize) -> Self {
415 PathElement::Index(n)
416 }
417}
418
419impl From<isize> for PathElement<'_> {
420 /// Converts a signed integer to a path element.
421 ///
422 /// - Non-negative values become `Index(n)`
423 /// - Negative values become `IndexFromEnd(-n - 1)`, so `-1` is the last element
424 fn from(n: isize) -> Self {
425 if n >= 0 {
426 PathElement::Index(n as usize)
427 } else {
428 PathElement::IndexFromEnd((-n - 1) as usize)
429 }
430 }
431}
432
433/// Creates a path for use with [`LookupResult::decode_path()`](crate::LookupResult::decode_path).
434///
435/// This macro provides a convenient way to construct paths with mixed string keys
436/// and integer indexes.
437///
438/// # Syntax
439///
440/// - String literals become [`PathElement::Key`]
441/// - Non-negative integers become [`PathElement::Index`]
442/// - Negative integers become [`PathElement::IndexFromEnd`] (e.g., `-1` is the last element)
443///
444/// # Examples
445///
446/// ```
447/// use maxminddb::{Reader, path};
448/// use std::net::IpAddr;
449///
450/// let reader = Reader::open_readfile("test-data/test-data/GeoIP2-City-Test.mmdb").unwrap();
451/// let ip: IpAddr = "89.160.20.128".parse().unwrap();
452/// let result = reader.lookup(ip).unwrap();
453///
454/// // Navigate to country.iso_code
455/// let iso_code: Option<String> = result.decode_path(&path!["country", "iso_code"]).unwrap();
456///
457/// // Navigate to subdivisions[0].names.en
458/// let subdiv: Option<String> = result.decode_path(&path!["subdivisions", 0, "names", "en"]).unwrap();
459/// ```
460///
461/// ```
462/// use maxminddb::{Reader, path};
463/// use std::net::IpAddr;
464///
465/// let reader = Reader::open_readfile("test-data/test-data/MaxMind-DB-test-decoder.mmdb").unwrap();
466/// let ip: IpAddr = "::1.1.1.0".parse().unwrap();
467/// let result = reader.lookup(ip).unwrap();
468///
469/// // Access the last element of an array
470/// let last: Option<u32> = result.decode_path(&path!["array", -1]).unwrap();
471/// assert_eq!(last, Some(3));
472///
473/// // Access the second-to-last element
474/// let second_to_last: Option<u32> = result.decode_path(&path!["array", -2]).unwrap();
475/// assert_eq!(second_to_last, Some(2));
476/// ```
477#[macro_export]
478macro_rules! path {
479 ($($elem:expr),* $(,)?) => {
480 [$($crate::PathElement::from($elem)),*]
481 };
482}
483
484/// Masks an IP address to its network address given a prefix length.
485fn mask_ip(ip: IpAddr, prefix: u8) -> IpAddr {
486 match ip {
487 IpAddr::V4(v4) => {
488 if prefix >= 32 {
489 IpAddr::V4(v4)
490 } else {
491 let int: u32 = v4.into();
492 let mask = if prefix == 0 {
493 0
494 } else {
495 !0u32 << (32 - prefix)
496 };
497 IpAddr::V4((int & mask).into())
498 }
499 }
500 IpAddr::V6(v6) => {
501 if prefix >= 128 {
502 IpAddr::V6(v6)
503 } else {
504 let int: u128 = v6.into();
505 let mask = if prefix == 0 {
506 0
507 } else {
508 !0u128 << (128 - prefix)
509 };
510 IpAddr::V6((int & mask).into())
511 }
512 }
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 #[test]
521 fn test_mask_ipv4() {
522 let ip: IpAddr = "192.168.1.100".parse().unwrap();
523 assert_eq!(mask_ip(ip, 24), "192.168.1.0".parse::<IpAddr>().unwrap());
524 assert_eq!(mask_ip(ip, 16), "192.168.0.0".parse::<IpAddr>().unwrap());
525 assert_eq!(mask_ip(ip, 32), "192.168.1.100".parse::<IpAddr>().unwrap());
526 assert_eq!(mask_ip(ip, 0), "0.0.0.0".parse::<IpAddr>().unwrap());
527 }
528
529 #[test]
530 fn test_mask_ipv6() {
531 let ip: IpAddr = "2001:db8:85a3::8a2e:370:7334".parse().unwrap();
532 assert_eq!(
533 mask_ip(ip, 64),
534 "2001:db8:85a3::".parse::<IpAddr>().unwrap()
535 );
536 assert_eq!(mask_ip(ip, 32), "2001:db8::".parse::<IpAddr>().unwrap());
537 }
538
539 #[test]
540 fn test_path_element_debug() {
541 assert_eq!(format!("{:?}", PathElement::Key("test")), "Key(\"test\")");
542 assert_eq!(format!("{:?}", PathElement::Index(5)), "Index(5)");
543 assert_eq!(
544 format!("{:?}", PathElement::IndexFromEnd(0)),
545 "IndexFromEnd(0)"
546 );
547 }
548
549 #[test]
550 fn test_path_element_from_str() {
551 let elem: PathElement = "key".into();
552 assert_eq!(elem, PathElement::Key("key"));
553 }
554
555 #[test]
556 fn test_path_element_from_i32() {
557 // Positive values become Index
558 let elem: PathElement = PathElement::from(0i32);
559 assert_eq!(elem, PathElement::Index(0));
560
561 let elem: PathElement = PathElement::from(5i32);
562 assert_eq!(elem, PathElement::Index(5));
563
564 // Negative values become IndexFromEnd
565 // -1 → IndexFromEnd(0) (last element)
566 let elem: PathElement = PathElement::from(-1i32);
567 assert_eq!(elem, PathElement::IndexFromEnd(0));
568
569 // -2 → IndexFromEnd(1) (second-to-last)
570 let elem: PathElement = PathElement::from(-2i32);
571 assert_eq!(elem, PathElement::IndexFromEnd(1));
572
573 // -3 → IndexFromEnd(2)
574 let elem: PathElement = PathElement::from(-3i32);
575 assert_eq!(elem, PathElement::IndexFromEnd(2));
576 }
577
578 #[test]
579 fn test_path_element_from_usize() {
580 let elem: PathElement = PathElement::from(0usize);
581 assert_eq!(elem, PathElement::Index(0));
582
583 let elem: PathElement = PathElement::from(42usize);
584 assert_eq!(elem, PathElement::Index(42));
585 }
586
587 #[test]
588 fn test_path_element_from_isize() {
589 let elem: PathElement = PathElement::from(0isize);
590 assert_eq!(elem, PathElement::Index(0));
591
592 let elem: PathElement = PathElement::from(-1isize);
593 assert_eq!(elem, PathElement::IndexFromEnd(0));
594 }
595
596 #[test]
597 fn test_path_macro_keys_only() {
598 let p = path!["country", "iso_code"];
599 assert_eq!(p.len(), 2);
600 assert_eq!(p[0], PathElement::Key("country"));
601 assert_eq!(p[1], PathElement::Key("iso_code"));
602 }
603
604 #[test]
605 fn test_path_macro_mixed() {
606 let p = path!["subdivisions", 0, "names", "en"];
607 assert_eq!(p.len(), 4);
608 assert_eq!(p[0], PathElement::Key("subdivisions"));
609 assert_eq!(p[1], PathElement::Index(0));
610 assert_eq!(p[2], PathElement::Key("names"));
611 assert_eq!(p[3], PathElement::Key("en"));
612 }
613
614 #[test]
615 fn test_path_macro_negative_indexes() {
616 let p = path!["array", -1];
617 assert_eq!(p.len(), 2);
618 assert_eq!(p[0], PathElement::Key("array"));
619 assert_eq!(p[1], PathElement::IndexFromEnd(0)); // last element
620
621 let p = path!["data", -2, "value"];
622 assert_eq!(p[1], PathElement::IndexFromEnd(1)); // second-to-last
623 }
624
625 #[test]
626 fn test_path_macro_trailing_comma() {
627 let p = path!["a", "b",];
628 assert_eq!(p.len(), 2);
629 }
630
631 #[test]
632 fn test_path_macro_empty() {
633 let p: [PathElement; 0] = path![];
634 assert_eq!(p.len(), 0);
635 }
636
637 #[test]
638 fn test_render_path() {
639 assert_eq!(render_path(&[]), "");
640 assert_eq!(render_path(&[PathElement::Key("city")]), "/city");
641 assert_eq!(
642 render_path(&[PathElement::Key("city"), PathElement::Key("names")]),
643 "/city/names"
644 );
645 assert_eq!(
646 render_path(&[PathElement::Key("arr"), PathElement::Index(0)]),
647 "/arr/0"
648 );
649 assert_eq!(
650 render_path(&[PathElement::Key("arr"), PathElement::Index(42)]),
651 "/arr/42"
652 );
653 // IndexFromEnd(0) = last = -1, IndexFromEnd(1) = second-to-last = -2
654 assert_eq!(
655 render_path(&[PathElement::Key("arr"), PathElement::IndexFromEnd(0)]),
656 "/arr/-1"
657 );
658 assert_eq!(
659 render_path(&[PathElement::Key("arr"), PathElement::IndexFromEnd(1)]),
660 "/arr/-2"
661 );
662 }
663
664 #[test]
665 fn test_decode_path_error_includes_path() {
666 use crate::Reader;
667
668 let reader = Reader::open_readfile("test-data/test-data/GeoIP2-City-Test.mmdb").unwrap();
669 let ip: IpAddr = "89.160.20.128".parse().unwrap();
670 let result = reader.lookup(ip).unwrap();
671
672 // Try to navigate with Index on a map (root is a map, not array)
673 let err = result
674 .decode_path::<String>(&[PathElement::Index(0)])
675 .unwrap_err();
676 let err_str = err.to_string();
677 assert!(
678 err_str.contains("path: /0"),
679 "error should include path context: {err_str}"
680 );
681 assert!(
682 err_str.contains("expected array"),
683 "error should mention expected type: {err_str}"
684 );
685
686 // Try to navigate deeper and fail at second element
687 let err = result
688 .decode_path::<String>(&[PathElement::Key("city"), PathElement::Index(0)])
689 .unwrap_err();
690 let err_str = err.to_string();
691 assert!(
692 err_str.contains("path: /city/0"),
693 "error should include full path to failure: {err_str}"
694 );
695 }
696}