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 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
79pub 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
151fn preset_extending_defaults(preset: &DiagnosticsPreset) -> String {
155 compose_filter_directives(preset.filter_directives(), DEFAULT_LOG_DIRECTIVES)
156}
157
158const GLOBAL_SLOT_KEY: &str = "";
163
164fn 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
185fn 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 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
245pub(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 anyhow::bail!("no resolvable data/cache/home dir for diagnostics path")
263}
264
265#[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 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 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 assert!(DEFAULT_LOG_DIRECTIVES.contains("app.input=debug"));
467 assert!(DEFAULT_LOG_DIRECTIVES.contains("bridge.lifecycle=debug"));
468 assert!(DEFAULT_LOG_DIRECTIVES.contains("agent.env_git=debug"));
472 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_server=debug"));
476 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 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 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 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 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 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::output_tail=debug"));
537 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::work=debug"));
542 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::gotify=debug"));
547 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::browser=debug"));
552 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_browser=debug"));
557 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::mcp=debug"));
564 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_agent::mcp=debug"));
565 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_workspace::dictate=debug"));
574 assert!(DEFAULT_LOG_DIRECTIVES.contains("forge_dictate=debug"));
575 assert!(DEFAULT_LOG_DIRECTIVES.contains("app.lifecycle=debug"));
579 assert!(DEFAULT_LOG_DIRECTIVES.contains("llama_cpp_2=error"));
583 assert!(DEFAULT_LOG_DIRECTIVES.contains("llama-cpp-2=error"));
587 assert!(
591 tracing_subscriber::EnvFilter::try_new(DEFAULT_LOG_DIRECTIVES).is_ok(),
592 "the default directives must parse",
593 );
594 }
595
596 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 fn emitted_under_defaults(emit: impl FnOnce()) -> String {
630 emitted_under(DEFAULT_LOG_DIRECTIVES, emit)
631 }
632
633 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 #[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 #[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 #[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 #[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 #[test]
748 fn a_preset_directive_wins_an_overlap_with_the_defaults() {
749 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 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 #[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 #[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 #[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}