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    ///
30    /// For Docker containers, this fetches logs from the Docker API and caches them.
31    /// For local processes, this reads directly from log files.
32    pub fn read_logs(&self) -> Result<Vec<LogEntry>> {
33        let mut entries = Vec::new();
34
35        // Use process read_logs() to fetch from Docker API if needed
36        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                    // Fallback to reading from file if process read fails
42                    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        // Sort by timestamp if parseable
68        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    /// Tail logs from all peers (blocking)
79    pub fn tail_logs(&self) -> Result<impl Iterator<Item = LogEntry> + '_> {
80        // TODO: Implement proper log tailing with file watchers
81        // For now, just read current logs
82        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
178/// Read a single log file and parse its entries
179pub(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(); // consume '['
223                while let Some(&next) = chars.peek() {
224                    // ANSI sequence ends with byte in 0x40..=0x7E
225                    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}