piyoparse 0.1.3

Parser for PiyoLog export files
Documentation
use crate::model::{BreastMilkOrder, RecordData};

use super::scanner::Scanner;

pub(super) fn parse_record_data(record_type: &str, detail: Option<String>) -> RecordData {
    let detail_text = detail.as_deref();

    match record_type {
        "母乳" => {
            let breast_milk = detail_text.and_then(extract_breast_milk_parts);
            let amount_ml = detail_text.and_then(extract_amount_ml);
            RecordData::Breastfeeding {
                left_minutes: breast_milk.map(|parts| parts.left_minutes),
                right_minutes: breast_milk.map(|parts| parts.right_minutes),
                order: breast_milk
                    .map(|parts| parts.order)
                    .unwrap_or(BreastMilkOrder::Unspecified),
                amount_ml,
                detail,
            }
        }
        "ミルク" => RecordData::Formula {
            amount_ml: detail_text.and_then(extract_amount_ml),
            detail,
        },
        "搾母乳" => RecordData::ExpressedBreastMilk {
            amount_ml: detail_text.and_then(extract_amount_ml),
            detail,
        },
        "お風呂" => no_detail_record(record_type, detail, RecordData::Baths),
        "寝る" => no_detail_record(record_type, detail, RecordData::Sleep),
        "おしっこ" => no_detail_record(record_type, detail, RecordData::Pee),
        "うんち" => RecordData::Poop { detail },
        "搾乳" => RecordData::Pumping {
            amount_ml: detail_text.and_then(extract_amount_ml),
            detail,
        },
        "体温" => RecordData::BodyTemp { detail },
        "身長" => RecordData::Height { detail },
        "体重" => RecordData::Weight { detail },
        "頭囲" => RecordData::HeadSize { detail },
        "胸囲" => RecordData::ChestSize { detail },
        "離乳食" => no_detail_record(record_type, detail, RecordData::SolidFood),
        "おやつ" => no_detail_record(record_type, detail, RecordData::Snack),
        "ごはん" => no_detail_record(record_type, detail, RecordData::Meal),
        "せき" => no_detail_record(record_type, detail, RecordData::Cough),
        "吐く" => no_detail_record(record_type, detail, RecordData::Vomit),
        "発疹" => no_detail_record(record_type, detail, RecordData::Rash),
        "けが" => no_detail_record(record_type, detail, RecordData::Injury),
        "くすり" => no_detail_record(record_type, detail, RecordData::Medicine),
        "病院" => no_detail_record(record_type, detail, RecordData::Hospital),
        "予防接種" => no_detail_record(record_type, detail, RecordData::Vaccine),
        "できた" => no_detail_record(record_type, detail, RecordData::Milestone),
        "その他" => no_detail_record(record_type, detail, RecordData::Others),
        "メモ" => no_detail_record(record_type, detail, RecordData::Notes),
        custom_type if custom_type.starts_with("カスタム") => RecordData::Other {
            type_name: custom_type.to_string(),
            detail,
        },
        "のみもの" => RecordData::Drink {
            amount_ml: detail_text.and_then(extract_amount_ml),
            detail,
        },
        "起きる" => RecordData::WakeUp {
            duration_minutes: detail_text.and_then(extract_duration_minutes),
            detail,
        },
        "さんぽ" => RecordData::Walks {
            duration_minutes: detail_text.and_then(extract_duration_minutes),
            detail,
        },
        _ => RecordData::Other {
            type_name: record_type.to_string(),
            detail,
        },
    }
}

fn no_detail_record(record_type: &str, detail: Option<String>, data: RecordData) -> RecordData {
    if detail.is_some() {
        RecordData::Other {
            type_name: record_type.to_string(),
            detail,
        }
    } else {
        data
    }
}

fn extract_amount_ml(value: &str) -> Option<u32> {
    for (ml_index, _) in value.match_indices("ml") {
        if let Some(amount) = trailing_ascii_number(&value[..ml_index]) {
            return Some(amount);
        }
    }

    None
}

