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