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) 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; return null;

@ -47,7 +47,7 @@ public class ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor
} }
catch (FileExistsException e) catch (FileExistsException e)
{ {
log.Error( log.Error(e,
"The file {ConfigFile} already exists. Please choose another path or " + "The file {ConfigFile} already exists. Please choose another path or " +
"delete/move the existing file and run this command again", e.AttemptedPath); "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(); processor.Process();
return 0; return 0;
} }
catch (NoConfigurationFilesException) catch (NoConfigurationFilesException e)
{ {
log.Error("No configuration files found"); log.Error(e, "Unable to list local config files");
} }
return 1; return 1;

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

@ -1,8 +1,13 @@
namespace Recyclarr.Cli.Console.Helpers; namespace Recyclarr.Cli.Console.Helpers;
// Taken from: https://github.com/spectreconsole/spectre.console/issues/701#issuecomment-1081834778 // 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(); private readonly CancellationTokenSource _cts = new();
public CancellationToken Token => _cts.Token; public CancellationToken Token => _cts.Token;

@ -7,9 +7,14 @@ using Spectre.Console.Cli;
namespace Recyclarr.Cli.Console.Interceptors; namespace Recyclarr.Cli.Console.Interceptors;
public class BaseCommandSetupInterceptor(LoggingLevelSwitch loggingLevelSwitch, IAppDataSetup appDataSetup) internal sealed class BaseCommandSetupInterceptor(LoggingLevelSwitch loggingLevelSwitch, IAppDataSetup appDataSetup)
: ICommandInterceptor : ICommandInterceptor, IDisposable
{ {
public void Dispose()
{
_ct.Dispose();
}
private readonly ConsoleAppCancellationTokenSource _ct = new(); private readonly ConsoleAppCancellationTokenSource _ct = new();
public void Intercept(CommandContext context, CommandSettings settings) 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() 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); return new ExpressionTemplate(template, theme: TemplateTheme.Code);
} }
private static ExpressionTemplate GetFileTemplate() 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); return new ExpressionTemplate(template);
} }
@ -39,7 +41,8 @@ public class LoggerFactory(IAppPaths paths, LoggingLevelSwitch levelSwitch)
return new LoggerConfiguration() return new LoggerConfiguration()
.MinimumLevel.Is(LogEventLevel.Verbose) .MinimumLevel.Is(LogEventLevel.Verbose)
.Enrich.With<ExceptionMessageEnricher>() .Enrich.FromLogContext()
.Enrich.With<FlurlExceptionSanitizingEnricher>()
.WriteTo.Console(GetConsoleTemplate(), levelSwitch: levelSwitch) .WriteTo.Console(GetConsoleTemplate(), levelSwitch: levelSwitch)
.WriteTo.Logger(c => c .WriteTo.Logger(c => c
.MinimumLevel.Debug() .MinimumLevel.Debug()
@ -47,7 +50,6 @@ public class LoggerFactory(IAppPaths paths, LoggingLevelSwitch levelSwitch)
.WriteTo.Logger(c => c .WriteTo.Logger(c => c
.Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Verbose) .Filter.ByIncludingOnly(e => e.Level == LogEventLevel.Verbose)
.WriteTo.File(GetFileTemplate(), LogFilePath("verbose"))) .WriteTo.File(GetFileTemplate(), LogFilePath("verbose")))
.Enrich.FromLogContext()
.CreateLogger(); .CreateLogger();
string LogFilePath(string type) string LogFilePath(string type)

@ -26,8 +26,8 @@ public class QualityProfileLogPhase(ILogger log) : ILogPipelinePhase<QualityProf
log.Warning( log.Warning(
"The following quality profile names have no definition in the top-level `quality_profiles` " + "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 " + "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"); "them to the top-level `quality_profiles` section so that Recyclarr can create the profiles for " +
log.Warning("{QualityProfileNames}", transactions.NonExistentProfiles); "you: {QualityProfileNames}", transactions.NonExistentProfiles);
} }
if (transactions.InvalidProfiles.Count > 0) if (transactions.InvalidProfiles.Count > 0)

@ -72,7 +72,7 @@ public class QualityProfileStatCalculator(ILogger log)
foreach (var (dto, newScore, reason) in scores) 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); dto.Name, dto.Format, dto.Score, newScore, reason);
} }

@ -1,6 +1,6 @@
namespace Recyclarr.Cli.Processors.Config; 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; public string AttemptedPath { get; } = attemptedPath;
} }

