use std::fmt::{self, Write as _};
use color_print::cformat;
use tracing::{Event, Subscriber};
use tracing_subscriber::Layer;
use tracing_subscriber::filter::{EnvFilter, FilterExt, LevelFilter, filter_fn};
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields, format::Writer};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::util::SubscriberInitExt;
use worktrunk::shell_exec::SUBPROCESS_FULL_TARGET;
use worktrunk::styling::{eprintln, info_message};
use worktrunk::trace::WT_TRACE_TARGET;
use crate::log_files::{self, SubprocessMakeWriter, TraceMakeWriter};
use crate::output;
fn thread_label() -> char {
let thread_id = format!("{:?}", std::thread::current().id());
let parsed = thread_id
.strip_prefix("ThreadId(")
.and_then(|s| s.strip_suffix(")"))
.and_then(|s| s.parse::<usize>().ok());
label_for_thread_index(parsed)
}
fn label_for_thread_index(n: Option<usize>) -> char {
let Some(n) = n else { return '?' };
if n == 0 {
'0'
} else if n <= 26 {
char::from(b'a' + (n - 1) as u8)
} else if n <= 52 {
char::from(b'A' + (n - 27) as u8)
} else {
'?'
}
}
fn event_message(event: &Event<'_>) -> String {
struct V(String);
impl tracing::field::Visit for V {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
if field.name() == "message" {
let _ = write!(&mut self.0, "{value:?}");
}
}
}
let mut v = V(String::new());
event.record(&mut v);
v.0
}
fn style_stderr_line(thread_num: char, msg: &str) -> String {
if let Some(rest) = msg.strip_prefix("$ ") {
let (command, worktree) = match rest.find(" [") {
Some(pos) => (&rest[..pos], &rest[pos..]),
None => (rest, ""),
};
cformat!("<dim>[{thread_num}]</> $ <bold>{command}</>{worktree}")
} else if msg.starts_with(" ! ") {
cformat!("<dim>[{thread_num}]</> <red>{msg}</>")
} else {
cformat!("<dim>[{thread_num}]</> {msg}")
}
}
struct StderrFormat;
impl<S, N> FormatEvent<S, N> for StderrFormat
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(
&self,
_ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
let msg = render_event_message(event);
let line = style_stderr_line(thread_label(), &msg);
writeln!(writer, "{line}")
}
}
fn render_event_message(event: &Event<'_>) -> String {
if event.metadata().target() == WT_TRACE_TARGET {
let mut fields = WtTraceFields::default();
event.record(&mut fields);
format_wt_trace(&fields)
} else {
event_message(event)
}
}
struct TraceFileFormat;
impl<S, N> FormatEvent<S, N> for TraceFileFormat
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(
&self,
_ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
let thread_num = thread_label();
let msg = render_event_message(event);
writeln!(writer, "[{thread_num}] {msg}")
}
}
#[derive(Default)]
struct WtTraceFields {
kind: Option<String>,
ts: Option<u64>,
tid: Option<u64>,
dur_us: Option<u64>,
ok: Option<bool>,
context: Option<String>,
cmd: Option<String>,
err: Option<String>,
event: Option<String>,
span: Option<String>,
}
impl tracing::field::Visit for WtTraceFields {
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
match field.name() {
"ts" => self.ts = Some(value),
"tid" => self.tid = Some(value),
"dur_us" => self.dur_us = Some(value),
_ => {}
}
}
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
if field.name() == "ok" {
self.ok = Some(value);
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.record_string(field.name(), value);
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
let mut buf = String::new();
let _ = write!(&mut buf, "{value:?}");
self.record_string(field.name(), &buf);
}
}
impl WtTraceFields {
fn record_string(&mut self, name: &str, value: &str) {
match name {
"kind" => self.kind = Some(value.to_owned()),
"context" => self.context = Some(value.to_owned()),
"cmd" => self.cmd = Some(value.to_owned()),
"err" => self.err = Some(value.to_owned()),
"event" => self.event = Some(value.to_owned()),
"span" => self.span = Some(value.to_owned()),
_ => {}
}
}
}
fn format_wt_trace(f: &WtTraceFields) -> String {
let ts = f.ts.unwrap_or(0);
let tid = f.tid.unwrap_or(0);
match f.kind.as_deref() {
Some("cmd_completed") => {
let cmd = f.cmd.as_deref().unwrap_or("");
let dur_us = f.dur_us.unwrap_or(0);
let ok = f.ok.unwrap_or(false);
match &f.context {
Some(ctx) => format!(
r#"[wt-trace] ts={ts} tid={tid} context={ctx} cmd="{cmd}" dur_us={dur_us} ok={ok}"#
),
None => {
format!(r#"[wt-trace] ts={ts} tid={tid} cmd="{cmd}" dur_us={dur_us} ok={ok}"#)
}
}
}
Some("cmd_errored") => {
let cmd = f.cmd.as_deref().unwrap_or("");
let dur_us = f.dur_us.unwrap_or(0);
let err = f.err.as_deref().unwrap_or("");
match &f.context {
Some(ctx) => format!(
r#"[wt-trace] ts={ts} tid={tid} context={ctx} cmd="{cmd}" dur_us={dur_us} err="{err}""#
),
None => format!(
r#"[wt-trace] ts={ts} tid={tid} cmd="{cmd}" dur_us={dur_us} err="{err}""#
),
}
}
Some("instant") => {
let event = f.event.as_deref().unwrap_or("");
format!(r#"[wt-trace] ts={ts} tid={tid} event="{event}""#)
}
Some("span") => {
let name = f.span.as_deref().unwrap_or("");
let dur_us = f.dur_us.unwrap_or(0);
format!(r#"[wt-trace] ts={ts} tid={tid} span="{name}" dur_us={dur_us}"#)
}
other => format!(
r#"[wt-trace] ts={ts} tid={tid} kind={kind:?}"#,
kind = other.unwrap_or("<unknown>")
),
}
}
struct SubprocessFileFormat;
impl<S, N> FormatEvent<S, N> for SubprocessFileFormat
where
S: Subscriber + for<'a> LookupSpan<'a>,
N: for<'a> FormatFields<'a> + 'static,
{
fn format_event(
&self,
_ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
writeln!(writer, "{}", event_message(event))
}
}
pub(crate) fn init(verbose_level: u8) {
output::set_verbosity(verbose_level);
if verbose_level >= 2 {
log_files::init();
}
let stderr_layer = build_stderr_layer(verbose_level);
let trace_layer = build_trace_layer(verbose_level);
let subprocess_layer = build_subprocess_layer(verbose_level);
let _ = tracing_subscriber::registry()
.with(stderr_layer)
.with(trace_layer)
.with(subprocess_layer)
.try_init();
let _ = tracing_log::LogTracer::builder()
.with_max_level(effective_log_max_level(verbose_level, rust_log_level()))
.init();
if verbose_level >= 2 {
announce_trace_destination();
}
}
fn effective_log_max_level(
verbose_level: u8,
from_env: Option<log::LevelFilter>,
) -> log::LevelFilter {
let baseline = match verbose_level {
0 => log::LevelFilter::Warn,
1 => log::LevelFilter::Info,
_ => log::LevelFilter::Debug,
};
from_env.unwrap_or(baseline)
}
fn rust_log_level() -> Option<log::LevelFilter> {
let raw = std::env::var("RUST_LOG").ok()?;
raw.split(',')
.filter_map(|directive| {
let level_token = directive.rsplit('=').next().unwrap_or(directive).trim();
level_token.parse::<log::LevelFilter>().ok()
})
.max()
}
fn build_stderr_layer<S>(verbose_level: u8) -> Option<impl Layer<S>>
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
let baseline = match verbose_level {
0 => LevelFilter::OFF,
_ => LevelFilter::INFO,
};
let env_filter = EnvFilter::builder()
.with_default_directive(baseline.into())
.from_env_lossy();
let exclude_full = filter_fn(|meta| meta.target() != SUBPROCESS_FULL_TARGET);
let layer = tracing_subscriber::fmt::layer()
.with_writer(std::io::stderr)
.with_ansi(true)
.event_format(StderrFormat)
.with_filter(env_filter.and(exclude_full));
Some(layer)
}
fn build_trace_layer<S>(verbose_level: u8) -> Option<impl Layer<S>>
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
if verbose_level < 2 || !log_files::TRACE.is_active() {
return None;
}
let env_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::DEBUG.into())
.from_env_lossy();
let exclude_full = filter_fn(|meta| meta.target() != SUBPROCESS_FULL_TARGET);
let layer = tracing_subscriber::fmt::layer()
.with_writer(TraceMakeWriter)
.with_ansi(false)
.event_format(TraceFileFormat)
.with_filter(env_filter.and(exclude_full));
Some(layer)
}
fn build_subprocess_layer<S>(verbose_level: u8) -> Option<impl Layer<S>>
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
if verbose_level < 2 || !log_files::SUBPROCESS.is_active() {
return None;
}
let only_full = filter_fn(|meta| meta.target() == SUBPROCESS_FULL_TARGET);
let layer = tracing_subscriber::fmt::layer()
.with_writer(SubprocessMakeWriter)
.with_ansi(false)
.event_format(SubprocessFileFormat)
.with_filter(only_full);
Some(layer)
}
fn announce_trace_destination() {
let Some(trace_path) = log_files::TRACE.path() else {
return;
};
let dir = trace_path.parent().expect("trace.log path has a parent");
let dir_display = worktrunk::path::format_path_for_display(dir);
let msg = match log_files::SUBPROCESS.path() {
Some(_) => cformat!(
"Writing to <underline>{dir_display}/</> — trace.log, subprocess.log, diagnostic.md"
),
None => cformat!(
"Writing to <underline>{dir_display}/</> — trace.log, diagnostic.md (subprocess.log unavailable)"
),
};
eprintln!("{}", info_message(msg));
}
#[cfg(test)]
mod tests {
use ansi_str::AnsiStr;
use super::{
WT_TRACE_TARGET, WtTraceFields, effective_log_max_level, format_wt_trace,
label_for_thread_index, style_stderr_line,
};
#[test]
fn label_covers_each_branch() {
assert_eq!(label_for_thread_index(None), '?');
assert_eq!(label_for_thread_index(Some(0)), '0');
assert_eq!(label_for_thread_index(Some(1)), 'a');
assert_eq!(label_for_thread_index(Some(26)), 'z');
assert_eq!(label_for_thread_index(Some(27)), 'A');
assert_eq!(label_for_thread_index(Some(52)), 'Z');
assert_eq!(label_for_thread_index(Some(53)), '?');
assert_eq!(label_for_thread_index(Some(9999)), '?');
}
#[test]
fn style_stderr_covers_each_shape() {
let cmd_ctx = style_stderr_line('a', "$ git status [feature]")
.ansi_strip()
.into_owned();
assert_eq!(cmd_ctx, "[a] $ git status [feature]");
let cmd_no_ctx = style_stderr_line('b', "$ gh pr list")
.ansi_strip()
.into_owned();
assert_eq!(cmd_no_ctx, "[b] $ gh pr list");
let err = style_stderr_line('c', " ! fatal: bad ref")
.ansi_strip()
.into_owned();
assert_eq!(err, "[c] ! fatal: bad ref");
let plain = style_stderr_line('d', "hello").ansi_strip().into_owned();
assert_eq!(plain, "[d] hello");
}
#[test]
fn wt_trace_fields_visit_records_every_type() {
use std::sync::{Arc, Mutex};
use tracing::Subscriber;
use tracing_subscriber::Registry;
use tracing_subscriber::layer::{Context, Layer, SubscriberExt};
struct Capture(Arc<Mutex<Vec<WtTraceFields>>>);
impl<S: Subscriber> Layer<S> for Capture {
fn on_event(&self, event: &tracing::Event<'_>, _: Context<'_, S>) {
if event.metadata().target() != WT_TRACE_TARGET {
return;
}
let mut fields = WtTraceFields::default();
event.record(&mut fields);
self.0.lock().unwrap().push(fields);
}
}
let events: Arc<Mutex<Vec<WtTraceFields>>> = Arc::new(Mutex::new(Vec::new()));
let subscriber = Registry::default().with(Capture(events.clone()));
tracing::subscriber::with_default(subscriber, || {
tracing::debug!(
target: WT_TRACE_TARGET,
ts = 7u64,
tid = 3u64,
unknown_u64 = 42u64,
);
tracing::debug!(target: WT_TRACE_TARGET, ok = true);
tracing::debug!(
target: WT_TRACE_TARGET,
cmd = "git status",
unknown_str = "ignored",
);
let msg = "fatal: bad ref".to_string();
tracing::debug!(target: WT_TRACE_TARGET, err = %msg);
});
let captured = events.lock().unwrap();
assert_eq!(captured[0].ts, Some(7));
assert_eq!(captured[0].tid, Some(3));
assert_eq!(captured[1].ok, Some(true));
assert_eq!(captured[2].cmd.as_deref(), Some("git status"));
assert_eq!(captured[3].err.as_deref(), Some("fatal: bad ref"));
}
#[test]
fn format_wt_trace_renders_each_kind() {
let f = WtTraceFields {
kind: Some("cmd_completed".into()),
ts: Some(100),
tid: Some(3),
context: Some("worktree".into()),
cmd: Some("git status".into()),
dur_us: Some(12300),
ok: Some(true),
..Default::default()
};
assert_eq!(
format_wt_trace(&f),
r#"[wt-trace] ts=100 tid=3 context=worktree cmd="git status" dur_us=12300 ok=true"#
);
let f = WtTraceFields {
kind: Some("cmd_completed".into()),
ts: Some(100),
tid: Some(3),
cmd: Some("gh pr list".into()),
dur_us: Some(45200),
ok: Some(false),
..Default::default()
};
assert_eq!(
format_wt_trace(&f),
r#"[wt-trace] ts=100 tid=3 cmd="gh pr list" dur_us=45200 ok=false"#
);
let f = WtTraceFields {
kind: Some("cmd_errored".into()),
ts: Some(100),
tid: Some(3),
context: Some("main".into()),
cmd: Some("git merge-base".into()),
dur_us: Some(100000),
err: Some("fatal: ...".into()),
..Default::default()
};
assert_eq!(
format_wt_trace(&f),
r#"[wt-trace] ts=100 tid=3 context=main cmd="git merge-base" dur_us=100000 err="fatal: ...""#
);
let f = WtTraceFields {
kind: Some("cmd_errored".into()),
ts: Some(100),
tid: Some(3),
cmd: Some("gh pr list".into()),
dur_us: Some(1000),
err: Some("network down".into()),
..Default::default()
};
assert_eq!(
format_wt_trace(&f),
r#"[wt-trace] ts=100 tid=3 cmd="gh pr list" dur_us=1000 err="network down""#
);
let f = WtTraceFields {
kind: Some("instant".into()),
ts: Some(100),
tid: Some(3),
event: Some("Showed skeleton".into()),
..Default::default()
};
assert_eq!(
format_wt_trace(&f),
r#"[wt-trace] ts=100 tid=3 event="Showed skeleton""#
);
let f = WtTraceFields {
kind: Some("span".into()),
ts: Some(100),
tid: Some(3),
span: Some("build_hook_context".into()),
dur_us: Some(8200),
..Default::default()
};
assert_eq!(
format_wt_trace(&f),
r#"[wt-trace] ts=100 tid=3 span="build_hook_context" dur_us=8200"#
);
let f = WtTraceFields {
kind: Some("future_kind".into()),
ts: Some(100),
tid: Some(3),
..Default::default()
};
assert_eq!(
format_wt_trace(&f),
r#"[wt-trace] ts=100 tid=3 kind="future_kind""#
);
let f = WtTraceFields::default();
assert_eq!(
format_wt_trace(&f),
r#"[wt-trace] ts=0 tid=0 kind="<unknown>""#
);
}
#[test]
fn effective_log_max_level_env_wins_when_set() {
use log::LevelFilter::*;
assert_eq!(effective_log_max_level(0, None), Warn);
assert_eq!(effective_log_max_level(1, None), Info);
assert_eq!(effective_log_max_level(2, None), Debug);
assert_eq!(effective_log_max_level(0, Some(Debug)), Debug);
assert_eq!(effective_log_max_level(2, Some(Warn)), Warn);
}
}