feat: Create configs from templates

pull/201/head
Robert Dailey 1 year ago
parent 67b2166d8b
commit ee377e55fa

@ -17,6 +17,10 @@ changes you may need to make.
- The `*.yaml` extension is now accepted for all YAML files (e.g. `settings.yaml`, `recyclarr.yaml`)
in addition to `*.yml` (which was already supported).
- New `--template` option added to `config create` which facilitates creating new configuration
files from the configuration template repository.
- New `--force` option added to the `config create` command. This will overwrite existing
configuration files, if they exist.
### Changed

@ -1,6 +1,6 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="config create -p custom" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr" />
<option name="PROGRAM_PARAMETERS" value="config create -p ./custom-config.yml" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />

@ -0,0 +1,20 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="config create -t" type="DotNetProject" factoryName=".NET Project">
<option name="EXE_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0/recyclarr.exe" />
<option name="PROGRAM_PARAMETERS" value="config create -t anime-radarr -f" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Recyclarr.Cli/bin/Debug/net7.0" />
<option name="PASS_PARENT_ENVS" value="1" />
<option name="USE_EXTERNAL_CONSOLE" value="0" />
<option name="USE_MONO" value="0" />
<option name="RUNTIME_ARGUMENTS" value="" />
<option name="PROJECT_PATH" value="$PROJECT_DIR$/Recyclarr.Cli/Recyclarr.Cli.csproj" />
<option name="PROJECT_EXE_PATH_TRACKING" value="1" />
<option name="PROJECT_ARGUMENTS_TRACKING" value="1" />
<option name="PROJECT_WORKING_DIRECTORY_TRACKING" value="1" />
<option name="PROJECT_KIND" value="DotNetCore" />
<option name="PROJECT_TFM" value="net7.0" />
<method v="2">
<option name="Build" />
</method>
</configuration>
</component>

