use std::collections::VecDeque;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Js,
Rust,
}
impl Source {
pub fn as_str(self) -> &'static str {
match self {
Source::Js => "js",
Source::Rust => "rust",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Level {
Debug,
Info,
Warn,
Error,
}
impl Level {
pub fn as_str(self) -> &'static str {
match self {
Level::Debug => "debug",
Level::Info => "info",
Level::Warn => "warn",
Level::Error => "error",
}
}
pub fn from_js(level: &str) -> Self {
match level {
"error" => Level::Error,
"warn" => Level::Warn,
"debug" => Level::Debug,
_ => Level::Info,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ConsoleEntry {
pub seq: u64,
pub time_ms: u64,
pub source: Source,
pub level: Level,
pub message: String,
}
const CAP: usize = 500;
const MESSAGE_CAP: usize = 4096;
#[derive(Debug, Default)]
pub struct ConsoleRing {
entries: VecDeque<ConsoleEntry>,
next_seq: u64,
}
impl ConsoleRing {
pub fn push(&mut self, time_ms: u64, source: Source, level: Level, message: &str) {
let mut end = message.len().min(MESSAGE_CAP);
while !message.is_char_boundary(end) {
end -= 1;
}
let mut msg = message[..end].to_owned();
if end < message.len() {
msg.push('…');
}
self.next_seq += 1;
self.entries.push_back(ConsoleEntry {
seq: self.next_seq,
time_ms,
source,
level,
message: msg,
});
while self.entries.len() > CAP {
self.entries.pop_front();
}
}
pub fn since(&self, since: u64) -> (Vec<ConsoleEntry>, u64) {
let entries = self
.entries
.iter()
.filter(|e| e.seq > since)
.cloned()
.collect();
(entries, self.next_seq)
}
pub fn clear(&mut self) {
self.entries.clear();
}
}
#[cfg(all(feature = "devtools", debug_assertions, not(target_arch = "wasm32")))]
mod imp {
use super::{ConsoleEntry, ConsoleRing, Level, Source};
use std::sync::Mutex;
static RING: Mutex<ConsoleRing> = Mutex::new(ConsoleRing {
entries: std::collections::VecDeque::new(),
next_seq: 0,
});
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
pub fn push(source: Source, level: Level, message: &str) {
RING.lock()
.unwrap_or_else(|e| e.into_inner())
.push(now_ms(), source, level, message);
}
pub fn since(since: u64) -> (Vec<ConsoleEntry>, u64) {
RING.lock().unwrap_or_else(|e| e.into_inner()).since(since)
}
pub fn clear() {
RING.lock().unwrap_or_else(|e| e.into_inner()).clear();
}
}
#[cfg(not(all(feature = "devtools", debug_assertions, not(target_arch = "wasm32"))))]
mod imp {
use super::{ConsoleEntry, Level, Source};
#[inline(always)]
pub fn push(_source: Source, _level: Level, _message: &str) {}
#[inline(always)]
pub fn since(_since: u64) -> (Vec<ConsoleEntry>, u64) {
(Vec::new(), 0)
}
#[inline(always)]
pub fn clear() {}
}
pub use imp::*;
#[cfg(test)]
mod tests {
use super::*;
fn push_n(ring: &mut ConsoleRing, n: usize) {
for i in 0..n {
ring.push(i as u64, Source::Js, Level::Info, &format!("msg {i}"));
}
}
#[test]
fn cap_evicts_oldest_and_seq_stays_continuous() {
let mut ring = ConsoleRing::default();
push_n(&mut ring, 502);
let (entries, watermark) = ring.since(0);
assert_eq!(entries.len(), 500);
assert_eq!(watermark, 502);
assert_eq!(entries.first().unwrap().seq, 3, "first two evicted");
assert_eq!(entries.last().unwrap().seq, 502);
let seqs: Vec<u64> = entries.iter().map(|e| e.seq).collect();
assert!(seqs.windows(2).all(|w| w[1] == w[0] + 1), "continuous seqs");
}
#[test]
fn since_returns_only_newer_entries() {
let mut ring = ConsoleRing::default();
push_n(&mut ring, 5);
let (entries, watermark) = ring.since(3);
assert_eq!(watermark, 5);
assert_eq!(
entries.iter().map(|e| e.seq).collect::<Vec<_>>(),
vec![4, 5]
);
let (entries, watermark) = ring.since(5);
assert!(entries.is_empty());
assert_eq!(watermark, 5);
let empty = ConsoleRing::default();
assert_eq!(empty.since(0), (Vec::new(), 0));
}
#[test]
fn clear_empties_but_seq_keeps_counting() {
let mut ring = ConsoleRing::default();
push_n(&mut ring, 3);
ring.clear();
let (entries, watermark) = ring.since(0);
assert!(entries.is_empty());
assert_eq!(watermark, 3, "the watermark survives a clear");
ring.push(9, Source::Rust, Level::Error, "after clear");
let (entries, _) = ring.since(0);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].seq, 4, "seq continues past the clear");
}
#[test]
fn long_messages_truncate_on_char_boundaries() {
let mut ring = ConsoleRing::default();
ring.push(0, Source::Js, Level::Info, &"a".repeat(5000));
let tricky = format!("{}é{}", "a".repeat(4095), "b".repeat(100));
ring.push(0, Source::Js, Level::Info, &tricky);
let (entries, _) = ring.since(0);
assert!(entries[0].message.chars().count() <= 4097); assert!(entries[0].message.ends_with('…'), "truncation is marked");
assert!(
entries[1]
.message
.is_char_boundary(entries[1].message.len())
);
assert!(!entries[1].message.contains('\u{FFFD}'));
}
#[test]
fn level_from_js_mirrors_op_log_map() {
assert_eq!(Level::from_js("error"), Level::Error);
assert_eq!(Level::from_js("warn"), Level::Warn);
assert_eq!(Level::from_js("debug"), Level::Debug);
assert_eq!(Level::from_js("info"), Level::Info);
assert_eq!(Level::from_js("bogus"), Level::Info, "unknown → info");
}
}