feat: YAML includes

Fixes #175
json-serializing-nullable-fields-issue
Robert Dailey 8 months ago
parent 175aa6733b
commit 5bb2bfa8a0

@ -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

@ -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("");

@ -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<RootConfigYaml>(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. " +

@ -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);

@ -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;

@ -89,4 +89,11 @@ public static class CollectionExtensions
{
return items.SelectMany(x => flattenWhich(x).Flatten(flattenWhich).Append(x));
}
public static async Task<IEnumerable<TResult>> SelectAsync<TSource, TResult>(
this IEnumerable<TSource> source,
Func<TSource, Task<TResult>> method)
{
return await Task.WhenAll(source.Select(async s => await method(s)));
}
}

@ -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();
}
}

@ -4,5 +4,5 @@ namespace Recyclarr.Common.FluentValidation;
public interface IRuntimeValidationService
{
ValidationResult Validate(object instance);
ValidationResult Validate(object instance, params string[] ruleSets);
}

@ -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<object>(instance));
IValidatorSelector validatorSelector = ruleSets.Any()
? new RulesetValidatorSelector(ruleSets)
: new DefaultValidatorSelector();
return validator.Validate(new ValidationContext<object>(instance, new PropertyChain(), validatorSelector));
}
}

@ -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<SecretsProvider>().As<ISecretsProvider>().SingleInstance();
builder.RegisterType<YamlSerializerFactory>().As<IYamlSerializerFactory>();
builder.RegisterType<YamlIncludeResolver>().As<IYamlIncludeResolver>();
builder.RegisterType<ConfigIncludeProcessor>().As<IIncludeProcessor>();
builder.RegisterType<TemplateIncludeProcessor>().As<IIncludeProcessor>();
builder.RegisterType<ConfigurationRegistry>().As<IConfigurationRegistry>();
builder.RegisterType<DefaultObjectFactory>().As<IObjectFactory>();
builder.RegisterType<ConfigurationLoader>().As<IConfigurationLoader>();
builder.RegisterType<ConfigurationFinder>().As<IConfigurationFinder>();
builder.RegisterType<ConfigTemplateGuideService>().As<IConfigTemplateGuideService>();
builder.RegisterType<ConfigTemplateGuideService>().As<IConfigTemplateGuideService>().SingleInstance();
builder.RegisterType<ConfigValidationExecutor>();
builder.RegisterType<ConfigParser>();
builder.RegisterType<ConfigSaver>();
// Config Post Processors
builder.RegisterType<ImplicitUrlAndKeyPostProcessor>().As<IConfigPostProcessor>();
builder.RegisterType<IncludePostProcessor>().As<IConfigPostProcessor>();
RegisterValidators(builder);
}
// Validators
private static void RegisterValidators(ContainerBuilder builder)
{
builder.RegisterType<RootConfigYamlValidator>().As<IValidator>();
// These validators are required by IncludePostProcessor
builder.RegisterType<RadarrConfigYamlValidator>().As<IValidator>();
builder.RegisterType<SonarrConfigYamlValidator>().As<IValidator>();
}
}

@ -19,30 +19,24 @@ public class ConfigParser
_deserializer = yamlFactory.CreateDeserializer();
}
public RootConfigYaml? Load(IFileInfo file)
public T? Load<T>(IFileInfo file) where T : class
{
_log.Debug("Loading config file: {File}", file);
return Load(file.OpenText);
return Load<T>(file.OpenText);
}
public RootConfigYaml? Load(string yaml)
public T? Load<T>(string yaml) where T : class
{
_log.Debug("Loading config from string data");
return Load(() => new StringReader(yaml));
return Load<T>(() => new StringReader(yaml));
}
public RootConfigYaml? Load(Func<TextReader> streamFactory)
public T? Load<T>(Func<TextReader> streamFactory) where T : class
{
try
{
using var stream = streamFactory();
var config = _deserializer.Deserialize<RootConfigYaml?>(stream);
if (config.IsConfigEmpty())
{
_log.Warning("Configuration is empty");
}
return config;
return _deserializer.Deserialize<T?>(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;
}
}

@ -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;

@ -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<CustomFormatConfigYaml>? CustomFormats { get; init; }
public QualitySizeConfigYaml? QualityDefinition { get; init; }
public IReadOnlyCollection<QualityProfileConfigYaml>? QualityProfiles { get; init; }
public IReadOnlyCollection<IYamlInclude>? Include { get; init; }
}
[UsedImplicitly(ImplicitUseTargetFlags.WithMembers)]

@ -8,12 +8,18 @@ public class ServiceConfigYamlValidator : AbstractValidator<ServiceConfigYaml>
{
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)

