1use std::{
2 cmp::Ordering,
3 ffi::{OsStr, OsString},
4 fmt, fs,
5 io::{Cursor, ErrorKind as IoErrorKind, Result as IoResult},
6 ops::{Range, RangeInclusive},
7 path::PathBuf,
8 str::{from_utf8, FromStr},
9 time::SystemTime,
10};
11
12use thiserror::Error;
13use time::{format_description::FormatItem, macros::format_description, PrimitiveDateTime};
14
15use crate::time::{Interval, Timestamp};
16
17const TIME_STAMP_FORMAT: &[FormatItem<'static>] = format_description!(
23 "[year repr:full][month repr:numerical][day]T[hour repr:24][minute][second].[subsecond digits:9]Z"
24);
25const TIME_STAMP_STRING_LEN: usize = 4 + 2 + 2 + 1 + 2 + 2 + 2 + 1 + 9 + 1;
26
27const PREALLOCATE_NUMBER_OF_DIR_ENTRIES: usize = 365;
29
30#[derive(Clone, Debug, Default, Eq, PartialEq)]
31pub struct RollingFileLimits {
32 pub max_bytes_written: Option<u64>,
33 pub max_records_written: Option<u64>,
34 pub max_nanoseconds_offset: Option<u64>,
35 pub interval: Option<Interval>,
36}
37
38impl RollingFileLimits {
39 #[must_use]
40 pub fn daily() -> Self {
41 Self {
42 interval: Some(Interval::Days(1)),
43 ..Default::default()
44 }
45 }
46
47 #[must_use]
48 pub fn weekly() -> Self {
49 Self {
50 interval: Some(Interval::Weeks(1)),
51 ..Default::default()
52 }
53 }
54}
55
56#[derive(Clone, Debug, Eq, PartialEq)]
57pub struct RollingFileStatus {
58 pub created_at: SystemTime,
59 pub bytes_written: Option<u64>,
60 pub records_written: Option<u64>,
61}
62
63impl RollingFileStatus {
64 #[must_use]
65 pub const fn new(created_at: SystemTime) -> Self {
66 Self {
67 created_at,
68 bytes_written: None,
69 records_written: None,
70 }
71 }
72
73 #[must_use]
74 pub fn should_roll(
75 &self,
76 now: SystemTime,
77 now_nanoseconds_offset: u64,
78 limits: &RollingFileLimits,
79 ) -> bool {
80 let Self {
81 created_at,
82 bytes_written,
83 records_written,
84 } = self;
85 let RollingFileLimits {
86 max_bytes_written,
87 max_records_written,
88 max_nanoseconds_offset,
89 interval,
90 } = limits;
91 if let Some(bytes_written) = bytes_written {
92 if let Some(max_bytes_written) = max_bytes_written {
93 if bytes_written >= max_bytes_written {
94 return true;
95 }
96 }
97 }
98 if let Some(records_written) = records_written {
99 if let Some(max_records_written) = max_records_written {
100 if records_written >= max_records_written {
101 return true;
102 }
103 }
104 }
105 if let Some(max_nanoseconds_offset) = max_nanoseconds_offset {
106 if now_nanoseconds_offset >= *max_nanoseconds_offset {
107 return true;
108 }
109 }
110 if let Some(interval) = interval {
111 let next_rollover = interval.system_time_after(*created_at);
112 if next_rollover <= now {
113 return true;
114 }
115 }
116 false
117 }
118}
119
120#[derive(Debug, Clone, Eq, PartialEq)]
121pub struct RollingFileNameTemplate {
122 pub prefix: String,
123 pub suffix: String,
124}
125
126#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd)]
127pub struct FileNameTimeStamp(SystemTime);
128
129impl From<SystemTime> for FileNameTimeStamp {
130 fn from(from: SystemTime) -> Self {
131 Self(from)
132 }
133}
134
135impl From<FileNameTimeStamp> for SystemTime {
136 fn from(from: FileNameTimeStamp) -> Self {
137 from.0
138 }
139}
140
141impl FileNameTimeStamp {
142 #[must_use]
143 pub fn format(&self) -> String {
144 let formatted = Timestamp::from(self.0)
145 .to_utc()
146 .as_ref()
147 .format(TIME_STAMP_FORMAT)
148 .unwrap_or_default();
149 debug_assert_eq!(TIME_STAMP_STRING_LEN, formatted.len());
150 formatted
151 }
152
153 #[must_use]
154 pub fn format_into(&self, output: &mut [u8; TIME_STAMP_STRING_LEN]) -> usize {
155 let formatted_len = Timestamp::from(self.0)
156 .to_utc()
157 .as_ref()
158 .format_into(&mut Cursor::new(output.as_mut_slice()), TIME_STAMP_FORMAT)
159 .unwrap_or_default();
160 debug_assert_eq!(TIME_STAMP_STRING_LEN, formatted_len);
161 formatted_len
162 }
163}
164
165impl fmt::Display for FileNameTimeStamp {
166 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167 let mut output = [0u8; TIME_STAMP_STRING_LEN];
168 let formatted_len = self.format_into(&mut output);
169 f.write_str(from_utf8(&output.as_slice()[..formatted_len]).unwrap_or_default())
170 }
171}
172
173impl FromStr for FileNameTimeStamp {
174 type Err = time::error::Parse;
175
176 fn from_str(input: &str) -> Result<Self, Self::Err> {
177 let date_time = PrimitiveDateTime::parse(input, TIME_STAMP_FORMAT)?.assume_utc();
178 Ok(Self(date_time.into()))
179 }
180}
181
182impl RollingFileNameTemplate {
183 #[must_use]
184 pub fn format_os_string_with_time_stamp(&self, ts: FileNameTimeStamp) -> OsString {
185 let Self { prefix, suffix } = self;
186 let infix_capacity = TIME_STAMP_STRING_LEN * 2;
188 let mut file_name = OsString::with_capacity(prefix.len() + infix_capacity + suffix.len());
189 file_name.push(prefix);
190 file_name.push(ts.to_string());
191 file_name.push(suffix);
192 debug_assert!(file_name.len() <= file_name.capacity());
193 file_name
194 }
195
196 pub fn parse_time_stamp_from_file_name(
197 &self,
198 file_name: &OsStr,
199 ) -> Result<FileNameTimeStamp, FileNameError> {
200 let Self { prefix, suffix } = self;
201 let file_name = file_name.to_str().ok_or(FileNameError::Pattern)?;
202 if !file_name.starts_with(prefix) || !file_name.ends_with(suffix) {
204 return Err(FileNameError::Pattern);
205 }
206 let (_, without_prefix) = file_name.split_at(prefix.len());
207 let (ts, _) = without_prefix.split_at(without_prefix.len() - suffix.len());
208 if ts.len() != TIME_STAMP_STRING_LEN {
209 return Err(FileNameError::Pattern);
210 }
211 Ok(ts.parse()?)
212 }
213}
214
215#[derive(Debug, Clone, Eq, PartialEq)]
216pub struct RollingFileSystem {
217 pub base_path: PathBuf,
218 pub file_name_template: RollingFileNameTemplate,
219}
220
221#[derive(Clone, Debug, Eq, PartialEq)]
222pub struct RollingFileInfo {
223 pub path: PathBuf,
224 pub created_at: FileNameTimeStamp,
225}
226
227#[derive(Clone, Debug, Eq, PartialEq)]
228pub struct RollingFileInfoWithSize {
229 pub path: PathBuf,
230 pub created_at: FileNameTimeStamp,
231 pub size_in_bytes: u64,
232}
233
234impl RollingFileInfoWithSize {
235 #[must_use]
236 pub fn new(info: RollingFileInfo, size_in_bytes: u64) -> Self {
237 let RollingFileInfo { path, created_at } = info;
238 Self {
239 path,
240 created_at,
241 size_in_bytes,
242 }
243 }
244
245 fn cmp_created_at(&self, other: &Self) -> Ordering {
246 self.created_at.cmp(&other.created_at)
247 }
248}
249
250impl From<RollingFileInfoWithSize> for RollingFileInfo {
251 fn from(from: RollingFileInfoWithSize) -> Self {
252 let RollingFileInfoWithSize {
253 path,
254 created_at,
255 size_in_bytes: _,
256 } = from;
257 Self { path, created_at }
258 }
259}
260
261#[derive(Debug)]
262pub enum OpenRollingFile {
263 Opened(fs::File, RollingFileInfo),
264 AlreadyExists(PathBuf),
265}
266
267#[derive(Error, Debug)]
268pub enum FileNameError {
269 #[error("unrecognized file name pattern")]
270 Pattern,
271
272 #[error(transparent)]
273 Parse(#[from] time::error::Parse),
274}
275
276#[derive(Clone, Debug)]
277pub enum SystemTimeRange {
278 OnlyMostRecent,
279 ExclusiveUpperBound(Range<SystemTime>),
280 InclusiveUpperBound(RangeInclusive<SystemTime>),
281}
282
283#[derive(Clone, Debug, Default)]
284pub struct FileInfoFilter {
285 pub created_at: Option<SystemTimeRange>,
286}
287
288impl RollingFileSystem {
289 #[must_use]
290 pub fn new_file_path(&self, created_at: FileNameTimeStamp) -> PathBuf {
291 debug_assert!(PathBuf::from(self.file_name_template.prefix.clone()).is_relative());
292 let new_name = self
293 .file_name_template
294 .format_os_string_with_time_stamp(created_at);
295 debug_assert_eq!(
296 PathBuf::from(new_name.clone()).is_relative(),
297 PathBuf::from(self.file_name_template.prefix.clone()).is_relative()
298 );
299 debug_assert!(self.base_path.is_dir());
300 let mut new_file_path = self.base_path.clone();
301 new_file_path.push(new_name);
302 new_file_path
303 }
304
305 pub fn open_new_file_for_writing(
306 &self,
307 created_at: FileNameTimeStamp,
308 ) -> IoResult<OpenRollingFile> {
309 let mut open_options = fs::OpenOptions::new();
310 open_options.write(true).create_new(true);
311 let path = self.new_file_path(created_at);
312 match open_options.open(&path) {
313 Ok(file) => {
314 let info = RollingFileInfo { path, created_at };
315 Ok(OpenRollingFile::Opened(file, info))
316 }
317 Err(e) => {
318 if e.kind() == IoErrorKind::AlreadyExists {
319 Ok(OpenRollingFile::AlreadyExists(path))
320 } else {
321 Err(e)
322 }
323 }
324 }
325 }
326
327 pub fn read_all_dir_entries_filtered(
331 &self,
332 filter: &FileInfoFilter,
333 ) -> IoResult<Vec<RollingFileInfoWithSize>> {
334 let capacity = if let Some(SystemTimeRange::OnlyMostRecent) = filter.created_at {
335 1
336 } else {
337 PREALLOCATE_NUMBER_OF_DIR_ENTRIES
338 };
339 let mut entries = Vec::with_capacity(capacity);
340 let mut first_created_at_filtered = None; for entry in fs::read_dir(&self.base_path)? {
342 let entry = entry?;
343 let path = entry.path();
344 if path.is_file() {
345 if let Some(created_at) = path.file_name().and_then(|file_name| {
346 self.file_name_template
347 .parse_time_stamp_from_file_name(file_name)
348 .ok()
349 }) {
350 if let Some(filter_created_at) = &filter.created_at {
351 let filter_created_at_start = match filter_created_at {
352 SystemTimeRange::OnlyMostRecent => {
353 if created_at.0 >= first_created_at_filtered.unwrap_or(created_at.0)
354 {
355 entries.clear();
356 }
357 created_at.0
358 }
359 SystemTimeRange::ExclusiveUpperBound(filter_created_at) => {
360 if created_at.0 >= filter_created_at.end {
361 continue;
362 }
363 filter_created_at.start
364 }
365 SystemTimeRange::InclusiveUpperBound(filter_created_at) => {
366 if created_at.0 > *filter_created_at.end() {
367 continue;
368 }
369 *filter_created_at.start()
370 }
371 };
372 if let Some(first_created_at) = first_created_at_filtered {
373 debug_assert!(first_created_at <= filter_created_at_start);
374 if created_at.0 < first_created_at {
375 continue;
376 }
377 }
378 if created_at.0 <= filter_created_at_start {
379 first_created_at_filtered = Some(created_at.0);
380 }
381 }
382 let size_in_bytes = path.metadata()?.len();
383 entries.push(RollingFileInfoWithSize {
384 path,
385 created_at,
386 size_in_bytes,
387 });
388 continue;
389 }
390 }
391 log::debug!("Ignoring directory entry {}", path.display());
392 }
393 if let Some(first_created_at_filtered) = first_created_at_filtered {
394 entries.retain(|file_info| file_info.created_at.0 >= first_created_at_filtered);
396 }
397 Ok(entries)
398 }
399
400 pub fn read_all_dir_entries_filtered_chronologically(
402 &self,
403 filter: &FileInfoFilter,
404 ) -> IoResult<Vec<RollingFileInfoWithSize>> {
405 let mut entries = self.read_all_dir_entries_filtered(filter)?;
406 entries.sort_unstable_by(RollingFileInfoWithSize::cmp_created_at);
407 Ok(entries)
408 }
409
410 pub fn read_most_recent_dir_entry(&self) -> IoResult<Option<RollingFileInfoWithSize>> {
411 Ok(self
412 .read_all_dir_entries_filtered_chronologically(&FileInfoFilter {
413 created_at: Some(SystemTimeRange::OnlyMostRecent),
414 })?
415 .into_iter()
416 .last())
417 }
418}
419
420#[derive(Debug, Clone, Eq, PartialEq)]
421pub struct RollingFileConfig {
422 pub system: RollingFileSystem,
423 pub limits: RollingFileLimits,
424}
425
426#[cfg(test)]
427mod tests;