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