refactor: Better handling of exceptions in logging system

- Centralize sanitization into a custom Serilog enricher
- More log sites pass the exception object in
- Console output now consistently only prints the mssage, but not the
  stack trace.
- File output always outputs the stack trace.

Additionally, there are fixes to several SonarLint issues.
pull/259/head
Robert Dailey 4 months ago
parent 925d54368a
commit cb56ab5737

@ -28,7 +28,7 @@ public partial class ServiceCache(ICacheStoragePath storagePath, ILogger log) :
}
catch (JsonException e)
{
log.Error("Failed to read cache data, will proceed without cache. Reason: {Msg}", e.Message);
log.Error(e, "Failed to read cache data, will proceed without cache");
}
return null;

@ -47,7 +47,7 @@ public class ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor
}
catch (FileExistsException e)
{
log.Error(
log.Error(e,
"The file {ConfigFile} already exists. Please choose another path or " +
"delete/move the existing file and run this command again", e.AttemptedPath);

@ -24,9 +24,9 @@ public class ConfigListLocalCommand(ILogger log, ConfigListLocalProcessor proces
processor.Process();
return 0;
}
catch (NoConfigurationFilesException)
catch (NoConfigurationFilesException e)
{
log.Error("No configuration files found");
log.Error(e, "Unable to list local config files");
}
return 1;

@ -34,9 +34,9 @@ public class ConfigListTemplatesCommand(
processor.Process(settings);
return 0;
}
catch (NoConfigurationFilesException)
catch (NoConfigurationFilesException e)
{
log.Error("No configuration files found");
log.Error(e, "Unable to list template files");
}
return 1;

@ -1,8 +1,13 @@
namespace Recyclarr.Cli.Console.Helpers;
// Taken from: https://github.com/spectreconsole/spectre.console/issues/701#issuecomment-1081834778
internal sealed class ConsoleAppCancellationTokenSource
internal sealed class ConsoleAppCancellationTokenSource : IDisposable
{
public void Dispose()
{
_cts.Dispose();
}
private readonly CancellationTokenSource _cts = new();
public CancellationToken Token => _cts.Token;

@ -7,9 +7,14 @@ using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Interceptors;
public class BaseCommandSetupInterceptor(LoggingLevelSwitch loggingLevelSwitch, IAppDataSetup appDataSetup)
: ICommandInterceptor
internal sealed class BaseCommandSetupInterceptor(LoggingLevelSwitch loggingLevelSwitch, IAppDataSetup appDataSetup)
: ICommandInterceptor, IDisposable
{
public void Dispose()
{
_ct.Dispose();
}
private readonly ConsoleAppCancellationTokenSource _ct = new();
public void Intercept(CommandContext context, CommandSettings settings)

@ -1,18 +0,0 @@
using Serilog.Core;
using Serilog.Events;
namespace Recyclarr.Cli.Logging;
public class ExceptionMessageEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
var msg = logEvent.Exception?.Message;
if (string.IsNullOrEmpty(msg))
{
return;
}
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("ExceptionMessage", msg));
}
}

@ -0,0 +1,43 @@
using System.Text;
using Recyclarr.Http;
using Serilog.Core;
using Serilog.Events;
namespace Recyclarr.Cli.Logging;
public class FlurlExceptionSanitizingEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
if (logEvent.Exception is null)
{
return;
}
var sanitizedMessage = Sanitize.ExceptionMessage(logEvent.Exception);
// Use a builder to handle whether to use a newline character for the full exception message without checking if
// the sanitized message is null or whitespace more than once.
var fullBuilder = new StringBuilder();
if (!string.IsNullOrWhiteSpace(sanitizedMessage))
{
MakeProperty("SanitizedExceptionMessage", sanitizedMessage);
fullBuilder.Append($"{sanitizedMessage}\n");
}
// ReSharper disable once InvertIf
if (logEvent.Exception.StackTrace is not null)
{
fullBuilder.Append(logEvent.Exception.StackTrace);
MakeProperty("SanitizedExceptionFull", fullBuilder.ToString());
}
return;
void MakeProperty(string propertyName, object value)
{
logEvent.AddOrUpdateProperty(propertyFactory.CreateProperty(propertyName, value));
}
}
}