@ -1,6 +1,7 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using JetBrains.Annotations;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Processors.Config;
using Recyclarr.TrashLib.ExceptionTypes;
using Spectre.Console.Cli;
@ -16,12 +17,27 @@ public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings>
[UsedImplicitly]
[SuppressMessage("Design", "CA1034:Nested types should not be visible")]
public class CliSettings : BaseCommandSettings
[SuppressMessage("Performance", "CA1819:Properties should not return arrays",
Justification = "Spectre.Console requires it")]
public class CliSettings : BaseCommandSettings, ICreateConfigSettings
{
[CommandOption("-p|--path")]
[Description("Path to where the configuration file should be created.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public string? Path { get; init; }
[CommandOption("-t|--template")]
[Description(
"One or more template configuration files to create. Use `config list templates` to get a list of " +
"names accepted here.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public string[] TemplatesOption { get; init; } = Array.Empty<string>();
public IReadOnlyCollection<string> Templates => TemplatesOption;
[CommandOption("-f|--force")]
[Description("Replace any existing configuration file, if present.")]
[UsedImplicitly(ImplicitUseKindFlags.Assign)]
public bool Force { get; init; }
}
public ConfigCreateCommand(ILogger log, IConfigCreationProcessor processor)
@ -34,7 +50,7 @@ public class ConfigCreateCommand : AsyncCommand<ConfigCreateCommand.CliSettings>
{
try
{
await _processor.Process(settings.Path);
await _processor.Process(settings);
}
catch (FileExistsException e)
{

@ -0,0 +1,8 @@
namespace Recyclarr.Cli.Console.Settings;
public interface ICreateConfigSettings
{
public string? Path { get; }
public IReadOnlyCollection<string> Templates { get; }
public bool Force { get; }
}

@ -1,46 +1,25 @@
using System.IO.Abstractions;
using Recyclarr.Common;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Startup;
namespace Recyclarr.Cli.Processors.Config;
public class ConfigCreationProcessor : IConfigCreationProcessor
{
private readonly ILogger _log;
private readonly IAppPaths _paths;
private readonly IFileSystem _fs;
private readonly IResourceDataReader _resources;
private readonly IOrderedEnumerable<IConfigCreator> _creators;
public ConfigCreationProcessor(
ILogger log,
IAppPaths paths,
IFileSystem fs,
IResourceDataReader resources)
public ConfigCreationProcessor(IOrderedEnumerable<IConfigCreator> creators)
{
_log = log;
_paths = paths;
_fs = fs;
_resources = resources;
_creators = creators;
}
public async Task Process(string? configFilePath)
public async Task Process(ICreateConfigSettings settings)
{
var configFile = configFilePath is null
? _paths.AppDataDirectory.File("recyclarr.yml")
: _fs.FileInfo.New(configFilePath);
if (configFile.Exists)
var creator = _creators.FirstOrDefault(x => x.CanHandle(settings));
if (creator is null)
{
throw new FileExistsException(configFile.FullName);
throw new FatalException("Unable to determine which config creation logic to use");
}
configFile.Directory?.Create();
await using var stream = configFile.CreateText();
var ymlData = _resources.ReadData("config-template.yml");
await stream.WriteAsync(ymlData);
_log.Information("Created configuration at: {Path}", configFile.FullName);
await creator.Create(settings);
}
}

@ -0,0 +1,85 @@
using System.IO.Abstractions;
using Recyclarr.TrashLib.Config.Parsing;
using Spectre.Console;
namespace Recyclarr.Cli.Processors.Config;
/// <remarks>
/// This was originally intended to be used by `config create`, but YamlDotNet cannot serialize
/// comments so this
/// class was not used. I kept it around in case I want to revisit later. There might be an
/// opportunity to use this
/// with the GUI.
/// </remarks>
public class ConfigManipulator : IConfigManipulator
{
private readonly IAnsiConsole _console;
private readonly ConfigParser _configParser;
private readonly ConfigSaver _configSaver;
private readonly ConfigValidationExecutor _validator;
public ConfigManipulator(
IAnsiConsole console,
ConfigParser configParser,
ConfigSaver configSaver,
ConfigValidationExecutor validator)
{
_console = console;
_configParser = configParser;
_configSaver = configSaver;
_validator = validator;
}
private static IReadOnlyDictionary<string, TConfig> InvokeCallbackForEach<TConfig>(
Func<string, ServiceConfigYaml, ServiceConfigYaml> editCallback,
IReadOnlyDictionary<string, TConfig>? configs)
where TConfig : ServiceConfigYaml
{
var newConfigs = new Dictionary<string, TConfig>();
if (configs is null)
{
return newConfigs;
}
foreach (var (instanceName, config) in configs)
{
newConfigs[instanceName] = (TConfig) editCallback(instanceName, config);
}
return newConfigs;
}
public void LoadAndSave(
IFileInfo source,
IFileInfo destinationFile,
Func<string, ServiceConfigYaml, ServiceConfigYaml> editCallback)
{
// Parse & save the template file to address the following:
// - Find & report any syntax errors
// - 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);
if (config is null)
{
// Do not log here, since ConfigParser already has substantial logging
throw new FileLoadException("Problem while loading config template");
}
config = new RootConfigYaml
{
Radarr = InvokeCallbackForEach(editCallback, config.Radarr),
Sonarr = InvokeCallbackForEach(editCallback, config.Sonarr)
};
if (!_validator.Validate(config))
{
_console.WriteLine(
"The configuration file will still be created, despite the previous validation errors. " +
"You must open the file and correct the above issues before running a sync command.");
}
_configSaver.Save(config, destinationFile);
}
}

@ -1,6 +1,8 @@
using Recyclarr.Cli.Console.Settings;
namespace Recyclarr.Cli.Processors.Config;
public interface IConfigCreationProcessor
{
Task Process(string? configFilePath);
Task Process(ICreateConfigSettings settings);
}

@ -0,0 +1,9 @@
using Recyclarr.Cli.Console.Settings;
namespace Recyclarr.Cli.Processors.Config;
public interface IConfigCreator
{
bool CanHandle(ICreateConfigSettings settings);
Task Create(ICreateConfigSettings settings);
}

@ -0,0 +1,12 @@
using System.IO.Abstractions;
using Recyclarr.TrashLib.Config.Parsing;
namespace Recyclarr.Cli.Processors.Config;
public interface IConfigManipulator
{
void LoadAndSave(
IFileInfo source,
IFileInfo destinationFile,
Func<string, ServiceConfigYaml, ServiceConfigYaml> editCallback);
}

@ -0,0 +1,49 @@
using System.IO.Abstractions;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Common;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Startup;
namespace Recyclarr.Cli.Processors.Config;
public class LocalConfigCreator : IConfigCreator
{
private readonly ILogger _log;
private readonly IAppPaths _paths;
private readonly IFileSystem _fs;
private readonly IResourceDataReader _resources;
public LocalConfigCreator(ILogger log, IAppPaths paths, IFileSystem fs, IResourceDataReader resources)
{
_log = log;
_paths = paths;
_fs = fs;
_resources = resources;
}
public bool CanHandle(ICreateConfigSettings settings)
{
return true;
}
public async Task Create(ICreateConfigSettings settings)
{
var configFile = settings.Path is null
? _paths.AppDataDirectory.File("recyclarr.yml")
: _fs.FileInfo.New(settings.Path);
if (configFile.Exists)
{
throw new FileExistsException(configFile.FullName);
}
configFile.CreateParentDirectory();
await using var stream = configFile.CreateText();
var ymlData = _resources.ReadData("config-template.yml");
await stream.WriteAsync(ymlData);
_log.Information("Created configuration at: {Path}", configFile.FullName);
}
}

@ -0,0 +1,100 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Startup;
namespace Recyclarr.Cli.Processors.Config;
public class TemplateConfigCreator : IConfigCreator
{
private readonly ILogger _log;
private readonly IConfigTemplateGuideService _templates;
private readonly IAppPaths _paths;
// private readonly IConfigManipulator _configManipulator;
// private readonly IAnsiConsole _console;
public TemplateConfigCreator(
ILogger log,
IConfigTemplateGuideService templates,
IAppPaths paths)
{
_log = log;
_templates = templates;
_paths = paths;
// _configManipulator = configManipulator;
// _console = console;
}
public bool CanHandle(ICreateConfigSettings settings)
{
return settings.Templates.Any();
}
[SuppressMessage("Design", "CA1031:Do not catch general exception types")]
public Task Create(ICreateConfigSettings settings)
{
_log.Debug("Creating config from templates: {Templates}", settings.Templates);
var matchingTemplateData = _templates.LoadTemplateData()
.IntersectBy(settings.Templates, path => path.Id, StringComparer.CurrentCultureIgnoreCase)
.Select(x => x.TemplateFile);
foreach (var templateFile in matchingTemplateData)
{
var destinationFile = _paths.ConfigsDirectory.File(templateFile.Name);
try
{
if (destinationFile.Exists && !settings.Force)
{
throw new FileExistsException($"{destinationFile} already exists");
}
destinationFile.CreateParentDirectory();
templateFile.CopyTo(destinationFile.FullName, true);
// ReSharper disable once ConvertIfStatementToConditionalTernaryExpression
if (destinationFile.Exists)
{
_log.Information("Replacing existing file: {Path}", destinationFile);
}
else
{
_log.Information("Created configuration file: {Path}", destinationFile);
}
// -- See comment in ConfigManipulator.cs --
// _configManipulator.LoadAndSave(templateFile, destinationFile, (instanceName, config) =>
// {
// _console.MarkupLineInterpolated($"Enter configuration info for instance [green]{instanceName}[/]:");
// var baseUrl = _console.Prompt(new TextPrompt<string>("Base URL:"));
// var apiKey = _console.Prompt(new TextPrompt<string>("API Key:"));
// return config with
// {
// BaseUrl = baseUrl,
// ApiKey = apiKey
// };
// });
}
catch (FileExistsException e)
{
_log.Error("Template configuration file could not be saved: {Reason}", e.AttemptedPath);
}
catch (FileLoadException)
{
// Do not log here since the origin of this exception is ConfigParser.Load(), which already has
// sufficient logging.
}
catch (Exception e)
{
_log.Error(e, "Unable to save configuration template file");
}
}
return Task.CompletedTask;
}
}

@ -1,4 +1,5 @@
using Autofac;
using Autofac.Extras.Ordering;
using Recyclarr.Cli.Processors.Config;
using Recyclarr.Cli.Processors.Sync;
@ -9,9 +10,20 @@ public class ServiceProcessorsAutofacModule : Module
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterType<ConfigCreationProcessor>().As<IConfigCreationProcessor>();
// Sync
builder.RegisterType<SyncProcessor>().As<ISyncProcessor>();
builder.RegisterType<SyncPipelineExecutor>();
// Configuration
builder.RegisterType<ConfigManipulator>().As<IConfigManipulator>();
builder.RegisterType<ConfigCreationProcessor>().As<IConfigCreationProcessor>();
builder.RegisterType<ConfigListProcessor>();
builder.RegisterTypes(
typeof(TemplateConfigCreator),
typeof(LocalConfigCreator))
.As<IConfigCreator>()
.OrderByRegistration();
}
}

@ -23,6 +23,12 @@ public static class FileSystemExtensions
}
}
public static void CreateFullPath(this IFileInfo file)
{
file.CreateParentDirectory();
file.Create();
}
public static void MergeDirectory(
this IFileSystem fs,
IDirectoryInfo targetDir,

@ -35,6 +35,7 @@ public class ConfigAutofacModule : Module
builder.RegisterType<ConfigTemplateGuideService>().As<IConfigTemplateGuideService>();
builder.RegisterType<ConfigValidationExecutor>();
builder.RegisterType<ConfigParser>();
builder.RegisterType<ConfigSaver>();
// Config Listers
builder.RegisterType<ConfigTemplateLister>().Keyed<IConfigLister>(ConfigCategory.Templates);

@ -1,5 +1,4 @@
using System.IO.Abstractions;
using FluentValidation;
using JetBrains.Annotations;
using Recyclarr.TrashLib.Config.Parsing.ErrorHandling;
using Recyclarr.TrashLib.Config.Yaml;
@ -13,16 +12,11 @@ namespace Recyclarr.TrashLib.Config.Parsing;
public class ConfigParser
{
private readonly ILogger _log;
private readonly ConfigValidationExecutor _validator;
private readonly IDeserializer _deserializer;
public ConfigParser(
ILogger log,
ConfigValidationExecutor validator,
IYamlSerializerFactory yamlFactory)
public ConfigParser(ILogger log, IYamlSerializerFactory yamlFactory)
{
_log = log;
_validator = validator;
_deserializer = yamlFactory.CreateDeserializer();
}
@ -50,11 +44,6 @@ public class ConfigParser
_log.Warning("Configuration is empty");
}
if (!_validator.Validate(config))
{
throw new ValidationException("Validation Failed");
}
return config;
}
catch (FeatureRemovalException e)

@ -0,0 +1,24 @@
using System.IO.Abstractions;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Yaml;
namespace Recyclarr.TrashLib.Config.Parsing;
public class ConfigSaver
{
private readonly IYamlSerializerFactory _serializerFactory;
public ConfigSaver(IYamlSerializerFactory serializerFactory)
{
_serializerFactory = serializerFactory;
}
public void Save(RootConfigYaml config, IFileInfo destinationFile)
{
var serializer = _serializerFactory.CreateSerializer();
destinationFile.CreateParentDirectory();
using var stream = destinationFile.CreateText();
serializer.Serialize(stream, config);
}
}

@ -30,9 +30,11 @@ public record QualityProfileConfigYaml
public record ServiceConfigYaml
{
public string? ApiKey { get; [UsedImplicitly] init; }
[SuppressMessage("Design", "CA1056:URI-like properties should not be strings")]
public string? BaseUrl { get; [UsedImplicitly] init; }
public string? ApiKey { get; [UsedImplicitly] init; }
public bool DeleteOldCustomFormats { get; [UsedImplicitly] init; }
public bool ReplaceExistingCustomFormats { get; [UsedImplicitly] init; }

@ -8,11 +8,13 @@ public class ConfigurationLoader : IConfigurationLoader
{
private readonly ConfigParser _parser;
private readonly IMapper _mapper;
private readonly ConfigValidationExecutor _validator;
public ConfigurationLoader(ConfigParser parser, IMapper mapper)
public ConfigurationLoader(ConfigParser parser, IMapper mapper, ConfigValidationExecutor validator)
{
_parser = parser;
_mapper = mapper;
_validator = validator;
}
public ICollection<IServiceConfiguration> LoadMany(
@ -42,10 +44,15 @@ public class ConfigurationLoader : IConfigurationLoader
}
private IReadOnlyCollection<IServiceConfiguration> ProcessLoadedConfigs(
RootConfigYaml? configs,
RootConfigYaml? config,
SupportedServices? desiredServiceType)
{
if (configs is null)
if (config is null)
{
return Array.Empty<IServiceConfiguration>();
}
if (!_validator.Validate(config))
{
return Array.Empty<IServiceConfiguration>();
}
@ -55,13 +62,13 @@ public class ConfigurationLoader : IConfigurationLoader
if (desiredServiceType is null or SupportedServices.Radarr)
{
convertedConfigs.AddRange(
ValidateAndMap<RadarrConfigYaml, RadarrConfiguration>(configs.Radarr));
ValidateAndMap<RadarrConfigYaml, RadarrConfiguration>(config.Radarr));
}
if (desiredServiceType is null or SupportedServices.Sonarr)
{
convertedConfigs.AddRange(
ValidateAndMap<SonarrConfigYaml, SonarrConfiguration>(configs.Sonarr));
ValidateAndMap<SonarrConfigYaml, SonarrConfiguration>(config.Sonarr));
}
return convertedConfigs;

@ -14,7 +14,13 @@ public record TemplatesData
public ReadOnlyCollection<TemplateEntry> Sonarr { get; [UsedImplicitly] init; } = new(Array.Empty<TemplateEntry>());
}
public record TemplatePath(SupportedServices Service, string Id, IFileInfo TemplateFile, bool Hidden);
public record TemplatePath
{
public required string Id { get; init; }
public required IFileInfo TemplateFile { get; init; }
public required SupportedServices Service { get; init; }
public bool Hidden { get; init; }
}
public class ConfigTemplateGuideService : IConfigTemplateGuideService
{
@ -38,7 +44,13 @@ public class ConfigTemplateGuideService : IConfigTemplateGuideService
TemplatePath NewTemplatePath(TemplateEntry entry, SupportedServices service)
{
return new TemplatePath(service, entry.Id, _repo.Path.File(entry.Template), entry.Hidden);
return new TemplatePath
{
Id = entry.Id,
TemplateFile = _repo.Path.File(entry.Template),
Service = service,
Hidden = entry.Hidden
};
}
return templates.Radarr

@ -5,4 +5,5 @@ namespace Recyclarr.TrashLib.Config.Yaml;
public interface IYamlSerializerFactory
{
IDeserializer CreateDeserializer();
ISerializer CreateSerializer();
}

@ -25,10 +25,9 @@ public class YamlSerializerFactory : IYamlSerializerFactory
// resolvers.
builder.WithNodeTypeResolver(new SyntaxErrorHelper());
CommonSetup(builder);
builder
.IgnoreFields()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new YamlNullableEnumTypeConverter())
.WithNodeDeserializer(new ForceEmptySequences(_objectFactory))
.WithNodeTypeResolver(new ReadOnlyCollectionNodeTypeResolver())
.WithObjectFactory(_objectFactory);
@ -40,4 +39,28 @@ public class YamlSerializerFactory : IYamlSerializerFactory
return builder.Build();
}
public ISerializer CreateSerializer()
{
var builder = new SerializerBuilder();
CommonSetup(builder);
builder
.DisableAliases()
.ConfigureDefaultValuesHandling(
DefaultValuesHandling.OmitEmptyCollections |
DefaultValuesHandling.OmitNull |
DefaultValuesHandling.OmitDefaults);
return builder.Build();
}
private static void CommonSetup<T>(BuilderSkeleton<T> builder)
where T : BuilderSkeleton<T>
{
builder
.IgnoreFields()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.WithTypeConverter(new YamlNullableEnumTypeConverter());
}
}

