Skip to content

Commit 0501ad3

Browse files
authored
feat(with-watch): use WW_LOG and default diagnostic logs to off (#374)
## Summary - switch with-watch diagnostic tracing configuration from `RUST_LOG` to `WW_LOG` - default with-watch diagnostic logging to `with_watch=off` while keeping fatal user-facing errors unchanged - document the new logging contract in the crate README, public docs, and canonical docs ## Testing - pnpm --filter public-docs test - cargo test -p with-watch - cargo test
1 parent 25e0955 commit 0501ad3

File tree

6 files changed

+258
-3
lines changed

6 files changed

+258
-3
lines changed

apps/public-docs/with-watch.mdx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,15 @@ with-watch exec --input 'src/**/*.rs' -- cargo test -p with-watch
124124
- Self-mutating commands such as `sed -i.bak -e 's/old/new/' config.txt` refresh their baseline after each run so they do not loop on their own writes.
125125
- Replace-style writers remain watchable because path inputs subscribe from the nearest existing directory anchor.
126126

127+
## Logging
128+
129+
- `with-watch` reads diagnostic `tracing` filters only from `WW_LOG`.
130+
- Diagnostic logs are off by default.
131+
- Set `WW_LOG=with_watch=info` for normal watcher diagnostics or `WW_LOG=with_watch=debug` for deeper troubleshooting.
132+
- `RUST_LOG` does not affect `with-watch` logging.
133+
- `WITH_WATCH_LOG_COLOR` and `NO_COLOR` still control ANSI log coloring.
134+
- Fatal user-facing errors still print to stderr even when diagnostic logging is off.
135+
127136
## Related pages
128137

129138
- [Projects Overview](projects-overview)

crates/with-watch/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,14 @@ with-watch exec --input 'src/**/*.rs' -- cargo test -p with-watch
111111
- Commands that mutate watched inputs directly, such as `sed -i.bak -e 's/old/new/' config.txt`, refresh their baseline after each run so they do not loop on their own writes.
112112
- Path-based inputs anchor the watcher at the nearest existing directory so replace-style writers keep producing later external change events.
113113

114+
## Logging
115+
116+
- `with-watch` reads `tracing` filter directives only from `WW_LOG`.
117+
- Diagnostic logs are off by default. Set `WW_LOG=with_watch=info` or `WW_LOG=with_watch=debug` when you want planner and watcher details.
118+
- `RUST_LOG` does not configure `with-watch` logging.
119+
- `WITH_WATCH_LOG_COLOR` and `NO_COLOR` continue to control ANSI log coloring.
120+
- Fatal user-facing errors still print to stderr even when diagnostic logging is off.
121+
114122
## Troubleshooting
115123

116124
- `No watch inputs could be inferred from the delegated command`: switch to `with-watch exec --input ... -- <command>`.

crates/with-watch/src/logging.rs

Lines changed: 176 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
use tracing_subscriber::EnvFilter;
22

3+
const WW_LOG_ENV: &str = "WW_LOG";
34
const WITH_WATCH_LOG_COLOR_ENV: &str = "WITH_WATCH_LOG_COLOR";
45
const NO_COLOR_ENV: &str = "NO_COLOR";
6+
const DEFAULT_LOG_FILTER: &str = "with_watch=off";
57

68
pub fn init_logging() {
7-
let env_filter =
8-
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("with_watch=info"));
9+
let env_filter = resolve_env_filter_from_environment();
910
let _ = tracing_subscriber::fmt()
1011
.with_env_filter(env_filter)
1112
.with_ansi(log_color_enabled())
@@ -15,6 +16,19 @@ pub fn init_logging() {
1516
.try_init();
1617
}
1718

19+
fn resolve_env_filter_from_environment() -> EnvFilter {
20+
resolve_env_filter(std::env::var(WW_LOG_ENV).ok().as_deref())
21+
}
22+
23+
fn resolve_env_filter(ww_log: Option<&str>) -> EnvFilter {
24+
let filter = ww_log
25+
.map(str::trim)
26+
.filter(|value| !value.is_empty())
27+
.unwrap_or(DEFAULT_LOG_FILTER);
28+
29+
EnvFilter::try_new(filter).unwrap_or_else(|_| EnvFilter::new(DEFAULT_LOG_FILTER))
30+
}
31+
1832
fn log_color_enabled() -> bool {
1933
resolve_log_color_enabled(
2034
std::env::var(WITH_WATCH_LOG_COLOR_ENV).ok().as_deref(),
@@ -54,7 +68,19 @@ fn parse_log_color_mode(raw: &str) -> Option<LogColorMode> {
5468

5569
#[cfg(test)]
5670
mod tests {
57-
use super::resolve_log_color_enabled;
71+
use std::{
72+
io::{self, Write},
73+
sync::{Arc, Mutex, OnceLock},
74+
};
75+
76+
use tracing::{debug, info};
77+
78+
use super::{
79+
resolve_env_filter, resolve_env_filter_from_environment, resolve_log_color_enabled,
80+
EnvFilter, DEFAULT_LOG_FILTER, WW_LOG_ENV,
81+
};
82+
83+
const RUST_LOG_ENV: &str = "RUST_LOG";
5884

5985
#[test]
6086
fn default_enables_colored_logs() {
@@ -70,4 +96,151 @@ mod tests {
7096
fn no_color_disables_logs_without_override() {
7197
assert!(!resolve_log_color_enabled(None, Some("1")));
7298
}
99+
100+
#[test]
101+
fn default_filter_disables_info_logs() {
102+
let output = capture_logs(resolve_env_filter(None), || {
103+
info!("hidden");
104+
});
105+
106+
assert!(output.is_empty());
107+
}
108+
109+
#[test]
110+
fn blank_ww_log_uses_default_filter() {
111+
let output = capture_logs(resolve_env_filter(Some(" ")), || {
112+
info!("hidden");
113+
});
114+
115+
assert!(
116+
output.is_empty(),
117+
"blank WW_LOG should fall back to {DEFAULT_LOG_FILTER}"
118+
);
119+
}
120+
121+
#[test]
122+
fn ww_log_can_enable_info_logs() {
123+
let output = capture_logs(resolve_env_filter(Some("with_watch=info")), || {
124+
info!("visible");
125+
debug!("still hidden");
126+
});
127+
128+
assert!(output.contains("visible"));
129+
assert!(!output.contains("still hidden"));
130+
}
131+
132+
#[test]
133+
fn ww_log_can_enable_debug_logs() {
134+
let output = capture_logs(resolve_env_filter(Some("with_watch=debug")), || {
135+
debug!("visible");
136+
});
137+
138+
assert!(output.contains("visible"));
139+
}
140+
141+
#[test]
142+
fn invalid_ww_log_falls_back_to_default_filter() {
143+
let output = capture_logs(resolve_env_filter(Some("not a valid filter[")), || {
144+
info!("hidden");
145+
});
146+
147+
assert!(output.is_empty());
148+
}
149+
150+
#[test]
151+
fn rust_log_is_ignored_when_resolving_environment_filter() {
152+
with_logging_environment(None, Some("with_watch=debug"), || {
153+
let output = capture_logs(resolve_env_filter_from_environment(), || {
154+
info!("hidden");
155+
});
156+
157+
assert!(output.is_empty());
158+
});
159+
}
160+
161+
#[test]
162+
fn ww_log_from_environment_overrides_default_off() {
163+
with_logging_environment(Some("with_watch=info"), Some("with_watch=off"), || {
164+
let output = capture_logs(resolve_env_filter_from_environment(), || {
165+
info!("visible");
166+
});
167+
168+
assert!(output.contains("visible"));
169+
});
170+
}
171+
172+
fn capture_logs(env_filter: EnvFilter, callback: impl FnOnce()) -> String {
173+
let buffer = Arc::new(Mutex::new(Vec::new()));
174+
let writer = SharedWriter(buffer.clone());
175+
let subscriber = tracing_subscriber::fmt()
176+
.with_env_filter(env_filter)
177+
.with_ansi(false)
178+
.with_target(false)
179+
.with_level(false)
180+
.without_time()
181+
.with_writer(move || writer.clone())
182+
.finish();
183+
184+
tracing::subscriber::with_default(subscriber, callback);
185+
186+
let output = buffer.lock().expect("lock log buffer").clone();
187+
String::from_utf8(output).expect("utf8 log output")
188+
}
189+
190+
fn with_logging_environment<T>(
191+
ww_log: Option<&str>,
192+
rust_log: Option<&str>,
193+
callback: impl FnOnce() -> T,
194+
) -> T {
195+
let _guard = environment_lock().lock().expect("lock logging env");
196+
197+
let original_ww_log = std::env::var_os(WW_LOG_ENV);
198+
let original_rust_log = std::env::var_os(RUST_LOG_ENV);
199+
200+
set_optional_env(WW_LOG_ENV, ww_log);
201+
set_optional_env(RUST_LOG_ENV, rust_log);
202+
203+
let result = callback();
204+
205+
restore_optional_env(WW_LOG_ENV, original_ww_log);
206+
restore_optional_env(RUST_LOG_ENV, original_rust_log);
207+
208+
result
209+
}
210+
211+
fn environment_lock() -> &'static Mutex<()> {
212+
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
213+
LOCK.get_or_init(|| Mutex::new(()))
214+
}
215+
216+
fn set_optional_env(key: &str, value: Option<&str>) {
217+
match value {
218+
Some(value) => std::env::set_var(key, value),
219+
None => std::env::remove_var(key),
220+
}
221+
}
222+
223+
fn restore_optional_env(key: &str, value: Option<std::ffi::OsString>) {
224+
match value {
225+
Some(value) => std::env::set_var(key, value),
226+
None => std::env::remove_var(key),
227+
}
228+
}
229+
230+
#[derive(Clone)]
231+
struct SharedWriter(Arc<Mutex<Vec<u8>>>);
232+
233+
impl Write for SharedWriter {
234+
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
235+
self.0
236+
.lock()
237+
.expect("lock log buffer")
238+
.extend_from_slice(buf);
239+
Ok(buf.len())
240+
}
241+
242+
fn flush(&mut self) -> io::Result<()> {
243+
Ok(())
244+
}
245+
}
73246
}

crates/with-watch/tests/cli.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,66 @@ fn short_help_stays_compact() {
5151
.stdout(predicate::str::contains("Recognized but not auto-watchable commands:").not());
5252
}
5353

54+
#[cfg(unix)]
55+
#[test]
56+
fn tracing_logs_are_off_by_default() {
57+
let temp_dir = tempfile::tempdir().expect("create tempdir");
58+
let input_path = temp_dir.path().join("input.txt");
59+
fs::write(&input_path, "hello\n").expect("write input");
60+
61+
with_watch_command()
62+
.env_remove("WW_LOG")
63+
.env_remove("RUST_LOG")
64+
.env("WITH_WATCH_LOG_COLOR", "never")
65+
.env("WITH_WATCH_TEST_MAX_RUNS", "1")
66+
.arg("cat")
67+
.arg(&input_path)
68+
.assert()
69+
.success()
70+
.stdout(predicate::str::contains("hello"))
71+
.stdout(predicate::str::contains("Starting with-watch run loop").not());
72+
}
73+
74+
#[cfg(unix)]
75+
#[test]
76+
fn ww_log_can_enable_startup_logs() {
77+
let temp_dir = tempfile::tempdir().expect("create tempdir");
78+
let input_path = temp_dir.path().join("input.txt");
79+
fs::write(&input_path, "hello\n").expect("write input");
80+
81+
with_watch_command()
82+
.env("WW_LOG", "with_watch=info")
83+
.env_remove("RUST_LOG")
84+
.env("WITH_WATCH_LOG_COLOR", "never")
85+
.env("WITH_WATCH_TEST_MAX_RUNS", "1")
86+
.arg("cat")
87+
.arg(&input_path)
88+
.assert()
89+
.success()
90+
.stdout(predicate::str::contains("hello"))
91+
.stdout(predicate::str::contains("Starting with-watch run loop"));
92+
}
93+
94+
#[cfg(unix)]
95+
#[test]
96+
fn rust_log_does_not_enable_startup_logs() {
97+
let temp_dir = tempfile::tempdir().expect("create tempdir");
98+
let input_path = temp_dir.path().join("input.txt");
99+
fs::write(&input_path, "hello\n").expect("write input");
100+
101+
with_watch_command()
102+
.env_remove("WW_LOG")
103+
.env("RUST_LOG", "with_watch=info")
104+
.env("WITH_WATCH_LOG_COLOR", "never")
105+
.env("WITH_WATCH_TEST_MAX_RUNS", "1")
106+
.arg("cat")
107+
.arg(&input_path)
108+
.assert()
109+
.success()
110+
.stdout(predicate::str::contains("hello"))
111+
.stdout(predicate::str::contains("Starting with-watch run loop").not());
112+
}
113+
54114
#[test]
55115
fn commands_without_filesystem_inputs_guide_users_to_exec_input() {
56116
with_watch_command()

docs/crates-with-watch-foundation.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
- After watch input inference, watcher setup, and baseline snapshot capture succeed, all command modes must execute the delegated command immediately once before waiting for the first filesystem change event.
2323
- `exec --input` must accept repeatable explicit glob/path values, keep the delegated command unchanged, and remain the canonical fallback for otherwise ambiguous or pathless commands.
2424
- `--no-hash` must remain a global flag that switches rerun filtering from content hashes to metadata-only comparison.
25+
- `WW_LOG` must remain the only supported environment variable for configuring `with-watch` diagnostic `tracing` filters.
26+
- The default diagnostic log filter must remain `with_watch=off`, and `RUST_LOG` must not affect `with-watch` logging behavior.
2527
- Public crate installation must remain `cargo install with-watch`.
2628
- Publish tag naming must remain `with-watch@v<version>`.
2729
- Stable internal enums must remain aligned with the current v1 contract:
@@ -61,6 +63,7 @@
6163

6264
## Logging
6365
- Use structured `tracing` logs for command planning, watcher setup, snapshot capture, debounce decisions, and rerun causes.
66+
- Diagnostic `tracing` logs are operator opt-in: they are disabled by default and enabled via `WW_LOG`.
6467
- Logs must include `command_source`, `detection_mode`, input counts, `adapter_id`, `fallback_used`, `default_watch_root_used`, `filtered_output_count`, `side_effect_profile`, snapshot modes, snapshot entry counts, snapshot capture elapsed time, and rerun suppression outcomes.
6568

6669
## Build and Test

docs/project-with-watch.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Provide a Rust-based CLI wrapper that reruns delegated shell utilities and arbit
2020
- The public CLI surface must keep exactly one delegated-command entrypoint per invocation: passthrough argv, `--shell`, or `exec --input`.
2121
- After watch input inference, watcher setup, and baseline snapshot capture succeed, `with-watch` must execute the delegated command immediately once before waiting for the first filesystem change event.
2222
- Default change detection must prefer content hashing, while `--no-hash` must switch the rerun filter to metadata-only comparison.
23+
- `WW_LOG` must remain the only supported environment variable for configuring `with-watch` diagnostic `tracing` logs, and the default diagnostic filter must remain `with_watch=off`.
24+
- `RUST_LOG` must not affect `with-watch` diagnostic logging.
2325
- `exec --input` reruns the delegated command unchanged and must not inject changed paths into argv or environment variables.
2426
- Commands without safe inferred filesystem inputs must fail clearly and direct operators to `with-watch exec --input ...`.
2527
- Passthrough and shell modes must use adapter-driven input inference that excludes known outputs, scripts, and pattern operands from the watch set.

0 commit comments

Comments
 (0)