Skip to content

Add ability for static base file and customizable file roll patterns. #345

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs
Original file line number Diff line number Diff line change
@@ -155,7 +155,8 @@ public static LoggerConfiguration File(
Encoding encoding)
{
return File(sinkConfiguration, path, restrictedToMinimumLevel, outputTemplate, formatProvider, fileSizeLimitBytes, levelSwitch, buffered,
shared, flushToDiskInterval, rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, null);
shared, flushToDiskInterval, rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, null,
null);
}

/// <summary>
@@ -164,7 +165,7 @@ public static LoggerConfiguration File(
/// <param name="sinkConfiguration">Logger sink configuration.</param>
/// <param name="formatter">A formatter, such as <see cref="JsonFormatter"/>, to convert the log events into
/// text for the file. If control of regular text formatting is required, use the other
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?)"/>
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?, bool, Func{DateTime?,string}?, string?)"/>
/// and specify the outputTemplate parameter instead.
/// </param>
/// <param name="path">Path to the file.</param>
@@ -236,6 +237,11 @@ public static LoggerConfiguration File(
/// Must be greater than or equal to <see cref="TimeSpan.Zero"/>.
/// Ignored if <paramref see="rollingInterval"/> is <see cref="RollingInterval.Infinite"/>.
/// The default is to retain files indefinitely.</param>
/// <param name="keepPathStaticOnRoll">Use the initial file path as a static file.</param>
/// <param name="customFormatFunc">A custom function that returns a custom string for rolling over files.
/// Accepts a DateTime for using custom DateTime formats.
/// This must return a string that can be matched by <paramref name="customRollPattern"/>.</param>
/// <param name="customRollPattern">A custom pattern for rolling over files. This must compile into a <see cref="System.Text.RegularExpressions.Regex"/> </param>
/// <returns>Configuration object allowing method chaining.</returns>
/// <exception cref="ArgumentNullException">When <paramref name="sinkConfiguration"/> is <code>null</code></exception>
/// <exception cref="ArgumentNullException">When <paramref name="path"/> is <code>null</code></exception>
@@ -262,7 +268,10 @@ public static LoggerConfiguration File(
int? retainedFileCountLimit = DefaultRetainedFileCountLimit,
Encoding? encoding = null,
FileLifecycleHooks? hooks = null,
TimeSpan? retainedFileTimeLimit = null)
TimeSpan? retainedFileTimeLimit = null,
bool keepPathStaticOnRoll = false,
Func<DateTime?,string>? customFormatFunc = null,
string? customRollPattern = null)
{
if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration));
if (path == null) throw new ArgumentNullException(nameof(path));
@@ -271,7 +280,7 @@ public static LoggerConfiguration File(
var formatter = new MessageTemplateTextFormatter(outputTemplate, formatProvider);
return File(sinkConfiguration, formatter, path, restrictedToMinimumLevel, fileSizeLimitBytes,
levelSwitch, buffered, shared, flushToDiskInterval,
rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, hooks, retainedFileTimeLimit);
rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, hooks, retainedFileTimeLimit, keepPathStaticOnRoll, customFormatFunc, customRollPattern);
}

