1use crate::components::log_pane::{BeeLogLine, LogTab};
25
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
29pub struct BeeLogEntry {
30 pub time: String,
34 pub level: String,
38 pub logger: String,
41 pub msg: String,
43 pub extras: Vec<(String, String)>,
47}
48
49impl BeeLogEntry {
50 pub fn is_bee_http(&self) -> bool {
56 self.logger.starts_with("node/api")
57 }
58
59 pub fn is_bee_tui_request(&self) -> bool {
72 for (k, v) in &self.extras {
73 let key = k.to_ascii_lowercase();
74 if matches!(
75 key.as_str(),
76 "user_agent" | "user-agent" | "useragent" | "ua"
77 ) && v.contains("bee-tui")
78 {
79 return true;
80 }
81 }
82 false
83 }
84
85 pub fn tab(&self) -> Option<LogTab> {
94 if self.is_bee_http() {
95 return Some(LogTab::BeeHttp);
96 }
97 match self.level.as_str() {
98 "error" | "err" | "fatal" => Some(LogTab::Errors),
99 "warning" | "warn" => Some(LogTab::Warning),
100 "info" => Some(LogTab::Info),
101 "debug" | "trace" => Some(LogTab::Debug),
102 _ => None,
103 }
104 }
105
106 pub fn to_log_line(&self) -> BeeLogLine {
110 let mut message = self.msg.clone();
111 for (k, v) in &self.extras {
112 if !message.is_empty() {
116 message.push(' ');
117 }
118 message.push_str(k);
119 message.push('=');
120 if v.chars().any(|c| c == ' ' || c == '"') || v.is_empty() {
122 message.push('"');
123 message.push_str(v);
124 message.push('"');
125 } else {
126 message.push_str(v);
127 }
128 }
129 BeeLogLine {
130 timestamp: self.time.clone(),
131 logger: self.logger.clone(),
132 message,
133 }
134 }
135}
136
137pub fn parse_line(line: &str) -> Option<BeeLogEntry> {
141 let line = line.trim();
142 if line.is_empty() {
143 return None;
144 }
145
146 let mut entry = BeeLogEntry::default();
147 let mut cursor = line;
148
149 while !cursor.is_empty() {
150 cursor = cursor.trim_start();
151 if cursor.is_empty() {
152 break;
153 }
154 let (key, rest) = take_key(cursor)?;
158 let after_eq = rest.strip_prefix('=')?;
159 let (value, rest) = take_value(after_eq)?;
160 match key.as_str() {
161 "time" => entry.time = value,
162 "level" => entry.level = value.to_ascii_lowercase(),
163 "logger" => entry.logger = value,
164 "msg" => entry.msg = value,
165 _ => entry.extras.push((key, value)),
166 }
167 cursor = rest;
168 }
169
170 if entry.time.is_empty() && entry.level.is_empty() && entry.logger.is_empty() {
174 return None;
175 }
176 Some(entry)
177}
178
179fn take_key(s: &str) -> Option<(String, &str)> {
184 if let Some(rest) = s.strip_prefix('"') {
185 let end = rest.find('"')?;
186 let key = rest[..end].to_string();
187 Some((key, &rest[end + 1..]))
188 } else {
189 let end = s
192 .find(|c: char| c == '=' || c.is_whitespace())
193 .unwrap_or(s.len());
194 if end == 0 {
195 return None;
196 }
197 Some((s[..end].to_string(), &s[end..]))
198 }
199}
200
201fn take_value(s: &str) -> Option<(String, &str)> {
205 if let Some(rest) = s.strip_prefix('"') {
206 let end = rest.find('"')?;
207 let value = rest[..end].to_string();
208 Some((value, &rest[end + 1..]))
209 } else {
210 let end = s.find(char::is_whitespace).unwrap_or(s.len());
211 Some((s[..end].to_string(), &s[end..]))
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 #[test]
220 fn parses_pseudosettle_debug_line() {
221 let line = r#""time"="2026-05-07 22:14:19.605485" "level"="debug" "logger"="node/pseudosettle" "v"=1 "msg"="pseudosettle sending payment message to peer" "peer_address"="097b3be6af660b6d9569c47f1f077ed419e5326f6ab4930c587b2f6a1cdada55" "amount"="48870000""#;
223 let e = parse_line(line).expect("must parse");
224 assert_eq!(e.time, "2026-05-07 22:14:19.605485");
225 assert_eq!(e.level, "debug");
226 assert_eq!(e.logger, "node/pseudosettle");
227 assert_eq!(e.msg, "pseudosettle sending payment message to peer");
228 assert_eq!(e.extras[0], ("v".into(), "1".into()));
230 assert_eq!(
231 e.extras[1],
232 (
233 "peer_address".into(),
234 "097b3be6af660b6d9569c47f1f077ed419e5326f6ab4930c587b2f6a1cdada55".into()
235 )
236 );
237 assert_eq!(e.extras[2], ("amount".into(), "48870000".into()));
238 assert_eq!(e.tab(), Some(LogTab::Debug));
239 }
240
241 #[test]
242 fn parses_unquoted_numeric_value() {
243 let line = r#""time"="t" "level"="debug" "logger"="node/batchservice" "msg"="block height updated" "new_block"=10809557"#;
245 let e = parse_line(line).expect("must parse");
246 assert_eq!(e.extras, vec![("new_block".into(), "10809557".into())]);
247 }
248
249 #[test]
250 fn parses_unquoted_bool_value() {
251 let line = r#""time"="t" "level"="debug" "logger"="node" "msg"="sync status check" "synced"=false "reserveSize"=2582243"#;
252 let e = parse_line(line).expect("must parse");
253 assert_eq!(e.extras[0], ("synced".into(), "false".into()));
254 assert_eq!(e.extras[1], ("reserveSize".into(), "2582243".into()));
255 }
256
257 #[test]
258 fn parses_unquoted_float_value() {
259 let line = r#""time"="t" "level"="debug" "logger"="node" "msg"="sync status check" "syncRate"=0.0989528913580248"#;
261 let e = parse_line(line).expect("must parse");
262 assert_eq!(
263 e.extras[0],
264 ("syncRate".into(), "0.0989528913580248".into())
265 );
266 }
267
268 #[test]
269 fn parses_long_error_message() {
270 let line = r#""time"="t" "level"="debug" "logger"="node/libp2p" "msg"="handle protocol failed" "protocol"="swap" "version"="1.0.0" "stream"="swap" "peer"="54b5..." "error"="read request from peer 54b5...: stream reset (remote): code: 0x0: transport error: stream reset by remote, error code: 0""#;
273 let e = parse_line(line).expect("must parse");
274 let err_pair = e.extras.iter().find(|(k, _)| k == "error").unwrap();
275 assert!(err_pair.1.contains("stream reset by remote"));
276 }
277
278 #[test]
279 fn level_routing_covers_known_severities() {
280 for (lvl, tab) in [
281 ("error", LogTab::Errors),
282 ("err", LogTab::Errors),
283 ("fatal", LogTab::Errors),
284 ("warning", LogTab::Warning),
285 ("warn", LogTab::Warning),
286 ("info", LogTab::Info),
287 ("debug", LogTab::Debug),
288 ("trace", LogTab::Debug),
289 ] {
290 let e = BeeLogEntry {
291 level: lvl.into(),
292 ..Default::default()
293 };
294 assert_eq!(e.tab(), Some(tab), "level {lvl} should route to {tab:?}");
295 }
296 }
297
298 #[test]
299 fn level_routing_unknown_returns_none() {
300 let e = BeeLogEntry {
303 level: "panic".into(),
304 ..Default::default()
305 };
306 assert_eq!(e.tab(), None);
307 let e = BeeLogEntry::default();
308 assert_eq!(e.tab(), None);
309 }
310
311 #[test]
312 fn node_api_logger_routes_to_bee_http() {
313 for logger in ["node/api", "node/api/access", "node/api/handler"] {
316 let e = BeeLogEntry {
317 logger: logger.into(),
318 level: "debug".into(),
319 ..Default::default()
320 };
321 assert_eq!(e.tab(), Some(LogTab::BeeHttp), "logger {logger}");
322 }
323 }
324
325 #[test]
326 fn bee_http_wins_over_severity_routing() {
327 let e = BeeLogEntry {
331 logger: "node/api".into(),
332 level: "error".into(),
333 ..Default::default()
334 };
335 assert_eq!(e.tab(), Some(LogTab::BeeHttp));
336 }
337
338 #[test]
339 fn non_api_logger_falls_through_to_severity() {
340 let e = BeeLogEntry {
344 logger: "node/batchapi".into(),
345 level: "error".into(),
346 ..Default::default()
347 };
348 assert_eq!(e.tab(), Some(LogTab::Errors));
349 }
350
351 #[test]
352 fn is_bee_tui_request_detects_user_agent() {
353 for key in ["user_agent", "user-agent", "useragent", "ua"] {
357 let e = BeeLogEntry {
358 extras: vec![(key.into(), "bee-tui/1.0.0".into())],
359 ..Default::default()
360 };
361 assert!(e.is_bee_tui_request(), "key {key:?} should match");
362 }
363 }
364
365 #[test]
366 fn is_bee_tui_request_is_case_insensitive_on_keys() {
367 let e = BeeLogEntry {
370 extras: vec![("User-Agent".into(), "bee-tui/1.0.0 extra-suffix".into())],
371 ..Default::default()
372 };
373 assert!(e.is_bee_tui_request());
374 }
375
376 #[test]
377 fn is_bee_tui_request_rejects_other_clients() {
378 let e = BeeLogEntry {
379 extras: vec![("user_agent".into(), "curl/8.0.1".into())],
380 ..Default::default()
381 };
382 assert!(!e.is_bee_tui_request());
383 let e = BeeLogEntry::default();
385 assert!(!e.is_bee_tui_request());
386 }
387
388 #[test]
389 fn level_is_lowercased_during_parse() {
390 let line = r#""time"="t" "level"="ERROR" "logger"="node" "msg"="oops""#;
391 let e = parse_line(line).expect("must parse");
392 assert_eq!(e.level, "error");
393 assert_eq!(e.tab(), Some(LogTab::Errors));
394 }
395
396 #[test]
397 fn empty_input_returns_none() {
398 assert!(parse_line("").is_none());
399 assert!(parse_line(" ").is_none());
400 assert!(parse_line("\n").is_none());
401 }
402
403 #[test]
404 fn line_without_structural_fields_returns_none() {
405 assert!(parse_line(r#""foo"="bar" "baz"=42"#).is_none());
408 }
409
410 #[test]
411 fn malformed_line_returns_none() {
412 assert!(parse_line(r#""time" "level"="debug""#).is_none());
414 assert!(parse_line(r#""time"="2026" "level"="debug"#).is_none());
416 }
417
418 #[test]
419 fn to_log_line_compacts_extras_into_message() {
420 let e = BeeLogEntry {
421 time: "t1".into(),
422 logger: "node/foo".into(),
423 msg: "did a thing".into(),
424 extras: vec![("count".into(), "42".into()), ("peer".into(), "abc".into())],
425 ..Default::default()
426 };
427 let line = e.to_log_line();
428 assert_eq!(line.timestamp, "t1");
429 assert_eq!(line.logger, "node/foo");
430 assert_eq!(line.message, "did a thing count=42 peer=abc");
431 }
432
433 #[test]
434 fn to_log_line_quotes_values_with_spaces() {
435 let e = BeeLogEntry {
436 msg: "x".into(),
437 extras: vec![("error".into(), "stream reset by remote".into())],
438 ..Default::default()
439 };
440 let line = e.to_log_line();
441 assert!(line.message.contains(r#"error="stream reset by remote""#));
442 }
443
444 #[test]
445 fn to_log_line_quotes_empty_values() {
446 let e = BeeLogEntry {
449 msg: "x".into(),
450 extras: vec![("nullable".into(), "".into())],
451 ..Default::default()
452 };
453 let line = e.to_log_line();
454 assert!(line.message.contains(r#"nullable="""#));
455 }
456}