Change parser output

Changed to parse main+plus+left of any types reusing the same parser
functions for main and plus and returning an Enum of milk type.
This commit is contained in:
Alex Wright 2026-04-06 16:01:54 +01:00
parent 8d3190c343
commit 67f77ce160
3 changed files with 219 additions and 89 deletions

View File

@ -5,6 +5,7 @@ use ext_php_rs::types::ZendObject;
mod model;
mod parser;
use model::Type;
#[php_class]
#[php(name = "SamiAndAlex\\TrackerApp\\Parser")]
@ -19,24 +20,32 @@ impl Parser {
}
pub fn parse_feed(&self, input: &str) -> PhpResult<Option<ZBox<ZendObject>>> {
let (unparsed, fed) = crate::parser::parse_feed(input)
let (_unparsed, fed) = crate::parser::parse_feed(input)
.map_err(|e| format!("nom parsing failed: {}", e))?;
if fed.formula.is_some() || fed.whole_milk.is_some() || fed.left.is_some() {
let mut result = ZendObject::new_stdclass();
result.set_property("unparsed", unparsed)?;
if fed.formula.is_some() {
result.set_property("formula", fed.formula.unwrap().volume)?;
if let Some((main_amount, main_type)) = fed.main {
let key = match main_type {
Type::Formula => "formula",
Type::PremixedFormula => "premixed_formula",
Type::BreastMilk => "breast_milk",
Type::WholeMilk => "whole_milk",
};
result.set_property(key, main_amount.volume)?;
}
if fed.whole_milk.is_some() {
result.set_property("whole_milk", fed.whole_milk.unwrap().volume)?;
if let Some((plus_amount, plus_type)) = fed.plus {
let key = match plus_type {
Type::Formula => "formula",
Type::PremixedFormula => "premixed_formula",
Type::BreastMilk => "breast_milk",
Type::WholeMilk => "whole_milk",
};
result.set_property(key, plus_amount.volume)?;
}
if fed.left.is_some() {
result.set_property("left", fed.left.unwrap().volume)?;
if let Some(left_amount) = fed.left {
result.set_property("left", left_amount.volume)?;
}
Ok(Some(result))
} else {
Ok(None)
}
}
}

View File

@ -1,3 +1,4 @@
use std::convert::TryFrom;
use std::fmt::{
Debug,
Display,
@ -38,21 +39,56 @@ impl Display for Amount {
}
}
#[derive(Debug)]
pub enum Type {
Formula,
PremixedFormula,
WholeMilk,
BreastMilk,
}
impl Display for Type {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:?}", self)
}
}
impl TryFrom<&str> for Type {
type Error = ();
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"formula" => Ok(Self::Formula),
"premixed formula" => Ok(Self::PremixedFormula),
"premixed" => Ok(Self::PremixedFormula),
"whole milk" => Ok(Self::WholeMilk),
"milk" => Ok(Self::WholeMilk),
"breast milk" => Ok(Self::BreastMilk),
_ => Err(()),
}
}
}
#[derive(Debug)]
pub struct Fed {
pub formula: Option<Amount>,
pub whole_milk: Option<Amount>,
pub main: Option<(Amount, Type)>,
pub plus: Option<(Amount, Type)>,
pub left: Option<Amount>,
}
impl Display for Fed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let amount_or_0 = |o: Option<&Amount>| o.map_or(0, |a| a.volume);
write!(f, "Fed {}ml formula and {}ml whole milk. Left {}ml.",
amount_or_0(self.formula.as_ref()),
amount_or_0(self.whole_milk.as_ref()),
amount_or_0(self.left.as_ref()),
)
if let Some((main_amount, main_type)) = &self.main {
write!(f, "{}", format!("Fed {} of {}", &main_amount, &main_type))?;
}
if let Some((plus_amount, plus_type)) = &self.plus {
write!(f, " and {} of {}", &plus_amount, &plus_type)?;
}
if let Some(left_amount) = &self.left {
write!(f, ". Left {}", &left_amount)?;
}
write!(f, ".")?;
Ok(())
}
}

View File