@ -20,14 +20,16 @@ public class LoggerFactory(IAppPaths paths, LoggingLevelSwitch levelSwitch)
private static ExpressionTemplate GetConsoleTemplate()
{
var template = "[{@l:u3}] " + GetBaseTemplateString() + "\n{@x}";
var template = "[{@l:u3}] " + GetBaseTemplateString() +
"{#if SanitizedExceptionMessage is not null}: {SanitizedExceptionMessage}{#end}\n";
return new ExpressionTemplate(template, theme: TemplateTheme.Code);
}
private static ExpressionTemplate GetFileTemplate()
{
var template = "[{@t:HH:mm:ss} {@l:u3}] " + GetBaseTemplateString() + "\n{@x}";
var template = "[{@t:HH:mm:ss} {@l:u3}] " + GetBaseTemplateString() +
"{#if SanitizedExceptionFull is not null}: {SanitizedExceptionFull}{#end}\n";
return new ExpressionTemplate(template);
}
@ -39,7 +41,8 @@ public class LoggerFactory(IAppPaths paths, LoggingLevelSwitch levelSwitch)
return new LoggerConfiguration()
.MinimumLevel.Is(LogEventLevel.Verbose)
.Enrich.With<ExceptionMessageEnricher>()
.Enrich.FromLogContext()
.Enrich.With<FlurlExceptionSanitizingEnricher>()
.WriteTo.Console(GetConsoleTemplate(), levelSwitch: levelSwitch)
.WriteTo.Logger(c => c
.MinimumLevel.Debug()
@ -47,7 +50,6 @@ public class LoggerFactory(IAppPaths paths, LoggingLevelSwitch levelSwitch)
.WriteTo.Logger(c => c
.Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Verbose)
.WriteTo.File(GetFileTemplate(), LogFilePath("verbose")))
.Enrich.FromLogContext()
.CreateLogger();
string LogFilePath(string type)

@ -26,8 +26,8 @@ public class QualityProfileLogPhase(ILogger log) : ILogPipelinePhase<QualityProf
log.Warning(
"The following quality profile names have no definition in the top-level `quality_profiles` " +
"list *and* do not exist in the remote service. Either create them manually in the service *or* add " +
"them to the top-level `quality_profiles` section so that Recyclarr can create the profiles for you");
log.Warning("{QualityProfileNames}", transactions.NonExistentProfiles);
"them to the top-level `quality_profiles` section so that Recyclarr can create the profiles for " +
"you: {QualityProfileNames}", transactions.NonExistentProfiles);
}
if (transactions.InvalidProfiles.Count > 0)

