decdn_cli/logging.rs
1//! Opt-in client-side tracing for the `decdn` CLI.
2//!
3//! The CLI is a terminal UI: by default it installs no tracing subscriber, so
4//! its output is only the command's own stdout result and the stderr progress
5//! bar. That keeps the common case clean, but it also means every
6//! `tracing::debug!/info!/warn!` in `decdn-client`, `decdn-incentive`, and
7//! `iroh` goes to a no-op collector — a stalled `decdn fetch` is silent on the
8//! side that is actually failing.
9//!
10//! [`init`] turns logging on only when the operator asks for it, through
11//! `-v`/`-vv`/`-vvv`, `--log-level`, or the `RUST_LOG` environment variable.
12//! `RUST_LOG` wins (the same precedence the daemon uses), then `--log-level`,
13//! then the `-v` count ([`requested_level`]). With none of them set the CLI
14//! installs no subscriber and stays silent.
15//!
16//! # Progress-bar coexistence
17//!
18//! `decdn fetch` draws an [`indicatif`] progress bar to stderr, and a plain
19//! stderr subscriber would shred it. When [`init`] installs a subscriber it
20//! also creates one process-global [`indicatif::MultiProgress`]: log lines are
21//! written through [`indicatif::MultiProgress::suspend`], and the fetch bar is
22//! registered with the same `MultiProgress` (see [`attach_progress_bar`]), so
23//! the two share stderr without corrupting each other. When no subscriber is
24//! installed the bar draws straight to stderr as it always has.
25
26use std::sync::OnceLock;
27
28use decdn_common::cli::common::LogLevel;
29
30/// The process-global [`indicatif::MultiProgress`] that log output and the
31/// fetch progress bar share, set once by [`init`] when — and only when — a
32/// subscriber is installed. Empty on the default (no-verbosity) path.
33static PROGRESS: OnceLock<indicatif::MultiProgress> = OnceLock::new();
34
35/// The level the command line asks for: an explicit `--log-level` wins over
36/// the `-v` count, and a zero count with no `--log-level` asks for nothing.
37#[must_use]
38pub const fn requested_level(log_level: Option<LogLevel>, verbose: u8) -> Option<LogLevel> {
39 if log_level.is_some() {
40 return log_level;
41 }
42 match verbose {
43 0 => None,
44 1 => Some(LogLevel::Info),
45 2 => Some(LogLevel::Debug),
46 _ => Some(LogLevel::Trace),
47 }
48}
49
50/// Decide the tracing filter directive from `RUST_LOG` and the command-line
51/// level (`--log-level` or `-v`, see [`requested_level`]), or `None` to stay
52/// silent.
53///
54/// `RUST_LOG` (when present and not blank) takes precedence over the
55/// command-line level, matching the daemon. With neither source set the CLI installs no subscriber
56/// and its output is identical to a build without this module.
57fn filter_directive(rust_log: Option<&str>, log_level: Option<LogLevel>) -> Option<String> {
58 match rust_log {
59 Some(s) if !s.trim().is_empty() => Some(s.to_string()),
60 _ => log_level.map(|level| level.to_string()),
61 }
62}
63
64/// Level to use when the chosen directive is `RUST_LOG` but it fails to parse:
65/// the operator's `--log-level` if they passed one, otherwise `info`. Mirrors
66/// the daemon, so a broken `RUST_LOG` exported in the shell does not defeat an
67/// explicit `--log-level` on the invocation.
68fn malformed_rust_log_fallback(log_level: Option<LogLevel>) -> LogLevel {
69 log_level.unwrap_or(LogLevel::Info)
70}
71
72/// Install the CLI tracing subscriber when the operator opts in.
73///
74/// Reads `RUST_LOG` and the parsed `--log-level`; if neither requests logging
75/// this returns without touching global state, so the default terminal UX is
76/// unchanged. Otherwise it installs a `fmt` subscriber whose writer routes
77/// every line through a shared [`indicatif::MultiProgress`] so the `decdn
78/// fetch` bar survives.
79///
80/// Call once, before command dispatch, so the subscriber covers every
81/// subcommand.
82pub fn init(log_level: Option<LogLevel>) {
83 let rust_log = std::env::var("RUST_LOG").ok();
84 let Some(directive) = filter_directive(rust_log.as_deref(), log_level) else {
85 return;
86 };
87
88 // `EnvFilter::try_new` reports a malformed directive instead of panicking.
89 // Only a `RUST_LOG` value can be malformed here — a `--log-level` directive
90 // is always a valid level name — so fall back to the operator's
91 // `--log-level` (or `info`), like the daemon, rather than dropping to a
92 // fixed level or going silent.
93 let filter = match tracing_subscriber::EnvFilter::try_new(&directive) {
94 Ok(f) => f,
95 Err(e) => {
96 let fallback = malformed_rust_log_fallback(log_level);
97 eprintln!(
98 "warning: ignoring malformed RUST_LOG '{directive}'; \
99 falling back to log level '{fallback}': {e}"
100 );
101 tracing_subscriber::EnvFilter::new(fallback.to_string())
102 }
103 };
104
105 let progress = indicatif::MultiProgress::new();
106 // A second `init` call would be a bug (dispatch runs once), but guard
107 // against clobbering the shared handle regardless.
108 let _ = PROGRESS.set(progress.clone());
109
110 tracing_subscriber::fmt()
111 .with_env_filter(filter)
112 .with_writer(SuspendingMakeWriter { progress })
113 .init();
114}
115
116/// Register a freshly built progress bar with the shared
117/// [`indicatif::MultiProgress`] when a subscriber is installed, so log lines
118/// and the bar coexist on stderr.
119///
120/// On the default path (no subscriber) the bar is returned untouched and draws
121/// straight to stderr.
122pub fn attach_progress_bar(bar: indicatif::ProgressBar) -> indicatif::ProgressBar {
123 match PROGRESS.get() {
124 Some(progress) => progress.add(bar),
125 None => bar,
126 }
127}
128
129/// The [`indicatif::MultiProgress`] a multi-bar command (`bundle pull`) draws its
130/// bars into: the process-global one when a subscriber is installed — so its bars
131/// share stderr with log lines through [`indicatif::MultiProgress::suspend`] — or
132/// a fresh standalone container on the default silent path, where there are no log
133/// lines to coordinate with. Either way the bars stack coherently, and a
134/// non-terminal stderr auto-hides them.
135pub fn progress_container() -> indicatif::MultiProgress {
136 match PROGRESS.get() {
137 Some(progress) => progress.clone(),
138 None => indicatif::MultiProgress::new(),
139 }
140}
141
142/// [`tracing_subscriber::fmt::MakeWriter`] that emits each log line through
143/// [`indicatif::MultiProgress::suspend`], so writing never overlaps a redraw of
144/// the fetch progress bar.
145struct SuspendingMakeWriter {
146 /// Shared handle to the bar container; a clone of the one stored in
147 /// [`PROGRESS`].
148 progress: indicatif::MultiProgress,
149}
150
151impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SuspendingMakeWriter {
152 type Writer = SuspendingWriter;
153
154 fn make_writer(&'a self) -> Self::Writer {
155 SuspendingWriter {
156 progress: self.progress.clone(),
157 }
158 }
159}
160
161/// Per-event writer returned by [`SuspendingMakeWriter`]. Each `write` suspends
162/// the shared [`indicatif::MultiProgress`] for the duration of the stderr write.
163struct SuspendingWriter {
164 /// Shared bar container to suspend while writing.
165 progress: indicatif::MultiProgress,
166}
167
168impl std::io::Write for SuspendingWriter {
169 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
170 // With no active bar this is a plain stderr write; with the fetch bar
171 // present, `suspend` clears it, writes the line, and redraws it.
172 self.progress.suspend(|| std::io::stderr().write(buf))
173 }
174
175 fn flush(&mut self) -> std::io::Result<()> {
176 std::io::stderr().flush()
177 }
178}
179
180#[cfg(test)]
181mod tests;