@ -33,7 +33,7 @@ public class TemplateConfigCreator(
} }
catch (FileExistsException e) 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) catch (FileLoadException)
{ {
@ -54,7 +54,7 @@ public class TemplateConfigCreator(
if (destinationFile.Exists && !settings.Force) if (destinationFile.Exists && !settings.Force)
{ {
throw new FileExistsException($"{destinationFile} already exists"); throw new FileExistsException(destinationFile.FullName);
} }
destinationFile.CreateParentDirectory(); destinationFile.CreateParentDirectory();

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

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

@ -19,7 +19,6 @@
<PackageReference Include="SystemTextJson.JsonDiffPatch" /> <PackageReference Include="SystemTextJson.JsonDiffPatch" />
<PackageReference Include="TestableIO.System.IO.Abstractions" /> <PackageReference Include="TestableIO.System.IO.Abstractions" />
<PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" /> <PackageReference Include="TestableIO.System.IO.Abstractions.Wrappers" />
<PackageReference Include="YamlDotNet" />
</ItemGroup> </ItemGroup>
<!-- Following found during vulerabilities Code Scan --> <!-- 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 return
type is {IsInterface: true} && type.IsGenericTypeOf(collectionType) || 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 System.IO.Abstractions;
using JetBrains.Annotations; using JetBrains.Annotations;
using Recyclarr.Config.Parsing.ErrorHandling; using Recyclarr.Config.Parsing.ErrorHandling;
using Recyclarr.Settings;
using Recyclarr.Yaml; using Recyclarr.Yaml;
using YamlDotNet.Core; using YamlDotNet.Core;
using YamlDotNet.Serialization; using YamlDotNet.Serialization;
@ -37,28 +38,23 @@ public class ConfigParser(ILogger log, IYamlSerializerFactory yamlFactory)
} }
catch (YamlException e) catch (YamlException e)
{ {
log.Debug(e, "Exception while parsing config file");
var line = e.Start.Line; var line = e.Start.Line;
var contextualMsg = ConfigContextualMessages.GetContextualErrorFromException(e); switch (e.InnerException)
if (contextualMsg is not null)
{ {
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: var context = SettingsContextualMessages.GetContextualErrorFromException(e);
log.Error("Exception at line {Line}: {Msg}", line, e.InnerException?.Message ?? e.Message); if (context is not null)
break; {
} log.Error(context);
} }
} }

@ -1,3 +1,3 @@
namespace Recyclarr.Config.Parsing.ErrorHandling; 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); 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) 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); log.Debug("HTTP Request: {Method} {Url}", call.HttpRequestMessage.Method, url);
FlurlLogging.LogBody(log, url, "Request", call.HttpRequestMessage.Method, call.RequestBody); 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) public override async Task HandleAsync(FlurlEventType eventType, FlurlCall call)
{ {
var statusCode = call.Response?.StatusCode.ToString() ?? "(No response)"; 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); log.Debug("HTTP Response: {Status} {Method} {Url}", statusCode, call.HttpRequestMessage.Method, url);
var content = call.Response?.ResponseMessage.Content; var content = call.Response?.ResponseMessage.Content;
@ -45,7 +45,7 @@ public class FlurlRedirectPreventer(ILogger log) : FlurlSpecificEventHandler
public override void Handle(FlurlEventType eventType, FlurlCall call) 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}", 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 // 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 // communication stops and existing methods will return nothing / null. This messes with Observable

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

