Skip to main content

mtlog_core/
log_rotation.rs

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
28/// A file logger that supports single-file, time-based rotation, or size-based rotation.
29pub 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
69/// Configuration for time-based log file rotation.
70pub 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
78/// A log file writer that rotates files based on time intervals.
79pub 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        // Migrate active progress bars to the new file
129        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, ""); // e.g. "myproject_"
147        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
203/// Configuration for size-based log file rotation.
204pub 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
213/// A log file writer that rotates files based on size or line count.
214pub 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        // Migrate active progress bars to the new file
299        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        // Sort lexicographically (chronological due to timestamp naming)
331        matching_files.sort();
332        // Delete oldest files until count <= max_files
333        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        // Write 6 lines → should create 6 files but cleanup keeps only 3
514        for i in 0..6 {
515            // Small sleep to ensure unique timestamps
516            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        // Create some fake "old" files manually
528        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        // Trigger rotation so cleanup runs
546        thread::sleep(Duration::from_millis(60));
547        writer.regular("line2");
548        writer.flush();
549
550        // Old file should have been cleaned up
551        assert!(!old_file.exists());
552        // Current files should still exist
553        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        // Use same-length strings (progress bars use fixed-width content)
570        writer.progress("progress: 050%", progress_id);
571        writer.flush();
572
573        // Trigger rotation
574        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        // The new file should contain the migrated progress bar
581        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        // Update progress after rotation should work (same byte length)
599        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), // too small
614            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), // too small
629            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, // too small
647        });
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, // neither set
662            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        // Each line is ~100 bytes; 30 lines = ~3000 bytes → should create >= 3 files
698        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), // won't be hit
726            max_lines: Some(2),         // triggers first
727            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), // triggers first
748            max_lines: Some(1000),     // won't be hit
749            max_files: 20,
750        })
751        .unwrap();
752
753        // ~200 bytes per line; 20 lines = ~4000 bytes → should create >= 3 files
754        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        // 3 lines written (1 progress + 2 regular) → next write triggers rotation
792        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        // The new file should contain the migrated progress bar
799        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        // Update progress after rotation (same byte length)
817        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        // 5 lines → next write triggers rotation
846        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        // Update each bar independently (same byte length)
871        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        // 4 lines → next write triggers rotation
905        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        // Active bar should be migrated
926        assert!(newest.contains("active bar 50%"));
927        // Finished bar should NOT be migrated
928        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), // below MIN_LINES=1 in test mode
940            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        // Send shutdown
977        sender
978            .send(Arc::new(LogMessage {
979                message: "___SHUTDOWN___".into(),
980                level: Level::Info,
981                name: None,
982            }))
983            .unwrap();
984        // shutdown() joins the thread
985        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}