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{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: <([^>]+)> (.+)")
60 .context("Failed to compile chat pattern")?;
61
62 let join_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (\w+) joined the game")
63 .context("Failed to compile join pattern")?;
64
65 let leave_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (\w+) left the game")
66 .context("Failed to compile leave pattern")?;
67
68 let death_pattern = Regex::new(r"\[(\d{1,2}:\d{2}:\d{2})\] \[[^\]]+\]: (\w+) .*(died|was|fell|drowned|blew up|burned|froze|suffocated|starved)")
69 .context("Failed to compile death pattern")?;
70
71 Ok(Self {
72 log_path,
73 chat_pattern,
74 join_pattern,
75 leave_pattern,
76 death_pattern,
77 })
78 }
79
80 pub fn start_monitoring(self) -> Result<Receiver<LogEvent>> {
81 let (tx, rx) = mpsc::channel(100);
82
83 let log_path = self.log_path.clone();
84
85 info!("Started monitoring log file: {:?}", log_path);
86
87 let mut last_offset: u64 = 0;
88 let mut last_file_id: Option<FileId> = None;
89
90 if log_path.exists() {
91 if let Ok(metadata) = std::fs::metadata(&log_path) {
92 last_offset = metadata.len();
93 last_file_id = FileId::from_metadata(&metadata);
94 }
95 } else {
96 warn!(
97 "Log file not found: {:?}. \n\
98 Please start the Minecraft server first to generate the log file.",
99 log_path
100 );
101 }
102
103 let (notify_tx, notify_rx) = std::sync::mpsc::channel();
104
105 let mut watcher = RecommendedWatcher::new(
106 move |res: Result<Event, notify::Error>| {
107 if let Ok(event) = res {
108 let _ = notify_tx.send(event);
109 }
110 },
111 Config::default(),
112 ).context("Failed to create file watcher")?;
113
114 let parent_dir = log_path
115 .parent()
116 .context("Log file has no parent directory")?
117 .to_path_buf();
118
119 let parent_dir_for_unwatch = parent_dir.clone();
120
121 watcher
122 .watch(&parent_dir, RecursiveMode::NonRecursive)
123 .context("Failed to watch log directory")?;
124
125 let patterns = (
126 self.chat_pattern,
127 self.join_pattern,
128 self.leave_pattern,
129 self.death_pattern,
130 );
131
132 std::thread::spawn(move || {
133 loop {
134 match notify_rx.recv() {
135 Ok(event) => {
136 if !event.paths.iter().any(|p| p.file_name().map(|n| n == "latest.log").unwrap_or(false)) {
137 continue;
138 }
139
140 match event.kind {
141 EventKind::Modify(_) | EventKind::Create(_) => {
142 if let Ok(events) = Self::check_file_changes(
143 &log_path,
144 &mut last_offset,
145 &mut last_file_id,
146 &patterns,
147 ) {
148 for log_event in events {
149 if tx.blocking_send(log_event).is_err() {
150 debug!("Receiver dropped, stopping monitor");
151 return;
152 }
153 }
154 }
155 }
156 EventKind::Remove(_) => {
157 debug!("Log file removed/rotated, resetting state");
158 last_offset = 0;
159 last_file_id = None;
160 }
161 _ => {}
162 }
163 }
164 Err(_) => {
165 debug!("Notify channel closed, stopping monitor");
166 break;
167 }
168 }
169 }
170 let _ = watcher.unwatch(&parent_dir_for_unwatch);
171 });
172
173 Ok(rx)
174 }
175
176 fn check_file_changes(
177 log_path: &PathBuf,
178 last_offset: &mut u64,
179 last_file_id: &mut Option<FileId>,
180 patterns: &(Regex, Regex, Regex, Regex),
181 ) -> Result<Vec<LogEvent>> {
182 if !log_path.exists() {
184 return Ok(Vec::new());
185 }
186
187 let metadata = std::fs::metadata(log_path)?;
188 let current_file_id = FileId::from_metadata(&metadata);
189
190 if let (Some(current), Some(last)) = (current_file_id, *last_file_id) {
192 if current != last {
193 debug!("File rotation detected, resetting offset");
194 *last_offset = 0;
195 }
196 }
197
198 let current_size = metadata.len();
199
200 if current_size < *last_offset {
202 debug!("File size decreased, resetting offset");
203 *last_offset = 0;
204 }
205
206 if current_size == *last_offset {
208 return Ok(Vec::new());
209 }
210
211 let new_content = Self::read_from_offset(log_path, *last_offset, current_size)?;
213
214 *last_offset = current_size;
216 *last_file_id = current_file_id;
217
218 let events = Self::parse_lines(&new_content, patterns);
219 Ok(events)
220 }
221
222 fn read_from_offset(log_path: &PathBuf, offset: u64, end: u64) -> Result<String> {
224 use std::fs::File;
225 use std::io::{Read, Seek, SeekFrom};
226
227 let mut file = File::open(log_path)?;
228 file.seek(SeekFrom::Start(offset))?;
229
230 let bytes_to_read = (end - offset) as usize;
231 let mut buffer = Vec::with_capacity(bytes_to_read);
232 file.take(bytes_to_read as u64).read_to_end(&mut buffer)?;
233
234 String::from_utf8(buffer.clone())
235 .map_err(|e| {
236 let lossy = String::from_utf8_lossy(&buffer);
237 warn!("UTF-8 decode error, using lossy conversion: {}", e);
238 anyhow::anyhow!("Failed to convert file content to UTF-8: {}", lossy)
239 })
240 .or_else(|_| Ok(String::from_utf8_lossy(&buffer).into_owned()))
241 }
242
243 fn parse_lines(content: &str, patterns: &(Regex, Regex, Regex, Regex)) -> Vec<LogEvent> {
244 let (chat_pattern, join_pattern, leave_pattern, death_pattern) = patterns;
245 let mut events = Vec::new();
246
247 for line in content.lines() {
248 if let Some(caps) = chat_pattern.captures(line) {
249 if let (Some(player), Some(content)) = (caps.get(2), caps.get(3)) {
250 debug!("[Monitor] Parsed chat event: player='{}', content='{}'", player.as_str(), content.as_str());
252 events.push(LogEvent::Chat(ChatMessage {
253 player: player.as_str().to_string(),
254 content: content.as_str().to_string(),
255 timestamp: chrono::Local::now(),
256 }));
257 }
258 } else if let Some(caps) = join_pattern.captures(line) {
259 if let Some(player) = caps.get(2) {
260 events.push(LogEvent::PlayerJoin(player.as_str().to_string()));
261 }
262 } else if let Some(caps) = leave_pattern.captures(line) {
263 if let Some(player) = caps.get(2) {
264 events.push(LogEvent::PlayerLeave(player.as_str().to_string()));
265 }
266 } else if let Some(caps) = death_pattern.captures(line) {
267 if let Some(player) = caps.get(2) {
268 events.push(LogEvent::PlayerDeath(player.as_str().to_string()));
269 }
270 }
271 }
272
273 events
274 }
275}