/// <summary>
@@ -280,7 +289,7 @@ public static LoggerConfiguration File(
/// <param name="sinkConfiguration">Logger sink configuration.</param>
/// <param name="formatter">A formatter, such as <see cref="JsonFormatter"/>, to convert the log events into
/// text for the file. If control of regular text formatting is required, use the other
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?)"/>
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?, bool, Func{DateTime?, string}?, string?)"/>
/// and specify the outputTemplate parameter instead.
/// </param>
/// <param name="path">Path to the file.</param>
@@ -306,6 +315,9 @@ public static LoggerConfiguration File(
/// Must be greater than or equal to <see cref="TimeSpan.Zero"/>.
/// Ignored if <paramref see="rollingInterval"/> is <see cref="RollingInterval.Infinite"/>.
/// The default is to retain files indefinitely.</param>
/// <param name="keepPathStaticOnRoll">Use the initial file path as a static file.</param>
/// <param name="customFormatFunc">A custom function that returns a custom string for rolling over files. This must return a string that can be matched by <paramref name="customRollPattern"/>.</param>
/// <param name="customRollPattern">A custom pattern for rolling over files. This must compile into a <see cref="System.Text.RegularExpressions.Regex"/> </param>
/// <returns>Configuration object allowing method chaining.</returns>
/// <exception cref="ArgumentNullException">When <paramref name="sinkConfiguration"/> is <code>null</code></exception>
/// <exception cref="ArgumentNullException">When <paramref name="formatter"/> is <code>null</code></exception>
@@ -331,15 +343,18 @@ public static LoggerConfiguration File(
int? retainedFileCountLimit = DefaultRetainedFileCountLimit,
Encoding? encoding = null,
FileLifecycleHooks? hooks = null,
TimeSpan? retainedFileTimeLimit = null)
TimeSpan? retainedFileTimeLimit = null,
bool keepPathStaticOnRoll = false,
Func<DateTime?,string>? customFormatFunc = null,
string? customRollPattern = null)
{
if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration));
if (formatter == null) throw new ArgumentNullException(nameof(formatter));
if (path == null) throw new ArgumentNullException(nameof(path));

return ConfigureFile(sinkConfiguration.Sink, formatter, path, restrictedToMinimumLevel, fileSizeLimitBytes, levelSwitch,
buffered, false, shared, flushToDiskInterval, encoding, rollingInterval, rollOnFileSizeLimit,
retainedFileCountLimit, hooks, retainedFileTimeLimit);
retainedFileCountLimit, hooks, retainedFileTimeLimit, keepPathStaticOnRoll, customFormatFunc, customRollPattern);
}

/// <summary>
@@ -487,14 +502,15 @@ public static LoggerConfiguration File(
LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum,
LoggingLevelSwitch? levelSwitch = null,
Encoding? encoding = null,
FileLifecycleHooks? hooks = null)
FileLifecycleHooks? hooks = null
)
{
if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration));
if (formatter == null) throw new ArgumentNullException(nameof(formatter));
if (path == null) throw new ArgumentNullException(nameof(path));

return ConfigureFile(sinkConfiguration.Sink, formatter, path, restrictedToMinimumLevel, null, levelSwitch, false, true,
false, null, encoding, RollingInterval.Infinite, false, null, hooks, null);
false, null, encoding, RollingInterval.Infinite, false, null, hooks, null, false, null, null);
}

