use crate::prelude::*;
use chrono::Utc;
use std::collections::VecDeque;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use tokio::sync::{broadcast, Mutex};
use tracing::Subscriber;
use tracing_appender::non_blocking::{NonBlocking, WorkerGuard};
use tracing_subscriber::layer::Context as LayerContext;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::{
fmt, fmt::format::FmtSpan, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter, Layer,
};
static LOGGING_INITIALIZED: AtomicBool = AtomicBool::new(false);
static LOG_BROADCAST_TX: OnceLock<broadcast::Sender<LogLine>> = OnceLock::new();
static LOG_RING_BUFFER: OnceLock<LogRingBuffer> = OnceLock::new();
pub fn set_log_broadcast(tx: broadcast::Sender<LogLine>, ring: LogRingBuffer) {
let _ = LOG_BROADCAST_TX.set(tx);
let _ = LOG_RING_BUFFER.set(ring);
}
#[derive(Clone, Debug, serde::Serialize)]
pub struct LogLine {
pub ts: String,
pub level: String,
pub target: String,
pub message: String,
}
const RING_CAPACITY: usize = 200;
#[derive(Clone)]
pub struct LogRingBuffer {
inner: Arc<Mutex<VecDeque<LogLine>>>,
}
impl LogRingBuffer {
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(VecDeque::with_capacity(RING_CAPACITY))),
}
}
pub async fn push(&self, line: LogLine) {
let mut buf = self.inner.lock().await;
if buf.len() >= RING_CAPACITY {
buf.pop_front();
}
buf.push_back(line);
}
pub async fn snapshot(&self) -> Vec<LogLine> {
self.inner.lock().await.iter().cloned().collect()
}
}
pub struct BroadcastLayer;
const SUPPRESSED_TARGETS: &[&str] = &[
"hyper",
"h2",
"tower",
"axum",
"tokio",
"mio",
"want",
"rustls",
"tungstenite",
"reqwest",
"rusqlite",
"notify",
"notify_debouncer_mini",
"ignore",
"globset",
];
impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for BroadcastLayer {
fn enabled(&self, metadata: &tracing::Metadata<'_>, _ctx: LayerContext<'_, S>) -> bool {
let target = metadata.target();
!SUPPRESSED_TARGETS.iter().any(|t| target.starts_with(t))
}
fn on_event(&self, event: &tracing::Event<'_>, _ctx: LayerContext<'_, S>) {
let Some(tx) = LOG_BROADCAST_TX.get() else {
return;
};
let target = event.metadata().target();
if SUPPRESSED_TARGETS.iter().any(|t| target.starts_with(t)) {
return;
}
let level = event.metadata().level().to_string();
let mut visitor = MessageVisitor {
message: String::new(),
};
event.record(&mut visitor);
let line = LogLine {
ts: Utc::now().format("%H:%M:%S%.3f").to_string(),
level,
target: target.to_string(),
message: visitor.message,
};
if let Some(ring) = LOG_RING_BUFFER.get() {
let inner = &ring.inner;
if let Ok(mut buf) = inner.try_lock() {
if buf.len() >= RING_CAPACITY {
buf.pop_front();
}
buf.push_back(line.clone());
drop(buf); }
}
let _ = tx.send(line);
}
}
struct MessageVisitor {
message: String,
}
impl tracing::field::Visit for MessageVisitor {
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
self.message = value.to_string();
} else if !self.message.is_empty() {
self.message
.push_str(&format!(" {}={}", field.name(), value));
} else {
self.message = format!("{}={}", field.name(), value);
}
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
self.message = format!("{:?}", value);
} else if !self.message.is_empty() {
self.message
.push_str(&format!(" {}={:?}", field.name(), value));
} else {
self.message = format!("{}={:?}", field.name(), value);
}
}
}
pub struct LoggingGuard {
_file_guard: Option<WorkerGuard>,
}
const NOISE_FILTER: &str = "\
,hyper=warn\
,h2=warn\
,tower=warn\
,mio=off\
,want=off\
,rustls=warn\
,reqwest=warn\
,notify=warn\
,notify_debouncer_mini=warn\
,ignore=warn\
,globset=warn\
";
fn open_run_log_file(log_dir: &std::path::Path) -> Result<(NonBlocking, WorkerGuard)> {
std::fs::create_dir_all(log_dir)
.with_context(|| format!("failed to create log directory {}", log_dir.display()))?;
let ts = Utc::now().format("%Y-%m-%dT%H-%M-%S");
let filename = format!("wsb-{ts}.log");
let path = log_dir.join(&filename);
let file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to open log file {}", path.display()))?;
Ok(tracing_appender::non_blocking(file))
}
pub fn setup_logging(debug_mode: bool) -> Result<LoggingGuard> {
if LOGGING_INITIALIZED.swap(true, Ordering::SeqCst) {
return Ok(LoggingGuard { _file_guard: None });
}
let workspace_root = detect_workspace_root();
let _console_default = if debug_mode {
tracing::Level::DEBUG
} else {
tracing::Level::WARN
};
let console_base = if debug_mode { "debug" } else { "warn" };
let console_spec = format!("{}{}", console_base, NOISE_FILTER);
let console_filter =
EnvFilter::try_from_env("RUST_LOG").unwrap_or_else(|_| EnvFilter::new(&console_spec));
let console_layer = fmt::layer()
.with_writer(std::io::stderr)
.with_ansi(true)
.with_target(debug_mode)
.with_span_events(FmtSpan::NONE)
.event_format(fmt::format().compact())
.with_filter(console_filter);
let file_guard = if debug_mode {
if let Some(ref root) = workspace_root {
let log_dir = root.join(".wsb").join("logs");
let (non_blocking, guard) = open_run_log_file(&log_dir)?;
let file_spec = format!("debug{}", NOISE_FILTER);
let file_filter = EnvFilter::new(&file_spec);
let file_layer = fmt::layer()
.with_writer(non_blocking)
.with_ansi(false)
.with_target(true)
.with_filter(file_filter);
tracing_subscriber::registry()
.with(console_layer)
.with(file_layer)
.with(BroadcastLayer)
.try_init()
.map_err(|e| anyhow::anyhow!("Failed to initialize tracing: {}", e))?;
tracing::debug!(log_dir = %log_dir.display(), "debug logging active");
Some(guard)
} else {
tracing_subscriber::registry()
.with(console_layer)
.with(BroadcastLayer)
.try_init()
.map_err(|e| anyhow::anyhow!("Failed to initialize tracing: {}", e))?;
tracing::debug!("debug logging active (no .wsb workspace — file log skipped)");
None
}
} else {
tracing_subscriber::registry()
.with(console_layer)
.with(BroadcastLayer)
.try_init()
.map_err(|e| anyhow::anyhow!("Failed to initialize tracing: {}", e))?;
None
};
Ok(LoggingGuard {
_file_guard: file_guard,
})
}
pub fn detect_workspace_root() -> Option<PathBuf> {
let current_dir = std::env::current_dir().ok()?;
let mut dir = current_dir.as_path();
loop {
if dir.join(".wsb").exists() {
return Some(dir.to_path_buf());
}
dir = dir.parent()?;
}
}
pub fn log_operation_start(operation: &str, details: &str) {
tracing::info!(operation, details, "starting");
}
pub fn log_operation_complete(operation: &str, duration: std::time::Duration) {
tracing::info!(
operation,
elapsed_secs = duration.as_secs_f64(),
"completed"
);
}
pub fn log_operation_error(operation: &str, error: &anyhow::Error) {
tracing::error!(operation, error = %error, "failed");
let mut cause = error.source();
while let Some(err) = cause {
tracing::debug!(caused_by = %err);
cause = err.source();
}
}
pub fn log_warning(context: &str, message: &str) {
tracing::warn!(context, message);
}
pub fn log_version_info(version: &str, git_hash: Option<&str>) {
match git_hash {
Some(hash) => tracing::info!(version, git_commit = hash, "workspace starting"),
None => tracing::info!(version, "workspace starting"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_line(msg: &str) -> LogLine {
LogLine {
ts: "00:00:00.000".to_string(),
level: "INFO".to_string(),
target: "test".to_string(),
message: msg.to_string(),
}
}
#[tokio::test]
async fn ring_buffer_starts_empty() {
let buf = LogRingBuffer::new();
assert!(buf.snapshot().await.is_empty());
}
#[tokio::test]
async fn ring_buffer_preserves_order_below_capacity() {
let buf = LogRingBuffer::new();
for i in 0..10 {
buf.push(make_line(&format!("msg-{i}"))).await;
}
let snap = buf.snapshot().await;
assert_eq!(snap.len(), 10);
for (i, line) in snap.iter().enumerate() {
assert_eq!(line.message, format!("msg-{i}"));
}
}
#[tokio::test]
async fn ring_buffer_evicts_oldest_at_capacity() {
let buf = LogRingBuffer::new();
let overflow = 50;
for i in 0..(RING_CAPACITY + overflow) {
buf.push(make_line(&format!("msg-{i}"))).await;
}
let snap = buf.snapshot().await;
assert_eq!(
snap.len(),
RING_CAPACITY,
"buffer must cap at RING_CAPACITY={RING_CAPACITY}"
);
assert_eq!(snap[0].message, format!("msg-{overflow}"));
assert_eq!(
snap[RING_CAPACITY - 1].message,
format!("msg-{}", RING_CAPACITY + overflow - 1)
);
}
#[tokio::test]
async fn ring_buffer_snapshot_is_independent_clone() {
let buf = LogRingBuffer::new();
buf.push(make_line("first")).await;
let snap1 = buf.snapshot().await;
buf.push(make_line("second")).await;
let snap2 = buf.snapshot().await;
assert_eq!(snap1.len(), 1, "first snapshot must have 1 line");
assert_eq!(snap2.len(), 2, "second snapshot must have 2 lines");
assert_eq!(snap1[0].message, "first");
assert_eq!(snap2[1].message, "second");
}
}