Skip to main content

forge_server/
logging.rs

1use crate::cli::{Cli, DiagnosticsPreset};
2use anyhow::Context as _;
3use std::fs::{File, OpenOptions, create_dir_all, metadata, remove_file, rename};
4use std::io::{BufWriter, Write};
5use std::path::{Path, PathBuf};
6use std::str::FromStr as _;
7use tracing::level_filters::LevelFilter;
8use tracing_appender::non_blocking::WorkerGuard;
9
10pub mod targets {
11    pub const APP_INPUT: &str = "app.input";
12    pub const APP_LIFECYCLE: &str = "app.lifecycle";
13    pub const APP_PERMISSION: &str = "app.permission";
14    pub const BRIDGE_LIFECYCLE: &str = "bridge.lifecycle";
15    pub const BRIDGE_MCP: &str = "bridge.mcp";
16    pub const BRIDGE_PERMISSION: &str = "bridge.permission";
17    pub const BRIDGE_PROTOCOL: &str = "bridge.protocol";
18    pub const BRIDGE_SDK: &str = "bridge.sdk";
19}
20
21const DEFAULT_LOG_DIR: &str = "forge";
22const DEFAULT_LOG_FILE_NAME: &str = "forge.log";
23pub(crate) const LOG_ROTATION_MAX_BYTES: u64 = 10 * 1024 * 1024;
24pub(crate) const LOG_ROTATION_MAX_FILES: usize = 5;
25
26pub struct LoggingRuntime {
27    _guard: Option<WorkerGuard>,
28}
29
30impl LoggingRuntime {
31    pub fn init(cli: &Cli) -> anyhow::Result<Self> {
32        let Some(log_path) = resolve_log_path(cli)? else {
33            return Ok(Self { _guard: None });
34        };
35
36        let directives = build_filter_directives(cli);
37        let filter = tracing_subscriber::EnvFilter::try_new(directives.as_str())
38            .map_err(|e| anyhow::anyhow!("invalid tracing filter `{directives}`: {e}"))?;
39        // Always append so a restart immediately after a bug
40        // doesn't overwrite the events that produced it. The window
41        // keeps the active file plus `.1` through `.5`, so rotation
42        // is capped at 10 MB × 6 slots (~60 MB total).
43        let writer = RollingFileWriter::new(
44            &log_path.path,
45            true,
46            LOG_ROTATION_MAX_BYTES,
47            LOG_ROTATION_MAX_FILES,
48        )?;
49        let (non_blocking, guard) = tracing_appender::non_blocking(writer);
50
51        tracing_subscriber::fmt()
52            .json()
53            .flatten_event(true)
54            .with_env_filter(filter)
55            .with_writer(non_blocking)
56            .with_ansi(false)
57            .with_file(true)
58            .with_line_number(true)
59            .with_target(true)
60            .try_init()
61            .map_err(|e| anyhow::anyhow!("failed to initialize tracing subscriber: {e}"))?;
62
63        tracing::info!(
64            target: targets::APP_LIFECYCLE,
65            event_name = "logging_initialized",
66            message = "tracing subscriber initialized",
67            log_file = %log_path.path.display(),
68            log_path_source = log_path.source.as_str(),
69            log_filter = %directives,
70            log_rotation_max_bytes = LOG_ROTATION_MAX_BYTES,
71            log_rotation_max_files = LOG_ROTATION_MAX_FILES,
72            version = crate::FORGE_VERSION,
73        );
74
75        Ok(Self { _guard: Some(guard) })
76    }
77}
78
79/// Default tracing filter. `info` baseline keeps signal-to-noise
80/// sensible; the targets at `debug` are the ones we actually stare at
81/// when triaging UI/event drops (input submission, the bridge connect
82/// path). `agent.env_git` is there for a second reason: a git probe
83/// missing `refs/remotes/origin/HEAD` is real and is not forge's
84/// problem. At `debug` it stays readable without raising a warning
85/// that says forge is unwell. `forge_workspace::output_tail` is there for
86/// the same reason: a line in a command's output file that cannot be
87/// decoded is the watched command's own output rather than a problem with
88/// forge. (`forge_server` too: its session reads are one session's own
89/// work.)
90///
91/// `forge_dictate` and `forge_workspace::dictate` are there for the
92/// same reason again: a take that reaches its capture cap, a normalizer
93/// that runs out of token budget and a take whose recognition failed are
94/// one session's own work, not forge's health. They were demoted out of
95/// `WARN` for it, so they are named here to stay readable.
96///
97/// `forge_browser` carries the browser service's own records of a viewer
98/// attaching and of a controller role being let go. Each is that session's
99/// own work rather than forge's health, so they are `debug`; the refusal of
100/// one gesture lives on `forge_server` (its `browser_input_refused`), which
101/// the directive above already keeps readable.
102///
103/// The two `llama` targets are the dictation engine's own log bridge,
104/// which reaches tracing through the llama-cpp-2 crate under both its
105/// hyphenated target and its module path. What it reports is the model
106/// loading itself, not forge, so it is not forge's warning to raise.
107///
108/// `forge_workspace::mcp` and `forge_agent::mcp` carry the two halves of
109/// one record: the surface a spawn hands its child and the surface the
110/// CLI's boot init frame says it received. The list is fixed once the child
111/// starts, so the pair is the only way a "tool not found" is traced to the
112/// side that actually lacked the name.
113///
114/// `forge_connectors::slack::throttle` carries the sweep's per-pass throttle
115/// detail, which was demoted to `debug` once the warning moved to one per
116/// episode. A workspace Slack throttles is that workspace's own rate limit
117/// rather than forge's health, so the repeated numbers are `debug`; the
118/// dedicated target keeps the module's per-sweep chatter out of the default
119/// filter while these stay readable.
120///
121/// `forge_workspace::idle_sweep` carries the injected sweep's delivery
122/// record, which is a `debug` and would otherwise land nowhere: without
123/// the directive the subsystem reports nothing at all between a failure
124/// and its recovery.
125pub const DEFAULT_LOG_DIRECTIVES: &str = "info,\
126    app.input=debug,\
127    app.lifecycle=debug,\
128    bridge.lifecycle=debug,\
129    agent.env_git=debug,\
130    forge_agent::env::config_watch=debug,\
131    forge_server=debug,\
132    agent.catalog_scan=debug,\
133    forge_agent::userdata::catalog::scan=debug,\
134    forge_workspace::output_tail=debug,\
135    forge_workspace::idle_sweep=debug,\
136    forge_workspace::work=debug,\
137    forge_workspace::browser=debug,\
138    forge_browser=debug,\
139    forge_supervise=debug,\
140    forge_workspace::mcp=debug,\
141    forge_workspace::skills=debug,\
142    forge_workspace::gotify=debug,\
143    forge_agent::mcp=debug,\
144    forge_workspace::dictate=debug,\
145    forge_workspace::subagent_cards=debug,\
146    forge_dictate=debug,\
147    forge_connectors::slack::throttle=debug,\
148    llama_cpp_2=error,\
149    llama-cpp-2=error";
150
151/// The preset's directives with the default set composed in, so a preset
152/// extends the always-on coverage rather than replacing it: a target that
153/// only the defaults name stays readable under every preset.
154fn preset_extending_defaults(preset: &DiagnosticsPreset) -> String {
155    compose_filter_directives(preset.filter_directives(), DEFAULT_LOG_DIRECTIVES)
156}
157
158/// No target can be empty, so this key collides with no real slot: every
159/// bare level token shares it, because the parser resolves them all to the
160/// filter's one global directive and the level is not part of that slot's
161/// identity.
162const GLOBAL_SLOT_KEY: &str = "";
163
164/// A directive's slot in the filter, the part two directives naming the
165/// same slot collide on. A bare level token (`info`, `4`, a case variant)
166/// is the global directive, which every bare level shares. Anything else
167/// keys by its target, cut at the `=` the parser would use: the first one
168/// outside a brace shape.
169fn directive_key(directive: &str) -> &str {
170    if LevelFilter::from_str(directive).is_ok() {
171        return GLOBAL_SLOT_KEY;
172    }
173    let mut depth = 0usize;
174    for (index, ch) in directive.char_indices() {
175        match ch {
176            '{' => depth += 1,
177            '}' => depth = depth.saturating_sub(1),
178            '=' if depth == 0 => return &directive[..index],
179            _ => {}
180        }
181    }
182    directive
183}
184
185/// The two directive lists composed into one, a directive both name kept
186/// once - the preset's copy, whatever level it sets - so a default never
187/// overrides a slot the preset names. The preset's own order leads; the
188/// defaults' directives it did not name follow.
189fn compose_filter_directives(preset_directives: &str, default_directives: &str) -> String {
190    let mut directives: Vec<&str> = Vec::new();
191    for directive in preset_directives.split(',').chain(default_directives.split(',')) {
192        let key = directive_key(directive);
193        if !directives.iter().any(|kept| directive_key(kept) == key) {
194            directives.push(directive);
195        }
196    }
197    directives.join(",")
198}
199
200fn build_filter_directives(cli: &Cli) -> String {
201    cli.log_filter
202        .clone()
203        .or_else(|| cli.diagnostics_preset.as_ref().map(preset_extending_defaults))
204        .or_else(|| std::env::var("RUST_LOG").ok())
205        .unwrap_or_else(|| DEFAULT_LOG_DIRECTIVES.to_owned())
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq)]
209enum LogPathSource {
210    Explicit,
211    Default,
212}
213
214impl LogPathSource {
215    fn as_str(self) -> &'static str {
216        match self {
217            Self::Explicit => "explicit",
218            Self::Default => "default",
219        }
220    }
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
224struct ResolvedLogPath {
225    path: PathBuf,
226    source: LogPathSource,
227}
228
229fn resolve_log_path(cli: &Cli) -> anyhow::Result<Option<ResolvedLogPath>> {
230    if let Some(path) = cli.log_file.clone() {
231        return Ok(Some(ResolvedLogPath { path, source: LogPathSource::Explicit }));
232    }
233    // Logging on by default: the rolling writer caps disk at ~60MB
234    // (6 x 10MB), which is cheap enough that diagnostics need no opt-in.
235    // `RUST_LOG=off` silences it.
236    let path = default_log_path()?;
237    Ok(Some(ResolvedLogPath { path, source: LogPathSource::Default }))
238}
239
240fn default_log_path() -> anyhow::Result<PathBuf> {
241    let base_dir = default_diagnostics_dir()?;
242    Ok(base_dir.join(DEFAULT_LOG_FILE_NAME))
243}
244
245/// The directory forge's own logs live in, which a client's perf log shares.
246pub(crate) fn default_diagnostics_dir() -> anyhow::Result<PathBuf> {
247    if let Some(dir) = dirs::data_local_dir() {
248        return Ok(dir.join(DEFAULT_LOG_DIR).join("logs"));
249    }
250    if let Some(dir) = dirs::cache_dir() {
251        return Ok(dir.join(DEFAULT_LOG_DIR).join("logs"));
252    }
253    if let Some(home) = dirs::home_dir() {
254        return Ok(home.join(format!(".{DEFAULT_LOG_DIR}")).join("logs"));
255    }
256    // No state / cache / home dir available - refuse to fall back
257    // to `std::env::current_dir()`. Where the binary was launched
258    // must not change the log location: a `forge` invocation from
259    // `~/Projects/forge` and one from `/tmp` must write to the same
260    // path or to no path. Returning an error here disables logging
261    // for the rare environment that has no resolvable user dirs.
262    anyhow::bail!("no resolvable data/cache/home dir for diagnostics path")
263}
264
265/// Rotation is decided from the current `write` call's length, so a
266/// caller must hand over one whole record per call and keep it under
267/// the buffer capacity - a partial write re-enters the check with only
268/// the remainder.
269#[derive(Debug)]
270pub struct RollingFileWriter {
271    base_path: PathBuf,
272    max_bytes: u64,
273    max_files: usize,
274    file: BufWriter<File>,
275    current_size: u64,
276}
277
278impl RollingFileWriter {
279    pub fn new(
280        path: &Path,
281        append: bool,
282        max_bytes: u64,
283        max_files: usize,
284    ) -> anyhow::Result<Self> {
285        if let Some(parent) = path.parent() {
286            create_dir_all(parent)
287                .with_context(|| format!("failed to create log directory {}", parent.display()))?;
288        }
289        if append {
290            let current_size = metadata(path).map_or(0, |m| m.len());
291            if current_size >= max_bytes {
292                // The recursion's `clear_rotated_files` is the intended
293                // outcome: an already-over-cap log is discarded, not
294                // preserved as `.1`.
295                rotate_file_window(path, max_files)?;
296                return Self::new(path, false, max_bytes, max_files);
297            }
298            let file = open_log_file(path, true)?;
299            return Ok(Self {
300                base_path: path.to_path_buf(),
301                max_bytes,
302                max_files,
303                file: BufWriter::new(file),
304                current_size,
305            });
306        }
307
308        clear_rotated_files(path, max_files)?;
309        let file = open_log_file(path, false)?;
310        Ok(Self {
311            base_path: path.to_path_buf(),
312            max_bytes,
313            max_files,
314            file: BufWriter::new(file),
315            current_size: 0,
316        })
317    }
318
319    fn rotate_if_needed(&mut self, incoming_len: usize) -> std::io::Result<()> {
320        let incoming = u64::try_from(incoming_len).unwrap_or(u64::MAX);
321        if self.current_size == 0 || self.current_size.saturating_add(incoming) <= self.max_bytes {
322            return Ok(());
323        }
324        self.file.flush()?;
325        rotate_file_window(&self.base_path, self.max_files)?;
326        self.file = BufWriter::new(open_log_file(&self.base_path, false)?);
327        self.current_size = 0;
328        Ok(())
329    }
330}
331
332impl Write for RollingFileWriter {
333    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
334        self.rotate_if_needed(buf.len())?;
335        let written = self.file.write(buf)?;
336        self.current_size =
337            self.current_size.saturating_add(u64::try_from(written).unwrap_or(u64::MAX));
338        Ok(written)
339    }
340
341    fn flush(&mut self) -> std::io::Result<()> {
342        self.file.flush()
343    }
344}
345
346fn open_log_file(path: &Path, append: bool) -> std::io::Result<File> {
347    let mut options = OpenOptions::new();
348    options.create(true).write(true);
349    if append {
350        options.append(true);
351    } else {
352        options.truncate(true);
353    }
354    options.open(path)
355}
356
357fn rotate_file_window(base_path: &Path, max_files: usize) -> std::io::Result<()> {
358    if max_files == 0 {
359        if base_path.exists() {
360            remove_file(base_path)?;
361        }
362        return Ok(());
363    }
364
365    let oldest = rotated_log_path(base_path, max_files);
366    if oldest.exists() {
367        remove_file(&oldest)?;
368    }
369
370    for index in (1..max_files).rev() {
371        let from = rotated_log_path(base_path, index);
372        if from.exists() {
373            let to = rotated_log_path(base_path, index + 1);
374            if to.exists() {
375                remove_file(&to)?;
376            }
377            rename(&from, &to)?;
378        }
379    }
380
381    if base_path.exists() {
382        let first = rotated_log_path(base_path, 1);
383        if first.exists() {
384            remove_file(&first)?;
385        }
386        rename(base_path, first)?;
387    }
388
389    Ok(())
390}
391
392fn clear_rotated_files(base_path: &Path, max_files: usize) -> std::io::Result<()> {
393    for index in 1..=max_files {
394        let rotated = rotated_log_path(base_path, index);
395        if rotated.exists() {
396            remove_file(rotated)?;
397        }
398    }
399    Ok(())
400}
401
402fn rotated_log_path(base_path: &Path, index: usize) -> PathBuf {
403    let suffix = format!(".{index}");
404    if let Some(name) = base_path.file_name().and_then(|name| name.to_str()) {
405        base_path.with_file_name(format!("{name}{suffix}"))
406    } else {
407        let mut path = base_path.as_os_str().to_os_string();
408        path.push(suffix);
409        PathBuf::from(path)
410    }
411}
412
413#[cfg(test)]
414mod tests {
415    use super::{
416        DEFAULT_LOG_DIRECTIVES, RollingFileWriter, build_filter_directives, clear_rotated_files,
417        compose_filter_directives, directive_key, resolve_log_path, rotated_log_path,
418    };
419    use crate::cli::{Cli, DiagnosticsPreset};
420    use std::fs;
421    use std::io::Write;
422    use std::path::PathBuf;
423    use tempfile::tempdir;
424
425    #[test]
426    fn resolve_log_path_uses_explicit_path_when_provided() {
427        let cli = Cli {
428            project: None,
429            new: false,
430            generate_completion: None,
431            diagnostics_preset: None,
432            log_file: Some(PathBuf::from("custom.log")),
433            log_filter: None,
434        };
435
436        let resolved = resolve_log_path(&cli).expect("resolve succeeds").expect("path exists");
437        assert_eq!(resolved.path, PathBuf::from("custom.log"));
438        assert_eq!(resolved.source.as_str(), "explicit");
439    }
440
441    #[test]
442    fn resolve_log_path_returns_default_with_no_triggers_set() {
443        // Logs are on by default - no CLI flag, env var, or filter
444        // needed. Pins the policy so a future refactor can't
445        // accidentally re-introduce the "opt-in" gate.
446        let cli = Cli {
447            project: None,
448            new: false,
449            generate_completion: None,
450            diagnostics_preset: None,
451            log_file: None,
452            log_filter: None,
453        };
454
455        let resolved = resolve_log_path(&cli).expect("resolve succeeds").expect("path exists");
456        assert_eq!(resolved.source.as_str(), "default");
457        let path = resolved.path.to_string_lossy().replace('\\', "/");
458        assert!(path.ends_with("forge/logs/forge.log"));
459    }
460
461    #[test]
462    fn default_log_directives_target_the_drop_diagnosis_paths() {
463        // `app.input` covers input submission; `bridge.lifecycle`
464        // covers connect/spawn. Anything dropping out of these
465        // regresses the always-on debug coverage we ship for triage.
466        assert!(DEFAULT_LOG_DIRECTIVES.contains("app.input=debug"));
467        assert!(DEFAULT_LOG_DIRECTIVES.contains("bridge.lifecycle=debug"));
468        // `agent.env_git` is the target whose records were demoted out of
469        // WARN; dropping it from this set would silence the git probe miss
470        // entirely, which is the state the levels moved away from.
471        assert!(DEFAULT_LOG_DIRECTIVES.contains("agent.env_git=debug"));
472        // `forge_server` pins the whole server crate at `debug`, so the
473        // transport, surface and boot records ride it alongside the session
474        // reads. Without the directive those records land nowhere at all.
475        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_server=debug"));
476        // `forge_supervise` pins the registry's start and stop records at
477        // `debug`: without the directive the two lines that say a member
478        // came up or was stopped land nowhere at all.
479        let supervise_target = "forge_supervise=debug";
480        assert!(
481            DEFAULT_LOG_DIRECTIVES.split(',').any(|directive| directive.trim() == supervise_target),
482            "the supervisor's start and stop records keep their directive ({supervise_target}): \
483             {DEFAULT_LOG_DIRECTIVES}",
484        );
485        // `forge_workspace::idle_sweep` carries the injected sweep's
486        // delivery record, which is a `debug` and lands nowhere without
487        // this directive.
488        let idle_sweep_target = "forge_workspace::idle_sweep=debug";
489        assert!(
490            DEFAULT_LOG_DIRECTIVES
491                .split(',')
492                .any(|directive| directive.trim() == idle_sweep_target),
493            "the idle sweep's delivery records keep their directive ({idle_sweep_target}): \
494             {DEFAULT_LOG_DIRECTIVES}",
495        );
496        // `forge_agent::userdata::catalog::scan` carries the scan's
497        // module-path records at `debug`: an unparseable transcript line, a
498        // session with no on-disk file, an anchor no row names. Each is one
499        // session's own data rather than forge's health, and without the
500        // directive the records land nowhere at all.
501        let scan_module = "forge_agent::userdata::catalog::scan=debug";
502        assert!(
503            DEFAULT_LOG_DIRECTIVES.split(',').any(|directive| directive.trim() == scan_module),
504            "the scan's module path keeps its directive ({scan_module}): \
505             {DEFAULT_LOG_DIRECTIVES}",
506        );
507        // `forge_workspace::subagent_cards` carries the tracker's record for a
508        // parented frame it cannot resolve: the frame keeps its wire parent
509        // and draws nowhere, so without the directive its only trace lands
510        // nowhere too.
511        let tracker_module = "forge_workspace::subagent_cards=debug";
512        assert!(
513            DEFAULT_LOG_DIRECTIVES.split(',').any(|directive| directive.trim() == tracker_module),
514            "the tracker's module path keeps its directive ({tracker_module}): \
515             {DEFAULT_LOG_DIRECTIVES}",
516        );
517        // The scan's lite-read failures carry a target of their own, which
518        // the module-path directive above does not match: a target is not a
519        // module path. Without this directive those records - the only trace
520        // of a session whose transcript could not be read - land nowhere at
521        // all. The expected directive is built from the constant the sites
522        // emit under, so renaming that constant cannot leave the directive
523        // pointing at a name no record carries; matched as a whole directive
524        // rather than a substring, so a prefix ending in this target cannot
525        // satisfy the pin.
526        let scan_directive = format!("{}=debug", forge_agent::logging::targets::CATALOG_SCAN);
527        assert!(
528            DEFAULT_LOG_DIRECTIVES.split(',').any(|directive| directive.trim() == scan_directive),
529            "the lite-read target has a directive of its own ({scan_directive}): \
530             {DEFAULT_LOG_DIRECTIVES}",
531        );
532        // `forge_workspace::output_tail` carries the tail read's
533        // unreadable-line record, demoted there for the same reason: a line
534        // the watched command wrote that cannot be decoded is that command's
535        // own output. Without the directive the record never lands at all.
536        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::output_tail=debug"));
537        // The seat's working-tree watch says why it holds nothing and why it
538        // stops, both at `debug` because neither is forge's health. They are
539        // the only record of a seat whose tree is silently not being read, so
540        // the target needs the directive or the silence has no explanation.
541        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::work=debug"));
542        // `forge_workspace::gotify` carries the subsystem's declines at
543        // `debug`: unconfigured, idle and already running are expected
544        // states, not forge problems. Without the directive the only record
545        // of a subsystem that is not running lands nowhere.
546        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::gotify=debug"));
547        // `forge_workspace::browser` records the browser calls a session
548        // made - what ran, for whom, how it ended - at `debug` because a
549        // call is the session's own work. Without the directive the call
550        // record lands nowhere at all.
551        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::browser=debug"));
552        // `forge_browser` records what happened to a person's gesture and to
553        // the controller role that is supposed to carry it. Both are the
554        // session's own work, demoted to `debug` - so without the directive
555        // the records land nowhere and a dead page keeps its silence.
556        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_browser=debug"));
557        // `forge_workspace::mcp` records the tool surface a spawn hands its
558        // child and `forge_agent::mcp` records the surface the CLI's boot
559        // init frame reports. The list is fixed once the child starts, so
560        // the pair is the only artifact that says which side lacked a name
561        // when a tool answers "not found"; without the directives both
562        // lines land nowhere at all.
563        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::mcp=debug"));
564        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_agent::mcp=debug"));
565        // Dictation's own records were demoted out of WARN for the same
566        // reason: a take reaching its capture cap, a normalizer hitting
567        // its token budget and a take whose recognition failed are one
568        // session's work, not forge's health. Without these two the
569        // records land nowhere at all, which is the state the levels
570        // moved away from. `forge_dictate` covers the leaf crate's sites
571        // by module-path prefix; `forge_workspace::dictate` the seat
572        // that runs a take.
573        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::dictate=debug"));
574        assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_dictate=debug"));
575        // The `[server] enabled = false` record is a `debug` on
576        // `app.lifecycle` because a config choice is not a problem, so
577        // the target needs the directive or that record never lands.
578        assert!(DEFAULT_LOG_DIRECTIVES.contains("app.lifecycle=debug"));
579        // llama-cpp emits per-token INFO/WARN chatter while a model runs.
580        // Pinning to `error` rejects it at the filter before
581        // serialisation.
582        assert!(DEFAULT_LOG_DIRECTIVES.contains("llama_cpp_2=error"));
583        // The engine reaches tracing under both spellings of its target
584        // (module path and hyphenated), so the pin has to ride both or the
585        // chatter arrives under the one left out.
586        assert!(DEFAULT_LOG_DIRECTIVES.contains("llama-cpp-2=error"));
587        // A directive the parser rejects takes the whole filter with it
588        // and leaves forge logging nothing, so the default set is
589        // checked by parsing it rather than by reading it.
590        assert!(
591            tracing_subscriber::EnvFilter::try_new(DEFAULT_LOG_DIRECTIVES).is_ok(),
592            "the default directives must parse",
593        );
594    }
595
596    /// Emit one record through `directives` and answer what the sink
597    /// caught. A directive names a target by module-path prefix, so the
598    /// record a demoted site emits is the one its own module path names
599    /// rather than the crate's. Measured rather than read: a directive
600    /// that matches nothing reads exactly like one that works.
601    fn emitted_under(directives: &str, emit: impl FnOnce()) -> String {
602        use std::sync::{Arc, Mutex};
603
604        struct Sink(Arc<Mutex<Vec<u8>>>);
605        impl std::io::Write for Sink {
606            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
607                self.0.lock().expect("the sink is not poisoned").extend_from_slice(buf);
608                Ok(buf.len())
609            }
610            fn flush(&mut self) -> std::io::Result<()> {
611                Ok(())
612            }
613        }
614
615        let written = Arc::new(Mutex::new(Vec::<u8>::new()));
616        let sink = Arc::clone(&written);
617        let subscriber = tracing_subscriber::fmt()
618            .json()
619            .with_env_filter(directives)
620            .with_writer(move || Sink(Arc::clone(&sink)))
621            .with_ansi(false)
622            .finish();
623        tracing::subscriber::with_default(subscriber, emit);
624        String::from_utf8(written.lock().expect("the sink is not poisoned").clone())
625            .expect("the sink holds utf-8")
626    }
627
628    /// The same, through the default set.
629    fn emitted_under_defaults(emit: impl FnOnce()) -> String {
630        emitted_under(DEFAULT_LOG_DIRECTIVES, emit)
631    }
632
633    /// The directive string boot builds for a preset, through the same
634    /// `Cli` shape the binary passes.
635    fn directives_under_preset(preset: &DiagnosticsPreset) -> String {
636        build_filter_directives(&Cli {
637            project: None,
638            new: false,
639            generate_completion: None,
640            diagnostics_preset: Some(preset.clone()),
641            log_file: None,
642            log_filter: None,
643        })
644    }
645
646    /// Driven through the site rather than through a target written here:
647    /// a directive matches by prefix, so a record labelled by the test would
648    /// prove only that the label matches. The reader's unreadable-line arm
649    /// is the demoted one, and a line that is not valid UTF-8 is what
650    /// reaches it.
651    #[test]
652    fn a_demoted_site_emits_under_the_default_directives() {
653        let path =
654            std::env::temp_dir().join(format!("forge-directive-check-{}.log", std::process::id()));
655        std::fs::write(&path, b"one\ntwo\n\xff\xfe not utf-8\n").expect("write the probe file");
656
657        let caught = emitted_under_defaults(|| {
658            let _ = forge_server::output_tail::read_output_file_tail(&path, 12);
659        });
660        let _ = std::fs::remove_file(&path);
661
662        assert!(
663            caught.contains("output_tail_line_unreadable"),
664            "the demoted record must land under the default directives: {caught}",
665        );
666    }
667
668    /// The Slack sweep's per-pass detail was demoted out of WARN, so the
669    /// default directives are the only thing that keeps it readable. Driven
670    /// through the pump's own throttle reporter, not a target written here.
671    #[test]
672    fn the_demoted_slack_throttle_detail_emits_under_the_default_directives() {
673        let caught =
674            emitted_under_defaults(forge_connectors::slack::testing::emit_a_repeated_throttle_pass);
675
676        assert!(
677            caught.contains("slack sweep still throttled"),
678            "the demoted record must land under the default directives: {caught}",
679        );
680    }
681
682    /// The resolver composes a preset with the default set rather than
683    /// letting the preset replace it: every default slot rides along under
684    /// every preset, so a target that only the defaults name stays readable.
685    /// `agent.env_git` is the sharp case - its records were demoted out of
686    /// WARN, so without the directive they land nowhere at all - and it is
687    /// in no preset's own list.
688    #[test]
689    fn a_preset_carries_the_default_set_rather_than_replacing_it() {
690        let presets = [
691            DiagnosticsPreset::Runtime,
692            DiagnosticsPreset::Session,
693            DiagnosticsPreset::Bridge,
694            DiagnosticsPreset::Full,
695        ];
696
697        for preset in presets {
698            let directives = directives_under_preset(&preset);
699            for directive in DEFAULT_LOG_DIRECTIVES.split(',') {
700                let key = directive_key(directive);
701                assert!(
702                    directives.split(',').any(|d| directive_key(d) == key),
703                    "{preset:?} drops the default slot {directive}: {directives}",
704                );
705            }
706
707            let caught = emitted_under(&directives, || {
708                tracing::debug!(
709                    target: "agent.env_git",
710                    event_name = "preset_default_check",
711                    "a record",
712                );
713            });
714            assert!(
715                caught.contains("preset_default_check"),
716                "{preset:?} must keep a default-only target readable: {caught}",
717            );
718        }
719    }
720
721    /// The control for the composition: a preset's own directives still
722    /// apply. `bridge.sdk` is the Bridge preset's to name - the defaults
723    /// say nothing about it - so the record landing is the preset's doing.
724    #[test]
725    fn a_presets_own_directives_still_apply() {
726        let directives = directives_under_preset(&DiagnosticsPreset::Bridge);
727
728        let caught = emitted_under(&directives, || {
729            tracing::debug!(
730                target: "bridge.sdk",
731                event_name = "preset_own_check",
732                "a record",
733            );
734        });
735
736        assert!(
737            caught.contains("preset_own_check"),
738            "the preset's own directives must survive the composition: {directives}",
739        );
740    }
741
742    /// A slot both sides name keeps the preset's directive: no preset can be
743    /// silently overridden by a default naming the same slot at a different
744    /// level. Every overlap in the shipped sets is byte-identical, so the
745    /// pairs here are synthetic - a divergent level is the case the rule
746    /// exists for.
747    #[test]
748    fn a_preset_directive_wins_an_overlap_with_the_defaults() {
749        // The target slot: the defaults name the same target at a lower level.
750        let composed =
751            compose_filter_directives("info,forge_server=trace", "info,forge_server=debug");
752
753        let caught = emitted_under(&composed, || {
754            tracing::trace!(
755                target: "forge_server",
756                event_name = "overlap_check",
757                "a record",
758            );
759        });
760        assert!(
761            caught.contains("overlap_check"),
762            "the preset's level must survive the target overlap: {composed}",
763        );
764
765        // The global slot: the parser resolves every bare level token to the
766        // one global directive, so the defaults' trailing `info` would replace
767        // the preset's `debug` if both rode the string.
768        let composed = compose_filter_directives("debug,bridge.sdk=trace", "info,bridge.sdk=debug");
769
770        let caught = emitted_under(&composed, || {
771            tracing::debug!(
772                target: "a_crate_forge_does_not_name::module",
773                event_name = "global_overlap_check",
774                "a record",
775            );
776        });
777        assert!(
778            caught.contains("global_overlap_check"),
779            "the preset's baseline must survive the global overlap: {composed}",
780        );
781    }
782
783    /// The key cuts at the `=` the parser would: one inside a brace shape
784    /// (`[take{kind=open}]`) belongs to the shape, not to the directive, so
785    /// an `open` shape and a `stop` shape key apart.
786    #[test]
787    fn a_directive_key_keeps_a_brace_shape_whole() {
788        assert_eq!(directive_key("browser[take{kind=open}]=trace"), "browser[take{kind=open}]");
789        assert_ne!(
790            directive_key("browser[take{kind=open}]=trace"),
791            directive_key("browser[take{kind=stop}]=debug"),
792        );
793    }
794
795    /// The control for `a_demoted_site_emits_under_the_default_directives`:
796    /// a crate the defaults say nothing about emits nothing under them, so
797    /// the record landing is the directive's doing rather than the
798    /// subscriber's.
799    #[test]
800    fn a_target_the_defaults_say_nothing_about_stays_out() {
801        let absent = emitted_under_defaults(|| {
802            tracing::debug!(
803                target: "a_crate_forge_does_not_name::module",
804                event_name = "directive_check",
805                "a record",
806            );
807        });
808
809        assert!(absent.is_empty(), "no directive names this crate: {absent}");
810    }
811
812    /// The other half of that control: the baseline token is what decides
813    /// whether a record no directive names lands at all, and it sits one
814    /// character from `into` - which parses as a TRACE target of that
815    /// name, leaving every specific directive passing its own records
816    /// while the baseline is gone. One unread character, and the set goes
817    /// quiet for everything unnamed. Measured over every set forge ships,
818    /// not just the default one.
819    #[test]
820    fn every_shipped_filter_set_keeps_its_info_baseline() {
821        let presets = [
822            DiagnosticsPreset::Runtime,
823            DiagnosticsPreset::Session,
824            DiagnosticsPreset::Bridge,
825            DiagnosticsPreset::Full,
826        ];
827        let sets = std::iter::once(("defaults".to_owned(), DEFAULT_LOG_DIRECTIVES.to_owned()))
828            .chain(presets.into_iter().map(|p| (format!("{p:?}"), directives_under_preset(&p))));
829
830        for (label, directives) in sets {
831            let caught = emitted_under(&directives, || {
832                tracing::info!(
833                    target: "a_web_view::nowhere",
834                    event_name = "baseline_check",
835                    "a record no directive names",
836                );
837            });
838            assert!(
839                caught.contains("baseline_check"),
840                "{label} has no working `info` baseline, so an unnamed target goes dark: {caught}",
841            );
842        }
843    }
844
845    #[test]
846    fn resolve_log_path_uses_default_when_filter_enables_logging() {
847        let cli = Cli {
848            project: None,
849            new: false,
850            generate_completion: None,
851            diagnostics_preset: None,
852            log_file: None,
853            log_filter: Some("app.render=trace".to_owned()),
854        };
855
856        let resolved = resolve_log_path(&cli).expect("resolve succeeds").expect("path exists");
857        assert_eq!(resolved.source.as_str(), "default");
858        let path = resolved.path.to_string_lossy().replace('\\', "/");
859        assert!(path.ends_with("forge/logs/forge.log"));
860    }
861
862    #[test]
863    fn resolve_log_path_uses_default_when_no_log_file() {
864        let cli = Cli {
865            project: None,
866            new: false,
867            generate_completion: None,
868            diagnostics_preset: None,
869            log_file: None,
870            log_filter: None,
871        };
872
873        let resolved = resolve_log_path(&cli).expect("resolve succeeds").expect("path exists");
874        assert_eq!(resolved.source.as_str(), "default");
875    }
876
877    #[test]
878    fn resolve_log_path_uses_default_when_preset_is_set() {
879        let cli = Cli {
880            project: None,
881            new: false,
882            generate_completion: None,
883            diagnostics_preset: Some(DiagnosticsPreset::Session),
884            log_file: None,
885            log_filter: None,
886        };
887
888        let resolved = resolve_log_path(&cli).expect("resolve succeeds").expect("path exists");
889        assert_eq!(resolved.source.as_str(), "default");
890    }
891
892    #[test]
893    fn rolling_writer_rotates_by_size() {
894        let dir = tempdir().expect("temp dir");
895        let base = dir.path().join("runtime.log");
896        let mut writer = RollingFileWriter::new(&base, false, 10, 2).expect("writer");
897
898        writer.write_all(b"12345").expect("first write");
899        writer.write_all(b"67890").expect("second write");
900        writer.write_all(b"abc").expect("rotation write");
901        writer.flush().expect("flush");
902
903        let current = fs::read_to_string(&base).expect("current log");
904        let rotated = fs::read_to_string(rotated_log_path(&base, 1)).expect("rotated log");
905
906        assert_eq!(current, "abc");
907        assert_eq!(rotated, "1234567890");
908    }
909
910    #[test]
911    fn rolling_writer_discards_the_oldest_beyond_max_files() {
912        let dir = tempdir().expect("temp dir");
913        let base = dir.path().join("runtime.log");
914        let mut writer = RollingFileWriter::new(&base, false, 10, 2).expect("writer");
915
916        for chunk in [b"aaaaa", b"bbbbb", b"ccccc", b"ddddd", b"eeeee", b"fffff", b"ggggg"] {
917            writer.write_all(chunk).expect("write");
918        }
919        writer.flush().expect("flush");
920
921        assert_eq!(fs::read_to_string(&base).expect("current log"), "ggggg");
922        assert_eq!(fs::read_to_string(rotated_log_path(&base, 1)).expect("first"), "eeeeefffff");
923        assert_eq!(fs::read_to_string(rotated_log_path(&base, 2)).expect("second"), "cccccddddd");
924        assert!(
925            !rotated_log_path(&base, 3).exists(),
926            "the window must stay capped at max_files, dropping the oldest"
927        );
928    }
929
930    #[test]
931    fn clear_rotated_files_removes_existing_window() {
932        let dir = tempdir().expect("temp dir");
933        let base = dir.path().join("runtime.log");
934        fs::write(rotated_log_path(&base, 1), "a").expect("write first");
935        fs::write(rotated_log_path(&base, 2), "b").expect("write second");
936
937        clear_rotated_files(&base, 2).expect("clear rotated files");
938
939        assert!(!rotated_log_path(&base, 1).exists());
940        assert!(!rotated_log_path(&base, 2).exists());
941    }
942}