static LoggerConfiguration ConfigureFile(
@@ -513,7 +529,10 @@ static LoggerConfiguration ConfigureFile(
bool rollOnFileSizeLimit,
int? retainedFileCountLimit,
FileLifecycleHooks? hooks,
TimeSpan? retainedFileTimeLimit)
TimeSpan? retainedFileTimeLimit,
bool keepPathStaticOnRoll,
Func<DateTime?,string>? customFormatFunc,
string? customRollPattern)
{
if (addSink == null) throw new ArgumentNullException(nameof(addSink));
if (formatter == null) throw new ArgumentNullException(nameof(formatter));
@@ -523,14 +542,15 @@ static LoggerConfiguration ConfigureFile(
if (retainedFileTimeLimit.HasValue && retainedFileTimeLimit < TimeSpan.Zero) throw new ArgumentException("Negative value provided; retained file time limit must be non-negative.", nameof(retainedFileTimeLimit));
if (shared && buffered) throw new ArgumentException("Buffered writes are not available when file sharing is enabled.", nameof(buffered));
if (shared && hooks != null) throw new ArgumentException("File lifecycle hooks are not currently supported for shared log files.", nameof(hooks));
if(keepPathStaticOnRoll && (fileSizeLimitBytes == null && rollingInterval == RollingInterval.Infinite )) throw new ArgumentException("keepPathStaticOnRoll is only supported when either fileSizeLimitBytes or rollingInterval are enabled");

ILogEventSink sink;

try
{
if (rollOnFileSizeLimit || rollingInterval != RollingInterval.Infinite)
{
sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks, retainedFileTimeLimit);
sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks, retainedFileTimeLimit, keepPathStaticOnRoll, customFormatFunc, customRollPattern);
}
else
{
19 changes: 16 additions & 3 deletions src/Serilog.Sinks.File/Sinks/File/FileSink.cs
Original file line number Diff line number Diff line change
@@ -44,6 +44,7 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene
/// <param name="encoding">Character encoding used to write the text file. The default is UTF-8 without BOM.</param>
/// <param name="buffered">Indicates if flushing to the output file can be buffered or not. The default
/// is false.</param>
/// <param name="keepPathStatic">Indicates that the path is static/persistent</param>
/// <returns>Configuration object allowing method chaining.</returns>
/// <remarks>This constructor preserves compatibility with early versions of the public API. New code should not depend on this type.</remarks>
/// <exception cref="ArgumentNullException">When <paramref name="textFormatter"/> is <code>null</code></exception>
@@ -56,7 +57,7 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene
/// <exception cref="UnauthorizedAccessException">The caller does not have the required permission to access the <paramref name="path"/></exception>
/// <exception cref="ArgumentException">Invalid <paramref name="path"/></exception>
[Obsolete("This type and constructor will be removed from the public API in a future version; use `WriteTo.File()` instead.")]
public FileSink(string path, ITextFormatter textFormatter, long? fileSizeLimitBytes, Encoding? encoding = null, bool buffered = false)
public FileSink(string path, ITextFormatter textFormatter, long? fileSizeLimitBytes, Encoding? encoding = null, bool buffered = false, bool keepPathStatic = false)
: this(path, textFormatter, fileSizeLimitBytes, encoding, buffered, null)
{
}
@@ -68,7 +69,9 @@ internal FileSink(
long? fileSizeLimitBytes,
Encoding? encoding,
bool buffered,
FileLifecycleHooks? hooks)
FileLifecycleHooks? hooks,
bool keepPathStatic = false
)
{
if (path == null) throw new ArgumentNullException(nameof(path));
if (fileSizeLimitBytes is < 1) throw new ArgumentException("Invalid value provided; file size limit must be at least 1 byte, or null.");
@@ -82,7 +85,16 @@ internal FileSink(
Directory.CreateDirectory(directory);
}

Stream outputStream = _underlyingStream = System.IO.File.Open(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read);
FileMode fileOpenMode;
if (System.IO.File.Exists(path) && keepPathStatic)
{
fileOpenMode = FileMode.Truncate;
}
else
{
fileOpenMode = FileMode.OpenOrCreate;
}
Stream outputStream = _underlyingStream = System.IO.File.Open(path, fileOpenMode, FileAccess.Write, FileShare.Read);
outputStream.Seek(0, SeekOrigin.End);

if (_fileSizeLimitBytes != null)
@@ -99,6 +111,7 @@ internal FileSink(
{
outputStream = hooks.OnFileOpened(path, outputStream, encoding) ??
throw new InvalidOperationException($"The file lifecycle hook `{nameof(FileLifecycleHooks.OnFileOpened)}(...)` returned `null`.");

}
catch
{
125 changes: 114 additions & 11 deletions src/Serilog.Sinks.File/Sinks/File/PathRoller.cs
Original file line number Diff line number Diff line change
@@ -26,15 +26,29 @@ sealed class PathRoller
readonly string _filenamePrefix;
readonly string _filenameSuffix;
readonly Regex _filenameMatcher;
readonly bool _keepPathStatic;
readonly string? _customRollPattern;
private Func<DateTime?, string>? _customFormatFunc;

readonly RollingInterval _interval;
readonly string _periodFormat;

public PathRoller(string path, RollingInterval interval)
public PathRoller(string path, RollingInterval interval, bool keepPathStatic = false, Func<DateTime?,string>? customFormatFunc = null,
string? customRollPattern = null)
{
if (path == null) throw new ArgumentNullException(nameof(path));
_interval = interval;
_periodFormat = interval.GetFormat();
_keepPathStatic = keepPathStatic;
_customRollPattern = customRollPattern;
_customFormatFunc = customFormatFunc;

if (_customRollPattern != null && _customFormatFunc != null)
{
ValidateCustomRollPattern(_customRollPattern);
ValidateCustomFormatFuncMatchesPattern(customFormatFunc, _customRollPattern);
}



var pathDirectory = Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(pathDirectory))
@@ -43,30 +57,107 @@ public PathRoller(string path, RollingInterval interval)
_directory = Path.GetFullPath(pathDirectory);
_filenamePrefix = Path.GetFileNameWithoutExtension(path);
_filenameSuffix = Path.GetExtension(path);
_filenameMatcher = new Regex(
if (_customRollPattern == null)
{
_periodFormat = interval.GetFormat();
_filenameMatcher = new Regex(
"^" +
Regex.Escape(_filenamePrefix) +
"(?<" + PeriodMatchGroup + ">\\d{" + _periodFormat.Length + "})" +
"(?<" + SequenceNumberMatchGroup + ">_[0-9]{3,}){0,1}" +
Regex.Escape(_filenameSuffix) +
"$",
RegexOptions.Compiled);
}
else
{
_periodFormat = _customRollPattern;
_filenameMatcher = new Regex(
"^" +
Regex.Escape(_filenamePrefix) +
"(?<" + PeriodMatchGroup + ">\\d{" + _periodFormat.Length + "})" +
"(?<" + SequenceNumberMatchGroup + ">_[0-9]{3,}){0,1}" +
Regex.Escape(_filenameSuffix) +
"$",
Regex.Escape(_filenamePrefix) +
"(?<" + PeriodMatchGroup + ">" + _customRollPattern + ")" +
"(?<" + SequenceNumberMatchGroup + ">_[0-9]{3,}){0,1}" +
Regex.Escape(_filenameSuffix) +
"$",
RegexOptions.Compiled);
}

DirectorySearchPattern = $"{_filenamePrefix}*{_filenameSuffix}";
}

private void ValidateCustomFormatFuncMatchesPattern(Func<DateTime?, string>? customFormatFunc, string customRollPattern)
{
var temp = customFormatFunc?.Invoke(DateTime.Now);
if (temp == null)
{
throw new ArgumentException("Custom format function did not return a value.", nameof(customFormatFunc));
}

if (!Regex.IsMatch(temp, customRollPattern))
{
throw new ArgumentException($"Custom format function does not match the custom roll pattern of {customRollPattern}.", nameof(customFormatFunc));
}

}

public string LogFileDirectory => _directory;

public string DirectorySearchPattern { get; }

public void GetLogFilePath(DateTime date, int? sequenceNumber, out string path)
public void GetLogFilePath(DateTime date, int? sequenceNumber, out string path, out string? copyPath)
{
var currentCheckpoint = GetCurrentCheckpoint(date);

var tok = currentCheckpoint?.ToString(_periodFormat, CultureInfo.InvariantCulture) ?? "";
var tok = GetToken(sequenceNumber, currentCheckpoint);

if (_keepPathStatic)
{
path = Path.Combine(_directory, _filenamePrefix + _filenameSuffix);

copyPath = Path.Combine(_directory, _filenamePrefix + tok + _filenameSuffix);
return;
}

copyPath = null;
GetLogFilePath(date, sequenceNumber, out path);
}

private string GetToken(int? sequenceNumber, DateTime? currentCheckpoint)
{
var tok = string.Empty;

if (sequenceNumber != null)
if (_customFormatFunc == null)
{
tok = currentCheckpoint?.ToString(_periodFormat, CultureInfo.InvariantCulture) ?? "";
}
else if( _customFormatFunc != null && sequenceNumber == null && currentCheckpoint != null)
{
tok = _customFormatFunc.Invoke(currentCheckpoint);
}
else if( _customFormatFunc != null && sequenceNumber != null && currentCheckpoint == null)
{
tok = _customFormatFunc.Invoke(DateTime.Now);
}
else if (_customFormatFunc != null && sequenceNumber == null && currentCheckpoint == null)
{
return string.Empty;
}

if (sequenceNumber == null) return tok;
var path = Path.Combine(_directory, _filenamePrefix + tok + _filenameSuffix);
if (System.IO.File.Exists(path))
{
tok += "_" + sequenceNumber.Value.ToString("000", CultureInfo.InvariantCulture);
}

return tok;
}

public void GetLogFilePath(DateTime date, int? sequenceNumber, out string path)
{
var currentCheckpoint = GetCurrentCheckpoint(date);

var tok = GetToken(sequenceNumber, currentCheckpoint);

path = Path.Combine(_directory, _filenamePrefix + tok + _filenameSuffix);
}
@@ -110,4 +201,16 @@ public IEnumerable<RollingLogFile> SelectMatches(IEnumerable<string> filenames)
public DateTime? GetCurrentCheckpoint(DateTime instant) => _interval.GetCurrentCheckpoint(instant);

public DateTime? GetNextCheckpoint(DateTime instant) => _interval.GetNextCheckpoint(instant);

private void ValidateCustomRollPattern(string pattern)
{
try
{
_ = new Regex(pattern);
}
catch (ArgumentException)
{
throw new ArgumentException("The custom roll pattern is not a valid regex pattern.", nameof(pattern));
}
}
}
44 changes: 40 additions & 4 deletions src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.

using System.Globalization;
using System.Text;
using Serilog.Core;
using Serilog.Debugging;
@@ -31,7 +32,9 @@ sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable, I
readonly bool _buffered;
readonly bool _shared;
readonly bool _rollOnFileSizeLimit;
readonly bool _keepPathAsStaticFile;
readonly FileLifecycleHooks? _hooks;
readonly Func<DateTime?, string>? _customFormatFunc;

ILoggingFailureListener _failureListener = SelfLog.FailureListener;

@@ -51,14 +54,18 @@ public RollingFileSink(string path,
RollingInterval rollingInterval,
bool rollOnFileSizeLimit,
FileLifecycleHooks? hooks,
TimeSpan? retainedFileTimeLimit)
TimeSpan? retainedFileTimeLimit,
bool keepPathAsStaticFile,
Func<DateTime?, string>? customFormatFunc,
string? customRollPattern)
{
if (path == null) throw new ArgumentNullException(nameof(path));
if (fileSizeLimitBytes is < 1) throw new ArgumentException("Invalid value provided; file size limit must be at least 1 byte, or null.");
if (retainedFileCountLimit is < 1) throw new ArgumentException("Zero or negative value provided; retained file count limit must be at least 1.");
if (retainedFileTimeLimit.HasValue && retainedFileTimeLimit < TimeSpan.Zero) throw new ArgumentException("Negative value provided; retained file time limit must be non-negative.", nameof(retainedFileTimeLimit));
if(customFormatFunc != null && customRollPattern == null) throw new ArgumentException("When Supplying a Custom Format Function, a Custom Roll Pattern must also be supplied.");

_roller = new PathRoller(path, rollingInterval);
_roller = new PathRoller(path, rollingInterval, keepPathAsStaticFile, customFormatFunc, customRollPattern);
_textFormatter = textFormatter;
_fileSizeLimitBytes = fileSizeLimitBytes;
_retainedFileCountLimit = retainedFileCountLimit;
@@ -68,6 +75,8 @@ public RollingFileSink(string path,
_shared = shared;
_rollOnFileSizeLimit = rollOnFileSizeLimit;
_hooks = hooks;
_keepPathAsStaticFile = keepPathAsStaticFile;
_customFormatFunc = customFormatFunc;
}

public void Emit(LogEvent logEvent)
@@ -163,10 +172,37 @@ void OpenFile(DateTime now, int? minSequence = null)
sequence = minSequence;
}

if (sequence != null)
{
_roller.GetLogFilePath(now, sequence, out var p, out var c);
switch (_keepPathAsStaticFile)
{
// var path = Path.Combine(_roller.LogFileDirectory, $"{_roller.PathRollerPrefix}{_customFormatFunc?.Invoke()}_{sequence.Value.ToString("000", CultureInfo.InvariantCulture)}{_roller.DirectorySearchPattern}");
case true when System.IO.File.Exists(c):
sequence++;
break;
case false when _customFormatFunc != null && !System.IO.File.Exists(p):
sequence = 1;
break;
}
}

const int maxAttempts = 3;
for (var attempt = 0; attempt < maxAttempts; attempt++)
{
_roller.GetLogFilePath(now, sequence, out var path);
string path;
if (_keepPathAsStaticFile)
{
_roller.GetLogFilePath(now, sequence, out path, out var copyPath);
if (copyPath != null && System.IO.File.Exists(path))
{
System.IO.File.Move(path, copyPath);
}
}
else
{
_roller.GetLogFilePath(now, sequence, out path);
}

try
{
@@ -176,7 +212,7 @@ void OpenFile(DateTime now, int? minSequence = null)
new SharedFileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding)
:
#pragma warning restore 618
new FileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding, _buffered, _hooks);
new FileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding, _buffered, _hooks, _keepPathAsStaticFile);

_currentFileSequence = sequence;

170 changes: 165 additions & 5 deletions test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.IO.Compression;
using System.Text.RegularExpressions;
using Xunit;
using Serilog.Events;
using Serilog.Sinks.File.Tests.Support;
@@ -293,6 +294,163 @@ public void WhenSizeLimitIsBreachedNewFilesCreated()
Assert.True(files[2].EndsWith("_002.txt"), files[2]);
}

