1use std::fs;
10use std::path::{Path, PathBuf};
11use std::time::SystemTime;
12
13pub const ENV_RETENTION_DAYS: &str = "DIG_LOG_RETENTION_DAYS";
15
16pub const ENV_MAX_BYTES: &str = "DIG_LOG_MAX_BYTES";
18
19pub const DEFAULT_RETENTION_DAYS: usize = 7;
21
22pub const DEFAULT_MAX_BYTES: u64 = 50 * 1024 * 1024;
24
25pub fn retention_days<G: Fn(&str) -> Option<String>>(get: G) -> usize {
27 get(ENV_RETENTION_DAYS)
28 .and_then(|value| value.trim().parse::<usize>().ok())
29 .filter(|days| *days > 0)
30 .unwrap_or(DEFAULT_RETENTION_DAYS)
31}
32
33pub fn max_bytes<G: Fn(&str) -> Option<String>>(get: G) -> u64 {
35 get(ENV_MAX_BYTES)
36 .and_then(|value| value.trim().parse::<u64>().ok())
37 .filter(|bytes| *bytes > 0)
38 .unwrap_or(DEFAULT_MAX_BYTES)
39}
40
41struct Entry {
43 path: PathBuf,
44 bytes: u64,
45 modified: SystemTime,
46}
47
48fn list_log_files(dir: &Path, service: &str) -> Vec<Entry> {
51 let prefix = format!("{service}.jsonl");
52 let mut entries: Vec<Entry> = fs::read_dir(dir)
53 .into_iter()
54 .flatten()
55 .flatten()
56 .filter_map(|dirent| {
57 let path = dirent.path();
58 let name = path.file_name()?.to_string_lossy().into_owned();
59 if !name.starts_with(&prefix) {
60 return None;
61 }
62 let meta = dirent.metadata().ok()?;
63 Some(Entry {
64 path,
65 bytes: meta.len(),
66 modified: meta.modified().unwrap_or(SystemTime::UNIX_EPOCH),
67 })
68 })
69 .collect();
70 entries.sort_by_key(|entry| entry.modified);
72 entries
73}
74
75pub fn enforce_byte_cap(dir: &Path, service: &str, max_bytes: u64) -> usize {
79 let entries = list_log_files(dir, service);
80 let mut total: u64 = entries.iter().map(|entry| entry.bytes).sum();
81 let mut deleted = 0;
82 let prunable = entries.len().saturating_sub(1);
84 for entry in entries.iter().take(prunable) {
85 if total <= max_bytes {
86 break;
87 }
88 if fs::remove_file(&entry.path).is_ok() {
89 total -= entry.bytes;
90 deleted += 1;
91 }
92 }
93 deleted
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99 use std::io::Write;
100 use std::thread::sleep;
101 use std::time::Duration;
102
103 fn write_file(dir: &Path, name: &str, bytes: usize) {
104 let mut file = fs::File::create(dir.join(name)).unwrap();
105 file.write_all(&vec![b'x'; bytes]).unwrap();
106 }
107
108 #[test]
109 fn env_overrides_parse_or_fall_back() {
110 assert_eq!(retention_days(|_| Some("14".into())), 14);
111 assert_eq!(
112 retention_days(|_| Some("junk".into())),
113 DEFAULT_RETENTION_DAYS
114 );
115 assert_eq!(retention_days(|_| None), DEFAULT_RETENTION_DAYS);
116 assert_eq!(max_bytes(|_| Some("1024".into())), 1024);
117 assert_eq!(max_bytes(|_| None), DEFAULT_MAX_BYTES);
118 }
119
120 #[test]
121 fn prunes_oldest_until_under_cap_and_keeps_newest() {
122 let dir = tempfile::tempdir().unwrap();
123 for (i, name) in [
125 "dig-node.jsonl.2026-07-14",
126 "dig-node.jsonl.2026-07-15",
127 "dig-node.jsonl.2026-07-16",
128 ]
129 .iter()
130 .enumerate()
131 {
132 write_file(dir.path(), name, 1024);
133 if i < 2 {
134 sleep(Duration::from_millis(20));
135 }
136 }
137 let deleted = enforce_byte_cap(dir.path(), "dig-node", 1536);
138 assert_eq!(deleted, 2);
139 assert!(
140 dir.path().join("dig-node.jsonl.2026-07-16").exists(),
141 "newest is protected"
142 );
143 assert!(!dir.path().join("dig-node.jsonl.2026-07-14").exists());
144 }
145
146 #[test]
147 fn under_cap_deletes_nothing() {
148 let dir = tempfile::tempdir().unwrap();
149 write_file(dir.path(), "dig-dns.jsonl.2026-07-16", 100);
150 assert_eq!(
151 enforce_byte_cap(dir.path(), "dig-dns", DEFAULT_MAX_BYTES),
152 0
153 );
154 }
155
156 #[test]
157 fn ignores_foreign_files() {
158 let dir = tempfile::tempdir().unwrap();
159 write_file(dir.path(), "unrelated.txt", 10_000);
160 write_file(dir.path(), "dig-node.jsonl.2026-07-16", 100);
161 enforce_byte_cap(dir.path(), "dig-node", 1);
163 assert!(dir.path().join("unrelated.txt").exists());
164 }
165}