diff --git a/.gitignore b/.gitignore index 8f843cc..61d4635 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ [Oo]bj/ .artifacts +# Test coverage output (e.g. dotnet test --results-directory ./coverage) +[Cc]overage/ +TestResults/ + .idea .vs diff --git a/Directory.Packages.props b/Directory.Packages.props index 860514d..330a748 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,9 +1,12 @@ + + + diff --git a/HISTORY.md b/HISTORY.md index cd44a6c..6131598 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -4,6 +4,14 @@ Some useful and not so useful C# .NET utility classes. ## Release History +- v3.7: + - Replaced the Serilog-coupled logging model with the backend-agnostic `Microsoft.Extensions.Logging` abstraction, matching the sibling `LanguageTags` project. + - Removed the global Serilog `LogOptions.Logger` property (a breaking API change) in favor of a thread-safe, injectable `ILoggerFactory` configured via `LogOptions.SetFactory(...)` / `TrySetFactory(...)`; the library now depends only on `Microsoft.Extensions.Logging.Abstractions`. + - Reworked `FileEx` and `Download` to resolve per-class cached loggers through `LogOptions.CreateLogger(...)` and to emit source-generated `[LoggerMessage]` messages, keeping the build clean under `AnalysisMode=All` and `TreatWarningsAsErrors`. + - Moved the `LogAndHandle` / `LogAndPropagate` helpers onto `Microsoft.Extensions.Logging.ILogger` as internal extensions (exposed to tests via `InternalsVisibleTo`). + - Renamed the public `Extensions` class to `CompressExtensions` (a breaking API change for direct references; instance-style extension calls such as `value.Compress()` are unaffected), resolving the naming clash with the `Microsoft.Extensions` namespace. + - Updated the `Sandbox` example to configure a Serilog console logger and inject it through a `SerilogLoggerFactory`, borrowing the pattern from `LanguageTagsCreate`. + - Dropped the library's Serilog dependency and its `IL3058` AOT warning suppression. - v3.6: - Reworked the CI/CD pipeline to the branch-scoped self-publishing model: `main` publishes stable releases and `develop` publishes prereleases, each branch publishing itself when a shipped input changes. - Switched NuGet publishing to keyless OIDC trusted publishing, removing the `NUGET_API_KEY` secret. diff --git a/README.md b/README.md index a4d710e..0f28263 100644 --- a/README.md +++ b/README.md @@ -21,14 +21,13 @@ Some useful and not so useful C# .NET utility classes. ### Release Notes -**Version: 3.6**: +**Version: 3.7**: **Summary**: -- Internal CI/CD rework, no library API changes. - - Branch-scoped self-publishing workflows. - - Keyless OIDC NuGet publishing. - - Hardened repository configuration, see [WORKFLOW.md](./WORKFLOW.md). +- Adopted an injectable `Microsoft.Extensions.Logging` logging model. + - Configure logging with `LogOptions.SetFactory(ILoggerFactory)` instead of the removed Serilog-typed `LogOptions.Logger` property. + - The library now depends on `Microsoft.Extensions.Logging.Abstractions` and is backend-agnostic; the `Sandbox` shows Serilog console wiring. See [Release History](./HISTORY.md) for complete release notes and older versions. diff --git a/Sandbox/LoggerFactory.cs b/Sandbox/LoggerFactory.cs new file mode 100644 index 0000000..7bebd64 --- /dev/null +++ b/Sandbox/LoggerFactory.cs @@ -0,0 +1,44 @@ +using System.Globalization; +using Serilog; +using Serilog.Extensions.Logging; +using Serilog.Sinks.SystemConsole.Themes; + +namespace InsaneGenius.Utilities.Sandbox; + +/// +/// Configures a Serilog console logger and exposes it as a +/// for the library. +/// +internal static class LoggerFactory +{ + private static readonly Lazy s_serilogLoggerFactory = new(() => + { + // Use the already configured Log.Logger if set, else create a new logger. + ILogger logger = ReferenceEquals(Log.Logger, Serilog.Core.Logger.None) + ? Create() + : Log.Logger; + bool disposeLogger = !ReferenceEquals(logger, Log.Logger); + return new SerilogLoggerFactory(logger, dispose: disposeLogger); + }); + + /// + /// Creates a Serilog console logger. + /// + /// The configured Serilog logger. + internal static ILogger Create() => + new LoggerConfiguration() + .MinimumLevel.Information() + .WriteTo.Console( + theme: AnsiConsoleTheme.Code, + formatProvider: CultureInfo.InvariantCulture, + outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" + ) + .CreateLogger(); + + /// + /// Gets the logger factory wrapping the configured Serilog logger. + /// + /// The logger factory. + internal static Microsoft.Extensions.Logging.ILoggerFactory CreateLoggerFactory() => + s_serilogLoggerFactory.Value; +} diff --git a/Sandbox/Program.cs b/Sandbox/Program.cs index a18b2f6..5d32cf1 100644 --- a/Sandbox/Program.cs +++ b/Sandbox/Program.cs @@ -1,11 +1,24 @@ using System.Diagnostics; using System.Reflection; +using InsaneGenius.Utilities; +using InsaneGenius.Utilities.Sandbox; using Serilog; -// Get the assembly directory -Assembly? entryAssembly = Assembly.GetEntryAssembly(); -Debug.Assert(entryAssembly != null); -string? assemblyDirectory = Path.GetDirectoryName(AppContext.BaseDirectory); -Debug.Assert(assemblyDirectory != null); -string projectDirectory = Path.GetFullPath(Path.Combine(assemblyDirectory, "../../../../")); -Log.Logger.Information("Project directory: {ProjectDirectory}", projectDirectory); +// Configure logging: build a Serilog console logger and inject it into the library. +Log.Logger = LoggerFactory.Create(); +LogOptions.SetFactory(LoggerFactory.CreateLoggerFactory()); + +try +{ + // Get the assembly directory + Assembly? entryAssembly = Assembly.GetEntryAssembly(); + Debug.Assert(entryAssembly != null); + string? assemblyDirectory = Path.GetDirectoryName(AppContext.BaseDirectory); + Debug.Assert(assemblyDirectory != null); + string projectDirectory = Path.GetFullPath(Path.Combine(assemblyDirectory, "../../../../")); + Log.Logger.Information("Project directory: {ProjectDirectory}", projectDirectory); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/Sandbox/Sandbox.csproj b/Sandbox/Sandbox.csproj index 376f948..db9ae56 100644 --- a/Sandbox/Sandbox.csproj +++ b/Sandbox/Sandbox.csproj @@ -1,6 +1,7 @@ Exe + InsaneGenius.Utilities.Sandbox true false true @@ -10,6 +11,7 @@ + diff --git a/Utilities/Download.cs b/Utilities/Download.cs index 9489733..b910702 100644 --- a/Utilities/Download.cs +++ b/Utilities/Download.cs @@ -1,5 +1,6 @@ using System.Net.Http.Headers; using System.Reflection; +using Microsoft.Extensions.Logging; namespace InsaneGenius.Utilities; @@ -9,6 +10,11 @@ namespace InsaneGenius.Utilities; public static class Download { private static readonly Lazy s_httpClient = new(CreateHttpClient); + private static readonly Lazy s_logger = new(() => + LogOptions.CreateLogger(typeof(Download).FullName!) + ); + + private static ILogger Log => s_logger.Value; /// /// Gets or sets the HTTP client timeout in seconds. Default is 180 seconds. @@ -41,7 +47,7 @@ public static bool GetContentInfo(Uri uri, out long size, out DateTime modifiedT size = httpResponse.Content.Headers.ContentLength ?? 0; modifiedTime = httpResponse.Content.Headers.LastModified?.DateTime ?? DateTime.MinValue; } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -75,7 +81,7 @@ public static bool GetContentInfo(Uri uri, out long size, out DateTime modifiedT httpResponse.Content.Headers.LastModified?.DateTime ?? DateTime.MinValue; return (true, size, modifiedTime); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return (false, 0, DateTime.MinValue); } @@ -99,7 +105,7 @@ public static bool DownloadFile(Uri uri, string fileName) using FileStream fileStream = File.OpenWrite(fileName); httpStream.CopyTo(fileStream); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -132,7 +138,7 @@ public static async Task DownloadFileAsync( await using FileStream fileStream = File.OpenWrite(fileName); await httpStream.CopyToAsync(fileStream, cancellationToken).ConfigureAwait(false); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -156,7 +162,7 @@ public static bool DownloadString(Uri uri, out string value) { value = GetHttpClient().GetStringAsync(uri).GetAwaiter().GetResult(); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -185,7 +191,7 @@ public static bool DownloadString(Uri uri, out string value) .ConfigureAwait(false); return (true, value); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return (false, string.Empty); } diff --git a/Utilities/Extensions.cs b/Utilities/Extensions.cs index 2002e52..c001e77 100644 --- a/Utilities/Extensions.cs +++ b/Utilities/Extensions.cs @@ -1,15 +1,13 @@ using System.IO.Compression; using System.Runtime.CompilerServices; -using Serilog; +using Microsoft.Extensions.Logging; namespace InsaneGenius.Utilities; /// -/// Provides extension methods for string compression and logger error handling. +/// Provides extension methods for string compression. /// -#pragma warning disable CA1708 // Identifiers should differ by more than case -public static class Extensions -#pragma warning restore CA1708 // Identifiers should differ by more than case +public static class CompressExtensions { /// /// Extension methods for string compression and decompression. @@ -54,9 +52,15 @@ public Task CompressAsync( public Task DecompressAsync(CancellationToken cancellationToken = default) => StringCompression.DecompressAsync(uncompressedString, cancellationToken); } +} +/// +/// Provides logger error handling helpers and strongly-typed, source-generated log messages. +/// +internal static partial class LogExtensions +{ /// - /// Extension methods for Serilog ILogger error handling. + /// Extension methods for Microsoft.Extensions.Logging ILogger error handling. /// extension(ILogger logger) { @@ -66,12 +70,12 @@ public Task DecompressAsync(CancellationToken cancellationToken = defaul /// The exception to log. /// The function name (automatically captured). /// Always returns false to allow exception to propagate. - public bool LogAndPropagate( + internal bool LogAndPropagate( Exception exception, [CallerMemberName] string function = "unknown" ) { - logger.Error(exception, "{Function}", function); + logger.LogCatchException(function, exception); return false; } @@ -81,13 +85,88 @@ public bool LogAndPropagate( /// The exception to log. /// The function name (automatically captured). /// Always returns true to indicate exception was handled. - public bool LogAndHandle( + internal bool LogAndHandle( Exception exception, [CallerMemberName] string function = "unknown" ) { - logger.Error(exception, "{Function}", function); + logger.LogCatchException(function, exception); return true; } } + + [LoggerMessage(Message = "Exception in {Function}", Level = LogLevel.Error)] + internal static partial void LogCatchException( + this ILogger logger, + string function, + Exception exception + ); + + [LoggerMessage( + Message = "Deleting ({RetryCount} / {OptionsRetryCount}) : {FileName}", + Level = LogLevel.Information + )] + internal static partial void LogDeletingFile( + this ILogger logger, + int retryCount, + int optionsRetryCount, + string fileName + ); + + [LoggerMessage( + Message = "Deleting ({RetryCount} / {OptionsRetryCount}) : {Directory}", + Level = LogLevel.Information + )] + internal static partial void LogDeletingDirectory( + this ILogger logger, + int retryCount, + int optionsRetryCount, + string directory + ); + + [LoggerMessage( + Message = "Renaming file failed due to invalid path(s) : {OriginalName} to {NewName}", + Level = LogLevel.Error + )] + internal static partial void LogRenameInvalidPath( + this ILogger logger, + string originalName, + string newName + ); + + [LoggerMessage( + Message = "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalDirectory} : {OriginalFile} to {NewFile}", + Level = LogLevel.Information + )] + internal static partial void LogRenamingInDirectory( + this ILogger logger, + int retryCount, + int optionsRetryCount, + string originalDirectory, + string originalFile, + string newFile + ); + + [LoggerMessage( + Message = "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalName} to {NewName}", + Level = LogLevel.Information + )] + internal static partial void LogRenaming( + this ILogger logger, + int retryCount, + int optionsRetryCount, + string originalName, + string newName + ); + + [LoggerMessage( + Message = "Waiting for file to become readable ({RetryCount} / {OptionsRetryCount}) : {Name}", + Level = LogLevel.Information + )] + internal static partial void LogWaitingForReadable( + this ILogger logger, + int retryCount, + int optionsRetryCount, + string name + ); } diff --git a/Utilities/FileEx.cs b/Utilities/FileEx.cs index a2c0279..668e9a0 100644 --- a/Utilities/FileEx.cs +++ b/Utilities/FileEx.cs @@ -1,6 +1,7 @@ using System.Runtime.InteropServices; using System.Security.AccessControl; using System.Security.Principal; +using Microsoft.Extensions.Logging; namespace InsaneGenius.Utilities; @@ -9,6 +10,12 @@ namespace InsaneGenius.Utilities; /// public static class FileEx { + private static readonly Lazy s_logger = new(() => + LogOptions.CreateLogger(typeof(FileEx).FullName!) + ); + + private static ILogger Log => s_logger.Value; + /// /// Settings for file operations including retry behavior and cancellation. /// @@ -47,18 +54,13 @@ public static bool DeleteFile(string fileName) result = true; break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { // Retry - LogOptions.Logger.Information( - "Deleting ({RetryCount} / {OptionsRetryCount}) : {FileName}", - retryCount, - Options.RetryCount, - fileName - ); + Log.LogDeletingFile(retryCount, Options.RetryCount, fileName); _ = Options.RetryWaitForCancel(); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -101,18 +103,13 @@ public static async Task DeleteFileAsync( result = true; break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { - LogOptions.Logger.Information( - "Deleting ({RetryCount} / {OptionsRetryCount}) : {FileName}", - retryCount, - Options.RetryCount, - fileName - ); + Log.LogDeletingFile(retryCount, Options.RetryCount, fileName); await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) .ConfigureAwait(false); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -154,20 +151,15 @@ public static bool DeleteDirectory(string directory) result = true; break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { // TODO : Do not retry if folder is not empty, it will never succeed // Retry - LogOptions.Logger.Information( - "Deleting ({RetryCount} / {OptionsRetryCount}) : {Directory}", - retryCount, - Options.RetryCount, - directory - ); + Log.LogDeletingDirectory(retryCount, Options.RetryCount, directory); _ = Options.RetryWaitForCancel(); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -210,18 +202,13 @@ public static async Task DeleteDirectoryAsync( result = true; break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { - LogOptions.Logger.Information( - "Deleting ({RetryCount} / {OptionsRetryCount}) : {Directory}", - retryCount, - Options.RetryCount, - directory - ); + Log.LogDeletingDirectory(retryCount, Options.RetryCount, directory); await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) .ConfigureAwait(false); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -291,11 +278,7 @@ public static bool RenameFile(string originalName, string newName) || string.IsNullOrEmpty(newFile) ) { - LogOptions.Logger.Error( - "Renaming file failed due to invalid path(s) : {OriginalName} to {NewName}", - originalName, - newName - ); + Log.LogRenameInvalidPath(originalName, newName); return false; } @@ -321,18 +304,17 @@ public static bool RenameFile(string originalName, string newName) result = true; break; } - catch (FileNotFoundException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (FileNotFoundException e) when (Log.LogAndHandle(e)) { // File not found break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { // Retry if (originalDirectory.Equals(newDirectory, StringComparison.OrdinalIgnoreCase)) { - LogOptions.Logger.Information( - "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalDirectory} : {OriginalFile} to {NewFile}", + Log.LogRenamingInDirectory( retryCount, Options.RetryCount, originalDirectory, @@ -342,18 +324,12 @@ public static bool RenameFile(string originalName, string newName) } else { - LogOptions.Logger.Information( - "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalName} to {NewName}", - retryCount, - Options.RetryCount, - originalName, - newName - ); + Log.LogRenaming(retryCount, Options.RetryCount, originalName, newName); } _ = Options.RetryWaitForCancel(); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -391,11 +367,7 @@ public static async Task RenameFileAsync( || string.IsNullOrEmpty(newFile) ) { - LogOptions.Logger.Error( - "Renaming file failed due to invalid path(s) : {OriginalName} to {NewName}", - originalName, - newName - ); + Log.LogRenameInvalidPath(originalName, newName); return false; } @@ -418,18 +390,17 @@ public static async Task RenameFileAsync( result = true; break; } - catch (FileNotFoundException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (FileNotFoundException e) when (Log.LogAndHandle(e)) { // File not found break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { // Retry if (originalDirectory.Equals(newDirectory, StringComparison.OrdinalIgnoreCase)) { - LogOptions.Logger.Information( - "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalDirectory} : {OriginalFile} to {NewFile}", + Log.LogRenamingInDirectory( retryCount, Options.RetryCount, originalDirectory, @@ -439,19 +410,13 @@ public static async Task RenameFileAsync( } else { - LogOptions.Logger.Information( - "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalName} to {NewName}", - retryCount, - Options.RetryCount, - originalName, - newName - ); + Log.LogRenaming(retryCount, Options.RetryCount, originalName, newName); } await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) .ConfigureAwait(false); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -495,24 +460,18 @@ public static bool RenameFolder(string originalName, string newName) result = true; break; } - catch (FileNotFoundException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (FileNotFoundException e) when (Log.LogAndHandle(e)) { // File not found break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { // Retry - LogOptions.Logger.Information( - "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalName} to {NewName}", - retryCount, - Options.RetryCount, - originalName, - newName - ); + Log.LogRenaming(retryCount, Options.RetryCount, originalName, newName); _ = Options.RetryWaitForCancel(); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -559,25 +518,19 @@ public static async Task RenameFolderAsync( result = true; break; } - catch (FileNotFoundException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (FileNotFoundException e) when (Log.LogAndHandle(e)) { // File not found break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { // Retry - LogOptions.Logger.Information( - "Renaming ({RetryCount} / {OptionsRetryCount}) : {OriginalName} to {NewName}", - retryCount, - Options.RetryCount, - originalName, - newName - ); + Log.LogRenaming(retryCount, Options.RetryCount, originalName, newName); await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) .ConfigureAwait(false); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -737,7 +690,7 @@ public static bool IsFileReadable(string fileName) FileInfo fileInfo = new(fileName); return IsFileReadable(fileInfo); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -771,18 +724,13 @@ public static bool WaitFileReadable(string fileName) result = true; break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { // Retry - LogOptions.Logger.Information( - "Waiting for file to become readable ({RetryCount} / {OptionsRetryCount}) : {Name}", - retryCount, - Options.RetryCount, - fileName - ); + Log.LogWaitingForReadable(retryCount, Options.RetryCount, fileName); _ = Options.RetryWaitForCancel(); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -822,19 +770,14 @@ public static async Task WaitFileReadableAsync( result = true; break; } - catch (IOException e) when (LogOptions.Logger.LogAndHandle(e)) + catch (IOException e) when (Log.LogAndHandle(e)) { // Retry - LogOptions.Logger.Information( - "Waiting for file to become readable ({RetryCount} / {OptionsRetryCount}) : {Name}", - retryCount, - Options.RetryCount, - fileName - ); + Log.LogWaitingForReadable(retryCount, Options.RetryCount, fileName); await Task.Delay(Options.RetryWaitTime * 1000, cancellationToken) .ConfigureAwait(false); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { break; } @@ -861,7 +804,7 @@ public static bool IsFileReadable(FileInfo fileInfo) FileShare.ReadWrite ); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -887,7 +830,7 @@ public static bool IsFileWriteable(FileInfo fileInfo) FileShare.ReadWrite ); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -913,7 +856,7 @@ public static bool IsFileReadWriteable(FileInfo fileInfo) FileShare.ReadWrite ); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -934,7 +877,7 @@ public static bool AreFilesInDirectoryReadable(string directory) DirectoryInfo dirInfo = new(directory); return dirInfo.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly).All(IsFileReadable); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -954,7 +897,7 @@ public static bool CreateDirectory(string directory) _ = Directory.CreateDirectory(directory); } } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -1056,7 +999,7 @@ out List directoryList fileList.AddRange(dirInfo.EnumerateFiles("*.*", SearchOption.TopDirectoryOnly)); } } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -1119,7 +1062,7 @@ public static bool ResetDirectoryPermissions(string directory) directorySecurity.AddAccessRule(accessRule); directoryInfo.SetAccessControl(directorySecurity); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -1191,7 +1134,7 @@ public static bool CreateRandomFilledFile(string name, long size) // Close stream.Close(); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -1239,7 +1182,7 @@ await stream remaining -= writeSize; } } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -1268,7 +1211,7 @@ public static bool CreateSparseFile(string name, long size) // Set length stream.SetLength(size); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } @@ -1295,7 +1238,7 @@ public static async Task CreateSparseFileAsync(string name, long size) stream.SetLength(size); } - catch (Exception e) when (LogOptions.Logger.LogAndHandle(e)) + catch (Exception e) when (Log.LogAndHandle(e)) { return false; } diff --git a/Utilities/LogOptions.cs b/Utilities/LogOptions.cs index 5620387..da8e2dd 100644 --- a/Utilities/LogOptions.cs +++ b/Utilities/LogOptions.cs @@ -1,21 +1,88 @@ -// TODO: Problematic when used in different environments -// using Microsoft.Extensions.Logging; - -using Serilog; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; namespace InsaneGenius.Utilities; /// -/// Provides global logging configuration options. +/// Provides global logging configuration for the library. /// +/// +/// +/// This class manages static logger configuration used by all library classes. +/// When a logger is requested, it is resolved using the following priority order: +/// +/// +/// +/// - The global logger factory, when set to a non- instance. Creates a logger for the requested category. +/// +/// +/// - The default fallback when no logger factory is configured. +/// +/// +/// +/// Note that loggers are created and cached at the time of use by each class. Changes to +/// after a logger has been created will not affect existing cached loggers. Only new logger requests will use the updated configuration. +/// +/// public static class LogOptions { + private static ILoggerFactory s_loggerFactory = NullLoggerFactory.Instance; + + /// + /// Gets or sets the logger factory used to create category loggers. + /// + /// + /// Changes to this property after loggers have been created will not affect existing cached loggers. + /// + public static ILoggerFactory LoggerFactory + { + get => Volatile.Read(ref s_loggerFactory); + set => _ = Interlocked.Exchange(ref s_loggerFactory, value ?? NullLoggerFactory.Instance); + } + + /// + /// Configures the library to use the specified logger factory. + /// + /// The factory to use for new loggers. + /// + /// This will only affect loggers created after this call. + /// Existing cached loggers remain unchanged. + /// + public static void SetFactory(ILoggerFactory loggerFactory) => LoggerFactory = loggerFactory; + /// - /// Gets or sets the logger instance used throughout the utilities library. - /// Defaults to a silent logger that outputs nothing. + /// Attempts to configure the library to use the specified logger factory if none is set. /// + /// The factory to use for new loggers. + /// + /// true when the factory was set because no factory was configured; otherwise, false. + /// /// - /// Configure this property with your application's logger before using FileEx or Download utilities. + /// Use this method for one-time initialization to avoid overwriting an existing factory. /// - public static ILogger Logger { get; set; } = new LoggerConfiguration().CreateLogger(); + public static bool TrySetFactory(ILoggerFactory loggerFactory) + { + ILoggerFactory candidate = loggerFactory ?? NullLoggerFactory.Instance; + ILoggerFactory original = Interlocked.CompareExchange( + ref s_loggerFactory, + candidate, + NullLoggerFactory.Instance + ); + + return ReferenceEquals(original, NullLoggerFactory.Instance); + } + + internal static ILogger CreateLogger() => CreateLogger(typeof(T).FullName ?? typeof(T).Name); + + internal static ILogger CreateLogger(string categoryName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(categoryName); + + // Read the factory once so a concurrent swap cannot split the check + // and the create across two different instances. LoggerFactory -> NullLogger + ILoggerFactory factory = LoggerFactory; + return !ReferenceEquals(factory, NullLoggerFactory.Instance) + ? factory.CreateLogger(categoryName) + : NullLogger.Instance; + } } diff --git a/Utilities/Utilities.csproj b/Utilities/Utilities.csproj index 5875b26..b2ef063 100644 --- a/Utilities/Utilities.csproj +++ b/Utilities/Utilities.csproj @@ -3,10 +3,6 @@ true false true - - $(NoWarn);IL3058 true true true @@ -30,8 +26,11 @@ snupkg + - + + + diff --git a/UtilitiesTests/CommandLineTests.cs b/UtilitiesTests/CommandLineTests.cs index c4683f2..d56d74b 100644 --- a/UtilitiesTests/CommandLineTests.cs +++ b/UtilitiesTests/CommandLineTests.cs @@ -22,13 +22,13 @@ public void ParseArguments() ]; string[] output = CommandLineEx.ParseArguments(input); - Assert.Equal(expected, output); + _ = output.Should().Equal(expected); } [Fact] public void GetCommandLineArgs() { string[] commandlineArgs = CommandLineEx.GetCommandLineArgs(); - Assert.True(commandlineArgs.Length > 0); + _ = (commandlineArgs.Length > 0).Should().BeTrue(); } } diff --git a/UtilitiesTests/ConsoleTests.cs b/UtilitiesTests/ConsoleTests.cs index 572073c..8dc38b0 100644 --- a/UtilitiesTests/ConsoleTests.cs +++ b/UtilitiesTests/ConsoleTests.cs @@ -15,7 +15,7 @@ public void WriteLineColor_WithValidString_ShouldContainMessage() ConsoleEx.WriteLineColor(ConsoleColor.Green, "Test message"); string result = output.ToString(); - Assert.Contains("Test message", result); + _ = result.Should().Contain("Test message"); // Reset console StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; @@ -31,7 +31,7 @@ public void WriteLineColor_WithEmptyString_ShouldNotThrow() ConsoleEx.WriteLineColor(ConsoleColor.Green, string.Empty); string result = output.ToString(); - Assert.NotNull(result); + _ = result.Should().NotBeNull(); // Reset console StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; @@ -43,9 +43,10 @@ public void WriteLineColor_WithNullObject_ShouldThrowArgumentNullException() { object? nullValue = null; - _ = Assert.Throws(() => - ConsoleEx.WriteLineColor(ConsoleColor.Red, nullValue!) - ); + _ = FluentActions + .Invoking(() => ConsoleEx.WriteLineColor(ConsoleColor.Red, nullValue!)) + .Should() + .Throw(); } [Fact] @@ -57,7 +58,7 @@ public void WriteLineError_WithString_ShouldContainMessage() ConsoleEx.WriteLineError("Error message"); string result = output.ToString(); - Assert.Contains("Error message", result); + _ = result.Should().Contain("Error message"); // Reset console StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; @@ -73,7 +74,7 @@ public void WriteLineEvent_WithString_ShouldContainMessage() ConsoleEx.WriteLineEvent("Event message"); string result = output.ToString(); - Assert.Contains("Event message", result); + _ = result.Should().Contain("Event message"); // Reset console StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; @@ -89,7 +90,7 @@ public void WriteLineTool_WithString_ShouldContainMessage() ConsoleEx.WriteLineTool("Tool message"); string result = output.ToString(); - Assert.Contains("Tool message", result); + _ = result.Should().Contain("Tool message"); // Reset console StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; @@ -105,7 +106,7 @@ public void WriteLine_WithString_ShouldContainMessage() ConsoleEx.WriteLine("Output message"); string result = output.ToString(); - Assert.Contains("Output message", result); + _ = result.Should().Contain("Output message"); // Reset console StreamWriter standardOutput = new(Console.OpenStandardOutput()) { AutoFlush = true }; @@ -132,9 +133,9 @@ public void WriteLineColor_WithDifferentColors_ShouldNotThrow(ConsoleColor color [Fact] public void ColorConstants_ShouldHaveCorrectValues() { - Assert.Equal(ConsoleColor.Green, ConsoleEx.ToolColor); - Assert.Equal(ConsoleColor.Red, ConsoleEx.ErrorColor); - Assert.Equal(ConsoleColor.Cyan, ConsoleEx.OutputColor); - Assert.Equal(ConsoleColor.Yellow, ConsoleEx.EventColor); + _ = ConsoleEx.ToolColor.Should().Be(ConsoleColor.Green); + _ = ConsoleEx.ErrorColor.Should().Be(ConsoleColor.Red); + _ = ConsoleEx.OutputColor.Should().Be(ConsoleColor.Cyan); + _ = ConsoleEx.EventColor.Should().Be(ConsoleColor.Yellow); } } diff --git a/UtilitiesTests/DownloadAsyncTests.cs b/UtilitiesTests/DownloadAsyncTests.cs index 6336cec..66fe65b 100644 --- a/UtilitiesTests/DownloadAsyncTests.cs +++ b/UtilitiesTests/DownloadAsyncTests.cs @@ -15,8 +15,8 @@ public async Task GetContentInfoAsync_WithValidUri_ShouldReturnSuccess() (bool success, long size, DateTime _) = await Download.GetContentInfoAsync(uri); - Assert.True(success); - Assert.True(size > 0); + _ = success.Should().BeTrue(); + _ = (size > 0).Should().BeTrue(); } [Fact] @@ -26,9 +26,9 @@ public async Task DownloadStringAsync_WithValidUri_ShouldReturnContent() (bool success, string? content) = await Download.DownloadStringAsync(uri); - Assert.True(success); - Assert.NotEmpty(content); - Assert.Contains("google", content, StringComparison.OrdinalIgnoreCase); + _ = success.Should().BeTrue(); + _ = content.Should().NotBeEmpty(); + _ = content.Should().ContainEquivalentOf("google"); } [Fact] @@ -43,9 +43,9 @@ public async Task DownloadFileAsync_WithValidUri_ShouldCreateFile() { bool result = await Download.DownloadFileAsync(uri, tempFile); - Assert.True(result); - Assert.True(File.Exists(tempFile)); - Assert.True(new FileInfo(tempFile).Length > 0); + _ = result.Should().BeTrue(); + _ = File.Exists(tempFile).Should().BeTrue(); + _ = (new FileInfo(tempFile).Length > 0).Should().BeTrue(); } finally { @@ -63,7 +63,7 @@ public async Task DownloadAsync_WithInvalidUri_ShouldReturnFalse() (bool success, long _, DateTime _) = await Download.GetContentInfoAsync(invalidUri); - Assert.False(success); + _ = success.Should().BeFalse(); } [Fact] @@ -76,7 +76,7 @@ public async Task DownloadAsync_WithCancellation_ShouldRespectCancellation() (bool Success, string _) = await Download.DownloadStringAsync(uri, cts.Token); // Should either throw cancellation or return false due to cancellation - Assert.False(Success); + _ = Success.Should().BeFalse(); } [Fact] @@ -84,9 +84,10 @@ public async Task DownloadAsync_WithNullUri_ShouldThrowArgumentNullException() { Uri? nullUri = null; - _ = await Assert.ThrowsAsync(async () => - await Download.GetContentInfoAsync(nullUri!) - ); + _ = await FluentActions + .Awaiting(() => Download.GetContentInfoAsync(nullUri!)) + .Should() + .ThrowAsync(); } [Fact] @@ -98,7 +99,7 @@ public void CreateUri_WithCredentials_ShouldIncludeCredentials() Uri result = Download.CreateUri(url, username, password); - Assert.Contains(username, result.ToString()); + _ = result.ToString().Should().Contain(username); } [Fact] @@ -106,6 +107,9 @@ public void CreateUri_WithNullUrl_ShouldThrowArgumentNullException() { string? nullUrl = null; - _ = Assert.Throws(() => Download.CreateUri(nullUrl!)); + _ = FluentActions + .Invoking(() => Download.CreateUri(nullUrl!)) + .Should() + .Throw(); } } diff --git a/UtilitiesTests/DownloadTests.cs b/UtilitiesTests/DownloadTests.cs index 748c864..c4a310d 100644 --- a/UtilitiesTests/DownloadTests.cs +++ b/UtilitiesTests/DownloadTests.cs @@ -12,6 +12,6 @@ public void GetUriInformation() Uri uri = new( "https://www.google.com/images/branding/googlelogo/1x/googlelogo_color_272x92dp.png" ); - Assert.True(Download.GetContentInfo(uri, out long _, out DateTime _)); + _ = Download.GetContentInfo(uri, out long _, out DateTime _).Should().BeTrue(); } } diff --git a/UtilitiesTests/ExtensionsTests.cs b/UtilitiesTests/ExtensionsTests.cs index e272aa3..3786b7c 100644 --- a/UtilitiesTests/ExtensionsTests.cs +++ b/UtilitiesTests/ExtensionsTests.cs @@ -1,6 +1,6 @@ using System.IO.Compression; -using Serilog; -using Serilog.Core; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace InsaneGenius.Utilities.Tests; @@ -18,9 +18,9 @@ public void StringExtension_Compress_ShouldCompressString() string compressed = original.Compress(); - Assert.NotNull(compressed); - Assert.NotEmpty(compressed); - Assert.NotEqual(original, compressed); + _ = compressed.Should().NotBeNull(); + _ = compressed.Should().NotBeEmpty(); + _ = compressed.Should().NotBe(original); } [Fact] @@ -31,7 +31,7 @@ public void StringExtension_Decompress_ShouldDecompressString() string decompressed = compressed.Decompress(); - Assert.Equal(original, decompressed); + _ = decompressed.Should().Be(original); } [Theory] @@ -45,7 +45,7 @@ public void StringExtension_Compress_WithDifferentLevels_ShouldWork(CompressionL string compressed = original.Compress(level); string decompressed = compressed.Decompress(); - Assert.Equal(original, decompressed); + _ = decompressed.Should().Be(original); } [Fact] @@ -55,9 +55,9 @@ public async Task StringExtension_CompressAsync_ShouldCompressString() string compressed = await original.CompressAsync(); - Assert.NotNull(compressed); - Assert.NotEmpty(compressed); - Assert.NotEqual(original, compressed); + _ = compressed.Should().NotBeNull(); + _ = compressed.Should().NotBeEmpty(); + _ = compressed.Should().NotBe(original); } [Fact] @@ -68,7 +68,7 @@ public async Task StringExtension_DecompressAsync_ShouldDecompressString() string decompressed = await compressed.DecompressAsync(); - Assert.Equal(original, decompressed); + _ = decompressed.Should().Be(original); } [Fact] @@ -78,9 +78,10 @@ public async Task StringExtension_CompressAsync_WithCancellation_ShouldRespectTo cts.Cancel(); string original = "Test string"; - _ = await Assert.ThrowsAnyAsync(async () => - await original.CompressAsync(cancellationToken: cts.Token) - ); + _ = await FluentActions + .Awaiting(() => original.CompressAsync(cancellationToken: cts.Token)) + .Should() + .ThrowAsync(); } [Fact] @@ -88,7 +89,10 @@ public void StringExtension_Compress_WithNullString_ShouldThrow() { string? nullString = null; - _ = Assert.Throws(() => nullString.Compress()); + _ = FluentActions + .Invoking(() => nullString.Compress()) + .Should() + .Throw(); } #endregion @@ -98,29 +102,29 @@ public void StringExtension_Compress_WithNullString_ShouldThrow() [Fact] public void LoggerExtension_LogAndHandle_ShouldReturnTrue() { - Logger logger = new LoggerConfiguration().CreateLogger(); + ILogger logger = NullLogger.Instance; InvalidOperationException exception = new("Test exception"); bool result = logger.LogAndHandle(exception); - Assert.True(result); + _ = result.Should().BeTrue(); } [Fact] public void LoggerExtension_LogAndPropagate_ShouldReturnFalse() { - Logger logger = new LoggerConfiguration().CreateLogger(); + ILogger logger = NullLogger.Instance; InvalidOperationException exception = new("Test exception"); bool result = logger.LogAndPropagate(exception); - Assert.False(result); + _ = result.Should().BeFalse(); } [Fact] public void LoggerExtension_LogAndHandle_CanBeUsedInCatchWhen() { - Logger logger = new LoggerConfiguration().CreateLogger(); + ILogger logger = NullLogger.Instance; bool exceptionCaught; try @@ -132,13 +136,13 @@ public void LoggerExtension_LogAndHandle_CanBeUsedInCatchWhen() exceptionCaught = true; } - Assert.True(exceptionCaught); + _ = exceptionCaught.Should().BeTrue(); } [Fact] public void LoggerExtension_LogAndPropagate_AllowsExceptionToBubble() { - Logger logger = new LoggerConfiguration().CreateLogger(); + ILogger logger = NullLogger.Instance; bool exceptionHandled; try @@ -156,31 +160,31 @@ public void LoggerExtension_LogAndPropagate_AllowsExceptionToBubble() exceptionHandled = false; } - Assert.False(exceptionHandled); + _ = exceptionHandled.Should().BeFalse(); } [Fact] public void LoggerExtension_LogAndHandle_WithCustomFunction_ShouldLog() { - Logger logger = new LoggerConfiguration().CreateLogger(); + ILogger logger = NullLogger.Instance; InvalidOperationException exception = new("Test exception"); // Test that it works with explicit function name bool result = logger.LogAndHandle(exception, "CustomFunction"); - Assert.True(result); + _ = result.Should().BeTrue(); } [Fact] public void LoggerExtension_LogAndPropagate_WithCustomFunction_ShouldLog() { - Logger logger = new LoggerConfiguration().CreateLogger(); + ILogger logger = NullLogger.Instance; InvalidOperationException exception = new("Test exception"); // Test that it works with explicit function name bool result = logger.LogAndPropagate(exception, "CustomFunction"); - Assert.False(result); + _ = result.Should().BeFalse(); } #endregion @@ -192,7 +196,10 @@ public void StringExtension_Decompress_WithNullString_ShouldThrow() { string? nullString = null; - _ = Assert.Throws(() => nullString!.Decompress()); + _ = FluentActions + .Invoking(() => nullString!.Decompress()) + .Should() + .Throw(); } [Fact] @@ -200,9 +207,10 @@ public async Task StringExtension_CompressAsync_WithNullString_ShouldThrow() { string? nullString = null; - _ = await Assert.ThrowsAsync(async () => - await nullString.CompressAsync() - ); + _ = await FluentActions + .Awaiting(() => nullString.CompressAsync()) + .Should() + .ThrowAsync(); } [Fact] @@ -210,9 +218,10 @@ public async Task StringExtension_DecompressAsync_WithNullString_ShouldThrow() { string? nullString = null; - _ = await Assert.ThrowsAsync(async () => - await nullString.DecompressAsync() - ); + _ = await FluentActions + .Awaiting(() => nullString.DecompressAsync()) + .Should() + .ThrowAsync(); } #endregion @@ -242,7 +251,7 @@ public void StringExtension_CompressDecompress_RoundTrip_ShouldPreserveData() string compressed = original.Compress(); string decompressed = compressed.Decompress(); - Assert.Equal(original, decompressed); + _ = decompressed.Should().Be(original); } } @@ -263,7 +272,7 @@ public async Task StringExtension_CompressDecompressAsync_RoundTrip_ShouldPreser string compressed = await original.CompressAsync(); string decompressed = await compressed.DecompressAsync(); - Assert.Equal(original, decompressed); + _ = decompressed.Should().Be(original); } } diff --git a/UtilitiesTests/FileExAsyncTests.cs b/UtilitiesTests/FileExAsyncTests.cs index 92c3860..7963bba 100644 --- a/UtilitiesTests/FileExAsyncTests.cs +++ b/UtilitiesTests/FileExAsyncTests.cs @@ -15,8 +15,8 @@ public async Task DeleteFileAsync_WithExistingFile_ShouldReturnTrue() { bool result = await FileEx.DeleteFileAsync(tempFile); - Assert.True(result); - Assert.False(File.Exists(tempFile)); + _ = result.Should().BeTrue(); + _ = File.Exists(tempFile).Should().BeFalse(); } finally { @@ -37,8 +37,8 @@ public async Task DeleteDirectoryAsync_WithExistingDirectory_ShouldReturnTrue() { bool result = await FileEx.DeleteDirectoryAsync(tempDir); - Assert.True(result); - Assert.False(Directory.Exists(tempDir)); + _ = result.Should().BeTrue(); + _ = Directory.Exists(tempDir).Should().BeFalse(); } finally { @@ -63,8 +63,8 @@ public async Task DeleteDirectoryAsync_Recursive_ShouldDeleteAllContents() { bool result = await FileEx.DeleteDirectoryAsync(tempDir, recursive: true); - Assert.True(result); - Assert.False(Directory.Exists(tempDir)); + _ = result.Should().BeTrue(); + _ = Directory.Exists(tempDir).Should().BeFalse(); } finally { @@ -85,9 +85,9 @@ public async Task RenameFileAsync_WithValidPaths_ShouldRenameFile() { bool result = await FileEx.RenameFileAsync(tempFile, newPath); - Assert.True(result); - Assert.False(File.Exists(tempFile)); - Assert.True(File.Exists(newPath)); + _ = result.Should().BeTrue(); + _ = File.Exists(tempFile).Should().BeFalse(); + _ = File.Exists(newPath).Should().BeTrue(); } finally { @@ -113,9 +113,9 @@ public async Task RenameFolderAsync_WithValidPaths_ShouldRenameFolder() { bool result = await FileEx.RenameFolderAsync(tempDir, newPath); - Assert.True(result); - Assert.False(Directory.Exists(tempDir)); - Assert.True(Directory.Exists(newPath)); + _ = result.Should().BeTrue(); + _ = Directory.Exists(tempDir).Should().BeFalse(); + _ = Directory.Exists(newPath).Should().BeTrue(); } finally { @@ -141,7 +141,7 @@ public async Task WaitFileReadableAsync_WithReadableFile_ShouldReturnTrue() bool result = await FileEx.WaitFileReadableAsync(tempFile); - Assert.True(result); + _ = result.Should().BeTrue(); } finally { @@ -162,9 +162,9 @@ public async Task CreateRandomFilledFileAsync_ShouldCreateFile() { bool result = await FileEx.CreateRandomFilledFileAsync(tempFile, fileSize); - Assert.True(result); - Assert.True(File.Exists(tempFile)); - Assert.Equal(fileSize, new FileInfo(tempFile).Length); + _ = result.Should().BeTrue(); + _ = File.Exists(tempFile).Should().BeTrue(); + _ = new FileInfo(tempFile).Length.Should().Be(fileSize); } finally { @@ -185,9 +185,9 @@ public async Task CreateSparseFileAsync_ShouldCreateFile() { bool result = await FileEx.CreateSparseFileAsync(tempFile, fileSize); - Assert.True(result); - Assert.True(File.Exists(tempFile)); - Assert.Equal(fileSize, new FileInfo(tempFile).Length); + _ = result.Should().BeTrue(); + _ = File.Exists(tempFile).Should().BeTrue(); + _ = new FileInfo(tempFile).Length.Should().Be(fileSize); } finally { @@ -211,7 +211,7 @@ public async Task FileOperationsAsync_WithCancellation_ShouldRespectCancellation bool result = await FileEx.DeleteFileAsync(tempFile, cts.Token); // Should either succeed quickly or respect cancellation - Assert.True(result || cts.Token.IsCancellationRequested); + _ = (result || cts.Token.IsCancellationRequested).Should().BeTrue(); } finally { @@ -234,9 +234,9 @@ public async Task DeleteInsideDirectoryAsync_ShouldDeleteContentsOnly() { bool result = await FileEx.DeleteInsideDirectoryAsync(tempDir); - Assert.True(result); - Assert.True(Directory.Exists(tempDir)); // Directory should still exist - Assert.False(File.Exists(testFile)); // But file should be gone + _ = result.Should().BeTrue(); + _ = Directory.Exists(tempDir).Should().BeTrue(); // Directory should still exist + _ = File.Exists(testFile).Should().BeFalse(); // But file should be gone } finally { diff --git a/UtilitiesTests/FileTests.cs b/UtilitiesTests/FileTests.cs index bb002f5..da260fe 100644 --- a/UtilitiesTests/FileTests.cs +++ b/UtilitiesTests/FileTests.cs @@ -23,7 +23,7 @@ public void CombineAbsolutePath2(string path1, string path2, string output) } string path = FileEx.CombinePath(path1, path2); - Assert.Equal(path, output); + _ = path.Should().Be(output); } [Theory] @@ -57,6 +57,6 @@ public void CombineAbsolutePath3(string path1, string path2, string path3, strin } string path = FileEx.CombinePath(path1, path2, path3); - Assert.Equal(path, output); + _ = path.Should().Be(output); } } diff --git a/UtilitiesTests/FormatTests.cs b/UtilitiesTests/FormatTests.cs index e42ec72..bd8e2f2 100644 --- a/UtilitiesTests/FormatTests.cs +++ b/UtilitiesTests/FormatTests.cs @@ -27,7 +27,7 @@ public class FormatTests(UtilitiesTests fixture) : IClassFixture public void BytestoKibi(long value, string output) { string kibi = Format.BytesToKibi(value); - Assert.Equal(kibi, output); + _ = kibi.Should().Be(output); } [Theory] @@ -51,6 +51,6 @@ public void BytestoKibi(long value, string output) public void BytestoKilo(long value, string output) { string kilo = Format.BytesToKilo(value); - Assert.Equal(kilo, output); + _ = kilo.Should().Be(output); } } diff --git a/UtilitiesTests/GlobalUsings.cs b/UtilitiesTests/GlobalUsings.cs new file mode 100644 index 0000000..892cfab --- /dev/null +++ b/UtilitiesTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using AwesomeAssertions; diff --git a/UtilitiesTests/LogOptionsTests.cs b/UtilitiesTests/LogOptionsTests.cs new file mode 100644 index 0000000..324f7d1 --- /dev/null +++ b/UtilitiesTests/LogOptionsTests.cs @@ -0,0 +1,193 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace InsaneGenius.Utilities.Tests; + +/// +/// Serializes tests that mutate the process-global state. +/// +[CollectionDefinition("LogOptions", DisableParallelization = true)] +public sealed class LogOptionsCollectionDefinition { } + +[Collection("LogOptions")] +public sealed class LogOptionsTests +{ + [Fact] + public void CreateLogger_UsesFactory_WhenFactorySet() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + using TestLoggerFactory testFactory = new(); + + try + { + LogOptions.LoggerFactory = testFactory; + + ILogger logger = LogOptions.CreateLogger("category"); + + _ = LogOptions.LoggerFactory.Should().BeSameAs(testFactory); + _ = testFactory.LastCategory.Should().Be("category"); + _ = logger.Should().NotBeNull(); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + } + } + + [Fact] + public void CreateLogger_UsesNullLogger_WhenFactoryDefault() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + + try + { + LogOptions.LoggerFactory = NullLoggerFactory.Instance; + + ILogger logger = LogOptions.CreateLogger("category"); + + _ = logger.Should().BeSameAs(NullLogger.Instance); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + } + } + + [Fact] + public void CreateLoggerGeneric_UsesTypeFullNameAsCategory() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + using TestLoggerFactory testFactory = new(); + + try + { + LogOptions.LoggerFactory = testFactory; + + ILogger logger = LogOptions.CreateLogger(); + + _ = testFactory.LastCategory.Should().Be(typeof(LogOptionsTests).FullName); + _ = logger.Should().NotBeNull(); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + } + } + + [Fact] + public void CreateLogger_WithNullCategory_ThrowsArgumentNullException() => + _ = FluentActions + .Invoking(() => LogOptions.CreateLogger(null!)) + .Should() + .Throw(); + + [Fact] + public void CreateLogger_WithEmptyCategory_ThrowsArgumentException() => + _ = FluentActions + .Invoking(() => LogOptions.CreateLogger(" ")) + .Should() + .Throw(); + + [Fact] + public void SetFactory_WithNull_ResetsToNullLoggerFactory() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + using TestLoggerFactory testFactory = new(); + + try + { + LogOptions.SetFactory(testFactory); + LogOptions.SetFactory(null!); + + _ = LogOptions.LoggerFactory.Should().BeSameAs(NullLoggerFactory.Instance); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + } + } + + [Fact] + public void TrySetFactory_WhenUnset_ReturnsTrueAndSets() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + using TestLoggerFactory testFactory = new(); + + try + { + LogOptions.LoggerFactory = NullLoggerFactory.Instance; + + bool result = LogOptions.TrySetFactory(testFactory); + + _ = result.Should().BeTrue(); + _ = LogOptions.LoggerFactory.Should().BeSameAs(testFactory); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + } + } + + [Fact] + public void TrySetFactory_WhenAlreadySet_ReturnsFalseAndDoesNotOverwrite() + { + ILoggerFactory originalFactory = LogOptions.LoggerFactory; + using TestLoggerFactory testFactory = new(); + using TestLoggerFactory otherFactory = new(); + + try + { + LogOptions.LoggerFactory = testFactory; + + bool result = LogOptions.TrySetFactory(otherFactory); + + _ = result.Should().BeFalse(); + _ = LogOptions.LoggerFactory.Should().BeSameAs(testFactory); + } + finally + { + LogOptions.LoggerFactory = originalFactory; + } + } + + private sealed class TestLoggerFactory : ILoggerFactory + { + public ILogger Logger { get; } = new TestLogger(); + + public string? LastCategory { get; private set; } + + public void AddProvider(ILoggerProvider provider) { } + + public ILogger CreateLogger(string categoryName) + { + LastCategory = categoryName; + return Logger; + } + + public void Dispose() { } + } + + private sealed class TestLogger : ILogger + { + public IDisposable BeginScope(TState state) + where TState : notnull => NullScope.Instance; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter + ) { } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() { } + } + } +} diff --git a/UtilitiesTests/StringCompressionAsyncTests.cs b/UtilitiesTests/StringCompressionAsyncTests.cs index 39fb5ae..56c9866 100644 --- a/UtilitiesTests/StringCompressionAsyncTests.cs +++ b/UtilitiesTests/StringCompressionAsyncTests.cs @@ -24,7 +24,7 @@ public async Task CompressDecompressAsync() string decompressed = await compressed.DecompressAsync(); // Compare to original string - Assert.Equal(text, decompressed); + _ = decompressed.Should().Be(text); } [Theory] @@ -39,7 +39,7 @@ public async Task CompressAsync_WithDifferentLevels_ShouldSucceed(CompressionLev string compressed = await text.CompressAsync(level); string decompressed = await compressed.DecompressAsync(); - Assert.Equal(text, decompressed); + _ = decompressed.Should().Be(text); } [Fact] @@ -52,8 +52,8 @@ public async Task CompressAsync_WithCancellation_ShouldComplete() string compressed = await text.CompressAsync(cancellationToken: cts.Token); - Assert.NotNull(compressed); - Assert.NotEmpty(compressed); + _ = compressed.Should().NotBeNull(); + _ = compressed.Should().NotBeEmpty(); } [Fact] @@ -61,9 +61,10 @@ public async Task DecompressAsync_WithInvalidBase64_ShouldThrow() { string invalidBase64 = "This is not valid Base64!"; - _ = await Assert.ThrowsAsync(async () => - await invalidBase64.DecompressAsync() - ); + _ = await FluentActions + .Awaiting(() => invalidBase64.DecompressAsync()) + .Should() + .ThrowAsync(); } [Fact] @@ -71,9 +72,10 @@ public async Task CompressAsync_WithNullString_ShouldThrowArgumentNullException( { string? nullString = null; - _ = await Assert.ThrowsAsync(async () => - await StringCompression.CompressAsync(nullString!) - ); + _ = await FluentActions + .Awaiting(() => StringCompression.CompressAsync(nullString!)) + .Should() + .ThrowAsync(); } [Fact] @@ -81,9 +83,10 @@ public async Task DecompressAsync_WithNullString_ShouldThrowArgumentNullExceptio { string? nullString = null; - _ = await Assert.ThrowsAsync(async () => - await StringCompression.DecompressAsync(nullString!) - ); + _ = await FluentActions + .Awaiting(() => StringCompression.DecompressAsync(nullString!)) + .Should() + .ThrowAsync(); } [Fact] @@ -95,7 +98,9 @@ public async Task CompressAsync_LargeString_ShouldCompress() string compressed = await largeText.CompressAsync(); string decompressed = await compressed.DecompressAsync(); - Assert.Equal(largeText, decompressed); - Assert.True(compressed.Length < largeText.Length, "Compressed size should be smaller"); + _ = decompressed.Should().Be(largeText); + _ = (compressed.Length < largeText.Length) + .Should() + .BeTrue("Compressed size should be smaller"); } } diff --git a/UtilitiesTests/StringCompressionTests.cs b/UtilitiesTests/StringCompressionTests.cs index 29b5c82..69d32b0 100644 --- a/UtilitiesTests/StringCompressionTests.cs +++ b/UtilitiesTests/StringCompressionTests.cs @@ -23,6 +23,6 @@ public void CompressDecompress() string decompressed = compressed.Decompress(); // Compare to original string - Assert.Equal(text, decompressed); + _ = decompressed.Should().Be(text); } } diff --git a/UtilitiesTests/StringHistoryTests.cs b/UtilitiesTests/StringHistoryTests.cs index c7df5ae..00e08c9 100644 --- a/UtilitiesTests/StringHistoryTests.cs +++ b/UtilitiesTests/StringHistoryTests.cs @@ -11,10 +11,10 @@ public void Constructor_Default_ShouldInitialize() { StringHistory history = new(); - Assert.NotNull(history); - Assert.Equal(0, history.MaxFirstLines); - Assert.Equal(0, history.MaxLastLines); - Assert.Empty(history.StringList); + _ = history.Should().NotBeNull(); + _ = history.MaxFirstLines.Should().Be(0); + _ = history.MaxLastLines.Should().Be(0); + _ = history.StringList.Should().BeEmpty(); } [Fact] @@ -22,9 +22,9 @@ public void Constructor_WithLimits_ShouldSetLimits() { StringHistory history = new(maxFirstLines: 5, maxLastLines: 3); - Assert.Equal(5, history.MaxFirstLines); - Assert.Equal(3, history.MaxLastLines); - Assert.Empty(history.StringList); + _ = history.MaxFirstLines.Should().Be(5); + _ = history.MaxLastLines.Should().Be(3); + _ = history.StringList.Should().BeEmpty(); } [Fact] @@ -37,7 +37,7 @@ public void AppendLine_NoLimits_ShouldAddAllLines() history.AppendLine($"Line {i}"); } - Assert.Equal(10, history.StringList.Count); + _ = history.StringList.Count.Should().Be(10); } [Fact] @@ -51,10 +51,10 @@ public void AppendLine_WithFirstLinesLimit_ShouldRespectLimit() } // Should have 5 first + 3 last = 8 lines - Assert.Equal(8, history.StringList.Count); - Assert.Equal("Line 0", history.StringList[0]); - Assert.Equal("Line 4", history.StringList[4]); - Assert.Equal("Line 9", history.StringList[^1]); + _ = history.StringList.Count.Should().Be(8); + _ = history.StringList[0].Should().Be("Line 0"); + _ = history.StringList[4].Should().Be("Line 4"); + _ = history.StringList[^1].Should().Be("Line 9"); } [Fact] @@ -68,16 +68,16 @@ public void AppendLine_BeyondLimits_ShouldRollLastLines() } // Should have 3 first + 2 last = 5 lines - Assert.Equal(5, history.StringList.Count); + _ = history.StringList.Count.Should().Be(5); // First 3 lines - Assert.Equal("Line 0", history.StringList[0]); - Assert.Equal("Line 1", history.StringList[1]); - Assert.Equal("Line 2", history.StringList[2]); + _ = history.StringList[0].Should().Be("Line 0"); + _ = history.StringList[1].Should().Be("Line 1"); + _ = history.StringList[2].Should().Be("Line 2"); // Last 2 lines - Assert.Equal("Line 8", history.StringList[3]); - Assert.Equal("Line 9", history.StringList[4]); + _ = history.StringList[3].Should().Be("Line 8"); + _ = history.StringList[4].Should().Be("Line 9"); } [Fact] @@ -85,7 +85,10 @@ public void AppendLine_WithNull_ShouldThrowArgumentNullException() { StringHistory history = new(); - _ = Assert.Throws(() => history.AppendLine(null!)); + _ = FluentActions + .Invoking(() => history.AppendLine(null!)) + .Should() + .Throw(); } [Fact] @@ -95,7 +98,7 @@ public void ToString_EmptyHistory_ShouldReturnEmptyString() string result = history.ToString(); - Assert.Equal(string.Empty, result); + _ = result.Should().Be(string.Empty); } [Fact] @@ -108,10 +111,10 @@ public void ToString_WithLines_ShouldReturnFormattedString() string result = history.ToString(); - Assert.Contains("Line 1", result); - Assert.Contains("Line 2", result); - Assert.Contains("Line 3", result); - Assert.EndsWith(Environment.NewLine, result); + _ = result.Should().Contain("Line 1"); + _ = result.Should().Contain("Line 2"); + _ = result.Should().Contain("Line 3"); + _ = result.Should().EndWith(Environment.NewLine); } [Fact] @@ -119,8 +122,8 @@ public void Properties_CanBeModified_ShouldUpdateLimits() { StringHistory history = new() { MaxFirstLines = 10, MaxLastLines = 5 }; - Assert.Equal(10, history.MaxFirstLines); - Assert.Equal(5, history.MaxLastLines); + _ = history.MaxFirstLines.Should().Be(10); + _ = history.MaxLastLines.Should().Be(5); } [Fact] @@ -131,25 +134,25 @@ public void AppendLine_MultipleSequences_ShouldMaintainCorrectState() // Add first batch history.AppendLine("A"); history.AppendLine("B"); - Assert.Equal(2, history.StringList.Count); + _ = history.StringList.Count.Should().Be(2); // Add more to trigger last lines history.AppendLine("C"); history.AppendLine("D"); - Assert.Equal(4, history.StringList.Count); + _ = history.StringList.Count.Should().Be(4); // Add more to trigger rolling history.AppendLine("E"); - Assert.Equal(4, history.StringList.Count); + _ = history.StringList.Count.Should().Be(4); history.AppendLine("F"); - Assert.Equal(4, history.StringList.Count); + _ = history.StringList.Count.Should().Be(4); // Should have: A, B, E, F - Assert.Equal("A", history.StringList[0]); - Assert.Equal("B", history.StringList[1]); - Assert.Equal("E", history.StringList[2]); - Assert.Equal("F", history.StringList[3]); + _ = history.StringList[0].Should().Be("A"); + _ = history.StringList[1].Should().Be("B"); + _ = history.StringList[2].Should().Be("E"); + _ = history.StringList[3].Should().Be("F"); } [Fact] @@ -163,10 +166,10 @@ public void AppendLine_OnlyFirstLinesLimit_ShouldWork() } // Should have 3 first lines only - Assert.Equal(3, history.StringList.Count); - Assert.Equal("Line 0", history.StringList[0]); - Assert.Equal("Line 1", history.StringList[1]); - Assert.Equal("Line 2", history.StringList[2]); + _ = history.StringList.Count.Should().Be(3); + _ = history.StringList[0].Should().Be("Line 0"); + _ = history.StringList[1].Should().Be("Line 1"); + _ = history.StringList[2].Should().Be("Line 2"); } [Fact] @@ -180,10 +183,10 @@ public void AppendLine_OnlyLastLinesLimit_ShouldWork() } // Should have 3 last lines only - Assert.Equal(3, history.StringList.Count); - Assert.Equal("Line 7", history.StringList[0]); - Assert.Equal("Line 8", history.StringList[1]); - Assert.Equal("Line 9", history.StringList[2]); + _ = history.StringList.Count.Should().Be(3); + _ = history.StringList[0].Should().Be("Line 7"); + _ = history.StringList[1].Should().Be("Line 8"); + _ = history.StringList[2].Should().Be("Line 9"); } [Fact] @@ -194,7 +197,7 @@ public void ToString_WithSingleLine_ShouldFormat() string result = history.ToString(); - Assert.Equal("Single line" + Environment.NewLine, result); + _ = result.Should().Be("Single line" + Environment.NewLine); } [Fact] @@ -204,8 +207,8 @@ public void AppendLine_EmptyString_ShouldBeAllowed() history.AppendLine(string.Empty); - _ = Assert.Single(history.StringList); - Assert.Equal(string.Empty, history.StringList[0]); + _ = history.StringList.Should().ContainSingle(); + _ = history.StringList[0].Should().Be(string.Empty); } [Fact] @@ -216,7 +219,7 @@ public void AppendLine_SpecialCharacters_ShouldPreserve() history.AppendLine(specialLine); - Assert.Equal(specialLine, history.StringList[0]); + _ = history.StringList[0].Should().Be(specialLine); } [Fact] @@ -227,6 +230,6 @@ public void AppendLine_UnicodeCharacters_ShouldPreserve() history.AppendLine(unicodeLine); - Assert.Equal(unicodeLine, history.StringList[0]); + _ = history.StringList[0].Should().Be(unicodeLine); } } diff --git a/UtilitiesTests/UtilitiesTests.csproj b/UtilitiesTests/UtilitiesTests.csproj index bdf2773..7c31295 100644 --- a/UtilitiesTests/UtilitiesTests.csproj +++ b/UtilitiesTests/UtilitiesTests.csproj @@ -19,9 +19,10 @@ snupkg + + - all diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000..f781e07 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,19 @@ +# Codecov configuration. +# +# Coverage is report-only for this repo: the upload step in +# .github/workflows/validate-task.yml sets fail_ci_if_error: false, and the +# patch status below is informational so a coverage delta never blocks a merge. +# The project status stays as a visible (non-blocking-by-target) signal. +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true + +# The Sandbox is an example/demo console app, not the shipped +# InsaneGenius.Utilities library, and is intentionally not unit-tested. +ignore: + - "Sandbox/**" diff --git a/version.json b/version.json index f146b1f..87b9545 100644 --- a/version.json +++ b/version.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/dotnet/Nerdbank.GitVersioning/master/src/NerdBank.GitVersioning/version.schema.json", - "version": "3.6", + "version": "3.7", "publicReleaseRefSpec": [ "^refs/heads/main$" ],