[Fact]
public void WhenCustomRollingEnabledItUsesThePattern()
{
string CustomStringOut(DateTime? time)
{
return $"_{time??DateTime.Now:yyyy-MM-dd-HH-mm-ss}";
}

var regexPattern = @"_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}";
var fileName = Some.String() + ".txt";
using var temp = new TempFolder();
using var log = new LoggerConfiguration()
.WriteTo.File(Path.Combine(temp.Path, fileName), rollOnFileSizeLimit: true,
fileSizeLimitBytes: 40, customFormatFunc: CustomStringOut,
customRollPattern: regexPattern)
.CreateLogger();
LogEvent e1 = Some.InformationEvent(),
e2 = Some.InformationEvent(e1.Timestamp),
e3 = Some.InformationEvent(e1.Timestamp),
e4 = Some.InformationEvent(e1.Timestamp),
e5 = Some.InformationEvent(e1.Timestamp);
log.Write(e1);
log.Write(e2);
log.Write(e3);
log.Write(e4);
log.Write(e5);
var files = Directory.GetFiles(temp.Path)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToArray();

Assert.Equal(5, files.Length);

Assert.True(files[0].EndsWith(fileName), files[0]);
foreach (var file in files.Skip(1))
{
Assert.True(Regex.IsMatch(file, regexPattern), file);
}
}

