freenet_test_network/
logs.rs1use crate::{Result, TestNetwork};
2use chrono::{DateTime, Utc};
3use std::cmp::Ordering;
4use std::fs::File;
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7
8#[derive(Debug, Clone)]
10pub struct LogEntry {
11 pub peer_id: String,
12 pub timestamp_raw: Option<String>,
13 pub timestamp: Option<DateTime<Utc>>,
14 pub level: Option<String>,
15 pub message: String,
16}
17
18impl TestNetwork {
19 pub fn log_files(&self) -> Vec<(String, PathBuf)> {
21 self.gateways
22 .iter()
23 .chain(self.peers.iter())
24 .map(|p| (p.id().to_string(), p.log_path()))
25 .collect()
26 }
27
28 pub fn read_logs(&self) -> Result<Vec<LogEntry>> {
33 let mut entries = Vec::new();
34
35 for peer in self.gateways.iter().chain(self.peers.iter()) {
37 match peer.read_logs() {
38 Ok(peer_entries) => entries.extend(peer_entries),
39 Err(e) => {
40 tracing::warn!("Failed to read logs from {}: {}", peer.id(), e);
41 let log_path = peer.log_path();
43 if let Ok(file) = File::open(&log_path) {
44 let reader = BufReader::new(file);
45 for line in reader.lines().flatten() {
46 let parsed = parse_log_line(peer.id(), &line);
47 let is_new_entry = parsed.timestamp.is_some()
48 || parsed.timestamp_raw.is_some()
49 || entries.is_empty();
50
51 if is_new_entry {
52 entries.push(parsed);
53 } else if let Some(last) = entries.last_mut() {
54 if !parsed.message.is_empty() {
55 if !last.message.is_empty() {
56 last.message.push('\n');
57 }
58 last.message.push_str(&parsed.message);
59 }
60 }
61 }
62 }
63 }
64 }
65 }
66
67 entries.sort_by(|a, b| match (&a.timestamp, &b.timestamp) {
69 (Some(a_ts), Some(b_ts)) => a_ts.cmp(b_ts),
70 (Some(_), None) => Ordering::Less,
71 (None, Some(_)) => Ordering::Greater,
72 _ => a.peer_id.cmp(&b.peer_id),
73 });
74
75 Ok(entries)
76 }
77
78 pub fn tail_logs(&self) -> Result<impl Iterator<Item = LogEntry> + '_> {
80 Ok(self.read_logs()?.into_iter())
83 }
84}
85
86fn parse_log_line(peer_id: &str, line: &str) -> LogEntry {
87 let cleaned = strip_ansi_codes(line);
88 let trimmed = cleaned.trim();
89
90 if let Some(entry) = parse_rfc3339_style(peer_id, trimmed) {
91 return entry;
92 }
93
94 if let Some(entry) = parse_bracket_style(peer_id, trimmed) {
95 return entry;
96 }
97
98 LogEntry {
99 peer_id: peer_id.to_string(),
100 timestamp_raw: None,
101 timestamp: None,
102 level: None,
103 message: trimmed.to_string(),
104 }
105}
106
107fn parse_rfc3339_style(peer_id: &str, line: &str) -> Option<LogEntry> {
108 let (ts_token, rest) = split_first_token(line)?;
109 let parsed = chrono::DateTime::parse_from_rfc3339(ts_token).ok()?;
110 let timestamp = parsed.with_timezone(&Utc);
111
112 let (level_token, remainder) = split_first_token(rest).unwrap_or(("", ""));
113 let level = if level_token.is_empty() {
114 None
115 } else {
116 Some(
117 level_token
118 .trim_matches(|c| c == ':' || c == '-')
119 .to_string(),
120 )
121 };
122
123 Some(LogEntry {
124 peer_id: peer_id.to_string(),
125 timestamp_raw: Some(ts_token.to_string()),
126 timestamp: Some(timestamp),
127 level,
128 message: remainder.trim().to_string(),
129 })
130}
131
132fn parse_bracket_style(peer_id: &str, line: &str) -> Option<LogEntry> {
133 let rest = line.strip_prefix('[')?;
134 let end_idx = rest.find(']')?;
135 let inside = &rest[..end_idx];
136
137 let (ts_token, inside_rest) = split_first_token(inside)?;
138 let parsed = chrono::DateTime::parse_from_rfc3339(ts_token).ok()?;
139 let timestamp = parsed.with_timezone(&Utc);
140
141 let (level_token, _) = split_first_token(inside_rest).unwrap_or(("", ""));
142 let level = if level_token.is_empty() {
143 None
144 } else {
145 Some(
146 level_token
147 .trim_matches(|c| c == ':' || c == '-')
148 .to_string(),
149 )
150 };
151
152 let remainder = rest[end_idx + 1..].trim();
153
154 Some(LogEntry {
155 peer_id: peer_id.to_string(),
156 timestamp_raw: Some(ts_token.to_string()),
157 timestamp: Some(timestamp),
158 level,
159 message: remainder.to_string(),
160 })
161}
162
163fn split_first_token(input: &str) -> Option<(&str, &str)> {
164 let trimmed = input.trim_start();
165 if trimmed.is_empty() {
166 return None;
167 }
168
169 if let Some(idx) = trimmed.find(char::is_whitespace) {
170 let token = &trimmed[..idx];
171 let rest = trimmed[idx..].trim_start();
172 Some((token, rest))
173 } else {
174 Some((trimmed, ""))
175 }
176}
177
178pub(crate) fn read_log_file(log_path: &Path) -> Result<Vec<LogEntry>> {
180 let peer_id = log_path
181 .parent()
182 .and_then(|p| p.file_name())
183 .and_then(|n| n.to_str())
184 .unwrap_or("unknown");
185
186 let mut entries = Vec::new();
187
188 if let Ok(file) = File::open(log_path) {
189 let reader = BufReader::new(file);
190 for line in reader.lines().flatten() {
191 let parsed = parse_log_line(peer_id, &line);
192 let is_new_entry =
193 parsed.timestamp.is_some() || parsed.timestamp_raw.is_some() || entries.is_empty();
194
195 if is_new_entry {
196 entries.push(parsed);
197 } else if let Some(last) = entries.last_mut() {
198 if !parsed.message.is_empty() {
199 if !last.message.is_empty() {
200 last.message.push('\n');
201 }
202 last.message.push_str(&parsed.message);
203 }
204 }
205 }
206 }
207
208 Ok(entries)
209}
210
211fn strip_ansi_codes(input: &str) -> String {
212 if !input.contains('\x1b') {
213 return input.to_string();
214 }
215
216 let mut output = String::with_capacity(input.len());
217 let mut chars = input.chars().peekable();
218
219 while let Some(ch) = chars.next() {
220 if ch == '\x1b' {
221 if matches!(chars.peek(), Some('[')) {
222 chars.next(); while let Some(&next) = chars.peek() {
224 if ('@'..='~').contains(&next) {
226 chars.next();
227 break;
228 }
229 chars.next();
230 }
231 }
232 continue;
233 }
234 output.push(ch);
235 }
236
237 output
238}