@ -72,7 +72,7 @@ public class QualityProfileStatCalculator(ILogger log)
foreach (var (dto, newScore, reason) in scores)
{
log.Debug(" - {Format} ({Id}): {OldScore} -> {NewScore} ({Reason})",
log.Debug(" - {Name} ({Id}): {OldScore} -> {NewScore} ({Reason})",
dto.Name, dto.Format, dto.Score, newScore, reason);
}

@ -1,6 +1,6 @@
namespace Recyclarr.Cli.Processors.Config;
public class FileExistsException(string attemptedPath) : Exception
public class FileExistsException(string attemptedPath) : Exception($"File already exists: {attemptedPath}")
{
public string AttemptedPath { get; } = attemptedPath;
}

@ -33,7 +33,7 @@ public class TemplateConfigCreator(
}
catch (FileExistsException e)
{
log.Error("Template configuration file could not be saved: {Reason}", e.AttemptedPath);
log.Error(e, "Template configuration file could not be saved");
}
catch (FileLoadException)
{
@ -54,7 +54,7 @@ public class TemplateConfigCreator(
if (destinationFile.Exists && !settings.Force)
{
throw new FileExistsException($"{destinationFile} already exists");
throw new FileExistsException(destinationFile.FullName);
}
destinationFile.CreateParentDirectory();

@ -3,7 +3,6 @@ using Recyclarr.Cli.Console;
using Recyclarr.Compatibility;
using Recyclarr.Config.ExceptionTypes;
using Recyclarr.Config.Parsing.ErrorHandling;
using Recyclarr.Http;
using Recyclarr.VersionControl;
namespace Recyclarr.Cli.Processors.ErrorHandling;
@ -15,12 +14,11 @@ public class ConsoleExceptionHandler(ILogger log, IFlurlHttpExceptionHandler htt
switch (sourceException)
{
case GitCmdException e:
log.Error(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}",
e.ExitCode, e.Error);
log.Error(e, "Non-zero exit code {ExitCode} while executing Git command", e.ExitCode);
break;
case FlurlHttpException e:
log.Error("HTTP error: {Message}", e.SanitizedExceptionMessage());
log.Error(e, "HTTP error");
await httpExceptionHandler.ProcessServiceErrorMessages(new ServiceErrorMessageExtractor(e));
break;
@ -50,7 +48,7 @@ public class ConsoleExceptionHandler(ILogger log, IFlurlHttpExceptionHandler htt
break;
case PostProcessingException e:
log.Error("Configuration post-processing failed: {Message}", e.Message);
log.Error(e, "Configuration post-processing failed");
break;
case ServiceIncompatibilityException e:

@ -21,7 +21,6 @@ internal static class Program
config.ValidateExamples();
#endif
// config.Settings.PropagateExceptions = true;
config.Settings.StrictParsing = true;
config.SetApplicationName("recyclarr");

@ -19,7 +19,6 @@
<PackageReference Include="SystemTextJson.JsonDiffPatch" />
<PackageReference Include="TestableIO.System.IO.Abstractions" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
<PackageReference Include="YamlDotNet" />
</ItemGroup>
<!-- Following found during vulerabilities Code Scan -->

@ -0,0 +1,21 @@
namespace Recyclarr.Common.Extensions;
public static class ExceptionExtensions
{
public static string FullMessage(this Exception ex)
{
if (ex is AggregateException aex)
{
return aex.InnerExceptions.Aggregate("[ ", (total, next) => $"{total}[{next.FullMessage()}] ") + "]";
}
var msg = ex.Message.Replace(", see inner exception.", "").Trim();
var innerMsg = ex.InnerException?.FullMessage();
if (innerMsg != null && !innerMsg.ContainsIgnoreCase(msg) && !msg.ContainsIgnoreCase(innerMsg))
{
msg = $"{msg} [ {innerMsg} ]";
}
return msg;
}
}

@ -11,6 +11,6 @@ public static class TypeExtensions
{
return
type is {IsInterface: true} && type.IsGenericTypeOf(collectionType) ||
type.GetInterfaces().Any(i => i.IsGenericTypeOf(typeof(ICollection<>)));
Array.Exists(type.GetInterfaces(), i => i.IsGenericTypeOf(typeof(ICollection<>)));
}
}

@ -1,6 +1,7 @@
using System.IO.Abstractions;
using JetBrains.Annotations;
using Recyclarr.Config.Parsing.ErrorHandling;
using Recyclarr.Settings;
using Recyclarr.Yaml;
using YamlDotNet.Core;
using YamlDotNet.Serialization;
@ -37,28 +38,23 @@ public class ConfigParser(ILogger log, IYamlSerializerFactory yamlFactory)
}
catch (YamlException e)
{
log.Debug(e, "Exception while parsing config file");
var line = e.Start.Line;
var contextualMsg = ConfigContextualMessages.GetContextualErrorFromException(e);
if (contextualMsg is not null)
switch (e.InnerException)
{
log.Error("Exception at line {Line}: {Msg}", line, contextualMsg);
case InvalidCastException:
log.Error(e, "Incompatible value assigned/used at line {Line}", line);
break;
default:
log.Error(e, "Exception at line {Line}", line);
break;
}
else
{
switch (e.InnerException)
{
case InvalidCastException:
log.Error("Incompatible value assigned/used at line {Line}: {Msg}", line,
e.InnerException.Message);
break;
default:
log.Error("Exception at line {Line}: {Msg}", line, e.InnerException?.Message ?? e.Message);
break;
}
var context = SettingsContextualMessages.GetContextualErrorFromException(e);
if (context is not null)
{
log.Error(context);
}
}

@ -1,3 +1,3 @@
namespace Recyclarr.Config.Parsing.ErrorHandling;
public class NoConfigurationFilesException : Exception;
public class NoConfigurationFilesException() : Exception("No configuration files found");

@ -24,11 +24,4 @@ public static class FlurlLogging
log.Verbose("HTTP {Direction} Body: {Method} {Url} {Body}", direction, method, url, body);
}
public static Url SanitizeUrl(Url url)
{
// Replace hostname for user privacy
url.Host = "REDACTED";
return url;
}
}

@ -11,7 +11,7 @@ public class FlurlBeforeCallLogRedactor(ILogger log) : FlurlSpecificEventHandler
public override void Handle(FlurlEventType eventType, FlurlCall call)
{
var url = FlurlLogging.SanitizeUrl(call.Request.Url.Clone());
var url = Sanitize.Url(call.Request.Url.Clone());
log.Debug("HTTP Request: {Method} {Url}", call.HttpRequestMessage.Method, url);
FlurlLogging.LogBody(log, url, "Request", call.HttpRequestMessage.Method, call.RequestBody);
}
@ -25,7 +25,7 @@ public class FlurlAfterCallLogRedactor(ILogger log) : FlurlSpecificEventHandler
public override async Task HandleAsync(FlurlEventType eventType, FlurlCall call)
{
var statusCode = call.Response?.StatusCode.ToString() ?? "(No response)";
var url = FlurlLogging.SanitizeUrl(call.Request.Url.Clone());
var url = Sanitize.Url(call.Request.Url.Clone());
log.Debug("HTTP Response: {Status} {Method} {Url}", statusCode, call.HttpRequestMessage.Method, url);
var content = call.Response?.ResponseMessage.Content;
@ -45,7 +45,7 @@ public class FlurlRedirectPreventer(ILogger log) : FlurlSpecificEventHandler
public override void Handle(FlurlEventType eventType, FlurlCall call)
{
log.Warning("HTTP Redirect received; this indicates a problem with your URL and/or reverse proxy: {Url}",
FlurlLogging.SanitizeUrl(call.Redirect.Url));
Sanitize.Url(call.Redirect.Url));
// Must follow redirect because we want an exception to be thrown eventually. If it is set to false, HTTP
// communication stops and existing methods will return nothing / null. This messes with Observable

@ -4,4 +4,7 @@
<PackageReference Include="Flurl.Http" />
<PackageReference Include="Serilog" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Recyclarr.Common\Recyclarr.Common.csproj" />
</ItemGroup>
</Project>

@ -1,23 +1,36 @@
using System.Text.RegularExpressions;
using Flurl.Http;
using Flurl;
using Recyclarr.Common.Extensions;
namespace Recyclarr.Http;
public static partial class FlurlExtensions
public static partial class Sanitize
{
public static string SanitizedExceptionMessage(this FlurlHttpException exception)
public static string Message(string message)
{
// Replace full URLs
var result = UrlRegex().Replace(exception.Message, Sanitize);
var result = UrlRegex().Replace(message, SanitizeMatch);
// There are sometimes parenthetical parts of the message that contain the host but are not
// detected as true URLs. Just strip those out completely.
return HostRegex().Replace(result, "");
}
private static string Sanitize(Match match)
public static string ExceptionMessage(Exception exception)
{
return FlurlLogging.SanitizeUrl(match.Value).ToString() ?? match.Value;
return Message(exception.FullMessage());
}
public static Url Url(Url url)
{
// Replace hostname for user privacy
url.Host = "REDACTED";
return url;
}
private static string SanitizeMatch(Match match)
{
return Url(match.Value).ToString() ?? match.Value;
}
[GeneratedRegex(@"\([-a-zA-Z0-9@:%._+~#=]{1,256}(?::[0-9]+)?\)")]

@ -25,7 +25,7 @@ public class RepoUpdater(ILogger log, IGitRepositoryFactory repositoryFactory) :
}
catch (GitCmdException e)
{
log.Debug(e, "Non-zero exit code {ExitCode} while executing Git command: {Error}", e.ExitCode, e.Error);
log.Debug(e, "Non-zero exit code {ExitCode} while executing Git command", e.ExitCode);
}
catch (InvalidGitRepoException e)
{
@ -63,7 +63,7 @@ public class RepoUpdater(ILogger log, IGitRepositoryFactory repositoryFactory) :
}
catch (GitCmdException e)
{
log.Debug(e, "Non-zero exit code {ExitCode} while running git fetch: {Error}", e.ExitCode, e.Error);
log.Debug(e, "Non-zero exit code {ExitCode} while running git fetch", e.ExitCode);
log.Error(
"Updating the repo '{RepoDir}' (git fetch) failed. Proceeding with existing files. " +
"Check clone URL is correct and that github is not down",

@ -5,28 +5,33 @@ namespace Recyclarr.ServarrApi.CustomFormat;
public class CustomFormatApiService(IServarrRequestBuilder service) : ICustomFormatApiService
{
private IFlurlRequest Request(params object[] path)
{
return service.Request(["customformat", ..path]);
}
public async Task<IList<CustomFormatData>> GetCustomFormats()
{
return await service.Request("customformat")
return await Request()
.GetJsonAsync<IList<CustomFormatData>>();
}
public async Task<CustomFormatData?> CreateCustomFormat(CustomFormatData cf)
{
return await service.Request("customformat")
return await Request()
.PostJsonAsync(cf)
.ReceiveJson<CustomFormatData>();
}
public async Task UpdateCustomFormat(CustomFormatData cf)
{
await service.Request("customformat", cf.Id)
await Request(cf.Id)
.PutJsonAsync(cf);
}
public async Task DeleteCustomFormat(int customFormatId, CancellationToken cancellationToken = default)
{
await service.Request("customformat", customFormatId)
await Request(customFormatId)
.DeleteAsync(cancellationToken: cancellationToken);
}
}

@ -4,9 +4,14 @@ namespace Recyclarr.ServarrApi.QualityProfile;
internal class QualityProfileApiService(IServarrRequestBuilder service) : IQualityProfileApiService
{
private IFlurlRequest Request(params object[] path)
{
return service.Request(["qualityprofile", ..path]);
}
public async Task<IList<QualityProfileDto>> GetQualityProfiles()
{
var response = await service.Request("qualityprofile")
var response = await Request()
.GetJsonAsync<IList<QualityProfileDto>>();
return response.Select(x => x.ReverseItems()).ToList();
@ -14,7 +19,7 @@ internal class QualityProfileApiService(IServarrRequestBuilder service) : IQuali
public async Task<QualityProfileDto> GetSchema()
{
var response = await service.Request("qualityprofile", "schema")
var response = await Request("schema")
.GetJsonAsync<QualityProfileDto>();
return response.ReverseItems();
@ -27,13 +32,13 @@ internal class QualityProfileApiService(IServarrRequestBuilder service) : IQuali
throw new ArgumentException($"Profile's ID property must not be null: {profile.Name}");
}
await service.Request("qualityprofile", profile.Id)
await Request(profile.Id)
.PutJsonAsync(profile.ReverseItems());
}
public async Task CreateQualityProfile(QualityProfileDto profile)
{
var response = await service.Request("qualityprofile")
var response = await Request()
.PostJsonAsync(profile.ReverseItems())
.ReceiveJson<QualityProfileDto>();

@ -9,12 +9,12 @@ namespace Recyclarr.Settings;
public class SettingsProvider : ISettingsProvider
{
public SettingsValues Settings => _settings.Value;
private readonly ILogger _log;
private readonly IAppPaths _paths;
private readonly Lazy<SettingsValues> _settings;
public SettingsValues Settings => _settings.Value;
public SettingsProvider(ILogger log, IAppPaths paths, IYamlSerializerFactory serializerFactory)
{
_log = log;
@ -24,11 +24,7 @@ public class SettingsProvider : ISettingsProvider
private SettingsValues LoadOrCreateSettingsFile(IYamlSerializerFactory serializerFactory)
{
var yamlPath = _paths.AppDataDirectory.YamlFile("settings");
if (yamlPath is null)
{
yamlPath = CreateDefaultSettingsFile();
}
var yamlPath = _paths.AppDataDirectory.YamlFile("settings") ?? CreateDefaultSettingsFile();
try
{
@ -38,11 +34,13 @@ public class SettingsProvider : ISettingsProvider
}
catch (YamlException e)
{
_log.Debug(e, "Exception while parsing settings file");
_log.Error(e, "Exception while parsing settings.yml at line {Line}", e.Start.Line);
var line = e.Start.Line;
var msg = SettingsContextualMessages.GetContextualErrorFromException(e) ?? e.Message;
_log.Error("Exception while parsing settings.yml at line {Line}: {Msg}", line, msg);
var context = SettingsContextualMessages.GetContextualErrorFromException(e);
if (context is not null)
{
_log.Error(context);
}
throw;
}

@ -1,11 +1,8 @@
namespace Recyclarr.VersionControl;
public class GitCmdException(int exitCode, string error) : Exception("Git command failed with a non-zero exit code")
public class GitCmdException(int exitCode, string errorMessage) : Exception(errorMessage)
{
// ReSharper disable UnusedAutoPropertyAccessor.Global
public string Error { get; } = error;
public int ExitCode { get; } = exitCode;
// ReSharper restore UnusedAutoPropertyAccessor.Global
}
public class InvalidGitRepoException(string? message) : Exception(message);

@ -1,31 +0,0 @@
using JetBrains.Annotations;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NodeDeserializers;
// ReSharper disable UnusedMember.Global
namespace Recyclarr.Yaml.YamlDotNet;
public static class YamlDotNetExtensions
{
public static T? DeserializeType<T>(this IDeserializer deserializer, string data)
where T : class
{
var extractor = deserializer.Deserialize<RootExtractor<T>>(data);
return extractor.RootObject;
}
public static DeserializerBuilder WithRequiredPropertyValidation(this DeserializerBuilder builder)
{
return builder
.WithNodeDeserializer(inner => new ValidatingDeserializer(inner),
s => s.InsteadOf<ObjectNodeDeserializer>());
}
[UsedImplicitly(ImplicitUseTargetFlags.Members)]
private sealed class RootExtractor<T>
where T : class
{
public T? RootObject { get; }
}
}

@ -10,7 +10,7 @@ internal class AutoMapperConfigurationTest : CliIntegrationFixture
{
var mapper = Resolve<MapperConfiguration>();
// Build an execution plan like:
// var plan = mapper.BuildExecutionPlan(typeof(QualityProfileConfigYaml), typeof(QualityProfileConfig));
// var plan = mapper.BuildExecutionPlan(typeof(QualityProfileConfigYaml), typeof(QualityProfileConfig))
// And do `plan.ToReadableString()` in the Debug Expressions/Watch
mapper.AssertConfigurationIsValid();
}

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Autofac.Extras.Ordering;
using Recyclarr.Cli.Migration;
using Recyclarr.Cli.Migration.Steps;
@ -78,6 +79,9 @@ public class MigrationExecutorTest
}
[Test]
[SuppressMessage("SonarLint",
"S3928:Parameter names used into ArgumentException constructors should match an existing one",
Justification = "Used in unit test only")]
public void Migration_exceptions_are_not_converted()
{
using var console = new TestConsole();

@ -14,41 +14,41 @@ public static class MockFileSystemExtensions
fs.AddFileFromEmbeddedResource(path.FullName, resourceAssembly, embeddedResourcePath);
}
public static void AddSameFileFromEmbeddedResource(
public static void AddFileFromEmbeddedResource(
this MockFileSystem fs,
IFileInfo path,
Type typeInAssembly,
string resourceSubPath = "Data")
string embeddedResourcePath)
{
fs.AddFileFromEmbeddedResource(path, typeInAssembly, $"{resourceSubPath}.{path.Name}");
fs.AddFileFromEmbeddedResource(path.FullName, typeInAssembly, embeddedResourcePath);
}
public static void AddSameFileFromEmbeddedResource(
public static void AddFileFromEmbeddedResource(
this MockFileSystem fs,
string path,
Type typeInAssembly,
string resourceSubPath = "Data")
string embeddedResourcePath)
{
fs.AddFileFromEmbeddedResource(fs.FileInfo.New(path), typeInAssembly, resourceSubPath);
var resourcePath = $"{typeInAssembly.Namespace}.{embeddedResourcePath}";
fs.AddFileFromEmbeddedResource(path, typeInAssembly.Assembly, resourcePath);
}
public static void AddFileFromEmbeddedResource(
public static void AddSameFileFromEmbeddedResource(
this MockFileSystem fs,
IFileInfo path,
Type typeInAssembly,
string embeddedResourcePath)
string resourceSubPath = "Data")
{
fs.AddFileFromEmbeddedResource(path.FullName, typeInAssembly, embeddedResourcePath);
fs.AddFileFromEmbeddedResource(path, typeInAssembly, $"{resourceSubPath}.{path.Name}");
}
public static void AddFileFromEmbeddedResource(
public static void AddSameFileFromEmbeddedResource(
this MockFileSystem fs,
string path,
Type typeInAssembly,
string embeddedResourcePath)
string resourceSubPath = "Data")
{
var resourcePath = $"{typeInAssembly.Namespace}.{embeddedResourcePath}";
fs.AddFileFromEmbeddedResource(path, typeInAssembly.Assembly, resourcePath);
fs.AddFileFromEmbeddedResource(fs.FileInfo.New(path), typeInAssembly, resourceSubPath);
}
public static IEnumerable<string> LeafDirectories(this MockFileSystem fs)

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Common.Extensions;
namespace Recyclarr.Tests.Common.Extensions;
@ -5,6 +6,8 @@ namespace Recyclarr.Tests.Common.Extensions;
[TestFixture]
public class DictionaryExtensionsTest
{
[SuppressMessage("SonarLint", "S2094:Classes should not be empty",
Justification = "This is for test code only")]
private sealed class MySampleValue;
[Test]

@ -52,18 +52,6 @@ public class ScopedStateTest
state.Value.Should().Be(50);
}
[Test]
public void AccessValue_ResetAfterScope_ReturnDefault()
{
var state = new ScopedState<int>(50);
state.PushValue(100, 1);
state.Reset(1).Should().BeTrue();
state.ActiveScope.Should().BeNull();
state.Value.Should().Be(50);
}
[Test]
public void AccessValue_WholeSectionScope_ReturnValueAcrossMultipleResets()
{

Loading…
Cancel
Save