Skip to main content

plugx_input/
position.rs

1//! Source locations for deserialization errors.
2//!
3//! Built incrementally while decoding nested maps and lists. Display format:
4//! `filename:line:[key][index]…` (each part optional).
5
6use std::fmt;
7
8/// Path to the value that failed deserialization.
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct InputPath {
11    maybe_filename: Option<String>,
12    maybe_line_number: Option<usize>,
13    segment_list: Vec<PathSegment>,
14}
15
16/// One step in an [`InputPath`]: map key or list index.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum PathSegment {
19    /// Map key segment, rendered as `[key]`.
20    Key(String),
21    /// List index segment, rendered as `[index]`.
22    Index(usize),
23}
24
25impl InputPath {
26    /// Empty path (no filename, line, or segments).
27    pub fn root() -> Self {
28        Self {
29            maybe_filename: None,
30            maybe_line_number: None,
31            segment_list: Vec::with_capacity(4),
32        }
33    }
34
35    /// Attach a source filename (shown first in display output).
36    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
37        self.maybe_filename = Some(filename.into());
38        self
39    }
40
41    /// Attach a 1-based line number (after filename, before segments).
42    pub fn with_line_number(mut self, line_number: usize) -> Self {
43        self.maybe_line_number = Some(line_number);
44        self
45    }
46
47    /// Append a map-key segment.
48    pub fn with_key(mut self, key: impl Into<String>) -> Self {
49        self.segment_list.push(PathSegment::Key(key.into()));
50        self
51    }
52
53    /// Append a list-index segment.
54    pub fn with_index(mut self, index: usize) -> Self {
55        self.segment_list.push(PathSegment::Index(index));
56        self
57    }
58
59    /// Whether filename, line number, and segments are all unset.
60    pub fn is_empty(&self) -> bool {
61        self.maybe_filename.is_none()
62            && self.maybe_line_number.is_none()
63            && self.segment_list.is_empty()
64    }
65}
66
67impl fmt::Display for InputPath {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        let mut has_path = false;
70        if let Some(filename) = &self.maybe_filename {
71            write!(f, "{filename}")?;
72            has_path = true;
73        }
74        let mut has_line_number = false;
75        if let Some(line_number) = &self.maybe_line_number {
76            if has_path {
77                write!(f, ":{line_number}")?;
78            } else {
79                write!(f, "{line_number}")?;
80            }
81            has_line_number = true;
82        }
83        if has_line_number && !self.segment_list.is_empty() {
84            write!(f, ":")?;
85        }
86        for segment in &self.segment_list {
87            match segment {
88                PathSegment::Key(key) => write!(f, "[{key}]")?,
89                PathSegment::Index(index) => write!(f, "[{index}]")?,
90            }
91        }
92        Ok(())
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::InputPath;
99
100    #[test]
101    fn root_is_empty() {
102        assert!(InputPath::root().is_empty());
103        assert_eq!(InputPath::root().to_string(), "");
104    }
105
106    #[test]
107    fn segment_path_display() {
108        let path = InputPath::root()
109            .with_key("foo")
110            .with_index(0)
111            .with_key("bar");
112        assert!(!path.is_empty());
113        assert_eq!(path.to_string(), "[foo][0][bar]");
114    }
115
116    #[test]
117    fn filename_display() {
118        let path = InputPath::root().with_filename("config.toml");
119        assert!(!path.is_empty());
120        assert_eq!(path.to_string(), "config.toml");
121    }
122
123    #[test]
124    fn line_number_without_filename() {
125        let path = InputPath::root().with_line_number(12);
126        assert!(!path.is_empty());
127        assert_eq!(path.to_string(), "12");
128    }
129
130    #[test]
131    fn filename_and_line_number() {
132        let path = InputPath::root()
133            .with_filename("config.toml")
134            .with_line_number(42);
135        assert_eq!(path.to_string(), "config.toml:42");
136    }
137
138    #[test]
139    fn line_number_and_segments_use_colon_separator() {
140        let path = InputPath::root()
141            .with_line_number(3)
142            .with_key("items")
143            .with_index(1);
144        assert_eq!(path.to_string(), "3:[items][1]");
145    }
146
147    #[test]
148    fn filename_line_number_and_segments() {
149        let path = InputPath::root()
150            .with_filename("app.json")
151            .with_line_number(7)
152            .with_key("server")
153            .with_key("host");
154        assert_eq!(path.to_string(), "app.json:7:[server][host]");
155    }
156
157    #[test]
158    fn builders_do_not_mutate_cloned_path() {
159        let base = InputPath::root().with_key("root");
160        let child = base.clone().with_key("child");
161        assert_eq!(base.to_string(), "[root]");
162        assert_eq!(child.to_string(), "[root][child]");
163    }
164}