@ -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<IConfigPostProcessor> _postProcessors;
public ConfigurationLoader(
ILogger log,
ConfigParser parser,
IMapper mapper,
ConfigValidationExecutor validator,
IEnumerable<IConfigPostProcessor> postProcessors)
{
_log = log;
_parser = parser;
_mapper = mapper;
_validator = validator;
@ -28,17 +31,17 @@ public class ConfigurationLoader : IConfigurationLoader
public IReadOnlyCollection<IServiceConfiguration> Load(IFileInfo file)
{
using var logScope = LogContext.PushProperty(LogProperty.Scope, file.Name);
return ProcessLoadedConfigs(_parser.Load(file));
return ProcessLoadedConfigs(_parser.Load<RootConfigYaml>(file));
}
public IReadOnlyCollection<IServiceConfiguration> Load(string yaml)
{
return ProcessLoadedConfigs(_parser.Load(yaml));
return ProcessLoadedConfigs(_parser.Load<RootConfigYaml>(yaml));
}
public IReadOnlyCollection<IServiceConfiguration> Load(Func<TextReader> streamFactory)
{
return ProcessLoadedConfigs(_parser.Load(streamFactory));
return ProcessLoadedConfigs(_parser.Load<RootConfigYaml>(streamFactory));
}
private IReadOnlyCollection<IServiceConfiguration> 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<IServiceConfiguration>();

@ -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;
}
}

@ -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);
}

@ -0,0 +1,8 @@
using System.IO.Abstractions;
namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging;
public interface IYamlIncludeResolver
{
IFileInfo GetIncludePath(IYamlInclude includeType, SupportedServices serviceType);
}

@ -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<IYamlInclude>(new Dictionary<string, Type>
{
[nameof(ConfigYamlInclude.Config).ToSnakeCase()] = typeof(ConfigYamlInclude),
[nameof(TemplateYamlInclude.Template).ToSnakeCase()] = typeof(TemplateYamlInclude)
}));
}
}

@ -0,0 +1,5 @@
namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging;
public class RadarrConfigMerger : ServiceConfigMerger<RadarrConfigYaml>
{
}

@ -0,0 +1,86 @@
using System.Diagnostics.CodeAnalysis;
namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging;
[SuppressMessage("ReSharper", "WithExpressionModifiesAllMembers")]
public abstract class ServiceConfigMerger<T> where T : ServiceConfigYaml
{
protected static TVal? Combine<TVal>(TVal? a, TVal? b, Func<TVal, TVal, TVal?> 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<QualityProfileConfigYaml>? MergeQualityProfiles(
IReadOnlyCollection<QualityProfileConfigYaml>? a,
IReadOnlyCollection<QualityProfileConfigYaml>? 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())
};
}
}

@ -0,0 +1,13 @@
namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging;
public class SonarrConfigMerger : ServiceConfigMerger<SonarrConfigYaml>
{
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())
};
}
}

@ -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;
}
}

@ -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; }
}

@ -0,0 +1,9 @@
namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging;
public class YamlIncludeException : Exception
{
public YamlIncludeException(string? message)
: base(message)
{
}
}

@ -0,0 +1,30 @@
using System.IO.Abstractions;
namespace Recyclarr.TrashLib.Config.Parsing.PostProcessing.ConfigMerging;
public class YamlIncludeResolver : IYamlIncludeResolver
{
private readonly IReadOnlyCollection<IIncludeProcessor> _includeProcessors;
public YamlIncludeResolver(IReadOnlyCollection<IIncludeProcessor> 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;
}
}

@ -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<string, T>? ProcessIncludes<T>(
IReadOnlyDictionary<string, T>? configs,
ServiceConfigMerger<T> merger,
SupportedServices serviceType)
where T : ServiceConfigYaml, new()
{
if (configs is null)
{
return null;
}
var mergedConfigs = new Dictionary<string, T>();
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<T>(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<T>(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<T>(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;
}
}

@ -0,0 +1,6 @@
namespace Recyclarr.TrashLib.Config.Parsing;
public static class YamlValidatorRuleSets
{
public const string RootConfig = "RootConfig";
}

@ -25,15 +25,27 @@ public record TemplatePath
public class ConfigTemplateGuideService : IConfigTemplateGuideService
{
private readonly IConfigTemplatesRepo _repo;
private IReadOnlyCollection<TemplatePath>? _templateData;
private IReadOnlyCollection<TemplatePath>? _includeData;
public ConfigTemplateGuideService(IConfigTemplatesRepo repo)
{
_repo = repo;
}
public IReadOnlyCollection<TemplatePath> LoadTemplateData()
public IReadOnlyCollection<TemplatePath> GetTemplateData()
{
var templatesPath = _repo.Path.File("templates.json");
return _templateData ??= LoadTemplateData("templates.json");
}
public IReadOnlyCollection<TemplatePath> GetIncludeData()
{
return _includeData ??= LoadTemplateData("includes.json");
}
private IReadOnlyCollection<TemplatePath> 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<TemplatesData>(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();
}
}

@ -2,5 +2,6 @@ namespace Recyclarr.TrashLib.Config.Services;
public interface IConfigTemplateGuideService
{
IReadOnlyCollection<TemplatePath> LoadTemplateData();
IReadOnlyCollection<TemplatePath> GetTemplateData();
IReadOnlyCollection<TemplatePath> GetIncludeData();
}

@ -0,0 +1,9 @@
namespace Recyclarr.TrashLib.ExceptionTypes;
public class PostProcessingException : Exception
{
public PostProcessingException(string? message)
: base(message)
{
}
}

@ -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},

@ -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");
}
}