[Fact]
public void WhenCustomRollingEnabledAndTimeIntervalItUsesThePattern()
{
string CustomStringOut(DateTime? time = null)
{
var t = time ?? DateTime.Now;
return $"_{t:yyyy-MM-dd-HH-mm-ss}";
}

var regexPattern = @"_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}";
var fileName = Some.String() + ".txt";
using var temp = new TempFolder();
LogEvent e1 = Some.InformationEvent(),
e2 = Some.InformationEvent(e1.Timestamp.AddDays(2)),
e3 = Some.InformationEvent(e2.Timestamp.AddDays(2));
TestRollingEventSequence((pf, wt) => wt.File(pf,
rollingInterval: RollingInterval.Minute,
customFormatFunc: CustomStringOut,
customRollPattern: regexPattern),
new[] { e1, e2, e3 },
files =>
{
Assert.Equal(3, files.Count);
Assert.True(System.IO.File.Exists(files[0]));
Assert.True(System.IO.File.Exists(files[1]));
Assert.True(System.IO.File.Exists(files[2]));
foreach (var file in files.Skip(1))
{
Assert.True(Regex.IsMatch(file, regexPattern), file);
}
},
fileNameOverride: fileName,
patternOverride:"_yyyy-MM-dd-HH-mm-ss");
}