@ -0,0 +1,19 @@
using System.Runtime.Serialization;
using JetBrains.Annotations;
namespace Recyclarr.TrashLib.ExceptionTypes;
[Serializable]
public class FatalException : Exception
{
public FatalException(string? message)
: base(message)
{
}
[UsedImplicitly]
protected FatalException(SerializationInfo info, StreamingContext context)
: base(info, context)
{
}
}

@ -0,0 +1,53 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using Recyclarr.Cli.Processors.Config;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.Common.Extensions;
namespace Recyclarr.Cli.Tests.Processors.Config;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigManipulatorTest : CliIntegrationFixture
{
[Test]
public void Create_file_when_no_file_already_exists()
{
var sut = Resolve<ConfigManipulator>();
var src = Fs.CurrentDirectory().File("template.yml");
var dst = Fs.CurrentDirectory().SubDir("one", "two", "three").File("config.yml");
const string yamlData = @"
sonarr:
instance1:
base_url: http://localhost:80
api_key: 123abc
";
Fs.AddFile(src, new MockFileData(yamlData));
sut.LoadAndSave(src, dst, (_, yaml) => yaml);
Fs.AllFiles.Should().Contain(dst.FullName);
}
[Test]
public void Throw_on_invalid_yaml()
{
var sut = Resolve<ConfigManipulator>();
var src = Fs.CurrentDirectory().File("template.yml");
var dst = Fs.CurrentDirectory().File("config.yml");
const string yamlData = @"
sonarr:
instance1:
invalid: yaml
";
Fs.AddFile(src, new MockFileData(yamlData));
var act = () => sut.LoadAndSave(src, dst, (_, yaml) => yaml);
act.Should().Throw<FileLoadException>();
}
}

