use crate::components::log_pane::{BeeLogLine, LogTab};
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct BeeLogEntry {
pub time: String,
pub level: String,
pub logger: String,
pub msg: String,
pub extras: Vec<(String, String)>,
}
impl BeeLogEntry {
pub fn is_bee_http(&self) -> bool {
self.logger.starts_with("node/api")
}
pub fn is_bee_tui_request(&self) -> bool {
for (k, v) in &self.extras {
let key = k.to_ascii_lowercase();
if matches!(
key.as_str(),
"user_agent" | "user-agent" | "useragent" | "ua"
) && v.contains("bee-tui")
{
return true;
}
}
false
}
pub fn tab(&self) -> Option<LogTab> {
if self.is_bee_http() {
return Some(LogTab::BeeHttp);
}
match self.level.as_str() {
"error" | "err" | "fatal" => Some(LogTab::Errors),
"warning" | "warn" => Some(LogTab::Warning),
"info" => Some(LogTab::Info),
"debug" | "trace" => Some(LogTab::Debug),
_ => None,
}
}
pub fn to_log_line(&self) -> BeeLogLine {
let mut message = self.msg.clone();
for (k, v) in &self.extras {
if !message.is_empty() {
message.push(' ');
}
message.push_str(k);
message.push('=');
if v.chars().any(|c| c == ' ' || c == '"') || v.is_empty() {
message.push('"');
message.push_str(v);
message.push('"');
} else {
message.push_str(v);
}
}
BeeLogLine {
timestamp: self.time.clone(),
logger: self.logger.clone(),
message,
}
}
}
pub fn parse_line(line: &str) -> Option<BeeLogEntry> {
let line = line.trim();
if line.is_empty() {
return None;
}
let mut entry = BeeLogEntry::default();
let mut cursor = line;
while !cursor.is_empty() {
cursor = cursor.trim_start();
if cursor.is_empty() {
break;
}
let (key, rest) = take_key(cursor)?;
let after_eq = rest.strip_prefix('=')?;
let (value, rest) = take_value(after_eq)?;
match key.as_str() {
"time" => entry.time = value,
"level" => entry.level = value.to_ascii_lowercase(),
"logger" => entry.logger = value,
"msg" => entry.msg = value,
_ => entry.extras.push((key, value)),
}
cursor = rest;
}
if entry.time.is_empty() && entry.level.is_empty() && entry.logger.is_empty() {
return None;
}
Some(entry)
}
fn take_key(s: &str) -> Option<(String, &str)> {
if let Some(rest) = s.strip_prefix('"') {
let end = rest.find('"')?;
let key = rest[..end].to_string();
Some((key, &rest[end + 1..]))
} else {
let end = s
.find(|c: char| c == '=' || c.is_whitespace())
.unwrap_or(s.len());
if end == 0 {
return None;
}
Some((s[..end].to_string(), &s[end..]))
}
}
fn take_value(s: &str) -> Option<(String, &str)> {
if let Some(rest) = s.strip_prefix('"') {
let end = rest.find('"')?;
let value = rest[..end].to_string();
Some((value, &rest[end + 1..]))
} else {
let end = s.find(char::is_whitespace).unwrap_or(s.len());
Some((s[..end].to_string(), &s[end..]))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_pseudosettle_debug_line() {
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""#;
let e = parse_line(line).expect("must parse");
assert_eq!(e.time, "2026-05-07 22:14:19.605485");
assert_eq!(e.level, "debug");
assert_eq!(e.logger, "node/pseudosettle");
assert_eq!(e.msg, "pseudosettle sending payment message to peer");
assert_eq!(e.extras[0], ("v".into(), "1".into()));
assert_eq!(
e.extras[1],
(
"peer_address".into(),
"097b3be6af660b6d9569c47f1f077ed419e5326f6ab4930c587b2f6a1cdada55".into()
)
);
assert_eq!(e.extras[2], ("amount".into(), "48870000".into()));
assert_eq!(e.tab(), Some(LogTab::Debug));
}
#[test]
fn parses_unquoted_numeric_value() {
let line = r#""time"="t" "level"="debug" "logger"="node/batchservice" "msg"="block height updated" "new_block"=10809557"#;
let e = parse_line(line).expect("must parse");
assert_eq!(e.extras, vec![("new_block".into(), "10809557".into())]);
}
#[test]
fn parses_unquoted_bool_value() {
let line = r#""time"="t" "level"="debug" "logger"="node" "msg"="sync status check" "synced"=false "reserveSize"=2582243"#;
let e = parse_line(line).expect("must parse");
assert_eq!(e.extras[0], ("synced".into(), "false".into()));
assert_eq!(e.extras[1], ("reserveSize".into(), "2582243".into()));
}
#[test]
fn parses_unquoted_float_value() {
let line = r#""time"="t" "level"="debug" "logger"="node" "msg"="sync status check" "syncRate"=0.0989528913580248"#;
let e = parse_line(line).expect("must parse");
assert_eq!(
e.extras[0],
("syncRate".into(), "0.0989528913580248".into())
);
}
#[test]
fn parses_long_error_message() {
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""#;
let e = parse_line(line).expect("must parse");
let err_pair = e.extras.iter().find(|(k, _)| k == "error").unwrap();
assert!(err_pair.1.contains("stream reset by remote"));
}
#[test]
fn level_routing_covers_known_severities() {
for (lvl, tab) in [
("error", LogTab::Errors),
("err", LogTab::Errors),
("fatal", LogTab::Errors),
("warning", LogTab::Warning),
("warn", LogTab::Warning),
("info", LogTab::Info),
("debug", LogTab::Debug),
("trace", LogTab::Debug),
] {
let e = BeeLogEntry {
level: lvl.into(),
..Default::default()
};
assert_eq!(e.tab(), Some(tab), "level {lvl} should route to {tab:?}");
}
}
#[test]
fn level_routing_unknown_returns_none() {
let e = BeeLogEntry {
level: "panic".into(),
..Default::default()
};
assert_eq!(e.tab(), None);
let e = BeeLogEntry::default();
assert_eq!(e.tab(), None);
}
#[test]
fn node_api_logger_routes_to_bee_http() {
for logger in ["node/api", "node/api/access", "node/api/handler"] {
let e = BeeLogEntry {
logger: logger.into(),
level: "debug".into(),
..Default::default()
};
assert_eq!(e.tab(), Some(LogTab::BeeHttp), "logger {logger}");
}
}
#[test]
fn bee_http_wins_over_severity_routing() {
let e = BeeLogEntry {
logger: "node/api".into(),
level: "error".into(),
..Default::default()
};
assert_eq!(e.tab(), Some(LogTab::BeeHttp));
}
#[test]
fn non_api_logger_falls_through_to_severity() {
let e = BeeLogEntry {
logger: "node/batchapi".into(),
level: "error".into(),
..Default::default()
};
assert_eq!(e.tab(), Some(LogTab::Errors));
}
#[test]
fn is_bee_tui_request_detects_user_agent() {
for key in ["user_agent", "user-agent", "useragent", "ua"] {
let e = BeeLogEntry {
extras: vec![(key.into(), "bee-tui/1.0.0".into())],
..Default::default()
};
assert!(e.is_bee_tui_request(), "key {key:?} should match");
}
}
#[test]
fn is_bee_tui_request_is_case_insensitive_on_keys() {
let e = BeeLogEntry {
extras: vec![("User-Agent".into(), "bee-tui/1.0.0 extra-suffix".into())],
..Default::default()
};
assert!(e.is_bee_tui_request());
}
#[test]
fn is_bee_tui_request_rejects_other_clients() {
let e = BeeLogEntry {
extras: vec![("user_agent".into(), "curl/8.0.1".into())],
..Default::default()
};
assert!(!e.is_bee_tui_request());
let e = BeeLogEntry::default();
assert!(!e.is_bee_tui_request());
}
#[test]
fn level_is_lowercased_during_parse() {
let line = r#""time"="t" "level"="ERROR" "logger"="node" "msg"="oops""#;
let e = parse_line(line).expect("must parse");
assert_eq!(e.level, "error");
assert_eq!(e.tab(), Some(LogTab::Errors));
}
#[test]
fn empty_input_returns_none() {
assert!(parse_line("").is_none());
assert!(parse_line(" ").is_none());
assert!(parse_line("\n").is_none());
}
#[test]
fn line_without_structural_fields_returns_none() {
assert!(parse_line(r#""foo"="bar" "baz"=42"#).is_none());
}
#[test]
fn malformed_line_returns_none() {
assert!(parse_line(r#""time" "level"="debug""#).is_none());
assert!(parse_line(r#""time"="2026" "level"="debug"#).is_none());
}
#[test]
fn to_log_line_compacts_extras_into_message() {
let e = BeeLogEntry {
time: "t1".into(),
logger: "node/foo".into(),
msg: "did a thing".into(),
extras: vec![("count".into(), "42".into()), ("peer".into(), "abc".into())],
..Default::default()
};
let line = e.to_log_line();
assert_eq!(line.timestamp, "t1");
assert_eq!(line.logger, "node/foo");
assert_eq!(line.message, "did a thing count=42 peer=abc");
}
#[test]
fn to_log_line_quotes_values_with_spaces() {
let e = BeeLogEntry {
msg: "x".into(),
extras: vec![("error".into(), "stream reset by remote".into())],
..Default::default()
};
let line = e.to_log_line();
assert!(line.message.contains(r#"error="stream reset by remote""#));
}
#[test]
fn to_log_line_quotes_empty_values() {
let e = BeeLogEntry {
msg: "x".into(),
extras: vec![("nullable".into(), "".into())],
..Default::default()
};
let line = e.to_log_line();
assert!(line.message.contains(r#"nullable="""#));
}
}