[Fact]
public void WhenStaticEnabledPathBasePathIsLastUpdated()
{
var fileName = Some.String() + ".txt";
using var temp = new TempFolder();
using var log = new LoggerConfiguration()
.WriteTo.File(Path.Combine(temp.Path, fileName), rollOnFileSizeLimit: true,
fileSizeLimitBytes: 40, keepPathStaticOnRoll: true)
.CreateLogger();
LogEvent e1 = Some.InformationEvent(),
e2 = Some.InformationEvent(e1.Timestamp),
e3 = Some.InformationEvent(e1.Timestamp),
e4 = Some.InformationEvent(e1.Timestamp),
e5 = Some.InformationEvent(e1.Timestamp);
log.Write(e1);
//Sleep here so that LastModifiedTime of the file can be different
Thread.Sleep(1000);
log.Write(e2);
Thread.Sleep(1000);
log.Write(e3);
var files = Directory.GetFiles(temp.Path)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToArray();

Assert.Equal(3, files.Length);

Assert.True(files[0].EndsWith(fileName), files[0]);
Assert.True(files[1].EndsWith("_001.txt"), files[1]);
Assert.True(files[2].EndsWith("_002.txt"), files[2]);
var lastModifiedFile = files
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.LastWriteTime)
.First();
// Using the full path can result in failures with a space in the User folder name
Assert.Equal(Path.GetFileName(files[0]), Path.GetFileName(lastModifiedFile.FullName));
}

