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>> {
30 let mut entries = Vec::new();
31
32 for (peer_id, log_path) in self.log_files() {
33 if let Ok(file) = File::open(&log_path) {
34 let reader = BufReader::new(file);
35 for line in reader.lines().flatten() {
36 let parsed = parse_log_line(&peer_id, &line);
37 let is_new_entry = parsed.timestamp.is_some()
38 || parsed.timestamp_raw.is_some()
39 || entries.is_empty();
40
41 if is_new_entry {
42 entries.push(parsed);
43 } else if let Some(last) = entries.last_mut() {
44 if !parsed.message.is_empty() {
45 if !last.message.is_empty() {
46 last.message.push('\n');
47 }
48 last.message.push_str(&parsed.message);
49 }
50 }
51 }
52 }
53 }
54
55 entries.sort_by(|a, b| match (&a.timestamp, &b.timestamp) {
57 (Some(a_ts), Some(b_ts)) => a_ts.cmp(b_ts),
58 (Some(_), None) => Ordering::Less,
59 (None, Some(_)) => Ordering::Greater,
60 _ => a.peer_id.cmp(&b.peer_id),
61 });
62
63 Ok(entries)
64 }
65
66 pub fn tail_logs(&self) -> Result<impl Iterator<Item = LogEntry> + '_> {
68 Ok(self.read_logs()?.into_iter())
71 }
72}
73
74fn parse_log_line(peer_id: &str, line: &str) -> LogEntry {
75 let cleaned = strip_ansi_codes(line);
76 let trimmed = cleaned.trim();
77
78 if let Some(entry) = parse_rfc3339_style(peer_id, trimmed) {
79 return entry;
80 }
81
82 if let Some(entry) = parse_bracket_style(peer_id, trimmed) {
83 return entry;
84 }
85
86 LogEntry {
87 peer_id: peer_id.to_string(),
88 timestamp_raw: None,
89 timestamp: None,
90 level: None,
91 message: trimmed.to_string(),
92 }
93}
94
95fn parse_rfc3339_style(peer_id: &str, line: &str) -> Option<LogEntry> {
96 let (ts_token, rest) = split_first_token(line)?;
97 let parsed = chrono::DateTime::parse_from_rfc3339(ts_token).ok()?;
98 let timestamp = parsed.with_timezone(&Utc);
99
100 let (level_token, remainder) = split_first_token(rest).unwrap_or(("", ""));
101 let level = if level_token.is_empty() {
102 None
103 } else {
104 Some(
105 level_token
106 .trim_matches(|c| c == ':' || c == '-')
107 .to_string(),
108 )
109 };
110
111 Some(LogEntry {
112 peer_id: peer_id.to_string(),
113 timestamp_raw: Some(ts_token.to_string()),
114 timestamp: Some(timestamp),
115 level,
116 message: remainder.trim().to_string(),
117 })
118}
119
120fn parse_bracket_style(peer_id: &str, line: &str) -> Option<LogEntry> {
121 let rest = line.strip_prefix('[')?;
122 let end_idx = rest.find(']')?;
123 let inside = &rest[..end_idx];
124
125 let (ts_token, inside_rest) = split_first_token(inside)?;
126 let parsed = chrono::DateTime::parse_from_rfc3339(ts_token).ok()?;
127 let timestamp = parsed.with_timezone(&Utc);
128
129 let (level_token, _) = split_first_token(inside_rest).unwrap_or(("", ""));
130 let level = if level_token.is_empty() {
131 None
132 } else {
133 Some(
134 level_token
135 .trim_matches(|c| c == ':' || c == '-')
136 .to_string(),
137 )
138 };
139
140 let remainder = rest[end_idx + 1..].trim();
141
142 Some(LogEntry {
143 peer_id: peer_id.to_string(),
144 timestamp_raw: Some(ts_token.to_string()),
145 timestamp: Some(timestamp),
146 level,
147 message: remainder.to_string(),
148 })
149}
150
151fn split_first_token(input: &str) -> Option<(&str, &str)> {
152 let trimmed = input.trim_start();
153 if trimmed.is_empty() {
154 return None;
155 }
156
157 if let Some(idx) = trimmed.find(char::is_whitespace) {
158 let token = &trimmed[..idx];
159 let rest = trimmed[idx..].trim_start();
160 Some((token, rest))
161 } else {
162 Some((trimmed, ""))
163 }
164}
165
166pub(crate) fn read_log_file(log_path: &Path) -> Result<Vec<LogEntry>> {
168 let peer_id = log_path
169 .parent()
170 .and_then(|p| p.file_name())
171 .and_then(|n| n.to_str())
172 .unwrap_or("unknown");
173
174 let mut entries = Vec::new();
175
176 if let Ok(file) = File::open(log_path) {
177 let reader = BufReader::new(file);
178 for line in reader.lines().flatten() {
179 let parsed = parse_log_line(peer_id, &line);
180 let is_new_entry =
181 parsed.timestamp.is_some() || parsed.timestamp_raw.is_some() || entries.is_empty();
182
183 if is_new_entry {
184 entries.push(parsed);
185 } else if let Some(last) = entries.last_mut() {
186 if !parsed.message.is_empty() {
187 if !last.message.is_empty() {
188 last.message.push('\n');
189 }
190 last.message.push_str(&parsed.message);
191 }
192 }
193 }
194 }
195
196 Ok(entries)
197}
198
199fn strip_ansi_codes(input: &str) -> String {
200 if !input.contains('\x1b') {
201 return input.to_string();
202 }
203
204 let mut output = String::with_capacity(input.len());
205 let mut chars = input.chars().peekable();
206
207 while let Some(ch) = chars.next() {
208 if ch == '\x1b' {
209 if matches!(chars.peek(), Some('[')) {
210 chars.next(); while let Some(&next) = chars.peek() {
212 if ('@'..='~').contains(&next) {
214 chars.next();
215 break;
216 }
217 chars.next();
218 }
219 }
220 continue;
221 }
222 output.push(ch);
223 }
224
225 output
226}