@ -23,6 +23,7 @@ use crate::model::{
Amount,
Duration,
Time,
Type,
};
fn parse_time(input: &str) -> IResult<&str, Time> {
@ -73,26 +74,37 @@ pub fn parse_amount(input: &str) -> IResult<&str, Amount> {
Ok((rest, Amount { volume }))
}
pub fn parse_main_amount(input: &str) -> IResult<&str, Amount> {
let (rest, (amount, _)) = pair(
parse_amount,
opt(alt((
tag_no_case(" premixed formula"),
tag_no_case(" formula"),
))),
pub fn parse_type(input: &str) -> IResult<&str, Option<Type>> {
let (rest, feed_type_str) = opt(
preceded(
opt(tag_no_case(" of")),
preceded(
tag(" "),
alt((
tag_no_case("premixed formula"),
tag_no_case("formula"),
tag_no_case("whole milk"),
tag_no_case("breast milk"),
tag_no_case("milk"),
))
)
)
)(input)?;
Ok((rest, amount))
Ok((
rest,
feed_type_str
.map(Type::try_from)
.transpose()
.expect("Failed to resolve Type from str"),
))
}
pub fn parse_second_amount(input: &str) -> IResult<&str, Amount> {
let (rest, (amount, _)) = pair(
pub fn parse_amount_and_type(input: &str) -> IResult<&str, (Amount, Option<Type>)> {
let (rest, (amount, feed_type)) = pair(
parse_amount,
opt(alt((
tag_no_case(" whole milk"),
tag_no_case(" milk"),
))),
parse_type,
)(input)?;
Ok((rest, amount))
Ok((rest, (amount, feed_type)))
}
pub fn parse_left_amount(input: &str) -> IResult<&str, Amount> {
@ -117,22 +129,24 @@ pub fn parse_and_plus(input: &str) -> IResult<&str, &str> {
}
pub fn parse_feed(input: &str) -> IResult<&str, Fed> {
let (rest, (main, plus, _timestamp, left)) = tuple((
let (rest, ((main_amount, main_type), plus, _timestamp, left)) = tuple((
preceded(
tag("Fed "),
parse_main_amount,
parse_amount_and_type,
),
opt(preceded(
parse_and_plus,
parse_second_amount,
parse_amount_and_type,
)),
parse_timestamp,
opt(parse_left_amount),
))(input)?;
let main = Some((main_amount, main_type.unwrap_or(Type::Formula)));
let plus = plus.map(|(a, t)| (a, t.unwrap_or(Type::WholeMilk)));
let fed = Fed {
formula: Some(main),
whole_milk: plus,
left: left,
main,
plus,
left,
};
Ok((rest, fed))
}
@ -207,33 +221,60 @@ mod tests {
"Fed 150ml with 50ml whole milk at 01:16-01:27 40ml left",
"",
Fed {
formula: Some(Amount { volume: 150 }),
whole_milk: Some(Amount { volume: 50 }),
main: Some((Amount { volume: 150 }, Type::Formula)),
plus: Some((Amount { volume: 50 }, Type::WholeMilk)),
left: Some(Amount { volume: 40 }),
},
),
(
"Fed 150ml formula and 50ml whole milk at 01:16-01:27 40ml left",
"",
Fed {
main: Some((Amount { volume: 150 }, Type::Formula)),
plus: Some((Amount { volume: 50 }, Type::WholeMilk)),
left: Some(Amount { volume: 40 }),
},
),
(
"Fed 150ml formula and 50ml milk at 01:16-01:27 40ml left",
"",
Fed {
main: Some((Amount { volume: 150 }, Type::Formula)),
plus: Some((Amount { volume: 50 }, Type::WholeMilk)),
left: Some(Amount { volume: 40 }),
},
),
];
for (input, remaining, output) in test_cases {
for (input, remaining, expected) in test_cases {
let result = parse_feed(input);
assert!(result.is_ok(), "Parsing failed");
let (rest, actual) = result.unwrap();
assert_eq!(remaining, rest);
if let Some(expected_formula) = output.formula {
assert!(actual.formula.is_some(), "Expected to have formula");
assert_eq!(expected_formula.volume, actual.formula.unwrap().volume);
if let Some((expected_main_amount, _expected_main_type)) = expected.main {
assert!(actual.main.is_some(), "Expected to have a main");
let main = actual.main.unwrap();
assert_eq!(expected_main_amount.volume, main.0.volume);
assert!(matches!(main.1, Type::Formula));
} else {
assert!(actual.formula.is_none(), "Wasn't expecting formula");
assert!(actual.main.is_none(), "Wasn't expecting main");
}
if let Some(expected_whole_milk) = output.whole_milk {
assert!(actual.whole_milk.is_some(), "Expected to have whole_milk");
assert_eq!(expected_whole_milk.volume, actual.whole_milk.unwrap().volume);
macro_rules! assert_amount_and_type {
($expected_amount_and_type:expr, $actual_amount_and_type:expr, $type_pattern:pat) => {
if let Some((expected_amount, _expected_type)) = $expected_amount_and_type {
assert!($actual_amount_and_type.is_some(), "Expected to be Some");
let value = $actual_amount_and_type.unwrap();
assert_eq!(expected_amount.volume, value.0.volume, "Expected amount to be {}", expected_amount.volume);
assert!(matches!(value.1, $type_pattern));
} else {
assert!(actual.whole_milk.is_none(), "Wasn't expecting whole_milk");
assert!($actual_amount_and_type.is_none(), "Expected to be None");
}
}
}
assert_amount_and_type!(expected.plus, actual.plus, Type::WholeMilk);
if let Some(expected_left) = output.left {
if let Some(expected_left) = expected.left {
assert!(actual.left.is_some(), "Expected to have left");
assert_eq!(expected_left.volume, actual.left.unwrap().volume);
} else {
@ -246,80 +287,124 @@ mod tests {
(
$name:ident,
$input:expr,
$formula:expr,
$whole_milk:expr,
$left:expr $(,)?
$remained:expr,
$expected_main_amount:expr,
$expected_main_type:pat,
$expected_plus_amount:expr,
$expected_plus_type:pat,
$expected_left_amount:expr,
) => {
#[test]
fn $name() {
let result = parse_feed($input);
assert!(result.is_ok(), "Parsing failed");
let (rest, actual) = result.unwrap();
assert_eq!("", rest);
let (rest, result) = result.unwrap();
assert_eq!($remained, rest, "Unexpected remaining text");
// formula
match ($formula, actual.formula) {
(Some(expected), Some(actual)) => {
assert_eq!(expected.volume, actual.volume, "formula mismatch");
}
(None, None) => {}
(Some(_), None) => panic!("Expected formula but got none"),
(None, Some(_)) => panic!("Unexpected formula"),
if let Some(expected) = $expected_main_amount {
assert!(result.main.is_some(), "Expecting a main amount");
let actual = result.main.unwrap();
assert_eq!(expected.volume, actual.0.volume);
assert!(matches!(actual.1, $expected_main_type));
} else {
assert!(result.main.is_none(), "Expecting a None main");
}
// whole_milk
match ($whole_milk, actual.whole_milk) {
(Some(expected), Some(actual)) => {
assert_eq!(expected.volume, actual.volume, "whole_milk mismatch");
}
(None, None) => {}
(Some(_), None) => panic!("Expected whole_milk but got none"),
(None, Some(_)) => panic!("Unexpected whole_milk"),
if let Some(expected) = $expected_plus_amount {
assert!(result.plus.is_some(), "Expecting a plus amount");
let actual = result.plus.unwrap();
assert_eq!(expected.volume, actual.0.volume);
assert!(matches!(actual.1, $expected_plus_type));
} else {
assert!(result.plus.is_none(), "Expecting a None plus");
}
// left
match ($left, actual.left) {
(Some(expected), Some(actual)) => {
assert_eq!(expected.volume, actual.volume, "left mismatch");
}
(None, None) => {}
(Some(_), None) => panic!("Expected left but got none"),
(None, Some(_)) => panic!("Unexpected left"),
if let Some(expected) = $expected_left_amount {
assert!(result.left.is_some(), "Expecting a left amount");
let actual = result.left.unwrap();
assert_eq!(expected.volume, actual.volume);
} else {
assert!(result.left.is_none(), "Expecting a None left");
}
}
}
};
}
make_feed_test!(
test_parse_feed_basic,
basic_parsing_1,
"Fed 150ml with 50ml whole milk at 01:16-01:27 40ml left",
"",
Some(Amount { volume: 150 }),
Type::Formula,
Some(Amount { volume: 50 }),
Type::WholeMilk,
Some(Amount { volume: 40 }),
);
make_feed_test!(
test_parse_feed_basic,
"Fed 150ml breast milk with 50ml whole milk at 01:16-01:27 40ml left",
"",
Some(Amount { volume: 150 }),
Type::BreastMilk,
Some(Amount { volume: 50 }),
Type::WholeMilk,
Some(Amount { volume: 40 }),
);
make_feed_test!(
test_parse_feed_2,
"Fed 150ml + 50ml whole milk at 01:16-01:27 40ml left",
"",
Some(Amount { volume: 150 }),
Type::Formula,
Some(Amount { volume: 50 }),
Type::WholeMilk,
Some(Amount { volume: 40 }),
);
make_feed_test!(
test_parse_feed_3,
"Fed 150ml + 50ml milk at 01:16-01:27",
"",
Some(Amount { volume: 150 }),
Type::Formula,
Some(Amount { volume: 50 }),
Type::WholeMilk,
None::<Amount>,
);
make_feed_test!(
test_parse_feed_4,
"Fed 150ml and 25ml whole milk at 01:16-01:27",
"",
Some(Amount { volume: 150 }),
Type::Formula,
Some(Amount { volume: 25 }),
Type::WholeMilk,
None::<Amount>,
);
make_feed_test!(
test_parse_feed_5,
"Fed 150ml and 25ml at 01:16-01:27",
"",
Some(Amount { volume: 150 }),
Type::Formula,
Some(Amount { volume: 25 }),
Type::WholeMilk,
None::<Amount>,
);
make_feed_test!(
test_parse_feed_6,
"Fed 50ml of breast milk @ 15:00-15:15",
"",
Some(Amount { volume: 50 }),
Type::BreastMilk,
None::<Amount>,
Type::WholeMilk,
None::<Amount>,
);
}