1#![doc = include_str!("../README.md")]
2
3use chumsky::IterParser as _;
4use chumsky::Parser;
5use chumsky::prelude::{any, just, none_of, one_of};
6use chumsky::text::whitespace;
7
8pub mod avatar_messages;
9pub mod system_messages;
10pub mod utils;
11
12#[expect(
14 clippy::large_enum_variant,
15 reason = "boxing gets in the way of properly pattern matching"
16)]
17#[derive(Debug, Clone, PartialEq)]
18pub enum ChatLogEvent {
19 AvatarLine {
21 name: String,
23 message: crate::avatar_messages::AvatarMessage,
25 },
26 SystemMessage {
28 message: crate::system_messages::SystemMessage,
30 },
31 OtherMessage {
33 message: String,
35 },
36}
37
38#[must_use]
44pub fn avatar_name_parser<'src>()
45-> impl Parser<'src, &'src str, String, chumsky::extra::Err<chumsky::error::Rich<'src, char>>> {
46 none_of(":")
47 .repeated()
48 .collect::<String>()
49 .try_map(|s, _span: chumsky::span::SimpleSpan| Ok(s))
50}
51
52#[must_use]
58fn chat_log_event_parser<'src>()
59-> impl Parser<'src, &'src str, ChatLogEvent, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
60{
61 just("Second Life: ")
62 .ignore_then(
63 take_until!(
64 crate::avatar_messages::avatar_came_online_message_parser().or(
65 crate::avatar_messages::avatar_went_offline_message_parser()
66 .or(crate::avatar_messages::avatar_entered_area_message_parser())
67 .or(crate::avatar_messages::avatar_left_area_message_parser()),
68 )
69 )
70 .map(|(name, message)| ChatLogEvent::AvatarLine {
71 name: name.strip_suffix(" ").unwrap_or(&name).to_owned(),
72 message,
73 }),
74 )
75 .or(just("Second Life: ").ignore_then(
76 crate::system_messages::system_message_parser()
77 .map(|message| ChatLogEvent::SystemMessage { message }),
78 ))
79 .or(avatar_name_parser()
80 .then_ignore(just(":").then(whitespace()))
81 .then(crate::avatar_messages::avatar_message_parser())
82 .map(|(name, message)| ChatLogEvent::AvatarLine { name, message }))
83 .or(any()
84 .repeated()
85 .collect::<String>()
86 .map(|s| ChatLogEvent::OtherMessage { message: s }))
87}
88
89#[derive(Debug, Clone, PartialEq)]
91pub struct ChatLogLine {
92 pub timestamp: Option<time::PrimitiveDateTime>,
94 pub event: ChatLogEvent,
96}
97
98#[must_use]
104pub fn chat_log_line_parser<'src>()
105-> impl Parser<'src, &'src str, ChatLogLine, chumsky::extra::Err<chumsky::error::Rich<'src, char>>>
106{
107 just("[")
108 .ignore_then(
109 one_of("0123456789")
110 .repeated()
111 .exactly(4)
112 .collect::<String>(),
113 )
114 .then(
115 just("/").ignore_then(
116 one_of("0123456789")
117 .repeated()
118 .exactly(2)
119 .collect::<String>(),
120 ),
121 )
122 .then(
123 just("/").ignore_then(
124 one_of("0123456789")
125 .repeated()
126 .exactly(2)
127 .collect::<String>(),
128 ),
129 )
130 .then(
131 just(" ").ignore_then(
132 one_of("0123456789")
133 .repeated()
134 .exactly(2)
135 .collect::<String>(),
136 ),
137 )
138 .then(
139 just(":").ignore_then(
140 one_of("0123456789")
141 .repeated()
142 .exactly(2)
143 .collect::<String>(),
144 ),
145 )
146 .then(
147 just(":")
148 .ignore_then(
149 one_of("0123456789")
150 .repeated()
151 .exactly(2)
152 .collect::<String>(),
153 )
154 .or_not(),
155 )
156 .then_ignore(just("]"))
157 .try_map(
158 |(((((year, month), day), hour), minute), second),
159 span: chumsky::span::SimpleSpan| {
160 let second = second.unwrap_or_else(|| "00".to_string());
161 let format = time::macros::format_description!(
162 "[year]/[month]/[day] [hour]:[minute]:[second]"
163 );
164 Ok(Some(
165 time::PrimitiveDateTime::parse(
166 &format!("{year}/{month}/{day} {hour}:{minute}:{second}"),
167 format,
168 ).map_err(|e| chumsky::error::Rich::custom(span, format!("{e:?}")))?
169 ))
170 }
171 )
172 .or(just("[[year,datetime,slt]/[mthnum,datetime,slt]/[day,datetime,slt] [hour,datetime,slt]:[min,datetime,slt]]").map(|_| None))
173 .then_ignore(whitespace())
174 .then(chat_log_event_parser())
175 .try_map(
176 |(timestamp, event),
177 _span: chumsky::span::SimpleSpan| {
178 Ok(ChatLogLine {
179 timestamp,
180 event,
181 })
182 },
183 )
184}
185
186macro_rules! take_until {
188 ($p:expr) => {
189 any()
190 .and_is($p.not())
191 .repeated()
192 .collect::<String>()
193 .then($p)
194 };
195}
196pub(crate) use take_until;
197
198#[expect(
199 clippy::print_stderr,
200 reason = "unfortunately our ariadne reports with ANSI are just gibberish in the latest tracing version because someone thought the ability to use ANSI escape codes was a security risk"
201)]
202#[cfg(test)]
203mod test {
204 use std::io::{BufRead as _, BufReader};
205
206 use super::*;
207
208 #[derive(Debug, serde::Deserialize)]
210 struct EnvOptions {
211 #[serde(
212 deserialize_with = "serde_aux::field_attributes::deserialize_vec_from_string_or_vec"
213 )]
214 test_avatar_names: Vec<String>,
215 }
216
217 #[derive(thiserror::Error, Debug)]
219 pub(crate) enum TestError {
220 #[error("error loading environment: {0}")]
222 Env(#[from] envy::Error),
223 #[error("error loading .env file: {0}")]
225 DotEnv(#[from] dotenvy::Error),
226 #[error("error determining current user home directory")]
228 HomeDir,
229 #[error("error opening chat log file {0}: {1}")]
231 OpenChatLogFile(std::path::PathBuf, std::io::Error),
232 #[error("error reading chat log line from file: {0}")]
234 ChatLogLineRead(std::io::Error),
235 }
236
237 pub(crate) fn avatar_log_dir(avatar_name: &str) -> Result<std::path::PathBuf, TestError> {
239 let avatar_dir_name = avatar_name.replace(' ', "_").to_lowercase();
240 tracing::debug!("Avatar dir name: {}", avatar_dir_name);
241
242 let Some(home_dir) = dirs2::home_dir() else {
243 tracing::error!("Could not determine current user home directory");
244 return Err(TestError::HomeDir);
245 };
246
247 Ok(home_dir.join(".firestorm/").join(avatar_dir_name))
248 }
249
250 #[tracing_test::traced_test]
251 #[tokio::test]
252 async fn test_log_line_parser() -> Result<(), TestError> {
253 dotenvy::dotenv()?;
254 let env_options = envy::from_env::<EnvOptions>()?;
255 for avatar_name in env_options.test_avatar_names {
256 let avatar_dir = avatar_log_dir(&avatar_name)?;
257 let local_chat_log_file = avatar_dir.join("chat.txt");
258 let file = std::fs::File::open(&local_chat_log_file)
259 .map_err(|e| TestError::OpenChatLogFile(local_chat_log_file.clone(), e))?;
260 let file = BufReader::new(file);
261 let mut last_line: Option<String> = None;
262 let mut failed_to_parse_line = false;
263 for line in file.lines() {
264 let line = line.map_err(TestError::ChatLogLineRead)?;
265 if (line.starts_with(' ') || line.is_empty())
266 && let Some(ll) = last_line
267 {
268 last_line = Some(format!("{ll}\n{line}"));
269 continue;
270 }
271 if let Some(ref ll) = last_line {
272 match chat_log_line_parser().parse(ll).into_result() {
273 Err(e) => {
274 tracing::error!("failed to parse line\n{}", ll);
275 eprintln!(
276 "{}",
277 utils::ChumskyError {
278 description: "the above line".to_string(),
279 source: ll.to_owned(),
280 errors: e.into_iter().map(|e| e.into_owned()).collect(),
281 }
282 );
283 failed_to_parse_line = true;
284 }
285 Ok(parsed_line) => {
286 if let ChatLogLine {
287 timestamp: _,
288 event:
289 ChatLogEvent::SystemMessage {
290 message:
291 system_messages::SystemMessage::OtherSystemMessage {
292 ref message,
293 },
294 },
295 } = parsed_line
296 {
297 let message = message.to_string();
298 tracing::info!("parsed line\n{}\n{:?}", ll, parsed_line);
299 if message.starts_with("The message sent to") &&
300 let Err(e) =
301 system_messages::chat_message_still_being_processed_message_parser()
302 .parse(&message).into_result()
303 {
304 eprintln!("{}", utils::ChumskyError {
305 description: "group chat message still being processed".to_string(),
306 source: message.to_owned(),
307 errors: e.into_iter().map(|e| e.into_owned()).collect(),
308 });
309 }
310 if message.contains("owned by")
311 && message.contains("gave you")
312 && let Err(e) =
313 system_messages::object_gave_object_message_parser()
314 .parse(&message)
315 .into_result()
316 {
317 eprintln!(
318 "{}",
319 utils::ChumskyError {
320 description: "owned by gave you".to_string(),
321 source: message.to_owned(),
322 errors: e.into_iter().map(|e| e.into_owned()).collect(),
323 }
324 );
325 }
326 if message.contains("An object named")
327 && message.contains("gave you this folder")
328 && let Err(e) =
329 system_messages::object_gave_folder_message_parser()
330 .parse(&message)
331 .into_result()
332 {
333 eprintln!(
334 "{}",
335 utils::ChumskyError {
336 description: "An object named ... gave you this folder"
337 .to_string(),
338 source: message.to_owned(),
339 errors: e.into_iter().map(|e| e.into_owned()).collect(),
340 }
341 );
342 }
343 if message.starts_with("Can't rez object")
344 && message.contains(
345 "because the owner of this land does not allow it",
346 )
347 && let Err(e) =
348 system_messages::permission_to_rez_object_denied_message_parser()
349 .parse(&message).into_result()
350 {
351 eprintln!("{}", utils::ChumskyError {
352 description: "permission to rez object denied".to_string(),
353 source: message.to_owned(),
354 errors: e.into_iter().map(|e| e.into_owned()).collect(),
355 });
356 }
357 if message.starts_with("Teleport completed from")
358 && let Err(e) =
359 system_messages::teleport_completed_message_parser()
360 .parse(&message)
361 .into_result()
362 {
363 eprintln!(
364 "{}",
365 utils::ChumskyError {
366 description: "teleported completed".to_string(),
367 source: message.to_owned(),
368 errors: e.into_iter().map(|e| e.into_owned()).collect(),
369 }
370 );
371 }
372 if message.starts_with('[')
373 && message.contains("status.secondlifegrid.net")
374 && let Err(e) =
375 system_messages::grid_status_event_message_parser()
376 .parse(&message)
377 .into_result()
378 {
379 eprintln!(
380 "{}",
381 utils::ChumskyError {
382 description: "grid status event".to_string(),
383 source: message.to_owned(),
384 errors: e.into_iter().map(|e| e.into_owned()).collect(),
385 }
386 );
387 }
388 if message.starts_with("Object ID:")
389 && let Err(e) =
390 system_messages::extended_script_info_message_parser()
391 .parse(&message)
392 .into_result()
393 {
394 eprintln!(
395 "{}",
396 utils::ChumskyError {
397 description: "extended script info".to_string(),
398 source: message.to_owned(),
399 errors: e.into_iter().map(|e| e.into_owned()).collect(),
400 }
401 );
402 }
403 if message.starts_with("Bridge")
404 && let Err(e) = system_messages::bridge_message_parser()
405 .parse(&message)
406 .into_result()
407 {
408 eprintln!(
409 "{}",
410 utils::ChumskyError {
411 description: "bridge message".to_string(),
412 source: message.to_owned(),
413 errors: e.into_iter().map(|e| e.into_owned()).collect(),
414 }
415 );
416 }
417
418 if message.starts_with("You paid")
419 && let Err(e) = system_messages::sent_payment_message_parser()
420 .parse(&message)
421 .into_result()
422 {
423 eprintln!(
424 "{}",
425 utils::ChumskyError {
426 description: "sent payment".to_string(),
427 source: message.to_owned(),
428 errors: e.into_iter().map(|e| e.into_owned()).collect(),
429 }
430 );
431 }
432 if message.contains("Take Linden dollars") &&
433 let Err(e) = system_messages::object_granted_permission_to_take_money_parser()
434 .parse(&message).into_result()
435 {
436 eprintln!(
437 "{}",
438 utils::ChumskyError {
439 description: "object granted permission to take money".to_string(),
440 source: message.to_owned(),
441 errors: e.into_iter().map(|e| e.into_owned()).collect(),
442 }
443 );
444 }
445 if message.starts_with("You have offered a calling card")
446 && let Err(e) =
447 system_messages::offered_calling_card_message_parser()
448 .parse(&message)
449 .into_result()
450 {
451 eprintln!(
452 "{}",
453 utils::ChumskyError {
454 description: "offered calling card".to_string(),
455 source: message.to_owned(),
456 errors: e.into_iter().map(|e| e.into_owned()).collect(),
457 }
458 );
459 }
460 if message.starts_with("Draw Distance set")
461 && let Err(e) =
462 system_messages::draw_distance_set_message_parser()
463 .parse(&message)
464 .into_result()
465 {
466 eprintln!(
467 "{}",
468 utils::ChumskyError {
469 description: "draw distance set".to_string(),
470 source: message.to_owned(),
471 errors: e.into_iter().map(|e| e.into_owned()).collect(),
472 }
473 );
474 }
475 if message.starts_with("Your object") &&
476 let Err(e) =
477 system_messages::your_object_has_been_returned_message_parser()
478 .parse(&message).into_result()
479 {
480 eprintln!(
481 "{}",
482 utils::ChumskyError {
483 description: "your object has been returned".to_string(),
484 source: message.to_owned(),
485 errors: e.into_iter().map(|e| e.into_owned()).collect(),
486 }
487 );
488 }
489 }
490 }
491 }
492 }
493 last_line = Some(line);
494 }
495 #[expect(
496 clippy::panic,
497 reason = "panics are okay - even intentional - in tests"
498 )]
499 if failed_to_parse_line {
500 panic!("Failed to parse a line");
501 }
502 }
503 Ok(())
506 }
507}