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 model;
mod parser; mod parser;
use model::Type;
#[php_class] #[php_class]
#[php(name = "SamiAndAlex\\TrackerApp\\Parser")] #[php(name = "SamiAndAlex\\TrackerApp\\Parser")]
@ -19,24 +20,32 @@ impl Parser {
} }
pub fn parse_feed(&self, input: &str) -> PhpResult<Option<ZBox<ZendObject>>> { 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))?; .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(); let mut result = ZendObject::new_stdclass();
result.set_property("unparsed", unparsed)?; if let Some((main_amount, main_type)) = fed.main {
if fed.formula.is_some() { let key = match main_type {
result.set_property("formula", fed.formula.unwrap().volume)?; 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() { if let Some((plus_amount, plus_type)) = fed.plus {
result.set_property("whole_milk", fed.whole_milk.unwrap().volume)?; 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() { if let Some(left_amount) = fed.left {
result.set_property("left", fed.left.unwrap().volume)?; result.set_property("left", left_amount.volume)?;
} }
Ok(Some(result)) Ok(Some(result))
} else {
Ok(None)
}
} }
} }

View File

@ -1,3 +1,4 @@
use std::convert::TryFrom;
use std::fmt::{ use std::fmt::{
Debug, Debug,
Display, 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)] #[derive(Debug)]
pub struct Fed { pub struct Fed {
pub formula: Option<Amount>, pub main: Option<(Amount, Type)>,
pub whole_milk: Option<Amount>, pub plus: Option<(Amount, Type)>,
pub left: Option<Amount>, pub left: Option<Amount>,
} }
impl Display for Fed { impl Display for Fed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 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); if let Some((main_amount, main_type)) = &self.main {
write!(f, "Fed {}ml formula and {}ml whole milk. Left {}ml.", write!(f, "{}", format!("Fed {} of {}", &main_amount, &main_type))?;
amount_or_0(self.formula.as_ref()), }
amount_or_0(self.whole_milk.as_ref()), if let Some((plus_amount, plus_type)) = &self.plus {
amount_or_0(self.left.as_ref()), 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, Amount,
Duration, Duration,
Time, Time,
Type,
}; };
fn parse_time(input: &str) -> IResult<&str, Time> { 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 })) Ok((rest, Amount { volume }))
} }
pub fn parse_main_amount(input: &str) -> IResult<&str, Amount> { pub fn parse_type(input: &str) -> IResult<&str, Option<Type>> {
let (rest, (amount, _)) = pair( let (rest, feed_type_str) = opt(
parse_amount, preceded(
opt(alt(( opt(tag_no_case(" of")),
preceded(
tag(" "),
alt((
tag_no_case("premixed formula"), tag_no_case("premixed formula"),
tag_no_case("formula"), tag_no_case("formula"),
))), tag_no_case("whole milk"),
tag_no_case("breast milk"),
tag_no_case("milk"),
))
)
)
)(input)?; )(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> { pub fn parse_amount_and_type(input: &str) -> IResult<&str, (Amount, Option<Type>)> {
let (rest, (amount, _)) = pair( let (rest, (amount, feed_type)) = pair(
parse_amount, parse_amount,
opt(alt(( parse_type,
tag_no_case(" whole milk"),
tag_no_case(" milk"),
))),
)(input)?; )(input)?;
Ok((rest, amount)) Ok((rest, (amount, feed_type)))
} }
pub fn parse_left_amount(input: &str) -> IResult<&str, Amount> { 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> { 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( preceded(
tag("Fed "), tag("Fed "),
parse_main_amount, parse_amount_and_type,
), ),
opt(preceded( opt(preceded(
parse_and_plus, parse_and_plus,
parse_second_amount, parse_amount_and_type,
)), )),
parse_timestamp, parse_timestamp,
opt(parse_left_amount), opt(parse_left_amount),
))(input)?; ))(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 { let fed = Fed {
formula: Some(main), main,
whole_milk: plus, plus,
left: left, left,
}; };
Ok((rest, fed)) Ok((rest, fed))
} }
@ -207,33 +221,60 @@ mod tests {
"Fed 150ml with 50ml whole milk at 01:16-01:27 40ml left", "Fed 150ml with 50ml whole milk at 01:16-01:27 40ml left",
"", "",
Fed { Fed {
formula: Some(Amount { volume: 150 }), main: Some((Amount { volume: 150 }, Type::Formula)),
whole_milk: Some(Amount { volume: 50 }), 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 }), left: Some(Amount { volume: 40 }),
}, },
), ),
]; ];
for (input, remaining, output) in test_cases { for (input, remaining, expected) in test_cases {
let result = parse_feed(input); let result = parse_feed(input);
assert!(result.is_ok(), "Parsing failed"); assert!(result.is_ok(), "Parsing failed");
let (rest, actual) = result.unwrap(); let (rest, actual) = result.unwrap();
assert_eq!(remaining, rest); assert_eq!(remaining, rest);
if let Some(expected_formula) = output.formula { if let Some((expected_main_amount, _expected_main_type)) = expected.main {
assert!(actual.formula.is_some(), "Expected to have formula"); assert!(actual.main.is_some(), "Expected to have a main");
assert_eq!(expected_formula.volume, actual.formula.unwrap().volume); let main = actual.main.unwrap();
assert_eq!(expected_main_amount.volume, main.0.volume);
assert!(matches!(main.1, Type::Formula));
} else { } 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 { macro_rules! assert_amount_and_type {
assert!(actual.whole_milk.is_some(), "Expected to have whole_milk"); ($expected_amount_and_type:expr, $actual_amount_and_type:expr, $type_pattern:pat) => {
assert_eq!(expected_whole_milk.volume, actual.whole_milk.unwrap().volume); 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 { } 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!(actual.left.is_some(), "Expected to have left");
assert_eq!(expected_left.volume, actual.left.unwrap().volume); assert_eq!(expected_left.volume, actual.left.unwrap().volume);
} else { } else {
@ -246,80 +287,124 @@ mod tests {
( (
$name:ident, $name:ident,
$input:expr, $input:expr,
$formula:expr, $remained:expr,
$whole_milk:expr, $expected_main_amount:expr,
$left:expr $(,)? $expected_main_type:pat,
$expected_plus_amount:expr,
$expected_plus_type:pat,
$expected_left_amount:expr,
) => { ) => {
#[test] #[test]
fn $name() { fn $name() {
let result = parse_feed($input); let result = parse_feed($input);
assert!(result.is_ok(), "Parsing failed"); assert!(result.is_ok(), "Parsing failed");
let (rest, actual) = result.unwrap(); let (rest, result) = result.unwrap();
assert_eq!("", rest); assert_eq!($remained, rest, "Unexpected remaining text");
// formula if let Some(expected) = $expected_main_amount {
match ($formula, actual.formula) { assert!(result.main.is_some(), "Expecting a main amount");
(Some(expected), Some(actual)) => { let actual = result.main.unwrap();
assert_eq!(expected.volume, actual.volume, "formula mismatch"); assert_eq!(expected.volume, actual.0.volume);
} assert!(matches!(actual.1, $expected_main_type));
(None, None) => {} } else {
(Some(_), None) => panic!("Expected formula but got none"), assert!(result.main.is_none(), "Expecting a None main");
(None, Some(_)) => panic!("Unexpected formula"),
} }
// whole_milk if let Some(expected) = $expected_plus_amount {
match ($whole_milk, actual.whole_milk) { assert!(result.plus.is_some(), "Expecting a plus amount");
(Some(expected), Some(actual)) => { let actual = result.plus.unwrap();
assert_eq!(expected.volume, actual.volume, "whole_milk mismatch"); assert_eq!(expected.volume, actual.0.volume);
} assert!(matches!(actual.1, $expected_plus_type));
(None, None) => {} } else {
(Some(_), None) => panic!("Expected whole_milk but got none"), assert!(result.plus.is_none(), "Expecting a None plus");
(None, Some(_)) => panic!("Unexpected whole_milk"),
} }
// left if let Some(expected) = $expected_left_amount {
match ($left, actual.left) { assert!(result.left.is_some(), "Expecting a left amount");
(Some(expected), Some(actual)) => { let actual = result.left.unwrap();
assert_eq!(expected.volume, actual.volume, "left mismatch"); assert_eq!(expected.volume, actual.volume);
} } else {
(None, None) => {} assert!(result.left.is_none(), "Expecting a None left");
(Some(_), None) => panic!("Expected left but got none"), }
(None, Some(_)) => panic!("Unexpected left"),
} }
} }
};
} }
make_feed_test!( make_feed_test!(
test_parse_feed_basic, basic_parsing_1,
"Fed 150ml with 50ml whole milk at 01:16-01:27 40ml left", "Fed 150ml with 50ml whole milk at 01:16-01:27 40ml left",
"",
Some(Amount { volume: 150 }), Some(Amount { volume: 150 }),
Type::Formula,
Some(Amount { volume: 50 }), 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 }), Some(Amount { volume: 40 }),
); );
make_feed_test!( make_feed_test!(
test_parse_feed_2, test_parse_feed_2,
"Fed 150ml + 50ml whole milk at 01:16-01:27 40ml left", "Fed 150ml + 50ml whole milk at 01:16-01:27 40ml left",
"",
Some(Amount { volume: 150 }), Some(Amount { volume: 150 }),
Type::Formula,
Some(Amount { volume: 50 }), Some(Amount { volume: 50 }),
Type::WholeMilk,
Some(Amount { volume: 40 }), Some(Amount { volume: 40 }),
); );
make_feed_test!( make_feed_test!(
test_parse_feed_3, test_parse_feed_3,
"Fed 150ml + 50ml milk at 01:16-01:27", "Fed 150ml + 50ml milk at 01:16-01:27",
"",
Some(Amount { volume: 150 }), Some(Amount { volume: 150 }),
Type::Formula,
Some(Amount { volume: 50 }), 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>, None::<Amount>,
); );
make_feed_test!( make_feed_test!(
test_parse_feed_5, test_parse_feed_5,
"Fed 150ml and 25ml at 01:16-01:27", "Fed 150ml and 25ml at 01:16-01:27",
"",
Some(Amount { volume: 150 }), Some(Amount { volume: 150 }),
Type::Formula,
Some(Amount { volume: 25 }), 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>, None::<Amount>,
); );
} }