use chrono::{DateTime, Local, NaiveDateTime}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum LogEvent { NpcDialogue { at: DateTime, speaker: String, text: String, }, ZoneTransition { at: DateTime, zone: String, }, } impl LogEvent { pub fn label(&self) -> &'static str { match self { Self::NpcDialogue { .. } => "NPC", Self::ZoneTransition { .. } => "ZONE", } } pub fn text(&self) -> &str { match self { Self::NpcDialogue { text, .. } => text, Self::ZoneTransition { zone, .. } => zone, } } pub fn occurred_at(&self) -> DateTime { match self { Self::NpcDialogue { at, .. } | Self::ZoneTransition { at, .. } => *at, } } } pub fn parse_line(line: &str) -> Option { let (timestamp, message) = split_timestamp(line)?; if let Some((speaker, text)) = parse_npc_says(message) { return Some(LogEvent::NpcDialogue { at: timestamp, speaker, text, }); } message .strip_prefix("You have entered ") .and_then(|zone| zone.strip_suffix('.')) .filter(|zone| !zone.is_empty()) .map(|zone| LogEvent::ZoneTransition { at: timestamp, zone: zone.to_string(), }) } fn split_timestamp(line: &str) -> Option<(DateTime, &str)> { let end = line.find("] ")?; let raw_timestamp = line.strip_prefix('[')?.get(..end - 1)?; let timestamp = NaiveDateTime::parse_from_str(raw_timestamp, "%a %b %d %H:%M:%S %Y") .ok()? .and_local_timezone(Local) .single()?; Some((timestamp, &line[end + 2..])) } fn parse_npc_says(message: &str) -> Option<(String, String)> { let (speaker, quote) = message.split_once(" says, '")?; let text = quote.strip_suffix('\'')?; if speaker.is_empty() || text.is_empty() || speaker == "You" { return None; } Some((speaker.to_string(), text.to_string())) } pub fn speaker_key(name: &str) -> String { let normalized = name .chars() .flat_map(char::to_lowercase) .map(|character| { if character.is_ascii_alphanumeric() { character } else { '_' } }) .collect::(); let normalized = normalized.trim_matches('_'); let compact = normalized .split('_') .filter(|part| !part.is_empty()) .collect::>() .join("_"); format!("npc:{compact}") } #[cfg(test)] mod tests { use super::*; #[test] fn parses_npc_dialogue_with_apostrophe() { let event = parse_line("[Fri Jul 17 20:10:29 2026] Turga says, 'I've got a Rusty Long Sword.'") .unwrap(); assert_eq!( event, LogEvent::NpcDialogue { at: NaiveDateTime::parse_from_str( "Fri Jul 17 20:10:29 2026", "%a %b %d %H:%M:%S %Y" ) .unwrap() .and_local_timezone(Local) .single() .unwrap(), speaker: "Turga".into(), text: "I've got a Rusty Long Sword.".into(), } ); } #[test] fn ignores_player_dialogue_and_channel_tells() { assert!(parse_line("[Fri Jul 17 20:10:29 2026] You say, 'Hail'").is_none()); assert!(parse_line("[Fri Jul 17 20:10:29 2026] Rath tells General:2, 'hello'").is_none()); } #[test] fn parses_zone_message() { let event = parse_line("[Fri Jul 17 20:09:32 2026] You have entered Lake Rathetear.").unwrap(); assert!(matches!(event, LogEvent::ZoneTransition { zone, .. } if zone == "Lake Rathetear")); } #[test] fn normalizes_speaker_keys() { assert_eq!(speaker_key("A lizardman healer"), "npc:a_lizardman_healer"); assert_eq!(speaker_key("Zok Caropni!"), "npc:zok_caropni"); } }