From 5bb2bfa8a0cf71f7c0919306a9f23dd4642e2732 Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Fri, 1 Sep 2023 18:23:33 -0500 Subject: [PATCH] feat: YAML includes Fixes #175 --- CHANGELOG.md | 3 + .../Config/ConfigListTemplateProcessor.cs | 2 +- .../Processors/Config/ConfigManipulator.cs | 4 +- .../Config/TemplateConfigCreator.cs | 2 +- .../ErrorHandling/ConsoleExceptionHandler.cs | 4 + .../Extensions/CollectionExtensions.cs | 7 + .../Extensions/StringExtensions.cs | 26 + .../IRuntimeValidationService.cs | 2 +- .../RuntimeValidationService.cs | 9 +- .../Config/ConfigAutofacModule.cs | 18 +- .../Config/Parsing/ConfigParser.cs | 20 +- .../Parsing/ConfigValidationExecutor.cs | 4 +- .../Config/Parsing/ConfigYamlDataObjects.cs | 6 +- .../ConfigYamlDataObjectsValidation.cs | 18 +- .../Config/Parsing/ConfigurationLoader.cs | 14 +- .../ConfigMerging/ConfigIncludeProcessor.cs | 45 ++ .../ConfigMerging/IIncludeProcessor.cs | 9 + .../ConfigMerging/IYamlIncludeResolver.cs | 8 + .../PolymorphicIncludeYamlBehavior.cs | 20 + .../ConfigMerging/RadarrConfigMerger.cs | 5 + .../ConfigMerging/ServiceConfigMerger.cs | 86 +++ .../ConfigMerging/SonarrConfigMerger.cs | 13 + .../ConfigMerging/TemplateIncludeProcessor.cs | 42 ++ .../ConfigMerging/YamlIncludeDataObjects.cs | 22 + .../ConfigMerging/YamlIncludeException.cs | 9 + .../ConfigMerging/YamlIncludeResolver.cs | 30 + .../PostProcessing/IncludePostProcessor.cs | 105 ++++ .../Config/Parsing/YamlValidatorRuleSets.cs | 6 + .../Services/ConfigTemplateGuideService.cs | 26 +- .../Services/IConfigTemplateGuideService.cs | 3 +- .../ExceptionTypes/PostProcessingException.cs | 9 + .../Processors/ConfigTemplateListerTest.cs | 2 +- .../Extensions/StringExtensionsTest.cs | 7 + .../ConfigIncludeProcessorTest.cs | 101 ++++ .../ConfigMerging/ServiceConfigMergerTest.cs | 525 ++++++++++++++++++ .../ConfigMerging/SonarrConfigMergerTest.cs | 134 +++++ .../TemplateIncludeProcessorTest.cs | 102 ++++ .../ConfigMerging/YamlIncludeResolverTest.cs | 72 +++ .../IncludePostProcessorIntegrationTest.cs | 176 ++++++ .../ConfigTemplateGuideServiceTest.cs | 4 +- .../Config/YamlConfigValidatorTest.cs | 6 +- 41 files changed, 1657 insertions(+), 49 deletions(-) create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessor.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/IIncludeProcessor.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/IYamlIncludeResolver.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/PolymorphicIncludeYamlBehavior.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/RadarrConfigMerger.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMerger.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMerger.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/TemplateIncludeProcessor.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeDataObjects.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeException.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeResolver.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/IncludePostProcessor.cs create mode 100644 src/Recyclarr.TrashLib/Config/Parsing/YamlValidatorRuleSets.cs create mode 100644 src/Recyclarr.TrashLib/ExceptionTypes/PostProcessingException.cs create mode 100644 src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessorTest.cs create mode 100644 src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMergerTest.cs create mode 100644 src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMergerTest.cs create mode 100644 src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/TemplateIncludeProcessorTest.cs create mode 100644 src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeResolverTest.cs create mode 100644 src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/IncludePostProcessorIntegrationTest.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index fe9eaea1..800c1fd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Print date & time log at the end of each completed instance sync (#165). - Add status indicator when cloning or updating git repos. +- YAML includes are now supported (#175) ([docs][includes]). ### Changed @@ -23,6 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Service failures (e.g. HTTP 500) no longer cause exceptions (#206). - Error out when duplicate instance names are used. +[includes]: https://recyclarr.dev/wiki/yaml/config-reference/include/ + ## [5.3.1] - 2023-08-21 ### Fixed diff --git a/src/Recyclarr.Cli/Processors/Config/ConfigListTemplateProcessor.cs b/src/Recyclarr.Cli/Processors/Config/ConfigListTemplateProcessor.cs index c31a31f1..d621cf25 100644 --- a/src/Recyclarr.Cli/Processors/Config/ConfigListTemplateProcessor.cs +++ b/src/Recyclarr.Cli/Processors/Config/ConfigListTemplateProcessor.cs @@ -22,7 +22,7 @@ public class ConfigListTemplateProcessor private void ListTemplates() { - var data = _guideService.LoadTemplateData(); + var data = _guideService.GetTemplateData(); var table = new Table(); var empty = new Markup(""); diff --git a/src/Recyclarr.Cli/Processors/Config/ConfigManipulator.cs b/src/Recyclarr.Cli/Processors/Config/ConfigManipulator.cs index 5148ee6c..f85614f3 100644 --- a/src/Recyclarr.Cli/Processors/Config/ConfigManipulator.cs +++ b/src/Recyclarr.Cli/Processors/Config/ConfigManipulator.cs @@ -60,7 +60,7 @@ public class ConfigManipulator : IConfigManipulator // - Run validation & report issues // - Consistently reformat the output file (when it is saved again) // - Ignore stuff for diffing purposes, such as comments. - var config = _configParser.Load(source); + var config = _configParser.Load(source); if (config is null) { // Do not log here, since ConfigParser already has substantial logging @@ -73,7 +73,7 @@ public class ConfigManipulator : IConfigManipulator Sonarr = InvokeCallbackForEach(editCallback, config.Sonarr) }; - if (!_validator.Validate(config)) + if (!_validator.Validate(config, YamlValidatorRuleSets.RootConfig)) { _console.WriteLine( "The configuration file will still be created, despite the previous validation errors. " + diff --git a/src/Recyclarr.Cli/Processors/Config/TemplateConfigCreator.cs b/src/Recyclarr.Cli/Processors/Config/TemplateConfigCreator.cs index 7fcbebde..68a34593 100644 --- a/src/Recyclarr.Cli/Processors/Config/TemplateConfigCreator.cs +++ b/src/Recyclarr.Cli/Processors/Config/TemplateConfigCreator.cs @@ -33,7 +33,7 @@ public class TemplateConfigCreator : IConfigCreator { _log.Debug("Creating config from templates: {Templates}", settings.Templates); - var matchingTemplateData = _templates.LoadTemplateData() + var matchingTemplateData = _templates.GetTemplateData() .IntersectBy(settings.Templates, path => path.Id, StringComparer.CurrentCultureIgnoreCase) .Select(x => x.TemplateFile); diff --git a/src/Recyclarr.Cli/Processors/ErrorHandling/ConsoleExceptionHandler.cs b/src/Recyclarr.Cli/Processors/ErrorHandling/ConsoleExceptionHandler.cs index a5de6675..229d1025 100644 --- a/src/Recyclarr.Cli/Processors/ErrorHandling/ConsoleExceptionHandler.cs +++ b/src/Recyclarr.Cli/Processors/ErrorHandling/ConsoleExceptionHandler.cs @@ -56,6 +56,10 @@ public class ConsoleExceptionHandler _log.Error("Manually-specified configuration files do not exist: {Files}", e.InvalidFiles); break; + case PostProcessingException e: + _log.Error("Configuration post-processing failed: {Message}", e.Message); + break; + case CommandException e: _log.Error(e.Message); break; diff --git a/src/Recyclarr.Common/Extensions/CollectionExtensions.cs b/src/Recyclarr.Common/Extensions/CollectionExtensions.cs index 0071b5bf..0d078670 100644 --- a/src/Recyclarr.Common/Extensions/CollectionExtensions.cs +++ b/src/Recyclarr.Common/Extensions/CollectionExtensions.cs @@ -89,4 +89,11 @@ public static class CollectionExtensions { return items.SelectMany(x => flattenWhich(x).Flatten(flattenWhich).Append(x)); } + + public static async Task> SelectAsync( + this IEnumerable source, + Func> method) + { + return await Task.WhenAll(source.Select(async s => await method(s))); + } } diff --git a/src/Recyclarr.Common/Extensions/StringExtensions.cs b/src/Recyclarr.Common/Extensions/StringExtensions.cs index f87bd529..dce50de4 100644 --- a/src/Recyclarr.Common/Extensions/StringExtensions.cs +++ b/src/Recyclarr.Common/Extensions/StringExtensions.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Text; // ReSharper disable UnusedMember.Global @@ -50,4 +51,29 @@ public static class StringExtensions { return char.ToLowerInvariant(value[0]) + value[1..]; } + + public static string ToSnakeCase(this string text) + { + if (text.Length < 2) + { + return text; + } + + var sb = new StringBuilder(); + sb.Append(char.ToLowerInvariant(text[0])); + foreach (var c in text[1..]) + { + if (char.IsUpper(c)) + { + sb.Append('_'); + sb.Append(char.ToLowerInvariant(c)); + } + else + { + sb.Append(c); + } + } + + return sb.ToString(); + } } diff --git a/src/Recyclarr.Common/FluentValidation/IRuntimeValidationService.cs b/src/Recyclarr.Common/FluentValidation/IRuntimeValidationService.cs index 453a92b1..90a9a18a 100644 --- a/src/Recyclarr.Common/FluentValidation/IRuntimeValidationService.cs +++ b/src/Recyclarr.Common/FluentValidation/IRuntimeValidationService.cs @@ -4,5 +4,5 @@ namespace Recyclarr.Common.FluentValidation; public interface IRuntimeValidationService { - ValidationResult Validate(object instance); + ValidationResult Validate(object instance, params string[] ruleSets); } diff --git a/src/Recyclarr.Common/FluentValidation/RuntimeValidationService.cs b/src/Recyclarr.Common/FluentValidation/RuntimeValidationService.cs index d4a0dbe9..f910b82f 100644 --- a/src/Recyclarr.Common/FluentValidation/RuntimeValidationService.cs +++ b/src/Recyclarr.Common/FluentValidation/RuntimeValidationService.cs @@ -1,4 +1,5 @@ using FluentValidation; +using FluentValidation.Internal; using FluentValidation.Results; namespace Recyclarr.Common.FluentValidation; @@ -21,13 +22,17 @@ public class RuntimeValidationService : IRuntimeValidationService .ToDictionary(x => x.Item2!.GetGenericArguments()[0], x => x.Item1); } - public ValidationResult Validate(object instance) + public ValidationResult Validate(object instance, params string[] ruleSets) { if (!_validators.TryGetValue(instance.GetType(), out var validator)) { throw new ValidationException($"No validator is available for type: {instance.GetType().FullName}"); } - return validator.Validate(new ValidationContext(instance)); + IValidatorSelector validatorSelector = ruleSets.Any() + ? new RulesetValidatorSelector(ruleSets) + : new DefaultValidatorSelector(); + + return validator.Validate(new ValidationContext(instance, new PropertyChain(), validatorSelector)); } } diff --git a/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs b/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs index fc103b19..ed9971c8 100644 --- a/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs +++ b/src/Recyclarr.TrashLib/Config/ConfigAutofacModule.cs @@ -2,6 +2,7 @@ using Autofac; using FluentValidation; using Recyclarr.TrashLib.Config.Parsing; using Recyclarr.TrashLib.Config.Parsing.PostProcessing; +using Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; using Recyclarr.TrashLib.Config.Secrets; using Recyclarr.TrashLib.Config.Services; using Recyclarr.TrashLib.Config.Yaml; @@ -23,19 +24,32 @@ public class ConfigAutofacModule : Module builder.RegisterType().As().SingleInstance(); builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); + builder.RegisterType().As(); builder.RegisterType().As(); builder.RegisterType().As(); builder.RegisterType().As(); - builder.RegisterType().As(); + builder.RegisterType().As().SingleInstance(); builder.RegisterType(); builder.RegisterType(); builder.RegisterType(); // Config Post Processors builder.RegisterType().As(); + builder.RegisterType().As(); + + RegisterValidators(builder); + } - // Validators + private static void RegisterValidators(ContainerBuilder builder) + { builder.RegisterType().As(); + + // These validators are required by IncludePostProcessor + builder.RegisterType().As(); + builder.RegisterType().As(); } } diff --git a/src/Recyclarr.TrashLib/Config/Parsing/ConfigParser.cs b/src/Recyclarr.TrashLib/Config/Parsing/ConfigParser.cs index 57c01626..6fbe7334 100644 --- a/src/Recyclarr.TrashLib/Config/Parsing/ConfigParser.cs +++ b/src/Recyclarr.TrashLib/Config/Parsing/ConfigParser.cs @@ -19,30 +19,24 @@ public class ConfigParser _deserializer = yamlFactory.CreateDeserializer(); } - public RootConfigYaml? Load(IFileInfo file) + public T? Load(IFileInfo file) where T : class { _log.Debug("Loading config file: {File}", file); - return Load(file.OpenText); + return Load(file.OpenText); } - public RootConfigYaml? Load(string yaml) + public T? Load(string yaml) where T : class { _log.Debug("Loading config from string data"); - return Load(() => new StringReader(yaml)); + return Load(() => new StringReader(yaml)); } - public RootConfigYaml? Load(Func streamFactory) + public T? Load(Func streamFactory) where T : class { try { using var stream = streamFactory(); - var config = _deserializer.Deserialize(stream); - if (config.IsConfigEmpty()) - { - _log.Warning("Configuration is empty"); - } - - return config; + return _deserializer.Deserialize(stream); } catch (FeatureRemovalException e) { @@ -69,6 +63,6 @@ public class ConfigParser } _log.Error("Due to previous exception, this config will be skipped"); - return null; + return default; } } diff --git a/src/Recyclarr.TrashLib/Config/Parsing/ConfigValidationExecutor.cs b/src/Recyclarr.TrashLib/Config/Parsing/ConfigValidationExecutor.cs index bac76204..94715840 100644 --- a/src/Recyclarr.TrashLib/Config/Parsing/ConfigValidationExecutor.cs +++ b/src/Recyclarr.TrashLib/Config/Parsing/ConfigValidationExecutor.cs @@ -15,9 +15,9 @@ public class ConfigValidationExecutor _validationService = validationService; } - public bool Validate(object config) + public bool Validate(object config, params string[] ruleSets) { - var result = _validationService.Validate(config); + var result = _validationService.Validate(config, ruleSets); if (result.IsValid) { return true; diff --git a/src/Recyclarr.TrashLib/Config/Parsing/ConfigYamlDataObjects.cs b/src/Recyclarr.TrashLib/Config/Parsing/ConfigYamlDataObjects.cs index 716d353c..3e8df15c 100644 --- a/src/Recyclarr.TrashLib/Config/Parsing/ConfigYamlDataObjects.cs +++ b/src/Recyclarr.TrashLib/Config/Parsing/ConfigYamlDataObjects.cs @@ -2,6 +2,7 @@ using System.ComponentModel; using System.Diagnostics.CodeAnalysis; using JetBrains.Annotations; using Recyclarr.TrashLib.Config.Parsing.BackwardCompatibility; +using Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; using Recyclarr.TrashLib.Config.Services; using YamlDotNet.Serialization; @@ -76,12 +77,13 @@ public record ServiceConfigYaml [SuppressMessage("Design", "CA1056:URI-like properties should not be strings")] public string? BaseUrl { get; init; } - public bool DeleteOldCustomFormats { get; init; } - public bool ReplaceExistingCustomFormats { get; init; } + public bool? DeleteOldCustomFormats { get; init; } + public bool? ReplaceExistingCustomFormats { get; init; } public IReadOnlyCollection? CustomFormats { get; init; } public QualitySizeConfigYaml? QualityDefinition { get; init; } public IReadOnlyCollection? QualityProfiles { get; init; } + public IReadOnlyCollection? Include { get; init; } } [UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] diff --git a/src/Recyclarr.TrashLib/Config/Parsing/ConfigYamlDataObjectsValidation.cs b/src/Recyclarr.TrashLib/Config/Parsing/ConfigYamlDataObjectsValidation.cs index 26384fc7..15ff7213 100644 --- a/src/Recyclarr.TrashLib/Config/Parsing/ConfigYamlDataObjectsValidation.cs +++ b/src/Recyclarr.TrashLib/Config/Parsing/ConfigYamlDataObjectsValidation.cs @@ -8,12 +8,18 @@ public class ServiceConfigYamlValidator : AbstractValidator { public ServiceConfigYamlValidator() { - RuleFor(x => x.BaseUrl).Cascade(CascadeMode.Stop) - .NotEmpty().Must(x => x!.StartsWith("http")) - .WithMessage("{PropertyName} must start with 'http' or 'https'") - .WithName("base_url"); - - RuleFor(x => x.ApiKey).NotEmpty().WithName("api_key"); + RuleSet(YamlValidatorRuleSets.RootConfig, () => + { + RuleFor(x => x.BaseUrl).Cascade(CascadeMode.Stop) + .NotEmpty() + .Must(x => x!.StartsWith("http")) + .WithMessage("{PropertyName} must start with 'http' or 'https'") + .WithName("base_url"); + + RuleFor(x => x.ApiKey) + .NotEmpty() + .WithName("api_key"); + }); RuleFor(x => x.CustomFormats) .NotEmpty().When(x => x.CustomFormats is not null) diff --git a/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationLoader.cs b/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationLoader.cs index 84947ba8..05cd9118 100644 --- a/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationLoader.cs +++ b/src/Recyclarr.TrashLib/Config/Parsing/ConfigurationLoader.cs @@ -8,17 +8,20 @@ namespace Recyclarr.TrashLib.Config.Parsing; public class ConfigurationLoader : IConfigurationLoader { + private readonly ILogger _log; private readonly ConfigParser _parser; private readonly IMapper _mapper; private readonly ConfigValidationExecutor _validator; private readonly IEnumerable _postProcessors; public ConfigurationLoader( + ILogger log, ConfigParser parser, IMapper mapper, ConfigValidationExecutor validator, IEnumerable postProcessors) { + _log = log; _parser = parser; _mapper = mapper; _validator = validator; @@ -28,17 +31,17 @@ public class ConfigurationLoader : IConfigurationLoader public IReadOnlyCollection Load(IFileInfo file) { using var logScope = LogContext.PushProperty(LogProperty.Scope, file.Name); - return ProcessLoadedConfigs(_parser.Load(file)); + return ProcessLoadedConfigs(_parser.Load(file)); } public IReadOnlyCollection Load(string yaml) { - return ProcessLoadedConfigs(_parser.Load(yaml)); + return ProcessLoadedConfigs(_parser.Load(yaml)); } public IReadOnlyCollection Load(Func streamFactory) { - return ProcessLoadedConfigs(_parser.Load(streamFactory)); + return ProcessLoadedConfigs(_parser.Load(streamFactory)); } private IReadOnlyCollection ProcessLoadedConfigs(RootConfigYaml? config) @@ -50,6 +53,11 @@ public class ConfigurationLoader : IConfigurationLoader config = _postProcessors.Aggregate(config, (current, processor) => processor.Process(current)); + if (config.IsConfigEmpty()) + { + _log.Warning("Configuration is empty"); + } + if (!_validator.Validate(config)) { return Array.Empty(); diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessor.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessor.cs new file mode 100644 index 00000000..2e5b92bb --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessor.cs @@ -0,0 +1,45 @@ +using System.IO.Abstractions; +using Recyclarr.TrashLib.Startup; + +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +public class ConfigIncludeProcessor : IIncludeProcessor +{ + private readonly IFileSystem _fs; + private readonly IAppPaths _paths; + + public ConfigIncludeProcessor(IFileSystem fs, IAppPaths paths) + { + _fs = fs; + _paths = paths; + } + + public bool CanProcess(IYamlInclude includeDirective) + { + return includeDirective is ConfigYamlInclude; + } + + public IFileInfo GetPathToConfig(IYamlInclude includeDirective, SupportedServices serviceType) + { + var include = (ConfigYamlInclude) includeDirective; + + if (include.Config is null) + { + throw new YamlIncludeException("`config` property is required."); + } + + var rooted = _fs.Path.IsPathRooted(include.Config); + + var configFile = rooted + ? _fs.FileInfo.New(include.Config) + : _paths.ConfigsDirectory.File(include.Config); + + if (!configFile.Exists) + { + var pathType = rooted ? "Absolute" : "Relative"; + throw new YamlIncludeException($"{pathType} include path does not exist: {configFile.FullName}"); + } + + return configFile; + } +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/IIncludeProcessor.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/IIncludeProcessor.cs new file mode 100644 index 00000000..e2d87f64 --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/IIncludeProcessor.cs @@ -0,0 +1,9 @@ +using System.IO.Abstractions; + +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +public interface IIncludeProcessor +{ + bool CanProcess(IYamlInclude includeDirective); + IFileInfo GetPathToConfig(IYamlInclude includeDirective, SupportedServices serviceType); +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/IYamlIncludeResolver.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/IYamlIncludeResolver.cs new file mode 100644 index 00000000..beaeb902 --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/IYamlIncludeResolver.cs @@ -0,0 +1,8 @@ +using System.IO.Abstractions; + +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +public interface IYamlIncludeResolver +{ + IFileInfo GetIncludePath(IYamlInclude includeType, SupportedServices serviceType); +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/PolymorphicIncludeYamlBehavior.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/PolymorphicIncludeYamlBehavior.cs new file mode 100644 index 00000000..73b9a3d0 --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/PolymorphicIncludeYamlBehavior.cs @@ -0,0 +1,20 @@ +using JetBrains.Annotations; +using Recyclarr.Common.Extensions; +using Recyclarr.TrashLib.Config.Yaml; +using YamlDotNet.Serialization; + +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +[UsedImplicitly] +public class PolymorphicIncludeYamlBehavior : IYamlBehavior +{ + public void Setup(DeserializerBuilder builder) + { + builder.WithTypeDiscriminatingNodeDeserializer(o => o + .AddUniqueKeyTypeDiscriminator(new Dictionary + { + [nameof(ConfigYamlInclude.Config).ToSnakeCase()] = typeof(ConfigYamlInclude), + [nameof(TemplateYamlInclude.Template).ToSnakeCase()] = typeof(TemplateYamlInclude) + })); + } +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/RadarrConfigMerger.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/RadarrConfigMerger.cs new file mode 100644 index 00000000..040aff7f --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/RadarrConfigMerger.cs @@ -0,0 +1,5 @@ +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +public class RadarrConfigMerger : ServiceConfigMerger +{ +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMerger.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMerger.cs new file mode 100644 index 00000000..8a9c9158 --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMerger.cs @@ -0,0 +1,86 @@ +using System.Diagnostics.CodeAnalysis; + +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +[SuppressMessage("ReSharper", "WithExpressionModifiesAllMembers")] +public abstract class ServiceConfigMerger where T : ServiceConfigYaml +{ + protected static TVal? Combine(TVal? a, TVal? b, Func combine) + { + if (b is null) + { + return a; + } + + if (a is null) + { + return b; + } + + return combine(a, b); + } + + public virtual T Merge(T a, T b) + { + return a with + { + CustomFormats = Combine(a.CustomFormats, b.CustomFormats, (x, y) => x.Concat(y).ToList()), + QualityProfiles = MergeQualityProfiles(a.QualityProfiles, b.QualityProfiles), + QualityDefinition = Combine(a.QualityDefinition, b.QualityDefinition, + (x, y) => x with + { + Type = y.Type ?? x.Type, + PreferredRatio = y.PreferredRatio ?? x.PreferredRatio + }), + + DeleteOldCustomFormats = + b.DeleteOldCustomFormats ?? a.DeleteOldCustomFormats, + + ReplaceExistingCustomFormats = + b.ReplaceExistingCustomFormats ?? a.ReplaceExistingCustomFormats + }; + } + + private static IReadOnlyCollection? MergeQualityProfiles( + IReadOnlyCollection? a, + IReadOnlyCollection? b) + { + return Combine(a, b, (x1, y1) => + { + return x1 + .FullOuterJoin(y1, JoinType.Hash, + x => x.Name, + x => x.Name, + l => l, + r => r, + MergeQualityProfile, + StringComparer.InvariantCultureIgnoreCase) + .ToList(); + }); + } + + private static QualityProfileConfigYaml MergeQualityProfile(QualityProfileConfigYaml a, QualityProfileConfigYaml b) + { + return a with + { + Upgrade = Combine(a.Upgrade, b.Upgrade, (x1, y1) => x1 with + { + Allowed = y1.Allowed ?? x1.Allowed, + UntilQuality = y1.UntilQuality ?? x1.UntilQuality, + UntilScore = y1.UntilScore ?? x1.UntilScore + }), + MinFormatScore = b.MinFormatScore ?? a.MinFormatScore, + QualitySort = b.QualitySort ?? a.QualitySort, + ScoreSet = b.ScoreSet ?? a.ScoreSet, + ResetUnmatchedScores = Combine(a.ResetUnmatchedScores, b.ResetUnmatchedScores, (x1, y1) => x1 with + { + Enabled = y1.Enabled ?? x1.Enabled, + Except = Combine(x1.Except, y1.Except, (x2, y2) => Combine(x2, y2, (x3, y3) => x3 + .Concat(y3) + .Distinct(StringComparer.InvariantCultureIgnoreCase) + .ToList())) + }), + Qualities = Combine(a.Qualities, b.Qualities, (x1, y1) => x1.Concat(y1).ToList()) + }; + } +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMerger.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMerger.cs new file mode 100644 index 00000000..f0ef03b6 --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMerger.cs @@ -0,0 +1,13 @@ +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +public class SonarrConfigMerger : ServiceConfigMerger +{ + public override SonarrConfigYaml Merge(SonarrConfigYaml a, SonarrConfigYaml b) + { + return base.Merge(a, b) with + { + ReleaseProfiles = Combine(a.ReleaseProfiles, b.ReleaseProfiles, + (x, y) => x.Concat(y).ToList()) + }; + } +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/TemplateIncludeProcessor.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/TemplateIncludeProcessor.cs new file mode 100644 index 00000000..8191878e --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/TemplateIncludeProcessor.cs @@ -0,0 +1,42 @@ +using System.IO.Abstractions; +using Recyclarr.Common.Extensions; +using Recyclarr.TrashLib.Config.Services; + +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +public class TemplateIncludeProcessor : IIncludeProcessor +{ + private readonly IConfigTemplateGuideService _templates; + + public TemplateIncludeProcessor(IConfigTemplateGuideService templates) + { + _templates = templates; + } + + public bool CanProcess(IYamlInclude includeDirective) + { + return includeDirective is TemplateYamlInclude; + } + + public IFileInfo GetPathToConfig(IYamlInclude includeDirective, SupportedServices serviceType) + { + var include = (TemplateYamlInclude) includeDirective; + + if (include.Template is null) + { + throw new YamlIncludeException("`template` property is required."); + } + + var includePath = _templates.GetIncludeData() + .Where(x => x.Service == serviceType) + .FirstOrDefault(x => x.Id.EqualsIgnoreCase(include.Template)); + + if (includePath is null) + { + throw new YamlIncludeException( + $"For service type '{serviceType}', unable to find config template with name '{include.Template}'"); + } + + return includePath.TemplateFile; + } +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeDataObjects.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeDataObjects.cs new file mode 100644 index 00000000..53981ee9 --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeDataObjects.cs @@ -0,0 +1,22 @@ +using System.Diagnostics.CodeAnalysis; +using JetBrains.Annotations; + +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +[SuppressMessage("Design", "CA1040:Avoid empty interfaces", Justification = + "Used for type-discriminating node deserializer")] +public interface IYamlInclude +{ +} + +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public record ConfigYamlInclude : IYamlInclude +{ + public string? Config { get; init; } +} + +[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)] +public record TemplateYamlInclude : IYamlInclude +{ + public string? Template { get; init; } +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeException.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeException.cs new file mode 100644 index 00000000..887a4916 --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeException.cs @@ -0,0 +1,9 @@ +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +public class YamlIncludeException : Exception +{ + public YamlIncludeException(string? message) + : base(message) + { + } +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeResolver.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeResolver.cs new file mode 100644 index 00000000..1a4b1cb7 --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeResolver.cs @@ -0,0 +1,30 @@ +using System.IO.Abstractions; + +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +public class YamlIncludeResolver : IYamlIncludeResolver +{ + private readonly IReadOnlyCollection _includeProcessors; + + public YamlIncludeResolver(IReadOnlyCollection includeProcessors) + { + _includeProcessors = includeProcessors; + } + + public IFileInfo GetIncludePath(IYamlInclude includeType, SupportedServices serviceType) + { + var processor = _includeProcessors.FirstOrDefault(x => x.CanProcess(includeType)); + if (processor is null) + { + throw new YamlIncludeException("Include type is not supported"); + } + + var yamlFile = processor.GetPathToConfig(includeType, serviceType); + if (!yamlFile.Exists) + { + throw new YamlIncludeException($"Included YAML file does not exist: {yamlFile.FullName}"); + } + + return yamlFile; + } +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/IncludePostProcessor.cs b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/IncludePostProcessor.cs new file mode 100644 index 00000000..ee4d8f8e --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/PostProcessing/IncludePostProcessor.cs @@ -0,0 +1,105 @@ +using Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; +using Serilog.Context; + +namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing; + +public class IncludePostProcessor : IConfigPostProcessor +{ + private readonly ILogger _log; + private readonly ConfigParser _parser; + private readonly ConfigValidationExecutor _validator; + private readonly IYamlIncludeResolver _includeResolver; + + public IncludePostProcessor( + ILogger log, + ConfigParser parser, + ConfigValidationExecutor validator, + IYamlIncludeResolver includeResolver) + { + _log = log; + _parser = parser; + _validator = validator; + _includeResolver = includeResolver; + } + + public RootConfigYaml Process(RootConfigYaml config) + { + return new RootConfigYaml + { + Radarr = ProcessIncludes(config.Radarr, new RadarrConfigMerger(), SupportedServices.Radarr), + Sonarr = ProcessIncludes(config.Sonarr, new SonarrConfigMerger(), SupportedServices.Sonarr) + }; + } + + private IReadOnlyDictionary? ProcessIncludes( + IReadOnlyDictionary? configs, + ServiceConfigMerger merger, + SupportedServices serviceType) + where T : ServiceConfigYaml, new() + { + if (configs is null) + { + return null; + } + + var mergedConfigs = new Dictionary(); + + foreach (var (key, config) in configs) + { + if (config.Include is null) + { + mergedConfigs.Add(key, config); + continue; + } + + // Combine all includes together first + var aggregateInclude = config.Include + .Select(x => LoadYamlInclude(x, serviceType)) + .Aggregate(new T(), merger.Merge); + + // Merge the config into the aggregated includes so that root config values overwrite included values. + mergedConfigs.Add(key, merger.Merge(aggregateInclude, config) with + { + // No reason to keep these around anymore now that they have been merged + Include = null + }); + } + + return mergedConfigs; + } + + private T LoadYamlInclude(IYamlInclude includeType, SupportedServices serviceType) + where T : ServiceConfigYaml + { + var yamlFile = _includeResolver.GetIncludePath(includeType, serviceType); + using var logScope = LogContext.PushProperty(LogProperty.Scope, $"Include {yamlFile.Name}"); + + var configToMerge = _parser.Load(yamlFile); + if (configToMerge is null) + { + throw new YamlIncludeException($"Failed to parse include file: {yamlFile.FullName}"); + } + + if (!_validator.Validate(configToMerge)) + { + throw new YamlIncludeException($"Validation of included YAML failed: {yamlFile.FullName}"); + } + + if (configToMerge.BaseUrl is not null) + { + _log.Warning("`base_url` is not allowed in YAML includes"); + } + + if (configToMerge.ApiKey is not null) + { + _log.Warning("`api_key` is not allowed in YAML includes"); + } + + if (configToMerge.Include is not null) + { + _log.Warning("Nested `include` directives are not supported"); + } + + return configToMerge; + } +} diff --git a/src/Recyclarr.TrashLib/Config/Parsing/YamlValidatorRuleSets.cs b/src/Recyclarr.TrashLib/Config/Parsing/YamlValidatorRuleSets.cs new file mode 100644 index 00000000..6094dfc7 --- /dev/null +++ b/src/Recyclarr.TrashLib/Config/Parsing/YamlValidatorRuleSets.cs @@ -0,0 +1,6 @@ +namespace Recyclarr.TrashLib.Config.Parsing; + +public static class YamlValidatorRuleSets +{ + public const string RootConfig = "RootConfig"; +} diff --git a/src/Recyclarr.TrashLib/Config/Services/ConfigTemplateGuideService.cs b/src/Recyclarr.TrashLib/Config/Services/ConfigTemplateGuideService.cs index 19f4eec2..66bc2afc 100644 --- a/src/Recyclarr.TrashLib/Config/Services/ConfigTemplateGuideService.cs +++ b/src/Recyclarr.TrashLib/Config/Services/ConfigTemplateGuideService.cs @@ -25,15 +25,27 @@ public record TemplatePath public class ConfigTemplateGuideService : IConfigTemplateGuideService { private readonly IConfigTemplatesRepo _repo; + private IReadOnlyCollection? _templateData; + private IReadOnlyCollection? _includeData; public ConfigTemplateGuideService(IConfigTemplatesRepo repo) { _repo = repo; } - public IReadOnlyCollection LoadTemplateData() + public IReadOnlyCollection GetTemplateData() { - var templatesPath = _repo.Path.File("templates.json"); + return _templateData ??= LoadTemplateData("templates.json"); + } + + public IReadOnlyCollection GetIncludeData() + { + return _includeData ??= LoadTemplateData("includes.json"); + } + + private IReadOnlyCollection LoadTemplateData(string templateFileName) + { + var templatesPath = _repo.Path.File(templateFileName); if (!templatesPath.Exists) { throw new InvalidDataException( @@ -42,6 +54,11 @@ public class ConfigTemplateGuideService : IConfigTemplateGuideService var templates = TrashRepoJsonParser.Deserialize(templatesPath); + return templates.Radarr + .Select(x => NewTemplatePath(x, SupportedServices.Radarr)) + .Concat(templates.Sonarr.Select(x => NewTemplatePath(x, SupportedServices.Sonarr))) + .ToList(); + TemplatePath NewTemplatePath(TemplateEntry entry, SupportedServices service) { return new TemplatePath @@ -52,10 +69,5 @@ public class ConfigTemplateGuideService : IConfigTemplateGuideService Hidden = entry.Hidden }; } - - return templates.Radarr - .Select(x => NewTemplatePath(x, SupportedServices.Radarr)) - .Concat(templates.Sonarr.Select(x => NewTemplatePath(x, SupportedServices.Sonarr))) - .ToList(); } } diff --git a/src/Recyclarr.TrashLib/Config/Services/IConfigTemplateGuideService.cs b/src/Recyclarr.TrashLib/Config/Services/IConfigTemplateGuideService.cs index 3c8d232e..5ed9654c 100644 --- a/src/Recyclarr.TrashLib/Config/Services/IConfigTemplateGuideService.cs +++ b/src/Recyclarr.TrashLib/Config/Services/IConfigTemplateGuideService.cs @@ -2,5 +2,6 @@ namespace Recyclarr.TrashLib.Config.Services; public interface IConfigTemplateGuideService { - IReadOnlyCollection LoadTemplateData(); + IReadOnlyCollection GetTemplateData(); + IReadOnlyCollection GetIncludeData(); } diff --git a/src/Recyclarr.TrashLib/ExceptionTypes/PostProcessingException.cs b/src/Recyclarr.TrashLib/ExceptionTypes/PostProcessingException.cs new file mode 100644 index 00000000..28cccb30 --- /dev/null +++ b/src/Recyclarr.TrashLib/ExceptionTypes/PostProcessingException.cs @@ -0,0 +1,9 @@ +namespace Recyclarr.TrashLib.ExceptionTypes; + +public class PostProcessingException : Exception +{ + public PostProcessingException(string? message) + : base(message) + { + } +} diff --git a/src/tests/Recyclarr.Cli.Tests/Processors/ConfigTemplateListerTest.cs b/src/tests/Recyclarr.Cli.Tests/Processors/ConfigTemplateListerTest.cs index 00c548a6..17dc4147 100644 --- a/src/tests/Recyclarr.Cli.Tests/Processors/ConfigTemplateListerTest.cs +++ b/src/tests/Recyclarr.Cli.Tests/Processors/ConfigTemplateListerTest.cs @@ -18,7 +18,7 @@ public class ConfigTemplateListerTest : TrashLibIntegrationFixture [Frozen] IConfigTemplateGuideService guideService, ConfigListTemplateProcessor sut) { - guideService.LoadTemplateData().Returns(new[] + guideService.GetTemplateData().Returns(new[] { new TemplatePath {Id = "r1", TemplateFile = stubFile, Service = SupportedServices.Radarr, Hidden = false}, new TemplatePath {Id = "r2", TemplateFile = stubFile, Service = SupportedServices.Radarr, Hidden = false}, diff --git a/src/tests/Recyclarr.Common.Tests/Extensions/StringExtensionsTest.cs b/src/tests/Recyclarr.Common.Tests/Extensions/StringExtensionsTest.cs index 23697252..9ba87bd5 100644 --- a/src/tests/Recyclarr.Common.Tests/Extensions/StringExtensionsTest.cs +++ b/src/tests/Recyclarr.Common.Tests/Extensions/StringExtensionsTest.cs @@ -17,4 +17,11 @@ public class StringExtensionsTest { "\n test \r".TrimNewlines().Should().Be(" test "); } + + [Test] + public void Snake_case_works() + { + "UpperCamelCase".ToSnakeCase().Should().Be("upper_camel_case"); + "lowerCamelCase".ToSnakeCase().Should().Be("lower_camel_case"); + } } diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessorTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessorTest.cs new file mode 100644 index 00000000..5e0fa540 --- /dev/null +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/ConfigIncludeProcessorTest.cs @@ -0,0 +1,101 @@ +using System.IO.Abstractions; +using Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; +using Recyclarr.TrashLib.Startup; + +namespace Recyclarr.TrashLib.Tests.Config.Parsing.PostProcessing.ConfigMerging; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class ConfigIncludeProcessorTest +{ + [Test, AutoMockData] + public void Can_process_expected_type( + ConfigIncludeProcessor sut) + { + var result = sut.CanProcess(new ConfigYamlInclude()); + result.Should().BeTrue(); + } + + [Test, AutoMockData] + public void Throw_when_null_include_path( + ConfigIncludeProcessor sut) + { + var includeDirective = new ConfigYamlInclude + { + Config = null + }; + + var act = () => sut.GetPathToConfig(includeDirective, default); + + act.Should().Throw(); + } + + [Test, AutoMockData] + public void Get_relative_config_include_path( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + [Frozen] IAppPaths paths, + ConfigIncludeProcessor sut) + { + fs.AddEmptyFile(paths.ConfigsDirectory.File("foo/bar/config.yml")); + + var includeDirective = new ConfigYamlInclude + { + Config = "foo/bar/config.yml" + }; + + var path = sut.GetPathToConfig(includeDirective, default); + + path.FullName.Should().Be(paths.ConfigsDirectory.File("foo/bar/config.yml").FullName); + } + + [Test, AutoMockData] + public void Get_absolute_config_include_path( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + ConfigIncludeProcessor sut) + { + var absolutePath = fs.CurrentDirectory().File("foo/bar/config.yml"); + fs.AddEmptyFile(absolutePath); + + var includeDirective = new ConfigYamlInclude + { + Config = absolutePath.FullName + }; + + var path = sut.GetPathToConfig(includeDirective, default); + + path.FullName.Should().Be(absolutePath.FullName); + } + + [Test, AutoMockData] + public void Throw_when_relative_config_include_path_does_not_exist( + // Freeze the mock FS even though we don't use it so that the "Exists" check works right. + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + ConfigIncludeProcessor sut) + { + var includeDirective = new ConfigYamlInclude + { + Config = "foo/bar/config.yml" + }; + + var act = () => sut.GetPathToConfig(includeDirective, default); + + act.Should().Throw().WithMessage("Relative*not exist*"); + } + + [Test, AutoMockData] + public void Throw_when_absolute_config_include_path_does_not_exist( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + ConfigIncludeProcessor sut) + { + var absolutePath = fs.CurrentDirectory().File("foo/bar/config.yml"); + + var includeDirective = new ConfigYamlInclude + { + Config = absolutePath.FullName + }; + + var act = () => sut.GetPathToConfig(includeDirective, default); + + act.Should().Throw().WithMessage("Absolute*not exist*"); + } +} diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMergerTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMergerTest.cs new file mode 100644 index 00000000..99455cd6 --- /dev/null +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/ServiceConfigMergerTest.cs @@ -0,0 +1,525 @@ +using System.Diagnostics.CodeAnalysis; +using FluentAssertions.Execution; +using Recyclarr.TrashLib.Config.Parsing; +using Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; +using Recyclarr.TrashLib.Config.Services; + +namespace Recyclarr.TrashLib.Tests.Config.Parsing.PostProcessing.ConfigMerging; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class ServiceConfigMergerTest +{ + [Test] + public void Merge_api_key_from_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + ApiKey = "a" + }; + + var rightConfig = new SonarrConfigYaml(); + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + [Test] + public void Merge_api_key_from_non_empty_right_to_empty_left() + { + var leftConfig = new SonarrConfigYaml(); + + // API Key should not be merged! + var rightConfig = new SonarrConfigYaml + { + ApiKey = "b" + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + [Test] + public void Merge_api_key_from_non_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + ApiKey = "a" + }; + + // API Key should not be merged! + var rightConfig = new SonarrConfigYaml + { + ApiKey = "b" + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + //------------------------------------------------------ + + [Test] + public void Merge_base_url_from_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + BaseUrl = "a" + }; + + var rightConfig = new SonarrConfigYaml(); + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + [Test] + public void Merge_base_url_from_non_empty_right_to_empty_left() + { + var leftConfig = new SonarrConfigYaml(); + + // BaseUrl should not be merged! + var rightConfig = new SonarrConfigYaml + { + BaseUrl = "b" + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + [Test] + public void Merge_base_url_from_non_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + BaseUrl = "a" + }; + + // Baseurl should not be merged! + var rightConfig = new SonarrConfigYaml + { + BaseUrl = "b" + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + //------------------------------------------------------ + + [Test] + public void Merge_quality_definition_from_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + QualityDefinition = new QualitySizeConfigYaml + { + Type = "type1", + PreferredRatio = 0.5m + } + }; + + var rightConfig = new SonarrConfigYaml(); + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + [Test] + public void Merge_quality_definition_from_non_empty_right_to_empty_left() + { + var leftConfig = new SonarrConfigYaml(); + + var rightConfig = new SonarrConfigYaml + { + QualityDefinition = new QualitySizeConfigYaml + { + Type = "type1", + PreferredRatio = 0.5m + } + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(rightConfig); + } + + [Test] + public void Merge_quality_definition_from_non_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + QualityDefinition = new QualitySizeConfigYaml + { + Type = "type1", + PreferredRatio = 0.5m + } + }; + + var rightConfig = new SonarrConfigYaml + { + QualityDefinition = new QualitySizeConfigYaml + { + Type = "type2", + PreferredRatio = 1.0m + } + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(rightConfig); + } + + //------------------------------------------------------ + + [Test] + public void Merge_custom_formats_from_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + CustomFormats = new[] + { + new CustomFormatConfigYaml + { + TrashIds = new[] {"id1", "id2"}, + QualityProfiles = new[] + { + new QualityScoreConfigYaml {Name = "c", Score = 100} + } + } + } + }; + + var rightConfig = new SonarrConfigYaml(); + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + [Test] + public void Merge_custom_formats_from_non_empty_right_to_empty_left() + { + var leftConfig = new SonarrConfigYaml(); + + var rightConfig = new SonarrConfigYaml + { + CustomFormats = new[] + { + new CustomFormatConfigYaml + { + TrashIds = new[] {"id1", "id2"}, + QualityProfiles = new[] + { + new QualityScoreConfigYaml {Name = "c", Score = 100} + } + } + } + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(rightConfig); + } + + [Test] + public void Merge_custom_formats_from_non_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + CustomFormats = new[] + { + new CustomFormatConfigYaml + { + TrashIds = new[] {"id1", "id2"}, + QualityProfiles = new[] + { + new QualityScoreConfigYaml {Name = "c", Score = 100} + } + } + } + }; + + var rightConfig = new SonarrConfigYaml + { + CustomFormats = new[] + { + new CustomFormatConfigYaml + { + TrashIds = new[] {"id3", "id4"}, + QualityProfiles = new[] + { + new QualityScoreConfigYaml {Name = "d", Score = 200} + } + }, + new CustomFormatConfigYaml + { + TrashIds = new[] {"id5", "id6"}, + QualityProfiles = new[] + { + new QualityScoreConfigYaml {Name = "e", Score = 300} + } + } + } + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(new SonarrConfigYaml + { + CustomFormats = leftConfig.CustomFormats.Concat(rightConfig.CustomFormats).ToList() + }); + } + + //------------------------------------------------------ + + [Test] + public void Merge_quality_profiles_from_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + QualityProfiles = new[] + { + new QualityProfileConfigYaml + { + Name = "e", + QualitySort = QualitySortAlgorithm.Top, + MinFormatScore = 100, + ScoreSet = "set1", + ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml + { + Enabled = true, + Except = new[] {"except1"} + }, + Upgrade = new QualityProfileFormatUpgradeYaml + { + Allowed = true, + UntilQuality = "quality1", + UntilScore = 200 + }, + Qualities = new[] + { + new QualityProfileQualityConfigYaml + { + Enabled = true, + Name = "quality1", + Qualities = new[] {"quality"} + } + } + } + } + }; + + var rightConfig = new SonarrConfigYaml(); + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + [Test] + public void Merge_quality_profiles_from_non_empty_right_to_empty_left() + { + var leftConfig = new SonarrConfigYaml(); + + var rightConfig = new SonarrConfigYaml + { + QualityProfiles = new[] + { + new QualityProfileConfigYaml + { + Name = "e", + QualitySort = QualitySortAlgorithm.Top, + MinFormatScore = 100, + ScoreSet = "set1", + ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml + { + Enabled = true, + Except = new[] {"except1"} + }, + Upgrade = new QualityProfileFormatUpgradeYaml + { + Allowed = true, + UntilQuality = "quality1", + UntilScore = 200 + }, + Qualities = new[] + { + new QualityProfileQualityConfigYaml + { + Enabled = true, + Name = "quality1", + Qualities = new[] {"quality"} + } + } + } + } + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(rightConfig); + } + + [Test] + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")] + public void Merge_quality_profiles_from_non_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + QualityProfiles = new[] + { + new QualityProfileConfigYaml + { + Name = "e", + QualitySort = QualitySortAlgorithm.Top, + MinFormatScore = 100, + ScoreSet = "set1", + ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml + { + Enabled = true, + Except = new[] {"except1"} + }, + Upgrade = new QualityProfileFormatUpgradeYaml + { + Allowed = true, + UntilQuality = "quality1", + UntilScore = 200 + }, + Qualities = new[] + { + new QualityProfileQualityConfigYaml + { + Enabled = true, + Name = "quality1", + Qualities = new[] {"quality"} + } + } + } + } + }; + + var rightConfig = new SonarrConfigYaml + { + QualityProfiles = new[] + { + new QualityProfileConfigYaml + { + Name = "e", + ScoreSet = "set2", + ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml + { + Except = new[] {"except2", "except3"} + }, + Upgrade = new QualityProfileFormatUpgradeYaml + { + UntilQuality = "quality2" + }, + Qualities = new[] + { + new QualityProfileQualityConfigYaml + { + Enabled = false, + Name = "quality2", + Qualities = new[] {"quality3"} + }, + new QualityProfileQualityConfigYaml + { + Enabled = true, + Name = "quality4", + Qualities = new[] {"quality5", "quality6"} + } + } + } + } + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + using var scope = new AssertionScope().UsingLineBreaks; + + result.Should().BeEquivalentTo(new SonarrConfigYaml + { + QualityProfiles = new[] + { + new QualityProfileConfigYaml + { + Name = "e", + QualitySort = QualitySortAlgorithm.Top, + MinFormatScore = 100, + ScoreSet = "set2", + ResetUnmatchedScores = new ResetUnmatchedScoresConfigYaml + { + Enabled = true, + Except = new[] {"except1", "except2", "except3"} + }, + Upgrade = new QualityProfileFormatUpgradeYaml + { + Allowed = true, + UntilQuality = "quality2", + UntilScore = 200 + }, + Qualities = new[] + { + new QualityProfileQualityConfigYaml + { + Enabled = true, + Name = "quality1", + Qualities = new[] {"quality"} + }, + new QualityProfileQualityConfigYaml + { + Enabled = false, + Name = "quality2", + Qualities = new[] {"quality3"} + }, + new QualityProfileQualityConfigYaml + { + Enabled = true, + Name = "quality4", + Qualities = new[] {"quality5", "quality6"} + } + } + } + } + }); + } +} diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMergerTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMergerTest.cs new file mode 100644 index 00000000..9b8d494d --- /dev/null +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/SonarrConfigMergerTest.cs @@ -0,0 +1,134 @@ +using System.Diagnostics.CodeAnalysis; +using FluentAssertions.Execution; +using Recyclarr.TrashLib.Config.Parsing; +using Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +namespace Recyclarr.TrashLib.Tests.Config.Parsing.PostProcessing.ConfigMerging; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class SonarrConfigMergerTest +{ + [Test] + public void Merge_release_profiles_from_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + ReleaseProfiles = new[] + { + new ReleaseProfileConfigYaml + { + TrashIds = new[] {"id1"}, + Filter = new ReleaseProfileFilterConfigYaml + { + Exclude = new[] {"exclude"}, + Include = new[] {"include"} + }, + Tags = new[] {"tag1", "tag2"}, + StrictNegativeScores = true + } + } + }; + + var rightConfig = new SonarrConfigYaml(); + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(leftConfig); + } + + [Test] + public void Merge_release_profiles_from_non_empty_right_to_empty_left() + { + var leftConfig = new SonarrConfigYaml(); + + var rightConfig = new SonarrConfigYaml + { + ReleaseProfiles = new[] + { + new ReleaseProfileConfigYaml + { + TrashIds = new[] {"id1"}, + Filter = new ReleaseProfileFilterConfigYaml + { + Exclude = new[] {"exclude"}, + Include = new[] {"include"} + }, + Tags = new[] {"tag1", "tag2"}, + StrictNegativeScores = true + } + } + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + result.Should().BeEquivalentTo(rightConfig); + } + + [Test] + [SuppressMessage("Reliability", "CA2000:Dispose objects before losing scope")] + public void Merge_release_profiles_from_non_empty_right_to_non_empty_left() + { + var leftConfig = new SonarrConfigYaml + { + ReleaseProfiles = new[] + { + new ReleaseProfileConfigYaml + { + TrashIds = new[] {"id1"}, + Filter = new ReleaseProfileFilterConfigYaml + { + Exclude = new[] {"exclude1"}, + Include = new[] {"include1"} + }, + Tags = new[] {"tag1", "tag2"}, + StrictNegativeScores = true + }, + new ReleaseProfileConfigYaml + { + TrashIds = new[] {"id2", "id3"}, + Filter = new ReleaseProfileFilterConfigYaml + { + Exclude = new[] {"exclude2"}, + Include = new[] {"include2"} + }, + Tags = new[] {"tag3"}, + StrictNegativeScores = true + } + } + }; + + var rightConfig = new SonarrConfigYaml + { + ReleaseProfiles = new[] + { + new ReleaseProfileConfigYaml + { + TrashIds = new[] {"id4"}, + Filter = new ReleaseProfileFilterConfigYaml + { + Exclude = new[] {"exclude3"}, + Include = new[] {"include3"} + }, + Tags = new[] {"tag4", "tag5"}, + StrictNegativeScores = false + } + } + }; + + var sut = new SonarrConfigMerger(); + + var result = sut.Merge(leftConfig, rightConfig); + + using var scope = new AssertionScope().UsingLineBreaks; + + result.Should().BeEquivalentTo(new SonarrConfigYaml + { + ReleaseProfiles = leftConfig.ReleaseProfiles.Concat(rightConfig.ReleaseProfiles).ToList() + }); + } +} diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/TemplateIncludeProcessorTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/TemplateIncludeProcessorTest.cs new file mode 100644 index 00000000..ece164cf --- /dev/null +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/TemplateIncludeProcessorTest.cs @@ -0,0 +1,102 @@ +using System.IO.Abstractions; +using Recyclarr.TrashLib.Config; +using Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; +using Recyclarr.TrashLib.Config.Services; + +namespace Recyclarr.TrashLib.Tests.Config.Parsing.PostProcessing.ConfigMerging; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class TemplateIncludeProcessorTest +{ + [Test, AutoMockData] + public void Can_process_expected_type( + TemplateIncludeProcessor sut) + { + var result = sut.CanProcess(new TemplateYamlInclude()); + result.Should().BeTrue(); + } + + [Test, AutoMockData] + public void Obtain_path_from_template( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + [Frozen] IConfigTemplateGuideService templates, + TemplateIncludeProcessor sut) + { + var templatePath = fs.CurrentDirectory().File("some/path/template.yml"); + templates.GetIncludeData().Returns(new[] + { + new TemplatePath + { + Id = "my-template", + Service = SupportedServices.Radarr, + TemplateFile = templatePath + } + }); + + var includeDirective = new TemplateYamlInclude {Template = "my-template"}; + + var path = sut.GetPathToConfig(includeDirective, SupportedServices.Radarr); + + path.FullName.Should().Be(templatePath.FullName); + } + + [Test, AutoMockData] + public void Throw_when_template_is_null( + TemplateIncludeProcessor sut) + { + var includeDirective = new TemplateYamlInclude {Template = null}; + + var act = () => sut.GetPathToConfig(includeDirective, SupportedServices.Radarr); + + act.Should().Throw().WithMessage("*template*is required*"); + } + + [Test, AutoMockData] + public void Throw_when_service_types_are_mixed( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + [Frozen] IConfigTemplateGuideService templates, + TemplateIncludeProcessor sut) + { + var templatePath = fs.CurrentDirectory().File("some/path/template.yml"); + templates.GetIncludeData().Returns(new[] + { + new TemplatePath + { + Id = "my-template", + Service = SupportedServices.Radarr, + TemplateFile = templatePath + } + }); + + var includeDirective = new TemplateYamlInclude {Template = "my-template"}; + + var act = () => sut.GetPathToConfig(includeDirective, SupportedServices.Sonarr); + + act.Should().Throw().WithMessage("*unable to find*"); + } + + [Test, AutoMockData] + public void Throw_when_no_template_found( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + [Frozen] IConfigTemplateGuideService templates, + TemplateIncludeProcessor sut) + { + var templatePath = fs.CurrentDirectory().File("some/path/template.yml"); + templates.GetIncludeData().Returns(new[] + { + new TemplatePath + { + Id = "my-template", + Service = SupportedServices.Radarr, + TemplateFile = templatePath + } + }); + + var includeDirective = new TemplateYamlInclude {Template = "template-does-not-exist"}; + + var act = () => sut.GetPathToConfig(includeDirective, SupportedServices.Radarr); + + act.Should().Throw().WithMessage("*unable to find*"); + } +} diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeResolverTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeResolverTest.cs new file mode 100644 index 00000000..ea0385b4 --- /dev/null +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/ConfigMerging/YamlIncludeResolverTest.cs @@ -0,0 +1,72 @@ +using System.IO.Abstractions; +using Recyclarr.TrashLib.Config; +using Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; + +namespace Recyclarr.TrashLib.Tests.Config.Parsing.PostProcessing.ConfigMerging; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class YamlIncludeResolverTest +{ + [Test] + public void Find_and_return_processor() + { + var processors = new[] + { + Substitute.For(), + Substitute.For() + }; + + processors[1].CanProcess(default!).ReturnsForAnyArgs(true); + processors[1].GetPathToConfig(default!, default!).ReturnsForAnyArgs(_ => + { + var fileInfo = Substitute.For(); + fileInfo.Exists.Returns(true); + fileInfo.FullName.Returns("the_path"); + return fileInfo; + }); + + var sut = new YamlIncludeResolver(processors); + var result = sut.GetIncludePath(Substitute.For(), SupportedServices.Radarr); + + result.FullName.Should().Be("the_path"); + } + + [Test] + public void Throw_when_no_matching_processor() + { + var processors = new[] + { + Substitute.For(), + Substitute.For() + }; + + var sut = new YamlIncludeResolver(processors); + var act = () => sut.GetIncludePath(Substitute.For(), SupportedServices.Radarr); + + act.Should().Throw().WithMessage("*type is not supported*"); + } + + [Test] + public void Throw_when_path_does_not_exist() + { + var processors = new[] + { + Substitute.For(), + Substitute.For() + }; + + processors[1].CanProcess(default!).ReturnsForAnyArgs(true); + processors[1].GetPathToConfig(default!, default!).ReturnsForAnyArgs(_ => + { + var fileInfo = Substitute.For(); + fileInfo.Exists.Returns(false); + return fileInfo; + }); + + var sut = new YamlIncludeResolver(processors); + var act = () => sut.GetIncludePath(Substitute.For(), SupportedServices.Radarr); + + act.Should().Throw().WithMessage("*does not exist*"); + } +} diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/IncludePostProcessorIntegrationTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/IncludePostProcessorIntegrationTest.cs new file mode 100644 index 00000000..9d8848ab --- /dev/null +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Parsing/PostProcessing/IncludePostProcessorIntegrationTest.cs @@ -0,0 +1,176 @@ +using System.IO.Abstractions; +using Recyclarr.TrashLib.Config.Parsing; +using Recyclarr.TrashLib.Config.Parsing.PostProcessing; +using Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging; +using Recyclarr.TrashLib.TestLibrary; + +namespace Recyclarr.TrashLib.Tests.Config.Parsing.PostProcessing; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class IncludePostProcessorIntegrationTest : TrashLibIntegrationFixture +{ + [Test] + public void No_change_when_no_includes() + { + var sut = Resolve(); + + var config = new RootConfigYaml + { + Radarr = new Dictionary + { + ["service1"] = new() + { + ApiKey = "asdf", + BaseUrl = "fdsa" + } + } + }; + + var result = sut.Process(config); + + result.Should().BeEquivalentTo(config); + } + + [Test] + public void Throw_when_unable_to_parse() + { + var sut = Resolve(); + + var configPath = Fs.CurrentDirectory().File("my-include.yml"); + + Fs.AddFile(configPath, new MockFileData( + """ + asdf: invalid + """)); + + var config = new RootConfigYaml + { + Radarr = new Dictionary + { + ["service1"] = new() + { + Include = new[] + { + new ConfigYamlInclude {Config = configPath.FullName} + } + } + } + }; + + var act = () => sut.Process(config); + + act.Should().Throw().WithMessage("*parse include file*my-include.yml*"); + } + + [Test] + public void Throw_when_unable_to_validate() + { + var sut = Resolve(); + + var configPath = Fs.CurrentDirectory().File("my-include.yml"); + + Fs.AddFile(configPath, new MockFileData( + """ + custom_formats: + """)); + + var config = new RootConfigYaml + { + Radarr = new Dictionary + { + ["service1"] = new() + { + Include = new[] + { + new ConfigYamlInclude {Config = configPath.FullName} + } + } + } + }; + + var act = () => sut.Process(config); + + act.Should().Throw().WithMessage("*Validation*failed*my-include.yml*"); + } + + [Test] + public void Merge_works() + { + var sut = Resolve(); + + var configPath1 = Fs.CurrentDirectory().File("my-include1.yml"); + Fs.AddFile(configPath1, new MockFileData( + """ + custom_formats: + - trash_ids: + - 496f355514737f7d83bf7aa4d24f8169 + + quality_definition: + type: anime + preferred_ratio: 0.75 + + delete_old_custom_formats: false + """)); + + var configPath2 = Fs.CurrentDirectory().File("sub_dir/my-include2.yml"); + Fs.AddFile(configPath2, new MockFileData( + """ + custom_formats: + - trash_ids: + - 240770601cc226190c367ef59aba7463 + + delete_old_custom_formats: true + """)); + + var config = new RootConfigYaml + { + Radarr = new Dictionary + { + ["service1"] = new() + { + CustomFormats = new[] + { + new CustomFormatConfigYaml + { + TrashIds = new[] {"2f22d89048b01681dde8afe203bf2e95"} + } + }, + QualityDefinition = new QualitySizeConfigYaml + { + Type = "series" + }, + Include = new[] + { + new ConfigYamlInclude {Config = configPath1.FullName}, + new ConfigYamlInclude {Config = configPath2.FullName} + } + } + } + }; + + var result = sut.Process(config); + + result.Should().BeEquivalentTo(new RootConfigYaml + { + Radarr = new Dictionary + { + ["service1"] = new() + { + CustomFormats = new[] + { + new CustomFormatConfigYaml {TrashIds = new[] {"496f355514737f7d83bf7aa4d24f8169"}}, + new CustomFormatConfigYaml {TrashIds = new[] {"2f22d89048b01681dde8afe203bf2e95"}}, + new CustomFormatConfigYaml {TrashIds = new[] {"240770601cc226190c367ef59aba7463"}} + }, + QualityDefinition = new QualitySizeConfigYaml + { + Type = "series", + PreferredRatio = 0.75m + }, + DeleteOldCustomFormats = true + } + } + }); + } +} diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/Services/ConfigTemplateGuideServiceTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/Services/ConfigTemplateGuideServiceTest.cs index dfd1422e..8ec5270b 100644 --- a/src/tests/Recyclarr.TrashLib.Tests/Config/Services/ConfigTemplateGuideServiceTest.cs +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/Services/ConfigTemplateGuideServiceTest.cs @@ -15,7 +15,7 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture public void Throw_when_templates_dir_does_not_exist( ConfigTemplateGuideService sut) { - var act = () => _ = sut.LoadTemplateData(); + var act = () => _ = sut.GetTemplateData(); act.Should().Throw().WithMessage("Recyclarr*templates*"); } @@ -43,7 +43,7 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture var sut = Resolve(); - var data = sut.LoadTemplateData(); + var data = sut.GetTemplateData(); data.Should().BeEquivalentTo(expectedPaths, o => o.Excluding(x => x.TemplateFile)); data.Select(x => x.TemplateFile.FullName) .Should().BeEquivalentTo(expectedPaths.Select(x => x.TemplateFile.FullName)); diff --git a/src/tests/Recyclarr.TrashLib.Tests/Config/YamlConfigValidatorTest.cs b/src/tests/Recyclarr.TrashLib.Tests/Config/YamlConfigValidatorTest.cs index b1222671..8fe11d23 100644 --- a/src/tests/Recyclarr.TrashLib.Tests/Config/YamlConfigValidatorTest.cs +++ b/src/tests/Recyclarr.TrashLib.Tests/Config/YamlConfigValidatorTest.cs @@ -70,7 +70,7 @@ public class YamlConfigValidatorTest : TrashLibIntegrationFixture }; var validator = Resolve(); - var result = validator.TestValidate(config); + var result = validator.TestValidate(config, o => o.IncludeRuleSets(YamlValidatorRuleSets.RootConfig)); result.ShouldHaveValidationErrorFor(x => x.ApiKey); } @@ -103,7 +103,7 @@ public class YamlConfigValidatorTest : TrashLibIntegrationFixture }; var validator = Resolve(); - var result = validator.TestValidate(config); + var result = validator.TestValidate(config, o => o.IncludeRuleSets(YamlValidatorRuleSets.RootConfig)); result.ShouldHaveValidationErrorFor(x => x.BaseUrl) .WithErrorMessage("'base_url' must not be empty."); @@ -137,7 +137,7 @@ public class YamlConfigValidatorTest : TrashLibIntegrationFixture }; var validator = Resolve(); - var result = validator.TestValidate(config); + var result = validator.TestValidate(config, o => o.IncludeRuleSets(YamlValidatorRuleSets.RootConfig)); result.ShouldHaveValidationErrorFor(x => x.BaseUrl) .WithErrorMessage("base_url must start with 'http' or 'https'");