use thiserror::Error;
use crate::geometry::Point;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Line {
Blank,
Comment,
Point {
point: Point,
expect: Option<String>,
},
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum PointError {
#[error("{input:?} is not X,Y[,label] — it has no comma")]
NoComma { input: String },
#[error("{value:?} in {input:?} is not a whole number of pixels")]
NotANumber { value: String, input: String },
#[error(
"{input:?} ends in a comma with no label — drop the comma, or name \
the region the point should land in"
)]
EmptyLabel { input: String },
}
pub fn parse_line(text: &str) -> Result<Line, PointError> {
let trimmed = text.trim();
if trimmed.is_empty() {
return Ok(Line::Blank);
}
if trimmed.starts_with('#') {
return Ok(Line::Comment);
}
let mut parts = trimmed.splitn(3, ',');
let (Some(x), Some(y)) = (parts.next(), parts.next()) else {
return Err(PointError::NoComma {
input: trimmed.to_string(),
});
};
let point = Point::new(coord(x, trimmed)?, coord(y, trimmed)?);
let expect = match parts.next() {
None => None,
Some(label) => {
let label = label.trim();
if label.is_empty() {
return Err(PointError::EmptyLabel {
input: trimmed.to_string(),
});
}
Some(label.to_string())
}
};
Ok(Line::Point { point, expect })
}
fn coord(value: &str, input: &str) -> Result<i32, PointError> {
value
.trim()
.parse::<i32>()
.map_err(|_| PointError::NotANumber {
value: value.trim().to_string(),
input: input.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn point(text: &str) -> Line {
parse_line(text).expect("a valid line")
}
#[test]
fn a_bare_pair_is_a_point_with_no_expectation() {
assert_eq!(
point("812,440"),
Line::Point {
point: Point::new(812, 440),
expect: None,
}
);
}
#[test]
fn a_third_field_is_the_expected_label() {
assert_eq!(
point("812,440,submit"),
Line::Point {
point: Point::new(812, 440),
expect: Some("submit".into()),
}
);
}
#[test]
fn surrounding_whitespace_is_ignored_everywhere() {
assert_eq!(point(" 812 , 440 , submit "), point("812,440,submit"));
}
#[test]
fn coordinates_may_be_negative() {
assert_eq!(
point("-1920,-40"),
Line::Point {
point: Point::new(-1920, -40),
expect: None,
}
);
}
#[test]
fn a_label_may_contain_commas() {
assert_eq!(
point("1,2,row 3, column 4"),
Line::Point {
point: Point::new(1, 2),
expect: Some("row 3, column 4".into()),
}
);
}
#[test]
fn blanks_and_comments_are_part_of_the_grammar() {
assert_eq!(parse_line("").unwrap(), Line::Blank);
assert_eq!(parse_line(" \t ").unwrap(), Line::Blank);
assert_eq!(parse_line("# the login flow").unwrap(), Line::Comment);
assert_eq!(parse_line(" # indented").unwrap(), Line::Comment);
}
#[test]
fn a_line_with_no_comma_names_itself() {
assert_eq!(
parse_line("812").unwrap_err(),
PointError::NoComma {
input: "812".into()
}
);
}
#[test]
fn a_non_numeric_coordinate_names_the_offending_field() {
let err = parse_line("12,x").unwrap_err();
assert_eq!(
err,
PointError::NotANumber {
value: "x".into(),
input: "12,x".into(),
}
);
let text = err.to_string();
assert!(text.contains("\"x\"") && text.contains("\"12,x\""));
}
#[test]
fn decimals_are_refused_rather_than_rounded() {
assert!(matches!(
parse_line("12.5,40").unwrap_err(),
PointError::NotANumber { .. }
));
}
#[test]
fn a_trailing_comma_is_an_error_not_an_empty_label() {
assert_eq!(
parse_line("1,2,").unwrap_err(),
PointError::EmptyLabel {
input: "1,2,".into()
}
);
assert!(matches!(
parse_line("1,2, ").unwrap_err(),
PointError::EmptyLabel { .. }
));
}
#[test]
fn an_out_of_range_coordinate_is_refused() {
assert!(matches!(
parse_line("99999999999,0").unwrap_err(),
PointError::NotANumber { .. }
));
}
}