@ -1,23 +1,36 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Flurl.Http; using Flurl;
using Recyclarr.Common.Extensions;
namespace Recyclarr.Http; 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 // 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 // There are sometimes parenthetical parts of the message that contain the host but are not
// detected as true URLs. Just strip those out completely. // detected as true URLs. Just strip those out completely.
return HostRegex().Replace(result, ""); 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]+)?\)")] [GeneratedRegex(@"\([-a-zA-Z0-9@:%._+~#=]{1,256}(?::[0-9]+)?\)")]

@ -25,7 +25,7 @@ public class RepoUpdater(ILogger log, IGitRepositoryFactory repositoryFactory) :
} }
catch (GitCmdException e) 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) catch (InvalidGitRepoException e)
{ {
@ -63,7 +63,7 @@ public class RepoUpdater(ILogger log, IGitRepositoryFactory repositoryFactory) :
} }
catch (GitCmdException e) 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( log.Error(
"Updating the repo '{RepoDir}' (git fetch) failed. Proceeding with existing files. " + "Updating the repo '{RepoDir}' (git fetch) failed. Proceeding with existing files. " +
"Check clone URL is correct and that github is not down", "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 public class CustomFormatApiService(IServarrRequestBuilder service) : ICustomFormatApiService
{ {
private IFlurlRequest Request(params object[] path)
{
return service.Request(["customformat", ..path]);
}
public async Task<IList<CustomFormatData>> GetCustomFormats() public async Task<IList<CustomFormatData>> GetCustomFormats()
{ {
return await service.Request("customformat") return await Request()
.GetJsonAsync<IList<CustomFormatData>>(); .GetJsonAsync<IList<CustomFormatData>>();
} }
public async Task<CustomFormatData?> CreateCustomFormat(CustomFormatData cf) public async Task<CustomFormatData?> CreateCustomFormat(CustomFormatData cf)
{ {
return await service.Request("customformat") return await Request()
.PostJsonAsync(cf) .PostJsonAsync(cf)
.ReceiveJson<CustomFormatData>(); .ReceiveJson<CustomFormatData>();
} }
public async Task UpdateCustomFormat(CustomFormatData cf) public async Task UpdateCustomFormat(CustomFormatData cf)
{ {
await service.Request("customformat", cf.Id) await Request(cf.Id)
.PutJsonAsync(cf); .PutJsonAsync(cf);
} }
public async Task DeleteCustomFormat(int customFormatId, CancellationToken cancellationToken = default) public async Task DeleteCustomFormat(int customFormatId, CancellationToken cancellationToken = default)
{ {
await service.Request("customformat", customFormatId) await Request(customFormatId)
.DeleteAsync(cancellationToken: cancellationToken); .DeleteAsync(cancellationToken: cancellationToken);
} }
} }

@ -4,9 +4,14 @@ namespace Recyclarr.ServarrApi.QualityProfile;
internal class QualityProfileApiService(IServarrRequestBuilder service) : IQualityProfileApiService internal class QualityProfileApiService(IServarrRequestBuilder service) : IQualityProfileApiService
{ {
private IFlurlRequest Request(params object[] path)
{
return service.Request(["qualityprofile", ..path]);
}
public async Task<IList<QualityProfileDto>> GetQualityProfiles() public async Task<IList<QualityProfileDto>> GetQualityProfiles()
{ {
var response = await service.Request("qualityprofile") var response = await Request()
.GetJsonAsync<IList<QualityProfileDto>>(); .GetJsonAsync<IList<QualityProfileDto>>();
return response.Select(x => x.ReverseItems()).ToList(); return response.Select(x => x.ReverseItems()).ToList();
@ -14,7 +19,7 @@ internal class QualityProfileApiService(IServarrRequestBuilder service) : IQuali
public async Task<QualityProfileDto> GetSchema() public async Task<QualityProfileDto> GetSchema()
{ {
var response = await service.Request("qualityprofile", "schema") var response = await Request("schema")
.GetJsonAsync<QualityProfileDto>(); .GetJsonAsync<QualityProfileDto>();
return response.ReverseItems(); 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}"); 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()); .PutJsonAsync(profile.ReverseItems());
} }
public async Task CreateQualityProfile(QualityProfileDto profile) public async Task CreateQualityProfile(QualityProfileDto profile)
{ {
var response = await service.Request("qualityprofile") var response = await Request()
.PostJsonAsync(profile.ReverseItems()) .PostJsonAsync(profile.ReverseItems())
.ReceiveJson<QualityProfileDto>(); .ReceiveJson<QualityProfileDto>();

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

@ -1,11 +1,8 @@
namespace Recyclarr.VersionControl; 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; public int ExitCode { get; } = exitCode;
// ReSharper restore UnusedAutoPropertyAccessor.Global
} }
public class InvalidGitRepoException(string? message) : Exception(message); 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>(); var mapper = Resolve<MapperConfiguration>();
// Build an execution plan like: // 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 // And do `plan.ToReadableString()` in the Debug Expressions/Watch
mapper.AssertConfigurationIsValid(); mapper.AssertConfigurationIsValid();
} }

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Autofac.Extras.Ordering; using Autofac.Extras.Ordering;
using Recyclarr.Cli.Migration; using Recyclarr.Cli.Migration;
using Recyclarr.Cli.Migration.Steps; using Recyclarr.Cli.Migration.Steps;
@ -78,6 +79,9 @@ public class MigrationExecutorTest
} }
[Test] [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() public void Migration_exceptions_are_not_converted()
{ {
using var console = new TestConsole(); using var console = new TestConsole();

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

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

@ -52,18 +52,6 @@ public class ScopedStateTest
state.Value.Should().Be(50); 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] [Test]
public void AccessValue_WholeSectionScope_ReturnValueAcrossMultipleResets() public void AccessValue_WholeSectionScope_ReturnValueAcrossMultipleResets()
{ {

Loading…
Cancel
Save