Skip to content

Commit 2985af8

Browse files
committed
feat: Implement daily log file rotation with configurable retention for old log files.
1 parent 69c745f commit 2985af8

4 files changed

Lines changed: 194 additions & 32 deletions

File tree

config.example.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ filters = "rustalink=debug" # e.g. "rustalink=debug,davey=off"
5252
[logging.file]
5353
path = "./logs/rustalink.log"
5454
max_lines = 10000
55+
rotate_daily = false # when true, creates rustalink-YYYY-MM-DD.log each day
56+
max_files = 7 # max daily log files to keep (0 = unlimited, only applies when rotate_daily = true)
5557

5658
[route_planner]
5759
enabled = false

src/common/logger/mod.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,12 @@ pub fn init(config: &LoggingConfig) {
6363
let _ = fs::create_dir_all(parent);
6464
}
6565

66-
let writer = CircularFileWriter::new(file_config.path.clone(), file_config.max_lines);
66+
let writer = CircularFileWriter::new(
67+
file_config.path.clone(),
68+
file_config.max_lines,
69+
file_config.max_files,
70+
file_config.rotate_daily,
71+
);
6772
let _ = GLOBAL_FILE_WRITER.set(writer.clone());
6873

6974
fmt::layer()

src/common/logger/writer.rs

Lines changed: 182 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,146 @@
11
use std::{
22
fs::{File, OpenOptions},
33
io::{self, BufRead, BufReader, Write},
4-
path::Path,
4+
path::{Path, PathBuf},
55
sync::Arc,
66
};
77

88
use parking_lot::Mutex;
99

10-
/// A simple writer that appends to a file and periodically prunes old lines
11-
/// to stay under a maximum line count.
10+
fn today_date() -> String {
11+
// Use std::time to get the current UTC date without pulling in chrono.
12+
let secs = std::time::SystemTime::now()
13+
.duration_since(std::time::UNIX_EPOCH)
14+
.unwrap_or_default()
15+
.as_secs();
16+
17+
// Simple arithmetic to derive YYYY-MM-DD from a Unix timestamp.
18+
let days = (secs / 86400) as u32;
19+
// Days since 1970-01-01
20+
let (y, m, d) = days_to_ymd(days);
21+
format!("{y:04}-{m:02}-{d:02}")
22+
}
23+
24+
fn days_to_ymd(mut days: u32) -> (u32, u32, u32) {
25+
// Using the proleptic Gregorian calendar algorithm.
26+
days += 719468;
27+
28+
let era = days / 146097;
29+
let doe = days - era * 146097;
30+
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
31+
let y = yoe + era * 400;
32+
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
33+
let mp = (5 * doy + 2) / 153;
34+
let d = doy - (153 * mp + 2) / 5 + 1;
35+
let m = if mp < 10 { mp + 3 } else { mp - 9 };
36+
let y = if m <= 2 { y + 1 } else { y };
37+
38+
(y, m, d)
39+
}
40+
41+
fn resolve_path(base_path: &str, rotate_daily: bool, date: &str) -> String {
42+
if !rotate_daily {
43+
return base_path.to_string();
44+
}
45+
46+
let base = Path::new(base_path);
47+
48+
// Derive a stem from the configured path's file name (e.g. "rustalink" from "rustalink.log").
49+
let stem = base
50+
.file_stem()
51+
.and_then(|s| s.to_str())
52+
.unwrap_or("rustalink");
53+
54+
let dir: PathBuf = base.parent().unwrap_or(Path::new(".")).into();
55+
56+
dir.join(format!("{stem}-{date}.log"))
57+
.to_string_lossy()
58+
.into_owned()
59+
}
60+
1261
#[derive(Clone)]
1362
pub struct CircularFileWriter {
14-
path: String,
63+
base_path: String,
1564
max_lines: u32,
65+
max_files: u32,
66+
rotate_daily: bool,
1667
state: Arc<Mutex<WriterState>>,
1768
}
1869

