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::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
166fn strip_ansi_codes(input: &str) -> String {
167 if !input.contains('\x1b') {
168 return input.to_string();
169 }
170
171 let mut output = String::with_capacity(input.len());
172 let mut chars = input.chars().peekable();
173
174 while let Some(ch) = chars.next() {
175 if ch == '\x1b' {
176 if matches!(chars.peek(), Some('[')) {
177 chars.next(); while let Some(&next) = chars.peek() {
179 if ('@'..='~').contains(&next) {
181 chars.next();
182 break;
183 }
184 chars.next();
185 }
186 }
187 continue;
188 }
189 output.push(ch);
190 }
191
192 output
193}