11use std:: {
22 fs:: { File , OpenOptions } ,
33 io:: { self , BufRead , BufReader , Write } ,
4- path:: Path ,
4+ path:: { Path , PathBuf } ,
55 sync:: Arc ,
66} ;
77
88use 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 ) ]
1362pub 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
1970struct WriterState {
2071 file : Option < File > ,
72+ current_date : Option < String > ,
2173 lines_since_prune : u32 ,
2274 is_pruning : bool ,
2375}
2476
2577impl 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\n line2\n line3\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