1970
struct WriterState {
2071
file: Option<File>,
72+
current_date: Option<String>,
2173
lines_since_prune: u32,
2274
is_pruning: bool,
2375
}
2476

2577
impl CircularFileWriter {
26-
pub fn new(path: String, max_lines: u32) -> Self {
78+
pub fn new(path: String, max_lines: u32, max_files: u32, rotate_daily: bool) -> Self {
2779
Self {
28-
path,
80+
base_path: path,
2981
max_lines,
82+
max_files,
83+
rotate_daily,
3084
state: Arc::new(Mutex::new(WriterState {
3185
file: None,
86+
current_date: None,
3287
lines_since_prune: 0,
3388
is_pruning: false,
3489
})),
3590
}
3691
}
3792

93+
/// Return the resolved path for today's log file.
94+
fn current_path(&self) -> String {
95+
if self.rotate_daily {
96+
resolve_path(&self.base_path, true, &today_date())
97+
} else {
98+
self.base_path.clone()
99+
}
100+
}
101+
38102
fn ensure_file_open<'a>(&self, state: &'a mut WriterState) -> io::Result<&'a mut File> {
39-
if state.file.is_none() {
40-
state.file = Some(
41-
OpenOptions::new()
42-
.create(true)
43-
.append(true)
44-
.open(&self.path)?,
45-
);
103+
let today = if self.rotate_daily {
104+
Some(today_date())
105+
} else {
106+
None
107+
};
108+
109+
let need_rotate = state.file.is_none()
110+
|| match (&state.current_date, &today) {
111+
(Some(curr), Some(new)) => curr != new,
112+
_ => false,
113+
};
114+
115+
if need_rotate {
116+
// Close the old file handle so the OS can flush/rename it.
117+
state.file = None;
118+
119+
let path = if self.rotate_daily {
120+
let d = today.as_deref().unwrap_or("");
121+
resolve_path(&self.base_path, true, d)
122+
} else {
123+
self.base_path.clone()
124+
};
125+
126+
if let Some(parent) = Path::new(&path).parent() {
127+
let _ = std::fs::create_dir_all(parent);
128+
}
129+
130+
state.file = Some(OpenOptions::new().create(true).append(true).open(&path)?);
131+
state.current_date = today;
132+
133+
// Clean up old daily files beyond max_files limit.
134+
if self.rotate_daily && self.max_files > 0 {
135+
self.cleanup_old_files();
136+
}
46137
}
138+
47139
Ok(state.file.as_mut().expect("file was just opened"))
48140
}
49141

50142
fn spawn_prune(&self) {
51-
let path = self.path.clone();
143+
let path = self.current_path();
52144
let max_lines = self.max_lines;
53145
let state_arc = self.state.clone();
54146

@@ -61,6 +153,46 @@ impl CircularFileWriter {
61153
});
62154
}
63155

