1use crate::{writers::file_log_writer::InfixFilter, DeferredNow, FlexiLoggerError};
2use std::{
3 ffi::{OsStr, OsString},
4 ops::Add,
5 path::{Path, PathBuf},
6};
7
8#[derive(Debug, Clone, Eq, PartialEq)]
47pub struct FileSpec {
48 pub(crate) directory: PathBuf,
49 pub(crate) basename: String,
50 pub(crate) o_discriminant: Option<String>,
51 timestamp_cfg: TimestampCfg,
52 o_suffix: Option<String>,
53 pub(crate) use_utc: bool,
54}
55impl Default for FileSpec {
56 fn default() -> Self {
60 FileSpec {
61 directory: PathBuf::from("."),
62 basename: Self::default_basename(),
63 o_discriminant: None,
64 timestamp_cfg: TimestampCfg::Default,
65 o_suffix: Some(String::from("log")),
66 use_utc: false,
67 }
68 }
69}
70impl FileSpec {
71 fn default_basename() -> String {
72 let arg0 = std::env::args().next().unwrap_or_else(|| "rs".to_owned());
73 Path::new(&arg0).file_stem().map(OsStr::to_string_lossy).unwrap().to_string()
74 }
75
76 #[allow(clippy::missing_panics_doc)]
96 pub fn try_from<P: Into<PathBuf>>(p: P) -> Result<Self, FlexiLoggerError> {
97 let input: PathBuf = p.into();
98
99 if input.is_dir() {
100 Err(FlexiLoggerError::BadFileSpec("File path is a directory"))
101 } else {
102 let input_as_str = input.as_os_str().to_string_lossy();
103 if input_as_str.is_empty() {
104 Err(FlexiLoggerError::BadFileSpec("File path is empty"))
105 } else if input_as_str.ends_with('/')
106 || input_as_str.ends_with("/.")
107 || input_as_str.ends_with("/..")
108 {
109 Err(FlexiLoggerError::BadFileSpec(
110 "Path ends with '/' or '/.' or '/..'",
111 ))
112 } else if input
113 .file_name()
114 .ok_or(FlexiLoggerError::OutputBadFile)?
115 .to_string_lossy()
116 .starts_with('.')
117 && input.extension().is_none()
118 {
119 Err(FlexiLoggerError::BadFileSpec(
120 "File name cannot start with '.' without an extension",
121 ))
122 } else {
123 match input.parent() {
124 None => Err(FlexiLoggerError::BadFileSpec(
125 "File path has no parent directory",
126 )),
127 Some(parent) => {
128 let filespec = FileSpec {
129 directory: if parent.as_os_str().is_empty() {
130 PathBuf::from(".")
131 } else {
132 parent.to_path_buf()
133 },
134 basename: input.file_stem().unwrap().to_string_lossy().to_string(),
135 o_discriminant: None,
136 o_suffix: input.extension().map(|s| s.to_string_lossy().to_string()),
137 timestamp_cfg: TimestampCfg::No,
138 use_utc: false,
139 };
140 Ok(filespec)
141 }
142 }
143 }
144 }
145 }
146
147 #[must_use]
151 pub fn suppress_basename(self) -> Self {
152 self.basename("")
153 }
154
155 #[must_use]
158 pub fn basename<S: Into<String>>(mut self, basename: S) -> Self {
159 self.basename = basename.into();
160 self
161 }
162
163 #[must_use]
166 pub fn o_basename<S: Into<String>>(mut self, o_basename: Option<S>) -> Self {
167 self.basename = o_basename.map_or_else(Self::default_basename, Into::into);
168 self
169 }
170
171 #[must_use]
176 pub fn directory<P: Into<PathBuf>>(mut self, directory: P) -> Self {
177 self.directory = directory.into();
178 self
179 }
180
181 #[must_use]
186 pub fn o_directory<P: Into<PathBuf>>(mut self, directory: Option<P>) -> Self {
187 self.directory = directory.map_or_else(|| PathBuf::from("."), Into::into);
188 self
189 }
190
191 #[must_use]
193 pub fn discriminant<S: Into<String>>(self, discriminant: S) -> Self {
194 self.o_discriminant(Some(discriminant))
195 }
196
197 #[must_use]
199 pub fn o_discriminant<S: Into<String>>(mut self, o_discriminant: Option<S>) -> Self {
200 self.o_discriminant = o_discriminant.map(Into::into);
201 self
202 }
203 #[must_use]
207 pub fn suffix<S: Into<String>>(self, suffix: S) -> Self {
208 self.o_suffix(Some(suffix))
209 }
210
211 #[must_use]
215 pub fn o_suffix<S: Into<String>>(mut self, o_suffix: Option<S>) -> Self {
216 self.o_suffix = o_suffix.map(Into::into);
217 self
218 }
219
220 #[must_use]
224 pub fn suppress_timestamp(self) -> Self {
225 self.use_timestamp(false)
226 }
227
228 #[must_use]
234 pub fn use_timestamp(mut self, use_timestamp: bool) -> Self {
235 self.timestamp_cfg = if use_timestamp {
236 TimestampCfg::Yes
237 } else {
238 TimestampCfg::No
239 };
240 self
241 }
242
243 #[doc(hidden)]
244 #[must_use]
245 pub fn used_directory(&self) -> PathBuf {
246 self.directory.clone()
247 }
248 pub(crate) fn has_basename(&self) -> bool {
249 !self.basename.is_empty()
250 }
251 pub(crate) fn has_discriminant(&self) -> bool {
252 self.o_discriminant.is_some()
253 }
254 pub(crate) fn uses_timestamp(&self) -> bool {
255 matches!(self.timestamp_cfg, TimestampCfg::Yes)
256 }
257
258 pub(crate) fn if_default_use_timestamp(&mut self, use_timestamp: bool) {
261 if let TimestampCfg::Default = self.timestamp_cfg {
262 self.timestamp_cfg = if use_timestamp {
263 TimestampCfg::Yes
264 } else {
265 TimestampCfg::No
266 };
267 }
268 }
269
270 pub(crate) fn get_directory(&self) -> PathBuf {
271 self.directory.clone()
272 }
273
274 pub(crate) fn get_suffix(&self) -> Option<String> {
275 self.o_suffix.clone()
276 }
277
278 pub(crate) fn fixed_name_part(&self) -> String {
280 let mut fixed_name_part = self.basename.clone();
281 fixed_name_part.reserve(50);
282
283 if let Some(discriminant) = &self.o_discriminant {
284 append_underscore_if_not_empty(&mut fixed_name_part);
285 fixed_name_part.push_str(discriminant);
286 }
287 if let Some(timestamp) = &self.timestamp_cfg.get_timestamp() {
288 append_underscore_if_not_empty(&mut fixed_name_part);
289 fixed_name_part.push_str(timestamp);
290 }
291 fixed_name_part
292 }
293
294 #[must_use]
296 pub fn as_pathbuf(&self, o_infix: Option<&str>) -> PathBuf {
297 let mut filename = self.fixed_name_part();
298
299 if let Some(infix) = o_infix {
300 if !infix.is_empty() {
301 append_underscore_if_not_empty(&mut filename);
302 filename.push_str(infix);
303 }
304 }
305 if let Some(suffix) = &self.o_suffix {
306 filename.push('.');
307 filename.push_str(suffix);
308 }
309
310 let mut p_path = self.directory.clone();
311 p_path.push(filename);
312 p_path
313 }
314
315 pub(crate) fn collision_free_infix_for_rotated_file(&self, infix: &str) -> String {
317 let uncompressed_files = self.list_of_files(
318 &InfixFilter::Equls(infix.to_string()),
319 self.o_suffix.as_deref(),
320 );
321 let compressed_files =
322 self.list_of_files(&InfixFilter::Equls(infix.to_string()), Some("gz"));
323
324 let mut restart_siblings = uncompressed_files
325 .into_iter()
326 .chain(compressed_files)
327 .filter(|pb| {
328 let mut pb2 = PathBuf::from(pb);
330 if pb2.extension() == Some(OsString::from("gz").as_ref()) {
331 pb2.set_extension("");
332 }
333 match self.o_suffix {
335 Some(ref sfx) => pb2.extension() == Some(OsString::from(sfx).as_ref()),
336 None => true,
337 }
338 })
339 .filter(|pb| {
340 pb.file_name()
341 .unwrap()
342 .to_string_lossy()
343 .contains(".restart-")
344 })
345 .collect::<Vec<PathBuf>>();
346
347 let new_path = self.as_pathbuf(Some(infix));
348 let new_path_with_gz = {
349 let mut new_path_with_gz = new_path.clone();
350 new_path_with_gz
351 .set_extension([self.o_suffix.as_deref().unwrap_or(""), ".gz"].concat());
352 new_path_with_gz
353 };
354
355 if new_path.exists() || new_path_with_gz.exists() || !restart_siblings.is_empty() {
358 let next_number = if restart_siblings.is_empty() {
359 0
360 } else {
361 restart_siblings.sort_unstable();
362 let new_path = restart_siblings.pop().unwrap();
363 let file_stem_string = if self.o_suffix.is_some() {
364 new_path
365 .file_stem().unwrap()
366 .to_string_lossy().to_string()
367 } else {
368 new_path.to_string_lossy().to_string()
369 };
370 let index = file_stem_string.find(".restart-").unwrap();
371 file_stem_string[(index + 9)..(index + 13)].parse::<usize>().unwrap() + 1
372 };
373
374 infix.to_string().add(&format!(".restart-{next_number:04}"))
375 } else {
376 infix.to_string()
377 }
378 }
379
380 pub(crate) fn list_of_files(
381 &self,
382 infix_filter: &InfixFilter,
383 o_suffix: Option<&str>,
384 ) -> Vec<PathBuf> {
385 self.filter_files(&self.read_dir_related_files(), infix_filter, o_suffix)
386 }
387
388 pub(crate) fn read_dir_related_files(&self) -> Vec<PathBuf> {
390 let fixed_name_part = self.fixed_name_part();
391 let mut log_files = std::fs::read_dir(&self.directory)
392 .unwrap()
393 .flatten()
394 .filter(|entry| entry.path().is_file())
395 .map(|de| de.path())
396 .filter(|path| {
397 if let Some(fln) = path.file_name() {
399 fln.to_string_lossy().starts_with(&fixed_name_part)
400 } else {
401 false
402 }
403 })
404 .collect::<Vec<PathBuf>>();
405 log_files.sort_unstable();
406 log_files.reverse();
407 log_files
408 }
409
410 pub(crate) fn filter_files(
411 &self,
412 files: &[PathBuf],
413 infix_filter: &InfixFilter,
414 o_suffix: Option<&str>,
415 ) -> Vec<PathBuf> {
416 let fixed_name_part = self.fixed_name_part();
417 files
418 .iter()
419 .filter(|path| {
420 if let Some(suffix) = o_suffix {
422 path.extension().is_some_and(|ext| {
423 let s = ext.to_string_lossy();
424 s == suffix
425 })
426 } else {
427 true
428 }
429 })
430 .filter(|path| {
431 let stem = path.file_stem().unwrap().to_string_lossy();
433 let infix_start = if fixed_name_part.is_empty() {
434 0
435 } else {
436 fixed_name_part.len() + 1 };
438 if stem.len() <= infix_start {
439 return false;
440 }
441 let maybe_infix = &stem[infix_start..];
442 let end = maybe_infix.find('.').unwrap_or(maybe_infix.len());
443 infix_filter.filter_infix(&maybe_infix[..end])
444 })
445 .map(PathBuf::clone)
446 .collect::<Vec<PathBuf>>()
447 }
448
449 #[cfg(test)]
450 pub(crate) fn get_timestamp(&self) -> Option<String> {
451 self.timestamp_cfg.get_timestamp()
452 }
453}
454
455fn append_underscore_if_not_empty(filename: &mut String) {
456 if !filename.is_empty() {
457 filename.push('_');
458 }
459}
460
461const TS_USCORE_DASHES_USCORE_DASHES: &str = "%Y-%m-%d_%H-%M-%S";
462
463#[derive(Debug, Clone, Eq, PartialEq)]
464enum TimestampCfg {
465 Default,
466 Yes,
467 No,
468}
469impl TimestampCfg {
470 fn get_timestamp(&self) -> Option<String> {
471 match self {
472 Self::Default | Self::Yes => Some(
473 DeferredNow::new()
474 .format(TS_USCORE_DASHES_USCORE_DASHES)
475 .to_string(),
476 ),
477 Self::No => None,
478 }
479 }
480}
481
482#[cfg(test)]
483mod test {
484 use super::{FileSpec, TimestampCfg};
485 use crate::writers::file_log_writer::InfixFilter;
486 use std::{
487 fs::File,
488 path::{Path, PathBuf},
489 };
490
491 #[test]
492 fn test_timstamp_cfg() {
493 let ts = TimestampCfg::Yes;
494 let s = ts.get_timestamp().unwrap();
495 let bytes = s.into_bytes();
496 assert_eq!(bytes[4], b'-');
497 assert_eq!(bytes[7], b'-');
498 assert_eq!(bytes[10], b'_');
499 assert_eq!(bytes[13], b'-');
500 assert_eq!(bytes[16], b'-');
501 }
502
503 #[test]
504 fn test_default() {
505 let path = FileSpec::default().as_pathbuf(None);
506 assert_file_spec(&path, &PathBuf::from("."), true, "log");
507 }
508
509 #[test]
510 fn issue_194() {
511 assert!(dbg!(FileSpec::try_from("")).is_err());
512 assert!(dbg!(FileSpec::try_from(".")).is_err());
513 assert!(dbg!(FileSpec::try_from("..")).is_err());
514 assert!(dbg!(FileSpec::try_from("./f/")).is_err());
517 assert!(dbg!(FileSpec::try_from("./f/.")).is_err());
518 assert!(dbg!(FileSpec::try_from("./f/..")).is_err());
519 assert!(dbg!(FileSpec::try_from(".log")).is_err());
520 assert!(dbg!(FileSpec::try_from("./.log")).is_err());
521 assert!(dbg!(FileSpec::try_from("./f/.log")).is_err());
522
523 let filespec = FileSpec::try_from("test.log").unwrap();
524 std::fs::create_dir_all(filespec.get_directory()).unwrap();
525 assert!(std::fs::metadata(filespec.get_directory())
526 .unwrap()
527 .is_dir());
528 }
529
530 fn assert_file_spec(path: &Path, folder: &Path, with_timestamp: bool, suffix: &str) {
532 assert_eq!(
534 path.parent().unwrap(), folder );
537 let progname = PathBuf::from(std::env::args().next().unwrap())
540 .file_stem()
541 .unwrap()
542 .to_string_lossy()
543 .clone()
544 .to_string();
545 let stem = path
546 .file_stem()
547 .unwrap()
548 .to_string_lossy()
549 .clone()
550 .to_string();
551 assert!(
552 stem.starts_with(&progname),
553 "stem: {stem:?}, progname: {progname:?}",
554 );
555 if with_timestamp {
556 assert_eq!(stem.as_bytes()[progname.len()], b'_');
558 let s_ts = &stem[progname.len() + 1..];
559 assert!(
560 chrono::NaiveDateTime::parse_from_str(s_ts, "%Y-%m-%d_%H-%M-%S").is_ok(),
561 "s_ts: \"{s_ts}\"",
562 );
563 } else {
564 assert_eq!(
565 stem.len(),
566 progname.len(),
567 "stem: {stem:?}, progname: {progname:?}",
568 );
569 }
570
571 assert_eq!(path.extension().unwrap(), suffix);
573 }
574
575 #[test]
576 fn test_if_default_use_timestamp() {
577 {
579 let mut fs = FileSpec::default();
580 fs.if_default_use_timestamp(false);
581 let path = fs.as_pathbuf(None);
582 assert_file_spec(&path, &PathBuf::from("."), false, "log");
583 }
584 {
586 let mut fs = FileSpec::default().use_timestamp(true);
587 fs.if_default_use_timestamp(false);
588 let path = fs.as_pathbuf(None);
589 assert_file_spec(&path, &PathBuf::from("."), true, "log");
590 }
591 {
593 let mut fs = FileSpec::default();
594 fs.if_default_use_timestamp(false);
595 let path = fs.use_timestamp(true).as_pathbuf(None);
596 assert_file_spec(&path, &PathBuf::from("."), true, "log");
597 }
598 {
600 let mut fs = FileSpec::default();
601 fs.if_default_use_timestamp(false);
602 let path = fs.use_timestamp(true).as_pathbuf(None);
603 assert_file_spec(&path, &PathBuf::from("."), true, "log");
604 }
605 }
606
607 #[test]
608 fn test_from_url() {
609 let path = FileSpec::try_from("/a/b/c/d_foo_bar.trc")
610 .unwrap()
611 .as_pathbuf(None);
612 assert_eq!(path.parent().unwrap(), PathBuf::from("/a/b/c"));
614 let stem = path
617 .file_stem()
618 .unwrap()
619 .to_string_lossy()
620 .clone()
621 .to_string();
622 assert_eq!(stem, "d_foo_bar");
623
624 assert_eq!(path.extension().unwrap(), "trc");
626 }
627
628 #[test]
629 fn test_basename() {
630 {
631 let path = FileSpec::try_from("/a/b/c/d_foo_bar.trc")
632 .unwrap()
633 .o_basename(Some("boo_far"))
634 .as_pathbuf(None);
635 assert_eq!(path.parent().unwrap(), PathBuf::from("/a/b/c"));
637
638 let stem = path
641 .file_stem()
642 .unwrap()
643 .to_string_lossy()
644 .clone()
645 .to_string();
646 assert_eq!(stem, "boo_far");
647
648 assert_eq!(path.extension().unwrap(), "trc");
650 }
651 {
652 let path = FileSpec::try_from("/a/b/c/d_foo_bar.trc")
653 .unwrap()
654 .o_basename(Option::<String>::None)
655 .as_pathbuf(None);
656 assert_file_spec(&path, &PathBuf::from("/a/b/c"), false, "trc");
657 }
658 }
659
660 #[test]
661 fn test_directory_and_suffix() {
662 {
663 let path = FileSpec::try_from("/a/b/c/d_foo_bar.trc")
664 .unwrap()
665 .directory("/x/y/z")
666 .o_suffix(Some("txt"))
667 .o_basename(Option::<String>::None)
668 .as_pathbuf(None);
669 assert_file_spec(&path, &PathBuf::from("/x/y/z"), false, "txt");
670 }
671 }
672
673 #[test]
674 fn test_discriminant() {
675 let path = FileSpec::try_from("/a/b/c/d_foo_bar.trc")
676 .unwrap()
677 .directory("/x/y/z")
678 .o_suffix(Some("txt"))
679 .o_discriminant(Some("1234"))
680 .as_pathbuf(None);
681 assert_eq!(
682 path.file_name().unwrap().to_str().unwrap(),
683 "d_foo_bar_1234.txt"
684 );
685 }
686
687 #[test]
688 fn test_suppress_basename() {
689 let path = FileSpec::try_from("/a/b/c/d_foo_bar.trc")
690 .unwrap()
691 .suppress_basename()
692 .o_suffix(Some("txt"))
693 .o_discriminant(Some("1234"))
694 .as_pathbuf(None);
695 assert_eq!(path.file_name().unwrap().to_str().unwrap(), "1234.txt");
696 }
697
698 #[test]
699 fn test_empty_base_name() {
700 let path = FileSpec::default()
701 .suppress_basename()
702 .suppress_timestamp()
703 .o_discriminant(Option::<String>::None)
704 .as_pathbuf(None);
705 assert_eq!(path.file_name().unwrap(), ".log");
706 }
707
708 #[test]
709 fn test_empty_name() {
710 let path = FileSpec::default()
711 .suppress_basename()
712 .suppress_timestamp()
713 .o_suffix(Option::<String>::None)
714 .as_pathbuf(None);
715 assert!(path.file_name().is_none());
716 }
717
718 #[test]
719 fn issue_178() {
720 let path = FileSpec::default()
721 .basename("BASENAME")
722 .suppress_timestamp()
723 .as_pathbuf(Some(""));
724 assert_eq!(path.file_name().unwrap().to_string_lossy(), "BASENAME.log");
725
726 let path = FileSpec::default()
727 .basename("BASENAME")
728 .discriminant("1")
729 .suppress_timestamp()
730 .as_pathbuf(Some(""));
731 assert_eq!(
732 path.file_name().unwrap().to_string_lossy(),
733 "BASENAME_1.log"
734 );
735 }
736
737 #[test]
738 fn test_list_of_files() {
739 let dir = temp_dir::TempDir::new().unwrap();
740 let pd = dir.path();
741 let filespec: FileSpec = FileSpec::default()
742 .directory(pd)
743 .basename("Base")
744 .discriminant("Discr")
745 .use_timestamp(true);
746 println!("Filespec: {}", filespec.as_pathbuf(Some("Infix")).display());
747
748 let mut fn1 = String::new();
749 fn1.push_str("Base_Discr_");
750 fn1.push_str(&filespec.get_timestamp().unwrap());
751 fn1.push_str("_Infix");
752 fn1.push_str(".log");
753 assert_eq!(
754 filespec
755 .as_pathbuf(Some("Infix"))
756 .file_name()
757 .unwrap()
758 .to_string_lossy(),
759 fn1
760 );
761 create_file(pd, "test1.txt");
763 create_file(pd, &build_filename(&filespec, "Infix1"));
764 create_file(pd, &build_filename(&filespec, "Infix2"));
765
766 println!("\nFolder content:");
767 for entry in std::fs::read_dir(pd).unwrap() {
768 println!(" {}", entry.unwrap().path().display());
769 }
770
771 println!("\nRelevant subset:");
772 for pb in filespec.list_of_files(&InfixFilter::StartsWth("Infix".to_string()), Some("log"))
773 {
774 println!(" {}", pb.display());
775 }
776 }
777
778 fn build_filename(file_spec: &FileSpec, infix: &str) -> String {
779 let mut fn1 = String::new();
780 fn1.push_str("Base_Discr_");
781 fn1.push_str(&file_spec.get_timestamp().unwrap());
782 fn1.push('_');
783 fn1.push_str(infix);
784 fn1.push_str(".log");
785 fn1
786 }
787
788 fn create_file(dir: &Path, filename: &str) {
789 File::create(dir.join(filename)).unwrap();
790 }
791}