@ -0,0 +1,143 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using Recyclarr.Cli.Console.Settings;
using Recyclarr.Cli.Processors.Config;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TestLibrary.AutoFixture;
using Recyclarr.TrashLib.Config;
using Recyclarr.TrashLib.Config.Services;
using Recyclarr.TrashLib.ExceptionTypes;
using Recyclarr.TrashLib.Repo;
namespace Recyclarr.Cli.Tests.Processors.Config;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class TemplateConfigCreatorTest : CliIntegrationFixture
{
[Test, AutoMockData]
public void Can_handle_returns_true_with_templates(
ICreateConfigSettings settings,
TemplateConfigCreator sut)
{
settings.Templates.Returns(new[] {"template1"});
var result = sut.CanHandle(settings);
result.Should().Be(true);
}
[Test, AutoMockData]
public void Can_handle_returns_false_with_no_templates(
ICreateConfigSettings settings,
TemplateConfigCreator sut)
{
settings.Templates.Returns(Array.Empty<string>());
var result = sut.CanHandle(settings);
result.Should().Be(false);
}
[Test, AutoMockData]
public void Throw_when_file_exists_and_not_forced(
[Frozen] IConfigTemplateGuideService templates,
MockFileSystem fs,
ICreateConfigSettings settings,
TemplateConfigCreator sut)
{
templates.LoadTemplateData().Returns(new[]
{
new TemplatePath
{
Id = "template1",
TemplateFile = fs.CurrentDirectory().File("template-file1.yml"),
Service = SupportedServices.Radarr
}
});
settings.Force.Returns(false);
settings.Templates.Returns(new[]
{
"template1"
});
var act = () => sut.Create(settings);
act.Should().ThrowAsync<FileExistsException>();
}
[Test, AutoMockData]
public void No_throw_when_file_exists_and_forced(
[Frozen] IConfigTemplateGuideService templates,
MockFileSystem fs,
ICreateConfigSettings settings,
TemplateConfigCreator sut)
{
templates.LoadTemplateData().Returns(new[]
{
new TemplatePath
{
Id = "template1",
TemplateFile = fs.CurrentDirectory().File("template-file1.yml"),
Service = SupportedServices.Radarr
}
});
settings.Force.Returns(true);
settings.Templates.Returns(new[]
{
"template1"
});
var act = () => sut.Create(settings);
act.Should().NotThrowAsync();
}
[Test]
public void Template_id_matching_works()
{
const string templatesJson = @"
{
'radarr': [
{
'template': 'template-file1.yml',
'id': 'template1'
}
],
'sonarr': [
{
'template': 'template-file2.yml',
'id': 'template2'
},
{
'template': 'template-file3.yml',
'id': 'template3'
}
]
}";
var repo = Resolve<IConfigTemplatesRepo>();
Fs.AddFile(repo.Path.File("templates.json"), new MockFileData(templatesJson));
Fs.AddEmptyFile(repo.Path.File("template-file1.yml"));
Fs.AddEmptyFile(repo.Path.File("template-file2.yml"));
// This one shouldn't show up in the result because the user didn't ask for it
Fs.AddEmptyFile(repo.Path.File("template-file3.yml"));
var settings = Substitute.For<ICreateConfigSettings>();
settings.Templates.Returns(new[]
{
"template1",
"template2",
// This one shouldn't show up in the results because:
// User specified it, but no template file exists for it.
"template4"
});
var sut = Resolve<TemplateConfigCreator>();
sut.Create(settings);
Fs.AllFiles.Should().Contain(new[]
{
Paths.ConfigsDirectory.File("template-file1.yml").FullName,
Paths.ConfigsDirectory.File("template-file2.yml").FullName
});
}
}

