use std::{ fs::{self, File}, io::Write, path::Path, process::exit, }; use ab_glyph::{FontRef, PxScale}; use anyhow::{Error, Result}; use clap::Parser; use csv::Writer; use dialoguer::MultiSelect; use image::{Rgb, RgbImage}; use imageproc::drawing::{draw_text_mut, text_size}; use once_cell::sync::Lazy; use rayon::{ iter::{IntoParallelIterator, ParallelIterator}, str::ParallelString, }; use regex::Regex; /// Simple program to greet a person #[derive(Parser, Debug)] #[command(version, about, long_about = None)] struct Args { #[arg(short, long, help = "Path to log file")] input: String, #[arg( short, long, help = "Format of output file", default_value_t, value_enum )] format: OutputFormat, #[arg(short, long, help = "Path to output file")] output: String, } #[derive(clap::ValueEnum, Debug, Clone, Default)] enum OutputFormat { Image, Csv, #[default] Txt, } fn main() -> Result<()> { let args = Args::parse(); if !fs::exists(&args.input)? { println!("Please provide a valid file path!"); exit(1) } let log_contents = fs::read_to_string(&args.input)?; let split_log = log_contents.par_split('\n'); let filtered_log: Vec<&str> = split_log.filter(|x| is_possible_chat_msg(x)).collect(); let extracted: Vec = filtered_log .clone() .into_par_iter() .map(|x| { format!( "{} {}", extract_date_time(x).first().unwrap(), extract_message(x) ) }) .collect(); let selection = MultiSelect::new() .with_prompt("What messages do you want to render?") .items(&extracted) .interact()?; match &args.format { OutputFormat::Image => save_image_file(&args.output, extracted, selection)?, OutputFormat::Csv => save_csv_file(&args.output, extracted, selection)?, OutputFormat::Txt => save_txt_file(&args.output, extracted, selection)?, } Ok(()) } fn save_image_file( output: &String, extracted: Vec, selection: Vec, ) -> Result<(), Error> { let out_path = Path::new(output); let font = FontRef::try_from_slice(include_bytes!("../font/Minecraft-Regular.ttf")).unwrap(); let height = 40.0; let scale = PxScale { x: height * 2.0, y: height, }; let mut selected: Vec = vec![]; if selection.is_empty() { selected = extracted; } else { for x in selection { selected.push(extracted[x].clone()); } } let mut longest: String = selected.first().expect("selected list is empty?").clone(); for msg in &selected { if msg.len() > longest.len() { longest = msg.clone(); } } // let text = extracted[selection[0]].clone(); let (w, h) = text_size(scale, &font, &longest); println!("Text size: {w}x{h}"); let image_width = w + 8; let image_height = (h * selected.len() as u32) + (4 * selected.len() as u32) + 4; let mut image = RgbImage::new(image_width, image_height); for (current_line, msg) in (0_u32..).zip(selected.iter()) { draw_text_mut( &mut image, Rgb([254u8, 254u8, 254u8]), 4, ((current_line * h) + (4 * current_line)) .try_into() .unwrap(), scale, &font, msg, ); } image.save(out_path).unwrap(); Ok(()) } fn save_txt_file( output: &String, extracted: Vec, selection: Vec, ) -> Result<(), Error> { let mut out_file = File::create(output)?; if selection.is_empty() { for msg in extracted { out_file.write_all(msg.as_bytes())?; out_file.write_all(b"\n")?; } } else { for msg in selection { out_file.write_all(extracted[msg].as_bytes())?; out_file.write_all(b"\n")?; } } Ok(()) } fn save_csv_file( output: &String, extracted: Vec, selection: Vec, ) -> Result<(), Error> { let mut out_file = Writer::from_path(output)?; out_file.write_record(["date", "time", "msg"])?; let mut selected: Vec = vec![]; if selection.is_empty() { selected = extracted; } else { for x in selection { selected.push(extracted[x].clone()); } } for msg in selected { let mut time: Vec<&str> = extract_date_time(&msg); // if there is only 1 entry in the vec then it has to be a client time so we can just add a pointless entry for the csv if time.len() == 1 { time.insert(0, "Null"); } out_file.write_record([time[0], time[1], &extract_message(&msg)])?; } Ok(()) } fn extract_message(msg: &str) -> String { /// follow up by extracting just the user and the message static EXTRACT_MSG: Lazy = Lazy::new(|| Regex::new(r"<.*> .*").unwrap()); EXTRACT_MSG .captures(msg) .expect("Unable to extract time") .get(0) .unwrap() .as_str() .to_string() } fn extract_date_time(msg: &str) -> Vec<&str> { static EXTRACT_TIME: Lazy = Lazy::new(|| Regex::new(r"^\[.*?\]").unwrap()); let time: Vec<&str> = EXTRACT_TIME .captures(msg) .expect("Unable to extract time") .get(0) .unwrap() .as_str() .strip_prefix("[") .expect("Unable to remove time prefix") .strip_suffix("]") .expect("Unable to remove time suffix") .split(' ') .collect(); time } fn is_possible_chat_msg(input: &str) -> bool { /// first pass to find all possible lines that could have a chat message static SERVER_MSG_RE: Lazy = Lazy::new(|| { Regex::new( r"\[.*\] \[Server thread\/INFO\] \[net.minecraft.server.MinecraftServer\/\]: <.*>*", ) .unwrap() }); static CLIENT_PRISM_MSG_RE: Lazy = Lazy::new(|| { Regex::new(r"\[.*\] \[Render thread\/INFO\] \[minecraft\/ChatComponent\]: \[CHAT\] <.*>*") .unwrap() }); static CLIENT_LOG_MSG_RE: Lazy = Lazy::new(|| { Regex::new(r"\[.*\] \[Render thread\/INFO\] \[net.minecraft.client.gui.components.ChatComponent\/\]: \[CHAT\] <.*>*") .unwrap() }); // This is here just in case I need to get a chat message from a strange place static _CLIENT_CATCHALL_MSG_RE: Lazy = Lazy::new(|| Regex::new(r".*?: \[CHAT\] .*").unwrap()); SERVER_MSG_RE.is_match(input) || CLIENT_PRISM_MSG_RE.is_match(input) || CLIENT_LOG_MSG_RE.is_match(input) } #[cfg(test)] mod tests { use super::*; const TEST_SERVER_MSG: &str = "[05Jul2025 12:41:12.295] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: <🐈 Vftdan> :3"; const TEST_CLIENT_LOG_MSG: &str = "[11Jul2025 20:30:20.286] [Render thread/INFO] [net.minecraft.client.gui.components.ChatComponent/]: [CHAT] tehe"; const TEST_PRISM_MSG: &str = "[16:53:50] [Render thread/INFO] [minecraft/ChatComponent]: [CHAT] ;3"; const RANDOM_LOG_LINES: [&str; 5] = [ "[05Jul2025 13:08:11.886] [VoiceChatPacketProcessingThread/INFO] [voicechat/]: [voicechat] Player 399aedb6-a257-49d1-930b-af62fc328ae7 timed out", "[05Jul2025 13:09:19.890] [Server thread/INFO] [me.ichun.mods.serverpause.common.core.MinecraftServerMethods/]: Saving and pausing game...", "[05Jul2025 12:02:58.057] [Server thread/INFO] [net.minecraft.server.MinecraftServer/]: alto joined the game", "java.lang.NullPointerException: Cannot invoke \"net.minecraft.world.Container.getContainerSize()\" because the return value of \"net.neoforged.neoforge.items.wrapper.InvWrapper.getInv()\" is null", "[05Jul2025 10:31:36.112] [Server thread/INFO] [owo/]: Receiving client config", ]; #[test] fn detect_server_chat_messages() { assert!(is_possible_chat_msg(TEST_SERVER_MSG)); } #[test] fn detect_prism_chat_messages() { assert!(is_possible_chat_msg(TEST_PRISM_MSG)); } #[test] fn detect_client_log_chat_messages() { assert!(is_possible_chat_msg(TEST_CLIENT_LOG_MSG)); } #[test] fn extract_chat_message_server() { assert_eq!("<🐈 Vftdan> :3", &extract_message(TEST_SERVER_MSG)); } #[test] fn extract_chat_message_client_log() { assert_eq!( " tehe", &extract_message(TEST_CLIENT_LOG_MSG) ); } #[test] fn extract_chat_message_prism_log() { assert_eq!( " ;3", &extract_message(TEST_PRISM_MSG) ); } #[test] fn extract_datetime_server_messages() { let msg_string = TEST_SERVER_MSG.to_string(); let datetime: Vec<&str> = extract_date_time(&msg_string); println!("server datetime: {:?}", datetime); let correct_datetime: Vec<&str> = vec![&"05Jul2025", &"12:41:12.295"]; assert_eq!(datetime, correct_datetime); } #[test] fn extract_datetime_client_messages() { let msg_string = TEST_CLIENT_LOG_MSG.to_string(); let datetime: Vec<&str> = extract_date_time(&msg_string); println!("server datetime: {:?}", datetime); let correct_datetime: Vec<&str> = vec![&"11Jul2025", &"20:30:20.286"]; assert_eq!(datetime, correct_datetime); } #[test] fn extract_datetime_prism_messages() { let msg_string = TEST_PRISM_MSG.to_string(); let datetime: Vec<&str> = extract_date_time(&msg_string); println!("prism datetime: {:?}", datetime); let correct_datetime: Vec<&str> = vec![&"16:53:50"]; assert_eq!(datetime, correct_datetime); } #[test] fn ignore_random_log_lines() { let mut is_msg: bool = false; for line in RANDOM_LOG_LINES { if is_possible_chat_msg(line) { is_msg = true; } } assert!(!is_msg); } }