use crate::Error;
use crate::parser::common::strip_inline_comment;
use crate::pie::{PieChart, PieSlice};
pub fn parse(src: &str) -> Result<PieChart, Error> {
let mut chart = PieChart::default();
let mut header_seen = false;
for raw in src.lines() {
let line = strip_inline_comment(raw).trim();
if line.is_empty() {
continue;
}
if !header_seen {
parse_header(line, &mut chart)?;
header_seen = true;
continue;
}
let slice = parse_slice_line(line)?;
chart.slices.push(slice);
}
if !header_seen {
return Err(Error::ParseError(
"missing `pie` header line".to_string(),
));
}
if chart.slices.is_empty() {
return Err(Error::ParseError(
"pie chart must have at least one slice".to_string(),
));
}
if chart.total() <= 0.0 {
return Err(Error::ParseError(
"pie chart total must be greater than zero".to_string(),
));
}
Ok(chart)
}
fn parse_header(line: &str, chart: &mut PieChart) -> Result<(), Error> {
let mut rest = line.trim_start();
let (head, tail) = split_first_word(rest);
if !head.eq_ignore_ascii_case("pie") {
return Err(Error::ParseError(format!(
"expected `pie` header, got {head:?}"
)));
}
rest = tail.trim_start();
let (next, after) = split_first_word(rest);
if next.eq_ignore_ascii_case("showData") {
chart.show_data = true;
rest = after.trim_start();
}
let (next, after) = split_first_word(rest);
if next.eq_ignore_ascii_case("title") {
let title = after.trim();
if !title.is_empty() {
chart.title = Some(title.to_string());
}
} else if !next.is_empty() {
return Err(Error::ParseError(format!(
"unexpected token after `pie` header: {next:?}"
)));
}
Ok(())
}
fn parse_slice_line(line: &str) -> Result<PieSlice, Error> {
let bytes = line.as_bytes();
if bytes.first() != Some(&b'"') {
return Err(Error::ParseError(format!(
"pie slice must start with a quoted label: {line:?}"
)));
}
let close = line[1..].find('"').ok_or_else(|| {
Error::ParseError(format!(
"pie slice label is missing closing quote: {line:?}"
))
})?;
let label = line[1..1 + close].to_string();
let after = line[1 + close + 1..].trim_start();
let after = after.strip_prefix(':').ok_or_else(|| {
Error::ParseError(format!(
"pie slice missing `:` between label and value: {line:?}"
))
})?;
let value_str = after.trim();
let value: f64 = value_str.parse().map_err(|_| {
Error::ParseError(format!(
"pie slice value is not numeric: {value_str:?} in {line:?}"
))
})?;
if !value.is_finite() || value <= 0.0 {
return Err(Error::ParseError(format!(
"pie slice value must be a positive finite number: {value} in {line:?}"
)));
}
Ok(PieSlice { label, value })
}
fn split_first_word(s: &str) -> (&str, &str) {
let s = s.trim_start();
match s.find(char::is_whitespace) {
Some(i) => (&s[..i], &s[i..]),
None => (s, ""),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_minimal_pie() {
let c = parse("pie\n\"A\" : 1").unwrap();
assert_eq!(c.title, None);
assert!(!c.show_data);
assert_eq!(c.slices.len(), 1);
assert_eq!(c.slices[0].label, "A");
assert_eq!(c.slices[0].value, 1.0);
}
#[test]
fn parse_with_title() {
let c = parse("pie title Pet Counts\n\"Dogs\" : 386\n\"Cats\" : 85").unwrap();
assert_eq!(c.title.as_deref(), Some("Pet Counts"));
assert_eq!(c.slices.len(), 2);
assert_eq!(c.slices[1].value, 85.0);
}
#[test]
fn parse_with_show_data_and_title() {
let c = parse(
"pie showData title Browser Share\n\
\"Chrome\" : 60\n\
\"Firefox\" : 25\n\
\"Safari\" : 15",
)
.unwrap();
assert!(c.show_data);
assert_eq!(c.title.as_deref(), Some("Browser Share"));
assert_eq!(c.slices.len(), 3);
}
#[test]
fn parse_show_data_without_title() {
let c = parse("pie showData\n\"A\" : 1").unwrap();
assert!(c.show_data);
assert_eq!(c.title, None);
}
#[test]
fn parse_float_values_supported() {
let c = parse("pie\n\"A\" : 12.5\n\"B\" : 7.5").unwrap();
assert_eq!(c.slices[0].value, 12.5);
assert_eq!(c.slices[1].value, 7.5);
assert_eq!(c.total(), 20.0);
}
#[test]
fn parse_skips_comments_and_blanks() {
let c = parse(
"%% top comment\n\
pie title X\n\
\n\
\"A\" : 1\n\
%% mid comment\n\
\"B\" : 2",
)
.unwrap();
assert_eq!(c.slices.len(), 2);
}
#[test]
fn parse_rejects_negative_value() {
let err = parse("pie\n\"A\" : -1").expect_err("negative must error");
assert!(err.to_string().contains("positive"));
}
#[test]
fn parse_rejects_zero_total() {
let err = parse("pie\n\"A\" : 0").expect_err("zero must error");
assert!(err.to_string().contains("positive"));
}
#[test]
fn parse_rejects_missing_header() {
let err = parse("\"A\" : 1").expect_err("no header must error");
assert!(err.to_string().contains("pie"));
}
#[test]
fn parse_rejects_no_slices() {
let err = parse("pie title Empty").expect_err("zero slices must error");
assert!(err.to_string().contains("at least one slice"));
}
#[test]
fn parse_rejects_unquoted_label() {
let err = parse("pie\nA : 1").expect_err("missing quote must error");
assert!(err.to_string().contains("quoted"));
}
#[test]
fn parse_rejects_missing_colon() {
let err = parse("pie\n\"A\" 1").expect_err("missing colon must error");
assert!(err.to_string().contains("`:`"));
}
#[test]
fn parse_rejects_non_numeric_value() {
let err = parse("pie\n\"A\" : abc").expect_err("non-numeric must error");
assert!(err.to_string().contains("not numeric"));
}
}