1use anyhow::{Context, Result};
2use log::{info, debug, warn};
3use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
4use regex::Regex;
5use std::path::PathBuf;
6use tokio::sync::mpsc;
7use tokio::sync::mpsc::Receiver;
8
9#[derive(Debug, Clone)]
10#[allow(dead_code)]
11pub struct ChatMessage {
12 pub player: String,
13 pub content: String,
14 pub timestamp: chrono::DateTime<chrono::Local>,
15}
16
17pub struct LogMonitor {
18 log_path: PathBuf,
19 chat_pattern: Regex,
20 join_pattern: Regex,
21 leave_pattern: Regex,
22 death_pattern: Regex,
23}
24
25#[derive(Debug, Clone)]
26#[allow(dead_code)]
27pub enum LogEvent {
28 Chat(ChatMessage),
29 PlayerJoin(String),
30 PlayerLeave(String),
31 PlayerDeath(String),
32 ServerStart,
33 ServerStop,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38struct FileId {
39 size: u64,
40 modified_secs: i64,
41}
42
43impl FileId {
44 fn from_metadata(metadata: &std::fs::Metadata) -> Option<Self> {
46 let size = metadata.len();
47 let modified = metadata.modified().ok()?;
48 let modified_secs = modified.duration_since(std::time::UNIX_EPOCH).ok()?.as_secs() as i64;
49 Some(Self { size, modified_secs })
50 }
51}
52
53impl LogMonitor {
54 pub fn new(log_path: PathBuf) -> Result<Self> {
55 let chat_pattern = Regex::new(r"\[(\d{2}:\d{2}:\d{2})\] \[Server thread/INFO\]: <([^>]+)> (.+)")
56 .context("Failed to compile chat pattern")?;
57
58 let join_pattern = Regex::new(r"\[(\d{2}:\d{2}:\d{2})\] \[Server thread/INFO\]: (\w+) joined the game")
59 .context("Failed to compile join pattern")?;
60
61 let leave_pattern = Regex::new(r"\[(\d{2}:\d{2}:\d{2})\] \[Server thread/INFO\]: (\w+) left the game")
62 .context("Failed to compile leave pattern")?;
63
64 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)")
65 .context("Failed to compile death pattern")?;
66
67 Ok(Self {
68 log_path,
69 chat_pattern,
70 join_pattern,
71 leave_pattern,
72 death_pattern,
73 })
74 }
75
76 pub fn start_monitoring(self) -> Result<Receiver<LogEvent>> {
77 let (tx, rx) = mpsc::channel(100);
78
79 let log_path = self.log_path.clone();
80
81 info!("Started monitoring log file: {:?}", log_path);
82
83 let mut last_offset: u64 = 0;
84 let mut last_file_id: Option<FileId> = None;
85
86 if log_path.exists() {
87 if let Ok(metadata) = std::fs::metadata(&log_path) {
88 last_offset = metadata.len();
89 last_file_id = FileId::from_metadata(&metadata);
90 }
91 } else {
92 warn!(
93 "Log file not found: {:?}. \n\
94 Please start the Minecraft server first to generate the log file.",
95 log_path
96 );
97 }
98
99 let (notify_tx, notify_rx) = std::sync::mpsc::channel();
100
101 let mut watcher = RecommendedWatcher::new(
102 move |res: Result<Event, notify::Error>| {
103 if let Ok(event) = res {
104 let _ = notify_tx.send(event);
105 }
106 },
107 Config::default(),
108 ).context("Failed to create file watcher")?;
109
110 let parent_dir = log_path
111 .parent()
112 .context("Log file has no parent directory")?
113 .to_path_buf();
114
115 let parent_dir_for_unwatch = parent_dir.clone();
116
117 watcher
118 .watch(&parent_dir, RecursiveMode::NonRecursive)
119 .context("Failed to watch log directory")?;
120
121 let patterns = (
122 self.chat_pattern,
123 self.join_pattern,
124 self.leave_pattern,
125 self.death_pattern,
126 );
127
128 std::thread::spawn(move || {
129 loop {
130 match notify_rx.recv() {
131 Ok(event) => {
132 if !event.paths.iter().any(|p| p.file_name().map(|n| n == "latest.log").unwrap_or(false)) {
133 continue;
134 }
135
136 match event.kind {
137 EventKind::Modify(_) | EventKind::Create(_) => {
138 if let Ok(events) = Self::check_file_changes(
139 &log_path,
140 &mut last_offset,
141 &mut last_file_id,
142 &patterns,
143 ) {
144 for log_event in events {
145 if tx.blocking_send(log_event).is_err() {
146 debug!("Receiver dropped, stopping monitor");
147 return;
148 }
149 }
150 }
151 }
152 EventKind::Remove(_) => {
153 debug!("Log file removed/rotated, resetting state");
154 last_offset = 0;
155 last_file_id = None;
156 }
157 _ => {}
158 }
159 }
160 Err(_) => {
161 debug!("Notify channel closed, stopping monitor");
162 break;
163 }
164 }
165 }
166 let _ = watcher.unwatch(&parent_dir_for_unwatch);
167 });
168
169 Ok(rx)
170 }
171
172 fn check_file_changes(
173 log_path: &PathBuf,
174 last_offset: &mut u64,
175 last_file_id: &mut Option<FileId>,
176 patterns: &(Regex, Regex, Regex, Regex),
177 ) -> Result<Vec<LogEvent>> {
178 if !log_path.exists() {
180 return Ok(Vec::new());
181 }
182
183 let metadata = std::fs::metadata(log_path)?;
184 let current_file_id = FileId::from_metadata(&metadata);
185
186 if let (Some(current), Some(last)) = (current_file_id, *last_file_id) {
188 if current != last {
189 debug!("File rotation detected, resetting offset");
190 *last_offset = 0;
191 }
192 }
193
194 let current_size = metadata.len();
195
196 if current_size < *last_offset {
198 debug!("File size decreased, resetting offset");
199 *last_offset = 0;
200 }
201
202 if current_size == *last_offset {
204 return Ok(Vec::new());
205 }
206
207 let new_content = Self::read_from_offset(log_path, *last_offset, current_size)?;
209
210 *last_offset = current_size;
212 *last_file_id = current_file_id;
213
214 let events = Self::parse_lines(&new_content, patterns);
215 Ok(events)
216 }
217
218 fn read_from_offset(log_path: &PathBuf, offset: u64, end: u64) -> Result<String> {
220 use std::fs::File;
221 use std::io::{Read, Seek, SeekFrom};
222
223 let mut file = File::open(log_path)?;
224 file.seek(SeekFrom::Start(offset))?;
225
226 let bytes_to_read = (end - offset) as usize;
227 let mut buffer = Vec::with_capacity(bytes_to_read);
228 file.take(bytes_to_read as u64).read_to_end(&mut buffer)?;
229
230 String::from_utf8(buffer)
231 .context("Failed to convert file content to UTF-8 string")
232 }
233
234 fn parse_lines(content: &str, patterns: &(Regex, Regex, Regex, Regex)) -> Vec<LogEvent> {
235 let (chat_pattern, join_pattern, leave_pattern, death_pattern) = patterns;
236 let mut events = Vec::new();
237
238 for line in content.lines() {
239 if let Some(caps) = chat_pattern.captures(line) {
240 if let (Some(player), Some(content)) = (caps.get(2), caps.get(3)) {
241 events.push(LogEvent::Chat(ChatMessage {
242 player: player.as_str().to_string(),
243 content: content.as_str().to_string(),
244 timestamp: chrono::Local::now(),
245 }));
246 }
247 } else if let Some(caps) = join_pattern.captures(line) {
248 if let Some(player) = caps.get(2) {
249 events.push(LogEvent::PlayerJoin(player.as_str().to_string()));
250 }
251 } else if let Some(caps) = leave_pattern.captures(line) {
252 if let Some(player) = caps.get(2) {
253 events.push(LogEvent::PlayerLeave(player.as_str().to_string()));
254 }
255 } else if let Some(caps) = death_pattern.captures(line) {
256 if let Some(player) = caps.get(2) {
257 events.push(LogEvent::PlayerDeath(player.as_str().to_string()));
258 }
259 }
260 }
261
262 events
263 }
264}