mc-minder 0.1.0

A smart management suite for Minecraft Fabric servers on Termux/Android
Documentation
use anyhow::{Result, Context};
use log::{info, debug};
use regex::Regex;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::mpsc;
use tokio::time::{interval, Duration};

#[derive(Debug, Clone)]
pub struct ChatMessage {
    pub player: String,
    pub content: String,
    pub timestamp: chrono::DateTime<chrono::Local>,
}

pub struct LogMonitor {
    log_path: PathBuf,
    chat_pattern: Regex,
    join_pattern: Regex,
    leave_pattern: Regex,
    death_pattern: Regex,
}

impl LogMonitor {
    pub fn new(log_path: PathBuf) -> Result<Self> {
        let chat_pattern = Regex::new(r"\[(\d{2}:\d{2}:\d{2})\] \[Server thread/INFO\]: <([^>]+)> (.+)")
            .context("Failed to compile chat pattern")?;
        
        let join_pattern = Regex::new(r"\[(\d{2}:\d{2}:\d{2})\] \[Server thread/INFO\]: (\w+) joined the game")
            .context("Failed to compile join pattern")?;
        
        let leave_pattern = Regex::new(r"\[(\d{2}:\d{2}:\d{2})\] \[Server thread/INFO\]: (\w+) left the game")
            .context("Failed to compile leave pattern")?;
        
        let death_pattern = Regex::new(r"\[(\d{2}:\d{2}:\d{2})\] \[Server thread/INFO\]: (\w+) .*(died|was|fell|drowned|blew up|burned|froze|suffocated|starved)")
            .context("Failed to compile death pattern")?;

        Ok(Self {
            log_path,
            chat_pattern,
            join_pattern,
            leave_pattern,
            death_pattern,
        })
    }

    pub async fn start_monitoring(
        self,
        tx: mpsc::Sender<LogEvent>,
        shutdown: Arc<tokio::sync::Notify>,
    ) -> Result<()> {
        info!("Started monitoring log file: {:?}", self.log_path);

        let mut last_size = std::fs::metadata(&self.log_path)
            .map(|m| m.len())
            .unwrap_or(0);

        let mut poll_interval = interval(Duration::from_millis(500));

        loop {
            tokio::select! {
                _ = shutdown.notified() => {
                    info!("Log monitor shutting down");
                    break;
                }
                
                _ = poll_interval.tick() => {
                    match self.check_for_new_content(&mut last_size) {
                        Ok(Some(events)) => {
                            for log_event in events {
                                if tx.send(log_event).await.is_err() {
                                    debug!("Receiver dropped, stopping monitor");
                                    return Ok(());
                                }
                            }
                        }
                        Ok(None) => {}
                        Err(e) => {
                            debug!("Error checking log content: {}", e);
                        }
                    }
                }
            }
        }

        Ok(())
    }

    fn check_for_new_content(&self, last_size: &mut u64) -> Result<Option<Vec<LogEvent>>> {
        let metadata = std::fs::metadata(&self.log_path)?;
        let current_size = metadata.len();

        if current_size < *last_size {
            *last_size = 0;
            return Ok(None);
        }

        if current_size == *last_size {
            return Ok(None);
        }

        let new_bytes = current_size - *last_size;
        let file = std::fs::File::open(&self.log_path)?;
        use std::io::{Read, Seek, SeekFrom};
        let mut reader = std::io::BufReader::new(file);
        reader.seek(SeekFrom::End(-(new_bytes as i64)))?;

        let mut new_content = String::new();
        reader.read_to_string(&mut new_content)?;
        *last_size = current_size;

        let events = self.parse_lines(&new_content);
        if events.is_empty() {
            Ok(None)
        } else {
            Ok(Some(events))
        }
    }

    fn parse_lines(&self, content: &str) -> Vec<LogEvent> {
        let mut events = Vec::new();
        
        for line in content.lines() {
            if let Some(caps) = self.chat_pattern.captures(line) {
                if let (Some(player), Some(content)) = (caps.get(2), caps.get(3)) {
                    events.push(LogEvent::Chat(ChatMessage {
                        player: player.as_str().to_string(),
                        content: content.as_str().to_string(),
                        timestamp: chrono::Local::now(),
                    }));
                }
            } else if let Some(caps) = self.join_pattern.captures(line) {
                if let Some(player) = caps.get(2) {
                    events.push(LogEvent::PlayerJoin(player.as_str().to_string()));
                }
            } else if let Some(caps) = self.leave_pattern.captures(line) {
                if let Some(player) = caps.get(2) {
                    events.push(LogEvent::PlayerLeave(player.as_str().to_string()));
                }
            } else if let Some(caps) = self.death_pattern.captures(line) {
                if let Some(player) = caps.get(2) {
                    events.push(LogEvent::PlayerDeath(player.as_str().to_string()));
                }
            }
        }
        
        events
    }
}

#[derive(Debug, Clone)]
pub enum LogEvent {
    Chat(ChatMessage),
    PlayerJoin(String),
    PlayerLeave(String),
    PlayerDeath(String),
    ServerStart,
    ServerStop,
}