From bede26c2137a5f1f248eb4de71a1c2e68b5edc8e Mon Sep 17 00:00:00 2001 From: Robert Dailey Date: Sun, 1 May 2022 13:36:39 -0500 Subject: [PATCH] feat: Default config is now `recyclarr.yml` Also: - New "migration" system which helps perform upgrade steps if needed - Migration step to rename `trash.yml` to `recyclarr.yml` - Fixup the `create-config` template --- .../Command/CreateConfigCommandTest.cs | 6 +- .../Migration/MigrationExecutorTest.cs | 95 +++++++++++++++++++ .../Migration/Steps/MigrateTrashYmlTest.cs | 61 ++++++++++++ src/Recyclarr/AppPaths.cs | 2 +- src/Recyclarr/Command/CreateConfigCommand.cs | 4 +- src/Recyclarr/Command/ServiceCommand.cs | 2 +- src/Recyclarr/CompositionRoot.cs | 5 + src/Recyclarr/Migration/IMigrationExecutor.cs | 6 ++ src/Recyclarr/Migration/IMigrationStep.cs | 11 +++ src/Recyclarr/Migration/MigrationException.cs | 16 ++++ src/Recyclarr/Migration/MigrationExecutor.cs | 41 ++++++++ .../Migration/Steps/MigrateTrashYml.cs | 47 +++++++++ src/Recyclarr/Program.cs | 5 + src/Recyclarr/Recyclarr.csproj | 2 +- ...onfig-template.yml => config-template.yml} | 0 15 files changed, 295 insertions(+), 8 deletions(-) create mode 100644 src/Recyclarr.Tests/Migration/MigrationExecutorTest.cs create mode 100644 src/Recyclarr.Tests/Migration/Steps/MigrateTrashYmlTest.cs create mode 100644 src/Recyclarr/Migration/IMigrationExecutor.cs create mode 100644 src/Recyclarr/Migration/IMigrationStep.cs create mode 100644 src/Recyclarr/Migration/MigrationException.cs create mode 100644 src/Recyclarr/Migration/MigrationExecutor.cs create mode 100644 src/Recyclarr/Migration/Steps/MigrateTrashYml.cs rename src/Recyclarr/{trash-config-template.yml => config-template.yml} (100%) diff --git a/src/Recyclarr.Tests/Command/CreateConfigCommandTest.cs b/src/Recyclarr.Tests/Command/CreateConfigCommandTest.cs index d0b864a7..29f86b47 100644 --- a/src/Recyclarr.Tests/Command/CreateConfigCommandTest.cs +++ b/src/Recyclarr.Tests/Command/CreateConfigCommandTest.cs @@ -2,8 +2,8 @@ using CliFx.Infrastructure; using NSubstitute; using NUnit.Framework; -using Serilog; using Recyclarr.Command; +using Serilog; // ReSharper disable MethodHasAsyncOverload @@ -22,8 +22,8 @@ public class CreateConfigCommandTest await cmd.ExecuteAsync(Substitute.For()).ConfigureAwait(false); - filesystem.File.Received().Exists(Arg.Is(s => s.EndsWith("trash.yml"))); - filesystem.File.Received().WriteAllText(Arg.Is(s => s.EndsWith("trash.yml")), Arg.Any()); + filesystem.File.Received().Exists(Arg.Is(s => s.EndsWith("recyclarr.yml"))); + filesystem.File.Received().WriteAllText(Arg.Is(s => s.EndsWith("recyclarr.yml")), Arg.Any()); } [Test] diff --git a/src/Recyclarr.Tests/Migration/MigrationExecutorTest.cs b/src/Recyclarr.Tests/Migration/MigrationExecutorTest.cs new file mode 100644 index 00000000..dfa1f51a --- /dev/null +++ b/src/Recyclarr.Tests/Migration/MigrationExecutorTest.cs @@ -0,0 +1,95 @@ +using FluentAssertions; +using NSubstitute; +using NUnit.Framework; +using Recyclarr.Migration; +using Serilog; + +namespace Recyclarr.Tests.Migration; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class MigrationExecutorTest +{ + [Test] + public void Step_not_executed_if_check_returns_false() + { + var step = Substitute.For(); + var executor = new MigrationExecutor(new[] {step}, Substitute.For()); + + step.CheckIfNeeded().Returns(false); + + executor.PerformAllMigrationSteps(); + + step.Received().CheckIfNeeded(); + step.DidNotReceive().Execute(Arg.Any()); + } + + [Test] + public void Step_executed_if_check_returns_true() + { + var step = Substitute.For(); + var executor = new MigrationExecutor(new[] {step}, Substitute.For()); + + step.CheckIfNeeded().Returns(true); + + executor.PerformAllMigrationSteps(); + + step.Received().CheckIfNeeded(); + step.Received().Execute(Arg.Any()); + } + + [Test] + public void Steps_executed_in_ascending_order() + { + var steps = new[] + { + Substitute.For(), + Substitute.For(), + Substitute.For() + }; + + steps[0].Order.Returns(20); + steps[1].Order.Returns(10); + steps[2].Order.Returns(30); + + var executor = new MigrationExecutor(steps, Substitute.For()); + + executor.PerformAllMigrationSteps(); + + Received.InOrder(() => + { + steps[1].CheckIfNeeded(); + steps[0].CheckIfNeeded(); + steps[2].CheckIfNeeded(); + }); + } + + [Test] + public void Exception_converted_to_migration_exception() + { + var step = Substitute.For(); + var executor = new MigrationExecutor(new[] {step}, Substitute.For()); + + step.CheckIfNeeded().Returns(true); + step.When(x => x.Execute(Arg.Any())).Throw(new ArgumentException("test message")); + + var act = () => executor.PerformAllMigrationSteps(); + + act.Should().Throw().Which.FailureReason.Should().Be("test message"); + } + + [Test] + public void Migration_exceptions_are_not_converted() + { + var step = Substitute.For(); + var executor = new MigrationExecutor(new[] {step}, Substitute.For()); + var exception = new MigrationException("a", "b"); + + step.CheckIfNeeded().Returns(true); + step.When(x => x.Execute(Arg.Any())).Throw(exception); + + var act = () => executor.PerformAllMigrationSteps(); + + act.Should().Throw().Which.Should().Be(exception); + } +} diff --git a/src/Recyclarr.Tests/Migration/Steps/MigrateTrashYmlTest.cs b/src/Recyclarr.Tests/Migration/Steps/MigrateTrashYmlTest.cs new file mode 100644 index 00000000..5e319a41 --- /dev/null +++ b/src/Recyclarr.Tests/Migration/Steps/MigrateTrashYmlTest.cs @@ -0,0 +1,61 @@ +using System.IO.Abstractions.TestingHelpers; +using System.Text.RegularExpressions; +using AutoFixture.NUnit3; +using FluentAssertions; +using NSubstitute; +using NUnit.Framework; +using Recyclarr.Migration; +using Recyclarr.Migration.Steps; +using Serilog; +using TestLibrary.AutoFixture; + +namespace Recyclarr.Tests.Migration.Steps; + +[TestFixture] +[Parallelizable(ParallelScope.All)] +public class MigrateTrashYmlTest +{ + private static readonly string BasePath = AppContext.BaseDirectory; + + [Test, AutoMockData] + public void Migration_check_returns_true_if_trash_yml_exists( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + MigrateTrashYml sut) + { + fs.AddFile(Path.Combine(BasePath, "trash.yml"), MockFileData.NullObject); + sut.CheckIfNeeded().Should().BeTrue(); + } + + [Test, AutoMockData] + public void Migration_check_returns_false_if_trash_yml_doesnt_exists( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + MigrateTrashYml sut) + { + sut.CheckIfNeeded().Should().BeFalse(); + } + + [Test, AutoMockData] + public void Migration_throws_if_recyclarr_yml_already_exists( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + MigrateTrashYml sut) + { + fs.AddFile(Path.Combine(BasePath, "recyclarr.yml"), MockFileData.NullObject); + + var act = () => sut.Execute(Substitute.For()); + + act.Should().Throw().WithMessage("*already exist*"); + } + + [Test, AutoMockData] + public void Migration_success( + [Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs, + MigrateTrashYml sut) + { + const string expectedData = "fake contents"; + fs.AddFile(Path.Combine(BasePath, "trash.yml"), expectedData); + + sut.Execute(Substitute.For()); + + fs.AllFiles.Should().ContainSingle(x => Regex.IsMatch(x, @"[/\\]recyclarr\.yml$")); + } +} diff --git a/src/Recyclarr/AppPaths.cs b/src/Recyclarr/AppPaths.cs index 691a22af..07e0cbc0 100644 --- a/src/Recyclarr/AppPaths.cs +++ b/src/Recyclarr/AppPaths.cs @@ -5,7 +5,7 @@ internal static class AppPaths public static string AppDataPath { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "trash-updater"); - public static string DefaultConfigPath { get; } = Path.Combine(AppContext.BaseDirectory, "trash.yml"); + public static string DefaultConfigPath { get; } = Path.Combine(AppContext.BaseDirectory, "recyclarr.yml"); public static string DefaultSettingsPath { get; } = Path.Combine(AppDataPath, "settings.yml"); diff --git a/src/Recyclarr/Command/CreateConfigCommand.cs b/src/Recyclarr/Command/CreateConfigCommand.cs index 154b1db9..2ab3e57c 100644 --- a/src/Recyclarr/Command/CreateConfigCommand.cs +++ b/src/Recyclarr/Command/CreateConfigCommand.cs @@ -25,14 +25,14 @@ public class CreateConfigCommand : ICommand [CommandOption("path", 'p', Description = "Path where the new YAML file should be created. Must include the filename (e.g. path/to/config.yml). " + - "File must not already exist. If not specified, uses the default path of `trash.yml` right next to the " + + "File must not already exist. If not specified, uses the default path of `recyclarr.yml` right next to the " + "executable.")] public string Path { get; [UsedImplicitly] set; } = AppPaths.DefaultConfigPath; public ValueTask ExecuteAsync(IConsole console) { var reader = new ResourceDataReader(typeof(Program)); - var ymlData = reader.ReadData("trash-config-template.yml"); + var ymlData = reader.ReadData("config-template.yml"); if (_fileSystem.File.Exists(Path)) { diff --git a/src/Recyclarr/Command/ServiceCommand.cs b/src/Recyclarr/Command/ServiceCommand.cs index b16fb8f1..39251bad 100644 --- a/src/Recyclarr/Command/ServiceCommand.cs +++ b/src/Recyclarr/Command/ServiceCommand.cs @@ -36,7 +36,7 @@ public abstract class ServiceCommand : ICommand, IServiceCommand [CommandOption("config", 'c', Description = "One or more YAML config files to use. All configs will be used and settings are additive. " + - "If not specified, the script will look for `trash.yml` in the same directory as the executable.")] + "If not specified, the script will look for `recyclarr.yml` in the same directory as the executable.")] public ICollection Config { get; [UsedImplicitly] set; } = new List {AppPaths.DefaultConfigPath}; diff --git a/src/Recyclarr/CompositionRoot.cs b/src/Recyclarr/CompositionRoot.cs index ca50099a..aa6c7e55 100644 --- a/src/Recyclarr/CompositionRoot.cs +++ b/src/Recyclarr/CompositionRoot.cs @@ -7,6 +7,7 @@ using CliFx.Infrastructure; using Common; using Recyclarr.Command.Helpers; using Recyclarr.Config; +using Recyclarr.Migration; using Serilog; using Serilog.Core; using TrashLib.Cache; @@ -88,6 +89,10 @@ public static class CompositionRoot builder.RegisterType().As(); builder.RegisterType().As(); + // Automatically register all migration steps + builder.RegisterAssemblyTypes(Assembly.GetExecutingAssembly()).AssignableTo(); + builder.RegisterType().As(); + ConfigurationRegistrations(builder); CommandRegistrations(builder); diff --git a/src/Recyclarr/Migration/IMigrationExecutor.cs b/src/Recyclarr/Migration/IMigrationExecutor.cs new file mode 100644 index 00000000..4487b2e4 --- /dev/null +++ b/src/Recyclarr/Migration/IMigrationExecutor.cs @@ -0,0 +1,6 @@ +namespace Recyclarr.Migration; + +public interface IMigrationExecutor +{ + void PerformAllMigrationSteps(); +} diff --git a/src/Recyclarr/Migration/IMigrationStep.cs b/src/Recyclarr/Migration/IMigrationStep.cs new file mode 100644 index 00000000..094e19f0 --- /dev/null +++ b/src/Recyclarr/Migration/IMigrationStep.cs @@ -0,0 +1,11 @@ +using Serilog; + +namespace Recyclarr.Migration; + +public interface IMigrationStep +{ + int Order { get; } + string Description { get; } + bool CheckIfNeeded(); + void Execute(ILogger log); +} diff --git a/src/Recyclarr/Migration/MigrationException.cs b/src/Recyclarr/Migration/MigrationException.cs new file mode 100644 index 00000000..cf508ac4 --- /dev/null +++ b/src/Recyclarr/Migration/MigrationException.cs @@ -0,0 +1,16 @@ +namespace Recyclarr.Migration; + +public class MigrationException : Exception +{ + public MigrationException(string operationDescription, string failureReason) + { + OperationDescription = operationDescription; + FailureReason = failureReason; + } + + public string OperationDescription { get; } + public string FailureReason { get; } + + public override string Message => + $"Fatal exception during migration step [Desc: {OperationDescription}] [Reason: {FailureReason}]"; +} diff --git a/src/Recyclarr/Migration/MigrationExecutor.cs b/src/Recyclarr/Migration/MigrationExecutor.cs new file mode 100644 index 00000000..0ec59075 --- /dev/null +++ b/src/Recyclarr/Migration/MigrationExecutor.cs @@ -0,0 +1,41 @@ +using Serilog; + +namespace Recyclarr.Migration; + +public class MigrationExecutor : IMigrationExecutor +{ + private readonly ILogger _log; + private readonly List _migrationSteps; + + public MigrationExecutor(IEnumerable migrationSteps, ILogger log) + { + _log = log; + _migrationSteps = migrationSteps.OrderBy(x => x.Order).ToList(); + } + + public void PerformAllMigrationSteps() + { + _log.Debug("Performing migration steps..."); + + // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator + foreach (var step in _migrationSteps) + { + // Do not use LINQ to filter using CheckIfNeeded(). If it returns true, then 'Execute()' must be invoked to + // cause the necessary changes to happen. Those changes may be required in order for the *next* step's + // CheckIfNeeded() to work properly! + if (!step.CheckIfNeeded()) + { + continue; + } + + try + { + step.Execute(_log); + } + catch (Exception e) when (e is not MigrationException) + { + throw new MigrationException(step.Description, e.Message); + } + } + } +} diff --git a/src/Recyclarr/Migration/Steps/MigrateTrashYml.cs b/src/Recyclarr/Migration/Steps/MigrateTrashYml.cs new file mode 100644 index 00000000..ad6d3a61 --- /dev/null +++ b/src/Recyclarr/Migration/Steps/MigrateTrashYml.cs @@ -0,0 +1,47 @@ +using System.IO.Abstractions; +using JetBrains.Annotations; +using Serilog; + +namespace Recyclarr.Migration.Steps; + +/// +/// Rename `trash.yml` to `recyclarr.yml`. +/// +/// +/// Implemented on 4/30/2022. +/// +[UsedImplicitly] +public class MigrateTrashYml : IMigrationStep +{ + private readonly IFileSystem _fileSystem; + private readonly string _oldConfigPath = Path.Combine(AppContext.BaseDirectory, "trash.yml"); + + // Do not use AppPaths class here since that may change yet again in the future and break this migration step. + private readonly string _newConfigPath = Path.Combine(AppContext.BaseDirectory, "recyclarr.yml"); + + public MigrateTrashYml(IFileSystem fileSystem) + { + _fileSystem = fileSystem; + } + + public int Order => 1; + + public string Description => "Migration from 'trash.yml' to 'recyclarr.yml'"; + + public bool CheckIfNeeded() => _fileSystem.File.Exists(_oldConfigPath); + + public void Execute(ILogger log) + { + try + { + _fileSystem.File.Move(_oldConfigPath, _newConfigPath); + log.Information("Migration: Default configuration renamed from {Old} to {New}", _oldConfigPath, + _newConfigPath); + } + catch (IOException) + { + throw new MigrationException(Description, + "Unable to move due to IO Exception (does 'recyclarr.yml' already exist next to the executable?)"); + } + } +} diff --git a/src/Recyclarr/Program.cs b/src/Recyclarr/Program.cs index b95f45dc..0bcea0d7 100644 --- a/src/Recyclarr/Program.cs +++ b/src/Recyclarr/Program.cs @@ -4,6 +4,7 @@ using Autofac; using CliFx; using CliFx.Infrastructure; using Recyclarr.Command.Helpers; +using Recyclarr.Migration; namespace Recyclarr; @@ -16,6 +17,10 @@ internal static class Program public static async Task Main() { _container = CompositionRoot.Setup(); + + var migration = _container.Resolve(); + migration.PerformAllMigrationSteps(); + return await new CliApplicationBuilder() .AddCommandsFromThisAssembly() .SetExecutableName(ExecutableName) diff --git a/src/Recyclarr/Recyclarr.csproj b/src/Recyclarr/Recyclarr.csproj index 7952dcc7..fbb18338 100644 --- a/src/Recyclarr/Recyclarr.csproj +++ b/src/Recyclarr/Recyclarr.csproj @@ -28,6 +28,6 @@ - + diff --git a/src/Recyclarr/trash-config-template.yml b/src/Recyclarr/config-template.yml similarity index 100% rename from src/Recyclarr/trash-config-template.yml rename to src/Recyclarr/config-template.yml