@ -1,7 +1,12 @@
using System.Diagnostics.CodeAnalysis;
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using Autofac.Extras.Ordering;
using AutoFixture;
using Recyclarr.Cli.Console.Commands;
using Recyclarr.Cli.Processors.Config;
using Recyclarr.Cli.TestLibrary;
using Recyclarr.TestLibrary.AutoFixture;
using Recyclarr.TrashLib.ExceptionTypes;
namespace Recyclarr.Cli.Tests.Processors;
@ -15,7 +20,10 @@ public class ConfigCreationProcessorTest : CliIntegrationFixture
{
var sut = Resolve<ConfigCreationProcessor>();
await sut.Process(null);
await sut.Process(new ConfigCreateCommand.CliSettings
{
Path = null
});
var file = Fs.GetFile(Paths.AppDataDirectory.File("recyclarr.yml"));
file.Should().NotBeNull();
@ -27,14 +35,18 @@ public class ConfigCreationProcessorTest : CliIntegrationFixture
{
var sut = Resolve<ConfigCreationProcessor>();
var ymlPath = Fs.CurrentDirectory()
.SubDirectory("user")
.SubDirectory("specified")
.File("file.yml");
var settings = new ConfigCreateCommand.CliSettings
{
Path = Fs.CurrentDirectory()
.SubDirectory("user")
.SubDirectory("specified")
.File("file.yml")
.FullName
};
await sut.Process(ymlPath.FullName);
await sut.Process(settings);
var file = Fs.GetFile(ymlPath);
var file = Fs.GetFile(settings.Path);
file.Should().NotBeNull();
file.Contents.Should().NotBeEmpty();
}
@ -44,11 +56,36 @@ public class ConfigCreationProcessorTest : CliIntegrationFixture
{
var sut = Resolve<ConfigCreationProcessor>();
var yml = Fs.CurrentDirectory().File("file.yml");
Fs.AddEmptyFile(yml);
var settings = new ConfigCreateCommand.CliSettings
{
Path = Fs.CurrentDirectory().File("file.yml").FullName
};
var act = () => sut.Process(yml.FullName);
Fs.AddEmptyFile(settings.Path);
var act = () => sut.Process(settings);
await act.Should().ThrowAsync<FileExistsException>();
}
[SuppressMessage("Performance", "CA1812", Justification =
"Used implicitly by test methods in this class")]
private sealed class EmptyOrderedEnumerable : ICustomization
{
public void Customize(IFixture fixture)
{
fixture.Inject(Array.Empty<IConfigCreator>().AsOrdered());
}
}
[Test, AutoMockData]
public async Task Throw_when_no_config_creators_can_handle(
[CustomizeWith(typeof(EmptyOrderedEnumerable))] ConfigCreationProcessor sut)
{
var settings = new ConfigCreateCommand.CliSettings();
var act = () => sut.Process(settings);
await act.Should().ThrowAsync<FatalException>();
}
}

