simple_file_rotation/
lib.rs

1//! This is an implementation of simple [FileRotation](FileRotation) mechanism using only std.
2//! Given a file like `my.log`, it will copy that file to `my.1.log`, renaming a
3//! potentially pre-existing `my.1.log` to `my.2.log`. It accepts an optional
4//! number of max filesto keep. It will only rotate files when invoked, it will
5//! /not/ watch any files or do any kind of background processing.
6//!
7//! ```no_run
8//! use simple_file_rotation::{FileRotation};
9//! # fn example() -> simple_file_rotation::Result<()> {
10//! FileRotation::new("my.log")
11//!     .max_old_files(2)
12//!     .rotate()?;
13//! # Ok(())
14//! # }
15//! ```
16//!
17//! Why yet another file rotation library?
18//! - No additional dependencies.
19//! - No features I don't need.
20
21pub use error::{FileRotationError, Result};
22use std::path::{is_separator, Path, PathBuf};
23
24mod error;
25
26pub struct FileRotation {
27    max_old_files: Option<usize>,
28    file: PathBuf,
29    extension: String,
30}
31
32/// See module documentation.
33impl FileRotation {
34    pub fn new(file: impl AsRef<Path>) -> Self {
35        Self {
36            file: file.as_ref().to_path_buf(),
37            max_old_files: None,
38            extension: "log".to_string(),
39        }
40    }
41
42    /// Set a maximum of how many files to keep.
43    #[must_use]
44    pub fn max_old_files(mut self, max_old_files: usize) -> Self {
45        self.max_old_files = Some(max_old_files);
46        self
47    }
48
49    /// Set a file extension to use if none is present in the original filename
50    pub fn file_extension(mut self, extension: impl ToString) -> Self {
51        self.extension = extension.to_string();
52        self
53    }
54
55    pub fn rotate(self) -> Result<()> {
56        let Self {
57            max_old_files,
58            file,
59            extension,
60        } = self;
61
62        let is_dir = file
63            .to_str()
64            .and_then(|path| path.chars().last())
65            .map(is_separator)
66            .unwrap_or(false);
67
68        if is_dir {
69            return Err(FileRotationError::NotAFile(file));
70        }
71
72        // enforce the file to have an extension
73        let data_file = match file.extension() {
74            Some(_) => file,
75            None => file.with_extension(&extension),
76        };
77
78        let data_file_name = match data_file.file_name() {
79            Some(data_file_name) => data_file_name,
80            _ => return Err(FileRotationError::NotAFile(data_file)),
81        };
82
83        let data_file_dir = data_file
84            .parent()
85            .and_then(|p| {
86                let dir = p.to_path_buf();
87                if dir.to_string_lossy().is_empty() {
88                    None
89                } else {
90                    Some(dir)
91                }
92            })
93            .unwrap_or_else(|| PathBuf::from("."));
94
95        let mut rotations = Vec::new();
96        for entry in (std::fs::read_dir(&data_file_dir)?).flatten() {
97            let direntry_pathbuf = entry.path();
98
99            let file_name = entry.file_name();
100            if file_name == data_file_name {
101                rotations.push((
102                    direntry_pathbuf.clone(),
103                    data_file_name
104                        .to_string_lossy()
105                        .replace(&format!(".{}", &extension), &format!(".1.{}", &extension)),
106                ));
107            }
108
109            let data_file_name = data_file_name.to_string_lossy();
110            let file_name = file_name.to_string_lossy();
111            let parts = file_name.split('.').collect::<Vec<_>>();
112            match parts[..] {
113                [prefix, n, ext] if !prefix.is_empty() && data_file_name.starts_with(prefix) => {
114                    if let Ok(n) = n.parse::<usize>() {
115                        let new_name = format!("{prefix}.{}.{ext}", n + 1);
116                        rotations.push((direntry_pathbuf, new_name));
117                    }
118                }
119                _ => continue,
120            }
121        }
122
123        rotations.sort_by_key(|(_, new_name)| new_name.to_string());
124
125        if let Some(max_old_files) = max_old_files {
126            while rotations.len() > max_old_files {
127                if let Some((data_file, _)) = rotations.pop() {
128                    if let Err(err) = std::fs::remove_file(data_file.clone()) {
129                        eprintln!(
130                            "Rotating logs: cannot remove file {}: {err}",
131                            data_file.display()
132                        );
133                    }
134                }
135            }
136        }
137
138        for (entry, new_file_name) in rotations.into_iter().rev() {
139            if let Err(err) = std::fs::rename(entry.clone(), data_file_dir.join(new_file_name)) {
140                eprintln!("Error rotating file {entry:?}: {err}");
141            }
142        }
143
144        Ok(())
145    }
146}