156+
/// Delete old daily log files, keeping only the most recent `max_files`.
157+
fn cleanup_old_files(&self) {
158+
let base = Path::new(&self.base_path);
159+
let dir = base.parent().unwrap_or(Path::new("."));
160+
let stem = base
161+
.file_stem()
162+
.and_then(|s| s.to_str())
163+
.unwrap_or("rustalink");
164+
let max_files = self.max_files as usize;
165+
166+
let mut log_files: Vec<std::path::PathBuf> = match std::fs::read_dir(dir) {
167+
Ok(entries) => entries
168+
.filter_map(|e| e.ok())
169+
.map(|e| e.path())
170+
.filter(|p| {
171+
p.extension().and_then(|e| e.to_str()) == Some("log")
172+
&& p.file_stem()
173+
.and_then(|s| s.to_str())
174+
.map(|s| s.starts_with(stem) && s != stem)
175+
.unwrap_or(false)
176+
})
177+
.collect(),
178+
Err(_) => return,
179+
};
180+
181+
if log_files.len() <= max_files {
182+
return;
183+
}
184+
185+
// Sort by name (YYYY-MM-DD suffix sorts lexicographically = chronologically).
186+
log_files.sort();
187+
188+
let to_delete = log_files.len() - max_files;
189+
for path in log_files.iter().take(to_delete) {
190+
if let Err(e) = std::fs::remove_file(path) {
191+
eprintln!("Failed to delete old log file '{}': {}", path.display(), e);
192+
}
193+
}
194+
}
195+
64196
fn do_prune(path: &str, max_lines: u32) -> io::Result<()> {
65197
if !Path::new(path).exists() {
66198
return Ok(());
@@ -74,7 +206,6 @@ impl CircularFileWriter {
74206

75207
if lines.len() > max_lines as usize {
76208
let start = lines.len() - max_lines as usize;
77-
// Atomic-ish replacement: write to .tmp then rename
78209
let tmp_path = format!("{}.tmp", path);
79210
{
80211
let mut file = File::create(&tmp_path)?;
@@ -102,7 +233,7 @@ impl io::Write for CircularFileWriter {
102233
if state.lines_since_prune >= prune_threshold && !state.is_pruning {
103234
state.is_pruning = true;
104235
state.lines_since_prune = 0;
105-
state.file = None; // Close file so rename can happen on Windows if needed
236+
state.file = None; // Close file so rename can work on Windows
106237
self.spawn_prune();
107238
}
108239

@@ -141,7 +272,7 @@ mod tests {
141272

142273
#[test]
143274
fn test_circular_file_writer_new() {
144-
let writer = CircularFileWriter::new("test_new.log".to_string(), 100);
275+
let writer = CircularFileWriter::new("test_new.log".to_string(), 100, 0, false);
145276
let state = writer.state.lock();
146277
assert_eq!(state.lines_since_prune, 0);
147278
assert!(!state.is_pruning);
@@ -154,7 +285,7 @@ mod tests {
154285
let path = "test_create.log";
155286
cleanup_test_file(path);
156287

157-
let mut writer = CircularFileWriter::new(path.to_string(), 100);
288+
let mut writer = CircularFileWriter::new(path.to_string(), 100, 0, false);
158289
let data = b"test line\n";
159290
let result = writer.write(data);
160291

@@ -170,7 +301,7 @@ mod tests {
170301
let path = "test_newlines.log";
171302
cleanup_test_file(path);
172303

173-
let mut writer = CircularFileWriter::new(path.to_string(), 1000);
304+
let mut writer = CircularFileWriter::new(path.to_string(), 1000, 0, false);
174305
writer.write(b"line1\nline2\nline3\n").unwrap();
175306

176307
let state = writer.state.lock();
@@ -184,7 +315,7 @@ mod tests {
184315
let path = "test_no_newlines.log";
185316
cleanup_test_file(path);
186317

187-
let mut writer = CircularFileWriter::new(path.to_string(), 1000);
318+
let mut writer = CircularFileWriter::new(path.to_string(), 1000, 0, false);
188319
writer.write(b"no newline here").unwrap();
189320

190321
let state = writer.state.lock();
@@ -198,7 +329,7 @@ mod tests {
198329
let path = "test_flush.log";
199330
cleanup_test_file(path);
200331

201-
let mut writer = CircularFileWriter::new(path.to_string(), 100);
332+
let mut writer = CircularFileWriter::new(path.to_string(), 100, 0, false);
202333
writer.write(b"test\n").unwrap();
203334

204335
let result = writer.flush();
@@ -209,15 +340,16 @@ mod tests {
209340

210341
#[test]
211342
fn test_flush_without_file() {
212-
let mut writer = CircularFileWriter::new("test_flush_no_file.log".to_string(), 100);
343+
let mut writer =
344+
CircularFileWriter::new("test_flush_no_file.log".to_string(), 100, 0, false);
213345
let result = writer.flush();
214346
assert!(result.is_ok());
215347
cleanup_test_file("test_flush_no_file.log");
216348
}
217349

218350
#[test]
219351
fn test_clone() {
220-
let writer = CircularFileWriter::new("test_clone.log".to_string(), 100);
352+
let writer = CircularFileWriter::new("test_clone.log".to_string(), 100, 0, false);
221353
let cloned = writer.clone();
222354

223355
// Both should share the same state
@@ -228,7 +360,7 @@ mod tests {
228360

229361
#[test]
230362
fn test_make_writer() {
231-
let writer = CircularFileWriter::new("test_make_writer.log".to_string(), 100);
363+
let writer = CircularFileWriter::new("test_make_writer.log".to_string(), 100, 0, false);
232364
let made = writer.make_writer();
233365

234366
// Should be a clone
@@ -253,7 +385,6 @@ mod tests {
253385
let result = CircularFileWriter::do_prune(path, 10);
254386
assert!(result.is_ok());
255387

256-
// File should still have 3 lines (less than max)
257388
let content = fs::read_to_string(path).unwrap();
258389
assert_eq!(content.lines().count(), 3);
259390

@@ -265,18 +396,15 @@ mod tests {
265396
let path = "test_prune_large.log";
266397
cleanup_test_file(path);
267398

268-
// Write 20 lines
269399
let mut content = String::new();
270400
for i in 1..=20 {
271401
content.push_str(&format!("line{}\n", i));
272402
}
273403
fs::write(path, content).unwrap();
274404

275-
// Prune to 10 lines
276405
let result = CircularFileWriter::do_prune(path, 10);
277406
assert!(result.is_ok());
278407

279-
// Should only have last 10 lines
280408
let pruned = fs::read_to_string(path).unwrap();
281409
let lines: Vec<&str> = pruned.lines().collect();
282410
assert_eq!(lines.len(), 10);
@@ -286,17 +414,40 @@ mod tests {
286414
cleanup_test_file(path);
287415
}
288416

417+
#[test]
418+
fn test_resolve_path_no_rotate() {
419+
let p = resolve_path("./logs/rustalink.log", false, "2026-03-13");
420+
assert_eq!(p, "./logs/rustalink.log");
421+
}
422+
423+
#[test]
424+
fn test_resolve_path_rotate() {
425+
let p = resolve_path("./logs/rustalink.log", true, "2026-03-13");
426+
assert!(p.contains("2026-03-13"));
427+
assert!(p.ends_with(".log"));
428+
}
429+
430+
#[test]
431+
fn test_today_date_format() {
432+
let d = today_date();
433+
let parts: Vec<&str> = d.split('-').collect();
434+
assert_eq!(parts.len(), 3);
435+
assert_eq!(parts[0].len(), 4); // year
436+
assert_eq!(parts[1].len(), 2); // month
437+
assert_eq!(parts[2].len(), 2); // day
438+
}
439+
289440
#[test]
290441
fn test_prune_threshold_calculation() {
291-
let _writer = CircularFileWriter::new("test.log".to_string(), 1000);
442+
let _writer = CircularFileWriter::new("test.log".to_string(), 1000, 0, false);
292443
let threshold = (1000 / 10).max(50);
293444
assert_eq!(threshold, 100);
294445

295-
let _writer = CircularFileWriter::new("test.log".to_string(), 100);
446+
let _writer = CircularFileWriter::new("test.log".to_string(), 100, 0, false);
296447
let threshold = (100 / 10).max(50);
297448
assert_eq!(threshold, 50);
298449

299-
let _writer = CircularFileWriter::new("test.log".to_string(), 10);
450+
let _writer = CircularFileWriter::new("test.log".to_string(), 10, 0, false);
300451
let threshold = (10 / 10).max(50);
301452
assert_eq!(threshold, 50);
302453

0 commit comments

Comments
 (0)