@ -5,4 +5,7 @@
<ProjectReference Include="..\..\Recyclarr.Cli\Recyclarr.Cli.csproj" />
<ProjectReference Include="..\Recyclarr.TrashLib.TestLibrary\Recyclarr.TrashLib.TestLibrary.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="JetBrains.Annotations" />
</ItemGroup>
</Project>

@ -0,0 +1,31 @@
using System.Reflection;
using AutoFixture;
namespace Recyclarr.TestLibrary.AutoFixture;
// Based on the answer here: https://stackoverflow.com/a/16735551/157971
[AttributeUsage(AttributeTargets.Parameter, AllowMultiple = true)]
public sealed class CustomizeWithAttribute : CustomizeAttribute
{
public Type CustomizationType { get; }
public CustomizeWithAttribute(Type customizationType)
{
if (customizationType == null)
{
throw new ArgumentNullException(nameof(customizationType));
}
if (!typeof(ICustomization).IsAssignableFrom(customizationType))
{
throw new ArgumentException("Type needs to implement ICustomization");
}
CustomizationType = customizationType;
}
public override ICustomization? GetCustomization(ParameterInfo parameter)
{
return (ICustomization?) Activator.CreateInstance(CustomizationType);
}
}

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

@ -0,0 +1,45 @@
using Serilog.Core;
using Serilog.Events;
using Serilog.Sinks.TestCorrelator;
namespace Recyclarr.TestLibrary;
public sealed class TestableLogger : ILogger, IDisposable
{
private readonly Logger _log;
private ITestCorrelatorContext _logContext;
public TestableLogger()
{
_log = new LoggerConfiguration()
.MinimumLevel.Is(LogEventLevel.Verbose)
.WriteTo.TestCorrelator()
.WriteTo.Console()
.CreateLogger();
_logContext = TestCorrelator.CreateContext();
}
public void Write(LogEvent logEvent)
{
_log.Write(logEvent);
}
public void Dispose()
{
_logContext.Dispose();
_log.Dispose();
}
public void ResetCapturedLogs()
{
_logContext.Dispose();
_logContext = TestCorrelator.CreateContext();
}
public IEnumerable<string> GetRenderedMessages()
{
return TestCorrelator.GetLogEventsFromContextGuid(_logContext.Guid)
.Select(x => x.RenderMessage());
}
}

