1use std::{
2 collections::HashMap,
3 fs::{self, File},
4 io::{BufWriter, Write},
5 path::{Path, PathBuf},
6 time::{Duration, Instant},
7};
8
9use chrono::Utc;
10use uuid::Uuid;
11
12use crate::log_writer::{LogFile, LogWriter, replace_line_in_file};
13
14#[cfg(not(test))]
15mod limits {
16 pub const MIN_ROTATION_DURATION_MS: u64 = 1_000;
17 pub const MIN_FILE_SIZE: u64 = 4_096;
18 pub const MIN_LINES: u64 = 10;
19}
20
21#[cfg(test)]
22mod limits {
23 pub const MIN_ROTATION_DURATION_MS: u64 = 10;
24 pub const MIN_FILE_SIZE: u64 = 1_024;
25 pub const MIN_LINES: u64 = 1;
26}
27
28pub enum FileLogger {
30 Single(LogFile),
31 TimeRotation(LogFileTimeRotation),
32 SizeRotation(LogFileSizeRotation),
33}
34
35impl LogWriter for FileLogger {
36 fn regular(&mut self, line: &str) {
37 match self {
38 FileLogger::Single(w) => w.regular(line),
39 FileLogger::TimeRotation(w) => w.regular(line),
40 FileLogger::SizeRotation(w) => w.regular(line),
41 }
42 }
43
44 fn progress(&mut self, line: &str, id: Uuid) {
45 match self {
46 FileLogger::Single(w) => w.progress(line, id),
47 FileLogger::TimeRotation(w) => w.progress(line, id),
48 FileLogger::SizeRotation(w) => w.progress(line, id),
49 }
50 }
51
52 fn finished(&mut self, id: Uuid) {
53 match self {
54 FileLogger::Single(w) => w.finished(id),
55 FileLogger::TimeRotation(w) => w.finished(id),
56 FileLogger::SizeRotation(w) => w.finished(id),
57 }
58 }
59
60 fn flush(&mut self) {
61 match self {
62 FileLogger::Single(w) => w.flush(),
63 FileLogger::TimeRotation(w) => w.flush(),
64 FileLogger::SizeRotation(w) => w.flush(),
65 }
66 }
67}
68
69pub struct TimeRotationConfig {
71 pub folder: PathBuf,
72 pub filename: String,
73 pub extension: String,
74 pub rotation_duration: Duration,
75 pub cleanup_after: Duration,
76}
77
78pub struct LogFileTimeRotation {
80 folder: PathBuf,
81 filename: String,
82 extension: String,
83 rotation_duration: Duration,
84 cleanup_after: Duration,
85 current_file: BufWriter<File>,
86 file_opened_at: Instant,
87 progress_positions: HashMap<Uuid, u64>,
88 progress_content: HashMap<Uuid, String>,
89}
90
91impl LogFileTimeRotation {
92 pub fn new(config: TimeRotationConfig) -> Result<Self, std::io::Error> {
93 if config.rotation_duration.as_millis() < limits::MIN_ROTATION_DURATION_MS as u128 {
94 return Err(std::io::Error::new(
95 std::io::ErrorKind::InvalidInput,
96 format!(
97 "rotation_duration must be at least {} ms",
98 limits::MIN_ROTATION_DURATION_MS
99 ),
100 ));
101 }
102 fs::create_dir_all(&config.folder)?;
103 let file = open_timestamped_file(&config.folder, &config.filename, &config.extension)?;
104 Ok(Self {
105 folder: config.folder,
106 filename: config.filename,
107 extension: config.extension,
108 rotation_duration: config.rotation_duration,
109 cleanup_after: config.cleanup_after,
110 current_file: file,
111 file_opened_at: Instant::now(),
112 progress_positions: HashMap::new(),
113 progress_content: HashMap::new(),
114 })
115 }
116
117 fn should_rotate(&self) -> bool {
118 self.file_opened_at.elapsed() >= self.rotation_duration
119 }
120
121 fn rotate(&mut self) {
122 self.current_file.flush().unwrap();
123 let new_file =
124 open_timestamped_file(&self.folder, &self.filename, &self.extension).unwrap();
125 self.current_file = new_file;
126 self.file_opened_at = Instant::now();
127
128 let mut new_positions = HashMap::new();
130 for (id, content) in &self.progress_content {
131 self.current_file.flush().unwrap();
132 let pos = self.current_file.get_ref().metadata().unwrap().len();
133 writeln!(self.current_file, "{content}").unwrap();
134 new_positions.insert(*id, pos);
135 }
136 self.current_file.flush().unwrap();
137 self.progress_positions = new_positions;
138
139 self.cleanup();
140 }
141
142 fn cleanup(&self) {
143 let Ok(entries) = fs::read_dir(&self.folder) else {
144 return;
145 };
146 let prefix = format!("{}_{}", self.filename, ""); let suffix = format!(".{}", self.extension);
148 let now = Utc::now();
149 for entry in entries.flatten() {
150 let name = entry.file_name().to_string_lossy().to_string();
151 if !name.starts_with(&prefix) || !name.ends_with(&suffix) {
152 continue;
153 }
154 let timestamp_str = &name[prefix.len()..name.len() - suffix.len()];
155 if let Ok(file_time) =
156 chrono::NaiveDateTime::parse_from_str(timestamp_str, "%Y%m%d%H%M%S%6f")
157 {
158 let file_utc = file_time.and_utc();
159 if let Ok(age) = (now - file_utc).to_std()
160 && age > self.cleanup_after
161 {
162 let _ = fs::remove_file(entry.path());
163 }
164 }
165 }
166 }
167}
168
169impl LogWriter for LogFileTimeRotation {
170 fn regular(&mut self, line: &str) {
171 if self.should_rotate() {
172 self.rotate();
173 }
174 writeln!(self.current_file, "{line}").unwrap();
175 }
176
177 fn progress(&mut self, line: &str, id: Uuid) {
178 if self.should_rotate() {
179 self.rotate();
180 }
181 self.current_file.flush().unwrap();
182 if let Some(pos) = self.progress_positions.get(&id) {
183 replace_line_in_file(&mut self.current_file, line, *pos);
184 } else {
185 let pos = self.current_file.get_ref().metadata().unwrap().len();
186 self.progress_positions.insert(id, pos);
187 writeln!(self.current_file, "{line}").unwrap();
188 }
189 self.progress_content.insert(id, line.to_string());
190 }
191
192 fn finished(&mut self, id: Uuid) {
193 self.progress_positions.remove(&id);
194 self.progress_content.remove(&id);
195 self.current_file.flush().unwrap();
196 }
197
198 fn flush(&mut self) {
199 self.current_file.flush().unwrap();
200 }
201}
202
203pub struct SizeRotationConfig {
205 pub folder: PathBuf,
206 pub filename: String,
207 pub extension: String,
208 pub max_file_size: Option<u64>,
209 pub max_lines: Option<u64>,
210 pub max_files: u32,
211}
212
213pub struct LogFileSizeRotation {
215 folder: PathBuf,
216 filename: String,
217 extension: String,
218 max_file_size: Option<u64>,
219 max_lines: Option<u64>,
220 max_files: u32,
221 current_file: BufWriter<File>,
222 current_lines: u64,
223 progress_positions: HashMap<Uuid, u64>,
224 progress_content: HashMap<Uuid, String>,
225}
226
227impl LogFileSizeRotation {
228 pub fn new(config: SizeRotationConfig) -> Result<Self, std::io::Error> {
229 if let Some(max_size) = config.max_file_size
230 && max_size < limits::MIN_FILE_SIZE
231 {
232 return Err(std::io::Error::new(
233 std::io::ErrorKind::InvalidInput,
234 format!(
235 "max_file_size must be at least {} bytes",
236 limits::MIN_FILE_SIZE
237 ),
238 ));
239 }
240 if let Some(max_lines) = config.max_lines
241 && max_lines < limits::MIN_LINES
242 {
243 return Err(std::io::Error::new(
244 std::io::ErrorKind::InvalidInput,
245 format!("max_lines must be at least {}", limits::MIN_LINES),
246 ));
247 }
248 if config.max_files < 1 {
249 return Err(std::io::Error::new(
250 std::io::ErrorKind::InvalidInput,
251 "max_files must be at least 1",
252 ));
253 }
254 if config.max_file_size.is_none() && config.max_lines.is_none() {
255 return Err(std::io::Error::new(
256 std::io::ErrorKind::InvalidInput,
257 "at least one of max_file_size or max_lines must be set",
258 ));
259 }
260 fs::create_dir_all(&config.folder)?;
261 let file = open_timestamped_file(&config.folder, &config.filename, &config.extension)?;
262 Ok(Self {
263 folder: config.folder,
264 filename: config.filename,
265 extension: config.extension,
266 max_file_size: config.max_file_size,
267 max_lines: config.max_lines,
268 max_files: config.max_files,
269 current_file: file,
270 current_lines: 0,
271 progress_positions: HashMap::new(),
272 progress_content: HashMap::new(),
273 })
274 }
275
276 fn should_rotate(&mut self) -> bool {
277 if let Some(max_lines) = self.max_lines
278 && self.current_lines >= max_lines
279 {
280 return true;
281 }
282 if let Some(max_size) = self.max_file_size {
283 self.current_file.flush().unwrap();
284 if self.current_file.get_ref().metadata().unwrap().len() >= max_size {
285 return true;
286 }
287 }
288 false
289 }
290
291 fn rotate(&mut self) {
292 self.current_file.flush().unwrap();
293 let new_file =
294 open_timestamped_file(&self.folder, &self.filename, &self.extension).unwrap();
295 self.current_file = new_file;
296 self.current_lines = 0;
297
298 let mut new_positions = HashMap::new();
300 for (id, content) in &self.progress_content {
301 self.current_file.flush().unwrap();
302 let pos = self.current_file.get_ref().metadata().unwrap().len();
303 writeln!(self.current_file, "{content}").unwrap();
304 new_positions.insert(*id, pos);
305 self.current_lines += 1;
306 }
307 self.current_file.flush().unwrap();
308 self.progress_positions = new_positions;
309
310 self.cleanup();
311 }
312
313 fn cleanup(&self) {
314 let Ok(entries) = fs::read_dir(&self.folder) else {
315 return;
316 };
317 let prefix = format!("{}_", self.filename);
318 let suffix = format!(".{}", self.extension);
319 let mut matching_files: Vec<PathBuf> = entries
320 .flatten()
321 .filter_map(|entry| {
322 let name = entry.file_name().to_string_lossy().to_string();
323 if name.starts_with(&prefix) && name.ends_with(&suffix) {
324 Some(entry.path())
325 } else {
326 None
327 }
328 })
329 .collect();
330 matching_files.sort();
332 while matching_files.len() > self.max_files as usize {
334 if let Some(oldest) = matching_files.first() {
335 let _ = fs::remove_file(oldest);
336 }
337 matching_files.remove(0);
338 }
339 }
340}
341
342impl LogWriter for LogFileSizeRotation {
343 fn regular(&mut self, line: &str) {
344 if self.should_rotate() {
345 self.rotate();
346 }
347 writeln!(self.current_file, "{line}").unwrap();
348 self.current_lines += 1;
349 }
350
351 fn progress(&mut self, line: &str, id: Uuid) {
352 if self.should_rotate() {
353 self.rotate();
354 }
355 self.current_file.flush().unwrap();
356 if let Some(pos) = self.progress_positions.get(&id) {
357 replace_line_in_file(&mut self.current_file, line, *pos);
358 } else {
359 let pos = self.current_file.get_ref().metadata().unwrap().len();
360 self.progress_positions.insert(id, pos);
361 writeln!(self.current_file, "{line}").unwrap();
362 self.current_lines += 1;
363 }
364 self.progress_content.insert(id, line.to_string());
365 }
366
367 fn finished(&mut self, id: Uuid) {
368 self.progress_positions.remove(&id);
369 self.progress_content.remove(&id);
370 self.current_file.flush().unwrap();
371 }
372
373 fn flush(&mut self) {
374 self.current_file.flush().unwrap();
375 }
376}
377
378fn open_timestamped_file(
379 folder: &Path,
380 filename: &str,
381 extension: &str,
382) -> Result<BufWriter<File>, std::io::Error> {
383 use std::io::{Seek, SeekFrom};
384 let timestamp = Utc::now().format("%Y%m%d%H%M%S%6f");
385 let path = folder.join(format!("{filename}_{timestamp}.{extension}"));
386 let mut file = File::options()
387 .create(true)
388 .truncate(false)
389 .write(true)
390 .open(path)?;
391 file.seek(SeekFrom::End(0))?;
392 Ok(BufWriter::new(file))
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use std::thread;
399
400 fn test_dir(name: &str) -> PathBuf {
401 let dir = PathBuf::from(format!("/tmp/mtlog_test_{name}"));
402 let _ = fs::remove_dir_all(&dir);
403 dir
404 }
405
406 fn count_log_files(dir: &PathBuf, filename: &str, extension: &str) -> usize {
407 let prefix = format!("{filename}_");
408 let suffix = format!(".{extension}");
409 fs::read_dir(dir)
410 .unwrap()
411 .flatten()
412 .filter(|e| {
413 let name = e.file_name().to_string_lossy().to_string();
414 name.starts_with(&prefix) && name.ends_with(&suffix)
415 })
416 .count()
417 }
418
419 fn read_all_log_content(dir: &PathBuf, filename: &str, extension: &str) -> String {
420 let prefix = format!("{filename}_");
421 let suffix = format!(".{extension}");
422 let mut files: Vec<PathBuf> = fs::read_dir(dir)
423 .unwrap()
424 .flatten()
425 .filter_map(|e| {
426 let name = e.file_name().to_string_lossy().to_string();
427 if name.starts_with(&prefix) && name.ends_with(&suffix) {
428 Some(e.path())
429 } else {
430 None
431 }
432 })
433 .collect();
434 files.sort();
435 let mut content = String::new();
436 for f in files {
437 content.push_str(&fs::read_to_string(f).unwrap());
438 }
439 content
440 }
441
442 #[test]
443 fn test_time_rotation_creates_multiple_files() {
444 let dir = test_dir("time_rotation");
445 let mut writer = LogFileTimeRotation::new(TimeRotationConfig {
446 folder: dir.clone(),
447 filename: "app".into(),
448 extension: "log".into(),
449 rotation_duration: Duration::from_millis(50),
450 cleanup_after: Duration::from_secs(3600),
451 })
452 .unwrap();
453
454 writer.regular("line1");
455 writer.flush();
456 assert_eq!(count_log_files(&dir, "app", "log"), 1);
457
458 thread::sleep(Duration::from_millis(60));
459 writer.regular("line2");
460 writer.flush();
461 assert_eq!(count_log_files(&dir, "app", "log"), 2);
462
463 thread::sleep(Duration::from_millis(60));
464 writer.regular("line3");
465 writer.flush();
466 assert_eq!(count_log_files(&dir, "app", "log"), 3);
467
468 let content = read_all_log_content(&dir, "app", "log");
469 assert!(content.contains("line1"));
470 assert!(content.contains("line2"));
471 assert!(content.contains("line3"));
472 }
473
474 #[test]
475 fn test_size_rotation_by_lines() {
476 let dir = test_dir("size_rotation_lines");
477 let mut writer = LogFileSizeRotation::new(SizeRotationConfig {
478 folder: dir.clone(),
479 filename: "app".into(),
480 extension: "log".into(),
481 max_file_size: None,
482 max_lines: Some(3),
483 max_files: 10,
484 })
485 .unwrap();
486
487 for i in 0..9 {
488 writer.regular(&format!("line{i}"));
489 }
490 writer.flush();
491
492 assert_eq!(count_log_files(&dir, "app", "log"), 3);
493
494 let content = read_all_log_content(&dir, "app", "log");
495 for i in 0..9 {
496 assert!(content.contains(&format!("line{i}")));
497 }
498 }
499
500 #[test]
501 fn test_size_rotation_cleanup_max_files() {
502 let dir = test_dir("size_rotation_cleanup");
503 let mut writer = LogFileSizeRotation::new(SizeRotationConfig {
504 folder: dir.clone(),
505 filename: "app".into(),
506 extension: "log".into(),
507 max_file_size: None,
508 max_lines: Some(1),
509 max_files: 3,
510 })
511 .unwrap();
512
513 for i in 0..6 {
515 thread::sleep(Duration::from_millis(2));
517 writer.regular(&format!("line{i}"));
518 }
519 writer.flush();
520
521 assert_eq!(count_log_files(&dir, "app", "log"), 3);
522 }
523
524 #[test]
525 fn test_time_rotation_cleanup() {
526 let dir = test_dir("time_rotation_cleanup");
527 fs::create_dir_all(&dir).unwrap();
529 let old_timestamp = "20200101000000000000";
530 let old_file = dir.join(format!("app_{old_timestamp}.log"));
531 File::create(&old_file).unwrap();
532
533 let mut writer = LogFileTimeRotation::new(TimeRotationConfig {
534 folder: dir.clone(),
535 filename: "app".into(),
536 extension: "log".into(),
537 rotation_duration: Duration::from_millis(50),
538 cleanup_after: Duration::from_secs(1),
539 })
540 .unwrap();
541
542 writer.regular("line1");
543 writer.flush();
544
545 thread::sleep(Duration::from_millis(60));
547 writer.regular("line2");
548 writer.flush();
549
550 assert!(!old_file.exists());
552 assert!(count_log_files(&dir, "app", "log") >= 1);
554 }
555
556 #[test]
557 fn test_progress_bar_migration_on_rotation() {
558 let dir = test_dir("progress_migration");
559 let mut writer = LogFileTimeRotation::new(TimeRotationConfig {
560 folder: dir.clone(),
561 filename: "app".into(),
562 extension: "log".into(),
563 rotation_duration: Duration::from_millis(50),
564 cleanup_after: Duration::from_secs(3600),
565 })
566 .unwrap();
567
568 let progress_id = Uuid::new_v4();
569 writer.progress("progress: 050%", progress_id);
571 writer.flush();
572
573 thread::sleep(Duration::from_millis(60));
575 writer.regular("after rotation");
576 writer.flush();
577
578 assert_eq!(count_log_files(&dir, "app", "log"), 2);
579
580 let mut files: Vec<PathBuf> = fs::read_dir(&dir)
582 .unwrap()
583 .flatten()
584 .filter_map(|e| {
585 let name = e.file_name().to_string_lossy().to_string();
586 if name.starts_with("app_") && name.ends_with(".log") {
587 Some(e.path())
588 } else {
589 None
590 }
591 })
592 .collect();
593 files.sort();
594 let newest = fs::read_to_string(files.last().unwrap()).unwrap();
595 assert!(newest.contains("progress: 050%"));
596 assert!(newest.contains("after rotation"));
597
598 writer.progress("progress: 100%", progress_id);
600 writer.flush();
601 let newest = fs::read_to_string(files.last().unwrap()).unwrap();
602 assert!(newest.contains("progress: 100%"));
603 assert!(!newest.contains("progress: 050%"));
604 }
605
606 #[test]
607 fn test_validation_time_rotation_duration() {
608 let dir = test_dir("validation_time");
609 let result = LogFileTimeRotation::new(TimeRotationConfig {
610 folder: dir,
611 filename: "app".into(),
612 extension: "log".into(),
613 rotation_duration: Duration::from_millis(1), cleanup_after: Duration::from_secs(3600),
615 });
616 assert!(result.is_err());
617 let err = result.err().unwrap();
618 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
619 }
620
621 #[test]
622 fn test_validation_size_rotation_file_size() {
623 let dir = test_dir("validation_size");
624 let result = LogFileSizeRotation::new(SizeRotationConfig {
625 folder: dir,
626 filename: "app".into(),
627 extension: "log".into(),
628 max_file_size: Some(100), max_lines: None,
630 max_files: 5,
631 });
632 assert!(result.is_err());
633 let err = result.err().unwrap();
634 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
635 }
636
637 #[test]
638 fn test_validation_size_rotation_max_files() {
639 let dir = test_dir("validation_max_files");
640 let result = LogFileSizeRotation::new(SizeRotationConfig {
641 folder: dir,
642 filename: "app".into(),
643 extension: "log".into(),
644 max_file_size: Some(4096),
645 max_lines: None,
646 max_files: 0, });
648 assert!(result.is_err());
649 let err = result.err().unwrap();
650 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
651 }
652
653 #[test]
654 fn test_validation_no_rotation_trigger() {
655 let dir = test_dir("validation_no_trigger");
656 let result = LogFileSizeRotation::new(SizeRotationConfig {
657 folder: dir,
658 filename: "app".into(),
659 extension: "log".into(),
660 max_file_size: None,
661 max_lines: None, max_files: 5,
663 });
664 assert!(result.is_err());
665 let err = result.err().unwrap();
666 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
667 }
668
669 #[test]
670 fn test_file_logger_enum_delegates() {
671 let dir = test_dir("file_logger_enum");
672 fs::create_dir_all(&dir).unwrap();
673 let log_file = LogFile::new(dir.join("test.log")).unwrap();
674 let mut logger = FileLogger::Single(log_file);
675 logger.regular("hello");
676 logger.flush();
677 assert!(
678 fs::read_to_string(dir.join("test.log"))
679 .unwrap()
680 .contains("hello")
681 );
682 }
683
684 #[test]
685 fn test_size_rotation_by_file_size() {
686 let dir = test_dir("size_rotation_file_size");
687 let mut writer = LogFileSizeRotation::new(SizeRotationConfig {
688 folder: dir.clone(),
689 filename: "app".into(),
690 extension: "log".into(),
691 max_file_size: Some(1024),
692 max_lines: None,
693 max_files: 20,
694 })
695 .unwrap();
696
697 for i in 0..30 {
699 thread::sleep(Duration::from_millis(1));
700 writer.regular(&format!(
701 "line{i:03} padding to make this line about one hundred bytes long............."
702 ));
703 }
704 writer.flush();
705
706 let file_count = count_log_files(&dir, "app", "log");
707 assert!(
708 file_count >= 3,
709 "expected >= 3 files from file-size rotation, got {file_count}"
710 );
711
712 let content = read_all_log_content(&dir, "app", "log");
713 for i in 0..30 {
714 assert!(content.contains(&format!("line{i:03}")));
715 }
716 }
717
718 #[test]
719 fn test_size_rotation_lines_trigger_first() {
720 let dir = test_dir("size_rotation_lines_first");
721 let mut writer = LogFileSizeRotation::new(SizeRotationConfig {
722 folder: dir.clone(),
723 filename: "app".into(),
724 extension: "log".into(),
725 max_file_size: Some(10240), max_lines: Some(2), max_files: 20,
728 })
729 .unwrap();
730
731 for i in 0..6 {
732 thread::sleep(Duration::from_millis(1));
733 writer.regular(&format!("short{i}"));
734 }
735 writer.flush();
736
737 assert_eq!(count_log_files(&dir, "app", "log"), 3);
738 }
739
740 #[test]
741 fn test_size_rotation_bytes_trigger_first() {
742 let dir = test_dir("size_rotation_bytes_first");
743 let mut writer = LogFileSizeRotation::new(SizeRotationConfig {
744 folder: dir.clone(),
745 filename: "app".into(),
746 extension: "log".into(),
747 max_file_size: Some(1024), max_lines: Some(1000), max_files: 20,
750 })
751 .unwrap();
752
753 for i in 0..20 {
755 thread::sleep(Duration::from_millis(1));
756 writer.regular(&format!(
757 "line{i:03} this is padded to roughly two hundred bytes of content so that file size triggers before line count does, padding padding padding padding padding padding padding pad"
758 ));
759 }
760 writer.flush();
761
762 let file_count = count_log_files(&dir, "app", "log");
763 assert!(
764 file_count >= 3,
765 "expected >= 3 files from byte-size rotation, got {file_count}"
766 );
767
768 let content = read_all_log_content(&dir, "app", "log");
769 for i in 0..20 {
770 assert!(content.contains(&format!("line{i:03}")));
771 }
772 }
773
774 #[test]
775 fn test_size_rotation_progress_bar_migration() {
776 let dir = test_dir("size_progress_migration");
777 let mut writer = LogFileSizeRotation::new(SizeRotationConfig {
778 folder: dir.clone(),
779 filename: "app".into(),
780 extension: "log".into(),
781 max_file_size: None,
782 max_lines: Some(3),
783 max_files: 20,
784 })
785 .unwrap();
786
787 let progress_id = Uuid::new_v4();
788 writer.progress("progress: 050%", progress_id);
789 writer.regular("filler line one..");
790 writer.regular("filler line two..");
791 thread::sleep(Duration::from_millis(1));
793 writer.regular("after rotation..");
794 writer.flush();
795
796 assert_eq!(count_log_files(&dir, "app", "log"), 2);
797
798 let mut files: Vec<PathBuf> = fs::read_dir(&dir)
800 .unwrap()
801 .flatten()
802 .filter_map(|e| {
803 let name = e.file_name().to_string_lossy().to_string();
804 if name.starts_with("app_") && name.ends_with(".log") {
805 Some(e.path())
806 } else {
807 None
808 }
809 })
810 .collect();
811 files.sort();
812 let newest = fs::read_to_string(files.last().unwrap()).unwrap();
813 assert!(newest.contains("progress: 050%"));
814 assert!(newest.contains("after rotation.."));
815
816 writer.progress("progress: 100%", progress_id);
818 writer.flush();
819 let newest = fs::read_to_string(files.last().unwrap()).unwrap();
820 assert!(newest.contains("progress: 100%"));
821 assert!(!newest.contains("progress: 050%"));
822 }
823
824 #[test]
825 fn test_multiple_progress_bars_migration() {
826 let dir = test_dir("multi_progress_migration");
827 let mut writer = LogFileSizeRotation::new(SizeRotationConfig {
828 folder: dir.clone(),
829 filename: "app".into(),
830 extension: "log".into(),
831 max_file_size: None,
832 max_lines: Some(5),
833 max_files: 20,
834 })
835 .unwrap();
836
837 let id1 = Uuid::new_v4();
838 let id2 = Uuid::new_v4();
839 let id3 = Uuid::new_v4();
840 writer.progress("bar1: 000%", id1);
841 writer.progress("bar2: 000%", id2);
842 writer.progress("bar3: 000%", id3);
843 writer.regular("filler line 01");
844 writer.regular("filler line 02");
845 thread::sleep(Duration::from_millis(1));
847 writer.regular("after rotation");
848 writer.flush();
849
850 assert_eq!(count_log_files(&dir, "app", "log"), 2);
851
852 let mut files: Vec<PathBuf> = fs::read_dir(&dir)
853 .unwrap()
854 .flatten()
855 .filter_map(|e| {
856 let name = e.file_name().to_string_lossy().to_string();
857 if name.starts_with("app_") && name.ends_with(".log") {
858 Some(e.path())
859 } else {
860 None
861 }
862 })
863 .collect();
864 files.sort();
865 let newest = fs::read_to_string(files.last().unwrap()).unwrap();
866 assert!(newest.contains("bar1: 000%"));
867 assert!(newest.contains("bar2: 000%"));
868 assert!(newest.contains("bar3: 000%"));
869
870 writer.progress("bar1: 100%", id1);
872 writer.progress("bar2: 050%", id2);
873 writer.progress("bar3: 075%", id3);
874 writer.flush();
875 let newest = fs::read_to_string(files.last().unwrap()).unwrap();
876 assert!(newest.contains("bar1: 100%"));
877 assert!(newest.contains("bar2: 050%"));
878 assert!(newest.contains("bar3: 075%"));
879 assert!(!newest.contains("bar1: 000%"));
880 assert!(!newest.contains("bar2: 000%"));
881 assert!(!newest.contains("bar3: 000%"));
882 }
883
884 #[test]
885 fn test_finished_prevents_migration() {
886 let dir = test_dir("finished_no_migrate");
887 let mut writer = LogFileSizeRotation::new(SizeRotationConfig {
888 folder: dir.clone(),
889 filename: "app".into(),
890 extension: "log".into(),
891 max_file_size: None,
892 max_lines: Some(4),
893 max_files: 20,
894 })
895 .unwrap();
896
897 let active_id = Uuid::new_v4();
898 let finished_id = Uuid::new_v4();
899 writer.progress("active bar 50%", active_id);
900 writer.progress("done bar 100%%", finished_id);
901 writer.finished(finished_id);
902 writer.regular("filler line 01.");
903 writer.regular("filler line 02.");
904 thread::sleep(Duration::from_millis(1));
906 writer.regular("after rotation.");
907 writer.flush();
908
909 assert_eq!(count_log_files(&dir, "app", "log"), 2);
910
911 let mut files: Vec<PathBuf> = fs::read_dir(&dir)
912 .unwrap()
913 .flatten()
914 .filter_map(|e| {
915 let name = e.file_name().to_string_lossy().to_string();
916 if name.starts_with("app_") && name.ends_with(".log") {
917 Some(e.path())
918 } else {
919 None
920 }
921 })
922 .collect();
923 files.sort();
924 let newest = fs::read_to_string(files.last().unwrap()).unwrap();
925 assert!(newest.contains("active bar 50%"));
927 assert!(!newest.contains("done bar 100%"));
929 }
930
931 #[test]
932 fn test_validation_size_rotation_max_lines() {
933 let dir = test_dir("validation_max_lines");
934 let result = LogFileSizeRotation::new(SizeRotationConfig {
935 folder: dir,
936 filename: "app".into(),
937 extension: "log".into(),
938 max_file_size: None,
939 max_lines: Some(0), max_files: 5,
941 });
942 assert!(result.is_err());
943 let err = result.err().unwrap();
944 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
945 }
946
947 #[test]
948 fn test_integration_spawn_log_thread_with_size_rotation() {
949 use crate::utils::{LogMessage, spawn_log_thread_file};
950 use log::Level;
951 use std::sync::Arc;
952
953 let dir = test_dir("integration_spawn_size");
954 let writer = LogFileSizeRotation::new(SizeRotationConfig {
955 folder: dir.clone(),
956 filename: "app".into(),
957 extension: "log".into(),
958 max_file_size: None,
959 max_lines: Some(3),
960 max_files: 20,
961 })
962 .unwrap();
963
964 let logger = FileLogger::SizeRotation(writer);
965 let sender = spawn_log_thread_file(logger);
966
967 for i in 0..9 {
968 sender
969 .send(Arc::new(LogMessage {
970 message: format!("msg{i}"),
971 level: Level::Info,
972 name: Some("test".into()),
973 }))
974 .unwrap();
975 }
976 sender
978 .send(Arc::new(LogMessage {
979 message: "___SHUTDOWN___".into(),
980 level: Level::Info,
981 name: None,
982 }))
983 .unwrap();
984 sender.shutdown();
986
987 let file_count = count_log_files(&dir, "app", "log");
988 assert!(
989 file_count >= 2,
990 "expected multiple files from integration test, got {file_count}"
991 );
992
993 let content = read_all_log_content(&dir, "app", "log");
994 for i in 0..9 {
995 assert!(
996 content.contains(&format!("msg{i}")),
997 "missing msg{i} in output"
998 );
999 }
1000 }
1001}