fn extract_duration_minutes(value: &str) -> Option<u32> {
    let mut in_digits = false;
    for (index, character) in value.char_indices() {
        if character.is_ascii_digit() {
            if !in_digits {
                let mut scanner = Scanner::new(&value[index..]);
                if let Some(hours) = scanner.take_u32()
                    && scanner.strip_prefix("時間")
                    && let Some(minutes) = scanner.take_u32()
                    && scanner.strip_prefix("")
                {
                    return Some(hours * 60 + minutes);
                }
            }
            in_digits = true;
        } else {
            in_digits = false;
        }
    }

    let mut in_digits = false;
    for (index, character) in value.char_indices() {
        if character.is_ascii_digit() {
            if !in_digits {
                let mut scanner = Scanner::new(&value[index..]);
                if let Some(minutes) = scanner.take_u32()
                    && scanner.strip_prefix("")
                {
                    return Some(minutes);
                }
            }
            in_digits = true;
        } else {
            in_digits = false;
        }
    }

    None
}

fn trailing_ascii_number(value: &str) -> Option<u32> {
    let end = value.len();
    let start = value
        .char_indices()
        .rev()
        .find_map(|(index, character)| {
            (!character.is_ascii_digit()).then_some(index + character.len_utf8())
        })
        .unwrap_or(0);

    (start < end).then(|| value[start..end].parse().ok())?
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BreastMilkSide {
    Left,
    Right,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct BreastMilkParts {
    left_minutes: u32,
    right_minutes: u32,
    order: BreastMilkOrder,
}

fn extract_breast_milk_parts(value: &str) -> Option<BreastMilkParts> {
    for (index, character) in value.char_indices() {
        if matches!(character, '' | '')
            && let Some(parts) = extract_breast_milk_parts_at(&value[index..])
        {
            return Some(parts);
        }
    }

    None
}

fn extract_breast_milk_parts_at(value: &str) -> Option<BreastMilkParts> {
    let mut scanner = Scanner::new(value);
    let first_side = scanner
        .take_char_if(|character| matches!(character, '' | ''))
        .and_then(parse_breast_milk_side)?;
    scanner.skip_spaces();
    let first_minutes = scanner.take_u32()?;
    scanner.strip_prefix("").then_some(())?;
    scanner.skip_spaces();
    let separator = scanner.take_char_if(|character| matches!(character, '/' | '' | ''))?;
    scanner.skip_spaces();
    let second_side = scanner
        .take_char_if(|character| matches!(character, '' | ''))
        .and_then(parse_breast_milk_side)?;
    scanner.skip_spaces();
    let second_minutes = scanner.take_u32()?;
    scanner.strip_prefix("").then_some(())?;

    let mut left_minutes = None;
    let mut right_minutes = None;
    for (side, minutes) in [(first_side, first_minutes), (second_side, second_minutes)] {
        match side {
            BreastMilkSide::Left => left_minutes = Some(minutes),
            BreastMilkSide::Right => right_minutes = Some(minutes),
        }
    }

    Some(BreastMilkParts {
        left_minutes: left_minutes?,
        right_minutes: right_minutes?,
        order: extract_breast_milk_order(first_side, separator, second_side),
    })
}

fn parse_breast_milk_side(value: char) -> Option<BreastMilkSide> {
    match value {
        '' => Some(BreastMilkSide::Left),
        '' => Some(BreastMilkSide::Right),
        _ => None,
    }
}

fn extract_breast_milk_order(
    first_side: BreastMilkSide,
    separator: char,
    second_side: BreastMilkSide,
) -> BreastMilkOrder {
    match separator {
        '/' => BreastMilkOrder::Unspecified,
        '' => order_from_sides(first_side, second_side),
        '' => order_from_sides(second_side, first_side),
        _ => BreastMilkOrder::Unspecified,
    }
}

fn order_from_sides(first: BreastMilkSide, second: BreastMilkSide) -> BreastMilkOrder {
    match (first, second) {
        (BreastMilkSide::Left, BreastMilkSide::Right) => BreastMilkOrder::LeftThenRight,
        (BreastMilkSide::Right, BreastMilkSide::Left) => BreastMilkOrder::RightThenLeft,
        _ => BreastMilkOrder::Unspecified,
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn extracts_amount_after_multibyte_separator() {
        assert_eq!(extract_amount_ml("左 30ml"), Some(30));
        assert_eq!(extract_amount_ml("搾母乳:110ml"), Some(110));
    }

    #[test]
    fn trailing_ascii_number_starts_after_utf8_boundary() {
        assert_eq!(trailing_ascii_number("左 30"), Some(30));
        assert_eq!(trailing_ascii_number("合計:180"), Some(180));
        assert_eq!(trailing_ascii_number("なし"), None);
    }
}