@ -7,6 +7,5 @@
<ItemGroup>
<PackageReference Include="AutoMapper.Collection" />
<PackageReference Include="AutoMapper.Contrib.Autofac.DependencyInjection" />
<PackageReference Include="Serilog.Sinks.Console" />
</ItemGroup>
</Project>

@ -20,10 +20,10 @@ public class ConfigTemplateListerTest : TrashLibIntegrationFixture
{
guideService.LoadTemplateData().Returns(new[]
{
new TemplatePath(SupportedServices.Radarr, "r1", stubFile, false),
new TemplatePath(SupportedServices.Radarr, "r2", stubFile, false),
new TemplatePath(SupportedServices.Sonarr, "s1", stubFile, false),
new TemplatePath(SupportedServices.Sonarr, "s2", stubFile, true)
new TemplatePath {Id = "r1", TemplateFile = stubFile, Service = SupportedServices.Radarr, Hidden = false},
new TemplatePath {Id = "r2", TemplateFile = stubFile, Service = SupportedServices.Radarr, Hidden = false},
new TemplatePath {Id = "s1", TemplateFile = stubFile, Service = SupportedServices.Sonarr, Hidden = false},
new TemplatePath {Id = "s2", TemplateFile = stubFile, Service = SupportedServices.Sonarr, Hidden = true}
});
await sut.List();

@ -0,0 +1,72 @@
using System.IO.Abstractions;
using System.IO.Abstractions.Extensions;
using Recyclarr.Common.Extensions;
using Recyclarr.TrashLib.Config.Parsing;
using Recyclarr.TrashLib.TestLibrary;
namespace Recyclarr.TrashLib.Tests.Config.Parsing;
[TestFixture]
[Parallelizable(ParallelScope.All)]
public class ConfigSaverTest : TrashLibIntegrationFixture
{
[Test]
public void Replace_file_when_already_exists()
{
var sut = Resolve<ConfigSaver>();
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{
"instance1", new RadarrConfigYaml
{
ApiKey = "apikey"
}
}
}
};
var destFile = Fs.CurrentDirectory().File("config.yml");
Fs.AddEmptyFile(destFile);
sut.Save(config, destFile);
Fs.GetFile(destFile).TextContents.Should().Contain("apikey");
}
[Test]
public void Create_intermediate_directories()
{
var sut = Resolve<ConfigSaver>();
var config = new RootConfigYaml
{
Radarr = new Dictionary<string, RadarrConfigYaml>
{
{
"instance1", new RadarrConfigYaml
{
ApiKey = "apikey",
BaseUrl = "http://baseurl.com"
}
}
}
};
var destFile = Fs.CurrentDirectory().SubDir("one", "two", "three").File("config.yml");
sut.Save(config, destFile);
var expectedYaml = @"
radarr:
instance1:
api_key: apikey
base_url: http://baseurl.com
".TrimStart();
var expectedFile = Fs.GetFile(destFile);
expectedFile.Should().NotBeNull();
expectedFile.TextContents.Should().Be(expectedYaml);
}
}

@ -31,7 +31,7 @@ public class ConfigTemplateGuideServiceTest : TrashLibIntegrationFixture
{
var fsPath = templateDir.File(path);
Fs.AddEmptyFile(fsPath);
return new TemplatePath(service, id, fsPath, false);
return new TemplatePath {Service = service, Id = id, TemplateFile = fsPath, Hidden = false};
}
var expectedPaths = new[]

Loading…
Cancel
Save