freenet_test_network/
logs.rs

1use 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/// A log entry from a peer with timestamp
9#[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    /// Get all log files from the network
20    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    /// Read all logs in chronological order
29    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        // Sort by timestamp if parseable
56        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    /// Tail logs from all peers (blocking)
67    pub fn tail_logs(&self) -> Result<impl Iterator<Item = LogEntry> + '_> {
68        // TODO: Implement proper log tailing with file watchers
69        // For now, just read current logs
70        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
166/// Read a single log file and parse its entries
167pub(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(); // consume '['
211                while let Some(&next) = chars.peek() {
212                    // ANSI sequence ends with byte in 0x40..=0x7E
213                    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}