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                    let key_bytes = key.as_bytes();
256                    for _ in 0..size {
257                        let k = decoder.read_str_as_bytes().map_err(with_path)?;
258                        if k == key_bytes {
259                            found = true;
260                            break;
261                        } else {
262                            decoder.skip_value().map_err(with_path)?;
263                        }
264                    }
265
266                    if !found {
267                        return Ok(None);
268                    }
269                }
270                PathElement::Index(idx) => {
271                    let (_, type_num) = decoder.peek_type().map_err(with_path)?;
272                    if type_num != TYPE_ARRAY {
273                        return Err(MaxMindDbError::decoding_at_path(
274                            format!("expected array for Index({idx}), got type {type_num}"),
275                            decoder.offset(),
276                            render_path(&path[..=i]),
277                        ));
278                    }
279
280                    // Consume the array header and get size
281                    let size = decoder.consume_array_header().map_err(with_path)?;
282
283                    if idx >= size {
284                        return Ok(None); // Out of bounds
285                    }
286
287                    // Skip to the target index
288                    for _ in 0..idx {
289                        decoder.skip_value().map_err(with_path)?;
290                    }
291                }
292                PathElement::IndexFromEnd(idx) => {
293                    let (_, type_num) = decoder.peek_type().map_err(with_path)?;
294                    if type_num != TYPE_ARRAY {
295                        return Err(MaxMindDbError::decoding_at_path(
296                            format!("expected array for IndexFromEnd({idx}), got type {type_num}"),
297                            decoder.offset(),
298                            render_path(&path[..=i]),
299                        ));
300                    }
301
302                    // Consume the array header and get size
303                    let size = decoder.consume_array_header().map_err(with_path)?;
304
305                    if idx >= size {
306                        return Ok(None); // Out of bounds
307                    }
308
309                    let actual_idx = size - 1 - idx;
310
311                    // Skip to the target index
312                    for _ in 0..actual_idx {
313                        decoder.skip_value().map_err(with_path)?;
314                    }
315                }
316            }
317        }
318
319        // Decode the value at the current position
320        T::deserialize(&mut decoder)
321            .map(Some)
322            .map_err(|e| add_path_context(e, path))
323    }
324}
325
326/// Adds path context to a Decoding error if it doesn't already have one.
327fn add_path_context(err: MaxMindDbError, path: &[PathElement<'_>]) -> MaxMindDbError {
328    match err {
329        MaxMindDbError::Decoding {
330            message,
331            offset,
332            path: None,
333        } => MaxMindDbError::Decoding {
334            message,
335            offset,
336            path: Some(render_path(path)),
337        },
338        _ => err,
339    }
340}
341
342/// Renders path elements as a JSON-pointer-like string (e.g., "/city/names/0").
343fn render_path(path: &[PathElement<'_>]) -> String {
344    use std::fmt::Write;
345    let mut s = String::new();
346    for elem in path {
347        s.push('/');
348        match elem {
349            PathElement::Key(k) => s.push_str(k),
350            PathElement::Index(i) => write!(s, "{i}").unwrap(),
351            PathElement::IndexFromEnd(i) => write!(s, "{}", -((*i as isize) + 1)).unwrap(),
352        }
353    }
354    s
355}
356
357/// A path element for navigating into nested data structures.
358///
359/// Used with [`LookupResult::decode_path()`] to selectively decode
360/// specific fields without parsing the entire record.
361///
362/// # Creating Path Elements
363///
364/// You can create path elements directly or use the [`path!`](crate::path) macro
365/// for a more convenient syntax:
366///
367/// ```
368/// use maxminddb::{path, PathElement};
369///
370/// // Direct construction
371/// let path = [PathElement::Key("country"), PathElement::Key("iso_code")];
372///
373/// // Using the macro - string literals become Keys, integers become Indexes
374/// let path = path!["country", "iso_code"];
375/// let path = path!["subdivisions", 0, "names"];  // Mixed keys and indexes
376/// let path = path!["array", -1];  // Negative indexes count from the end
377/// ```
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub enum PathElement<'a> {
380    /// Navigate into a map by key.
381    Key(&'a str),
382    /// Navigate into an array by index (0-based from the start).
383    ///
384    /// - `Index(0)` - first element
385    /// - `Index(1)` - second element
386    Index(usize),
387    /// Navigate into an array by index from the end.
388    ///
389    /// - `IndexFromEnd(0)` - last element
390    /// - `IndexFromEnd(1)` - second-to-last element
391    IndexFromEnd(usize),
392}
393
394impl<'a> From<&'a str> for PathElement<'a> {
395    fn from(s: &'a str) -> Self {
396        PathElement::Key(s)
397    }
398}
399
400impl From<i32> for PathElement<'_> {
401    /// Converts an integer to a path element.
402    ///
403    /// - Non-negative values become `Index(n)`
404    /// - Negative values become `IndexFromEnd(-n - 1)`, so `-1` is the last element
405    fn from(n: i32) -> Self {
406        if n >= 0 {
407            PathElement::Index(n as usize)
408        } else {
409            PathElement::IndexFromEnd((-n - 1) as usize)
410        }
411    }
412}
413
414impl From<usize> for PathElement<'_> {
415    fn from(n: usize) -> Self {
416        PathElement::Index(n)
417    }
418}
419
420impl From<isize> for PathElement<'_> {
421    /// Converts a signed integer to a path element.
422    ///
423    /// - Non-negative values become `Index(n)`
424    /// - Negative values become `IndexFromEnd(-n - 1)`, so `-1` is the last element
425    fn from(n: isize) -> Self {
426        if n >= 0 {
427            PathElement::Index(n as usize)
428        } else {
429            PathElement::IndexFromEnd((-n - 1) as usize)
430        }
431    }
432}
433
434/// Creates a path for use with [`LookupResult::decode_path()`](crate::LookupResult::decode_path).
435///
436/// This macro provides a convenient way to construct paths with mixed string keys
437/// and integer indexes.
438///
439/// # Syntax
440///
441/// - String literals become [`PathElement::Key`]
442/// - Non-negative integers become [`PathElement::Index`]
443/// - Negative integers become [`PathElement::IndexFromEnd`] (e.g., `-1` is the last element)
444///
445/// # Examples
446///
447/// ```
448/// use maxminddb::{Reader, path};
449/// use std::net::IpAddr;
450///
451/// let reader = Reader::open_readfile("test-data/test-data/GeoIP2-City-Test.mmdb").unwrap();
452/// let ip: IpAddr = "89.160.20.128".parse().unwrap();
453/// let result = reader.lookup(ip).unwrap();
454///
455/// // Navigate to country.iso_code
456/// let iso_code: Option<String> = result.decode_path(&path!["country", "iso_code"]).unwrap();
457///
458/// // Navigate to subdivisions[0].names.en
459/// let subdiv: Option<String> = result.decode_path(&path!["subdivisions", 0, "names", "en"]).unwrap();
460/// ```
461///
462/// ```
463/// use maxminddb::{Reader, path};
464/// use std::net::IpAddr;
465///
466/// let reader = Reader::open_readfile("test-data/test-data/MaxMind-DB-test-decoder.mmdb").unwrap();
467/// let ip: IpAddr = "::1.1.1.0".parse().unwrap();
468/// let result = reader.lookup(ip).unwrap();
469///
470/// // Access the last element of an array
471/// let last: Option<u32> = result.decode_path(&path!["array", -1]).unwrap();
472/// assert_eq!(last, Some(3));
473///
474/// // Access the second-to-last element
475/// let second_to_last: Option<u32> = result.decode_path(&path!["array", -2]).unwrap();
476/// assert_eq!(second_to_last, Some(2));
477/// ```
478#[macro_export]
479macro_rules! path {
480    ($($elem:expr),* $(,)?) => {
481        [$($crate::PathElement::from($elem)),*]
482    };
483}
484
485/// Masks an IP address to its network address given a prefix length.
486fn mask_ip(ip: IpAddr, prefix: u8) -> IpAddr {
487    match ip {
488        IpAddr::V4(v4) => {
489            if prefix >= 32 {
490                IpAddr::V4(v4)
491            } else {
492                let int: u32 = v4.into();
493                let mask = if prefix == 0 {
494                    0
495                } else {
496                    !0u32 << (32 - prefix)
497                };
498                IpAddr::V4((int & mask).into())
499            }
500        }
501        IpAddr::V6(v6) => {
502            if prefix >= 128 {
503                IpAddr::V6(v6)
504            } else {
505                let int: u128 = v6.into();
506                let mask = if prefix == 0 {
507                    0
508                } else {
509                    !0u128 << (128 - prefix)
510                };
511                IpAddr::V6((int & mask).into())
512            }
513        }
514    }
515}
516
517#[cfg(test)]
518mod tests {
519    use super::*;
520
521    #[test]
522    fn test_mask_ipv4() {
523        let ip: IpAddr = "192.168.1.100".parse().unwrap();
524        assert_eq!(mask_ip(ip, 24), "192.168.1.0".parse::<IpAddr>().unwrap());
525        assert_eq!(mask_ip(ip, 16), "192.168.0.0".parse::<IpAddr>().unwrap());
526        assert_eq!(mask_ip(ip, 32), "192.168.1.100".parse::<IpAddr>().unwrap());
527        assert_eq!(mask_ip(ip, 0), "0.0.0.0".parse::<IpAddr>().unwrap());
528    }
529
530    #[test]
531    fn test_mask_ipv6() {
532        let ip: IpAddr = "2001:db8:85a3::8a2e:370:7334".parse().unwrap();
533        assert_eq!(
534            mask_ip(ip, 64),
535            "2001:db8:85a3::".parse::<IpAddr>().unwrap()
536        );
537        assert_eq!(mask_ip(ip, 32), "2001:db8::".parse::<IpAddr>().unwrap());
538    }
539
540    #[test]
541    fn test_path_element_debug() {
542        assert_eq!(format!("{:?}", PathElement::Key("test")), "Key(\"test\")");
543        assert_eq!(format!("{:?}", PathElement::Index(5)), "Index(5)");
544        assert_eq!(
545            format!("{:?}", PathElement::IndexFromEnd(0)),
546            "IndexFromEnd(0)"
547        );
548    }
549
550    #[test]
551    fn test_path_element_from_str() {
552        let elem: PathElement = "key".into();
553        assert_eq!(elem, PathElement::Key("key"));
554    }
555
556    #[test]
557    fn test_path_element_from_i32() {
558        // Positive values become Index
559        let elem: PathElement = PathElement::from(0i32);
560        assert_eq!(elem, PathElement::Index(0));
561
562        let elem: PathElement = PathElement::from(5i32);
563        assert_eq!(elem, PathElement::Index(5));
564
565        // Negative values become IndexFromEnd
566        // -1 → IndexFromEnd(0) (last element)
567        let elem: PathElement = PathElement::from(-1i32);
568        assert_eq!(elem, PathElement::IndexFromEnd(0));
569
570        // -2 → IndexFromEnd(1) (second-to-last)
571        let elem: PathElement = PathElement::from(-2i32);
572        assert_eq!(elem, PathElement::IndexFromEnd(1));
573
574        // -3 → IndexFromEnd(2)
575        let elem: PathElement = PathElement::from(-3i32);
576        assert_eq!(elem, PathElement::IndexFromEnd(2));
577    }
578
579    #[test]
580    fn test_path_element_from_usize() {
581        let elem: PathElement = PathElement::from(0usize);
582        assert_eq!(elem, PathElement::Index(0));
583
584        let elem: PathElement = PathElement::from(42usize);
585        assert_eq!(elem, PathElement::Index(42));
586    }
587
588    #[test]
589    fn test_path_element_from_isize() {
590        let elem: PathElement = PathElement::from(0isize);
591        assert_eq!(elem, PathElement::Index(0));
592
593        let elem: PathElement = PathElement::from(-1isize);
594        assert_eq!(elem, PathElement::IndexFromEnd(0));
595    }
596
597    #[test]
598    fn test_path_macro_keys_only() {
599        let p = path!["country", "iso_code"];
600        assert_eq!(p.len(), 2);
601        assert_eq!(p[0], PathElement::Key("country"));
602        assert_eq!(p[1], PathElement::Key("iso_code"));
603    }
604
605    #[test]
606    fn test_path_macro_mixed() {
607        let p = path!["subdivisions", 0, "names", "en"];
608        assert_eq!(p.len(), 4);
609        assert_eq!(p[0], PathElement::Key("subdivisions"));
610        assert_eq!(p[1], PathElement::Index(0));
611        assert_eq!(p[2], PathElement::Key("names"));
612        assert_eq!(p[3], PathElement::Key("en"));
613    }
614
615    #[test]
616    fn test_path_macro_negative_indexes() {
617        let p = path!["array", -1];
618        assert_eq!(p.len(), 2);
619        assert_eq!(p[0], PathElement::Key("array"));
620        assert_eq!(p[1], PathElement::IndexFromEnd(0)); // last element
621
622        let p = path!["data", -2, "value"];
623        assert_eq!(p[1], PathElement::IndexFromEnd(1)); // second-to-last
624    }
625
626    #[test]
627    fn test_path_macro_trailing_comma() {
628        let p = path!["a", "b",];
629        assert_eq!(p.len(), 2);
630    }
631
632    #[test]
633    fn test_path_macro_empty() {
634        let p: [PathElement; 0] = path![];
635        assert_eq!(p.len(), 0);
636    }
637
638    #[test]
639    fn test_render_path() {
640        assert_eq!(render_path(&[]), "");
641        assert_eq!(render_path(&[PathElement::Key("city")]), "/city");
642        assert_eq!(
643            render_path(&[PathElement::Key("city"), PathElement::Key("names")]),
644            "/city/names"
645        );
646        assert_eq!(
647            render_path(&[PathElement::Key("arr"), PathElement::Index(0)]),
648            "/arr/0"
649        );
650        assert_eq!(
651            render_path(&[PathElement::Key("arr"), PathElement::Index(42)]),
652            "/arr/42"
653        );
654        // IndexFromEnd(0) = last = -1, IndexFromEnd(1) = second-to-last = -2
655        assert_eq!(
656            render_path(&[PathElement::Key("arr"), PathElement::IndexFromEnd(0)]),
657            "/arr/-1"
658        );
659        assert_eq!(
660            render_path(&[PathElement::Key("arr"), PathElement::IndexFromEnd(1)]),
661            "/arr/-2"
662        );
663    }
664
665    #[test]
666    fn test_decode_path_error_includes_path() {
667        use crate::Reader;
668
669        let reader = Reader::open_readfile("test-data/test-data/GeoIP2-City-Test.mmdb").unwrap();
670        let ip: IpAddr = "89.160.20.128".parse().unwrap();
671        let result = reader.lookup(ip).unwrap();
672
673        // Try to navigate with Index on a map (root is a map, not array)
674        let err = result
675            .decode_path::<String>(&[PathElement::Index(0)])
676            .unwrap_err();
677        let err_str = err.to_string();
678        assert!(
679            err_str.contains("path: /0"),
680            "error should include path context: {err_str}"
681        );
682        assert!(
683            err_str.contains("expected array"),
684            "error should mention expected type: {err_str}"
685        );
686
687        // Try to navigate deeper and fail at second element
688        let err = result
689            .decode_path::<String>(&[PathElement::Key("city"), PathElement::Index(0)])
690            .unwrap_err();
691        let err_str = err.to_string();
692        assert!(
693            err_str.contains("path: /city/0"),
694            "error should include full path to failure: {err_str}"
695        );
696    }
697}