diff --git a/src/Nimble.Extensions/Logging/File/FileLogRollingMethod.cs b/src/Nimble.Extensions/Logging/File/FileLogRollingMethod.cs
new file mode 100644
index 0000000..ccdbe69
--- /dev/null
+++ b/src/Nimble.Extensions/Logging/File/FileLogRollingMethod.cs
@@ -0,0 +1,32 @@
+namespace Nimble.Extensions.Logging.File;
+
+///
+/// This determines the type of rolling for file logging.
+///
+public enum FileLogRollingMethod
+{
+ ///
+ /// No file rolling is performed.
+ ///
+ None = 0,
+
+ ///
+ /// File rolling is performed Daily.
+ ///
+ Daily = 1,
+
+ ///
+ /// File rolling is performed Weekly.
+ ///
+ Weekly = 2,
+
+ ///
+ /// File rolling is performed Monthly.
+ ///
+ Monthly = 3,
+
+ ///
+ /// File rolling is performed Yearly.
+ ///
+ Yearly = 4
+}
diff --git a/src/Nimble.Extensions/Logging/File/FileLogger.cs b/src/Nimble.Extensions/Logging/File/FileLogger.cs
new file mode 100644
index 0000000..18d2639
--- /dev/null
+++ b/src/Nimble.Extensions/Logging/File/FileLogger.cs
@@ -0,0 +1,63 @@
+namespace Nimble.Extensions.Logging.File;
+
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+internal class FileLogger : ILogger
+{
+ private readonly string categoryName;
+ private readonly IOptions _options;
+ private static readonly Lock _lock = new();
+
+ public FileLogger(string categoryName, IOptions options)
+ {
+ _options = options;
+ this.categoryName = categoryName;
+
+ // Create the log folder, this optionally includes the datestamped rolling
+ // log folders when rolling logging are enabled.
+ options.Value.GetFilter(this.categoryName)?.CheckFileRolling();
+ }
+
+ internal IExternalScopeProvider? ScopeProvider { get; set; }
+
+ public bool IsEnabled(LogLevel logLevel)
+ {
+ var filter = _options.Value.GetFilter(categoryName);
+ return filter != null && logLevel >= filter.Value.MinLevel;
+ }
+
+ public void Log(LogLevel logLevel, EventId eventId,
+ TState state, Exception? exception, Func formatter)
+ {
+ if (!IsEnabled(logLevel))
+ {
+ return;
+ }
+
+ if (formatter != null)
+ {
+ if (_lock.TryEnter(Timeout.InfiniteTimeSpan))
+ {
+ // We know for sure that if IsEnabled returns true, then filter is not null,
+ // but we need to get the filter again to get the file path.
+ var filter = _options.Value.GetFilter(categoryName);
+ var message = $"[{categoryName}] {logLevel}: {DateTime.UtcNow} {formatter(state, exception)}{Environment.NewLine}";
+
+ // if file rolling is enabled this will update the file path in the filter to the new log file.
+ filter!.Value.CheckFileRolling();
+ try
+ {
+ System.IO.File.AppendAllText(filter!.Value.FilePath, message);
+ }
+ finally
+ {
+ _lock.Exit();
+ }
+ }
+ }
+ }
+
+ public IDisposable? BeginScope(TState state) where TState : notnull
+ => ScopeProvider?.Push(state);
+}
diff --git a/src/Nimble.Extensions/Logging/File/FileLoggerOptions.cs b/src/Nimble.Extensions/Logging/File/FileLoggerOptions.cs
new file mode 100644
index 0000000..784e713
--- /dev/null
+++ b/src/Nimble.Extensions/Logging/File/FileLoggerOptions.cs
@@ -0,0 +1,69 @@
+namespace Nimble.Extensions.Logging.File;
+
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using Microsoft.Extensions.Logging;
+
+///
+/// The options for the FileLogger.
+///
+[DebuggerDisplay("{DebuggerToString(),nq}")]
+public sealed class FileLoggerOptions
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public FileLoggerOptions()
+ {
+ }
+
+ private ConcurrentDictionary Filters { get; } = new();
+
+ ///
+ /// Adds a new logging filter to this options instance.
+ ///
+ /// The category for the filter.
+ /// The file path (including file name) for the log file to output to.
+ /// The minimum level of log messages for the filter.
+ /// To enable capturing of logging scopes in the filter.
+ /// The same options so that multiple calls can be chained.
+ public FileLoggerOptions AddFilter(
+ string categoryName,
+ string filePath,
+ LogLevel minLevel = LogLevel.Debug,
+ bool captureScopes = true,
+ FileLogRollingMethod rollingMethod = FileLogRollingMethod.None)
+ {
+ Filters.TryAdd(categoryName, new FileLoggerOptionsFilter
+ {
+ CaptureScopes = captureScopes,
+ MinLevel = minLevel,
+ FilePath = filePath,
+ RollingMethod = rollingMethod,
+ });
+ return this;
+ }
+
+ // TODO: Add the ability to configure the formatter in this Options instance for the file logger.
+ public FileLoggerOptions AddFormatter()
+ {
+ return this;
+ }
+
+ internal FileLoggerOptionsFilter? GetFilter(string categoryName)
+ {
+ foreach (var filter in Filters)
+ {
+ if (categoryName.StartsWith(filter.Key, StringComparison.Ordinal))
+ {
+ return filter.Value;
+ }
+ }
+
+ // in the code that uses this method, if the return value from this is null, then it will be logged anyways.
+ return null;
+ }
+
+ internal string DebuggerToString()
+ => string.Join(", ", Filters.Values.Select(static flof => flof.DebuggerToString()));
+}
diff --git a/src/Nimble.Extensions/Logging/File/FileLoggerOptionsFilter.cs b/src/Nimble.Extensions/Logging/File/FileLoggerOptionsFilter.cs
new file mode 100644
index 0000000..adfff47
--- /dev/null
+++ b/src/Nimble.Extensions/Logging/File/FileLoggerOptionsFilter.cs
@@ -0,0 +1,79 @@
+namespace Nimble.Extensions.Logging.File;
+
+using Microsoft.Extensions.Logging;
+
+internal struct FileLoggerOptionsFilter
+{
+ public FileLoggerOptionsFilter()
+ {
+ }
+
+ public required bool CaptureScopes { get; set; } = true;
+
+ public required LogLevel MinLevel { get; set; }
+
+ public required string FilePath { get; set; } = string.Empty;
+
+ public required FileLogRollingMethod RollingMethod { get; set; } = FileLogRollingMethod.None;
+
+ private string? OriginalLogFolderName { get; set; }
+
+ private DateOnly? CurrentRollingDate { get; set; }
+
+ internal readonly string DebuggerToString()
+ => $"CaptureScopes = {CaptureScopes}, {(
+ MinLevel != LogLevel.None ? $"MinLevel = {MinLevel}" : "Enabled = false")}";
+
+ internal void CheckFileRolling()
+ {
+ OriginalLogFolderName ??= Path.GetDirectoryName(FilePath) ?? string.Empty;
+ var directoryName = OriginalLogFolderName;
+ var fileName = Path.GetFileName(FilePath) ?? string.Empty;
+ if (RollingMethod is not FileLogRollingMethod.None)
+ {
+ var currentDate = DateOnly.FromDateTime(DateTime.UtcNow);
+
+ // if null or when the current date is greater than or equal to the current rolling date.
+ if (CurrentRollingDate is null || currentDate >= CurrentRollingDate)
+ {
+ CurrentRollingDate = RollingMethod switch
+ {
+ FileLogRollingMethod.Daily => currentDate.AddDays(1),
+ FileLogRollingMethod.Weekly => currentDate.AddDays(7),
+ FileLogRollingMethod.Monthly => currentDate.AddMonths(1),
+ FileLogRollingMethod.Yearly => currentDate.AddYears(1),
+ _ => throw new InvalidOperationException("bug by design.")
+ };
+ }
+
+ // This should ideally compare the current date with a private stored date so that way there
+ // are no attempts to roll the file early in the case of the Monthly/Yearly file rolling
+ // options being used.
+ directoryName = RollingMethod switch
+ {
+ FileLogRollingMethod.Daily or FileLogRollingMethod.Weekly => Path.Combine(OriginalLogFolderName, currentDate.ToString("yyyy-MM-dd")),
+ FileLogRollingMethod.Monthly => Path.Combine(OriginalLogFolderName, currentDate.ToString("yyyy-MM")),
+ FileLogRollingMethod.Yearly => Path.Combine(OriginalLogFolderName, currentDate.ToString("yyyy")),
+ _ => throw new InvalidOperationException("bug by design.")
+ };
+ }
+
+ CreateDirectory(directoryName);
+ FilePath = Path.Combine(directoryName, fileName);
+ }
+
+ private static void CreateDirectory(string namePath)
+ {
+ if (!string.IsNullOrEmpty(namePath))
+ {
+ // If the directory containing the target file name to write to does not exist; create it.
+ try
+ {
+ Directory.CreateDirectory(namePath);
+ }
+ catch
+ {
+ }
+ }
+ }
+}
diff --git a/src/Nimble.Extensions/Logging/File/FileLoggerProvider.cs b/src/Nimble.Extensions/Logging/File/FileLoggerProvider.cs
new file mode 100644
index 0000000..84902f8
--- /dev/null
+++ b/src/Nimble.Extensions/Logging/File/FileLoggerProvider.cs
@@ -0,0 +1,31 @@
+namespace Nimble.Extensions.Logging.File;
+
+using System.Collections.Concurrent;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+internal class FileLoggerProvider : ILoggerProvider, ISupportExternalScope
+{
+ private readonly IOptions _options;
+ private readonly ConcurrentDictionary _loggers = new();
+ private IExternalScopeProvider _scopeProvider = null!;
+
+ public FileLoggerProvider(IOptions options)
+ => _options = options;
+
+ public ILogger CreateLogger(string name)
+ => _loggers.GetOrAdd(name, n => new FileLogger(n, _options));
+
+ public void SetScopeProvider(IExternalScopeProvider scopeProvider)
+ {
+ _scopeProvider = scopeProvider;
+ foreach (var logger in _loggers)
+ {
+ logger.Value.ScopeProvider = _scopeProvider;
+ }
+ }
+
+ public void Dispose()
+ {
+ }
+}
diff --git a/src/Nimble.Extensions/Logging/FormatterExtensions.cs b/src/Nimble.Extensions/Logging/FormatterExtensions.cs
index a51c0e2..9823ff8 100644
--- a/src/Nimble.Extensions/Logging/FormatterExtensions.cs
+++ b/src/Nimble.Extensions/Logging/FormatterExtensions.cs
@@ -1,6 +1,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Nimble.Extensions.Logging.Console;
+using Nimble.Extensions.Logging.File;
using System.ComponentModel;
namespace Nimble.Extensions.Logging;
@@ -12,16 +13,12 @@ namespace Nimble.Extensions.Logging;
public static class FormatterExtensions
{
public static PrettierFormatterOptions ResetColors(this PrettierFormatterOptions options)
- {
- options.LogLevelColors[LogLevel.Trace] = ConsoleColor.Gray;
- options.LogLevelColors[LogLevel.Debug] = ConsoleColor.Cyan;
- options.LogLevelColors[LogLevel.Information] = ConsoleColor.Green;
- options.LogLevelColors[LogLevel.Warning] = ConsoleColor.Yellow;
- options.LogLevelColors[LogLevel.Error] = ConsoleColor.Red;
- options.LogLevelColors[LogLevel.Critical] = ConsoleColor.Magenta;
-
- return options;
- }
+ => options.SetColor(LogLevel.Trace, ConsoleColor.Gray)
+ .SetColor(LogLevel.Debug, ConsoleColor.Cyan)
+ .SetColor(LogLevel.Information, ConsoleColor.Green)
+ .SetColor(LogLevel.Warning, ConsoleColor.Yellow)
+ .SetColor(LogLevel.Error, ConsoleColor.Red)
+ .SetColor(LogLevel.Critical, ConsoleColor.Magenta);
public static PrettierFormatterOptions SetColor(this PrettierFormatterOptions options, LogLevel level, ConsoleColor color)
{
@@ -42,12 +39,44 @@ public static ILoggingBuilder AddPrettierConsole(this ILoggingBuilder builder)
public static ILoggingBuilder AddPrettierConsole(this ILoggingBuilder builder, Action configure)
{
ArgumentNullException.ThrowIfNull(configure);
+ builder.AddConsoleFormatter()
+ .AddConsole(static (options) => options.FormatterName = nameof(PrettierFormatter));
+ builder.Services.AddSingleton()
+ .Configure(configure);
+ return builder;
+ }
+
+ ///
+ /// Adds a file logging provider to the .
+ ///
+ /// The to add the provider to.
+ /// The action used to configure the logger.
+ /// If the existing providers added to the should be removed.
+ /// The so that additional calls can be chained.
+ public static ILoggingBuilder AddFile(this ILoggingBuilder builder, Action configureOptions, bool clearExistingProviders = false)
+ => AddFileLoggerProvider(builder, configureOptions, clearExistingProviders);
+
+ ///
+ /// Adds a file logging provider to the .
+ ///
+ /// The to add the provider to.
+ /// The action used to configure the logger.
+ /// If the existing providers added to the should be removed.
+ /// The so that additional calls can be chained.
+ public static ILoggingBuilder AddFileLoggerProvider(this ILoggingBuilder builder, Action configureOptions, bool clearExistingProviders = false)
+ {
+ if (clearExistingProviders)
+ {
+ builder.ClearProviders();
+ }
- builder.AddConsoleFormatter();
- builder.AddConsole((options) => options.FormatterName = nameof(PrettierFormatter));
- builder.Services.AddSingleton();
- builder.Services.Configure(configure);
+ builder.Services
+ .Configure(configureOptions)
+ .AddSingleton();
+ // here in my own original code I would register a "static logger" to use for general
+ // logs in my discord bot to possibly find bugs/issues in my code, but left it out here
+ // as not everyone happens to need said "logger".
return builder;
}
@@ -55,7 +84,8 @@ public static ILoggingBuilder AddConsoleListener(this ILoggingBuilder builder, A
{
ArgumentNullException.ThrowIfNull(configure);
- builder.Services.AddOptionsWithValidateOnStart()
+ builder.Services
+ .AddOptionsWithValidateOnStart()
.Configure(configure);
builder.Services.AddHostedService();
diff --git a/src/Nimble.Tests.Extensions/Program.cs b/src/Nimble.Tests.Extensions/Program.cs
index 63e7a45..1f7e335 100644
--- a/src/Nimble.Tests.Extensions/Program.cs
+++ b/src/Nimble.Tests.Extensions/Program.cs
@@ -7,13 +7,13 @@
builder.Logging
.SetMinimumLevel(LogLevel.Trace)
- .AddPrettierConsole(configure =>
+ .AddPrettierConsole(static configure =>
{
configure.MaxLogWidth = 120;
configure.TimestampColor = ConsoleColor.DarkCyan;
configure.SpecialCategoryPrefix = "Nimble";
})
- .AddConsoleListener(configure =>
+ .AddConsoleListener(static configure =>
{
configure.CreateScopes = true;
configure.OnReadlineCompleted = HandleCommand;
@@ -37,4 +37,4 @@ static void HandleCommand(string input, IServiceProvider srv, CancellationToken
}
else
logger.LogWarning("Unrecognized command: {Input}", input);
-}
\ No newline at end of file
+}