[Fact]
public void WhenStaticEnabledAndCustomRollPathBasePathIsLastUpdated()
{
string CustomStringOut(DateTime? time = null)
{
return $"_{time??DateTime.Now:yyyy-MM-dd-HH-mm-ss}";
}

var regexPattern = @"_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}";
var fileName = Some.String() + ".txt";
using var temp = new TempFolder();
using var log = new LoggerConfiguration()
.WriteTo.File(Path.Combine(temp.Path, fileName),
rollOnFileSizeLimit: true,
fileSizeLimitBytes: 40,
customFormatFunc: CustomStringOut,
customRollPattern: regexPattern,
keepPathStaticOnRoll: true)
.CreateLogger();
LogEvent e1 = Some.InformationEvent(),
e2 = Some.InformationEvent(e1.Timestamp),
e3 = Some.InformationEvent(e1.Timestamp);
log.Write(e1);
Thread.Sleep(1000);
log.Write(e2);
Thread.Sleep(1001);
log.Write(e3);
var files = Directory.GetFiles(temp.Path)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.ToArray();

Assert.Equal(3, files.Length);

Assert.True(files[0].EndsWith(fileName), files[0]);
foreach (var file in files.Skip(1))
{
Assert.True(Regex.IsMatch(file, regexPattern), file);
}
var lastModifiedFile = files
.Select(f => new FileInfo(f))
.OrderByDescending(f => f.LastWriteTime)
.First();
// Using the full path can result in failures with a space in the User folder name
Assert.Equal(Path.GetFileName(files[0]), Path.GetFileName(lastModifiedFile.FullName));
}

[Fact]
public void WhenStreamWrapperSpecifiedIsUsedForRolledFiles()
{
@@ -384,11 +542,14 @@ static void TestRollingEventSequence(params LogEvent[] events)
static void TestRollingEventSequence(
Action<string, LoggerSinkConfiguration> configureFile,
IEnumerable<LogEvent> events,
Action<IList<string>>? verifyWritten = null)
Action<IList<string>>? verifyWritten = null,
string? fileNameOverride = null,
string? patternOverride = null)
{
var fileName = Some.String() + "-.txt";
var folder = Some.TempFolderPath();
var fileName = fileNameOverride ?? Some.String() + "-.txt";
var folder = new TempFolder().Path;
var pathFormat = Path.Combine(folder, fileName);
var pattern = patternOverride ?? "yyyyMMdd";

var config = new LoggerConfiguration();
configureFile(pathFormat, config.WriteTo);
@@ -402,8 +563,7 @@ static void TestRollingEventSequence(
{
Clock.SetTestDateTimeNow(@event.Timestamp.DateTime);
log.Write(@event);

var expected = ExpectedFileName(pathFormat, @event.Timestamp, "yyyyMMdd");
var expected = ExpectedFileName(pathFormat, @event.Timestamp, pattern);
Assert.True(System.IO.File.Exists(expected));

verified.Add(expected);