1use thiserror::Error;
17
18use crate::geometry::Point;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub enum Line {
23 Blank,
25 Comment,
27 Point {
32 point: Point,
33 expect: Option<String>,
34 },
35}
36
37#[derive(Debug, Error, PartialEq, Eq)]
38pub enum PointError {
39 #[error("{input:?} is not X,Y[,label] — it has no comma")]
40 NoComma { input: String },
41 #[error("{value:?} in {input:?} is not a whole number of pixels")]
42 NotANumber { value: String, input: String },
43 #[error(
44 "{input:?} ends in a comma with no label — drop the comma, or name \
45 the region the point should land in"
46 )]
47 EmptyLabel { input: String },
48}
49
50pub fn parse_line(text: &str) -> Result<Line, PointError> {
57 let trimmed = text.trim();
58 if trimmed.is_empty() {
59 return Ok(Line::Blank);
60 }
61 if trimmed.starts_with('#') {
62 return Ok(Line::Comment);
63 }
64
65 let mut parts = trimmed.splitn(3, ',');
66 let (Some(x), Some(y)) = (parts.next(), parts.next()) else {
67 return Err(PointError::NoComma {
68 input: trimmed.to_string(),
69 });
70 };
71 let point = Point::new(coord(x, trimmed)?, coord(y, trimmed)?);
72
73 let expect = match parts.next() {
74 None => None,
75 Some(label) => {
76 let label = label.trim();
77 if label.is_empty() {
78 return Err(PointError::EmptyLabel {
79 input: trimmed.to_string(),
80 });
81 }
82 Some(label.to_string())
83 }
84 };
85 Ok(Line::Point { point, expect })
86}
87
88fn coord(value: &str, input: &str) -> Result<i32, PointError> {
89 value
90 .trim()
91 .parse::<i32>()
92 .map_err(|_| PointError::NotANumber {
93 value: value.trim().to_string(),
94 input: input.to_string(),
95 })
96}
97
98#[cfg(test)]
99mod tests {
100 use super::*;
101
102 fn point(text: &str) -> Line {
103 parse_line(text).expect("a valid line")
104 }
105
106 #[test]
107 fn a_bare_pair_is_a_point_with_no_expectation() {
108 assert_eq!(
109 point("812,440"),
110 Line::Point {
111 point: Point::new(812, 440),
112 expect: None,
113 }
114 );
115 }
116
117 #[test]
118 fn a_third_field_is_the_expected_label() {
119 assert_eq!(
120 point("812,440,submit"),
121 Line::Point {
122 point: Point::new(812, 440),
123 expect: Some("submit".into()),
124 }
125 );
126 }
127
128 #[test]
129 fn surrounding_whitespace_is_ignored_everywhere() {
130 assert_eq!(point(" 812 , 440 , submit "), point("812,440,submit"));
131 }
132
133 #[test]
134 fn coordinates_may_be_negative() {
135 assert_eq!(
138 point("-1920,-40"),
139 Line::Point {
140 point: Point::new(-1920, -40),
141 expect: None,
142 }
143 );
144 }
145
146 #[test]
147 fn a_label_may_contain_commas() {
148 assert_eq!(
151 point("1,2,row 3, column 4"),
152 Line::Point {
153 point: Point::new(1, 2),
154 expect: Some("row 3, column 4".into()),
155 }
156 );
157 }
158
159 #[test]
160 fn blanks_and_comments_are_part_of_the_grammar() {
161 assert_eq!(parse_line("").unwrap(), Line::Blank);
162 assert_eq!(parse_line(" \t ").unwrap(), Line::Blank);
163 assert_eq!(parse_line("# the login flow").unwrap(), Line::Comment);
164 assert_eq!(parse_line(" # indented").unwrap(), Line::Comment);
165 }
166
167 #[test]
168 fn a_line_with_no_comma_names_itself() {
169 assert_eq!(
170 parse_line("812").unwrap_err(),
171 PointError::NoComma {
172 input: "812".into()
173 }
174 );
175 }
176
177 #[test]
178 fn a_non_numeric_coordinate_names_the_offending_field() {
179 let err = parse_line("12,x").unwrap_err();
180 assert_eq!(
181 err,
182 PointError::NotANumber {
183 value: "x".into(),
184 input: "12,x".into(),
185 }
186 );
187 let text = err.to_string();
190 assert!(text.contains("\"x\"") && text.contains("\"12,x\""));
191 }
192
193 #[test]
194 fn decimals_are_refused_rather_than_rounded() {
195 assert!(matches!(
198 parse_line("12.5,40").unwrap_err(),
199 PointError::NotANumber { .. }
200 ));
201 }
202
203 #[test]
204 fn a_trailing_comma_is_an_error_not_an_empty_label() {
205 assert_eq!(
206 parse_line("1,2,").unwrap_err(),
207 PointError::EmptyLabel {
208 input: "1,2,".into()
209 }
210 );
211 assert!(matches!(
212 parse_line("1,2, ").unwrap_err(),
213 PointError::EmptyLabel { .. }
214 ));
215 }
216
217 #[test]
218 fn an_out_of_range_coordinate_is_refused() {
219 assert!(matches!(
220 parse_line("99999999999,0").unwrap_err(),
221 PointError::NotANumber { .. }
222 ));
223 }
224}