@ -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<YamlIncludeException>();
}
[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<YamlIncludeException>().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<YamlIncludeException>().WithMessage("Absolute*not exist*");
}
}

@ -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"}
}
}
}
}
});
}
}

@ -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()
});
}
}

@ -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<YamlIncludeException>().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<YamlIncludeException>().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<YamlIncludeException>().WithMessage("*unable to find*");
}
}

@ -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<IIncludeProcessor>(),
Substitute.For<IIncludeProcessor>()
};
processors[1].CanProcess(default!).ReturnsForAnyArgs(true);
processors[1].GetPathToConfig(default!, default!).ReturnsForAnyArgs(_ =>
{
var fileInfo = Substitute.For<IFileInfo>();
fileInfo.Exists.Returns(true);
fileInfo.FullName.Returns("the_path");
return fileInfo;
});
var sut = new YamlIncludeResolver(processors);
var result = sut.GetIncludePath(Substitute.For<IYamlInclude>(), SupportedServices.Radarr);
result.FullName.Should().Be("the_path");
}
[Test]
public void Throw_when_no_matching_processor()
{
var processors = new[]
{
Substitute.For<IIncludeProcessor>(),
Substitute.For<IIncludeProcessor>()
};
var sut = new YamlIncludeResolver(processors);
var act = () => sut.GetIncludePath(Substitute.For<IYamlInclude>(), SupportedServices.Radarr);
act.Should().Throw<YamlIncludeException>().WithMessage("*type is not supported*");
}
[Test]
public void Throw_when_path_does_not_exist()
{
var processors = new[]
{
Substitute.For<IIncludeProcessor>(),
Substitute.For<IIncludeProcessor>()
};
processors[1].CanProcess(default!).ReturnsForAnyArgs(true);
processors[1].GetPathToConfig(default!, default!).ReturnsForAnyArgs(_ =>
{
var fileInfo = Substitute.For<IFileInfo>();
fileInfo.Exists.Returns(false);
return fileInfo;
});
var sut = new YamlIncludeResolver(processors);
var act = () => sut.GetIncludePath(Substitute.For<IYamlInclude>(), SupportedServices.Radarr);
act.Should().Throw<YamlIncludeException>().WithMessage("*does not exist*");
}
}

@ -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<IncludePostProcessor>();
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
["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<IncludePostProcessor>();
var configPath = Fs.CurrentDirectory().File("my-include.yml");
Fs.AddFile(configPath, new MockFileData(
"""
asdf: invalid
"""));
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
["service1"] = new()
{
Include = new[]
{
new ConfigYamlInclude {Config = configPath.FullName}
}
}
}
};
var act = () => sut.Process(config);
act.Should().Throw<YamlIncludeException>().WithMessage("*parse include file*my-include.yml*");
}
[Test]
public void Throw_when_unable_to_validate()
{
var sut = Resolve<IncludePostProcessor>();
var configPath = Fs.CurrentDirectory().File("my-include.yml");
Fs.AddFile(configPath, new MockFileData(
"""
custom_formats:
"""));
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
["service1"] = new()
{
Include = new[]
{
new ConfigYamlInclude {Config = configPath.FullName}
}
}
}
};
var act = () => sut.Process(config);
act.Should().Throw<YamlIncludeException>().WithMessage("*Validation*failed*my-include.yml*");
}
[Test]
public void Merge_works()
{
var sut = Resolve<IncludePostProcessor>();
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<string, RadarrConfigYaml>
{
["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<string, RadarrConfigYaml>
{
["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
}
}
});
}
}

@ -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<InvalidDataException>().WithMessage("Recyclarr*templates*");
}
@ -43,7 +43,7 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture
var sut = Resolve<ConfigTemplateGuideService>();
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));

@ -70,7 +70,7 @@ public class YamlConfigValidatorTest : TrashLibIntegrationFixture
};
var validator = Resolve<ServiceConfigYamlValidator>();
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<ServiceConfigYamlValidator>();
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<ServiceConfigYamlValidator>();
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'");

Loading…
Cancel
Save